Compare commits

..
1466 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
陈大猫andCursor 86e2336370 Fix directory tracking setup after su / sudo su (#1946)
* Fix directory tracking setup after su / sudo su (#1942)

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

Fixes #1850

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

* Move shellUtils require to module scope in cattyExecHandlers

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

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

---------

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

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

Fixes #1939

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

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

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

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

---------

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

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

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

Fixes #1938

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

* Dedupe fallback worker output before session log taps

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

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

---------

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

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

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

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

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

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

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

---------

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

* Fix Dock menu window readiness

* Wait for renderer before Dock menu delivery

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

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

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

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

* feat: add script dialog text and number fields

* feat: add conditional script dialog fields

* fix: address script dialog review feedback

* fix: harden script dialog form validation

* fix: harden script dialog review issues

---------

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

* Honor workspace compose bar for enter reconnect
2026-07-04 15:00:45 +08:00
陈大猫 f299271b1b fix opencode windows permission allowlist (#1921) 2026-07-04 14:02:12 +08:00
陈大猫 49787215d2 Fix RPM build-id file conflicts (#1919) 2026-07-04 13:34:04 +08:00
陈大猫 5e34538c55 Add double-click copy for session tabs (#1917) 2026-07-04 10:59:34 +08:00
陈大猫 862c3f6fb5 Fix vault host drag reset (#1916) 2026-07-04 10:22:03 +08:00
陈大猫 2edbeae02c Remove unused code and assets
Remove unused code, assets, and stale config entries.
2026-07-03 10:47:59 +08:00
陈大猫 02ada0c61d Update Nix release metadata for v1.1.56
Update Nix release metadata for v1.1.56
2026-07-03 00:30:15 +08:00
陈大猫 98e95c36dc Align localized READMEs
Align localized READMEs
2026-07-03 00:03:14 +08:00
Copilot a1b1c37f36 Update Chinese and Japanese READMEs to match English README (#1894)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-02 23:45:52 +08:00
陈大猫 45905f8f34 Clean up README by removing unused image URLs (#1893)
Removed unnecessary image links from the README.
2026-07-02 23:36:24 +08:00
陈大猫 6d83b32815 Remove demos and project structure from README (#1892)
Removed demo section and project structure details from README.
2026-07-02 23:25:47 +08:00
陈大猫 a366cf81d9 fix ssh-agent certificate auth (#1889) 2026-07-02 23:00:57 +08:00
陈大猫 3a8bd7eea7 Update README.md (#1891) 2026-07-02 22:58:03 +08:00
陈大猫 a46c3321db Fix explicit script wait APIs (#1885) 2026-07-02 17:48:04 +08:00
陈大猫 eedb123b3a Update Nix release metadata for v1.1.55 (#1883) 2026-07-02 15:58:06 +08:00
陈大猫 1c7dc33852 [codex] Fix terminal catch-up after backgrounding (#1881)
* Fix terminal catch-up after backgrounding

* Harden background terminal output flushing
2026-07-02 14:25:48 +08:00
Ryanisgoodandbincxz 49a3c31de8 修复 SSH 加密默认密钥重试导致会话提前关闭 (#1878)
* fix: prevent ssh auth retry from closing session early

* fix: cover ssh auth retry stale exits

* fix: handle ssh retry reuse edge cases

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-07-02 14:15:41 +08:00
陈大猫 82e318c17f Update Nix release metadata for v1.1.54 (#1875) 2026-07-02 02:01:16 +08:00
陈大猫 920e3cf8ec Adjust all hosts header spacing (#1874) 2026-07-02 01:38:14 +08:00
陈大猫 2fe7de6cd7 Merge pull request #1873 from binaricat/codex/terminal-ui-dev-perf
[codex] Improve terminal tab switching stability
2026-07-02 01:25:57 +08:00
bincxz f3ac99e587 Address terminal UI review findings 2026-07-02 01:13:38 +08:00
bincxz fe7a867f38 Improve terminal tab switching stability 2026-07-02 01:00:32 +08:00
陈大猫 7d70d85d23 Merge pull request #1872 from binaricat/codex/terminal-tab-switch-recovery
[codex] Fix terminal tab switch recovery
2026-07-02 00:05:56 +08:00
bincxz 5a70e9d6b8 Respect scroll-on-output for hidden recovery 2026-07-01 23:54:48 +08:00
bincxz 65b00ae12f Fix terminal tab switch recovery 2026-07-01 23:44:37 +08:00
陈大猫 c36ff6bd49 Merge pull request #1869 from binaricat/codex/update-nix-release-v1.1.53
update nix release metadata for v1.1.53
2026-07-01 21:16:32 +08:00
bincxz 269b4d7a7a update nix release metadata for v1.1.53 2026-07-01 21:16:03 +08:00
陈大猫 3c499de5fa Merge pull request #1868 from binaricat/codex/terminal-address-title
fix terminal address title
2026-07-01 20:51:53 +08:00
bincxz c2ce086027 fix terminal address title 2026-07-01 20:51:11 +08:00
陈大猫 fc16da92d1 Merge pull request #1867 from binaricat/codex/terminal-scroll-smoothness
Improve terminal scroll and input smoothness
2026-07-01 19:25:21 +08:00
bincxz 49bd0ad1ae Improve terminal scroll and input smoothness 2026-07-01 19:20:05 +08:00
陈大猫 6462fe8db4 Merge pull request #1865 from binaricat/codex/fix-process-count-limit
[codex] Fix process list count limit
2026-07-01 17:51:59 +08:00
bincxz b118230218 Address process listing review feedback 2026-07-01 17:47:49 +08:00
bincxz cc5f2bdd7f Bound SSH process list output 2026-07-01 17:39:06 +08:00
陈大猫 ab309c0db2 Merge pull request #1864 from binaricat/codex/fix-long-line-highlight-lock
[codex] Fix long-line keyword highlight lockup
2026-07-01 17:36:42 +08:00
bincxz 6346754ad4 Handle large process list output 2026-07-01 17:32:56 +08:00
bincxz 3c72bbad39 Cache capped keyword highlight misses 2026-07-01 17:30:38 +08:00
陈大猫 9ae973b87a Merge pull request #1863 from binaricat/codex/fix-cpu-core-scroll
[codex] Fix CPU core stats scrolling
2026-07-01 17:25:49 +08:00
bincxz 49f79f0897 Fix process list count limit 2026-07-01 17:23:10 +08:00
bincxz 21373fc67f Fix long-line keyword highlight lockup 2026-07-01 17:21:10 +08:00
bincxz fd3552d8ef Fix CPU core stats scrolling 2026-07-01 17:20:18 +08:00
陈大猫 2f7059fbbf Merge pull request #1861 from binaricat/codex/clarify-telnet-manual-identity
[codex] Clarify Telnet manual identity label
2026-07-01 17:00:24 +08:00
bincxz 73429565c5 Clarify Telnet manual identity label 2026-07-01 16:59:31 +08:00
陈大猫 a7d7039317 Merge pull request #1853 from binaricat/codex/fix-combobox-wheel-scroll
[codex] Fix combobox wheel scrolling
2026-07-01 10:11:17 +08:00
bincxz e389cce5a2 Improve combobox scroll regression coverage 2026-07-01 10:03:25 +08:00
bincxz debae7f89e Fix combobox wheel scrolling 2026-07-01 09:56:06 +08:00
陈大猫 172466a2c7 Merge pull request #1848 from binaricat/codex/issue-1847-terminal-flood-yield
[codex] Fix terminal output flood responsiveness
2026-07-01 08:31:01 +08:00
bincxz da81caa72c Harden terminal flood flow control 2026-07-01 02:49:24 +08:00
bincxz 91610bbbaa Strengthen terminal flood flow control 2026-06-30 21:43:12 +08:00
bincxz 9562f909e1 Keep merged terminal flood writes cooperative 2026-06-30 21:09:28 +08:00
bincxz 878f9bfbb7 Fix terminal output flood responsiveness 2026-06-30 20:10:40 +08:00
陈大猫 5df248b5ea Merge pull request #1844 from binaricat/codex/add-nix-install-support
[codex] Add Nix AppImage package
2026-06-30 19:06:42 +08:00
bincxz d019ded7bf Skip prereleases for Nix metadata 2026-06-30 18:08:26 +08:00
陈大猫 354a7000b4 Merge pull request #1845 from binaricat/codex/fix-host-tree-font-clipping
[codex] Fix host tree font clipping
2026-06-30 18:03:08 +08:00
bincxz 5271f64402 Automate Nix release metadata 2026-06-30 18:03:02 +08:00
bincxz 5665578b2e Fix host tree font clipping 2026-06-30 17:56:31 +08:00
bincxz b0b88bb3a2 Add Nix flake lock 2026-06-30 17:51:33 +08:00
陈大猫 8d324a2afb Merge pull request #1843 from binaricat/codex/audit-ssh-compat-entrypoints
[codex] Harden SSH compat entrypoints
2026-06-30 17:43:11 +08:00
bincxz 433927fcfe Add Nix AppImage package 2026-06-30 17:27:34 +08:00
陈大猫 fb78596c85 Merge pull request #1839 from LeoKon3/feature/tab-tooltip-ip-1677
Show target address on SSH and telnet tabs
2026-06-30 17:22:01 +08:00
bincxz 546eb9129a Harden SSH compat entrypoints 2026-06-30 17:15:08 +08:00
陈大猫 24c978cf4d Merge pull request #1838 from binaricat/codex/fix-multiline-snippet-run
Fix multiline snippet execution modes
2026-06-30 17:12:49 +08:00
陈大猫 74a8f4d32d Merge pull request #1842 from binaricat/codex/issue-1841-worker-legacy-kex
[codex] Fix worker legacy SSH key exchange
2026-06-30 17:08:57 +08:00
bincxz b4151c61d6 Fix worker legacy SSH key exchange 2026-06-30 17:03:05 +08:00
bincxz 0b94a82579 Fix multiline snippet execution modes 2026-06-30 17:01:27 +08:00
LeoKon3 fce196599a fix: handle default ssh tab address behavior 2026-06-30 15:21:03 +08:00
LeoKon3 c43af71572 feat: Optimize hovering 2026-06-30 14:59:33 +08:00
LeoKon3 cf6ebadb08 feat: show user@host:port tooltip on SSH/telnet tabs (#1677) 2026-06-30 14:59:33 +08:00
陈大猫 d027207bed Merge pull request #1834 from binaricat/binaricat-patch-1
Add CODE_OF_CONDUCT.md
2026-06-30 01:19:19 +08:00
bincxz 109c8075c2 docs: align contributor guidance with project 2026-06-30 00:55:00 +08:00
陈大猫 2fcda46801 Add PULL_REQUEST_TEMPLATE.md 2026-06-30 00:45:16 +08:00
陈大猫 12fdd1c724 Add SECURITY.md
Added a security policy document outlining supported versions, vulnerability reporting, response timelines, and disclosure policy.
2026-06-30 00:44:37 +08:00
陈大猫 1a52a0b846 Add CONTRIBUTING.md
Added a contributing guide for the Netcatty project, detailing the code of conduct, ways to contribute, development setup, build and packaging instructions, linting, pull request process, commit message conventions, and architecture guidelines.
2026-06-30 00:43:54 +08:00
陈大猫 26e3c7c2b7 Add CODE_OF_CONDUCT.md 2026-06-30 00:42:19 +08:00
陈大猫 b88f7a0bfd Merge pull request #1826 from binaricat/codex/issue-937-identity-proxy-telnet
[codex] Support identities for Telnet and proxy credentials
2026-06-29 23:49:22 +08:00
bincxz c1635a0f55 Give Telnet its own identity selection 2026-06-29 23:48:41 +08:00
陈大猫 986551f146 Merge pull request #1828 from binaricat/codex/issue-1201-terminal-reconnect-history
[codex] Preserve terminal output on reconnect
2026-06-29 23:27:03 +08:00
bincxz 2796975526 Address identity proxy review gaps 2026-06-29 23:26:53 +08:00
bincxz 0cbb377385 keep terminal exit cleanup after close 2026-06-29 23:18:47 +08:00
bincxz ee3dce4cc5 ignore duplicate terminal exit events 2026-06-29 23:04:32 +08:00
陈大猫 854ae0d6f2 Merge pull request #1825 from binaricat/codex/terminal-side-panel-auto-open
Add terminal side panel auto-open setting
2026-06-29 23:03:59 +08:00
陈大猫 a20bb21af8 Merge pull request #1829 from binaricat/codex/issue-1824-zmodem-progress
Fix ZMODEM transfer progress events
2026-06-29 22:56:39 +08:00
bincxz 2fbc78fdc0 Fix ZMODEM transfer progress events 2026-06-29 22:55:53 +08:00
bincxz 32d9824eb9 preserve reconnect exit listener 2026-06-29 22:55:05 +08:00
陈大猫 98ead6af1f Merge pull request #1822 from binaricat/codex/fix-issue-1820-windows-force-focus 2026-06-29 22:41:52 +08:00
bincxz 595f42c84d Stabilize proxy timeout test 2026-06-29 22:39:52 +08:00
陈大猫 025d0cec08 Merge pull request #1823 from binaricat/codex/fix-issue-1821-script-stale-output 2026-06-29 22:30:39 +08:00
bincxz 6ba594800f Tighten side panel auto-open test coverage 2026-06-29 22:29:43 +08:00
陈大猫 16caa48a2a Merge pull request #1827 from binaricat/codex/issue-1824-zmodem-progress-toast
Fix ZMODEM transfer progress notifications
2026-06-29 22:24:51 +08:00
bincxz cdcacaef28 fix terminal reconnect output retention 2026-06-29 22:24:23 +08:00
bincxz 90b6e514ef Address terminal side panel auto-open review feedback 2026-06-29 22:24:07 +08:00
bincxz c10debea35 preserve script output during startup sync 2026-06-29 22:21:21 +08:00
bincxz d6f98948e0 Fix ZMODEM transfer progress notifications 2026-06-29 22:20:10 +08:00
bincxz 7770e86743 handle delayed show focus recovery 2026-06-29 22:18:26 +08:00
bincxz 1f7d2f91c9 Support identities for telnet and proxy credentials 2026-06-29 22:16:00 +08:00
bincxz 05231d3fba Add terminal side panel auto-open setting 2026-06-29 22:08:07 +08:00
bincxz d534b80532 fix script waits ignoring stale output 2026-06-29 21:55:26 +08:00
bincxz acd39df54b fix windows focus recovery trigger 2026-06-29 21:51:30 +08:00
陈大猫 657aa5a637 Merge pull request #1818 from binaricat/codex/issue-1817-zmodem-download-picker
[codex] Fix ZMODEM download picker in terminal worker
2026-06-29 17:32:14 +08:00
bincxz 1386357d23 fix zmodem download picker in worker 2026-06-29 17:23:45 +08:00
陈大猫 0749847fa3 Merge pull request #1810 from binaricat/codex/terminal-rendering-smoothness
[codex] Improve terminal output flood responsiveness
2026-06-29 16:32:46 +08:00
bincxz 10c9318e96 Address remaining PR review feedback 2026-06-29 16:30:50 +08:00
bincxz 136be6cdc6 Address terminal rendering review feedback 2026-06-29 16:17:50 +08:00
陈大猫 28b111dae8 Merge pull request #1813 from binaricat/codex/windows-zip-release
[codex] Add Windows zip release artifact
2026-06-29 16:04:59 +08:00
bincxz 98a2cdf22b Limit Windows zip to requested arch 2026-06-29 15:58:28 +08:00
bincxz 50a8c57023 Add Windows zip release artifact 2026-06-29 15:41:01 +08:00
bincxz 569b3d1426 Merge remote-tracking branch 'origin/main' into codex/terminal-rendering-smoothness
# Conflicts:
#	components/terminal/runtime/terminalOutputPipeline.test.ts
#	components/terminal/runtime/terminalWriteQueue.test.ts
#	components/terminal/runtime/terminalWriteQueue.ts
2026-06-29 15:25:46 +08:00
bincxz bd9dbf30f8 Preserve terminal output under backpressure 2026-06-29 15:15:53 +08:00
陈大猫 281948ee17 Merge pull request #1809 from pyroch/main
fix: Windows UI font fallback order
2026-06-29 15:04:34 +08:00
bincxz c47a116403 Improve terminal output flood responsiveness 2026-06-29 15:03:48 +08:00
陈大猫 e70b501990 Merge pull request #1808 from binaricat/codex/fix-terminal-input-echo-drift
[codex] Fix terminal input echo drift
2026-06-29 14:35:27 +08:00
pyroch 8c54a3b036 fix: index.html font stack 2026-06-29 09:33:26 +03:00
pyroch 3f0ff88107 fix: Windows UI font fallback order 2026-06-29 09:26:45 +03:00
bincxz 071ff09d8e Fix terminal input echo drift 2026-06-29 14:25:37 +08:00
陈大猫 45c9b9e86d Collapse host notes editor
Move host notes below connection fields and hide empty notes behind a switch.
2026-06-29 13:56:16 +08:00
陈大猫 ae7798f997 Merge pull request #1781 from StartHex/codex/remember-terminal-toolbar-state
Remember terminal encoding per host
2026-06-29 13:29:44 +08:00
陈大猫 94224d41a8 Merge pull request #1799 from binaricat/codex/issue-1425-ssh-timeout-display
Fix SSH timeout countdown mismatch
2026-06-29 13:27:49 +08:00
bincxz 324b7b47fd Fix terminal flow ack threshold test 2026-06-29 13:22:55 +08:00
bincxz b1db158b09 Merge remote-tracking branch 'origin/main' into codex/pr-1781-review-fix 2026-06-29 13:18:59 +08:00
bincxz 331edcbcc5 Fix SSH timeout countdown mismatch 2026-06-29 13:16:27 +08:00
bincxz c1563e3862 Fix remembered serial terminal encoding 2026-06-29 13:12:39 +08:00
陈大猫 922c372cb8 Merge pull request #1796 from binaricat/codex/issue-1502-keyword-highlight-enable-rule
[codex] Fix host keyword highlight enablement
2026-06-29 13:07:00 +08:00
陈大猫 4fdf892b6a Merge pull request #1797 from binaricat/codex/issue-1779-script-editor-layout
Fix script output trigger modal layout
2026-06-29 13:04:42 +08:00
陈大猫 c2361461f7 Merge pull request #1798 from binaricat/codex/issue-1060-topsec-ghost-delete 2026-06-29 12:09:50 +08:00
陈大猫 1e9e4a10e7 Merge pull request #1795 from binaricat/codex/issue-1753-sftp-reopen-last-path 2026-06-29 12:09:18 +08:00
陈大猫 45d51d204c Merge pull request #1802 from binaricat/codex/issue-1790-terminal-flow-ack-idle-flush
[codex] Fix terminal flow ack idle flush
2026-06-29 12:05:23 +08:00
陈大猫 9f3603d4ae Merge pull request #1800 from binaricat/codex/issue-1250-mosh-utf8-decoder
Fix Windows mosh reconnect output garbling
2026-06-29 12:02:24 +08:00
bincxz 4ab29424b7 fix(sftp): restore reopened panel path 2026-06-29 12:00:35 +08:00
bincxz 6c04cecbba Fix ghost autocomplete after Topsec backspace echo 2026-06-29 11:57:08 +08:00
陈大猫 1337eb355b Merge pull request #1792 from binaricat/codex/issue-1784-zmodem-worker-dialog
Fix ZMODEM upload picker in terminal worker
2026-06-29 11:55:09 +08:00
陈大猫 584abffa74 Merge pull request #1801 from binaricat/codex/issue-1786-terminal-tui-flow
Fix terminal TUI output throttling regression
2026-06-29 11:51:18 +08:00
bincxz 6fd8d711e9 Publish pinned Windows mosh runtime bundle 2026-06-29 11:42:39 +08:00
陈大猫 506bc849ee Merge pull request #1803 from binaricat/codex/issue-1285-codex-login-status
[codex] Fix Codex login status validation
2026-06-29 11:42:26 +08:00
陈大猫 ff639063b0 Merge pull request #1804 from binaricat/codex/issue-1768-terminal-input-priority
Fix terminal history edit echo desync
2026-06-29 11:37:28 +08:00
bincxz 4c00ec42f9 fix terminal input echo desync 2026-06-29 11:30:43 +08:00
bincxz 4e5ad952f8 Fix ZMODEM upload picker in terminal worker 2026-06-29 11:29:59 +08:00
bincxz 9f86ae2c54 fix codex login status validation 2026-06-29 11:29:58 +08:00
bincxz 4683cbc299 fix terminal flow ack idle flush 2026-06-29 11:24:15 +08:00
bincxz 4eebd091bf Fix terminal TUI output throttling regression 2026-06-29 11:23:40 +08:00
陈大猫 70e06201d3 Merge pull request #1794 from binaricat/codex/issue-1497-webgl-render-guard
test: harden terminal WebGL recovery guard
2026-06-29 11:22:58 +08:00
bincxz 2c953cd5cb Fix Windows mosh reconnect output garbling 2026-06-29 11:22:13 +08:00
bincxz 8f1167ff34 Fix script output trigger modal layout 2026-06-29 11:17:54 +08:00
bincxz f5f3b1bb20 Fix host keyword highlight enablement 2026-06-29 11:17:13 +08:00
bincxz 98438de1c4 test: harden terminal webgl recovery guard 2026-06-29 11:16:17 +08:00
shixu 61f89e7f7d Update CI expectations for terminal theme and MCP tools 2026-06-29 10:58:25 +08:00
陈大猫 30d78ebc5a Merge pull request #1791 from binaricat/fix/terminal-search-refocus-1789
fix(terminal): refocus search box when Cmd/Ctrl+F re-fires while open
2026-06-29 10:57:08 +08:00
bincxzandClaude f23c0fa0e0 chore: drop dead setIsSearchOpen from Terminal ctx plumbing
useTerminalEffects no longer consumes setIsSearchOpen (the runtime now uses
requestSearchFocus), so stop destructuring and passing it through. Caught by
codex review as a harmless leftover.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-29 10:52:15 +08:00
shixu e2e23c29da Fix terminal memo and interrupt output tests 2026-06-29 10:49:19 +08:00
shixu 2e9c021e68 Address terminal encoding review feedback 2026-06-29 10:49:19 +08:00
shixu b7491fd05a Scope terminal encoding memory to host 2026-06-29 10:49:19 +08:00
shixu 003f1e1cb9 Remember terminal encoding preference 2026-06-29 10:49:19 +08:00
bincxzandClaude fc30fd4b3c fix(terminal): make search hotkey robust to stale ctx capture
Codex review of the first pass found a stale-closure bug: createXTermRuntime
captures its ctx once (the effect only re-runs on host.id/sessionId change),
so the original requestSearchFocus — which branched on a captured isSearchOpen
— locked in the initial state and never bumped the focus token on later
Cmd/Ctrl+F presses.

Make requestSearchFocus state-independent: always setIsSearchOpen(true) and
bump the focus token. When closed, opening mounts the SearchBar (isOpen effect
focuses); when open, the token bump refocuses. The callback now has a stable
identity and behaves correctly regardless of when it was captured.

Also drop the now-unused setIsSearchOpen from the runtime ctx type and its
ref/construction sites.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-29 10:45:34 +08:00
bincxzandClaude 377cd2ae6f fix(terminal): refocus search box when Cmd/Ctrl+F re-fires while open
The searchTerminal hotkey called setIsSearchOpen(true), which is a no-op
when the bar is already open — React bails on the unchanged boolean, the
SearchBar's focus effect (dep [isOpen]) never re-runs, and focus stays
wherever the user moved it. Route the hotkey through a new
requestSearchFocus that bumps a focusToken when already open; the
SearchBar watches that token and refocuses its input.

Closes #1789

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-29 10:36:17 +08:00
陈大猫 a50be64266 Merge pull request #1787 from binaricat/fix/vite-outdated-optimize-dep
Fix dev 504 Outdated Optimize Dep
2026-06-29 10:16:21 +08:00
bincxzandCursor 9a81b11317 Use cross-platform Node script for clean:vite on Windows.
Replace rm -rf with fs.rmSync so dev:clean works under npm on Windows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 10:10:09 +08:00
陈大猫 2f22765133 Merge pull request #1777 from LaiYueJi/i18n/add-zh-tw
feat(i18n): add Traditional Chinese (zh-TW) locale
2026-06-29 10:09:00 +08:00
bincxzandCursor 98f260b047 Fix dev 504 Outdated Optimize Dep with cache clean scripts and auto reload.
Add clean:vite/dev:clean npm scripts and reload the renderer when Vite
returns 504 for stale pre-bundled deps after pull or npm install.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 10:05:46 +08:00
LaiYueJi 42211fb432 feat(i18n): add Traditional Chinese (zh-TW) locale
Add Traditional Chinese (Taiwan) translations, derived from zh-CN and
adapted to Taiwan terminology and phrasing (e.g. 軟體/網路/設定/工作階段/
檔案/連接埠/伺服器). Register zh-TW in messages.ts and append it to
SUPPORTED_UI_LOCALES so it appears in the language selector.
2026-06-29 07:18:00 +08:00
陈大猫 52974c5e77 Merge pull request #1778 from binaricat/fix/wait-for-script-run-tdz
Fix waitForScriptRun TDZ crash and remove connect script auto-retry
2026-06-29 02:47:32 +08:00
bincxzandCursor 41f4dd0781 Fix connect-script catch path without falsely marking batch complete.
Keep in-flight until optional pending script run finishes; do not mark
unstarted connect scripts as completed on batch failure.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 02:47:23 +08:00
bincxzandCursor 1d18ca9761 Address bugbot findings for script wait and connect lifecycle.
Use tail-fresh matching instead of script-start baseline so waitForPrompt
still works while stale scrollback no longer satisfies waitFor (#1776).
Defer subscribeScriptRuns notifications, pre-check terminal runs, and
run pending connect scripts when an earlier connect batch fails without retry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 02:45:02 +08:00
bincxzandCursor b310ec7a25 Fix waitFor matching stale terminal scrollback at script start.
Mark the session output scan baseline after syncing the screen snapshot
so waitFor/waitForAny only match output that arrives after the run begins.
Fixes interactive prompts firing sendLine before the server asks (#1776).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 02:41:52 +08:00
bincxzandCursor 85524afc0d Fix waitForScriptRun TDZ crash and remove connect script auto-retry.
subscribeScriptRuns invokes listeners synchronously; finish() must not call
unsubscribe before assignment when the run is already terminal. Drop the
connectScriptRetryTick loop so a post-run error no longer re-queues scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 02:39:08 +08:00
陈大猫 6b85fbbafc Merge pull request #1775 from binaricat/cursor/snippets-header-actions-fade
Add fade gradient to scripts header when side panel is open
2026-06-29 01:56:10 +08:00
bincxzandCursor 12971e1ca4 Add fade gradient to scripts header actions when side panel is open.
Softens the scroll edge before the fixed view controls so toolbar buttons do not look abruptly cut off in split layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 01:55:59 +08:00
陈大猫 997bd2b3d7 Merge pull request #1774 from binaricat/cursor/fix-scripts-header-after-panel-close
Fix scripts header buttons after closing side panel
2026-06-29 01:54:27 +08:00
bincxzandCursor db4cff0309 Fix scripts header actions staying hidden after closing the side panel.
Keep toolbar buttons visible with a scrollable action row instead of collapsing them to zero width when the edit panel opens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 01:54:11 +08:00
陈大猫 8709a5e354 Merge pull request #1773 from binaricat/cursor/vault-host-panel-and-snippet-tooltip
Polish vault UI: resizable host panels, snippet tooltips, known hosts actions
2026-06-29 01:48:59 +08:00
bincxzandCursor e9587de1ea Align known hosts card actions with vault host list hover buttons.
Move convert/delete controls to the item trailing edge with standard ghost icon buttons instead of tiny corner overlays.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 01:48:43 +08:00
bincxzandCursor bd12dc663e Polish vault host-details and snippet UI.
Truncate long snippet command tooltips, add drag-resize for inline host/group detail panels, and compact appearance override reset controls.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 01:41:03 +08:00
陈大猫 24667b7163 Merge pull request #1772 from binaricat/fix/ci-lockfile-anthropic-sdk
fix(ci): restore @anthropic-ai/sdk entry in package-lock.json
2026-06-29 00:58:31 +08:00
bincxzandCursor 108cea1e5d fix(ci): restore @anthropic-ai/sdk entry in package-lock.json
PR #1763 accidentally dropped the resolved @anthropic-ai/sdk@0.106.0 lock
entry, causing npm ci to fail on npm 10 (Node 22 CI) with EUSAGE.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 00:58:04 +08:00
陈大猫andCursor b2e08e3963 feat(vault-agent): expose snippet/script CRUD and chat artifact cards (#1771)
Expose snippets and automation scripts to Catty, MCP, and CLI with full run lifecycle, vault bridge CRUD, and clickable tool-result cards in the chat message list.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 00:40:54 +08:00
陈大猫andCursor b1f199c4d7 fix(sync): show sync dates and pending-only green dot (#1765) (#1770)
Add yyyymmdd hhmm timestamps to cloud sync history and only show the green status dot when local changes are still pending upload.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 00:12:03 +08:00
陈大猫andCursor ee4b09059e Fix autocomplete popup boundary clamp. (#1769)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 23:59:24 +08:00
陈大猫andCursor 6b9b71ac8a feat(scripts): terminal script automation, recording, and side panel (#1763)
* feat(scripts): add terminal script automation, recording, and side panel

Introduce nct script runtime in the main process with terminal-side recording,
execution overlay, Monaco editor, connect-script queues, and scripts side panel
integration. Fix recording to wire scriptRecorderRef into xterm, use waitForPrompt
for root/user shells, and save recorded scripts through the global editor dialog.

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

* fix(scripts): address Bugbot review findings for recording and side panel

Refresh script run state in side panel memo, guard toolbar recording against
multi-session conflicts, reset connect scripts on reconnect, resolve focused
session via getActiveTerminalSessionId, and avoid wiping host edit form when
snippets list updates.

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

* fix(scripts): prevent pending script re-run and improve workspace run feedback

Track one-shot pendingScript execution separately from onConnect queue so
reconnects do not replay Run-now scripts. Fall back to focused session when
running on all tabs outside a workspace, and toast when every tab is still
connecting.

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

* fix(scripts): consume connect-script window once and filter serial hosts

Mark the onConnect script evaluation as consumed even when the queue is
empty so later snippet/host edits do not rerun automation mid-session.
Restore serial-host filtering in SelectHostPanel confirm handler.

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

* fix(scripts): grace period before giving up on connect-script queue

Only mark onConnect automation as consumed after scripts run or a short
post-connect grace window expires, so late-loading snippets still trigger
while mid-session Vault edits do not.

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

* fix(scripts): gate connect-script consumption on vault hydration

Replace the grace timer with vault-init and unresolved host script binding
checks so late-loading snippets still run onConnect automation while
mid-session edits stay suppressed. Clear pending script run state on
disconnect so reconnect retries workspace pending scripts.

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

* fix(scripts): honor run targets and retry failed connect automation

Route QuickScriptEditorDialog Run through onRunSnippet for multi-host
workspaces. Defer connect/pending script completion markers until each
script succeeds and retry the queue after failures.

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

* fix(scripts): stabilize pending runs and global connect queue sync

Keep pending script run state across reconnects, run onConnect scripts
before one-shot workspace pending scripts, and strip per-host queue entries
when an onConnect script targets all hosts.

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

* fix(scripts): defer host queue snippet sync and guard connect runs

Persist snippet target links only when the host panel is saved, block
duplicate connect-script scheduling while a batch is running, and require
runnable hosts before showing Run in the snippets editor footer.

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

* fix(scripts): retry connect failures and surface output-trigger errors

Use a retry tick in connect-script effect deps, toast automation failures
from output triggers, and require runnable hosts before QuickScript Run.

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

* fix(scripts): track id-less pending runs and re-arm output triggers

Remember anonymous pendingScript objects after first run and allow
onOutput triggers to fire again when new terminal output arrives.

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

* fix(scripts): unblock paused connect runs and pass session meta

Resolve waitForScriptRun on pause, run workspace scripts per session
with hostname context, defer output-trigger arming until runs start, and
add noRunnableHosts i18n for QuickScript Run.

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

* fix(scripts): serialize output triggers and defer empty connect finalize

Fire at most one onOutput trigger per scan while a run is active, and
only finalize an empty connect queue after vault snippets are loaded.

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

* fix(scripts): guard output launch race and script run waits

Block overlapping onOutput launches, timeout stuck waitForScriptRun
promises, and disable vault Run when no connectable targets exist.

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

* fix(scripts): match output triggers on new chunks only

Scan onOutput patterns against recent terminal output instead of buffer
length dedupe, and restore overflow-visible on SelectHostPanel new-host flow.

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

* fix(scripts): await sequential workspace runs and output launch

Wait for each tab script to finish in sequential workspace mode, and hold
output-trigger launch guard until the session run becomes active.

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

* fix(scripts): wait through pause and hold output trigger until done

Keep connect and sequential workspace queues blocked until scripts
complete or fail, and retain output-trigger guard until the launched run
finishes.

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

* fix(scripts): harden output trigger wait helpers

Add explicit timeouts to output-trigger wait helpers and stop rescanning
the buffer after script completion to avoid sticky-match rerun loops.

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

* fix(scripts): allow empty connect queue and match output in new chunks

Treat an empty connectScriptIds array as an explicit cleared queue instead
of re-migrating from snippet targets, and only fire onOutput triggers when
the latest terminal output contains the pattern.

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

* fix(scripts): require fresh output overlap for onOutput triggers

Only fire output-matched scripts when the regex match extends into the
latest terminal chunk, preventing reruns from stale buffer text.

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

* fix(scripts): promote manual scripts added to host connect queue

Diff connect queue changes from raw connectScriptIds so saving a host
updates manual scripts to onConnect when they are newly queued.

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

* fix(scripts): dedupe output triggers by match position

Track the buffer end offset of each output trigger match and ignore
stale re-scans, with a longer wait for run registration before retry.

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

* fix(scripts): record output trigger matches only after run starts

Propagate automation launch failures to output triggers and commit match
dedupe only once the session run is registered as active.

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

* fix(scripts): address Codex review on wait cursor and sensitive codegen

Track scan offset so waitFor/waitForAny only match output after the previous wait, and emit sendLine after sensitive prompt in recorded scripts.

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

* fix(scripts): harden onOutput triggers against PTY echo and local input

Filter server output before pattern matching so onOutput fires only after
Enter on real stdout, not on keystroke/paste echo, OSC/SGR sequences, or
alternate-screen apps. Add dev diagnostics and scope triggers to the active
session when no explicit host targets are configured.

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

* chore(scripts): remove onOutput trigger debug logging

Drop temporary diagnostics module and console traces now that PTY echo
filtering is verified; keep the production trigger pipeline unchanged.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 23:48:12 +08:00
陈大猫 9d98853091 Merge pull request #1762 from binaricat/fix/issue-1761-unfocused-terminal-repaint
fix(terminal): keep tmux updating when window loses focus (#1761)
2026-06-28 13:11:42 +08:00
bincxzandCursor 7f90896dbc fix(terminal): flush write coalescer when window loses focus
Bypass the RAF-bound output coalescer for visible unfocused panes so flow control does not pause the PTY before unfocused repaints can run.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 13:06:40 +08:00
bincxzandCursor 8c4575c0d1 fix(terminal): skip unfocused repaint for hidden panes
Only run the synchronous unfocused repaint path when the pane is visible, avoiding wasted row rendering for background tabs while the window is unfocused.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 13:02:21 +08:00
bincxzandCursor 232cbb9b67 fix(terminal): keep tmux output flowing when the window loses focus
Force synchronous xterm repaints while the app stays visible but unfocused so Windows Chromium does not stall write callbacks, output flow control, and tmux redraws until refocus.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 12:57:31 +08:00
陈大猫 d1ef4c4e33 Merge pull request #1759 from binaricat/codex/fix-ctrl-c-htop-exit
[codex] Fix Ctrl+C full-screen terminal cleanup
2026-06-28 06:30:27 +08:00
bincxzandCursor ff298476f1 Fix interrupt priority regressions found in Bugbot review.
Keep write-queue and flow ack flushing when stale display drain is disabled for non-SSH protocols, and preserve terminal restore controls on split ^C caret echoes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 06:30:14 +08:00
bincxz 59ea442f5c Fix Ctrl+C full-screen terminal cleanup 2026-06-28 06:10:57 +08:00
陈大猫 124bab4761 Merge pull request #1758 from binaricat/fix/terminal-tui-tab-switch-resize
Fix TUI layout after switching terminal tabs
2026-06-28 03:50:57 +08:00
bincxzandCursor 526187f635 Defer PTY sync until after RAF terminal refit completes.
Run finishLayoutRecoveryAfterFit so SIGWINCH uses post-refit cols/rows instead of stale dimensions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 03:49:26 +08:00
bincxzandCursor 99095e2f43 Fix TUI layout after switching back to a terminal tab.
Force refit and PTY resize when a hidden pane becomes visible again so full-screen TUIs receive SIGWINCH instead of staying at a stale default size.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 03:45:55 +08:00
陈大猫 8db0b40d74 Merge pull request #1757 from binaricat/fix/terminal-theme-switch-and-tab-perf
Fix terminal theme mode switching and workspace tab switch perf
2026-06-28 03:29:04 +08:00
bincxzandCursor 71687491f0 Fix terminal theme mode switching and speed up workspace tab changes.
Keep connection dialog and chrome surfaces in sync when toggling follow-app vs manual themes, and isolate side-panel live state per tab so switching workspace tabs no longer re-renders every mounted panel.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 03:28:50 +08:00
陈大猫 c998b69bd8 Merge pull request #1756 from binaricat/fix/restored-session-sidebar-chrome
Fix restored session sidebar chrome for host tree and side panel
2026-06-28 02:54:48 +08:00
bincxzandCursor d207a1fb44 Align workspace fallback session selection for theme and host highlight.
Share tree-order fallback between resolveActiveChromeTheme and resolveWorkTabActiveHostId so stale focusedSessionId after restore keeps sidebar theme and highlight in sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 02:52:19 +08:00
bincxzandCursor 19844e2831 Fix restored session sidebar chrome for host tree and side panel tabs.
Align AppHostTreeLayer with resolveActiveChromeTheme, use inline terminal colors for side panel tab bars/borders, and fall back workspace active host when focused session is stale.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 02:48:51 +08:00
陈大猫 13061fda9a Merge pull request #1755 from binaricat/feat/terminal-theme-runtime
Unify terminal theme updates through ThemeRuntime
2026-06-28 02:38:29 +08:00
bincxzandCursor 5f403232ab Fix host tree toolbar fade to use themed background color.
Drive the action-row gradient from theme.termBg instead of falling back to
the app background, and satisfy useMemo exhaustive-deps for preview theme.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 02:37:49 +08:00
bincxzandCursor 6a9ed0fc9d Re-render host tree sidebar when resolved preview theme changes.
Include theme fingerprint in the sidebar memo comparator so inline host-tree
colors update when terminal themes change without host list churn.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 02:29:41 +08:00
bincxzandCursor 12e1ec7dcd Fix host tree theme drift when leaving terminal work tabs.
Derive sidebar row colors from resolvedPreviewTheme again and limit manual
chrome var injection to terminal-layer surfaces only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 02:27:20 +08:00
bincxzandCursor efa223d9db Re-sync xterm theme when a hidden pane becomes visible again.
Include pane visibility in the theme layout effect dependencies so pending
scheduler updates are cancelled and the current theme applies immediately.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 02:23:22 +08:00
bincxzandCursor f726c6b4f6 Fix ThemeRuntime review findings for intent scope and theme sync.
Keep follow-app intent until the side panel closes, scope manual picks to
the focused host, and cancel stale hidden-pane scheduler updates when a
visible terminal applies its theme synchronously.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 02:20:06 +08:00
bincxzandCursor eee7a8846c Unify terminal theme updates through ThemeRuntime for faster, synced UI.
Replace parallel preview/pending follow-app pipelines with a single runtime
that drives xterm, side panel, host tree, and top tabs together in both
follow-app and manual modes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 02:17:06 +08:00
陈大猫 b09e468f04 Merge pull request #1752 from binaricat/codex/app-resume-webgl-recovery
[codex] Recover terminal renderer on app resume
2026-06-28 00:47:05 +08:00
bincxz 686543b90a Recover terminal renderer on app resume 2026-06-28 00:38:42 +08:00
陈大猫 c89d3a780e Merge pull request #1751 from binaricat/fix/terminal-close-capture-perf
Speed up terminal tab close with deferred capture
2026-06-28 00:29:27 +08:00
bincxzandCursor fa72bb28ff Fix close capture races found in Bugbot review.
Persist connection-log and fallback serialize payloads before teardown, defer only xterm disposal via microtask, and ignore stale async close work when the boot effect restarts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 00:20:22 +08:00
bincxzandCursor 3c96452513 Fix terminal boot effect infinite loop from teardown dependency.
teardown is recreated every render; including it in the boot effect deps caused cleanup to run repeatedly and exceed React update depth.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 00:12:37 +08:00
bincxzandCursor cc511a6b43 Speed up terminal tab close by deferring close capture work.
Prefer the incremental connection log buffer on unmount, fall back to capped hibernate serialize, tear down xterm before async capture, and avoid blocking the UI on full SerializeAddon output.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 00:05:28 +08:00
陈大猫 1678404081 Merge pull request #1745 from binaricat/codex/terminal-ctrlc-output-drain
Improve terminal interrupt responsiveness
2026-06-27 23:52:45 +08:00
bincxzandCursor b2ea89a1a0 Arm broadcast Ctrl+C sibling panes with renderer interrupt drain.
Register per-session prioritize handlers so broadcast interrupt clears xterm backlog on peer tabs, not just the focused session.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 23:49:19 +08:00
bincxzandCursor ffd6cb588c Require prompt suffix before main-process prompt-gap resume.
Prevent stale flood bytes from bypassing the interrupt output gate when only a short quiet gap is observed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 23:38:14 +08:00
bincxzandCursor 1263eaa31f Fix interrupt output gate edge cases found in Bugbot review.
Align main-process caret splitting and renderer prompt-candidate filtering with the display gate, and route broadcast Ctrl+C through interruptSession.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 23:33:52 +08:00
bincxz cb2189a2e3 Improve SSH Ctrl-C responsiveness 2026-06-27 22:36:51 +08:00
bincxz 235eea1e80 Pace flood output before terminal interrupt 2026-06-27 17:59:05 +08:00
bincxz 0f85eaba8e Fix urgent interrupt input cleanup 2026-06-27 17:46:24 +08:00
陈大猫 bb680703e7 scale catty total timeout for multi-step turns (#1749) 2026-06-27 17:44:30 +08:00
陈大猫 cfd778e7d5 fix ai command timeout budget (#1748) 2026-06-27 17:25:18 +08:00
bincxz 5080d62a8b Fix worker terminal AI and output edge cases 2026-06-27 17:19:03 +08:00
陈大猫 5883362f62 Merge pull request #1744 from StartHex/codex/remember-terminal-toolbar-state
Remember terminal compose and search visibility
2026-06-27 16:34:28 +08:00
bincxz 3b774ca9a1 Improve terminal interrupt responsiveness 2026-06-27 12:46:38 +08:00
shixu 6c1ff4e4ca Remember terminal compose and search visibility 2026-06-27 11:57:06 +08:00
陈大猫 6c93016460 Merge pull request #1742 from binaricat/fix/pack-ship-terminal-flow-constants
fix(pack): ship terminal flow constants in app.asar
2026-06-27 03:32:46 +08:00
bincxzandCursor 8e4d6fe752 fix(pack): ship terminal flow constants in app.asar
terminalFlowAck.cjs requires infrastructure/config/terminalFlowConstants.cjs
at main-process startup; omitting it caused v1.1.46 to crash immediately on launch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 03:32:06 +08:00
陈大猫 daaa51e090 Merge pull request #1741 from binaricat/fix/settings-ui-theme-above-app-icon
fix(settings): move UI theme above app icon
2026-06-27 02:53:20 +08:00
bincxz 81d88aed13 fix(settings): move UI theme section above app icon
Reorder appearance settings so interface theme controls appear before
the app icon picker.
2026-06-27 02:53:06 +08:00
陈大猫 5d555c9eb9 Merge pull request #1739 from binaricat/codex/fix-packaged-tool-cli-runtime
[codex] fix packaged tool cli runtime
2026-06-27 02:50:01 +08:00
陈大猫 7dc5103833 Merge pull request #1740 from StartHex/codex/notes-export
Add notes markdown export
2026-06-27 02:48:51 +08:00
陈大猫 0020181b3e Merge pull request #1738 from binaricat/fix/issue-1711-output-flood-input
fix(terminal): keep input responsive during high-output floods (#1711)
2026-06-27 02:48:16 +08:00
bincxz 579daf8852 fix(terminal): load flow constants via JSON in renderer
createRequire() in terminalFlowConstants.ts breaks Vite/browser bundling
and caused "Terminal could not load". Share thresholds through
terminalFlowConstants.json consumed by both renderer and main process.
2026-06-27 02:45:18 +08:00
bincxz a3eae4d2ca fix(terminal): defer only IPC acks, not renderer flow.written
Deferred batching must not inflate renderer pendingBytes after xterm
has already parsed output; only main-process IPC ACKs are batched.
Flush/teardown/prioritize paths now emit deferred IPC without double
counting flow.written.
2026-06-27 02:41:46 +08:00
bincxz 7163e0e144 perf(terminal): high-ROI flood optimizations for flow control (#1711)
- Share flow thresholds via infrastructure/config/terminalFlowConstants.cjs
- Drop main-process ptyOutputBuffer data when session flow is paused
- Tighten rAF coalescer cap to 128KB during renderer flood mode
- Batch deferred flow ACKs (~100KB) for small writes; flush on input/teardown
2026-06-27 02:39:32 +08:00
shixu 8050e0dab4 Add notes markdown export 2026-06-27 02:33:56 +08:00
bincxz a759e11066 fix(terminal): flush IPC ack remainders after drop-based recovery
Dropped coalescer/queue bytes can sit below the 5KB batch threshold;
flush immediately so main-process unackedBytes drains and the stream resumes.
2026-06-27 02:26:36 +08:00
bincxz 7071741315 fix(terminal): flush hibernate ACK buffer on wake without reattach
Clear batched IPC acks and rendererPaused when stopping hibernate
listeners so main-process unackedBytes cannot stay inflated after wake.
2026-06-27 02:23:22 +08:00
bincxz 52b5cbc300 fix(terminal): tear down flow pipeline before hibernate and cleanup
- Release coalescer/queue backlog and clear rendererPaused before hibernate
- Flush ACK buffers and resume flow on hibernate listener setup and force-close
- Reuse releaseTerminalFlowBeforeHibernate in cleanupSession when xterm is alive
2026-06-27 02:19:24 +08:00
bincxz 490f033cfd fix(terminal): align ingress byte accounting and queue drop handlers
- Track IPC ingress bytes separately from display expansion (serial LF→CRLF)
- Bind write-queue drop handlers before the queue is first created
- Flush/clear session ACK buffers on cleanup; reset flow on input prioritize
- Add tests for ingress accounting, drop-handler binding, and constant parity
2026-06-27 02:16:12 +08:00
bincxz c4333ae87a fix packaged tool cli runtime 2026-06-27 02:16:08 +08:00
bincxz 1cd4f1827f fix(terminal): align flow control with VS Code and batch IPC acks
- Match VS Code watermarks (100K/5K) and CharCountAckSize (5K)
- ACK ingress bytes (not filtered length) to stay in sync with trackEmitted
- Add AckDataBufferer-style batching via terminalFlowAckBuffer
- Hibernate listeners ack buffered output so main-process backpressure works
2026-06-27 02:10:19 +08:00
bincxz b3bf566db4 fix(terminal): keep input responsive during high-output floods (#1711)
Tighten renderer output back-pressure (128KB/32KB watermarks), account
coalescer and write-queue backlog, drain output on user keystrokes, and
add main-process IPC ACK flow control so SSH floods cannot starve Ctrl+C
and other keyboard input.

Closes #1711
2026-06-27 01:53:41 +08:00
陈大猫andCursor 5dad3d0e65 fix(logs): preserve connection log terminal replay data (#1733)
* fix(logs): preserve connection log terminal replay data (#1733)

Persist unsaved terminal replay buffers in a side store and merge on reload so pruned localStorage writes and cross-window sync no longer wipe captured session output. Also bind hotkey local terminals to sessionId and match captures strictly by session.

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

* fix(logs): merge side-store writes and handle quota failures (#1733)

Fold existing localStorage terminal replay data before persisting the side store so multi-window writes do not clobber each other. Skip pruning the main connection-log blob when the side-store write fails so unsaved replay data is not dropped from disk.

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

* fix(logs): retain orphan side-store entries across window sync (#1733)

Keep terminal replay buffers for log ids not yet present in this window's connection log snapshot, and warn when the main blob fallback write also fails under quota pressure.

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

* fix(logs): gate orphan side-store retention on persisted log ids (#1733)

Only retain side-store replay buffers for log ids still present in the persisted connection-log blob, and write the pruned main blob before syncing the side store so deletions are not resurrected.

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

* fix(logs): prefer fresher local data when retaining orphan replays (#1733)

When restoring side-store entries for log ids not yet in memory, use the merged local/ref snapshot instead of stale on-disk side-store bytes.

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

* fix(logs): skip orphan side-store sync when main blob write fails (#1733)

Avoid resurrecting deleted replay buffers from a stale persisted log-id set when the pruned connection-log blob could not be written.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 00:02:12 +08:00
陈大猫 4e2a9ef4d1 Merge pull request #1706 from Ryanisgood/restore-terminal-cwd-debug
Fix restored terminal cwd tracking
2026-06-26 23:49:30 +08:00
陈大猫 d6b926899d Merge pull request #1728 from binaricat/codex/theme-presets-copilot
[codex] Add Copilot-style theme presets
2026-06-26 23:37:56 +08:00
bincxz 367c73afb9 Reset terminal override on default theme change 2026-06-26 23:36:18 +08:00
陈大猫 25faca7f3f Merge pull request #1732 from binaricat/cursor/ca1c24b6
feat(settings): runtime Dock/taskbar icon variants (#1716)
2026-06-26 23:27:10 +08:00
bincxzandCursor 7f0500ec04 fix(ui): use live app icon for OAuth and fallback popups
Resolve the current variant icon when child windows open so icon changes apply to window.open popups.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 23:27:01 +08:00
bincxz f03fcffc80 Update npm lockfile for ci 2026-06-26 23:18:46 +08:00
bincxz b299d34a6c merge main into cursor/ca1c24b6 2026-06-26 23:17:00 +08:00
bincxzandCursor 7cba6bae2a feat(settings): add runtime Dock/taskbar icon variants (#1716)
Let users pick icon styles in Appearance settings with live Dock/window updates, dev public asset resolution, and cross-window sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 23:14:57 +08:00
陈大猫 1679e2984f Merge pull request #1730 from StartHex/codex/remote-macos-stats-pr
Fix remote macOS SSH server stats display
2026-06-26 23:13:56 +08:00
bincxz 0df189b83a Update follow theme handler test 2026-06-26 23:13:10 +08:00
bincxz 1c5723ddc7 Preserve manual terminal theme defaults 2026-06-26 23:13:10 +08:00
bincxz 0699a8a25b Prevent hidden vault header actions retaining focus 2026-06-26 23:13:10 +08:00
bincxz 7d321a4f03 Hide terminal theme preview when following app theme 2026-06-26 23:13:10 +08:00
bincxz 99f4d4b5c0 Optimize terminal theme lookup paths 2026-06-26 23:13:10 +08:00
bincxz 32b46b1ef3 Remove UI match suffix from terminal theme names 2026-06-26 23:13:10 +08:00
bincxz 0e80c874d6 Clarify terminal theme follow behavior 2026-06-26 23:13:10 +08:00
bincxz 89ed8735e7 Embed system theme presets 2026-06-26 23:13:10 +08:00
bincxz 2d4c27b12c Add Copilot-style theme presets 2026-06-26 23:13:10 +08:00
shixu f575317de8 Normalize macOS CPU stats by core count 2026-06-26 22:52:39 +08:00
陈大猫 6e8abe2a19 Merge pull request #1731 from binaricat/codex/fix-empty-command-grant
Fix empty command approval grants
2026-06-26 22:31:01 +08:00
bincxz 20f0ef77ae fix empty command approval grant 2026-06-26 22:29:58 +08:00
shixu 983574ce8c Improve remote macOS server stats collection 2026-06-26 22:26:50 +08:00
shixu cff6aa2906 Fix remote macOS stats detection 2026-06-26 22:26:50 +08:00
陈大猫 c8adc97cef Merge pull request #1729 from binaricat/codex/fix-ai-command-permission-splitting
[codex] fix AI command permission splitting
2026-06-26 21:36:56 +08:00
bincxz 2dac87f56e fix ai command permission splitting 2026-06-26 21:25:26 +08:00
陈大猫 f95cc754e7 fix(windows): restore main-window input focus on show/hide hotkey (#1722)
Route global hotkey/tray show paths through restoreWindowInputFocus, recover renderer focus on visibility/show, dismiss transient overlays before native hide, and cancel deferred focus callbacks on hide.

Closes #1722
2026-06-26 18:01:57 +08:00
陈大猫 270a02a372 Merge pull request #1719 from binaricat/codex/fix-osc7-history
Fix OSC 7 setup history pollution
2026-06-26 17:52:38 +08:00
bincxz 23719e955e fix osc7 reload history cleanup 2026-06-26 17:52:03 +08:00
bincxz 36b7c828e9 Merge remote-tracking branch 'origin/main' into codex/fix-osc7-history
# Conflicts:
#	components/terminal/osc7Setup.ts
2026-06-26 17:12:59 +08:00
bincxz 8539747e29 preserve zsh history hook during osc7 setup 2026-06-26 17:01:43 +08:00
陈大猫 b88ba552f4 Merge pull request #1723 from binaricat/codex/serial-autocomplete-preview
Fix serial autocomplete preview
2026-06-26 16:52:39 +08:00
bincxz 9ea1900f30 Fix serial autocomplete preview 2026-06-26 16:52:08 +08:00
陈大猫 7366467831 Merge pull request #1720 from binaricat/codex/osc7-background-setup
Fix directory tracking setup flow
2026-06-26 16:51:03 +08:00
陈大猫 5fdb56c243 Merge pull request #1725 from binaricat/codex/autocomplete-popup-boundary-clamp
fix(autocomplete): clamp popup within terminal viewport
2026-06-26 16:50:54 +08:00
mam15monandmam15mon e64df373a7 Add group device type inheritance (#1708)
* Add group device type inheritance

* Sync package lock for CI

* Persist group device type setting

* Fix group device type inheritance controls

* Fix device type inheritance with shell transports

* Use resolved hosts for AI executor context

* Reduce device type inheritance diff scope

---------

Co-authored-by: mam15mon <mam15mon@gmail.com>
2026-06-26 16:44:08 +08:00
bincxz 1c4be78060 fix(autocomplete): clamp popup within terminal viewport 2026-06-26 16:43:17 +08:00
陈大猫 fd3de5c6c1 [codex] Lazy load OpenCode model catalogs
Lazy load OpenCode model catalogs for OpenCode.
2026-06-26 16:37:08 +08:00
bincxz c9a160556a Fix directory tracking setup flow 2026-06-26 16:28:38 +08:00
bincxz 54a85d8e99 fix osc7 history test shell echo handling 2026-06-26 16:21:33 +08:00
bincxz ace0cbe578 fix npm 10 lockfile sync 2026-06-26 16:16:20 +08:00
陈大猫 ced31bbf0e Merge pull request #1713 from binaricat/binaricat-fix-autocomplete-position-1710
fix(autocomplete): prevent popup from rendering below visible terminal screen (#1710)
2026-06-26 16:12:49 +08:00
bincxz c1d0a43a29 fix osc7 setup history pollution 2026-06-26 16:12:13 +08:00
bincxz 0ac753e298 fix(autocomplete): define popup reposition before dependent hooks 2026-06-26 15:53:01 +08:00
bincxzandCopilot fa3f17fa32 fix(autocomplete): prevent popup from rendering below visible terminal screen (#1710)
Two related fixes for the autocomplete popup flipping below the visible
terminal area when navigating suggestions with the cursor at the bottom:

1. Clamp popup bounds to the actual .xterm-screen rect instead of the
   .xterm-container rect. The container can be a few pixels taller due to
   padding/rounding, which created a false positive spaceBelow value and
   caused short suggestion lists to flip downward off-screen.

2. Refresh the popup anchor after live-preview writes and after sub-dir
   panels update. Cursor movement from preview text or width changes from
   sub-dir panels could leave the stored anchor stale.

Add unit tests covering short-popup bottom-row placement, clamp viewport
using the screen rect, and split-pane precedence.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 15:46:10 +08:00
陈大猫 a35319f0ad Merge pull request #1715 from binaricat/codex/fix-terminal-startup-race
[codex] Fix terminal startup output race
2026-06-26 15:32:50 +08:00
bincxz 33dec754d5 Fix terminal startup output race 2026-06-26 15:22:52 +08:00
陈大猫andCursor 2029b21e0b feat(ai): improve Catty context budget, step guards, and compaction UX
* Improve Catty context budget, step guards, and compaction reliability.

Anchors session state across compactions and surfaces compaction to the UI so long tool-heavy turns stay within context limits.

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

* Fix Codex review: gate reinjection and match sftp_read_file.

Only reinject session context after actual compaction adjustments, and recognize the generated sftp_read_file tool name in stale read pruning.

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

* Scope stale pruning by session for SFTP reads and terminals.

Include sessionId in read fingerprints and keep the last two terminal outputs per session instead of globally across the conversation.

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

* Cap output reserve for small context windows in budget math.

Prevent default 4096-token output reserve from collapsing compaction thresholds on 4k/8k models into near-zero values.

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

* Gate terminal stale pruning on budget pressure and clear old assistant status.

Only omit older terminal_execute output when compaction budget is tight, and clear step-compaction statusText from the prior assistant when a new assistant message starts after tool results.

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

* Gate SFTP read supersession on budget pressure like terminal pruning.

Preserve earlier file reads during read-edit-reread compare flows until context compaction is actually needed.

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

* Use compaction summary budget for pre-summarize pruning.

Reserve the 1600-token summary output when fitting messages for LLM compaction, and skip superseding successful reads when a later read failed.

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

* Prune oldest messages first when fitting compaction summarize input.

Keep the portion of pre-tail history closest to the current task instead of dropping recent summarize candidates from the end.

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

* Detect JSON tool errors before superseding stale reads.

Treat structured error payloads as failed reads so a later SFTP failure does not replace the last successful file contents.

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

* Show compaction as a transient inline shimmer instead of a banner.

Emit compaction_start/compaction events and render a single ephemeral status line in the message list that clears when compaction finishes.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 14:35:44 +08:00
陈大猫andCursor 97b1a2a336 Migrate Catty harness to AI SDK v7 and fix tool denial UI. (#1707)
Adopts streamText instructions/runtimeContext/toolApproval APIs, lifecycle events, and denied-tool result rendering so observer blocks and approval rejections stay visible in chat history.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 11:15:46 +08:00
Zheng Liu f4ca635c1a Fix restored terminal cwd tracking 2026-06-26 10:51:59 +08:00
陈大猫 d5de40218c Merge pull request #1700 from binaricat/codex/fix-node-pty-spawn-helper-perms
[codex] Fix node-pty spawn helper permissions
2026-06-26 03:03:02 +08:00
bincxz c9db5d5429 Fix node-pty spawn helper permissions 2026-06-26 03:02:41 +08:00
陈大猫 4bc3882d58 Merge pull request #1699 from binaricat/codex/simplify-confirm-rules-copy
[codex] Simplify confirm rules copy
2026-06-26 02:51:50 +08:00
bincxz ecbc967644 Simplify confirm rules copy 2026-06-26 02:51:33 +08:00
陈大猫 fb34595170 Merge pull request #1698 from binaricat/codex/cleanup-lint-warnings
[codex] Clean up lint warnings
2026-06-26 02:44:18 +08:00
bincxz 59ae79837d Clean up lint warnings 2026-06-26 02:43:59 +08:00
陈大猫 31ba02e74b Merge pull request #1696 from binaricat/codex/fix-external-tool-artifact-cards
[codex] Fix external tool artifact cards
2026-06-26 02:38:54 +08:00
bincxz c93f58bd27 Merge remote-tracking branch 'origin/main' into codex/fix-external-tool-artifact-cards
# Conflicts:
#	components/ai/ChatMessageList.test.tsx
2026-06-26 02:31:26 +08:00
bincxz 5a993f200c Prefer CLI artifact inference over generic tool names 2026-06-26 02:30:39 +08:00
bincxz 6b65d055fd Support OpenCode and Copilot artifact envelopes 2026-06-26 02:28:23 +08:00
bincxz 79944017d2 Handle wrapped external tool artifact results 2026-06-26 02:23:53 +08:00
陈大猫 6e04d0e766 Merge pull request #1697 from binaricat/codex/ai-note-artifact-click
[codex] Open AI note artifacts in side panel
2026-06-26 02:22:03 +08:00
bincxz b73c9a90ae Fix repeated AI note artifact opens 2026-06-26 02:17:15 +08:00
陈大猫 538595cff9 Merge pull request #1695 from binaricat/codex/fix-ai-attachment-import
[codex] Fix Catty attachment visibility
2026-06-26 02:01:43 +08:00
bincxz 92da189360 Fix Catty attachment visibility 2026-06-26 01:59:12 +08:00
bincxz 09268f9b97 Open AI note artifacts in side panel 2026-06-26 01:56:40 +08:00
bincxz 22ff5d2e16 Fix external tool artifact cards 2026-06-26 01:52:55 +08:00
陈大猫 0b025fe40e Merge pull request #1692 from Pgooone/fix/compose-bar-multiline
fix(terminal): 撰写栏多行输入只发送第一行
2026-06-26 01:50:41 +08:00
bincxz 2c485e9806 Address compose multiline review feedback 2026-06-26 01:49:34 +08:00
陈大猫 de16715ae4 Merge pull request #1694 from binaricat/codex/terminal-context-reader
Add AI terminal context reader
2026-06-26 01:41:43 +08:00
bincxz ce24b83db6 Merge remote-tracking branch 'origin/main' into codex/terminal-context-reader
# Conflicts:
#	electron/capabilities/catalog/integrity.test.cjs
2026-06-26 01:41:32 +08:00
bincxz d010065da1 Add terminal context read tool for AI 2026-06-26 01:34:29 +08:00
陈大猫 cb4a84b439 Handle empty DeepSeek tool call fields (#1690) 2026-06-26 01:30:33 +08:00
StartHex a3052ae429 Add latency to terminal network stats (#1670) 2026-06-26 01:17:59 +08:00
mam15mon 59502d629a Include Ctrl+Tab in pinned tab shortcut skip (#1680) 2026-06-26 01:17:31 +08:00
PgoooneandClaude 6979a6b165 fix(terminal): 修复撰写栏多行输入只发送第一行
撰写栏(及自动执行的多行 snippet)采用"逐行延迟发送":第一行立即写入,
其余行通过 setTimeout 排入待发队列。writeToSession 在收到任何"非自动写入"
时清空该队列,本意是让用户中途输入(如 Ctrl+C)中断整批发送。但终端的自动
回写会被误判为用户输入,导致第 2 行及之后被丢弃,表现为"只发出第一行"。

## 根因
writeToSession 开头 `if (!payload.automated) clearPendingAutomatedWrites(session)`
把"终端自动回写"也当成"用户开始输入"。终端的光标位置报告(ESC[…R)、设备
属性、焦点进出(ESC[I/O)走同一条 netcatty:write 路径且不带 automated 标志,
常在第一行执行后立即触发,于是清空了待发队列。又因 shouldDelayAutoRunSnippetInput
只检测 \n,多行中英文一视同仁——与中文/编码无关。

## 修复
新增 isTerminalReportSequence(),将光标位置报告 / 设备属性 / 焦点进出等终端
自动回写序列从"清队列"判定中排除:真正的用户输入仍能中断整批发送,无害的
终端自动回写不再误清待发行。

## 测试
新增 terminalBridge.composeMultiline.test.cjs:
- [REPRO] 行间的终端自动回写不应取消待发行(修复前失败、修复后通过)
- [GUARD] 行间的 Ctrl+C 仍应取消待发行(保留既有行为)
回归:terminalBridge.ctrlCInterrupt.test.cjs 仍 8/8 通过。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 02:16:58 +09:00
陈大猫 46d2a6e50a Fit large AI tool outputs before model replay (#1689)
* Add generic AI tool output fitting

* Preserve explicit tool output readbacks
2026-06-26 00:44:58 +08:00
陈大猫 0c8370477c Unify AI permission auto mode (#1688) 2026-06-26 00:26:30 +08:00
陈大猫andCursor 9aed2c41d4 feat(ai): unify capability registry, harness tools, and agent runtime (#1685)
* feat(ai): unify capability registry, harness tools, and agent runtime

Centralize MCP/CLI/Catty tool surfaces in the capability catalog with codegen,
move Catty-only harness tools into catalog with local execution, converge
mcpServerBridge builtin dispatch via registry, and wire AgentRuntime with
vault bridge, permission grants, and Round 2 catalog parity.

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

* feat(ai): vault host tools, artifact cards, and external agent vault guidance

Unify vault host create/import with capability RPC routing, add chat artifact cards with Vault navigation, and align external MCP agents with Catty rules so host creation never falls back to Vault Notes.

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

* fix(ai): address Bugbot findings for streaming, vault bridge, and grants

Clear Catty streaming by chat sessionId, read latest vault state via getters on agent mutations, and stack grant persisters so unmounting one AI panel does not break others.

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

* fix(vault): clear pending openNoteId after chat navigation focus

NotesManager notifies VaultView once the one-shot openNoteId request is applied so the id does not stick for the rest of the session.

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

* fix(vault): serialize vault agent IPC mutations to prevent lost writes

Queue vault agent bridge handlers so concurrent hosts.create or note.create tool calls cannot overwrite each other via stale read-modify-write snapshots.

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

* fix(vault): keep synchronous vault snapshot for agent IPC reads

Mirror hosts, notes, and groups in a ref updated immediately on agent writes so serialized mutations see the latest state before React re-renders.

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

* fix(ai): sync Catty MCP sessions and defer runtime cleanup on delete

Register terminal sessions with the main process before Catty tool calls, and clear ToolOutputStore only after stopping an active turn when deleting a chat session.

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

* fix(ai): avoid deleting a newer abort controller on late stop

Only remove the session abort-controller entry when stop finishes if it still refers to the controller that was stopped.

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

* fix(ai): sync vault snapshot on render and await active turn on stop

Refresh vault agent reads when React props change without clobbering in-flight agent writes, and wait for runTurn to finish before clearing ToolOutputStore on session delete.

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

* fix(ai): reload permission grants from storage before auto-approve

Re-read persisted grant rules on each approval check so revocations in Settings take effect without requiring a chat hook remount.

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

* fix(ai): sync permission grants to main process immediately on write

Push MCP permissionGrantsSnapshot as soon as Always allow persists a rule so the next tool call in the same agent turn sees the grant without waiting for useEffect.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 00:08:39 +08:00
陈大猫 a0f7be4f2f [codex] Respect scrollback clear setting for terminal clears (#1684)
* Respect scrollback clear setting for terminal clears

* Fix scrollback clear ordering
2026-06-25 19:13:17 +08:00
陈大猫 9aa5606ed6 Merge pull request #1681 from binaricat/codex/follow-up-host-key-settings
Follow up host key setting memo refresh
2026-06-25 17:15:33 +08:00
bincxz c8670435d3 Refresh views when host key setting changes 2026-06-25 17:02:15 +08:00
陈大猫 84fed10878 Merge pull request #1655 from binaricat/codex/issue-1653-port-forward-known-hosts
[codex] Fix port forwarding through jump hosts
2026-06-25 16:56:05 +08:00
bincxz 766dd36d76 Merge remote-tracking branch 'origin/main' into codex/issue-1653-port-forward-known-hosts
# Conflicts:
#	components/terminal/runtime/createTerminalSessionStarters.ts
2026-06-25 16:52:03 +08:00
bincxz 659c193769 Tighten tray host key prompt buttons 2026-06-25 16:46:52 +08:00
bincxz 57da0ad4de Remove tray host key prompt divider 2026-06-25 16:39:33 +08:00
bincxz 8dc4dbf009 Simplify tray host key prompt styling 2026-06-25 16:36:02 +08:00
bincxz 7a62acb828 Add tray host key trust prompt 2026-06-25 15:44:59 +08:00
陈大猫 2f8523a0a1 Merge pull request #1669 from binaricat/codex/fix-hostname-whitespace
[codex] Fix host hostname whitespace handling
2026-06-25 15:36:31 +08:00
陈大猫 a455577b21 fix focus sidebar dynamic title refresh
Refresh focus sidebar rows when Dynamic tab title mode changes. Follow-up to Codex review on #1658.
2026-06-25 15:25:23 +08:00
bincxz 4881485fa6 polish tray host key dialog sizing 2026-06-25 15:11:59 +08:00
bincxz 55ab3cc3f2 address port forward host key review 2026-06-25 15:04:00 +08:00
bincxz 156fcbebed fix host hostname whitespace handling 2026-06-25 15:03:12 +08:00
bincxz f0d8b51c44 handle port forward host key prompts 2026-06-25 14:49:20 +08:00
陈大猫 0d8d68d954 fix(telnet): handle NVT newlines and echo mode (#1649)
* fix(telnet): normalize NVT newlines

* fix(telnet): handle echo negotiation

* fix(telnet): align echo negotiation
2026-06-25 14:47:17 +08:00
陈大猫 f191a9b449 fix terminal dynamic tab title settings
Add a global dynamic tab title mode, default to agent-only titles, remove the per-host disable control, and keep terminal UI refresh paths in sync with the setting.
2026-06-25 14:34:40 +08:00
bincxz 1b965ac6ad fix port forwarding jump host verification 2026-06-25 14:23:10 +08:00
陈大猫 7b474f6b9e Merge pull request #1646 from mam15mon/feature/manual-session-log-button
Add manual session log toolbar button
2026-06-25 13:58:30 +08:00
陈大猫 033b904982 fix(terminal): platform-aware default font + remeasure on webfont load (#1647)
Fix #1647: on Windows/Linux the macOS-only `menlo` default left terminal ASCII garbled on cold start until a manual resize, because the primary glyphs fell through to a bundled `font-display: swap` webfont (JetBrains Mono) that swapped in after xterm measured the cell grid.

- Store the terminal font default as a platform-neutral `auto` sentinel, resolved to a locally-installed font per OS at render (win32→consolas, linux→dejavu-sans-mono, darwin→menlo). Safe to persist/sync; never leaks one OS's font to another.
- Explicitly preload the bundled webfonts the terminal actually renders with and remeasure on every `document.fonts` `loadingdone`, so any late webfont swap (incl. user-chosen) recovers without a manual resize.

Reviewed by local Codex (3 adversarial passes); findings on default persistence/sync and side-panel no-op writes addressed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-25 13:32:12 +08:00
mam15mon b0b9d60438 Preserve manual session logs across stale closes 2026-06-25 10:31:45 +08:00
mam15mon d8304f3eec Add manual session log toolbar button 2026-06-25 10:15:39 +08:00
陈大猫 e661169b60 Merge pull request #1645 from binaricat/codex/fix-port-forward-jump-auth-scope
fix: route port forward jump auth externally
2026-06-25 10:11:32 +08:00
bincxz 95f39f11b7 fix: route port forward jump auth externally 2026-06-25 10:08:29 +08:00
陈大猫 642905043c Merge pull request #1642 from binaricat/codex/fix-terminal-write-test-raf
fix: guard terminal write coalescing without raf
2026-06-25 09:52:42 +08:00
bincxz 3943b4869f fix: guard terminal write coalescing without raf 2026-06-25 09:52:22 +08:00
陈大猫 ee8687af15 Merge pull request #1641 from binaricat/codex/remove-terminal-background-mirror
fix: remove terminal background mirror
2026-06-25 09:45:09 +08:00
bincxz bf723e7348 fix: remove terminal background mirror 2026-06-25 09:42:19 +08:00
陈大猫andCursor 15baefaa57 fix(deps): resync package-lock.json for npm 10 npm ci (#1639)
Restore peer dependency lock entries removed in #1635 so CI npm ci on
Node 22 succeeds again.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 02:21:10 +08:00
陈大猫andCursor f0f858447e fix(ai): restore Codex thinking level picker in model dropdown (#1638)
Inline thinking levels under each model row so they are not clipped by the
scrollable model list container introduced for OpenCode.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 01:36:55 +08:00
陈大猫 83bbf9c73d Merge pull request #1637 from binaricat/fix/opencode-skills-cli-permissions
fix(ai): allow Netcatty CLI paths for OpenCode skills mode
2026-06-25 01:30:22 +08:00
bincxzandCursor 7ee5888795 fix(ai): align OpenCode skills allowlist with live app paths
Use Electron userData for CLI state fallback, include Netcatty temp dir and staged attachment paths, and drop duplicate cliStateDir when discovery file is already scoped.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 01:30:16 +08:00
bincxzandCursor 1349df9c0c fix(ai): correct discoveryPath import in sdkStreamHandlers
Use ../../../cli/discoveryPath.cjs from the sdk folder so Electron dev startup no longer fails with MODULE_NOT_FOUND.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 01:21:51 +08:00
bincxzandCursor 4259be6f57 fix(ai): allow Netcatty CLI paths for OpenCode skills mode
OpenCode skills integration blocked netcatty-tool-cli because external_directory was globally denied. Allowlist launcher, CLI, skill, discovery, and runtime paths while keeping unrelated external access denied.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 01:20:19 +08:00
陈大猫 24665eb532 Merge pull request #1636 from binaricat/fix/hibernate-hidden-tabs-default-off
fix(terminal): disable hibernate hidden tabs by default
2026-06-25 01:04:35 +08:00
bincxzandCursor 7faa0db866 fix(terminal): disable hibernate hidden tabs by default
Make tab hibernation opt-in so new users keep terminal renderers unless they explicitly enable memory-saving hibernate in settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 01:04:10 +08:00
陈大猫 56a86b48bf Merge pull request #1635 from binaricat/feat/terminal-hibernate-optimizations
feat(terminal): optimize hibernate wake for large buffers
2026-06-24 23:52:17 +08:00
bincxzandCursor 53328ca50d feat(terminal): optimize hibernate wake for large buffers
Add chunked replay, deferred WebGL during restore, alt-screen hibernate
policies, soft-hide LRU, two-phase viewport/scrollback snapshots, and a
main-process headless xterm mirror for faster wake serialization.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 23:52:03 +08:00
陈大猫 a0abcc3131 Merge pull request #1634 from binaricat/fix/terminal-layout-suppress-deferred-notify
fix(ui): defer terminal layout suppress notifications to avoid render warnings
2026-06-24 23:36:53 +08:00
bincxzandCursor 8083aece66 fix(ui): defer terminal layout suppress notifications to avoid render warnings
Schedule layout suppress store updates on the next animation frame so tab
switches and resize drags no longer trigger React setState-during-render
warnings in TerminalComponent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 23:36:32 +08:00
陈大猫 6893b986ca Merge pull request #1633 from binaricat/fix/issue-1632-telnet-no-auth-input
fix(telnet): allow input on silent no-auth sessions (#1632)
2026-06-24 23:29:15 +08:00
bincxzandCursor 599c8ff200 fix(telnet): dismiss connection overlay for silent no-auth sessions
Mark Telnet as connected once the socket session attaches so devices
that send no banner until client input no longer leave the connecting
overlay blocking keyboard input (fixes #1632).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 23:25:45 +08:00
陈大猫 cd6578ba25 Merge pull request #1631 from binaricat/fix/codex-sync-block-clear-filter
fix(terminal): preserve scrollback for Codex/Claude Code full-screen redraws
2026-06-24 23:08:04 +08:00
bincxzandCursor 12835ab0e5 fix(terminal): coalesce agent TUI writes and refine DEC 2026 redraw handling
Batch PTY output per animation frame (superset write-coalescer) so Codex
and Claude Code frames render atomically. Distinguish full H+2J redraws
from incremental sync blocks, skip preserve-before-clear inside DEC 2026,
and preserve viewport position across terminal resize.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 23:07:16 +08:00
bincxzandCursor 0913c6188f fix(terminal): harden split CSI detection for sync-block filter
Hold long incomplete private-mode sequences across chunks, terminate CSI
parsing at the real final byte, and avoid treating complete `\x1b[2J`
output as an unfinished escape prefix.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 21:51:15 +08:00
bincxzandCursor 9ef868f7ed fix(terminal): parse combined alternate-screen private-mode CSIs
Recognize `\x1b[?1049;1000h`-style sequences when deciding whether sync-block
clears must be preserved, without swallowing DEC 2026 markers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 21:46:18 +08:00
bincxzandCursor 20b613d1bb fix(terminal): track alternate-screen transitions in sync-block filter
Parse alternate-screen enter/leave sequences in-band so bundled PTY
chunks preserve `\x1b[2J` redraws before xterm applies buffer switches.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 21:39:30 +08:00
bincxzandCursor 12d7d6c730 fix(terminal): guard sync-block scroll detection when buffer is missing
Minimal terminal stubs used in session-starter tests do not expose
xterm buffer state; treat that as "not scrolled up" instead of throwing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 21:35:17 +08:00
bincxzandCursor 07e837b21c fix(terminal): detect scrollback via viewportY and restart block timers
Use xterm viewportY/baseY to decide when sync-block clears may be stripped,
and re-arm the abandonment timeout whenever a new DEC 2026 block starts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 21:31:46 +08:00
bincxzandCursor 825bd722d1 fix(terminal): only strip sync-block clears while viewport is scrolled up
Preserve `\x1b[2J` inside DEC 2026 blocks for alternate-screen TUIs and
for normal-buffer redraws at the bottom, while still preventing xterm.js
viewport jumps when the user is reading scrollback history.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 21:27:54 +08:00
bincxzandCursor 51f5fd9088 fix(terminal): arm sync-block timeout once and keep pending bytes
Only start the DEC 2026 abandonment timer when entering a block, and
preserve trailing partial marker bytes when the timeout fires.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 21:20:39 +08:00
bincxzandCursor d9ed716b01 fix(terminal): expire abandoned DEC 2026 sync blocks after 1s
Mirror xterm.js synchronized-output safety timeout so an unclosed sync
block cannot keep stripping later clear-screen sequences in the session.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 21:17:23 +08:00
bincxzandCursor c1ebedcc1f test(terminal): cover split sync-end and trailing ESC chunk edges
Add regression tests for SYNC_END marker splits and for releasing a
held trailing ESC when the following chunk is ordinary terminal text.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 21:12:01 +08:00
bincxzandCursor 7c707bf3b0 fix(terminal): hold partial sync markers across PTY chunks
Address Codex review: DEC 2026 start/end and clear-screen sequences can
be split across arbitrary PTY/IPC chunk boundaries. Keep a pending suffix
between calls so the sync-block filter still strips in-block clears.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 20:45:56 +08:00
bincxzandCursor 59f70af0c3 fix(terminal): strip sync-block clear-screen for Codex/Claude Code scrollback
Filter `\x1b[2J` inside DEC 2026 synchronized-output blocks before PTY
data reaches xterm.js so full-screen coding CLI redraws no longer reset
viewportY and make earlier output appear eaten.

Fixes #1612

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 20:15:36 +08:00
陈大猫 a15f677727 Merge pull request #1630 from binaricat/fix-terminal-host-tree-toolbar-close-button
fix: 终端主机树窄宽度下保持关闭按钮可见
2026-06-24 19:40:05 +08:00
bincxz 2cfbf235f7 merge main into fix-terminal-host-tree-toolbar-close-button 2026-06-24 19:39:56 +08:00
bincxzandCursor 3446749573 fix: clip host tree toolbar actions instead of hiding them when narrow
Keep all action buttons in the overflow row so the toolbar does not leave empty gaps beside the pinned close control.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 19:39:38 +08:00
mam15monandmam15mon b709417e23 Improve network device vendor detection (#1624)
* restore h3c network device detection

* ignore release build artifacts in lint

* restore h3c mpssh banner detection

* detect network device vendor banners

* Map mpSSH banners to HPE iLO

---------

Co-authored-by: mam15mon <mam15mon@gmail.com>
2026-06-24 19:37:55 +08:00
bincxzandCursor d79353942b fix: keep host tree toolbar close button visible when sidebar is narrow
Pin the collapse control outside the clipped action row and fade overflow so narrow widths stay usable without hiding the close button.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 19:34:36 +08:00
陈大猫andCursor 0ceec3d45c fix(sftp): stop follow-terminal-cwd retry loop on unreachable paths (#1629)
* fix(sftp): stop follow-terminal-cwd retry loop on unreachable paths

When per-host follow navigates to a terminal cwd that SFTP cannot reach, stop retrying and allow the toolbar toggle to update immediately.

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

* fix(sftp): address Codex review on follow-terminal-cwd blocking

Return navigateTo success explicitly and scope blocked follow paths per connection so successful navigations and host switches are not misclassified.

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

* fix(sftp): clear follow block after manual go-to-terminal-cwd

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

* fix(sftp): honor cleared follow-cwd overrides from vault sync

Use an explicit pending toggle override instead of stale session host values when the vault clears per-host follow settings back to inherit-global.

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

* fix(sftp): ignore stale follow navigation when follow is disabled mid-flight

Bump a follow-sync generation when follow is turned off so pending navigateTo failures cannot restore a blocked cwd after the user disables and re-enables follow.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 19:27:20 +08:00
陈大猫 fc287f9038 Merge pull request #1623 from binaricat/codex/pr-1611-bugbot-fixes
fix host search punctuation-only and hostname compact false positives
2026-06-24 14:50:30 +08:00
bincxzandCursor d071827a1e fix host search punctuation-only and hostname compact false positives
Reject separator-only queries before substring matching and skip compact
matching on hostname fields to avoid IP digit false positives.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 14:50:07 +08:00
陈大猫 6b114f55eb Merge pull request #1611 from mam15mon/feature/search-fix
Reviewed locally with bugbot; includes follow-up fix for punctuation-only queries and hostname compact false positives pushed separately.
2026-06-24 14:48:34 +08:00
陈大猫andCursor 3235f11870 [Bug] 选择 Keychain 引用密钥后不再尝试默认 SSH key (#1620)
* Fix default SSH key fallback after Keychain reference key selection.

Treat identityFilePaths as an explicit user key choice so terminal SSH sessions no longer fall back to ~/.ssh/id_* when a host uses a Keychain file-referenced key.

Fixes #1614

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

* Skip default-key primaries when explicit key config fails to load.

Address Codex review: if identityFilePaths were set but loadFirstIdentityFileForAuth returns null, do not install ~/.ssh/id_* as the primary auth method.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 14:36:11 +08:00
陈大猫andCursor 6fad3f0b76 fix(sftp): reconnect terminal side panel after SSH session loss (#1608) (#1621)
* fix(sftp): reconnect terminal side panel after SSH session loss

When SSH disconnects while the terminal SFTP side panel is browsing a stale
path, auto-connect reused a dead tab and left the pane stuck on loading.
Rebind SFTP when the terminal session changes and skip reuse unless the
backend session is healthy; also stop follow-terminal-cwd retry loops to
missing directories.

Fixes #1608

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

* fix(sftp): address review findings for side panel reconnect

Harden auto-connect after bugbot review: skip tab reuse on SSH session
change, move follow-cwd backoff ref into the interactive body, and make
navigateTo return reached/failed/aborted so follow mode only backs off on
real path failures.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 14:31:23 +08:00
278f528deb Improve terminal keyword highlight refresh (#1619)
* improve terminal keyword highlight refresh

* fix(terminal): address Codex review on keyword highlight refresh

Map viewport probe diffs to line numbers so off-cursor ANSI redraws mark the
rewritten line dirty without forcing full-viewport rescans on normal typing,
and cache wrapped-line context per processLineRange to avoid repeated scans.

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

---------

Co-authored-by: mam15mon <mam15mon@gmail.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 14:16:16 +08:00
陈大猫andCursor b6da5e60c9 fix(notes): allow clearing default note title (#1610) (#1618)
* fix(notes): allow clearing the default note title while editing

Stop sanitizeNoteTitle from forcing empty titles back to "Untitled note", so users can delete the placeholder and type a custom title.

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

* fix(notes): allow clearing titles from tree rename

Remove the empty-title guard in sidebar tree rename so it matches the main title editor behavior.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 14:10:17 +08:00
陈大猫andCursor 355488f4d0 feat(copilot): Skills CLI mode for Copilot backend (#1617)
* feat(copilot): support Skills CLI mode alongside MCP

Wire Copilot sessions to respect toolIntegrationMode: MCP keeps the existing
netcatty-mcp-only gate, while Skills exposes bash/skill tools, passes CLI
discovery env to the runtime, and approves shell commands that invoke
netcatty-tool-cli.

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

* test(copilot): restore driver tests and cover skills CLI mode

Merge the new Skills-mode assertions with the existing copilot driver suite
instead of replacing it.

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

* fix(copilot): harden Skills shell gate against chained commands

Validate only the local netcatty-tool-cli prefix (before exec `--` payloads),
reject shell wrappers/chaining operators, and require the invocation to start
with the CLI launcher or script path.

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

* fix(copilot): block backtick substitution and drop skill builtin

Reject backticks in the local CLI prefix and expose only builtin:bash in
Skills mode; CLI workflow instructions already come from the host prompt.

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

* fix(copilot): quote-aware shell gate and strict CLI name match

Use the last unquoted exec delimiter for local-prefix checks, scan metachars
outside quotes only, and require an exact netcatty-tool-cli launcher token.

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

* fix(copilot): tighten exec delimiter and Windows CLI launcher match

Only honor ` -- ` payloads for exec/job-start, scan full local commands for
redirection/chaining, and accept netcatty-tool-cli.cmd with Windows paths.

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

* fix(copilot): reject quoted substitution and unqualified CLI paths

Block $(...) and backticks inside double quotes, require absolute/relative
path prefixes for non-node launchers, and fix quoted launcher path matching.

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

* fix(copilot): anchor shell metachar scan and block exec chaining

Match metacharacters only at the current scan offset, scan the full shell
command for exec turns, and require quoted remote payloads for chained ops.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 13:59:39 +08:00
陈大猫andCursor 61a2473a7c feat(capabilities): shared agent capability layer for MCP and CLI (#1616)
* feat(capabilities): add shared agent capability layer for MCP and CLI

Introduce a registry-driven capability foundation so terminal, SFTP, vault,
and port-forward tools can be extended from one policy and surface definition.
Wire mcpServerBridge permission checks and the Skills CLI RPC client through
the shared layer without changing existing AI runtime behavior.

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

* fix(capabilities): restore SFTP cancel gating and home RPC timeout

Match legacy mcpServerBridge behavior by blocking SFTP write RPCs after
chat cancel and treating sftp/home as a long-running operation for timeout
budget calculation.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 13:13:57 +08:00
陈大猫 5e6fc5f721 Merge pull request #1615 from StartHex/fix-gb18030-terminal-encoding
Fix GB18030 terminal decoding for locale charsets
2026-06-24 12:02:57 +08:00
shixu b17835bf58 Fix GB18030 terminal decoding for locale charsets 2026-06-24 11:56:51 +08:00
mam15mon 977c5c56e1 address search review feedback 2026-06-24 11:26:33 +08:00
陈大猫 06ec9d6b1d Merge pull request #1605 from binaricat/fix/issue-1602-osc7-sftp-cwd-toggle
fix: stop SFTP cwd toggling when OSC 7 tracking is active (#1602)
2026-06-24 11:19:26 +08:00
mam15mon a7c35f2813 add improved host search matching 2026-06-24 11:09:08 +08:00
陈大猫 0c01238a39 Merge pull request #1606 from binaricat/fix/1604-windows-flag-emoji-icons
fix(ui): render flag emoji icons on Windows (#1604)
2026-06-24 01:37:37 +08:00
陈大猫 46d730f2fa fix(terminal): fall back to isVisible for popup hibernate
Fixes Codex P1 from #1603: popup Terminal mounts without paneVisibilityStore entries no longer hibernate after 5 seconds.
2026-06-24 01:29:24 +08:00
bincxzandCursor c7de55007b test: document OSC 7 probe suppression for unchanged cwd paths
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 01:27:20 +08:00
bincxzandCursor 4caa37c10d fix(ui): render flag emoji icons on Windows with bundled font (#1604)
Windows system fonts omit flag glyphs and show ISO country codes instead
of flag icons. Bundle a Noto Color Emoji regional-indicator subset and
prepend it to the Windows UI sans stack so host labels display real flags.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 01:21:26 +08:00
bincxzandCursor 6feef21841 fix(terminal): fall back to isVisible when pane store has no entry
Popup and other standalone Terminal mounts do not publish paneVisibilityStore state; hibernate now respects the isVisible prop instead of treating missing entries as hidden.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 01:21:21 +08:00
bincxzandCursor 99003f9716 fix: stop SFTP cwd toggling when OSC 7 tracking is active (#1602)
Skip backend pwd probes after commands once the shell reports OSC 7, even when the path is unchanged, so sudo sessions no longer alternate between login-shell fallback and the interactive cwd. Also silence OSC 7 setup script status text in the terminal since the toast already confirms success.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 01:20:28 +08:00
陈大猫 9180e6abb6 feat(terminal): hibernate hidden tabs to reduce renderer memory
Dispose xterm for off-screen tabs while keeping SSH connected, with settings toggle, configurable delay, and file-transfer exemptions.
2026-06-24 01:12:39 +08:00
bincxzandCursor 8b5135b008 fix(terminal): drain hibernate buffer on wake and reschedule when hidden
Loop pending-buffer replay until caught up before stopping listeners, and re-arm hibernate when wake finishes while the pane is still off-screen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 01:06:11 +08:00
bincxzandCursor 306a91d1e3 fix(terminal): flush hibernate output after replay before reattach
Keep hibernate listeners active during async replay, append any output that arrived mid-replay, then stop listeners immediately before reattach.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 01:02:54 +08:00
bincxzandCursor 4fa3eff976 fix(terminal): finalize hibernate capture before replay and reattach
Stop hibernate IPC listeners before reading the wake payload so post-snapshot output is included and skipped reattach does not leave stale listeners.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 00:58:43 +08:00
bincxzandCursor db89462e00 fix(terminal): snapshot hibernate output at wake and re-check session
Read pending buffer after async mount and verify connectivity before reattaching so in-flight SSH output and late disconnects are handled correctly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 00:55:10 +08:00
bincxzandCursor f26c057128 fix(terminal): address hibernate wake races and unmount capture
Read live session connectivity at wake time, persist hibernated output on unmount, and skip scrollToBottom for alternate-screen resumes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 00:51:33 +08:00
bincxzandCursor 7ca3974bf8 fix(terminal): keep hibernate listeners alive until wake succeeds
Route session exit to the hibernate buffer instead of a disposed xterm, and only tear down hibernate IPC listeners after a successful disconnected wake.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 00:47:16 +08:00
bincxzandCursor 58bef9344a fix(terminal): harden hibernate wake and serialize edge cases
Defer clearing hibernate replay state until wake succeeds, skip reattach when the backend session is disconnected, preserve alternate-screen detection on serialize failure, and wake immediately when hibernate is disabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 00:43:48 +08:00
bincxzandCursor 12a513606b feat(terminal): hibernate hidden tabs to reduce renderer memory
Dispose xterm for off-screen tabs while keeping SSH connected, with configurable delay, file-transfer exemptions, and wake replay for alternate-screen apps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 00:40:16 +08:00
陈大猫 89da4674cd Merge pull request #1601 from binaricat/fix/terminal-layer-usecallback-deps
fix: add missing useCallback deps in TerminalLayerSupport
2026-06-23 20:31:15 +08:00
bincxzandCursor 3a540a5730 fix: add missing useCallback deps in TerminalLayerSupport
Include host and session in handleDetachPointerDown dependencies so drag
label stays in sync when dynamic tab titles or host settings change.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 20:30:32 +08:00
陈大猫andCursor bd3491a3e4 revert(terminal): remove auto-run snippet stty protection (#1594) (#1599)
* fix(terminal): narrow snippet terminal restore to RHEL-family hosts

Limit auto-run snippet stty protection to distros affected by #1498 and
suppress wrapper echo so Ubuntu, Rocky, and macOS users see their raw
commands again.

Fixes #1594

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

* revert(terminal): remove auto-run snippet stty protection (#1594)

The wrapper added in #1498 caused visible preamble noise for all snippet
executions. Revert to inserting the original command directly; the underlying
CentOS 7 terminal issue reproduces when running the script manually too.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 20:25:16 +08:00
陈大猫andCursor 8322a2aeb8 Fix tmux detach focus regression on Windows (#1587) (#1600)
* Fix tmux detach focus regression on Windows (#1587)

Replace native confirm with an in-app dialog and restore terminal cursor/focus after detach so sidebar tmux actions keep working.

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

* Skip terminal refocus while app dialogs are open

Avoid deferred tmux detach focus from stealing Radix modal focus when users open the new-session dialog immediately after detach.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 20:08:43 +08:00
陈大猫 7ac21c3c33 Merge pull request #1598 from binaricat/fix/1589-windows-host-label-flag-emoji
fix(ui): render flag emoji in host labels on Windows (#1589)
2026-06-23 19:43:23 +08:00
bincxzandCursor 5cd6149981 fix(ui): render flag emoji in host labels on Windows
Windows was showing regional-indicator letters instead of composed flag
emoji in host labels because bundled UI fonts win font matching. Prepend
Segoe UI Emoji to the UI sans stack on Windows, including when settings
apply a custom UI font at runtime (#1589).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 19:39:21 +08:00
陈大猫andCursor 947cca5199 fix: support NOPASSWD sudo fallback for Docker panel (#1595) (#1597)
Try passwordless sudo before requiring a saved sudo password when Docker
socket access is denied, matching the popup terminal behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 19:32:24 +08:00
陈大猫 4108538dbb Fix multiline snippet telnet input timing (#1593)
Send auto-run multi-line snippets line-by-line with delay instead of bracketed-paste burst. Fixes #1590.

Also limit broadcast line delay to explicit snippet options so normal multi-line broadcast pastes stay byte-for-byte.
2026-06-23 19:23:56 +08:00
陈大猫 3beb909a09 Add OpenCode SDK integration (#1596)
Add OpenCode SDK integration
2026-06-23 18:59:28 +08:00
bincxzandCursor 4b874410f9 fix(opencode): harden SDK streaming, model list, and Windows shim path
Route Netcatty context through body.system, filter echoed user parts,
keep OpenCode env/shim alive for server lifetime, and cap model preset dropdown height.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 18:56:46 +08:00
bincxzandCursor f237cfb4ab Merge branch 'main' into codex/opencode-sdk-integration
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 18:56:30 +08:00
陈大猫 aa37b353f7 Add snippet import and export workflow
Adds snippet import/export interactions, multi-file import, array-format support, and downloadable sample files.
2026-06-23 17:22:55 +08:00
陈大猫 3d134abe94 Merge pull request #1583 from binaricat/codex/sftp-follow-latest-host
[codex] Refresh SFTP follow toggle from latest host
2026-06-23 14:54:39 +08:00
陈大猫 218bf2183a Merge pull request #1578 from binaricat/codex/fix-script-log-readability
[codex] Improve snippet command log readability
2026-06-23 14:52:54 +08:00
bincxz a7cc2cbee3 Refresh SFTP follow toggle from latest host 2026-06-23 14:52:26 +08:00
bincxz c79f485270 Merge remote-tracking branch 'origin/main' into codex/fix-script-log-readability
# Conflicts:
#	components/terminal/runtime/createTerminalSessionStarters.types.ts
2026-06-23 14:45:00 +08:00
陈大猫 8424471ff6 Merge pull request #1581 from binaricat/codex/sftp-follow-visible-host
[codex] Save SFTP follow toggle on visible host
2026-06-23 14:37:58 +08:00
bincxz 46262d18f3 Harden OpenCode SDK integration 2026-06-23 14:36:59 +08:00
bincxz 6554f7bc8f Persist SFTP follow toggle for visible host 2026-06-23 14:34:50 +08:00
陈大猫 010e2f356e Merge pull request #1580 from binaricat/codex/host-sftp-follow-terminal-cwd
[codex] Bind SFTP follow directory to hosts
2026-06-23 14:28:34 +08:00
bincxz d184819b61 Merge remote-tracking branch 'origin/main' into codex/opencode-sdk-integration 2026-06-23 14:25:30 +08:00
bincxz f53d359d38 Bind SFTP follow cwd to hosts 2026-06-23 14:24:22 +08:00
陈大猫andCursor c6bdcffb89 feat: dynamic terminal tab titles (fixes #1572) (#1576)
* feat: add dynamic terminal tab titles for shell-reported session names

Follow OSC 0/2 window titles on session tabs so tools like Claude Code can show live session state, with a per-host opt-out to keep connection names instead.

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

* fix: address dynamic tab title review findings

Restore the missing cn import, clear dynamic titles when the shell sends an empty OSC title, and drop stored dynamic titles when a host disables the feature.

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

* fix: preserve renamed session labels and align focus sidebar titles

Prefer user-assigned customName over shell titles, skip no-op dynamic title updates, and use resolveSessionTabTitle in the workspace focus sidebar.

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

* feat: show coding agent icons on session tabs

Detect Claude Code, Codex, OpenCode, Droid, and other CLIs from OSC titles and launch commands, reusing shared agent icon assets inspired by freshell pane icons.

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

* fix: harden coding CLI detection across SSH and startup paths

Wire output scanning through attachSessionToTerminal, seed providers from
auto startup commands, prevent title signals from overriding command
detection, clear sticky state on shell titles, and release output scanners
on session close.

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

* fix: reset CLI output scanner when provider state clears

Prevent exited-agent banners from immediately re-sticking the tab icon
after the shell title returns to a normal shell name.

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

* fix: update sticky CLI provider when title signals a different agent

Allow agent switches in the same terminal while still resetting the output
scanner whenever the provider identity changes.

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

* fix: avoid clearing CLI provider on blank titles and cap output scan

Ignore transient empty OSC titles and stop banner scanning after 16KB of
unmatched output so regular SSH sessions do not pay perpetual scan cost.

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

* fix: prefer runtime CLI provider over host startup command on tabs

Runtime detection from commands, output, and titles should win over static
host startup commands when resolving the session tab icon.

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

* fix: satisfy eslint for terminal escape stripping regexes

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 14:20:05 +08:00
bincxz 369251099f Harden OpenCode agent integration 2026-06-23 14:16:46 +08:00
bincxz 65c3df2ff4 Improve snippet command log readability 2026-06-23 14:11:03 +08:00
bincxz 44a84b5046 Add OpenCode agent integration 2026-06-23 14:09:42 +08:00
陈大猫 0ea8269a78 Merge pull request #1577 from Ryanisgood/fix/settings-pointer-cursor
修复设置窗口按钮悬停时不显示手型光标
2026-06-23 13:01:09 +08:00
陈大猫 d6b00df333 Merge pull request #1575 from binaricat/fix/issue-1574-notes-import-md-copy
feat(notes): import Markdown files and add code block copy button
2026-06-23 12:08:04 +08:00
bincxzandCursor 8e165a7101 Prevent import from overwriting concurrent note edits.
Keep sortedNotesRef in sync on render and route note mutations through commitNotes so imports merge against the latest vault state.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 11:57:05 +08:00
bincxzandCursor 8deb52842b Address review findings for notes import and code block copy.
Reset import input on all paths, log failures, report skipped files, improve copy button a11y with MutationObserver, scope code block select CSS, and add readTextFile tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 11:49:36 +08:00
bincxzandCursor a82e69865f Polish notes import UX and code block editor controls.
Add folder context-menu import, show copy buttons only in preview, tighten code block spacing, and fix language selector/dropdown sizing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 11:42:04 +08:00
Zheng Liu 6e4a1cf2a2 Fix settings window pointer cursors 2026-06-23 11:30:55 +08:00
bincxzandCursor 194691038c Merge imported notes after file reads complete.
Read Markdown payloads first, then merge against the latest note list so in-flight edits are not overwritten during import.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 09:57:52 +08:00
bincxzandCursor 28762b3a73 Fix note import race conditions and title parsing edge cases.
Skip fenced code blocks when deriving import titles, guard concurrent imports with a ref, and tighten the file picker accept list.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 09:55:01 +08:00
bincxzandCursor a493fbfea8 Add Markdown import and code block copy to vault notes.
Support bulk .md/.txt import into the current notebook folder and add hover copy buttons on note code blocks in edit and preview modes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 09:51:32 +08:00
陈大猫 6304608114 Merge pull request #1571 from binaricat/codex/render-pasted-markdown-in-notes
Improve notes markdown editing
2026-06-23 03:36:19 +08:00
bincxz e4c7484181 Improve notes markdown editing 2026-06-23 03:35:44 +08:00
陈大猫 11d5c269d1 Merge pull request #1570 from binaricat/codex/improve-netcatty-smoothness
[codex] Improve Netcatty responsiveness
2026-06-23 01:50:41 +08:00
bincxz 8741d683ba Address smoothness review findings 2026-06-23 01:49:57 +08:00
bincxz affe197f26 Improve Netcatty responsiveness 2026-06-23 01:26:12 +08:00
陈大猫 40dd3d1a6a Merge pull request #1569 from binaricat/codex/auto-reconnect-restored-sessions
[codex] Auto reconnect restored terminal sessions
2026-06-23 00:25:43 +08:00
bincxz 9b397053ca Auto reconnect restored terminal sessions 2026-06-23 00:24:39 +08:00
陈大猫 0380eef924 Merge pull request #1568 from binaricat/codex/fix-vault-theme-flash
Fix vault theme flash
2026-06-23 00:00:05 +08:00
bincxz 475e896cf0 Fix vault theme flash 2026-06-22 23:59:32 +08:00
陈大猫 256dbca4ba Merge pull request #1567 from binaricat/codex/fix-notes-initial-selection
[codex] Polish notes and terminal UI
2026-06-22 23:07:51 +08:00
bincxz 541e627702 Polish notes and terminal UI 2026-06-22 23:06:05 +08:00
陈大猫 96f0a9e559 Fix tooltip theme contrast
Share accent foreground contrast logic across app and active terminal theme paths.
2026-06-22 22:26:36 +08:00
陈大猫 dea182c38a Polish vault UI state and contrast
Keep Vault notes state while switching sections, improve accent button text contrast, and adjust host tree top spacing.
2026-06-22 22:19:20 +08:00
陈大猫 77856252cf Merge pull request #1564 from binaricat/codex/ui-polish-settings-terminal
Polish terminal and settings UI
2026-06-22 22:05:07 +08:00
bincxz cff74b6e49 polish terminal and settings UI 2026-06-22 22:04:33 +08:00
陈大猫 94464da004 [codex] Fix follow-app terminal theme selection (#1562)
* Fix follow-app terminal theme selection

* Fix custom follow theme selection
2026-06-22 20:48:16 +08:00
陈大猫 055fa4bb97 Address managed agent path review feedback 2026-06-22 18:59:52 +08:00
陈大猫 d8ac9b081b Add explicit clipboard image upload action
Closes #1536
2026-06-22 18:57:47 +08:00
陈大猫 b74b5a442a [codex] Add vault notes markdown workspace (#1558)
* Add vault notes markdown workspace

* Improve notes tree drag interactions

* Harden notes tree move behavior

* Refine vault notes interactions

* Address vault notes review feedback

* Tighten notes tree review fixes
2026-06-22 18:23:58 +08:00
陈大猫 6f074d620e Merge pull request #1496 from lengyuqu/codebuddy 2026-06-22 17:57:10 +08:00
bincxz e2f8ca9c00 Clarify CodeBuddy tool option boundaries 2026-06-22 17:50:07 +08:00
陈大猫 d1cb3e100b Allow editing managed agent CLI paths (#1559) 2026-06-22 17:35:28 +08:00
陈大猫 1e939c9b31 [codex] Fix terminal WebGL atlas corruption (#1556)
* fix terminal webgl atlas corruption

* fix osc7 setup shell compatibility
2026-06-22 16:34:28 +08:00
陈大猫 9bf846978d Protect snippet temp command files (#1555) 2026-06-22 15:03:32 +08:00
陈大猫 e897a3a30a Merge pull request #1554 from binaricat/codex/issue-1498-stty-restore
[codex] Restore terminal mode after auto-run snippets
2026-06-22 14:20:57 +08:00
bincxz a238a5ac2f Restore terminal mode after auto-run snippets 2026-06-22 14:10:50 +08:00
陈大猫 b2c02559f1 Merge pull request #1551 from binaricat/codex/ssh-deep-link-1512
[codex] Support ssh deep links
2026-06-22 14:10:26 +08:00
bincxz f95cf73ca4 Fix ssh deep link edge cases 2026-06-22 14:08:53 +08:00
陈大猫 018067032e [codex] Add OSC 7 directory tracking setup (#1553)
* Add OSC 7 directory tracking setup

* Address OSC 7 setup review feedback

* Harden OSC 7 setup command

* Cover OSC 7 shell and locale edge cases
2026-06-22 13:30:23 +08:00
陈大猫 1d07879c11 fix terminal prompt escape parsing (#1552) 2026-06-22 12:28:10 +08:00
bincxz eb1a39dbc2 Add ssh deep link setting 2026-06-22 12:09:52 +08:00
陈大猫 982569532c Sync AI API keys across devices
Sync AI API keys through cloud sync while keeping device-local external tool paths local. Also preserves local AI keys in backups and stops sync if a local encrypted key cannot be read.
2026-06-22 11:45:07 +08:00
陈大猫 554d1991df Merge pull request #1545 from binaricat/codex/issue-1471-sftp-clear-filter
Clear SFTP filter on directory navigation
2026-06-22 11:43:55 +08:00
bincxz feb6f60db2 Support ssh deep links 2026-06-22 11:35:02 +08:00
Maxandbincxz 96101c0c73 fix(ssh): complete publickey+password MFA in a single connection (#1537)
* fix(ssh): complete publickey+password MFA in a single connection

Send the key and saved password in one connection when both are set, and
re-offer a previously-rejected method after the server's partial success.

* fix(ssh): preserve local identity files for key auth; trim comments

* test: align SSH key password MFA expectation

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-06-22 11:18:04 +08:00
bincxz fe297dbc55 Preserve SFTP filter edits on failed refresh 2026-06-22 11:16:33 +08:00
陈大猫 e3580a45e5 fix terminal autocomplete cwd after user switch (#1548) 2026-06-22 11:03:11 +08:00
陈大猫 df359f6a9a Simplify host icon color setting (#1546) 2026-06-22 10:49:24 +08:00
陈大猫 7bdad5e701 Clarify port forwarding summaries (#1544) 2026-06-22 10:03:05 +08:00
bincxz 39f8bf194a Clear SFTP filter on directory navigation 2026-06-22 09:51:02 +08:00
陈大猫 36295e63ed [codex] Fix Agent history persistence (#1534)
* fix agent history persistence

* Keep mismatched terminal history scoped out
2026-06-20 11:27:00 +08:00
陈大猫 257cda56e3 [codex] Reduce SFTP transfer pressure (#1533)
* reduce sftp transfer pressure

* reduce sftp transfer pressure
2026-06-20 11:26:56 +08:00
陈大猫 7587090bcd fix terminal history scroll override (#1532) 2026-06-20 11:26:53 +08:00
陈大猫 eba27d94b6 [codex] Fix rz upload completion wait (#1531)
* fix rz upload completion wait

* Address zmodem timeout recovery review
2026-06-20 11:26:50 +08:00
陈大猫 3289c0deca Merge pull request #1513 from Ryanisgood/feature/session-restore
feat: 恢复上次会话与工作区状态
2026-06-19 20:16:41 +08:00
bincxz d1de0d39e8 fix: keep peer windows out of settings sync 2026-06-19 16:08:18 +08:00
bincxz 25a2141bb3 Merge remote-tracking branch 'origin/main' into codex/local-pr-1513
# Conflicts:
#	components/TerminalLayer.tsx
2026-06-19 16:03:15 +08:00
bincxz b9c164aeb6 fix: refresh restore cwd setting in terminal layer 2026-06-19 16:01:48 +08:00
RainySYandsakuradairong 5afdbdf8fc fix: cap unlimited terminal scrollback (#1519)
Co-authored-by: sakuradairong <sakuradairong@users.noreply.github.com>
2026-06-19 15:49:26 +08:00
陈大猫 14da682578 Fix tmux detach terminal focus (#1525) 2026-06-19 15:49:02 +08:00
陈大猫 7008262762 Merge pull request #1527 from binaricat/codex/remove-slash-command-button
[codex] Remove AI slash command toolbar button
2026-06-19 15:49:00 +08:00
bincxz 7c2e1ef238 Remove AI slash command toolbar button 2026-06-19 15:48:28 +08:00
陈大猫 8eb3ee44b4 [codex] fix tmux popup proxy resolution (#1526)
* fix tmux popup proxy resolution

* test tmux popup proxy coverage

* Fix popup fallback protocol
2026-06-19 15:47:47 +08:00
陈大猫 0b5c48282a Fix snippet Go template literals (#1523)
* Fix snippet Go template literals

* Handle additional Go template forms
2026-06-19 15:46:36 +08:00
陈大猫 45a757c0e0 fix: keep long credential names removable (#1521) 2026-06-19 15:46:23 +08:00
陈大猫 b480eabb3c Fix SSH Ctrl+C interrupt handling
Fixes #1480
2026-06-19 15:28:08 +08:00
陈大猫 7477f5c47a fix docker popup mosh command (#1524) 2026-06-19 15:21:27 +08:00
陈大猫 4364522bf1 Fix AI new chat input focus (#1522) 2026-06-19 15:20:35 +08:00
lengyuqu 4d3fa2d225 Merge branch 'binaricat:main' into codebuddy 2026-06-18 17:29:13 +08:00
Zheng Liu cde2fa716c fix: guard main window dirty editor close 2026-06-18 12:35:05 +08:00
Zheng Liu 43063e1744 fix: isolate peer auth and settings effects 2026-06-18 12:10:02 +08:00
Zheng Liu 5b83b47845 fix: route peer keyboard prompts 2026-06-18 11:59:01 +08:00
Zheng Liu a815e7d2e5 fix: guard hidden restore windows 2026-06-18 11:50:19 +08:00
Zheng Liu 9674cd8ae6 fix: protect peer restore windows 2026-06-18 11:39:18 +08:00
Zheng Liu d9334a8e73 fix: harden session restore isolation 2026-06-18 11:26:26 +08:00
Zheng Liu ba9d9c1e58 fix: isolate session restore side effects 2026-06-18 11:04:41 +08:00
Zheng Liu 73e615cb64 fix: isolate restored peer session windows 2026-06-18 10:52:15 +08:00
Zheng Liu 93079bb576 fix: prevent session restore snapshot loss 2026-06-17 23:41:29 +08:00
Ryanisgood 52bc48f73a 为主机添加可自定义图标和颜色 (#1504) 2026-06-17 23:32:36 +08:00
Ryanisgood 46755465f9 feat: add SFTP current path copy action (#1506) 2026-06-17 23:31:54 +08:00
Zheng Liu 04906a80ff fix: tighten session restore edge cases 2026-06-17 22:39:10 +08:00
Zheng Liu 193b2b73c9 fix: harden session restore reconnect and sanitization 2026-06-17 22:07:32 +08:00
Zheng Liu b32310b073 docs: document session restore behavior 2026-06-17 21:37:50 +08:00
Zheng Liu 5a27dbe955 feat: polish restored terminal sessions 2026-06-17 21:37:50 +08:00
Zheng Liu 2cbcebf45a feat: restore terminal working directory on reconnect 2026-06-17 18:43:50 +08:00
Zheng Liu 644d55a0c0 feat: restore terminal tabs and workspace layout 2026-06-17 18:43:42 +08:00
lengyuquandClaude Opus 4.8 1bb20026a2 fix(codebuddy): MCP 模式工具泄漏 & 模型列表为空
两处 codebuddy driver 缺陷:

1. MCP 模式下工具调用以纯文本 <tool_call> 泄漏:
   buildCodebuddyQueryOptions 设了 allowedTools: builtinTools,
   而 MCP 模式 builtinTools 为 [],SDK 把空数组当成"只放行列表内"
   的白名单 → 注入的 mcp__netcatty-remote-hosts__* 工具被全部挡掉,
   模型拿不到真实工具只能把调用当文本打印。删除该行,与 claude
   driver 对齐(靠 tools 限定内建集、disallowedTools 屏蔽 UI 工具,
   allowedTools 保持 unset 放行 MCP 工具)。skills 模式不受影响。

2. 模型列表恒为空、永远回退预设:
   实测 supportedModels() 运行时返回 {id, name}(如 {id:'glm-5.1',
   name:'GLM-5.1'}),而 SDK 类型声明写的是 {value, displayName,
   description},类型与 wire 不符。mapCodebuddyModels 只认
   modelId/value → 12 个模型被全部过滤为 []。改为同时接受
   id/modelId/value,name 回退 name/displayName/id。

两处均补充单测断言。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:18:18 +08:00
lengyuqu 932dd4f191 Merge branch 'binaricat:main' into codebuddy 2026-06-16 17:49:50 +08:00
陈大猫 ecadc1fc2d [codex] Enable sudo fallback for Docker panel (#1466)
* Enable sudo fallback for Docker panel

* Prefer sudo for Docker panel commands

* Use pending saved sudo password immediately

* Try plain Docker before sudo fallback

* Detect Docker before sudo fallback

* Add sudo fallback for Docker popup commands

* Harden Docker popup sudo fallback
2026-06-14 10:47:21 +08:00
陈大猫 79ccf47655 fix editor tab theme toggle (#1467) 2026-06-14 09:54:13 +08:00
陈大猫 6ef0a4ad6b Fix settings localization gaps (#1465) 2026-06-14 09:00:49 +08:00
yabirthdayandbincxz 88142d2a92 fix: let Ctrl+C send SIGINT when no text is selected in terminal (#1461)
* fix: let Ctrl+C send SIGINT when no text is selected in terminal

When the copy shortcut is customized to Ctrl+C (default Ctrl+Shift+C),
the terminal always consumed the event for copy even without a text
selection, preventing the standard Ctrl+C → SIGINT interrupt signal.

Added a guard in attachCustomKeyEventHandler: if the matched action is
'copy' and term.hasSelection() is false, return true to pass the event
through to xterm.js, which encodes it as \x03 (ETX) for normal SIGINT
delivery. When text IS selected, copy proceeds as before.

This change is platform-agnostic (renderer-side only) and does not
affect any other terminal actions or default key bindings.

* fix: gate copy passthrough on Ctrl+C/⌃C interrupt chord specifically

The previous fix passed ALL no-selection copy bindings through to xterm,
which would send unintended escape/control sequences to the remote
process if copy were bound to keys like F5 or Ctrl+L.

Now gate the passthrough on the exact interrupt chord: Ctrl+C (PC) or
⌃C (Mac) — key 'c' with only Ctrl pressed, no Shift/Alt/Meta. Any other
copy binding with no selection is consumed as a safe no-op.

* fix: preserve Ctrl-C passthrough for copy shortcut

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-06-14 01:49:21 +08:00
陈大猫andCursor f5c3302329 feat: terminal rename, closeSession shortcut, and pane zoom (#1459)
* feat: auto-poll Docker capabilities while Docker tab is active

When the Docker tab is visible and hasDocker is not yet true,
poll refreshCapabilities() at the process refresh interval.
Stop polling once hasDocker becomes true, or when switching
to a different tab.

* fix: use resolvedTab instead of activeTab for Docker auto-poll condition

The auto-poll useEffect condition used activeTab, which stays stale
when Docker becomes unavailable. Changed to resolvedTab which reflects
the actual displayed tab. Also updated the dep array.

* fix: replace setInterval with setTimeout recursion in Docker tab probe

Replace setInterval-based polling with setTimeout recursion in the Docker
tab capability probe effect. This ensures the next probe only starts after
the previous one finishes, avoiding overlapping probes when SSH timeout
exceeds the polling interval.

- Add dockerPollTimerRef to track the timeout handle
- Use async pollOnce() that awaits refreshCapabilities() before scheduling next
- Use cancelled flag in cleanup to prevent scheduling after unmount
- Keep same dependency array for correctness

* fix: stabilize docker poll timer by using useRef for refreshCapabilities

refreshCapabilities() can return a new reference on every render, causing
the useEffect to re-run on every render — cleanup cancels the polling timer,
then the effect immediately calls pollOnce(), effectively bypassing the
configured timeout interval.

Fix: store refreshCapabilities in a useRef (refreshRef), use
refreshRef.current() inside pollOnce(), and replace refreshCapabilities
with refreshRef in the useEffect dependency array.

Closes #PR1456 Codex P2 review item.

* fix: delay auto-poll first probe by one interval to avoid overlap with tab-switch probe

When switching to the Docker tab, two mechanisms were triggering probes:
1. tab-switch effect (line 67-76): immediate probe via refreshCapabilities()
2. auto-poll effect: pollOnce() executing immediately on mount

This caused duplicate probes that waste SSH channel resources.

Fix: pollOnce() no longer fires on mount. Instead, the effect schedules the
first probe with setTimeout(pollOnce, capabilitiesTtlMs), so the first probe
happens after one full interval. Subsequent probes continue at interval pace
via the setTimeout recursion in pollOnce itself.

The tab-switch effect still fires the immediate probe (the correct one),
so responsiveness on tab switch is preserved.

* fix: reset cancelledRef in effect body to prevent permanent stalling of Docker polling

The cancelledRef was set to true in the cleanup function when dependencies
changed, but never reset when the effect re-ran. This caused pollOnce to
always early-return on subsequent timer ticks, permanently halting
Docker capability probing after the first dependency change.

* fix(system-manager): replace cancelledRef with closure variables for per-effect cancellation

Each effect generation now has its own  and  closure
variables instead of shared  / . This
prevents stale probes from surviving cleanup when the panel hides and
re-shows (Codex P2 review).

* fix: wrap refreshCapabilities in try/catch to keep polling on exception

If refreshCapabilities throws (instead of returning {success: false}),
the await would exit pollOnce without scheduling the next setTimeout,
silently killing Docker auto-detection polling.

* fix: add in-flight probe guard to prevent tab-switch and auto-poll concurrent probes

Add probingRef to track whether a capabilities probe is already in-flight.
- Tab-switch effect for Docker branch checks probingRef before starting a new probe
- Auto-poll pollOnce checks probingRef at entry and sets/clears it around the actual probe
- Tmux branch left unchanged as it has no auto-poll overlap risk

* fix: re-schedule next poll timer when probe is in-flight

When probingRef.current is true (tab-switch probe still running),
pollOnce was returning early without scheduling the next timer,
causing auto-poll to stop permanently afterward.

Now it schedules the next poll within the interval and returns,
so the polling loop keeps running until a slot where no probe is
active.

* fix: convert comments to ASCII-only English

- Line 105: translate Chinese comment to 'probe is in-flight, reschedule for next cycle'
- Line 113: replace em dash (U+2014) with ASCII dash

* feat: session inline rename, closeSession shortcut, pane zoom

* fix: sidebar inline rename with local state

* fix: add sessionDisplayName to terminalPropsAreEqual comparator

The Terminal component is wrapped with React.memo(…, terminalPropsAreEqual),
but the comparator was missing a check for sessionDisplayName. After renaming
a session, the pane title bar would show the old name until some other prop
changed and triggered a re-render.

Add prev.sessionDisplayName === next.sessionDisplayName to the comparator
so that display name changes cause the Terminal to re-render immediately.

* fix: add onStartSessionRename to TerminalLayerWorkspaceSection ctx destructuring and TerminalPanesHost props

* fix: add toggleWorkspaceViewMode to executeHotkeyActionImpl destructuring

The togglePaneZoom handler calls toggleWorkspaceViewMode() but it
wasn't destructured from getCtx(), causing a ReferenceError at runtime.

* fix: restore truncated ctx object in TerminalView render call

The TerminalView ctx object literal on line 1265 was truncated to
'showSele...' due to an editing tool truncation bug, causing
Parsing error: ',' expected on npm run lint / tsc --noEmit.

Restored the missing fields from the base commit:
showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef,
sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef,
terminalBackend, terminalContextActions, terminalCwdTracker,
terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem

Kept the PR's new additions (isVisible, onRename, sessionDisplayName)
intact.

* fix: add toggleWorkspaceViewMode to executeHotkeyAction context and add terminal.menu.rename translations

- Add toggleWorkspaceViewMode to the context getter in executeHotkeyAction (App.tsx)
- Add terminal.menu.rename translation for en (Rename), zh-CN (重命名), ru (Переименовать)

* fix: validate focusedSessionId before closing in closeSession hotkey

When closeSession hotkey fires, workspace.focusedSessionId may reference
a session that was already closed by another trigger (e.g., mouse click
on tab close button). Collect alive session IDs from the workspace root
and fall back to the first living pane if the stored focusedSessionId
is stale.

* fix(auto-poll): check useSessionCapabilities probing state in pollOnce

When auto-poll timer fires before the initial probe (from
useSessionCapabilities) completes, probingRef.current is still false
because the initial probe doesn't set it — causing a second overlapping
probe.

Add  check so that any in-flight probe from any path
(initial/auto-poll/tab-switch) prevents auto-poll overlap.

PR #1459

* fix: address remaining Codex review issues

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

* feat: add detach session from workspace with toolbar button and context menu

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

* fix: use customName in pane header display name for renamed sessions

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

* fix: refine workspace terminal detach interactions

* fix: preserve workspace detach tab ordering

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 01:30:44 +08:00
陈大猫 bb02f8e162 fix docker availability flicker and add openEuler icon 2026-06-13 12:28:07 +08:00
陈大猫 d57dd664a2 feat: auto-poll Docker capabilities while Docker tab is active (#1456) 2026-06-13 11:41:50 +08:00
陈大猫 74ec6678bb fix(system): increase process list limit and improve Docker detection for openEuler (#1453) (#1455)
* fix(system): increase process list limit and improve Docker detection for openEuler

Root cause analysis for issue #1453:

1. Process list limit too low: The `head -n 200` pipeline capped the process
   list at 200 entries, causing the displayed count to mismatch `ps aux | wc -l`
   on systems with many processes (common on openEuler servers with Docker,
   databases, etc.). Increased limit from 200 to 2000 for both Linux/SSH
   (ps) and Windows (PowerShell) backends.

2. Docker detection failure on openEuler 24.03: The capability probe only
   relied on `docker info >/dev/null 2>&1`, which can fail even when Docker
   is running due to:
   - SSH exec channel environment differences vs interactive shell
   - Docker socket permission variations in non-interactive sessions
   - Different socket path configurations on openEuler

   Added a fallback: if `docker info` fails but the Docker socket exists at
   `/var/run/docker.sock`, Docker is still detected as available. This
   matches the behavior of other SSH terminal clients.

* fix(system): also add Docker socket fallback to fallback probe script for consistency

* fix(system): remove hardcoded process list limit, add capability probe TTL, auto-reprobe on tab switch

Three remaining issues from PR #1455:

1. Remove hardcoded `head -n 2000` / `Select-Object -First 2000`
   process list limits — virtual list handles rendering efficiently.

2. Add 60-second TTL to sessionCapabilitiesStore cache. `get()` returns
   undefined for expired entries, forcing re-probe on next access.
   `set()` always refreshes `probedAt`. Export CAPABILITIES_TTL_MS
   constant for future tuning.

3. Auto-trigger capability re-probe when switching to Docker/Tmux tab
   whose tool was previously reported unavailable — handles the case
   where Docker/Tmux was installed after the last probe.

* fix: replace Docker socket -S check with -r for permission accuracy; sync capabilities TTL with process refresh interval

- Change [ -S /var/run/docker.sock ] to [ -r /var/run/docker.sock ] in
  both the main capability probe script and the POSIX fallback (electron bridge).
  -r verifies the socket exists AND the current user has read permission,
  preventing false-positive Docker detection that leads to failed Docker ops.
- Remove hardcoded CAPABILITIES_TTL_MS (60s) from sessionCapabilitiesStore.
  Store now computes expiresAt internally in set(ttlMs) and checks it in get()
  without requiring a parameter at call sites.
- useSessionCapabilities and useSystemCapabilitiesWarmup accept a
  capabilitiesTtlMs parameter derived from
  terminalSettings.systemManagerProcessRefreshInterval (default 3s → 3 000ms).
- SystemManagerSidePanel passes the TTL from terminalSettings to the hook.
- TerminalLayerTabBridge passes TTL from stableRef settings to warmup hook.
- Fix missing refreshCapabilities destructuring in SystemManagerSidePanel.

* fix: restore process list safety cap (head -n 2000 / -First 2000)

Codex review flagged that removing the process list cap entirely could cause
timeout/maxBuffer issues on process-dense hosts. Restore head -n 2000 (POSIX)
and -First 2000 (Windows) as a safety guard with comments clarifying this is
NOT a functional limit — monitored processes still show accurate metrics.

* fix: hoist useRef/useEffect before early returns to fix React hook order violation

The useRef and useEffect for tab-switch re-probe were placed after early returns
for missing/disconnected sessions. When a session later connects, React discovers
new hooks that weren't registered before, causing hook order violation crashes.

Moved both hooks immediately after the resolvedTab computation, before any early
return path, satisfying React's Rules of Hooks.

* fix: change Docker detection from OR to AND (CLI + socket)

Both capability detection and fallback probe now require:
- docker CLI is on PATH (command -v docker)
- docker.sock is readable ([ -r /var/run/docker.sock ])

Previously used OR logic (docker info || socket readable),
which could report hasDocker=true even when docker CLI
was unavailable (e.g., non-login SSH shell).

Fixes #1453

* fix(system-monitor): prefer docker info, fallback to CLI+socket

Co-authored-by: Codex <codex@anthropic.com>

Changes:
- Line 12: Replace strict CLI+socket check with docker info first,
          falling back to CLI+socket check only if docker info fails.
- Line 139: Same fix in the fallback probe script.

This handles DOCKER_HOST, Docker contexts, and rootless Docker.

* fix: notify subscribers when TTL expires in sessionCapabilitiesStore.get()

When capabilitiesBySessionId.get() finds an expired entry, it deletes the
entry but did not notify session subscribers. This caused components to
stale capabilities until the next successful set() call.

Now get() calls notifySession() on expiry, matching the notification
behavior already present in delete().
2026-06-13 08:58:04 +08:00
陈大猫 b9e88cd99d Simplify SFTP conflict dialog
Simplify the SFTP file conflict dialog and reduce visual noise.
2026-06-12 18:12:28 +08:00
陈大猫 32afade4f9 Fix SFTP type-mismatch upload conflicts (#1449) 2026-06-12 17:46:32 +08:00
lengyuqu 66de2db912 Fix CodeBuddy Windows CLI discovery (#1448) 2026-06-12 17:27:52 +08:00
bincxz b08ddc73b4 Fix Windows PATH refresh precedence 2026-06-12 17:24:54 +08:00
lengyuqu dccd872122 Merge branch 'binaricat:main' into codebuddy 2026-06-12 17:07:20 +08:00
lengyuqu 1e568011f8 修复 windwos 下 codebuddy 自动扫描路径问题 2026-06-12 17:04:33 +08:00
陈大猫 0a38da8867 Implement ZMODEM drag-and-drop file upload support in terminal
Adds SFTP fallback when rz is unavailable and cleans up drag-drop upload edge cases.
2026-06-12 16:58:50 +08:00
bincxz 5e739f8293 Merge remote PR branch updates 2026-06-12 16:58:20 +08:00
bincxz 6f64245d10 Add SFTP fallback for missing rz uploads 2026-06-12 16:56:48 +08:00
lengyuqu 2318eca54e [fix] 修复 codebuddy 在 windwos 在无法有效使用的问题 2026-06-12 16:45:27 +08:00
lengyuqu ed8a04c84e Merge remote-tracking branch 'upstream/main' into codebuddy 2026-06-12 16:43:56 +08:00
陈大猫 d48ca65a1e Slim release package (#1446) 2026-06-12 16:38:45 +08:00
bincxz 285fcd55a9 Merge main into terminal drag-drop zmodem 2026-06-12 16:37:01 +08:00
陈大猫 05b713ab18 [codex] Add configurable middle-click terminal behavior (#1443)
* Add configurable middle-click terminal behavior

* Fix middle-click terminal behavior edge cases
2026-06-12 16:28:09 +08:00
shideqinandCursor 293b15f67a Merge branch 'main' into feature/terminal-drag-drop-zmodem
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 16:14:04 +08:00
shideqinandCursor 83aec35f2f Merge main into feature/terminal-drag-drop-zmodem
Resolve conflicts in terminal backend, Terminal, and TerminalView to keep ZMODEM drag-drop alongside serial YMODEM receive and terminal selection AI settings from main.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 16:11:45 +08:00
陈大猫 910ef72205 [codex] Fix known host fingerprint coverage (#1442)
* Fix known host fingerprint coverage

* Tighten SFTP host key verification
2026-06-12 16:09:29 +08:00
陈大猫 550a37b379 [codex] Add serial YMODEM receive (#1438)
* Add serial YMODEM receive

* Address YMODEM receive review feedback
2026-06-12 15:47:24 +08:00
陈大猫 2b396c14e3 [codex] Fix CentOS 7 process listing (#1440)
* Fix CentOS 7 process listing

* Tighten CentOS 7 process listing regression test
2026-06-12 14:43:10 +08:00
陈大猫 36724a3abd Add SFTP tab duplication menu
Fixes #1423
2026-06-12 14:40:22 +08:00
陈大猫 4459aa4ef3 Add terminal zoom disable setting
Adds a Shortcuts setting to disable terminal zoom shortcuts.
2026-06-12 14:33:17 +08:00
陈大猫 64a6986d01 Sync terminal selection AI preference (#1441)
Syncs the terminal selection Add to Conversation preference with AI settings so cloud sync, local backups, restores, and settings-only auto-sync detect the value.

Follow-up for #1436.
2026-06-12 14:28:33 +08:00
陈大猫 a301ecb2ca Add terminal selection AI toggle (#1436)
Adds a Settings > AI switch to hide the automatic Add to Conversation button on terminal selection while keeping the context menu action available.

Closes #1397
2026-06-12 14:16:09 +08:00
shideqin f16429e30f Implement ZMODEM drag-and-drop file upload support in terminal
- Remove outdated SFTP upload message and replace it with ZMODEM-specific messages in English, Russian, and Chinese locales.
- Add a new function to handle ZMODEM drag-and-drop uploads in the terminal backend.
- Update terminal components to support ZMODEM drag-and-drop functionality.
- Enhance error handling for file uploads and provide user feedback for no files to upload.
- Introduce tests to verify ZMODEM upload behavior and fallback to SFTP for network devices.
2026-06-12 14:12:34 +08:00
陈大猫 46b9bf6ccb [codex] Hide managed startup commands from history
Hide Netcatty-managed Docker and tmux terminal launch commands from command history.

Validated locally with lint, full tests, and build. Multi-agent review completed with no remaining issues.
2026-06-12 14:00:16 +08:00
陈大猫 17c8f11194 Fix macOS package ad-hoc signing (#1433) 2026-06-12 12:36:46 +08:00
陈大猫 4d1a7ea55a Prevent app reload shortcut from closing sessions (#1432) 2026-06-12 12:10:59 +08:00
陈大猫 babe06a944 Fix local debug launch
Fix local debug launch
2026-06-12 11:54:26 +08:00
陈大猫 9e31d53bdd Slim release package
Slim release package
2026-06-12 11:48:59 +08:00
lengyuqu ea24841939 Fix Windows AI model selection
Fix the Windows issue where AI models could not be selected.
2026-06-12 01:42:38 +08:00
陈大猫 bf9f557e42 Fix popup timestamps and sidebar return animation
Hide timestamp controls in popup shell windows and keep the terminal host sidebar width while root pages are shown so returning to terminal tabs does not replay the open animation.
2026-06-12 01:35:50 +08:00
陈大猫 106e748a9b Fix terminal tab theme sync (#1421) 2026-06-12 01:21:28 +08:00
lengyuqu 3d669fffed fix windwos 下 ai 无法选择获取模型的问题 2026-06-12 01:11:21 +08:00
陈大猫 94fff62f9b [codex] Virtualize process manager list
Render only visible process rows while preserving sorting, search, status pills, and row actions.
2026-06-12 00:57:05 +08:00
陈大猫 324253f23a [codex] Optimize terminal side panel performance
Optimize terminal side panel performance, keep process status labels visible, and improve heavy panel loading behavior.
2026-06-12 00:47:17 +08:00
陈大猫 e9a2e44a91 Improve terminal timestamp gutter (#1417) 2026-06-12 00:45:08 +08:00
陈大猫 7b4f046001 [codex] Optimize AI settings agent performance (#1416)
* Optimize AI settings agent performance

* Address AI settings review feedback

* Retry interrupted agent discovery

* Harden agent CLI probe timeout cleanup

* Avoid duplicate agent discovery retries

* Ensure timed-out CLI probes are killed
2026-06-11 22:38:08 +08:00
陈大猫 1a3560a19f Polish tmux session modal (#1412) 2026-06-11 18:07:11 +08:00
陈大猫 3b525300e0 Tighten host tree topbar UX (#1411) 2026-06-11 17:49:35 +08:00
陈大猫 08ff49d3f5 Avoid broadcasting remote image paste paths (#1410) 2026-06-11 17:10:13 +08:00
陈大猫 f5c4271a07 Polish AI settings integrations UI (#1409) 2026-06-11 17:07:11 +08:00
陈大猫 74d41b43b6 [codex] Support remote image clipboard paste (#1408)
* Support remote image clipboard paste

* Address remote image paste review findings
2026-06-11 17:01:36 +08:00
陈大猫 3408bba303 [codex] Add Cursor SDK agent support (#1399)
* feat(ai): add Cursor SDK agent support

* fix(ai): harden Cursor SDK support

* fix(ai): address Cursor SDK review findings

* fix(ai): refresh Cursor environment handling

* fix(ai): refresh Cursor discovery scans

* fix(ai): enable Cursor recheck without path

* Use official Cursor agent icon

* Clarify Cursor SDK setup requirements

* Split Cursor SDK setup status

* Simplify Cursor settings copy

* Improve Cursor API key error

* Add safe Cursor auth diagnostics

* Disable Cursor local sandbox by default

* Show Cursor MCP tool names in tool cards

* Add spacing inside tool call groups
2026-06-11 16:43:34 +08:00
陈大猫 5e00e998a8 Add vault drag reorder ordering (#1407) 2026-06-11 16:05:17 +08:00
陈大猫 3847f0cda0 Merge pull request #1406 from binaricat/codex/per-host-line-timestamps
Make terminal timestamps per-host
2026-06-11 15:26:09 +08:00
bincxz 1ebcd017bd Make terminal timestamps per-host 2026-06-11 15:25:34 +08:00
陈大猫 9013a7e312 fix terminal popup release behavior (#1403) 2026-06-11 14:48:52 +08:00
陈大猫 afefbd953f Add serial YMODEM send support (#1400) 2026-06-11 11:11:31 +08:00
陈大猫 535b141b23 Merge pull request #1322 from lengyuqu/codebuddy
[Codebuddy] sdk
2026-06-11 10:21:35 +08:00
bincxz b21e44b65f fix(ai): address CodeBuddy PR review findings 2026-06-11 10:18:49 +08:00
陈大猫 b42be379e3 Merge pull request #1393 from binaricat/fix/host-tree-search-clear
fix(terminal): add clear button to host tree search field
2026-06-11 04:34:18 +08:00
bincxzandCursor b2f0a3bea3 fix(terminal): add clear button to host tree search field
Show an X control beside the host sidebar search input so users can reset the filter without manually deleting text.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 04:34:07 +08:00
陈大猫 df3745d185 Merge pull request #1392 from binaricat/feat/terminal-system-sidebar-button
feat(terminal): add statusbar button to open system sidebar
2026-06-11 04:31:27 +08:00
bincxzandCursor f85bb3f9b2 feat(terminal): add statusbar button to open system sidebar
Expose a quick Activity icon on SSH Linux sessions so users can jump directly to the system manager side panel from the terminal header.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 04:31:01 +08:00
陈大猫 566f3e3c32 Merge pull request #1391 from binaricat/fix/history-scope-tab-style
fix(terminal): align history scope tab styles with system manager panel
2026-06-11 04:26:17 +08:00
bincxzandCursor 58eb91fb23 fix(terminal): align history scope tab styles with system manager panel
Use the shared bg-muted selected state so history host/global tabs match the system manager tab styling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 04:24:48 +08:00
陈大猫 36267717ac feat(terminal): add system manager side panel for processes, tmux, and Docker
Introduce workspace-aware System side panel with remote process/tmux/Docker management, terminal popup for interactive attach, capability warmup, review-hardened IPC, performance optimizations, toast action errors, and SSH channel recovery on reconnect.
2026-06-11 04:19:21 +08:00
陈大猫andCursor 5e323f1f8f feat(terminal): add global command history tab in side panel (#1387)
Record commands as users type across sessions with dedup and AI-noise
filtering, and browse them alongside per-host remote history. Refine the
scope switcher UI and route fullscreen layout recovery through the terminal
backend hook. Closes #1253.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 03:14:30 +08:00
陈大猫 c0efc9d5c1 feat(ai): add quick messages with slash command picker (#691)
Closes #691
2026-06-11 03:11:39 +08:00
陈大猫 61188ab8e2 fix(ai): reset panel view when deleting active chat session (#1386)
Fixes #1382

Deleting the active AI chat session left panelViewByScope pointing at a removed session, causing the side panel UI to blank. Sync panel view cleanup with session deletion, stop in-flight streams on delete, and return to draft with the deleted session's agent preserved.
2026-06-10 23:22:19 +08:00
陈大猫andCursor ae209d37c1 feat(terminal): remote command history side panel (#1385)
* feat(terminal): add remote command history side panel

Read remote shell history over SSH/ET/Mosh exec channels, browse it in a virtualized side panel with search, paste, and save-as-snippet actions. Closes #1381.

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

* fix(history): expand command detail inline below selected row

Move the detail strip from a fixed slot above the list into the row
immediately below the clicked entry so expansion reads top-to-bottom.

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

* fix(history): filter Netcatty AI PTY commands from remote history

Drop shell history lines containing the __NCMCP_ marker so AI exec noise
does not clutter the command history panel.

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

* fix(history): tighten detail strip and add run action

Size the expanded row to its content, add a run-in-terminal button, and
use clearer snippet icon/tooltip for save-as-snippet.

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

* fix(history): address review findings before merge

Key cache by host+session, retry Mosh pending reads, and clamp virtual
list scroll position when filtered items shrink.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 23:19:31 +08:00
陈大猫andCursor a5b0efba75 fix(hotkeys): toggle quick switcher with the same shortcut (#1384)
Allow the quick switch shortcut to close the panel when pressed again,
including while the search input is focused, so users are not limited to
clicking outside to dismiss it.

Closes #1355

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 23:00:13 +08:00
陈大猫andCursor 5adb64e40e fix(terminal): recover terminal fit after app background and fullscreen (#1383)
Restore resize when macOS App Nap or GPU eviction leaves xterm stale after
switching away, by refitting on visibility/focus/fullscreen and fixing the
ResizeObserver race with async xterm boot.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 22:43:16 +08:00
陈大猫 41fea1028d Merge pull request #1380 from binaricat/fix/issue-1363-nerd-font-glyphs-on-startup
fix(terminal): refresh Nerd Font glyphs on cold start (fixes #1363)
2026-06-10 19:59:13 +08:00
bincxzandCursor 5a90a4331b fix(terminal): refresh Nerd Font glyphs after bundled fonts load
Cold-start local terminals on Linux could cache Powerline icon tofu when the
shell prompt arrived before Symbols Nerd Font Mono finished loading. Preload
the icon fallback at the active cell size and clear the xterm atlas so
already-drawn prompt glyphs re-rasterize (fixes #1363).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 19:55:46 +08:00
881f3b1a34 feat(et): support server stats for EternalTerminal sessions (#1377)
* feat(et): support server stats for EternalTerminal sessions

- Generalize the Mosh stats companion into reusable connection helpers
- Open a companion SSH connection so the host-info bar works for ET sessions
- Fall back to execOnEtSession for jumped ET sessions without a direct connection
- Forward host-key and algorithm options to the ET backend for companion parity
- Close the ET stats companion on session close, cleanup, and PTY exit

* fix(et): harden stats exec host-key trust and cleanup

Enforce StrictHostKeyChecking=yes for background ET stats/distro probes
instead of accept-new, merge vault known_hosts for parity with ssh2
companions, and wrap companion connection teardown in try/catch.

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

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 19:46:24 +08:00
陈大猫andCursor 8be5865b76 fix(terminal): isolate workspace pane font zoom from global settings (#1379)
Store per-session font size in workspace splits so Ctrl+wheel zoom no longer
changes sibling panes or reverts on blur when terminalSettings re-sync runs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 19:38:39 +08:00
陈大猫 685d1cb41a Merge pull request #1378 from binaricat/fix/issue-1375-shell-only-tab-numbers
feat(shortcuts): add option to skip pinned tabs for number keys
2026-06-10 19:15:48 +08:00
bincxzandCursor 14fe1e3ecb feat(shortcuts): add option to skip pinned tabs for number keys
Fixes #1375 by letting Cmd/Ctrl+[1...9] target only work tabs when enabled, while keeping the existing Terminus-style default.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 19:15:15 +08:00
陈大猫andCursor 636f4d7037 fix(terminal): reflow workspace when compose bar or side panel layout changes (#1376)
Keep the compose bar inside the terminal workspace so SFTP side panels stay full height, refit xterm when the bar toggles, and remeasure split-pane geometry when side panels open or close.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 19:00:46 +08:00
lengyuqu c92ad2f601 fix: resolve merge artifacts - duplicate div tag and undefined deps 2026-06-10 16:01:35 +08:00
陈大猫 8a876fd67d Merge pull request #1372 from binaricat/fix/settings-remove-lazy-tabs
fix(settings): eager-load AI and sync tabs
2026-06-10 15:45:12 +08:00
bincxzandCursor d39cd60863 fix(settings): eager-load AI and sync tabs
Remove React.lazy/Suspense for settings AI and sync tabs to avoid loading flashes when switching tabs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 15:44:34 +08:00
lengyuqu 602ca92476 merge: sync fork with upstream/main (v1.1.31+) 2026-06-10 15:42:24 +08:00
陈大猫andCursor f413035295 fix(terminal): refocus input when switching work tabs on macOS (#1371)
Restore xterm keyboard focus after top-level tab changes so macOS users
can type immediately without an extra click (discussion #1339).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 15:27:14 +08:00
陈大猫andCursor bfd3fb4dad feat(terminal): enhance compose bar with quick snippets and resizable input (#1370)
Add a persistent quick-snippet strip, draggable height, and terminal-matched UI to the compose bar, addressing quick-command and resize requests from community discussions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 15:18:37 +08:00
陈大猫andCursor 733e19a6f6 perf(settings): reduce Mac settings window input lag (#1347) (#1368)
* perf(settings): reduce Mac settings window input lag (#1347)

Debounce custom CSS commits, memoize heavy tabs, and replace Radix ScrollArea
with native scrolling so typing and navigation stay responsive on macOS.

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

* fix(settings): flush debounced textarea on unmount

Avoid losing custom CSS edits when the settings window closes before the
debounce timer fires.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 14:45:33 +08:00
陈大猫andCursor 85b552e1a6 fix(terminal): fix black block glyphs on Linux local terminal (#1364) (#1369)
* fix(terminal): resolve bold font weight without document.fonts.check false positives

Chromium reports unavailable bold weights as available, so xterm tried to rasterize weight 700 while the bundled JetBrains Mono fallback only ships 400/500/600. Bold glyphs then rendered as black blocks on Linux local terminals (fixes #1364).

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

* chore: drop unused primaryFontFamily from terminal effects context

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 14:39:36 +08:00
陈大猫andCursor 068730c53c fix(ui): improve host tree inline group rename interactions (#1367)
* fix(ui): improve host tree inline group rename interactions

Cancel rename when clicking another tree row and prevent parent drag from blocking text selection in the rename input.

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

* fix(ui): block group toggle keyboard while inline renaming

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

* fix(ui): cancel host inline rename when clicking other tree rows

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 14:38:33 +08:00
陈奇 c9d84c7ce3 Merge commit 'refs/restore/pr-1365' 2026-06-10 04:22:46 +00:00
陈奇 d558aea7de Merge commit 'refs/restore/pr-1361' 2026-06-10 04:22:44 +00:00
陈奇 e211eec693 Merge commit 'refs/restore/pr-1360' 2026-06-10 04:22:41 +00:00
陈奇 6b1277d3e1 fix(packaging): refresh hicolor icon cache in FPM after-install to fix Arch pacman icon (#1358)
Root cause: FPM-generated .pacman packages copy icons directly to
/usr/share/icons/hicolor/*/apps/netcatty.png, bypassing Arch's alpm
hooks that normally run gtk-update-icon-cache. Without a refreshed
cache, KDE Plasma cannot resolve Icon=netcatty and falls back to a
generic document icon in the app menu.

Fix:
- Copy electron-builder's default after-install template to
  scripts/linux/after-install.tpl, append gtk-update-icon-cache call
- Create scripts/linux/after-remove.tpl with the same cache refresh
- Wire into pacman.afterInstall/pacman.afterRemove
  (NOT linux.afterInstall — the schema places these under target-level
  options like PacmanOptions/DebOptions, not LinuxConfiguration)
- Add test in electron-builder-config.test.cjs

The command is idempotent on systems without gtk-update-icon-cache
(hash guard) and uses || true to never break package installation.
2026-06-10 03:24:18 +00:00
bincxz 35bf38be70 Improve host tree rename and hover details 2026-06-10 11:04:36 +08:00
bincxz 555c00406e Polish vault and log icons 2026-06-10 10:41:42 +08:00
bincxz e67012654a Improve SFTP bookmark list accessibility 2026-06-10 10:25:19 +08:00
bincxz ecdb1d17cd Address SFTP toolbar review feedback 2026-06-10 10:22:51 +08:00
bincxz a5578b5e60 Refine SFTP toolbar view and bookmarks 2026-06-10 10:16:44 +08:00
陈大猫 fb4641878f Merge pull request #1354 from binaricat/fix/issue-1352-window-controls
fix(ui): restore Windows title bar window control hover (#1352)
2026-06-10 01:32:31 +08:00
bincxzandCursor 7d6f30f51f fix(ui): restore Windows title bar window control hover (#1352)
Align window controls with utility icons, extend hover to the full title bar height, restore red close-button hover, flush close to the right edge, and use neutral gray hover for top-bar utility buttons.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 01:29:49 +08:00
陈大猫 9869b645b1 Merge pull request #1353 from binaricat/fix/active-chrome-theme-split-autocomplete
fix(ui): smooth work-tab chrome transitions and split-pane autocomplete
2026-06-10 01:17:34 +08:00
bincxzandCursor 037b85bd66 fix(ui): smooth work-tab chrome transitions and split-pane autocomplete
Replace immersive instant-switch with animated active chrome theme sync so
top tabs match terminal sessions immediately on tab click, and clamp
autocomplete popups to the active pane so they stay anchored to the cursor
in split workspaces.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 01:10:33 +08:00
陈大猫 ba784b8b35 fix: resolve .cmd shim to native exe on Windows to avoid spawn EINVAL (#1350) (#1351)
Windows + Node >= 24: spawning .cmd files with shell=false causes EINVAL.
Claude Code v2.1.169 ships as native binary (no cli.js), npm global install
creates only claude.cmd. Netcatty detected claude.cmd but spawned it with
shell:false -> EINVAL.

Changes:
- resolveWindowsShimToNativeExe: new function that reads .cmd/.bat shims
  and resolves to the real .exe using "%~dp0\...\*.exe" pattern matching
- prepareCommandForSpawn: tries native exe resolution first, falls back
  to shell:true wrapping
- resolveClaudeCodeExecutableForSdk: when cli.js not found, looks for
  bin/claude.exe native binary
- 3 new tests for shim resolution and spawn spec
- Codex CLI unaffected (already handles native exe resolution)

Test: 38/38 shellUtils tests pass, npx tsc --noEmit clean
2026-06-09 23:40:21 +08:00
陈大猫 eae760db3f fix: upload terminal drops to current cwd
Fix terminal drag-and-drop uploads so they target the active terminal cwd and avoid fallback home/login-shell cwd when the active cwd cannot be confirmed.
2026-06-09 21:25:32 +08:00
陈大猫 4b5993cad6 fix host sidebar behavior for editor tabs (#1348) 2026-06-09 21:24:16 +08:00
陈大猫 6af62aa093 Add Arch pacman Linux package (#1344) 2026-06-09 21:07:56 +08:00
陈奇 61e8de4270 fix: synchronous preventDefault in paste handler + text paste fallback
- event.preventDefault() must be called synchronously before the
  async IPC call, otherwise the browser processes the default paste
  action before we can intercept it
- When clipboard has no files (or on error), fall back to text paste
  via pasteTextIntoTerminal since the default action was already
  prevented
2026-06-09 12:13:59 +00:00
陈大猫 27dce4e427 feat: local terminal paste file inserts file path (#1345) (#1346)
When pasting (Ctrl+V / right-click paste) in a local terminal,
if the clipboard contains files, insert their paths instead of
doing nothing.

- New hook useTerminalFilePaste: capture-phase paste listener
  on terminal container, reads clipboard files via Electron bridge,
  formats paths (spaces quoted, deduped), writes to session
- Updated useTerminalContextActions: right-click paste checks
  clipboard files first, falls back to text paste
- New terminalHelpers.extractRootPathsFromClipboardFiles
- Tests: 9 unit tests for path extraction logic
- Verified via headless Chromium integration test (15 tests)
- Build: npm run build ✅, npm test ✅ (1899 pass)
2026-06-09 20:11:12 +08:00
XuerandCursor 8b53fb1c7b feat(snippets): 为快速添加弹窗补充仅粘贴选项 (#1342)
终端侧快速添加片段时无法设置 noAutoRun,与完整编辑面板行为不一致,
补充该选项以便在终端上下文中直接创建仅粘贴不执行的片段。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 18:56:49 +08:00
陈大猫 6c1661dc3c fix: keep connection logs fully visible (#1343) 2026-06-09 18:52:30 +08:00
陈大猫andCursor 3662b45121 fix(ai): resolve Windows Codex npm shims before SDK spawn (#1337)
* fix(ai): resolve Windows Codex npm shims before SDK spawn

Codex SDK spawns codexPathOverride without shell:true, so passing
codex.cmd triggers spawn EINVAL on Node 18+. Rewrite npm shims to the
native codex.exe (mirroring #1102 for Claude) on SDK paths only.

Fixes #1101

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

* fix(ai): drop unused ctx export and restore test file encoding

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

* fix(ai): harden Windows Codex SDK executable resolution

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 18:34:45 +08:00
陈大猫andCursor 437253179e fix(linux): Ubuntu software icon missing due to single-size linux.icon override (#1341)
* fix(linux): restore multi-size hicolor icons for Ubuntu launchers (#1340)

PR #816 set linux.icon to a single 1024px PNG, which regressed the #274
fix and left only hicolor/1024x1024 on .deb installs. Drop the override
so electron-builder uses build/icons again, regenerate those PNGs from the
tight-crop icon-win source, and add a helper script plus a config test.

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

* fix(linux): set linux.icon to icons dir for proper multi-size hicolor icons (#1340)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 18:26:07 +08:00
Pyro d85f4edbbb fix: host tree tab jump on first terminal open (#1331) 2026-06-09 17:27:10 +08:00
陈大猫andCursor 96c9ccaaa0 fix(vault): add duplicate host to tree view context menu (#1336)
* fix(vault): add duplicate host action to tree view context menu

Wire the existing onDuplicateHost handler into vault host tree menus so
tree view matches grid/list duplicate behavior. Fixes #1329.

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

* fix(vault): switch to hosts section when duplicating from terminal tree

Ensure the host details panel is visible after duplicate is triggered
from the terminal host tree sidebar.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 17:20:12 +08:00
lengyuqu 3203ed7a19 merge: sync fork with upstream/main (latest) 2026-06-09 13:35:13 +08:00
陈大猫 517cbb6cee fix(ai): compress Catty requests only after 413 (#1327)
* fix(ai): compress Catty requests only after 413

* fix(ai): retry 413 after tool progress safely

* fix(ai): mark thrown 413 retries after tool progress

* fix(ai): preserve tool results in 413 retry
2026-06-09 13:11:42 +08:00
陈大猫 3bc373dbec Expand custom CSS hooks (#1326) 2026-06-09 12:36:15 +08:00
陈大猫 273fe10296 fix(ui): keep terminal tabs clear of host tree (#1325) 2026-06-09 12:12:41 +08:00
陈大猫andCursor 2a10a28cc8 fix(ai): cap Catty agent request payload to prevent HTTP 413 (#1323) (#1324)
* fix(ai): cap Catty agent request payload to prevent HTTP 413

Long-running chats accumulated full terminal tool outputs in SDK history
while token-based compaction only triggered near the model context window,
so nginx gateways could reject oversized JSON bodies before the model saw them.

Add a byte-budget pass that compresses verbose output, tail-preserving
truncation, and a safe sliding window before each Catty agent turn.

Fixes #1323

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

* fix(ai): compaction was using unfiltered sdkMessages, fix didAdjust and emergency loop

* fix(ai): compact and retry oversized Catty requests

* fix(ai): preserve current input while guarding 413 retries

* fix(ai): avoid false 413 detection and fit oversized current input

* fix(ai): pair replayed tool results chronologically

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 11:51:03 +08:00
陈奇 f74645e1a4 chore: upgrade Electron to 42.3.3, @electron/asar to 4.2.0, electron-builder to 26.15.2 2026-06-08 22:48:02 +00:00
bincxz 15ec02dcae feat(tabs): smooth-scroll edge tabs toward center 2026-06-09 04:37:59 +08:00
bincxz e75c654a1a fix(tabs): keep host tree gutter outside tab scroll 2026-06-09 04:34:28 +08:00
bincxz 29b1eca1fd fix(settings): improve responsive settings controls 2026-06-09 04:05:28 +08:00
陈大猫 e2d036e710 Merge pull request #1318 from binaricat/codex/fix-tray-menu-1314
fix(linux): restore tray icon context menu for Linux via setContextMenu (#1314)
2026-06-09 03:53:18 +08:00
bincxz 094f0abe4a fix(sftp): follow terminal cwd after submitted commands 2026-06-09 03:51:33 +08:00
bincxz 8ab2003dae fix(sftp): restore terminal cwd following 2026-06-09 03:43:49 +08:00
bincxz b1b0c5648c fix(terminal): stabilize tab switch follow-up 2026-06-09 03:40:18 +08:00
陈大猫andCursor 36e5779d94 perf(terminal): reduce terminal tab-switch and layout jank (#1321)
* perf(terminal): smooth layout drags and faster tab switching

Defer xterm refit during split, sidebar, and host-tree drags while keeping pane containers in sync with live layout measurements. Refactor TerminalLayer into focused sections with TabBridge/memo optimizations and add the terminal host tree sidebar.

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

* fix(terminal): keep side panels alive and guard session attach races

Prevent terminal boot unmount from leaking backend sessions, keep SFTP/scripts/theme/AI state when switching side tabs, and defer heavy SFTP UI mount so first entry stays responsive.

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

* perf(terminal): reduce tab switch jank

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 03:35:03 +08:00
lengyuqu 846d8246a3 Merge branch 'binaricat:main' into codebuddy 2026-06-08 23:44:08 +08:00
lengyuqu 26a04b22d3 merge: sync fork with upstream/main 2026-06-08 21:44:19 +08:00
陈大猫andCursor 53aef452cc fix(sftp): default unchecked opener preference for extensionless files (#1320)
Avoid accidentally persisting built-in editor as the default for all
extensionless files when double-clicking binaries without an extension.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 21:42:18 +08:00
lengyuqu f5f55ffc2e 完整修复功能 2026-06-08 21:35:44 +08:00
陈大猫andCursor 3ef5a64b94 feat(settings): unify settings page card and section layout (#1316) (#1319)
Introduce shared SettingCard and SettingsSection primitives so AI, SFTP,
system, and terminal tabs use the same white-card + row control pattern.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 21:24:17 +08:00
陈奇 c28db932a4 fix(linux): restore tray icon context menu for Linux via setContextMenu (#1314) 2026-06-08 12:48:23 +00:00
lengyuqu 0792ce1415 codebuddy sdk 接入 2026-06-08 20:10:27 +08:00
陈大猫andCursor f2c2501fa5 fix(sftp): Ctrl+V 粘贴文件夹不再变成空文件 (#1266) (#1312)
* fix(sftp): paste folders as directories instead of empty files (#1266)

Prefer Electron readClipboardFiles and extractDropEntries over clipboardData.files,
upload pasted directories via uploadExternalFolderPath, and add macOS NSFilenames
clipboard support.

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

* fix(sftp): harden clipboard paste after review findings

Start extractDropEntries synchronously during paste, restore path-backed
file snapshot fallback, handle per-folder upload failures, and add
public.file-url clipboard test.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 19:42:45 +08:00
陈大猫andCursor b1f930a995 feat(sftp): 侧栏 SFTP 增加追随终端目录模式 (#1266) (#1310)
* feat(sftp): add follow terminal directory mode for sidebar (#1266)

Add a toolbar toggle that keeps the side-panel SFTP browser synced with the linked SSH terminal cwd, inspired by MobaXterm's follow-folder behavior.

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

* fix(sftp): probe pwd after commands when follow mode lacks OSC 7

Add a deferred getSessionPwd fallback after terminal commands when follow-terminal-cwd is enabled and the shell did not report OSC 7, and fix settings sync hook dependencies.

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

* fix(sftp): repair follow toggle UI and backend cwd sync fallback

Fix SftpPaneView memo skipping follow prop updates, probe fresh pwd when OSC 7 is missing, and broaden linked terminal session resolution for sidebar follow mode.

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

* fix(sftp): harden follow sync after review findings

Reuse shouldFollowTerminalCwdNavigate in production path, re-read connection
after async cwd probe, skip redundant navigate on toggle, and only probe pwd
when the SFTP side panel is open.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 19:28:30 +08:00
lengyuqu eca23a2691 Merge remote-tracking branch 'origin/codebuddy' into codebuddy 2026-06-08 19:02:54 +08:00
bincxzandCursor de60b616cd Add structured GitHub issue templates and align in-app bug reports.
Require bug/feature reports via issue forms with automated format checks, while accepting legacy Bug: titles from older app builds.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 17:42:50 +08:00
陈大猫 6e6a0240a7 Merge pull request #1295 from binaricat/codex/fix-issue-1293
fix(terminal): support Kylin sudo and telnet prompts without trailing colon (#1293)
2026-06-08 16:49:01 +08:00
bincxzandCursor 2e2360a9fc test(terminal): cover Kylin screenshot prompts and fix sudo fast path
Add issue #1293 screenshot-exact cases for sudo/telnet autofill and
include English password in the handleOutput fast-path bypass.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 16:29:25 +08:00
陈奇 8011f4e2e8 fix(terminal): support Kylin sudo and telnet prompts without trailing colon (#1293)
Kylin Professional's sudo prompt doesn't include the [sudo] tag and
doesn't end with a colon. The existing regex patterns required either
[sudo] or a trailing colon, causing the autofill hint to never fire.

Changes:
- Make trailing colon optional in SUDO_PROMPT_PATTERN and
  EXPLICIT_SUDO_PROMPT_PATTERN (terminalSudoAutofill.ts)
- Update fast path to not skip output containing Chinese password
  keywords (密码/口令) that lack a colon
- Make trailing colon/angle-bracket optional in telnet username and
  password prompt patterns (telnetAutoLogin.cjs)
- Also relax LAST_LOGIN_PATTERN for consistency
- Add Kylin-style test cases for both sudo and telnet auto-login
2026-06-08 16:04:20 +08:00
陈大猫andCursor 970037682c feat: add adjustable window opacity for overlay use (#1304) (#1309)
Expose whole-window transparency via setOpacity with settings and a top-bar quick control, persisting across restarts and syncing across windows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 15:57:25 +08:00
陈大猫andCursor 42b58efc5c fix: align top bar right-side icon buttons (#1298) (#1303)
* fix: align top bar right-side icon buttons (#1298)

Unify AI/sync/theme/settings buttons to h-7 in one row aligned with tabs.
SyncStatusButton was h-8 and settings lived in a separate container, causing
misalignment. Preserve Windows spacing before window controls (mr-2).

Fixes #1298

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

* fix: align window controls with utility icons in top bar

Merge window controls into the same row as utility buttons, match h-7 height, add left margin separation, and restore rectangular gray hover for all three buttons.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 14:07:07 +08:00
陈大猫andCursor b20163d762 fix: add custom CSS hooks for terminal side panel and split view (#1302)
Expose data-section selectors for SFTP/side panel, split panes, and resizers
so custom CSS can target the correct regions. Clarify docs that
terminal-workspace-sidebar is focus-mode only.

Fixes #1301

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 13:11:51 +08:00
陈大猫 e0a56cbb14 fix(ai): infer MIME type from file extension for YAML and other code files (#1289)
* fix(ai): infer MIME type from file extension for YAML and other code files

When uploading YAML files (and other code/text files) via Electron,
file.type is often empty, causing the system to default to
'application/octet-stream'. AI providers reject this media type
with 'functionality not supported'.

Fix by inferring the correct MIME type from the file extension
when file.type is empty. Includes mappings for YAML, JSON, TOML,
shell scripts, and 50+ common code/text file extensions.

Fixes #1287

* fix(ai): use text/plain for all code/text files to ensure provider compatibility

Change all non-standard MIME types (text/x-*, application/x-*) to
text/plain for maximum provider compatibility. Anthropic and other
providers reject non-standard MIME types like application/x-yaml
with 'UnsupportedFunctionalityError'.

Changes:
- All code files (js, ts, py, rb, rs, go, java, c, cpp, sh, etc.) → text/plain
- Web component/stylesheet files (vue, svelte, scss, sass, less) → text/plain
- yaml/yml → text/plain (was application/x-yaml)
- dockerfile → text/plain (was text/x-dockerfile)
- Standard types (html, css, json, xml, csv, md, txt, pdf) preserved
2026-06-07 19:05:33 +08:00
陈大猫 8dae851ea3 fix(telnet, sudo): support Chinese-localized prompts with full-width colons (#1286) (#1288)
* fix(telnet, sudo): support Chinese-localized prompts with full-width colons (#1286)

Two bugs in prompt detection for Chinese-locale users:

1. telnetAutoLogin: USERNAME_PROMPT_PATTERN, PASSWORD_PROMPT_PATTERN,
   and LAST_LOGIN_PATTERN only matched half-width colon ':' or '>'.
   Chinese-locale telnet prompts use full-width colon ':' (U+FF1A),
   e.g. '登录:', '密码:'. Changed [:'>] to [::'|] in all three
   patterns to accept both colon variants.

2. terminalSudoAutofill: EXPLICIT_SUDO_PROMPT_PATTERN required
   '[sudo]' (closing bracket immediately after 'sudo'), but Chinese
   sudo prompts use '[sudo: authenticate] 密码:' format where sudo
   is followed by colon. Changed \[sudo\] to \[sudo[^\]]*\] to
   match any '[sudo...]' variant, making the explicit (no-arm-needed)
   hint detection work for Chinese locale.

Fixes #1286

* fix: restore OSC stripping pattern broken in previous commit

The regex negated character class [^\x07]* was truncated to just \x07,
breaking OSC sequence stripping (e.g. window title changes embedded in
terminal output). Restore the original negated class so stripTerminalControl-
Sequences continues to remove OSC title sequences before prompt detection.

This was caught by Codex review of PR #1288.
2026-06-07 19:05:27 +08:00
bincxzandClaude Opus 4.8 03ba9595c0 fix(terminal): hint reliably on explicit [sudo] prompts (#1284)
Sudo hints were flaky for manually typed commands: arming depends on
recognizing the submitted line as a command (recordedCommand), which is
unreliable while echo round-trips over SSH — so the hint sometimes didn't
fire for "sudo -i" / "sudo -s".

Explicit "[sudo] …" prompts are sudo-specific, so hint on them without
requiring an arm — reliable regardless of command recording. Bare
"Password:" still needs the arm window to avoid noise on unrelated prompts
(ssh, mysql). Filling still requires explicit Enter, so showing the hint
without arming stays safe. Added a colon fast-path so bulk output skips the
detection regex.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 13:47:08 +08:00
bincxzandClaude Opus 4.8 4b07b4826a fix(terminal): resolve sudo autofill password through identity references (#1284)
Sudo autofill only read host.password and never resolved a host's reference
to a Keychain identity (host.identityId). When the account password lived in
a referenced identity, the autofill got nothing — while SSH login worked
because it goes through resolveHostAuth, which resolves the identity.

Add domain resolveHostAutofillPassword (same resolveHostAuth resolution:
identity.password ?? host.password, honoring savePassword and dropping
undecryptable placeholders) and use it as the terminal autofill password
source. Login and autofill now share one resolution path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 13:21:54 +08:00
陈大猫 80d9b33c59 feat(terminal): confirm-to-fill sudo password hint (#1281)
Rework sudo password autofill from auto-fill to a Tabby-style hint + Enter to confirm. When a sudo command is armed and a password prompt appears, show a dimmed inline hint instead of sending the password; Enter pastes the saved password and submits, any other key dismisses it.

Confirmation removes the credential-leak class (nothing is sent without the user pressing Enter at a visible hint), so detection is relaxed to a broad match (Ubuntu/PAM bare "Password:", "[sudo] password for…", localized prompts) and the per-host toggle is removed — always available when the host has a saved password.

Safety guards:
- don't arm when the hint can't render (no overlay) so Enter isn't silently intercepted;
- swallow Escape/Backspace so the byte never reaches the no-echo prompt;
- clear the pending hint once output moves past the prompt (sudo timeout/failure/returns to shell) so a later Enter can't leak the password to the shell.

Implementation ~140 lines; full suite green; manually verified on a real Linux host.
2026-06-07 12:39:06 +08:00
陈大猫 3be3c14912 fix(terminal): simplify sudo password autofill, scoped to real sudo prompts (#1281)
Replace the command-rewriting scheme (inject sudo -p marker, sanitize the echo, Ctrl-U retype) with passive observation: arm a short window on a sudo command and fill the password when a real sudo prompt appears.

- Fixes the Ubuntu/PAM no-fill, the cursor jump below the prompt, and the typed-vs-autocomplete discrepancy from #1281.
- Detection requires the [sudo] tag, or a whole-line bare "Password:" / "密码:"; prefixed prompts (mysql -p "Enter password:", ssh "x@h's password:", psql "Password for user x:") are rejected so the sudo password can't leak to a child program when sudo's creds are warm.
- Disarms when a non-sudo command follows, so a stale window can't fill a later prompt.

Implementation: 322 -> ~140 lines.
2026-06-07 03:11:25 +08:00
陈大猫 4171f85c73 Merge pull request #1279 from binaricat/perf/connection-startup
perf: speed up single & batch SSH connect, stop the main-thread freeze (#1276)
2026-06-06 23:23:14 +08:00
bincxzandClaude Opus 4.8 5a78ebcf7c test(ssh): pin default-key equivalence against the functions actually used
The dedupe in startSession.cjs runs under `with(ctx)`, where ctx is wired
with sshBridge.cjs's own local findDefaultPrivateKey /
findAllDefaultPrivateKeys — not the sshAuthHelper.cjs copies. The
characterization test targeted the helper's exports, which the connect
path never calls (sshAuthHelper.findDefaultPrivateKey has no production
consumers at all), so it gave false confidence and the in-code comment
pointed at the wrong test.

Expose the local pair as _findDefaultPrivateKey / _findAllDefaultPrivateKeys
(matching the existing _-prefixed test-export convention) and retarget the
test at them, so it actually guards the path the optimization depends on.
Behavior is unchanged; the two local functions are verified equivalent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 23:20:22 +08:00
bincxzandClaude Opus 4.8 9294a7130f perf(terminal): defer WebGL renderer for hidden panes until visible
Each terminal that loads the WebGL addon holds a live WebGL context for
its whole lifetime, and all session panes stay mounted (hidden ones
off-screen). Batch connect therefore created a WebGL context per host up
front, contending for the GPU on the main thread — also the root of the
"garbled / 花屏" corruption in #1049/#1063. Defer WebGL creation for panes
that mount hidden (background tabs of a batch) and upgrade them on first
visibility via an idempotent ensureWebglRenderer(); a hidden pane renders
through xterm's DOM renderer until shown. Visible panes (single connect,
the active tab) keep creating WebGL immediately — unchanged behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 22:52:35 +08:00
bincxzandClaude Opus 4.8 9ce3abc2b4 perf(connect): stagger batch host connects across frames
Batch-connecting N hosts called onConnect() in a synchronous forEach, so
all N terminals mounted in one React commit and each createXTermRuntime()
(which spins up a live WebGL context) ran back-to-back on the main
thread, freezing the UI until the whole batch finished (~2-3s per host,
linear). Spread the connects across frames via a small injectable-scheduler
helper: the first host still connects synchronously so its tab appears
immediately, the rest are deferred one step apart so no two heavy mounts
land on the same frame.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 22:52:35 +08:00
bincxzandClaude Opus 4.8 327594a598 perf(ssh): scan ~/.ssh once and overlap it with key prep on connect
Every SSH connect ran two separate ~/.ssh scans back-to-back:
findDefaultPrivateKey() then findAllDefaultPrivateKeys(). They share
identical filter/sort/encrypted-skip logic, so the first scan's result
is exactly findAllDefaultPrivateKeys()[0]. Derive the preferred default
key from the full list (scanned once) instead, and kick that single scan
off before the identity-file / inline-key preparation so the filesystem
work overlaps the key prep instead of running serially after it.

Behavior is unchanged: auth order and fallback keys are identical. The
equivalence the dedupe relies on is pinned by a new characterization
test against a faked ~/.ssh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 22:52:35 +08:00
陈大猫 31cccdec03 Fix duplicate-tab clone payload dropped on renderer readiness timeout (#1278) 2026-06-06 22:44:39 +08:00
陈大猫 29a6172120 Add duplicate tab to new window
Adds a tab context menu action to duplicate a terminal into an independent peer window, with per-window active-tab titles and multi-window lifecycle safeguards.
2026-06-06 22:13:47 +08:00
陈大猫 06486e06dd Merge pull request #1274 from binaricat/codex/fix-sudo-autofill-prompt-verification
[codex] Fix sudo autofill prompt verification
2026-06-06 21:27:02 +08:00
bincxz ada55ab461 Fix sudo autofill prompt verification 2026-06-06 21:26:01 +08:00
陈大猫 a9e4de65a9 Merge pull request #1273 from binaricat/codex/host-sudo-password-autofill
Add per-host sudo password autofill
2026-06-06 20:50:52 +08:00
bincxz 2867262e4d Add per-host sudo password autofill 2026-06-06 20:47:16 +08:00
陈大猫 779c09186c Merge pull request #1272 from binaricat/codex/terminal-selection-ai-attach
Add terminal selection AI attachments
2026-06-06 19:08:38 +08:00
bincxz 6a0408b942 Add terminal selection AI attachments 2026-06-06 18:59:48 +08:00
陈大猫 43e094c345 Merge pull request #1269 from binaricat/codex/terminal-log-timestamps
Add terminal and log timestamps
2026-06-06 17:36:03 +08:00
bincxz 7d30b19421 Harden timestamp edge cases 2026-06-06 17:31:41 +08:00
bincxz e9e8c35178 Add terminal and log timestamps 2026-06-06 17:04:33 +08:00
陈大猫 646e7ce001 fix(tray): update Windows tray click and right-click behaviors to match conventions (#1152) (#1268) 2026-06-06 11:08:14 +08:00
陈大猫 21da34187e Merge pull request #1267 from binaricat/codex/fix-cmd-pty-wrapper 2026-06-06 02:59:35 +08:00
LAPTOP-O016UC3M\Qi Chen d2fa4f1cd9 Fix cmd PTY wrapper expansion 2026-06-06 02:28:16 +08:00
陈大猫 72a6fc14f9 Merge pull request #1263 from binaricat/codex/domestic-ai-provider-presets
Add domestic AI provider presets
2026-06-05 16:52:04 +08:00
bincxz 97c2cb1f86 Keep OpenRouter model discovery covered 2026-06-05 16:49:00 +08:00
bincxz 73fd091b80 Cover provider dropdown presentation 2026-06-05 16:43:52 +08:00
bincxz 5bb4052f3d Keep preset model suggestions visible 2026-06-05 16:39:39 +08:00
bincxz 36e7e3cb7f Keep add provider menu inside settings panel 2026-06-05 16:37:54 +08:00
bincxz 25b73187f5 Update domestic provider model suggestions 2026-06-05 16:35:29 +08:00
bincxz 75a9600089 Document Xiaomi provider icon source 2026-06-05 16:30:32 +08:00
bincxz c9216b32ab Add domestic AI provider presets 2026-06-05 16:28:21 +08:00
陈大猫 70e374ef11 Merge pull request #1262 from binaricat/codex/proxy-type-dropdown
Use dropdown for proxy type selection
2026-06-05 16:02:25 +08:00
bincxz 24840c539c fix(proxy): use dropdown for proxy type 2026-06-05 15:59:21 +08:00
陈大猫 461be76821 Merge pull request #1260 from binaricat/codex/proxy-command-1257
feat(ssh): support ProxyCommand connections
2026-06-05 15:49:38 +08:00
bincxz 28a7184cc4 fix(proxy): harden ProxyCommand summaries 2026-06-05 15:35:21 +08:00
陈奇 13f1453276 test(proxy): avoid ProxyCommand stdin race 2026-06-05 06:42:49 +00:00
陈奇 ff25c36ede test(proxy): harden ProxyCommand stream coverage 2026-06-05 06:34:12 +00:00
陈奇 092aa45fd9 Fix ProxyCommand shell substitution 2026-06-05 06:11:31 +00:00
陈大猫 64acf80024 Merge pull request #1259 from binaricat/codex/chacha20-poly1305-1256
fix(ssh): support chacha20-poly1305 cipher
2026-06-05 14:08:52 +08:00
陈奇 099beb8438 feat(ssh): support proxy command connections 2026-06-05 06:03:45 +00:00
陈奇 8bee13c3f9 fix(ssh): offer chacha20-poly1305 cipher 2026-06-05 05:54:23 +00:00
陈大猫 65cd8aba79 Merge pull request #1258 from binaricat/feat/sftp-skip-loading-on-reuse
feat(sftp): skip loading animation when reusing terminal SSH connection
2026-06-05 12:07:08 +08:00
陈奇 37856e5608 fix: set reusedConnection on initial connect, tolerate bridge fallback
When sourceSessionId is requested but the bridge falls back to a
fresh connection, the pane remains non-interactive (loading=true)
with stale cached files shown — acceptable trade-off vs always
showing the distracting spinner for near-instant reused connections.
2026-06-05 04:05:04 +00:00
陈奇 5b1deaa08a fix: clear reusedConnection flag after SFTP connection established
The flag was persisting forever, suppressing loading UI for all
subsequent navigations and refreshes. Clear it once status
becomes 'connected' so only the initial reuse skips the spinner.
2026-06-05 03:58:54 +00:00
陈奇 a41bced1d7 feat(sftp): skip loading animation when reusing terminal SSH connection
When SFTP reuses an existing terminal SSH connection, the
connection is near-instant so the loading spinner and overlay
are distracting noise. Added a reusedConnection flag to
SftpConnection and skip the loading UI when set.

Changes:
- SftpConnection model: +reusedConnection boolean
- useSftpConnections: set reusedConnection when sourceSessionId exists
- SftpPaneToolbar: skip animate-spin for reused connections
- SftpPaneFileList: skip loading overlay for reused connections
- SftpPaneTreeView: skip loading overlay for reused connections

Follow-up to #1254
2026-06-05 03:56:13 +00:00
陈大猫 8c207a1dff Merge pull request #1254 from binaricat/feat/sftp-reuse-terminal-connection
feat(sftp): reuse terminal SSH connection for side panel SFTP
2026-06-05 11:07:47 +08:00
陈奇 99e1974a69 fix: add activeSessionId to auto-connect effect deps
When activeSessionId arrives after activeHost (e.g. focus
update in workspace), the effect must re-run to pass the
session ID to connect() — otherwise SFTP falls back to a
fresh SSH connection.
2026-06-05 03:04:30 +00:00
陈奇 132bf288ac feat(sftp): reuse terminal SSH connection for side panel SFTP
When opening the SFTP side panel for a host that already has an
active terminal session, reuse the terminal's authenticated SSH
connection instead of creating a new one.

Changes:
- TerminalLayer: compute activeTerminalSessionIdForSftp, matching
  hostname/port/username against the active session
- TerminalLayerView: pass activeSessionId to SftpSidePanel
- SftpSidePanel: accept activeSessionId, pass to connect()
- useSftpConnections: pass sourceSessionId to bridge.openSftp()
- sftpBridge/openConnection: try to find and reuse terminal session's
  SSH connection via findReusableSession, fall back to fresh connection
- sftpBridge: wire up acquireConnectionRef/releaseConnectionRef for
  shared connection lifecycle

Only SSH (non-mosh/et/local) connected sessions are reused. Falls
back gracefully to a fresh connection on any reuse failure.
2026-06-05 03:01:23 +00:00
陈大猫 0a9f9848b7 Merge pull request #1252 from binaricat/fix/telnet-default-port-1251
fix(host-details): auto-switch default port when toggling primary protocol between SSH/Telnet
2026-06-05 10:22:13 +08:00
陈奇 11da55abf7 fix: respect group telnet port defaults during protocol switch
When a host is in a group with telnetPort configured, switching
protocol should not override the port to 23 — let the group default
take effect. Also handles undefined port during switch (fallback to
protocol default when no group defaults exist).
2026-06-05 02:15:50 +00:00
陈奇 e751c0f23e fix: preserve group-inherited Telnet port on save
When a host inherits its Telnet port from a group config
(groupDefaults.telnetPort), the save handler should leave
port as undefined rather than materialising 23.

Added hasGroupTelnetPortDefault parameter to
resolvePrimaryProtocolSavePort to preserve inheritance.
2026-06-05 02:12:30 +00:00
陈奇 f4b5beec01 fix(host-details): auto-switch default port when toggling primary protocol between SSH/Telnet
When a user toggles the primary protocol to Telnet, the port field
previously stayed at 22 (SSH default). Now it auto-switches:
- SSH → Telnet: port 22 → 23
- Telnet → SSH: port 23 → 22
Custom ports are preserved.

Also fixes the save handler to fall back to 23 for telnet when
no explicit port is set, matching the telnet protocol default.

Closes #1251
2026-06-05 02:09:09 +00:00
陈大猫 9e2b8093fb fix terminal split connection reuse (#1249) 2026-06-04 23:44:27 +08:00
陈大猫 7b2f66000c Support pasting local files into SFTP (#1248)
* feat(sftp): paste local clipboard files

* fix(sftp): handle native clipboard paste event

* fix(sftp): harden clipboard paste upload
2026-06-04 23:08:32 +08:00
陈大猫 6e7593dee2 improve codex live progress (#1247) 2026-06-04 22:14:10 +08:00
陈大猫 2098b2b09d fix copilot thinking stream (#1243) 2026-06-04 21:57:30 +08:00
lengyuqu aa1781577b 修复检查无 cli 的问题 2026-06-04 20:57:23 +08:00
lengyuqu 409d293faa 修复缺陷 2026-06-04 20:43:18 +08:00
陈大猫 8181fe71cf fix(ai): make SDK deps optional, degrade gracefully when missing (#1242)
* fix(ai): make SDK deps optional, degrade gracefully when missing

- Move @anthropic-ai/claude-agent-sdk and @github/copilot-sdk
  from dependencies to optionalDependencies so npm install does
  not fail when they are unavailable
- claudeDriver.listClaudeModels: catch import error, return [] silently
- copilotDriver.listCopilotModels: catch import error, return [] silently
- sdkStreamHandlers: downgrade log from console.error to console.debug

The renderer already falls back to curated model presets when
list-models returns [], so no functional change.

* fixup: honor queryFn before SDK import; regenerate lockfile with optional markers

* fixup: guard runTurn against missing SDK modules

runClaudeTurn and runCopilotTurn now catch dynamic import errors
and emit a user-friendly error message instead of crashing with
a raw module-not-found error.
2026-06-04 19:53:29 +08:00
陈大猫 d06009684e fix(ai): redact attachment payloads during context compaction (#1241) 2026-06-04 19:40:04 +08:00
陈大猫 55236ce34a fix(terminal): use composed CJK font stack (#1233) 2026-06-04 19:29:17 +08:00
陈大猫 b89f06b7f0 fix(terminal): prevent horizontal content drift (#1240) 2026-06-04 19:28:58 +08:00
陈大猫 a01d1f770f feat(sftp): add Open with system default for native Windows file association (fixes #1236) (#1239)
* feat(sftp): add Open with system default for native Windows file association (fixes #1236)

Adds a new 'Open with system default' option to the SFTP context menu
that uses Electron's shell.openPath() to invoke the native OS file opening
mechanism. On Windows, this uses ShellExecute, which works with UWP/WinUI
apps (Photos, Paint, etc.) whose executable paths change with updates.

The existing 'Open with...' (browse for executable) is preserved.

Closes #1236

* fix(sftp): add file watch support to openWithSystemDefault for SFTP auto-sync

P2: The new default-app open flow was not starting a file watch
when SFTP auto-sync was enabled, so edits saved in the system
default app were not uploaded back to the remote host.

Mirrors the downloadToTempAndOpen behavior — accepts an optional
{ enableWatch } parameter and starts a file watch when set.

* fix(sftp): mark transfer as failed when system default open fails

P2: When shell.openPath fails for a remote file, the transfer queue
still shows success because downloadToTemp had already completed the
temp download. Now updates externalTransferId to 'failed' before
throwing, matching downloadToTempAndOpen's behavior.
2026-06-04 19:25:29 +08:00
陈大猫 f1fdb61195 Merge pull request #1212 from lateautumn233/feat/et
Add full EternalTerminal (ET) protocol support alongside the existing SSH, Mosh, Telnet, and Serial protocols. ET automatically reconnects when the network drops or the user's IP changes, making it ideal for mobile and unreliable networks.
2026-06-04 18:55:18 +08:00
lengyuqu 39fea86f13 过滤一些工具文件 2026-06-04 18:51:00 +08:00
lengyuqu ce5d1d0e5a 文件遗漏 2026-06-04 18:49:07 +08:00
lengyuqu 7ac29366ae 修正 codebuddy认证服务 2026-06-04 18:48:34 +08:00
bincxz 9d0f6a9cea Merge origin/main into feat/et 2026-06-04 18:12:54 +08:00
bincxz e0403412e7 fix(et): harden session auth and dev binary fetch 2026-06-04 18:09:53 +08:00
陈大猫 bb67aa77f5 [codex] compact long Catty agent context (#1237)
* feat: compact long Catty agent context

* fix: tighten context compaction review issues

* fix: preserve tool context during compaction

* fix: clear stale model metadata on key edits
2026-06-04 17:56:39 +08:00
atoz03 e948a7a869 fix: route Cmd+W through existing tab close flow (#1234)
* fix: route Cmd+W through existing tab close flow

Keep the original tab-close behavior intact, and close the main or settings window only when there is no active closable tab to handle.

* fix: fall back to closing non-listener windows on Cmd+W

Treat BrowserWindow instances that do not participate in Netcatty's command-close bridge as regular closable windows. Keep the existing command-close path for the main and settings windows, and add tests that cover both the fallback close behavior and the renderer-capable send path.
2026-06-04 17:20:44 +08:00
陈大猫 3fc56df111 refactor(ai): migrate agent backends from ACP to official SDKs (claude/codex/copilot) (#1229) 2026-06-04 17:11:02 +08:00
lengyuqu 4860581525 Merge branch 'binaricat:main' into codebuddy 2026-06-04 16:17:35 +08:00
陈大猫andClaude Opus 4.8 008890a688 feat(terminal): 自定义本地 Shell 支持启动参数 (#1221) (#1225)
* feat(terminal): 支持为自定义本地 Shell 配置启动参数 (#1221)

自定义本地 Shell 此前只能填可执行文件路径,无法指定启动参数,
导致接入 msys2 bash 时缺少 `--login -i`,shell 不经 profile 初始化、
环境变量缺失而无法使用。

- TerminalSettings 新增 localShellArgs(string[],默认 []),随设置自动迁移
- 新增 domain/shellArgs:引号感知的命令行分词/回显格式化
- resolveShellSetting 透传自定义参数;参数为空时回退到 bridge 默认参数,
  命中已发现 shell(WSL/Git Bash)时忽略自定义参数,避免串味
- 自定义 Shell 弹窗新增「启动参数」输入,设置行展示完整命令(en/zh-CN/ru)

参数复用已有管线 session.localShellArgs → startLocalSession → pty.spawn,
不涉及云同步(与机器相关的 localShell 一致,均不同步)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(terminal): 修复自定义 Shell 参数的两处 review 问题

- 切换到默认/已发现 shell 时清空 localShellArgs:自定义参数仅对自定义
  路径有意义,清空可避免在发现列表尚未加载(启动竞态)或所选 shell ID
  在本机不可用时,旧参数被当作该 shell 的启动参数而泄漏导致启动失败
- formatShellArgs 对含双引号的参数改用单引号包裹,修复 `-c "..."`
  这类参数重新打开弹窗保存时被破坏的往返问题

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(terminal): shellArgs 量化处理含两种引号的参数往返

采用单引号优先的引号策略:单引号内全字面(Windows 路径、双引号原样保留,
无需转义);仅当 token 自身含单引号时改用双引号包裹并转义 \ 与 "。
解析端仅在双引号区内将 \" / \\ 视为转义,其余反斜杠保持字面,
确保未加引号的 Windows 路径不被破坏。修复 `echo "it's ok"` 这类同时含
单双引号的参数在弹窗重新保存时被破坏的问题。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(terminal): 自定义 Shell 已选中后可重新打开编辑参数

自定义 shell 选中时下拉框值已是 __custom__,再次点选「自定义…」不会触发
onValueChange,导致无法重新打开弹窗修改参数/路径。改为把自定义 shell 概要
做成可点击的编辑入口(铅笔图标),点击即重新填充草稿并打开弹窗;
打开逻辑抽到 openCustomShellModal 复用。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(terminal): shellArgs 改用 POSIX 单引号方案,修复尾部反斜杠与空参数

- 解析:两种引号区内均全字面(双引号不再把 \" 当转义),保留 Windows
  路径尾部反斜杠等手输入;引号外仅 \' 转义为字面单引号(支撑 '\'' 习语),
  其余反斜杠保持字面,未加引号的 Windows 路径不受影响
- 格式化:统一单引号包裹(内容全字面),内嵌单引号用 POSIX '\'' 习语;
  显式空参数输出为 '' 以免重新保存时被丢弃

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(terminal): customArgs take precedence over discovered shell defaults when user explicitly sets them

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 11:07:13 +08:00
陈大猫andClaude Opus 4.8 178f56455e fix(terminal): destructure sshDebugLogEnabled in TerminalPane (#1231)
TerminalPane forwards sshDebugLogEnabled to its child at render (L717) but
never destructured it from props, so rendering threw
"ReferenceError: sshDebugLogEnabled is not defined". The prop already exists
in TerminalPaneProps and the memo comparator; only the destructuring was
missing. tsc flags it (TS2304) but the build does not gate on tsc. Introduced
in 85f486e6.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 10:54:13 +08:00
陈大猫andClaude Opus 4.8 8376e35022 fix: macOS 自动更新装不上(进程退不掉) (#1224)
* fix(auto-update): commit app to quit before quitAndInstall on macOS (#1215)

macOS in-place auto-update downloaded and unpacked the new version but
never installed it: the app appeared to close, ShipIt never ran, and no
restart happened. A full uninstall + reinstall did not help; only a
manual DMG replace worked.

Root cause is a code-level coordination bug, not the release pipeline.
The published mac zips are correctly Developer-ID signed, notarized, and
stapled (Team H7WS5L2ML4, consistent across 1.1.17 and 1.1.20), and
latest-mac.yml is well-formed — so Squirrel.Mac signature validation
passes. The failure is that quitAndInstall() drives app.quit() while two
normal-quit behaviors keep the process alive:

  1. the main-window close handler hides to tray when close-to-tray is
     enabled (it only closes when isQuitting is true), and
  2. the before-quit dirty-editor guard preventDefault()s the quit for a
     5s renderer round-trip.

Either keeps the parent process running, so Squirrel.Mac's ShipIt helper
— which waits on the parent PID to die before swapping the bundle —
lands in launchd "pending spawn / on-demand-only" limbo and the service
is removed without installing. This matches the reporter's diagnosis
exactly ("ShipIt 没有真正启动安装器", launchd on-demand-only).

Fix: before quitAndInstall fires app.quit(), mark the app as quitting
for an update via windowManager.setQuittingForUpdate(true). That sets
isQuitting (bypassing close-to-tray) and the before-quit handler now
returns early when isQuittingForUpdate() is true (skipping the
dirty-editor round-trip), so the process exits cleanly and ShipIt can
run. The same fix also covers the latent Windows NSIS case where
close-to-tray would block an in-place update.

If the install never actually quits the app (quitAndInstall throws, or
returns without app.quit() on a Squirrel follow-up error / stale
download), the quitting-for-update flags are rolled back — synchronously
on throw, and via a short unref'd watchdog otherwise — so the app does
not get stuck permanently bypassing close-to-tray and the quit guard.

Tests: unit tests for the new windowManager flags and for the install
handler — ordering (setQuittingForUpdate before quitAndInstall), tray
cleanup still runs, no-op when the updater fails to load, rollback on
synchronous throw, and watchdog rollback when the app never quits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(auto-update): keep dirty-editor guard during update install

setQuittingForUpdate only bypasses close-to-tray (so the window actually
closes and Squirrel.Mac's ShipIt can swap the bundle); it must NOT skip the
unsaved-work guard, or clicking "Restart Now" with a dirty SFTP editor would
silently lose edits. If the user cancels to save, the quit aborts and
autoUpdateBridge's watchdog clears the quitting-for-update flags.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(auto-update): clear update-quit state when the quit is cancelled

When the user clicks "Restart Now" with a dirty editor open, the before-quit
guard cancels the quit (settle "stay"). The update path had already called
setQuittingForUpdate(true) (which flips isQuitting=true to bypass close-to-tray
for the install), so without clearing it the app stays in a quitting state —
close-to-tray and other !isQuitting-gated behavior bypassed — until the 10s
watchdog fires. Clear it immediately on the cancelled-quit path (#1215 review).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(auto-update): check for unsaved editors before quitAndInstall (#1215)

The previous fix bypassed close-to-tray so the app process actually exits and
Squirrel.Mac's ShipIt can swap the bundle, while keeping the before-quit
dirty-editor guard as the unsaved-work safety net. But on macOS that net has a
hole: quitAndInstall() closes the window FIRST and only then fires before-quit.
Once setQuittingForUpdate(true) lets the main window truly close (instead of
hiding to tray), the before-quit guard can run after the window is already gone
— isReachableByUser is false, so it commits the quit and silently drops unsaved
SFTP edits.

Fix: move the dirty-editor check to the moment the user clicks "Restart Now",
in the install handler, BEFORE setQuittingForUpdate / quitAndInstall — while the
window and renderer are still alive:

  - dirty   -> abort the install (don't set the quitting flags, don't
               quitAndInstall) and broadcast netcatty:update:needs-save so the
               renderer prompts the user to save and retry.
  - clean   -> proceed with the existing flow (commit-to-quit, tray cleanup,
               quitAndInstall, watchdog).
  - no reachable main window / crashed renderer -> install directly (no user to
               ask), matching the before-quit fail-open path.

The before-quit dirty guard is kept as defense-in-depth: if the window is still
reachable it re-checks (clean, since we just verified), and if it's already gone
it lets the quit through — which is now safe because the install handler already
confirmed there were no unsaved editors.

The request/reply/timeout round-trip is extracted into a shared helper,
electron/bridges/dirtyEditorGuard.cjs (queryDirtyEditors), so the install
handler and main.cjs's before-quit guard use one implementation. main.cjs's
before-quit is refactored onto it (behavior preserved: sender-filtered reply,
fail-open timeout, and the setQuittingForUpdate(false) rollback when the user
cancels to save).

The needs-save notice is BROADCAST to every window, not just the queried main
window: "Restart to Update" can be clicked from the Settings window, which would
otherwise see the click do nothing. preload exposes onUpdateNeedsSave; the
subscription lives in useUpdateCheck (state layer), and both consumers — App.tsx
(main window) and SettingsPage (settings window) — pass an onNeedsSave callback
that shows an actionable toast ("save your editors, then click Restart Now
again") in en / zh-CN / ru.

Also lengthen the quitting-for-update rollback watchdog from 10s to 60s. On
macOS quitAndInstall() can return while Squirrel is still pulling the downloaded
ZIP from the local update server before it closes the windows; on a large/slow
update that can exceed 10s. Clearing isQuitting that early would let the eventual
native quit hit a non-quitting close-to-tray handler and strand the install
again — the exact #1215 failure. The longer window only fires when the app is
realistically stuck, at the cost of close-to-tray staying bypassed a little
longer in the rare genuine-failure case.

Tests: queryDirtyEditors (result / no-dirty / timeout / wrong-sender / dead or
crashed webContents / send-throws / no-ipcMain paths); install handler pre-check
(dirty -> no quitAndInstall + needs-save broadcast to all windows; clean ->
quitAndInstall runs; no main window -> installs without asking). Existing
install-handler tests updated for the now-async handler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 00:05:09 +08:00
陈大猫andClaude Opus 4.8 0b8206aecb fix(terminal): encode input with the session charset (#1216) (#1222)
The terminal output path decodes remote bytes with an iconv decoder built
from the user's configured charset (GB18030, etc.), but the input path
serialized keystrokes as UTF-8 unconditionally. On a non-UTF-8 device that
made input and output asymmetric: with GB18030 selected the device's command
completion decoded correctly while manually typed Chinese went out as UTF-8
bytes and showed up garbled (and the reverse with UTF-8 selected).

Make input symmetric with output:

- Add electron/bridges/terminalEncoding.cjs as the single source of truth for
  charset normalization plus encodeTerminalInput(), which encodes a keystroke
  string with the same iconv charset (returning the string untouched for UTF-8
  and for unset/unknown encodings so the transport's native serialization and
  the Mosh/local-PTY paths are unchanged). ASCII control bytes and CSI escape
  sequences pass through byte-for-byte under GB18030.
- terminalBridge.writeToSession() now encodes outgoing data via
  encodeTerminalInput(session.encoding) before writing to the SSH stream,
  telnet socket, or serial port. Telnet IAC 0xFF escaping still runs on the
  encoded bytes.
- session.encoding (the input charset) is now kept in lock-step with the
  output decoder everywhere the decoder is configured:
    * Telnet/serial already stored session.encoding; writeToSession now reads
      it for input too.
    * SSH mirrors session.encoding wherever it sets sessionEncodings: the
      GB-variant pre-seed at session start and the runtime setEncoding handler.
      The pre-seed stays gated to GB variants to match the renderer's two-value
      encoding state, so behavior for other/arbitrary charsets is unchanged —
      the renderer still pushes the effective encoding via setEncoding on
      attach, and that handler keeps both halves in sync.
- The SSH startup command is encoded with the same charset as interactive
  input.

Mosh stays UTF-8 (mosh-client is UTF-8-only and sets LANG accordingly), and
local PTY stays UTF-8 — neither sets session.encoding, so their input is
untouched.

Adds unit tests for the encoding helper (GB18030/UTF-8 round-trips, ASCII
control preservation, symmetry with the output decoder) and integration tests
driving writeToSession over a raw TCP device to assert GB18030 bytes on the
wire and a UTF-8 regression guard.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:08:21 +08:00
陈大猫andClaude Opus 4.8 203505bc25 fix: persist GoogleDrive tokens after silent refresh (#1219)
GoogleDriveAdapter refreshed its access token only in memory: the rotated
tokens were never written back, so the next launch loaded a stale access
token and the user was forced to reconnect. This is the same defect #1208
fixed for OneDrive, which GoogleDriveAdapter never received because it did
not expose setOnTokensRefreshed — making the shared
attachTokenRefreshPersistence a no-op for Google.

Add setOnTokensRefreshed to GoogleDriveAdapter and route both refresh
points (setTokens / ensureValidToken) through a refreshTokens helper that
stores the rotated tokens in memory and notifies the persistence callback,
so attachTokenRefreshPersistence now encrypts and persists them.

Google's refresh response usually omits refresh_token (it does not rotate
on every refresh), so refreshTokens carries the previous refresh token
forward when the response lacks one — otherwise the persisted connection
would become unrefreshable.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:23:59 +08:00
陈大猫andClaude Opus 4.8 c5ac85ae5b fix: OneDrive 同步 access token 自动续期与失效重连提示 (#1189) (#1208)
* Auto-refresh OneDrive sync token and prompt reconnect on dead refresh token

OneDrive cloud sync (#1189) broke for users on 1.1.20 and only recovered
after disconnecting and re-authorizing. Two gaps caused this:

1. Refreshed tokens were never persisted. When OneDriveAdapter silently
   refreshed the access token mid-session, the rotated tokens lived only in
   the adapter's in-memory state — CloudSyncManager never read them back or
   saved them. Microsoft consumer refresh tokens rotate on every refresh and
   invalidate the previous one, so the next app launch loaded a stale,
   rotated-out refresh token. Eventually that stored token was dead and sync
   failed, forcing a manual reconnect.

   Fix: OneDriveAdapter now exposes setOnTokensRefreshed(); the manager wires
   it so every silent refresh writes the rotated tokens back into provider
   state and encrypted storage (attachTokenRefreshPersistence /
   persistRefreshedProviderTokens), keeping the stored refresh token current.

2. A genuinely dead refresh token surfaced as a raw, generic error with no
   guidance. The bridge now detects invalid_grant / interaction_required /
   consent_required / login_required on refresh and tags the error with a
   stable marker. OneDriveAdapter normalizes these to
   OneDriveReauthRequiredError; the marker survives IPC and error re-wrapping
   so the condition stays detectable, and the UI strips it to show a clean
   "OneDrive session expired, please reconnect." message.

Shared marker + detection/clean helpers live in domain/sync.ts so the bridge,
adapter, and UI use one source of truth. Scope is limited to OneDrive; other
providers (Gist/iCloud/Google/WebDAV/S3) are untouched.

Tests: OneDriveAdapter refresh-persistence + reauth detection, manager
token-persistence wiring, and bridge invalid_grant tagging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Drop OneDrive to a reconnect state when its refresh token is dead

codex review: detecting a dead refresh token was not enough. A provider in
`error` state that still holds tokens stays "ready for sync"
(isProviderReadyForSync), and syncAllProviders resets such providers back to
`connected` and retries — so auto-sync kept hammering the dead refresh token
and the user never got a stable reconnect prompt.

Now, when a sync/download error indicates OneDrive reauth is required, the
manager clears the stale tokens and tears down the cached adapter
(handleProviderReauthRequired), leaving the provider in an error state with no
credentials. With no tokens, isProviderReadyForSync returns false so auto-sync
stops retrying, and the card shows a clean "please reconnect" message with a
Connect button. The account is preserved for display; the error message is
stripped of the internal marker.

Wired into the syncToProvider / uploadToProvider / downloadFromProvider error
paths. Added tests for the clear-on-reauth behavior and provider/error scoping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Clear OneDrive reauth during inspection paths; include service tests in npm test

codex review round 2:

P2 — A dead OneDrive refresh token can also surface during startup remote
inspection and the syncAllProviders preflight conflict check, both of which run
through inspectProviderRemoteState. That path swallowed the error into an
{error} tuple without clearing the stale tokens, so the provider stayed
retryable. Wired the reauth handler into inspectProviderRemoteState's catch,
covering sync preflight, syncAll preflight, and startup inspection in one place.
Made the handler idempotent so the operation's own catch can also call it
without re-saving.

P3 — New tests live under infrastructure/services/, which npm test's globs did
not cover (the pre-existing syncAllStorageMethods.test.ts was also uncovered).
Added infrastructure/services/*.test.ts and infrastructure/services/*/*.test.ts
to the test script.

Full suite: 1400 tests, 0 failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: drop unmatched services test glob from npm test

The npm test script listed both infrastructure/services/*.test.ts and the
nested infrastructure/services/*/*.test.ts. No .test.ts files live directly
under infrastructure/services/ (the OneDrive adapter and cloudSync tests are
in adapters/ and cloudSync/ subdirs), so the flat glob never matched.

Under a default POSIX shell (sh/bash without nullglob), an unmatched glob is
passed through literally, so node --test received the raw pattern. On Node
versions without test-runner glob support this aborts with
"Could not find '.../infrastructure/services/*.test.ts'" before any test runs,
breaking npm test.

Remove the redundant flat glob; the nested glob already covers the new
OneDrive adapter and cloudSync tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 16:26:33 +08:00
陈大猫andClaude Opus 4.8 c6552ddc75 fix: Mosh 模式显示主机信息(stats companion SSH 连接) (#1198) (#1213)
* Show host info for Mosh sessions via a stats companion SSH connection

Mosh sessions run over UDP through a local mosh-client PTY and carry no
ssh2 connection (session.conn), so getServerStats could not open an exec
channel and the terminal's host-info bar (CPU/memory/disk/network) stayed
empty — unlike SSH sessions (issue #1198).

Add a best-effort, non-interactive companion SSH connection that is opened
lazily on the first stats poll for a Mosh session, reusing the credentials
the Mosh handshake already validated, and assign it to session.conn so the
existing stats path works unchanged:

- electron/bridges/sshBridge/moshStatsConnection.cjs: new helper that
  builds the companion connection. It never prompts (only stored password,
  parseable private key, unencrypted/stored-passphrase identity files, or
  ssh-agent), shares one in-flight attempt across concurrent polls, treats
  auth rejection as permanent but transient errors as retryable, and skips
  host-key verification like the existing one-off execCommand path.
- sessionOps.getServerStats: establish the companion connection for Mosh
  sessions that lack session.conn before running the stats command;
  degrades gracefully to the existing "not connected" error otherwise.
- moshSession.swapToMoshClient: stash the handshake credentials and
  algorithm settings on session.moshStatsAuth once the handshake succeeds.
- terminalBridge closeSession / cleanupAllSessions and the mosh-client exit
  handler: tear down the companion connection (it has no session.stream).
- Forward legacyAlgorithms / skipEcdsaHostKey / algorithmOverrides through
  the renderer's Mosh starter and the bridge type so the companion
  negotiates the same algorithms the interactive session would.

Tests cover the helper (auth selection, agent fallback, dedup, permanent vs
transient failure, late-ready discard), the getServerStats integration, and
the moshStatsAuth stash + companion teardown on close.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address codex review: target SSH host and settle on mid-handshake close

- moshSession: store options.hostname (the SSH endpoint) on moshStatsAuth
  instead of parsed.host. A `MOSH IP` line advertises the UDP endpoint for
  mosh-client, which can differ from the SSH host on NAT / multi-homed
  setups; the companion is an SSH connection and must target the SSH host.
- moshStatsConnection: resolve the pending attempt from the "close" handler
  when the socket drops mid-handshake without a prior "ready"/"error", so an
  awaiting getServerStats call (and session.moshStatsConnPromise) cannot
  hang indefinitely. Treated as transient so the next poll may retry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address codex review: keyboard-interactive password for stats companion

PAM-backed SSH servers often offer password auth only via
keyboard-interactive, not the plain "password" method. The Mosh handshake's
system ssh handles that through its PTY responder, so without it the
companion stats connection would fail auth on those hosts even with a saved
password, leaving the stats bar empty.

When a saved password is present, enable tryKeyboard and attach a
non-interactive keyboard-interactive handler that auto-fills the password
for a single password prompt (using the existing
isAutoFillablePasswordChallenge predicate) and finishes empty on
2FA/OTP/multi-prompt challenges. It auto-fills at most once so a wrong
password can't drive a retry loop, and it never shows a modal — the
companion stays fully non-interactive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address codex review: verify host key before sending a saved password

A background companion connection that auto-submits a saved password to an
unverified host could disclose it to a spoofed / MITM server (P1). Gate
password auth (plain and keyboard-interactive) behind a silent, trusted-only
host-key check against Netcatty's known-hosts store:

- moshStatsConnection: when the companion would authenticate with a
  password, attach an ssh2 hostVerifier that accepts only a key already
  "trusted" in known-hosts (via hostKeyVerifier.classifyHostKey) and rejects
  unknown/changed keys outright — no prompt, so the password is never sent to
  an unvetted host and no host-key dialog pops for a background poll.
  Public-key / agent auth proves possession via a signature and discloses no
  reusable secret, so it is not gated (matches the existing execCommand
  precedent; the handshake already vetted the host via system ssh).
- Thread knownHosts through the renderer Mosh starter, the bridge type, and
  session.moshStatsAuth.

Note: a password-auth Mosh host that Netcatty has never seen via its own SSH
path (so it is absent from Netcatty's known-hosts) will not get the stats
companion until its key is known — the safe default. Key/agent-auth hosts are
unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address codex review: transient pre-handshake polls and agent+password auth

Two functional gaps in the stats companion:

- Missing moshStatsAuth is now transient, not permanent. The renderer can
  mark a Mosh session "connected" (and start polling) from the SSH
  bootstrap's visible PTY output before the swap to mosh-client assigns
  moshStatsAuth. Previously that first poll set moshStatsConnFailed
  permanently, so the companion was never attempted after the handshake
  actually completed. Now it just returns null and a later poll retries.

- A saved password no longer suppresses ssh-agent auth. A public-key host
  that authenticates via the agent may still carry a stored password; the
  companion now offers the agent alongside the password (ssh2 tries agent
  first) instead of attempting password-only and failing permanently. An
  explicit private key still suppresses the agent fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address codex review: gate password at authHandler, not whole connection

The previous fix installed a trusted-only hostVerifier whenever a password
was present. Because ssh2 verifies the host before any auth method, that
rejected the entire connection on a host absent from Netcatty's known-hosts
— blocking key/agent auth too, even though those never need to send the
password.

Move the gate from the transport to the auth layer:

- A trust-tracking hostVerifier records whether the live host key is trusted
  (during the transport handshake) and then accepts the transport so
  public-key / agent auth can proceed on any host.
- A function-form authHandler offers none -> agent -> publickey always, and
  appends password + keyboard-interactive only when the host key is trusted.

Result: key/agent auth works on hosts Netcatty hasn't vetted, while a saved
password is still never sent to an untrusted host. Public-key / agent auth
remains ungated (no reusable secret is disclosed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address codex review: isolate Mosh stats connection from session.conn

Storing the stats companion on session.conn made it look like the session's
primary interactive SSH connection. Other bridges key off session.conn —
getSessionPwd assumes its exec channel is a sibling of the interactive shell,
and SFTP / MCP exec run over session.conn — but a Mosh session's shell lives
on the UDP mosh-client, not this background connection. After a stats poll
they could return a bogus cwd or operate over the wrong connection.

Keep the companion strictly on session.moshStatsConn:

- ensureMoshStatsConnection stores/reuses/clears only session.moshStatsConn.
- getServerStats reads session.conn || session.moshStatsConn (real SSH still
  uses conn; Mosh uses the companion) and only opens one when neither exists.
- closeSession / cleanupAllSessions / the mosh-client exit handler tear down
  session.moshStatsConn.

This leaves session.conn untouched for Mosh, so getSessionPwd / SFTP / MCP
exec behave exactly as before (no primary SSH connection for Mosh).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address codex review: don't count pre-handshake Mosh polls as failures

useServerStats gives up after 3 consecutive failures. A Mosh session can be
marked "connected" (and start polling) from the SSH bootstrap's visible
output before swapToMoshClient stores moshStatsAuth, during which
ensureMoshStatsConnection returns null. Previously getServerStats reported
that as a normal failure, so a handshake taking ~15s (3 polls) would
permanently disable stats for the session even after credentials became
available.

Introduce a `pending` result:

- getServerStats returns { success: false, pending: true } for a Mosh session
  that has no connection yet and no moshStatsAuth and hasn't permanently
  failed. Once moshStatsAuth is set (or the companion permanently fails), it
  reports a normal failure again.
- useServerStats treats `pending` as neutral: it does not update stats and
  does not increment the consecutive-failure counter, so polling continues
  until the handshake completes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address codex review: verify host key for all Mosh stats companion auth

The companion installed a trust-tracking host verifier only when a saved
password was present; key/agent-only connections fell back to ssh2's
default of accepting any host key. A background, user-invisible connection
that authenticated against an unverified host could let a MITM/DNS-spoofed
host feed bogus host-info to the user and enumerate the ssh-agent's public
keys — breaking the host-key guarantee the interactive session enforces.

Attach the host verifier for every auth method and reject an unknown or
changed host key outright (never prompting; stats just stay empty). Treat
an untrusted host as a permanent failure so polling stops reconnecting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(mosh): trust system known_hosts for the stats companion host-key check

The Mosh stats companion opens a background ssh2 connection and only rides
on a host whose live key is already trusted, rejecting unknown/changed keys
as a permanent failure. Trust was sourced solely from Netcatty's in-app
known-hosts snapshot (options.knownHosts).

But a Mosh session is bootstrapped by the system `ssh`, which vets and
records the host key in the user's OpenSSH known_hosts (~/.ssh/known_hosts,
etc). Netcatty's snapshot is never updated by that handshake, so a host
trusted purely via system ssh was misread as "unknown" and the companion
permanently disabled — Mosh stats never appeared unless the user manually
scanned/imported the host into Netcatty (codex P2).

Add a system-known_hosts trust source (systemKnownHosts.cjs) and consult it
in the companion verifier when the in-app snapshot does not already vouch
for the key. Matching is by the LIVE key's SHA-256 fingerprint, so trust is
granted only for the exact key the user's own OpenSSH already trusts; an
arbitrary or mismatched key is never accepted. Unknown/changed keys stay
rejected and remain a permanent failure.

The parser handles the OpenSSH known_hosts(5) format that the in-app scan
parser does not fully cover for matching: plain hosts, comma lists,
[host]:port, hashed |1|salt|HMAC-SHA1(salt,token) entries (with the
bracketed token for non-default ports, verified against ssh-keygen -H),
multiple key types, @revoked (forces NOT trusted) and @cert-authority
(skipped). Wildcard/negation patterns are deliberately not honored. Paths
mirror localFsBridge.readKnownHosts and are cross-platform (incl. Windows
%PROGRAMDATA%\ssh\known_hosts). All errors fail closed.

ssh2 ships no known_hosts parser, so this is implemented in CommonJS using
node:crypto and covered by unit tests (real ssh-keygen hashed fixtures,
revoked/cert-authority, fingerprint mismatch, fail-closed) plus companion
integration tests (system-only trust accepts; neither source trusts ->
rejected + permanent; key rotation not rescued; optional-dependency safety).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 16:26:27 +08:00
bincxzandClaude Opus 4.8 7972b19bdd fix(et): forward jump-host reference key path and custom ET port
From the local codex review of the ET jump-host changes:

- A jump host authenticated by a saved reference key had its on-disk key path
  dropped: privateKey is undefined for reference keys and only
  jumpHost.identityFilePaths was forwarded, so ET jump auth fell back to
  defaults despite a valid key being selected. Mirror startSSH and forward the
  reference key's filePath as an IdentityFile (with the same password-method
  and keyId fallback guards).
- A jump host listening on a non-default ET server port was always contacted
  on 2022: the bridge reads jump.etPort (--jport) but the renderer never sent
  it. Forward jumpHost.etPort and add etPort to NetcattyJumpHost.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:25:08 +08:00
bincxzandClaude Opus 4.8 33918a2433 fix(et): show the ET server port in the connection dialog
Copilot review: for an ET host the connection dialog displayed host.port
(the SSH port, e.g. 22), but ET connectivity hinges on the etserver port
(host.etPort, default 2022). Showing 22 is misleading when a connection is
actually stuck on the ET port. Display etPort (falling back to 2022) for ET
hosts instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:17:37 +08:00
bincxzandClaude Opus 4.8 9e7f6d98fd fix(et): fail loudly on missing/over-long jump chain in startEt
Copilot review: startEt only checked resolvedChainHosts.length, so a
configured jump chain that failed to resolve (missing/invalid host ID) would
silently fall back to a direct connection — possibly to the wrong target —
unlike startSSH, which explicitly rejects missing chain host IDs.

Add the same getMissingChainHostIds check startSSH uses, erroring early with
a clear message when a configured jump host cannot be resolved. Also base the
"at most one jump host" limit on the configured chain (host.hostChain.hostIds)
rather than only the resolved list, so a second hop whose ID fails to resolve
cannot slip past the check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:17:32 +08:00
bincxzandClaude Opus 4.8 ceda20510f fix(et): run Unix askpass via Electron exec and route jumps through ET --jumphost
Addresses two correctness issues from the codex review of the ET protocol
support.

codex P1 #1 (Unix askpass helper): on macOS/Linux the SSH_ASKPASS helper
was the askpass .cjs itself, relying on its `#!/usr/bin/env node` shebang.
Packaged Electron builds put no `node` on the user's PATH, so ssh could not
run the helper and ET could not supply the saved password / key passphrase,
failing the connection outright. Mirror the existing Windows .cmd wrapper:
write a small /bin/sh wrapper that execs the helper through process.execPath
with ELECTRON_RUN_AS_NODE=1 (process.execPath is POSIX single-quoted to
survive spaces in an .app path).

codex P1 #2 (jump host routing): a jumped ET host only got an ssh
ProxyCommand, which fixes the SSH bootstrap but leaves ET opening its TCP
socket straight at the destination etserver — which fails whenever the
destination ET port is not directly reachable. Use ET's own
--jumphost/--jport so ET connects its socket to the jumphost's etserver and
reaches the destination over the SSH tunnel ET sets up (`ssh -J`). Per-hop
jump credentials move from the ProxyCommand into a `Host <jumphost>` ssh_config
block (OpenSSH applies command-line -o only to the final hop, so jump settings
must come from config); the destination's comma/space options are scoped under
a `Host <dest>` block with a ProxyJump so the standalone ssh used for distro
detection tunnels through the jump too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:17:26 +08:00
lateautumn233 82f3250b5b Bundle ET client binaries in package build workflow
Mirror the existing mosh bundling pipeline so that release and tag
builds automatically fetch and package the EternalTerminal `et`
client alongside mosh-client.
2026-06-03 12:35:50 +08:00
陈大猫 c37e087332 Merge pull request #1209 from binaricat/feat/duplicate-tab-reuse-session-1204 2026-06-03 11:26:31 +08:00
bincxzandClaude Opus 4.8 f282c58edc fix(ssh): validate reuse target and handle synchronous shell failures
Two more review findings on the connection-reuse path:

- Verify the source connection's endpoint matches the duplicate's
  requested hostname/port/username before reusing it. A saved host edited
  after the source tab connected would otherwise let the copy silently
  open on the old connection and run commands on the wrong machine.
  findReusableSession now takes the requested target and requires an exact
  endpoint match; the session records its actual SSH endpoint at connect.

- Wrap conn.shell() in the reuse path with try/catch. ssh2 can throw
  synchronously (e.g. "Not connected") if the borrowed transport dropped
  between findReusableSession and the shell request; without this the
  up-front connection ref hold would leak. On a synchronous throw we now
  drop the error listener, release the ref, and fall back to a fresh
  connection.

Adds tests for endpoint mismatch and synchronous shell failure.

Refs #1204

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:07:58 +08:00
bincxzandClaude Opus 4.8 45e208f1d8 fix(ssh): harden connection-reuse lifecycle against teardown races
Two reference-counting races found in review:

- Reuse path read the source's connRef only inside the async conn.shell()
  callback. If the source tab closed while the shell was opening,
  releaseConnectionRef could drop the count to zero and end the shared
  connection out from under the opening channel. Now pin the connection
  (acquireConnectionRef) before issuing the async shell request and hand
  the hold over to the real session once the channel opens; release it on
  any failure so fallback to a fresh connection doesn't leak the count.

- closeSession read session.connRef *after* stream.close(), but closing the
  channel can synchronously fire the stream "close" handler that nulls
  connRef and releases the connection — so the post-close check fell into
  the legacy path and ended the shared connection a second time. Snapshot
  the multiplexing flag before closing the channel.

Adds a regression test covering "source closed while the copied shell is
still opening".

Refs #1204

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:59:49 +08:00
bincxzandClaude Opus 4.8 132c597d1e fix(ssh): scope session-log stop to the owning connection
Reading _logStreamToken back off the session map in the connection-level
close/error/timeout handlers could let a late close from an old transport
stop a newer same-sessionId log stream after a reconnect, regressing the
token guard from #916. Capture the owner channel's log stream token in the
connection closure and pass it to stopStream, matching the original
closure-scoped behavior.

Refs #1204

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:51:09 +08:00
bincxzandClaude Opus 4.8 85f486e6cd feat(ssh): reuse authenticated connection for Copy Tab to skip re-MFA
Duplicating an SSH tab ("Copy Tab") used to open a brand-new SSH
connection, forcing a second MFA prompt on hosts with multi-factor auth.
Like Tabby's session multiplexing, open a new shell *channel* on the
source tab's already-authenticated connection instead, so the duplicate
reuses the existing transport and skips key exchange + authentication.

- copySession records the source session id (reuseConnectionFromSessionId)
  for connected, non-mosh SSH sessions; it is threaded down to the SSH
  bridge as options.sourceSessionId.
- startSession.cjs gains a reuse path that opens conn.shell() on the
  source connection. The shell wiring is extracted into a shared
  setupShellSession helper used by both the fresh and reuse paths.
- Connection lifecycle is reference-counted via a new sshConnectionPool
  module (mirrors Tabby ref/unref/destroy): the shared transport + jump
  host chain are torn down only when the last channel closes, so closing
  a copy — or the original while a copy is open — never kills siblings.
  terminalBridge.closeSession routes SSH teardown through the same release.
- Reuse falls back to a fresh connection when the source is gone, and is
  skipped for X11 hosts (X11 is negotiated per channel).

Tests: sshConnectionPool refcount unit tests, bridge reuse integration
tests (reuse vs fresh, sibling survival, X11 skip, fallback), and
terminalBridge close lifecycle tests.

Refs #1204

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:43:49 +08:00
陈大猫 2c96773679 Merge pull request #1207 from binaricat/fix/autocomplete-popup-overflow-1202
fix(autocomplete): 补全弹窗边界翻转 + 配色跟随强调色 (#1202)
2026-06-03 10:41:16 +08:00
bincxzandClaude Opus 4.8 a8f9fd7a56 fix(autocomplete): make popup panels border-box for exact width clamp
Codex P2: the panel maxWidth constants used by the horizontal clamp's
totalWidth excluded each panel's padding + border (inline styles default to
content-box), so a padded detail tooltip at its 280px max could still render
wider than reserved and spill off-screen.

Set box-sizing: border-box on the shared panel style so every maxWidth is the
true outer width and totalWidth is exact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:34:54 +08:00
陈大猫 aae4ad4da8 Merge pull request #1206 from binaricat/fix/topbar-ai-toggle-1194
顶部 AI 按钮支持 toggle 收起,移除无用的提醒按钮
2026-06-03 10:32:22 +08:00
bincxzandClaude Opus 4.8 014d7b4d39 fix(autocomplete): reserve popup detail space per-set to avoid hover jump
Codex P2: totalWidth and the vertical height reservation depended on the
hovered/selected item's detail panel, so moving the mouse between a
no-detail row and a detail row could change the clamped position and shift
the popup under the pointer (flicker, unreliable clicks near the edge).

Reserve detail-panel width/height from a set-level flag (any non-path row
with a description) so placement stays stable while hovering; the tooltip
itself still renders only for the active row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:32:13 +08:00
陈大猫 5912339813 Merge pull request #1205 from binaricat/feat/alinux-distro-icon-1200
feat(distro): 新增 Alibaba Cloud Linux 发行版图标
2026-06-03 10:29:13 +08:00
bincxzandClaude Opus 4.8 20bfa0e3bd fix(autocomplete): keep completion popup inside the window and follow accent
The terminal completion popup could spill past the window edges and ignored
the theme accent color (#1202):

- Horizontal: the left-edge clamp only reserved the main list width (400px),
  so expanding a directory near the right edge pushed the cascading sub-dir
  panels and the detail tooltip off-screen. Now the clamp accounts for the
  full assembly width (main list + sub-dir panels + detail tooltip) and pins
  to the left padding when it is wider than the viewport.
- Vertical: extracted the flip/clamp math into a pure, unit-tested
  computeAutocompletePopupPlacement() so "not enough room below -> flip up
  and bound the height" is verifiable. The detail tooltip is now height-
  bounded and scrolls instead of overflowing for long snippet descriptions.
- Accent: the selected/hover row background and the active-row accent rail now
  derive from the active terminal theme's cursor/selection colors (which track
  the user's accent setting) instead of a hardcoded blue.

Adds terminalAutocompleteLayout.test.ts covering downward/upward placement,
bottom-of-viewport flip, height clamping, and horizontal clamping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:28:45 +08:00
bincxzandClaude Opus 4.8 41ebe0fa64 Make top-bar AI button toggle the side panel and drop reminder button
The top-bar AI button previously dispatched netcatty:toggle-ai-panel,
which was wired to handleOpenAI -> handleSwitchSidePanelTab('ai'). That
switch is a no-op when AI is already the open sub-panel, so the panel
could not be dismissed by clicking the button again.

Add a dedicated handleToggleAiFromTopBar that routes through a new
resolveAiSidePanelToggleIntent helper: a second click on an already-open
AI panel closes the side panel, while a click from a closed panel or a
different sub-panel switches to AI. The top-bar event listener now uses
this toggle handler. handleOpenAI stays a plain switch so the AI icon in
the side-panel rail remains idempotent like the other rail tabs.

Also remove the non-functional reminder (bell) button from the top bar;
it had no click handler and no backing feature.

Refs #1194

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:27:50 +08:00
bincxzandClaude Opus 4.8 66cf610cc0 feat(distro): add Alibaba Cloud Linux (alinux) icon and detection
Alibaba Cloud Linux hosts (os-release ID="alinux") previously showed the
generic Linux icon because normalizeDistroId fell through to the catch-all
`linux` branch ('alinux'.includes('linux') is true).

- Add a dedicated `alinux` branch in normalizeDistroId, matching the
  os-release ID, the legacy `aliyun` ID, and the NAME/PRETTY_NAME text
  "Alibaba Cloud Linux"; place it before the generic linux fallback.
- Register `alinux` in LINUX_DISTRO_OPTIONS so it is selectable in the
  manual distro override and classified as linux-like.
- Add the brand SVG (public/distro/alinux.svg) plus logo/color mappings
  in DistroAvatar (brand color #FF6A00).
- Add the localized label in en / ru / zh-CN.
- Cover normalizeDistroId's alinux cases (ID, legacy aliyun, PRETTY_NAME)
  with unit tests, including a regression guard against the generic linux
  fallback.

Icon source: simple-icons "Alibaba Cloud" mark (CC0-1.0, public domain),
matching the existing distro icon set already sourced from simple-icons.

Closes #1200

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:24:44 +08:00
lateautumn233 62ec391523 Add ET integration checklist
Document the implementation status of EternalTerminal integration
across all layers (domain, electron, application, components, i18n)
with testing notes and known limitations.
2026-06-03 01:55:06 +08:00
lateautumn233 7927da2085 Build ET UI: host settings panel, protocol picker, and session starters
Add ET configuration section in HostDetailsAdvancedSections (etPort,
etTerminalPath). Add ET option to ProtocolSelectDialog. Wire ET session
creation in createTerminalSessionStarters with proxy and multi-jump
validation. Add ET badges and labels in Terminal, TerminalToolbar,
TerminalConnectionDialog, and TerminalLayerSupport. Propagate ET
settings through GroupDetailsPanel, GroupSshSettingsSection, and
VaultView.
2026-06-03 01:45:29 +08:00
lateautumn233 d45dea4bff Add ET localization strings for en and zh-CN
Terminal: protocol label, proxy-unsupported warning, multi-jump
limitation notice. Vault: ET settings section title, etPort and
etTerminalPath field labels with descriptions.
2026-06-03 01:45:28 +08:00
lateautumn233 816e274dfc Route ET protocol through application session state and backend
Resolve 'et' protocol in tray panel and host connection handlers with
priority over mosh. Propagate etEnabled through session factories and
useSessionState. Expose etAvailable guard and startEtSession wrapper
in useTerminalBackend.
2026-06-03 01:45:28 +08:00
lateautumn233 1a20a6a4a8 Expose startEtSession IPC channel via preload and bridge types
Add startEtSession to the preload API surface, routing through the
netcatty:et:start IPC channel. Define the startEtSession options type
in netcatty-bridge-session.d.ts with ET-specific parameters (etPort,
terminalPath, jumpHosts, etc.).
2026-06-03 01:45:28 +08:00
lateautumn233 910049b0ea Implement etSession process manager and bundled ET client resolver
Add etSession.cjs: full ET session lifecycle including SSH bootstrap with
host-key verification, etclient PTY spawning, temp directory management,
and external auth artifact cleanup. Wire the session API into
terminalBridge.cjs with IPC handler registration. Add bundledEtClient
resolver that locates the platform-specific et binary in both packaged
and dev environments. Include unit tests for both etSession and
bundledEtClient.
2026-06-03 01:45:28 +08:00
lateautumn233 173a83aafa Extend HostProtocol union and host models with ET fields
Add 'et' to HostProtocol union type. Add etEnabled, etPort, and
etTerminalPath fields to Host and GroupConfig interfaces. Update
vaultImport type guards to exclude 'et' from importable protocols.
Register ET fields in groupConfig inheritable keys.
2026-06-03 01:45:28 +08:00
陈大猫 b7093f88b1 Merge pull request #1203 from pyroch/main 2026-06-03 00:54:39 +08:00
pyroch 2e66bcf254 fix: hover feedback for window controls 2026-06-02 19:40:29 +03:00
lateautumn233 95208294b0 Bundle the prebuilt et client at pack time
Download and package the et binaries produced by build-et-binaries:

- resolve-et-bin-release picks the latest et-bin-* release; fetch-et-binaries
  downloads the platform client into resources/et/, verifying SHA256SUMS.
- et-extra-resources emits the electron-builder extraResources entry only
  when the binary is on disk, so pack still works without a bundled et.
- electron-builder.config.cjs wires et into the mac/win/linux bundles;
  package.json adds the fetch:et scripts.
2026-06-02 20:35:07 +08:00
lateautumn233 a4bf2234cd Add build-et-binaries workflow to compile the et client
Build the EternalTerminal `et` client in CI the way mosh-client is:

- build-et-binaries.yml builds et for linux x64/arm64, macOS universal
  and windows x64, uploads artifacts, and optionally publishes them to a
  dedicated binary repository on manual workflow_dispatch.
- scripts/build-et/{linux,macos,windows} compile et from upstream.
- .gitignore excludes the fetched binaries; resources/et/README.md
  documents source provenance.
2026-06-02 20:35:07 +08:00
陈大猫 e527e7233f Merge pull request #1197 from binaricat/codex/ssh-debug-logs
Add SSH debug logging setting
2026-06-02 12:05:00 +08:00
bincxz afe959835d Add SSH debug log setting 2026-06-02 12:01:40 +08:00
陈大猫 3b2b05064b Merge pull request #1195 from binaricat/codex/fix-terminal-wheel-font-zoom
[codex] Fix terminal wheel font zoom capture
2026-06-02 10:49:52 +08:00
bincxz 1e94fe983f Fix terminal wheel font zoom capture 2026-06-02 10:46:37 +08:00
陈大猫 274ac4e0e1 Merge pull request #1192 from binaricat/codex/fix-catty-tool-result-repair
[codex] Fix Catty tool result history repair
2026-06-02 10:30:29 +08:00
bincxz 1ad4443e3b Fix Catty tool result history repair 2026-06-02 10:15:38 +08:00
陈大猫 031bf0ee45 Merge pull request #1188 from binaricat/codex/vault-sidebar-spacing
[codex] Balance vault sidebar spacing
2026-06-02 01:29:14 +08:00
bincxz 0efe80b06d Balance vault sidebar spacing 2026-06-02 01:28:39 +08:00
陈大猫 3fb7c6dd21 Merge pull request #1183 from pplulee/feature/codesnip-edit
feat: 优化代码片段脚本编辑
2026-06-02 01:17:36 +08:00
bincxz c7e4ac82ca Fix snippet editor focus outline 2026-06-02 01:15:36 +08:00
bincxz d5e29598d3 Merge main into PR 1183 and address review 2026-06-02 01:07:04 +08:00
陈大猫 fca7782634 Remove vault content left margin (#1187) 2026-06-02 01:00:19 +08:00
Pyro 42b23a9faa fix: blurry inline aside panel rendering (#1185) 2026-06-02 00:40:17 +08:00
Pyro 06011d01d6 fix: update RU localization (#1184) 2026-06-01 23:31:03 +08:00
pplulee 4bf4e65df8 fix(keychain): enhance key management UI with copy, edit, and delete actions (#1182) 2026-06-01 23:13:31 +08:00
pplulee 45e62ed43e feat(snippets): add SnippetScriptEditor component and update usage in dialogs 2026-06-01 23:05:56 +08:00
lengyuqu d9156349e1 Merge branch 'binaricat:main' into codebuddy 2026-06-01 21:41:08 +08:00
lengyuqu 983b0b2f1d 修复 CodeBuddy 未检测时无法预配置 API Key 的问题
当 discovered_codebuddy 条目不存在时,updateCodebuddyEnv 现在会创建
一个 disabled 的托管条目,允许用户在安装 CLI 前预配置认证信息。
2026-06-01 21:28:44 +08:00
陈大猫 368c31e48d Remove default host seed data
Remove seeded example hosts so new installs start with an empty vault.
2026-06-01 21:09:04 +08:00
lengyuqu a552c14cbd Delete .qoder directory 2026-06-01 21:06:26 +08:00
lengyuqu 3f5787ceb1 Merge branch 'binaricat:main' into codebuddy 2026-06-01 21:05:51 +08:00
lengyuqu e4ec2363d0 md 2026-06-01 21:03:53 +08:00
陈大猫 0fa926de26 Polish vault UI (#1177) 2026-06-01 21:03:43 +08:00
lengyuqu 84b71910ee 修复 CodeBuddy CLI 集成缺陷,对齐 Claude/Codex 标准
- 修复 ALLOWED_AGENT_COMMANDS 缺少 codebuddy(非 ACP 回退路径)
- 添加 CODEBUDDY_API_KEY 预检(list-models + stream handler)
- 添加 codebuddyAuthPresence 认证状态检测
- 错误时强制清理 provider 进程(避免僵尸进程)
- 认证失败时显示友好错误提示(引导用户配置 API Key)
2026-06-01 20:55:35 +08:00
lengyuqu 371217832b 移除 CodeBuddy auto 模型预设,新增 tool call 分组折叠
- 移除 CODEBUDDY_MODEL_PRESETS 常量及 getAgentModelPresets 中的 codebuddy 分支(auto 模型实际不可用)
- 新增 ToolCallGroup 组件,支持 Codex 风格可折叠 tool call 分组
- ChatMessageList 将连续 tool 消息、底部 pending calls、内嵌未解析 calls 分组展示
- 流式输出时自动展开,完成后自动折叠,支持手动切换
2026-06-01 20:22:08 +08:00
陈大猫 b9b7db2a4e fix kimi tool stream parsing (#1176) 2026-06-01 19:42:42 +08:00
陈大猫 e3f68e1a3f [codex] fix cloud sync master key refresh (#1175)
* fix cloud sync master key refresh

* Protect first sync from default settings

* Avoid recovery prompt for settings-only sync

* Cancel stale syncs after master key changes
2026-06-01 19:29:53 +08:00
陈大猫 3c4746aea0 [codex] add sync reliability metadata (#1174)
* feat: add sync reliability metadata

* fix: preserve tombstones from checked remotes

* fix: keep conflict change counts typed
2026-06-01 18:40:19 +08:00
lengyuqu afb514b472 md 2026-06-01 18:24:41 +08:00
lengyuqu e14dc22bba 修正 bug 2026-06-01 18:14:48 +08:00
陈大猫 463dd4464f [codex] add cloud sync strategies (#1171)
* feat: add cloud sync strategies

* chore: clarify cloud sync strategy options

* chore: keep selected sync strategy concise

* fix: sync cloud-wins payload to remaining providers

* fix: apply cloud sync strategy during startup

* fix: retry partial startup cloud syncs
2026-06-01 17:07:07 +08:00
lengyuqu 6b7c12c23c Merge branch 'binaricat:main' into codebuddy 2026-06-01 16:40:44 +08:00
陈大猫 63b95bb68e [codex] add terminal font size shortcuts 2026-06-01 15:25:31 +08:00
陈大猫 ea41389842 Fix Ruijie network device detection (#1168) 2026-06-01 13:52:22 +08:00
Jerry 429cb8d6e9 fix: keep macOS Dock icon consistent with bundle icon (#1166) 2026-06-01 13:05:07 +08:00
陈大猫andClaude Opus 4.8 55faae767a feat(vault): show/hide toggle for the host Telnet password (#1164)
* feat(vault): show/hide toggle for the host Telnet password

Issue #1099 (part 2): the host-level Telnet password field was a bare type="password" with no way to reveal it. Add an eye toggle matching the SSH password field and the group-level Telnet password.

Refs #1099

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(vault): reset Telnet password reveal when the edited host changes

HostDetailsPanel is reused across hosts without remounting, so the new showTelnetPassword state has to be cleared in the initialData effect alongside showPassword — otherwise revealing one host's Telnet password leaves the next host's shown unmasked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 12:56:51 +08:00
陈大猫andClaude Opus 4.8 94b8f298ae feat(vault): connect with the host's default protocol, prompt only for Telnet (#1163)
Issue #1099: a Telnet host shows the protocol picker on every connect. Connect resolution is now explicit, and the picker only appears when it's genuinely a choice:

- Telnet set as default (protocol = telnet) -> connect Telnet (single-click and batch)
- Telnet enabled but not the default -> show the picker (unchanged)
- Mosh enabled -> connect Mosh
- otherwise -> connect SSH

A "Connect with Telnet by default" switch in the Telnet card sets the host's primary protocol. SSH+Mosh hosts now connect Mosh directly instead of prompting every time. Addresses part 1 of #1099.

Refs #1099

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 12:38:14 +08:00
陈大猫andClaude Opus 4.8 1ef3f9f979 feat(vault): batch-connect selected hosts (#1162)
The multi-select toolbar already supported batch delete; add a "Connect" button next to it so connecting many hosts no longer has to be done one by one. Connects each selected host in list order with its configured protocol (multi-protocol hosts use their default rather than prompting per host).

Closes #870

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 11:16:01 +08:00
陈大猫andClaude Opus 4.8 e88313eb84 fix(sftp): confirm delete/overwrite dialogs on Enter key (#1160)
The delete and overwrite confirmation dialogs are opened from a context menu, whose focus-return could leave focus outside the dialog, so pressing Enter did nothing. Focus the confirm button when each dialog opens via onOpenAutoFocus so Enter activates it. Esc and Tab+Enter behavior is unchanged.

Fixes #1150

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 10:21:55 +08:00
pplulee 03cd9bc968 feat(snippets): implement snippet variable handling and UI prompts (#1159) 2026-05-31 18:01:43 +08:00
pplulee 4d7c56e537 主机备注功能与脚本编辑体验优化 (#1158) 2026-05-31 15:39:26 +08:00
Jerry 4769668ff9 docs: add CLAUDE.md and untrack from .gitignore (#1149) 2026-05-29 16:23:59 +08:00
陈大猫andClaude Opus 4.8 8ca36a695b fix(ssh): repair mangled PEM private keys before parsing (#1147)
* fix(ssh): repair mangled PEM private keys before parsing

A valid PEM key whose framing was damaged in transit — newlines
collapsed to spaces, turned into literal "\n", or lines indented —
fails ssh2's parser with "Unsupported key format" even though the key
material is intact. This commonly happens when a key is copy/pasted
through a field or app that strips line breaks. (follow-up to #1139)

When parsing fails, rebuild clean PEM framing from the BEGIN/END markers
(which survive newline loss) and the base64 body, then retry through the
existing parse and PKCS#8 conversion paths. The body is preserved
byte-for-byte and a repaired key is only used if it re-validates, so
this can never produce a different or invalid key. Encrypted legacy PEM
(Proc-Type/DEK-Info) and truncated keys are left untouched.

Refs #1139

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ssh): detect encryption on mangled OpenSSH keys

A mangled encrypted OpenSSH key (line breaks flattened to literal "\n")
was not recognized as encrypted: the literal escapes corrupt the base64
decode used to read the cipher name, so isKeyEncrypted() returned false
and preparePrivateKeyForAuth routed the key to the unencrypted branch
with no passphrase prompt — and the repaired candidate was discarded
because it can't parse without one.

Repair the PEM framing before reading the OpenSSH cipher name, so such
keys are detected as encrypted and reach the passphrase prompt, where
normalizePrivateKeyForSsh2(key, passphrase) already repairs and
validates them. Addresses Codex review feedback on #1147.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 15:45:56 +08:00
lengyuqu 222b3869dd 修复 codebuddy 的 acp 路径问题 2026-05-29 15:35:19 +08:00
lengyuqu 56af2d3840 Merge branch 'binaricat:main' into codebuddy 2026-05-29 15:32:07 +08:00
陈大猫andClaude Opus 4.8 053a976d37 fix(ssh): support PKCS#8 private keys by converting for ssh2 (#1146)
ssh2's key parser only accepts OpenSSH, legacy PKCS#1/SEC1 and PuTTY
keys, so PKCS#8 keys (-----BEGIN PRIVATE KEY----- / BEGIN ENCRYPTED
PRIVATE KEY) fail with "Cannot parse privateKey: Unsupported key
format" even though they are valid and work in other clients such as
Termius. (#1139)

Add privateKeyNormalizer: when ssh2 can't parse a key but it is PKCS#8,
read it with Node's crypto and re-export RSA->PKCS#1 / EC->SEC1, the
legacy PEM forms ssh2 understands. Encrypted PKCS#8 is decrypted with
the passphrase first. Ed25519 (and other) PKCS#8 keys, which have no
legacy PEM form, now surface a clear "convert with ssh-keygen" message
instead of ssh2's opaque error.

Wired into sshAuthHelper's preparePrivateKeyForAuth and
loadIdentityFileForAuth, so SSH, SFTP and port-forwarding all benefit.
Also fixes a latent bug where a correct passphrase on an encrypted
PKCS#8 key was rejected and re-prompted indefinitely.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 15:09:52 +08:00
lengyuqu 1695470089 修复 codebuddy cli 2026-05-28 23:50:51 +08:00
lengyuqu d4b5f799cb 添加 codebuddy cli 2026-05-28 23:50:15 +08:00
陈大猫 40fb5b62a9 Fix storage change render warning (#1138) 2026-05-28 15:40:04 +08:00
陈大猫 1fec5925eb Refactor large modules and fix runtime errors (#1136) 2026-05-28 15:12:19 +08:00
陈大猫andClaude Opus 4.7 23d4b342b9 fix(vault): flat Vaults tab selection + fill host grid beside side panel (#1134)
- TopTabs: the Vaults root tab no longer paints an active background fill when
  selected (text/icon still brighten). Clear the imperatively-set hover fill on
  click so it can't get stuck when active bg stays transparent.
- VaultView: when the host side panel is open, drive the grid column count from
  the container width as a fixed N columns instead of viewport-based grid-cols-*
  (which can't see the narrowed area) or auto-fit+1fr (which stretched a lone
  card across the whole row). Fills the row with no trailing gap and keeps a
  single card at one column's width. The count rides on a CSS variable set
  imperatively via ResizeObserver, so reflowing the grid never re-renders this
  large component.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 11:54:30 +08:00
陈大猫 2c716cd74c fix packaged MCP blocklist assets (#1132) 2026-05-28 10:49:20 +08:00
陈大猫andClaude Opus 4.7 6c23514d84 fix(ai): prefix wrapped AI commands with a leading space (#1129)
Netcatty's AI / Skill+CLI integration sends marker-wrapped commands
(__NCMCP_xxx=0; { ... eval ...; }) straight into the user's interactive
shell. preload.cjs filters the PTY echo of those wrappers from the
visible terminal, but they still land in ~/.bash_history — making the
user's shell history hard to read after each AI session (#1126 user
report on v1.1.16).

Prefix the POSIX (bash/zsh/dash) and fish wrappers with a single space.
On the shells/configurations that already honor "ignore leading-space"
in history recording, those wrappers now skip the history file
entirely:

- bash with HISTCONTROL containing `ignorespace` (Debian/Ubuntu default
  via /etc/bash.bashrc, also part of `ignoreboth` which is the most
  common explicit setting)
- zsh with HIST_IGNORE_SPACE set (Oh-My-Zsh and most prezto templates
  enable this)
- fish with a user-defined fish_should_add_to_history function (opt-in
  via fish config)

Known limitations (no behavior change needed on netcatty's side):

- bash on bare RHEL/CentOS ships HISTCONTROL=ignoredups by default —
  leading space is not honored. Users on those distros can opt in with
  `HISTCONTROL=ignoreboth` in their ~/.bashrc.
- zsh without HIST_IGNORE_SPACE: same; add `setopt HIST_IGNORE_SPACE`.
- Fish without a custom history filter: leading space is not honored.
- PowerShell, cmd, network-device CLIs: unaffected (their wrappers are
  not changed, and the persistent-history semantics differ).

This is intentionally a minimal change — 4 characters of behavior plus
the explanatory comments. We rely on the user's existing shell config
instead of trying to mutate HISTCONTROL ourselves at session start,
which would either be visible in the terminal echo, mis-fire on hosts
that already had ignorespace (deleting a real previous history entry),
or error on non-POSIX shells.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:02:18 +08:00
陈大猫andClaude Opus 4.7 456ddcfe68 chore(ai): upgrade ACP packages and unwrap Skill+CLI command in tool-call panel (#1128)
* chore(ai): upgrade ACP packages and unwrap Skill+CLI command in tool-call panel

Package bumps:
- @zed-industries/claude-agent-acp 0.22.2 → @agentclientprotocol/claude-agent-acp 0.37.0
  (old npm package is deprecated; scope rename)
- @zed-industries/codex-acp 0.10.0 → 0.15.0
- @mcpc-tech/acp-ai-provider 0.2.8 → 0.3.3
- electron-builder asarUnpack glob + bridge require.resolve switched to the new scope

After the upgrade Codex tool-call cards started showing the local
worktree path for every step — "Run /Users/.../netcatty-tool-cli session
--session …" — instead of the remote command. Three things lined up:

1. The new acp-ai-provider maps ACP's `title` to `toolName`, and Codex's
   title is the full shell invocation it's about to run.
2. Codex local_shell ships args as ["/bin/zsh","-lc","<full>"], so the
   old `typeof args.command === 'string'` branch in ToolCall never fired
   and we fell through to printing `name` (i.e. the title).
3. The bridge serializes tool args under `args`, but the ACP adapter
   only read `event.input`, so even when args were available the
   renderer received {}.

Fixes:
- acpAgentAdapter: read tool input from both `event.input` and
  `event.args` so bridge-serialized chunks and direct AI SDK chunks
  both work.
- ai-elements/tool-call: new extractDisplayCommand() unwraps the shell
  array, then the netcatty-tool-cli wrapper (exec/job-start … -- <cmd>),
  and renders the real remote command. session/env/job-poll/etc. fall
  back to short labels ("netcatty: inspect session", …) instead of
  exposing the binary path.
- shellUtils.cjs: defensive JSON-parse the ACP wrapper input in case
  the AI SDK ever stops auto-parsing it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(build): exclude bundled Claude CLI binaries from the installer

@anthropic-ai/claude-agent-sdk@0.3.x bundles the native Claude Code CLI
(~211MB per arch) as optional sibling packages. Including them would
silently regress Netcatty's "bring your own Claude" design — the project
has always required users to install Claude Code locally, and the entire
path-discovery flow exists precisely to honor that contract:

- useAgentDiscovery.ts scans the user's PATH for `claude` and writes
  the absolute path into the agent config's CLAUDE_CODE_EXECUTABLE env.
- aiBridge.cjs runs normalizeClaudeCodeExecutableEnvForAcp on every ACP
  spawn, forwarding the env var to the child process.
- The @agentclientprotocol/claude-agent-acp wrapper's claudeCliPath()
  (acp-agent.js) prefers process.env.CLAUDE_CODE_EXECUTABLE over the
  bundled binary and only falls back to sibling-package resolution when
  the env var is empty.

So the right place to enforce the design is electron-builder: exclude
node_modules/@anthropic-ai/claude-agent-sdk-* from `files`. Dev mode is
unaffected (optional deps still install for `npm run dev`); only the
packaged installer drops the binaries, saving ~150MB. Users without
Claude Code installed get the same SDK error they got pre-upgrade.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:26:14 +08:00
陈大猫andClaude Opus 4.7 2a283a4f83 fix(ai): run bundled claude-agent-acp via Electron's Node (#1127)
@zed-industries/claude-agent-acp ships dist/index.js with a
`#!/usr/bin/env node` shebang. We bundle the package and unpack it from
asar, but Windows ignores the shebang entirely and macOS/Linux only
honours it when `node` is on the user's PATH. When `node` was missing,
the resolver fell back to spawning the bare `claude-agent-acp` command,
which only works if the user manually ran
`npm install -g @zed-industries/claude-agent-acp` — see #1118.

Run the bundled script through `process.execPath` with
`ELECTRON_RUN_AS_NODE=1` (matching `resolveMcpServerRuntimeCommand` in
the MCP server bridge) so the embedded Electron acts as the Node
runtime. This makes the bundled copy work with zero external deps on
every supported platform.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:50:54 +08:00
陈大猫 b29533259b fix(terminal): probe cwd via SSH_CONNECTION on older OpenSSH (#1123) (#1125) 2026-05-27 18:09:21 +08:00
Pyroandpyroch 0f8aa08994 (fix) autocomplete popup visible after sending command (#1121)
Co-authored-by: pyroch <cvdysh@gmail.com>
2026-05-27 16:37:21 +08:00
陈大猫andClaude Opus 4.7 fb522c5016 fix(ai): keep chat input toolbar on one row when sidebar is narrow (#1122)
When the AI sidebar is dragged narrow, the model chip used to wrap
to its own line (and the perm chip to a third line), wasting vertical
space. Switch the toolbar from flex-wrap to a single-row flex container
where the +/perm chips stay shrink-0 and the model chip absorbs all
the squeeze via min-w-0, letting the existing truncate kick in and
ellipsize the model label instead of wrapping.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:35:31 +08:00
陈大猫andClaude Opus 4.7 7272f2564d fix(ssh): per-host skipEcdsaHostKey toggle + advanced algorithm overrides (#1027) (#1116)
* fix(ssh): per-host skipEcdsaHostKey toggle + advanced algorithm overrides (#1027)

#1027 reported an old Huawei S7706 (SSH banner `SSH-2.0--`, empty
software-version field) where the legacy-algorithms toggle still
couldn't get a connection through. The debug log shows the handshake
makes it through every negotiation step, picks `ecdsa-sha2-nistp521`
for the host key, then dies at:

  Handshake failed: signature verification failed

i.e. ssh2's strict RFC verifier rejects the ECDSA signature the
switch produces. OpenSSH on the same machine connects because its
known_hosts is already pinned to an RSA fingerprint, so it never
advertises ECDSA in the first place.

This adds two layers of escape hatch:

A. `host.skipEcdsaHostKey` (one-click) — drops every `ecdsa-sha2-*`
   from the offered host-key list. Forces the fallback to
   ssh-rsa / ssh-dss / ssh-ed25519 that those old stacks implement
   correctly. Wired through ssh / sftp / port-forwarding bridges,
   inheritable from the group default.

B. `host.algorithms` (advanced) — per-category override lists
   (`kex`, `cipher`, `hmac`, `serverHostKey`, `compress`). When a
   category's array is non-empty, it fully replaces the negotiated
   list for that category. Exposed in a collapsible "Advanced
   algorithm overrides" panel on both host and group settings,
   inspired by Tabby's per-profile algorithm UI. Empty arrays
   normalize to "use default" so picking zero algorithms in a
   category doesn't bench the connection with
   "no matching algorithm".

Overrides apply BEFORE `skipEcdsaHostKey` so the latter stays an
unconditional kill switch even if the user explicitly puts
`ecdsa-*` back into the host-key list.

Behavior with default values (neither toggle set, no override list)
is identical to before — zero change for hosts that aren't opted in.

Tests cover the new options on both `buildAlgorithms` and
`buildSftpAlgorithms`, plus group→host inheritance for both fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): address Codex review on #1116 — proper seed + jump-host carry + missing call sites

Four review findings from the Codex pass on #1116, all real:

1. AlgorithmOverridesPanel was seeding the first customization in a
   category from `SUPPORTED_ALGORITHMS_BY_CATEGORY`, which contains
   legacy algorithms (CBC, arcfour, MD5, ssh-dss, group1-sha1...).
   Unchecking a single modern algorithm in a host that had legacy mode
   *off* would silently start offering those legacy algorithms. Now
   seeds from `effectiveDefaultAlgorithms(legacyEnabled)`, mirroring
   what `buildAlgorithms` actually emits at connect time.

   - New `effectiveDefaultAlgorithms` pure helper in
     `domain/sshAlgorithmList.ts` with its own test suite (also asserts
     the modern subset contains no SHA-1 KEX / CBC / arcfour / MD5)
   - `AlgorithmOverridesPanel` takes a `legacyEnabled` prop
   - `isChecked` now reflects the effective-default state for an
     untouched category, so unchecked rows visually represent
     algorithms the connection wouldn't currently advertise

2. The chain-mode jump-host loop in `sshBridge.cjs` was applying the
   target host's `skipEcdsaHostKey` / `algorithmOverrides` to every
   bastion hop, which is wrong when the bastion has its own settings.
   This was actually a pre-existing issue with `legacyAlgorithms` too
   — `NetcattyJumpHost` simply didn't carry any of these fields.

   - `NetcattyJumpHost` gains `legacyAlgorithms`, `skipEcdsaHostKey`,
     `algorithmOverrides`
   - `createTerminalSessionStarters` populates them from each jump
     host's own configuration
   - The jump-host bridge call now reads `jump.* ?? options.*` so a
     hop with its own setting wins, but unset hops still fall back to
     the target's settings (preserves historic chain-wide behavior
     when nothing is overridden)

3. `infrastructure/services/portForwardingService.ts` only forwarded
   `legacyAlgorithms` to the port-forwarding bridge, so a host that
   needed the ECDSA skip or advanced overrides could connect through
   the terminal but its auto-start tunnels would still hit the
   original handshake failure.

   - Forward `skipEcdsaHostKey` and `algorithmOverrides` at the target
     call site and at the jump-host map.

4. `application/state/sftp/useSftpHostCredentials.ts` built
   `NetcattySSHOptions` for `openSftp` without any of the algorithm
   fields — and on inspection it didn't even forward `legacyAlgorithms`
   to begin with, so SFTP panes for legacy-mode hosts were silently
   negotiating with the modern default list. Same gap at the jump-host
   map.

   - Forward all three fields at both the target and the jump-host map.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* polish(hosts): make the advanced-algorithm collapsible trigger a real button

The first cut used a plain underlined caption for the
"Advanced algorithm overrides" collapsible trigger. That blends into
the surrounding helper text and doesn't read as an interactive
control — users couldn't tell that the per-category checkbox editor
was reachable at all.

Match the project's existing collapsible-trigger pattern (see
`SerialConnectModal`): full-width ghost Button, label on the left,
ChevronDown / ChevronUp on the right that flips with open state,
controlled via `useState`. Applied in both `HostDetailsPanel` and
`GroupDetailsPanel`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* polish(hosts): rename Legacy Algorithms card to SSH Algorithms; split Backspace into its own section

The card now carries three algorithm controls (Allow Legacy / Skip
ECDSA / Advanced overrides) plus a Backspace Behavior dropdown that
doesn't belong with the rest. Two cleanups:

1. Rename the card from "Legacy Algorithms" to "SSH Algorithms".
   The original title only described the first toggle; the section
   now covers the whole algorithm-negotiation surface, including the
   ECDSA host-key skip and the per-category override editor.
   - i18n key renamed `hostDetails.section.legacyAlgorithms`
     -> `hostDetails.section.sshAlgorithms` in zh-CN / en / ru
   - `HostDetailsPanel` references the new key

2. Move the Backspace Behavior control out of the algorithm card.
   - `HostDetailsPanel`: new dedicated "Terminal Behavior" card
     (TerminalSquare icon) placed between SSH Algorithms and
     Keepalive. New i18n key `hostDetails.section.terminalBehavior`.
   - `GroupDetailsPanel`: no separate Card scaffolding for group
     defaults — moved Backspace to the bottom of the SSH section
     (after Mosh) so it visually separates from the algorithm block
     above without introducing a new card heading.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sftp): per-hop algorithm settings in SFTP chain (Codex review on #1116)

`sshBridge.cjs`'s jump loop already reads `jump.* ?? options.*` for
the three algorithm fields, but `sftpBridge.cjs#connectThroughChainForSftp`
was missed in that change and still applied the target host's
`options.legacyAlgorithms` / `skipEcdsaHostKey` / `algorithmOverrides`
to every bastion. A bastion that needed the ECDSA skip or a custom
algorithm list while the target didn't would still fail the SFTP
handshake before reaching the target.

Mirror the sshBridge fix: read each setting from the jump host first,
falling back to the target options when the hop didn't override.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): keychain SSH exec + advanced editor seed honor inherited algorithm settings

Two findings from a local Codex review pass on the branch:

1. The keychain "export public key to host" flow opens its own one-off
   SSH connection through `sshBridge#execCommand`, but the connectOpts
   built there didn't include any `algorithms`. ssh2 then negotiated
   with its built-in modern defaults regardless of what the host had
   set, so a host that needs the ECDSA skip (or legacy mode) would
   connect in the terminal but the keychain export would fail with
   the original signature-verification error.

   - `sshBridge.cjs#execCommand` now sets `algorithms` from
     `payload.legacyAlgorithms` / `skipEcdsaHostKey` /
     `algorithmOverrides`, mirroring `startSSHSession`.
   - `useKeychainBackend`'s `execCommand` typing gets the three new
     optional fields.
   - `KeychainManager` forwards the host's `legacyAlgorithms`,
     `skipEcdsaHostKey`, and `algorithms` when invoking the export.

2. The Advanced Algorithm Overrides editor seeded its first
   customization from `form.legacyAlgorithms`, which is the host's
   own field — it doesn't reflect a value the host *inherits* from
   its group's default. A user in a group with `legacyAlgorithms=true`
   editing a host that hadn't explicitly set the flag would see the
   editor seed in modern-only mode, and saving could silently drop
   the legacy algorithms the host actually needed.

   - `HostDetailsPanel` passes `form.legacyAlgorithms ?? groupDefaults?.legacyAlgorithms`.
   - `GroupDetailsPanel` adds an `inheritedLegacyAlgorithms` memo
     resolved from the parent group chain via `resolveGroupDefaults`,
     and the editor uses `form.legacyAlgorithms ?? inheritedLegacyAlgorithms`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(keychain): export-public-key honors group-inherited algorithm settings

Second-pass Codex review on PR #1116 flagged that the keychain
"export public key to host" flow now carries the three algorithm
fields end-to-end, but only reads them from `exportHost.*` directly —
which doesn't reflect values the host inherits from its group's
defaults. A host that left `legacyAlgorithms` / `skipEcdsaHostKey` /
`algorithms` unset but sat inside a group that turned them on would
work fine in the terminal (the terminal starter applies group
defaults before sending the IPC payload) but its keychain export
would silently fall back to ssh2's modern defaults and hit the
original signature-verification failure.

Resolve the effective host with `applyGroupDefaults` +
`resolveGroupDefaults` before the `execCommand` call, then read the
algorithm fields off the effective host. Requires plumbing
`groupConfigs` into `KeychainManager` (added as an optional prop,
forwarded by `VaultView`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(host-details): algorithm-overrides editor reads currently selected group's defaults

Third-pass Codex review caught that the editor seed was reading from
the `groupDefaults` prop, which is whatever group the host belonged
to when the panel opened. If a user switched the host into a different
group inside the panel and then opened the Advanced Algorithm
Overrides collapsible before saving, the editor would seed from the
old group's `legacyAlgorithms` flag and could save the wrong list.

The panel already memoizes `effectiveGroupDefaults` from
`form.group`/`defaultGroup`/`groupConfigs` for exactly this kind of
re-resolution (used by theme/font effective lookups). Read the
inherited flag from there instead of the stale prop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(algorithms): trim UI cipher list to algorithms ssh2 actually supports

Fourth-pass Codex review flagged that `SUPPORTED_CIPHER_ALGORITHMS`
included `blowfish-cbc`, `cast128-cbc`, and the `arcfour*` family,
but OpenSSL 3 disabled those primitives, so ssh2's `canUseCipher`
filter drops them from `SUPPORTED_CIPHER` at startup. Selecting any
of them in the Advanced Algorithm Overrides editor would make
`ssh2.Client.connect()` throw `Unsupported algorithm` synchronously,
turning the override into a "host now unreachable" footgun instead
of a narrowing knob.

Realigned each `SUPPORTED_*_ALGORITHMS` list to ssh2's actual
`SUPPORTED_*` constants:

- `SUPPORTED_CIPHER_ALGORITHMS`: dropped blowfish / cast128 / arcfour;
  added the no-suffix `aes128-gcm` / `aes256-gcm` variants ssh2 also
  accepts.
- `SUPPORTED_KEX_ALGORITHMS`: added `diffie-hellman-group15-sha512`
  and `diffie-hellman-group17-sha512` (present in ssh2 but missing
  from the UI list); reordered to ssh2's canonical order.
- `SUPPORTED_HMAC_ALGORITHMS`: reordered so the ETM/SHA-2 grouping
  matches ssh2's `DEFAULT_MAC` and lookups are predictable.

Locked the invariant in `sshAlgorithmList.test.ts` with a new test
that asserts every UI-offered algorithm is a member of ssh2's
`SUPPORTED_*` for its category. A future ssh2 bump that drops an
algorithm we still expose will fail this test instead of silently
becoming a connect-time error for users.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(algorithms): run KEX override through the runtime fixed-DH filter

Codex round-N flagged a gap in `applyAlgorithmOverrides`: when the
user supplies a custom KEX list via the advanced editor, the previous
implementation copied it verbatim into the negotiated `algorithms.kex`
field. The default builder already passes its KEX list through
`filterSupportedFixedDhKex` to drop fixed-DH groups the runtime
doesn't support (notably `diffie-hellman-group1-sha1` on
Electron/BoringSSL, which lacks modp2), but the override path
bypassed that filter — so an Electron user enabling legacy mode and
saving any KEX checkbox state would re-advertise group1-sha1 and the
handshake would crash with "Unknown DH group" instead of failing fast.

Apply the same `filterSupportedFixedDhKex` to the override list. New
test in `sshAlgorithms.test.cjs` exercises a simulated BoringSSL
runtime that lacks modp2 and asserts the override-filtered KEX no
longer includes group1-sha1, while group14-sha1 / group-exchange-sha1
remain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(algorithms): run HMAC override through the FIPS MD5 filter

Codex flagged the same runtime-bypass pattern Round-N-1 fixed for KEX,
now in HMAC: `applyLegacyHmacAlgorithms` gates `hmac-md5` behind
`md5Supported()` so FIPS Node builds don't get it, but the UI's
`effectiveDefaultAlgorithms(true)` seed adds `hmac-md5` /
`hmac-md5-96` unconditionally. A user with legacy mode on who saves
any HMAC checkbox change would push those MD5 entries through
`applyAlgorithmOverrides`, which previously copied the override
verbatim — bypassing the FIPS gate and making ssh2 throw
"Unsupported algorithm" before negotiation.

New `filterRuntimeUnsupportedHmac` helper applies the same
`md5Supported()` gate to a user override. `applyAlgorithmOverrides`
routes the HMAC override through it (mirrors how the same function
already routes KEX overrides through `filterSupportedFixedDhKex`).
New test simulates a FIPS-disabled MD5 runtime and asserts MD5
variants drop from the final HMAC list while SHA-1 / SHA-2 entries
remain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): jump-host overrides no longer inherit the leaf's algorithm overrides

Codex flagged that the jump-loop fallback I added for chain
convenience applied the target's per-host \`algorithmOverrides\` to
every bastion whose own override wasn't set, which is wrong: a target
restricted to e.g. \`serverHostKey: [\"ssh-rsa\"]\` would lock the hop
to ssh-rsa too and break negotiation against an Ed25519-only bastion.

The fallback IS still correct for \`legacyAlgorithms\` and
\`skipEcdsaHostKey\` — those are append/safety toggles that widen the
offered list, so propagating them to a bastion is safe and matches
the historic chain-wide behavior of \`options.legacyAlgorithms\`
(single-toggle convenience for a chain with one old leaf).

Treat \`algorithmOverrides\` strictly per-host instead. Same change in
both \`sshBridge.cjs\` and \`sftpBridge.cjs\` jump loops, with a
comment block explaining the asymmetry so a future refactor doesn't
"clean up" the distinction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ui(host-details): surface inherited algorithm overrides in the advanced editor

Codex flagged a real gap in the inheritance model: when a parent
group has set algorithm overrides (e.g. \`algorithms.kex: [...]\`),
a host or child group under it can't simply Reset a category back to
NetCatty's defaults — \`applyGroupDefaults\` treats an unset host
field as "inherit", so the local Reset falls back to the group's
list rather than to ssh2's defaults. Cleanly distinguishing "reset
to NetCatty defaults" from "inherit from group" needs a new schema
field or sentinel, which is a non-trivial design change well beyond
the scope of this PR.

For now, surface the situation in the UI so the user understands
why Reset doesn't behave the way they might expect and where to go
to actually clear the restriction:

- \`AlgorithmOverridesPanel\` accepts an optional \`inheritedFromGroup\`
  prop and, when populated, renders a blue inline notice listing the
  inherited categories and directing the user to the group's
  algorithm settings if they need to opt out.
- \`HostDetailsPanel\` passes \`effectiveGroupDefaults?.algorithms\`.
- \`GroupDetailsPanel\` adds a new \`inheritedAlgorithmOverrides\`
  memo that resolves the same way the existing
  \`inheritedLegacyAlgorithms\` does, and forwards it.
- i18n strings added in zh-CN / en / ru.

Follow-up (out of scope for this PR): if real users do hit this,
introduce a \`host.algorithms = null\` (or explicit
\`algorithmsOverride: boolean\`) sentinel and a Reset-to-defaults
button that uses it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): jump-host skipEcdsaHostKey is per-host; panel seeds from inherited overrides

Three findings from the latest Codex pass:

1 & 2. `skipEcdsaHostKey` on the leaf was still falling back onto
   jump hosts in both `sshBridge.cjs` and `sftpBridge.cjs`. I had
   classified it with `legacyAlgorithms` as a "safety widening" knob,
   but Codex is right that it actually *narrows* the offered host-key
   list by dropping every `ecdsa-sha2-*`. An ECDSA-only bastion (or
   one where the operator pinned ECDSA via known_hosts) would still
   negotiate when ECDSA is offered, but fails when the leaf's skip is
   propagated to it. Same fix as `algorithmOverrides` last round:
   strictly per-host. Only `legacyAlgorithms` keeps the chain-wide
   fallback (append-only — it widens the offer, can never break a
   hop that wasn't already failing).

3. `AlgorithmOverridesPanel` was seeding the first customization of a
   category from the NetCatty/legacy effective default, ignoring any
   list the host inherits from its group for OTHER categories.
   Because `applyGroupDefaults` treats `host.algorithms` as an
   all-or-nothing inherit boundary, the moment the user saved any
   host-local override `{ cipher: [...] }`, the group's
   `{ serverHostKey: ["ssh-rsa"] }` restriction silently dropped from
   the effective host. Now:
   - `toggleAlgorithm`'s first-click seed reads
     `inheritedFromGroup?.[category] ?? effectiveDefault[category]`,
     so customizing one category preserves the group's narrowing on
     the others.
   - `updateCategory` initializes `next` from
     `{ ...inheritedFromGroup, ...value }` so saved overrides carry
     the inherited categories alongside the host's own edits.
   - `isChecked` reflects the inherited list when there's no local
     value, so the visible checkbox state matches what would actually
     be advertised.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(host-details): Reset preserves the inherited list instead of silently widening

Codex caught a follow-on bug from the previous "carry inheritance"
fix: pressing Reset on a category that's inherited from the group
deleted that category from \`host.algorithms\` while other host-local
or carried-inherited categories remained. Because
\`applyGroupDefaults\` treats \`host.algorithms\` as all-or-nothing,
the moment any host-local entry exists the group's \`algorithms\`
object stops being inherited as a whole — so the freshly-deleted
category fell back to NetCatty defaults rather than the group's
narrower list. Effective result: Reset on an inherited category
*widened* the offer instead of restoring it.

Reset now persists the inherited list verbatim onto the host when
the group has an override for that category, so "Reset" means "use
what this host would otherwise inherit" in all cases.

Also tightened \`isCustomized\` to suppress the "customized" badge
(and the per-category Reset button) when the host's stored list is
identical to the inherited list — those rows haven't really been
customized by the user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ui(host-details): gate the inherited-notice / checkbox baseline on no host override

Codex caught the asymmetry between the read and write sides of the
panel. The write side (`updateCategory`, `toggleAlgorithm`, Reset)
intentionally carries `inheritedFromGroup` onto the host on the first
edit so the runtime's all-or-nothing inherit boundary in
`applyGroupDefaults` doesn't silently widen the offer. But the read
side (the inherited-notice banner and the `isChecked` baseline for
unfilled categories) was applying inherited values *unconditionally*
— including when the host already had any local `algorithms` object,
which makes `applyGroupDefaults` stop inheriting from the group as a
whole. Net effect on a host that only locally overrode `cipher`: the
UI claimed the group's `serverHostKey` restriction was still in
effect, while the runtime would actually use NetCatty's modern
defaults for that category.

Introduce `inheritedForDisplay`, defined as `value === undefined ?
inheritedFromGroup : undefined`, and route the notice + the
`isChecked` baseline through it. The write side keeps consulting the
unconditional `inheritedFromGroup` so the carry-over still happens on
first edit — that part is what makes the runtime behavior match what
the UI used to advertise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ui(host-details): legacy/skipEcdsa toggles reflect group-inherited value

Codex caught that the Skip ECDSA toggle in both the host and group
panels read `form.skipEcdsaHostKey` directly, so a host whose group
turned the flag on (and `applyGroupDefaults` therefore applied it to
the runtime SSH negotiation) still saw the toggle rendered as off.
Worse, clicking it computed `!form.skipEcdsaHostKey` — which is `!undefined`
= `true` — so the first click could not actually disable the
inherited setting on a per-host basis; it would just store `true`
explicitly, the same effective state.

The Allow Legacy Algorithms toggle had the same pre-existing issue.
Fix both at once: each toggle now derives its enabled state from
`form.<field> ?? <inherited>` and the onToggle handler flips that same
effective value, so the toggle accurately represents what the runtime
would do and a single click off correctly stores `false`
(which `applyGroupDefaults` then leaves alone, breaking inheritance
for this host).

- `HostDetailsPanel` reads from `effectiveGroupDefaults`.
- `GroupDetailsPanel` adds an `inheritedSkipEcdsaHostKey` memo
  alongside the existing `inheritedLegacyAlgorithms` and uses both.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 15:12:42 +08:00
Pyroandpyroch 07a2f3a899 fix(shortcuts) keyboard shortcuts on non-English and non-QWERTY layouts (#1109)
* (fix) keyboard shortcuts on non-English layouts

* fix(shortcuts): respect Latin layout keys

* fix(shortcuts): preserve non-ASCII Latin keys

* fix(shortcuts): preserve punctuation layout keys

* fix(shortcuts): preserve shifted number-row symbols

---------

Co-authored-by: pyroch <cvdysh@gmail.com>
2026-05-27 11:20:08 +08:00
陈大猫andClaude Opus 4.7 399e6a6f2d fix(terminal): use client OS for local-shell autocomplete clear sequence (#1112) (#1115)
* fix(terminal): use client OS for local-shell autocomplete clear sequence (#1112)

Synthetic fallback Host in TerminalLayer hardcoded os: 'linux', so the
'host.os || navigator.platform check' expression in Terminal.tsx never
ran the client-OS detection branch for local shells. Result: autocomplete
emitted Ctrl-U (\x15) for line-clear on Windows local PowerShell/cmd,
where it's rendered literally as ^U instead of erasing the input — the
popup live-preview, fuzzy-accept, and snippet-accept paths all leak it.

Fix both ends: Terminal.tsx prefers client OS detection for local
protocol via the existing detectLocalOs helper; TerminalLayer.tsx
populates the synthetic host's os field from detectLocalOs too, so
other host.os readers (notably the server-stats gate in
Terminal.tsx:642) also stop treating local Windows as Linux.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(terminal): keep non-local fallback host on 'linux'

The TerminalLayer fallback path also triggers for unsaved serial sessions
and orphaned remote sessions (where the saved host was deleted while the
session lived on). Terminal.tsx trusts host.os for non-local protocols,
so tagging the fallback with the Windows client OS would push POSIX
remote/serial shells onto the Windows autocomplete/path-completion
branch. Gate the client-OS detection on protocol === 'local'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:17:52 +08:00
陈大猫andClaude Opus 4.7 46d1cf1696 chore(ai): drop unused eslint-disable around suppressed SDK warning (#1111)
PR #1110 added an \`// eslint-disable-next-line no-console\` above
\`console.warn\` when suppressing SDK reasoning/text state-machine
errors, but this file already permits \`console.*\` calls (there are
several pre-existing \`console.error\` lines in the same hook) so the
directive is unused and ESLint flags it:

\`\`\`
666:13  warning  Unused eslint-disable directive (no problems were
reported from 'no-console')
\`\`\`

Removes the directive; the \`console.warn\` stays.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 01:29:33 +08:00
陈大猫andClaude Opus 4.7 5be9bb58df fix(ai): suppress SDK reasoning/text state-machine error chunks (#1110)
Issue #1101 follow-up. When a third-party Anthropic-compat backend
streams thinking deltas without first emitting a `reasoning-start`
content-block signal (DeepSeek's \`-v4-flash\` is the canonical
offender), the Vercel AI SDK has nothing registered for the incoming
\`part.id\` and enqueues an \`error\` chunk on \`fullStream\` with the
text \`reasoning part <id> not found\` — once per orphan delta. The
analogous error exists for text parts.

These are internal SDK bookkeeping noise, not user-facing errors. Our
\`case 'error'\` handler was treating each one as a real error: it
appended an empty assistant message carrying the SDK string. The
visible damage was a wall of red banners in the chat panel — but the
worse damage was protocol-level. On the next turn we rebuild
\`sdkMessages\` from local history; the placeholder assistants slot
in between the assistant that holds the parallel \`tool_use\` blocks
and the subsequent \`role: 'tool'\` messages with their results. The
Anthropic SDK's \`groupIntoBlocks\` only merges *consecutive* tool
messages into a single user block, so the tool_results no longer
immediately follow their tool_use parent — and the backend rejects
the request as
\`400 messages.N: tool_use ids were found without tool_result blocks
immediately after\`. \`messages.N\` was literally counting our 12-ish
phantom assistants.

Adds \`isSdkStreamStateError(error)\` to
\`infrastructure/ai/shared/streamStateErrors.ts\` (matches
\`/^(reasoning|text)\\s+part\\s+\\S+\\s+not\\s+found$/i\` against
string / Error / \`{ message }\` shapes), and skips placeholder
emission for matching errors in the streaming hook. Drops a
\`console.warn\` so the noise is still visible in devtools when
debugging.

5 unit tests cover positive matches, the Error/object wrappings,
non-matches that should stay surfaced, and non-string inputs.

Refs #1101.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 01:02:26 +08:00
陈大猫andClaude Opus 4.7 cab4fc36ab Serialize Catty terminal_execute per session to fix the parallel tool_use race (#1108)
* fix(ai): serialize Catty terminal_execute calls per session

Issue #1101 problem 3. When the LLM emits multiple tool_use blocks in
one assistant turn (DeepSeek's Anthropic-compat happily does this for
parallel info-gathering), the Vercel AI SDK dispatches every tool
through `Promise.all(toolCalls.map(execute))`. All of them then race
into the same per-session mutex inside the main-process bridge
(`mcpServerBridge.reserveSessionExecution`). One wins; the rest get
`{ ok: false, error: "Session already has another command in
progress..." }` synthesized back as the tool result. The LLM sees a
turn full of synthetic errors instead of the answers it asked for, the
UI cards look stuck on "executing", and the Anthropic API has
sometimes rejected the resulting trace as
`tool_use ids were found without tool_result blocks`.

Adds an in-renderer Promise-chain queue keyed by
`${chatSessionId}:${terminalSessionId}` so the actual
`bridge.aiExec()` calls are issued one at a time per terminal. The LLM
can still parallel-call as many tool_use blocks as it wants — they
just resolve sequentially with real output. Approval prompts are kept
*outside* the queue: three parallel tool_use blocks still surface
three approval cards together, so the user can dispatch them in any
order rather than waiting for each prior command to finish before the
next prompt appears. The bridge-side mutex stays as defense-in-depth
for non-LLM paths (terminal_start, MCP, etc.).

`chainBySessionKey` is a self-contained helper with cleanup logic so
the queue map doesn't leak across many short-lived sessions:
- Each task awaits the previous tail via `.then(task, task)` so a
  failure doesn't poison the chain.
- A non-rejecting wrapper is stored as the new tail to keep that
  contract regardless of how the task settles.
- The cleanup `finally` only deletes the map entry when the current
  tail is still ours, so a caller that arrived between
  `set` and `finally` doesn't have its tail evicted.

Four unit tests cover dispatch ordering, rejection isolation,
per-key parallelism, and the cleanup path. Full suite 1255/1255.

Refs #1101.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): preserve LLM emission order + honor abort in serialized queue

Codex local review on the first cut of this branch flagged two real
issues plus a weak cleanup test:

1. **Approval order vs queue order** — the previous version awaited
   approval *before* reserving a queue slot. With three parallel
   tool_use blocks the user could approve B's prompt first, and B
   would slip into the queue ahead of A, running out of LLM emission
   order. Reserving the slot synchronously up front fixes that:
   `Promise.all`-dispatched executes each grab a slot at the same
   instant in their dispatch order, then wait on approval while
   holding it, then await `slot.ready` and run.

2. **Abort not honored** — once approvals were settled, queued-but-
   not-started commands ran to completion even after the user hit
   Stop. Their results were ignored by the SDK but the side effects
   still happened. Two `abortSignal` checks now short-circuit before
   the queue wait and again after.

3. **Cleanup test was vacuous** — the previous "drains" test would
   pass even if the cleanup logic were removed. Exposed
   `getSessionExecutionQueueSizeForTests` and assert the Map is empty
   after the only queued task settles.

Queue API now has two entry points: high-level `chainBySessionKey`
for run-it-when-it's-your-turn callers, and lower-level
`reserveSessionSlot` for callers (like `terminal_execute`) that
need to do non-blocking pre-work in parallel with the queue wait.

Two new tests cover the reservation-order-vs-prework-order guarantee
and the skip-without-serialized-work path.

Refs #1101.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): re-issue cancel on abort to close IPC-transit race

Codex local review (round 2) flagged a small remaining race: between
the abort check after \`slot.ready\` and the main-process bridge
registering the new exec into \`activePtyExecs\`, the user can hit
Stop. \`handleStop\` does issue \`aiCattyCancelExec\`, but if that
cancel IPC arrives at main *before* the exec has finished
registering, the cancel finds nothing to cancel and the exec keeps
running. Result: the user already cancelled, but the command runs
anyway.

Plug the gap by re-issuing the cancel from the tool's own abort
listener. Once the exec has registered into \`activePtyExecs\` (a
synchronous step on the main side once the IPC lands), the duplicate
cancel finds the entry and cancels it. \`cancelPtyExecsForSession\`
is already idempotent — it iterates the live tracker and skips
entries with mismatched \`chatSessionId\` — so double-firing from
both \`handleStop\` and the tool is safe.

Extends \`NetcattyBridge\` with the optional
\`aiCattyCancelExec(chatSessionId)\` method. Already exposed in
\`electron/preload.cjs\` so the runtime call works on the live
bridge; the type addition just makes it visible to the tool.

Refs codex review on #1101 problem 3 fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): register pending-cancel marker before SSH exec-channel opens

Codex local review (round 3) caught a remaining race in
`execViaChannel`: the cancellation marker is only registered inside
`sshClient.exec`'s open callback, which is async. If a cancel arrives
in the window between `sshClient.exec(...)` being dispatched and the
callback firing, `cancelPtyExecsForSession` finds nothing for the
session and is a no-op. The channel then opens, the marker
registers, and the command runs to completion — exactly the "user
already cancelled, but the command still ran" failure mode.

Plug the window by registering a *pending* marker synchronously
before `sshClient.exec`. The pending marker carries a `cancel()` that
just latches a `cancelled` flag and a `cleanup()` that is a no-op.
When the open callback fires it removes the pending marker and
checks the latch: if it tripped, close the just-opened stream and
resolve with `{ ok: false, error: "Cancelled" }`. If not, the normal
post-open registration takes over with the real `execStream` close.

The PTY (`execViaPty`) and raw-serial (`execViaRawPty`) paths
already register their marker before any async wait, so they don't
share this race.

Two regression tests in `ptyExec.test.cjs`:
- The pending marker is present immediately after `execViaChannel`
  returns, before the open callback runs.
- A cancel during the pre-open window short-circuits the eventual
  callback with `{ ok: false, error: "Cancelled" }` and closes the
  now-unwanted stream.

Refs codex review on #1101 problem 3 fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): clean up pending-cancel marker when sshClient.exec throws sync

Codex local review (round 4) noted that if `sshClient.exec(...)`
itself throws synchronously — e.g. because the underlying ssh2 client
was destroyed between the session lookup and the actual `.exec`
call — the pending-cancel marker stays in `activePtyExecs`
indefinitely and the surrounding Promise rejects instead of
resolving with the normal `{ ok, error }` shape the tool layer
expects.

Wraps the `sshClient.exec` invocation in `try/catch` so:
- Pending marker is removed when the throw escapes.
- The Promise resolves cleanly with `{ ok: false, error: err.message }`
  so the tool layer gets the same shape it does for every other
  failure mode (closed stream, timeout, cancellation).

Adds a regression test covering this exact path: a fake client whose
`.exec` throws synchronously; the result is a clean failure and the
cancellation map is empty afterwards.

Refs codex review on #1101 problem 3 fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:45:37 +08:00
陈大猫andClaude Opus 4.7 53d3e05bb4 Per-agent provider switcher chip in the Catty Agent chat input (#1107)
* feat(ai): provider switcher chip in the Catty Agent chat input

Catty Agent currently has no model chip in the chat input — modelPresets
is empty for the catty agentId so `hasModelPicker` is false. The
provider/model the chat ends up using comes from the global
`activeProviderId` / `activeModelId`, which means switching providers
requires opening Settings → AI → Providers. Closes the gap surfaced in
the original feedback on #1101 (problem 2) and partially addresses
#986 (per-context model switching) by going per-agent.

State layer adds an `agentProviderMap` (`Record<agentId, providerId>`)
alongside the existing `agentModelMap`. Both are written together when
the user picks from the new dropdown; together they form a per-agent
override that beats the global active provider/model when set. Cross-
window sync mirrors the agentModelMap pattern.

ChatInput grows a `providerSwitcher` prop carrying the enabled
ProviderConfigs, the selected (providerId, modelId), and an onSelect
callback. When supplied, the model chip switches from the Cpu glyph
to the provider's ProviderIconBadge + `providerName · modelId`, and
the popover renders a two-column layout — providers on the left
(icon + name + default model caption), that provider's known model(s)
on the right. ACP agents (Claude/Codex) are untouched because they
plumb their provider through the CLI, not the Vercel AI SDK.

AIChatSidePanel resolves `effectiveActiveProvider` /
`effectiveActiveModelId` for the catty agent from the per-agent map,
falling back to the global selection. handleSend now passes those
through to sendToCattyAgent so a provider picked in one Catty session
applies everywhere Catty runs.

For v1, each provider's right column shows just its configured
`defaultModel`. The two-level structure is in place to absorb richer
listings (cached /models, manual additions) later without UI
surgery — left a note in the popover code.

Refs #1101, #986.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* polish(ai): flatten provider picker to a single list

Two-level dropdown was theatre — each ProviderConfig exposes exactly
one model (its `defaultModel`), so the right column ended up showing
the same string already captioned on the left row. Picking a provider
implicitly picks its model; one click is enough.

Also drops the `p.enabled !== false` filter on the picker list. The
user's expectation, confirmed by feedback on the first cut, is that
the chat-input list mirrors what Settings → AI → Providers shows. The
per-provider `enabled` toggle is an "active-ish" flag, not a
visibility gate; hiding disabled providers in the picker silently
made everything but the one currently active disappear, which is what
the reviewer hit.

Pares the popover down to a vertical list — provider icon + name +
default-model caption (mono if set, italic "configure default model"
hint if not) + a Check on the bound row. Removes the `hoveredProviderId`
state along with the right-column rendering since it's no longer
driving anything.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* polish(ai): tune provider icon sizes in the chat input

Chip icon was sitting at sm (20px badge) and crowded the h-6 toolbar
chip alongside the truncated label; popover rows used the same sm, so
nothing differentiated the picker from the chip visually.

Adds an xs size to ProviderIconBadge (16px badge, 10px glyph, 9px
letter fallback) and uses it on the chip. Popover rows step up to md
(32px badge, 16px glyph) and the row padding grows accordingly so the
larger brand mark has room to breathe — the picker now reads like a
list of choices, the chip reads like a status line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): keep per-agent override honest in model fallback + chip label

Two codex P1/P2 findings on #1107:

P1 — When Catty has a per-agent provider override but no per-agent
model (agentModelMap['catty'] empty), cattyAgentModelId fell through
to the global `activeModelId`. That id belongs to whichever provider
was globally active, not the one Catty is now bound to, so e.g.
overriding to DeepSeek without a defaultModel happily sent gpt-4o
(global) to DeepSeek and produced a wrong-model error. The fallback
is now: stored agent model → override provider's defaultModel → empty.
Only the no-override path keeps the activeModelId tail; the
no-provider send guard catches the empty case for everyone else.

P2 — ChatInput's selectedSwitcherProvider used `?? providers[0]` so
the chip always displayed *some* provider even when none was actually
bound (selectedProviderId missing). The rest of the pipeline (send
guard, agentProviderMap) treats that as "no provider", so the chip
was lying. Dropped the fallback; when nothing is bound the chip now
shows a generic Cpu glyph + "Select provider" label until the user
picks one from the popover. Adds `ai.chat.selectProvider` in en /
zh-CN / ru.

Refs codex review on #1107.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): purge per-agent binding when the bound provider is deleted

Codex local review (round 2) flagged that a saved Catty model id could
outlive its provider. The chain:
- catty bound to provider A (DeepSeek): agentProviderMap['catty']='A',
  agentModelMap['catty']='deepseek-v4-flash'
- user deletes provider A
- cattyAgentProvider falls back to the global active provider (B, OpenAI)
- cattyAgentModelId still returned the saved 'deepseek-v4-flash'
- send dispatched the DeepSeek model id to OpenAI → wrong-model error

Two layers of fix:

1. **Defensive resolution** (AIChatSidePanel) — cattyAgentModelId now
   looks at the override provider before trusting the stored model id.
   If the override is stale (provider deleted), the stored model id is
   treated as orphan and the resolution falls back to the global active
   selection just like cattyAgentProvider already does.

2. **Cleanup on remove** (useAIState.removeProvider) — when a provider
   is deleted, any agent whose agentProviderMap entry pointed at it
   gets both maps cleared (provider override + saved model). Same
   rationale: that model id is now meaningless against every other
   provider. Mirrors the existing activeProviderId cleanup.

Adds a small agentProviderMapRef so removeProvider can snapshot which
agents to clean up without relying on the captured-at-callback-create
state (which would be stale).

Refs codex local review on #1107.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): include agentProviderMap in settings sync + block empty-model send

Codex local review (round 3) found two issues:

P2 — `agentProviderMap` was missing from the cross-device sync surface.
Added it to the sync key list, the build path, the apply path, and the
SyncPayload type alongside the existing `agentModelMap`. Sync tests in
`syncPayload.test.ts` now cover the new field on both the build and
apply sides.

P2 — Provider rows with no `defaultModel` were still clickable in the
chat-input picker, so selecting one would save a binding with empty
model id; the send path then dispatched an empty model name and the
SDK would surface a vague backend error. Two changes:

1. ChatInput disables the row (\`disabled\`, \`aria-disabled\`, dimmed
   styling, tooltip pointing the user to Settings) when the provider
   has no \`defaultModel\`. Click is suppressed.
2. AIChatSidePanel \`handleSend\` adds a model-required guard mirroring
   the existing no-provider guard — surfaces \`ai.chat.noProviderModel\`
   as an assistant message. Catches stale bindings (e.g. user edited
   the provider's \`defaultModel\` to empty after binding) before they
   reach the SDK.

Refs codex local review on #1107.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): trim whitespace-only model ids + reconcile orphan bindings on sync

Codex local review (round 4) found two more leaks:

P2 — Whitespace-only `defaultModel` passed the picker's `disabled` gate
(which trims) but every other layer (cattyAgentModelId resolution,
the send-guard `!sendActiveModelId` check, the SDK call) compared the
raw string. A provider whose default model was "   " could still reach
the SDK and surface a vague backend error. Normalized: trim at the
resolution boundary (catty model fallback chain) and trim before the
send-guard's existence check.

P2 — Sync apply did not reconcile per-agent bindings against the
incoming provider set. A payload could change `providers` without
shipping a fresh `agentProviderMap`, leaving local overrides pointing
to provider ids the synced set no longer includes — the same ghost-
binding bug that `removeProvider` already handles for explicit user
deletes. Added `pruneOrphanPerAgentBindings()` that runs after every
AI settings apply: it walks `agentProviderMap`, drops any entry whose
provider id isn't in the current `providers` list, and clears the
saved model id for those agents alongside it (mirroring removeProvider
cleanup). A new test in `syncPayload.test.ts` exercises the ghost-
binding case end-to-end.

Refs codex local review on #1107.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): notify open AI state of cross-device sync apply in the same window

Codex local review (round 4) flagged that `applySyncPayload` writes
straight to localStorage but `useAIState` only re-reads on browser
`storage` events. Those events only fire in *other* windows — the
window doing the apply (the one with the active chat panel) keeps
showing pre-sync providers and per-agent bindings until reload.
Concrete leak: cloud sync swaps in a new provider set, pruning runs,
localStorage is correct, but the open Catty chip still references the
ghost provider binding until the user reloads.

Extracts the existing `AI_STATE_CHANGED_EVENT` constant +
`emitAIStateChanged` helper out of `useAIState` and into a new
`application/state/aiStateEvents.ts` so non-React call sites (sync
apply, future IPC handlers) can fire it without pulling in the hook.
After AI settings apply, `syncPayload.ts` walks every AI key it
touched (including the agentProviderMap / agentModelMap that the
reconcile step may have mutated even when the payload didn't ship
them) and emits a same-window nudge. `useAIState`'s existing local
listener routes unknown keys back through its storage handler, so
each affected piece of React state rehydrates from localStorage.

Adds a regression test in `syncPayload.test.ts` that asserts the
expected `netcatty:ai-state-changed` keys are dispatched for the
providers + per-agent map keys.

Refs codex local review on #1107.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:03:08 +08:00
陈大猫andClaude Opus 4.7 0c4de74c84 polish(ai): hint that the provider icon is clickable (#1106)
The icon badge next to the Display Name input opens the icon picker
but had no visual cue — users had no reason to suspect it was
clickable, and reviewer feedback flagged it as too hidden.

Adds a primary-tinted ring on hover/focus plus a small pencil glyph
overlaid on the bottom-right corner of the badge (also hover/focus
gated). The badge itself doesn't change shape, so the layout stays
stable; the affordance is purely additive.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:13:15 +08:00
陈大猫andClaude Opus 4.7 2a4feea40f Customizable provider name, icon, and protocol style (#1105)
* feat(ai): customizable provider name, icon, and protocol style

Issue #1101 reported that the only way to wire DeepSeek through the
Catty Agent was to add an "Anthropic" provider with a DeepSeek base URL,
which (a) wasn't relabelable except on the "custom" providerId and
(b) implicitly conflated wire-protocol style with providerId, locking
"custom" to the OpenAI-compatible client.

ProviderConfig now carries three optional fields — `style`
(anthropic/openai/google), `iconId` (built-in brand glyph key), and
`iconDataUrl` (user upload). createModelFromConfig routes on the
resolved style first, falling back to providerId only for per-vendor
quirks (ollama's throwaway apiKey and openrouter's baseURL). Display
name becomes editable for every provider, not just custom.

The icon picker exposes a curated lobe-icons subset (MIT) covering the
common Anthropic/OpenAI-compatible third parties — DeepSeek, Moonshot,
Kimi, Qwen, Zhipu, Doubao, Mistral, Cohere, Grok, Perplexity, Groq,
Hugging Face — plus the existing six built-ins. Uploads are downsampled
to 64×64 WebP on a canvas so localStorage doesn't blow up. See
public/ai/providers/NOTICE.md for attribution.

Closes part of #1101 (problem 2). PR2 will reuse the new name/icon
plumbing in the Catty Agent chat input to surface a provider switcher.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* polish(ai): bigger labelled icon tiles and auto-fill display name

The first pass shipped the icon picker as an 8-column grid of bare
20px badges. Reviewer feedback: the icons are too small to read at a
glance, every preset should announce its brand name, and a second
click on a selected preset should let the user back out.

Switches the grid to auto-fill 120px tiles with a 32px icon plus a
truncated label so the picker reads like a brand list, not a sprite
sheet. Selecting a preset now also writes the brand's canonical
English display name into the Display Name field — the picker labels
keep their "/ 中文" suffix as a bilingual hint, but a new `name` field
on each catalog entry supplies a clean string for autofill (e.g.
"Qwen / 通义" → "Qwen"). Re-clicking the selected tile clears
iconId/iconDataUrl but leaves the typed name alone, so users who
already edited the name don't lose their edit on accidental toggle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* polish(ai): add explicit close button to icon picker

The picker only opened via the icon badge above the Display Name field,
which left no obvious affordance to collapse it once a preset was
chosen — users either had to scroll past or click back up to the icon.
Drops a ghost Close button in the bottom-right of the picker's action
row (next to Upload/Reset) so dismissing the panel is one click and
visible without hunting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): wire model discovery to the resolved provider style

Codex review on #1105 flagged that ModelSelector still derives auth
headers from `providerId` (`x-api-key` only when providerId is the
literal "anthropic"). Now that ProviderConfig lets the user override
`style` independently, the form persists the override but the model
discovery call ignored it — so picking an Anthropic providerId pointed
at an OpenAI-compatible backend (or vice versa) would send the wrong
header and fail to list models even though chat routing already used
the override.

Extracts `buildModelDiscoveryHeaders(style, apiKey)` as a pure helper
in infrastructure/ai/. ModelSelector now resolves the protocol family
via `resolveProviderStyle` with the explicit `style` prop taking
precedence, then asks the helper for headers. ProviderConfigForm
passes the resolved style through. The `needsApiKey = providerId !==
"ollama"` shortcut stays as-is — that's a per-vendor concession, not a
style decision.

Refs codex review on #1105.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): apply per-vendor URL fallbacks across every style override

Codex review on #1105 caught that the openrouter (and ollama) baseURL
fallback was sitting inside the `style === 'openai'` branch, so a user
who picked OpenRouter providerId + Anthropic/Google style with an
empty baseURL would skip the fallback entirely. The SDK then routed
to its own default endpoint (api.anthropic.com /
generativelanguage.googleapis.com) using the user's OpenRouter key —
silent misrouting plus auth failures.

Pulls the per-vendor quirks out into a new pure helper
`resolveProviderEndpoint(config, style, safeApiKey)`. The URL
fallback now fires for every style — the user picked openrouter for a
reason, even if they overrode the wire format. The ollama-only
`'ollama'` literal apiKey swap stays gated on `style === 'openai'`
because Anthropic/Google clients need a real key, not the throwaway
placeholder.

Refs codex review on #1105.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): send x-goog-api-key for google-style model discovery

Codex review on #1105 flagged that buildModelDiscoveryHeaders bucketed
google-style providers with the OpenAI-compat default, so a model
refresh against any google-routed endpoint was sending `Authorization:
Bearer …`. That's the wrong auth header — Google Generative AI
rejects Bearer entirely and expects `x-goog-api-key` (or `?key=`).
The runtime chat path already uses `createGoogleGenerativeAI`, which
authenticates with the Google header family, so discovery was the
odd one out.

Adds an explicit `google` branch returning `{ "x-goog-api-key":
<apiKey> }` and updates the unit test. The default google providerId
never hits this path today (PROVIDER_PRESETS["google"] has no
modelsEndpoint), but the style override surface created in this PR
lets users compose providerId + style pairs where discovery does
fire — e.g. an openai/anthropic-style providerId pointed at a Google
endpoint via baseURL override.

Refs codex review on #1105.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): pick discovery endpoint from resolved provider style

Codex review on #1105 pointed out the lopsided fix in ae7394c8: I
switched discovery *headers* to honor the resolved `style` but left
the URL path coming straight from `PROVIDER_PRESETS[providerId]`. So
the moment style and providerId disagree the request shape is half
correct — e.g. Anthropic providerId + style=openai sends Bearer
(right) at `/v1/models` (wrong; the OpenAI-compat backend exposes
/models). 404s either way.

Adds `STYLE_DEFAULT_MODELS_ENDPOINT` (`anthropic → /v1/models`,
`openai → /models`, `google → undefined`) and a
`resolveModelsDiscoveryEndpoint(style, presetEndpoint)` helper. The
style's convention wins; the caller-supplied `presetEndpoint` is the
fallback for styles with no listing convention (currently just
google).

Behavior for stock configs is unchanged — every preset's existing
modelsEndpoint matches its style's default. Mismatches now line up
(headers + path together), and `custom` providers gain a sensible
discovery attempt when the user sets a style. My earlier inline reply
ducking this was wrong; codex's call was right.

Added three unit tests covering style defaults, the override-on-flip,
and the google-style passthrough.

Refs codex review on #1105.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:41:19 +08:00
Pyroandpyroch faa90e1aa5 Reduce interactive terminal latency with TCP_NODELAY (#1103)
Co-authored-by: pyroch <cvdysh@gmail.com>
2026-05-26 15:31:14 +08:00
陈大猫 1aa96c3490 Fix Claude ACP Windows shim launch (#1102) 2026-05-26 14:25:03 +08:00
陈大猫andClaude Opus 4.7 0e80955e96 fix(hosts): make group startup-command field multi-line typeable (#1100)
The group details panel rendered Startup Command as a single-line <Input>,
so users couldn't type newlines into it — only the first line ever made it
into the saved config, which then broke the multi-line sequencing behavior
shipped in #1096 for any host inheriting the command from its group.
Switch to a 3-row <Textarea> to match the per-host details panel, so a
multi-line command typed on the group is preserved end to end.

Refs #1083.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 10:27:00 +08:00
陈大猫andClaude Sonnet 4.6 7771592cf2 feat(shortcuts): Ctrl+W closes the tab directly + add configurable side-panel toggle (#1098)
* feat(shortcuts): add resolveSidePanelToggleIntent pure resolver

* feat(shortcuts): Ctrl+W closes the tab directly (drop side-panel priority)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(shortcuts): register toggle-side-panel binding (default ⌘/Ctrl+\)

* feat(shortcuts): add side-panel toggle handler + last-panel memory in TerminalLayer

* feat(shortcuts): dispatch toggleSidePanel hotkey to TerminalLayer

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 02:42:41 +08:00
陈大猫andClaude Opus 4.7 6e9e8fc40d feat(autocomplete): make snippets a first-class terminal completion source (#1097)
* feat(autocomplete): add snippet completion source

* feat(autocomplete): merge snippets at the command position

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(autocomplete): place snippet tests where the test runner finds them

The npm test glob covers components/terminal/*.test.ts but not the
autocomplete/ subdirectory, so the snippet tests added in the previous two
commits weren't actually running in the suite. Move them up to
components/terminal/ (the existing convention for autocomplete tests) with
corrected import paths; the engine snippet cases go in a separate
completionEngineSnippets.test.ts to avoid colliding with the existing
completionEngine.test.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(autocomplete): snippet ghost-exclusion, preview skip, and accept path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(autocomplete): wire snippets into the terminal + preview snippet command

* fix(autocomplete): show only label in snippet popup row; keep snippet over colliding history

The popup row for a snippet now omits the inline command echo — the full
command lives in the detail preview only, matching the "label-only row"
design. The completion engine pushes snippet suggestions without the early
seen-text skip so that when a snippet's label collides with a history
entry's text, the higher-scored snippet survives the final dedup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): broadcast snippet command so popup acceptance mirrors peers

In broadcast mode, accepting a snippet from the autocomplete popup cleared
peer input (the line-clear keystrokes flow through the broadcast-aware path)
but never sent the command, since executeSnippetCommand wrote only to the
active session. Broadcast the normalized snippet data (matching the snippet
shortkey path) so peers receive both the clear and the command, keeping all
sessions in sync.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): broadcast wrapped snippet bytes to preserve noAutoRun

Broadcasting the raw normalized command sent un-wrapped newlines to peers,
so a multi-line noAutoRun snippet was pasted-but-not-run on the active
session yet executed line-by-line on broadcast peers (handleBroadcastInput
writes bytes directly without re-wrapping). Broadcast the exact bytes the
active session receives instead — bracketed-paste wrapping plus the auto-run
\r — so peers mirror the active session and noAutoRun is preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 02:03:33 +08:00
陈大猫andClaude Opus 4.7 67448cea65 feat(terminal): configurable startup-command delay + multi-line sequencing (#1096)
* feat(terminal): add global startupCommandDelayMs setting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(terminal): add startup-command line-split and delay-clamp helpers

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(terminal): run multi-line startup commands in sequence with configurable delay

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(terminal): expose startup command delay in Terminal settings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(terminal): keep startup-command line content verbatim

splitStartupCommandLines now only drops blank/whitespace-only lines and
normalizes CRLF, but no longer trims each line's content. This keeps a
single-line startup command byte-identical to what the user typed (e.g. a
leading space for HISTCONTROL=ignorespace is preserved), while still
supporting multi-line sequencing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sync): include startupCommandDelayMs in synced terminal settings

Terminal settings sync via the SYNCABLE_TERMINAL_KEYS allowlist; the new
startupCommandDelayMs preference was missing, so it wouldn't propagate across
devices. Add it (it's a user preference like keepaliveInterval, not
device-specific).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 00:47:00 +08:00
陈大猫andClaude Opus 4.7 770b06a9ee fix(ai): make Claude Code agent diagnosable + configurable (auth) (#1095)
* feat(ai): add Claude auth-presence detection helper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ai): surface actionable Claude auth errors and reap stuck agent processes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ai): add pure helpers for Claude config dir + env editor

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ai): let users set Claude config dir and env vars in settings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* polish(ai): harden Claude config env, de-dupe error text, label a11y

- buildClaudeEnv: drop managed keys (CLAUDE_CONFIG_DIR/CLAUDE_CODE_EXECUTABLE)
  if a user types them into the free-text env editor, so they can't clobber
  the config-dir field or the discovered executable path (+ regression test).
- bridge: only append error data fields not already shown as message/code,
  so the actionable error text doesn't echo the same code/message twice.
- ClaudeCodeCard: associate the new config-dir/env labels with their inputs
  via htmlFor/id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ai): make the Claude auth & config section collapsible

The optional "Authentication & config" section now has a collapsible
header (chevron toggle). Collapsed by default to keep the card tidy, but
auto-expanded when the user already has a config directory or env vars set
so existing config isn't hidden. Local UI state, not persisted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): preserve raw env editor text; don't encourage plaintext secrets

P1: the env textarea was bound to the persisted value, which is the parsed
env re-serialized — so typing a key before its "=" was erased mid-entry
(buildClaudeEnv drops lines without "="). Keep the raw typed text in local
draft state and only resync from the persisted value on genuine external
changes (not our own parse→serialize round-trip).

P2: the env editor persists to localStorage in plaintext (no credential
encryption). Stop suggesting ANTHROPIC_API_KEY in the placeholder and warn
that values are stored in plaintext, steering credentials to the config
directory (a `claude` login) — consistent with keeping Claude auth
CLI/config-owned (#705).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): expand ~ in Claude config directory before passing to the agent

CLAUDE_CONFIG_DIR is handed to the spawned agent as an env var, which is not
shell-expanded — so "~/.claude" was treated as a literal "~" directory.
Expand a leading ~ at consume time (normalizeAgentEnv + getClaudeConfigDir)
rather than on save, so the stored value stays portable across machines
(cloud sync) and each device expands to its own home.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): recreate cached Claude provider when its env config changes

Claude ACP providers are cached per chat session and reused unless one of
the fingerprinted dimensions changes. authFingerprint was null for Claude,
so editing the config directory / env vars in Settings didn't take effect on
an already-running session. Fingerprint the Claude agent env so a config
change invalidates the cached provider and the next turn respawns with it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 23:59:48 +08:00
陈大猫andClaude Opus 4.7 1d50b2c4a1 feat(terminal): choose dark/light terminal theme when following app theme (#1094)
* feat(terminal): add per-mode follow-theme resolver and storage keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(terminal): persist per-mode follow-theme selections in settings state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sync): include per-mode follow terminal themes in cloud sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* i18n(terminal): add per-mode follow-theme picker strings

* feat(terminal): add type filter and auto option to theme picker

* feat(terminal): pick dark/light terminal theme when following app theme

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(terminal): don't flag the auto sentinel as a missing theme in the picker

The per-mode follow-theme pickers default to the 'auto' sentinel, which is
not a real theme id, so ThemeList's deletedSelectedTheme check classified it
as a deleted custom theme and rendered a spurious "Missing Theme" banner above
the Auto entry on first open. Exclude TERMINAL_THEME_AUTO from that check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* i18n(terminal): align zh-CN dark/light wording with app convention

Use 深色/浅色 (matching the theme picker section headers and global
appearance settings) instead of 暗色/亮色 for the per-mode terminal
theme labels, so the picker modal reads consistently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(terminal): match follow-theme preview fallback to runtime resolution

The per-mode preview memos fell back straight to TERMINAL_THEMES[0] when a
selection resolved to a deleted theme, while the runtime currentTerminalTheme
memo falls back to the manual terminalThemeId first. Mirror the runtime chain
so the Settings preview matches the actual terminal for users with a
non-default manual theme.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 22:20:45 +08:00
陈大猫 453202df8f perf(terminal): add output flow control / back-pressure for heavy streams (#1090) 2026-05-25 15:19:27 +08:00
陈大猫andClaude Opus 4.7 a78c052d86 perf(autocomplete): skip completion queries when nothing is shown (#1088)
fetchSuggestions ran the full completion pipeline (history scan, fig specs, remote path lookups) on the main thread even when both the popup and ghost text were disabled — the results were then discarded. Add a shouldQueryCompletions(settings) gate and bail out early (clearing any stale state) when neither display mode is on.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:39:49 +08:00
陈大猫andClaude Opus 4.7 e6b0a551e8 perf(terminal): isolate autocomplete re-renders into a child component (#1089)
The autocomplete hook (useState) lived in Terminal, so every suggestion / selection / live-preview update re-rendered the whole ~2775-line Terminal component. Move the hook and its popup into a dedicated <TerminalAutocomplete> component so those frequent state updates re-render only that small subtree.

The hook's handlers are surfaced back to Terminal via refs (the same refs already used to wire the xterm runtime), and the component is mounted unconditionally so the hook keeps recording command history and intercepting completion keys for the session's lifetime. No behavior change intended.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:39:38 +08:00
陈大猫andClaude Opus 4.7 38775245d2 perf(terminal): bound the connection log with a chunk ring buffer (#1087)
* perf(terminal): bound the connection log with a chunk ring buffer

The connection log kept the last 1,000,000 chars via `log += chunk; log = log.slice(-MAX)`. Once a session emits more than that, the slice flattens a ~1M-char string on every subsequent output chunk — on the render thread, for each echoed keystroke included — on long/busy sessions.

Replace the string with a small chunk-queue ring buffer that trims only the boundary chunk (amortized O(chunk) append) and materializes the full string once on read. Behavior is unchanged: it still retains exactly the last MAX_CONNECTION_LOG_DATA_CHARS characters.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* perf(terminal): coalesce connection log into bounded blocks (O(1) trim)

The first cut used one array entry per append and trimmed with chunks.shift(). For interactive output (many tiny chunks) the array grows toward the cap in entries, so once full, shift() reindexes ~N elements on every append — O(appends) per chunk, no better than the slice it replaced.

Coalesce appends into a small, bounded set of fixed-size blocks (~maxChars/blockSize). New data fills an open tail that seals into a block at blockSize; trimming only drops/slices a handful of blocks. Adds segmentCount() and a test asserting the segment count stays bounded across many tiny appends.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:39:02 +08:00
陈大猫 fcb699ffb9 chore(eslint): lint electron/bridges for undefined references (#1086) 2026-05-25 13:53:53 +08:00
陈大猫andClaude Opus 4.7 e889d8fc20 perf(terminal): flush shell output on the event-loop turn instead of a fixed 8ms timer (#1085)
* perf(terminal): flush shell output on the event-loop turn, not a fixed 8ms timer

SSH/PTY output was coalesced and shipped to the renderer on a fixed 8ms timer. For interactive use that interval is pure added latency: every echoed keystroke waits out the timer before it can paint, so typing feels slightly behind.

Replace the timer with turn-based (setImmediate) coalescing in a single shared ptyOutputBuffer module, used by the SSH, local, telnet, and mosh paths. A single echoed keystroke is now forwarded almost immediately, while data arriving in the same turn still collapses into one IPC send, and a 16KB size cap still forces an immediate flush under heavy output.

Also de-duplicates two copies of the buffering logic (SSH had an inline copy; local/telnet/mosh shared another) and adds unit tests for the buffer.

Related to #1084.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(terminal): drop orphaned flushTimeout reference in SSH close handler

The SSH stream "close" handler still cleared `flushTimeout`, a variable that lived in the inline buffer removed when this path moved to the shared ptyOutputBuffer. Reading it now throws ReferenceError on every channel close, aborting the cleanup and exit signaling. The shared buffer's flush() cancels any pending flush internally, so the timer bookkeeping is removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 13:42:49 +08:00
陈大猫andClaude Opus 4.7 bf1c95500a feat #826: optional Option+←/→ word jump on macOS (#1082)
* feat #826: optional Option+←/→ word jump on macOS

Adds a Terminal → Keyboard toggle "Option+←/→ jumps by word" (off by default,
synced). When on, a bare Option+Left/Right sends Meta-b / Meta-f instead of
xterm's default ^[[1;3D / ^[[1;3C, so readline/zle moves by word without
per-host bindkey setup (Termius-style).

The key→sequence mapping is a tested pure function; the handler reads the
setting live (no reconnect) and runs after kitty mode + autocomplete so it
doesn't override them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix #826: gate Option+←/→ word jump to macOS

The setting is syncable, so without a platform gate, enabling it on a Mac
would also rewrite Alt+←/→ to Meta-b/f on synced Linux/Windows devices,
breaking apps/shells that expect the default ^[[1;3D / ^[[1;3C. Pass
isMacPlatform() into the mapping so it only applies on macOS; add a test
for the non-macOS case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:10:40 +08:00
陈大猫andClaude Opus 4.7 f9d00c9d23 fix #1079: preserve remote file mode when rz overwrites a same-named file (#1081)
* fix #1079: preserve remote file mode when rz overwrites a same-named file

#1070's overwrite path rm's the remote file and lets rz re-create it, which
writes with the remote umask and drops the original permission bits — e.g. a
0755 script became 0644 after choosing "replace". (It didn't happen before
because rz used to skip same-named files, leaving the original untouched.)

Capture each conflicting file's mode during the pre-upload probe
(stat -c %a, BSD stat -f %Lp fallback) and chmod it back once the transfer
finishes and the files are on disk. Restore is best-effort: any failure
silently falls back to today's behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix #1079: probe file mode with `stat -- "$n"` for dash-prefixed names

Without `--`, `stat -c %a "-x.sh"` (and the BSD `-f %Lp` fallback) parse a
leading-dash filename as options, so the mode was never captured and overwrite
fell back to rz defaults — losing permission preservation for a valid filename
class. Mirrors the existing `rm -f --` handling. (chmod left as-is: its path is
always absolute, and BSD chmod doesn't accept `--`.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:44:41 +08:00
陈大猫andClaude Opus 4.7 8fd7ff6475 fix #1078: send macOS Option as Meta (wire altAsMeta to xterm macOptionIsMeta) (#1080)
"Use Option as Meta key" was read into `altIsMeta` but only applied to the
mouse alt-click options (`altClickMovesCursor`). xterm.js's `macOptionIsMeta`
— the option that actually makes Option emit ESC-prefixed (Meta) sequences —
was never set, so on macOS Option kept producing layout characters (ƒ, ∫, …)
and readline/zle word shortcuts (Alt+f, Alt+b, Alt+Backspace) were dead.

Extract the altAsMeta→xterm mapping into one tested helper used by both the
terminal init path (createXTermRuntime) and the live settings sync
(Terminal.tsx) so the two can't drift again.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:30:35 +08:00
陈大猫andClaude Opus 4.7 02c80ae7d2 chore: silence two production build warnings (#1072)
- Drop the manualChunks 'vendor-react' entry: react/react-dom already land
  in another chunk, so it only ever produced an empty chunk + a build
  warning, with no caching benefit.
- Import domain/syncMerge statically in useAutoSync. It's already in the
  eager graph via CloudSyncManager's static import, so the dynamic
  `import()` couldn't be code-split anyway and only emitted a mixed
  static/dynamic-import warning.

No behavior change; production build is warning-free.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:50:33 +08:00
陈大猫andClaude Opus 4.7 e5d3d02b17 fix #1063: give each terminal its own WebGL texture atlas (disable cross-terminal sharing) (#1071)
Root cause of the persistent split-view 花屏: xterm's WebGL addon shares
ONE TextureAtlas across terminal instances with equal config (font / size
/ theme / DPR) — acquireTextureAtlas does `if (configEquals) { ownedBy.push;
return atlas }`. Two split panes then share an atlas, so the
clearTextureAtlas calls netcatty makes to recover from glyph corruption
(on resize / DPR / font change / tab show, from #1049 and #1066) clobber
the *other* pane's rendering. That's why the earlier redraw/clear-based
recovery attempts didn't help and only bounced the garble between panes.

Disable the sharing: remove the "reuse a matching atlas" loop so every
terminal creates its own atlas. The published bundle is minified, so this
is done with a small idempotent postinstall script (a patch-package patch
would be a ~550KB unreadable blob of the whole minified line). It
string-replaces the exact loop in the CJS + ESM builds, runs after
patch-package, and warns without failing if @xterm/addon-webgl changes.

Verified: split-view WebGL no longer garbles; script is idempotent
(patched=2 → already=2) and the production build is unaffected.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:45:05 +08:00
陈大猫andClaude Opus 4.7 78186d8d46 feat #1064: prompt to overwrite when rz upload hits a remote filename conflict (#1070)
* feat #1064: add buildUploadPlan for rz overwrite/skip/cancel resolution

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1064: handle remote filename conflicts in rz handleUpload

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1064: SSH exec probe + remove for rz upload conflicts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1064: IPC for rz overwrite-conflict prompt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1064: renderer prompt for rz overwrite conflicts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix #1064: repair sshBridge test mock (ipcMain.on) and i18n the overwrite dialog

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix #1064: make upload plan index-based to preserve per-file decisions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:20:20 +08:00
陈大猫andClaude Opus 4.7 c899653621 fix #1065: resolve terminal cwd through su/sudo for the SFTP locate (#1068)
* fix #1065: resolve terminal cwd through su/sudo for the SFTP locate

The SFTP "locate to terminal's current directory" feature kept showing the
login user's home (e.g. /root) after the user switched accounts with su /
sudo -s and cd'd elsewhere.

getSessionPwd walks the remote process tree from a sibling exec channel to
find the interactive shell's cwd, but it only followed children whose comm
is a shell name (bash/zsh/...). su and sudo are named "su"/"sudo", so the
walk stopped at the login shell and read its cwd. The actual shell the user
is typing in lives *under* su/sudo as the controlling tty's foreground
process group.

Rewrite the walk to pick the deepest foreground shell ("+" in stat) within
the login shell's whole process subtree, which transparently follows
through su/sudo to the active shell, falling back to the login shell when
no foreground shell is found.

Verified on a real server (root -> su user -> cd /tmp):
  before: /root   after: /tmp
and confirmed the no-su case is unchanged (cd /var -> /var).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fall back to login shell cwd when the active shell's /proc is unreadable (Codex review)

When an unprivileged user runs `sudo -s` / `su root`, find_active_shell
correctly selects the root-owned foreground shell, but the exec channel
(running as the login user) cannot readlink another uid's /proc/<pid>/cwd
due to ptrace permissions. Without a fallback the script dropped straight
to the home directory, regressing user→root sessions.

Retry readlink on the same-uid login shell before falling back to home.

Verified live (user -> cd /var -> sudo -s -> cd /tmp): the root shell's
cwd is unreadable, and the result is now /var (login shell cwd) instead of
/home/<user>.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Select the interactive (tty-bearing) login shell deterministically (Codex review)

find_login_shell picked the first shell child of sshd and exited, but ps
output is unsorted, so when other exec channels (server-stats polls, etc.)
are running on the same connection their transient sh could be chosen,
making find_active_shell walk the wrong subtree.

Prefer the shell child that has a controlling tty: the interactive shell
has a pts, while non-PTY probe exec channels have tty "?". This is
deterministic regardless of ps order, in both the su and no-su cases (the
old "prefer foreground" heuristic was itself nondeterministic under su).
Falls back to any shell child if none has a tty.

Verified live with a concurrent no-tty `sh -c sleep` under the same sshd:
the pts/0 bash is selected and the result is /tmp, not the probe shell.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:26:20 +08:00
陈大猫andClaude Opus 4.7 a91fbcdd68 fix #1062: treat SSH shell TMOUT auto-logout as a timeout, not a normal exit (#1067)
* fix #1062: treat SSH shell TMOUT auto-logout as a timeout, not a normal exit

A shell-level TMOUT idle auto-logout makes bash/csh exit cleanly (numeric
exit code, no signal), which is byte-for-byte indistinguishable from a
user-typed `exit` at the SSH protocol level. PR #1057 keyed the
close-vs-keep decision on `streamExited` (numeric code + no signal), so
TMOUT exits were reported as reason "exited" and the tab was auto-closed —
reintroducing the problem from #977.

Verified against a real server that bash TMOUT exits with code 0 / no
signal and prints "timed out waiting for input: auto-logout" to the
channel before it closes. Since exit code/signal can't distinguish it from
an intentional exit, detect that banner in the session's existing rolling
output tail (_promptTrackTail) and report reason "timeout" instead, which
routes to the existing markDisconnected path (keep tab + reconnect). A
normal `exit`/`logout` (no "auto-" prefix) still auto-closes the tab, so
PR #1057's behavior is preserved.

zsh's TMOUT raises SIGALRM (a signal), so it already took the
keep-tab/reconnect path and is unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Anchor TMOUT auto-logout match to the banner's final line (Codex review)

The detector matched "auto-logout" as an unanchored substring within the
last 256 chars, so command output that merely mentions it (e.g. `grep
auto-logout /etc/profile` while investigating TMOUT) followed by an
intentional `exit` could be misclassified as a timeout and wrongly keep
the tab open. Anchor on the final non-empty line of output instead — the
banner the shell prints right before exiting — which loses no true
positives (verified against the real-server output shape) while rejecting
mid-stream mentions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 10:53:03 +08:00
陈大猫andClaude Opus 4.7 74b315e285 fix #1063: force WebGL redraw on tab show to recover from garbled multi-tab terminals (#1066)
Hidden tabs stay mounted off-screen (visibility:hidden) so each keeps a
live WebGL context. Creating another terminal's WebGL context — or the GPU
dropping a non-composited off-screen canvas — leaves the hidden terminals'
drawing buffers corrupted ("花屏"). This reproduces on both Windows and
macOS: opening 2 tabs garbles the 1st, opening 3 garbles the 1st and 2nd,
while the just-created (visible) one is always fine. The DOM renderer is
immune because it uses real DOM nodes.

A window resize recovers the display because it triggers a full repaint
(clearTextureAtlas + RenderService._renderRows). A tab switch did not:
the visibility effect only calls safeFit, which early-returns when the
pane's dimensions are unchanged, so no redraw happened.

Perform the same recovery a resize does when a tab becomes visible:
clear the texture atlas (no-op on the DOM renderer) and synchronously
repaint every row. Verified against xterm core that _renderRows draws
unconditionally, independent of dimension changes or dirty-row tracking.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 09:56:30 +08:00
陈大猫andClaude Opus 4.7 60eeafe7a9 feat #1005: Termius-style live-preview popup autocomplete (free the Tab key) (#1059)
* feat #1005: add live-preview keystroke calculator for popup autocomplete

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1005: live-render the selected popup suggestion on arrow navigation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1005: free Tab for the shell; Enter runs the rendered line; Esc reverts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1005: show key hint (→ expand / ↵ run) on the selected popup row

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1005: live-render full path while navigating sub-directory panels

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test #1005: move live-preview test into the npm test glob

The test runner only scans components/terminal/*.test.ts (not the
autocomplete/ subdir), matching where the other autocomplete-module tests
live (e.g. completionEngine.test.ts). Relocate so it actually runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix #1005: center and refine the popup key-cap hint

Use inline-flex centering (the ↵ glyph was vertically off with line-height +
padding), softer color-mixed border/background, a system-sans font so the
glyph renders consistently regardless of the terminal font, and the more
balanced ⏎ return symbol.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix #1005: record the actual executed line on Enter, not the stale suggestion

Codex review (P2): the popup Enter handler recorded selected.text and
suppressed handleInput's recorder, so editing a previewed command (select
docker, type ' ps', Enter before the re-query) logged the stale 'docker'
instead of 'docker ps'. Delegate to handleInput's Enter path, which records
lastAcceptedCommandRef on a clean select and falls back to the live buffer
after an edit (typing nulls that ref).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix #1005: don't revert user edits when Escape closes the popup

Codex review (P2): previewActiveRef stayed true after the user edited a
previewed command, so Escape (before the debounced re-query reset state)
called renderPreviewSelection(-1) and rewrote the line back to the stale
baseline, dropping the edits. Clear previewActiveRef when the user types
(alongside the existing lastAcceptedCommandRef reset), so Escape only reverts
a pristine preview and otherwise just dismisses the popup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:58:57 +08:00
陈大猫andClaude Opus 4.7 ee2c21e712 feat #1044: close tabs with the middle mouse button (#1058)
Middle-clicking a tab (mouse wheel click) is a conventional "close tab"
gesture in browsers and editors. Wire it to every closeable tab strip:
the top session / workspace / log-view / editor tabs and the SFTP tab bar.

A small shared helper (lib/tabInteractions.ts) handles the gesture:
onAuxClick closes the tab when button === 1, and onMouseDown calls
preventDefault for the middle button so the Chromium/Electron autoscroll
overlay does not appear. Left-click activation and right-click context
menus are untouched.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:58:19 +08:00
陈大猫 e678ad3546 fix terminal exit auto close (#1057) 2026-05-22 22:49:15 +08:00
陈大猫 c47c780b48 fix s3 checksum compatibility (#1056) 2026-05-22 22:41:25 +08:00
陈大猫 88074ac9b3 fix auto sync remote checks (#1055) 2026-05-22 22:26:05 +08:00
陈大猫 59cb0c4b65 fix #1043: skip pwd probe on network devices to keep Huawei VRP sessions alive (#1052) 2026-05-22 22:06:03 +08:00
陈大猫andClaude Opus 4.7 bf0bd193eb fix #1049: clear WebGL texture atlas to recover from garbled terminal (#1050)
Heavy full-screen TUIs (claude code / gemini cli / opencode), font changes,
and device pixel ratio changes can leave xterm.js's WebGL glyph texture atlas
in a corrupted state that persists for the life of the terminal — users see
persistent "garbled / 花屏" output that only clears when a brand-new terminal
is opened (most often on Windows with display scaling / multi-monitor setups).

Clear the texture atlas so glyphs re-rasterize at the correct scale instead of
forcing users to reopen the terminal:

- Add watchDevicePixelRatio() helper (TDD, unit-tested) that re-registers a
  matchMedia listener across DPI changes and fires a repair callback.
- Wire it into createXTermRuntime: on devicePixelRatio change, clear the atlas
  and refit; also clear the atlas on reflow (term.onResize). Watcher is torn
  down on dispose.
- Expose clearTextureAtlas() on XTermRuntime and call it after font changes in
  Terminal.tsx (xterm.js #3280). All calls are no-ops under the DOM renderer.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:25:38 +08:00
陈大猫 7661375925 fix huawei vrp ssh detection (#1046) 2026-05-22 01:05:46 +08:00
陈大猫 308fb45985 fix comware legacy ssh handshake (#1045) 2026-05-22 00:13:59 +08:00
陈大猫andClaude Opus 4.7 f4aa6ddb46 fix #1013: stop ghost text from drawing over untracked echoed input (#1042)
Inline (ghost-text) suggestions render suggestion.substring(trackedInput.length)
after the cursor, where trackedInput is a client-side reconstruction of the
command line (buffer heuristics + keystroke prediction, to mask SSH echo
latency). On hosts with non-standard echo — hardware bastion hosts / network OS
like `ecOS#` (#1013, previously #756 / #906) — that reconstruction drifts and
the ghost gets painted over characters the user already typed (`int` + ghost
`terface` -> `intterface`).

Add a fail-safe consistency check: on each post-echo render, if the real
terminal line before the cursor contains the tracked input followed by more
untracked, non-whitespace characters (reality is AHEAD of what we tracked),
hide the ghost instead of drawing it over real text. SSH echo latency is the
opposite case (the line is a prefix-behind of the tracked input) and is
deliberately not flagged, so the ghost stays responsive on slow links. The
check is ASCII-only (wide-char column mapping is ambiguous) and fail-open, so
it can only ever suppress a ghost that would otherwise corrupt — never change
correct behaviour.

This converts the recurring "ghost shows already-typed characters" bug into
"ghost simply doesn't show" on devices we can't track reliably.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:40:35 +08:00
陈大猫andClaude Opus 4.7 f6cb73fdd6 fix #1040: unique macOS Mach-O LC_UUID for Local Network privacy (#1041)
macOS keys the "Local Network" privacy permission on the main executable's
Mach-O LC_UUID (Apple TN3179). Electron's prebuilt binary is linked with LLD,
which derives the UUID from a content hash, so every app built from the same
Electron version ships the *same* LC_UUID even with a different bundle id. That
collision makes the grant unreliable: a user who enables Local Network for
Netcatty can still hit `connect EHOSTUNREACH` on LAN / VMware host-only
addresses, while loopback-forwarded connections work.

Add an electron-builder afterPack hook that rewrites the packaged macOS
executable's LC_UUID to a value derived deterministically from the appId —
stable across builds (so the grant survives updates) but distinct from every
other app. It runs before code signing, so signature/notarization cover the
patched binary. No-op on Windows/Linux.

Verified the rewrite on a copy of Electron's binary (LC_UUID changes, file
stays a valid Mach-O, deterministic) and added unit tests for the Mach-O
patcher (thin + fat) and the UUID derivation.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:11:25 +08:00
陈大猫andClaude Opus 4.7 3c100b0ae2 fix #1035: support diffie-hellman-group1-sha1 under BoringSSL (#1039)
Electron's BoringSSL dropped several standard MODP groups from the named
crypto.createDiffieHellmanGroup() API — notably the 1024-bit Oakley Group 2
(modp2) that backs SSH's diffie-hellman-group1-sha1. ssh2 calls
createDiffieHellmanGroup('modp2') for that kex, so connecting to legacy
network devices that only speak group1-sha1 failed with "Error: Unknown DH
group".

The underlying DH math still works on BoringSSL via createDiffieHellman()
with an explicit prime, so add a compatibility shim that wraps
createDiffieHellmanGroup and falls back to the well-known prime constants
when (and only when) the runtime can't resolve a group by name. On OpenSSL
builds the original call succeeds and the fallback is never used.

The shim is installed in main.cjs before any ssh2-using bridge loads, since
ssh2 destructures createDiffieHellmanGroup at module load. Once installed,
the existing legacy-group probe detects modp2 as supported again and offers
group1-sha1, so affected devices actually connect (still gated behind the
per-host legacy-algorithms toggle).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:51:50 +08:00
陈大猫andClaude Opus 4.7 168e42b5fa fix slow first SSH connect from DH group probe under BoringSSL (#1038)
The fixed-DH-group support probe called crypto.createDiffieHellmanGroup()
for each MODP group to feature-detect runtime support. Under Electron's
BoringSSL, instantiating the large groups is pathologically slow
(modp18/8192-bit takes ~20s on first call), and the result is only cached
in-process, so the first connection after every app launch froze for ~24s.

The standard modern groups (modp14/16/18) are universally supported and
always pass the probe anyway, so treat them as supported without probing.
Only groups a runtime may genuinely drop (e.g. BoringSSL removed the weak
1024-bit group1/modp2) are still feature-detected; those fail instantly.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:35:20 +08:00
陈大猫 2ce6bd5ed1 [codex] Reorder legacy SSH group-exchange fallback (#1034)
* reorder legacy ssh kex fallback

* add ssh handshake debug logging
2026-05-21 11:30:24 +08:00
陈大猫 7bd5d6465a fix claude system cli detection (#1033) 2026-05-21 00:11:51 +08:00
陈大猫 65387d4c61 fix legacy group exchange sha1 (#1032) 2026-05-20 23:19:07 +08:00
6084e8e94f fix(terminal): handle forced prompt newline (#1025)
* fix(terminal): handle forced prompt newline

* fix review issue

* fix(terminal): harden prompt newline handling

---------

Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-05-20 23:08:42 +08:00
陈大猫 3ccc5c9fc6 Fix broadcast hotkey refresh (#1030) 2026-05-20 16:30:25 +08:00
陈大猫 d07859f604 [codex] Prevent terminal host preference pollution (#1026)
* Prevent terminal host preference pollution

* Preserve terminal host updates while isolating session ports
2026-05-20 11:51:54 +08:00
陈大猫 88a322a03b [codex] Filter terminal cursor replies from broadcast input (#1022)
* Filter terminal CPR from broadcast input

* Handle split cursor reports in broadcast
2026-05-20 11:12:27 +08:00
陈大猫 0e02bbc2fb [codex] Persist vault host sort mode (#1021)
* Persist vault host sort mode

* Harden vault host sort persistence tests
2026-05-20 10:53:20 +08:00
陈大猫 affd9217e2 Fix session log capture after reconnect (#1020) 2026-05-20 10:53:04 +08:00
陈大猫 7b4a349e3f [codex] Guard unsupported legacy SSH groups (#1023)
* Guard legacy SSH DH groups

* Align legacy SFTP algorithms
2026-05-20 10:52:52 +08:00
陈大猫 7dc5ab5035 [codex] Use terminal cwd when opening SFTP (#1024)
* Use terminal cwd when opening SFTP

* Clear stale terminal cwd for SFTP open
2026-05-20 10:52:35 +08:00
yuzifu 3e8965f9a9 Fix pr987 (#1010) 2026-05-19 20:13:16 +08:00
陈大猫 23a27bf544 Handle missing streamed tool call ids (#1007) 2026-05-19 11:29:50 +08:00
陈大猫 86a815ad46 [codex] Optimize terminal tab switching (#1003)
* Optimize terminal tab switching

* Reduce themed tab switch repaint work
2026-05-18 22:19:54 +08:00
陈大猫 cb4fb091aa [codex] Fix browser loading of shared rule files (#1002)
* Fix local shell browser import

* Fix command blocklist browser import
2026-05-18 21:05:33 +08:00
陈大猫 b30696c98b Clean up dead code and duplicated helpers (#1001) 2026-05-18 20:00:10 +08:00
bincxz 6b8f05c65a Merge branch 'codex/fix-russian-settings-sync-icon' 2026-05-18 19:23:44 +08:00
bincxz 64dd3a4a2f Fix settings sidebar icon clipping 2026-05-18 19:23:36 +08:00
88732040aa fix(terminal): separate prompt after unterminated command output (#987)
* fix(terminal): separate prompt after unterminated command output
Add a display-layer prompt line break handler so recognized shell prompts move to the next visual line when the final command output line is not newline terminated.

Also add a terminal setting to toggle the behavior, sync support, i18n copy, and focused tests for prompt insertion.

* fix review issue

* Fix prompt cache initialization

* Serialize terminal output writes for prompt breaks

* Keep terminal status lines ordered with output

* Fix prompt arming without command callback

* Keep prompt display breaks out of session logs

* Avoid prompt breaks for output suffix matches

---------

Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-05-18 18:45:41 +08:00
ウィール スペースandbincxz b9f3bfa8bb Add i18n russian (#991)
* add i18n russian

* Added the Russian translation

* Complete Russian SFTP transfer translations

* Add Russian reconnect menu translation

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-05-18 16:55:02 +08:00
陈大猫 b7ec3c12f7 Handle ConPTY controls in Mosh password prompts (#1000) 2026-05-18 15:44:58 +08:00
DeepFal d20a18b862 Fix AI code block rendering fallback (#983) 2026-05-18 13:19:30 +08:00
陈大猫 ff6b4a4625 Broadcast pasted terminal input (#927) (#996)
* Broadcast user paste to terminals

* Use workspace session id for context paste broadcast

* Consume paste broadcast suppression before toggle check
2026-05-18 11:53:14 +08:00
陈大猫 5a94b4cf39 Preserve Unicode session log names (#988) (#998)
* Preserve Unicode session log names

* Harden Windows session log name handling
2026-05-18 11:42:43 +08:00
陈大猫 3963cd4af9 Fix remote path completion cwd (#993) 2026-05-18 11:32:04 +08:00
陈大猫 5b2a048917 Add transfer target path actions (#997) 2026-05-18 11:31:50 +08:00
陈大猫 2414cb00e4 Keep terminal tab after remote exit (#994) 2026-05-18 11:31:28 +08:00
陈大猫 03f980e939 Add reconnect terminal context action (#995) 2026-05-18 11:30:27 +08:00
Bet4 ac819fd4fd feat(workspace): add focus sidebar drag reorder (#992) 2026-05-18 01:26:14 +08:00
yuzifuandyuzifu fb9400a5fb fix #984: After running the clear command, the inline session log will be cleared (#990)
Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
2026-05-16 20:44:05 +08:00
陈大猫andClaude Opus 4.7 7da983a56c ci: auto-bump Homebrew tap on stable release tags (#938) (#976)
After the GitHub Release is published, push an updated Cask to
binaricat/homebrew-netcatty so `brew install binaricat/netcatty/netcatty`
stays current within minutes of the release. Stable tags only — prerelease
tags (v1.2.0-rc.1 etc.) are skipped to keep brew users on stable.

Implementation:
- New script .github/scripts/bump-homebrew-cask.sh computes SHA-256 of the
  arm64 + x64 DMGs already downloaded by the release job, sed-patches the
  Cask file in the tap repo, sanity-checks the result parses as Ruby, and
  pushes the bump. Idempotent on re-run when checksums match.
- New homebrew-tap job in build.yml runs after the release job on the same
  stable-tag gate, downloads the macOS artifact bundle, then runs the
  bump script with HOMEBREW_TAP_TOKEN.

Requires HOMEBREW_TAP_TOKEN secret with contents:write on
binaricat/homebrew-netcatty. With the secret missing the job will fail
fast at the env-var check with no side effects (no push attempted).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:01:26 +08:00
陈大猫andClaude Opus 4.7 344b226ce8 Fix #969: auto-fill saved password into PAM-style keyboard-interactive prompts (#974)
* Fix #969: auto-fill saved password into PAM-style keyboard-interactive prompts

Servers running stock PAM Linux configurations (most distros) only advertise
`keyboard-interactive` as their auth method, not `password` — so even when
the user has saved a password on the host, Netcatty was popping a modal
asking them to type it again. Every connect ended up being a two-password
flow: one to dispatch, one in the modal.

The shared `createKeyboardInteractiveHandler` factory now recognizes the
classic "PAM-wrapped password" challenge (a single prompt with
`echo === false`) and finishes it with the saved password directly,
skipping the modal. Real multi-prompt or echo-visible challenges (2FA / OTP
/ security questions) still go to the modal as before, and a wrong-password
auto-fill on the first attempt falls back to the modal on the retry so the
user can correct it.

Also consolidated startSSHSession's inline keyboard-interactive handler —
which duplicated ~45 lines of the factory logic without the auto-fill
fix — to use the factory with progress callbacks. The chain / SFTP /
port-forwarding bridges already went through the factory and pick up the
auto-fill for free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address Codex review: only auto-fill prompts that mention a password

The previous heuristic ("single prompt + echo=false + saved password →
auto-fill") would also fire for OTP / Duo / hardware-token challenges,
which are single hidden-echo prompts too. That would burn one auth
attempt per reconnect on those servers and could trip pam_faillock /
pam_tally2 lockout policies before the user ever saw the modal.

Add a prompt-text gate: auto-fill only when the prompt contains a known
password keyword (Latin "password" / "passwd"; CJK "密码" / "口令").
Custom-localized prompts that don't match fall through to the modal,
which is the same behavior as the pre-#969 baseline — strictly no
worse than before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address Codex review (round 2): exclude OTP vocabulary from auto-fill

The previous PASSWORD_PROMPT_PATTERN matched anything containing "password"
/ "passwd" / "密码" / "口令", which still let through OTP shapes that
happen to include those words: "Enter your one-time password", "动态密码"
(Chinese for "dynamic password" = OTP), "动态口令", "一次性密码", etc.

Add an OTP/MFA vocabulary check that runs before the password keyword
check. Any prompt containing OTP terminology (one-time, OTP, verification,
passcode, token, 2FA, two-factor, MFA, Duo, 动态, 一次性, 验证码, 令牌,
双因素, 多因素, 短信验证, 手机验证) is disqualified from auto-fill even
if it also matches the password keywords.

Tests cover both English "One-time password" and the three common Chinese
OTP phrasings, plus a regression guard that normal sudo-style password
prompts still auto-fill.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:36:07 +08:00
陈大猫andClaude Opus 4.7 86e47b5f9e Fix #972: stop false "fingerprint changed" warnings on every SSH connect (#973)
The host-key verifier was misclassifying connections as `changed` in three
situations that had nothing to do with a real key rotation:

1. Records imported from the system `~/.ssh/known_hosts` (or older builds)
   landed in localStorage without a `fingerprint` field. The verifier then
   re-derived the fingerprint from the stored `publicKey` blob on every
   connect — a brittle path that produced a different value than ssh2 if
   anything about the serialization differed by even one byte.
2. `classifyHostKey` had a loose "single candidate with unknown / empty
   keyType → changed" heuristic. Any imported record whose keyType failed
   to parse would be promoted to a rotation warning the first time the
   server presented a real algorithm, even though the user had never
   actually trusted any fingerprint for that algorithm.
3. A host that genuinely had multiple algorithms (e.g. one stored ssh-rsa
   record plus a live ssh-ed25519 handshake) was being reported as
   `changed` instead of `unknown`, even though we had no comparable
   record for the algorithm the server presented.

Tabby (`tabby-ssh/src/session/ssh.ts`) and OpenSSH both treat case (3) as a
first-time prompt rather than a mismatch; this change brings Netcatty in
line with that model.

Changes:
- `domain/knownHosts.ts` ports `fingerprintFromPublicKey` to TS and adds
  `normalizeKnownHost` / `normalizeKnownHosts` so the renderer can backfill
  legacy records on hydration. Pure-JS SHA-256 keeps the migration
  synchronous so it can run inline in `useVaultState` without async
  plumbing.
- `application/state/useVaultState.ts` runs the migration on hydration
  and on cross-window storage events. When anything changes on hydration
  the migrated list is written back to localStorage so the next launch
  starts clean.
- `components/KnownHostsManager.tsx` populates `fingerprint` at import
  time instead of leaving it for the verifier to re-derive.
- `electron/bridges/hostKeyVerifier.cjs` simplifies `classifyHostKey` to
  fingerprint-first, then strict (host, port, keyType) match for the
  changed branch, then fall through to `unknown`. Two existing tests
  that locked in the loose heuristic are updated to assert the new
  (safer) behavior, and a new test covers the multi-algorithm
  first-encounter case.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:41:36 +08:00
陈大猫andClaude Opus 4.7 37012da26a Use shadcn Button for the settings gear in the top tab bar (#967)
Follow-up on #966 which added `hover:bg-accent` to the existing raw
`<button>` element. That element is `h-full w-10`, so the new hover
fill spanned the entire title-bar height — a giant vertical accent
strip instead of the small icon-button highlight we wanted.

Replace the raw element with the same shadcn `Button variant="ghost"
size="icon" h-6 w-6` that every other icon on the same row already
uses. Wrap it in a centered container that keeps the title-bar height
for window-control alignment and carries `app-drag` so the empty
space around the icon still drags the window; the button itself stays
`app-no-drag`.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:28:29 +08:00
陈大猫andClaude Opus 4.7 0fd6a8c31d Add hover background to settings gear in top tab bar (#966)
Hovering the gear icon in the top tab bar left no visual response while
every other icon on the same row (AI, theme toggle, sync) lights up on
hover with the accent fill. The gear button is a raw `<button>` rather
than the shadcn `Button variant="ghost"` because it spans the full
title-bar height to align with the window controls, so it never picked
up the ghost variant's `hover:bg-accent`.

Adds the matching `hover:bg-accent` class so the gear behaves the same
as its neighbours. The inline `color` style for the resting state stays
in place; the accent fill on hover is what was missing.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:22:19 +08:00
陈大猫andClaude Opus 4.7 10af904681 Bring Duplicate / Copy Credentials to Pinned + Recently Connected menus (#965)
The right-click menu on host cards in the Pinned and Recently Connected
sections only exposed Connect / Edit / Pin-Unpin / Delete, while the
canonical "All hosts" listing also offers Duplicate and Copy Credentials.
There is no reason to omit those two for hosts you've pinned or recently
opened — the underlying handlers are already wired up.

Add the missing entries in the same order as the All-hosts menu so the
three context menus stay visually identical.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:17:28 +08:00
陈大猫andClaude Opus 4.7 b02b83f225 Place per-host statusbar tooltips below their triggers (#964)
The copy-host-address, broadcast and focus-mode buttons sit on the
per-host statusbar directly under the top tab bar. With the default
top-side tooltip placement, hovering any of them paints the tooltip
on top of the tab title above (the visible "Copy host address …"
covering "Rainyun-114.66.26.174" in the bug report screenshot).

Drop the tooltips on the bottom side instead, matching the
HoverCardContent panels already used for the CPU/Memory/Disk stats
buttons on the same bar.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:13:07 +08:00
陈大猫andClaude Opus 4.7 bca5d63a4e Fix #919: harden built-in Telnet handshake for legacy gear (#963)
* Fix #919: harden built-in Telnet handshake for legacy gear

The built-in Telnet client failed to advance past the welcome banner on
some older switch firmware (HP ProCurve 2610 reported in #919) and, in
the same session, leaked snippets of subnegotiation payloads into the
terminal display as random-looking characters. Three independent
correctness gaps in the old implementation, all rolled into one PR:

1. The negotiation parser was stateless per chunk. An IAC sequence
   split across TCP frames either dropped the lone IAC (lost command)
   or, for IAC SB...IAC SE blocks whose terminator landed in the next
   frame, fell through to "skip IAC SB and treat the rest as data" —
   spilling the subnegotiation payload (TERMINAL-TYPE strings,
   environment data) into the user's terminal as garbage.

2. The client was purely reactive — it only ever responded to options
   the server raised. Quite a bit of legacy equipment waits for the
   client to commit to SUPPRESS-GO-AHEAD / TERMINAL-TYPE / NAWS before
   it will continue past its banner, so connections silently hung at
   "Press any key to continue" forever.

3. Outbound user input was never IAC-escaped, so any 0xFF byte the user
   pastes (or that an alternate input encoding emits) would be read by
   the peer as the start of a command and eat the following byte.

Approach:

- New `electron/bridges/telnetProtocol.cjs` owns RFC 854 framing as a
  pure module. `createTelnetParser` is a stateful machine that buffers
  any partial command (lone IAC, IAC + verb, unterminated SB) across
  feeds and replays it once the rest arrives. Emits clean stream
  bytes, option commands and complete subnegotiations through
  callbacks. `escapeIacForWire` doubles 0xFF bytes on the way out with
  a cheap fast-path for the common (no 0xFF) case.

- `terminalBridge.cjs` flips telnet handling into a lazy mode: until
  the peer sends an IAC byte the connection is plain passthrough, so
  raw-TCP-on-port-23 services are not corrupted by the protocol layer.
  Once the protocol activates, we proactively request DO
  SUPPRESS-GO-AHEAD, WILL TERMINAL-TYPE and WILL NAWS, and track those
  in a `requestedOptions` Set so the peer's acknowledgement does not
  trigger another reply (the classic negotiation loop).

- TERMINAL-TYPE is now advertised as "XTERM-256COLOR" (upper-case);
  legacy boxes that case-sensitive-match termcap names recognise it.

- Resize-driven NAWS subnegotiations now only fire after the protocol
  has actually activated, so a passthrough session is never poisoned.

- Outbound writes for telnet sockets convert strings to UTF-8 buffers
  and run them through `escapeIacForWire`, so paste of binary content
  and non-ASCII input encodings round-trip safely.

Tests:
- 17 unit tests in `telnetProtocol.test.cjs` cover normal data,
  option commands, subnegotiation (including IAC IAC inside payload),
  every cross-frame split point (lone IAC, IAC + verb, mid-SB), the
  specific regression that previously leaked SB payload as data,
  ordering of data vs command callbacks, and the IAC escape helper.
- Existing 18 telnet auto-login tests still pass, exercising the
  end-to-end socket → parser → renderer path. Full suite: 825 / 0 / 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address review: per-direction Telnet negotiation tracking

RFC 858 §"Default Specification" treats WILL/WONT and DO/DONT as two
independent option streams. The first revision of this PR used a single
`requestedOptions` Set keyed by option byte, which incorrectly swallowed
a peer's independent request on the opposite direction whenever we had
our own request still pending for the same option.

Concrete failure mode (highlighted by code review on the PR): we send
`DO SGA` and the peer simultaneously sends `DO SGA` asking us to enable
SGA on our outgoing side. The old check matched the peer's DO against
our pending DO and returned silently, leaving the peer's request
unanswered — strict implementations would either time out or proceed in
the wrong mode.

Fix: split pending requests into `pendingDoRequests` (we sent DO,
awaiting WILL/WONT) and `pendingWillRequests` (we sent WILL, awaiting
DO/DONT). Acknowledgement matching is now direction-aware; the peer's
independent request on the orthogonal direction is treated as a fresh
negotiation and replied to.

While in there, the related bug uncovered by reviewing this code: when
the peer's `DO NAWS` acknowledges our own `WILL NAWS`, we previously
just dropped it on the floor — but the actual window-size SB payload
needs to follow the WILL handshake either way (whether the DO is an
acknowledgement of our WILL or an independent fresh request). The
negotiator now always pushes the size subnegotiation on `DO NAWS`.

Refactor: the negotiation policy lives in a new
`createTelnetNegotiator` factory inside `telnetProtocol.cjs`, separate
from the parser. That keeps `terminalBridge.cjs` thin and — more
importantly — makes the policy directly unit-testable. 13 new tests
cover the bidirectional-collision regression, the missing NAWS
follow-through, fresh vs ack handling for each verb, the canonical
handshake sequence, unsupported-option WONT/DONT replies, the
TERMINAL-TYPE SEND→IS roundtrip, and the 80×24 fallback for invalid
sizes.

Total: 30 parser+negotiator unit tests, 18 existing telnet auto-login
integration tests, full suite 838 / 0 / 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:07:51 +08:00
陈大猫andClaude Opus 4.7 67c5571df5 Fix #958: highlight IPv6 + allow editing built-in keyword rules (#962)
Two changes addressing both halves of #958:

1. IPv6 highlighting
   The built-in 'URL, IP & MAC' rule only shipped URL, IPv4 and MAC
   patterns, so compressed IPv6 addresses such as 2001:11:22:33::5 or
   fe80::d2dd:bff:fe79:f2bb were never highlighted. Add an IPv6 regex
   covering full and compressed forms (including ::1 and leading-/trailing-
   :: variants) and merge it into the same 'ip-mac' rule's patterns. The
   normalizer's existing "fill missing defaults" path means existing users
   pick this up on next start with no migration step.

2. Editable built-in rules
   Add an optional `customized` flag to KeywordHighlightRule. When false /
   absent, normalize re-syncs the rule's label/patterns with the shipped
   defaults (so future default-pattern upgrades reach users automatically).
   When true, normalize keeps the user's label/patterns/color/enabled
   verbatim, allowing built-ins like 'ip-mac' to be tailored.

   SettingsTerminalTab:
   - Pencil icon now appears on built-ins too. Editing one routes through
     the same dialog and flips `customized` on save.
   - The pattern field becomes a Textarea so multi-pattern built-ins (e.g.
     'error' ships seven spellings) can all be edited in one go.
   - A per-rule "↺" reset icon appears on customized built-ins and restores
     the shipped label/patterns while preserving the user's color/enabled.
   - The footer's "Reset to default colors" button is broadened into
     "Reset built-ins to defaults", restoring every built-in to shipped
     label/patterns/color and clearing `customized`.

Tests:
   New domain/keywordHighlight.test.ts (6 tests) covers IPv6 matches for
   both #958 examples plus loopback and full-form, IPv4/MAC still match,
   normalize migrates legacy non-customized 'ip-mac' to include IPv6,
   normalize preserves customized patterns, and normalize keeps user
   custom rules verbatim. Full suite: 808/0/3.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:35:07 +08:00
陈大猫andClaude Opus 4.7 ea5320d94a Fix #954: unify Tooltip styling + replace native selects (#961)
* Fix #954: unify Tooltip styling + replace native selects

Replace native HTML title= tooltips and native <select> dropdowns
with the existing Radix-based Tooltip / Select components so they
share the app's rounded styling, theme tokens and i18n pipeline.
Adds a global TooltipProvider in AppWithProviders so every
descendant Tooltip works without a per-file Provider wrapper.

Scope (driven by the issue #954 examples and "全部都处理" follow-up):

- TerminalLayer toolbar: Add Terminal / Split View / SFTP / Scripts
  / Theme / AI Chat / Move panel / Close panel.
- TopTabs middle bar: quick switcher, more tabs, AI assistant, theme
  toggle, settings; window-control buttons (min/max/close), tray
  close and hotkey reset/disable have their native title dropped per
  the user's explicit opt-out ("可以不用Tooltip,直接全局禁用
  原生title 属性").
- AI panels: AIChatSidePanel session history / new chat / delete,
  ConversationExport, AgentSelector, ChatInput attach / expand /
  permission, ModelSelector, ProviderCard, ai-elements/tool-call.
- SFTP: SftpSidePanel header, SftpBreadcrumb, SftpFileRow,
  SftpPaneToolbar, SftpTabBar, SftpTransferQueue.
- Settings: SettingsPage close, SettingsAppearanceTab theme/accent
  swatches, SettingsFileAssociationsTab edit/remove, SettingsSystemTab
  crash-log paths and global hotkey reset.
- Host vault: HostDetailsPanel (clear / suggestions / show-password /
  key path / browse key), GroupDetailsPanel, KnownHostsManager,
  ConnectionLogsManager, KeychainManager, SyncStatusButton,
  CloudSyncSettings, LogView, QuickSwitcher, ScriptsSidePanel,
  Terminal status bar copy-host + broadcast/focus, ZmodemProgressIndicator.
- Terminal subcomponents: HostKeywordHighlightPopover, TerminalComposeBar,
  TerminalConnectionDialog, TerminalSearchBar.
- Editor: TextEditorPane (subtitle, search, wrap, promote-to-tab).
- TrayPanel session rows and port-forwarding rows.

Native <select> migrated to custom Select component:
- SerialConnectModal (data bits, stop bits, parity, flow control)
- SerialHostDetailsPanel (same four fields)
- HostDetailsPanel backspace behavior
- GroupDetailsPanel backspace behavior
- SettingsTerminalTab local shell picker
- terminal/ThemeSidePanel font weight

Hardcoded English strings extracted to i18n. New keys for both
en and zh-CN: terminal.layer.*, topTabs.*, ai.chat.* (sessionHistory,
attach, collapse, expand, enableAgent), zmodem.*, settings.shortcuts.
resetToDefault. Inline help text on SnippetsManager package-name input
removed because the same hint is already shown in a visible <p> below
the input.

Existing per-file <TooltipProvider> wrappers (SnippetsManager,
ScriptsSidePanel, SelectHostPanel, RuleCard, HostDetailsPanel proxy
section) are left in place — they nest harmlessly under the global
provider and stay self-sufficient for component tests.

Tests:
- tsc clean for changed files (pre-existing repo-wide errors
  unrelated to this PR).
- All 802 tests pass (3 skipped pre-existing).
- HostDetailsPanel.proxyProfile.test and TextEditorPane.test
  updated to wrap with TooltipProvider, matching the runtime
  context now needed by the migrated components.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix #954: wrap Settings + Tray windows with TooltipProvider

Settings and the tray panel mount as separate Electron windows with
their own React root in index.tsx, so they do not inherit the global
TooltipProvider added under AppWithProviders. After the unified
Tooltip migration, any settings tab that used a Tooltip (Appearance,
Application, FileAssociations, System, Shortcuts, Terminal, AI
ProviderCard, AI ModelSelector) — and TrayPanel — threw
"Tooltip must be used within TooltipProvider" and rendered nothing.

Wrap both branches with TooltipProvider at the same level as
ToastProvider in index.tsx.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:14:24 +08:00
陈大猫andClaude Opus 4.7 ffd3111b71 Fix #957: persist SSH known-host trust across app restarts (#960)
useVaultState hydrates knownHosts asynchronously — its init awaits the
decryption of hosts, keys, identities and proxyProfiles before reading
knownHosts from localStorage. The state is briefly [] at boot even when
localStorage has saved entries.

The host-key verifier introduced in bce33f34 reads the renderer's
knownHosts state at connect time. Any SSH connect that fires inside
that hydration window (manual click or auto-restored session) sees an
empty trust list, marks every host as unknown, and prompts again. The
fix accepted by the user is saved to localStorage, but next restart
the same race repeats, giving the impression that fingerprints are
never persisted.

Use the existing getEffectiveKnownHosts helper at the two sites that
feed the SSH connect path (VaultView + TerminalLayerMount). The helper
falls back to localStorage while state is still settling, mirroring
the same pattern already applied to sync payloads (App.tsx:479).

Memoised on the knownHosts state so the prop reference is stable and
the TerminalLayer/VaultView React.memo equality checks still hold.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:24:06 +08:00
b0949f1a1e feat(sftp): add drive switcher dropdown for local Windows panes (#953)
* feat(sftp): add drive switcher dropdown for local Windows panes

On Windows, the SFTP breadcrumb's first segment (drive letter) now shows
a dropdown to switch between available drives. This makes it easy to
navigate across drives without manually editing the path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sftp): probe drives async to avoid blocking main process

fs.accessSync in the listDrives IPC handler could stall the Electron
main process for seconds per disconnected mapped drive or empty optical
drive. Use fs.promises.access with Promise.allSettled so the 26 probes
run in parallel without blocking the event loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-05-12 17:58:07 +08:00
陈大猫 84416d04bf [codex] Fix issue 957 long paste display (#959)
* Fix long paste display artifacts

* Fix serial line mode pasted chunks

* Narrow long paste display cleanup scope

* Strip only matched paste echo highlights

* Honor paste scroll setting through xterm paste
2026-05-12 17:33:31 +08:00
陈大猫andClaude Opus 4.7 109d0a7ab7 feat(terminal): add copy-host-address button to per-host statusbar (#951) (#952)
Adds a small clipboard-copy icon next to the host label / status dot in
the terminal pane's statusbar. Clicking copies the host's hostname
(IP or DNS name — what users called "machine IP" in #951) to the
clipboard and surfaces a toast.

The button only renders for non-local SSH/serial/telnet sessions —
local shells don't have an addressable hostname so showing it would
be confusing.

Placed in the pane statusbar (not the top tab) because the statusbar
is per-host: a workspace pane carries exactly one host, so the button
always identifies the right address. Top tabs in a workspace can share
multiple panes / hosts and would be ambiguous.

Visual treatment matches the surrounding stats buttons: 10px icon,
inline with the existing host label + status dot, opacity-60 →
opacity-100 on hover, `title` attribute for the tooltip to match the
pattern of the CPU/MEM/disk stats triggers right next to it.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:51:50 +08:00
陈大猫andClaude Opus 4.7 92ecd84edf Fix #939: per-host SSH keepalive override + cloud-friendly defaults (#947)
* fix(ssh): per-host keepalive override + cloud-friendly defaults (#939, #581)

Issues #939 (cloud / Aliyun sessions silently freezing after 15-20 min idle
because no SSH keepalive packets are sent) and #581 (older routers like
NOKIA / ALCATEL being killed by ssh2 after a few unanswered keepalives) are
in direct tension at the global-setting level: cloud users want keepalive
ON, embedded-device users want it OFF, and any single global default hurts
the other group.

Resolves the conflict by moving keepalive to a per-host setting (mirroring
the existing `legacyAlgorithms` per-host pattern), with cloud-friendly
global defaults:

Domain:
  - Host gains `keepaliveOverride?: boolean` + `keepaliveInterval?: number`
    + `keepaliveCountMax?: number`. When override is true, the host's
    values are used; otherwise the global TerminalSettings values apply.
    Per-field fallback so a host can override interval only or countMax only.
  - TerminalSettings gains `keepaliveCountMax: number` so the second knob
    (number of unanswered keepalives before declaring dead) is no longer
    hardcoded at 3 in the bridge.
  - DEFAULT_TERMINAL_SETTINGS: keepaliveInterval bumped from 0 to 30, and
    keepaliveCountMax = 10. Cloud LBs / NAT tables stay populated; brief
    network glitches don't trip the dead-connection check; an actually
    dead session is detected within ~5 minutes. Existing users with 0
    saved keep their value (no migration) — they were the #581 router
    cohort and their setup still works untouched.

Plumbing:
  - domain/host.ts adds resolveHostKeepalive(host, globalSettings) with
    five unit tests covering both directions of the override flag and
    per-field fallback.
  - components/terminal/runtime/createTerminalSessionStarters.ts uses the
    resolver when building startSSHSession options.
  - electron/bridges/sshBridge.cjs reads keepaliveCountMax from options
    (defaulting to 10) at both connection sites (direct + jump host) and
    still routes interval=0 through to a fully disabled keepalive
    (preserving #581's escape hatch).

UI:
  - Settings → Terminal → Connection grows a second input next to the
    existing interval: "Max unanswered keepalives".
  - Host details panel gains a Keepalive section with a "Override global
    keepalive" toggle that, when on, exposes per-host interval +
    countMax inputs and an inline hint when interval = 0 (explaining
    the implications). Same visual pattern as the existing Legacy
    Algorithms section.

Sync:
  - keepaliveCountMax added to SYNCABLE_TERMINAL_KEYS so the new global
    field rides existing sync infrastructure. Per-host fields ride the
    hosts array passthrough automatically (older clients receiving them
    ignore unknown fields, per the existing lenient sync contract).

i18n: en + zh-CN strings for the new settings row, the host section
header, and the override toggle / inputs / disabled hint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): resolve keepalive per jump host, not just the final target

Addresses codex review on PR #947:
  https://github.com/binaricat/Netcatty/pull/947#discussion_r3217027xxx

The first cut only resolved keepalive for the final target host and
forwarded a single interval/countMax pair across the whole start-SSH
call. connectThroughChain in sshBridge.cjs then applied that one pair
to every hop, so a chain like:

   router (bastion, needs keepalive=0)  →  cloud target (needs 30s)

would either kill the router (with cloud-friendly defaults) or fail
to keep the target alive (with router-friendly 0). The per-host
override was effectively useless for bastion hosts.

Fix:
  - NetcattyJumpHost gains optional keepaliveInterval / keepaliveCountMax.
  - createTerminalSessionStarters runs resolveHostKeepalive() per
    jumpHost when building the chain, so each hop carries its own
    resolved pair.
  - sshBridge.cjs's chain connector reads jump.keepaliveInterval /
    jump.keepaliveCountMax for each hop, falling back to the call's
    target-level options for backward compatibility with older
    serializers that don't yet populate the per-hop fields.

The final target's keepalive path is unchanged — it still reads
options.keepaliveInterval / options.keepaliveCountMax that the
session starter resolves from the target host.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): per-host keepalive for SFTP + port forwarding too

Follow-up to the maintainer review on PR #947 — terminal SSH was the
only path that honored per-host keepalive overrides. SFTP and port
forwarding share the same NetcattyJumpHost type but their builders
weren't resolving keepalive per-hop, and their bridges hardcoded the
old 10s/3 defaults. Net result: a router-as-bastion in a chain still
got killed when reached via the SFTP file panel or a port-forwarding
tunnel, even though the user had toggled per-host override.

Plumbing:
  - useSftpHostCredentials / buildSftpHostCredentials: accept optional
    terminalSettings; call resolveHostKeepalive() for the target and
    each jump entry; emit keepaliveInterval / keepaliveCountMax in the
    returned NetcattySSHOptions.
  - useSftpConnections + useSftpState + SftpStateOptions thread the
    setting down. SftpSidePanel passes the global terminalSettings prop
    it already has from TerminalLayer.
  - portForwardingService.startPortForward: accepts terminalSettings
    as an 8th argument, resolves per-host (target + each jump), and
    populates the bridge payload.
  - usePortForwardingState.startTunnel and usePortForwardingAutoStart
    forward the new parameter; App.tsx supplies terminalSettings (via
    a ref in the once-on-launch auto-start effect so changing global
    keepalive later doesn't re-fire it).

Bridges:
  - sftpBridge.cjs target connect: now also reads keepaliveCountMax
    from options (was hardcoded 3). 10s/3 stays as the bridge-level
    fallback to preserve the #669 protection when the renderer hasn't
    supplied a value.
  - sftpBridge.cjs jump hop: reads jump.keepaliveInterval /
    jump.keepaliveCountMax, then falls back to the target-call options
    (matches the symmetric SSH bridge change).
  - portForwardingBridge.cjs: reads keepaliveInterval /
    keepaliveCountMax from the IPC payload; same 10s/3 fallback.

Types:
  - NetcattyJumpHost already grew keepalive fields earlier; this
    commit also adds them to PortForwardOptions so the IPC contract
    is explicit.

End-to-end: a chain `[router-as-bastion, cloud-host]` with the
router host's keepaliveOverride=true / interval=0 now correctly
disables keepalive on the router hop for terminal SSH AND SFTP AND
port forwarding, while the cloud target still gets the resolved
30s/10 default for each path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): honor explicit keepalive=0 in SFTP + port forwarding bridges

Addresses codex review on PR #947:
  - https://github.com/binaricat/Netcatty/pull/947#discussion_r3217448xxx
  - https://github.com/binaricat/Netcatty/pull/947#discussion_r3217449xxx

The previous follow-up commit (5c8bc923) plumbed per-host keepalive
into SFTP / port forwarding but kept the existing bridge-level
"if interval > 0 use it, else 10s" fallback. That collapsed two
semantically distinct inputs:

  - "user explicitly resolved interval = 0" (host with keepaliveOverride
    + interval=0; the whole point of the override)
  - "no value supplied at all" (legacy serializer)

Both ended up as 10s in the bridge, so a router-as-bastion / direct
router connection through SFTP or a port-forward tunnel still got
ssh2-killed after countMax unanswered probes — exactly the case
per-host override was supposed to fix.

Fix: bridges now distinguish on `== null`:
  - positive value → honor it
  - explicit 0 → truly disabled (0 ms, 0 countMax — ssh2 skips its
    dead-connection check entirely on this connection)
  - undefined / null → fall back to 10s/3 (preserves #669 idle-NAT
    protection for older callers that pre-date per-host plumbing)

Applies to both SFTP target connect and SFTP jump hop builders, plus
the port forwarding target builder. Terminal SSH bridge is unchanged
since it already treated 0 as disabled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): plumb terminalSettings to all remaining keepalive call sites

Addresses codex review on PR #947:
  - PortForwardingNew + TrayPanel were not passing terminalSettings into
    startTunnel, so tunnels started from the main port-forwarding UI or
    from the tray menu silently used the FALLBACK 30/10 instead of the
    user's actual global keepalive settings. Hosts inheriting global
    policy could see different behavior depending on the entry point.
  - SftpView was not threading terminalSettings into useSftpState, so
    SFTP connections opened from the main tab UI also fell back to the
    same hardcoded default and ignored the user's settings.

Wiring:
  - PortForwardingProps gains `terminalSettings`; VaultView accepts it
    on the same prop and forwards from its own new prop; App.tsx
    supplies it from useSettingsState. The startTunnel call site uses
    it directly and includes it in the useCallback dep list so the
    handler updates when settings change.
  - SftpViewProps gains `terminalSettings`; SftpViewMount accepts and
    forwards it; the sftpOptions memo includes it in its dep list.
  - TrayPanelContent gains a `terminalSettings` prop; the TrayPanel
    wrapper (which already calls useSettingsState for uiLanguage)
    passes it down so the standalone tray window agrees with the main
    window's settings.

Also updates the explicit `startTunnel` signature in
UsePortForwardingStateResult so callers see the new 8th parameter
through the hook's return type, not just through the implementation.

Net result: every place that starts an SSH-derived connection
(terminal session, SFTP browse, port-forward tunnel) now consistently
sees the user's configured global keepalive policy and any per-host
overrides; the FALLBACK_KEEPALIVE constants in the service /
credentials builder are now only reached by genuinely-decoupled call
sites (tests, headless usage) rather than masking missing wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): include terminalSettings keepalive fields in memo comparators

Addresses codex review on PR #947 — all three components that grew a
`terminalSettings` prop (SftpView, SftpSidePanel, VaultView) are wrapped
in React.memo with manual equality comparators, and none of those
comparators were updated to include the new prop. React would skip the
re-render when global keepalive changed, so new SFTP / port-forwarding
connections from those subtrees would silently keep using the old
keepalive policy until some other tracked prop happened to flip.

Each comparator now compares the keepalive fields directly rather than
the whole terminalSettings object — only those two fields drive
connection resolution in this subtree, and ignoring the rest avoids
unnecessary re-renders for unrelated terminal-setting changes (fonts,
themes, etc.) that already have their own targeted comparator entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:22:12 +08:00
DeepFal 311f44525b Fix AI export menu theme colors (#944) 2026-05-11 15:00:49 +08:00
陈大猫andClaude Opus 4.7 b4e185e1c6 fix(terminal): restore right-click paste in mouse-tracking TUIs (#941) (#946)
When a TUI app enables SGR mouse tracking (opencode, tmux with
`mouse on`, vim with `set mouse=a`, etc.), Terminal.tsx attaches a
capture-phase contextmenu listener that calls
stopImmediatePropagation. The original purpose is to bypass xterm.js's
own right-click handler — which calls textarea.select() and dismisses
TUI popup menus — but stopImmediatePropagation also kills the bubble
that React's onContextMenu delegation relies on, so
TerminalContextMenu's handleRightClick never fires.

Result: with `rightClickBehavior` set to "paste" (or "select-word"),
right-click silently does nothing inside any mouse-tracking TUI. Menu
mode still works because Radix opens via pointerdown (not affected by
the contextmenu capture block). Middle-click paste works because its
auxclick listener in createXTermRuntime is also unrelated to
contextmenu.

Fix: have the capture handler itself dispatch the user's chosen
right-click action when it intercepts the event. terminalContextActions
already exposes onPaste / onSelectWord; mirror them into a ref so the
once-bound capture handler can call the current implementation
without re-binding on every action identity change.

'context-menu' mode is intentionally not handled in the capture path —
Radix's pointerdown listener opens the menu independently.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:00:09 +08:00
陈大猫andClaude Opus 4.7 92dd898eb4 Fix #931: let users pick a CJK font + per-font smart pairing (#940)
* feat(fonts): add CJK font pairing composition module

Introduces composeFontFamilyStack() which builds the xterm fontFamily
CSS string at runtime from:
  - the user's primary Latin font
  - an explicit CJK font (TerminalSettings.fallbackFont) if set
  - otherwise a per-Latin-font recommended CJK pairing
  - a hardcoded system CJK fallback stack
  - a Nerd Font icon fallback stack
  - the universal monospace generic

14 unit tests cover composition order, deduplication, OS defaults,
quoting, and recommendation override behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(fonts): expose raw Latin families and add CJK-coverage entries

- TERMINAL_FONTS[].family no longer bakes in the CJK fallback stack;
  composition is deferred to runtime via composeFontFamilyStack().
- Drops withCjkFallback helper from this module and its caller in
  lib/localFonts.ts.
- Adds 6 CJK-coverage primary fonts to the dropdown: Sarasa Mono SC/TC,
  Maple Mono CN, LXGW WenKai Mono, Microsoft YaHei UI, PingFang SC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(terminal): compose font-family stack with user-configurable CJK fallback

resolvedFontFamily now passes through composeFontFamilyStack(), which
prepends the user's TerminalSettings.fallbackFont (if set) ahead of the
per-Latin-font recommended CJK pairing and the system fallback stack.

The platform argument is derived from navigator.platform inside the
useMemo, so the same Latin font may pair with PingFang SC on macOS and
Microsoft YaHei UI on Windows out of the box.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(settings): add CJK font picker to terminal settings

Adds a new "CJK font" select row right under the main font selector in
the Terminal settings tab. Bound to TerminalSettings.fallbackFont (an
already-existing-but-unused field), so this needs no schema or sync
payload change.

Default value "Auto" leaves fallbackFont empty, which lets the new
per-Latin-font pairing in cjkFonts.ts pick a CJK font automatically.
Selecting any explicit option (Sarasa Mono SC, PingFang SC, Microsoft
YaHei UI, etc.) takes precedence over the per-font pairing.

Includes en + zh-CN i18n strings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(sync): cover fallbackFont round-trip + legacy payload tolerance

Four new test cases verify cloud-sync compatibility for the new CJK
font setting:

  - buildSyncPayload includes fallbackFont when set
  - buildSyncPayload omits fallbackFont when unset
  - applySyncPayload writes incoming fallbackFont to TERM_SETTINGS
  - applySyncPayload from a legacy client (no fallbackFont) does NOT
    wipe the local value — critical for old-to-new upgrades

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(fonts): add font availability detection (canvas + document.fonts API)

Three-layer detection used by isFontInstalled(family):
  1. Known @fontsource-bundled families (e.g. JetBrains Mono) always
     count as installed.
  2. document.fonts.check() — picks up @font-face and system-loaded fonts.
  3. Canvas width measurement against serif / sans-serif / monospace
     fallbacks; only counts if the target font produces a width that
     differs from ALL three generics for a probe string.

detectInstalledWithContext is a pure function taking an injected
measurement context, which keeps the canvas / DOM behind a seam and
lets the logic be unit-tested without a browser. 11 tests cover
quoted-family parsing, the three-generic-fallback rule, bundled
short-circuit, and document.fonts.check fast-path.

Results are cached per process; clearFontAvailabilityCache() invalidates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(fonts): filter dropdowns to fonts actually installed on this machine

Layer 3 of #931 added Sarasa Mono SC / Maple Mono CN / Microsoft YaHei UI
/ PingFang SC etc. to the terminal font dropdown, but users who don't
have these installed would still see them and pick them — resulting in
"I changed the font and nothing happened" confusion.

This commit filters both dropdowns through isFontInstalled():

  - TerminalFontSelect: drops any built-in or system-discovered font
    that detection can't render. If filtering would leave fewer than 4
    fonts (detection misfire safety net), shows the full list.

  - TerminalCjkFontSelect: keeps the "Auto" sentinel always, drops
    concrete CJK choices that aren't present on this machine.

Both selects always keep the currently-selected value visible — even
when the underlying font is missing — so users can read and clear
their setting without surprise.

Also expands `npm test` globs to pick up infrastructure/config/*.test.ts
and lib/*.test.ts, which previously matched no patterns and meant the
new cjkFonts and fontAvailability suites were silently excluded from
CI runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): never recommend proportional CJK fonts for terminal use

The previous PingFang SC / Microsoft YaHei UI / Hiragino Sans GB choices
were proportional sans-serif fonts whose CJK glyphs aren't designed to
fit a terminal's 2x cell grid — the rendered Chinese ended up visibly
wider than its allocated cells, breaking grid alignment (reported on
macOS with PingFang SC selected as the CJK font).

Changes:
  - TerminalCjkFontSelect: drops PingFang SC / Microsoft YaHei UI /
    Hiragino Sans GB from the dropdown. Legacy explicit selections
    still surface as a synthetic "not recommended" option so users can
    see and re-pick.
  - CJK_SYSTEM_FALLBACK_FONTS: monospace-only list. Sarasa Mono SC/TC,
    Maple Mono CN, LXGW WenKai Mono, Noto Sans Mono CJK SC, Source Han
    Mono SC, NSimSun, SimSun. Proportional fonts removed.
  - PER_FONT_CJK_PAIRING: every entry now points at a true monospace
    CJK font. Cascadia / Consolas / Menlo etc. all recommend Sarasa
    Mono SC, which the next commit bundles via @font-face.
  - getDefaultCjkFallback: Windows = SimSun (always installed,
    monospace); macOS = Sarasa Mono SC (will be bundled); Linux =
    Noto Sans Mono CJK SC. A regression test enforces that no
    per-OS default is a known proportional font.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(fonts): bundle Sarasa Mono SC as the universal CJK monospace

Previous commit removed proportional CJK fonts (PingFang SC, etc.)
from the picker and switched per-OS defaults to true monospace, but
macOS ships NO system-installed monospace CJK font — leaving macOS
users with a broken default unless they manually install Sarasa or
similar. This commit closes that gap by bundling Sarasa Mono SC as
an @font-face webfont, so the recommended pairings and macOS default
"just work" out of the box.

Details:
  - public/fonts/SarasaMonoSC-Regular.woff2 (~4.8 MB): subsetted from
    be5invis/Sarasa-Gothic v1.0.37 SarasaMonoSC-Regular.ttf (24 MB).
    Covers ASCII, Latin-1, common punctuation/symbols, CJK Unified
    Ideographs main block, Hiragana/Katakana, halfwidth/fullwidth,
    box-drawing — the everyday-Chinese coverage that matters for a
    terminal. Rare CJK Ext-A/B/historical chars fall through to the
    system fallback stack.
  - public/fonts/SarasaMono-LICENSE.txt: OFL-1.1 verbatim, required
    by the license.
  - index.css: @font-face declaration with font-display: swap so the
    user doesn't see a flash of nothing while the woff2 loads.
  - KNOWN_BUNDLED_FAMILIES: "Sarasa Mono SC" added so the dropdown
    availability filter doesn't hide it.

Installer impact: ~+4.8 MB (vs current ~100-200 MB Electron baseline).
The font replaces what would otherwise have been "Chinese chars look
broken in the terminal" for every macOS user without a manually
installed CJK monospace font.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): use Local Font Access API as the authoritative install check

document.fonts.check() turned out to be unreliable as an installed-font
signal in Chromium — it returns true for any syntactically-valid family
name regardless of whether the font is actually installed, as a
deliberate fingerprinting-mitigation. The previous detector took it as
a positive signal and ended up keeping uninstalled fonts in the dropdown
(reported by a macOS user seeing dozens of fonts they don't have).

This commit pivots the detection chain:

  - lib/localFonts.ts: getAllSystemFontFamilies() exposes the unfiltered
    set of installed family names from queryLocalFonts(), reusing the
    same underlying call as getMonospaceFonts() via a shared cache.

  - lib/fontAvailability.ts: drops the document.fonts.check fast-path.
    Adds setSystemFamilies() / hasAuthoritativeData(). When the set has
    been populated, isFontInstalled answers from membership lookup
    directly — no canvas guessing. Canvas remains as a fallback for
    environments where the Local Font Access API is unavailable or
    permission is denied.

  - application/state/fontStore.ts: during initialize(), runs the
    monospace-only query and the full-system-families query together,
    then pipes the result into fontAvailability.

  - TerminalFontSelect: with authoritative data, drops the "if filtered
    list is suspiciously small, show all" safety net. Empty would now
    really mean empty (highly unlikely since Sarasa Mono SC is bundled).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): drop PingFang SC / Microsoft YaHei UI from primary dropdown

Step 1 of this PR removed proportional CJK fonts from the CJK fallback
picker but left them in BASE_TERMINAL_FONTS, so PingFang SC and
Microsoft YaHei UI were still selectable as the *primary* terminal
font. Picking PingFang SC as primary produced visibly bloated Latin
character spacing (xterm.js samples cell width from the primary font;
the wide proportional 'M' inflates every cell), reported by a macOS
user in the same thread that opened #931.

Both entries are removed from BASE_TERMINAL_FONTS. A new
infrastructure/config/fonts.test.ts asserts that no known proportional
CJK font name (including PingFang TC/HK, Microsoft YaHei variants,
Hiragino Sans GB, Heiti SC/TC) is ever shipped in TERMINAL_FONTS as a
primary choice.

Migration for users already saved to one of the removed ids:
useSettingsState rewrites STORAGE_KEY_TERM_FONT_FAMILY to the default
(Menlo) on read when it sees a deprecated id, so the bad value also
stops getting carried into cloud-sync uploads. Per-host fontFamily
overrides are NOT migrated automatically — they still gracefully
fall through to the dropdown's first entry via the existing
getFontById fallback; users can re-pick from the host settings UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): drop Comic Sans MS — it's a proportional handwriting font

Same symptom as the PingFang SC / Microsoft YaHei UI removal: Comic
Sans MS was historically in the primary font dropdown labeled
"Casual, non-traditional terminal font", but Comic Sans is a
handwriting-style proportional sans-serif. Picking it as the terminal
primary inflates cell width and spaces every Latin character far
apart (reported in the same #931 thread).

- BASE_TERMINAL_FONTS: comic-sans-ms entry removed.
- DEPRECATED_PRIMARY_FONT_IDS: gains comic-sans-ms so existing
  selections silently migrate to Menlo on read.
- fonts.test.ts: the proportional-font ban list now also covers
  Latin proportional fonts (Comic Sans MS, Arial, Helvetica, Times
  New Roman, Georgia, Verdana, Trebuchet MS, Tahoma) so the test
  catches any future mislabeled body-text font from being added to
  the terminal dropdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): keep monospace ahead of CJK fallbacks in composed stack

Addresses codex P1 review comment on PR #940
(https://github.com/binaricat/Netcatty/pull/940#discussion_r3216017737).

The previous behavior of withCjkFallback() had monospace immediately
after the primary family, before any CJK fallback. composeFontFamilyStack
had moved monospace to the very end, which means: when the primary
font isn't installed on the user's machine (common for Layer 3 CJK
choices that aren't bundled and not present on a given OS, or for any
built-in id like cascadia-code on a Linux system without it), CSS
per-glyph fallback resolves Latin glyphs from a CJK font's full-width
Latin variants before ever reaching monospace generic. That breaks
xterm.js's fixed cell-grid alignment.

The composed stack now reads:
  <primary>, monospace, <userFallback>, <recommended-cjk>,
  <system-cjk-stack>, <nerd-font-stack>

Per-glyph CSS fallback behavior:
  - Latin → primary if installed → monospace generic. Cell width
    stays consistent.
  - CJK → primary (no) → monospace (no Chinese glyphs) → walks into
    CJK fallbacks.
  - Nerd PUA → falls past all of the above into the Nerd Font stack.

Updates the position-invariant tests and adds a regression test that
explicitly asserts monospace appears before every CJK family in the
output stack.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): dedupe Local Font Access API calls under concurrent init

Addresses codex P2 review on PR #940:
  https://github.com/binaricat/Netcatty/pull/940#discussion_r3216246xxx

fontStore.initialize() runs getMonospaceFonts() and
getAllSystemFontFamilies() in Promise.all; both internally called
queryAllSystemFontsOnce(), whose cache check (`if (cache) return`) was
only useful once the result had been written. Concurrent callers both
passed the empty-cache check and fired their own queryLocalFonts()
request — two real Local Font Access API invocations on cold start,
with the risk of one succeeding while the other was denied (leaving
the authoritative set unset).

Fix: cache the *in-flight promise itself*, so subsequent callers
await the same single invocation. The first await populates the
family-set cache as a side effect, and the resolved promise keeps
returning the same value to every subsequent caller.

Adds lib/localFonts.test.ts with three regression tests:
  - concurrent getMonospaceFonts + getAllSystemFontFamilies = 1 API call
  - sequential repeats also reuse the resolved promise
  - missing API returns null authoritative set (canvas fallback signal)

Exports __resetLocalFontsCacheForTesting() so each test gets a fresh
module-level state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): retry LFA on transient failure + notify on availability changes

Two follow-up fixes from codex P2 review on PR #940:

1) queryAllSystemFontsOnce() previously kept its in-flight promise even
   when queryLocalFonts threw. Subsequent callers reused the cached
   empty result for the rest of the session, so any transient failure
   at boot (permission state not ready, AbortError, etc.) permanently
   blinded the rest of the app to installed fonts. Catch now clears
   queryPromise so the next caller retries. Regression test added.

2) TerminalCjkFontSelect.visibleOptions and TerminalFontSelect
   .visibleFonts were memoized on [value] / [fonts, value] only, but
   the filter calls isFontInstalled() which reads module-level
   systemFamilies — a value that arrives asynchronously after the
   initial render. The memos never recomputed when authoritative
   availability data landed, so the dropdowns could continue showing
   stale "filtered" results until the user changed selection.

   fontAvailability now exposes subscribeFontAvailability() and
   getFontAvailabilityVersion() (monotonic counter bumped on
   setSystemFamilies / clearFontAvailabilityCache). Both selects
   subscribe via useSyncExternalStore and include the version in
   their memo deps; tests cover subscriber notification and version
   monotonicity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): migrate host/group deprecated font ids + localize CJK labels

Two follow-up fixes from codex review on PR #940:

P2 — Host/group level font migration
====================================
The earlier deprecated-id migration only rewrote
STORAGE_KEY_TERM_FONT_FAMILY, so hosts and group configs that had
explicitly opted into a now-removed font id (e.g. pingfang-sc,
microsoft-yahei, comic-sans-ms) kept `fontFamily` set with
`fontFamilyOverride=true`. After the dropdown entries were dropped
in 9f2bd282/c9b622d8, those records silently fell through to the
first font in the registry (Menlo) while the override flag still
read "true" — users saw a host claiming a custom font but rendering
the global default with no way to tell what happened.

Fix:
  - infrastructure/config/fonts.ts gains migrateDeprecatedFontOverride(),
    a structurally-shared helper that drops fontFamily and clears
    fontFamilyOverride when the id is deprecated.
  - sanitizeHost now runs it on every host load.
  - domain/groupConfig.ts grows sanitizeGroupConfig(); useVaultState
    applies it both on initial load and on cross-tab storage events.
  - Existing decrypt → sanitize → encrypt round-trip in useVaultState
    means the migrated values are persisted back to localStorage and
    propagate through cloud sync naturally.

Tests: two each in domain/host.test.ts and domain/groupConfig.test.ts
covering deprecated-id reset and untouched-valid-id preservation.

P3 — Localize CJK font option labels
====================================
TerminalCjkFontSelect previously hardcoded Chinese option labels
("Auto · 按主字体智能搭配", "Sarasa Mono SC (更纱黑体 简)", etc.) and
the synthetic "not recommended" warning. Non-Chinese locales saw a
mixed-language UI despite the rest of the setting going through i18n.

OPTIONS now references i18n keys; the component looks them up via
useI18n(). Both en and zh-CN locales gain matching keys, including
`...option.legacy` with `{font}` interpolation for the synthetic
"not recommended" item that surfaces saved-but-removed values.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): also sanitize group configs on the write/import path

Addresses codex P2 review on PR #940:
  https://github.com/binaricat/Netcatty/pull/940#discussion_r3216314xxx

The previous commit (09c87820) added sanitizeGroupConfig() but only
plumbed it into the decrypt paths (initial load + storage event).
updateGroupConfigs() — which is also the write path used by
applySyncPayload / importVaultData when ingesting a legacy payload —
still set state from raw input. A sync from an older client carrying
{ fontFamily: "pingfang-sc", fontFamilyOverride: true } would land in
memory unsanitized AND be re-persisted with the bad override active
until the next reload re-ran the decrypt path.

Fix mirrors updateHosts → sanitizeHost: map every incoming entry
through sanitizeGroupConfig before both setGroupConfigs and the
encrypt-and-persist step. Same call site now feeds the cleaned data
to localStorage, so legacy values are scrubbed on first import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): migrate deprecated terminal font ids on every ingest path

Addresses codex P2 review on PR #940:
  https://github.com/binaricat/Netcatty/pull/940#discussion_r3216517xxx

The previous migration only ran in the initial useState() initializer
for terminalFontFamilyId, so deprecated ids (pingfang-sc /
microsoft-yahei / comic-sans-ms) could still re-enter state via:

  - rehydrateAllFromStorage() at line ~527 — runs on remote-import
    completion and re-reads STORAGE_KEY_TERM_FONT_FAMILY raw.
  - The notifySettingsChanged IPC handler at line ~663 — fires when a
    cloud sync or programmatic localStorage write announces a change.
  - The cross-window storage event handler at line ~873.

Any of these paths could pull a deprecated id back into state after
the initial migration ran, leaving the font selector with no matching
option and silently rendering the global default while continuing to
propagate the stale value through subsequent sync uploads.

Centralizes the migration in migrateIncomingTerminalFontId(raw):
  - returns null when raw is empty
  - if raw is deprecated, writes DEFAULT_FONT_FAMILY back to
    localStorage AND returns it
  - otherwise returns raw unchanged

All four ingest sites (initial init, rehydrate, IPC, storage event)
now route through this helper. The rewrite-on-deprecated semantics
also guarantee that the moment any path sees a bad value, the next
sync upload carries the cleaned default — not the deprecated id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): use bundled Latin-only fallback instead of monospace generic

Resolves the tension between codex's two P1 reviews on PR #940:

  Round 1 (da1fe4cd): "monospace must come BEFORE CJK fallbacks" —
    otherwise Latin glyphs fall into a CJK font's full-width Latin
    when the primary font is missing.

  Round 2 (this commit): "monospace must come AFTER CJK fallbacks" —
    otherwise on macOS Chrome, the generic `monospace` pulls in
    PingFang via Chromium's CJK system fallback and silently masks
    the user's CJK picker.

Both are right; using a single `monospace` token can't satisfy both
roles because `monospace` is a generic family whose CJK-glyph
coverage is platform-dependent.

Fix mirrors Tabby's approach (their "monospace-fallback" SourceCodePro
sitting before any CJK in the chain): insert a known Latin-only
bundled font between the primary and CJK fallbacks. JetBrains Mono is
already shipped via @fontsource/jetbrains-mono and carries no CJK
glyphs, so it catches Latin without intercepting Chinese.

New stack order:
  <primary>, "JetBrains Mono", <userFallback>, <recommended-cjk>,
  <system-cjk-stack>, <nerd-font-stack>, monospace

Per-glyph CSS fallback now behaves as intended on every platform:
  - Latin: primary (if installed) → JetBrains Mono. Cells stay aligned.
  - CJK: primary (no) → JetBrains Mono (no CJK glyphs) → user CJK pick.
  - Nerd PUA: all of the above → Nerd Font stack.

Replaces the two prior positional-invariant tests with one for each
codex review concern: JetBrains Mono precedes every CJK family
(Latin alignment), and user CJK precedes generic monospace (CJK
picker effectiveness).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): use OR-of-fallbacks for canvas font detection

Addresses codex P2 review on PR #940:
  https://github.com/binaricat/Netcatty/pull/940#discussion_r3216556xxx

detectInstalledWithContext required the target font to produce a
different rendered width from *all three* generic fallbacks (serif,
sans-serif, monospace) to be counted as installed. That's too strict:
on macOS the `monospace` generic resolves to Menlo itself, so
measure(`"Menlo", monospace`) === measure(`monospace`), and the
detector reported Menlo as missing even when it was clearly installed.
The same false-negative trap exists for any font that happens to
share metrics with one of the three generics on a given platform.

Switches to OR-of-fallbacks: a font counts as installed if its
rendered width differs from at least one generic baseline. A truly
uninstalled font still falls through to each generic in turn and
matches all three baselines, so this doesn't introduce false positives.

Regression tests added for both directions:
  - Menlo with metrics identical to `monospace` generic → installed.
  - "Definitely Not Installed" font → still reported missing.

The path only fires when the Local Font Access API is unavailable or
denied — when LFA succeeds, `setSystemFamilies` short-circuits ahead of
canvas — so this primarily improves the degraded-permission scenario.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): quote-aware tokenizer for font-family lists

Addresses codex P2 review on PR #940:
  https://github.com/binaricat/Netcatty/pull/940#discussion_r3216559xxx

composeFontFamilyStack and extractPrimaryFamily both tokenized their
input with a raw String.split(',') — which corrupts any CSS family
list whose quoted family name contains a comma (CSS allows that, e.g.
`"Foo, Inc. Mono"` is a single family). A naive split would shred
that into `"Foo` / `Inc. Mono"` and emit a malformed font-family back
out.

No current TERMINAL_FONTS entry hits this case, but lib/localFonts.ts
builds family strings from arbitrary system fonts via the Local Font
Access API — a user with a comma-bearing family name would have
silently broken filtering until now.

Adds splitFontFamilyList(css) in cjkFonts.ts: an exported quote-aware
tokenizer that splits on commas only when outside quoted segments
(handles both " and '). composeFontFamilyStack uses it instead of raw
split; extractPrimaryFamily in lib/fontAvailability.ts imports it for
symmetry so the two call sites can't drift.

Tests cover the tokenizer directly (simple list, quoted-with-comma,
single quotes, double commas) and end-to-end (a quoted primary with
an internal comma survives composition intact).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): translate Layer 3 CJK font descriptions to English

The 4 CJK-coverage entries added in earlier commits (Sarasa Mono SC,
Sarasa Mono TC, Maple Mono CN, LXGW WenKai Mono) had hardcoded Chinese
description strings, while every other TERMINAL_FONTS entry uses
English ('Adobe's professional programming font', 'Iosevka variant
mimicking Berkeley Mono style', etc.). The dropdown rendered a
mixed-language list — flagged by the maintainer.

Converted the 4 descriptions to English in the same style as the
existing entries. No i18n scaffolding added; the existing convention
is "English-only `description` field, not routed through t()", and
the rest of the registry stays consistent with that.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:07:15 +08:00
bincxzandClaude Opus 4.7 478e148b40 Drop noisy [XTerm] renderer=... boot log
The line printed once per terminal session and offered no diagnostic
value beyond what window.__xtermRenderer already exposes for ad-hoc
introspection. Keep the detection + retry + window publish; just
stop polluting the console. Rename logRenderer → trackRenderer to
match the now-narrowed responsibility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:11:58 +08:00
陈大猫 231fb9c74c Merge pull request #936 from binaricat/fix/stable-use-terminal-backend
Stabilize useTerminalBackend return identity
2026-05-11 00:05:21 +08:00
bincxzandClaude Opus 4.7 8870eb4de9 Stabilize useTerminalBackend return identity
The hook returned a fresh object literal every render. The 26 methods
inside were already useCallback([])-stable, but the wrapping object
was not — so every consumer's effect with `terminalBackend` in deps
(e.g. cwd polling, lifecycle wiring, write-to-session) re-ran on
every parent render even though nothing semantic had changed, and
ESLint flagged the one site that depended on a property access
(`terminalBackend.onHostKeyVerification`) because it could not prove
that path safe.

Wrap the return in useMemo with all stable callbacks listed as deps
so the object is computed once and cached for the hook's lifetime.
Switch the host-key-verification effect's dep to the now-stable
`terminalBackend`, clearing the warning at the root rather than
patching it locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:04:23 +08:00
陈大猫 c9114eb198 Merge pull request #935 from binaricat/fix/906-ghost-text-after-tab
Fix ghost text duplicating glyphs after Tab completion (#906)
2026-05-10 23:59:03 +08:00
bincxzandClaude Opus 4.7 938d1ef48b Fix ghost text duplicating glyphs after Tab completion (#906)
The reliability gate at handleInput's adjustToInput call froze the
ghost at its last show()-time tail in any path where the typed buffer
becomes unreliable (Tab pass-through to shell, history recall, cursor
moves). When the user kept typing into that gap, the next render
advanced the cursor past the ghost's anchor while the ghost text
stayed put — a → -accept then pasted the stale tail on top of the
just-typed glyphs (e.g. "systemctl s" + typing "t" → screen showed
"systemctl sttop firewalld").

Add GhostTextAddon.applyKeystroke so the ghost can evolve its own
currentInput off raw keystrokes (printable / Backspace / Ctrl-W),
seeded by whatever the last show() captured from the live xterm
reading. handleInput now uses the existing adjustToInput on the
reliable path (preserves multi-char paste re-alignment) and routes
single-keystroke events through applyKeystroke on the unreliable
path, fixing the visual misalignment and the duplication-on-accept
in one shot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:53:11 +08:00
陈大猫 52c097d9f8 Merge pull request #928 from binaricat/binaricat/fix-issue-920
Sync AI/UI settings and fix multi-display settings window placement
2026-05-10 23:24:15 +08:00
bincxzandClaude Opus 4.7 684c094d40 Drop externalAgents from cloud sync (device-local config)
ExternalAgentConfig.command/acpCommand/args/env are OS- and
machine-specific (binary paths, .exe suffixes, platform-dependent
environment values). Pushing them to other devices either fails to
resolve or silently runs the wrong thing.

Stop collecting/applying STORAGE_KEY_AI_EXTERNAL_AGENTS and remove the
field from the SyncPayload type. apply silently ignores the field on
legacy snapshots that still carry it, so existing remote data is safe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:16:37 +08:00
bincxzandClaude Opus 4.7 d84c2cc902 Preserve local AI apiKeys when applying synced settings
`collectSyncableSettings` strips device-bound encrypted apiKeys from
provider entries and webSearchConfig before upload, but
`applySyncableSettings` was writing them back wholesale, silently wiping
local credentials whenever any other setting changed on a second device.

Merge by id (providers) and by providerId (web search) so a synced
payload only overrides the apiKey when it explicitly carries one.

Also include `application/*.test.ts` in the npm test glob so the
syncPayload tests added in this PR actually run in CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:17:41 +08:00
陈大猫 3a233a3279 Merge pull request #934 from binaricat/claude/suspicious-bohr-32d9f2
Fix WebDAV Basic Auth for non-ASCII passwords (Hetzner #891)
2026-05-10 21:41:37 +08:00
bincxz ba675fa944 Use UTF-8 for WebDAV Basic Auth credentials
The upstream `webdav` package builds the `Authorization: Basic …` header
through `base-64`, which Latin1-encodes the credentials. RFC 7617 (and
servers that follow it, like Hetzner Storage Box) expect UTF-8, so any
non-ASCII character in the password (e.g. `ö`, `ä`) produces a different
byte sequence on the wire than what the server stored, and the request
gets a 401 even though the credentials are correct (#891).

Skip the upstream auth path for password mode and pass an Authorization
header we built ourselves with UTF-8 encoding. ASCII-only passwords are
byte-identical, so existing setups are unaffected. Digest and token
modes are untouched.

Tested with a local HTTP server that enforces UTF-8-encoded Basic Auth
for a password containing umlauts (the exact failing case from #891).
2026-05-10 21:37:52 +08:00
bincxzandClaude Opus 4.7 c9da2a5893 Sync AI/UI settings and fix multi-display settings window
Extend cloud sync to cover AI provider config, external agents,
permission/tool modes, command policy, web search settings,
workspace focus style, terminal follow-app theme, SFTP default view,
and additional terminal options. Device-bound encrypted apiKey
placeholders are stripped from providers and webSearchConfig before
upload. Auto-sync now reacts to syncable localStorage changes via a
new adapter-level event.

Center the Settings window on the display of the window that opened
it instead of always using the main window, fixing issue #920.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 00:41:14 +08:00
陈大猫 a377d39446 Merge pull request #926 from binaricat/codex/fix-ssh-known-host-verification
Fix SSH known host verification
2026-05-09 23:56:31 +08:00
bincxz 4b7249997f Update changed known hosts in place 2026-05-09 23:42:08 +08:00
bincxz eb3f55b477 Integrate host key confirmation into connection dialog 2026-05-09 20:15:22 +08:00
bincxz bce33f34ee Fix SSH known host verification 2026-05-09 19:44:21 +08:00
陈大猫 b6c59b9683 Merge pull request #924 from bet4it/shift-enter-support
Support Shift+Enter
2026-05-09 19:12:30 +08:00
bincxz ff6b75aba7 Harden Shift+Enter keyboard support 2026-05-09 19:12:08 +08:00
陈大猫 b65ed74ced Merge pull request #922 from binaricat/feat/915-sftp-upload-context-menu
Add Upload File(s) item to SFTP context menu
2026-05-09 18:01:35 +08:00
bincxz 6c6a051c0c Fix SFTP upload context menu handling 2026-05-09 17:47:45 +08:00
陈大猫 621eae28f4 Merge pull request #918 from gorgiaxx/main
feat: Optimization of SSH Key Passphrase and Keychain
2026-05-09 16:17:46 +08:00
bincxz 2329014e22 fix: harden SSH key passphrase flows 2026-05-09 16:16:17 +08:00
Bet4 5c5ab21b10 support Shift+Enter 2026-05-09 14:56:17 +08:00
bincxzandClaude Opus 4.7 a01ee1da61 Hide SFTP upload on local panes; add folder picker
The SFTP file-list "Upload File(s)" context menu items only make sense
on remote panes — local panes have no upload semantic. Plumb a new
`isLocal` prop into SftpPaneFileList and suppress both the menu items
and the hidden file inputs when the active pane is local.

Also add an "Upload Folder..." item alongside "Upload File(s)..." that
opens a `<input type="file" webkitdirectory>` picker. The resulting
FileList is routed through a new `uploadExternalFolder` /
`onUploadExternalFolder` callback that calls `uploadFromFileList`, so
folder structure is preserved via webkitRelativePath without any new
IPC. When invoked from a directory row, the folder is uploaded INTO
that directory (matching drag-and-drop semantics).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:57:56 +08:00
陈大猫 c94ded1a77 Merge pull request #923 from binaricat/fix/916-session-log-on-reconnect
Restart session log stream on reconnect
2026-05-09 12:52:08 +08:00
陈大猫 59de39e2ab Merge pull request #921 from binaricat/feat/912-settings-hotkey
Add hotkey to open Settings panel
2026-05-09 12:51:44 +08:00
bincxzandClaude Opus 4.7 4a3869369e Restart session log stream on reconnect
Fixes #916.

When the user clicks "Restart" after a session disconnects, the
renderer reuses the same sessionId and the bridges call startStream
again to open a fresh log file for the new connection. The previous
connection's close handlers (e.g. SSH conn.once('close'),
stream.on('close'), serial 'close', telnet 'close', mosh PTY exit)
all still fire asynchronously and call stopStream(sessionId)
unconditionally. If they land after the new stream is already
active, they silently destroy it and subsequent terminal output for
the reconnected session is dropped, matching the bug report where
the first connection's IO is saved but the reconnect's is not.

Make startStream return a unique token and require stopStream
callers to pass it. A stale stop call carrying the previous
incarnation's token is now a no-op, so a late close handler from
the previous connection cannot kill the freshly-started stream.

Each reconnect therefore produces its own timestamped log file,
which mirrors the existing auto-save-on-close semantics and is the
simpler of the two options the issue offered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:38:50 +08:00
bincxzandClaude Opus 4.7 11856b09e5 Add Settings gear button to top tab bar
Provides a discoverable entry point to the Settings panel for users
who don't use the Cmd/Ctrl+, hotkey. Sits at the right edge of the
title bar on macOS and immediately to the left of the custom window
controls on Windows/Linux. Reuses the existing onOpenSettings prop
already wired through from App.tsx.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:31:52 +08:00
bincxzandClaude Opus 4.7 76b013f128 Add Upload File(s) item to SFTP context menu
Right-click on an SFTP pane now offers an "Upload File(s)" menu item
that opens a native multi-file picker, so users no longer have to drag
and drop to upload (issue #915). Selected files are wrapped in a
DataTransfer and dispatched through the existing onUploadExternalFiles
pipeline; right-clicking a directory uploads into that folder. Folder
upload via the picker is intentionally out of scope.

Fixes #915

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:31:13 +08:00
bincxzandClaude Opus 4.7 44abf420c2 Add hotkey to open Settings panel
Adds Cmd+, on macOS and Ctrl+, on Windows/Linux to open Settings,
matching the platform convention. Previously Settings was only
reachable via Vaults -> Settings (#912).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:28:51 +08:00
gorgiaxx cb98bdba2b fix: Improve passphrase handling by purging cached passphrases only on specific errors 2026-05-08 23:44:10 +08:00
gorgiaxx 18d411bb95 fix: preserve reference SSH keys and retry passphrase prompts
Keep file-backed SSH keys intact across app restarts and keep bad key passphrases in the dedicated retry flow instead of falling back to generic SSH auth. Also clear invalid saved passphrases from both legacy storage and reference-key records after auth failures.
2026-05-08 18:50:40 +08:00
gorgiaxx 1e80337a46 Merge branch 'main' of github.com:gorgiaxx/Netcatty 2026-05-08 17:26:55 +08:00
gorgiaxx f1cfce45cf feat: Enhance SSH key management with reference key support and UI updates 2026-05-08 17:23:07 +08:00
Gorgias 833f9d2cac Merge branch 'binaricat:main' into main 2026-05-07 22:41:58 +08:00
gorgiaxx 72847a05af fix: Refactor passphrase handling: remove auto-responded keys tracking and related logic 2026-05-07 22:41:14 +08:00
陈大猫 0eccb2a252 Merge pull request #911 from yuzifu/allow-quick-edit 2026-05-07 19:52:31 +08:00
gorgiaxx 8a44152b36 Add support for remembering SSH key passphrases and update UI accordingly 2026-05-07 17:38:17 +08:00
yuzifu c20abd86d9 allow quick edit for grid mode of keychain view 2026-05-07 16:23:38 +08:00
陈大猫 3fc9622695 Merge pull request #909 from binaricat/codex/telnet-auto-login
[codex] Improve Telnet credential login
2026-05-07 13:12:49 +08:00
bincxz eb1fd9c127 Harden Telnet auto-login 2026-05-07 12:57:54 +08:00
bincxz 5cf1dd1de6 Match Telnet port field width to SSH 2026-05-07 11:46:59 +08:00
bincxz 137f8affbb Handle concatenated Telnet login prompts 2026-05-07 11:37:17 +08:00
bincxz b9ac14f497 Improve Telnet credential login 2026-05-07 11:22:24 +08:00
陈大猫 43097c43b1 Merge pull request #905 from binaricat/fix/mosh-strip-lc-env
Strip LC_* before mosh ssh handshake
2026-05-07 02:03:21 +08:00
bincxzandClaude Opus 4.7 329e94752b Strip LC_* before mosh ssh handshake
macOS Terminal/iTerm export LC_CTYPE=UTF-8 (a bare value, not a real
locale name). The system ssh_config has SendEnv LC_*, so the value
leaks to the remote and bash warns "cannot change locale (UTF-8)" on
every login. mosh-server sets its own locale separately, so dropping
LC_* from the spawned ssh's env is the cleanest fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 02:01:57 +08:00
陈大猫 b6a34131f6 Merge pull request #904 from binaricat/fix/mosh-windows-pinned-asset-check
Fix Windows mosh binary fallback selection
2026-05-07 01:42:18 +08:00
LAPTOP-O016UC3M\Qi Chen 3f16818d8d Fix Windows mosh binary fallback selection 2026-05-07 01:36:15 +08:00
陈大猫 3efc9ada8e Fix Windows mosh startup
Fix Windows mosh startup
2026-05-07 01:31:09 +08:00
陈大猫 8efdd1c9cb Merge pull request #901 from binaricat/codex/proxy-library
[codex] add reusable proxy profiles
2026-05-06 18:03:19 +08:00
bincxz 585a654668 Polish proxy form headings 2026-05-06 17:42:28 +08:00
bincxz 72e305fb7a Add reusable proxy profiles 2026-05-06 17:33:46 +08:00
bincxz 012a6bf521 Tone down proxy add button 2026-05-06 15:40:26 +08:00
陈大猫 4c72d5e0af Merge pull request #899 from yuzifu/fix-agent-path
fix: handle Windows agent paths with spaces
2026-05-06 15:36:32 +08:00
bincxz cedc7f6c5f Align proxy profiles vault styles 2026-05-06 15:34:40 +08:00
bincxz 155463f77c add reusable proxy profiles 2026-05-06 15:20:23 +08:00
yuzifu e5a74058ad add test unit 2026-05-06 15:12:17 +08:00
yuzifu 4ced32257e fix: handle Windows agent paths with spaces
When the executable file is installed in a directory containing spaces, the Codex and Claude path/version detection do not work.
2026-05-06 13:58:52 +08:00
陈大猫 64e7719715 Merge pull request #896 from yuzifu/fix-session-log
Fix session log
2026-05-06 12:34:07 +08:00
yuzifu 04b5aba62d fix: Preserve pending screen across redundant ED2 2026-05-04 17:27:04 +08:00
yuzifu 9f97f3870d fix: Preserve ED2-cleared screen when no trailing ED3 arrives 2026-05-04 17:15:41 +08:00
yuzifu 6bfd0e17a2 add ED3 test unit 2026-05-04 14:10:30 +08:00
yuzifu 1ac538eedc fix preserve terminal history during log sanitization 2026-05-04 14:07:22 +08:00
yuzifu d34e23c7b3 preserve history while sanitizing terminal clears
Add a stateful terminal log sanitizer for txt/html session logs so saved output handles backspace, carriage-return overwrites, erase controls, split CSI/OSC sequences, and ANSI styling without leaking terminal control bytes.

Stream txt/html logs through a persistent renderer and write rendered snapshots directly to the final file, avoiding raw temp files and redundant full rewrites.
Preserve prior log history across clear-screen transitions while coalescing TUI repaint loops to avoid stale frame growth.

  Add regression coverage for tmux/zellij-style clears, repeated ED2/ED3 clears, home-clear repaint loops, and shell clear behavior.
2026-05-04 14:01:37 +08:00
陈大猫 31bf5396cb Bundle mosh terminfo on Linux and macOS (#890) (#894) 2026-05-04 11:09:12 +08:00
陈大猫 2feecaa9b6 Fix Windows mosh terminfo bundle (#889) 2026-05-01 22:51:15 +08:00
bincxz 1f0d3d8274 Handle cross-device mosh bundle moves 2026-05-01 17:10:13 +08:00
bincxz d8c62a55f5 Fix Windows mosh bundle extraction 2026-05-01 16:54:57 +08:00
陈大猫 1b08e5ee88 [codex] Fix SFTP editor saved state (#887)
* Fix SFTP editor saved state

* Restore window input focus after SFTP editor

* Harden SFTP editor save flows
2026-05-01 16:31:58 +08:00
bincxz de7057183c Increase AI code block top spacing 2026-05-01 13:48:42 +08:00
bincxz dd910cc53d Tighten AI code block spacing 2026-05-01 13:43:06 +08:00
陈大猫 8ccefc821c [codex] Use dedicated mosh binary repository (#881)
* Use dedicated mosh binary repository

* Require bundled mosh client

* Auto-fill saved password for mosh SSH handshake

* Harden bundled mosh binary flow
2026-05-01 11:54:10 +08:00
陈大猫 863397fc7d Fix DeepSeek reasoning replay for tool loops (#882)
* Fix OpenAI-compatible reasoning replay for tool loops

* Fix reasoning continuation replay
2026-05-01 11:45:47 +08:00
陈大猫 6a39ed05a9 [codex] Tighten AI chat spacing (#883)
* Tighten AI chat spacing

* Scope AI table spacing styles
2026-05-01 11:33:07 +08:00
陈大猫 470d9b5aae [codex] Improve ACP agent error diagnostics (#880) 2026-05-01 08:00:50 +08:00
陈大猫 20694a47dd Fix Codex ACP model picker (#879) 2026-05-01 08:00:05 +08:00
陈大猫 d86c5ed05a [codex] Remove mosh client path setting (#878)
* fix(terminal): remove mosh client path setting

* fix(terminal): remove stale mosh detection bridge
2026-04-30 17:54:35 +08:00
陈大猫 fdaaaf62d8 [codex] Preserve provider reasoning context (#877)
* fix(ai): preserve provider reasoning context

* fix(ai): harden provider continuation replay
2026-04-30 17:08:19 +08:00
秋秋andbincxz 2ceea46b50 feat(ssh): enhance getSessionPwd to support fish shell and improve cwd retrieval (#869)
* feat(ssh): enhance getSessionPwd to support fish shell and improve cwd retrieval

* fix ssh cwd detection review issues

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-04-30 15:27:45 +08:00
5a1d6931a5 Fix Tab completion preferring history over local files (#867)
* Fix spec-aware path completion priority

Use resolved Fig spec args when deciding when filesystem suggestions should outrank command history. Add a regression test covering a spec-driven file argument command.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix generator-only spec path completion

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-04-30 14:42:01 +08:00
fb97e242ee feat: add SFTP upload conflict handling (#874)
* feat: add SFTP upload conflict handling
Add conflict resolution for SFTP uploads so files and folders can be stopped, skipped, replaced, duplicated, or merged depending on the target state. Support batch uploads with Apply to All behavior, route external upload conflicts through the shared SFTP conflict dialog, and add the bridge operations needed to stat and delete existing upload targets.

* fix review issue

* Fix SFTP conflict cancellation cleanup

---------

Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-04-30 14:22:00 +08:00
YumeSakuandbincxz 68040ebdd7 fix(autocomplete): recognize Nerd Font / Powerline glyphs as prompt terminators (#871)
* fix(autocomplete): recognize Nerd Font / Powerline glyphs as prompt terminators

oh-my-posh and similar themed prompts end with PUA codepoints (e.g. U+F105
chevron, U+E0B0 powerline arrow) that aren't in the hardcoded PROMPT_CHARS
set, so findPromptBoundary returned -1 and both ghost-text and popup
autocomplete went silent. Treat any Private Use Area char (U+E000-U+F8FF)
followed by a space as a candidate prompt terminator — real shell commands
essentially never contain PUA codepoints, so this is high-confidence.

* Fix Powerline glyph prompt splitting

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-04-30 13:57:07 +08:00
cca6dac543 fix(sftp): use custom tooltips in transfer queue (#872)
* fix(sftp): replace transfer queue native tooltips

* Fix SFTP transfer tooltip regressions

* Improve SFTP transfer tooltip accessibility

* Cover SFTP cancel tooltip label

---------

Co-authored-by: Mack Ding <mackding@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-04-30 13:23:51 +08:00
陈大猫andClaude Opus 4.7 d86b720748 Run CI on every push/PR; gate release on strict v tags (#868)
* Run CI on every push/PR; gate release on strict v<X>.<Y>.<Z> tags

The build-packages workflow used to trigger only on `push: tags: v*`,
so branches and PRs never built and the only way to test the matrix
was to push a tag — which also auto-published a GitHub Release. That
made it impossible to verify a CI change without either skipping
testing or shipping a junk release.

Restructure the triggers:

- `push: branches: ['**']` + `pull_request` so any push or PR runs
  the build matrix and uploads workflow artifacts.
- `push: tags` accepts only strict semver: `v<MAJOR>.<MINOR>.<PATCH>`
  with an optional pre-release suffix like `v1.2.3-rc.1`. Loose tags
  (`v-test`, `vNEXT`, `v1.0`) no longer match.
- The release job's `if:` enforces the same rule independently — even
  if someone re-broadens the trigger later, branches and PRs can't
  publish a release.
- `Set version` produces semver-compliant `0.0.0-sha.<short>` for
  non-tag runs so `npm pkg set` / electron-builder don't choke on a
  bare commit SHA like `abc1234`.
- Add a concurrency group that cancels superseded branch/PR builds
  to save runner minutes; tag builds use a unique group so releases
  never get cancelled by a follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Apply strict-semver Set-version step to Linux jobs too

The previous commit only patched the matrix job's Set version step
(macOS/Windows) because the Linux legs had a slightly different
template (no comments). The Linux Set version step kept setting
package.json's version to a bare 7-char commit SHA like "812f296",
which electron-builder rejects with `Invalid version: "812f296"`
during normalizePackageData.

Replicate the same strict regex + 0.0.0-sha.<short> fallback in both
Linux jobs so non-tag runs produce a valid semver across the matrix.

Reproduced from build-linux-x64 logs of the run on 112bf3a1:
  Setting version to 812f296
  ⨯ Invalid version: "812f296"  failedTask=build

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix build workflow trigger review issues

* Address build workflow review findings

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 12:22:50 +08:00
陈大猫 aa192c66c3 Wire bundled mosh release flow
* Wire bundled mosh release flow

* Fix bundled mosh release flow review findings
2026-04-30 09:28:08 +08:00
陈大猫andClaude Opus 4.7 7dd25a55bb Bundle mosh-client + Node-side PTY handshake
* Bundle mosh-client via CI build pipeline

Add a GitHub Actions workflow that builds a static, distro-portable
mosh-client for linux-x64, linux-arm64, darwin-universal (arm64+x86_64)
from upstream mobile-shell/mosh source, plus a pinned win32-x64 binary
sourced from FluentTerminal (GPL-3.0). Releases attach SHA256SUMS so
scripts/fetch-mosh-binaries.cjs can verify and pull the right binary
into resources/mosh/<platform-arch>/ during npm run pack.

electron-builder.config.cjs gains a moshExtraResources() helper that
adds the binary to extraResources only when present on disk, keeping
local dev packages working without bundled mosh.

terminalBridge.cjs now exports bundledMoshClient() and prefers the
bundled static client over whatever the system mosh wrapper would
resolve via PATH (via the MOSH_CLIENT env var). The Windows branch
throws a clear error pointing at Settings instead of silently falling
back to a literal "mosh.exe" string when no wrapper is installed.

This is Phase 1 — Phase 2 (follow-up) replaces the FluentTerminal
Windows binary with an in-CI Cygwin static build and adds a Node-side
mosh-server bootstrap so Mosh works out-of-the-box on Windows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Phase 2: Node-side Mosh handshake (no Perl wrapper required)

Reimplement what the upstream Mosh Perl wrapper does in pure Node:
spawn `ssh [user@]host -- mosh-server new`, sniff the byte stream
for `MOSH CONNECT <port> <key>`, then spawn `mosh-client` locally
with MOSH_KEY in the environment.

The new electron/bridges/moshHandshake.cjs module exposes the parser,
sniffer, and command builders as pure functions so they can be unit
tested without spawning real ssh. terminalBridge.startMoshSession now
prefers this path whenever a bare mosh-client (bundled, explicit, or
system) and ssh (in-box OpenSSH on Win10 1809+, system everywhere
else) are both detectable. The legacy path through the system mosh
Perl wrapper is preserved as a fallback so users with custom mosh
setups don't regress.

Auth is delegated to system ssh, so keys, agent, ssh_config, and
known_hosts all keep working. Password / 2FA need a controlling TTY
which the bootstrap doesn't provide; affected users keep the legacy
wrapper path until interactive UI lands.

Tests:
- moshHandshake.test.cjs (20 tests) — parser corner cases, command
  builders, sniffer split-chunk handling, ring-buffer trim, exec
  resolver
- terminalBridge.bareMoshClient.test.cjs (4 tests) — explicit-path
  basename gating

317 → 341 passing tests; lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Phase 3: in-CI Cygwin Windows build + visible PTY handshake

Phase 3a — in-CI Cygwin Windows build
- scripts/build-mosh/build-windows.sh builds mosh-client.exe from
  upstream mobile-shell/mosh source inside Cygwin, then walks the
  cygcheck import graph to bundle every required Cygwin DLL
  (cygwin1.dll, cygcrypto, cygprotobuf, cygncursesw, etc) into a
  tar.gz alongside the exe.
- The `build-mosh-binaries` workflow swaps the FluentTerminal-pinned
  fetch job for a real Cygwin build (windows-latest + cygwin-install-
  action). fetch-windows.sh is preserved as an emergency fallback but
  no longer wired into the matrix.
- fetch-mosh-binaries.cjs unpacks the tar.gz into resources/mosh/
  win32-x64/ so mosh-client.exe sits next to its DLLs.
- mosh-extra-resources.cjs ships the entire win32-x64/ dir
  (exe + DLL bundle) into Resources/mosh/, so the packaged installer
  runs on a stock Windows host with no Cygwin install.

Phase 3b — visible PTY handshake (password / 2FA prompts)
- terminalBridge.startMoshSession now spawns ssh inside node-pty so
  the user sees and can answer password / 2FA / known-hosts prompts
  in their terminal. When `MOSH CONNECT` is sniffed from the byte
  stream, session.proc is atomically swapped from the ssh PTY to a
  freshly-spawned mosh-client PTY. The MOSH CONNECT line itself is
  redacted from the visible output.
- writeToSession / resizeSession read session.proc lazily, so input
  arriving after the swap goes to mosh-client without extra wiring.
- The ZMODEM sentry is recreated for the new proc since its
  writeToRemote closure captured the previous handle.
- Removes the earlier non-PTY child_process.spawn handshake — the
  PTY-based one supersedes it.

Phase 3c — win32-arm64 deferred
- Cygwin's arm64 port has no stable cygwin1.dll release yet, so we
  do not attempt an arm64 Windows build. arm64 Windows installs fall
  through to the legacy `mosh` wrapper path that the bridge already
  handles. Documented in the workflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Allow branch/PR pushes to test the mosh-binaries workflow

Mirrors the build-packages workflow change in #868: any push or PR
that touches the mosh build pipeline triggers the matrix (artifacts
only, no release), while only `mosh-bin-*` tag pushes (or an
explicit workflow_dispatch with release_tag) publish a release.

`paths` filter keeps unrelated commits from running this expensive
workflow (~30min for the Cygwin leg). Concurrency group cancels
superseded branch/PR builds; tag builds use a unique group so a
follow-up commit can't kill an in-progress release.

Release job's `if:` enforces the same rule independently — even if
the trigger gets re-broadened, branches/PRs can't leak a release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix mosh binary workflow runners

* Fix Windows mosh workflow invocation

* Keep shell scripts LF in workflow checkouts

* Trigger mosh workflow on attributes changes

* Fix mosh build tool dependencies

* Fix Linux mosh static build

* Fix macOS mosh build tool lookup

* Skip macOS ncurses terminfo install

* Fix mosh PR review findings

* Allow Linux system mosh dependencies

* Fix Windows mosh DLL bundling

* Limit bundled Windows mosh DLLs

* Honor configured PATH for mosh handshake

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:25:57 +08:00
陈大猫 e4e1b54374 Fix terminal custom accent color (#864) 2026-04-29 11:21:29 +08:00
陈大猫 4dd2465388 Keep known hosts local during sync (#863) 2026-04-29 11:01:21 +08:00
陈大猫 b6734b9ef9 Show auto-detected mosh path (#858) 2026-04-28 21:38:10 +08:00
陈大猫 fb443541aa Optimize snippets shortcut behavior
Fixes #839
2026-04-28 21:21:46 +08:00
yuzifu 7622c43c38 fix: consume SFTP side panel initial location once (#856) 2026-04-28 18:21:27 +08:00
陈大猫 a4a5c703b1 Fix terminal cursor preference handling 2026-04-28 17:17:37 +08:00
陈大猫andClaude Opus 4.7 2063a5ccfe Expose data-role CSS hooks on chat messages (#854)
Closes #838.

Adds stable `data-role="user|assistant|system|tool"` attributes plus
`ai-chat-message` / `ai-chat-message-content` classnames on the chat
message rows in Catty Agent's chat panel. Users can now distinguish
their own messages from agent replies via Settings → Appearance →
Custom CSS, e.g.

  .ai-chat-message[data-role="user"] .ai-chat-message-content {
    background: rgba(91, 124, 250, 0.12);
  }

The default theme is intentionally minimal (bordered user bubble,
plain assistant text). Rather than change the default — different
users want different distinctions — this exposes a hook so anyone
can colour the rows however they prefer without forking.

The attribute names are part of the UI's stable contract; a comment
on the Message component flags this for future renames.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:34:30 +08:00
陈大猫andClaude Opus 4.7 1fcf77ef4d Harden the dirty-editor quit guard (#853)
* Harden the dirty-editor quit guard

Follow-up to #840. Three concrete failure modes that round-2 review
turned up:

1. `webContents.send` is unguarded. If the renderer is destroyed
   between the reachability check and the send (e.g. a dying GPU
   process), the throw escapes the `before-quit` handler with
   `quitGuardChannelBusy = true` already set and no timeout scheduled
   yet — the app becomes un-quittable until restart. Wrap the send,
   and tear the listener/timer down on failure.

2. The timeout vs. response race silently commits a quit on
   `hasDirty=true`. Once `setTimeout` has already enqueued its
   callback for the next tick, `clearTimeout` is a no-op and the
   timeout callback runs even after the response arrived — which
   unconditionally calls `commitQuit()`, overriding the user's
   "save first" intent. Funnel both paths through a `settle()` helper
   that only acts the first time it's called.

3. The reply listener accepted any sender. A rogue or future-buggy
   `webContents` could decide the quit by sending the channel name
   first. Validate `evt.sender === wc` and ignore non-matches; switch
   from `.once` to `.on` + explicit `removeListener` so a rogue early
   reply doesn't consume the listener slot.

Also wrap the renderer-side handler in try/catch so an unexpected
throw inside `editorTabStore.getTabs()` reports `hasDirty=false`
immediately instead of stranding the main process for 5 s on a
silent timeout.

Verify `webContents.isCrashed()` before sending so a known-dead
renderer skips the round-trip and quits instantly instead of waiting
on the timeout fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Tighten dirty-editor quit-guard validation

Codex round-2-2 review suggested two small follow-ons:

1. Sender check should reject missing/falsy `evt.sender` outright. In
   real Electron IPC the sender is always populated; a falsy sender
   is anomalous and treating it as legit defeats the rogue-reply
   defence we just added.
2. Wrap `bridge.reportDirtyEditorsResult` in try/catch on the
   renderer side. If the IPC bridge is in a bad state and the call
   throws, the rest of the listener body is fine but the React
   useEffect callback would propagate the error — and an uncaught
   error in the listener would silently disable the quit guard for
   the rest of the session.

Both are pure tightening; no behaviour change on the happy path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:13:23 +08:00
8296c2c780 fix(quit): target main window for dirty-editor check on quit (#840)
* fix(quit): target main window for dirty-editor check on quit

Use getMainWindow() instead of BrowserWindow.getAllWindows()[0] so the
app:query-dirty-editors round-trip isn't sent to the tray panel or
settings window, and skip the check when the main window is hidden to
avoid the 5s timeout fallback during tray-initiated quit.

* Also gate dirty-editor check on isMinimized for cross-platform robustness

A minimized main window has a taskbar/Dock entry the user can click to
restore, so the dirty-editor toast is still useful even though the
window isn't currently in the foreground. On some platforms isVisible()
can return false for a minimized window (see the comment at
globalShortcutBridge.cjs:478), so the original `!isVisible()`
short-circuit would silently lose dirty-editor protection in that case.

Treat a window as "reachable by the user" when either isVisible() or
isMinimized() is true. Truly hidden windows (close-to-tray, app.hide()
on macOS) still skip the round-trip and quit instantly, which is the
behaviour this PR set out to introduce.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:03:44 +08:00
陈大猫andClaude Opus 4.7 d1e6857f76 Drop stale lastIdlePrompt before forcing PowerShell wrapper (#852)
Follow-up to #851 (Codex review comment on 32bab2d4). After that PR,
`resolveEffectiveShellKind` flips an unknown-shell session to PowerShell
based on `session.lastIdlePrompt`, but that field is updated only when
`trackSessionIdlePrompt` recognizes a known prompt shape (default
PowerShell or `user@host[:path][#$]`). On an SSH/Telnet session that
enters PowerShell and then leaves it for a shell with an unrecognized
prompt — cmd.exe (`C:\>`), oh-my-posh / starship / a custom PS1 — the
cached `PS ...>` value persists indefinitely, and every subsequent MCP
command keeps getting wrapped as PowerShell against a non-PowerShell
shell. The new shell errors on the wrapper syntax once per command, and
nothing self-heals until the user reconnects.

Add `getFreshIdlePrompt(session)` which returns the cached prompt only
when the rolling PTY tail (`session._promptTrackTail`) still ends with
it. If the visible last line has moved on — even to a prompt shape we
don't recognize — the cache is treated as expired and downstream
wrapper selection / suffix matching falls back to `shellKind` alone,
which is the correct behavior for the unknown-shell case.

Wire this into the three call sites that previously read
`session.lastIdlePrompt || ""`:
- `aiBridge.cjs:1325` (Catty Agent foreground exec)
- `mcpServerBridge.cjs:1496` (MCP `terminal_execute`)
- `mcpServerBridge.cjs:1584` (MCP `terminal_start` background job)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:53:30 +08:00
陈大猫andClaude Opus 4.7 eccb9f2cfc [codex] Fix PowerShell MCP command execution (#851)
* Fix PowerShell MCP command execution

* Harden PowerShell prompt detection and document its scope

- Annotate isPowerShellPrompt and the matching regex in shellUtils with
  a "default prompt only" caveat, so future readers know custom prompt
  themes (oh-my-posh, starship, custom prompt functions) are out of
  scope on purpose, and keep the two regexes in sync.
- Cover edge cases that the original tests left implicit: trailing
  whitespace after the `>`, ANSI-coloured prompts, bare `PS>` with no
  working directory, empty/undefined inputs, and command output that
  merely starts with `PS` (e.g. `PSO>`, `ZIPS>`) so we don't regress
  into mis-wrapping non-PowerShell sessions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address multi-agent review findings on PowerShell prompt detection

- Refuse to override an explicit non-PowerShell shellKind. The override
  is only useful when the session has no confirmed shell type (the
  issue #841 case is an SSH session, where shellKind is undefined). On
  a confirmed bash/zsh/fish session a malicious remote process emitting
  a `PS ...>` line could otherwise coerce one mis-wrapped command; this
  closes that foothold while still fixing the original bug.
- Tighten the regex to /^PS(?:\s+\S.*)?>$/ so a literal `"PS >"` line
  is rejected. The default PowerShell prompt never emits that shape, so
  it's a clean spoof signal to ignore.
- Treat `\r` as a line break, not a stripped character, when extracting
  the last idle line. PSReadLine / ConPTY emit bare `\r` to repaint the
  current line; without this, `"PS C:\\old>\rPS C:\\new>"` would match
  as one long doubled prompt that never round-trips through the live
  PTY tail.
- Hoist the regex into shellUtils as `isDefaultPowerShellPromptLine` so
  prompt extraction and wrapper selection share one source of truth.
- Drop a redundant optional-chain on `String.prototype.split().pop()`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Drop dead 'powershell' entry from override set; document shellKind universe

Round-2 review noted that listing "powershell" in
SHELL_KINDS_OPEN_TO_PROMPT_OVERRIDE was a no-op: when the configured
shell kind is already powershell, the override path returns "powershell"
on a match and the fall-through returns "powershell" on a miss, so the
entry only mattered if reverse PS-to-POSIX detection were added later.
Removing it makes the gate's intent ("override only when there's no
confirmed shell type") obvious from the data alone.

Also enumerate the full universe of shellKind values in a comment next
to the set so the next reader doesn't have to grep terminalBridge and
localShell.cjs to know what's excluded and why ("raw" sessions bypass
buildWrappedCommand entirely; "cmd"/"fish" are confirmed and shouldn't
flip to PowerShell on a spoofed remote line).

Add a regression test that locks the current behavior for an explicit
shellKind="powershell" session whose visible prompt looks POSIX (e.g.
nested into WSL/bash) — we keep powershell wrapping. Lock this so a
future maintainer doesn't accidentally introduce reverse detection
without also handling the cross-shell quoting implications.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:32:27 +08:00
陈大猫andClaude Opus 4.7 74d56cdcb8 [codex] Settings: detect & override mosh client path (#849)
* Add Mosh client detection and override in Settings → Terminal

Builds on PR #847 (auto-detection across PATH gaps). Power users with
non-standard install locations (containers, custom builds, multiple
mosh versions) can now point the app at a specific mosh binary; less
technical users get a one-click "Detect" button to confirm where mosh
was found, with a Browse fallback for clicker-only flows.

Backend (electron/bridges/terminalBridge.cjs):
- detectMoshClient() returns { platform, found, path, searchedPaths }.
  Reuses resolvePosixExecutable; surfaces the searched dirs so the UI
  can tell users where to look when nothing was found.
- pickMoshClient() opens a native file picker via dialog.showOpenDialog.
- startMoshSession honors options.moshClientPath when provided. Strict
  failure: a missing/non-executable explicit path produces a clear
  error instead of falling back to auto-detect, so users notice typos
  and stale paths instead of getting silent recovery.

UI (components/settings/tabs/SettingsTerminalTab.tsx):
- New SettingRow under "Connection" with text input + Detect + Browse
  buttons, mirroring the localShell validation pattern. Shows inline
  validation (notFound/isDirectory) and the last detect result with
  searched directories on miss.

Plumbing:
- TerminalSettings.moshClientPath: string field with default "" so
  empty == auto-detect (matches existing PR #847 semantics).
- preload exposes detectMoshClient + pickMoshClient.
- createTerminalSessionStarters passes terminalSettings.moshClientPath
  into the IPC call, undefined when blank.
- en.ts / zh-CN.ts get the 9 new strings.

Verified locally:
- vite build succeeds; settings tab renders.
- detectMoshClient() against the live machine returns
  /opt/homebrew/bin/mosh with the expected searchedPaths list.
- Existing PR #847 auto-detection path is unchanged when the field is
  empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Skip POSIX execute-bit check for explicit Windows mosh path

Address Codex P2 on PR #849 commit 88e5c596. isExecutableFile used
`(stat.mode & 0o111) !== 0` to gate the explicit moshClientPath in
startMoshSession, but Windows Node returns mode 0o100666 even for
.exe / .bat / .cmd files (NTFS has no POSIX execute bits). Result:
a Windows user who picked a perfectly valid `mosh.exe` via the new
Browse dialog or typed an absolute path was rejected with
"Configured Mosh client not usable…" — making the manual override
unusable on Windows.

Make isExecutableFile platform-aware: still require isFile() and
the Unix execute bit on POSIX, but treat any regular file as
executable on Win32 and let spawn-time PATHEXT / extension handling
filter non-executables.

Resolver paths are unaffected — resolvePosixExecutable returns null
on Win32 before isExecutableFile is reached.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Augment Windows env when explicit mosh path is outside PATH

Address Codex P2 on PR #849 commit 69782471. When a Windows user
selected a mosh.exe outside %PATH% via Browse / custom path, the
explicit-client branch left resolvedMoshDir null, so the later
PATH/MOSH_CLIENT injection was skipped. The Mosh wrapper still
exec's `mosh-client` (and `ssh`) by name, so a valid selection
failed unless that directory was already on PATH.

- Always set resolvedMoshDir for explicit moshClientPath, regardless
  of platform.
- Use path.delimiter so PATH composition uses ";" on Win32 and ":"
  on POSIX. Compare directory membership with path.normalize so
  trailing-slash / case differences don't double-add.
- When picking mosh-client, try .exe / .bat / .cmd extensions on
  Win32 before the bare name; POSIX still uses just `mosh-client`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Validate Mosh client is executable in Settings UI

Address Codex P2 on PR #849 commit b6c384af. UI's debounced validator
called validatePath which only reported exists / isFile / isDirectory,
so a regular file without the POSIX execute bit (e.g. a stray
/etc/hosts-style path) was marked as valid in Settings — but
startMoshSession's isExecutableFile check then rejected the same path
at connect time, deferring the error until the user actually tried to
use Mosh.

- validatePath now returns `isExecutable: boolean`, mirroring
  isExecutableFile semantics (POSIX: stat.mode & 0o111; Win32: any
  regular file is treated as executable since NTFS lacks POSIX bits).
  Existing callers (localShell, localStartDir) ignore the new field.
- global.d.ts ValidatePath return type extended.
- SettingsTerminalTab Mosh validator surfaces a `notExecutable`
  message when the file exists but lacks exec permissions, keeping
  the UI in lockstep with main-process gating.
- en / zh-CN strings for the new state.

Verified: /bin/sh -> isExecutable:true, /etc/hosts -> false, /etc ->
false (directory). UI now warns immediately on the regression case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Require absolute Mosh client paths in Settings UI and main

Address Codex P2 on PR #849 commit 2eba549e. The shared validatePath
bridge resolves bare names through PATH (necessary for localShell
where 'powershell.exe' is a valid choice), so a user typing 'mosh' or
'mosh.exe' into the new Mosh field would get a green check in
Settings — but startMoshSession treats moshClientPath as a literal
filesystem path and calls isExecutableFile on the raw value. The
saved setting then disables auto-detection and Mosh sessions fail
unless a matching file happens to exist in the app's cwd.

Gate on absolute paths at both layers so UI validation and the
runtime check agree:

- startMoshSession: path.isAbsolute(expanded) before isExecutableFile,
  with a distinct error message naming the constraint.
- SettingsTerminalTab: same shape — UI checks looksAbsolute (POSIX
  /, leading ~, Windows drive letter, or UNC \\\\) before sending the
  IPC, surfacing notAbsolute inline. Tolerant across platforms so
  pasting a Windows-style path on macOS still produces a real
  downstream error rather than a misleading 'not absolute'.
- en / zh-CN strings.

Verified against the full case matrix (relative names, ./, ../, bare
basenames, POSIX absolute, ~/, Windows drive, UNC) — UI flags every
relative entry without an IPC round-trip, and any value that passes
UI also passes main-process validation (or both reject).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:39:37 +08:00
陈大猫andClaude Opus 4.7 cd04b0b33c [codex] Resolve mosh client across PATH gaps (closes #842) (#847)
* Resolve mosh client by absolute path on macOS / Linux

Closes #842.

macOS GUI Electron apps inherit launchd's reduced PATH
(/usr/bin:/bin:/usr/sbin:/sbin), missing /opt/homebrew/bin and other
common package-manager directories. The previous startMoshSession
called pty.spawn('mosh') with a bare name, so on Apple Silicon
Homebrew installs the spawn either failed silently or produced a
process that exited before the renderer could observe anything,
matching the issue: no terminal tab, no error toast, no DevTools log,
no network traffic.

- Add resolvePosixExecutable() that searches the inherited PATH and
  then a curated set of fallback directories (Homebrew arm64/x64,
  MacPorts, ~/.nix-profile, ~/.cargo, ~/.local).
- Resolve `mosh` to an absolute path before spawning. When it cannot
  be located, throw an Error with an installation hint instead of
  letting pty.spawn fail in a way that stays invisible — the
  renderer's existing catch in createTerminalSessionStarters already
  surfaces the message via term.writeln + setError.
- Prepend the resolved binary's directory to env.PATH and set
  MOSH_CLIENT, so the mosh wrapper script (Perl) finds mosh-client
  and ssh next to it even when the launchd PATH is reduced.

Verified the resolver against a fake binary placed only in a fallback
dir while the simulated PATH was reduced to /usr/bin:/bin — the
function correctly returns the fallback hit. Win32 path through
findExecutable() is left unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Resolve mosh against the merged child PATH

Address Codex P2 on PR #847 commit 314d396a: the resolver only checked
process.env.PATH plus hardcoded fallbacks, so a host that sets a custom
PATH via environmentVariables (later merged into the child env) could
trip the new "Mosh client not found" error even though the spawned
process would have had a valid PATH all along.

- Accept a { pathOverride } option on resolvePosixExecutable so the
  caller can pass the PATH the child will actually see.
- Pre-merge the host-supplied options.env.PATH (falling back to
  process.env.PATH when absent) and pass it to the resolver.
- Fallback dirs (Homebrew arm64/x64, MacPorts, ~/.nix-profile, etc.)
  still run after the override, so users who override PATH but forget
  to include their custom mosh location get the same silent rescue.

Verified four regression cases: no-override, Codex's custom-PATH
override, empty-string override, and opts-without-pathOverride —
each resolves the way the spawned process would.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:42:19 +08:00
yuzifuandyuzifu a29953f831 fix(session-logs): render terminal control sequences in saved logs (#832)
* fix(session-logs): render terminal control sequences in saved logs

Add a stateful terminal log sanitizer for txt/html session logs so saved output handles backspace, carriage-return overwrites, erase-line/display controls, and split CSI/OSC sequences correctly.

Stream txt/html auto-save through a persistent renderer and write rendered snapshots directly to the final log file, avoiding raw temp files and redundant full rewrites on session close. Keep raw log format unchanged.

* fix review issue

---------

Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
2026-04-28 08:50:46 +08:00
陈大猫andClaude Opus 4.7 c941038e68 [codex] Bundle Symbols Nerd Font Mono for terminal icon fallback (#846)
* Bundle Symbols Nerd Font Mono as terminal icon fallback

PR #845 added "Symbols Nerd Font Mono" to the terminal fontFamily
fallback chain so PUA glyphs (powerline / devicons / etc.) resolve
even when the user's primary font lacks them. That only worked if the
user had separately installed the symbol font; ship it ourselves so
icons render out of the box regardless of the chosen base font.

- Drop SymbolsNerdFontMono-Regular.ttf into public/fonts (~2.5 MB);
  Vite copies it to dist/fonts and the existing app:// protocol
  handler already knows the font/ttf MIME type.
- Register an @font-face in index.css pointing at the bundled file.
  font-display: block prevents tofu while the (instantly-available
  bundled) face loads, only affecting PUA glyphs since the base font
  is listed earlier in the fallback chain.
- Include the upstream LICENSE next to the font.

Source: ryanoasis/nerd-fonts NerdFontsSymbolsOnly v3.4.0 (MIT).

Refs #843

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Reference bundled font by absolute path so prod build resolves

Address Codex P2 on PR #846: the relative `./fonts/...` URL was emitted
verbatim into dist/assets/index-*.css, where the browser resolved it
against the CSS file's location and 404'd on
dist/assets/fonts/SymbolsNerdFontMono-Regular.ttf — the actual file
lives in dist/fonts/, so the icon fallback never loaded in packaged
builds and Nerd Font glyphs still rendered as tofu.

Switch the @font-face url() to `/fonts/...`. Vite's `base: "./"`
config rewrites that to the correct dist-relative form during build
(`../fonts/SymbolsNerdFontMono-Regular.ttf` from dist/assets/), and in
dev the same path is served by the Vite dev server out of public/.
Verified by re-running `vite build` and grepping the produced CSS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:39:01 +08:00
陈大猫andClaude Opus 4.7 b1ab4d7105 [codex] Enable Nerd Font glyphs in terminal (#845)
* Enable Nerd Font glyphs in terminal font picker and rendering

- Grant local-fonts permission on the default session so queryLocalFonts()
  can enumerate user-installed fonts; without it the picker only showed
  the 20 hard-coded built-ins, hiding Nerd Font sub-families like
  "JetBrainsMono Nerd Font Mono".
- Append a Symbols Nerd Font fallback to the terminal fontFamily chain so
  PUA icons (powerline / devicons / etc.) resolve even when the primary
  font lacks them, matching the cross-font fallback behavior CoreText-based
  terminals like Ghostty already provide.
- Whitelist "Symbols Nerd Font" / "Symbols Nerd Font Mono" in the local
  monospace allow-list so the symbol-only icon font is not filtered out.

Refs #843

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Restrict permission handler to app origin

Address review feedback on PR #845: the previous permissive fallthrough
granted every permission request/check that hit the default session,
which the in-app OAuth flow uses too. That meant remote OAuth pages
(accounts.google.com, login.microsoftonline.com, ...) could be auto-
approved for camera, microphone, geolocation, notifications, etc.

Gate the handler on the requesting origin: only the app's own renderer
(app://netcatty plus the dev server in dev) gets the local-fonts grant
and the prior approve-by-default behavior. Anything loaded from a
third-party origin is denied outright.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Use explicit permission allow-list for app origin

Address Codex P1 on PR #845 commit 975ca7e8: even after gating on the
app origin, the previous fallthrough still called callback(true) for
every non-local-fonts permission, so the main/settings renderers were
silently auto-granted notifications, geolocation, pointer lock, media,
etc. — none of which the app uses.

Replace the fallthrough with an explicit allow-list of the permissions
the renderer actually exercises (local-fonts plus clipboard read/write
for terminal + SFTP copy-paste). Anything outside that set is now
denied for the app origin too, matching the deny-by-default posture
Codex flagged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Match app:// origin by protocol+host, not URL.origin

Address Codex P1 on PR #845: in the packaged build the renderer loads
app://netcatty/index.html, but Node's WHATWG URL parser does not treat
app: as a standard scheme, so `new URL('app://netcatty/...').origin`
evaluates to the string "null". The previous Set-based origin check
therefore never matched the production renderer, causing the new
permission handlers to deny local-fonts as well as the existing
clipboard-read / clipboard-sanitized-write — breaking the font picker
and clipboard flows in release builds.

Compare protocol + host directly for app://, and keep the .origin
lookup for the dev server (which is HTTP-family and parses normally).
Verified against the relevant URL shapes (packaged main + settings,
dev server, third-party OAuth, file://).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:30:20 +08:00
陈大猫 08e566adb0 [codex] Add X11 forwarding support (#835)
* Add X11 forwarding support

* Address X11 forwarding review feedback

* Handle X11 auth for unix socket display paths

* Tighten X11 forwarding compatibility handling
2026-04-28 07:54:26 +08:00
秋秋 df25d6c4b0 fix: resolve WebGL blank frame on resize and keep split pane bright on context menu (#837) 2026-04-26 05:45:22 +08:00
陈大猫 324301e61a Show SFTP toolbar button (#834) 2026-04-25 16:48:48 +08:00
陈大猫andClaude Opus 4.7 2c3a8e7fb8 fix(cloud-sync): preserve adapter across browser handoff (closes #827) (#828)
The post-handoff `resetProviderStatus(provider)` call destroyed the
adapter that `startProviderAuth` had just created, because the hardened
`resetProviderStatus` now restores from the auth snapshot (which has
`adapter: null` for first-time connects). The subsequent OAuth callback
then failed with `google/onedrive adapter not initialized`, and the
error was persisted onto the provider state.

Introduce `clearConnectingStatus` for the "release connecting UI"
intent and switch the PKCE flow to use it, so adapter and auth
restore-snapshot are left untouched until the callback completes.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 20:48:22 +08:00
陈大猫 bd2642be74 Replace outdated asset links in README
Updated asset links in the README for various features.
2026-04-24 00:20:36 +08:00
陈大猫 23151c9db8 Replace Netcatty image and update Catty Agent section
Updated the README to replace the Netcatty image with a new image and removed some content related to the Catty Agent.
2026-04-23 23:29:17 +08:00
陈大猫 8215dfe6a1 Merge pull request #824 from binaricat/fix/cloud-sync-oauth-port-fallback-823
fix(cloud-sync): fall back to OS-assigned OAuth port when 45678 is busy (closes #823)
2026-04-23 17:24:54 +08:00
bincxz a1866747a5 fix(cloud-sync): harden auth cancellation flow 2026-04-23 17:24:28 +08:00
bincxz 78fc4628b9 refactor(cloud-sync): simplify OAuth callback flow 2026-04-23 14:51:50 +08:00
bincxzandClaude Opus 4.7 c721591466 fix(cloud-sync): fall back to OS-assigned OAuth port when 45678 is busy (#823)
The Google Drive / OneDrive PKCE flow bound a temporary callback server on
a hardcoded 127.0.0.1:45678. If anything on the user's machine already
holds that port (another desktop app, a leftover process, a firewall rule)
the listen fails with EADDRINUSE and the user sees
"Error invoking remote method 'oauth:startCallback': EADDRINUSE".

Split the bridge into a two-step flow so the chosen port is known before
we build the authorization URL:

- oauthBridge.prepareOAuthCallback(): tries the preferred 45678 first,
  falls back to an OS-assigned free port (listen(0)) if it's in use, and
  returns { port, redirectUri }.
- oauthBridge.awaitOAuthCallback(state): awaits the code on the
  already-prepared server.

CloudSyncManager.startProviderAuth now requires the redirectUri to be
passed in; useCloudSync calls prepare → startProviderAuth(redirectUri) →
await, and cancels the prepared server if anything fails before the
browser hop.

windowManager's in-app-popup allow-list reads the active port from
oauthBridge at popup-open time instead of hardcoding 45678, so the
loopback callback keeps working regardless of which port was chosen.

Also: unref() the callback server and closeAllConnections() on teardown
so the OS port is released promptly between flows and test runs don't
leave zombie listeners.

Tests: new electron/bridges/oauthBridge.test.cjs covers the preferred-
port path, the busy-port fallback (#823 regression), the state-mismatch
rejection, the provider-error rejection, the "await without prepare"
guard, and cancel/release semantics. All 85 bridge tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:12:16 +08:00
陈大猫andClaude Opus 4.7 8514c75301 fix(tray): ship multi-size .ico for Windows to fix HiDPI blur (#794) (#822)
The previous fix attached a 32x32 @2x representation to the 16x16 PNG,
which only covers 100% and 200% scale factors. Users on 125/150/175/
250%+ still got a blurry tray icon because Windows had to resample from
one of those two sizes.

Ship a proper multi-size tray-icon.ico (16, 20, 24, 32, 40, 48, 64) and
point the Windows tray loader at it. Windows picks the closest size per
DPI scale on its own, so no addRepresentation / resize juggling is
needed. Linux keeps the existing PNG + @2x path; macOS is unchanged.

Also add scripts/generate-tray-ico.py so the .ico can be regenerated
from public/icon-win.png whenever the source artwork changes.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:54:31 +08:00
陈大猫andClaude Opus 4.7 c30d872852 fix(settings): guard customKeyBindings sync against echo loop (closes #818) (#821)
* fix(settings): guard customKeyBindings cross-window sync against echo loop (closes #818)

customKeyBindings was the only synced setting whose two cross-window
handlers (DOM storage event + IPC onSettingsChanged) called
setCustomKeyBindings unconditionally. Every broadcast landed with a
fresh parsed object reference, so React re-rendered and the persist
effect re-broadcast, echoing across windows indefinitely.

While the echoes carry the same content, a rapid second click from
the user can arrive between the outbound broadcast and an older
in-flight echo — the echo's setState then clobbers the latest click
and the UI "bounces" from Disabled back to the original binding.
This matches the report in #818 (disable and reset operations
flicker between values when clicked in quick succession).

Fix: mirror the equality guards used by every other synced field.
Compare the incoming payload (stringified for objects) against the
current value from settingsSnapshotRef, and skip setCustomKeyBindings
when they match. Add customKeyBindings to settingsSnapshotRef so the
IPC handler has access without pulling it into the effect's closure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(settings): stop shortcut sync bounce flicker

* fix(settings): harden shortcut sync ordering

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:34:38 +08:00
陈大猫andClaude Opus 4.7 c58f018d24 fix(terminal): preserve selection when typing Space or uppercase letters (closes #819) (#820)
PR #763 captured and restored the mouse selection in a keydown-only
microtask. That covers lowercase letters — xterm's _keyDown calls
triggerDataEvent synchronously, so the selection is cleared before the
microtask drains and the restore runs.

Space (keyCode 32) and A–Z (the _keyDown macOS-IME HACK) are instead
routed through the keypress event, which fires in a *later* macrotask.
The keydown microtask drains first, sees the selection still intact, and
no-ops. Then keypress clears it without any restore.

Fix: hook both keydown and keypress in attachCustomKeyEventHandler. The
keypress path gives us a second microtask that drains after _keyPress
has cleared the selection, so the restore actually runs for those keys.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:38:23 +08:00
libalpm64 dd1d97ffff Fix Midnight brightness, optimize backdrop-blur, and remove unused radials. (#817)
- Fixed 8% brightness causes compositers to have severe rendering issues. (Only effected on the Midnight color scheme) 10% seems to be okay.
- Reduced backdrop-blur as it's expensive CSS.
- Removed radial-gradient backgrounds (they don't show up)
2026-04-23 10:01:02 +08:00
陈大猫andClaude Opus 4.7 3c6d888ca9 fix(icons): use a tight-crop source for Windows/Linux to unshrink the app icon (#816)
Closes #813.

#803 enlarged public/icon.svg's squircle to ~88% of the canvas so the
macOS dock icon would match third-party apps that don't leave Apple's
HIG grid margin. That fix is right for macOS — the dock already
rounds / shadows its own icons and the grid margin lines Netcatty up
with neighbors. But every non-mac launcher (Windows taskbar, Start
menu, desktop shortcuts, KDE / GNOME launchers, AppImage integrations)
renders icons full-bleed into a fixed-size slot, so that ~12% padding
shows up as visible empty space around the squircle — the reporter's
"taskbar icon looks smaller and blurrier than other apps".

Split the icon sources by platform:

- public/icon.svg / public/icon.png — unchanged, keeps the #803 88%
  fill. mac.icon (implicit via top-level) still uses it.
- public/icon-win.svg — new source with viewBox="100 100 824 824"
  (tight-cropped to the squircle) and the faint white outline stroke
  disabled. Rendered at 1024×1024 into public/icon-win.png.
- electron-builder.config.cjs wires win.icon and linux.icon to the
  new tight-crop source. Top-level icon: stays the padded version so
  the mac path is unchanged.

electron-builder generates a multi-size .ico from a ≥256px PNG on
Windows and scales PNG variants for Linux, so a single
1024×1024 source covers both platforms without new build steps.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 02:20:09 +08:00
陈大猫andClaude Opus 4.7 73b27ad7c4 fix(autocomplete): sync ghost text to live input on every keystroke (#815)
* fix(autocomplete): sync ghost text to live input on every keystroke

Ghost text was displayed based on whatever input was passed to
GhostTextAddon.show() at fetch time. Between a user's keystroke and
the next debounced fetchSuggestions firing (~100ms), the on-screen
line had already advanced one character but ghost.getGhostText() still
returned the pre-update tail. Pressing → during that window pasted the
stale tail on top of the new char — e.g. type "do", suggestion shows
"cker ls"; type "c", accept immediately → "doc" + "cker ls" lands as
"doccker ls" instead of the expected "docker ls".

Two-layer fix:

1. New GhostTextAddon.adjustToInput(newInput) that re-renders the ghost
   against a fresh input without waiting for a new fetch: shrinks /
   grows the tail if the suggestion still prefix-matches, hides
   otherwise. Called from handleInput after every buffer mutation
   (printable, backspace, Ctrl-W, paste tail) when the buffer is
   reliable. Unreliable-buffer paths skip the call to avoid making the
   ghost lie.

2. Defense-in-depth at both ghost-accept sites (→ and Ctrl-→):
   recompute the tail against the live typed buffer instead of trusting
   getGhostText's show()-time state. If the suggestion no longer
   prefixes the live buffer, hide without writing. Ctrl-→ additionally
   resyncs ghost.show() to the live buffer before picking the next word
   so getNextWord operates on an up-to-date tail.

* fix(autocomplete): defer ghost text updates to the next xterm render

The previous pass made adjustToInput re-show the ghost synchronously on
every keystroke, but xterm hasn't echoed the triggering char yet at
that moment — cursorX is still the pre-keystroke position. Painting
the shrunken tail there left it visibly overlapping with the char
xterm was about to draw, and the ghost only snapped to the right
column on the next onRender tick. That one-frame overlap is the
"jitter" the reporter still saw.

Switch adjustToInput to a defer-and-reapply pattern:

- On every keystroke that should re-align the ghost, stash the desired
  input in pendingInput and hide the element immediately. The
  transient blank frame is preferable to an overlap glyph.
- The existing term.onRender listener now checks for a pending update
  first: by that tick xterm has processed the echo, cursorX has
  advanced, and we can paint the new tail at the correct column via
  applyInputUpdate.
- New isActive() exposes "has a live suggestion even if hidden waiting
  for render" so a fast "type + →" / "type + Ctrl-→" sequence in the
  hide-until-render gap still hits the accept branch and grabs the
  recomputed tail from the live buffer.

show() and hide() clear pendingInput so an explicit state change
supersedes any queued adjust.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): restore ghost text, predict-anchor-shift on each keystroke

The previous refactor broke inline completion entirely:

1. useTerminalAutocomplete force-disabled showGhostText whenever
   showPopupMenu was on — and both are true by default, so ghost
   never rendered.
2. GhostTextAddon put its overlay container *under* xterm's screen
   via insertBefore + no z-index. xterm's default renderer paints
   theme.background across every cell including empty ones, so the
   ghost was fully occluded by the canvas even when the hook *did*
   call show().

Fixes both issues and lands the correct per-keystroke strategy the
jitter report was asking for:

- Drop the showGhostText-vs-showPopupMenu gate; respect user settings.
- Put the ghost container back on top of the screen (appendChild +
  z-index 1).
- Track anchorInputLength at show() time. adjustToInput now advances
  the ghost's left by (newInput.length - anchorInputLength) cells
  *synchronously* — i.e. it predicts where xterm's cursor will land
  once the echo arrives, instead of re-reading the live cursorX that
  hasn't advanced yet. textContent is trimmed in the same call, so
  ghost + real-input stay aligned across SSH echo latency with no
  one-frame overlap or blank gap.
- Updated GhostTextAddon.test.ts expectations for the new behavior
  (and cast the fake-document through unknown to fix the pre-existing
  TS error).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): address ghost text review feedback

Follow-ups on the predict-anchor-shift from the previous commit,
based on a code-reviewer pass:

- Backspace / Ctrl-W de-sync: updatePosition's Math.max(0, ...) was
  clamping the delta to zero when newInput shrank below the show-time
  input length. The ghost then stayed pinned at the original anchor
  column while the real cursor walked back left, leaving a gap
  between the cursor and the ghost. Let the delta go negative so the
  ghost tracks the cursor backwards; clamp the resulting left at 0
  instead of clamping the delta.
- Resize staleness: onResize now also resets lastLeft/lastTop and
  re-renders, so the dedup cache in updatePosition doesn't hide a
  now-stale pixel coordinate after xterm recomputes cell dims.
- Added a regression test for the backspace path covering both the
  step-back-below-anchor case and the clamp-at-0-on-overshoot case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): don't accept whole suggestion when buffer is unreliable

Codex flagged (#815 P1 ×2) that the live-buffer recompute on → and
Ctrl-→ falls into a degenerate path when typedBufferReliableRef is
false. My previous cut used live = "" as the fallback, but
fullSuggestion.startsWith("") is always true — so:

- → would write the entire suggestion over whatever is on the line
  (post history-recall ↑, Ctrl-R reverse search, etc.).
- Ctrl-→ would reanchor the ghost at the start and getNextWord would
  hand back the first token, duplicating leading content on top of
  the recalled command.

When the buffer is unreliable, empty buffer ≠ empty line — the line
has content we're not tracking. Fall back to the ghost's own cached
state instead of recomputing:

- → reliable: recompute tail vs live buffer, flip buffer to the
  accepted suggestion, reliability back on.
- → unreliable: use ghost.getGhostText() (shown-at-show-time tail)
  and don't touch the buffer/reliability flag.
- Ctrl-→ reliable: resync ghost to live, then proceed as before.
- Ctrl-→ unreliable: skip the resync, derive the shrink baseline from
  fullSuggestion - current-ghost-tail so the next-word logic still
  works off whatever the ghost was actually showing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): hide ghost on single-byte cursor/recall control chars

Reviewer caught that Ctrl-P / Ctrl-N / Ctrl-R / Ctrl-A / Ctrl-E and
friends flip typedBufferReliableRef to false but don't hide the
ghost — leaving it rendering a tail tied to the pre-recall line. The
previous commit's unreliable-→ fallback then reads that stale tail
via ghost.getGhostText() and writes it onto the recalled line,
reproducing the very duplication class the fallback was meant to
prevent (just triggered by Ctrl-P instead of ↑).

Mirror what the escape-sequence branch already does: clearState() +
return. Once the ghost is hidden, ghost.isActive() is false at the →
and Ctrl-→ gates, so the accept-path doesn't fire at all until a
fresh fetchSuggestions re-anchors it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): drop accepted-command cache on cursor/recall keys

Reviewer pointed out that the early returns in the single-byte
ctrl-char and escape-sequence branches leave lastAcceptedCommandRef
untouched. If the user accepts a suggestion via → and then immediately
hits Ctrl-R or ↑ to pick a different command, the fast Enter path
(lines ~611-612) still reads the cached accepted command and records
it — logging the old suggestion instead of whichever command the
reverse-search or history-recall actually ran.

Null lastAcceptedCommandRef at the top of both branches (same place
we hide the ghost and flip reliability off) so accept + recall + Enter
records the recalled command, not the stale accept.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): also null accepted-command cache on Ctrl-C / Ctrl-U

Reviewer flagged this class of bug is still reachable via Ctrl-C /
Ctrl-U. The branch handling those kills the zle line, but the early
return leaves lastAcceptedCommandRef pointing at a command that is
no longer on the line: accept "git status" via → → Ctrl-C to abandon
→ type "ls" → Enter logs "git status" via the fast path instead of
"ls".

Same one-liner as the other early-return branches: null the cache
alongside clearState(). Now the cache's lifetime truly ends at any
event that invalidates the accept.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): null accepted-command cache on bracketed paste too

Fifth-pass reviewer caught the last symmetric gap: the bracketed-paste
branch appends pasted bytes to the buffer but leaves lastAcceptedCommandRef
set. Accept "git status" via → then bracketed-paste " --short" (no
embedded newline), press Enter — the fast path at line 611 still reads
"git status" and logs that instead of "git status --short".

Mirror the non-bracketed paste branch: null the cache before clearState()
returns. All handleInput paths that extend or invalidate the line now
consistently end the cache's lifetime.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): predict ghost column by cell width + wrap at EOL

Review caught two geometry bugs in GhostTextAddon.updatePosition that
only surfaced outside the ASCII happy path:

- CJK / fullwidth / emoji glyphs occupy two xterm cells but the
  predictor advanced by one char-length per code unit, so ghost
  drifted one cell left for every wide char typed and visibly
  overlapped the user's glyph.
- When the predicted column crossed term.cols the real cursor wrapped
  to the next row, but the predictor just piled more pixels onto
  `left` — ghost walked off the right edge instead of following
  onto the next line.

Fix both by switching from code-unit count to a small EAW-style
width classifier, then applying row wrapping via
  col = (anchorX + cellDelta) % cols
  rowOffset = Math.floor((anchorX + cellDelta) / cols)
against the current term.cols. Fake terminal in the test suite now
exposes cols/rows so the unit tests can exercise both invariants:

- "advances the anchor by two cells when a CJK glyph is typed"
- "wraps the ghost to the next row when the predicted column crosses cols"

Known limitation the review already flagged: on backspace-after-wide
we don't have per-grapheme widths to reverse exactly, so the negative
delta falls back to code-unit width on the deleted slice. The slice
is `currentSuggestion[currentInput.length..anchorInputLength]` which
is the same text the user would have typed, so it's correct when
only ASCII edits; wide-char backspace can still drift by one cell.
Fixing this cleanly needs a per-grapheme buffer and is out of scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): honor showGhostText toggle while a ghost is on screen

Codex flagged (#815 P2) that fetchSuggestions gates new ghost shows
on settingsRef.current.showGhostText, but handleInput's adjustToInput
call had no such guard. A ghost that was already active at the moment
the user turned showGhostText off would keep tracking the typed
buffer via adjustToInput on every keystroke, so the "disabled" setting
only took hold after some unrelated path called clearState().

Two-part fix:

- Add a useEffect watching settings.showGhostText. When it flips false,
  hide the active ghost immediately so the disabled setting applies to
  whatever was already on screen.
- Gate the adjustToInput call in handleInput behind
  settingsRef.current.showGhostText too, so subsequent keystrokes under
  the disabled setting don't try to move or re-show a ghost.

Codex's earlier P2 about wrap-at-EOL on line 236 is already resolved
by e61f0e8b (predict-column-with-wrap + CJK width); that comment is
against an older commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): self-heal stale anchor + handle backward-wrap on delete

Codex flagged two real geometry gaps in the predict-anchor-shift math:

1. Stale anchor on high-latency shells. show() captures cursorX from
   xterm at debounce-fire time, but under SSH round-trip latency the
   user's latest keystroke may not have echoed yet — cursorX is still
   the pre-echo column. With updatePosition now purely anchor-based
   (no longer reading live cursorX on every render), that stale anchor
   becomes frozen; the ghost stays one-plus cells off for the whole
   suggestion session until another show() rebuilds it.
2. Backspace crossing a wrapped row boundary. Math.max(0, ...) clamped
   targetCol at zero, so deletions past column 0 stayed pinned to the
   current row instead of wrapping back to the previous row — exactly
   the symmetric case the forward wrap added in e61f0e8b handles.

Fixes:

- Self-heal in updatePosition: while no adjustToInput has moved us
  from the show-time baseline (currentInput.length === anchorInputLength),
  re-read live cursorX/Y each render tick. Once the user starts typing
  the anchor is frozen and delta math takes over.
- Normalize the wrap for negative targetCol: `col = targetCol % cols`
  plus `if (col < 0) col += cols`, `rowOffset = Math.floor(targetCol/cols)`
  naturally yielding -1 on underflow. Clamp `top` at row 0 so a
  runaway negative doesn't render above the terminal.

Two new tests cover both invariants:
- "self-heals a stale anchor on render while no adjustToInput has fired"
- "wraps the ghost to the previous row when deletion crosses a row boundary"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): restore ghost/popup mutual-exclusivity guard in hook

Codex flagged (#815 P2) that dropping the popup-wins-over-ghost
normalization inside useTerminalAutocomplete weakens the hook's own
defensive invariant. The repo enforces mutual exclusivity in two
places already — SettingsTerminalTab toggles one off when the other
turns on, and domain/models.ts normalizes stored settings so
autocompletePopupMenu === true forces autocompleteGhostText to false
— so on the normal Terminal.tsx → store path only one of the two
arrives as true. But the hook's own defaults (DEFAULT_AUTOCOMPLETE_SETTINGS)
have both flags true, and any caller that builds settings directly
from those defaults (tests, future embedders) would end up rendering
popup + inline ghost simultaneously against the repo-wide contract.

Restore the guard, comment it as defensive rather than load-bearing
so future readers don't mistake it for the hiding-invisible-ghost
bug I was fixing last time (that was really the insertBefore /
z-index issue in GhostTextAddon.ts, not this normalization).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 02:06:26 +08:00
libalpm64anddependabot[bot] 4090483738 Fix Security Issues (#799)
* chore(deps): bump fast-xml-parser and @aws-sdk/xml-builder

Bumps [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) and [@aws-sdk/xml-builder](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/xml-builder). These dependencies needed to be updated together.

Updates `fast-xml-parser` from 5.3.4 to 5.5.8
- [Release notes](https://github.com/NaturalIntelligence/fast-xml-parser/releases)
- [Changelog](https://github.com/NaturalIntelligence/fast-xml-parser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.3.4...v5.5.8)

Updates `@aws-sdk/xml-builder` from 3.972.4 to 3.972.18
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/xml-builder/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/xml-builder)

---
updated-dependencies:
- dependency-name: fast-xml-parser
  dependency-version: 5.5.8
  dependency-type: indirect
- dependency-name: "@aws-sdk/xml-builder"
  dependency-version: 3.972.18
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump follow-redirects from 1.15.11 to 1.16.0

Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.11 to 1.16.0.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.11...v1.16.0)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-version: 1.16.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): bump hono from 4.12.7 to 4.12.14

Bumps [hono](https://github.com/honojs/hono) from 4.12.7 to 4.12.14.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.7...v4.12.14)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.12.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump vite from 7.3.1 to 7.3.2

Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.1 to 7.3.2.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.2/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.2
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump flatted from 3.3.3 to 3.4.2

Bumps [flatted](https://github.com/WebReflection/flatted) from 3.3.3 to 3.4.2.
- [Commits](https://github.com/WebReflection/flatted/compare/v3.3.3...v3.4.2)

---
updated-dependencies:
- dependency-name: flatted
  dependency-version: 3.4.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump minimatch from 3.1.2 to 3.1.5

Bumps [minimatch](https://github.com/isaacs/minimatch) from 3.1.2 to 3.1.5.
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-version: 3.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump lodash from 4.17.23 to 4.18.1

Bumps [lodash](https://github.com/lodash/lodash) from 4.17.23 to 4.18.1.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.18.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): bump @hono/node-server from 1.19.11 to 1.19.14

Bumps [@hono/node-server](https://github.com/honojs/node-server) from 1.19.11 to 1.19.14.
- [Release notes](https://github.com/honojs/node-server/releases)
- [Commits](https://github.com/honojs/node-server/compare/v1.19.11...v1.19.14)

---
updated-dependencies:
- dependency-name: "@hono/node-server"
  dependency-version: 1.19.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): bump rollup from 4.57.1 to 4.60.2

Bumps [rollup](https://github.com/rollup/rollup) from 4.57.1 to 4.60.2.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.60.2)

---
updated-dependencies:
- dependency-name: rollup
  dependency-version: 4.60.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump electron from 40.1.0 to 40.8.5

Bumps [electron](https://github.com/electron/electron) from 40.1.0 to 40.8.5.
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v40.1.0...v40.8.5)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 40.8.5
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): bump path-to-regexp from 8.3.0 to 8.4.2

Bumps [path-to-regexp](https://github.com/pillarjs/path-to-regexp) from 8.3.0 to 8.4.2.
- [Release notes](https://github.com/pillarjs/path-to-regexp/releases)
- [Changelog](https://github.com/pillarjs/path-to-regexp/blob/master/History.md)
- [Commits](https://github.com/pillarjs/path-to-regexp/compare/v8.3.0...v8.4.2)

---
updated-dependencies:
- dependency-name: path-to-regexp
  dependency-version: 8.4.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump picomatch from 2.3.1 to 2.3.2

Bumps [picomatch](https://github.com/micromatch/picomatch) from 2.3.1 to 2.3.2.
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): bump yaml from 2.8.2 to 2.8.3

Bumps [yaml](https://github.com/eemeli/yaml) from 2.8.2 to 2.8.3.
- [Release notes](https://github.com/eemeli/yaml/releases)
- [Commits](https://github.com/eemeli/yaml/compare/v2.8.2...v2.8.3)

---
updated-dependencies:
- dependency-name: yaml
  dependency-version: 2.8.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump @xmldom/xmldom from 0.8.11 to 0.8.13

Bumps [@xmldom/xmldom](https://github.com/xmldom/xmldom) from 0.8.11 to 0.8.13.
- [Release notes](https://github.com/xmldom/xmldom/releases)
- [Changelog](https://github.com/xmldom/xmldom/blob/master/CHANGELOG.md)
- [Commits](https://github.com/xmldom/xmldom/compare/0.8.11...0.8.13)

---
updated-dependencies:
- dependency-name: "@xmldom/xmldom"
  dependency-version: 0.8.13
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump brace-expansion from 1.1.12 to 1.1.14

Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.12 to 1.1.14.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.14)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump tar from 7.5.7 to 7.5.13

Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.7 to 7.5.13.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.7...v7.5.13)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.13
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Security Fixes

Security fixes:
Added input validation for uncontrolled command lines.
Added Proper Shell Escaping for useTerminalAutocomplete
Fixed 4 race condition alerts by atomic stat+read(s) without following symlinks.

Misc:
Use Crypto randomness instead of Math.random() (Not a security issue but convenient)

* Fix OS quirk fallbacks

* Review fix

- use lstat before open to skip FIFO/devices early to prevent blocks
- SFTP skip UUID tag could be dubiously long

* allow symlinks alongside regular files.

* Use acutal target size for reading

* Fix Destructed import / fix to use full shellEscape charset

- Destructed import
- Guard now matches full shellEscape charset

* Supress Codex complaints

Replaced manual fd.read with fs.promises.readFile(fd) to ensure complete file reads to EOF.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 01:41:26 +08:00
陈大猫andClaude Opus 4.7 9bf4aed44f fix(autocomplete): stop prepending theme cwd ("~ ") to completed commands (closes #806) (#814)
* fix(autocomplete): honor typed keystrokes when the prompt parser over-captures

Closes #806.

## Root cause

findPromptBoundary stops at the first "PROMPT_CHAR + space" it sees on
the current line. Themes that render additional content after the
prompt char — most notably oh-my-zsh robbyrussell's "➜  ~ " where "~"
is the cwd — trip it: promptText becomes "➜ ", userInput becomes
"~ sudo id". Every consumer downstream treats the theme's cwd marker
as part of the user's command, so:

  1. recordCommand logs entries like "~ sudo id" into history.
  2. fuzzyQueryHistory later returns those polluted entries as
     suggestions.
  3. When the user hits Tab, insertSuggestion compares
     suggestion.text ("~ ls") against userInput ("~ lo"), falls into
     the Ctrl-U-plus-rewrite path, and the phantom "~ " ends up on
     the real command line.

The reporter hit this right after `sudo` because sudo's password
interaction gave history enough polluted entries to start winning
fuzzy matches; without sudo the popup stays empty so the Ctrl-U
rewrite path never fires and the bug is invisible.

## Fix

Track what the user actually typed in an independent keystroke buffer
(typedInputBufferRef) inside the autocomplete hook:

- Append every printable char / paste chunk.
- Pop on backspace, word-kill on Ctrl+W.
- Clear on Enter, Ctrl+C, Ctrl+U, and any escape sequence / unhandled
  control char (cursor moves we can't follow invalidate the buffer).

Introduce reconcilePromptWithTypedInput: if detectPrompt's userInput
ends with the typed buffer and is longer, the parser over-captured —
move the excess back to promptText so userInput matches what was
actually typed. Apply at every detectPrompt call site
(fetchSuggestions, the stale-result recheck, insertSuggestion).

For Enter-record the typed buffer wins outright when present, but
only after a live detectPrompt confirms we're at a shell prompt —
otherwise a password-entry Enter would log the password as a
command.

insertSuggestion / ghost-text accept update the typed buffer to the
accepted text so a subsequent Enter records the right command.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): track keystroke-buffer reliability, skip it after cursor moves

Codex flagged (#814 P1) that clearing typedInputBufferRef on escape /
control sequences and then re-appending printable keys leaves the
buffer holding only the post-navigation suffix of the real line.
A classic Up-arrow-recall workflow — ↑ to pull "git commit -m fix"
out of history, append one char, Enter — would record just that one
char as the command, polluting history and skewing future fuzzy
matches.

Add typedBufferReliableRef as a companion flag:

- Reset (reliable=true) on Enter / Ctrl-C / Ctrl-U (zle wipes the
  line, our buffer is a true view of the empty line again).
- Also reset by insertSuggestion and ghost-text right-arrow accept
  once they write the full accepted text and we re-align the buffer
  to it.
- Cleared (reliable=false) when any escape sequence, unhandled
  control char (Ctrl-P / Ctrl-N / Ctrl-R / Ctrl-A / Ctrl-E / ...)
  arrives — those can move the cursor or swap the zle line in ways
  an append-only buffer can't follow.

All four call sites now gate on the flag:

- reconcilePromptWithTypedInput receives the buffer only when
  reliable, so an unreliable buffer never trims the detector's
  userInput (avoids a symmetric flavor of the original bug where
  the detector is right and the buffer is wrong).
- Enter-record prefers the buffer only when reliable; otherwise it
  falls straight through to detectPrompt.
- The Ctrl+Right (next-word ghost accept) append is skipped when
  unreliable so we don't seed the buffer with just that word.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): resync typed buffer when sub-dir select rewrites the line

Codex flagged (#814 P2) that handleSubDirSelect rewrites the command
line via writeToTerminal(Ctrl-U + cmdPrefix + fullPath) but never
touches typedInputBufferRef. After the rewrite the buffer still holds
whatever was typed before, so pressing Enter records that stale partial
input as the executed command — polluting history and steering later
suggestions off course.

Same commit also routes handleSubDirSelect through
reconcilePromptWithTypedInput. The raw detectPrompt would include the
robbyrussell "~ " cwd marker in the command prefix it reconstructs,
which is the original symmetric #806 bug leaking into this path too.

After the rewrite, set the buffer to the newly written command string
and flip reliability back on — the terminal line content now matches
it exactly, so the next Enter-record does the right thing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): reset typed buffer when a paste chunk carries a newline

Codex flagged (#814 P2) that multi-character paste payloads skip the
top-of-handleInput Enter guard (which compares data === "\r" exactly),
so a paste like "cmd\r" goes through the paste branch and the "\r" gets
appended to typedInputBufferRef verbatim. The shell executes "cmd", but
our buffer is left holding "cmd\r...", still marked reliable. The next
Enter then records whatever combined stale string lives there.

Detect line terminators inside multi-char paste chunks: slice from the
last \r or \n onward and keep only that tail as the new buffer content
(and flip reliability back on, since the tail now matches the shell's
zle line). Skip synthesizing recordCommand entries for the flushed
intermediate lines — onCommandExecuted in createXTermRuntime already
tracks pasted multi-line input independently, so duplicating the logic
here would risk double-counting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): clear lastAcceptedCommandRef on paste-with-newline early return

Codex flagged (#814 P2) that the multi-line-paste branch clears the
keystroke buffer and bails out before the rest of handleInput runs —
including the line that resets lastAcceptedCommandRef. If the user had
just accepted a suggestion (Tab / → / popup click), the embedded
newline still flushes it in the shell, but our fast-path cache keeps
holding it. The next Enter then takes the lastAcceptedCommandRef
shortcut and logs that old suggestion as the executed command,
polluting history with something the user didn't actually run.

Null lastAcceptedCommandRef.current at the same point we reset the
typed buffer so the fast path stays aligned with the shell.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): require typed buffer to align with live line before recording

Codex flagged (#814 P1) that paste paths which bypass handleInput —
the createXTermRuntime hotkey / context-menu / middle-click handlers
all call writeToSession(...) directly — leave typedInputBufferRef
stale while still marked reliable. A "type prefix → paste remainder →
Enter" flow would then record just the keyboard-typed prefix, feeding
garbage back into autocomplete ranking.

Require alignment: livePrompt.userInput must end with the typed buffer
before we trust it. reconcilePromptWithTypedInput already snaps the two
together when they *are* aligned — if its endsWith check fails, the
buffer is stale (or mid-navigation) and we fall back to
livePrompt.userInput instead. That drops the #806 fix for this one
paste-bypass case, but the same flow would have hit the same pollution
before this PR, so it's a no-regression fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): route out-of-band paste writes through handleInput

Codex flagged (#814 P1) that the reconcile path in fetchSuggestions
has the same stale-buffer failure mode the Enter-record path now
guards against: snippet / keyboard-paste / selection-paste /
middle-click-paste handlers in createXTermRuntime call
writeToSession directly, so typedInputBufferRef only holds whatever
was typed *after* the paste. reconcilePromptWithTypedInput then
treats the pasted prefix as prompt text and trims it, completions
fetch on the truncated input, and accepting a suggestion rewrites
the command incorrectly.

Fix at the source: notify the autocomplete hook with the raw
(pre-bracket-wrap) bytes at every paste site so its keystroke
buffer absorbs them through the same handleInput path keyboard
input uses. handleInput's multi-char paste branch already resets /
aligns the buffer (and invalidates on embedded escape sequences),
so this single extra call per paste site is enough — no new hook
API needed. The existing onData-driven notification at line 684
already covers the non-paste keyboard path, and the snippet /
paste / pasteSelection / middle-click handlers are the only
remaining paths that bypass it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): preserve inner newlines of bracketed-paste input

Codex flagged (#814 P2) that the multi-char-paste branch in
handleInput drops everything before the last newline, but when
bracketed paste is active those newlines are literal input staying on
the zle line — not command terminators. A multi-line paste like
"cmd1\ncmd2" then left only "cmd2" in typedInputBufferRef and the
next Enter recorded / trusted just the tail.

Teach handleInput to recognize the bracketed-paste wrapper
"\x1b[200~...\x1b[201~" and append the enclosed content verbatim
(reliability flag stays on — we know exactly what was added).

Matching change in createXTermRuntime: pass the final (possibly
bracket-wrapped) bytes to ctx.onAutocompleteInput instead of the raw
pre-wrap text so the handle sees the markers when applicable.
Non-bracketed pastes still hit the existing newline-split branch so
each "\n" resets the buffer to the post-terminator tail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(autocomplete): route every prompt consumer through getAlignedPrompt

Each Codex round on #814 surfaced one more code path that needed the
"consume the keystroke buffer only when it's aligned with the live
line" gate: Enter-record, fetchSuggestions (×2), insertSuggestion,
handleSubDirSelect, fetchSubDirForIndex. The fixes were correct but
the guard ended up spelled three different ways across the file:

  reconcilePromptWithTypedInput(detectPrompt(term), reliable ? buf : "")

plus a separate `userInput.endsWith(buf)` check in the Enter branch.
That scatter is exactly how the next out-of-band writer gets missed
and regresses #806.

Collapse all six sites onto one helper:

  getAlignedPrompt(term, buffer, reliable) → { prompt, alignedTyped }

The helper owns the policy — reliability + endsWith alignment — in one
place. Non-aligned buffers fall through as raw detector output (same
pre-PR behavior, so the worst case for any future forgotten path is
a degrade, not a pollution). Enter-record additionally consumes
alignedTyped, which is only non-null when the buffer truly matches
the tail, so it can record the clean typed command directly without
redoing the endsWith check.

No behavior change from the previous commit; this is purely
deduplication of the alignment guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): inherit reliability on bracketed paste instead of resetting

Codex flagged (#814 P1 follow-up) that the bracketed-paste branch
unconditionally flipped typedBufferReliableRef back to true. A
history-recall-then-paste flow (↑ marks the buffer unreliable, then
bracketed paste arrives) would then set reliable=true even though
the buffer only contains the pasted tail, not the recalled head.
getAlignedPrompt's endsWith check can pass trivially for a short
paste tail that happens to equal the last N chars of the recalled
line, and Enter would record just the pasted fragment.

Reliability is now inherited across a bracketed paste rather than
reset: if the buffer was already aligned, appending the paste keeps
it aligned; if the buffer was unreliable (post-recall / post-cursor-
move), it stays unreliable and the alignment guard in getAlignedPrompt
falls through to the raw detector result the way it should.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:40:29 +08:00
陈大猫andClaude Opus 4.7 a5b5f15343 feat(terminal): quick encoding switch for telnet & serial (closes #804) (#812)
* feat(terminal): extend quick encoding switcher to telnet and serial sessions

Closes #804.

TerminalToolbar only showed the UTF-8 / GB18030 encoding menu for SSH
sessions. Telnet and serial sessions had no runtime control — their
decoder was fixed at session start via charsetToNodeEncoding + Node's
StringDecoder, which only knows utf8/latin1/ascii/utf16le. Users
connecting to legacy telnet daemons or MCU consoles emitting GBK were
stuck with the encoding chosen at connect time and could not switch to
read non-latin text correctly.

Main side (terminalBridge.cjs):
- Swap StringDecoder for iconv-lite on the telnet + serial paths so
  GB18030 actually decodes. Local PTY and mosh keep StringDecoder —
  local follows the OS locale and mosh frames its own UTF-8, neither
  needs a runtime swap.
- Store the decoder through a mutable decoderRef on the session object
  so the onData closures stay untouched while a new IPC handler can
  swap in a fresh decoder mid-session.
- Add normalizeTerminalEncoding that resolves user-facing charset
  names (utf-8/gbk/gb2312/gb18030) into iconv identifiers.
- Register netcatty:terminal:setEncoding, which updates the session's
  encoding + decoderRef (and mirrors to serialEncoding for aiBridge /
  mcpServerBridge exec calls that still read the legacy field).

Renderer + preload:
- preload.setSessionEncoding now tries the SSH handler first and falls
  through to the new terminal handler when the SSH side reports ok:
  false (non-SSH sessions don't have session.stream). Single preload
  method, one extra IPC round-trip only for telnet/serial, which only
  happens on explicit user click.
- Drop the isSSHSession gate in TerminalToolbar; replace with
  encodingSwitchSupported = not local, not mosh, not localhost-PTY.
- Terminal.tsx onSessionAttached now syncs the initial encoding for
  every protocol that supports it (same gate as the toolbar), not
  only SSH.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): decode serial exec output with iconv for non-Buffer encodings

Codex flagged (#812 P1) that session.serialEncoding can now be an
iconv-only label like gb18030 after a user switches encoding via the
new terminal toolbar menu. execViaRawPty then called
data.toString(encoding) on the raw Buffer, which throws
"TypeError: Unknown encoding" for anything outside Node's
utf8/latin1/ascii/utf16le set. The throw landed inside the data
listener so Catty Agent / MCP serial exec calls failed and, worse,
the uncaught path could destabilize the process.

Route the decode through a small decodeBufferAs helper: Node encoding
labels still use Buffer.toString for speed; anything else falls back
to iconv-lite (which already handles the toolbar's GB18030). A last-
resort utf8 fallback keeps the listener from throwing even if iconv
itself rejects an unrecognized label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(terminal): don't overwrite telnet/serial charset on session attach

Codex flagged (#812 P1) that extending onSessionAttached to sync the
UI encoding for telnet and serial sessions corrupts any host charset
outside the toolbar's two values. terminalEncodingRef is derived from
a useState that only ever resolves to 'utf-8' or 'gb18030', so a host
configured with latin1 / shift_jis had its correct decoder immediately
clobbered with one of those two as soon as the session attached.

SSH is the only protocol that actually needs this sync: its backend
starts in utf-8 regardless of host.charset. startTelnetSession and
startSerialSession already apply options.charset through
normalizeTerminalEncoding, so leaving them alone keeps arbitrary
iconv labels intact; the toolbar's runtime switch remains the path
for users who do want to flip to UTF-8 / GB18030 mid-session.

Restore the SSH-only gate on the sync and document why the new
protocols are intentionally excluded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(terminal): align encoding menu rows with the rest of the popover

The encoding section used a different template from every other row in
the overflow menu: an uppercase "TERMINAL ENCODING" section header,
then two indented rows with a leading check mark instead of a leading
icon. Next to Open SFTP / Scripts / Terminal settings it read as a
different component and made the popover feel disjointed.

Drop the section header and render both encoding options as plain
menuItemClass rows — Languages icon on the left to match the Zap /
Palette leading-icon pattern, label in the flex-1 slot, and the active
row gets a trailing Check in place of a right-side accessory. A single
divider above them still groups the choice visually without the
uppercase label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(terminal): collapse encoding picker into a proper submenu

The previous pass put UTF-8 and GB18030 as flat rows under a separator
inside the main overflow popover. It matched the top rows better but
still looked like a disjoint block of two choices stuck at the bottom.

Turn the encoding picker into a nested submenu so the parent popover
stays a flat list of actions and the choice lives behind a single row
that mirrors the other menu items exactly: Languages icon on the left,
t("terminal.toolbar.encoding") label in the flex slot, the current
value as a muted caption, and a ChevronRight to signal the submenu.

The submenu itself is a second Popover anchored to the right of the
parent. Both popovers are now controlled so picking a value closes
the whole chain in one click, and the parent's onInteractOutside
ignores clicks that land in the submenu portal — otherwise Radix
would treat the submenu click as "outside" the parent and dismiss it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(terminal): drop hostname gate, simplify encoding row label

Two issues in one pass:

1. Codex P2 (#812): encodingSwitchSupported still hard-disabled the
   menu when host.hostname === 'localhost'. That was a leftover from
   when the only "local" escape hatch was hostname-based, but it
   incorrectly blocks telnet / SSH sessions aimed at localhost (test
   daemons, forwarded endpoints) which do have a real backend decoder
   we can drive. The isLocalTerminal / isMoshSession gates already
   cover the true local PTY and mosh cases — drop the hostname check.

2. UI: the submenu trigger carried the current value as a muted
   caption next to the label. At w-48 the row ran out of room and
   truncated "Terminal Encoding" to "Terminal Enc...". Since the
   submenu already marks the active choice with a check, the caption
   is redundant. Remove it so the full label fits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): stream-decode serial output with a stateful per-command decoder

Codex flagged (#812 P2) that decoding each serial data event with a
stateless decodeBufferAs call corrupts multi-byte characters on
GBK/GB18030 consoles: serial ports deliver chunks at arbitrary byte
boundaries, so the leading half of a 2-byte char in one event gets
emitted as replacement bytes before the trailing half ever arrives.

Build a stateful decoder once per execViaRawPty call (StringDecoder
for Node-native encodings, iconv.getDecoder for iconv-only labels
like gb18030) and feed every chunk through decoder.write(). On
finish, decoder.end() flushes any partial bytes the decoder is still
holding into the final output before it's handed back to the caller.
Strings pass through untouched, same as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(terminal): sync SSH encoding on localhost sessions too

Codex flagged (#812 P2) that dropping the 'localhost' check from the
toolbar's encodingSwitchSupported gate left an inconsistency:
Terminal.tsx onSessionAttached still skipped setSessionEncoding when
host.hostname === 'localhost', so a user could pick GB18030, reconnect
a localhost SSH tab, and the backend would restart in utf-8 while the
UI still showed GB18030 — mojibake until manually toggled again.

Drop the hostname clause from the isSSH check here as well. SSH to
localhost is still a real SSH session whose backend starts in utf-8;
the sync is what keeps the UI's picked encoding aligned across
reconnects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(terminal): re-sync telnet/serial encoding after user opt-in

Codex flagged (#812 P2) that the SSH-only sync left telnet/serial with
a silent UI/backend mismatch across reconnects: a user picks GB18030,
the tab disconnects and retries, startTelnetSession/startSerialSession
re-apply host.charset, and the UI still shows GB18030 — garbled output
until the user toggles again.

An unconditional sync isn't right either (earlier review: it would
clobber arbitrary host.charset values like latin1 / shift_jis that
the UI's two-value state can't represent). Track whether the user
has actually clicked the toolbar menu this session via
userPickedEncodingRef — once set, any subsequent onSessionAttached
for telnet/serial re-applies the picked value; on first attach with
no user action the backend's configured charset stays intact.

SSH keeps the unconditional sync (its backend always starts in utf-8,
so there's no configured charset to preserve).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:28:05 +08:00
陈大猫andClaude Opus 4.7 5b26a4a447 fix(sftp): download all selected files instead of only the right-clicked one (#811)
Closes #805.

The SFTP file-list context menu's Download action only passed the
right-clicked entry to the single-file handler, so selecting N files
and hitting Download still downloaded only one — matching copy/move/
delete, which already iterate selectedFiles, this is the odd one out.

Add onDownloadFiles through the SftpContext → pane callbacks → file-
list chain. In the context menu, if the right-clicked row is part of
pane.selectedFiles and the selection has >1 entry, fall into the new
multi-file path; single selection stays on the existing handler so
its save-dialog UX is unchanged.

The new handleDownloadFilesForSide iterates local selections with the
existing blob path (browser auto-saves each file). For remote panes
it prompts for a target directory once via selectDirectory and streams
every selected file into it — avoids the N-save-dialog prompt storm
that a naive loop would trigger. Mirrors the existing directory-
download branch.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:30:13 +08:00
陈大猫andClaude Opus 4.7 6565e984b4 fix(ssh): include legacy HMACs for very old servers (closes #807) (#810)
* fix(ssh): include legacy HMAC algorithms when legacy toggle is enabled

buildAlgorithms() adds legacy kex, cipher, and host-key algorithms when
the user enables "allow legacy algorithms", but never specified hmac at
all — so ssh2's built-in modern HMAC defaults applied even in legacy
mode. Very old servers (FreeBSD 6.1's OpenSSH circa 2006, per issue #807)
only speak hmac-sha1 / hmac-md5, so MAC negotiation silently settled on
something the server couldn't actually compute. The resulting wrong
exchange-hash MAC then failed host-key signature verification, surfacing
as "Handshake failed: signature verification failed" which misleadingly
looks like a host-key algorithm problem.

Add an explicit algorithms.hmac list in the legacy branch that keeps
modern MACs at the top and appends hmac-sha1 / hmac-md5. Modern servers
will still prefer SHA-2; only servers that literally can't do SHA-2 will
fall back to SHA-1/MD5.

Closes #807.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): skip hmac-md5 when OpenSSL build disables MD5 (FIPS)

Codex flagged (#810 review) that ssh2 validates exact algorithm lists
strictly and FIPS-enabled Node/OpenSSL builds disable MD5. With an
unconditional 'hmac-md5' entry in algorithms.hmac, those builds would
throw "Unsupported algorithm" before the SSH handshake even begins,
turning the legacy toggle into a hard failure even for servers that
only needed hmac-sha1.

Feature-detect MD5 via crypto.getHashes() at module load and only append
'hmac-md5' when it's actually available. hmac-sha1 stays unconditional
— FIPS 140-2 permits HMAC-SHA1 even where SHA-1 is disallowed for other
uses, and ssh2 ships with it in its defaults anyway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): preserve EtM SHA-1 MAC in legacy algorithm list

Codex flagged (#810 P2) that replacing ssh2's default MAC set with an
exact list omitted 'hmac-sha1-etm@openssh.com', which is present in
ssh2's DEFAULT_MAC. Hosts that only offer EtM SHA-1 MACs would then
fail legacy-mode negotiation with "no matching C->S MAC" even though
they negotiated successfully before the legacy HMAC list was introduced.

Insert 'hmac-sha1-etm@openssh.com' between the SHA-2 EtM entries and
plain hmac-sha1 so modern MACs still take priority and the fallback
chain matches ssh2's own default ordering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:15:27 +08:00
bincxzandClaude Opus 4.7 587071cfea chore: ignore .worktrees/** in ESLint config
Running `eslint .` from the repo root traversed into local git worktrees
under .worktrees/ and linted their source copies, which don't match the
relative ignore patterns like `electron/**` and `scripts/**`. Result: a
thousand no-undef errors from Node/browser globals in worktree-mirrored
.cjs / .mjs files.

Add .worktrees/** to the global ignores list so worktrees are skipped
regardless of whether node_modules is symlinked or fresh-installed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:24:37 +08:00
陈大猫andClaude Opus 4.7 08f00ed143 fix(editor): address Codex review feedback on PR #808 (#809)
* fix(editor): address Codex review feedback on PR #808

Three issues raised on the merged editor-tab-form PR:

P1 — Host-picker switch ignored onDisconnect cancellation
SftpPaneDialogs' onSelectLocal / onSelectHost awaited onDisconnect() and
unconditionally called onConnect() regardless of the dirty-editor prompt
outcome. A user who hit Cancel on the "unsaved changes" dialog would still
end up switched to the new host, stranding the editor tabs on a now-stale
connection. Change onDisconnect to return Promise<boolean> (true when the
disconnect actually ran, false on prompt cancel) and gate onConnect on it.
Propagate the new signature through SftpPaneCallbacks, the pane-actions
hook result, and both left/right implementations.

P2 — setIsQuitting leaked across canceled quits
electron/main.cjs called windowManager.setIsQuitting(true) at the top of
before-quit, before the dirty-editor check returned. If the renderer
reported hasDirty=true and the quit was canceled, isQuitting stayed true,
changing later window-close behavior (close-to-tray paths gated on
!isQuitting would stop firing). Move the setIsQuitting call into a
commitQuit() helper that only runs once we've decided to actually proceed
— on hasDirty=true we leave state untouched.

P2 — SftpSidePanel unmount only cleaned active-pane connections
The cleanup effect inspected only leftPane / rightPane (the active tab
per side), missing editor tabs tied to inactive tabs in the same side
panel. On unmount those tabs would survive with a dead save bridge.
Iterate leftTabs.tabs and rightTabs.tabs and collect every connection id
before calling forceCloseBySessions.

npm test — 212/212 pass, tsc error count unchanged from main, lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* perf(editor): stabilize bridge registration effect and memoize filename dedup

Two perf concerns from a focused leak/perf audit of PR #808:

1. Bridge writer effect re-ran on every SFTP state change.
   SftpView / SftpSidePanel registered their bridge writer in an effect
   with `[sftp]` deps. The `sftp` object identity changes on every SFTP
   state update — transfer progress, directory listing, pane updates,
   tab switches — so the effect would unregister+reregister constantly
   during routine SFTP use. Not a leak (React runs cleanup before each
   re-effect), just high-frequency churn on the hot path.
   Route through sftpRef and run the effect once; writeTextFileByConnection
   is a methodsRef-backed dispatcher that stays valid across sftp re-renders.

2. O(n²) filename disambiguation scan in TopTabs render.
   Each editor tab ran `editorTabs.filter(same fileName)` inside the per-tab
   render branch. Negligible at ~20 tabs but trivially fixable: build a
   fileName→count map in a useMemo keyed on editorTabs and look up in O(1).

Separately noted but NOT fixed here (needs a store refactor and deserves
its own PR): App.tsx subscribing to useEditorTabs() means every keystroke
in an editor tab re-renders the App root. Would need a useEditorTabIds()
selector that only notifies on add/remove.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:17:28 +08:00
陈大猫andClaude Opus 4.7 b9e9a0d59c feat(editor): promote SFTP text editor into top-level tabs (#631) (#808)
* chore: ignore local .worktrees/ directory

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(editor): editorTabStore scaffold with single-tab ops

Implements the EditorTabStore class singleton (matching activeTabStore pattern)
with updateContent, markSaved, setWordWrap, setSavingState, close, and subscribe.
Includes useSyncExternalStore hooks and 6 passing unit tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(editor): editorTabStore promoteFromModal with per-session path dedup

* feat(editor): confirmCloseBySession for session teardown

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sftp): writeTextFileByConnection for pane-agnostic saves

Adds a new `writeTextFileByConnection(connectionId, expectedHostId, filePath, content, filenameEncoding?)` method to `useSftpExternalOperations` that looks up the SFTP pane by connection ID (with a hostId safety check) instead of the left/right-side coupling used by `writeTextFile`. Threads the existing `getPaneByConnectionId` callback through the call site and re-exports the new method via `SftpStateApi`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(editor): editorSftpBridge singleton for out-of-React saves

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(editor): extract TextEditorPane from TextEditorModal

Lift Monaco editor body + toolbar + theme sync + paste fallback into a
pure TextEditorPane component. Adds sftp.editor.maximize i18n key to
en.ts and zh-CN.ts locale files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(editor): drop unused getLanguageId import in TextEditorPane

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(editor): TextEditorModal delegates to TextEditorPane

Replace the monolithic modal (560 lines including full Monaco setup)
with a thin Dialog shell (~150 lines) that owns content/saving/saveError/
languageId state, save orchestration, and dirty-check on close, then
delegates all editor chrome to <TextEditorPane chrome="modal" />.

Exports TextEditorModalSnapshot for the optional onPromoteToTab callback
so callers can later wire tab promotion (Task 12) without breaking the
existing interface — the new prop is optional and existing callers
(SftpOverlays.tsx) are source-compatible with zero changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(editor): include fileName and wordWrap in TextEditorModalSnapshot

Task 12 will populate the promoted tab with these fields, so the snapshot
must carry them from the modal at maximize time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(editor): UnsavedChangesDialog three-button confirm

* fix(editor): resolve UnsavedChangesDialog re-entrance and unmount leaks

- Re-entrance: if prompt() is called while a prior prompt is still pending,
  cancel the prior one so its caller doesn't hang forever.
- Unmount: resolve any in-flight prompt as "cancel" in the effect cleanup
  so awaiters don't leak when the provider unmounts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(editor): TextEditorTabView tab-form shell

Add TextEditorTabView component that binds an editorTabStore entry to
TextEditorPane, with CSS display:none toggling for inactive tabs so the
Monaco instance persists across tab switches.  Also adds setLanguage
public method to EditorTabStore (lands Task 15's intent early — Task 15
can be a no-op).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(editor): read live store state in TextEditorTabView handlers

React state snapshot lags the store by a microtask. Closing over `tab`
meant a keystroke between Monaco's onChange and a Ctrl+S would write
stale content and mark a stale baseline. Read via editorTabStore.getTab
at call time instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(editor): dispatch editor:* tab ids in App and activeTabStore

- Add EDITOR_PREFIX, isEditorTabId, toEditorTabId, fromEditorTabId helpers
- Add useIsEditorTabActive hook to activeTabStore
- Update useIsTerminalLayerVisible to exclude editor tabs
- Import useEditorTabs and TextEditorTabView into App.tsx
- Append editor tab ids (editor:<id>) to allTabs in hotkey handler
- Mount TextEditorTabView per editorTab with CSS visibility toggling
- Add editorTabs to executeHotkeyAction useCallback dependency array

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(editor): render editor tabs in TopTabs with icon/dirty/tooltip

- Add `fromEditorTabId`, `isEditorTabId` imports to TopTabs.tsx
- Add `FileCode`, `FileText` icons; use FileCode for code-like extensions
- Extend `TopTabsProps` with `editorTabs`, `onRequestCloseEditorTab`, `hostById`
- Build `editorTabMap` for O(1) lookup; add `editor` branch in `orderedTabItems`
- Render editor tab chrome matching terminal tab style: file icon, dirty dot (●),
  filename with disambiguation suffix for duplicate filenames, close button
- In App.tsx: add stub `handleRequestCloseEditorTab`, `orderedTabsWithEditors`,
  pass new props to `<TopTabs>`

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(editor): hoist editor-tab code-extension regex and use onSelectTab

- Move CODE_EXTENSIONS_RE to module scope so it isn't recompiled per render.
- Call onSelectTab(tabId) for consistency with other tab types, instead of
  reaching into activeTabStore directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(editor): maximize modal to tab and dirty-confirm tab close

Wire onPromoteToTab from TextEditorModal through SftpOverlays and
useSftpViewFileOps so clicking the maximize button snapshots editor
state into editorTabStore and activates the new editor tab.

Replace the stub handleRequestCloseEditorTab in App.tsx with a real
dirty-confirm flow using UnsavedChangesProvider render-prop: clean tabs
close immediately, dirty tabs prompt save/discard/cancel, and save
routes through editorSftpBridge with markSaved on success.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(editor): register SFTP bridge and gate session close on dirty editor tabs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(editor): make onDisconnect async so host-picker waits for dirty check

The session-close dirty gate added in Task 13 made onDisconnect async, but
the host-picker in SftpPaneDialogs still called it synchronously before
kicking off onConnect — a fire-and-forget that raced past the dirty prompt
and let unsaved editor tabs slip through. Propagate the Promise return type
through SftpPaneCallbacks / SftpPaneDialogs / useSftpViewPaneActionsResult
and await it at the host-picker call sites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(editor): block app quit while editor tabs are dirty

Add a before-quit IPC guard that asks the renderer whether any editor
tab has unsaved changes. If dirty tabs exist, preventDefault() blocks
the quit and a warning toast is shown. The app quits normally once
editors are clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(editor): add 5s timeout fallback to quit-guard IPC check

If the renderer crashes or throws before reporting back, the quitGuard
would stay busy forever and the app could not be quit. Fall back to
force-quit after 5 s if no reply arrives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(editor): quit-guard uses quitConfirmed flag to prevent re-entry loop

The prior flow reset quitGuardChannelBusy before calling app.quit(), which
on macOS re-fires before-quit and re-entered the dirty check with the flag
cleared — creating an infinite IPC loop. Introduce a separate quitConfirmed
flag that commits to quitting before app.quit() fires, so the re-entry takes
the fast path.

Also extract QUIT_GUARD_TIMEOUT_MS and clarify that a concurrent quit while
a check is in flight is swallowed (preventDefault) rather than letting the
second event through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(editor): use absolute inset-0 for tab panel and add sr-only DialogTitle

Two bugs surfaced during the first dev-server smoke test:

1. Editor tab content was blank because TextEditorTabView used only
   className="h-full", while its sibling panels (VaultView, SftpView,
   TerminalLayerMount, LogView) all fill their flex-1 parent via
   `absolute inset-0`. In normal flow the editor tab collapsed to zero
   height. Match the sibling convention.

2. Radix printed an accessibility warning because the Task 7 refactor
   pulled the DialogTitle out of DialogContent and into the Pane header
   (now a plain span). Add a visually hidden DialogTitle that mirrors the
   filename, so screen readers have a title without showing it twice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(editor): raise tab panel z-index to 20 so it sits above TerminalLayer

TerminalLayer's root is visibility:hidden when the active tab is an editor
tab, but its inner panels set `absolute inset-0 z-10` on their own and those
still paint. Without an explicit z on the editor tab panel, TerminalLayer's
inner bg-background div was covering the Monaco content, producing a blank
screen.

Also add bg-background to the wrapper so the editor tab paints an opaque
surface (matches the pattern VaultViewContainer / TerminalLayer follow).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(editor): show host label and remote path next to filename in tab header

The editor tab form previously only showed the bare filename in its header,
which is ambiguous when the same filename is open against multiple hosts.
Add an optional subtitle prop on TextEditorPane and populate it from the
tab form with `<hostLabel>:<remotePath>` rendered in muted text beside the
filename. The modal keeps its existing filename-only header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(editor): bridge supports multiple useSftpState instances

useSftpState is instantiated in both the top-level SftpView and the
terminal's SftpSidePanel, each owning its own pane registry. The editor
bridge previously stored only one writer, so maximizing a file opened from
the terminal side panel registered nothing (bridge was owned by SftpView
which may never have mounted) and save failed with "bridge not registered".

Change the bridge to track a Set of writers and dispatch by trying each
until one owns the connectionId (signalled by its specific "connection no
longer available" error). Add registerEditorSftpWriterScoped that returns
an unregister fn so each instance's cleanup removes only its own entry.
Register in both SftpView and SftpSidePanel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(editor): Cmd+W closes editor tab + terminal close forces tab close

Two behaviors added after user feedback from dev-server smoke-test:

1. Cmd/Ctrl+W (the closeTab hotkey) previously did nothing on editor tabs
   because executeHotkeyAction had no branch for editor:* ids. Add one that
   reaches into the UnsavedChangesProvider render-prop's close flow via a
   ref, routing through the existing dirty-confirm path.

2. Closing a terminal tab unmounts its SftpSidePanel which destroys the
   useSftpState instance that owned the connection. Any editor tab promoted
   from that panel would then be stuck — bridge gone, save channel dead.
   On SftpSidePanel unmount, gather the connection ids it owned and call a
   new editorTabStore.forceCloseBySessions to drop matching editor tabs.
   Dirty state is dropped because the user closed the terminal knowing the
   file was open — there is no save channel left anyway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(editor): Cmd/Ctrl+W works when focus is inside Monaco

Monaco's internal key-event dispatcher swallows keydown before the
capture-phase handler on the Pane's root div can see it, so the global
hotkey dispatcher never got the chance to close the editor tab when the
editor had focus. Register a Monaco editor command for the close-tab
keybinding and route it through a handleCloseRef — mirrors the same
pattern used for Cmd/Ctrl+S. Also drop the modal-only guard in the
capture-phase handler so the outer-chrome path works in tab mode too.

TextEditorTabView now receives an onRequestClose(tabId) prop that App.tsx
wires via the render-prop-exposed handleRequestCloseEditorTabRef, same
mechanism as the hotkey-dispatcher path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(editor): fall back to Vaults when forceCloseBySessions removes the active tab

Closing a terminal tab triggers SftpSidePanel unmount which force-closes its
editor tabs. If the editor tab being removed happened to be the active tab
(user maximized → then closed the owning terminal from another path), the
app ended up on a stale activeTabId with no selected tab and blank content.

Inside forceCloseBySessions, if the active tab was one of the removed
editor ids, redirect to 'vault'. Picking a more sophisticated neighbor
would need the full orderedTabs list which isn't reachable from this layer;
Vaults is always valid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:03:38 +08:00
陈大猫andClaude Opus 4.7 d02e91a14d Enlarge app icon squircle to match other macOS dock apps (#803)
* Enlarge app icon squircle so it matches other macOS dock apps

public/icon.png was generated from logo.svg which keeps the Apple HIG
grid margin (~100px all around the 824x824 squircle in a 1024 canvas).
Most third-party macOS apps (WeChat, Office, Messages, etc.) enlarge
their squircle to fill ~90% of the canvas, so Netcatty's icon looks
visibly smaller than its neighbors in the dock.

Introduce public/icon.svg as a dedicated app-icon source that tightens
the viewBox to 68 68 888 888 so the squircle renders at ~93% fill, then
regenerate public/icon.png from it. logo.svg stays untouched since it
is shared with the splash screen and tray template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Dial back icon squircle fill from 93% to 88%

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:07:52 +08:00
陈大猫andClaude Opus 4.7 f38afd8bfc Align snippet row icons with package row icons in tree (#802)
Snippet rows used a padding-based offset to account for the chevron
column in package rows, but the flex gap between chevron and icon
wasn't being compensated so the FileCode icon sat 4-6px to the left of
the Package icon above it. Mirror the package row's flex layout
literally by rendering an invisible chevron placeholder, so both row
types share the same column structure.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:03:06 +08:00
陈大猫andClaude Opus 4.7 c3dabbfef2 Render snippets sidebar as an expandable tree (#800) (#801)
* Render snippets sidebar as an expandable tree (#800)

The terminal sidebar used breadcrumb navigation, so switching between
packages meant clicking out and back in. Replace that with a single
tree view where each package row has a chevron to expand/collapse
(SFTP-style), so snippets across multiple packages stay visible and
reachable without drilling.

- All discovered packages default to expanded, so the tree matches the
  user's expectation of seeing everything at once.
- Search flattens to a list of matching snippets regardless of nesting,
  each annotated with its package path so the origin is still clear.
- Implicit ancestor packages (e.g. "a/b/c" implies "a" and "a/b") are
  materialized so deeply nested snippets aren't orphaned when a parent
  package isn't explicitly listed.
- Depth-based left padding + chevron rotation mirror the SFTP tree
  view's affordances.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Unify snippet row typography with tree + move command to tooltip

Snippet rows were rendered as two-line blocks (label + inline command
preview), which made them visually taller and heavier than the
single-line package rows in the tree, and long commands overflowed the
container. Collapse them to single-line rows that match the package row
layout exactly (same text size, same padding, aligned icon column) and
surface the full label + command text in a tooltip on hover.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Preserve collapsed packages across snippet refreshes (codex)

The auto-expand effect compared prev.size to normalizedPackages.size to
decide whether to repopulate, but collapsed rows shrink prev.size, so any
later snippet/package change would trip the condition and overwrite the
user's collapse state with a bulk re-expand.

Track the set of packages ever observed in a ref and only auto-expand
paths that are new since the previous render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:56:14 +08:00
陈大猫andClaude Opus 4.7 d5c937b7a9 Redesign macOS tray template icon from app icon (#798)
The previous template icon was a tiny solid silhouette that didn't fill
the menu bar slot. Rebuild it by extracting the cat head, ears, paws,
squinty eyes and nose/mouth paths directly from public/logo.svg so the
tray icon matches the app icon character, then tighten the viewBox so
the cat fills the canvas.

Windows/Linux tray-icon.png is unchanged.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:10:57 +08:00
陈大猫andClaude Opus 4.7 c32a8e603f Fix blurry Windows/Linux tray icon on high-DPI displays (#794) (#797)
The tray icon was force-resized to 16x16 on all non-macOS platforms, so
Windows had to upscale it at every DPI scale above 100%. Attach the
existing @2x asset as a HiDPI representation instead and let the OS pick
the right pixel size per scale factor.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 13:45:16 +08:00
陈大猫andClaude Opus 4.7 0108390d4f Pin the host multi-select bar to the top of the page (#793)
The bulk-action bar for multi-select (selected count, Select All /
Deselect All / Delete / close) was rendered inside the Hosts
section, so it scrolled out of view as soon as the user moved
past the first row of cards.

Hoist the bar out of the scroll container and render it as a
sibling right after the top header. It is now always visible below
the header while multi-select is active in the Hosts section, and
slims down visually:

- Single flat row (no inner pill, no secondary border)
- Compact button sizing: h-7, px-2, text-xs, icon-12
- Bottom-only border for separation from the scroll area
- Count label forced to h-7 + leading-none so it vertically
  centers against the buttons

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 01:46:22 +08:00
陈大猫andClaude Opus 4.7 e992d51fa6 Collapse four terminal toolbar actions behind a More popover (#792)
* Collapse four terminal toolbar actions behind a "More" popover

The terminal status-bar toolbar had seven visible icon buttons
(SFTP, Encoding, Scripts, Theme, Highlight, Compose, Search) plus
the close button. That's a lot of icons for a toolbar that sits
right above the terminal output — it reads as cluttered and pushes
the connection info / host name around on narrow tabs.

Fold the four "opener" actions — SFTP, Encoding, Scripts, Terminal
Settings — behind a single `MoreHorizontal` (⋮) popover. The three
mid-session toggles (Highlight, Compose, Search) stay in the bar
because they're used repeatedly during a session.

- components/terminal/TerminalToolbar.tsx:
  * Add MoreHorizontal import, a shared `menuItemClass` style for
    popover rows.
  * Replace the four inline Buttons with a single Popover whose
    content lists each action as an icon + label row.
  * Inline the Encoding sub-popover into the same menu: a
    Languages-icon section header followed by two `Check`-marked
    radio-like rows for UTF-8 / GB18030 — still only rendered when
    `isSSHSession && onSetTerminalEncoding`.
  * SFTP row respects the existing connected-state: disabled +
    50% opacity until the session is connected, and label falls back
    to "availableAfterConnect".
- application/i18n/locales/en.ts, zh-CN.ts:
  * New `terminal.toolbar.more` key — "More actions" / "更多操作"
    — used as the ⋮ button's aria-label and tooltip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Move terminal overflow menu to end and use vertical dots

The ⋮ overflow trigger was the first icon in the toolbar with a
horizontal-dots glyph. Visually it read as the primary action and
competed with the mid-session toggles next to it.

Move the Popover to the end of the toolbar (just before the close
X when shown), switch the icon to MoreVertical, and flip the
popover alignment to `end` so it opens leftward from the right
edge.

Toolbar order is now: Highlight → Compose → Search → ⋮ → (X).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 01:32:36 +08:00
陈大猫andClaude Opus 4.7 7c55381f39 Add terminals to workspace + New Workspace from QuickSwitcher (#790)
* Add terminals to workspace + New Workspace from QuickSwitcher

Two entry points share a single multi-select picker that lets the
user add Local Terminal + any combination of hosts into a workspace:

1. Focus-mode sidebar "+" button appends the selected targets to the
   active workspace as new panes.
2. QuickSwitcher "New Workspace" button (small inline action next to
   the Jump To hint) spins up a brand-new workspace tab populated
   with the selected targets.

## Changes

### domain/workspace.ts
- pruneWorkspaceNode now rebalances surviving siblings to EQUAL
  sizes after removal, instead of re-normalising the prior skew.
  Matches the "auto-redistribute on close" expectation.
- New appendPaneToWorkspaceRoot(root, sessionId, direction='vertical'):
  if root already splits in the requested direction, pushes the new
  pane onto its children and resets sizes to equal; otherwise wraps
  root + new pane in a new 0.5/0.5 split. Flattens long chains of
  appends instead of producing degenerate nested trees.

### application/state/useSessionState.ts
- appendHostToWorkspace(workspaceId, host, direction?) — atomic
  "build a session for this host and append it to the root", keeps
  activeTab on the workspace and focuses the new pane.
- appendLocalTerminalToWorkspace(workspaceId, options?, direction?)
  — mirror of the above for local shells.
- createWorkspaceFromTargets(targets, name?) — accepts a mixed list
  of {kind:'local',...} / {kind:'host',host} and creates a new
  workspace with one pane per target. Defaults viewMode to 'focus'
  so the QuickSwitcher flow lands in the sidebar layout.
- All three exported from the hook.

### components/workspace/AddToWorkspaceDialog.tsx (new)
QuickSwitcher-styled multi-select picker:
- Fixed top-center overlay, same chrome as QuickSwitcher (border,
  shadow, rounded-xl, borderless search input, bg-primary/15 cursor).
- Two sections: Local Shells (currently just Local Terminal) and
  Hosts. Hover follows keyboard cursor.
- Toggle rows with click or Space / Enter; ⌘/Ctrl+Enter submits;
  Esc closes. Right-side Check marks visible items.
- Thin footer bar with Cancel + "Add N" button.

### App.tsx
- Root-mounted single instance of AddToWorkspaceDialog with a
  discriminated-union state:
  { mode: 'append'; workspaceId } | { mode: 'create' } | null.
- onAdd dispatches based on mode — append loops through the picker
  targets calling the two append helpers; create calls
  createWorkspaceFromTargets once.
- TerminalLayer's focus "+" now sends an onRequestAddToWorkspace
  (workspaceId) up to App instead of owning its own dialog.
- QuickSwitcher's onCreateWorkspace callback repurposed to open the
  dialog in create mode (replaces the older CreateWorkspaceDialog
  route for this specific flow).

### components/TerminalLayer.tsx
- Dropped the inline AddToWorkspaceDialog + addHostPanelOpen state;
  replaced the two append callbacks with a single
  onRequestAddToWorkspace prop wired to the "+" button.
- Focus-sidebar header: replaced the "Terminals · N" counter with an
  immersive borderless search input (bg-transparent, shadow-none,
  termFg color) for filtering the terminal list; "+" and Columns2
  buttons moved to the right.
- Session list filtered client-side by the search term across
  hostLabel / hostname / username.

### components/QuickSwitcher.tsx
- Re-introduced onCreateWorkspace prop (was removed as unused).
- "New Workspace" inline button (Plus icon + label) sits on the
  right of the Jump To hint row: border, rounded, hover bg. Click
  fires onCreateWorkspace then closes QS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add configurable New Workspace shortcut

Mirrors QuickSwitcher's "+ New Workspace" button via a keyboard
binding so the dialog can open in one keystroke without passing
through QS.

- domain/models.ts: new DEFAULT_KEY_BINDINGS entry id=new-workspace,
  action=newWorkspace, default ⌘+Shift+J (Mac) / Ctrl+Shift+J (PC).
  Audited the defaults — only quick-switch uses J (⌘+J), so the
  shifted combo is free. The binding sits in the 'app' category so
  it shows up in Settings → Shortcuts and can be rebound by the user.
- application/state/useGlobalHotkeys.ts: wire newWorkspace into the
  HotkeyActions interface, getAppLevelActions() allowlist, and the
  global keydown switch so the scheme-driven handler dispatches it.
- App.tsx: handle case 'newWorkspace' inside executeHotkeyAction by
  calling setAddToWorkspaceDialog({ mode: 'create' }) — same entry
  as QuickSwitcher's button, just without having to open QS first.
- application/i18n/locales/zh-CN.ts: add '新建工作区' translation for
  settings.shortcuts.binding.new-workspace. English falls back to
  the KeyBinding.label field ("New Workspace"), so no en.ts change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address codex P1: don't check setState flag after the updater returns

Codex flagged that appendHostToWorkspace / appendLocalTerminalToWorkspace
were racy: both flipped an `inserted` flag inside setWorkspaces'
updater and then read it synchronously to decide whether to commit
the matching session via setSessions. React does NOT guarantee
updaters run synchronously (concurrent rendering, StrictMode
double-invoke, etc.), so the flag could still be false at the read
site even though the workspace exists. In that case setSessions was
skipped while the queued workspace update could still insert a new
pane referencing newSessionId — leaving a pane with no backing
session in state.

Fix: add a workspacesRef kept in sync with the workspaces state on
every render, and perform the existence check synchronously *before*
queuing any setState. Once we've confirmed the workspace exists on
the latest committed state, both setWorkspaces and setSessions are
called unconditionally, so they can never diverge.

The ref approach also correctly handles the multi-target append
loop path — React batches the updaters and applies them in sequence,
so sibling pane/session writes land in matching order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address codex P1+P2: narrow prune rebalance; append in root direction

### P1 — pruneWorkspaceNode over-rebalanced ancestor splits

The equal-sizes rebalance was unconditional during the recursive
walk, so closing a pane deep in one branch also rewrote unrelated
ancestor ratios (e.g., a root 0.8/0.2 vertical split got normalised
to 0.5/0.5 when a grand-child horizontal pane closed).

Now each split level tracks whether it actually lost a DIRECT
child. Only splits where a direct child disappeared get their
siblings reset to equal sizes. Ancestors whose direct children all
survived keep their original ratios (defensively re-normalised in
case a descendant subtree collapsed shape).

### P2 — Append path ignored the root's current direction

onAdd in App.tsx called the two append helpers without a direction,
so both defaulted to 'vertical'. appendPaneToWorkspaceRoot only
flattens into the root split when the directions match; if the
workspace root was horizontal (e.g., user split top/bottom earlier),
each append wrapped the entire existing tree into one side of a new
vertical split — existing panes crammed into one branch, new pane
hoarding half the space.

Read the current root direction out of the target workspace and
pass it down so new panes become peers of the existing root
siblings regardless of horizontal vs vertical.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address codex P2: allow serial hosts in create-workspace picker

The picker used to filter out every host with protocol='serial'
regardless of mode. That was correct for append mode (the
appendHostToWorkspace helper has no serial path and early-returns)
but a regression for create mode — the old createWorkspaceWithHosts
flow passed serial hosts through and createWorkspaceFromTargets
still builds a SerialConfig-backed session for them, so there was
no reason to block them in the "+ New Workspace" entry.

Move the filter from the dialog up to App.tsx:
- AddToWorkspaceDialog drops the serial filter; selectableHosts is
  simply the hosts prop.
- App.tsx passes `hosts.filter(h => h.protocol !== 'serial')` when
  mode is 'append', and the full list when mode is 'create'.
Result: users can once again build a workspace from serial hosts
via QuickSwitcher's "+ New Workspace" button or the ⌘/Ctrl+Shift+J
hotkey, while append-to-existing keeps its earlier safe behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address codex P2: don't commit session when append target disappears

Follow-up to the earlier ref-based guard. The ref check eliminates
the common "workspace already gone" case but still leaves a small
race: if closeWorkspace runs between the ref read and setWorkspaces'
updater firing, prev.map returns the unchanged workspaces but
setSessions / setActiveTabId still execute — leaving an orphan
session whose workspaceId points at a deleted workspace and jumping
activeTabId to a closed tab.

Nest setSessions + setActiveTabId inside the setWorkspaces updater
so the writes are gated on the same authoritative match used for
the tree update. The setSessions updater also de-dupes by newSessionId
so React 18 StrictMode's dev-time double-invoke of the outer updater
doesn't append the same row twice. Same pattern applied to
appendLocalTerminalToWorkspace.

The existing closeSession already uses the nested-setState shape, so
this matches the codebase convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 01:19:33 +08:00
陈大猫andClaude Opus 4.7 d582baaf53 Match Settings wordmark style with Vault sidebar (#791)
Settings > Application used `text-3xl font-semibold` on
`{appInfo.name}`, which resolved to lowercase "netcatty" (from
electron's app.getName() / package.json). The Vault sidebar already
renders the brand as `text-xl font-black italic tracking-tight`
with mixed-case "Netcatty", so the two brand surfaces didn't
match — same logo, different wordmark weights and capitalization.

Use the Vault's italic/heavy treatment in Settings too (keeping
the hero text-3xl size) and hardcode "Netcatty" mixed-case so the
wordmark is consistent everywhere the app presents its identity.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 01:16:38 +08:00
陈大猫andClaude Opus 4.7 8c1657f1ba Polish workspace focus-mode sidebar (#788)
* Polish workspace focus-mode sidebar

- Decouple from side panel position: replace flex-row-reverse on the
  outer row with order-last on the side panel itself, so the workspace
  focus-mode sidebar and terminal area stay in source order (sidebar
  on the left) regardless of whether the terminal side panel is
  pinned left or right.
- Make the sidebar width user-resizable. New storage key
  STORAGE_KEY_WORKSPACE_FOCUS_SIDEBAR_WIDTH with a useStoredNumber
  default of 224px (matches the old w-56), clamped 160..480. Drag
  handle sits on the right edge using the same pattern as the side
  panel; rAF-throttled mousemove, persisted on mouseup.
- Paint the sidebar with resolvedPreviewTheme.colors.background /
  .foreground so it reads as one continuous surface with the focused
  terminal's output area instead of a distinct tinted panel. The
  border-r is kept as a thin separator from the terminal column.
- Session rows swapped from <div> to RippleButton to match the Vault
  sidebar's click ripple feel, and restyled to avoid the old
  primary-tinted selection:
  * selected:   bg-foreground/10 text-foreground (soft neutral over
                the terminal-theme sidebar bg)
  * unselected: bg-transparent   text-foreground/75
  * font weight upgrades to semibold on selected; font-size is fixed
  * hover:text-inherit pins text color on hover so the ghost
    variant's hover:text-accent-foreground doesn't flip the title
    color when the cursor passes over a row
- Drop the former `border border-primary/30` selection outline and
  the primary-tinted row bg entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address codex P1: use terminal-theme colors for focus sidebar rows

Codex flagged that the session rows were mixing two theme systems:
the sidebar now paints with resolvedPreviewTheme (terminal theme),
but row classes like bg-foreground/10, text-foreground, and
hover:bg-foreground/15 resolve against the app theme CSS vars. With
followAppTerminalTheme off and app/terminal themes diverging (e.g.
light app + dark terminal), row text and selection tint no longer
match the surface and can become low-contrast or invisible.

Derive every row color from resolvedPreviewTheme.colors via
color-mix and apply via inline style:

- selectedBg        = foreground 10% over transparent
- selectedHoverBg   = foreground 15%
- unselectedHoverBg = foreground 10%
- unselectedFg      = foreground 75% mixed toward termBg
- mutedFg           = foreground 55% mixed toward termBg (used for
  "Terminals · N" counter, switch-to-split icon color, fallback Server
  icon, and the username@host secondary line).
- separator         = foreground 10% over termBg (right-border and
  header bottom-border now use this instead of border-border/50,
  which was also app-theme bound).

Hover bg swap goes through onMouseEnter/Leave rather than
hover:bg-* utilities, since Tailwind arbitrary values can't easily
inject color-mix hover variants and we want terminal-theme alpha
either way.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:32:20 +08:00
陈大猫andClaude Opus 4.7 999ad916e3 Make terminal compose bar borderless and immersive (#789)
The old compose bar had a rounded gradient card with an inset box
shadow, a bordered inner textarea, and a prominent filled Send button
— visually heavy, and sitting on top of the terminal it looked like a
separate panel instead of a prompt line.

Rework it to sit flush on the terminal-theme background, Claude Code
compose-area style:

- Outer container uses resolvedBg directly (no gradient, no rounding,
  no box-shadow); separator from terminal output is a single 8%-alpha
  hairline border-top.
- Textarea is fully borderless and transparent — no bg, no border, no
  focus ring, no inner shadow. Text sits directly on the terminal bg.
- Send button removed entirely; Enter was already the send key, and
  the filled button was just visual weight. Shift+Enter still inserts
  a newline, Esc still closes.
- Close (X) button shrunk to a minimal 6x6 ghost; transparent at rest,
  only gains a 10% overlay + full fg on hover.
- Placeholder bumped from opacity-40 to opacity-70 so the "press Enter
  to send" hint is legible against dark and light terminal themes.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:18:01 +08:00
陈大猫andClaude Opus 4.7 8ca09b1616 Add right-click Edit/Delete to sidepanel snippets (#780) (#787)
The terminal-side ScriptsSidePanel was the surface the #780 reporter
was actually looking at when they asked for right-click delete/modify
on snippets. PR #783 closed the issue by adding a trash icon in the
Vault edit panel, but the sidepanel snippet rows were still plain
<button>s with no context menu — so the original complaint
("右键可以弹出一个菜单, 可以包含'删除, 修改'等操作") remained unaddressed
at the exact spot the screenshot came from.

Changes:

- ScriptsSidePanel: wrap each snippet row in a ContextMenu with Edit
  and Delete items. Menu actions dispatch window events instead of
  threading new callbacks — matches the existing netcatty:snippets:add
  pattern the + button already uses.
- QuickAddSnippetDialog: accept an optional onUpdateSnippet prop and
  listen for netcatty:snippets:edit. Prefills label/command/package
  from the dispatched snippet, and on save preserves the snippet's
  original tags/targets/shortkey/noAutoRun (the dialog only exposes
  the three quick-edit fields). Title flips to snippets.panel.editTitle
  in edit mode.
- App.tsx: pass onUpdateSnippet wired to updateSnippets(map-replace),
  and register a window listener for netcatty:snippets:delete that
  filters the deleted id out of snippets. Delete needs no UI so it
  doesn't go through a dialog.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:36:52 +08:00
陈大猫andClaude Opus 4.7 70b05bfaaf New app logo + sidebar ripple + manager UI polish (#786)
* Replace app logo across window icon, tray, splash, and in-app brand

- public/logo.svg: new netcatty mark
- public/icon.png: regenerated 1024x1024 from new SVG (source for
  electron-builder — .icns/.ico rebuilt automatically at pack time)
- public/dmg-fix-icon.png: regenerated 1024x1024
- public/tray-icon{,@2x}.png: regenerated color 16/32px for Linux/Windows
- public/tray-iconTemplate{,@2x}.png: regenerated monochrome silhouette
  for macOS menu bar (background stripped, foreground flattened to
  black on transparent so template-image rendering produces a clean
  mask)
- components/AppLogo.tsx: render the new logo as a static <img>. The
  old hand-coded inline SVG bound fills to the accent CSS variable;
  the new mark has a fixed palette, so callers keep their sizing /
  rounding classes via className while the asset itself is a single
  file served from /public.
- index.html: splash screen now uses the same /logo.svg via <img>,
  with border-radius for the rounded-square frame.

* Polish logo: theme the in-app mark, gloss the OS icon, shrink cat

- components/AppLogo.tsx: back to an inline SVG. Background rect fills
  with hsl(var(--primary)) so the in-app brand follows the theme
  accent (was fixed navy when imported as <img>). Cat scaled to 68%
  of the frame and centred so it doesn't crowd the edges at small
  sidebar sizes.
- public/logo.svg + regenerated PNGs: polished OS icon variant with a
  large rounded-square clip (rx 224 on 1024), top-left spotlight
  radial gradient, subtle top sheen + bottom darkening, and an inner
  edge vignette for a slight chamfer. The cat is shrunk to the same
  68% as the in-app logo for visual consistency.
- Monochrome tray template (macOS menu bar) is rebuilt from the
  shrunk-cat path set with all fills flattened to black; keeps a
  clean silhouette instead of a filled rounded square.

* Smooth paws, richer gloss on app icon

- Drop the dark toe/claw detail paths from the source illustration
  (indices 22-25, 30, 35, 37, 39 — the ones tracing vertical claw
  dividers inside the paws). At small sizes those read as teeth/
  claws; paws now render as clean rounded blobs.
- public/logo.svg (OS icon source): richer depth pass —
    * two-tone navy vertical gradient (lighter top, deeper bottom)
    * brighter upper-left spotlight for glassy highlight
    * top sheen + bottom darkening for sheen-across-curve effect
    * soft elliptical ground shadow beneath the cat to anchor it
    * 2% inner edge stroke to crisp the rounded-square chamfer
- components/AppLogo.tsx: regenerated with the same cleaned cat set,
  still themed via hsl(var(--primary)). The in-app mark stays flat
  (no gloss) because the effect adds nothing at 20-40px sidebar
  sizes and would fight theme accents.
- All raster variants (icon.png, dmg-fix-icon.png, tray color + tray
  macOS template) rebuilt from the cleaned sources.

* Respect Apple icon safe area; drop gloss, add thin border

macOS icon was rendering to the full 1024x1024 canvas, so it looked
noticeably larger than neighbour apps (VS Code, Ghostty, Zed) in the
Dock. Apple's Big Sur+ convention puts the artwork body inside an
~824x824 safe area centred in a 1024 canvas, which is how those apps
are sized.

- public/logo.svg: artwork body is now 824x824 centred with ~100px
  transparent padding. Corner radius 185 (close enough to the macOS
  squircle at Dock scale). Cat rescaled so it keeps the same 68%
  proportion within the smaller body.
- Gloss layers (spotlight / sheen / ground shadow / vignette) removed
  per request — went for a Ghostty-style clean look instead.
- Thin white inner border (stroke 3px, 22% opacity) outlines the
  rounded square for definition.
- Tray PNGs for Linux/Windows keep the full-bleed variant (tray slots
  expect the icon to fill the space, unlike the Dock safe area).
- components/AppLogo.tsx unchanged conceptually — it still fills its
  own bounding box via hsl(var(--primary)); the Apple safe-area rule
  is Dock-specific, not relevant to in-app rendering.

* AppLogo: tighten corner radius to match previous (rx 18.75%)

Previous AppLogo used rx=12 on a 64 viewBox (18.75%). The inline
replacement had rx=224 on a 1024 viewBox (21.9%), which combined
with the caller's rounded-xl class read noticeably rounder in the
sidebar. Drop to rx=192 on 1024 viewBox so the in-app mark matches
the old proportions.

* Beef up icon border so it survives Dock downscaling

3 px at 22% opacity disappeared when rasterised down to ~128 px Dock /
Launchpad size. Bumped stroke-width to 8 px and opacity to 40% so the
inner highlight reads as ~1 px at Dock scale. Stroke is inset by
stroke-width/2 so it sits fully inside the rounded-square body (no
anti-alias bleed outside the safe area). Same treatment applied to the
full-bleed tray variant.

* Enlarge cat inside icon tile (68% -> 85% of body)

Dock render had too much navy margin around the mark. Bump the cat's
scale so it fills 85% of the Apple safe-area body while keeping a
visible bezel to the rounded corners and the inner border. Tray color
variant and macOS template (scale 0.9, no border) follow the same
scale-up.

* Add ripple effect on sidebar nav and tidy logo in vault header

- Add RippleButton wrapper + ripple keyframe; use it for the six vault
  sidebar nav entries (Hosts, Keychain, Port Forwarding, Snippets,
  Known Hosts, Logs) so clicks get a subtle material-style ripple.
- Shrink vault sidebar AppLogo to h-8 w-8 and drop the outer rounded-xl
  so the visible corner comes from the SVG's own rx instead of the
  container clip.
- Relax AppLogo tile rx/ry to 144 for a more moderate corner radius.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* AppLogo: bump tile corner radius back up to rx 18.75%

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Unify manager toolbars, tighten tabs and vault sidebar title

- Manager toolbars (Keychain, KnownHosts, PortForwarding, Snippets)
  normalised to h-14 / h-10 controls with bg-secondary/80 backdrop-blur
  and the shared bg-foreground/5 secondary button treatment, so Hosts /
  Keychain / Known Hosts / Port Forwarding / Snippets headers size and
  tint identically.
- Keychain filter tabs: drop primary tint and cert-count pill; reuse
  the same foreground/5 vs foreground/10 active states as other
  managers. Search input grown to h-10 to match.
- Known Hosts: removed the leftover text-xs on Scan System / Import
  File so they inherit Button's text-sm like every other action.
- TopTabs: drop the 2px active-accent top line and add rounded-t-md +
  overflow-hidden so active tabs read as a clean soft tab shape rather
  than a banner.
- VaultView sidebar: wordmark grown to text-xl font-black italic with
  tightened tracking; logo gap trimmed from 3 to 2.5; outer bg dropped
  from secondary/80 to flat secondary to sit flush against the
  toolbars.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:16:49 +08:00
陈大猫 e6ab69b516 Vault global search spans all groups/packages (#777) (#785)
* Vault global search spans all groups/packages (#777)

Search was scoped to the current group (hosts page) or the current
package (snippets page), so a host or snippet the user wanted to find
could stay hidden unless they first navigated into the right group —
especially confusing with the "root only shows ungrouped hosts" setting
enabled.

When the search box is non-empty:
- hosts: skip the selectedGroupPath / showOnlyUngroupedHostsInRoot
  filters entirely. Each matching card shows a small outline badge with
  the host's group so cross-group origin is visible.
- snippets: skip the current-package filter. Hide the sub-package grid
  (would be redundant alongside a flat cross-package match list). Each
  snippet card shows the package path as a small badge.

Tree view already followed this "search crosses groups" shape — see
`treeViewHosts` — so this aligns the flat grid/list views with it.

* Show no-results feedback when snippet search is empty (#777)

Addresses Codex P2 review on PR #785. With the package tile grid hidden
during search and no matching snippets, the content area was blank and
the global empty state did not render (it requires snippets.length === 0).
Add a dedicated no-results panel for the "user is searching and nothing
matched but there are other snippets" case, with i18n for en and zh-CN.

* Drop group/package badges on search results (#777)

Search is itself a filter, so decorating each result card with the
group/package it came from added visual noise without adding
information. Only difference vs. pre-search rendering now is that the
result set spans all groups/packages.

* Fix snippet no-results empty state with packages present (#777)

Addresses Codex P2 on 4a778e63. The empty-state gate was
displayedPackages.length === 0, but package tiles are hidden during
search regardless of count. Any workspace that had packages was
rendering a blank content area on zero-match queries because that
guard never passed. Drop the package-count condition — the flat
snippet list is the only visible surface while searching.

* Cover package-only workspaces in snippet search no-results (#777)

Addresses Codex P2 on ccdf6afc. snippets.length > 0 also excluded
workspaces where the user has only created packages (no snippets yet).
The correct gate is the inverse of the global empty state's condition,
so we fall back whenever the workspace isn't completely empty.
2026-04-21 19:11:00 +08:00
陈大猫 c6d4d3ec16 Block empty/shrunk pushes when sync base is null (#779) (#784)
* Block empty/shrunk pushes when sync base is null (#779)

The shrink guard (detectSuspiciousShrink) returned suspicious:false
whenever base was null, which is exactly the condition on a fresh
install, after unlock-key re-derivation, or when the encrypted base
blob fails to decrypt. A device in that state could push a
degraded/empty payload and overwrite populated cloud data — the
failure mode reported in #779 (Mac → OneDrive → Win11 wiping the
keychain on both ends).

Accept an optional remote-payload fallback in the guard and use it
when base is missing. Plumb the already-decrypted remote payload
from the merge branch, and decrypt checkResult.remoteFile on demand
in the direct-upload and syncAll branches when base is null.

Legitimate cases stay untouched:
  - no base AND no remote → still not-suspicious (genuinely empty).
  - outgoing grew past remote → lost is negative, guard skips.
  - base present → behaviour unchanged, remote fallback ignored.

* Harden OneDrive 404 handling, restore barrier, multi-provider divergence (#779)

Follow-up fixes on top of the shrink-guard change for the same root
incident.

- OneDriveAdapter: findSyncFile/downloadSyncFile now retry with short
  backoff when the Graph API returns "not found". A file uploaded by
  another device can transiently 404 for seconds while the OneDrive
  client propagates it, and treating that as "cloud is empty" was a
  key step in how #779 escalated. The retry is bounded (2 extra
  attempts, 1.5s/3s backoff) and only fires on null/404 results.

- useAutoSync.isRestoreInProgress: self-clear the restore-barrier
  storage key when its deadline is in the past, and treat a deadline
  more than 10 minutes in the future as corrupt (clock skew, pathological
  holdMs, or tampered value) instead of letting it lock auto-sync.

- CloudSyncManager + SyncEvent: when the existing divergent-provider-
  bases check fires, emit a PROVIDERS_DIVERGED event in addition to the
  console.warn so the UI can surface the warning (was otherwise silent
  and a known path for one provider's merged payload to overwrite a
  differently-configured provider's data).
2026-04-21 17:14:21 +08:00
陈大猫 487b7adf3e Add 'Set to disabled' button to individual keybindings (#781) (#782)
The keybinding recorder couldn't assign the 'Disabled' sentinel — pressing
Esc just cancels. Add a Ban-icon button next to 'Reset to default' that
writes 'Disabled' for the active scheme, and render the button label using
the localized 'Disabled' string instead of the raw sentinel.
2026-04-21 16:57:56 +08:00
陈大猫 309996bf3c Add delete button in snippet edit panel (#780) (#783)
A right-click Delete already exists in the snippet grid's context menu,
but users overwhelmingly open snippets by clicking — and the edit panel
had no delete affordance, so many concluded the feature was missing.
Surface a Trash2 icon next to Save when editing an existing snippet;
it calls the existing onDelete and closes the panel.
2026-04-21 16:57:41 +08:00
libalpm64 071c95ab5c chore(deps): bump fast-xml-parser and @aws-sdk/xml-builder
Closes #770
2026-04-19 16:38:44 +08:00
陈大猫 ec99875dec [codex] avoid main-process runtime crashes (#772)
* avoid main-process runtime crashes

* fix main-process startup error boundary

* tighten main-process startup readiness

* fix startup fallback window health checks

* exclude hidden windows from recovery checks
2026-04-19 16:31:00 +08:00
陈大猫andClaude Opus 4.7 51a6b7efaa Preload compact history on first turn after app restart (#753 hedge) (#769)
* Preload compact history on first turn after app restart (#753 hedge)

Symptom (confirmed on Copilot CLI, originally reported on Codex in
#753): after closing and reopening Netcatty, the AI chat UI still
shows the prior conversation but the agent responds "this is the
beginning of our conversation, no previous records". Earlier context
is lost entirely.

Root cause: the bridge relied on session/load throwing "not found" to
trigger the catch-block fallback that replays compact history. Some
ACP agents (Copilot CLI, some Codex builds) silently spawn a new
session when handed a stale id instead of erroring. The catch-block
never fires → historyReplayFallback stays false → the first turn
sends only the latest prompt → agent sees zero context.

Fix: when we're creating a new provider process AND telling it to
resume an existing session id AND the renderer gave us compact
history, preload historyReplayFallback=true as a hedge. If the agent
really did reload the session, the replay is ~3KB of redundant
context (small waste). If the agent silently started fresh, the
replay restores durable constraints + last few raw turns so the
first response is coherent.

After the first successful streamed turn clears the flag (the round-2
post-stream hook), steady state is back to sending only the latest
prompt. Cost is bounded to one replay per app-restart-and-prompt.

Test: "replays compact history on the first turn after app restart
even when session/load 'succeeds'" — mocks createACPProvider to
behave like Copilot CLI (no error thrown, no real resume), asserts
the first streamText call carries history+latest (length 2) and the
second only latest (length 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix AI session resume and agent switching

* Preserve hidden draft when switching agents

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:44:41 +08:00
陈大猫andClaude Opus 4.7 30f5346035 Classify AI proxy / size-limit errors instead of showing raw Zod output (#765) (#768)
Symptom: when an AI request is proxied through nginx (or any gateway)
and the request body exceeds client_max_body_size, the proxy returns a
413 HTML error page. The Vercel AI SDK then fails to parse the HTML
as a chat completion and surfaces a cryptic Zod validation error like
"Expected 'id' to be a string." through the UI — users have no idea
what's wrong.

Root cause: classifyError only did light sanitization and returned the
raw SDK message. It also string-coerced the error before inspection, so
the structured statusCode / responseBody fields that APICallError
attaches were thrown away.

Fix: classifyError now accepts `unknown` and inspects the full error
shape. Adds explicit branches for:

- HTTP 413 (from statusCode, cause.statusCode, or message text) →
  "Request too large — exceeded proxy size limit. Try shorter
  message, fewer attachments, or raise client_max_body_size."
- HTTP 502/503/504 → retryable upstream-gateway message
- HTML response body (starts with <!DOCTYPE/<html> or contains such
  tags anywhere) → "Server returned HTML error page, likely a proxy
  intercept."
- Zod/schema parse shapes ("Expected 'X' to be …", "Invalid JSON
  response", "Type validation failed") → "Response could not be
  parsed; proxy may have replaced/truncated the body."

In every classified case the raw SDK text is still appended ("Raw: …")
so users can report the underlying error verbatim.

useAIChatStreaming.ts callers now pass the raw error to classifyError
instead of `.message`, so the new structured branches actually fire.
Also wired infrastructure/ai/*.test.ts into the npm test glob.

Closes #765

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:50:25 +08:00
陈大猫andClaude Opus 4.7 e0302e5f34 Batch Windows hidden-attribute detection in local FS listing (#766) (#767)
* Batch Windows hidden-attribute detection in local FS listing (#766)

Symptom: opening a local directory with ~800 files in the SFTP panel
hangs for ~30 s on Windows. Reported on netcatty 1.0.93.

Root cause: listLocalDir spawns attrib.exe once per entry inside the
worker pool to detect the Windows hidden flag. 800 subprocess spawns
× ~40 ms each is precisely the reported 30 s. fs.promises.stat and
readdir on their own are nearly free; the subprocess flood dominates.

Fix: replace the per-entry attrib call with a single
`attrib.exe "<dir>\*"` invocation up front, parse its output into a
Set<basename>, and have the workers do an O(1) set lookup. One
subprocess per directory listing instead of one per entry.

Expected speedup for the #766 case: ~30 s → <1 s. Behavior is
unchanged — hidden files keep their hidden flag, non-hidden files
stay not-hidden; only the mechanism is different. Broken-symlink
handling (lstat fallback) also uses the same set.

Tests:
- parseAttribOutput is extracted as a pure function and unit-tested
  against real attrib output shapes: drive-letter paths, UNC paths,
  the trailing [DIR] marker that some Windows versions emit, mixed
  flag columns (A/H/R), malformed "Parameter format not correct"
  lines, empty input.
- listWindowsHiddenBasenames short-circuits on non-Windows without
  spawning anything.
- Parser uses path.win32.basename explicitly so the tests pass under
  non-Windows CI.

I cannot reproduce or test on Windows directly. The diagnosis is
mechanical (we can count subprocess calls) and the fix is a local
rewrite that preserves behavior, but Windows verification is still
desirable before release.

Closes #766

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address codex review on #767: pass /d so batched attrib includes hidden directories

Codex flagged that attrib.exe treats `<dir>\*` as file-centric by
default — without `/d`, hidden directories (node_modules, .git, etc.)
never appear in the output, so listWindowsHiddenBasenames misses them
and the SFTP browser shows those folders as not-hidden. This is a
behavior regression from the per-file path, which passed each entry's
full path directly and therefore covered both files and directories.

Added `/d` to the execFileAsync argv and a regression test that
module-mocks child_process.execFile to capture the argv and assert
`/d` is present. The parser-level [DIR] marker test is also still
there, so both the attrib call shape and the parser behavior are
locked down.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address codex round 2 on #767: tighten [DIR] strip to the literal marker

Codex flagged that /\s+\[[^\]]+\]\s*$/ also swallows legitimate trailing
bracketed text, so a hidden file named "Notes [old]" gets stored as
"Notes" in hiddenSet and hiddenSet.has("Notes [old]") returns false —
the entry is misclassified as not-hidden, a regression from the old
per-entry attrib path which never saw a "[DIR]" marker to strip.

Narrowed the regex to /\s+\[DIR\]\s*$/ — only the literal attrib/d
marker. Added a regression test covering "Notes [old]", "Draft [v2].md",
"archived [2024]" alongside the existing [DIR] case to lock down both
behaviors together.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:32:33 +08:00
0425841032 Fix ACP history replay and compaction (#754)
* Fix ACP history replay and compaction

* Fix PR keyword importance matching

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address codex review on #754: preserve short constraints + cancel-clear

Two recovery-path regressions flagged by codex review:

1. Compact ACP history dropped short load-bearing user constraints
   (acpHistory.ts:55). The blanket length<10 rule treated short
   non-trivial messages like "Use ssh2" or "中文输出" as filler,
   while longer generic follow-ups still ate the budget. After
   stale-session recovery the fresh ACP session would resume without
   constraints that were present in the original chat. Removed the
   length heuristic; the TRIVIAL_USER_MESSAGE_PATTERNS regex already
   filters actual filler ("ok", "yes", "继续", "thanks").

2. historyReplayFallback was only cleared on non-aborted streams
   (aiBridge.cjs:2837). If the user stopped the first turn after
   stale-session recovery, the flag stayed set. The next turn would
   then trigger shouldResetProviderForHistoryReplay, discard the
   freshly recovered ACP session (resumeSessionId is forced to
   undefined in that path), and re-spend tokens on another compact
   replay — breaking the cancel-preserves-session contract. Now we
   also clear on abort; the empty-but-not-aborted retry path in the
   if-branch above is unchanged.

Tests:
- New test in acpHistory.test.ts asserts "Use ssh2" / "中文输出"
  survive when pushed outside the recent raw window
- New test asserts "ok" / "继续" still drop (sanity check that the
  trivial regex still does its job without the length backstop)
- Updated "does not treat pr inside ordinary words as important" to
  no longer assert that approach/improve/prepare are absent — the
  test's real intent (priority-2 line still wins) is preserved by
  the 不要提交 assertion
- New test in aiBridge.test.cjs simulates a user cancelling the first
  turn after recovery and verifies the next turn reuses the
  recovered session (no extra provider creation, no re-replay)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address codex re-review: preserve replay flag across orthogonal recreation + keep tool output in raw window

Two more P2 regressions flagged on the second review pass:

1. historyReplayFallback was only carried over in the reset-for-replay
   branch of the provider recreation path. An orthogonal change between
   an empty recovered turn and its retry — a permission-mode toggle,
   MCP scope/fingerprint flip, or auth rotation — would flip
   shouldReuseProvider to false, enter the !shouldReuseProvider branch,
   and drop the flag because preserveHistoryReplayFallback only covered
   the shouldResetProviderForHistoryReplay case. The next turn then
   sent only the latest prompt and lost the recovered conversation.
   Now the flag is preserved on any recreation where a replay is still
   pending.

2. Tool messages didn't flow through toRawHistoryMessage at all, so on
   stale-session recovery they only survived as the 500-char compact
   summary in summarizeToolMessage. Any follow-up referencing the last
   tool output ("use that output", "what did cat show?") lost the
   actual bytes when they exceeded the compact cap. Now tool results
   travel through the recent raw window up to MAX_RAW_MESSAGE_CHARS
   (2000), flattened to the "assistant" role since ACP only accepts
   user/assistant.

Tests:
- aiBridge.test.cjs: new "preserves history-replay across provider
  recreation caused by permission-mode / MCP / auth change" —
  exercises the gap via a permission-mode toggle between an empty
  recovered turn and its retry. Extends mock to support a dynamic
  getPermissionMode.
- acpHistory.test.ts: new "preserves recent tool results verbatim" —
  pushes a ~1500-char tool output through the pipeline and asserts the
  replay still contains enough bytes to exceed the 500-char compact
  cap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address codex round 3: inline tool_call context + bound durable scan

Two findings from the third codex review pass, both legitimate:

1. [P2] When the raw window starts mid-tool-interaction, the preceding
   assistant tool_call message can fall outside the 6-item slice while
   the tool_result stays in. Without the call's name+arguments, the
   result was opaque bytes and follow-ups like "use that output" had
   no provenance. The compact pass only preserved calls that matched
   IMPORTANT_PATTERNS, so read_file / grep / terminal_exec were
   silently dropped.

   Fix: build a toolCallId → { name, arguments } index from every
   assistant message and inline a `[from <name>(<args>)]` label next
   to each Tool result line in the raw window. Args are truncated to
   MAX_TOOL_CALL_LABEL_CHARS (200) so a verbose JSON payload can't eat
   the entire raw budget.

2. [P3] buildCompactContext scanned messages.entries() over the full
   transcript for durable-user/assistant candidates, even though
   MAX_MESSAGES_TO_SCAN (20) suggested the path was meant to be
   bounded. On a long ACP chat, every send did O(N) regex work plus
   an O(N log N) sort — the very chat-length-dependent latency the
   token-compaction PR was meant to address.

   Fix: introduce MAX_DURABLE_SCAN_MESSAGES (200) and restrict the
   durable scan to that tail. 200 is large enough to cover realistic
   sessions (99th-percentile chats are << 200 turns) while giving a
   constant-time worst case. Constraints older than the window age
   out of the compact replay; the live ACP provider's own persisted
   session still carries them when it can resume, which is the
   common path.

Tests:
- "inlines tool_call name+args so tool_result is interpretable without
  the preceding assistant turn" — pushes the tool_call out of the raw
  window and asserts the result line carries [from <tool>(<args>)].
- "bounds the durable-candidate scan to avoid O(N) work per send on
  long chats" — builds a 600+ message chat with an ancient priority-2
  constraint outside the scan window and a recent one inside; asserts
  only the recent one survives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address codex round 4: preserve short assistant decisions + provenance on older tool results

Two P2 findings from the fourth codex pass, both mirror-images of earlier
fixes on a different code path:

1. Short assistant decisions dropped from compact replay
   (acpHistory.ts:75-83). isSubstantiveAssistantMessage required length
   >= 40 OR a small English keyword match OR a numbered list. Short but
   load-bearing replies like "Use ssh2", "rebase instead", "中文输出"
   satisfied none of those and were silently dropped from the durable-
   assistant compact section. Once they fell outside the 6-item raw
   window, "do what you suggested earlier" would replay only the user
   question without the assistant's actual decision.

   Fix: mirror the user-side loosening — drop the length/keyword gate,
   rely on TRIVIAL_ASSISTANT_MESSAGE_PATTERNS to filter actual filler
   ("ok", "ack", "got it", "明白").

2. Older tool results lost provenance (acpHistory.ts:108-114). The
   raw-window fix (round 3) only covered the last 6 items. Once a tool
   result fell into the compact section via summarizeToolMessage, the
   paired assistant tool_call was usually gone too, so multiple older
   outputs surfaced as indistinguishable "Tool result (callN): ...".
   Follow-ups like "use the resolv.conf output" had no way to map to
   the right call.

   Fix: plumb the toolCallIndex through summarizeMessage →
   summarizeToolMessage and inline `[from <name>(<args>)]` labels in
   the compact section too, the same shape the raw window uses.

Tests:
- New: preserves short non-trivial assistant decisions that miss the
  keyword heuristic (Use ssh2 / 中文输出 / rebase instead)
- New: still drops trivial assistant filler like 'ack' / 'ok' / '明白'
- New: inlines tool_call context on OLDER summarized tool results
- Updated earlier raw-window tool regex tests to match the [from X(Y)]
  shape ([^)] was failing to cross the args JSON's closing paren)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address codex round 5: de-dup raw ∩ compact + wire userSkills test into npm test

[P2] The scanned loop (last 20) overlaps with recentRaw (last 6), so
without a raw-window skip in the summarizeMessage path the same last-6
turns were summarized into the compact section AND appended verbatim
in the raw section. Important user turns and large tool output paid
the budget twice — eating into the 3k compact cap and crowding out
older durable context the replay is meant to preserve. Added the
same recentRawSourceIds skip the durable-user / durable-assistant
passes already use, and a regression test that asserts markers inside
the raw window don't surface in compact while still appearing in raw.

[P3] electron/bridges/ai/userSkills.test.cjs (added by this PR) sat
in a subdirectory that the default "npm test" glob
(electron/bridges/*.test.cjs) didn't pick up. The new routing /
index-budget regressions would never run locally or in CI until
someone noticed. Extended the glob to also match
electron/bridges/*/*.test.cjs; the userSkills tests are now included
in the 148-test run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address codex round 6: cancel+immediate-send race + tool-call id collision

Two P2 regressions in the recovery path:

1. If the user clicks Stop and immediately sends the next prompt, the
   new stream handler's existingRun path unconditionally called
   cleanupAcpProvider — destroying the fresh ACP session the cancel
   IPC had just promised to preserve. The round-2 clear-on-abort
   fix ran too late (in post-stream code) to help, because the new
   stream can arrive before the aborted stream fully unwinds. In
   that common timing window the follow-up still started from a
   bare provider and lost all recovered conversation state.

   Fix: (a) cancel IPC now synchronously clears
   historyReplayFallback on the preserved provider entry, so the
   next stream can't trigger shouldResetProviderForHistoryReplay
   and tear the session down via that path; (b) the existingRun
   path skips cleanupAcpProvider when the prior run was already
   cancelled via the cancel IPC (captured via existingRun.cancelRequested
   before we overwrite it). True interrupt-and-restart without an
   explicit cancel still falls back to the old clean-slate behavior.

2. The tool-call provenance index used raw toolCall.id as the key.
   Nothing in ChatMessage or the ACP event path enforces per-chat
   unique ids, so a provider reusing "call1" across turns would
   overwrite the older entry and mis-label older tool results
   (e.g., an /etc/hosts result annotated as /etc/resolv.conf in
   the compact summary). That makes stale-session recovery
   misleading whenever a follow-up refers back to an earlier tool
   output.

   Fix: key the index by `${toolResultMessageId}:${toolCallId}` and
   walk the message stream in order, resolving each tool_result to
   the most recent preceding assistant tool_call with matching id.
   Each result keeps its own historically-correct label regardless
   of later id reuse.

Tests:
- aiBridge: "preserves recovered ACP session when user cancels then
  immediately sends the next prompt" — fires the next stream request
  after cancel but BEFORE releasing the first stream's blocked read,
  asserts providerCreationArgs.length stays at 2 (no third creation)
  and the second turn sends only the latest prompt.
- acpHistory: "resolves tool_call provenance correctly when tool ids
  are reused across turns" — two interactions sharing id "call1",
  asserts each tool_result carries its own call's args label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address codex round 7: turn-based scan bound + single-pass history build

Two P2 regressions in long-chat / tool-heavy recovery paths:

1. MAX_DURABLE_SCAN_MESSAGES (200) bounded the scan by raw message
   count. ACP tool interactions store the user turn, assistant
   tool_call turn, and each tool_result as separate messages, so a
   tool-heavy chat can produce 5+ messages per logical turn. 200
   messages could be only 30-40 user turns — early constraints
   like "不要提交" from turn 5 fell out of the compact replay long
   before the turn count justified aging them out.

   Fix: bound by MAX_DURABLE_SCAN_TURNS (100 user turns) instead.
   Walk backwards from the end and stop after seeing 100 user
   messages. Realistic tool-heavy 30-turn chats now keep their
   early constraints alive, while true 100+ turn chats still
   benefit from the bound.

2. buildToolCallIndex(messages) and messages.flatMap(...).slice(-6)
   both walked the entire transcript on every send, even after the
   bounded compaction window landed. Compaction's stated purpose
   was to remove chat-length-dependent latency, but these per-send
   linear passes kept it.

   Fix: compute the scan start once via computeDurableScanStart,
   then do all subsequent work over messages.slice(durableScanStart).
   buildToolCallIndex walks only the window; the raw-6 flatMap also
   runs over the window. On a 1000-message chat with 100-turn
   window, send-time cost drops from O(1000) to O(~window_size).

Acceptable trade: if a tool_call's matching tool_result straddles
the window boundary (result inside, call outside), the single
surviving result loses its [from X(Y)] label. Tool_calls and their
results are almost always adjacent, so this affects at most the
first 1-2 messages of the window.

Tests:
- "preserves an early constraint in a tool-heavy chat where message
  count balloons past the raw-count limit" — 35 turns × 6 msgs/turn =
  212 messages. The old bound would have dropped the early
  EARLY_CONSTRAINT_MARKER; with turn-based bound it survives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:52:57 +08:00
陈大猫andClaude Opus 4.7 156550f7eb Add Close All / Others / To-the-Right tab actions (#748) (#764)
Adds three bulk-close items to the right-click context menu on tabs:
- Close Others
- Close Tabs to the Right
- Close All

Anchor is the right-clicked tab (matches VSCode/JetBrains/FinalShell
UX), not the active tab. The "to the right" item is disabled when the
anchor is already the rightmost tab; "Close Others" is disabled when
it's the only tab.

To avoid spamming a busy-shell modal per tab, the new closeTabsBatch
helper in App.tsx expands workspace ids into their session ids, runs
ONE confirmIfBusyLocalTerminal probe across the whole batch, and only
proceeds when the user confirms. The probe + close path itself reuses
the existing PR #739 plumbing (ptyProcessTree + confirmCloseBusy).

Closes #748

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:40:11 +08:00
陈大猫andClaude Opus 4.7 a1648adf12 Add opt-in setting to preserve mouse selection across keystrokes (#755) (#763)
* Add opt-in setting to preserve mouse selection across keystrokes

Closes #755.

xterm.js hardcodes a "clear selection on user input" listener
(SelectionService.ts: coreService.onUserInput → clearSelection) with
no public option to disable. The user-reported workflow this breaks:
select a path with the mouse, type a command prefix like `sz `, then
middle-click-paste the still-live selection — but the very first
keystroke wipes the selection, so there's nothing left to paste.

Modern terminals (iTerm2, GNOME Terminal, Windows Terminal) preserve
the selection across input by default. We expose this as an opt-in
toggle for now since the visual semantics are a behavior change.

Implementation is capture-and-restore via xterm.js public APIs
(getSelectionPosition / select); xterm clears the selection
synchronously, then a queueMicrotask reapplies it on the next tick.
A ref (isRestoringSelectionRef) gates copy-on-select so the restore
doesn't redundantly rewrite the clipboard and clobber whatever the
user copied elsewhere in between.

Defaults to false (opt-in); can flip to default-on later if reception
is positive. Selection still clears on:

- Mouse click in empty space (xterm's mouse-driven path is untouched)
- Terminal scroll past the selected rows (existing buffer-trim logic)
- Programmatic clearSelection() callers

Files:
- domain/models.ts — new field, default false
- application/syncPayload.ts — added to SYNCABLE_TERMINAL_KEYS
- components/terminal/runtime/createXTermRuntime.ts — capture in
  attachCustomKeyEventHandler, restore via queueMicrotask
- components/Terminal.tsx — owns isRestoringSelectionRef, passes it
  through context, checks in copy-on-select listener
- components/settings/tabs/SettingsTerminalTab.tsx — UI toggle
- application/i18n/locales/{en,zh-CN}.ts — labels

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Trim verbose i18n descriptions to match neighboring rows

Both clearWipesScrollback and preserveSelectionOnInput descriptions
were too long. Cut to one sentence each, matching the brevity of
adjacent rows like Bracketed paste and OSC-52. Historical context and
edge-case caveats belong in the changelog/PR, not the settings UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:22:48 +08:00
陈大猫andClaude Opus 4.7 8182bd6b3c Fix invisible caret in settings window inputs on Windows (#760) (#762)
Symptom: in the Settings window (especially AI > Add Provider, but also
seen in Add Host), clicking an input occasionally shows no caret and
typed characters don't appear, yet select-all + delete still works on
the input's content.

Root cause: PR #502 introduced settings-window prewarming and
hide-on-close reuse. On Windows, calling `BrowserWindow.focus()` from
a non-foreground process is restricted by SetForegroundWindow rules —
the window is shown on top but never actually receives OS foreground
focus. With `document.hasFocus() === false`, Chromium deliberately
suppresses caret blink and keyboard routing, even though clicking an
input still moves activeElement to it (so non-keyboard interactions
like select-all-then-delete keep working — exactly the reported
symptom).

Fix: introduce `showAndFocusWindow(win)` and call it everywhere the
settings window is shown:

- Apply the alwaysOnTop toggle on win32 to bypass the
  SetForegroundWindow restriction (established Electron workaround)
- Always call `webContents.focus()` after `win.focus()` so the renderer
  marks the document as focused regardless of what the OS decided —
  this is what restores the caret + keyboard routing

Scope intentionally limited to the settings window (the path PR #502
introduced). Other windows use a different show path (ready-to-show
event) and were not reported to have the issue.

I cannot test this on Windows directly. The fix follows a
well-documented Electron pattern and the diagnosis matches the
reported symptoms (Windows-only, intermittent, post-1.0.81 only).

Closes #760

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:44:37 +08:00
陈大猫andClaude Opus 4.7 484ac5f463 Honor CSI 3 J by default; add toggle to preserve scrollback on clear (#761)
* Honor CSI 3 J by default; add toggle to preserve scrollback on `clear`

Default `clear` (ncurses ≥ 2013) emits CSI 2 J + CSI 3 J to wipe both
visible screen and scrollback. PR #633 unconditionally intercepted CSI
3 J to keep history across `clear`, which broke POSIX semantics — users
running standard `clear` could not wipe scrollback at all (#757).

Restore the standard behavior as the default and expose a toggle for
the iTerm2-style "preserve history" preference (matches what #622
asked for):

- domain/models.ts: add `clearWipesScrollback: boolean` (default true)
- createXTermRuntime.ts: CSI 3 J handler now reads the setting and
  only intercepts when the user opts out
- SettingsTerminalTab.tsx + i18n: expose the toggle with a description
  explaining the tradeoff
- The right-click "Clear Buffer" menu action keeps its independent
  semantics (always preserves scrollback) regardless of this setting,
  since it goes through `clearTerminalViewport`, not the CSI path

Closes #757

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: include clearWipesScrollback in cloud-sync terminal keys

Codex review on PR #761 caught that the new toggle was added to
TerminalSettings but not to SYNCABLE_TERMINAL_KEYS, so it would never
travel across devices via cloud sync — users disabling it on one
device would silently get the default back on another after sync.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:17:33 +08:00
陈大猫andClaude Opus 4.7 98e3a6b952 Let single Tab fall through to shell when only ghost text is shown (#745)
Closes #741. Bash/zsh use Tab for native completion, but our ghost-text
accept on single Tab was swallowing the keystroke before it reached the
PTY. Ghost text is still accepted with →; Tab in popup-menu mode is
unchanged (popup is an explicit UI so intent is clear).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 02:44:57 +08:00
陈大猫andClaude Opus 4.7 f6f3147afb Tab bar: duplicate-adjacent insertion + wheel-to-horizontal scroll (#743)
* Improve tab UX: insert duplicated tabs adjacent to source, enable wheel scroll on tab bar

Addresses #737.

- Duplicating a tab now inserts the new tab immediately after the source
  in the tab order, instead of appending it to the far right where it
  was hard to find with many tabs open.
- The top tab strip now translates vertical mouse-wheel deltas into
  horizontal scrolling, so users with many tabs can reach the ends of
  the strip without dragging. Trackpad gestures that already carry
  horizontal delta are left alone to preserve native two-finger swiping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address Codex review: read source session inside functional updater

Codex flagged that reading `session` from the closure broke the atomicity
guarantee of the previous implementation — rapid repeated duplicates could
miss freshly queued state.

- Pre-allocate the new session id outside both setters so it stays stable
  across StrictMode double-invocations.
- Move the source lookup back into `setSessions`' functional updater so it
  always reads the freshest committed/queued state.
- Drop `sessions` from the useCallback dependency list now that we no
  longer read it.
- Fast-path tabOrder insertion when the source is already in tabOrder to
  avoid re-deriving the full effective order in the common case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address Codex review: gate active-tab and tab-order updates on successful create

Codex flagged that `setActiveTabId(newSessionId)` and `setTabOrder(...)` ran
unconditionally even when `setSessions` bailed out (source tab was closed
before the duplicate handler ran). That left activeTabId pointing at an id
that was never appended to sessions, putting the terminal layer into an
invalid "no matching tab" state.

Move both nested setState calls inside the `setSessions` functional updater
so they only fire when the source is actually present. Mirrors the original
pre-PR pattern; nested updates are idempotent so StrictMode's
double-invocation is harmless.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 00:41:31 +08:00
陈大猫andClaude Sonnet 4.6 54b26511a1 Cloud sync data-loss prevention (4-layer defense) (#742)
* feat(sync-guard): extend SyncState with BLOCKED + add shrink event variants

* feat(sync-guard): add detectSuspiciousShrink pure function with 12 unit tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* polish(sync-guard): drop unnecessary cast, sharpen test naming, pin priority invariant

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(test): include domain/*.test.ts in npm test glob

* feat(sync-guard): gate syncToProvider with shrink detection + force-push override

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sync-guard): reset overrideShrinkOnce before early return for invariant strictness

* fix(sync-guard): extend shrink guard to syncAllProviders (the actual sync entry point)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sync-guard): apply empty-vault guard uniformly to auto and manual sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sync-guard): preserve merge base on same-account re-auth

Adds providerAccountId persistence; completePKCEAuth and completeGitHubAuth
now only clear syncBase/anchor when the authenticated account id differs from
the previously stored one, preventing zombie-entry resurrection on token
refresh. disconnectProvider clears the stored id so a reconnect starts fresh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sync-guard): add i18n strings for sync-blocked banner + force-push modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sync-guard): add SyncBlockedBanner showing shrink findings with restore/force-push actions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sync-guard): stable subscribeToEvents reference + type-safe finding narrowing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sync-guard): force-push confirmation modal + scroll restore button into view

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ux(local-backups): show version as title, demote reason+timestamp to meta line

* feat(local-backups): record + display sync data version (v5/v6...) on each backup

Each backup now captures the live CloudSyncManager.localVersion at creation
time. UI shows it as title (v5, v6, ...) with timestamp + reason demoted to
the meta line. Backups created before this field existed (or before any
successful cloud sync) fall back to timestamp as title.

Replaces the earlier app-version-transition title which conflated app
version with sync data version.

* fix(sync-guard): consume override flag at sync entry + restore provider status on block

- Snapshot+clear overrideShrinkOnce at top of syncToProvider and
  syncAllProviders so an early-return cannot leak the flag to a later
  unrelated sync (Codex P1).
- Restore provider status to 'connected' when shrink-block returns from
  syncToProvider; previously left provider stuck on 'syncing' in the
  UI (Codex P2).
- Process pre-existing check errors before returning from the
  shouldBlockAll branch in syncAllProviders so a check-failed provider
  isn't dropped from results (Codex P2).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sync-guard): refactor force-push to parameter passing + add credential-availability guard

The previous design used a one-shot boolean flag on CloudSyncManager set
by forcePushOverrideShrink(). Even with snapshot+clear at sync entry
points, the renderer wrapper's await ensureUnlocked() could throw before
the flag was consumed, leaving it armed for the next unrelated sync.

Fix: pass overrideShrink as a call-time parameter through the chain.
Eliminates the persistent flag and its leak surface.

Also: force-push now runs the same ensureSyncablePayload(...) guard the
other manual sync entry points use, so a vault with encrypted-credential
placeholders won't be uploaded via the force path either.

Addresses the latest two Codex P1/P2 findings on #742.

* fix(sync-guard): backfill account id from in-memory state for upgrade-path re-auth

Users upgrading to this PR have no netcatty.sync.accountId.* persisted yet.
On their first re-auth the guard saw previousId=null and cleared the
merge base anyway, defeating the point of the same-account preservation.

Snapshot the in-memory account id BEFORE overwriting providers[provider]
and use it as a fallback when the persisted id is missing. New users
(no prior connection at all) still get the clear-on-first-auth path.

Addresses Codex P1 on #742.

* fix(sync-guard): inspect force-push results + mark blocked single-provider as error

- Force-push handler now inspects syncNow result entries: applies any
  mergedPayload to local state, only clears the banner when all providers
  report success, surfaces a toast error otherwise. Previously the banner
  cleared unconditionally regardless of network/auth failures (Codex P1).

- syncToProvider shrink-block branches now mark provider status as
  'error' with a 'Sync blocked: would delete too much' message instead
  of 'connected'. Status aggregators treat 'connected' as healthy, so
  the blocked upload was surfacing as 'synced' in the UI (Codex P2).
  syncAllProviders already used this pattern; this brings the
  single-provider path in line.

* fix(sync-guard): exempt USE_LOCAL conflict + clear post-merge BLOCKED + expose 'blocked' status

- USE_LOCAL conflict resolution now passes { overrideShrink: true }: the
  conflict modal already served as user confirmation, and shrink-blocking
  it left users with a closed modal and an opaque banner (Review C-1).

- Post-merge round-trip in useAutoSync now detects shrink-blocked results
  and resets syncState to IDLE via new manager.clearShrinkBlockedState().
  The merged data is already applied locally; the next user-triggered
  sync will re-check, and we don't wedge the manager in BLOCKED with no
  visible banner outside the Settings tab (Review I-1).

- overallSyncStatus now reports 'blocked' as a distinct value from
  'error', so downstream UI (status icon, future badges) can offer
  shrink-block-specific affordances (Review I-2).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sync-guard): stabilize banner subscription dep + map 'blocked' status to error indicator

- The SyncBlockedBanner subscription useEffect depended on [sync] (the
  whole hook return object), which gets a new reference every render.
  This caused the listener to be unsubscribed+resubscribed on every
  render, opening a tiny race window where a SYNC_BLOCKED_SHRINK event
  could be missed and the banner would never appear. Destructure
  subscribeToEvents (already useCallback-stable) and depend on it
  directly, so the effect runs exactly once on mount.

- SyncStatusButton's status mapping had no arm for the new 'blocked'
  value, falling through to 'none' (idle). The global status indicator
  said healthy while the in-page banner said paused. Map 'blocked' to
  the same error indicator used for 'conflict' so the UI is consistent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sync-guard): only clear banner on actual success + hydrate from manager state

- Banner subscription now clears only on SYNC_COMPLETED with result.success.
  SYNC_STARTED (auto-sync timer ticks) and SYNC_FORCED (fires BEFORE upload)
  could clear the banner prematurely, removing the user's recovery affordance
  while the underlying issue was unresolved (Codex P2).

- Manager now persists the last shrink finding in state.lastShrinkFinding
  alongside the SYNC_BLOCKED_SHRINK emission. New public getter
  getShrinkBlockedFinding() returns it when syncState is BLOCKED. Renderer
  hydrates the banner on mount so a block that happened off-screen
  (auto-sync while user was on another tab) is still visible when they
  open Sync Settings (Codex P2).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sync-guard): unified BLOCKED-cleared event + USE_LOCAL inspects results

- USE_LOCAL conflict resolution now inspects syncNow() results, applies
  any mergedPayload to local state, surfaces a toast error and KEEPS the
  modal open on failure (so user can switch to USE_REMOTE). Mirrors the
  force-push handler pattern. Without this, USE_LOCAL silently 'succeeded'
  even when providers failed (Codex CLI P1).

- New SYNC_BLOCKED_CLEARED event emitted on every BLOCKED -> non-BLOCKED
  transition via a private exitBlockedState() helper. Banner subscribes to
  this single signal instead of guessing from per-provider SYNC_COMPLETED
  events. Fixes:
    - Multi-provider scenarios where first SYNC_COMPLETED clears the banner
      while a later provider was still going to fail (Codex CLI P1).
    - clearShrinkBlockedState() (post-merge self-heal) silently leaving
      the banner stuck because no event was emitted (Codex CLI P2).

- disconnectProvider() now also exits BLOCKED state. Disconnecting
  implicitly resolves any pending shrink-block warning, otherwise the
  stale alert carried over to the next-account reconnect (Codex CLI P2).

- All BLOCKED -> non-BLOCKED transitions consolidated through
  exitBlockedState() so lastShrinkFinding cleanup + event emission are
  always paired (Codex CLI P3 #6 covered).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sync-guard): only clear BLOCKED on actual success, not on transient ERROR/SYNCING/CONFLICT

Previous patch called exitBlockedState() at every BLOCKED -> non-BLOCKED
transition, but this clears the banner on transitions that don't actually
resolve the shrink concern:

- SYNCING (sync just started — about to try, may fail)
- ERROR (transient transport failure, shrink concern still real)
- CONFLICT (separate concern; doesn't resolve the shrink)

If a user was in BLOCKED then triggered a sync that failed for an unrelated
reason (network, auth), the banner cleared and they lost the warning.

Restrict exitBlockedState() to terminal-success transitions:
- IDLE on successful upload (data made it to cloud — concern resolved)
- explicit clears (disconnectProvider, clearShrinkBlockedState)
- conflict resolution (USE_REMOTE/USE_LOCAL also end in IDLE)

Found by Codex CLI review of commit 12d7fa7b.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 22:43:19 +08:00
陈大猫andClaude Sonnet 4.6 8ef91e1266 Ctrl+W close priority + local shell busy confirmation (#739)
* feat(ctrl-w): add ps-node + windows-process-tree + tsx deps for close-priority feature

* fix(ctrl-w): drop ps-node dep and add windows-process-tree to asarUnpack

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ctrl-w): add ptyProcessTree bridge with per-platform child-process enumeration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ctrl-w): ptyProcessTree uses args= for full command + warns on pid overwrite

- Replace `comm=` with `args=` in defaultListPosix so the full command
  line is captured on both macOS (BSD ps) and Linux (GNU ps), avoiding
  the 15-char TASK_COMM_LEN truncation.
- Add console.warn in registerPid when the same sessionId is overwritten
  with a different pid, making the race condition visible in logs.
- Add test: registerPid warns exactly once on a pid change, not on a
  same-pid re-registration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ctrl-w): register local PTY pid with ptyProcessTree on spawn/exit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ctrl-w): unregister pids in cleanupAllSessions to match per-delete invariant

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ctrl-w): add IPC handlers for pty child processes and confirm-close dialog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ctrl-w): guard BrowserWindow.fromWebContents null and document dialog dismiss contract

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ctrl-w): expose ptyGetChildProcesses and confirmCloseBusy on window.netcatty

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ctrl-w): add i18n strings for close-busy-terminal dialog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ctrl-w): add resolveCloseIntent pure function with 8 unit tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ctrl-w): expose handleCloseSidePanel via ref to App.tsx

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ctrl-w): wire resolveCloseIntent + local-shell busy confirmation into closeTab hotkey

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ctrl-w): add re-entrancy guard, aggregate busy count, sync sidebar ref, dedupe intent branches

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ctrl-w): auto-close workspace when its last session is closed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ctrl-w): sidebar close wins over focused terminal in priority chain

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ctrl-w): sidebar priority applies to single-session tabs too, not just workspaces

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ctrl-w): compute empty-workspace auto-close outside setSessions updater

Addresses Codex P2 on #739: React 18+ does not guarantee updater
execution timing under concurrent scheduling. Moving the decision
outside the updater makes the microtask queue deterministic.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 17:30:11 +08:00
Eric Chan b2689f96a4 Clarify Netcatty CLI launcher guidance (#738) 2026-04-16 14:59:24 +08:00
陈大猫 1b23bdcf15 [codex] Preserve terminal focus when clicking the toolbar overlay (#734)
* fix terminal toolbar focus loss

* restore focus after closing side panels

* fix terminal side panel focus helper order
2026-04-16 11:08:09 +08:00
陈大猫 2e63848e0e fix empty ssh identification banners (#733) 2026-04-16 10:34:51 +08:00
陈大猫 3a748aa1aa fix serial duplicate host save (#732) 2026-04-16 10:15:37 +08:00
4574f1e2b2 fix: stabilize scoped AI draft/session transitions (#724)
* fix: correct terminal AI history resume behavior

The previous implementation plan mistakenly treated reopening an old terminal AI session in a fresh or reconnected SSH tab as a scope-retargeting feature.

The intended rule is draft-first:
- a fresh or reconnected terminal opens on a blank draft
- older chats remain available in history for manual access
- selecting history does not imply automatic scope transfer into the new tab

This change is a rule correction, not a conflict between product rules.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: harden ai draft transitions

* fix ai session continuation from history

* fix: clear stale activeSessionIdMap entry when view resolves to draft

Addresses the Codex P2 review on aiPanelViewState.ts:38. When a terminal
scope mounts with a persisted activeSessionIdMap entry but no explicit
panelView and no draft, resolveDisplayedPanelView now returns the
default draft view (terminal fresh-start behavior). The sync effect
that writes into activeSessionIdMap is guarded by `if (!activeSession)
return`, so the old entry stays put. That stale entry then leaks into
activeTerminalTargetIds in every other scope, and
getSessionScopeMatchRank uses it to suppress host-matched history that
is actually resumable — so valid sessions vanish from the history
drawer until another action rewrites the map.

Add a dedicated effect that clears the scope's activeSessionIdMap
entry whenever the resolved panel view is draft but a persisted
session id is still present. This keeps the map an accurate record of
"which session each scope is currently showing" instead of a lagging
snapshot.

Also extend sessionScopeMatch.test.ts to cover the rank=2 exact-match
branch and the scope-type mismatch short-circuit, which were missing
from the original suite.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: track cross-terminal session ownership by session id, not targetId

Addresses the Codex follow-up review on commit 345244b2. When a user
resumes a session from history into a different terminal, the session's
`scope.targetId` still points at the original terminal. The previous
ownership tracking — which checked whether `session.scope.targetId`
appeared in `activeTerminalTargetIds` (derived from the keys of
`activeSessionIdMap`) — therefore:

- could not prevent the same session from being resumed in multiple
  terminals simultaneously, because the resumed session's targetId
  never matches the current scope's targetId; and
- let `pruneInactiveScopedSessions` treat a session as orphaned and
  clear its `externalSessionId` the moment the original terminal
  closed, even though another terminal was actively using it.

Switch ownership to be keyed on session id:

- `getSessionScopeMatchRank` now takes `activeTerminalSessionIds`
  (a Set of session ids currently displayed by other terminal scopes)
  and returns rank 0 when `session.id` is in that set.
- `AIChatSidePanel` derives `activeTerminalSessionIds` from the
  *values* of `activeSessionIdMap`, excluding the current scope's key.
- `pruneInactiveScopedSessions` gains an `activeSessionIds` parameter;
  sessions whose id is in this set are never reported as orphaned and
  never have their `externalSessionId` cleared, regardless of their
  stored `scope.targetId`.
- `cleanupOrphanedAISessions` computes the in-use set from the
  pre-cleanup `activeSessionIdMap`, filtered to live scopes, and
  passes it through. The map is read once and reused.

Tests cover the new id-based ownership, the rank-2 exact-match path,
the scope-type-mismatch short-circuit, and the
"resumed-elsewhere session must not be cleaned" invariant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:16:10 +08:00
陈大猫andClaude Opus 4.6 081b167172 feat(ai-chat): fit-to-content popovers + keyboard nav for @/slash menus (#726)
* feat(ai-chat): fit-to-content popovers and keyboard nav for @/slash menus

- Shrink the @ host and /skill popovers to their content width
  (auto width with min 220px, capped at the input width) instead of
  always filling the full input width, which left large empty gutters
  when the list was short.
- Add keyboard navigation: ArrowUp/ArrowDown cycle through items,
  Enter commits the highlighted item, Escape closes the menu. Mouse
  hover stays in sync with the active index so keyboard and pointer
  agree on which row is current. Enter does not fall through to
  submit while a menu is open.
- Expose aria-selected / aria-activedescendant for screen readers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style(ai-chat): tone down popover radius to match other menus

The @ and /skill popovers used rounded-[20px]/rounded-[16px] which
stood out against every other popover in this file (rounded-lg with
rounded-md items). Switch to the shared radii and drop shadow-2xl for
the standard shadow-lg so the surface feels consistent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style(ai-chat): tighten mention popover spacing

- Drop the redundant "Hosts" / "User Skills" header row — the @ or /
  trigger already makes the popover's purpose obvious, and the header
  added ~30px of vertical whitespace above a single-line list.
- Shrink wrapper and item padding (p-2.5/px-3 py-1.5 -> p-1/px-2 py-1)
  and remove the mt-0.5 gap between title and subtitle.
- Hide the hostname subline when the label already contains the
  hostname (common case: "Rainyun-114.66.26.174" as label and
  "114.66.26.174" as hostname — no need to repeat).
- Lower minWidth 220 -> 200 so short lists can shrink further.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ai-chat): address Codex review on PR #726

- Reset active menu index on any change to the *set* of visible items,
  not just its length. Watching only `.length` let Enter commit a
  different item when the slash query changed to a same-sized match
  set. Derive a stable identity key (sessionIds / skill ids) and use
  that as the effect dep instead.
- Clamp the popover's minWidth to the measured panel width so narrow
  layouts don't end up with minWidth > maxWidth, which CSS resolves
  by honoring min and clips the menu off-screen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:25:51 +08:00
陈大猫andClaude Opus 4.6 a818a7004f fix: remove invalid eval -- in fish shell wrapper (#725)
Fish's `eval` builtin does not recognize `--` as an end-of-options
marker, so the wrapper failed with `fish: Unknown command: --` for
every AI Agent command under fish. The `--` was unnecessary since
fish's `eval` has no options to terminate.

Fixes #721

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:58:26 +08:00
陈大猫andClaude Opus 4.6 5bc5a6c8b2 fix: address Codex follow-up review on PR #720 (#723)
* fix: address Codex follow-up review on PR #720

Two issues surfaced by Codex's post-merge review of PR #720:

P1 — useAutoSync.ts: startup retry exhaustion wedged auto-sync.
The retry effect previously returned at `attempt >= 4` without
opening `remoteCheckDoneRef`. A session with persistent inspect
failures (long network outage, provider rate-limit loop) left
auto-sync silently disabled for the rest of the session until
restart or provider/unlock transition. After exhaustion, open the
gate: the specific dangers we gate-closed against (empty-push,
partial-apply push) are now covered by independent guards
(`hasMeaningfulSyncData`, the apply-in-progress sentinel, and
`checkProviderConflict`'s inspect-failure throw at upload time).
This matches manual sync's existing semantic rather than silently
strict-gating auto-sync.

P2 — CloudSyncSettings.tsx: restore buttons were per-row disabled,
not globally. A user could click Row A, then Row B while A was
still applying — two concurrent `applyProtectedSyncPayload` calls
in the same window. `withRestoreBarrier` serializes across windows
but NOT same-window re-entry, so the second restore's
sentinel-clear could mask a still-partial first apply. Disable
every restore button while any restore is in flight.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: keep auto-sync gate closed on retry exhaust; open on manual sync

Codex's re-review of PR #723 correctly flagged that opening the
auto-sync gate after startup retry exhaustion reintroduces the
destructive-clobber path the gate was supposed to prevent. Concrete
scenario: local vault is partially lost (non-empty, just missing
entries), remote has not changed since our last anchor, user edits a
field after a long outage → auto-sync pushes the partially-lost
vault over the intact remote. `checkProviderConflict` doesn't catch
this (anchor matches), `hasMeaningfulSyncData` doesn't catch this
(non-empty), and the empty-vault prompt doesn't fire.

Revert the retry-exhaust gate-open. The gate now stays closed until
either:

  1. A startup `checkRemoteVersion` succeeds (normal path), OR
  2. A `syncNow` completes successfully. A manual sync from Settings
     implicitly runs per-provider `checkProviderConflict` — the same
     inspect the startup path would have done — so a successful
     manual sync is equivalent to a successful startup reconciliation
     from the gate's point of view and opens the gate for the rest
     of the session.

This preserves Codex's safety ask (no auto-push without a confirmed
remote state) while giving the user a clear escape hatch (manual
sync) that doesn't require a restart.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 03:37:36 +08:00
陈大猫andClaude Opus 4.6 6c8a39d269 feat: add stable CSS hooks to tab components (#714) (#722)
* feat: add stable CSS hooks to tab components (#714)

Expose stable attributes on every tab-like element so custom CSS can
target them reliably without chaining utility-class selectors or
relying on inline-style substring matches:

- data-tab-id: already present on session/workspace/logView/sftp tabs;
  now also added to the side-panel buttons (sftp/scripts/theme/ai)
  in TerminalLayer.tsx.
- data-tab-type: session | workspace | logView | sftp | sidepanel,
  lets a selector target one tab family without matching the rest.
- data-state: active | inactive, mirroring Radix Tabs' convention so
  users who already style Settings tabs can reuse the same idiom.
- .netcatty-tab class: a single, scope-free hook for "every tab,
  anywhere" — pairs with data-state="active" for the common "style
  the selected tab" recipe.

No visual changes. The existing inline-style / utility-class selectors
the issue reporter had to chain ([style*="var(--top-tabs-active-bg"],
.app-no-drag.relative.h-7.px-3, etc.) keep working, so no breakage
for people who've already written custom CSS.

Custom CSS can now be written as:

  .netcatty-tab[data-state="active"] { ... }
  [data-tab-type="sftp"][data-state="active"] { ... }
  [data-tab-id="ai"][data-state="active"] { ... }

Closes #714

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add CSS hooks to the root Vaults/SFTP tabs (#714)

The fixed-left root tabs ("Vaults" and "SFTP") in TopTabs.tsx were
missed in the first pass — they don't go through the session /
workspace / logView branches, so their div rendered without the new
data-tab-id / data-tab-type / data-state attributes or the
.netcatty-tab class.

Add them so custom CSS can target the whole root tab row the same
way:

  [data-tab-type="root"][data-state="active"] { ... }
  [data-tab-id="vault"] { ... }
  [data-tab-id="sftp"] { ... }

No visual change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 03:22:15 +08:00
陈大猫andClaude Opus 4.6 db69d5ac39 [codex] Harden sync overwrite protection and add local restore history (#720)
* fix: harden sync overwrite recovery

* refactor: separate backup retention settings

* refactor: align backup retention controls

* refactor: simplify backup retention card

* fix: address PR #720 deep-review findings

- Close the cross-window restore race by holding a time-bounded barrier
  in localStorage during every destructive apply; useAutoSync skips
  pushes while it's set, preventing a pre-restore snapshot from
  clobbering just-restored cloud data.
- Round-trip startup three-way merges so merged-in local additions
  actually reach the cloud instead of living only on the device that
  ran the merge until the next edit.
- Upgrade sync signatures from a 64-char ciphertext prefix to full
  SHA-256 (v3), closing the tail-mutation replay weakness.
- Harden the vault-backup IPC: payload size cap, enum-validated reason,
  sanitized version strings, strict maxCount, concurrent-call mutex,
  monotonic createdAt to avoid same-ms ordering ties.
- Extract the anchor-change decision into a pure module with unit tests
  covering no-anchor, resource-id drift, and signature mismatch paths.
- Capture the protective backup from the pre-apply closure snapshot so
  it reflects what's being replaced rather than what was imported.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR #720 follow-up review findings

Make protective backup abort-on-failure (was best-effort console.error),
preserve nested syncedAt in fingerprint, use UTF-8 byte length for size
guard, throw on conflict-inspect failure so stale uploads can't leak
through, treat unreadable remote as changed, canonical-JSON signature
meta, and hold the version stamp on transient backup failures so the
retry path still fires.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address second-pass review findings on PR #720

- Hold version-change stamp when payload is non-meaningful (covers the
  startup vault-rehydrate race where a transient empty snapshot would
  permanently skip the upgrade backup).
- readBackupRecord stat-checks before readFile so an oversized file in
  the backup dir cannot OOM the renderer on enumeration.
- Reject maxBackups input outside 1..100 instead of silently clamping
  (matches the i18n error copy and the main-process sanitizer bound).
- Wrap USE_LOCAL conflict-resolution push in withRestoreBarrier so a
  concurrent auto-sync in another window cannot interleave.
- sha256Hex throws SyncSignatureUnavailableError on missing WebCrypto
  subtle; createSyncedFileSignature returns null, forcing the
  unreadable-remote → three-way-merge path instead of a weak
  length-only pseudo-signature.
- Document that array order in normalizePayloadForHash is an invariant
  enforced by producers, not the hash function.
- Drop three-way-merge completion logs from console.log to console.info.
- Comment the implicit restore → store-listener refresh chain so
  future refactors don't silently break the UI reload path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address third-pass review findings on PR #720

Resolves I-3 through I-8 and related cleanup items identified in the
deep review. Highlights:

- replace setTimeout(0) post-merge round-trip with a direct
  syncAllProviders call using the already-computed merged payload,
  removing the React-commit race
- resolve the empty-vault confirmation promise on unmount so a
  mid-dialog window teardown doesn't leak the resolver
- retry the version-change backup as hosts/keys hydrate, instead of
  latching on the first (possibly empty) snapshot
- heartbeat-refresh the cross-window restore barrier so long applies
  cannot expose a post-60s window to concurrent auto-sync
- add a diagnostic warning when connected providers hold divergent
  bases (multi-account configurations)
- surface a user-visible "Sync paused" toast when startup inspect
  fails, replacing the previous silent gate-open
- tie-break backup list sort by id when createdAt collides
- extract applyProtectedSyncPayload so the main and settings windows
  cannot drift on restore-barrier / protective-backup handling

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address deep-review findings on PR #720

Deep re-review surfaced six Important issues that survived the prior
four review rounds. All are hardened here:

- I1: fsync the protective backup file AND its directory before the
  rename completes, so a system crash between backup creation and the
  restore it guards cannot leave a torn/zero-length safety net.
- I3: persist an apply-in-progress sentinel across the non-atomic
  localStorage writes in applySyncPayload. A crash mid-apply now
  surfaces on the next startup (toast + refuse auto-push) instead of
  silently pushing the half-applied state over an intact cloud copy.
- I2: only open the auto-sync gate (remoteCheckDoneRef) when the
  startup inspect validated cleanly. Add a bounded exponential-backoff
  retry so a transient inspect failure self-heals instead of wedging
  auto-sync until restart.
- I5: save the sync base BEFORE advancing the per-provider anchor
  inside uploadToProvider. A renderer crash between the two writes
  now degrades to "stale anchor forces re-inspect on next run," which
  re-merges against the fresh base — eliminating the silent
  base-drift window where a 3rd-device race could misclassify
  entries.
- I6: main process broadcasts a vaultBackups:changed IPC event on
  every mutation; useLocalVaultBackups subscribes so protective
  backups created from the main window show up in the Settings
  backup list without manual refresh.
- I4: update PR description + code comment to match the actual
  (safer) design: auto-sync gate opens on vault init, with
  hasMeaningfulSyncData + restore barrier preventing empty-push; the
  version-change backup is best-effort and retries as data hydrates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: serialize startup checkRemoteVersion and stabilize its deps

Re-review flagged that checkRemoteVersion's useCallback depended on
`config` — a fresh object literal from App.tsx on every render — so
the retry effect restarted with attempt=0 on every vault edit and
could spawn overlapping in-flight inspect+apply runs. Two concurrent
commitRemoteInspection + onApplyPayload calls could race on the
apply-in-progress sentinel around interleaved writes.

Route `buildPayload`, `config.onApplyPayload`, and `config.startupReady`
through refs so checkRemoteVersion's identity no longer churns with
unrelated App state. Add an in-flight guard that returns early when a
previous invocation is still awaiting the network, closing the
same-window re-entry gap that withRestoreBarrier intentionally doesn't
cover.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: release in-flight lock on no-connected-provider early return

Third-pass review caught that `checkRemoteInFlightRef` was acquired
before the `!connectedProvider` check, so that early return leaked
the lock and every subsequent retry-timer tick silently no-op'd.
Move the acquisition past the early return so the only path that
takes the lock reaches the finally-release.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 03:09:55 +08:00
陈大猫 ee400f424b Merge pull request #718 from binaricat/fix/mac-fullscreen-tray-hide-show-race
fix: stop cancelling mac fullscreen tray-hide on internal show event
2026-04-14 23:32:10 +08:00
bincxz ba93e2fa35 fix: do not cancel pending close-to-tray hide on window show event
Follow-up to the trailing-show fix. Codex review on #718 flagged that
`focusMainWindow()` in main.cjs (called from `app.on("second-instance")`
and as the fallback path of `app.on("activate")`) still calls
`win.show()/focus()` without cancelling any in-flight close-to-tray
pending hide. A user who closes a fullscreen window to tray and then
relaunches the app via a second instance would see the window briefly
reappear and get hidden again when `leave-full-screen` lands.

Add `clearPendingFullscreenHide(win)` at the top of `focusMainWindow()`
so every reopen entry point (dock click, second-instance, activate
fallback) cancels the pending hide before showing the window.
2026-04-14 23:26:38 +08:00
bincxz 591b240d12 fix: wait for trailing show after leave-full-screen before hiding to tray
The previous fix (dropping the show cancellation listener) still left
close-to-tray on a fullscreen mac window with a window-pops-back bug.
Reproduced with main-process logging on macOS 26:

  T+0ms   handleWindowClose + setFullScreen(false) + pending armed
  T+56ms  win.hide (internal, from setFullScreen false)
  T+106ms our polling hid the window (isFullScreen() returned false)
  T+591ms leave-full-screen arrives (animation actually done)
  T+603ms win.show (macOS trailing event, finalizing space transition)

Two realisations:
 1. isFullScreen() flips to false BEFORE the animation is visually
    complete. Polling it and calling win.hide() at that moment caused
    the pop-back (macOS undoes the hide when the animation finishes).
 2. Even without (1), macOS emits a trailing `show` event ~12ms after
    leave-full-screen. Any prior hide gets reversed by that show.

New strategy in hideWindowRespectingMacFullscreen:

  - Do not hide from the polling timer; use polling only as a watchdog
    that gives up after 5s without leave-full-screen (forces the leave
    path anyway so at least the tray-hide is attempted).
  - On leave-full-screen, arm a `once("show")` listener plus a 300ms
    fallback timer. Whichever fires first runs the hide. This way the
    hide lands on top of macOS's trailing show, so the show cannot
    undo it.
  - clearPendingFullscreenHide teardown now covers the new timer and
    the trailing-show listener, so every cancel entry point stays
    correct.

Tests rewritten to match the new state machine (no more poll-based
hide): one for the happy path, one for the trailing-show fallback,
one for the watchdog. All 11 tests pass.
2026-04-14 22:51:21 +08:00
bincxz 880812f48d fix: do not cancel pending close-to-tray hide on window show event
macOS emits a `show` event on the BrowserWindow internally while the
native fullscreen exit animation lands the window back in its home
Space. PR #717's defensive `show` listener in
hideWindowRespectingMacFullscreen treated that as user intent and
cleared the pending hide, so clicking the red close button on a
fullscreen window left it visible on screen instead of going to the
tray.

Remove the `show` listener entirely. The other paths that legitimately
"bring the window back" during the exit animation (openMainWindow,
toggleWindowVisibility, setCloseToTray(false), the tray "Open Main
Window" menu) already call clearPendingFullscreenHide explicitly, so
the listener was only ever catching the internal transition emit.

Also wire app.on("activate") in main.cjs to call
clearPendingFullscreenHide so a dock-click during the exit animation
correctly cancels the pending hide as user intent.

Update the existing regression test to assert the new behavior
(`show` does not cancel; leave-full-screen still does), and add a
new test covering the app-activate path.
2026-04-14 19:04:04 +08:00
陈大猫 445ce92dbc Merge pull request #717 from binaricat/codex/fix-mac-fullscreen-close
[codex] Fix mac fullscreen close-to-tray behavior
2026-04-14 18:00:24 +08:00
bincxz 7f582bb355 tighten fullscreen tray close handling 2026-04-14 17:53:23 +08:00
bincxz 59f9a1443b fix mac fullscreen close-to-tray flow 2026-04-14 17:25:40 +08:00
陈大猫 bcb56d8229 Merge pull request #715 from binaricat/feat/paste-selection-shortcut
feat: add paste-selection terminal command (closes #637)
2026-04-14 16:30:12 +08:00
bincxz 1ca2cd8ec2 feat: add "paste selection" terminal command with bindable shortcut
Adds a new terminal action that pastes the terminal's current selection
at the cursor without going through the system clipboard — the equivalent
of X11 PRIMARY-selection paste. Default shortcut: ⌘ + Shift + X / Ctrl + Shift + X.

Also surfaces the action in the terminal right-click menu, disabled when
there is no selection. Does not change middle-click paste behavior.

Closes #637
2026-04-14 16:22:51 +08:00
陈大猫 717d8b718a Merge pull request #712 from tces1/dev
feat: scope AI draft and session resume state
2026-04-14 15:58:32 +08:00
Eric Chan 363f03a92d fix ai draft scope state updates 2026-04-14 14:57:45 +08:00
Eric ChanandCopilot c5d15a14c9 fix: avoid orphaned AI session storage churn
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-14 12:33:22 +08:00
Eric ChanandCopilot 75dc3dd72b feat: scope AI draft and session resume state
- persist drafts, panel views, and active sessions per terminal/workspace scope
- restore scoped AI session selection on reconnect and cold mount
- prefer unsent drafts over implicit history fallback
- avoid redundant active session map rewrites during scoped cleanup

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-14 11:55:34 +08:00
陈大猫 110e050d20 Merge pull request #708 from binaricat/feat/claude-agent-dynamic-model-probe
feat: dynamically probe claude-agent-acp for available models
2026-04-13 19:55:13 +08:00
bincxzandClaude Opus 4.6 ebcfe49ed6 fix: clear stale model cache when ACP probe returns empty
Address Codex review feedback on #708: the previous guard silently
returned on an empty-but-ok probe response, which left any previously
cached runtimeAgentModelPresets[currentAgentId] in place. That kept
Claude/Copilot pickers showing stale model IDs (and skipped currentModelId
reconciliation) instead of falling back to the hardcoded presets when the
backend no longer advertised a catalog.

Now we explicitly drop the cache entry so the agentModelPresets memo falls
through to getAgentModelPresets(...) via the `?? ` branch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:46:39 +08:00
bincxzandClaude Opus 4.6 bc8ac08b9a feat: probe claude-agent-acp for available models instead of hardcoded presets
Claude agents now advertise their real model catalog via the ACP
initSession response, just like Copilot already does. Confirmed locally
that `claude-agent-acp` returns `models.availableModels` with full ids +
names + descriptions (default / sonnet / haiku on subscription; and would
return Bedrock/Vertex/custom-proxy ids when the user has configured those).

This closes the gap where the Claude picker was stuck on three hardcoded
entries from CLAUDE_MODEL_PRESETS regardless of what the underlying CLI
actually supports. If the probe fails or returns an empty list, we keep
the hardcoded presets as a fallback.

Codex keeps its existing path via `aiCodexGetIntegration` (reads
~/.codex/config.toml) — we deliberately do not probe codex-acp, since
probing would just return the stock OpenAI model list even when the
user has a custom model_provider set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:37:19 +08:00
陈大猫 309fbdbe7a Merge pull request #707 from binaricat/fix/claude-agent-independent-from-custom-provider
fix: decouple Claude agent auth from netcatty provider list
2026-04-13 19:28:24 +08:00
bincxzandClaude Opus 4.6 11f831d820 fix: decouple Claude agent auth from netcatty provider list
Apply the same fix as #706 to the Claude Code agent. The `claude` CLI has
its own auth surface (`claude auth login/logout/status`) that manages
subscription-based logins (Claude Max / Pro via claude.ai) alongside
ANTHROPIC_API_KEY / settings-based configs. Silently forwarding a
netcatty-configured provider's API key to claude-agent-acp overrides that
login — the user's subscription gets bypassed and charges go to their API
balance without their knowledge.

Claude's settings card never surfaced the `claude auth status` so this
regression was more hidden than the Codex one, but the underlying coupling
is the same class of bug.

Changes:
- Stop forwarding any providerId for managed ACP agents from the renderer;
  claude-agent-acp now resolves auth purely from its own CLI config / login
  state / shell env.
- Remove ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL injection at all three
  codex-acp / claude-acp spawn sites in aiBridge.
- Drop Claude from the authFingerprint computation (it no longer has any
  netcatty-side input to hash).
- Delete the now-unused `findManagedAgentProvider` helper and its
  ProviderConfig import from managedAgents.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:22:58 +08:00
陈大猫 806fb6cf29 Merge pull request #706 from binaricat/fix/issue-705-codex-independent-from-custom-provider
fix: decouple Codex agent auth from netcatty provider list (#705)
2026-04-13 19:14:08 +08:00
bincxzandClaude Opus 4.6 cc2702b825 fix: decouple Codex agent auth from netcatty provider list (#705)
Codex agent auth must be determined entirely by ~/.codex/auth.json or
~/.codex/config.toml. Before this change, if the user configured any
OpenAI-compatible API provider in netcatty settings (for Catty agent use),
useAIChatStreaming would silently hand that provider's apiKey to the Codex
agent too, causing aiBridge to spawn codex-acp with authMethodId
"codex-api-key" and completely override the user's ChatGPT login.

The regression was introduced in PR #702 (v1.0.89) when findManagedAgent
Provider started matching generic "custom" providers for Codex. Users who
logged into Codex via ChatGPT and also had a netcatty-configured custom
provider saw the UI flip to "API mode" on refresh and their ChatGPT
session get ignored.

Remove the codex branch from the agentProviderId resolver and from
findManagedAgentProvider itself. Also drop the now-meaningless
hasCompatibleProvider hint on the Codex settings card and its i18n copy.
Claude agent behavior is unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:08:18 +08:00
陈大猫 af2589e60b Merge pull request #704 from tces1/MoreSkills
feat: add Netcatty user skills scanning and chat selection flow
2026-04-13 13:33:12 +08:00
Eric Chan 971c8a4d8b fix: harden user skills prompt injection 2026-04-13 12:49:53 +08:00
Eric Chan 59364e0c75 fix: preserve user skill selections on refresh errors 2026-04-13 12:39:33 +08:00
Eric Chan ac83c4c27d fix: keep user skills state in sync 2026-04-13 11:15:32 +08:00
Eric Chan aa10f962ea fix: harden user skills scanning 2026-04-13 11:08:09 +08:00
Eric Chan 1f3e531d7b Fix AI skill selection handling 2026-04-13 11:03:43 +08:00
陈大猫 ca6ca3f477 Merge pull request #702 from binaricat/codex/issue-677-codex-provider-followup
[codex] finish Codex provider follow-up for #677
2026-04-13 02:34:25 +08:00
bincxzandClaude Opus 4.6 1c9c4fcec3 fix: address second-round review feedback
- Extract fail-loud check to shared getCodexCustomConfigPreflightError so
  the list-models handler (aiBridge.cjs:2149) enforces the same up-front
  error as the stream handler. Previously a user whose config.toml
  env_key was unexported would get the targeted message on chat send but
  a generic "Missing env var" from model-list probes (once the probe was
  rewired for Codex in a future change).

- Wire Settings "Refresh Status" to also invalidate the shell-env cache.
  New invalidateShellEnvCache() helper in shellUtils; aiCodexGetIntegration
  now accepts an optional { refreshShellEnv } flag; the button passes it
  so a user who just exported OPENROUTER_API_KEY in their rc file can
  click Refresh instead of having to restart netcatty.

- Declare authHash in CodexCustomProviderConfig (types.ts + global.d.ts)
  so renderer TS actually sees the field instead of needing a cast.

- DRY the 360 magic number in ChatInput: extract
  MODEL_PICKER_MAX_WIDTH, use it in both the className max-width and the
  left-clamp math so the two can't drift.

- Move codexCustomConfigResolved useState declaration next to its
  companion codexConfigModel, above the effect that invokes its setter,
  and drop the duplicate declaration further down. Pure code-organization
  cleanup but removes a use-before-declaration nit.

No functional changes beyond the fail-loud parity and the refresh-shell-env
path. ACP behavior when authMethodId is omitted still requires a
real-world OpenRouter config.toml validation, which the user is running.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:30:29 +08:00
bincxzandClaude Opus 4.6 8f68e24057 fix: address review feedback on config.toml detection flow
Round of fixes driven by two parallel reviewers:

- i18n placeholder mismatch (P0). Locale strings used ${envKey} (literal
  dollar-sign) but the replace call passed '{envKey}', so the warning
  displayed a raw "${envKey}" instead of the real env var name. Align on
  the codebase-standard {envKey} form.

- Fingerprint now folds the hash of the actual auth material (P1).
  readCodexCustomProviderConfig computes a sha256 over the hardcoded
  api_key or the resolved env_key value and returns authHash. The ACP
  provider-reuse fingerprint includes it, so rotating the key in
  ~/.zshrc + restarting netcatty (which refreshes shellEnv) now
  invalidates the cached provider instance instead of keeping the stale
  key alive. Raw value never crosses the IPC boundary — we only send
  the hex digest.

- Fail loud when config.toml's env_key isn't exported (P1). Previously
  we'd sail into spawn and let codex-acp fail mid-request with a cryptic
  "Missing environment variable". Now the stream handler rejects up
  front with a targeted error naming the missing variable and pointing
  at ~/.zshrc.

- TOML parser: basic-string escape tracking (P1). findUnquotedHash now
  tracks an explicit `escaped` flag (and only honors escapes inside
  double-quoted strings, since literal single-quoted strings don't), so
  values like "C:\\path\\" close correctly instead of consuming the
  trailing `#` as part of the string.

- TOML parser: strip UTF-8 BOM (P2). Windows editors frequently prepend
  one and the first-key regex would silently fail to match, dropping
  everything before the first section header.

- Picker correctness when config.toml lacks a `model` field (P1).
  Instead of silently falling back to CODEX_MODEL_PRESETS (stock
  OpenAI IDs the user's custom endpoint can't serve), show an empty
  list so the picker disables. Track codexCustomConfigResolved so we
  distinguish "still loading" from "not a custom-config session" and
  only clear the preset list once the integration probe confirmed
  connected_custom_config.

- Logout handler isConnected also considers connected_custom_config
  (P2 consistency), matching get-integration.

- Model picker popover clamps its left position so max-w-[360px] can't
  push it past the right edge of a narrow AI side panel (P2).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:21:36 +08:00
bincxzandClaude Opus 4.6 2374f67ffc fix: skip ChatGPT auth validation when config.toml provides custom provider
On stream start, aiBridge ran validateCodexChatGptAuth() for any Codex
request without a netcatty-managed API key. That helper spawns a fresh
codex-acp with authMethodId:"chatgpt" and expects the ChatGPT auth.json
to be valid — which it never is for users who only have a custom
model_provider set up in ~/.codex/config.toml. The validation failed,
the main window got "Codex ChatGPT login is stale or invalid. Reconnect
Codex in Settings" over the error channel, and the UI flipped to the
login prompt — exactly the flow the config.toml path is meant to skip.

Move readCodexCustomProviderConfig up so we compute it before the
validation gate, and only run the ChatGPT validation when there's
neither a netcatty-managed API key nor a detected config.toml custom
provider. The rest of the spawn path already omits authMethodId for
the custom-config case, so codex-acp connects directly with the shell
env and config.toml.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:13:21 +08:00
bincxzandClaude Opus 4.6 fea8e8b305 fix: stop probing codex-acp for models; show config.toml model when custom
Two issues the user flagged with the previous round:

1. Probing codex-acp for available models returned the stock ChatGPT
   catalog (GPT 5.4, Codex 5.x, o3, o4-mini) regardless of the active
   provider. For a user with a custom model_provider in
   ~/.codex/config.toml (OpenRouter + Qwen), those IDs are meaningless
   on their endpoint. Roll back the managed-Codex probe hook and go
   back to static CODEX_MODEL_PRESETS for the stock / ChatGPT path.

2. The fixed w-[300px] popover left empty space on the right whenever
   the longest row was narrower than 300px.

Instead of the probe, teach readCodexCustomProviderConfig to also
return the top-level `model` from config.toml and expose it on the
integration response. In AIChatSidePanel, call aiCodexGetIntegration
when Codex is the active agent and, if customConfig.model is present,
override agentModelPresets with a single-entry list pinned to that
model. Otherwise fall back to the static presets as before — so
ChatGPT users see GPT 5.x / Codex 5.x etc. exactly like before, while
custom-config users see just the model their provider is actually
pinned to.

Popover switches from fixed width to `w-max min-w-[160px] max-w-[360px]`
so it hugs content (great for short single-model lists) while still
capping very long rows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:10:35 +08:00
bincxzandClaude Opus 4.6 79a7e460be fix: parse model ids that contain '/' correctly in ChatInput
The picker label was being derived by splitting selectedModelId on the
first '/'. That works for Codex's ChatGPT-preset format
("gpt-5.4/high" → model "gpt-5.4" + thinking level "high"), but breaks
for OpenRouter-style ids from config.toml ("qwen/qwen3.6-plus"):
selectedBaseModelId became "qwen", which doesn't match any preset, so
selectedPreset fell back to undefined and the chip displayed the
unrelated app-level modelName (e.g. "gemini-3-flash-preview") instead
of the actually selected Codex model.

Replace the naive split with a two-step lookup: first try a direct id
match; only if that fails, look for a preset whose declared
thinkingLevels make "${preset.id}/${level}" equal to selectedModelId,
and derive the thinking segment from that. Model ids that happen to
contain '/' now round-trip correctly through the picker.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:07:05 +08:00
bincxzandClaude Opus 4.6 f48db8ee4e fix: drop description from model picker to keep it compact
codex-acp's provider descriptions can be paragraphs ("Latest frontier
model with improvements across a wide range of capabilities..."), which
made each row of the picker feel bloated. The model id and (thinking
sub-menu's) thinking level already convey the relevant distinction —
drop the description render entirely. Keeps the dropdown tight regardless
of how verbose the upstream model catalog is.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:03:57 +08:00
bincxzandClaude Opus 4.6 ba2a0389fa fix: stack model picker description below name (vertical layout)
Horizontal layout + truncate clipped too much of codex-acp's longer
descriptions ("Latest frontier model with improvements across a..." →
"Latest frontier model w..."). Reorganize each option as
checkmark | name-on-top, wrapped description below | chevron, so the
full description is readable across two lines without pushing the
popover width out. Fix popover to w-[300px] for a consistent column
width. Checkmark and chevron anchor to the first text line (self-start
with small top offset) so they stay visually aligned with the name
when the description wraps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:03:24 +08:00
bincxzandClaude Opus 4.6 6309a49c37 fix: cap model picker width and truncate long descriptions
With dynamic models now pulled from codex-acp, preset descriptions can be
arbitrarily long ("Latest frontier model with improvements across a..."
from OpenAI's public model list). The popover had whitespace-nowrap on
each option and no max-w on the container, so long descriptions pushed
the dropdown off-screen.

Cap the popover at max-w-[360px], add min-w-0 + truncate to the name
span so flex children can actually shrink, and cap the description span
at max-w-[160px] with truncate so it ellipses rather than expanding the
row. ChevronRight gets shrink-0 so it can't be pushed out of view.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:02:02 +08:00
bincxzandClaude Opus 4.6 b1291d3ee2 fix: probe codex-acp for available models instead of using hardcoded preset
AIChatSidePanel gates dynamic model probing behind isCopilotExternalAgent,
so Codex always fell back to CODEX_MODEL_PRESETS — a hardcoded list of
OpenAI-specific IDs (GPT 5.4, Codex 5.x, o3, o4-mini). That's only correct
for the stock ChatGPT/OpenAI path. When the user has a custom
model_provider in ~/.codex/config.toml (OpenRouter, local inference, etc.),
none of those IDs exist on their endpoint and the model picker is useless.

Extend the condition to also trigger the aiAcpListModels probe for the
Codex managed agent (detected via matchesManagedAgentConfig). The probe
launches codex-acp the same way a real session does, so it now also goes
through getCodexAuthOverride and respects the user's config.toml — and
whatever availableModels codex-acp returns (typically at least the
`model` field from config.toml) shows up in the picker. Claude keeps its
curated presets to avoid regressing that path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:00:04 +08:00
bincxzandClaude Opus 4.6 18c001e9c5 fix: show custom config even when env_key is not exported yet
The first pass required both a custom model_provider in ~/.codex/config.toml
AND the referenced env_key to already be present in the shell environment.
If a user had the config file set up but hadn't (yet) exported the key in
their shell, detection returned null and the UI fell back to "Not
connected" + "Connect ChatGPT" — which is the exact flow they were trying
to avoid.

The config.toml is a strong enough signal of intent on its own. Keep the
integration in the connected_custom_config state regardless of env_key
availability, but expose envKeyPresent on the response so the UI can
explicitly warn "Warning: $MY_KEY is not set in your shell — export it".
Status label and color also flip to amber ("Custom config detected — env
var missing") so the state is easy to spot without dropping back to the
login prompt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:53:33 +08:00
bincxzandClaude Opus 4.6 c2c6b265d4 feat: detect user's ~/.codex/config.toml custom provider as ready state
Users who hand-configure ~/.codex/config.toml with a custom model_provider
and matching [model_providers.<name>] entry are fully functional from the
Codex CLI, but netcatty only looked at codex login status — which reports
on ~/.codex/auth.json alone — and would therefore push them into the
ChatGPT login flow even though the CLI works for them.

Add a minimal TOML parser for the narrow subset we need (top-level keys
plus [model_providers.<name>] string tables), and readCodexCustomProvider
Config() to detect a usable custom-provider setup: an active model_provider
that isn't the built-in openai preset, pointing at a provider entry whose
env_key is set in the shell env (or api_key is hardcoded).

Surface this as a new integration state "connected_custom_config", add a
customConfig summary on the IPC response, and tweak the Codex settings
card so it shows the custom-provider name, hides the Connect ChatGPT
button, and drops the stale "OpenAI-compatible provider" hint when this
path is active.

At Codex-ACP spawn time, introduce getCodexAuthOverride() so we only pass
authMethodId: "chatgpt" when we truly have no other option. When a
netcatty-managed API key is present we still use "codex-api-key"; when the
user has a custom config we omit authMethodId entirely so codex-acp
resolves auth from the shell env / config.toml itself. Fold the detected
custom config (provider name, base url, env key presence) into the
provider reuse fingerprint so edits to config.toml invalidate cached ACP
instances.

Fixes the Codex half of #677 for users who skip Settings → AI providers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:49:23 +08:00
bincxz 1e50b66407 fix: finish Codex provider follow-up for #677 2026-04-13 01:21:05 +08:00
陈大猫 2fb2155d79 Merge pull request #701 from binaricat/feat/issue-695-preserve-buffer-on-reconnect
feat: preserve terminal buffer across reconnect (#695)
2026-04-13 01:12:01 +08:00
bincxzandClaude Opus 4.6 3429c498f9 fix: cancel pending retry when session is closed or cancelled
Per Codex P1 on #701: the nested term.write callbacks in handleRetry
kept a captured reference to startNewSession. If the user hit Cancel or
closed the tab while those writes were still queued, cleanupSession ran
first but the callback could still fire afterwards — opening a backend
session with no owning UI (a ghost connection that nothing would tear
down).

Introduce retryTokenRef. handleRetry stamps a fresh Symbol, captures it,
and the chained callbacks verify the token (plus termRef identity) is
still current before proceeding. Invalidate the token from every path
that ends the retry intent: handleCancelConnect, handleCloseDisconnected
Session, teardown. A subsequent handleRetry naturally invalidates the
prior one by overwriting the ref, so rapid double-clicks are also safe.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:06:08 +08:00
bincxzandClaude Opus 4.6 dc7b14e323 fix: delay new session start until reset sequence has flushed
Per Codex P1 on #701: term.write is asynchronous, but handleRetry was
calling sessionStarters.start* synchronously right after scheduling
the soft-reset write. On fast reconnect paths (local and serial
especially, where the backend has no network round-trip), the new
session's first output bytes can reach xterm before the \x1b[!p...\x1b[H
reset has been applied. That means the reset/home runs mid-stream of
the first prompt, repositioning the cursor or flipping modes partway
through the shell's init and producing intermittent corrupted first
screens.

Extract the protocol dispatch into startNewSession and pass it as the
callback of the second term.write, so the new session only starts
once every preparation byte (alt-screen exit, viewport preserve,
DECSTR, xterm mode disables, cursor home) has actually been applied
to the terminal state. State updates that only drive the UI overlay
(status, progress logs) stay synchronous so users see "connecting..."
immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:48:22 +08:00
bincxzandClaude Opus 4.6 5d675b9cef fix: exit alt-screen before preserving viewport; use DECSTR for mode reset
Addresses two Codex findings on #701:

P1 (alt-screen ordering) — preserveTerminalViewportInScrollback only
operates on the normal buffer. If the user disconnected while inside
vim/less/top, the alt buffer was active, preserve was a no-op, and
when \x1b[?1049l later switched back to normal, the new session wrote
over still-visible pre-disconnect content instead of a cleared
viewport. Send \x1b[?1049l first, then wait for the write to flush
(via xterm's write callback) before calling preserve, so it always
runs on the normal buffer.

P2 (DECCKM / keypad / other VT220 modes) — the previous reset sequence
only disabled xterm extensions (mouse tracking, bracketed paste) and
touched SGR / cursor visibility. Full-screen apps commonly enable
DECCKM (application cursor keys) and keypad application mode; those
would leak into the new session and break arrow-key history
navigation and numeric keypad input. Use DECSTR (\x1b[!p) — soft
terminal reset — to reset DECCKM, keypad mode, SGR, insert/replace,
origin mode, and cursor visibility in one shot without clearing the
buffer. Keep explicit disables for the xterm-specific modes DECSTR
doesn't cover.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:42:36 +08:00
bincxzandClaude Opus 4.6 bf9f0e1fc2 fix: reset bracketed-paste mode on reconnect
Per Codex P2 on #701: handleRetry previously removed term.reset() but
the replacement escape sequence didn't disable bracketed paste (DECSET
2004). If the disconnected session had turned it on, term.modes
.bracketedPasteMode stayed true into the next connection; the paste
and snippet paths in createXTermRuntime keep wrapping input with
\x1b[200~ ... \x1b[201~ markers. When the new session hasn't itself
enabled bracketed paste, the shell echoes those markers as literal
text and mangles pastes.

Add \x1b[?2004l to the retry reset sequence so bracketed-paste state
starts off for the new session; the new shell's init will re-enable
it normally if it wants.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:34:55 +08:00
bincxzandClaude Opus 4.6 02967d9258 fix: do not clear terminal buffer at the top of session starters
Each session starter (startSSH / startTelnet / startMosh / startLocal)
called term.clear() as its first step. In xterm.js, clear() wipes the
entire buffer including scrollback. On initial connect this is harmless
(the buffer is already empty), but on retry it undoes the viewport
preservation that handleRetry just performed — so #695 remained broken
for any protocol that went through these starters (i.e. all of them).

The clear call served no purpose: xterm mounts with an empty buffer and
nothing writes to it before the starter runs. Remove the four
try/catch(term.clear()) blocks so handleRetry's
preserveTerminalViewportInScrollback actually sticks across reconnect
on SSH reboots, telnet drops, mosh/local respawns, etc.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:33:16 +08:00
bincxzandClaude Opus 4.6 343176120e feat: preserve terminal buffer across reconnect (#695)
On disconnect + retry, handleRetry previously called term.reset(), which
wipes both the visible screen and the scrollback history — so users lost
every bit of context from the previous session the moment they hit
"Start Over".

Push the current viewport into scrollback via the existing
preserveTerminalViewportInScrollback utility, then explicitly disable
the modes we actually care about not leaking across sessions (mouse
tracking 1000/1002/1003/1006, alt-screen 1049, SGR attributes, hidden
cursor) and home the cursor. This keeps the full scrollback intact so
users can scroll up to read everything from before the disconnect,
while still preventing stale escape-sequence state from bleeding into
the new session.

Fixes #695

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:25:56 +08:00
陈大猫 c0b4dace87 Merge pull request #700 from binaricat/feat/issue-690-sftp-tab-toggle
feat: add setting to hide the standalone SFTP top tab (#690)
2026-04-13 00:21:20 +08:00
bincxzandClaude Opus 4.6 b6e8d63fef fix: remove SFTP from QuickSwitcher when SFTP tab is hidden
Per Codex P2 review on #700: QuickSwitcher always listed an 'sftp' tab
item, but with showSftpTab off the App-level redirect bounces the user
straight back to Vault. That left a dead entry in quick-switch — selecting
it appeared broken.

Thread showSftpTab through QuickSwitcher and skip the SFTP item in both
the flat item list (used for keyboard selection indexing) and the
rendered built-in Tabs row when the top tab is hidden. Keeps every
SFTP navigation surface consistent with the visibility setting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:13:31 +08:00
bincxzandClaude Opus 4.6 60c07da140 fix: exclude hidden SFTP tab from keyboard tab cycling
Per Codex P1 review on #700: when showSftpTab is off, executeHotkeyAction
still built allTabs as ['vault', 'sftp', ...orderedTabs]. nextTab from
Vault would land on hidden 'sftp', the showSftpTab effect then redirected
back to 'vault', trapping tab cycling so Ctrl/Cmd+Tab could not advance
into terminal tabs. Number shortcuts (Ctrl+1..9) were also shifted, e.g.
tab 2 resolved to hidden SFTP and ping-ponged back to Vault.

Build allTabs conditionally so 'sftp' is only in the cycle when the tab
is visible. This keeps nextTab/prevTab/switchToTab consistent with what
the user sees in the top tab bar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:05:36 +08:00
bincxzandClaude Opus 4.6 f89afc0e05 feat: add setting to hide the standalone SFTP top tab
Adds a "Show SFTP tab" toggle in Settings → Appearance (under the
Vault section) that controls visibility of the standalone SFTP view
in the top tab bar. When disabled:

- The SFTP tab is removed from the top tab strip.
- The openSftp hotkey (Ctrl+Shift+O / ⌘⇧O) becomes a no-op.
- If the user is currently on the SFTP tab, the active tab auto-
  switches to Vaults.

The in-session SFTP side panel (opened from the terminal toolbar) is
unaffected — that is the surface users keep when they hide the
top-level tab.

Setting persists via localStorage, syncs across windows, and is
included in the cloud SyncPayload alongside the existing Vault
visibility toggles (showRecentHosts,
showOnlyUngroupedHostsInRoot). Default: on.

Addresses the first ask in #690.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:57:15 +08:00
陈大猫 ca0b1ed9ae Merge pull request #699 from binaricat/fix/issue-694-ctrl-f-hardcoded
fix: remove hardcoded Ctrl+F handler bypassing configurable shortcuts
2026-04-12 23:46:09 +08:00
bincxzandClaude Opus 4.6 555438a02a fix: set Ctrl+F as the default PC shortcut for terminal search
Previously the documented default was Ctrl+Shift+F on PC, but a
hardcoded handler always captured plain Ctrl+F regardless of the
configured binding — so the effective default users experienced was
Ctrl+F. Now that the hardcoded handler is removed, align the declared
default with that historical behavior so existing users don't lose the
shortcut they were used to. Users who need plain Ctrl+F for the shell
(e.g. zsh forward-char) can remap or disable it in Settings → Shortcuts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:44:05 +08:00
bincxzandClaude Opus 4.6 97e78624bb fix: remove hardcoded Ctrl+F handler that bypassed configurable shortcuts
The xterm custom key event handler intercepted plain Ctrl+F / Cmd+F to
open terminal search, ignoring the user's configured keybinding scheme.
This conflicted with zsh's forward-char (Ctrl+F) and gave users no way
to disable it via the Shortcuts settings tab.

The configurable keybinding system below already routes the
searchTerminal action via checkAppShortcut, with defaults of
Ctrl+Shift+F (PC) and Cmd+F (Mac). Dropping the hardcoded branch
lets the user's settings take effect. Also remove the stale
"(Ctrl+F)" label from the toolbar tooltip since the shortcut is
configurable and the default on PC is Ctrl+Shift+F.

Fixes #694

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:42:27 +08:00
陈大猫 eab1e8db67 Merge pull request #698 from binaricat/codex/issue-638-root-ungrouped-hosts
[codex] Add vault root ungrouped host filter toggle
2026-04-12 23:36:47 +08:00
bincxz 8e6392e503 persist vault root filter toggles immediately 2026-04-12 23:30:02 +08:00
bincxz 8b99f2411f fix vault root host filter sync and empty states 2026-04-12 23:27:36 +08:00
bincxz 98905b9c81 fix vault hosts section initialization order 2026-04-12 23:14:59 +08:00
bincxz b7e1df9916 hide empty root hosts section 2026-04-12 23:13:44 +08:00
bincxz 3089cab88d add vault root ungrouped host toggle 2026-04-12 23:09:03 +08:00
Eric Chan 50b20eaa05 chore: triple-pass review and hardening of AI Skills logic 2026-04-12 17:25:45 +08:00
Eric Chan 3ab42bf588 chore: final hardening of User Skills logic and async IO 2026-04-12 17:14:49 +08:00
Eric Chan 84423a0096 fix: resolve TypeScript errors and optimize User Skills with async IO 2026-04-12 17:11:50 +08:00
陈大猫 98dda8a51b Merge pull request #693 from binaricat/fix/claude-acp-custom-model-provider
fix: Claude ACP agent now uses custom API key and base URL
2026-04-12 00:51:25 +08:00
bincxzandClaude Opus 4.6 42baa5cb78 fix: include provider base URL in ACP reuse fingerprint for Claude
The ACP provider reuse gate only computed authFingerprint for Codex,
leaving it null for Claude. Changing the configured provider or base
URL mid-session would keep reusing the stale provider instance.

Now Claude computes an authFingerprint from apiKey + baseURL, so
changing either value invalidates the cached provider and forces
recreation with the new credentials/endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:37:34 +08:00
bincxzandClaude Opus 4.6 11fd7fcd71 fix: prefer anthropic provider over generic custom for Claude ACP
A generic custom provider (OpenAI-compatible) could be selected for
Claude, passing wrong credentials. Now we prefer an explicit anthropic
provider and only fall back to a custom provider when it has a baseURL
configured (indicating intentional Anthropic-compatible gateway use).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:31:01 +08:00
bincxzandClaude Opus 4.6 d6950948fa fix: also inject OPENAI_BASE_URL for Codex ACP agent
Codex reads OPENAI_BASE_URL to connect to custom API endpoints.
Without this, users with a custom baseURL on their OpenAI provider
config would still hit the default api.openai.com endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:29:14 +08:00
bincxzandClaude Opus 4.6 9693793bba fix: allow Claude ACP agent to use custom API key and base URL
The renderer only resolved OpenAI providers (for Codex) when passing
provider IDs to the main process. Claude agent was never matched, so
no API key was injected. Additionally, the main process only injected
CODEX_API_KEY — never ANTHROPIC_API_KEY or ANTHROPIC_BASE_URL.

Changes:
- Renderer now resolves anthropic/custom provider for Claude agent,
  openai provider for Codex agent (via matchesManagedAgentConfig)
- Main process injects ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL into
  claude-agent-acp env when a provider is configured, across all three
  ACP provider creation paths (list-models, stream, fallback)

This enables users who configure an Anthropic provider with a custom
base URL (e.g. CC Switch proxy) to use Claude Code without being
redirected to the official OAuth flow.

Closes #677

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:26:24 +08:00
陈大猫 a72f012851 Merge pull request #692 from binaricat/fix/scrollback-zero-wheel-scroll
fix: mouse wheel scrolling broken when scrollback set to 0
2026-04-12 00:04:44 +08:00
bincxzandClaude Opus 4.6 1368709f4e fix: map scrollback=0 to large value so mouse wheel scrolling works
xterm.js treats scrollback=0 as "no scrollback buffer", which makes
hasScrollback return false and converts wheel events into arrow-key
sequences. The UI uses 0 to mean "no limit", so map it to 999999
before passing to xterm.js.

Closes #689

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:00:18 +08:00
陈大猫 d1408b8050 Merge pull request #688 from binaricat/feat/ui-matched-terminal-themes
feat: add Follow Application Theme for terminal + 14 UI-matched themes
2026-04-10 22:01:59 +08:00
bincxz 9ca68561b3 fix: clean up stale inherited theme state 2026-04-10 21:49:01 +08:00
bincxz c3c579b8a0 fix: close remaining theme sync gaps 2026-04-10 21:43:15 +08:00
bincxz 2784ecdf28 fix: sync inherited themes in editors 2026-04-10 21:30:25 +08:00
bincxz 75bbd1f300 fix: preserve theme inheritance and modal rollback 2026-04-10 21:21:51 +08:00
bincxz 4ee4ef7b60 fix: polish follow-app terminal theme UX 2026-04-10 21:03:14 +08:00
Eric Chan 58bc08a045 Add user skills injection and picker UI 2026-04-10 20:53:39 +08:00
bincxzandClaude Opus 4.6 32f4aadab2 fix: follow-app-theme now overrides per-host theme settings
When followAppTerminalTheme is on, all terminals should use the
UI-matched theme — but three resolution points were still checking
per-host overrides:

1. App.tsx resolveTheme() in the activeTerminalTheme computation
2. Terminal.tsx effectiveTheme computation
3. TerminalLayer.tsx focusedThemeId computation

Added followAppTerminalTheme prop flowing from App → TerminalLayer
→ Terminal. When the flag is true, per-host theme resolution is
bypassed so all terminals consistently match the app chrome.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:25:21 +08:00
bincxzandClaude Opus 4.6 fc32b44d8e fix: replace missing ToggleRow with SettingRow + Toggle
ToggleRow is a locally-defined component in HostDetailsPanel and
GroupDetailsPanel — it is NOT exported or available in the terminal
settings tab. Using it caused a white-screen crash. Replaced with
the existing SettingRow + Toggle pattern that's already used
throughout the terminal settings tab.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:19:54 +08:00
bincxzandClaude Opus 4.6 76cd1f2883 fix: remove unused variables flagged by eslint
- App.tsx: remove unused followAppTerminalTheme/setFollowAppTerminalTheme
  from destructuring (they flow through settings object, not App props)
- createTerminalSessionStarters.ts: remove dead usedKey/usedPassword
  assignments left over from PR #680 which changed runDistroDetection
  to use the existing session's connection instead of auth credentials

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:17:15 +08:00
bincxzandClaude Opus 4.6 76d37d982a fix: upgrade-safe default + cross-window broadcast for follow-theme
P1: Follow mode defaulted ON when the storage key was missing, which
is true for ALL existing users after upgrade (not just fresh
installs). Now checks whether a terminal theme was already stored —
if so, this is an upgrade and we default OFF to preserve the user's
manual choice. Only genuinely fresh installs (no terminal theme in
storage) default to ON.

P2: The follow-theme persist effect now calls notifySettingsChanged
and a matching branch in the cross-window storage event handler
syncs the toggle state across windows, matching the pattern used by
all other terminal settings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:14:05 +08:00
bincxzandClaude Opus 4.6 6d2f3f28c0 feat: add "Follow Application Theme" for terminal + 14 UI-matched terminal themes (#675)
When enabled (default for new users), the terminal theme automatically
switches to match the active app UI theme — so the terminal background
blends seamlessly with the app chrome, regardless of which UI theme
preset the user picks (Snow, Midnight, Forest, etc.).

## New terminal themes (14)

Each built-in UI theme preset now has a corresponding terminal theme
with an exactly matching background color:

Light: ui-snow, ui-pure-white, ui-ivory, ui-mist, ui-mint, ui-sand,
ui-lavender — ANSI palette based on netcatty-light with per-theme
cursor colors that complement the UI accent.

Dark: ui-pure-black, ui-midnight, ui-deep-blue, ui-vscode,
ui-graphite, ui-obsidian, ui-forest — ANSI palette based on
netcatty-dark with accent-matched cursors and selections.

## "Follow Application Theme" setting

- New toggle in Settings → Terminal → Theme section
- Default ON for new users, persisted in localStorage
- When ON: terminal theme auto-derived from the active UI theme via
  a mapping table in domain/terminalAppearance.ts
- When OFF: manual theme selector shown (existing behavior)
- Switching the app between light/dark (or changing the UI theme
  preset) instantly updates the terminal theme

## Files changed (9)

- terminalThemes.ts: +14 theme definitions
- terminalAppearance.ts: UI→terminal mapping table +
  getTerminalThemeForUiTheme()
- useSettingsState.ts: followAppTerminalTheme state + persist +
  currentTerminalTheme derivation
- storageKeys.ts: new storage key
- SettingsTerminalTab.tsx: toggle UI + conditional theme selector
- SettingsPage.tsx: pass new props
- App.tsx: destructure new state
- en.ts + zh-CN.ts: 2 new i18n keys

Closes #675

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:06:13 +08:00
陈大猫andClaude Opus 4.6 a1c9f5fbd0 fix: normalize CRLF to LF when saving text files via SFTP (#681) (#687)
On Windows, the built-in text editor produces CRLF line endings.
When saved to a Linux host via SFTP, the \r characters break shell
scripts ("command not found", syntax errors) because Linux treats
\r as part of the command.

Normalize \r\n → \n in writeSftp() before writing. LF is universally
supported — even Windows 10+ notepad handles LF-only files — so this
is safe for all target platforms.

Closes #681

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 18:51:01 +08:00
陈大猫andClaude Opus 4.6 ce5cb2afec feat: add Windows portable build target (#668) (#686)
Add a `portable` target alongside the existing `nsis` installer for
Windows builds. The portable version produces a single .exe that
runs without installation — just download and double-click.

The artifact is named with a `-portable-` infix to distinguish it
from the installer in the release assets.

Closes #668

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 18:49:32 +08:00
Eric Chan c771979178 Add Skills + CLI mode for external agents (#599)
* Add Skills + CLI external agent workflow

* feat: add Skills + CLI transport for ACP agents

* chore: remove branch-local compatibility shims
2026-04-10 18:41:53 +08:00
陈大猫andClaude Opus 4.6 58c651500e feat: add Gist revision history UI for vault restore (#685)
* feat: add Gist revision history UI for vault restore (#679)

Adds a "History" button on the GitHub Gist provider card in
Settings → Sync & Cloud. Clicking it opens a modal that lists all
Gist revisions (newest first) and lets the user preview and restore
any historical version with one click.

## How it works

1. The GitHub API already returns a `history` array when fetching a
   Gist (`GET /gists/{id}`). The existing `getGistHistory()` reads
   this. A new `downloadGistRevision(sha)` function fetches a
   specific revision via `GET /gists/{id}/{sha}`.

2. CloudSyncManager exposes `getGistRevisionHistory()` (metadata
   only, no decryption) and `downloadGistRevision(sha)` (decrypt
   + return payload and preview counts).

3. useCloudSync threads both methods through to the UI.

4. CloudSyncSettings renders a three-state modal:
   - **Loading**: spinner while fetching revision list
   - **Revision list**: clickable rows with SHA prefix + date,
     "Current" badge on the latest
   - **Preview**: after clicking a revision, shows entity counts
     (hosts, keys, snippets, identities) and a "Restore This
     Version" button

5. Decryption uses the current master password. If the revision
   was encrypted with a different password (user changed it since
   then), a clear error message is shown instead of a crash.

## Changes

- `GitHubAdapter.ts`: add `downloadGistRevision()` standalone
  function + `getHistory()` / `downloadRevision()` class methods
- `CloudSyncManager.ts`: add `getGistRevisionHistory()` and
  `downloadGistRevision(sha)` with decrypt + preview
- `useCloudSync.ts`: expose both methods
- `CloudSyncSettings.tsx`: add `extraActions` slot to ProviderCard,
  render "History" button on GitHub card, revision history modal
  with list → preview → restore flow
- `en.ts` + `zh-CN.ts`: 18 new i18n keys for the modal

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use getConnectedAdapter and lazy gist discovery for history APIs

P1: CloudSyncManager's history methods accessed this.adapters directly
instead of getConnectedAdapter(), which lazily initializes adapters.
After an app restart the adapter map is empty even though the provider
is persisted as connected, making history fail until another sync
path initializes it.

P2: GitHubAdapter.getHistory() and downloadRevision() bailed early
when gistId was missing, unlike download() which calls findSyncGist()
to lazily discover it. Users whose gist was created after initial
setup would see no revisions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-2 codex review on PR #685

P1: Renamed cloudSync.history.* keys to cloudSync.revisionHistory.*
to avoid duplicate key collision with the existing "Sync History"
section title.

P2: Added getGistRevisionHistory and downloadGistRevision to the
CloudSyncHook type interface so the hook contract matches reality.

P2: Simplified decrypt error handling — any error from the decrypt
path now shows the friendly "cannot decrypt" message rather than
relying on fragile substring matching.

P2: Clear historyRevisions on each handleOpenHistory call so stale
data doesn't linger under error banners on retry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: restore correct i18n key for Sync History section title

The sed rename pass accidentally changed the Sync History panel
heading (line 1290) from cloudSync.history.title to
cloudSync.revisionHistory.title. Restored the original key so the
two sections have distinct titles. Also removed unused err parameter
in the catch block.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:47:16 +08:00
陈大猫andClaude Opus 4.6 bcf653dd2e fix: prevent empty vault from overwriting cloud data on startup (#683)
* fix: prevent empty vault from overwriting cloud data on startup (#679)

Fixes a data-loss scenario where an empty local vault (caused by an
update, storage corruption, or import failure) silently overwrites
a non-empty cloud vault on startup via auto-sync.

The root cause is a startup timing race: the debounced auto-sync
effect (3s after data change) can fire before checkRemoteVersion
(1s delay + async download) completes its remote pull. When the
local vault is empty, this pushes an empty payload to the Gist,
permanently erasing the user's data.

Four complementary fixes:

A. Empty vault push guard (useAutoSync syncNow):
   Auto-sync refuses to push a payload where hosts, keys, snippets,
   and identities are ALL empty. Manual sync from Settings is still
   allowed for the rare case where the user intentionally emptied
   everything. Prevents the most dangerous path.

B. Skip redundant post-merge push (useAutoSync checkRemoteVersion):
   After applying a three-way merge result from the remote, set
   skipNextSyncRef so the data-change effect does not immediately
   re-upload the same payload. Removes one unnecessary API call per
   startup sync.

C. Gate auto-sync on remote check completion (useAutoSync effect):
   Added remoteCheckDoneRef — the debounced auto-sync effect will
   not fire until checkRemoteVersion has completed (success or
   failure). This closes the timing window entirely: an empty vault
   can no longer race ahead of the remote pull.

D. Empty-vault-vs-cloud confirmation dialog (App.tsx + useAutoSync):
   When checkRemoteVersion detects local is empty but cloud has
   data, it pauses and shows a root-level dialog with two options:
   - "Restore from Cloud" (recommended) — applies the remote payload
   - "Keep Empty" — starts fresh with an empty vault
   The dialog blocks the sync flow via a Promise that resolves when
   the user picks an option. This gives users explicit control over
   a situation that previously happened silently behind their backs.

Also adds en + zh-CN i18n strings for the new dialog and toast
messages.

Closes #679

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address codex review on PR #683

P1-1: Unified isPayloadEffectivelyEmpty helper covering all synced
entity arrays (hosts, keys, snippets, identities, customGroups,
snippetPackages, portForwardingRules, knownHosts, groupConfigs).
Replaces the three inline checks in syncNow and checkRemoteVersion
that only covered hosts/keys/snippets/identities.

P1-2: Replaced hand-rolled overlay div with the project's existing
Dialog/DialogContent/DialogHeader/DialogFooter components. This adds
role="dialog", aria-modal, focus trap, and ESC-key dismiss for free.
Used lucide-react AlertTriangle/Download/Trash2 icons instead of
inline SVGs.

P2-1: Guard against double-resolve in resolveEmptyVaultConflict by
nulling the ref immediately on first call.

P2-2: Replaced hardcoded "N hosts, N keys, N snippets" with an i18n
key using interpolation (cloudSummary) so the count text is properly
translated in zh-CN.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-2 codex review on PR #683

P1: isPayloadEffectivelyEmpty now also checks the settings object.
A vault with only settings (e.g. custom theme, font size) and zero
hosts/keys/snippets is no longer treated as empty.

P1: Dialog accessibility — use hideCloseButton to remove the non-
functional close button, onEscapeKeyDown + onOpenChange prevent
dismiss (the user MUST choose an option), and wrap the description
in DialogDescription so aria-describedby is properly linked.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use single-brace interpolation syntax for cloudSummary i18n key

The project's i18n system uses single-brace placeholders ({var}),
not double-brace ({{var}}). The double-brace syntax was rendering
as raw text instead of being interpolated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:20:27 +08:00
陈大猫andClaude Opus 4.6 0caf19af7e fix: pass legacyAlgorithms to port forwarding SSH connections (#682)
* fix: pass legacyAlgorithms to port forwarding SSH connections (#678)

Port forwarding connections always used modern-only algorithms because
the legacyAlgorithms host setting was never threaded through to the
port forwarding bridge. When the jump server or target host runs an
older SSH implementation (e.g. OpenSSH 7.4) that only supports legacy
key exchange algorithms like diffie-hellman-group14-sha1, the
handshake fails with "Connection lost before handshake".

The SSH terminal path already handles this correctly via
buildAlgorithms(options.legacyAlgorithms) — the port forwarding path
was simply missing the same plumbing.

Changes:
- sshBridge.cjs: export buildAlgorithms so portForwardingBridge can
  reuse it (avoids duplicating the algorithm list)
- portForwardingBridge.cjs: destructure legacyAlgorithms from the
  payload, pass it to connectOpts.algorithms via buildAlgorithms(),
  and thread it through to connectThroughChain for jump host
  connections
- portForwardingService.ts: include host.legacyAlgorithms in the
  startPortForward bridge call

Closes #678

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add legacyAlgorithms to PortForwardOptions type contract

Per Codex review: the new legacyAlgorithms field was being passed
in the startPortForward call but was not declared in the
PortForwardOptions interface in global.d.ts, causing a TS2353 type
error in strict type-checking environments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:31:52 +08:00
陈大猫andClaude Opus 4.6 e8b9122270 feat: auto-detect network devices from SSH banner and skip stats polling (#680)
* feat: auto-detect network devices from SSH banner and skip stats polling (#674)

Fixes rapid AAA session churn reported on Cisco/HPE/similar network
devices running Netcatty. The root cause was two separate polls that
both open fresh exec channels (each counted as its own AAA session on
many network devices):

- runDistroDetection() opens a brand new SSH connection every time a
  host connects to run `cat /etc/os-release || uname -a`
- useServerStats polls `conn.exec(statsCommand)` every 5 seconds

Both commands fail on non-POSIX CLIs, but the channels still hit AAA.

This change avoids both by reading the SSH server identification
string that ssh2 already captures during the handshake
(`conn._remoteVer`). No extra network round-trips, zero additional
AAA entries.

## Changes

**sshBridge.cjs**
- Store `conn._remoteVer` on the session object at connect time as
  `session.remoteSshVersion`
- New IPC handler `netcatty:ssh:remoteInfo` (`getSessionRemoteInfo`)
  returning the captured SSH server software string

**preload.cjs / global.d.ts / useTerminalBackend.ts**
- Thread `getSessionRemoteInfo(sessionId)` through to the renderer

**domain/host.ts**
- `NETWORK_DEVICE_OPTIONS` constant listing the vendor IDs we can
  recognize (cisco, juniper, huawei, hpe, mikrotik, fortinet,
  paloalto, zyxel)
- `detectVendorFromSshVersion()` — pure function that parses an SSH
  server software string and returns a vendor ID or ''. Pattern set
  is sourced from Nmap nmap-service-probes (authoritative), the
  ssh-audit software.py reference, and vendor docs; see code
  comments for the exact matches used.
- `classifyDistroId()` returns `linux-like | network-device | other`
  so features that require a POSIX shell can gate on the result.

**createTerminalSessionStarters.ts (runDistroDetection)**
- Before running the /etc/os-release probe, call
  `getSessionRemoteInfo` on the already-connected session and feed
  the banner into `detectVendorFromSshVersion`. If the vendor maps
  to a known network device, emit the vendor ID via the existing
  `onOsDetected` callback and skip the shell probe entirely. For
  unknown or generic OpenSSH/Dropbear banners the existing behavior
  is preserved.

**Terminal.tsx**
- `isSupportedOs` now derives from `classifyDistroId(effectiveDistro)`
  combined with `host.deviceType !== 'network'`, so neither explicit
  network-device hosts nor banner-detected ones trigger the stats
  polling loop.

**useServerStats.ts**
- Add a consecutive-failure counter. After 3 consecutive failed
  polls, stop the interval for this session (reset on disconnect /
  sessionId change / settings toggle). This is the fallback for
  hosts the banner classifier cannot identify (Juniper JUNOS,
  Cisco NX-OS, Arista EOS — all present as plain `OpenSSH_*` but
  do not support the POSIX stats pipeline).

**DistroAvatar.tsx / HostDetailsPanel.tsx**
- Add 8 network-device vendor icons (Cisco, Juniper, Huawei, HPE,
  MikroTik, Fortinet, Palo Alto, ZyXEL) alongside the existing
  Linux distro icons, with brand colors. Icons sourced from Simple
  Icons (CC0) where available; HPE and ZyXEL use simple
  abbreviation placeholders.
- Network device vendors are added to the manual distro override
  dropdown so users can pin an icon even if their device has an
  exotic banner we don't auto-detect.

**i18n**
- English + Chinese labels for the new vendor options in the
  Host Details distro selector.

Closes #674

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: gate network-device detection on raw host.distro, not manual icon override

Per Codex review on PR #680: the stats-polling gate was passing
`host` through getEffectiveHostDistro() before classifying, which
honors the manual distro override (`distroMode: 'manual'` +
`manualDistro`). That meant a user who previously pinned an
"ubuntu" icon on a host that later gets banner-detected as Cisco
would still be classified as linux-like and keep generating the
AAA session flood #674 is meant to eliminate.

Separate display from gating:
- Display (DistroAvatar, host cards): keeps using
  getEffectiveHostDistro so users can cosmetically override the
  icon.
- Gating (useServerStats via Terminal.tsx isSupportedOs): reads
  host.distro directly — the value populated by banner detection —
  alongside the explicit host.deviceType flag. Manual icon choice
  can no longer re-enable polling on a detected network device.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: guard distro detection against stale session timers

Per Codex review on PR #680: runDistroDetection is scheduled on a
600ms setTimeout after connection and also makes async calls of its
own. A quick disconnect + reconnect on the same session slot could
fire the old timer against the new session, reading host B's SSH
banner via getSessionRemoteInfo and writing host B's vendor onto
host A's distro field — wrong icon and wrong stats-polling state.

Follow the same pattern already used for the startup-command timer
in this file (scheduledSessionId captured at schedule time, checked
inside the timer). Capture `id` at schedule time, bail out if
ctx.sessionRef.current no longer matches, and re-check after every
async await inside runDistroDetection so that a reconnect during
the banner fetch or the os-release probe also bails cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address local codex review on PR #680

Addresses three issues found in a local Codex review pass after the
remote reviewer gate was flaky:

## P0 — session tokens instead of sessionId for stale-timer guard

The previous guard captured `id` returned from startSSHSession and
compared against `ctx.sessionRef.current` inside the setTimeout and
the async runDistroDetection. But the renderer passes
`sessionId: ctx.sessionId` into startSSHSession (see
createTerminalSessionStarters.ts:543), meaning a tab reuses the
SAME sessionId across disconnect+reconnect. The comparison
`T1 === T1` always passed, so the guard was a no-op.

Replaced with a module-level Map<sessionId, object> that stores the
live "connection token" for each sessionId slot. Each call to
startSSH mints a fresh `{}` token and overwrites the entry. Timers
and async continuations compare their captured token against the
current map value by reference — a reconnect replaces the map entry
with a new token, so stale callbacks bail cleanly.

## P1 — run os-release probe on the existing SSH connection

The fallback /etc/os-release probe used `execCommand` which creates
a brand-new SSHClient() on every call. On network devices that
present as plain `OpenSSH_*` and fall through to this step
(JUNOS, NX-OS, EOS) it added one extra full-auth AAA session log
entry per connect, in addition to the failing stats polls.

Added `getSessionDistroInfo(sessionId)` as a new IPC handler that
runs the same probe via `session.conn.exec()` — an exec channel on
the already-open connection, no new handshake. Plumbed through
preload.cjs, global.d.ts, and useTerminalBackend.ts.
runDistroDetection uses this instead of execCommand in the fallback
path, also removing the unused auth-credentials argument (we are no
longer opening a new connection, so no credentials are needed).

## P2.1 — don't re-arm timers after giving up

After the consecutive-failure counter trips, useServerStats cleared
the interval but a subsequent effect rerun (visibility change,
settings tweak, etc.) would schedule a fresh `setTimeout` and
`setInterval` that would just call the early-return path forever.

The scheduling block now checks `givenUpRef.current` before arming
either timer. The flag is still cleared on the normal disconnect /
sessionId-change reset path so a reconnect gets a fresh attempt.

## P2.2 — drop the ambiguous IPSSH-* → cisco mapping

Nmap's `match ssh m|^SSH-([\d.]+)-IPSSH-` line is labelled as
`Cisco/3com IPSSHd` — it cannot identify a specific vendor from the
banner alone. Mapping it to `cisco` would risk showing the wrong
vendor icon on a 3Com device. Removed the rule entirely and
documented why with a code comment; users with such devices can
still use the Host Details manual distro override.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address remaining gaps from local codex follow-up review

P0 gap — delete connection token on session exit. Previously the map
entry lingered after disconnect, so a very late-firing timer could
still pass the isConnectionTokenCurrent check even though the session
no longer existed. Functionally harmless (the IPC calls would fail)
but semantically wrong. Now connectionTokensBySessionId.delete() is
called in the onSessionExit handler.

P1 new — exec channel leak on timeout in getSessionDistroInfo. The
timeout branch resolved the promise but didn't close the stream, so
a hanging remote command would leave the exec channel open until the
SSH connection itself dropped. Added a settled guard (resolve-once)
and stream.close() on timeout.

P2.1 gap — givenUpRef not reset on sessionId change. The failure
counter reset only happened in the !isConnected branch of the main
effect, so a sessionId swap while still connected (rare, but
possible if the tab reconnects without toggling connected state)
would permanently suppress polling. Added a small dedicated effect
that resets both counters when sessionId changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:02:24 +08:00
陈大猫andClaude Opus 4.6 60071424d0 fix: prevent crash when clicking external links with no default browser (#676)
* fix: prevent crash when clicking external links with no default browser (#663)

On systems like Tiny11 where no default browser is associated with
http/https URLs, shell.openExternal() rejects with Windows error 0x483
("No application is associated..."). The main process treated that
rejection as an unhandledRejection, which the global handler re-throws
as fatal, crashing the entire app.

Root cause: windowManager.cjs used `void shell?.openExternal?.(url)`
inside a try/catch, assuming the try would cover the call. `void` only
discards the returned Promise — it does not catch async rejections,
so when openExternal rejected, the error escaped as a floating
unhandledRejection.

The IPC handler in main.cjs (`netcatty:openExternal`) also awaited
shell.openExternal() without any try/catch. Electron's ipcMain.handle
forwards rejections to the renderer over IPC, but the renderer-side
fallback called `window.open()`, which re-entered the same buggy
windowManager path — and that is where the process actually died.

Changes:
- windowManager.cjs: attach an explicit `.catch` on the openExternal
  Promise in both createExternalOnlyWindowOpenHandler and
  createAppWindowOpenHandler so rejections cannot propagate.
- main.cjs: wrap the IPC handler in try/catch and return a structured
  { success, error } result instead of throwing. This lets the
  renderer render an informative message.
- global.d.ts: update the openExternal return type to match.
- useApplicationBackend.ts: read the structured result and throw on
  failure so callers can react; drop the now-redundant window.open()
  fallback for the Electron branch (kept only for non-Electron envs).
- SettingsApplicationTab.tsx: show a friendly toast ("No default
  browser configured — please set one in system settings") when
  openExternal fails, instead of the previous silent failure.
- i18n: add en + zh-CN strings for the toast.

Closes #663

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: fall back to in-app browser window when system has no default browser

Instead of showing a toast when shell.openExternal() fails (e.g. Tiny11
with no default browser), open the URL in a minimal in-app BrowserWindow
so users can still read the linked page.

windowManager.cjs now exposes:
- openFallbackBrowser(url, opts): creates a stripped-down BrowserWindow
  that loads the URL. No preload script (remote content must never
  touch contextBridge), contextIsolation/nodeIntegration/sandbox all
  set to safe defaults, and an isolated persist:netcatty-fallback-browser
  session so cookies and storage do not leak into the main app.
  Basic Alt+Left / Alt+Right / Ctrl-or-Cmd+R shortcuts for navigation
  and reload.
- tryOpenExternalWithFallback(shell, url, opts): tries
  shell.openExternal first; on rejection, falls back to
  openFallbackBrowser. Returns { success, fallback?: "in-app-browser" }.

All three external-URL call paths now route through this helper:
- main.cjs netcatty:openExternal IPC handler
- createExternalOnlyWindowOpenHandler (popup blocker for child windows)
- createAppWindowOpenHandler (main/settings window window-open handler)

The renderer-side toast is retained as a last-resort for the rare case
that both system and in-app browsers fail (e.g. BrowserWindow creation
error). Copy updated to reflect the new behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: preserve rejection semantics for failed external opens

Per Codex review on PR #676: returning { success, error } from
bridge.openExternal changed the contract from "reject on failure" to
"resolve with a failure object on failure", which silently broke
callers that rely on rejection to abort flows.

useCloudSync's OAuth path is the clearest example: it wraps
bridge.openExternal in a try/catch and rejects browserPromise inside
the catch. With the resolved-failure contract, that catch never fires,
so Promise.race([callbackPromise, browserPromise]) can hang
indefinitely when no browser is available.

Revert the contract:
- tryOpenExternalWithFallback resolves void on success (system browser
  or in-app fallback) and throws on total failure
- main.cjs IPC handler awaits and lets rejections propagate
- global.d.ts openExternal is Promise<void> again
- useApplicationBackend just awaits — rejections propagate naturally
- SettingsApplicationTab's existing try/catch + toast continues to
  work as before

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: propagate fallback browser loadURL failures

Per Codex P2: openFallbackBrowser swallowed loadURL rejections by
attaching a .catch that only logged, so any caller using
tryOpenExternalWithFallback as a success signal saw an opened window
as success even when the page failed to load. OAuth flows would then
wait for the downstream callback timeout instead of canceling early
on malformed or unreachable URLs.

openFallbackBrowser now returns { window, loaded } where `loaded` is
the raw loadURL Promise, and tryOpenExternalWithFallback awaits it in
the fallback path. On initial load failure, the broken window is
closed and the original shell.openExternal error is re-thrown.

The internal popup handler inside the fallback window keeps its
fire-and-forget behavior (it must return synchronously) but now
explicitly catches the loaded rejection to avoid unhandledRejection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:39:37 +08:00
陈大猫andClaude Opus 4.6 51abe7da63 fix: send SSH keepalive on idle SFTP sessions to prevent NAT drop (#669) (#671)
The main openSftp() connection path was building ssh2 connect options
without setting keepaliveInterval at all, so no SSH-level keepalive
packets were sent on the SFTP channel. When the SFTP panel sits idle
(the common case while a user browses files), NAT/firewall state
tables reap the idle TCP connection after ~30-60s, causing the panel
to disconnect while the SSH terminal next to it — which has its own
keepalive config via sshBridge — stays connected. That matches the
exact symptom reported in #669.

Default to a 10s keepalive interval, matching the existing SFTP jump
host path (sftpBridge.cjs:466-467). Honor an explicitly configured
positive options.keepaliveInterval (in seconds) if one is passed in,
so the frontend can thread the user setting through later without
another bridge change.

Closes #669

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:45:51 +08:00
9667c03ddc fix: pin toolbar above content on KeychainManager page (#666)
* fix: pin toolbar above content on KeychainManager page

* fix: apply panel offset to outer wrapper so toolbar is not covered

The aside panel is rendered as an absolute overlay (right-0, w-[380px]),
so any container covered by the overlay needs mr-[380px] to avoid
having its right-side controls obscured. Previously only the inner
scroll area had the offset, which left the toolbar at full width —
its right-side controls (view-mode dropdown, etc.) would be covered
by the panel and become unclickable when it opened.

Move both the margin and the transition to the outer flex wrapper so
the toolbar and the scroll area shift together when the panel opens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:14:41 +08:00
陈大猫andClaude Opus 4.6 9935eb2ed1 fix: preserve file permissions when saving edited file via SFTP (#667)
* fix: preserve file permissions when saving edited file via SFTP (#665)

ssh2-sftp-client's put() overwrites existing files with the server's
default mode (typically 0o666 after umask), so a 0o755 file edited
through the built-in text editor would silently become 0o666 after
save.

Stat the file before writing to capture its existing mode, then
chmod it back to that mode after put() completes. For new files,
stat fails and we fall through to let the server apply defaults,
preserving existing behavior for file creation.

Closes #665

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: also preserve setuid/setgid/sticky bits when restoring mode

Use 0o7777 mask instead of 0o777 so special permission bits are
preserved alongside the regular rwx bits — otherwise a 4755
executable would still be restored as 0755 after editing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:04:10 +08:00
Eric Chan 268b698a39 Follow up #640 for the Snippets page (#662)
* Update snippets page to use inline aside panels

* Fix nested host editor overflow in selector panel
2026-04-09 15:21:55 +08:00
Eric Chan 2491d1a177 Shorten MCP approval timeout (#659) 2026-04-09 09:56:19 +08:00
陈大猫andClaude Opus 4.6 2bf2220d0b fix: open quick-add snippet modal in place instead of navigating (#657)
The previous "+" flow in ScriptsSidePanel switched the active tab to
Vault and jumped to the Snippets section, which ripped the user out
of their current terminal context — exactly what the feature was
supposed to avoid.

Replace the cross-panel navigation flow with a lightweight modal
dialog mounted at the App root:

- New component QuickAddSnippetDialog renders over everything and
  owns its own form state. Fields: label, command (multi-line), and
  package (combobox with allowCreate).
- App.tsx mounts the dialog globally and wires it to updateSnippets /
  updateSnippetPackages. No prop drilling through TerminalLayer.
- ScriptsSidePanel still dispatches the same netcatty:snippets:add
  window event; the dialog listens for it and opens in place.
- Reverted the navigateToSection / pendingSnippetAdd / openAddTrigger
  plumbing in App.tsx, VaultView, and SnippetsManager.

Advanced fields (targets, shortkey, tags) can still be set later
via the full Snippets manager. Cmd/Ctrl+Enter saves from any field.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 02:10:34 +08:00
陈大猫andClaude Opus 4.6 683756324e feat: add "new snippet" button in terminal ScriptsSidePanel (#641) (#656)
* feat: add "new snippet" button in terminal ScriptsSidePanel (#641)

Previously, adding a new snippet required navigating back to the main
Snippets section from the Vault view. This adds a "+" button in the
search header of the terminal-side ScriptsSidePanel that jumps
directly into the snippet edit flow.

Flow:
- ScriptsSidePanel "+" → dispatches window event `netcatty:snippets:add`
- App.tsx listens → switches activeTab to vault, navigates to Snippets
  section, and bumps a monotonic `openSnippetAddTrigger` state
- VaultView forwards the trigger to SnippetsManager
- SnippetsManager watches the trigger and opens its add panel when
  the value changes (uses a ref to ignore unrelated remounts)

Closes #641

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: switch add-snippet flow to one-shot pending flag

Codex review pointed out a real bug with the monotonic trigger approach:
when SnippetsManager mounts for the first time with openAddTrigger already
non-zero (the common "+ clicked from terminal while not on Snippets section"
path), the last-seen-trigger ref is initialized to the current value and
the useEffect immediately returns early, so the add panel never opens.

Switch to a cleaner one-shot pending flag:
- App.tsx holds pendingSnippetAdd: boolean + handlePendingSnippetAddHandled
- VaultView forwards pendingSnippetAdd + onPendingSnippetAddHandled
- SnippetsManager opens the add panel on every transition to pendingAdd=true,
  then clears the flag via onPendingAddHandled, so subsequent renders and
  plain remounts are no-ops

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: move useCallback above early return in ScriptsSidePanel

React's rules-of-hooks require all hooks to be called unconditionally.
The new handleAddSnippet useCallback was placed after the
`if (!isVisible) return null;` guard, which tripped eslint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:58:34 +08:00
陈大猫andClaude Opus 4.6 80fbf0da2f feat: add data-section hooks for Custom CSS targeting (#642) (#655)
Custom CSS already exists in Settings → Appearance, but major UI
components use only Tailwind utility classes, making it hard for
users to reliably target regions in their custom styles.

This adds stable `data-section="..."` attributes on the root element
of the most commonly customized UI regions so users can write selectors
like `[data-section="snippets-panel"] { font-size: 14px !important; }`
without depending on implementation details.

Instrumented regions:
- snippets-panel (ScriptsSidePanel)
- host-details-panel (HostDetailsPanel via AsidePanel dataSection prop)
- group-details-panel (GroupDetailsPanel)
- serial-host-details-panel (SerialHostDetailsPanel)
- ai-chat-panel (AIChatSidePanel)
- vault-view / vault-sidebar / vault-main / vault-hosts-header / vault-host-list (VaultView)
- terminal-workspace / terminal-workspace-sidebar (TerminalLayer)
- top-tabs (TopTabs — also keeps existing data-top-tabs-root)

Also updated the Custom CSS description and placeholder in both
English and Chinese to list available hooks and show a working
example (snippet panel font-size override).

Closes #642

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:38:50 +08:00
陈大猫andClaude Opus 4.6 556a14178c fix: prevent host details panel from being clipped on narrow windows (#653)
When the host details / new-host aside panel is open, narrow windows
could clip the panel content because the main area lacked min-w-0 and
the window had no minimum size.

- Add min-w-0 to the main area so flexbox can shrink the host list
  portion when the window narrows, keeping the 420px panel fully visible
- Set the BrowserWindow minWidth/minHeight to 1100x640 so the user
  cannot drag the window narrower than what the panel + sidebar +
  host list need to render comfortably
- Clamp previously saved window dimensions to the new minimum on launch
- Animate the New Host split button and the Terminal / Serial buttons
  to collapse with a 200ms transition when the host panel is open,
  freeing horizontal space and hiding controls that would be no-ops

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:04:55 +08:00
Eric Chan 7e566efe9c Add push-style host details panels (#649)
Refs: https://github.com/binaricat/Netcatty/issues/640
2026-04-08 16:42:32 +08:00
1d2489b02c feat: support long-running AI terminal jobs (#647)
* Add background terminal jobs for long AI commands

* Bound background job output buffering

* Fix long-running terminal job polling and stop behavior

* Fix terminal job final output and stopping retention

* Wait for PTY stop confirmation before cancelling

* fix: address codex review findings in PTY job refactor

- [P1] Use last occurrence of start marker to skip echoed wrapper command,
  preventing control markers from leaking into stdout
- [P1] Add wall-clock timeout for foreground PTY execution so commands that
  print continuously still get terminated at the configured limit
- [P2] Add hard deadline for cancellation so jobs that ignore Ctrl+C are
  force-finished after 30s instead of staying stuck in "stopping" forever

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-2 codex review findings

- [P1] Use visibleOutput for background job completion to keep offsets
  consistent with polling, preventing output loss when raw buffer
  (with ANSI codes) truncates earlier than the visible buffer
- [P2] Clarify system prompt that terminal_start requires PTY-backed
  sessions, so exec-only SSH sessions are not incorrectly routed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-3 codex review findings

- [P1] Always strip markers from visibleOutput in background job finish
  to prevent end-marker lines leaking into terminal_poll results
- [P2] Correct terminal_execute timeout guidance from ~2min to ~60s to
  match the actual default commandTimeoutMs (60000)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-4 codex review findings

- [P1] Delay session lock release when cancel is forced (process may
  still be running) to prevent sending commands into a busy shell
- [P2] Move scope validation before pendingSessionWriteApprovals so
  out-of-scope requests fail fast without blocking the write lock
- [P2] Add session scope checks to handleJobPoll and handleJobStop
  so chats that lose access cannot read output or cancel jobs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-5 codex review findings

- [P1] Strip marker lines before they enter the bounded visible buffer
  so they never occupy space or leak as partial fragments on truncation
- [P2] Never release session lock after forced cancellation since the
  previous process may still be attached to the PTY

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-6 codex review findings

- [P2] Buffer incomplete marker lines across PTY chunks to prevent
  partial marker fragments from leaking into visible output
- [P1] Release session lock after 60s delay on forced cancel as
  compromise between safety and permanent lock
- [P2] Enforce session scope checks on jobPoll/jobStop for both
  dynamic (chatSessionId) and static (NETCATTY_MCP_SESSION_IDS) modes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-7 codex review findings

- [P2] validateSessionScope now accepts explicit scopedSessionIds so
  static MCP scope mode is enforced for jobPoll/jobStop too
- [P2] Apply per-session execution lock to netcatty:ai:exec IPC path
  so it cannot race with active background jobs on the same session

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-8 codex review findings

- [P1] Make wall-clock timeout opt-in via enforceWallTimeout flag,
  enabled only for MCP terminal_execute path. Catty Agent's
  netcatty:ai:exec keeps the inactivity-based timeout since it has
  no terminal_start fallback for long-running streaming commands
- [P2] Always allow handleJobStop regardless of session scope so
  the per-session execution lock can always be released after
  workspace membership changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-9 codex review findings

- [P1] Enable enforceWallTimeout for netcatty:ai:exec to match the
  pre-PR behavior (hard wall-clock deadline). Without this, tail -f
  or verbose builds would hold the session lock indefinitely
- [P2] Treat explicit scopedSessionIds=[] as no access rather than
  falling through to global scope, matching handleGetContext's
  documented behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-10 codex review findings

- [P2] Add bounded startup deadline (30s) for the start marker arrival
  even when wall-clock timeout is disabled. Prevents background jobs
  from hanging indefinitely on already-chatty PTY sessions
- [P3] Use job-specific marker (not generic __NCMCP_) when stripping
  marker lines, so user output containing __NCMCP_ is preserved

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-11 codex review findings

- [P2] Skip the 30s startup timeout for foreground execViaPty paths.
  It now applies only when maxBufferedChars > 0 (background jobs),
  so foreground commands queued behind a busy shell can wait
- [P2] Return empty stdout from getSnapshot() before the start marker
  arrives, so an early poll cannot advance nextOffset past pre-start
  PTY noise that gets discarded once the real command begins

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-12 codex review findings

- [P1] Treat empty chat scopes as no access in validateSessionScope:
  if a chat has explicit scoped metadata (even []), enforce strictly
  rather than falling through to fallback/global scope
- [P2] Re-add session scope check in handleJobStop for static MCP
  clients (scopedSessionIds), while still allowing dynamic chat-scoped
  callers to always stop their own jobs even after scope changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-13 codex review findings

- [P2] getScopedJob now requires the caller to present the job's
  chatSessionId. Unscoped/static callers cannot reach into another
  chat's background jobs even if they learn the jobId
- [P2] Stop button no longer cancels terminal_start background jobs.
  They are intentionally long-running, so killing them on every
  per-response stop defeats the purpose of the feature. Cleanup on
  chat deletion (cleanupScopedMetadata) is preserved

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-14 codex review findings

- [P1] terminal_start jobs no longer registered in activePtyExecs so
  ACP "Stop" / cancelPtyExecsForSession does not kill them. They are
  still managed via terminal_stop and the per-session execution lock
- [P1] Remove enforceWallTimeout from netcatty:ai:exec since Catty
  Agent has no terminal_start fallback for long-running commands.
  Inactivity timeout still catches genuinely hung processes
- [P2] Forced-cancelled jobs stay in "stopping" (completed=false)
  until the 60s lock grace period ends, so callers don't see the
  job as completed while the session is still locked

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-15 codex review findings

- [P2] Allow netcatty/jobStop to bypass the chat-cancelled gate so
  users can stop terminal_start jobs even after ACP "Stop" was pressed
- [P2] Mark non-zero exit codes as failed (not completed) so callers
  don't have to special-case exitCode against status
- [P2] Pre-start cancel: clear startup timer in requestCancel and
  detect prompt return on preStartOutput so a queued job that gets
  cancelled resolves as "Cancelled", not "startup timed out"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-16 codex review findings

- [P2] Cap preStartOutput for background jobs at maxBufferedChars so
  noisy idle PTYs cannot accumulate megabytes before the start marker
  arrives or the startup timeout fires
- [P2] On forced cancel, immediately release the session lock and
  mark the job as cancelled. The error message clearly states that
  the process may still be running, and the caller sees completed=true
  exactly when the lock is no longer held — consistent semantics

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-17 codex review findings

- [P2] Disable prompt-suffix completion fallback for background jobs.
  Long-running commands often print prompt-like text (nested shells,
  ssh, sudo -s, REPLs) and would otherwise be misdetected as completed.
  Background jobs rely strictly on the end marker
- [P2] consumeVisibleText now treats \\r as a carriage return that
  resets the current line, so progress bars (npm, docker pull, curl)
  collapse to the latest frame instead of accumulating every redraw

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-18 codex review findings

- [P2] Pre-start cancel on sessions without a tracked idle prompt now
  gets a 2s fallback to finish as Cancelled, instead of waiting the
  full forced-cancel window for an end marker that will never arrive
- [P3] Move session-scope validation before the busy-session check so
  out-of-scope callers cannot probe the existence/activity of foreign
  sessions via busy-state error messages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-19 codex review findings

- [P1] Re-enable prompt-suffix completion fallback for background
  jobs but with a longer 10s delay so nested shells / REPLs have
  time to print past their initial prompt before the recheck
- [P2] Carriage returns now collapse progress redraws across PTY
  chunks: \\r is preserved through consumeVisibleText and
  applyCarriageReturns erases the trailing line of visibleOutput
  when a chunk starts with \\r. Verified with a fake PTY that
  emits "10%" then "\\r20%" then "\\r30%\\n" — final output is "30%"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-20 codex review findings

- [P1] Disable prompt-suffix completion fallback for background jobs.
  Commands that open child shells with the same prompt as the parent
  (bash, zsh, sudo -s, ssh) would otherwise be reported as completed
  while the child is still running. Background jobs rely strictly on
  the end marker, with their long timeout and explicit terminal_stop
- [P2] Track a monotonic visibleHighWatermark so polling nextOffset
  cannot move backwards across CR redraws. serializeBackgroundJob now
  returns the latest visible frame when the caller's offset has been
  passed by a redraw, instead of returning empty stdout permanently
- [P3] Buffer trailing lines that contain the constant __NCMCP_
  prefix (not just the full random marker token) so PTY chunk
  boundaries that split the marker mid-token cannot leak _E:0 noise

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-21 codex review findings

- [P2] Foreground execs now also get a hard startup deadline (using
  the configured timeoutMs as the limit). Background jobs use a
  fixed 30s. Without this, an already-chatty PTY would let onData
  re-arm the inactivity timer forever before _S arrives
- [P2] finish() now uses the monotonic visibleHighWatermark for
  totalOutputChars on completion, so the final poll's nextOffset
  cannot regress relative to earlier polls after CR redraws

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-22 codex review findings

- [P2] cleanupScopedMetadata now also calls clearPendingApprovals so
  in-flight approval requests resolve immediately. Otherwise a chat
  deleted while an approval was pending would leave the per-session
  write lock held until the 5-minute approval timeout expires
- [P2] Allow netcatty/jobStop in observer mode so users can stop
  long-running terminal_start jobs that were launched before they
  switched to observer mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-23 codex review finding

- [P2] Apply \\r as a "deferred" carriage return: park the cursor at
  the start of the line but defer erasure until the next character
  arrives. This preserves the latest visible frame for commands like
  printf '10%%\\r'; sleep; printf '20%%\\r' that pause between
  redraws, while still collapsing continuous progress redraws to a
  single frame. Verified: snapshots now show '40%' and '50%' instead
  of empty stdout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-24 codex review findings

- [P1] Re-enable prompt fallback for background jobs with a 30s
  delay so commands open child shells / REPLs have time to print
  past their initial prompt before the recheck. This is the third
  time codex has flip-flopped on this — 30s is the compromise
- [P2] Pass chatSessionId to execViaChannel in handleExec so
  cancelPtyExecsForSession can interrupt SSH exec-channel commands
  scoped to the originating chat

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-25 codex review finding

- [P1] Stop in-place CR collapsing in visibleOutput. The collapsed
  buffer made polling offsets non-monotonic and could drop finalized
  lines after a CR rewrite. Now visibleOutput stores raw bytes (with
  \\r dropped at consumeVisibleText to keep the buffer simple), the
  256KB cap naturally bounds progress-bar accumulation, and slice
  semantics work correctly across all redraw patterns. Consumers
  that want a "collapsed view" can post-process

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address round-26 codex review findings

- [P2] Carriage returns are now preserved in the raw buffer and
  collapsed at serialize time in collapseCarriageReturns. This keeps
  monotonic offsets in the buffer while polled output shows the
  latest progress frame. A trailing \\r leaves existing content
  intact (deferred erasure semantics)
- [P2] netcatty/jobStop now bypasses the confirm-mode approval gate
  so a runaway terminal_start job can always be interrupted, even
  when the renderer is unavailable
- [P3] requestCancel's one-shot timers (2s pre-start, 150ms reinforce,
  30s force-finish) are now tracked and cleared in finish() so they
  cannot keep the Node event loop alive after the job has resolved

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:39:21 +08:00
陈大猫andClaude Opus 4.6 5ad3d0ce32 fix: prevent crash when codex-acp binary is not found (#648)
* fix: prevent crash when codex-acp binary is not found (#645)

When codex-acp is not installed, resolveCodexAcpBinaryPath returned the
bare binary name as a fallback. This caused createACPProvider to spawn a
non-existent process, emitting an async ENOENT error that crashed the app.

Return null instead of the bare name and guard all createACPProvider call
sites so the error is handled gracefully.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: install cross-platform codex-acp binaries in CI build

macOS and Windows CI builds produce both arm64 and x64 packages, but
npm ci only installs optional dependencies for the host platform. This
means the codex-acp native binary for the other architecture is missing
from the packaged app, causing ENOENT crashes for users on the
non-host architecture.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add --force to bypass cpu/os constraints for cross-arch install

The platform-specific codex-acp packages declare cpu/os constraints in
their package.json, so npm refuses to install the non-host-arch binary
with EBADPLATFORM. Use --force to bypass this check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:53:27 +08:00
bincxzandClaude Opus 4.6 edf013164b fix: limit recently connected hosts to 6
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:59:47 +08:00
陈大猫andClaude Opus 4.6 504b576e1c fix: stop deduplicating pinned/recent hosts from main host list (#632) (#636)
Previously hosts shown in the pinned or recently-connected sections
were excluded from the main list and group view, causing incomplete
group counts and missing hosts under group sort mode.

Closes #632

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:53:46 +08:00
890abd1c4c Fix/terminal clear preserve scrollback (#633)
* fixd:issure #622

* fix: use baseY instead of viewportY for active screen row count

When the user scrolls up to browse history, viewportY differs from
baseY (the active screen origin). _core.scroll always operates on
the active screen, so counting rows from viewportY preserves the
wrong number of lines and may evict older scrollback unexpectedly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use term.clear() for local clear to preserve prompt line

The escape sequence \x1b[H\x1b[2J erases the entire display including
the current prompt/input line, which is a regression from term.clear()
that keeps the prompt as the first visible line. Remote CSI 2 J is
already handled separately by the CSI parser handler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: preserve both scrollback and prompt in local clear

term.clear() destroys scrollback (truncates buffer lines). The escape
sequence approach erases the prompt. This commit uses _core.scroll to
push lines above cursor into scrollback, then clears below the prompt
with CSI 0 J and repositions the cursor — preserving both history and
the current prompt line.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: panwk <panwk@88.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:03:39 +08:00
陈大猫andClaude Opus 4.6 0827dd416f fix: truncate long command text in snippet list to prevent layout overflow (#628) (#630)
- Use w-0 flex-1 pattern on text containers to enforce width constraint
- Add overflow-hidden on list item containers
- Add tooltip on snippet command text to show full content on hover

Closes #628

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:05:56 +08:00
陈大猫andClaude Opus 4.6 24df4b6548 fix: support CSV password import and save password in keyboard-interactive auth (#629)
* fix: support CSV password import and save password in keyboard-interactive auth (#627)

- Add Password column support to CSV import/export/template
- Add isAPasswordPrompt detection (prompt contains "password" + echo=false)
- Auto-fill saved password in keyboard-interactive modal
- Add "Save password" checkbox for password prompts in keyboard-interactive modal
- Wire save callback through sessionId → host to persist password

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review feedback for keyboard-interactive and CSV changes

- Merge password field in dedupeHosts to avoid losing passwords from duplicate CSV rows
- Extract isAPasswordPrompt to module-level pure function
- Only render save-password checkbox at the first password prompt index
- Clean up orphaned i18n keys (useSaved, useSavedPassword, fill, fillSaved)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: preserve whitespace in CSV imported passwords

Passwords may intentionally contain leading/trailing whitespace.
Removing .trim() ensures lossless CSV round-trip and correct auth.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: exclude OTP prompts from password detection and guard jump host save

- Add negative patterns (one-time, otp, verification, token, code) to
  isAPasswordPrompt to avoid auto-filling SSH password into OTP fields
- Only save password when request hostname matches session hostname,
  preventing jump host passwords from overwriting the destination host

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: skip formula injection guard for password column in CSV export

Password values starting with =, +, -, @ were getting a ' prefix from
the CSV formula injection protection, breaking round-trip fidelity.
Now password column is escaped for CSV syntax only, preserving the
credential verbatim.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: only skip formula guard for data rows, not header row

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:39:39 +08:00
陈大猫andClaude Opus 4.6 7db4b18cce fix: add missing props destructuring in HostTreeView causing white screen (#625) (#626)
getDropTargetClasses and setDragOverDropTarget were added to
HostTreeViewProps interface and used in JSX but never destructured
from the component's props parameter. TypeScript didn't catch it
because the interface defined them as optional, but at runtime the
bare variable references caused ReferenceError, crashing React and
producing a white screen on startup.

Closes #625

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:38:15 +08:00
陈大猫andClaude Opus 4.6 844c55e99d fix: sync built-in editor theme with terminal theme in immersive mode (#623) (#624)
The Monaco editor only synced background color from CSS variables and missed
foreground, cursor, selection, line numbers, and widget colors. Additionally,
switching between terminal themes of the same type (e.g. two dark themes)
did not trigger an editor theme update because the MutationObserver only
watched class/style attributes on <html>.

- Read 6 CSS variables (bg, fg, primary, card, muted-fg, border) and map
  them to 14 Monaco theme color tokens
- Set data-immersive-theme attribute on <html> when immersive mode applies
  a theme, so the MutationObserver detects same-type theme switches
- Clean up the data attribute when immersive mode is removed

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:03:40 +08:00
陈大猫andClaude Opus 4.6 778b43ceff fix: reset mouse tracking on start over to prevent escape sequence leak (#616) (#621)
When "Start Over" reconnects a session, the xterm instance retained
mouse tracking modes from the previous session. Mouse movements during
reconnection generated SGR mouse sequences (e.g. 35;XX;YYM) that were
sent to the new session as visible text input.

Fix: disable all mouse tracking modes (?1000l, ?1002l, ?1003l, ?1006l)
and reset the terminal before reconnecting.

Closes #616

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 15:03:04 +08:00
陈大猫andClaude Opus 4.6 6b2e5041d2 fix: sort default shell to top in quick switcher (#613) (#620)
The local shell list was displayed in discovery order (alphabetical),
burying the default shell (e.g. Zsh) at the bottom. Now sorts
isDefault shells to the top of the list.

Closes #613

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:55:46 +08:00
陈大猫andClaude Opus 4.6 1464cba6da feat: add xterm-container class for custom CSS bottom spacing (#614) (#619)
Add a stable .xterm-container CSS class to the terminal container div
so users can adjust bottom spacing via Custom CSS without color
mismatch issues.

Example custom CSS:
  .xterm-container { bottom: 10px !important; }

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:51:26 +08:00
陈大猫andClaude Opus 4.6 d74d9e28a0 fix: split shortcut in workspace panes and host delete form freeze (#612) (#618)
* fix: split shortcut in workspace panes and host delete form freeze (#612)

Bug 1: Split-pane shortcuts (Ctrl+Shift+D/E) did nothing after the
first split because the workspace branch in executeHotkeyAction only
logged a message. Now uses workspace.focusedSessionId to split the
focused pane.

Bug 2: Deleting a host left editingHost state pointing to the removed
host, keeping HostDetailsPanel mounted as an overlay that blocked all
form interactions. Added a useEffect to close the panel when the
edited host is no longer in the hosts array.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Shift+right-click context menu and split content loss (#612)

Bug 4: When rightClickBehavior is 'paste' or 'select-word', the context
menu was completely disabled with no fallback. Now Shift+Right-Click
always opens the context menu regardless of the right-click behavior
setting.

Bug 5: Splitting a terminal occasionally caused the original pane's
content to disappear due to a race between layout reflow and xterm
fit(). Added a second delayed fit (350ms) after workspace layout
changes as a safety net for cases where the first fit (100ms) runs
before the container dimensions have settled.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: guard host-deletion cleanup against unsaved duplicates

The cleanup effect that closes the host panel on deletion incorrectly
closed it for duplicated/new hosts whose IDs were never in the hosts
array. Track known host IDs via ref so the effect only fires when a
previously-saved host is actually removed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: check previous host IDs before updating ref in deletion cleanup

Merge the two effects into one so the deletion check reads from the
previous knownHostIdsRef before overwriting it with the current hosts.
Previously both effects ran in the same render cycle, causing the ref
to be updated before the check, making it impossible to detect deleted
hosts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: open context menu on first Shift+right-click

Replace state-based forceMenu approach with always-enabled
ContextMenuTrigger. The onContextMenu handler intercepts paste/
select-word actions unless Shift is held, so the Radix context menu
opens immediately on the first Shift+Right-Click without needing a
second click.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: fallback to first live pane when workspace focus is stale

When the focused pane is closed, focusedSessionId may point to a
non-existent session. Split shortcuts now fall back to the first
session in the workspace tree via collectSessionIds() so the hotkey
never silently no-ops.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: validate focusedSessionId against live workspace panes

focusedSessionId can be stale (non-null but pointing to a closed pane)
after pane closure. Now check it exists in collectSessionIds() before
using it, otherwise fall back to the first live pane.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:38:02 +08:00
陈大猫andClaude Opus 4.6 32b74f4fea fix: persist sidebar appearance overrides for quick-connect hosts (#611)
* fix: persist sidebar appearance overrides for quick-connect hosts

Quick-connect hosts (id starting with `quick-`) are not in the saved
hosts array, so per-host overrides set via the sidebar (fontWeight,
theme, fontFamily, fontSize) were silently lost:

1. onUpdateHost only updated existing entries (map), never inserted —
   change to upsert so quick-connect hosts are added on first override.
2. fontWeight handlers guarded on rawHost from hostMap, which is
   undefined for quick-connect hosts — fall back to focusedHost.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: only auto-add quick-connect hosts, never re-add deleted saved hosts

Restrict the onUpdateHost upsert to quick-connect hosts (id starts with
`quick-`). This prevents sidebar appearance changes from silently
re-adding a host that was intentionally deleted while its session was
still running.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use primary font only in document.fonts.check to fix bold weight fallback

document.fonts.check returns false when ANY listed font in the family
string is still loading. Our font family strings include a long CJK
fallback chain (Sarasa Mono SC, Noto Sans Mono CJK, PingFang SC, etc.)
that may not be loaded during early terminal creation. This caused
fontWeightBold to incorrectly fall back to the normal fontWeight,
making bold text (including shell prompts) render too thin in freshly
created terminals while live-updated terminals looked correct.

Fix: extract only the primary font family for the check, ignoring the
fallback chain that is irrelevant for bold weight availability.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: normalize WebGL fontWeight rendering after terminal connection

Work around xterm.js WebGL renderer bug where glyphs rendered via the
constructor look visually different from those set dynamically. After
the terminal connects and text is on screen, force a fontWeight
round-trip (original → normal → original) so the WebGL texture atlas
rebuilds through the dynamic path, producing consistent rendering
that matches sidebar font weight changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use global settings for quick-connect host appearance changes

Quick-connect hosts have ephemeral IDs (quick-${Date.now()}-...) that
are never reused across connections. Auto-adding them to the hosts
array would accumulate orphaned entries over time.

Instead, treat quick-connect hosts like local terminals: sidebar
appearance changes (fontWeight, etc.) update the global terminal
settings rather than creating per-host overrides.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address code review findings

- Apply isFocusedHostEphemeral to theme, fontFamily, fontSize handlers
  (not just fontWeight) so all appearance changes on ephemeral hosts
  update global settings
- Use hostMap.has() instead of id.startsWith('quick-') to detect
  ephemeral hosts — saved hosts with quick- prefix are handled correctly
- Re-read fontWeight at timer fire time to avoid stale closure
- Handle quoted font names with commas in primaryFontFamily parser

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:52:26 +08:00
Eric Chan f284fb0505 Refine host group drop feedback (#617) 2026-04-03 12:15:07 +08:00
1941 changed files with 348164 additions and 48899 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+118
View File
@@ -0,0 +1,118 @@
name: Bug Report
description: Report a reproducible problem in Netcatty
title: "[Bug] "
labels: ["bug", "triage"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug. Incomplete reports may be closed automatically.
Please search [existing issues](https://github.com/binaricat/Netcatty/issues) first.
- type: dropdown
id: platform
attributes:
label: Operating system
options:
- macOS
- Windows
- Linux
validations:
required: true
- type: input
id: version
attributes:
label: Netcatty version
description: Find it in Settings > Application, or on the [latest release](https://github.com/binaricat/Netcatty/releases/latest) page.
placeholder: "e.g. 1.2.3"
validations:
required: true
- type: dropdown
id: install_source
attributes:
label: How did you install Netcatty?
options:
- GitHub Release (.dmg / .exe / .AppImage / .deb / .rpm / .pacman)
- Homebrew
- Built from source (npm run dev / pack)
- Other
validations:
required: true
- type: dropdown
id: area
attributes:
label: Affected area
multiple: true
options:
- SSH connection / terminal
- SFTP / file browser
- Host vault / keychain
- Port forwarding
- Snippets
- AI assistant
- Settings / sync
- UI / layout
- Crash / app won't start
- Other
validations:
required: true
- type: dropdown
id: reproducibility
attributes:
label: Can you reproduce it?
options:
- Always (100%)
- Often (>50%)
- Sometimes
- Once / not sure
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Numbered steps so we can follow exactly.
placeholder: |
1. Open Netcatty and connect to host X
2. Click SFTP tab
3. ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs / screenshots
description: |
Optional but helpful. Crash logs: Settings > System > Crash Logs > Open folder.
For SSH errors, include redacted connection details (no passwords / private keys).
placeholder: Paste relevant log lines or attach screenshots.
- type: checkboxes
id: checklist
attributes:
label: Before submitting
options:
- label: I searched existing issues and did not find a duplicate
required: true
- label: I removed passwords, private keys, and other secrets from this report
required: true
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Questions & general help
url: https://github.com/binaricat/Netcatty/discussions
about: Not sure if it is a bug? Ask in Discussions first.
- name: Latest release
url: https://github.com/binaricat/Netcatty/releases/latest
about: Check your Netcatty version before reporting.
@@ -0,0 +1,72 @@
name: Feature Request
description: Suggest an improvement or new capability
title: "[Feature] "
labels: ["enhancement", "triage"]
body:
- type: markdown
attributes:
value: |
Describe the problem you are trying to solve and the change you want.
Vague requests like "make it better" may be closed.
- type: textarea
id: problem
attributes:
label: Problem / pain point
description: What is hard, missing, or frustrating today?
placeholder: When I manage 50+ hosts, I cannot ...
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed solution
description: What would you like Netcatty to do?
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Other tools, workarounds, or designs you thought about.
validations:
required: true
- type: dropdown
id: area
attributes:
label: Related area
multiple: true
options:
- SSH / terminal
- SFTP
- Host vault / keychain
- Port forwarding
- Snippets
- AI assistant
- Settings / sync
- UI / UX
- Other
validations:
required: true
- type: dropdown
id: priority
attributes:
label: How important is this to you?
options:
- Nice to have
- Would improve my daily workflow
- Blocking / critical for my use case
validations:
required: true
- type: checkboxes
id: checklist
attributes:
label: Before submitting
options:
- label: I searched existing issues and discussions for similar requests
required: true
+40
View File
@@ -0,0 +1,40 @@
## Summary
<!-- Briefly describe what this PR does and why. -->
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Refactor / code cleanup
- [ ] Documentation update
- [ ] Build / CI change
- [ ] Other (please describe):
## Related Issue (optional)
N/A
<!-- If this PR has a related issue, replace N/A with "Closes #123" or "Related to #123". -->
## Changes Made
<!-- List the key changes in this PR. -->
## Screenshots / Demo
<!-- If this PR affects UI or behavior, include screenshots or a short description. -->
## Testing
- [ ] I have tested these changes locally (`npm run dev`)
- [ ] Linting passes (`npm run lint`)
- [ ] Tests pass (`npm test`)
- [ ] Generated capability tool specs are updated when applicable (`npm run generate:capability-tools`)
- [ ] No new console errors or warnings, if this affects app behavior
## Checklist
- [ ] My code follows the existing project style
- [ ] I have added or updated relevant documentation
- [ ] I have not introduced any breaking changes (or I have described them above)
+45
View File
@@ -0,0 +1,45 @@
# Security Policy
## Supported Versions
We actively maintain the latest stable release of Netcatty. Security fixes are applied to the current release only.
| Version | Supported |
| ------- | ------------------ |
| Latest | :white_check_mark: |
| Older | :x: |
## Reporting a Vulnerability
If you discover a security vulnerability in Netcatty, **please do not open a public GitHub Issue**.
Instead, report it privately via one of the following methods:
- **GitHub Private Vulnerability Reporting**: Use the [Security tab](https://github.com/binaricat/Netcatty/security/advisories/new) to submit a private advisory.
- **Email**: Send details to support@netcatty.com.
- **GitHub Issues** (for non-sensitive security concerns only): https://github.com/binaricat/Netcatty/issues
Please include the following details in your report:
- A description of the vulnerability
- Steps to reproduce the issue
- The potential impact
- Any suggested mitigation or fix
## Response Timeline
We aim to:
- Acknowledge receipt within **3 business days**
- Provide a status update within **7 business days**
- Release a patch as soon as possible depending on severity
## Scope
This policy applies to the Netcatty desktop application (`netcatty.app`) and its source code in this repository.
Vulnerabilities in third-party dependencies should be reported directly to the respective upstream projects.
## Disclosure Policy
We follow responsible disclosure. Once a fix is available, we will publish a security advisory and credit the reporter (unless they prefer to remain anonymous).
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
#
# bump-homebrew-cask.sh — push a new version of the Netcatty cask to the
# binaricat/homebrew-netcatty tap.
#
# Called from the release pipeline (`build.yml` → `homebrew-tap` job) after
# the GitHub Release has been published with the signed + notarized DMGs.
# Computes SHA-256 of the arm64 and x64 DMGs, rewrites the cask file, and
# pushes the bump back to the tap repository using HOMEBREW_TAP_TOKEN.
#
# Required env vars:
# VERSION — semver without leading "v" (e.g. 1.1.6)
# HOMEBREW_TAP_TOKEN — PAT with contents:write on the tap repo
#
# Optional env vars:
# TAP_REPO — default: binaricat/homebrew-netcatty
# ARTIFACTS_DIR — default: artifacts
# CASK_PATH — default: Casks/netcatty.rb
set -euo pipefail
: "${VERSION:?VERSION env var required (no leading v)}"
: "${HOMEBREW_TAP_TOKEN:?HOMEBREW_TAP_TOKEN env var required}"
TAP_REPO="${TAP_REPO:-binaricat/homebrew-netcatty}"
ARTIFACTS_DIR="${ARTIFACTS_DIR:-artifacts}"
CASK_PATH="${CASK_PATH:-Casks/netcatty.rb}"
ARM_DMG="${ARTIFACTS_DIR}/Netcatty-${VERSION}-mac-arm64.dmg"
X64_DMG="${ARTIFACTS_DIR}/Netcatty-${VERSION}-mac-x64.dmg"
for f in "$ARM_DMG" "$X64_DMG"; do
if [[ ! -f "$f" ]]; then
echo "::error::Required DMG artifact not found: $f"
exit 1
fi
done
ARM_SHA=$(shasum -a 256 "$ARM_DMG" | awk '{print $1}')
X64_SHA=$(shasum -a 256 "$X64_DMG" | awk '{print $1}')
echo "Computed checksums:"
echo " arm64: ${ARM_SHA}"
echo " x64 : ${X64_SHA}"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
git clone --depth 1 \
"https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/${TAP_REPO}.git" \
"$TMP/tap"
cd "$TMP/tap"
if [[ ! -f "$CASK_PATH" ]]; then
echo "::error::Cask file not found in tap: $CASK_PATH"
exit 1
fi
# Patch the cask in place. The three lines we touch are anchored well enough
# that we don't need anything fancier than sed:
# - the `version "X.Y.Z"` line (single line, anchored to start)
# - the `sha256 arm: "..."` line
# - the ` intel: "..."` line (anchor on "intel:" at start, after the
# leading whitespace, so we don't accidentally match the `arch arm:
# "...", intel: "..."` line earlier in the file)
sed -i -E 's|^(\s*version)\s+"[^"]+"|\1 "'"$VERSION"'"|' "$CASK_PATH"
sed -i -E 's|(sha256\s+arm:\s+)"[^"]+"|\1"'"$ARM_SHA"'"|' "$CASK_PATH"
sed -i -E 's|^(\s*intel:\s+)"[^"]+"|\1"'"$X64_SHA"'"|' "$CASK_PATH"
# Sanity-check: parsed file should still be valid Ruby. Catches a broken
# substitution before we push.
if command -v ruby >/dev/null 2>&1; then
ruby -c "$CASK_PATH" >/dev/null
fi
if git diff --quiet; then
echo "Cask already at ${VERSION} with matching checksums — nothing to push."
exit 0
fi
echo "Cask diff:"
git --no-pager diff "$CASK_PATH"
git config user.email "github-actions[bot]@users.noreply.github.com"
git config user.name "github-actions[bot]"
git add "$CASK_PATH"
git commit -m "Bump netcatty to ${VERSION}"
git push origin HEAD:main
echo "Pushed bump for ${VERSION} to ${TAP_REPO}."
+12 -7
View File
@@ -50,14 +50,14 @@ const baseUrl = `https://github.com/${repo}/releases/download/${tag}`;
// - AppImage: x64 -> x86_64, arm64 -> arm64
// - deb: x64 -> amd64, arm64 -> arm64
// - rpm: x64 -> x86_64, arm64 -> aarch64
// - pacman: x64 -> x64, arm64 -> aarch64
const files = {
mac: {
arm64: `Netcatty-${version}-mac-arm64.dmg`,
x64: `Netcatty-${version}-mac-x64.dmg`
},
win: {
x64: `Netcatty-${version}-win-x64.exe`,
arm64: `Netcatty-${version}-win-arm64.exe`
x64: `Netcatty-${version}-win-x64.exe`
},
linux: {
appimage: {
@@ -71,14 +71,17 @@ const files = {
rpm: {
x64: `Netcatty-${version}-linux-x86_64.rpm`,
arm64: `Netcatty-${version}-linux-aarch64.rpm`
},
pacman: {
x64: `Netcatty-${version}-linux-x64.pacman`,
arm64: `Netcatty-${version}-linux-aarch64.pacman`
}
}
};
const badges = {
win: {
setup_x64: `[![Setup x64](https://img.shields.io/badge/Setup-x64-0078D6?style=flat-square&logo=windows)](${baseUrl}/${files.win.x64})`,
setup_arm64: `[![Setup arm64](https://img.shields.io/badge/Setup-arm64-0078D6?style=flat-square&logo=windows)](${baseUrl}/${files.win.arm64})`
setup_x64: `[![Setup x64](https://img.shields.io/badge/Setup-x64-0078D6?style=flat-square&logo=windows)](${baseUrl}/${files.win.x64})`
},
mac: {
apple_silicon: `[![DMG Apple Silicon](https://img.shields.io/badge/DMG-Apple_Silicon-000000?style=flat-square&logo=apple)](${baseUrl}/${files.mac.arm64})`,
@@ -90,7 +93,9 @@ const badges = {
deb_x64: `[![DebPackage x64](https://img.shields.io/badge/DebPackage-x64-A80030?style=flat-square&logo=debian)](${baseUrl}/${files.linux.deb.x64})`,
deb_arm64: `[![DebPackage arm64](https://img.shields.io/badge/DebPackage-arm64-A80030?style=flat-square&logo=debian)](${baseUrl}/${files.linux.deb.arm64})`,
rpm_x64: `[![RpmPackage x64](https://img.shields.io/badge/RpmPackage-x64-CC0000?style=flat-square&logo=redhat)](${baseUrl}/${files.linux.rpm.x64})`,
rpm_arm64: `[![RpmPackage arm64](https://img.shields.io/badge/RpmPackage-arm64-CC0000?style=flat-square&logo=redhat)](${baseUrl}/${files.linux.rpm.arm64})`
rpm_arm64: `[![RpmPackage arm64](https://img.shields.io/badge/RpmPackage-arm64-CC0000?style=flat-square&logo=redhat)](${baseUrl}/${files.linux.rpm.arm64})`,
pacman_x64: `[![ArchPackage x64](https://img.shields.io/badge/ArchPackage-x64-1793D1?style=flat-square&logo=archlinux)](${baseUrl}/${files.linux.pacman.x64})`,
pacman_arm64: `[![ArchPackage arm64](https://img.shields.io/badge/ArchPackage-arm64-1793D1?style=flat-square&logo=archlinux)](${baseUrl}/${files.linux.pacman.arm64})`
}
};
@@ -99,9 +104,9 @@ const content = `
| OS | Download |
| :--- | :--- |
| **Windows** | ${badges.win.setup_x64} ${badges.win.setup_arm64} |
| **Windows** | ${badges.win.setup_x64} |
| **macOS** | ${badges.mac.apple_silicon} ${badges.mac.intel} |
| **Linux** | ${badges.linux.appimage_x64} ${badges.linux.deb_x64} ${badges.linux.rpm_x64} <br> ${badges.linux.appimage_arm64} ${badges.linux.deb_arm64} ${badges.linux.rpm_arm64} |
| **Linux** | ${badges.linux.appimage_x64} ${badges.linux.deb_x64} ${badges.linux.rpm_x64} ${badges.linux.pacman_x64} <br> ${badges.linux.appimage_arm64} ${badges.linux.deb_arm64} ${badges.linux.rpm_arm64} ${badges.linux.pacman_arm64} |
`;
fs.writeFileSync('release_notes.md', content);
+76
View File
@@ -0,0 +1,76 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
function usage() {
console.error('Usage: node .github/scripts/update-nix-release.js --artifacts <dir> --version <semver>');
}
function parseArgs(argv) {
const args = {};
for (let index = 2; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--artifacts' || arg === '--version') {
args[arg.slice(2)] = argv[index + 1];
index += 1;
continue;
}
usage();
process.exit(2);
}
if (!args.artifacts || !args.version) {
usage();
process.exit(2);
}
return args;
}
function sriSha256(filePath) {
const digest = crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('base64');
return `sha256-${digest}`;
}
function findArtifact(artifactsDir, fileName) {
const filePath = path.join(artifactsDir, fileName);
if (!fs.existsSync(filePath)) {
throw new Error(`Missing release artifact: ${filePath}`);
}
return filePath;
}
function renderReleaseNix({ version, x64Hash, arm64Hash }) {
return `{
version = "${version}";
sources = {
x86_64-linux = {
appImageArch = "x86_64";
hash = "${x64Hash}";
};
aarch64-linux = {
appImageArch = "arm64";
hash = "${arm64Hash}";
};
};
}
`;
}
const args = parseArgs(process.argv);
const version = args.version.replace(/^v/, '');
if (!/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-.+)?$/.test(version)) {
throw new Error(`Expected semver version, got: ${args.version}`);
}
const x64AppImage = findArtifact(args.artifacts, `Netcatty-${version}-linux-x86_64.AppImage`);
const arm64AppImage = findArtifact(args.artifacts, `Netcatty-${version}-linux-arm64.AppImage`);
const releaseNix = renderReleaseNix({
version,
x64Hash: sriSha256(x64AppImage),
arm64Hash: sriSha256(arm64AppImage),
});
fs.writeFileSync('nix/release.nix', releaseNix);
console.log(`Updated nix/release.nix for Netcatty ${version}`);
+222
View File
@@ -0,0 +1,222 @@
name: build-et-binaries
# Trigger philosophy (mirrors build-mosh-binaries.yml):
# - Pushes that touch the et 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-et-bin` by default).
#
# `paths` keeps unrelated commits (UI, bridges, etc) from rebuilding the et
# binaries on every push.
on:
workflow_dispatch:
inputs:
et_ref:
description: "EternalTerminal git ref (tag/branch/commit) — see https://github.com/MisterTea/EternalTerminal"
type: string
default: "et-v6.2.10"
release_tag:
description: "Optional release tag to attach binaries to (e.g. et-bin-6.2.10-1). Empty = artifacts only."
type: string
default: ""
release_repo:
description: "Repository that stores et binary releases."
type: string
default: "binaricat/Netcatty-et-bin"
push:
branches:
- "**"
paths:
- ".github/workflows/build-et-binaries.yml"
- "electron-builder.config.cjs"
- "package.json"
- "scripts/build-et/**"
- "scripts/fetch-et-binaries.cjs"
- "scripts/et-extra-resources.cjs"
pull_request:
paths:
- ".github/workflows/build-et-binaries.yml"
- "electron-builder.config.cjs"
- "package.json"
- "scripts/build-et/**"
- "scripts/fetch-et-binaries.cjs"
- "scripts/et-extra-resources.cjs"
concurrency:
group: build-et-binaries-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
ET_REF: ${{ inputs.et_ref || 'et-v6.2.10' }}
jobs:
# ------------------------------------------------------------------
# Linux x64 (manylinux2014 / glibc 2.17, broad distro compatibility).
# ------------------------------------------------------------------
build-linux-x64:
name: build-linux-x64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build et (linux-x64)
run: |
docker run --rm \
-e ET_REF="${ET_REF}" \
-e OUT_DIR=/work/out \
-e ARCH=x64 \
-v "${GITHUB_WORKSPACE}:/work" \
-w /work \
quay.io/pypa/manylinux2014_x86_64 \
bash scripts/build-et/build-linux.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: et-linux-x64
path: out/
build-linux-arm64:
name: build-linux-arm64
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- name: Build et (linux-arm64)
run: |
docker run --rm \
-e ET_REF="${ET_REF}" \
-e OUT_DIR=/work/out \
-e ARCH=arm64 \
-v "${GITHUB_WORKSPACE}:/work" \
-w /work \
quay.io/pypa/manylinux2014_aarch64 \
bash scripts/build-et/build-linux.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: et-linux-arm64
path: out/
# ------------------------------------------------------------------
# macOS universal2 (arm64 + x86_64 lipo). Min deployment target macOS 11.
# ------------------------------------------------------------------
build-macos-universal:
name: build-macos-universal
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Build et (darwin-universal)
env:
ET_REF: ${{ env.ET_REF }}
OUT_DIR: ${{ github.workspace }}/out
MACOSX_DEPLOYMENT_TARGET: "11.0"
run: bash scripts/build-et/build-macos.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: et-darwin-universal
path: out/
# ------------------------------------------------------------------
# Windows x64 — static MSVC build (no DLL bundle).
# ------------------------------------------------------------------
build-windows-x64:
name: build-windows-x64
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Install ninja
run: choco install -y ninja
- name: Set up MSVC developer command prompt
uses: ilammy/msvc-dev-cmd@v1
with:
arch: x64
- name: Build et (win32-x64)
env:
ET_REF: ${{ env.ET_REF }}
OUT_DIR: ${{ github.workspace }}\out
shell: pwsh
run: pwsh -File scripts/build-et/build-windows.ps1
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: et-win32-x64
path: out/
# ------------------------------------------------------------------
# Windows arm64 — intentionally not built until a tested client exists.
# ------------------------------------------------------------------
# ------------------------------------------------------------------
# Aggregate + optional release to the dedicated binary repository.
# ------------------------------------------------------------------
release:
name: release
needs:
- build-linux-x64
- build-linux-arm64
- build-macos-universal
- build-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" =~ ^et-bin-[A-Za-z0-9._-]+$ ]]; then
echo "Invalid et binary release tag: $tag" >&2
exit 1
fi
printf 'name=%s\n' "$tag" >> "$GITHUB_OUTPUT"
- name: Create / update release
env:
GH_TOKEN: ${{ secrets.ET_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::ET_BIN_RELEASE_TOKEN is required to publish into ${RELEASE_REPO}."
exit 1
fi
{
printf '%s\n' 'Pre-built EternalTerminal `et` client binaries consumed by `scripts/fetch-et-binaries.cjs` during `npm run pack`.'
printf 'Built from `MisterTea/EternalTerminal` upstream ref `%s`.\n\n' "${ET_REF}"
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 Apache-2.0; see `resources/et/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
+640 -28
View File
@@ -1,5 +1,23 @@
name: build-packages
# Trigger philosophy
# - Any push to any branch + any PR -> run the build matrix so CI is
# always testable. Same-repo PR runs own package validation; matching
# branch push runs become a lightweight mirror only after a current
# open PR run for the same commit is visible. If lookup is slow or
# unavailable, the push run falls back to the full matrix. Artifacts
# upload as workflow artifacts only; *no* GitHub Release is published.
# - Tag push matching `v<MAJOR>.<MINOR>.<PATCH>` (with optional
# pre-release suffix like `v1.2.3-rc.1`) -> run the matrix and
# publish a GitHub Release. Loose tags like `v-test`, `vNEXT`, or
# `v1.0` no longer auto-publish.
# - Manual `workflow_dispatch` -> run the matrix on the selected ref.
# `publish_release` only publishes when the selected ref is also a
# strict version tag.
#
# The release job validates the exact same rule before publishing, so
# adding branches/PRs above is safe; accidental tag-like branch names
# won't leak a release.
on:
workflow_dispatch:
inputs:
@@ -7,13 +25,214 @@ on:
description: "Publish GitHub Release after build"
type: boolean
default: false
mosh_bin_release:
description: "Release tag containing bundled mosh-client binaries"
type: string
default: ""
et_bin_release:
description: "Release tag containing bundled et (EternalTerminal) binaries"
type: string
default: ""
push:
branches:
- "**"
tags:
- "v*"
- "v[0-9]+.[0-9]+.[0-9]+"
- "v[0-9]+.[0-9]+.[0-9]+-[0-9A-Za-z]*"
pull_request:
# A newer run for the same push branch or PR cancels older in-progress
# work. Push and PR events stay in separate groups so deduped push runs
# can mirror PR results cleanly instead of leaving cancelled checks on
# the PR. Publishing tag runs share a release group across push and
# manual dispatch; non-publishing manual tag runs use their own group.
concurrency:
group: build-packages-${{ github.workflow }}-${{ startsWith(github.ref, 'refs/tags/') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release)) && 'release' || github.event_name }}-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.ref_type }}-${{ github.event.pull_request.head.ref || github.ref_name }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
permissions:
actions: read
contents: read
pull-requests: read
env:
MOSH_BIN_RELEASE: ${{ github.event.inputs.mosh_bin_release || vars.MOSH_BIN_RELEASE || '' }}
BUNDLE_MOSH: ${{ (startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))) || (github.event_name == 'workflow_dispatch' && inputs.mosh_bin_release != '') }}
ET_BIN_RELEASE: ${{ github.event.inputs.et_bin_release || vars.ET_BIN_RELEASE || '' }}
BUNDLE_ET: ${{ (startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))) || (github.event_name == 'workflow_dispatch' && inputs.et_bin_release != '') }}
STRICT_VERSION_REF_RE: '^refs/tags/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[A-Za-z][0-9A-Za-z-]*|[0-9A-Za-z][0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[A-Za-z][0-9A-Za-z-]*|[0-9A-Za-z][0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?$'
jobs:
dedupe:
name: dedupe push run
runs-on: ubuntu-latest
outputs:
skip_heavy_ci: ${{ steps.detect.outputs.skip_heavy_ci }}
heavy_ci_pr_run_id: ${{ steps.detect.outputs.heavy_ci_pr_run_id }}
steps:
- name: Detect duplicate heavy CI
id: detect
shell: bash
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
REPOSITORY_OWNER: ${{ github.repository_owner }}
EVENT_NAME: ${{ github.event_name }}
REF: ${{ github.ref }}
HEAD_REF: ${{ github.ref_name }}
HEAD_SHA: ${{ github.sha }}
run: |
skip_heavy_ci=false
if [[ "$EVENT_NAME" == "push" && "$REF" == refs/heads/* ]]; then
pr_count=0
if ! pr_count="$(gh api --method GET "repos/${REPOSITORY}/pulls" \
-f state=open \
-f "head=${REPOSITORY_OWNER}:${HEAD_REF}" \
-F per_page=1 \
--jq 'length')"; then
echo "::warning::Could not check open PRs; running full push CI."
pr_count=0
fi
pr_run_id=""
if [[ "$pr_count" != "0" ]]; then
cutoff="$(date -u -d '20 minutes ago' +'%Y-%m-%dT%H:%M:%SZ')"
for attempt in {1..18}; do
if ! pr_run_id="$(gh api --method GET "repos/${REPOSITORY}/actions/workflows/build.yml/runs" \
-f event=pull_request \
-f "branch=${HEAD_REF}" \
-f "head_sha=${HEAD_SHA}" \
-F per_page=20 \
--jq "[.workflow_runs[] | select(.created_at >= \"${cutoff}\" and .conclusion != \"cancelled\" and .conclusion != \"skipped\")] | sort_by(.created_at, .id) | .[0].id // \"\"")"; then
echo "::warning::Could not check PR workflow runs; running full push CI."
pr_run_id=""
break
fi
if [[ -n "$pr_run_id" ]]; then
skip_heavy_ci=true
break
fi
if [[ "$attempt" == "18" ]]; then
break
fi
sleep 10
done
fi
if [[ -n "$pr_run_id" ]]; then
echo "heavy_ci_pr_run_id=${pr_run_id}" >> "$GITHUB_OUTPUT"
echo "heavy_ci_pr_run_id=${pr_run_id}"
fi
fi
echo "skip_heavy_ci=${skip_heavy_ci}" >> "$GITHUB_OUTPUT"
echo "skip_heavy_ci=${skip_heavy_ci}"
dedupe-result:
name: dedupe result
needs: dedupe
if: needs.dedupe.outputs.skip_heavy_ci == 'true'
runs-on: ubuntu-latest
steps:
- name: Mirror PR build result
shell: bash
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
PR_RUN_ID: ${{ needs.dedupe.outputs.heavy_ci_pr_run_id }}
run: |
if [[ -z "$PR_RUN_ID" ]]; then
echo "::error::No PR workflow run was selected for dedupe."
exit 1
fi
for attempt in {1..360}; do
if ! result="$(gh run view "$PR_RUN_ID" --repo "$REPOSITORY" --json status,conclusion --jq '.status + "|" + (.conclusion // "")')"; then
echo "::warning::Could not read PR workflow run ${PR_RUN_ID}; retrying."
sleep 30
continue
fi
status="${result%%|*}"
conclusion="${result#*|}"
echo "PR run ${PR_RUN_ID}: status=${status} conclusion=${conclusion:-pending}"
if [[ "$status" == "completed" ]]; then
if [[ "$conclusion" == "success" ]]; then
exit 0
fi
echo "::error::PR workflow run ${PR_RUN_ID} completed with conclusion '${conclusion}'."
exit 1
fi
sleep 30
done
echo "::error::Timed out waiting for PR workflow run ${PR_RUN_ID}."
exit 1
resolve-mosh:
name: resolve bundled mosh-client
needs: dedupe
if: |
needs.dedupe.outputs.skip_heavy_ci != 'true'
&& (
(startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release)))
|| (github.event_name == 'workflow_dispatch' && inputs.mosh_bin_release != '')
)
runs-on: ubuntu-latest
outputs:
mosh_bin_release: ${{ steps.resolve.outputs.mosh_bin_release }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve bundled mosh-client release
id: resolve
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
node scripts/resolve-mosh-bin-release.cjs
release="$(grep '^MOSH_BIN_RELEASE=' "$GITHUB_ENV" | tail -n 1 | cut -d= -f2-)"
if [[ -z "$release" ]]; then
echo "::error::MOSH_BIN_RELEASE was not resolved."
exit 1
fi
echo "mosh_bin_release=${release}" >> "$GITHUB_OUTPUT"
resolve-et:
name: resolve bundled et-client
needs: dedupe
if: |
needs.dedupe.outputs.skip_heavy_ci != 'true'
&& (
(startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release)))
|| (github.event_name == 'workflow_dispatch' && inputs.et_bin_release != '')
)
runs-on: ubuntu-latest
outputs:
et_bin_release: ${{ steps.resolve.outputs.et_bin_release }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve bundled et-client release
id: resolve
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
node scripts/resolve-et-bin-release.cjs
release="$(grep '^ET_BIN_RELEASE=' "$GITHUB_ENV" | tail -n 1 | cut -d= -f2-)"
if [[ -z "$release" ]]; then
echo "::error::ET_BIN_RELEASE was not resolved."
exit 1
fi
echo "et_bin_release=${release}" >> "$GITHUB_OUTPUT"
build:
name: build-${{ matrix.name }}
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && format('deduped build-{0}', matrix.name) || format('build-{0}', matrix.name) }}
needs: [dedupe, resolve-mosh, resolve-et]
if: |
always()
&& needs.dedupe.result == 'success'
&& needs.dedupe.outputs.skip_heavy_ci != 'true'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@@ -24,13 +243,40 @@ jobs:
pack_script: pack:mac
- name: windows
os: windows-latest
pack_script: pack:win
# The mosh binary workflow currently produces win32-x64 only.
# Keep official packages aligned with bundled-mosh coverage
# until Cygwin arm64 is stable enough to build win32-arm64.
pack_script: pack:win-x64
env:
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
ET_BIN_RELEASE: ${{ needs.resolve-et.outputs.et_bin_release }}
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 }}
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
steps:
- name: Validate bundled mosh-client release
if: env.BUNDLE_MOSH == 'true'
shell: bash
env:
RESOLVE_MOSH_RESULT: ${{ needs.resolve-mosh.result }}
run: |
if [[ "$RESOLVE_MOSH_RESULT" != "success" || -z "$MOSH_BIN_RELEASE" ]]; then
echo "::error::Bundled mosh-client release was not resolved for this package build."
exit 1
fi
- name: Validate bundled et-client release
if: env.BUNDLE_ET == 'true'
shell: bash
env:
RESOLVE_ET_RESULT: ${{ needs.resolve-et.result }}
run: |
if [[ "$RESOLVE_ET_RESULT" != "success" || -z "$ET_BIN_RELEASE" ]]; then
echo "::error::Bundled et-client release was not resolved for this package build."
exit 1
fi
- name: Checkout
uses: actions/checkout@v4
@@ -43,15 +289,38 @@ jobs:
- name: Install deps
run: npm ci
- name: Fetch bundled mosh-client
if: env.BUNDLE_MOSH == 'true'
shell: bash
run: |
if [[ "${{ matrix.name }}" == "macos" ]]; then
npm run fetch:mosh -- --platform=darwin --arch=universal
elif [[ "${{ matrix.name }}" == "windows" ]]; then
npm run fetch:mosh -- --platform=win32 --arch=x64
fi
- name: Fetch bundled et-client
if: env.BUNDLE_ET == 'true'
shell: bash
run: |
if [[ "${{ matrix.name }}" == "macos" ]]; then
npm run fetch:et -- --platform=darwin --arch=universal
elif [[ "${{ matrix.name }}" == "windows" ]]; then
npm run fetch:et -- --platform=win32 --arch=x64
fi
- name: Set version
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
# Tag release: use version from tag
# Strict semver matches v<MAJOR>.<MINOR>.<PATCH>[-pre]; loose
# tags / branches / PRs fall through to a semver-pre-release
# form (`0.0.0-sha-<short-sha>`) so npm pkg / electron-builder
# accept it. Non-semver versions (e.g. bare "abc1234") cause
# downstream tooling to error or pick weird codepaths.
if [[ "$GITHUB_REF" =~ $STRICT_VERSION_REF_RE ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
# workflow_dispatch: use short commit ID
VERSION="${GITHUB_SHA:0:7}"
VERSION="0.0.0-sha-${GITHUB_SHA:0:7}"
fi
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
@@ -79,46 +348,231 @@ jobs:
release/*.AppImage
release/*.deb
release/*.rpm
release/*.pacman
release/*.tar.gz
release/*.yml
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: build-linux-x64
runs-on: ubuntu-22.04
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }}
needs: [dedupe, resolve-mosh, resolve-et]
if: |
always()
&& needs.dedupe.result == 'success'
&& needs.dedupe.outputs.skip_heavy_ci != 'true'
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 }}
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
steps:
- name: Validate bundled mosh-client release
if: env.BUNDLE_MOSH == 'true'
shell: bash
env:
RESOLVE_MOSH_RESULT: ${{ needs.resolve-mosh.result }}
run: |
if [[ "$RESOLVE_MOSH_RESULT" != "success" || -z "$MOSH_BIN_RELEASE" ]]; then
echo "::error::Bundled mosh-client release was not resolved for this package build."
exit 1
fi
- name: Validate bundled et-client release
if: env.BUNDLE_ET == 'true'
shell: bash
env:
RESOLVE_ET_RESULT: ${{ needs.resolve-et.result }}
run: |
if [[ "$RESOLVE_ET_RESULT" != "success" || -z "$ET_BIN_RELEASE" ]]; then
echo "::error::Bundled et-client release was not resolved for this package build."
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
run: |
python3 --version
g++ --version
npm ci
- name: Set version
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
# See matrix job's Set version step for the strict-semver
# rationale; identical logic, duplicated because the Linux
# legs are standalone jobs.
if [[ "$GITHUB_REF" =~ $STRICT_VERSION_REF_RE ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="${GITHUB_SHA:0:7}"
VERSION="0.0.0-sha-${GITHUB_SHA:0:7}"
fi
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
@@ -128,11 +582,35 @@ jobs:
npm_config_arch: x64
run: bash scripts/ensure-node-pty-linux.sh prepare x64
- name: Fetch bundled mosh-client
if: env.BUNDLE_MOSH == 'true'
run: npm run fetch:mosh -- --platform=linux --arch=x64
- name: Fetch bundled et-client
if: env.BUNDLE_ET == 'true'
run: npm run fetch:et -- --platform=linux --arch=x64
- name: Build package
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
@@ -140,6 +618,9 @@ jobs:
- name: Verify packaged deb artifact
run: bash scripts/verify-linux-deb-artifact.sh amd64
- name: Verify packaged rpm artifact
run: bash scripts/verify-linux-rpm-artifact.sh x86_64
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
@@ -148,6 +629,7 @@ jobs:
release/*.AppImage
release/*.deb
release/*.rpm
release/*.pacman
release/*.yml
release/*.blockmap
if-no-files-found: ignore
@@ -156,11 +638,18 @@ jobs:
# to ensure compatibility with older distros like UOS/Deepin (GLIBC 2.28).
# Key: GLIBC < 2.34 avoids the libpthread-merge symbol requirement.
build-linux-arm64:
name: build-linux-arm64
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }}
needs: [dedupe, resolve-mosh, resolve-et]
if: |
always()
&& needs.dedupe.result == 'success'
&& needs.dedupe.outputs.skip_heavy_ci != 'true'
runs-on: ubuntu-24.04-arm
container:
image: debian:bullseye
env:
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
ET_BIN_RELEASE: ${{ needs.resolve-et.outputs.et_bin_release }}
npm_config_arch: arm64
npm_config_target_arch: arm64
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
@@ -168,10 +657,33 @@ jobs:
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
steps:
- name: Validate bundled mosh-client release
if: env.BUNDLE_MOSH == 'true'
shell: bash
env:
RESOLVE_MOSH_RESULT: ${{ needs.resolve-mosh.result }}
run: |
if [[ "$RESOLVE_MOSH_RESULT" != "success" || -z "$MOSH_BIN_RELEASE" ]]; then
echo "::error::Bundled mosh-client release was not resolved for this package build."
exit 1
fi
- name: Validate bundled et-client release
if: env.BUNDLE_ET == 'true'
shell: bash
env:
RESOLVE_ET_RESULT: ${{ needs.resolve-et.result }}
run: |
if [[ "$RESOLVE_ET_RESULT" != "success" || -z "$ET_BIN_RELEASE" ]]; then
echo "::error::Bundled et-client release was not resolved for this package build."
exit 1
fi
- name: Install build dependencies
run: |
apt-get update
apt-get install -y curl build-essential python3 git libfuse2 file rpm \
libarchive-tools \
libglib2.0-0 libgtk-3-0 libnss3 libxss1 libxtst6 libasound2 \
libatk-bridge2.0-0 libdrm2 libgbm1 libx11-xcb1 libxcb-dri3-0
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
@@ -186,10 +698,13 @@ jobs:
- name: Set version
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
# See matrix job's Set version step for the strict-semver
# rationale; identical logic, duplicated because the Linux
# legs are standalone jobs.
if [[ "$GITHUB_REF" =~ $STRICT_VERSION_REF_RE ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="${GITHUB_SHA:0:7}"
VERSION="0.0.0-sha-${GITHUB_SHA:0:7}"
fi
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
@@ -199,6 +714,14 @@ jobs:
npm_config_arch: arm64
run: bash scripts/ensure-node-pty-linux.sh prepare arm64
- name: Fetch bundled mosh-client
if: env.BUNDLE_MOSH == 'true'
run: npm run fetch:mosh -- --platform=linux --arch=arm64
- name: Fetch bundled et-client
if: env.BUNDLE_ET == 'true'
run: npm run fetch:et -- --platform=linux --arch=arm64
- name: Build package
env:
npm_config_arch: arm64
@@ -211,6 +734,9 @@ jobs:
- name: Verify packaged deb artifact
run: bash scripts/verify-linux-deb-artifact.sh arm64
- name: Verify packaged rpm artifact
run: bash scripts/verify-linux-rpm-artifact.sh aarch64
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
@@ -219,6 +745,7 @@ jobs:
release/*.AppImage
release/*.deb
release/*.rpm
release/*.pacman
release/*.yml
release/*.blockmap
if-no-files-found: ignore
@@ -227,7 +754,12 @@ jobs:
name: release
runs-on: ubuntu-latest
needs: [build, build-linux-x64, build-linux-arm64]
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
# Only release on a strict v<MAJOR>.<MINOR>.<PATCH>[-pre] tag.
# Manual workflow_dispatch can publish only when it is run from one
# of those tags. PRs and branch pushes skip this job.
if: |
startsWith(github.ref, 'refs/tags/v')
&& (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))
permissions:
contents: write
actions: read
@@ -235,6 +767,14 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Validate release tag
shell: bash
run: |
if [[ ! "$GITHUB_REF" =~ $STRICT_VERSION_REF_RE ]]; then
echo "::error::Release tags must be v<MAJOR>.<MINOR>.<PATCH> or v<MAJOR>.<MINOR>.<PATCH>-<prerelease>."
exit 1
fi
- name: Download artifacts
uses: actions/download-artifact@v4
with:
@@ -303,6 +843,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
body_path: release_notes.md
prerelease: ${{ contains(github.ref_name, '-') }}
files: |
artifacts/*.dmg
artifacts/*.zip
@@ -310,8 +851,79 @@ jobs:
artifacts/*.AppImage
artifacts/*.deb
artifacts/*.rpm
artifacts/*.pacman
artifacts/*.yml
artifacts/*.blockmap
generate_release_notes: true
fail_on_unmatched_files: false
token: ${{ secrets.RELEASE_TOKEN }}
update-nix-release:
name: update Nix release metadata
runs-on: ubuntu-latest
needs: release
if: |
startsWith(github.ref, 'refs/tags/v')
&& !contains(github.ref_name, '-')
&& (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))
permissions:
contents: write
actions: read
steps:
- name: Checkout default branch
uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}
token: ${{ secrets.RELEASE_TOKEN }}
- name: Download Linux artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Update Nix release metadata
run: node .github/scripts/update-nix-release.js --artifacts artifacts --version "${GITHUB_REF_NAME#v}"
- name: Commit Nix release metadata
shell: bash
run: |
if git diff --quiet -- nix/release.nix; then
echo "Nix release metadata is already current."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add nix/release.nix
git commit -m "Update Nix release metadata for ${GITHUB_REF_NAME}"
git push origin HEAD:${{ github.event.repository.default_branch }}
homebrew-tap:
name: bump homebrew tap
runs-on: ubuntu-latest
needs: release
# Only stable release tags update the Cask. Prerelease tags
# (e.g. v1.2.0-rc.1) are skipped so brew users stay on stable.
if: |
startsWith(github.ref, 'refs/tags/v')
&& !contains(github.ref_name, '-')
&& (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download macOS artifacts
uses: actions/download-artifact@v4
with:
name: netcatty-macos
path: artifacts/
- name: Bump Cask in binaricat/homebrew-netcatty
env:
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
ARTIFACTS_DIR: artifacts
run: |
# Strip the leading "v" — Cask version is plain semver.
VERSION="${GITHUB_REF_NAME#v}"
export VERSION
bash .github/scripts/bump-homebrew-cask.sh
+139
View File
@@ -0,0 +1,139 @@
name: issue-format
on:
issues:
types: [opened, edited]
permissions:
issues: write
jobs:
validate:
runs-on: ubuntu-latest
# Skip issues opened by bots (e.g. dependabot) and maintainers fixing format
if: >-
github.event.issue.user.type != 'Bot' &&
!contains(github.event.issue.labels.*.name, 'format-exempt')
steps:
- name: Validate title and body
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
const title = issue.title.trim();
const body = (issue.body || '').trim();
const errors = [];
const modernTitle = /^\[(Bug|Feature)\] .{8,}/.test(title);
const legacyAppTitle = /^Bug:\s*.{5,}/i.test(title);
if (!modernTitle && !legacyAppTitle) {
errors.push(
'Title must start with `[Bug]` or `[Feature]` followed by a short summary (at least 8 characters after the prefix). Legacy app links using `Bug: ...` are also accepted. Example: `[Bug] SFTP upload fails on Windows`'
);
}
if (body.length < 120) {
errors.push(
'Body is too short. Please use the Bug Report or Feature Request template and fill in all required fields.'
);
}
const templateMarkers = [
'Steps to reproduce',
'Expected behavior',
'Actual behavior',
'Describe the problem',
'Problem / pain point',
'Proposed solution',
'Operating system',
];
const hasTemplateStructure = templateMarkers.some((marker) =>
body.includes(marker)
);
if (!hasTemplateStructure) {
errors.push(
'Body does not look like it came from an issue template. Choose **Bug Report** or **Feature Request** when opening an issue.'
);
}
const labels = new Set(
(issue.labels || []).map((label) =>
typeof label === 'string' ? label : label.name
)
);
if (errors.length === 0) {
if (
issue.state === 'closed' &&
labels.has('invalid-format')
) {
labels.delete('invalid-format');
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: 'open',
labels: [...labels],
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: '<!-- issue-format-bot --> Format looks good now. Reopening this issue.',
});
}
core.info('Issue format OK');
return;
}
const issueNumber = issue.number;
const marker = '<!-- issue-format-bot -->';
const bodyText = [
marker,
'## Issue format check failed',
'',
'This issue was closed automatically because it does not follow the required format.',
'',
...errors.map((e) => `- ${e}`),
'',
'### How to resubmit',
'',
'1. Go to [New Issue](https://github.com/binaricat/Netcatty/issues/new/choose)',
'2. Pick **Bug Report** or **Feature Request**',
'3. Fill in every required field',
'4. Keep the `[Bug]` or `[Feature]` prefix in the title and add a clear summary after it (older app versions may use `Bug: ...`)',
'',
'For questions and open-ended discussion, use [GitHub Discussions](https://github.com/binaricat/Netcatty/discussions) instead.',
'',
'If you believe this was a mistake, reply here after fixing the title/body and a maintainer can reopen.',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
per_page: 100,
});
const alreadyNotified = comments.some((c) =>
(c.body || '').includes(marker)
);
if (!alreadyNotified) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: bodyText,
});
}
labels.add('invalid-format');
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
state: 'closed',
state_reason: 'not_planned',
labels: [...labels],
});
+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('; ')}`
);
+47
View File
@@ -0,0 +1,47 @@
name: test
on:
pull_request:
push:
branches:
- "**"
concurrency:
group: test-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
name: lint-and-test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install shell test dependencies
run: |
sudo apt-get update
sudo apt-get install -y fish
- name: Install deps
run: npm ci
- name: Lint
run: npm run lint
- name: Verify generated Catty tool specs
run: |
npm run generate:capability-tools
git diff --exit-code infrastructure/ai/harness/generated/cattyToolSpecs.json
- name: Test
run: npm test
+32 -1
View File
@@ -8,6 +8,7 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
@@ -40,10 +41,22 @@ coverage
# Codex
/.codex/
/CLAUDE.md
# OMO / local agent run-continuation state
/.omo/
# Qoder
.qoder
# Workbuddy
.workbuddy
# Codebuddy
.codebuddy
# AI / Superpowers generated docs (local only)
/docs/superpowers/
/.superpowers/
# Dev-only electron-updater test config (not for production)
/dev-app-update.yml
@@ -55,8 +68,26 @@ coverage
# Serena MCP project config (local only)
/.serena/
# Git worktrees (local isolated workspaces)
/.worktrees/
# Windows VS Build environment scripts (local dev only)
Directory.Build.props
Directory.Build.targets
build_with_vs.bat
build_with_vs2022.bat
# 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
# Bundled EternalTerminal `et` client binaries fetched at pack time by
# scripts/fetch-et-binaries.cjs. resources/et/README.md is committed; the
# actual binaries (and any DLL bundle for dynamically-linked Windows builds)
# are pulled from the dedicated et binary repository, never committed.
/resources/et/*/et
/resources/et/*/et.exe
/resources/et/*/et-*-dlls/
/resources/et/*/*.dll
+76 -8
View File
@@ -18,10 +18,72 @@ This project is wired around three layers: domain (pure logic), application stat
- **UI** (`components/`, `App.tsx`): Presentation; depends on hooks and domain helpers only.
## How Things Talk
- UI calls application hooks → hooks call domain helpers → persistence/config via infrastructure adapters.
- UI calls application hooks -> hooks call domain helpers -> persistence/config via infrastructure adapters.
- `App.tsx` wires hooks to components; no business logic should live in components beyond view glue.
- Local storage keys are centralized in `infrastructure/config/storageKeys.ts`; avoid ad-hoc `localStorage` calls elsewhere.
## AI Agent Harness (`infrastructure/ai/harness/`)
Turn orchestration is centralized in **AgentRuntime**; the React hook `useAIChatStreaming` only manages UI state and delegates `runTurn` / `stopTurn`.
| Layer | Module | Role |
|-------|--------|------|
| Runtime | `agentRuntime.ts`, `globalAgentRuntime.ts` | Turn lifecycle, trace fan-out, per-turn ToolOutputStore / ToolResultDedup |
| Drivers | `turnDrivers/cattyTurnDriver.ts`, `turnDrivers/externalSdkTurnDriver.ts` | Catty `streamText` + External SDK IPC; emit unified `AgentEvent`s |
| Context | `contextManager.ts`, `contextBudget.ts`, `tokenEstimator.ts`, `sessionState.ts`, `staleContextPruner.ts`, `compactionPruner.ts`, `cattyRuntime.ts` | Pre-turn / step / 413 compaction, dynamic thresholds, SessionState reinjection, stale tool pruning |
| Tools | `capabilityTools.ts`, `toolOutputStore.ts`, `toolResultDedup.ts` | Catalog tools, truncated output handles (`tool_output_read`), duplicate-read notices |
| Trace | `traceStore.ts`, `agentEventAdapter.ts` | Session event log incl. `usage`, `performance`, and `CompactionTrace` |
**Stop** always goes through `stopAgentTurn()` (UI, `/stop`, MCP). Do not add parallel abort paths in hooks.
### AI SDK v7 (Catty path)
Catty sidebar turns use **Vercel AI SDK 7** via `streamText` in `turnDrivers/cattyStreamProcessor.ts`. Key conventions:
| Concern | Module | Notes |
|---------|--------|-------|
| `runtimeContext` | `cattyRuntimeContext.ts` | Per-turn orchestration state (`chatSessionId`, `turnId`, `agentKind`, `permissionMode`, scope, `lastCompaction`). Passed to `prepareStep` and lifecycle callbacks. **Does not replace** pre-turn compaction or 413 handling. |
| `toolsContext` | `capabilityTools.ts` | Per-tool keyed context (`bridge`, `getExecutorContext`, `toolOutputStore`, …). Tools read deps from `{ context }` in `execute`, not closure capture. |
| `toolApproval` | `cattyToolApproval.ts` | Write-tool gating for Catty `streamText` only. Calls `requestApproval()` in confirm mode; observer auto-denies writes. **External MCP agents** still approve via main-process `mcpServerBridge` → `setupMcpApprovalBridge()` — unchanged. |
| `timeout` | `streamTimeouts.ts` | `totalMs` / `stepMs` / `chunkMs` / `toolMs` on `streamText`; compaction `generateText` uses a shorter 90s timeout. |
| Lifecycle | `cattyStreamProcessor.ts` | `onStart` → `model_call_start`; `onStepEnd` → per-step `step_end` usage; `onEnd` → turn-total `usage`; `finalStep.performance` → `performance` event. Distinct from `AgentRuntime` turn_start/turn_end. |
Compaction remains **`prepareTurnContext` / `compactCattyMessages`** (pre-turn + 413-retry). Step-level pruning is **`prepareStepContext`** only (typed compression + handle notices, no LLM summarize).
### Capability exposure (Round 2 + gap fill)
Single source of truth: `electron/capabilities/catalog/` + `electron/capabilities/codegen/toolSurfaces.cjs`.
**Agent kinds** (where an in-app agent runs — orthogonal to MCP/CLI/RPC surfaces):
| Kind | UI | Tool list | Notes |
|------|-----|-----------|--------|
| `sidebar` | Chat side panel (Catty) | `listAgentToolSpecs('sidebar')` → `cattyToolSpecs.json` | Includes `harness.*` renderer-local tools (`surfaces.catty`) |
| `global` | Future app-wide agent | `listAgentToolSpecs('global')` → `globalAgentToolSpecs.json` | Shared RPC tools (terminal, SFTP, vault, …); **no** sidebar-only harness tools unless opted in |
Placement rules (`resolveAgentKinds` in `toolSurfaces.cjs`):
- Explicit `agentKinds` on a catalog entry overrides inference.
- `surfaces.globalAgent` only → global agent (future global-only local tools).
- `surfaces.catty` only (harness) → sidebar only.
- RPC/MCP-backed tools → both agents unless restricted via `agentKinds`.
| Surface | Codegen / consumer | Notes |
|---------|-------------------|--------|
| Catty (sidebar) tools | `npm run generate:capability-tools` → `infrastructure/ai/harness/generated/cattyToolSpecs.json` | Sidebar agent tool set. CI verifies JSON drift. |
| Global agent tools | same script → `globalAgentToolSpecs.json` | Prepared for future global agent runtime; shared RPC tools only today. |
| MCP stdio | `electron/capabilities/codegen/mcpToolRegistry.cjs` → `electron/mcp/netcatty-mcp-server.cjs` | Registry-driven; external agents. Harness tools are **not** on MCP. |
| CLI | `electron/cli/netcatty-tool-cli.cjs` + `electron/capabilities/adapters/cliAdapter.cjs` | **30** catalog commands; exec/sftp/session remain special-case; vault/portforward/snippets use catalog fallback dispatch |
| RPC dispatch | `electron/bridges/mcpServerBridge.cjs` + `capabilityRpcDispatch.cjs` | `netcatty/*` builtin handlers via `buildBuiltinRpcHandlerRegistry` (catalog-aligned); `public/*`, `vault/*`, `portforward/*` → services |
| Vault bridge | `electron/bridges/aiBridge/vaultAgentBridge.cjs` + `infrastructure/ai/vaultAgentBridgeClient.ts` | Renderer vault state; **never** returns password/privateKey |
| AI context | `buildAITerminalSessionInfo` + `useTerminalAiContexts` | Per-session `hostChain` + `activePortForwards`; mirrored in `getContext` and Catty system prompt |
**Policy:** SFTP writes/transfers, `portforward_start`, and `host_notes_set` require confirm-mode approval. Observer mode blocks writes.
**Handles:** `ToolOutputStore` persists across turns per chat session; cleared on chat session delete. Large `sftp.read` results spill to `tool_output_read`.
**Harness domain (`catalog/harness.cjs`):** Catty-only surface (`surfaces.catty.toolName`). Registered in the capability catalog but executed locally in `capabilityTools.executeLocalCattyCapability` (not MCP/CLI). `harness.web.search` is omitted when web search is not configured.
## Extending the System
1) **New domain logic**: Add pure functions/types under `domain/`; avoid side effects.
2) **New stateful behavior**: Wrap it in a hook under `application/state/`; keep external I/O behind adapters.
@@ -44,6 +106,12 @@ This project is wired around three layers: domain (pure logic), application stat
- Avoid direct network/fetch in components; add a service/adaptor first.
- Maintain ASCII-only unless required by existing file content.
## Review Boundaries
- Treat `electron/cli/*`, `netcatty-tool-cli`, the CLI discovery file, and the local TCP bridge as internal Netcatty integration surfaces unless a task explicitly says otherwise.
- Do not review those surfaces as public APIs by default, and do not assume they must support third-party callers, manual launches, or non-Netcatty agents.
- On supported first-party paths, assume Netcatty's own launcher provides required integration environment such as `NETCATTY_TOOL_CLI_DISCOVERY_FILE`.
- If a review concern depends on external exposure, third-party compatibility, or public API stability, call it out as out of scope unless the task explicitly includes that contract.
---
## Aside Panel Design System
@@ -54,20 +122,20 @@ VaultView subpages (Hosts, Keychain, Port Forwarding, Snippets, Known Hosts) sha
Import from `./ui/aside-panel`:
```tsx
import {
AsidePanel,
AsidePanelHeader,
AsidePanelContent,
import {
AsidePanel,
AsidePanelHeader,
AsidePanelContent,
AsidePanelFooter,
AsideActionMenu,
AsideActionMenuItem
AsideActionMenuItem
} from "./ui/aside-panel";
```
### Basic Usage
```tsx
<AsidePanel
open={isOpen}
<AsidePanel
open={isOpen}
onClose={handleClose}
title="Panel Title"
subtitle="Optional subtitle"
+962 -1174
View File
File diff suppressed because it is too large. Load diff
+62
View File
@@ -0,0 +1,62 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
# Install dependencies
npm install
# Start dev server (runs lint first, then Vite + Electron concurrently)
npm run dev
# Lint
npm run lint
npm run lint:fix
# Run all tests
npm test
# Run a single test file
node --test --import tsx path/to/file.test.ts
# Build renderer
npm run build
# Package for current platform
npm run pack
# Package for specific platforms
npm run pack:mac
npm run pack:win
npm run pack:linux
```
## Architecture
Netcatty is an Electron + React desktop app (SSH manager, terminal, SFTP browser). It has two runtimes:
### Electron Main Process (`electron/`)
- **`main.cjs`** — entry point; wires crash logging, process error guards, and delegates to `main/registerBridges.cjs`
- **`bridges/`** — one `.cjs` file per capability domain (sshBridge, sftpBridge, terminalBridge, portForwardingBridge, aiBridge, etc.). Each bridge exposes IPC handlers via `ipcMain`. Tests live alongside the bridge file (`*.test.cjs`).
- **`preload.cjs`** — exposes a typed `window.electron` API to the renderer via `contextBridge`. Uses `preload/api.cjs` for the generated API surface.
- **`cli/`** — `netcatty-tool-cli.cjs` is a separate internal binary for tool/MCP integration; treat as internal surface only.
### Renderer Process (React + Vite)
Three-layer architecture (see `AGENTS.md` for full detail):
- **`domain/`** — pure TypeScript logic, no side effects. Models (`models.ts`), host helpers, workspace tree operations.
- **`application/state/`** — React hooks that own state and persistence boundaries. Key hooks: `useVaultState` (hosts/keys/snippets), `useSessionState` (terminal sessions/workspace), `useSettingsState` (theme/config).
- **`infrastructure/`** — external edges: `persistence/localStorageAdapter.ts` for storage, `services/` for network calls (Gemini AI, GitHub Gist sync), `config/` for defaults, storage keys, and terminal themes.
- **`components/`** — presentation only. `App.tsx` wires hooks to components; no business logic in components.
### IPC Pattern
UI calls `window.electron.*` (preload API) → IPC → bridge handler in main process. Never call `ipcRenderer` directly from components.
### Key Conventions
- All storage reads/writes go through `localStorageAdapter`; storage keys are in `infrastructure/config/storageKeys.ts`.
- Temporary files must use `tempDirBridge.getTempFilePath(fileName)` — never `os.tmpdir()` directly.
- Aside panels (VaultView subpages) use the shared design system in `components/ui/aside-panel.tsx` — see `AGENTS.md` for usage patterns.
- Renderer code is TypeScript/ESM; Electron main/bridges are CommonJS (`.cjs`).
- Path alias `@/` resolves to the repo root (configured in `vite.config.ts` and `tsconfig.json`).
+129
View File
@@ -0,0 +1,129 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported privately to the community leaders responsible for enforcement at
support@netcatty.com. Please do not report Code of Conduct incidents through
public GitHub issues.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
+93
View File
@@ -0,0 +1,93 @@
# Contributing to Netcatty
Thank you for your interest in contributing to Netcatty — an AI-powered SSH client, SFTP browser, and terminal manager built with Electron, React, and xterm.js.
Please read this guide before submitting issues or pull requests.
## Code of Conduct
This project follows the [Contributor Covenant Code of Conduct](./CODE_OF_CONDUCT.md). By participating, you agree to uphold this standard.
## Ways to Contribute
- Report bugs via [GitHub Issues](https://github.com/binaricat/Netcatty/issues)
- Suggest features or improvements
- Fix bugs or implement features listed in Issues
- Improve documentation
## Development Setup
**Prerequisites:** Node.js 22+ and npm.
```bash
# Clone the repository
git clone https://github.com/binaricat/Netcatty.git
cd Netcatty
# Install dependencies
npm ci
# Start in development mode (Vite + Electron)
npm run dev
```
## Build & Package
```bash
npm run build # Build for production
npm run pack # Package for current platform
npm run pack:mac # macOS (DMG + ZIP)
npm run pack:win # Windows (NSIS, portable, ZIP)
npm run pack:linux # Linux (AppImage, DEB, RPM, pacman)
```
## Linting and Tests
```bash
npm run lint # Run ESLint
npm test # Run the test suite
```
## Pull Request Process
1. Fork the repository.
2. Create a feature branch:
```bash
git checkout -b feature/your-feature-name
```
3. Make your changes, following the existing project style.
4. Commit with a clear message:
```bash
git commit -m 'feat: add amazing feature'
```
5. Push the branch and open a Pull Request against `main`.
6. Run `npm run lint` and `npm test` before requesting review.
7. If you changed the capability catalog, run `npm run generate:capability-tools`
and commit any generated updates.
8. Ensure all CI checks pass before requesting review.
## Commit Message Convention
We recommend following [Conventional Commits](https://www.conventionalcommits.org/):
- `feat:` — new feature
- `fix:` — bug fix
- `docs:` — documentation only
- `refactor:` — code change without feature or fix
- `chore:` — build process or tooling changes
## Architecture & Coding Guidelines
Netcatty is organized around three layers:
- Domain logic in `domain/`
- Application state in `application/state/`
- UI components in `components/`
Keep side effects in application or infrastructure code, avoid direct
`localStorage` or network calls from components, and update relevant
documentation when behavior changes.
## License
By contributing, you agree that your contributions will be licensed under the [GPL-3.0 License](./LICENSE).
+219
View File
@@ -0,0 +1,219 @@
# EternalTerminal (ET) 集成清单 — 按 Mosh 方式重做
> 目标:在上游最新架构(分支 `feat/et-history-reapply`,基于 `031bf0ee`)上,
> **完全照搬 Mosh 的方式**重新集成 EternalTerminal:
> 1. **打包客户端** —— 像 `mosh-client` 那样,把 `et` 客户端二进制构建 + 下载 +
> 捆绑进安装包,运行时只用捆绑的二进制(不依赖系统安装的 et)。
> 2. **接入协议** —— 把旧分支 `feat/eternal-terminal`(tip `67e81616`)里的 ET
> 后端 + UI 重新落到上游重构后的目录结构上,并让它启动**捆绑的** `et`。
>
> 旧实现参考:`git show 67e81616`(共 7 个 ET 提交,见 `feat/eternal-terminal`)。
> 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 做 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` 已完整实现,直接搬运)。
- **纯二进制**: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`),由 `scripts/build-et/` + `build-et-binaries.yml` 发布。
## 命名约定(镜像 Mosh / MoshCatty)
| Mosh (MoshCatty) | ET |
|------|----|
| `resources/mosh/<plat-arch>/mosh-client[.exe]` | `resources/et/<plat-arch>/et[.exe]` |
| 打包后 `<Resources>/mosh/mosh-client` | 打包后 `<Resources>/et/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` / 仓库 `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()` |
---
## Phase 1 — 打包基础设施(构建/下载/捆绑)
- [x] **1.1** `resources/et/README.md` —— 镜像 `resources/mosh/README.md`:说明
二进制来源、`Netcatty-et-bin` 发布仓库、`et-bin-*` tag、许可证(ET 为
Apache-2.0,与 GPL-3.0 兼容)、可复现构建命令。
- [x] **1.2** `.gitignore` —— 追加 ET 段(镜像 mosh 段):
`/resources/et/*/et`、`/resources/et/*/et.exe`、`/resources/et/*/*.dll`、
`/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`。
校验非系统动态库(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
`x64-windows-static`,产物 `et-win32-x64.tar.gz`(含 `et.exe`;若动态链接 CRT
则随附 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 目录。纯客户端文件为主。
- [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 用自建产物**。
- [x] **1.9** 单元测试:`scripts/fetch-et-binaries.test.cjs`、
`scripts/resolve-et-bin-release.test.cjs`、`scripts/et-extra-resources.test.cjs`
(镜像对应 mosh 测试,改名/改路径)。
- [x] **1.10** `package.json` scripts:新增
`"fetch:et": "node scripts/fetch-et-binaries.cjs"`、
`"fetch:et:dev": "node scripts/fetch-et-binaries.cjs --host --resolve-release"`;
把 `dev` 脚本改成先 `fetch:mosh:dev && fetch:et:dev`;`test` glob 已覆盖
`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` —— 四个构建 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 的 `MOSH_BIN_RELEASE`)。
## Phase 2 — 运行时定位捆绑客户端
- [x] **2.1** `electron/bridges/terminalBridge.cjs` 新增 `bundledEtClient(opts)`
—— 镜像 `bundledMoshClient`:打包路径 `<Resources>/et/et[.exe]`;dev 回退
`<projectRoot>/resources/et/<plat-arch>/et[.exe]`;导出到 module.exports。
## Phase 3 — ET 协议后端(搬运旧实现到新架构)
- [x] **3.1** 新建 `electron/bridges/terminalBridge/etSession.cjs` —— 用上游
`moshSession.cjs` 的 `createXxxSessionApi(ctx)` + `with(ctx)` 工厂模式,封装:
`ET_ASKPASS_SCRIPT`、`writeSecureFile`、`prepareEtSshEnvironment`、
`createEtAskpassArtifacts`、`cleanupStaleEtTempDirs`、
`cleanupSessionExternalAuthArtifacts`、`execOnEtSession`、`startEtSession`。
**改动点**:`etCmd` 由 `findExecutable('et')` 改为 `resolveBareEtClient()`
(取捆绑二进制);找不到时抛错(同 mosh:提示跑 `npm run fetch:et:dev`)。
Windows 若有动态链接 DLL 目录,可把该目录加进 PATH(MoshCatty 路径已无此需求)。
- [x] **3.2** `terminalBridge.cjs` 接线 `createEtSessionApi(ctx)`(镜像 moshSessionApi
的 ctx),传入 `bundledEtClient`、`tempDirBridge`、`execFile/execFileSync` 等;
解构出 `startEtSession`、`execOnEtSession`、`cleanupStaleEtTempDirs`、
`cleanupSessionExternalAuthArtifacts`、`resolveBareEtClient`。
- [x] **3.3** `init()` 调 `cleanupStaleEtTempDirs()`;`registerHandlers` 加
`ipcMain.handle("netcatty:et:start", startEtSession)`;`closeSession` 与
`cleanupAllSessions` 调 `cleanupSessionExternalAuthArtifacts(session)`;
`module.exports` 导出 `startEtSession`、`execOnEtSession`、`bundledEtClient`。
- [x] **3.4** 测试:`terminalBridge.bundledEt.test.cjs`(路径解析)+
`terminalBridge/etSession.test.cjs`(prepareEtSshEnvironment 的端口/密钥/
askpass/跳板/legacy 算法分支)。可参考旧分支是否已有 ET 测试并搬运。
## Phase 4 — domain / 类型 / preload 接口面
- [x] **4.1** `domain/models.ts`:`HostProtocol` 加 `'et'`;`ProtocolConfig.etPort?`;
`Host`/`GroupConfig` 加 `etEnabled?`/`etPort?`/`etTerminalPath?`;
`TerminalSession.etEnabled?`;`ConnectionLog.protocol` 加 `'et'`。
(照搬 `git show 794eecdf -- domain/models.ts`)
- [x] **4.2** `domain/groupConfig.ts`:加 `etEnabled` 默认项(照搬旧 diff)。
- [x] **4.3** `global.d.ts`:`NetcattyBridge` 加 `startEtSession?(options): Promise<...>`
及相关 options 类型(照搬 `git show 794eecdf -- global.d.ts`,并补齐后续 ET 提交
新增的 etPort/terminalPath/jumpHosts/legacyAlgorithms 字段)。
- [x] **4.4** `electron/preload/api.cjs`:加 `startEtSession`(镜像第 26 行的
`startMoshSession`)→ `ipcRenderer.invoke("netcatty:et:start", options)`。
**注意**:上游已把 preload 重构成 `createPreloadApi`,落点在 `preload/api.cjs`,
不是旧的 `preload.cjs` 内联对象。
## Phase 5 — 渲染层 + UI + i18n
- [x] **5.1** `application/state/useTerminalBackend.ts`:加 `etAvailable`(查
`bridge?.startEtSession`)+ `startEtSession`,并在返回对象/依赖数组里登记
(镜像 mosh 的第 10/42/198/205 行处)。
- [x] **5.2** `application/state/useSessionState.ts`:路由 ET 会话(照搬旧 diff,+6 行)。
- [x] **5.3** `components/terminal/runtime/createTerminalSessionStarters.ts`:加
`startEt(term)`(镜像 `startMosh`,组装 options:etPort/terminalPath/
jumpHosts/legacyAlgorithms/凭证/identityFilePaths)。
**注意**:上游把它从旧的 `infrastructure/runtime/` 移到了
`components/terminal/runtime/` —— 落点以上游为准。
- [x] **5.4** UI 组件(照搬 `git show b1a306f8 6c0d5bf3 55caa268` 的相应文件,
映射到上游同名组件):
- [ ] `components/ProtocolSelectDialog.tsx` —— 新增 ET 选项
- [ ] `components/QuickConnectWizard.tsx`
- [ ] `components/HostDetailsPanel.tsx` —— ET 设置(启用、ET 端口、etterminal 路径)
- [ ] `components/GroupDetailsPanel.tsx`
- [ ] `components/VaultView.tsx`
- [ ] `components/Terminal.tsx` / `components/TerminalLayer.tsx`
- [ ] `components/terminal/TerminalConnectionDialog.tsx` / `TerminalToolbar.tsx`
- [ ] `App.tsx`
- [x] **5.5** i18n:`application/i18n/locales/en.ts` 与 `zh-CN.ts` 加 ET 文案
(照搬旧 diff,键名对齐上游现有 mosh 文案结构)。
## Phase 6 — 校验
- [x] **6.1** `npm run lint`(确保新 .cjs 在 scripts/ 下不受 ESLint 限制,
或按需加 eslint-disable,与 mosh 脚本一致)。
- [x] **6.2** `npm test`(新增的 fetch/resolve/extra-resources/etSession 测试全绿)。
- [x] **6.3** `npm run build`(渲染层 TS 编译通过,无类型错误)。
- [ ] **6.4** 手动冒烟(需先有发布的二进制):
`ET_BIN_RELEASE=et-bin-... npm run fetch:et` → `npm run start` →
新建 ET 会话连一台装了 etserver 的主机,验证连接/输入/退出/凭证注入。
---
## 进度记录
- 状态:**Phase 1–5 已完成并通过校验**(仅余 1 个可选项 + CI 产二进制)
- 验证结果:
- `npx eslint <所有改动文件>` → 干净(0 错 0 警)
- `npx tsc --noEmit` → 我的改动 **0 个新增类型错误**
(`TerminalConnectionDialog` 里 `case 'mosh'` 的 TS2678 是既有问题,行号因我插入 ET 早返回从 60→64,非新增)
- `node --test`(ET 相关)→ etSession/bundledEt/3 个脚本测试 **全绿**
- `npm test` → 1383 通过 / 16 失败,**16 个全是既有的 Windows 环境失败**
(mosh 打包测试的 GNU-tar `C:` 问题、`isExecutableFile` 无 x 位、ACP execPath、SKILL.md 权限、Comware DH 等;均在我未改动的文件里)
- `npm run build`(Vite)→ **构建成功**(8.55s),渲染层打包通过
### 已完成
- **Phase 1**:`scripts/et-extra-resources.cjs` / `resolve-et-bin-release.cjs` /
`fetch-et-binaries.cjs`(+3 测试,27 通过)、`scripts/build-et/{build-linux.sh,
build-macos.sh,build-windows.ps1}`、`.github/workflows/build-et-binaries.yml`、
`resources/et/README.md`、`.gitignore`、`package.json`、`electron-builder.config.cjs`。
- **Phase 2**:`terminalBridge.cjs` 新增并导出 `bundledEtClient`。
- **Phase 3**:`terminalBridge/etSession.cjs`(startEtSession + prepareEtSshEnvironment +
SSH_ASKPASS 机制 + execOnEtSession + 清理),接线进 terminalBridge.cjs(ctx/IPC
`netcatty:et:start`/init 清理/close/quit 清理/导出),+2 测试(13 通过)。
**et 指向捆绑二进制**(resolveBareEtClient→bundledEtClient),找不到则报错。
- **Phase 4**:domain `connection.ts`/`history.ts`/`terminal.ts`、`groupConfig.ts`、
`types/global/netcatty-bridge-session.d.ts`(startEtSession + NetcattyJumpHost[])、
`electron/preload/api.cjs`、`domain/vaultImport.ts`(排除 'et' 导入协议)。
- **Phase 5**:
- 启动派发:`useTerminalEffects.ts`、`Terminal.tsx`(×3) → `startEt`
- 运行时 starter:`createTerminalSessionStarters.ts` 新增 `startEt`(含单跳板/凭证/
legacy 算法/askpass 路径),`.types.ts` 加 `etAvailable`/`startEtSession`
- 后端 hook:`useTerminalBackend.ts`(etAvailable + startEtSession)
- 会话透传 etEnabled:`sessionFactories.ts`、`useSessionState.ts`(×6)、
`TerminalLayer.tsx`(×3)、`TerminalLayerSupport.tsx`、`AppHandlers.ts`(协议解析/日志/选择)
- UI:`HostDetailsAdvancedSections.tsx`(ET 开关+端口+etterminal 路径,与 Mosh 互斥)、
`HostDetailsPanel.tsx`、`ProtocolSelectDialog.tsx`(ET 选项)、
`TerminalConnectionDialog.tsx`(ET 标签)、`TerminalToolbar.tsx`(编码菜单门控)、
`GroupSshSettingsSection.tsx` + `GroupDetailsPanel.tsx`(组级 ET)、`VaultView.tsx`
- i18n:en/zh-CN 的 `hostDetails.section.et`、`hostDetails.et.*`、
`terminal.connection.protocol.et`、`terminal.et.*`
### 剩余(可选 / 非阻塞)
- [ ] **QuickConnectWizard.tsx**:把 ET 加为“快速连接”协议按钮(type/端口/建主机映射 +
UI 按钮)。当前快速连接未列 ET;保存主机后开启 ET 再连即可,故仅为便利项。
- [ ] **产出二进制**:手动 `workflow_dispatch` 跑 `build-et-binaries.yml`(带
`release_tag=et-bin-<ver>-1`)发布到 `Netcatty-et-bin`,并配 `ET_BIN_RELEASE_TOKEN`
secret。之后 `ET_BIN_RELEASE=... npm run fetch:et` 即可本地/打包捆绑 `et`。
build-et 脚本本机无法编译 C++,需在 CI 验证。
- [ ] **端到端冒烟**:有二进制后 `npm run dev`,对装有 etserver 的主机建 ET 会话验证。
- 当前分支:`feat/et-history-reapply`(基于上游 `031bf0ee`)
- 旧 ET 实现参考分支:`feat/eternal-terminal`(tip `67e81616`,7 个 ET 提交)
+54 -76
View File
@@ -5,13 +5,13 @@
<h1 align="center">Netcatty</h1>
<p align="center">
<strong>モダンな SSH クライアント、SFTP ブラウザ & ターミナルマネージャー</strong><br/>
<strong>🔥 AI 搭載の SSH クライアント、SFTP ブラウザ & ターミナルマネージャー 🚀</strong><br/>
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
</p>
<p align="center">
Electron、React、xterm.js で構築された機能豊富な SSH ワークスペース。<br/>
分割ターミナル、Vault ビュー、SFTP ワークフロー、カスタムテーマ、キーワードハイライト — すべてが一つに。
🔥 内蔵 AI Agent · 分割ターミナル · Vault ビュー · SFTP ワークフロー · カスタムテーマ — すべてが一つに。
</p>
<p align="center">
@@ -40,16 +40,45 @@
---
[![Netcatty メインインターフェース](screenshots/main-window-dark.png)](screenshots/main-window-dark.png)
<img width="3142" height="1764" alt="Screenshot 2026-07-02 at 22 51 24" src="https://github.com/user-attachments/assets/3116165d-623a-4d3a-a28a-914befb9b72d" />
---
<a name="catty-agent"></a>
# 🔥 Catty Agent — あなたの IT 運用 AI パートナー
> 🚀 **AI で日常の IT 運用作業を加速。** Catty Agent はサーバー環境を理解し、コマンドを実行し、自然な会話で複数ホストの複雑な操作をこなす内蔵 AI アシスタントです。
### 🔥 Catty Agent にできること
- 🚀 **自然言語でサーバー管理** — コマンドを暗記せず、必要なことを話しかけるだけ
- 🔥 **リアルタイムサーバー診断** — 会話を通じてステータス確認、ログ調査、リソース監視
- 🚀 **マルチホスト連携** — 複数サーバーのタスクを同時にコーディネート
- 🔥 **インテリジェントなコンテキスト認識** — サーバー環境を理解し、的確な回答を提供
- 🚀 **ワンステップで複雑な操作** — クラスター構築、サービスデプロイなど簡単な指示で実行
### 🎬 AI の動作デモ
#### 🔥 シングルホスト — インテリジェントなサーバー診断
Catty Agent にサーバーの健全性チェックを依頼すると、適切なコマンドを実行し、出力を分析して数秒で明確なサマリーを提示します。
<img width="1510" height="870" alt="ai-single" src="https://github.com/user-attachments/assets/d3f34e53-8476-4fab-8634-394b931d1ff1" />
#### 🚀 マルチホスト — Docker Swarm クラスターのセットアップ
1 つの会話で 2 台のサーバーをまたいで Docker Swarm クラスターをオーケストレーションする Catty Agent をご覧ください。初期化、トークン交換、ノード参加まですべてこなします — あなたは望む結果を伝えるだけ。
<img width="1515" height="870" alt="ai-muti" src="https://github.com/user-attachments/assets/ca166fc5-65d9-4d7b-951a-962c2ef230d8" />
---
# 目次 <!-- omit in toc -->
- [🔥 Catty Agent — AI パートナー](#catty-agent)
- [Netcatty とは](#netcatty-とは)
- [なぜ Netcatty](#なぜ-netcatty)
- [機能](#機能)
- [デモ](#デモ)
- [スクリーンショット](#スクリーンショット)
- [メインウィンドウ](#メインウィンドウ)
- [Vault ビュー](#vault-ビュー)
@@ -71,9 +100,10 @@
**Netcatty** は、複数のリモートサーバーを効率的に管理する必要がある開発者、システム管理者、DevOps エンジニア向けに設計された、モダンなクロスプラットフォーム SSH クライアントおよびターミナルマネージャーです。
- **Netcatty は** PuTTY、Termius、SecureCRT、macOS Terminal.app の代替となる SSH 接続ツール
- **Netcatty は** 強力な SFTP クライアント(ドラッグ&ドロップ + 内蔵エディタ)
- **Netcatty は** デュアルペインのファイルブラウザを備えた強力な SFTP クライアント
- **Netcatty は** 分割ペイン、タブ、セッション管理を備えたターミナルワークスペース
- **Netcatty は** シェルの代替ではありません — SSH/Telnet/Mosh やローカル/シリアル経由でシェルに接続します(環境により異なります)
- **Netcatty は** SSH、ローカルターミナル、Telnet、Mosh、シリアル接続をサポートします(利用可能な場合)
- **Netcatty は** シェルの代替ではありません — SSH/Telnet/Mosh またはローカル/シリアルセッション経由でシェルに接続します
---
@@ -82,9 +112,9 @@
複数サーバーを日常的に扱うなら、Netcatty は「スピード」と「流れ」を重視した作りになっています:
- **ワークスペース中心** — 分割ペインで複数セッションを並行操作
- **Vault の見やすさ** — グリッド/リスト/ツリーで状況に合わせて切り替え
- **SFTP の作業感** — ドラッグ&ドロップと内蔵エディタでサクッと編集
- **ワークスペース中心** — 分割ペイン + タブ + セッション復元で常時使うワークフローに対応
- **Vault の整理** — グリッド/リスト/ツリー表示、高速検索、ドラッグしやすいワークフロー
- **本格的な SFTP** — 内蔵エディタ + ドラッグ&ドロップ + スムーズなファイル操作
---
@@ -109,43 +139,6 @@
---
<a name="デモ"></a>
# デモ
動画で機能をさっと確認できます(素材は `screenshots/gifs/`):
### Vault ビュー:グリッド / リスト / ツリー
状況に合わせて見え方を切り替え。グリッドで全体像、リストで密度、ツリーで階層を扱えます。
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
### 分割ターミナル + セッション管理
複数セッションを分割ペインで並べて作業。関連タスクを横並びにしてコンテキストスイッチを減らします。
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
### SFTP:ドラッグ&ドロップ + 内蔵エディタ
ドラッグ&ドロップでファイルを移動し、内蔵エディタでそのまま編集できます。
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
### ドラッグでアップロード
ファイルをそのままドロップしてアップロードを開始。ダイアログ操作を減らせます。
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
### カスタムテーマ
テーマを調整して自分の好みに合わせた見た目に。
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
### キーワードハイライト
重要な出力(エラー/警告/マーカーなど)を見つけやすくするために、ハイライトをカスタマイズできます。
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
---
<a name="スクリーンショット"></a>
# スクリーンショット
@@ -154,29 +147,25 @@ https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
メインウィンドウは、長時間の SSH 作業を前提に設計されています。セッション、ナビゲーション、主要ツールへ素早くアクセスできます。
![メインウィンドウ(ダーク)](screenshots/main-window-dark.png)
<img width="1531" height="875" alt="black-grid" src="https://github.com/user-attachments/assets/004b80f6-5bbb-4f14-b8cd-33a0a5913b8c" />
![メインウィンドウ(ライト)](screenshots/main-window-light.png)
<img width="1550" height="876" alt="light" src="https://github.com/user-attachments/assets/2b59a999-a25e-4217-944c-9aef0a09f272" />
<a name="vault-ビュー"></a>
## Vault ビュー
作業に合わせて見え方を切り替え:グリッドで全体像、リストでスキャン、ツリーで整理と階層ナビゲーション。
![Vault グリッドビュー](screenshots/vault_grid_view.png)
<img width="1554" height="882" alt="list" src="https://github.com/user-attachments/assets/03249f15-b5f8-4770-a3c3-d5001636ea00" />
![Vault リストビュー](screenshots/vault_list_view.png)
![Vault ツリービュー(ダーク)](screenshots/treeview-dark.png)
![Vault ツリービュー(ライト)](screenshots/treeview-light.png)
<img width="1561" height="878" alt="tree" src="https://github.com/user-attachments/assets/739f7b66-3898-43d0-8dd3-b9b97fd8e8de" />
<a name="分割ターミナル"></a>
## 分割ターミナル
分割ペインで複数のサーバー/タスクを同時に扱えます(例:デプロイ + ログ + 監視)。
![分割ウィンドウ](screenshots/split-window.png)
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
---
@@ -201,8 +190,6 @@ Netcatty は接続したホストの OS を検出し、ホスト一覧でアイ
<img src="public/distro/almalinux.svg" width="48" alt="AlmaLinux" title="AlmaLinux">
</p>
---
<a name="はじめに"></a>
# はじめに
@@ -220,6 +207,16 @@ Netcatty は接続したホストの OS を検出し、ホスト一覧でアイ
> **macOS ユーザーへ:** 現在のリリースはコード署名と notarization が行われている想定です。Gatekeeper の警告が出る場合は、GitHub Releases から最新版の公式ビルドを取得しているか確認してください。
### Nix / NixOS
Netcatty は Nix および NixOS ユーザー向けに、公式 Linux AppImage リリースをラップした flake を提供しています:
```bash
nix run github:binaricat/Netcatty
```
宣言型インストールには、Netcatty flake を input として追加し、NixOS または Home Manager のパッケージリストで `inputs.netcatty.packages.${pkgs.system}.default` を使用してください。
### 前提条件
- Node.js 18+ と npm
- macOS、Windows 10+、または Linux
@@ -238,25 +235,6 @@ npm install
npm run dev
```
### プロジェクト構造
```
├── App.tsx # メイン React アプリケーション
├── components/ # React コンポーネント
│ ├── Terminal.tsx # ターミナルコンポーネント
│ ├── SftpView.tsx # SFTP ブラウザ
│ ├── VaultView.tsx # ホスト管理
│ ├── KeyManager.tsx # SSH キー管理
│ └── ...
├── application/ # 状態管理 & 国際化
├── domain/ # ドメインモデル & ロジック
├── infrastructure/ # サービス & アダプター
├── electron/ # Electron メインプロセス
│ ├── main.cjs # メインエントリ
│ └── bridges/ # IPC ブリッジ
└── public/ # 静的アセット & アイコン
```
---
<a name="ビルドとパッケージ"></a>
+19 -97
View File
@@ -40,7 +40,7 @@
---
[![Netcatty Main Interface](screenshots/main-window-dark.png)](screenshots/main-window-dark.png)
<img width="3142" height="1764" alt="Screenshot 2026-07-02 at 22 51 24" src="https://github.com/user-attachments/assets/3116165d-623a-4d3a-a28a-914befb9b72d" />
---
@@ -48,11 +48,6 @@
# 🔥 Catty Agent — Your IT Ops AI Partner
> 🚀 **Boost your IT ops daily work with AI power.** Catty Agent is the built-in AI assistant that understands your servers, executes commands, and handles complex multi-host operations — all through natural conversation.
<p align="center">
<img src="screenshots/ai-feature.png" alt="Catty Agent Interface" width="800">
</p>
### 🔥 What can Catty Agent do?
- 🚀 **Natural language server management** — just tell it what you need, no more memorizing commands
@@ -67,21 +62,13 @@
Ask Catty Agent to check a server's health, and it runs the right commands, analyzes the output, and gives you a clear summary — all in seconds.
https://github.com/user-attachments/assets/eecf08f1-80bd-49db-886d-b36e93388865
<img width="1510" height="870" alt="ai-single" src="https://github.com/user-attachments/assets/d3f34e53-8476-4fab-8634-394b931d1ff1" />
#### 🚀 Multi-Host — Docker Swarm Cluster Setup
Watch Catty Agent orchestrate a Docker Swarm cluster across two servers in one conversation. It handles the init, token exchange, and node joining — you just tell it what you want.
https://github.com/user-attachments/assets/282027aa-5c9e-4bb1-b2c3-5eea9df2b203
<img width="1515" height="870" alt="ai-muti" src="https://github.com/user-attachments/assets/ca166fc5-65d9-4d7b-951a-962c2ef230d8" />
---
@@ -91,7 +78,6 @@ https://github.com/user-attachments/assets/282027aa-5c9e-4bb1-b2c3-5eea9df2b203
- [What is Netcatty](#what-is-netcatty)
- [Why Netcatty](#why-netcatty)
- [Features](#features)
- [Demos](#demos)
- [Screenshots](#screenshots)
- [Main Window](#main-window)
- [Vault Views](#vault-views)
@@ -150,59 +136,6 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
- **Custom themes** — tune the app appearance to your taste
- **Keyword highlighting** — customize highlight rules for terminal output
---
<a name="demos"></a>
# Demos
Video previews (stored in `screenshots/gifs/`), rendered inline on GitHub:
### Vault views: grid / list / tree
Switch between different Vault views to match your workflow: overview in grid, dense scanning in list, and hierarchical navigation in tree.
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
### Split terminals + session management
Work in multiple sessions at once with split panes. Keep related tasks side-by-side and reduce context switching.
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
### SFTP: drag & drop + built-in editor
Move files with drag & drop, then edit quickly using the built-in editor without leaving the app.
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
### Drag file upload
Drop files into the app to kick off uploads without hunting through dialogs.
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
### Custom themes
Make Netcatty yours: customize themes and UI appearance.
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
### Keyword highlighting
Highlight important terminal output so errors, warnings, and key events stand out at a glance.
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
---
<a name="screenshots"></a>
@@ -213,29 +146,27 @@ https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
The main window is designed for long-running SSH workflows: quick access to sessions, navigation, and core tools in one place.
![Main Window (Dark)](screenshots/main-window-dark.png)
<img width="1531" height="875" alt="black-grid" src="https://github.com/user-attachments/assets/004b80f6-5bbb-4f14-b8cd-33a0a5913b8c" />
<img width="1550" height="876" alt="light" src="https://github.com/user-attachments/assets/2b59a999-a25e-4217-944c-9aef0a09f272" />
![Main Window (Light)](screenshots/main-window-light.png)
<a name="vault-views"></a>
## Vault Views
Organize and navigate your hosts using the view that best fits the moment: grid for overview, list for scanning, tree for structure.
![Vault Grid View](screenshots/vault_grid_view.png)
<img width="1554" height="882" alt="list" src="https://github.com/user-attachments/assets/03249f15-b5f8-4770-a3c3-d5001636ea00" />
![Vault List View](screenshots/vault_list_view.png)
<img width="1561" height="878" alt="tree" src="https://github.com/user-attachments/assets/739f7b66-3898-43d0-8dd3-b9b97fd8e8de" />
![Vault Tree View (Dark)](screenshots/treeview-dark.png)
![Vault Tree View (Light)](screenshots/treeview-light.png)
<a name="split-terminals"></a>
## Split Terminals
Split panes help you monitor multiple servers/services at the same time (deploy + logs + metrics) without juggling windows.
![Split Windows](screenshots/split-window.png)
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
---
@@ -277,6 +208,16 @@ Or browse all releases at [GitHub Releases](https://github.com/binaricat/Netcatt
> **macOS Users:** Current releases are expected to be code-signed and notarized. If Gatekeeper still warns, make sure you downloaded the latest official build from GitHub Releases.
### Nix / NixOS
Netcatty provides a flake that wraps the official Linux AppImage release for Nix and NixOS users:
```bash
nix run github:binaricat/Netcatty
```
For declarative installs, add the Netcatty flake as an input and use `inputs.netcatty.packages.${pkgs.system}.default` in your NixOS or Home Manager package list.
### Prerequisites
- Node.js 18+ and npm
- macOS, Windows 10+, or Linux
@@ -295,25 +236,6 @@ npm install
npm run dev
```
### Project Structure
```
├── App.tsx # Main React application
├── components/ # React components
│ ├── Terminal.tsx # Terminal component
│ ├── SftpView.tsx # SFTP browser
│ ├── VaultView.tsx # Host management
│ ├── KeyManager.tsx # SSH key management
│ └── ...
├── application/ # State management & i18n
├── domain/ # Domain models & logic
├── infrastructure/ # Services & adapters
├── electron/ # Electron main process
│ ├── main.cjs # Main entry
│ └── bridges/ # IPC bridges
└── public/ # Static assets & icons
```
---
<a name="build--package"></a>
+52 -73
View File
@@ -5,13 +5,13 @@
<h1 align="center">Netcatty</h1>
<p align="center">
<strong>现代化 SSH 客户端、SFTP 浏览器 & 终端管理器</strong><br/>
<strong>🔥 AI 驱动的 SSH 客户端、SFTP 浏览器 & 终端管理器 🚀</strong><br/>
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
</p>
<p align="center">
一个基于 Electron、React 和 xterm.js 构建的功能丰富的 SSH 工作空间。<br/>
分屏终端、Vault 多视图、SFTP 工作流、自定义主题、关键词高亮 —— 一应俱全。
🔥 内置 AI Agent · 分屏终端 · Vault 多视图 · SFTP 工作流 · 自定义主题 —— 一应俱全。
</p>
<p align="center">
@@ -40,16 +40,45 @@
---
[![Netcatty 主界面](screenshots/main-window-dark.png)](screenshots/main-window-dark.png)
<img width="3142" height="1764" alt="Screenshot 2026-07-02 at 22 51 24" src="https://github.com/user-attachments/assets/3116165d-623a-4d3a-a28a-914befb9b72d" />
---
<a name="catty-agent"></a>
# 🔥 Catty Agent — 你的 IT 运维 AI 搭档
> 🚀 **用 AI 赋能你的日常 IT 运维工作。** Catty Agent 是内置的 AI 助手,能理解你的服务器环境、执行命令,并通过自然对话完成复杂的多主机操作。
### 🔥 Catty Agent 能做什么?
- 🚀 **自然语言管理服务器** —— 直接说出需求,不再需要记忆命令
- 🔥 **实时服务器诊断** —— 通过对话检查状态、查看日志、监控资源
- 🚀 **多主机协同** —— 跨多台服务器同时协调任务
- 🔥 **智能上下文感知** —— 理解你的服务器环境,给出有针对性的回应
- 🚀 **一键完成复杂操作** —— 搭集群、部署服务,一句话搞定
### 🎬 AI 实战演示
#### 🔥 单主机 —— 智能服务器诊断
让 Catty Agent 检查服务器健康状态,它会自动运行合适的命令、分析输出并给出清晰总结 —— 几秒内完成。
<img width="1510" height="870" alt="ai-single" src="https://github.com/user-attachments/assets/d3f34e53-8476-4fab-8634-394b931d1ff1" />
#### 🚀 多主机 —— Docker Swarm 集群搭建
看 Catty Agent 在一次对话中跨两台服务器编排 Docker Swarm 集群。它负责初始化、令牌交换和节点加入 —— 你只需说出想要的结果。
<img width="1515" height="870" alt="ai-muti" src="https://github.com/user-attachments/assets/ca166fc5-65d9-4d7b-951a-962c2ef230d8" />
---
# 目录 <!-- omit in toc -->
- [🔥 Catty Agent — AI 搭档](#catty-agent)
- [Netcatty 是什么](#netcatty-是什么)
- [为什么是 Netcatty](#为什么是-netcatty)
- [功能特性](#功能特性)
- [演示](#演示)
- [界面截图](#界面截图)
- [主界面](#主界面)
- [Vault 视图](#vault-视图)
@@ -83,9 +112,9 @@
如果你需要同时维护多台服务器,Netcatty 更像是“工作台”而不是单一终端:
- **以工作区为核心** —— 分屏 + 多会话并行,适合长期驻留的工作流
- **Vault 管理** —— 网格/列表/树形视图,配合搜索与拖拽更顺手
- **认真做的 SFTP** —— 内置编辑器 + 拖拽上传,文件操作更丝滑
- **以工作区为核心** —— 分屏 + 标签页 + 会话恢复,适合长期驻留的工作流
- **Vault 管理** —— 网格/列表/树形视图,配合快速搜索与拖拽流程更顺手
- **认真做的 SFTP** —— 内置编辑器 + 拖拽上传/下载,文件操作更丝滑
---
@@ -110,43 +139,6 @@
---
<a name="演示"></a>
# 演示
视频预览(素材均在 `screenshots/gifs/`),在 GitHub README 中可直接观看:
### Vault 视图:网格 / 列表 / 树形
根据不同场景自由切换视图:网格适合总览,列表适合密集浏览,树形适合层级导航与整理。
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
### 分屏终端 + 会话管理
用分屏把多个会话并排放在同一个工作区里,降低来回切换窗口/标签页的成本。
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
### SFTP:拖拽 + 内置编辑器
通过拖拽完成文件传输,并用内置编辑器快速修改文件内容,不用来回切换工具。
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
### 拖拽文件上传
把文件直接拖进应用即可触发上传流程,省去多层对话框与路径选择。
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
### 自定义主题
按自己的审美与习惯定制主题与界面外观,让日常使用更顺手。
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
### 关键词高亮
让关键输出一眼可见:错误、告警或特定标记被高亮后更容易扫到与定位。
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
---
<a name="界面截图"></a>
# 界面截图
@@ -155,29 +147,25 @@ https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
主界面围绕长期 SSH 工作流设计:把会话、导航和常用工具集中到同一处,减少切换成本。
![主界面(深色)](screenshots/main-window-dark.png)
<img width="1531" height="875" alt="black-grid" src="https://github.com/user-attachments/assets/004b80f6-5bbb-4f14-b8cd-33a0a5913b8c" />
![主界面(浅色)](screenshots/main-window-light.png)
<img width="1550" height="876" alt="light" src="https://github.com/user-attachments/assets/2b59a999-a25e-4217-944c-9aef0a09f272" />
<a name="vault-视图"></a>
## Vault 视图
用更适合当前任务的方式管理与浏览主机:网格看全局,列表做筛选,树形做整理与层级导航。
![Vault 网格视图](screenshots/vault_grid_view.png)
<img width="1554" height="882" alt="list" src="https://github.com/user-attachments/assets/03249f15-b5f8-4770-a3c3-d5001636ea00" />
![Vault 列表视图](screenshots/vault_list_view.png)
![Vault 树形视图(深色)](screenshots/treeview-dark.png)
![Vault 树形视图(浅色)](screenshots/treeview-light.png)
<img width="1561" height="878" alt="tree" src="https://github.com/user-attachments/assets/739f7b66-3898-43d0-8dd3-b9b97fd8e8de" />
<a name="分屏终端"></a>
## 分屏终端
分屏适合同时处理多个任务(例如部署 + 日志 + 排障),不用频繁切换窗口。
![分屏窗口](screenshots/split-window.png)
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
---
@@ -219,6 +207,16 @@ Netcatty 会自动识别并在主机列表中展示对应的系统图标:
> **macOS 用户注意:** 当前发布版本应已完成代码签名和公证。如果 Gatekeeper 仍然提示风险,请确认您下载的是 GitHub Releases 中的最新官方构建。
### Nix / NixOS
Netcatty 提供了一个 flake,为 Nix 和 NixOS 用户封装了官方 Linux AppImage 发行版:
```bash
nix run github:binaricat/Netcatty
```
声明式安装时,将 Netcatty flake 添加为输入,并在 NixOS 或 Home Manager 的软件包列表中使用 `inputs.netcatty.packages.${pkgs.system}.default`。
### 前置条件
- Node.js 18+ 和 npm
- macOS、Windows 10+ 或 Linux
@@ -237,25 +235,6 @@ npm install
npm run dev
```
### 项目结构
```
├── App.tsx # 主 React 应用
├── components/ # React 组件
│ ├── Terminal.tsx # 终端组件
│ ├── SftpView.tsx # SFTP 浏览器
│ ├── VaultView.tsx # 主机管理
│ ├── KeyManager.tsx # SSH 密钥管理
│ └── ...
├── application/ # 状态管理 & 国际化
├── domain/ # 领域模型 & 逻辑
├── infrastructure/ # 服务 & 适配器
├── electron/ # Electron 主进程
│ ├── main.cjs # 主入口
│ └── bridges/ # IPC 桥接
└── public/ # 静态资源 & 图标
```
---
<a name="构建与打包"></a>
@@ -271,7 +250,7 @@ npm run pack
# 为特定平台打包
npm run pack:mac # macOS (DMG + ZIP)
npm run pack:win # Windows (NSIS 安装程序)
npm run pack:linux # Linux (AppImage, deb, rpm)
npm run pack:linux # Linux (AppImage + DEB + RPM)
```
---
+118
View File
@@ -0,0 +1,118 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
flushQueuedTrayPanelConnectHostsImpl,
handleConnectToHostImpl,
handleTrayPanelConnectRequestImpl,
} from './app/AppHandlers.ts';
import type { Host } from '../types';
const baseHost: Host = {
id: 'host-1',
label: '10.2.0.32',
hostname: '10.2.0.32',
username: 'root',
tags: [],
os: 'linux',
protocol: 'ssh',
};
test('connect host handler returns the created terminal tab id', () => {
const logs: unknown[] = [];
const connectedHosts: Host[] = [];
const result = handleConnectToHostImpl(
() => ({
addConnectionLog: (entry: unknown) => logs.push(entry),
connectToHost: (host: Host) => {
connectedHosts.push(host);
return 'session-from-connect';
},
identities: [],
keys: [],
resolveEffectiveHost: (host: Host) => host,
resolveHostAuth: () => ({ username: 'root' }),
systemInfoRef: { current: { username: 'local-user', hostname: 'local-host' } },
}),
baseHost,
);
assert.equal(result, 'session-from-connect');
assert.equal(connectedHosts.length, 1);
assert.equal(logs.length, 1);
});
test('connect serial host handler returns the created terminal tab id', () => {
const serialHost: Host = {
...baseHost,
id: 'serial-1',
label: '',
hostname: '/dev/tty.usbserial',
protocol: 'serial',
};
const result = handleConnectToHostImpl(
() => ({
addConnectionLog: () => {},
connectToHost: () => 'serial-session',
identities: [],
keys: [],
resolveEffectiveHost: (host: Host) => host,
resolveHostAuth: () => ({ username: 'root' }),
systemInfoRef: { current: { username: 'local-user', hostname: 'local-host' } },
}),
serialHost,
);
assert.equal(result, 'serial-session');
});
test('tray panel connect request queues until the vault is initialized', () => {
const queuedHostIds: string[] = [];
const connectedHostIds: string[] = [];
handleTrayPanelConnectRequestImpl(
() => ({
connectNow: (hostId: string) => connectedHostIds.push(hostId),
isVaultInitialized: false,
queueConnect: (hostId: string) => queuedHostIds.push(hostId),
}),
'host-1',
);
assert.deepEqual(queuedHostIds, ['host-1']);
assert.deepEqual(connectedHostIds, []);
});
test('tray panel connect request runs immediately after the vault is initialized', () => {
const queuedHostIds: string[] = [];
const connectedHostIds: string[] = [];
handleTrayPanelConnectRequestImpl(
() => ({
connectNow: (hostId: string) => connectedHostIds.push(hostId),
isVaultInitialized: true,
queueConnect: (hostId: string) => queuedHostIds.push(hostId),
}),
'host-1',
);
assert.deepEqual(queuedHostIds, []);
assert.deepEqual(connectedHostIds, ['host-1']);
});
test('queued tray panel connects flush in order', () => {
const connectedHostIds: string[] = [];
let pendingHostIds = ['host-1', 'host-2'];
flushQueuedTrayPanelConnectHostsImpl(() => ({
connectNow: (hostId: string) => connectedHostIds.push(hostId),
pendingHostIds,
setPendingHostIds: (nextHostIds: string[]) => {
pendingHostIds = nextHostIds;
},
}));
assert.deepEqual(connectedHostIds, ['host-1', 'host-2']);
assert.deepEqual(pendingHostIds, []);
});
@@ -0,0 +1,294 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { executeHotkeyActionImpl, getLogHostVisualSnapshot, handleGlobalHotkeyKeyDownImpl } from './app/AppHandlers.ts';
import { matchesKeyBinding } from '../domain/models.ts';
import { DEFAULT_KEY_BINDINGS } from '../domain/models/keyBindings.ts';
class FakeInputHTMLElement {
tagName = 'INPUT';
isContentEditable = false;
closest(): FakeInputHTMLElement | null {
return null;
}
}
class FakeHTMLElement {
tagName = 'TEXTAREA';
isContentEditable = false;
classList = {
contains: (className: string) => className === 'xterm-helper-textarea',
};
closest(selector: string): FakeHTMLElement | null {
return selector.includes('xterm') ? this : null;
}
hasAttribute(name: string): boolean {
return name === 'data-session-id';
}
}
const previousHTMLElement = globalThis.HTMLElement;
globalThis.HTMLElement = FakeHTMLElement as unknown as typeof HTMLElement;
test.after(() => {
globalThis.HTMLElement = previousHTMLElement;
});
test('global hotkey handler lets terminal font size shortcuts reach xterm', () => {
const target = new FakeHTMLElement();
const handledActions: string[] = [];
let prevented = false;
let stopped = false;
const event = {
key: '=',
code: 'Equal',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
target,
composedPath: () => [target],
preventDefault: () => {
prevented = true;
},
stopPropagation: () => {
stopped = true;
},
} as unknown as KeyboardEvent;
handleGlobalHotkeyKeyDownImpl(
() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: 'Ctrl + W',
executeHotkeyAction: (action: string) => {
handledActions.push(action);
},
hotkeyScheme: 'pc',
keyBindings: DEFAULT_KEY_BINDINGS,
matchesKeyBinding,
}),
event,
);
assert.deepEqual(handledActions, []);
assert.equal(prevented, false);
assert.equal(stopped, false);
});
test('global hotkey handler routes quick switch through focused search inputs', () => {
const target = new FakeInputHTMLElement();
const handledActions: string[] = [];
const event = {
key: 'j',
code: 'KeyJ',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
target,
composedPath: () => [target],
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as KeyboardEvent;
handleGlobalHotkeyKeyDownImpl(
() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: 'Ctrl + W',
executeHotkeyAction: (action: string) => {
handledActions.push(action);
},
hotkeyScheme: 'pc',
keyBindings: DEFAULT_KEY_BINDINGS,
matchesKeyBinding,
}),
event,
);
assert.deepEqual(handledActions, ['quickSwitch']);
});
test('quick switch hotkey toggles the quick switcher open state', () => {
let isQuickSwitcherOpen = false;
const setIsQuickSwitcherOpen = (next: boolean) => {
isQuickSwitcherOpen = next;
};
const noop = () => {};
const baseCtx = {
IS_DEV: false,
MOVE_FOCUS_DEBOUNCE_MS: 0,
activeTabStore: { getActiveTabId: () => 'vault' },
addConnectionLogRef: { current: noop },
closeSession: noop,
closeTabInFlightRef: { current: false },
closeWorkspace: noop,
collectSessionIds: () => [],
confirmIfBusyLocalTerminal: async () => true,
createLocalTerminalWithCurrentShell: noop,
editorTabs: [],
fromEditorTabId: () => null,
handleOpenSettingsRef: { current: noop },
handleRequestCloseEditorTabRef: { current: noop },
isEditorTabId: () => false,
isQuickSwitcherOpen,
lastMoveFocusTimeRef: { current: 0 },
moveFocusInWorkspace: noop,
orderedTabs: [],
resolveCloseIntent: () => ({ kind: 'noop' }),
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
sessions: [],
setActiveTabId: noop,
setAddToWorkspaceDialog: noop,
setIsQuickSwitcherOpen,
setNavigateToSection: noop,
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: false },
splitSessionWithCurrentShell: noop,
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
toEditorTabId: (id: string) => `editor:${id}`,
toggleBroadcast: noop,
toggleScriptsSidePanelRef: { current: noop },
toggleSidePanelRef: { current: noop },
workspaces: [],
};
const event = {
key: 'j',
code: 'KeyJ',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
} as KeyboardEvent;
executeHotkeyActionImpl(() => baseCtx, 'quickSwitch', event);
assert.equal(isQuickSwitcherOpen, true);
executeHotkeyActionImpl(() => ({ ...baseCtx, isQuickSwitcherOpen: true }), 'quickSwitch', event);
assert.equal(isQuickSwitcherOpen, false);
});
test('next tab includes pinned tabs when shell-only shortcut mode is disabled', () => {
let activeTabId = '';
const noop = () => {};
executeHotkeyActionImpl(
() => ({
IS_DEV: false,
MOVE_FOCUS_DEBOUNCE_MS: 0,
activeTabStore: { getActiveTabId: () => 'vault' },
addConnectionLogRef: { current: noop },
closeSession: noop,
closeTabInFlightRef: { current: false },
closeWorkspace: noop,
collectSessionIds: () => [],
confirmIfBusyLocalTerminal: async () => true,
createLocalTerminalWithCurrentShell: noop,
editorTabs: [{ id: 'editor-1' }],
fromEditorTabId: () => null,
handleOpenSettingsRef: { current: noop },
handleRequestCloseEditorTabRef: { current: noop },
isEditorTabId: () => false,
isQuickSwitcherOpen: false,
lastMoveFocusTimeRef: { current: 0 },
moveFocusInWorkspace: noop,
orderedTabs: ['session-1'],
resolveCloseIntent: () => ({ kind: 'noop' }),
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
sessions: [],
setActiveTabId: (id: string) => { activeTabId = id; },
setAddToWorkspaceDialog: noop,
setIsQuickSwitcherOpen: noop,
setNavigateToSection: noop,
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: false },
splitSessionWithCurrentShell: noop,
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
toEditorTabId: (id: string) => `editor:${id}`,
toggleBroadcast: noop,
toggleScriptsSidePanelRef: { current: noop },
toggleSidePanelRef: { current: noop },
toggleWorkspaceViewMode: noop,
workspaces: [],
}),
'nextTab',
{ key: 'Tab', ctrlKey: true } as KeyboardEvent,
);
assert.equal(activeTabId, 'sftp');
});
test('next tab skips pinned tabs when shell-only shortcut mode is enabled', () => {
let activeTabId = '';
const noop = () => {};
executeHotkeyActionImpl(
() => ({
IS_DEV: false,
MOVE_FOCUS_DEBOUNCE_MS: 0,
activeTabStore: { getActiveTabId: () => 'vault' },
addConnectionLogRef: { current: noop },
closeSession: noop,
closeTabInFlightRef: { current: false },
closeWorkspace: noop,
collectSessionIds: () => [],
confirmIfBusyLocalTerminal: async () => true,
createLocalTerminalWithCurrentShell: noop,
editorTabs: [{ id: 'editor-1' }],
fromEditorTabId: () => null,
handleOpenSettingsRef: { current: noop },
handleRequestCloseEditorTabRef: { current: noop },
isEditorTabId: () => false,
isQuickSwitcherOpen: false,
lastMoveFocusTimeRef: { current: 0 },
moveFocusInWorkspace: noop,
orderedTabs: ['session-1'],
resolveCloseIntent: () => ({ kind: 'noop' }),
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
sessions: [],
setActiveTabId: (id: string) => { activeTabId = id; },
setAddToWorkspaceDialog: noop,
setIsQuickSwitcherOpen: noop,
setNavigateToSection: noop,
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: true },
splitSessionWithCurrentShell: noop,
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
toEditorTabId: (id: string) => `editor:${id}`,
toggleBroadcast: noop,
toggleScriptsSidePanelRef: { current: noop },
toggleSidePanelRef: { current: noop },
toggleWorkspaceViewMode: noop,
workspaces: [],
}),
'nextTab',
{ key: 'Tab', ctrlKey: true } as KeyboardEvent,
);
assert.equal(activeTabId, 'session-1');
});
test('connection log host snapshot includes custom host icon fields', () => {
assert.deepEqual(
getLogHostVisualSnapshot({
id: 'host-1',
label: 'Database',
hostname: 'db.example.com',
username: 'root',
tags: [],
os: 'linux',
distro: 'ubuntu',
iconMode: 'custom',
iconId: 'database',
iconColor: 'blue',
}),
{
hostOs: 'linux',
hostDistro: 'ubuntu',
hostIconMode: 'custom',
hostIconId: 'database',
hostIconColorMode: 'manual',
hostIconColor: 'blue',
},
);
});
+189
View File
@@ -0,0 +1,189 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { TerminalSession } from "../domain/models";
import { copySessionToNewWindowWithCurrentShellImpl } from "./app/AppHandlers";
const sourceSession = (overrides: Partial<TerminalSession> = {}): TerminalSession => ({
id: "session-1",
hostId: "host-1",
hostLabel: "Prod SSH",
hostname: "prod.example.com",
username: "deploy",
status: "connected",
protocol: "ssh",
port: 22,
...overrides,
});
test("copySessionToNewWindowWithCurrentShellImpl asks Electron to open a peer window for the selected session", async () => {
const openedPayloads: unknown[] = [];
await copySessionToNewWindowWithCurrentShellImpl(
() => ({
classifyLocalShellType: () => "zsh",
discoveredShells: [],
netcattyBridge: {
get: () => ({
openSessionInNewWindow: async (payload: unknown) => {
openedPayloads.push(payload);
return { success: true };
},
}),
},
resolveShellSetting: () => ({ command: "/bin/zsh" }),
sessions: [sourceSession()],
terminalSettings: { localShell: "system-default" },
}),
"session-1",
);
assert.equal(openedPayloads.length, 1);
assert.deepEqual(openedPayloads[0], {
title: "Prod SSH",
sourceSession: sourceSession(),
localShellType: "zsh",
});
});
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;
await copySessionToNewWindowWithCurrentShellImpl(
() => ({
classifyLocalShellType: () => "zsh",
discoveredShells: [],
netcattyBridge: {
get: () => ({
openSessionInNewWindow: async () => {
called = true;
return { success: true };
},
}),
},
resolveShellSetting: () => ({ command: "/bin/zsh" }),
sessions: [],
terminalSettings: { localShell: "system-default" },
}),
"missing-session",
);
assert.equal(called, false);
});
test("copySessionToNewWindowWithCurrentShellImpl shows an error when Electron cannot open the window", async () => {
const errors: string[] = [];
const result = await copySessionToNewWindowWithCurrentShellImpl(
() => ({
classifyLocalShellType: () => "zsh",
discoveredShells: [],
netcattyBridge: {
get: () => ({
openSessionInNewWindow: async () => ({ success: false }),
}),
},
resolveShellSetting: () => ({ command: "/bin/zsh" }),
sessions: [sourceSession()],
terminalSettings: { localShell: "system-default" },
t: (key: string) => key === "tabs.copyTabToNewWindowFailed" ? "Could not open" : key,
toast: {
error: (message: string) => errors.push(message),
},
}),
"session-1",
);
assert.equal(result, false);
assert.deepEqual(errors, ["Could not open"]);
});
test("copySessionToNewWindowWithCurrentShellImpl shows an error when the bridge is unavailable", async () => {
const errors: string[] = [];
const result = await copySessionToNewWindowWithCurrentShellImpl(
() => ({
classifyLocalShellType: () => "zsh",
discoveredShells: [],
netcattyBridge: {
get: () => ({}),
},
resolveShellSetting: () => ({ command: "/bin/zsh" }),
sessions: [sourceSession()],
terminalSettings: { localShell: "system-default" },
t: (key: string) => key === "tabs.copyTabToNewWindowFailed" ? "Could not open" : key,
toast: {
error: (message: string) => errors.push(message),
},
}),
"session-1",
);
assert.equal(result, false);
assert.deepEqual(errors, ["Could not open"]);
});
test("copySessionToNewWindowWithCurrentShellImpl shows an error when the bridge throws", async () => {
const errors: string[] = [];
const result = await copySessionToNewWindowWithCurrentShellImpl(
() => ({
classifyLocalShellType: () => "zsh",
discoveredShells: [],
netcattyBridge: {
get: () => ({
openSessionInNewWindow: async () => {
throw new Error("boom");
},
}),
},
resolveShellSetting: () => ({ command: "/bin/zsh" }),
sessions: [sourceSession()],
terminalSettings: { localShell: "system-default" },
t: (key: string) => key === "tabs.copyTabToNewWindowFailed" ? "Could not open" : key,
toast: {
error: (message: string) => errors.push(message),
},
}),
"session-1",
);
assert.equal(result, false);
assert.deepEqual(errors, ["Could not open"]);
});
+147
View File
@@ -0,0 +1,147 @@
import { useEffect, useMemo } from 'react';
import {
fromEditorTabId,
isEditorTabId,
useActiveTabId,
} from '../state/activeTabStore';
import { updateActiveChromeThemeDeps } from '../state/activeChromeThemeSync';
import { useActiveChromeTheme } from '../state/useActiveChromeTheme';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { resolveActiveChromeTheme } from './activeChromeTheme';
import type { TerminalAppearanceHostScope, ResolvedAppearance } from '../../domain/terminalAppearanceRuntime';
import type {
Host,
TerminalSession,
TerminalTheme,
Workspace,
} from '../../types';
import type { LogView } from '../state/logViewState';
import type { EditorTab } from '../state/editorTabStore';
interface AppActiveTabChromeProps {
showSftpTab: boolean;
setActiveTabId: (id: string) => void;
applyAppTheme: () => void;
hostById: Map<string, Host>;
sessionById: Map<string, TerminalSession>;
themeById: Map<string, TerminalTheme>;
workspaceById: Map<string, Workspace>;
currentTerminalTheme: TerminalTheme;
followAppTerminalTheme: boolean;
accentMode: 'theme' | 'custom';
customAccent: string;
editorTabs: readonly EditorTab[];
logViews: readonly LogView[];
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
t: (key: string) => string;
}
/**
* Owns the `activeTabId` subscription and the purely side-effectful "chrome"
* work derived from it: window title and the SFTP-tab guard.
* Extracted out of <App> so that switching top tabs only
* re-renders this null-rendering component (and the self-subscribing leaves)
* instead of forcing the entire App tree (which holds all vault/session/
* settings state and rebuilds the giant AppView ctx) to re-render.
*/
export function AppActiveTabChrome({
showSftpTab,
setActiveTabId,
applyAppTheme,
hostById,
sessionById,
themeById,
workspaceById,
currentTerminalTheme,
followAppTerminalTheme,
accentMode,
customAccent,
editorTabs,
logViews,
resolveSessionAppearance,
t,
}: AppActiveTabChromeProps) {
const activeTabId = useActiveTabId();
useEffect(() => {
if (!showSftpTab && activeTabId === 'sftp') {
setActiveTabId('vault');
}
}, [showSftpTab, activeTabId, setActiveTabId]);
const chromeThemeDeps = useMemo(() => ({
accentMode,
applyAppTheme,
currentTerminalTheme,
customAccent,
editorTabs,
followAppTerminalTheme,
hostById,
logViews,
resolveSessionAppearance,
sessionById,
themeById,
workspaceById,
}), [
accentMode,
applyAppTheme,
currentTerminalTheme,
customAccent,
editorTabs,
followAppTerminalTheme,
hostById,
logViews,
resolveSessionAppearance,
sessionById,
themeById,
workspaceById,
]);
updateActiveChromeThemeDeps(chromeThemeDeps);
const activeChromeTheme = useMemo(() => resolveActiveChromeTheme({
...chromeThemeDeps,
activeTabId,
}), [chromeThemeDeps, activeTabId]);
useActiveChromeTheme({
activeTheme: activeChromeTheme,
applyAppTheme,
});
const editorTabFileNameCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const tab of editorTabs) counts.set(tab.fileName, (counts.get(tab.fileName) ?? 0) + 1);
return counts;
}, [editorTabs]);
const activeWindowTitle = useMemo(() => {
if (activeTabId === 'vault') return 'Vaults';
if (activeTabId === 'sftp') return 'SFTP';
if (isEditorTabId(activeTabId)) {
const editorTab = editorTabs.find((tab) => tab.id === fromEditorTabId(activeTabId));
if (!editorTab) return 'Editor';
const suffix = (editorTabFileNameCounts.get(editorTab.fileName) ?? 0) > 1
? ` · ${editorTab.remotePath.split('/').slice(-2, -1)[0] || '/'}`
: '';
return `${editorTab.fileName}${suffix}`;
}
const workspace = workspaceById.get(activeTabId);
if (workspace) return workspace.title;
const session = sessionById.get(activeTabId);
if (session) return session.hostLabel;
const logView = logViews.find((item) => item.id === activeTabId);
if (logView) {
const isLocal = logView.log.protocol === 'local' || logView.log.hostname === 'localhost';
return `${t('tabs.logPrefix')} ${isLocal ? t('tabs.logLocal') : logView.log.hostname}`;
}
return 'Netcatty';
}, [activeTabId, editorTabFileNameCounts, editorTabs, logViews, sessionById, t, workspaceById]);
useEffect(() => {
void netcattyBridge.get()?.setWindowTitle?.(activeWindowTitle);
}, [activeWindowTitle]);
return null;
}
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const appSource = readFileSync(new URL("../../App.tsx", import.meta.url), "utf8");
const appViewSource = readFileSync(new URL("./AppView.tsx", import.meta.url), "utf8");
const runtimeSource = readFileSync(new URL("../state/useThemeRuntime.ts", import.meta.url), "utf8");
const settingsSource = readFileSync(new URL("../state/useSettingsState.ts", import.meta.url), "utf8");
test("follow-app terminal theme selection updates the matching UI theme via ThemeRuntime", () => {
assert.match(runtimeSource, /getFollowAppTerminalThemeSelectionUpdate\(themeId\)/);
assert.match(runtimeSource, /setDarkUiThemeId\(update\.uiThemeId\)/);
assert.match(runtimeSource, /setLightUiThemeId\(update\.uiThemeId\)/);
assert.match(runtimeSource, /setTheme\(update\.appTheme\)/);
assert.doesNotMatch(runtimeSource, /isFollowAppIntentSettled\(userIntent\.themeId/);
assert.match(appSource, /useThemeRuntime\(/);
assert.match(appSource, /themeRuntime\.pickTheme\(themeId\)/);
assert.match(appSource, /useTerminalAppearanceInjection/);
assert.match(appSource, /includeChromeSurfaces: followAppTerminalTheme/);
assert.match(appSource, /clearThemeIntent\(\)/);
assert.match(runtimeSource, /injectTerminalAppearanceVars\(appearance\.theme, \{ includeChromeSurfaces \}\)/);
assert.doesNotMatch(settingsSource, /pendingFollowAppTerminalThemeId/);
assert.doesNotMatch(settingsSource, /applyFollowAppTerminalThemePick/);
assert.match(settingsSource, /appearanceTransitionModeRef\.current = 'instant'/);
assert.match(appViewSource, /data-terminal-appearance-root/);
assert.match(appViewSource, /pickTerminalTheme=\{ctx\.pickTerminalTheme\}/);
});
test("default terminal theme selection clears the current mode override", () => {
assert.match(appSource, /const handleDefaultTerminalThemeChange = useCallback\(\(themeId: string\) => \{/);
assert.match(appSource, /setTerminalThemeId\(themeId\)/);
assert.match(appSource, /resolvedTheme === 'dark'[\s\S]*setTerminalThemeDarkId\(TERMINAL_THEME_AUTO\)/);
assert.match(appSource, /setTerminalThemeLightId\(TERMINAL_THEME_AUTO\)/);
assert.match(appViewSource, /onUpdateTerminalThemeId=\{handleDefaultTerminalThemeChange\}/);
});
+956
View File
@@ -0,0 +1,956 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type React from 'react';
import type { Host, HostProtocol } from '../../types';
import type { PassphraseRequest } from '../../components/PassphraseModal';
import { getEffectiveHostDistro } from '../../domain/host';
import { sanitizeHostIconFields } from '../../domain/hostIcon';
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
type AppContextGetter = () => Record<string, any>;
const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions();
export const getLogHostVisualSnapshot = (host: Host) => {
const icon = sanitizeHostIconFields(host);
return {
hostOs: host.os,
hostDistro: getEffectiveHostDistro(host) || undefined,
hostIconMode: icon.iconMode,
hostIconId: icon.iconId,
...(icon.iconColorMode ? { hostIconColorMode: icon.iconColorMode } : {}),
...(icon.iconColor ? { hostIconColor: icon.iconColor } : {}),
...(icon.iconColorCustom ? { hostIconColorCustom: icon.iconColorCustom } : {}),
};
};
export function handleTrayJumpToSessionImpl(getCtx: AppContextGetter, sessionId: string) {
const { sessions, setActiveTabId, setWorkspaceFocusedSession } = getCtx();
{
const session = sessions.find((item) => item.id === sessionId);
if (session?.workspaceId) {
setActiveTabId(session.workspaceId);
setWorkspaceFocusedSession(session.workspaceId, sessionId);
return;
}
setActiveTabId(sessionId);
}
}
export function handleTrayTogglePortForwardImpl(getCtx: AppContextGetter, ruleId: string, start: boolean) {
const { hosts, identities, keys, knownHosts, portForwardingRules, resolveEffectiveHost, startTunnel, stopTunnel, t, terminalSettings, toast } = getCtx();
{
const rule = portForwardingRules.find((item) => item.id === ruleId);
if (!rule) return;
const host = rule.hostId ? hosts.find((item) => item.id === rule.hostId) : undefined;
if (!host) {
toast.error(t("pf.error.hostNotFound"));
return;
}
if (start) {
const effectiveHost = resolveEffectiveHost(host);
void startTunnel(rule, effectiveHost, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart, terminalSettings, knownHosts);
return;
}
void stopTunnel(ruleId);
}
}
export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: string) {
const { addConnectionLog, connectToHost, hosts, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef, t, toast } = getCtx();
{
const host = hosts.find((item) => item.id === hostId);
if (!host) {
toast.error(t("pf.error.hostNotFound"));
return;
}
const effectiveHost = resolveEffectiveHost(host);
const { username, hostname: localHost } = systemInfoRef.current;
if (effectiveHost.protocol === 'serial') {
const portName = host.hostname.split('/').pop() || host.hostname;
const sessionId = connectToHost(effectiveHost);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: host.hostname,
username,
protocol: 'serial',
...getLogHostVisualSnapshot(effectiveHost),
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
saved: false,
});
return sessionId;
}
const protocol = effectiveHost.etEnabled ? 'et' : effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host: effectiveHost, keys, identities });
const sessionId = connectToHost(effectiveHost);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: resolvedAuth.username || 'root',
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh' | 'et',
...getLogHostVisualSnapshot(effectiveHost),
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
saved: false,
});
return sessionId;
}
}
export function handleTrayPanelConnectRequestImpl(getCtx: AppContextGetter, hostId: string) {
const { connectNow, isVaultInitialized, queueConnect } = getCtx();
if (!hostId) return;
if (!isVaultInitialized) {
queueConnect(hostId);
return;
}
connectNow(hostId);
}
export function flushQueuedTrayPanelConnectHostsImpl(getCtx: AppContextGetter) {
const { connectNow, pendingHostIds, setPendingHostIds } = getCtx();
if (!Array.isArray(pendingHostIds) || pendingHostIds.length === 0) return;
const hostIds = [...pendingHostIds];
setPendingHostIds([]);
hostIds.forEach((hostId: string) => connectNow(hostId));
}
export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEvent) {
const { HOTKEY_DEBUG, closeTabKeyStr, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding } = getCtx();
{
const isMac = hotkeyScheme === 'mac';
const target = e.target as HTMLElement;
const isCloseTabHotkey = closeTabKeyStr ? matchesKeyBinding(e, closeTabKeyStr, isMac) : false;
const dialogHotkeyScope = target.closest?.('[data-hotkey-close-tab="true"]');
if (isCloseTabHotkey && dialogHotkeyScope) {
return;
}
if (isCloseTabHotkey) {
const openDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][data-state="open"]'));
const topmostOpenDialog = openDialogs[openDialogs.length - 1] ?? null;
const topmostDialogClose = topmostOpenDialog?.querySelector<HTMLElement>('[data-dialog-close="true"]');
if (topmostDialogClose) {
e.preventDefault();
e.stopPropagation();
topmostDialogClose.click();
return;
}
}
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
const isMonacoElement =
target instanceof HTMLElement &&
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
const isXtermInput =
target instanceof HTMLElement &&
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
const quickSwitchBinding = keyBindings.find((binding) => binding.action === 'quickSwitch');
const quickSwitchKeyStr = quickSwitchBinding ? (isMac ? quickSwitchBinding.mac : quickSwitchBinding.pc) : null;
const isQuickSwitchHotkey = quickSwitchKeyStr ? matchesKeyBinding(e, quickSwitchKeyStr, isMac) : false;
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape' && !isQuickSwitchHotkey) {
return;
}
const isTerminalElement =
target instanceof HTMLElement &&
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
const isTerminalInPath = Boolean(
e.composedPath?.().some(
(node) =>
node instanceof HTMLElement &&
(node.classList.contains("xterm") ||
node.classList.contains("xterm-helper-textarea") ||
node.classList.contains("xterm-screen") ||
node.classList.contains("xterm-viewport") ||
node.hasAttribute("data-session-id")),
),
);
for (const binding of keyBindings) {
const keyStr = isMac ? binding.mac : binding.pc;
if (!matchesKeyBinding(e, keyStr, isMac)) continue;
if (HOTKEY_DEBUG) console.log('[Hotkeys] Matched binding:', binding.action, keyStr);
if (binding.category === 'sftp') {
continue;
}
if (TERMINAL_PASSTHROUGH_ACTIONS.has(binding.action)) {
if (isTerminalElement) {
return;
}
continue;
}
e.preventDefault();
e.stopPropagation();
if (HOTKEY_DEBUG) {
console.log('[Hotkeys] Global handle', {
action: binding.action,
key: e.key,
meta: e.metaKey,
ctrl: e.ctrlKey,
alt: e.altKey,
shift: e.shiftKey,
targetTag: target?.tagName,
isTerminalElement,
isTerminalInPath,
});
}
executeHotkeyAction(binding.action, e);
return;
}
}
}
export function handleEscapeKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEvent) {
const { isQuickSwitcherOpen, setIsQuickSwitcherOpen } = getCtx();
{
if (e.key === 'Escape' && isQuickSwitcherOpen) {
setIsQuickSwitcherOpen(false);
}
}
}
export function handleKeyboardInteractiveSubmitImpl(getCtx: AppContextGetter, requestId: string, responses: string[], savePassword?: string) {
const { hosts, keyboardInteractiveQueue, netcattyBridge, sessions, setKeyboardInteractiveQueue, updateHosts } = getCtx();
{
const bridge = netcattyBridge.get();
if (bridge?.respondKeyboardInteractive) {
void bridge.respondKeyboardInteractive(requestId, responses, false);
}
// Save password to host if requested
if (savePassword) {
const request = keyboardInteractiveQueue.find(r => r.requestId === requestId);
if (request?.sessionId) {
const session = sessions.find(s => s.id === request.sessionId);
// Only save when the prompting hostname matches the session's host,
// to avoid overwriting the destination host's password with a jump host's password
if (session?.hostId && (!request.hostname || request.hostname === session.hostname)) {
const host = hosts.find(h => h.id === session.hostId);
if (host) {
updateHosts(hosts.map(h => h.id === host.id ? { ...h, password: savePassword, savePassword: true } : h));
}
}
}
}
// Remove from queue by requestId
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
}
}
export function handleKeyboardInteractiveCancelImpl(getCtx: AppContextGetter, requestId: string) {
const { netcattyBridge, setKeyboardInteractiveQueue } = getCtx();
{
const bridge = netcattyBridge.get();
if (bridge?.respondKeyboardInteractive) {
void bridge.respondKeyboardInteractive(requestId, [], true);
}
// Remove from queue by requestId
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
}
}
export async function handlePassphraseSubmitImpl(getCtx: AppContextGetter, requestId: string, passphrase: string, remember: boolean) {
const { keysRef, netcattyBridge, passphraseQueue, rememberKeyPassphrase, setPassphraseQueue, updateKeys } = getCtx();
{
const bridge = netcattyBridge.get();
const request = passphraseQueue.find((r: PassphraseRequest) => r.requestId === requestId);
// Save passphrase if requested
if (remember && request?.keyPath) {
console.log('[App] Saving passphrase for:', request.keyPath);
try {
await rememberKeyPassphrase({
keyPath: request.keyPath,
passphrase,
keys: keysRef.current,
updateKeys,
setCurrentKeys: (updated) => {
keysRef.current = updated;
},
});
} catch (err) {
console.warn('[App] Failed to save passphrase:', err);
}
}
if (bridge?.respondPassphrase) {
void bridge.respondPassphrase(requestId, passphrase, false);
}
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
}
}
export function handlePassphraseCancelImpl(getCtx: AppContextGetter, requestId: string) {
const { netcattyBridge, setPassphraseQueue } = getCtx();
{
const bridge = netcattyBridge.get();
if (bridge?.respondPassphrase) {
// Cancel = stop the entire passphrase flow
void bridge.respondPassphrase(requestId, '', true);
}
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
}
}
export function handlePassphraseSkipImpl(getCtx: AppContextGetter, requestId: string) {
const { netcattyBridge, setPassphraseQueue } = getCtx();
{
const bridge = netcattyBridge.get();
if (bridge?.respondPassphraseSkip) {
// Skip = skip this key but continue asking for others
void bridge.respondPassphraseSkip(requestId);
} else if (bridge?.respondPassphrase) {
// Fallback for older API
void bridge.respondPassphrase(requestId, '', false);
}
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
}
}
export function createLocalTerminalWithCurrentShellImpl(getCtx: AppContextGetter) {
const { classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, terminalSettings } = getCtx();
{
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells, terminalSettings.localShellArgs);
const matchedShell = discoveredShells.find(s => s.id === terminalSettings.localShell);
return createLocalTerminal({
shellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
shell: resolved?.command,
shellArgs: resolved?.args,
shellName: matchedShell?.name,
shellIcon: matchedShell?.icon,
});
}
}
export function splitSessionWithCurrentShellImpl(getCtx: AppContextGetter, sessionId: string, direction: 'horizontal' | 'vertical') {
const { classifyLocalShellType, discoveredShells, resolveShellSetting, splitSession, terminalSettings } = getCtx();
{
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
return splitSession(sessionId, direction, {
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
});
}
}
export function copySessionWithCurrentShellImpl(getCtx: AppContextGetter, sessionId: string) {
const { classifyLocalShellType, copySession, discoveredShells, resolveShellSetting, terminalSettings } = getCtx();
{
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
return copySession(sessionId, {
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
});
}
}
export async function copySessionToNewWindowWithCurrentShellImpl(getCtx: AppContextGetter, sessionId: string) {
const { classifyLocalShellType, discoveredShells, netcattyBridge, resolveShellSetting, sessions, terminalSettings, t, toast } = getCtx();
{
const sourceSession = sessions.find((session: { id: string }) => session.id === sessionId);
if (!sourceSession) return false;
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
const bridge = netcattyBridge.get();
if (!bridge?.openSessionInNewWindow) {
toast?.error?.(t?.('tabs.copyTabToNewWindowFailed') ?? 'Failed to open tab in a new window');
return false;
}
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : '';
try {
const result = await bridge.openSessionInNewWindow({
title: sourceSession.hostLabel,
sourceSession,
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, userAgent),
});
const success = result?.success === true;
if (!success) toast?.error?.(t?.('tabs.copyTabToNewWindowFailed') ?? 'Failed to open tab in a new window');
return success;
} catch {
toast?.error?.(t?.('tabs.copyTabToNewWindowFailed') ?? 'Failed to open tab in a new window');
return false;
}
}
}
export async function confirmIfBusyLocalTerminalImpl(getCtx: AppContextGetter, sessionIds: string[]) {
const { netcattyBridge, sessions, t } = getCtx();
{
const bridge = netcattyBridge.get();
const localIds = sessionIds.filter((id) => {
const s = sessions.find((x) => x.id === id);
return s?.protocol === 'local';
});
const busyCommands: string[] = [];
for (const id of localIds) {
const children = (await bridge?.ptyGetChildProcesses?.(id)) ?? [];
if (children.length > 0) {
busyCommands.push(children[0].command);
}
}
if (busyCommands.length === 0) return true;
const primary = busyCommands[0];
const extraCount = busyCommands.length - 1;
const message =
extraCount > 0
? t('confirm.closeBusyTerminal.messageWithMore', {
command: primary,
count: extraCount,
})
: t('confirm.closeBusyTerminal.message', { command: primary });
const ok = await bridge?.confirmCloseBusy?.({
command: primary,
title: t('confirm.closeBusyTerminal.title'),
message,
cancelLabel: t('confirm.closeBusyTerminal.cancel'),
closeLabel: t('confirm.closeBusyTerminal.close'),
});
return ok === true;
}
}
export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: string[]) {
const { closeLogView, closeSession, closeTabsInFlightRef, closeWorkspace, confirmIfBusyLocalTerminal, logViews, sessions, workspaces } = getCtx();
{
if (targetIds.length === 0) return;
if (closeTabsInFlightRef.current) return;
// Expand workspace ids into their constituent session ids so the busy
// probe sees every local shell that's about to be killed.
const sessionIdsToProbe: string[] = [];
for (const tabId of targetIds) {
const ws = workspaces.find((w) => w.id === tabId);
if (ws) {
for (const s of sessions) {
if (s.workspaceId === tabId) sessionIdsToProbe.push(s.id);
}
} else if (sessions.find((s) => s.id === tabId)) {
sessionIdsToProbe.push(tabId);
}
}
closeTabsInFlightRef.current = true;
try {
const ok = await confirmIfBusyLocalTerminal(sessionIdsToProbe);
if (!ok) return;
for (const tabId of targetIds) {
if (workspaces.find((w) => w.id === tabId)) {
closeWorkspace(tabId);
} else if (sessions.find((s) => s.id === tabId)) {
closeSession(tabId);
} else if (logViews.find((lv) => lv.id === tabId)) {
closeLogView(tabId);
}
}
} finally {
closeTabsInFlightRef.current = false;
}
}
}
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, workspaces } = getCtx();
{
const shortcutTabs = buildNumberShortcutTabTargets({
showSftpTab: settings.showSftpTab ?? true,
shellOnlyTabNumberShortcuts: settings.shellOnlyTabNumberShortcuts ?? false,
orderedTabs,
editorTabIds: editorTabs.map((t) => toEditorTabId(t.id)),
});
switch (action) {
case 'switchToTab': {
// Get the number key pressed (1-9)
const num = parseInt(e.key, 10);
if (num >= 1 && num <= 9) {
if (num <= shortcutTabs.length) {
setActiveTabId(shortcutTabs[num - 1]);
}
}
break;
}
case 'nextTab': {
const currentId = activeTabStore.getActiveTabId();
const currentIdx = shortcutTabs.indexOf(currentId);
if (currentIdx !== -1 && shortcutTabs.length > 0) {
const nextIdx = (currentIdx + 1) % shortcutTabs.length;
setActiveTabId(shortcutTabs[nextIdx]);
} else if (shortcutTabs.length > 0) {
setActiveTabId(shortcutTabs[0]);
}
break;
}
case 'prevTab': {
const currentId = activeTabStore.getActiveTabId();
const currentIdx = shortcutTabs.indexOf(currentId);
if (currentIdx !== -1 && shortcutTabs.length > 0) {
const prevIdx = (currentIdx - 1 + shortcutTabs.length) % shortcutTabs.length;
setActiveTabId(shortcutTabs[prevIdx]);
} else if (shortcutTabs.length > 0) {
setActiveTabId(shortcutTabs[shortcutTabs.length - 1]);
}
break;
}
case 'closeTab': {
const currentId = activeTabStore.getActiveTabId();
if (!currentId || currentId === 'vault' || currentId === 'sftp') break;
if (closeTabInFlightRef.current) break;
// Editor tabs route through their own dirty-confirm close flow.
if (isEditorTabId(currentId)) {
const editorId = fromEditorTabId(currentId);
if (editorId) handleRequestCloseEditorTabRef.current(editorId);
break;
}
const session = sessions.find((s) => s.id === currentId) ?? null;
const workspace = workspaces.find((w) => w.id === currentId) ?? null;
const focusIsInsideTerminal = !!document.activeElement?.closest('[data-session-id]');
const intent = resolveCloseIntent({
activeTabId: currentId,
workspace: workspace ? { id: workspace.id, focusedSessionId: workspace.focusedSessionId } : null,
sessionForTab: session,
focusIsInsideTerminal,
});
closeTabInFlightRef.current = true;
(async () => {
try {
switch (intent.kind) {
case 'closeTerminal':
case 'closeSingleTab': {
const ok = await confirmIfBusyLocalTerminal([intent.sessionId]);
if (ok) closeSession(intent.sessionId);
return;
}
case 'closeWorkspace': {
const ids = sessions.filter((s) => s.workspaceId === intent.workspaceId).map((s) => s.id);
const ok = await confirmIfBusyLocalTerminal(ids);
if (ok) closeWorkspace(intent.workspaceId);
return;
}
case 'noop':
default:
return;
}
} finally {
closeTabInFlightRef.current = false;
}
})();
break;
}
case 'closeSession': {
const currentId = activeTabStore.getActiveTabId();
if (!currentId || currentId === 'vault' || currentId === 'sftp') break;
if (closeTabInFlightRef.current) break;
const session = sessions.find((s) => s.id === currentId) ?? null;
const workspace = workspaces.find((w) => w.id === currentId) ?? null;
closeTabInFlightRef.current = true;
(async () => {
try {
// If active tab is a workspace, close the focused session (pane)
if (workspace) {
// Validate focusedSessionId is still valid — it can become stale
// if the previously focused session was already closed
const aliveIds = collectSessionIds(workspace.root);
const focusedId = aliveIds.includes(workspace.focusedSessionId)
? workspace.focusedSessionId
: aliveIds[0];
if (focusedId) {
const ok = await confirmIfBusyLocalTerminal([focusedId]);
if (ok) closeSession(focusedId);
}
} else if (session) {
// Standalone session tab — close the session
const ok = await confirmIfBusyLocalTerminal([session.id]);
if (ok) closeSession(session.id);
}
} finally {
closeTabInFlightRef.current = false;
}
})();
break;
}
case 'newTab':
case 'openLocal': {
const sessionId = createLocalTerminalWithCurrentShell();
addConnectionLogRef.current({
sessionId,
hostId: '',
hostLabel: 'Local Terminal',
hostname: 'localhost',
username: systemInfoRef.current.username,
protocol: 'local',
startTime: Date.now(),
localUsername: systemInfoRef.current.username,
localHostname: systemInfoRef.current.hostname,
saved: false,
});
break;
}
case 'openHosts':
setActiveTabId('vault');
break;
case 'openSftp':
if (settings.showSftpTab) {
setActiveTabId('sftp');
}
break;
case 'quickSwitch':
setIsQuickSwitcherOpen(!isQuickSwitcherOpen);
break;
case 'commandPalette':
setIsQuickSwitcherOpen(true);
break;
case 'newWorkspace':
// Dedicated shortcut to launch the AddToWorkspaceDialog in
// create mode — same entry as QuickSwitcher's "New Workspace"
// button, but without having to open QS first.
setAddToWorkspaceDialog({ mode: 'create' });
break;
case 'portForwarding':
// Navigate to vault and open port forwarding section
setActiveTabId('vault');
setNavigateToSection('port');
break;
case 'snippets':
{
const currentId = activeTabStore.getActiveTabId();
const intent = resolveSnippetsShortcutIntent({
activeTabId: currentId,
sessionForTab: sessions.find((s) => s.id === currentId) ?? null,
workspaceForTab: workspaces.find((w) => w.id === currentId) ?? null,
terminalScriptsToggleAvailable: !!toggleScriptsSidePanelRef.current,
});
if (intent.kind === 'toggleTerminalScripts') {
toggleScriptsSidePanelRef.current();
break;
}
setActiveTabId('vault');
setNavigateToSection('snippets');
}
break;
case 'toggleSidePanel':
toggleSidePanelRef.current?.();
break;
case 'broadcast': {
// Toggle broadcast mode for the active workspace
const currentId = activeTabStore.getActiveTabId();
const activeWs = workspaces.find(w => w.id === currentId);
if (activeWs) {
toggleBroadcast(activeWs.id);
}
break;
}
case 'openSettings':
handleOpenSettingsRef.current();
break;
case 'splitHorizontal': {
const currentId = activeTabStore.getActiveTabId();
const activeSession = sessions.find(s => s.id === currentId);
const activeWs = workspaces.find(w => w.id === currentId);
if (activeSession && !activeSession.workspaceId) {
splitSessionWithCurrentShell(activeSession.id, 'horizontal');
} else if (activeWs) {
const liveIds = collectSessionIds(activeWs.root);
const targetId = (activeWs.focusedSessionId && liveIds.includes(activeWs.focusedSessionId))
? activeWs.focusedSessionId
: liveIds[0];
if (targetId) splitSessionWithCurrentShell(targetId, 'horizontal');
}
break;
}
case 'splitVertical': {
const currentId = activeTabStore.getActiveTabId();
const activeSession = sessions.find(s => s.id === currentId);
const activeWs = workspaces.find(w => w.id === currentId);
if (activeSession && !activeSession.workspaceId) {
splitSessionWithCurrentShell(activeSession.id, 'vertical');
} else if (activeWs) {
const liveIds = collectSessionIds(activeWs.root);
const targetId = (activeWs.focusedSessionId && liveIds.includes(activeWs.focusedSessionId))
? activeWs.focusedSessionId
: liveIds[0];
if (targetId) splitSessionWithCurrentShell(targetId, 'vertical');
}
break;
}
case 'togglePaneZoom': {
// Toggle workspace between split and focus (zoom) mode
const currentId = activeTabStore.getActiveTabId();
const activeWs = workspaces.find(w => w.id === currentId);
if (activeWs) {
toggleWorkspaceViewMode(activeWs.id);
}
break;
}
case 'moveFocus': {
// Debounce to prevent double-triggering when focus switches between terminals
const now = Date.now();
if (now - lastMoveFocusTimeRef.current < MOVE_FOCUS_DEBOUNCE_MS) {
if (IS_DEV) console.log('[App] moveFocus debounced, ignoring');
break;
}
lastMoveFocusTimeRef.current = now;
// Move focus between split panes
if (IS_DEV) console.log('[App] moveFocus action triggered, key:', e.key);
const direction = e.key === 'ArrowUp' ? 'up'
: e.key === 'ArrowDown' ? 'down'
: e.key === 'ArrowLeft' ? 'left'
: e.key === 'ArrowRight' ? 'right'
: null;
if (IS_DEV) console.log('[App] moveFocus direction:', direction);
if (direction) {
// Find the active workspace
const currentId = activeTabStore.getActiveTabId();
if (IS_DEV) console.log('[App] Active tab ID:', currentId);
const activeWs = workspaces.find(w => w.id === currentId);
if (IS_DEV) console.log('[App] Active workspace:', activeWs?.id, activeWs?.title);
if (activeWs) {
const result = moveFocusInWorkspace(activeWs.id, direction as 'up' | 'down' | 'left' | 'right');
if (IS_DEV) console.log('[App] moveFocusInWorkspace result:', result);
} else {
if (IS_DEV) console.log('[App] No active workspace found');
}
}
break;
}
}
}
}
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;
const resolved = shell ?? resolveShellSetting(terminalSettings.localShell, discoveredShells, terminalSettings.localShellArgs);
// Match by ID (not command) to avoid WSL distros all sharing wsl.exe
const matchedShell = !shell ? discoveredShells.find(s => s.id === terminalSettings.localShell) : undefined;
const shellName = shell?.name ?? matchedShell?.name;
const shellIcon = shell?.icon ?? matchedShell?.icon;
const sessionId = createLocalTerminal({
shellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
shell: resolved?.command,
shellArgs: resolved?.args,
shellName,
shellIcon,
localStartDir: options?.localStartDir,
});
addConnectionLog({
sessionId,
hostId: '',
hostLabel: shellName || 'Local Terminal',
hostname: 'localhost',
username: username,
protocol: 'local',
startTime: Date.now(),
localUsername: username,
localHostname: hostname,
saved: false,
});
}
}
export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host) {
const { addConnectionLog, connectToHost, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef } = getCtx();
{
const { username, hostname: localHost } = systemInfoRef.current;
const effectiveHost = resolveEffectiveHost(host);
// Handle serial hosts separately
if (effectiveHost.protocol === 'serial') {
const portName = host.hostname.split('/').pop() || host.hostname;
const sessionId = connectToHost(effectiveHost);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: host.hostname,
username: username,
protocol: 'serial',
...getLogHostVisualSnapshot(effectiveHost),
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
saved: false,
});
return sessionId;
}
const protocol = effectiveHost.etEnabled ? 'et' : effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host: effectiveHost, keys, identities });
const sessionId = connectToHost(effectiveHost);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: resolvedAuth.username || 'root',
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh' | 'et',
...getLogHostVisualSnapshot(effectiveHost),
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
saved: false,
});
return sessionId;
}
}
export function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, sessionId: string, data: string) {
const { IS_DEV, connectionLogs, selectConnectionLogForTerminalDataCapture, sessions, updateConnectionLog } = getCtx();
{
if (IS_DEV) console.log('[handleTerminalDataCapture] Called', { sessionId, dataLength: data.length });
const session = sessions.find(s => s.id === sessionId);
if (IS_DEV) console.log('[handleTerminalDataCapture] Session', session);
if (IS_DEV) console.log('[handleTerminalDataCapture] All logs:', connectionLogs.map(l => ({ id: l.id, sessionId: l.sessionId, hostname: l.hostname, endTime: l.endTime, hasTerminalData: !!l.terminalData })));
const matchingLog = selectConnectionLogForTerminalDataCapture(
connectionLogs,
{ sessionId, hostname: session?.hostname },
);
if (IS_DEV) console.log('[handleTerminalDataCapture] Matching log', matchingLog);
if (matchingLog) {
updateConnectionLog(matchingLog.id, {
endTime: Date.now(),
terminalData: data,
});
if (IS_DEV) console.log('[handleTerminalDataCapture] Updated log with terminalData');
// Auto-save is now handled by real-time streaming in the main process
// via sessionLogStreamManager. No renderer-side fallback needed.
} else {
if (IS_DEV) console.log('[handleTerminalDataCapture] No matching log found!');
}
}
}
export function hasMultipleProtocolsImpl(getCtx: AppContextGetter, host: Host) {
const { resolveEffectiveHost } = getCtx();
{
// Gates the protocol picker (legacy name kept for its existing wiring).
// Only prompt when Telnet is available but isn't the host's default protocol;
// SSH-only, SSH+Mosh and Telnet-default all connect directly.
const effective = resolveEffectiveHost(host);
return Boolean(effective.telnetEnabled) && effective.protocol !== 'telnet';
}
}
export function handleHostConnectWithProtocolCheckImpl(getCtx: AppContextGetter, host: Host) {
const { handleConnectToHost, hasMultipleProtocols, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost, setQuickSearch } = getCtx();
{
if (hasMultipleProtocols(host)) {
setProtocolSelectHost(resolveEffectiveHost(host));
setIsQuickSwitcherOpen(false);
setQuickSearch('');
} else {
handleConnectToHost(host);
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}
}
}
export function handleProtocolSelectImpl(getCtx: AppContextGetter, protocol: HostProtocol, port: number) {
const { handleConnectToHost, protocolSelectHost, setProtocolSelectHost } = getCtx();
{
if (protocolSelectHost) {
const hostWithProtocol: Host = {
...protocolSelectHost,
protocol: (protocol === 'mosh' || protocol === 'et') ? 'ssh' : protocol,
port,
moshEnabled: protocol === 'mosh',
etEnabled: protocol === 'et',
};
handleConnectToHost(hostWithProtocol);
setProtocolSelectHost(null);
}
}
}
export function handleToggleThemeImpl(getCtx: AppContextGetter) {
const { openSettingsWindow, resolvedTheme, setTheme, t, theme, toast } = getCtx();
{
if (theme === 'system') {
toast.info(
t('topTabs.toggleTheme.systemExitMessage'),
{
title: t('topTabs.toggleTheme.systemExitTitle'),
actionLabel: t('topTabs.toggleTheme.openSettings'),
onClick: () => {
void (async () => {
const opened = await openSettingsWindow();
if (!opened) toast.error(t('toast.settingsUnavailable'), t('common.settings'));
})();
},
}
);
return;
}
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
}
}
export function handleRootContextMenuImpl(getCtx: AppContextGetter, e: React.MouseEvent<HTMLDivElement>) {
void getCtx;
{
const editableSelector =
"input, textarea, [contenteditable], .monaco-editor, .monaco-diff-editor, .monaco-inputbox, .monaco-menu-container";
const nativeEvent = e.nativeEvent;
const path = typeof nativeEvent.composedPath === "function" ? nativeEvent.composedPath() : [];
const allowFromPath = path.some(
(node) => node instanceof Element && !!node.closest(editableSelector),
);
const target = e.target;
const targetElement =
target instanceof Element
? target
: target instanceof Node
? target.parentElement
: null;
const allowFromTarget = !!targetElement?.closest(editableSelector);
const allowNativeContextMenu = allowFromPath || allowFromTarget;
if (allowNativeContextMenu) {
return;
}
e.preventDefault();
}
}
+50
View File
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const storage = new Map<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
},
});
const {
getAppHostTreeLayerStyle,
} = await import('./AppHostTreeLayer');
const hostTreeLayerSource = readFileSync(new URL('./AppHostTreeLayer.tsx', import.meta.url), 'utf8');
test('shared host tree layer is visible above work tabs', () => {
assert.deepEqual(getAppHostTreeLayerStyle(true), {
visibility: 'visible',
pointerEvents: 'auto',
zIndex: 30,
});
});
test('shared host tree layer is hidden behind root pages', () => {
assert.deepEqual(getAppHostTreeLayerStyle(false), {
visibility: 'hidden',
pointerEvents: 'none',
zIndex: 0,
});
});
test('shared host tree does not force open when entering a work tab surface', () => {
assert.doesNotMatch(hostTreeLayerSource, /setIsOpen\(true\)/);
assert.doesNotMatch(hostTreeLayerSource, /shouldAutoOpenHostTreeOnSurfaceChange/);
});
test('host tree layer hides immediately when leaving work tab surfaces', () => {
assert.match(hostTreeLayerSource, /getAppHostTreeLayerStyle\(surfaceVisible\)/);
assert.doesNotMatch(hostTreeLayerSource, /layerVisible/);
});
test('shared host tree theme follows active chrome resolution and manual chrome injection', () => {
assert.match(hostTreeLayerSource, /resolveActiveChromeTheme/);
assert.match(hostTreeLayerSource, /useManualTerminalChromeSurfaceInjection/);
assert.match(hostTreeLayerSource, /resolveSessionAppearance/);
});
+150
View File
@@ -0,0 +1,150 @@
import React, { useMemo } from 'react';
import { useActiveTabId } from '../state/activeTabStore';
import type { EditorTab } from '../state/editorTabStore';
import type { LogView } from '../state/logViewState';
import { useManualTerminalChromeSurfaceInjection } from '../state/useManualTerminalChromeSurfaceInjection';
import { TerminalHostTreeSidebar } from '../../components/terminalLayer/TerminalHostTreeSidebar';
import type {
ResolvedAppearance,
TerminalAppearanceHostScope,
} from '../../domain/terminalAppearanceRuntime';
import type { GroupConfig, Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
import { resolveActiveChromeTheme } from './activeChromeTheme';
import {
isHostTreeWorkTabSurface,
resolveWorkTabActiveHostId,
} from './workTabSurface';
interface AppHostTreeLayerProps {
enabled: boolean;
hosts: Host[];
customGroups: string[];
groupConfigs: GroupConfig[];
sessions: TerminalSession[];
workspaces: Workspace[];
editorTabs: readonly EditorTab[];
logViews: readonly LogView[];
orderedTabs: readonly string[];
accentMode: 'theme' | 'custom';
currentTerminalTheme: TerminalTheme;
customAccent: string;
followAppTerminalTheme: boolean;
hostById: ReadonlyMap<string, Host>;
themeById: ReadonlyMap<string, TerminalTheme>;
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
onConnect: (host: Host) => void;
onCreateLocalTerminal?: () => void;
}
export function getAppHostTreeLayerStyle(surfaceVisible: boolean): React.CSSProperties {
return {
visibility: surfaceVisible ? 'visible' : 'hidden',
pointerEvents: surfaceVisible ? 'auto' : 'none',
zIndex: surfaceVisible ? 30 : 0,
};
}
export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
enabled,
hosts,
customGroups,
groupConfigs,
sessions,
workspaces,
editorTabs,
logViews,
orderedTabs,
accentMode,
currentTerminalTheme,
customAccent,
followAppTerminalTheme,
hostById,
themeById,
resolveSessionAppearance,
onConnect,
onCreateLocalTerminal,
}) => {
const activeTabId = useActiveTabId();
const sessionIds = useMemo(() => new Set(sessions.map((session) => session.id)), [sessions]);
const workspaceIds = useMemo(() => new Set(workspaces.map((workspace) => workspace.id)), [workspaces]);
const logViewIds = useMemo(() => new Set(logViews.map((logView) => logView.id)), [logViews]);
const sessionById = useMemo(
() => new Map(sessions.map((session) => [session.id, session])),
[sessions],
);
const workspaceById = useMemo(
() => new Map(workspaces.map((workspace) => [workspace.id, workspace])),
[workspaces],
);
const surfaceVisible = isHostTreeWorkTabSurface({
enabled,
activeTabId,
logViewIds,
orderedTabs,
sessionIds,
workspaceIds,
});
const activeHostId = useMemo(() => resolveWorkTabActiveHostId({
activeTabId,
editorTabs,
sessions,
workspaces,
}), [activeTabId, editorTabs, sessions, workspaces]);
const hostTreeTheme = useMemo(() => (
resolveActiveChromeTheme({
accentMode,
activeTabId,
currentTerminalTheme,
customAccent,
editorTabs,
followAppTerminalTheme,
hostById,
logViews,
resolveSessionAppearance,
sessionById,
themeById,
workspaceById,
}) ?? currentTerminalTheme
), [
accentMode,
activeTabId,
currentTerminalTheme,
customAccent,
editorTabs,
followAppTerminalTheme,
hostById,
logViews,
resolveSessionAppearance,
sessionById,
themeById,
workspaceById,
]);
useManualTerminalChromeSurfaceInjection(
hostTreeTheme,
!followAppTerminalTheme && surfaceVisible,
);
return (
<div
className="absolute left-0 top-0 bottom-0 flex min-h-0"
data-section="app-host-tree-layer"
style={getAppHostTreeLayerStyle(surfaceVisible)}
>
<TerminalHostTreeSidebar
enabled={enabled}
surfaceVisible={surfaceVisible}
hosts={hosts}
customGroups={customGroups}
groupConfigs={groupConfigs}
resolvedPreviewTheme={hostTreeTheme}
activeHostId={activeHostId}
onConnect={onConnect}
onCreateLocalTerminal={onCreateLocalTerminal}
/>
</div>
);
};
+67
View File
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const storage = new Map<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
},
});
const { getLogViewWrapperStyle, shouldRenderTerminalLayerMount } = await import('./AppMounts.tsx');
const activeTabChromeSource = readFileSync(new URL('./AppActiveTabChrome.tsx', import.meta.url), 'utf8');
const appViewSource = readFileSync(new URL('./AppView.tsx', import.meta.url), 'utf8');
const appMountsSource = readFileSync(new URL('./AppMounts.tsx', import.meta.url), 'utf8');
const globalCssSource = readFileSync(new URL('../../index.css', import.meta.url), 'utf8');
test('visible log view leaves room for the terminal host sidebar', () => {
assert.deepEqual(getLogViewWrapperStyle(true, 220), {
left: 220,
});
});
test('hidden log view remains hidden while preserving host sidebar offset', () => {
assert.deepEqual(getLogViewWrapperStyle(false, 220), {
visibility: 'hidden',
pointerEvents: 'none',
position: 'absolute',
zIndex: -1,
left: 220,
});
});
test('terminal layer renders only after terminal content is visible or mounted', () => {
assert.equal(shouldRenderTerminalLayerMount(true, false), true);
assert.equal(shouldRenderTerminalLayerMount(false, true), true);
assert.equal(shouldRenderTerminalLayerMount(false, false), false);
});
test('inactive app surfaces suppress background color transitions', () => {
assert.match(appMountsSource, /data-inactive-app-surface=\{isActive \? undefined : "true"\}/);
assert.match(appMountsSource, /data-inactive-app-surface=\{isVisible \? undefined : "true"\}/);
assert.match(globalCssSource, /\[data-inactive-app-surface\][\s\S]*transition: none !important;/);
});
test('vault activation suppresses inherited text color transitions', () => {
assert.match(appMountsSource, /data-app-surface-transition-suppressed/);
assert.match(appMountsSource, /setSuppressActiveTransition\(false\)/);
assert.match(globalCssSource, /\[data-app-surface-transition-suppressed\][\s\S]*transition: none !important;/);
});
test('vault surface carries app theme vars while terminal chrome is active', () => {
assert.match(appMountsSource, /appThemeStyle\?: React\.CSSProperties/);
assert.match(appMountsSource, /style=\{\{ \.\.\.appThemeStyle, \.\.\.containerStyle \}\}/);
assert.match(appViewSource, /buildAppThemeCssVars\(tokens, accentMode, customAccent\)/);
assert.match(appViewSource, /<VaultViewContainer appThemeStyle=\{appThemeStyle\}>/);
});
test('active tab chrome keeps removed theme side effects unmounted', () => {
const removedThemeHook = ['use', 'Im', 'mersive', 'Mode'].join('');
const removedThemeStoreSetter = ['set', 'Im', 'mersive', 'Active'].join('');
assert.equal(activeTabChromeSource.includes(removedThemeHook), false);
assert.equal(activeTabChromeSource.includes(removedThemeStoreSetter), false);
});
+207
View File
@@ -0,0 +1,207 @@
import React, { Suspense, lazy, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useActiveTabId, useIsSftpActive, useIsVaultActive } from '../state/activeTabStore';
import { useTerminalHostTreeLayoutWidth } from '../state/terminalHostTreeStore';
import { isTerminalContentTabSurface } from './workTabSurface';
import { cn } from '../../lib/utils';
import { ConnectionLog, TerminalTheme } from '../../types';
import { LazyLoadBoundary } from '../../components/ui/lazy-load-boundary';
import type { LogView as LogViewType } from '../state/logViewState';
import type { SftpView as SftpViewComponent } from '../../components/SftpView';
import type { TerminalLayer as TerminalLayerComponent } from '../../components/TerminalLayer';
// Visibility container for VaultView - isolates isActive subscription
export const VaultViewContainer: React.FC<{
children: React.ReactNode;
appThemeStyle?: React.CSSProperties;
}> = ({ children, appThemeStyle }) => {
const isActive = useIsVaultActive();
const wasActiveRef = useRef(isActive);
const [suppressActiveTransition, setSuppressActiveTransition] = useState(false);
const isActivating = isActive && !wasActiveRef.current;
const shouldSuppressTransition = isActivating || suppressActiveTransition;
const containerStyle: React.CSSProperties = isActive
? {}
: { visibility: 'hidden', pointerEvents: 'none', position: 'absolute', zIndex: -1 };
useLayoutEffect(() => {
const wasActive = wasActiveRef.current;
wasActiveRef.current = isActive;
if (!isActive || wasActive) return;
setSuppressActiveTransition(true);
const view = window;
let firstFrame = 0;
let secondFrame = 0;
firstFrame = view.requestAnimationFrame(() => {
secondFrame = view.requestAnimationFrame(() => {
setSuppressActiveTransition(false);
});
});
return () => {
view.cancelAnimationFrame(firstFrame);
view.cancelAnimationFrame(secondFrame);
};
}, [isActive]);
return (
<div
className={cn("absolute inset-0", isActive ? "z-20" : "")}
data-inactive-app-surface={isActive ? undefined : "true"}
data-app-surface-transition-suppressed={shouldSuppressTransition ? "true" : undefined}
style={{ ...appThemeStyle, ...containerStyle }}
>
{children}
</div>
);
};
// LogView wrapper - manages visibility based on active tab
interface LogViewWrapperProps {
logView: LogViewType;
defaultTerminalTheme: TerminalTheme;
defaultFontSize: number;
onClose: () => void;
onUpdateLog: (logId: string, updates: Partial<ConnectionLog>) => void;
}
export function getLogViewWrapperStyle(
isVisible: boolean,
hostTreeLayoutWidth: number,
): React.CSSProperties {
const baseStyle = {
left: hostTreeLayoutWidth,
};
return isVisible
? baseStyle
: { visibility: 'hidden', pointerEvents: 'none', position: 'absolute', zIndex: -1, ...baseStyle };
}
export const LogViewWrapper: React.FC<LogViewWrapperProps> = ({ logView, defaultTerminalTheme, defaultFontSize, onClose, onUpdateLog }) => {
const activeTabId = useActiveTabId();
const isVisible = activeTabId === logView.id;
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
const containerStyle = getLogViewWrapperStyle(isVisible, hostTreeLayoutWidth);
return (
<div
className={cn("absolute inset-0", isVisible ? "z-20" : "")}
data-inactive-app-surface={isVisible ? undefined : "true"}
style={containerStyle}
>
<LazyLoadBoundary name="Log view" resetKey={logView.id}>
<Suspense fallback={<LogViewFallback />}>
<LazyLogView
log={logView.log}
defaultTerminalTheme={defaultTerminalTheme}
defaultFontSize={defaultFontSize}
isVisible={isVisible}
onClose={onClose}
onUpdateLog={onUpdateLog}
/>
</Suspense>
</LazyLoadBoundary>
</div>
);
};
const LazyLogView = lazy(() => import('../../components/LogView'));
const LazySftpView = lazy(() =>
import('../../components/SftpView').then((m) => ({ default: m.SftpView })),
);
const LazyTerminalLayer = lazy(() =>
import('../../components/TerminalLayer').then((m) => ({ default: m.TerminalLayer })),
);
type SftpViewProps = React.ComponentProps<typeof SftpViewComponent>;
type TerminalLayerProps = React.ComponentProps<typeof TerminalLayerComponent>;
const LogViewFallback = () => (
<div className="netcatty-lazy-fade-in h-full min-h-0 bg-background" aria-hidden="true" />
);
const SftpViewFallback = ({ visible }: { visible: boolean }) => {
if (!visible) return null;
return (
<div className="netcatty-lazy-fade-in absolute inset-0 z-20 bg-background" aria-hidden="true" />
);
};
const TerminalLayerFallback = ({ visible }: { visible: boolean }) => {
if (!visible) return null;
return (
<div className="netcatty-lazy-fade-in absolute inset-0 z-20 bg-background" aria-hidden="true" />
);
};
export function shouldRenderTerminalLayerMount(
isVisible: boolean,
shouldMount: boolean,
): boolean {
return isVisible || shouldMount;
}
export const SftpViewMount: React.FC<SftpViewProps> = (props) => {
const isActive = useIsSftpActive();
const [shouldMount, setShouldMount] = useState(isActive);
useEffect(() => {
if (isActive) setShouldMount(true);
}, [isActive]);
if (!shouldMount) return null;
return (
<LazyLoadBoundary name="SFTP" resetKey={isActive ? "active" : "idle"}>
<Suspense fallback={<SftpViewFallback visible={isActive} />}>
<LazySftpView {...props} />
</Suspense>
</LazyLoadBoundary>
);
};
export const TerminalLayerMount: React.FC<TerminalLayerProps> = (props) => {
const activeTabId = useActiveTabId();
const sessionIds = useMemo(() => new Set(props.sessions.map((session) => session.id)), [props.sessions]);
const workspaceIds = useMemo(() => new Set(props.workspaces.map((workspace) => workspace.id)), [props.workspaces]);
const isVisible = isTerminalContentTabSurface({
activeTabId,
sessionIds,
workspaceIds,
}) || !!props.draggingSessionId;
const [shouldMount, setShouldMount] = useState(isVisible);
useEffect(() => {
if (isVisible) setShouldMount(true);
}, [isVisible]);
useEffect(() => {
if (shouldMount) return;
type IdleWindow = Window & {
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
cancelIdleCallback?: (id: number) => void;
};
const idleWindow = window as IdleWindow;
if (typeof idleWindow.requestIdleCallback === "function") {
const id = idleWindow.requestIdleCallback(() => setShouldMount(true), { timeout: 5000 });
return () => idleWindow.cancelIdleCallback?.(id);
}
const id = window.setTimeout(() => setShouldMount(true), 5000);
return () => window.clearTimeout(id);
}, [shouldMount]);
const shouldRender = shouldRenderTerminalLayerMount(isVisible, shouldMount);
if (!shouldRender) return null;
return (
<LazyLoadBoundary name="Terminal" resetKey={activeTabId}>
<Suspense fallback={<TerminalLayerFallback visible={isVisible} />}>
<LazyTerminalLayer {...props} />
</Suspense>
</LazyLoadBoundary>
);
};
+730
View File
@@ -0,0 +1,730 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { Suspense, lazy, useCallback, useMemo } from 'react';
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
import { activeTabStore, toEditorTabId, useIsEditorTabActive } from '../state/activeTabStore';
import { editorTabStore } from '../state/editorTabStore';
import { releaseEditorTabSaveCoordinator, saveEditorTab } from '../state/editorTabSave';
import { useTerminalHostTreeLayoutWidth } from '../state/terminalHostTreeStore';
import { TopTabs } from '../../components/TopTabs';
import { VaultView } from '../../components/VaultView';
import { QuickAddSnippetDialog } from '../../components/QuickAddSnippetDialog';
import { QuickScriptEditorDialog } from '../../components/scripts/QuickScriptEditorDialog';
import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog';
import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal';
import { PassphraseModal } from '../../components/PassphraseModal';
import { UnsavedChangesProvider } from '../../components/editor/UnsavedChangesDialog';
import { SnippetExecutionProvider } from '../../components/SnippetExecutionProvider';
import { Button } from '../../components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../components/ui/dialog';
import { Input } from '../../components/ui/input';
import { Label } from '../../components/ui/label';
import { LazyLoadBoundary } from '../../components/ui/lazy-load-boundary';
import { toast } from '../../components/ui/toast';
import { AppHostTreeLayer } from './AppHostTreeLayer';
import { getUiThemeById } from '../../infrastructure/config/uiThemes';
import { buildAppThemeCssVars } from '../state/settingsStateDefaults';
import { useMainWindowInputFocusRecovery } from '../state/useMainWindowInputFocusRecovery';
const LazyProtocolSelectDialog = lazy(() => import('../../components/ProtocolSelectDialog'));
const LazyQuickSwitcher = lazy(() =>
import('../../components/QuickSwitcher').then((m) => ({ default: m.QuickSwitcher })),
);
const LazyCreateWorkspaceDialog = lazy(() =>
import('../../components/CreateWorkspaceDialog').then((m) => ({ default: m.CreateWorkspaceDialog })),
);
const LazyTextEditorTabView = lazy(() =>
import('../../components/editor/TextEditorTabView').then((m) => ({ default: m.TextEditorTabView })),
);
const TextEditorTabFallback = ({ tabId }: { tabId: string }) => {
const isVisible = useIsEditorTabActive(tabId);
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
return (
<div
style={{
...(isVisible ? null : { pointerEvents: 'none', visibility: 'hidden' }),
zIndex: 20,
left: hostTreeLayoutWidth,
}}
className="netcatty-lazy-fade-in absolute top-0 right-0 bottom-0 min-h-0 flex flex-col bg-background"
aria-hidden="true"
/>
);
};
type AppViewContext = Record<string, any>;
export function AppView({ ctx }: { ctx: AppViewContext }) {
const {
resetSessionRename,
resetWorkspaceRename,
setAddToWorkspaceDialog,
setIsCreateWorkspaceOpen,
setIsQuickSwitcherOpen,
setProtocolSelectHost,
setQuickSearch,
} = ctx;
const dismissTransientOverlays = useCallback(() => {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
setIsCreateWorkspaceOpen(false);
setProtocolSelectHost(null);
setAddToWorkspaceDialog(null);
resetSessionRename();
resetWorkspaceRename();
}, [
resetSessionRename,
resetWorkspaceRename,
setAddToWorkspaceDialog,
setIsCreateWorkspaceOpen,
setIsQuickSwitcherOpen,
setProtocolSelectHost,
setQuickSearch,
]);
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
const {
accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, closeWorkspace, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell,
connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent,
customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
followAppTerminalTheme,
groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost,
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
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,
resolveEmptyVaultConflict, resolvedTheme, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
sessionRenameValue, sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
setNavigateToSection, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
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, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaceRenameValue, workspaces,
VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper,
} = ctx;
const appThemeStyle = useMemo(() => {
const tokens = getUiThemeById(
resolvedTheme,
resolvedTheme === 'dark' ? settings.darkUiThemeId : settings.lightUiThemeId,
).tokens;
return {
...buildAppThemeCssVars(tokens, accentMode, customAccent),
colorScheme: resolvedTheme,
} as React.CSSProperties;
}, [accentMode, customAccent, resolvedTheme, settings.darkUiThemeId, settings.lightUiThemeId]);
return (
<SnippetExecutionProvider>
<UnsavedChangesProvider>
{({ prompt }) => {
// Helper: close an editor tab and activate the neighbor (left-preference), or vault.
const closeEditorAndActivateNeighbor = (id: string) => {
const closingTabId = toEditorTabId(id);
const list = orderedTabsWithEditors;
const idx = list.indexOf(closingTabId);
releaseEditorTabSaveCoordinator(id);
editorTabStore.close(id);
if (activeTabStore.getActiveTabId() !== closingTabId) return;
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
};
// Real dirty-confirm close handler.
const handleRequestCloseEditorTab = async (id: string): Promise<boolean> => {
const tab = editorTabStore.getTab(id);
if (!tab) return false;
const dirty = tab.content !== tab.baselineContent;
if (!dirty) {
closeEditorAndActivateNeighbor(id);
return true;
}
const choice = await prompt(tab.fileName);
if (choice === 'cancel') return false;
if (choice === 'discard') {
closeEditorAndActivateNeighbor(id);
return true;
}
if (choice === 'save') {
const ok = await saveEditorTab(id);
if (!ok) {
const msg = editorTabStore.getTab(id)?.saveError ?? 'Save failed';
toast.error(msg, 'SFTP');
return false;
}
const latest = editorTabStore.getTab(id);
if (!latest || latest.content !== latest.baselineContent) return false;
closeEditorAndActivateNeighbor(id);
return true;
}
return false;
};
// Expose to the hotkey dispatcher (Cmd/Ctrl+W).
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
return (
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" data-terminal-appearance-root onContextMenu={handleRootContextMenu}>
<TopTabs
theme={resolvedTheme}
hosts={hosts}
sessions={sessions}
orphanSessions={orphanSessions}
workspaces={workspaces}
logViews={logViews}
orderedTabs={orderedTabsWithEditors}
draggingSessionId={draggingSessionId}
isMacClient={isMacClient}
onCloseSession={closeSession}
onRenameSession={startSessionRename}
onCopySession={copySessionWithCurrentShell}
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
onRenameWorkspace={startWorkspaceRename}
onCloseWorkspace={closeWorkspace}
onCloseLogView={closeLogView}
onCloseTabsBatch={closeTabsBatch}
onOpenQuickSwitcher={handleOpenQuickSwitcher}
onToggleTheme={handleToggleTheme}
onOpenSettings={handleOpenSettings}
windowOpacity={settings.windowOpacity}
setWindowOpacity={settings.setWindowOpacity}
onSyncNow={handleSyncNowManual}
onStartSessionDrag={setDraggingSessionId}
onEndSessionDrag={handleEndSessionDrag}
onReorderTabs={reorderWorkTabs}
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
showSftpTab={settings.showSftpTab}
showHostTreeSidebar={settings.showHostTreeSidebar}
dynamicTabTitleMode={settings.terminalSettings.dynamicTabTitleMode}
editorTabs={editorTabs}
onRequestCloseEditorTab={handleRequestCloseEditorTab}
hostById={hostById}
/>
<div className="flex-1 relative min-h-0">
<AppHostTreeLayer
enabled={settings.showHostTreeSidebar}
hosts={hosts}
customGroups={customGroups}
groupConfigs={groupConfigs}
sessions={sessions}
workspaces={workspaces}
editorTabs={editorTabs}
logViews={logViews}
orderedTabs={orderedTabsWithEditors}
accentMode={accentMode}
currentTerminalTheme={currentTerminalTheme}
customAccent={customAccent}
followAppTerminalTheme={followAppTerminalTheme}
hostById={hostById}
themeById={themeById}
resolveSessionAppearance={resolveSessionAppearance}
onConnect={handleConnectToHost}
onCreateLocalTerminal={handleCreateLocalTerminal}
/>
<VaultViewContainer appThemeStyle={appThemeStyle}>
<VaultView
hosts={hosts}
keys={keys}
identities={identities}
proxyProfiles={proxyProfiles}
snippets={snippets}
snippetPackages={snippetPackages}
notes={notes}
noteGroups={noteGroups}
customGroups={customGroups}
knownHosts={effectiveKnownHosts}
shellHistory={shellHistory}
connectionLogs={connectionLogs}
managedSources={managedSources}
sessionCount={sessions.length}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
terminalThemeId={terminalThemeId}
terminalFontSize={terminalFontSize}
onOpenSettings={handleOpenSettings}
onOpenQuickSwitcher={handleOpenQuickSwitcher}
onCreateLocalTerminal={handleCreateLocalTerminal}
onConnectSerial={handleConnectSerial}
onDeleteHost={handleDeleteHost}
onConnect={handleConnectToHost}
onOpenHostFromNote={handleOpenHostFromVaultNote}
groupConfigs={groupConfigs}
onUpdateGroupConfigs={updateGroupConfigs}
onUpdateHosts={updateHosts}
onUpdateKeys={updateKeys}
onImportOrReuseKey={importOrReuseKey}
onUpdateIdentities={updateIdentities}
onUpdateProxyProfiles={updateProxyProfiles}
onUpdateSnippets={updateSnippets}
onUpdateSnippetPackages={updateSnippetPackages}
onUpdateNotes={updateNotes}
onUpdateNoteGroups={updateNoteGroups}
onUpdateCustomGroups={updateCustomGroups}
onUpdateKnownHosts={updateKnownHosts}
onUpdateManagedSources={updateManagedSources}
onClearAndRemoveManagedSource={clearAndRemoveSource}
onClearAndRemoveManagedSources={clearAndRemoveSources}
onUnmanageSource={unmanageSource}
onConvertKnownHost={convertKnownHostToHost}
onToggleConnectionLogSaved={toggleConnectionLogSaved}
onDeleteConnectionLog={deleteConnectionLog}
onClearUnsavedConnectionLogs={clearUnsavedConnectionLogs}
onRunSnippet={runSnippet}
onOpenLogView={openLogView}
showRecentHosts={settings.showRecentHosts}
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
navigateToSection={navigateToSection}
onNavigateToSectionHandled={() => setNavigateToSection(null)}
deepLinkHostDraft={deepLinkHostDraft}
onDeepLinkHostDraftHandled={() => setDeepLinkHostDraft(null)}
vaultFocusRequest={vaultFocusRequest}
onVaultFocusRequestHandled={() => setVaultFocusRequest(null)}
terminalSettings={terminalSettings}
/>
</VaultViewContainer>
<SftpViewMount
hosts={terminalHosts}
writableHosts={hosts}
sessions={sessions}
keys={keys}
identities={identities}
knownHosts={effectiveKnownHosts}
proxyProfiles={proxyProfiles}
groupConfigs={groupConfigs}
updateHosts={updateTerminalHosts}
onAddKnownHost={handleAddKnownHost}
sftpDefaultViewMode={sftpDefaultViewMode}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={sftpAutoSync}
sftpShowHiddenFiles={sftpShowHiddenFiles}
sftpUseCompressedUpload={sftpUseCompressedUpload}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
terminalSettings={terminalSettings}
/>
<TerminalLayerMount
hosts={terminalHosts}
portForwardingRules={portForwardingRules}
customGroups={customGroups}
groupConfigs={groupConfigs}
proxyProfiles={proxyProfiles}
keys={keys}
identities={identities}
snippets={snippets}
snippetPackages={snippetPackages}
notes={notes}
noteGroups={noteGroups}
sessions={sessions}
workspaces={workspaces}
knownHosts={effectiveKnownHosts}
draggingSessionId={draggingSessionId}
terminalTheme={currentTerminalTheme}
terminalThemeId={terminalThemeId}
followAppTerminalTheme={followAppTerminalTheme}
pickTerminalTheme={ctx.pickTerminalTheme}
clearThemeIntent={ctx.clearThemeIntent}
settleManualThemeIntent={ctx.settleManualThemeIntent}
resolveSessionAppearance={ctx.resolveSessionAppearance}
accentMode={accentMode}
customAccent={customAccent}
terminalSettings={terminalSettings}
terminalFontFamilyId={terminalFontFamilyId}
fontSize={terminalFontSize}
hotkeyScheme={hotkeyScheme}
disableTerminalFontZoom={settings.disableTerminalFontZoom}
restoreTerminalCwd={settings.restoreTerminalCwd}
keyBindings={keyBindings}
onHotkeyAction={handleHotkeyAction}
onUpdateTerminalThemeId={handleDefaultTerminalThemeChange}
onUpdateFollowAppTerminalThemeId={handleFollowAppTerminalThemeChange}
onUpdateTerminalFontFamilyId={setTerminalFontFamilyId}
onUpdateTerminalFontSize={setTerminalFontSize}
onUpdateSessionFontSize={updateSessionFontSize}
onUpdateSessionRestoreCwd={updateSessionRestoreCwd}
onUpdateSessionDynamicTitle={updateSessionDynamicTitle}
onUpdateSessionCodingCliProvider={updateSessionCodingCliProvider}
onClearSessionFontSizeOverride={clearSessionFontSizeOverride}
onUpdateTerminalFontWeight={(w) => updateTerminalSetting('fontWeight', w)}
onCloseSession={closeSession}
onUpdateSessionStatus={handleSessionStatusChange}
onUpdateHostDistro={updateHostDistro}
onUpdateHost={handleUpdateHostFromTerminal}
onAddKnownHost={handleAddKnownHost}
onCommandExecuted={(command, hostId, hostLabel, sessionId) => {
addShellHistoryEntry({ command, hostId, hostLabel, sessionId });
}}
shellHistory={shellHistory}
onTerminalDataCapture={handleTerminalDataCapture}
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
onAddSessionToWorkspace={addSessionToWorkspace}
onRequestAddToWorkspace={(workspaceId) =>
setAddToWorkspaceDialog({ mode: 'append', workspaceId })
}
onUpdateSplitSizes={updateSplitSizes}
onSetDraggingSessionId={setDraggingSessionId}
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
onReorderWorkspaceSessions={reorderWorkspaceSessions}
onReorderTabs={reorderWorkTabs}
onCopySession={copySessionWithCurrentShell}
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
onSplitSession={splitSessionWithCurrentShell}
onConnectToHost={handleConnectToHost}
openNoteRequest={openNoteRequest}
onOpenVaultNoteFromChat={handleOpenVaultNoteFromChat}
onOpenVaultHostFromChat={handleOpenVaultHostFromChat}
onOpenVaultSectionFromChat={handleOpenVaultSectionFromChat}
onOpenVaultSnippetFromChat={handleOpenVaultSnippetFromChat}
onCreateLocalTerminal={handleCreateLocalTerminal}
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}
updateHosts={updateTerminalHosts}
updateSnippets={updateSnippets}
updateSnippetPackages={updateSnippetPackages}
updateNotes={updateNotes}
updateNoteGroups={updateNoteGroups}
sftpDefaultViewMode={sftpDefaultViewMode}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={sftpAutoSync}
sftpShowHiddenFiles={sftpShowHiddenFiles}
sftpUseCompressedUpload={sftpUseCompressedUpload}
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
terminalSidePanelAutoOpen={settings.terminalSidePanelAutoOpen}
terminalSidePanelAutoOpenTab={settings.terminalSidePanelAutoOpenTab}
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
setSftpFollowTerminalCwd={setSftpFollowTerminalCwd}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
sessionLogsEnabled={sessionLogsEnabled}
sessionLogsDir={sessionLogsDir}
sessionLogsFormat={sessionLogsFormat}
sessionLogsTimestampsEnabled={sessionLogsTimestampsEnabled}
sshDebugLogsEnabled={sshDebugLogsEnabled}
showHostTreeSidebar={settings.showHostTreeSidebar}
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
toggleSidePanelRef={toggleSidePanelRef}
onStartSessionRename={startSessionRename}
onSubmitSessionRename={submitSessionRename}
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
/>
{/* Log Views - readonly terminal replays */}
{logViews.map(logView => {
// Get the latest log data from connectionLogs to reflect updates
const latestLog = connectionLogs.find(l => l.id === logView.connectionLogId) || logView.log;
return (
<LogViewWrapper
key={logView.id}
logView={{ ...logView, log: latestLog }}
defaultTerminalTheme={currentTerminalTheme}
defaultFontSize={terminalFontSize}
onClose={() => closeLogView(logView.id)}
onUpdateLog={updateConnectionLog}
/>
);
})}
{/* Editor Tabs — kept mounted for Monaco instance persistence; visibility toggled via CSS */}
{editorTabs.map((tab) => (
<LazyLoadBoundary key={tab.id} name="Editor" resetKey={tab.id}>
<Suspense fallback={<TextEditorTabFallback tabId={tab.id} />}>
<LazyTextEditorTabView
tabId={tab.id}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
hostById={hostById}
onRequestClose={(id) => handleRequestCloseEditorTabRef.current(id)}
/>
</Suspense>
</LazyLoadBoundary>
))}
</div>
{/* Global "quick add / edit snippet" dialog, triggered by the
netcatty:snippets:add and :edit window events (from ScriptsSidePanel
"+" button and right-click menu). Delete is handled by a sibling
useEffect above — it does not need a dialog. */}
<QuickAddSnippetDialog
snippets={snippets}
packages={snippetPackages}
onCreateSnippet={(snippet) => updateSnippets([...snippets, snippet])}
onUpdateSnippet={(snippet) =>
updateSnippets(snippets.map((s) => (s.id === snippet.id ? snippet : s)))
}
onCreatePackage={(pkg) =>
updateSnippetPackages(Array.from(new Set([...snippetPackages, pkg])))
}
/>
<QuickScriptEditorDialog
snippets={snippets}
packages={snippetPackages}
hosts={hosts}
customGroups={customGroups}
onCreateSnippet={(snippet) => updateSnippets([...snippets, snippet])}
onUpdateSnippet={(snippet) =>
updateSnippets(snippets.map((s) => (s.id === snippet.id ? snippet : s)))
}
onCreatePackage={(pkg) =>
updateSnippetPackages(Array.from(new Set([...snippetPackages, pkg])))
}
onUpdateHosts={updateHosts}
onRunSnippet={runSnippet}
/>
{/* Root-mounted AddToWorkspaceDialog — triggered by the focus-mode
"+" button (mode='append') or QuickSwitcher's "New Workspace"
button (mode='create'). Single instance so dialog state and
styling stay consistent across entry points. */}
{addToWorkspaceDialog && (
<AddToWorkspaceDialog
open
onOpenChange={(open) => { if (!open) setAddToWorkspaceDialog(null); }}
// Filter serial hosts only in append mode — appendHostToWorkspace
// has no serial code path. Create mode goes through
// createWorkspaceFromTargets, which builds a SerialConfig-backed
// session for serial hosts, so those should remain pickable.
hosts={addToWorkspaceDialog.mode === 'append'
? hosts.filter((h) => h.protocol !== 'serial')
: hosts}
workspaceTitle={
addToWorkspaceDialog.mode === 'append'
? workspaces.find((w) => w.id === addToWorkspaceDialog.workspaceId)?.title
: 'New Workspace'
}
onAdd={(targets) => {
if (addToWorkspaceDialog.mode === 'append') {
// Match the workspace root's current split direction so
// the new panes peer the existing siblings instead of
// wrapping the whole tree into one side of a fresh split
// (which would happen if we always passed the helper's
// default 'vertical').
const ws = workspaces.find((w) => w.id === addToWorkspaceDialog.workspaceId);
const rootDir = ws && ws.root.type === 'split' ? ws.root.direction : 'vertical';
for (const target of targets) {
if (target.kind === 'local') {
appendLocalTerminalToWorkspace(addToWorkspaceDialog.workspaceId, undefined, rootDir);
} else {
appendHostToWorkspace(addToWorkspaceDialog.workspaceId, target.host, rootDir);
}
}
} else {
createWorkspaceFromTargets(targets);
}
}}
/>
)}
{isQuickSwitcherOpen && (
<LazyLoadBoundary name="Quick switcher" resetKey={quickSearch}>
<Suspense fallback={null}>
<LazyQuickSwitcher
isOpen={isQuickSwitcherOpen}
query={quickSearch}
results={quickResults}
sessions={sessions}
workspaces={workspaces}
showSftpTab={settings.showSftpTab}
onQueryChange={setQuickSearch}
onSelect={handleHostConnectWithProtocolCheck}
onSelectTab={(tabId) => {
setActiveTabId(tabId);
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}}
onCreateLocalTerminal={(shell) => {
handleCreateLocalTerminal(shell);
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}}
onCreateWorkspace={() => {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
setAddToWorkspaceDialog({ mode: 'create' });
}}
onClose={() => {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}}
keyBindings={keyBindings}
terminalSettings={terminalSettings}
/>
</Suspense>
</LazyLoadBoundary>
)}
<Dialog open={!!sessionRenameTarget} onOpenChange={(open) => {
if (!open) {
resetSessionRename();
}
}}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t('dialog.renameSession.title')}</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<Label htmlFor="session-name">{t('field.name')}</Label>
<Input
id="session-name"
value={sessionRenameValue}
onChange={(e) => setSessionRenameValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submitSessionRename(); }}
autoFocus
placeholder={t('placeholder.sessionName')}
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={resetSessionRename}>{t('common.cancel')}</Button>
<Button onClick={submitSessionRename} disabled={!sessionRenameValue.trim()}>{t('common.save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={!!workspaceRenameTarget} onOpenChange={(open) => {
if (!open) {
resetWorkspaceRename();
}
}}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t('dialog.renameWorkspace.title')}</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<Label htmlFor="workspace-name">{t('field.name')}</Label>
<Input
id="workspace-name"
value={workspaceRenameValue}
onChange={(e) => setWorkspaceRenameValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submitWorkspaceRename(); }}
autoFocus
placeholder={t('placeholder.workspaceName')}
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={resetWorkspaceRename}>{t('common.cancel')}</Button>
<Button onClick={submitWorkspaceRename} disabled={!workspaceRenameValue.trim()}>{t('common.save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{isCreateWorkspaceOpen && (
<LazyLoadBoundary name="Create workspace" resetKey="create-workspace">
<Suspense fallback={null}>
<LazyCreateWorkspaceDialog
isOpen={isCreateWorkspaceOpen}
onClose={() => setIsCreateWorkspaceOpen(false)}
hosts={hosts}
onCreate={createWorkspaceWithHosts}
/>
</Suspense>
</LazyLoadBoundary>
)}
{/* Protocol Select Dialog for QuickSwitcher */}
{protocolSelectHost && (
<LazyLoadBoundary name="Protocol selector" resetKey={protocolSelectHost.id}>
<Suspense fallback={null}>
<LazyProtocolSelectDialog
host={protocolSelectHost}
onSelect={handleProtocolSelect}
onCancel={() => setProtocolSelectHost(null)}
/>
</Suspense>
</LazyLoadBoundary>
)}
{/* Global Keyboard-Interactive Authentication Modal (2FA/MFA) - processes queue */}
<KeyboardInteractiveModal
request={keyboardInteractiveQueue[0] || null}
onSubmit={handleKeyboardInteractiveSubmit}
onCancel={handleKeyboardInteractiveCancel}
/>
{/* Indicator when more 2FA requests are pending */}
{keyboardInteractiveQueue.length > 1 && (
<div className="fixed bottom-4 right-4 z-50 bg-muted/90 backdrop-blur-sm text-sm px-3 py-1.5 rounded-full border shadow-sm">
{keyboardInteractiveQueue.length - 1} more pending
</div>
)}
{/* Global Passphrase Modal for encrypted SSH keys */}
<PassphraseModal
request={passphraseQueue[0] || null}
onSubmit={handlePassphraseSubmit}
onCancel={handlePassphraseCancel}
onSkip={handlePassphraseSkip}
/>
{/* Empty vault vs cloud data confirmation dialog (#679).
This dialog intentionally cannot be dismissed — the user MUST
choose "Restore" or "Keep Empty" before the sync flow can
proceed. hideCloseButton removes the X button, onOpenChange
is a no-op so ESC also does nothing, and onInteractOutside
prevents click-away. */}
<Dialog open={!!emptyVaultConflict} onOpenChange={() => { /* intentionally non-dismissable */ }}>
<DialogContent className="max-w-md" hideCloseButton onInteractOutside={(e) => e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-amber-500" />
{t('sync.autoSync.emptyVaultConflict.title')}
</DialogTitle>
<DialogDescription>
{t('sync.autoSync.emptyVaultConflict.description')}
</DialogDescription>
</DialogHeader>
{emptyVaultConflict && (
<div className="bg-muted/30 rounded-lg p-3 text-sm">
<div className="font-medium text-muted-foreground mb-1">{t('sync.autoSync.emptyVaultConflict.cloudLabel')}</div>
<div>{t('sync.autoSync.emptyVaultConflict.cloudSummary', {
hosts: emptyVaultConflict.hostCount,
keys: emptyVaultConflict.keyCount,
snippets: emptyVaultConflict.snippetCount,
notes: emptyVaultConflict.noteCount,
proxyProfiles: emptyVaultConflict.proxyProfileCount,
})}</div>
</div>
)}
<DialogFooter className="flex-col gap-2 sm:flex-col">
<Button
onClick={() => resolveEmptyVaultConflict('restore')}
className="w-full justify-start gap-2"
>
<Download className="w-4 h-4" />
<span>
{t('sync.autoSync.emptyVaultConflict.restore')}
<span className="text-xs opacity-70 ml-1">— {t('sync.autoSync.emptyVaultConflict.restoreDesc')}</span>
</span>
</Button>
<Button
variant="outline"
onClick={() => resolveEmptyVaultConflict('keep-empty')}
className="w-full justify-start gap-2"
>
<Trash2 className="w-4 h-4" />
<span>
{t('sync.autoSync.emptyVaultConflict.keepEmpty')}
<span className="text-xs opacity-70 ml-1">— {t('sync.autoSync.emptyVaultConflict.keepEmptyDesc')}</span>
</span>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}}
</UnsavedChangesProvider>
</SnippetExecutionProvider>
);
}
+266
View File
@@ -0,0 +1,266 @@
import assert from "node:assert/strict";
import test from "node:test";
import { toEditorTabId } from "../state/activeTabStore.ts";
import type { EditorTab } from "../state/editorTabStore.ts";
import type { LogView } from "../state/logViewState.ts";
import { isActiveChromeThemeResolvable, resolveActiveChromeTheme } from "./activeChromeTheme.ts";
import type { Host, TerminalSession, TerminalTheme, Workspace } from "../../types";
const theme = (id: string, type: "dark" | "light" = "dark"): TerminalTheme => ({
id,
name: id,
type,
colors: {
background: type === "dark" ? "#111111" : "#eeeeee",
foreground: type === "dark" ? "#eeeeee" : "#111111",
cursor: "#22aaff",
},
});
const currentTheme = theme("current");
const hostTheme = theme("host-theme");
const logTheme = theme("log-theme", "light");
const baseInput = {
accentMode: "theme" as const,
currentTerminalTheme: currentTheme,
customAccent: "221.2 83.2% 53.3%",
editorTabs: [],
followAppTerminalTheme: false,
hostById: new Map<string, Host>(),
logViews: [],
sessionById: new Map<string, TerminalSession>(),
themeById: new Map([
[currentTheme.id, currentTheme],
[hostTheme.id, hostTheme],
[logTheme.id, logTheme],
]),
workspaceById: new Map<string, Workspace>(),
};
test("editor tabs use the owning host terminal theme when follow-app terminal theme is off", () => {
const editorTab = {
id: "editor-1",
hostId: "host-1",
sessionId: "sftp-1",
};
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: toEditorTabId(editorTab.id),
editorTabs: [editorTab as unknown as EditorTab],
hostById: new Map([
["host-1", { id: "host-1", theme: hostTheme.id } as unknown as Host],
]),
});
assert.equal(resolved?.id, hostTheme.id);
});
test("editor tabs use the followed terminal theme when follow-app terminal theme is on", () => {
const editorTab = {
id: "editor-1",
hostId: "host-1",
sessionId: "sftp-1",
};
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: toEditorTabId(editorTab.id),
editorTabs: [editorTab as unknown as EditorTab],
followAppTerminalTheme: true,
hostById: new Map([
["host-1", { id: "host-1", theme: hostTheme.id } as unknown as Host],
]),
});
assert.equal(resolved?.id, currentTheme.id);
});
test("log tabs use the saved log theme when available", () => {
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: "log-1",
logViews: [{
id: "log-1",
connectionLogId: "1",
log: { id: "1", themeId: logTheme.id },
} as unknown as LogView],
});
assert.equal(resolved?.id, logTheme.id);
});
test("root pages use the normal application theme", () => {
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: "vault",
});
assert.equal(resolved, null);
});
test("follow-app workspace split view always uses the global terminal theme", () => {
const workspace: Workspace = {
id: "ws-1",
name: "Workspace",
viewMode: "split",
focusedSessionId: "session-1",
root: {
type: "split",
direction: "horizontal",
sizes: [50, 50],
children: [
{ type: "session", sessionId: "session-1" },
{ type: "session", sessionId: "session-2" },
],
},
} as unknown as Workspace;
const hostA = { id: "host-a", theme: hostTheme.id, themeOverride: true } as unknown as Host;
const hostB = { id: "host-b", theme: logTheme.id, themeOverride: true } as unknown as Host;
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: "ws-1",
followAppTerminalTheme: true,
hostById: new Map([
["host-a", hostA],
["host-b", hostB],
]),
sessionById: new Map([
["session-1", { id: "session-1", hostId: "host-a" } as TerminalSession],
["session-2", { id: "session-2", hostId: "host-b" } as TerminalSession],
]),
workspaceById: new Map([["ws-1", workspace]]),
});
assert.equal(resolved?.id, currentTheme.id);
});
test("manual workspace split view uses the focused session theme when panes differ", () => {
const workspace: Workspace = {
id: "ws-1",
name: "Workspace",
viewMode: "split",
focusedSessionId: "session-2",
root: {
type: "split",
direction: "horizontal",
sizes: [50, 50],
children: [
{ type: "pane", sessionId: "session-1" },
{ type: "pane", sessionId: "session-2" },
],
},
} as unknown as Workspace;
const hostA = { id: "host-a", theme: hostTheme.id, themeOverride: true } as unknown as Host;
const hostB = { id: "host-b", theme: logTheme.id, themeOverride: true } as unknown as Host;
const focusedTheme = theme("focused-intent");
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: "ws-1",
hostById: new Map([
["host-a", hostA],
["host-b", hostB],
]),
sessionById: new Map([
["session-1", { id: "session-1", hostId: "host-a" } as TerminalSession],
["session-2", { id: "session-2", hostId: "host-b" } as TerminalSession],
]),
workspaceById: new Map([["ws-1", workspace]]),
resolveSessionAppearance: ({ host }) => (
host?.id === "host-b"
? { themeId: focusedTheme.id, theme: focusedTheme, source: "intent", appThemeUpdate: null }
: { themeId: hostTheme.id, theme: hostTheme, source: "host-override", appThemeUpdate: null }
),
});
assert.equal(resolved?.id, focusedTheme.id);
});
test("manual split workspace falls back to the first tree session theme when focus is stale", () => {
const workspace: Workspace = {
id: "ws-1",
name: "Workspace",
viewMode: "split",
focusedSessionId: "missing-session",
root: {
type: "split",
direction: "horizontal",
sizes: [50, 50],
children: [
{ type: "pane", sessionId: "session-1" },
{ type: "pane", sessionId: "session-2" },
],
},
} as unknown as Workspace;
const hostA = { id: "host-a", theme: hostTheme.id, themeOverride: true } as unknown as Host;
const hostB = { id: "host-b", theme: logTheme.id, themeOverride: true } as unknown as Host;
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: "ws-1",
hostById: new Map([
["host-a", hostA],
["host-b", hostB],
]),
sessionById: new Map([
["session-1", { id: "session-1", hostId: "host-a" } as TerminalSession],
["session-2", { id: "session-2", hostId: "host-b" } as TerminalSession],
]),
workspaceById: new Map([["ws-1", workspace]]),
});
assert.equal(resolved?.id, hostTheme.id);
});
test("manual mode prefers runtime session appearance over stale host theme ids", () => {
const intentTheme = theme("intent-theme");
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: "session-1",
hostById: new Map([
["host-1", { id: "host-1", theme: hostTheme.id, themeOverride: true } as unknown as Host],
]),
sessionById: new Map([
["session-1", { id: "session-1", hostId: "host-1" } as TerminalSession],
]),
resolveSessionAppearance: () => ({
themeId: intentTheme.id,
theme: intentTheme,
source: "intent",
appThemeUpdate: null,
}),
});
assert.equal(resolved?.id, intentTheme.id);
});
test("chrome theme sync waits until a newly opened session is present in deps", () => {
assert.equal(
isActiveChromeThemeResolvable({
activeTabId: "session-new",
editorTabs: [],
logViews: [],
sessionById: new Map(),
workspaceById: new Map(),
}),
false,
);
assert.equal(
isActiveChromeThemeResolvable({
activeTabId: "session-new",
editorTabs: [],
logViews: [],
sessionById: new Map([["session-new", { id: "session-new" } as TerminalSession]]),
workspaceById: new Map(),
}),
true,
);
});
+120
View File
@@ -0,0 +1,120 @@
import { fromEditorTabId, isEditorTabId } from "../state/activeTabStore";
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from "../../domain/terminalAppearance";
import type {
ResolvedAppearance,
TerminalAppearanceHostScope,
} from "../../domain/terminalAppearanceRuntime";
import { collectSessionIds } from "../../domain/workspace";
import type { EditorTab } from "../state/editorTabStore";
import type { LogView } from "../state/logViewState";
import type { Host, TerminalSession, TerminalTheme, Workspace } from "../../types";
import { resolveWorkspaceTargetSessionFromMap } from "./workTabSurface";
export type ResolveActiveChromeThemeInput = {
accentMode: "theme" | "custom";
activeTabId: string;
currentTerminalTheme: TerminalTheme;
customAccent: string;
editorTabs: readonly EditorTab[];
followAppTerminalTheme: boolean;
hostById: Map<string, Host>;
logViews: readonly LogView[];
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
sessionById: Map<string, TerminalSession>;
themeById: Map<string, TerminalTheme>;
workspaceById: Map<string, Workspace>;
};
export function isActiveChromeThemeResolvable({
activeTabId,
editorTabs,
logViews,
sessionById,
workspaceById,
}: Pick<
ResolveActiveChromeThemeInput,
"activeTabId" | "editorTabs" | "logViews" | "sessionById" | "workspaceById"
>): boolean {
if (activeTabId === "vault" || activeTabId === "sftp") return true;
if (isEditorTabId(activeTabId)) {
return editorTabs.some((tab) => tab.id === fromEditorTabId(activeTabId));
}
if (logViews.some((item) => item.id === activeTabId)) return true;
if (workspaceById.has(activeTabId)) return true;
if (sessionById.has(activeTabId)) return true;
return false;
}
export function resolveActiveChromeTheme({
accentMode,
activeTabId,
currentTerminalTheme,
customAccent,
editorTabs,
followAppTerminalTheme,
hostById,
logViews,
resolveSessionAppearance,
sessionById,
themeById,
workspaceById,
}: ResolveActiveChromeThemeInput): TerminalTheme | null {
if (activeTabId === "vault" || activeTabId === "sftp") return null;
const resolveHostScope = (hostId: string): TerminalAppearanceHostScope => {
const host = hostById.get(hostId) ?? null;
return { host, isEphemeral: !host || !hostById.has(host.id) };
};
const resolveHostTheme = (hostId: string): TerminalTheme => {
if (followAppTerminalTheme) return currentTerminalTheme;
if (resolveSessionAppearance) {
return resolveSessionAppearance(resolveHostScope(hostId)).theme;
}
const host = hostById.get(hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
};
const resolveSessionTheme = (session: TerminalSession): TerminalTheme => resolveHostTheme(session.hostId);
if (isEditorTabId(activeTabId)) {
const editorTabId = fromEditorTabId(activeTabId);
const editorTab = editorTabs.find((tab) => tab.id === editorTabId);
if (!editorTab) return null;
return resolveHostTheme(editorTab.hostId);
}
const logView = logViews.find((item) => item.id === activeTabId);
if (logView) {
const explicitThemeId = logView.log.themeId;
return explicitThemeId ? themeById.get(explicitThemeId) ?? currentTerminalTheme : currentTerminalTheme;
}
const workspace = workspaceById.get(activeTabId);
if (workspace) {
if (followAppTerminalTheme) return currentTerminalTheme;
if (workspace.viewMode === "focus") {
const focusedSession = resolveWorkspaceTargetSessionFromMap(workspace, sessionById);
return focusedSession ? resolveSessionTheme(focusedSession) : null;
}
const workspaceSessions = collectSessionIds(workspace.root)
.map((id) => sessionById.get(id))
.filter(Boolean) as TerminalSession[];
if (workspaceSessions.length === 0) return null;
const firstTheme = resolveSessionTheme(workspaceSessions[0]);
const allSame = workspaceSessions.every((session) => resolveSessionTheme(session).id === firstTheme.id);
if (allSame) return firstTheme;
const focusedSession = resolveWorkspaceTargetSessionFromMap(workspace, sessionById);
return focusedSession ? resolveSessionTheme(focusedSession) : null;
}
const session = sessionById.get(activeTabId);
return session ? resolveSessionTheme(session) : null;
}
@@ -0,0 +1,49 @@
import test from "node:test";
import assert from "node:assert/strict";
import { shouldQueueKeyboardInteractiveRequest } from "./useAppStartupEffects.ts";
const sessions = [{ id: "terminal-1" }, { id: "terminal-2" }];
test("terminal-scoped keyboard-interactive requests are limited to owned sessions", () => {
assert.equal(
shouldQueueKeyboardInteractiveRequest({ scope: "terminal", sessionId: "terminal-1" }, sessions),
true,
);
assert.equal(
shouldQueueKeyboardInteractiveRequest({ scope: "terminal", sessionId: "foreign-terminal" }, sessions),
false,
);
});
test("external keyboard-interactive requests are not filtered by terminal session ids", () => {
assert.equal(
shouldQueueKeyboardInteractiveRequest({ scope: "external", sessionId: "sftp-conn-1" }, sessions),
true,
);
assert.equal(
shouldQueueKeyboardInteractiveRequest({ scope: "external", sessionId: "tunnel-1" }, sessions),
true,
);
});
test("disabled peer windows still queue sender-targeted external keyboard-interactive requests", () => {
assert.equal(
shouldQueueKeyboardInteractiveRequest({ scope: "external", sessionId: "sftp-conn-1" }, sessions),
true,
);
});
test("disabled peer windows can still queue owned terminal keyboard-interactive requests", () => {
assert.equal(
shouldQueueKeyboardInteractiveRequest({ scope: "terminal", sessionId: "terminal-1" }, sessions),
true,
);
});
test("legacy unscoped keyboard-interactive requests remain visible", () => {
assert.equal(
shouldQueueKeyboardInteractiveRequest({ sessionId: "legacy-conn" }, sessions),
true,
);
});
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildNumberShortcutTabTargets } from './tabShortcutTargets.ts';
test('number shortcut tabs include vault and sftp by default', () => {
assert.deepEqual(
buildNumberShortcutTabTargets({
showSftpTab: true,
shellOnlyTabNumberShortcuts: false,
orderedTabs: ['session-1', 'workspace-1'],
editorTabIds: ['editor:file-1'],
}),
['vault', 'sftp', 'session-1', 'workspace-1', 'editor:file-1'],
);
});
test('number shortcut tabs skip vault and sftp when shell-only mode is enabled', () => {
assert.deepEqual(
buildNumberShortcutTabTargets({
showSftpTab: true,
shellOnlyTabNumberShortcuts: true,
orderedTabs: ['session-1', 'workspace-1'],
editorTabIds: ['editor:file-1'],
}),
['session-1', 'workspace-1', 'editor:file-1'],
);
});
test('hidden sftp tab is omitted from default number shortcut targets', () => {
assert.deepEqual(
buildNumberShortcutTabTargets({
showSftpTab: false,
shellOnlyTabNumberShortcuts: false,
orderedTabs: ['session-1'],
editorTabIds: [],
}),
['vault', 'session-1'],
);
});
+14
View File
@@ -0,0 +1,14 @@
/** Tab ids targeted by keyboard tab navigation shortcuts. */
export function buildNumberShortcutTabTargets(params: {
showSftpTab: boolean;
shellOnlyTabNumberShortcuts: boolean;
orderedTabs: readonly string[];
editorTabIds: readonly string[];
}): string[] {
const workTabs = [...params.orderedTabs, ...params.editorTabIds];
if (params.shellOnlyTabNumberShortcuts) {
return workTabs;
}
const pinnedTabs = params.showSftpTab ? ['vault', 'sftp'] : ['vault'];
return [...pinnedTabs, ...workTabs];
}
@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
test("active chrome theme applies top tab vars and clears them before vault restore transition", () => {
const chromeThemeSource = readFileSync(new URL("../state/useActiveChromeTheme.ts", import.meta.url), "utf8");
const syncSource = readFileSync(new URL("../state/activeChromeThemeSync.ts", import.meta.url), "utf8");
const effectsSource = readFileSync(new URL("../../components/terminalLayer/useTerminalLayerEffects.ts", import.meta.url), "utf8");
assert.match(chromeThemeSource, /applyTopTabsChromeThemeVars\(theme\)/);
assert.match(chromeThemeSource, /resolveReadableForegroundForHsl\(cursor\)/);
const restoreBlock = chromeThemeSource.match(
/clearTopTabsChromeThemeVars\(\);\s*runThemeTransition\(\(\) => \{\s*removeActiveChromeTheme\(\);/,
)?.[0] ?? "";
assert.notEqual(restoreBlock, "", "top tab vars must clear before the vault restore transition starts");
assert.match(syncSource, /activeTabId === 'vault' \|\| activeTabId === 'sftp'\)[\s\S]*clearTopTabsChromeThemeVars\(\)/);
assert.match(effectsSource, /if \(!isTerminalLayerVisible\) \{[\s\S]*clearTopTabsPreviewVars\(\)/);
});
test("top tabs chrome theme keeps accent foreground in sync", () => {
const source = readFileSync(new URL("./topTabsChromeTheme.ts", import.meta.url), "utf8");
const supportSource = readFileSync(new URL("../../components/terminalLayer/TerminalLayerSupport.tsx", import.meta.url), "utf8");
assert.match(source, /--primary-foreground/);
assert.match(source, /--accent-foreground/);
assert.match(source, /resolveReadableForegroundForHsl\(accent\)/);
assert.match(supportSource, /removeStylePropertyIfSet\(tabsRoot, '--primary-foreground'\)/);
assert.match(supportSource, /removeStylePropertyIfSet\(tabsRoot, '--accent-foreground'\)/);
});
+115
View File
@@ -0,0 +1,115 @@
import type { TerminalTheme } from '../../types';
import { resolveReadableForegroundForHsl } from '../../domain/colorContrast';
function hexToHslToken(hex: string): string {
const normalized = hex.startsWith('#') ? hex : `#${hex}`;
const r = parseInt(normalized.slice(1, 3), 16) / 255;
const g = parseInt(normalized.slice(3, 5), 16) / 255;
const b = parseInt(normalized.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
break;
case g:
h = ((b - r) / d + 2) / 6;
break;
default:
h = ((r - g) / d + 4) / 6;
break;
}
}
return `${Math.round(h * 3600) / 10} ${Math.round(s * 1000) / 10}% ${Math.round(l * 1000) / 10}%`;
}
function adjustLightnessToken(hsl: string, delta: number): string {
const parts = hsl.split(/\s+/);
const newL = Math.max(0, Math.min(100, parseFloat(parts[2]) + delta));
return `${parts[0]} ${parts[1]} ${Math.round(newL * 10) / 10}%`;
}
function adjustSaturationToken(hsl: string, factor: number): string {
const parts = hsl.split(/\s+/);
const newS = Math.max(0, Math.min(100, parseFloat(parts[1]) * factor));
return `${parts[0]} ${Math.round(newS * 10) / 10}% ${parts[2]}`;
}
const setStylePropertyIfChanged = (element: HTMLElement, property: string, value: string) => {
if (element.style.getPropertyValue(property) === value) return;
element.style.setProperty(property, value);
};
const removeStylePropertyIfSet = (element: HTMLElement, property: string) => {
if (!element.style.getPropertyValue(property)) return;
element.style.removeProperty(property);
};
const TOP_TABS_THEME_PROPERTIES = [
'--top-tabs-bg',
'--top-tabs-fg',
'--top-tabs-muted',
'--top-tabs-active-bg',
'--top-tabs-accent',
'--background',
'--foreground',
'--accent',
'--accent-foreground',
'--primary',
'--primary-foreground',
'--secondary',
'--border',
'--muted-foreground',
] as const;
let topTabsChromeThemeVarsApplied = false;
export function clearTopTabsChromeThemeVars(): void {
if (typeof document === 'undefined') return;
if (!topTabsChromeThemeVarsApplied) return;
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
if (!tabsRoot) return;
for (const property of TOP_TABS_THEME_PROPERTIES) {
removeStylePropertyIfSet(tabsRoot, property);
}
topTabsChromeThemeVarsApplied = false;
}
export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
if (typeof document === 'undefined') return;
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
if (!tabsRoot) return;
const bg = hexToHslToken(theme.colors.background);
const fg = hexToHslToken(theme.colors.foreground);
const accent = hexToHslToken(theme.colors.cursor);
const accentForeground = resolveReadableForegroundForHsl(accent);
const isDark = theme.type === 'dark';
const secondary = adjustLightnessToken(bg, isDark ? 6 : -5);
const border = adjustLightnessToken(bg, isDark ? 12 : -10);
const mutedFg = adjustSaturationToken(adjustLightnessToken(fg, isDark ? -20 : 20), 0.5);
setStylePropertyIfChanged(tabsRoot, '--background', bg);
setStylePropertyIfChanged(tabsRoot, '--foreground', fg);
setStylePropertyIfChanged(tabsRoot, '--accent', accent);
setStylePropertyIfChanged(tabsRoot, '--accent-foreground', accentForeground);
setStylePropertyIfChanged(tabsRoot, '--primary', accent);
setStylePropertyIfChanged(tabsRoot, '--primary-foreground', accentForeground);
setStylePropertyIfChanged(tabsRoot, '--secondary', secondary);
setStylePropertyIfChanged(tabsRoot, '--border', border);
setStylePropertyIfChanged(tabsRoot, '--muted-foreground', mutedFg);
setStylePropertyIfChanged(tabsRoot, '--top-tabs-bg', 'hsl(var(--secondary))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-fg', 'hsl(var(--foreground))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-muted', 'hsl(var(--muted-foreground))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-active-bg', 'hsl(var(--background))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
topTabsChromeThemeVarsApplied = true;
}
+215
View File
@@ -0,0 +1,215 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useEffect, useRef } from 'react';
import { usePortForwardingAutoStart } from '../state/usePortForwardingAutoStart';
import { editorTabStore } from '../state/editorTabStore';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { toast } from '../../components/ui/toast';
type StartupEffectsContext = Record<string, any>;
type KeyboardInteractiveScope = "terminal" | "external";
type KeyboardInteractiveRequestLike = {
scope?: KeyboardInteractiveScope;
sessionId?: string;
};
type SessionIdLike = { id: string };
export function shouldQueueKeyboardInteractiveRequest(
request: KeyboardInteractiveRequestLike,
sessions: SessionIdLike[],
): boolean {
if (request.scope !== "terminal") return true;
if (!request.sessionId) return false;
return sessions.some((session) => session.id === request.sessionId);
}
export function useAppStartupEffects(ctx: StartupEffectsContext) {
const {dismissUpdate, enabled = true, groupConfigs, hosts, identities,
installUpdate, isVaultInitialized, keys, knownHosts, openSettingsWindow, portForwardingRules, proxyProfiles, sessions, setKeyboardInteractiveQueue,
t, terminalSettings, updateState, workspaces,
} = ctx;
const sessionsRef = useRef(sessions);
useEffect(() => {
sessionsRef.current = sessions;
}, [sessions]);
// Show toast notification when update is available (only when auto-download is idle)
useEffect(() => {
if (!enabled) return;
// Skip "update available" toast if auto-download has already started or completed
if (updateState.autoDownloadStatus !== 'idle') return;
// Don't show automatic notification when auto-update is disabled
if (localStorageAdapter.readString('netcatty_auto_update_enabled_v1') === 'false') return;
if (updateState.hasUpdate && updateState.latestRelease) {
const version = updateState.latestRelease.version;
toast.info(
t('update.available.message', { version }),
{
title: t('update.available.title'),
duration: 8000, // Show longer for update notifications
onClick: () => {
void openSettingsWindow();
// Dismiss the update so the toast doesn't re-fire on every render.
// On unsupported platforms (where autoDownloadStatus stays 'idle')
// this is the only way to suppress the notification for this version.
// On supported platforms this toast only shows before auto-download
// starts, and the Settings window's own useUpdateCheck will pick up
// the download state via IPC events independently of the dismiss.
dismissUpdate();
},
actionLabel: t('update.viewInSettings'),
}
);
}
}, [enabled, updateState.hasUpdate, updateState.latestRelease, updateState.autoDownloadStatus, t, openSettingsWindow, dismissUpdate]);
// Track previous autoDownloadStatus so toast effects fire only on actual transitions,
// not when unrelated deps (installUpdate, openSettingsWindow) change their reference.
const prevAutoDownloadStatusRef = useRef(updateState.autoDownloadStatus);
useEffect(() => {
if (!enabled) return;
const prev = prevAutoDownloadStatusRef.current;
prevAutoDownloadStatusRef.current = updateState.autoDownloadStatus;
if (prev === updateState.autoDownloadStatus) return;
if (updateState.autoDownloadStatus === 'ready') {
const version = updateState.latestRelease?.version ?? '';
toast.info(
t('update.readyToInstall.message', { version }),
{
title: t('update.readyToInstall.title'),
duration: 0,
actionLabel: t('update.restartNow'),
onClick: () => installUpdate(),
}
);
} else if (updateState.autoDownloadStatus === 'error') {
toast.error(
t('update.downloadFailed.message'),
{
title: t('update.downloadFailed.title'),
actionLabel: t('update.viewInSettings'),
onClick: () => void openSettingsWindow(),
}
);
}
}, [enabled, updateState.autoDownloadStatus, updateState.latestRelease?.version, t, installUpdate, openSettingsWindow]);
// Auto-start port forwarding rules on app launch
usePortForwardingAutoStart({
enabled,
isVaultInitialized,
hosts,
keys,
identities,
knownHosts,
proxyProfiles,
groupConfigs,
terminalSettings,
});
// Sync tray menu data + handle tray actions
useEffect(() => {
if (!enabled) return;
const bridge = netcattyBridge.get();
if (!bridge?.updateTrayMenuData) return;
let cancelled = false;
const timer = setTimeout(() => {
if (cancelled) return;
const sessionsForTray = sessions.map((s) => {
const ws = s.workspaceId ? workspaces.find((w) => w.id === s.workspaceId) : undefined;
return {
id: s.id,
label: s.hostname,
hostLabel: s.hostLabel,
status: s.status,
workspaceId: s.workspaceId,
workspaceTitle: ws?.title,
};
});
const hostsForSystemMenu = hosts
.filter((host: any) => typeof host?.id === "string" && host.id.length > 0)
.map((host: any) => ({
id: host.id,
label: host.label,
hostname: host.hostname,
group: host.group,
pinned: host.pinned,
lastConnectedAt: host.lastConnectedAt,
protocol: host.protocol,
}));
void bridge.updateTrayMenuData({
sessions: sessionsForTray,
portForwardRules: portForwardingRules,
hosts: hostsForSystemMenu,
});
}, 250);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [enabled, hosts, sessions, portForwardingRules, workspaces]);
// Quit guard: block app exit while any editor tab has unsaved changes.
// Main process sends "app:query-dirty-editors"; we respond with the result.
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onCheckDirtyEditors) return;
const unsub = bridge.onCheckDirtyEditors(() => {
// Always report SOMETHING so the main process doesn't time out for
// 5 s on an unhandled exception. If we can't determine the state,
// fail open — losing unsaved work is bad, but stranding the user
// on a slow quit and then quitting anyway after the timeout is
// exactly the same outcome.
let hasDirty = false;
try {
hasDirty = editorTabStore.getTabs().some((tab) => tab.content !== tab.baselineContent);
if (hasDirty) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
} catch (err) {
console.error('[App] dirty-editors check failed:', err);
}
try {
bridge.reportDirtyEditorsResult?.(hasDirty);
} catch (err) {
// Reporting itself shouldn't throw, but if the IPC bridge is in a
// bad state we'd rather log than bubble out of the listener and
// disable the quit guard for the rest of the session.
console.error('[App] reportDirtyEditorsResult failed:', err);
}
});
return unsub;
}, [enabled, t]);
// Keyboard-interactive authentication (2FA/MFA) event listener
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onKeyboardInteractive) return;
const unsubscribe = bridge.onKeyboardInteractive((request) => {
if (!shouldQueueKeyboardInteractiveRequest(request, sessionsRef.current)) return;
console.log('[App] Keyboard-interactive request received:', request);
// Add to queue instead of replacing - supports multiple concurrent sessions
setKeyboardInteractiveQueue(prev => [...prev, {
requestId: request.requestId,
sessionId: request.sessionId,
name: request.name,
instructions: request.instructions,
prompts: request.prompts,
hostname: request.hostname,
savedPassword: request.savedPassword,
}]);
});
return () => {
unsubscribe?.();
};
}, [enabled, setKeyboardInteractiveQueue]);
}
+263
View File
@@ -0,0 +1,263 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildOrderedWorkTabIds,
isHostTreeWorkTabSurface,
isRootPageTabId,
isTerminalContentTabSurface,
reorderWorkTabIds,
resolveWorkTabActiveHostId,
resolveWorkTabHostTreeTheme,
} from './workTabSurface';
import type { EditorTab } from '../state/editorTabStore';
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
const makeTheme = (id: string, type: TerminalTheme['type'], background: string): TerminalTheme => ({
id,
name: id,
type,
colors: {
background,
foreground: type === 'dark' ? '#ffffff' : '#000000',
cursor: '#888888',
selection: '#555555',
black: '#000000',
red: '#ff0000',
green: '#00ff00',
yellow: '#ffff00',
blue: '#0000ff',
magenta: '#ff00ff',
cyan: '#00ffff',
white: '#ffffff',
brightBlack: '#444444',
brightRed: '#ff5555',
brightGreen: '#55ff55',
brightYellow: '#ffff55',
brightBlue: '#5555ff',
brightMagenta: '#ff55ff',
brightCyan: '#55ffff',
brightWhite: '#ffffff',
},
});
test('work tab order keeps custom positions and appends new tabs', () => {
assert.deepEqual(
buildOrderedWorkTabIds(['log-1', 'session-1'], ['session-1', 'workspace-1', 'log-1', 'editor:file-1']),
['log-1', 'session-1', 'workspace-1', 'editor:file-1'],
);
});
test('work tab order removes duplicate ids before rendering', () => {
assert.deepEqual(
buildOrderedWorkTabIds(
['session-2', 'session-1', 'session-2', 'session-1'],
['session-1', 'session-2', 'session-3', 'session-3'],
),
['session-2', 'session-1', 'session-3'],
);
});
test('work tab order reorders with newly materialized tabs', () => {
assert.deepEqual(
reorderWorkTabIds(
['session-1', 'session-2', 'session-3'],
['session-1', 'session-2', 'session-3'],
'session-1',
'session-3',
'after',
),
['session-2', 'session-3', 'session-1'],
);
});
test('root pages are not work tab surfaces', () => {
assert.equal(isRootPageTabId('vault'), true);
assert.equal(isRootPageTabId('sftp'), true);
assert.equal(isRootPageTabId('session-1'), false);
});
test('shared host tree is visible for editor, log, session, and workspace tabs', () => {
const sessionIds = new Set(['session-1']);
const workspaceIds = new Set(['workspace-1']);
const logViewIds = new Set(['log-1']);
const orderedTabs = ['session-1', 'workspace-1', 'editor:file-1', 'log-1'];
for (const activeTabId of orderedTabs) {
assert.equal(isHostTreeWorkTabSurface({
enabled: true,
activeTabId,
logViewIds,
orderedTabs,
sessionIds,
workspaceIds,
}), true);
}
});
test('shared host tree recognizes active log view before tab ordering catches up', () => {
assert.equal(isHostTreeWorkTabSurface({
enabled: true,
activeTabId: 'log-1',
logViewIds: new Set(['log-1']),
orderedTabs: [],
sessionIds: new Set(),
workspaceIds: new Set(),
}), true);
});
test('terminal content surface is limited to sessions and workspaces', () => {
const sessionIds = new Set(['session-1']);
const workspaceIds = new Set(['workspace-1']);
assert.equal(isTerminalContentTabSurface({ activeTabId: 'session-1', sessionIds, workspaceIds }), true);
assert.equal(isTerminalContentTabSurface({ activeTabId: 'workspace-1', sessionIds, workspaceIds }), true);
assert.equal(isTerminalContentTabSurface({ activeTabId: 'editor:file-1', sessionIds, workspaceIds }), false);
assert.equal(isTerminalContentTabSurface({ activeTabId: 'log-1', sessionIds, workspaceIds }), false);
});
test('shared host tree resolves active host ids across work tab types', () => {
const sessions = [
{ id: 'session-1', hostId: 'host-1' },
{ id: 'session-2', hostId: 'host-2', workspaceId: 'workspace-1' },
] as TerminalSession[];
const workspaces = [{
id: 'workspace-1',
focusedSessionId: 'session-2',
root: { id: 'pane-2', type: 'pane', sessionId: 'session-2' },
}] as Workspace[];
const editorTabs = [
{ id: 'file-1', hostId: 'host-3' },
] as EditorTab[];
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'session-1', sessions, workspaces, editorTabs }), 'host-1');
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'workspace-1', sessions, workspaces, editorTabs }), 'host-2');
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'editor:file-1', sessions, workspaces, editorTabs }), 'host-3');
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'log-1', sessions, workspaces, editorTabs }), null);
});
test('shared host tree falls back to the first workspace session when focused session is missing', () => {
const sessions = [
{ id: 'session-1', hostId: 'host-1', workspaceId: 'workspace-1' },
{ id: 'session-2', hostId: 'host-2', workspaceId: 'workspace-1' },
] as TerminalSession[];
const workspaces = [{
id: 'workspace-1',
focusedSessionId: 'missing-session',
root: {
id: 'split-1',
type: 'split',
direction: 'horizontal',
children: [
{ id: 'pane-1', type: 'pane', sessionId: 'session-1' },
{ id: 'pane-2', type: 'pane', sessionId: 'session-2' },
],
sizes: [0.5, 0.5],
},
}] as Workspace[];
assert.equal(resolveWorkTabActiveHostId({
activeTabId: 'workspace-1',
sessions,
workspaces,
editorTabs: [],
}), 'host-1');
});
test('shared host tree fallback prefers workspace tree order over sessions array order', () => {
const sessions = [
{ id: 'session-2', hostId: 'host-2', workspaceId: 'workspace-1' },
{ id: 'session-1', hostId: 'host-1', workspaceId: 'workspace-1' },
] as TerminalSession[];
const workspaces = [{
id: 'workspace-1',
focusedSessionId: 'missing-session',
root: {
id: 'split-1',
type: 'split',
direction: 'horizontal',
children: [
{ id: 'pane-1', type: 'pane', sessionId: 'session-1' },
{ id: 'pane-2', type: 'pane', sessionId: 'session-2' },
],
sizes: [0.5, 0.5],
},
}] as Workspace[];
assert.equal(resolveWorkTabActiveHostId({
activeTabId: 'workspace-1',
sessions,
workspaces,
editorTabs: [],
}), 'host-1');
});
test('shared host tree uses the active host theme when follow-app terminal theme is off', () => {
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
const hostTheme = makeTheme('host-light', 'light', '#fafafa');
const host = {
id: 'host-1',
label: 'Host',
hostname: 'host.local',
username: 'root',
tags: [],
os: 'linux',
theme: hostTheme.id,
themeOverride: true,
} as Host;
const resolved = resolveWorkTabHostTreeTheme({
activeHostId: host.id,
accentMode: 'theme',
currentTerminalTheme: currentTheme,
customAccent: '#8b5cf6',
followAppTerminalTheme: false,
hostById: new Map([[host.id, host]]),
themeById: new Map([[currentTheme.id, currentTheme], [hostTheme.id, hostTheme]]),
});
assert.equal(resolved.id, hostTheme.id);
});
test('shared host tree uses the followed terminal theme when follow-app terminal theme is on', () => {
const currentTheme = makeTheme('app-light', 'light', '#ffffff');
const hostTheme = makeTheme('host-dark', 'dark', '#050505');
const host = {
id: 'host-1',
label: 'Host',
hostname: 'host.local',
username: 'root',
tags: [],
os: 'linux',
theme: hostTheme.id,
themeOverride: true,
} as Host;
const resolved = resolveWorkTabHostTreeTheme({
activeHostId: host.id,
accentMode: 'theme',
currentTerminalTheme: currentTheme,
customAccent: '#8b5cf6',
followAppTerminalTheme: true,
hostById: new Map([[host.id, host]]),
themeById: new Map([[currentTheme.id, currentTheme], [hostTheme.id, hostTheme]]),
});
assert.equal(resolved.id, currentTheme.id);
});
test('shared host tree falls back to the current terminal theme without an active host', () => {
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
const resolved = resolveWorkTabHostTreeTheme({
activeHostId: null,
accentMode: 'theme',
currentTerminalTheme: currentTheme,
customAccent: '#8b5cf6',
followAppTerminalTheme: false,
hostById: new Map(),
themeById: new Map([[currentTheme.id, currentTheme]]),
});
assert.equal(resolved.id, currentTheme.id);
});
+178
View File
@@ -0,0 +1,178 @@
import {
fromEditorTabId,
isEditorTabId,
} from '../state/activeTabStore';
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from '../../domain/terminalAppearance';
import { collectSessionIds } from '../../domain/workspace';
import type { EditorTab } from '../state/editorTabStore';
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
function uniqueTabIds(tabIds: readonly string[]): string[] {
const seen = new Set<string>();
const uniqueIds: string[] = [];
for (const tabId of tabIds) {
if (!tabId || seen.has(tabId)) continue;
seen.add(tabId);
uniqueIds.push(tabId);
}
return uniqueIds;
}
export function isRootPageTabId(activeTabId: string): boolean {
return activeTabId === 'vault' || activeTabId === 'sftp';
}
export function buildOrderedWorkTabIds(
tabOrder: readonly string[],
allTabIds: readonly string[],
): string[] {
const uniqueAllTabIds = uniqueTabIds(allTabIds);
const allTabIdSet = new Set(uniqueAllTabIds);
const orderedIds = uniqueTabIds(tabOrder.filter((id) => allTabIdSet.has(id)));
const orderedIdSet = new Set(orderedIds);
const newIds = uniqueAllTabIds.filter((id) => !orderedIdSet.has(id));
return [...orderedIds, ...newIds];
}
export function reorderWorkTabIds(
tabOrder: readonly string[],
allTabIds: readonly string[],
draggedId: string,
targetId: string,
position: 'before' | 'after' = 'before',
): string[] {
if (draggedId === targetId) return buildOrderedWorkTabIds(tabOrder, allTabIds);
const currentOrder = buildOrderedWorkTabIds(tabOrder, allTabIds);
const draggedIndex = currentOrder.indexOf(draggedId);
const targetIndex = currentOrder.indexOf(targetId);
if (draggedIndex === -1 || targetIndex === -1) return [...tabOrder];
currentOrder.splice(draggedIndex, 1);
let nextTargetIndex = targetIndex;
if (draggedIndex < targetIndex) {
nextTargetIndex -= 1;
}
if (position === 'after') {
nextTargetIndex += 1;
}
currentOrder.splice(nextTargetIndex, 0, draggedId);
return currentOrder;
}
export function isHostTreeWorkTabSurface({
enabled,
activeTabId,
logViewIds = new Set(),
orderedTabs,
sessionIds,
workspaceIds,
}: {
enabled: boolean;
activeTabId: string;
logViewIds?: ReadonlySet<string>;
orderedTabs: readonly string[];
sessionIds: ReadonlySet<string>;
workspaceIds: ReadonlySet<string>;
}): boolean {
if (!enabled) return false;
if (isRootPageTabId(activeTabId)) return false;
return orderedTabs.includes(activeTabId)
|| isEditorTabId(activeTabId)
|| logViewIds.has(activeTabId)
|| sessionIds.has(activeTabId)
|| workspaceIds.has(activeTabId);
}
export function isTerminalContentTabSurface({
activeTabId,
sessionIds,
workspaceIds,
}: {
activeTabId: string;
sessionIds: ReadonlySet<string>;
workspaceIds: ReadonlySet<string>;
}): boolean {
return sessionIds.has(activeTabId) || workspaceIds.has(activeTabId);
}
export function resolveWorkspaceTargetSession(
workspace: Workspace,
sessions: readonly TerminalSession[],
): TerminalSession | undefined {
const sessionById = new Map(sessions.map((session) => [session.id, session]));
return resolveWorkspaceTargetSessionFromMap(workspace, sessionById);
}
export function resolveWorkspaceTargetSessionFromMap(
workspace: Workspace,
sessionById: ReadonlyMap<string, TerminalSession>,
): TerminalSession | undefined {
const orderedSessionIds = collectSessionIds(workspace.root);
const workspaceSessionIdSet = new Set(orderedSessionIds);
const focusedSession = workspace.focusedSessionId
? sessionById.get(workspace.focusedSessionId)
: undefined;
const validFocusedSession = focusedSession && workspaceSessionIdSet.has(focusedSession.id)
? focusedSession
: undefined;
if (validFocusedSession) return validFocusedSession;
for (const sessionId of orderedSessionIds) {
const session = sessionById.get(sessionId);
if (session) return session;
}
return undefined;
}
export function resolveWorkTabActiveHostId({
activeTabId,
editorTabs,
sessions,
workspaces,
}: {
activeTabId: string;
editorTabs: readonly EditorTab[];
sessions: readonly TerminalSession[];
workspaces: readonly Workspace[];
}): string | null {
if (isEditorTabId(activeTabId)) {
const editorId = fromEditorTabId(activeTabId);
return editorTabs.find((tab) => tab.id === editorId)?.hostId ?? null;
}
const activeSession = sessions.find((session) => session.id === activeTabId);
if (activeSession) return activeSession.hostId ?? null;
const activeWorkspace = workspaces.find((workspace) => workspace.id === activeTabId);
if (!activeWorkspace) return null;
const targetSession = resolveWorkspaceTargetSession(activeWorkspace, sessions);
return targetSession?.hostId ?? null;
}
export function resolveWorkTabHostTreeTheme({
activeHostId,
accentMode,
currentTerminalTheme,
customAccent,
followAppTerminalTheme,
hostById,
themeById,
}: {
activeHostId: string | null;
accentMode: 'theme' | 'custom';
currentTerminalTheme: TerminalTheme;
customAccent: string;
followAppTerminalTheme: boolean;
hostById: ReadonlyMap<string, Host>;
themeById: ReadonlyMap<string, TerminalTheme>;
}): TerminalTheme {
if (!activeHostId || followAppTerminalTheme) return currentTerminalTheme;
const host = hostById.get(activeHostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}
+93
View File
@@ -0,0 +1,93 @@
import type { SSHKey } from "../domain/models";
import { isEncryptedCredentialPlaceholder } from "../domain/credentials";
import { STORAGE_KEY_DEFAULT_KEY_PASSPHRASES } from "../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../infrastructure/persistence/localStorageAdapter";
import { encryptField, decryptField } from "../infrastructure/persistence/secureFieldAdapter";
export async function saveDefaultKeyPassphrase(keyPath: string, passphrase: string): Promise<void> {
const store = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? {};
store[keyPath] = await encryptField(passphrase) ?? passphrase;
localStorageAdapter.write(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES, store);
}
export async function loadDefaultKeyPassphrase(keyPath: string): Promise<string | null> {
const store = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES);
const enc = store?.[keyPath];
if (!enc) return null;
const decrypted = await decryptField(enc);
if (!decrypted || isEncryptedCredentialPlaceholder(decrypted)) {
removeDefaultKeyPassphrases([keyPath]);
return null;
}
return decrypted;
}
export function removeDefaultKeyPassphrases(keyPaths: string[]): void {
const store = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES);
if (!store) return;
let changed = false;
for (const keyPath of keyPaths) {
if (keyPath in store) {
delete store[keyPath];
changed = true;
}
}
if (changed) {
localStorageAdapter.write(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES, store);
}
}
export function clearReferenceKeyPassphrases(keys: SSHKey[], keyPaths: string[]): SSHKey[] {
let changed = false;
const updated = keys.map((key) => {
if (key.source === "reference" && key.filePath && keyPaths.includes(key.filePath) && key.passphrase) {
changed = true;
return { ...key, passphrase: undefined, savePassphrase: false };
}
return key;
});
return changed ? updated : keys;
}
export function clearKeyPassphrasesByIds(keys: SSHKey[], keyIds: string[] = []): SSHKey[] {
if (keyIds.length === 0) return keys;
const ids = new Set(keyIds);
let changed = false;
const updated = keys.map((key) => {
if (ids.has(key.id) && key.passphrase) {
changed = true;
return { ...key, passphrase: undefined, savePassphrase: false };
}
return key;
});
return changed ? updated : keys;
}
export function shouldUpdateReferenceKeyPassphrase(key?: SSHKey | null): boolean {
return Boolean(
key &&
(!key.passphrase || isEncryptedCredentialPlaceholder(key.passphrase)),
);
}
export async function rememberKeyPassphrase(args: {
keyPath: string;
passphrase: string;
keys: SSHKey[];
updateKeys: (keys: SSHKey[]) => Promise<unknown> | unknown;
setCurrentKeys?: (keys: SSHKey[]) => void;
}): Promise<void> {
const { keyPath, passphrase, keys, updateKeys, setCurrentKeys } = args;
await saveDefaultKeyPassphrase(keyPath, passphrase);
const refKey = keys.find((key) => key.source === "reference" && key.filePath === keyPath);
if (!refKey) return;
const updated = keys.map((key) =>
key.id === refKey.id
? { ...key, passphrase, savePassphrase: true }
: key
);
setCurrentKeys?.(updated);
await updateKeys(updated);
}
+10 -2
View File
@@ -14,11 +14,19 @@ const I18nContext = createContext<I18nContextValue | null>(null);
const interpolate = (template: string, values?: InterpolationValues): string => {
if (!values) return template;
return template.replace(/\{(\w+)\}/g, (_match, key: string) => {
const replaceDoubleBraceToken = (match: string, key: string) => {
const v = values[key];
if (v === null || v === undefined) return match;
return String(v);
};
const replaceSingleBraceToken = (_match: string, key: string) => {
const v = values[key];
if (v === null || v === undefined) return '';
return String(v);
});
};
return template
.replace(/\{\{(\w+)\}\}/g, replaceDoubleBraceToken)
.replace(/\{(\w+)\}/g, replaceSingleBraceToken);
};
const resolveMessage = (resolvedLocale: string, key: string): string | undefined => {
@@ -0,0 +1,30 @@
import test from "node:test";
import assert from "node:assert/strict";
import en from "../locales/en.ts";
import ru from "../locales/ru.ts";
import zhCN from "../locales/zh-CN.ts";
const strategyKeys = [
"cloudSync.strategy.title",
"cloudSync.strategy.desc",
"cloudSync.strategy.smartMerge",
"cloudSync.strategy.smartMergeDesc",
"cloudSync.strategy.preferCloud",
"cloudSync.strategy.preferCloudDesc",
"cloudSync.strategy.preferLocal",
"cloudSync.strategy.preferLocalDesc",
] as const;
test("cloud sync strategy copy exists in every bundled locale", () => {
for (const [locale, messages] of Object.entries({ en, ru, zhCN })) {
for (const key of strategyKeys) {
assert.equal(
typeof messages[key],
"string",
`${locale} is missing ${key}`,
);
assert.notEqual(messages[key], "", `${locale} has empty ${key}`);
}
}
});
File diff suppressed because it is too large. Load diff
+492
View File
@@ -0,0 +1,492 @@
import type { Messages } from '../types';
export const enAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Agent Settings',
'ai.chat.preparing': 'Preparing…',
'ai.chat.compactingContext': 'Compacting earlier context…',
'ai.chat.compactingStep': 'Trimming context for next step…',
'ai.chat.compactionRetry': 'Request was too large. Compacting context and retrying…',
'ai.chat.compactionBanner': 'Context compacted: {before}K → {after}K tokens',
'ai.title': 'AI',
'ai.description': 'Configure AI providers, agents, and safety settings',
'ai.providers': 'Providers',
'ai.agents': 'Agents',
'ai.providers.empty': 'No providers configured. Add a provider to get started.',
'ai.providers.add': 'Add Provider',
'ai.providers.active': 'Active',
'ai.providers.apiKeyConfigured': 'API key configured',
'ai.providers.noApiKey': 'No API key',
'ai.providers.configure': 'Configure',
'ai.providers.remove': 'Remove',
'ai.providers.name': 'Display Name',
'ai.providers.name.placeholder': 'e.g. My Provider',
'ai.providers.style': 'Protocol style',
'ai.providers.style.anthropic': 'Anthropic-compatible',
'ai.providers.style.openai': 'OpenAI-compatible',
'ai.providers.style.google': 'Google-compatible',
'ai.providers.style.inherited': 'auto',
'ai.providers.style.help': 'Selects which API format requests use. Override when a third-party endpoint speaks a different dialect than its provider type suggests.',
'ai.providers.icon.change': 'Change icon',
'ai.providers.icon.upload': 'Upload image',
'ai.providers.icon.reset': 'Reset',
'ai.providers.icon.close': 'Close',
'ai.providers.icon.uploadedNote': 'Custom icon (64×64 WebP)',
'ai.providers.icon.errorType': 'Please choose an image file.',
'ai.providers.apiKey': 'API Key',
'ai.providers.apiKey.placeholder': 'Enter API key',
'ai.providers.apiKey.decrypting': 'Decrypting...',
'ai.providers.baseUrl': 'Base URL',
'ai.providers.skipTLSVerify': 'Skip TLS certificate verification (for self-signed certs)',
'ai.providers.defaultModel': 'Default Model',
'ai.providers.defaultModel.placeholder': 'e.g. gpt-4o, claude-sonnet-4-20250514',
'ai.providers.contextWindow': 'Context window',
'ai.providers.contextWindow.placeholder': 'e.g. 128000',
'ai.providers.contextWindow.help': 'Leave blank to use the model list value when available, otherwise Netcatty uses a safe default.',
'ai.providers.contextWindow.error': 'Enter a positive whole number, or leave it blank.',
'ai.providers.refreshModels': 'Refresh models',
'ai.providers.searchModel': 'Search or type model ID...',
'ai.providers.filterModels': 'Filter models...',
'ai.providers.loadingModels': 'Loading models...',
'ai.providers.noMatchingModels': 'No matching models',
'ai.providers.clickToLoadModels': 'Click to load models',
'ai.providers.showingModels': 'Showing first 100 of {count} models. Type to filter.',
'ai.providers.advancedParams': 'Advanced Parameters',
'ai.providers.advancedParams.hint': 'Leave blank to use provider defaults.',
'ai.providers.advancedParams.maxTokens.placeholder': 'e.g. 4096',
'ai.providers.advancedParams.default': 'Provider default',
// AI Codex
'ai.codex': 'Codex',
'ai.codex.title': 'Codex CLI',
'ai.codex.description': 'Connect OpenAI Codex. Sign in with ChatGPT here, or enable an OpenAI-compatible provider API key and custom endpoint in Settings.',
'ai.codex.detecting': 'Detecting...',
'ai.codex.notFound': 'Not found',
'ai.codex.awaitingLogin': 'Awaiting login',
'ai.codex.connectedChatGPT': 'Connected via ChatGPT',
'ai.codex.connectedApiKey': 'Connected via API key',
'ai.codex.connectedCustomConfig': 'Connected via ~/.codex/config.toml',
'ai.codex.customConfigIncomplete': 'Custom config detected (env var missing)',
'ai.codex.customConfigHint': 'Using custom provider "{provider}" configured in ~/.codex/config.toml — no ChatGPT login needed.',
'ai.codex.customConfigMissingEnvKey': 'Warning: {envKey} is not set in your shell environment. Export it (or launch netcatty from a shell that has it) so Codex can authenticate.',
'ai.codex.notConnected': 'Not connected',
'ai.codex.statusUnknown': 'Status unknown',
'ai.codex.path': 'Path:',
'ai.codex.notFoundHint': 'Could not find codex in PATH. Install it or specify the executable path below.',
'ai.codex.customPathPlaceholder': 'e.g. /usr/local/bin/codex',
'ai.codex.check': 'Check',
'ai.codex.resetPath': 'Reset',
'ai.codex.openLogin': 'Open Login',
'ai.codex.logout': 'Logout',
'ai.codex.connectChatGPT': 'Connect ChatGPT',
'ai.codex.refreshStatus': 'Refresh Status',
// AI Claude Code
'ai.claude.title': 'Claude Code',
'ai.claude.description': "Anthropic's agentic coding assistant. Requires the system Claude Code CLI.",
'ai.claude.detecting': 'Detecting...',
'ai.claude.detected': 'Detected',
'ai.claude.notFound': 'Not found',
'ai.claude.path': 'Path:',
'ai.claude.notFoundHint': 'Could not find claude in PATH. Install it or specify the executable path below.',
'ai.claude.customPathPlaceholder': 'e.g. /usr/local/bin/claude',
'ai.claude.configSection': 'Authentication & config (optional)',
'ai.claude.configDir': 'Config directory',
'ai.claude.configDir.placeholder': '~/.claude (leave blank for default)',
'ai.claude.configDir.hint': 'Sets CLAUDE_CONFIG_DIR — point at a folder where you have run `claude` login (contains settings.json + credentials).',
'ai.claude.settings': 'Settings file',
'ai.claude.settings.placeholder': '~/team-settings.json (path, or inline {"model":"..."})',
'ai.claude.settings.hint': 'Optional. A settings.json path or inline JSON, passed to the SDK as `settings`. Additive to — and independent of — the config directory above (merged on top, not a replacement).',
'ai.claude.envVars': 'Environment variables',
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
'ai.claude.envVars.hint': 'One KEY=VALUE per line, passed to the Claude agent. Stored locally in plaintext — for API keys / credentials, prefer the config directory above (a `claude` login).',
'ai.claude.check': 'Check',
'ai.claude.resetPath': 'Reset',
// AI GitHub Copilot CLI
'ai.copilot.title': 'GitHub Copilot CLI',
'ai.copilot.description': 'Uses the GitHub Copilot CLI. Once detected, it can be selected as an external coding agent.',
'ai.copilot.detecting': 'Detecting...',
'ai.copilot.detected': 'Detected',
'ai.copilot.notFound': 'Not found',
'ai.copilot.path': 'Path:',
'ai.copilot.notFoundHint': 'Could not find copilot in PATH. Install it or specify the executable path below.',
'ai.copilot.customPathPlaceholder': 'e.g. /usr/local/bin/copilot',
'ai.copilot.check': 'Check',
'ai.copilot.resetPath': 'Reset',
// AI Cursor SDK
'ai.cursor.title': 'Cursor',
'ai.cursor.description': 'Uses the Cursor SDK.',
'ai.cursor.detecting': 'Detecting...',
'ai.cursor.detected': 'Available',
'ai.cursor.notFound': 'Unavailable',
'ai.cursor.path': 'Runtime:',
'ai.cursor.notFoundHint': 'Enter an API key to enable Cursor.',
'ai.cursor.notInstalledHint': 'Cursor SDK was not detected.',
'ai.cursor.installStatus': 'Cursor SDK',
'ai.cursor.installed': 'Detected',
'ai.cursor.notInstalled': 'Not detected',
'ai.cursor.apiKeyStatus': 'API Key',
'ai.cursor.apiKeyConfigured': 'Configured',
'ai.cursor.apiKeyMissing': 'Missing',
'ai.cursor.apiKeyFromEnv': 'From environment',
'ai.cursor.apiKey': 'API Key',
'ai.cursor.apiKeyPlaceholder': 'Enter Cursor API key',
'ai.cursor.apiKeyPlaceholder.env': 'Using CURSOR_API_KEY; enter a key to override',
'ai.cursor.apiKeyEnvHint': 'Cursor can use CURSOR_API_KEY from your shell. Save a key here only if you want Netcatty to override it.',
'ai.cursor.apiKeyOverrideHint': 'Netcatty will use the saved key here before CURSOR_API_KEY.',
'ai.cursor.saveApiKey': 'Save',
'ai.cursor.saved': 'Saved',
'ai.cursor.showApiKey': 'Show API key',
'ai.cursor.hideApiKey': 'Hide API key',
'ai.cursor.customPathPlaceholder': 'e.g. /usr/local/bin/cursor',
'ai.cursor.check': 'Check',
// AI CodeBuddy Code
'ai.codebuddy.title': 'CodeBuddy Code',
'ai.codebuddy.description': 'Uses CodeBuddy Code via the official Agent SDK (`@tencent-ai/agent-sdk`). Once detected, it can be selected as an external coding agent.',
'ai.codebuddy.detecting': 'Detecting...',
'ai.codebuddy.detected': 'Detected',
'ai.codebuddy.notFound': 'Not found',
'ai.codebuddy.path': 'Path:',
'ai.codebuddy.notFoundHint': 'Could not find codebuddy in PATH. Install it or specify the executable path below.',
'ai.codebuddy.customPathPlaceholder': 'e.g. /usr/local/bin/codebuddy',
'ai.codebuddy.check': 'Check',
'ai.codebuddy.resetPath': 'Reset',
'ai.codebuddy.configSection': 'Authentication & config (optional)',
'ai.codebuddy.internetEnv': 'Internet Environment',
'ai.codebuddy.internetEnv.default': 'Default (overseas)',
'ai.codebuddy.internetEnv.internal': 'Internal',
'ai.codebuddy.internetEnv.ioa': 'IOA',
'ai.codebuddy.internetEnv.hint': 'Sets CODEBUDDY_INTERNET_ENVIRONMENT — choose Internal or IOA for restricted network environments.',
'ai.codebuddy.envVars': 'Environment variables',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': 'One KEY=VALUE per line, passed to the CodeBuddy agent. Set CODEBUDDY_API_KEY or CODEBUDDY_AUTH_TOKEN here for authentication. Stored locally in plaintext.',
// AI OpenCode
'ai.opencode.title': 'OpenCode',
'ai.opencode.description': 'Uses OpenCode via the official SDK. Configure providers and keys in OpenCode, then select it as an external coding agent.',
'ai.opencode.detecting': 'Detecting...',
'ai.opencode.detected': 'Detected',
'ai.opencode.notFound': 'Not found',
'ai.opencode.path': 'Path:',
'ai.opencode.notFoundHint': 'Could not find opencode in PATH. Install it or specify the executable path below.',
'ai.opencode.customPathPlaceholder': 'e.g. /usr/local/bin/opencode',
'ai.opencode.check': 'Check',
'ai.opencode.resetPath': 'Reset',
// AI Default Agent
'ai.defaultAgent': 'Default Agent',
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
'ai.defaultAgent.catty': 'Catty (Built-in)',
'ai.toolAccess.title': 'Tool Access',
'ai.toolAccess.mode': 'Netcatty Access Mode',
'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',
'ai.userSkills.reload': 'Reload Skills',
'ai.userSkills.location': 'Location',
'ai.userSkills.loading': 'Scanning user skills...',
'ai.userSkills.summary': '{ready} ready, {warnings} warnings',
'ai.userSkills.empty': 'No user skills found yet. Open the folder to add skill directories with a SKILL.md file.',
'ai.userSkills.unavailable': 'User skills are unavailable in this environment.',
'ai.userSkills.status.ready': 'Ready',
'ai.userSkills.status.warning': 'Warning',
// AI Quick Messages
'ai.quickMessages.title': 'Quick Messages',
'ai.quickMessages.description': 'Create reusable prompts you can insert from the AI chat with / or the quick-message button. Unlike user skills, quick messages fill the composer with text.',
'ai.quickMessages.add': 'Add Quick Message',
'ai.quickMessages.createTitle': 'New Quick Message',
'ai.quickMessages.editTitle': 'Edit Quick Message',
'ai.quickMessages.name': 'Name',
'ai.quickMessages.name.placeholder': 'e.g. Check disk space',
'ai.quickMessages.slug': 'Command',
'ai.quickMessages.slug.placeholder': 'disk-check',
'ai.quickMessages.descriptionField': 'Description (optional)',
'ai.quickMessages.descriptionField.placeholder': 'Short hint about what this prompt does',
'ai.quickMessages.content': 'Message content',
'ai.quickMessages.content.placeholder': 'Full prompt text to insert when selected...',
'ai.quickMessages.empty': 'No quick messages yet. Add a few prompts you use often.',
'ai.quickMessages.confirmDelete': 'Delete quick message "{name}"?',
'ai.quickMessages.error.nameRequired': 'Name is required.',
'ai.quickMessages.error.invalidSlug': 'Command may only contain lowercase letters, numbers, and hyphens.',
'ai.quickMessages.error.contentRequired': 'Message content is required.',
'ai.quickMessages.error.slugTaken': 'This command is already used by another quick message.',
'ai.quickMessages.error.slugConflictsWithSkill': 'This command conflicts with user skill "/{slug}". Choose another.',
'ai.quickMessages.error.maxItems': 'You can save at most {max} quick messages.',
// AI Chat
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
'ai.chat.toolDenied': 'Action was rejected by the user.',
'ai.chat.toolApproved': 'Approved',
'ai.chat.toolApprovalHint': 'Enter once · Esc reject',
'ai.chat.approve': 'Approve',
'ai.chat.approveOnce': 'Once',
'ai.chat.alwaysAllow': 'Always',
'ai.chat.slashStopDesc': 'Stop the current AI turn and cancel in-flight tools',
'ai.chat.reject': 'Reject',
'ai.chat.toolLabel': 'Tool',
'ai.chat.targetLabel': 'Target',
'ai.chat.permissionRequired': 'Permission Required',
'ai.chat.permissionDescription': 'The AI agent wants to execute a tool call that requires your approval.',
'ai.chat.commandBlocked': 'This command is blocked by your security policy and cannot be executed.',
'ai.chat.recommendAllow': 'Allow',
'ai.chat.recommendConfirm': 'Confirm',
'ai.chat.recommendDeny': 'Deny',
'ai.chat.exportConversation': 'Export conversation',
'ai.chat.exportAs': 'Export As',
'ai.chat.exportMarkdown': 'Markdown',
'ai.chat.exportJSON': 'JSON',
'ai.chat.exportPlainText': 'Plain Text',
'ai.chat.thinking': 'Thinking',
'ai.chat.thoughtFor': 'Thought for {duration}',
'ai.chat.thought': 'Thought',
'ai.chat.agents': 'Agents',
'ai.chat.detectedOnMachine': 'Detected on this machine',
'ai.chat.rescan': 'Re-scan',
'ai.chat.permObserver': 'Observer',
'ai.chat.permConfirm': 'Confirm',
'ai.chat.permAuto': 'Auto',
'ai.chat.permObserverDesc': 'Read only',
'ai.chat.permConfirmDesc': 'Ask before actions',
'ai.chat.permAutoDesc': 'Execute freely',
'ai.chat.emptyHint': 'Ask about your servers, run commands, or get help with configurations.',
'ai.chat.placeholder': 'Message {agent} — @ to include context, / for commands',
'ai.chat.placeholderDefault': 'Message Catty Agent...',
'ai.chat.noModel': 'No model',
'ai.chat.noProviderModel': 'No default model — set one in Settings → AI → Providers.',
'ai.chat.selectProvider': 'Select provider',
'ai.chat.recent': 'Recent',
'ai.chat.viewAll': 'View All',
'ai.chat.untitled': 'Untitled',
'ai.chat.justNow': 'Just now',
'ai.chat.minutesAgo': '{n}m ago',
'ai.chat.hoursAgo': '{n}h ago',
'ai.chat.daysAgo': '{n}d ago',
'ai.chat.newChat': 'New Chat',
'ai.chat.allSessions': 'All Sessions',
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
'ai.chat.usedTools': 'Tools used: {n}',
'ai.chat.loadMoreSessions': 'Load more sessions ({n} more)',
'ai.chat.noSessions': 'No previous sessions',
'ai.chat.retryHint': 'You can retry by sending your message again.',
'ai.chat.approvalTimeout': 'Tool approval timed out after 5 minutes. You can retry by sending your message again.',
'ai.chat.menuHosts': 'Hosts',
'ai.chat.menuContext': 'Context',
'ai.chat.menuFiles': 'Files',
'ai.chat.menuImage': 'Image',
'ai.chat.menuMentionHost': 'Mention Host',
'ai.chat.menuUserSkills': 'User Skills',
'ai.chat.menuSlashCommands': 'Slash Commands',
'ai.chat.slashCommands': 'Slash commands',
'ai.chat.slashQuickMessages': 'Quick messages',
'ai.chat.slashUserSkills': 'User skills',
'ai.chat.quickMessages': 'Slash commands',
'ai.chat.slashNoResults': 'No matching commands',
'ai.chat.slashEmptyHint': 'Add prompts in Settings → AI → Quick Messages.',
// AI Chat Shortcuts
'ai.chatShortcuts.title': 'Chat Shortcuts',
'ai.chatShortcuts.selectionAction': 'Show Add to Conversation when selecting terminal text',
'ai.chatShortcuts.selectionAction.description': 'Show a small AI button next to selected terminal text.',
// AI Error
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
// AI Web Search
'ai.webSearch.title': 'Web Search',
'ai.webSearch.enable': 'Enable Web Search',
'ai.webSearch.enable.description': 'Allow the AI agent to search the web for current information.',
'ai.webSearch.provider': 'Search Provider',
'ai.webSearch.provider.description': 'Choose a web search API provider.',
'ai.webSearch.apiKey': 'API Key',
'ai.webSearch.apiKey.description': 'API key for the selected search provider.',
'ai.webSearch.apiKey.placeholder': 'Enter API key...',
'ai.webSearch.apiHost': 'API Host',
'ai.webSearch.apiHost.description': 'Custom API endpoint. Leave default unless you use a proxy.',
'ai.webSearch.apiHost.searxngDescription': 'URL of your SearXNG instance (required).',
'ai.webSearch.maxResults': 'Max Results',
'ai.webSearch.maxResults.description': 'Maximum number of search results to return (1-20).',
// AI Safety Settings
'ai.safety.title': 'Safety',
'ai.safety.permissionMode': 'Permission Mode',
'ai.safety.permissionMode.description': 'Controls how the AI interacts with your Netcatty terminal sessions. Observer mode blocks write operations that go through Netcatty. External agent CLIs may still have their own local tools and approval flow.',
'ai.safety.permissionMode.observer': 'Observer - Read only, no actions',
'ai.safety.permissionMode.confirm': 'Confirm - Ask before actions',
'ai.safety.permissionMode.auto': 'Auto - Execute freely',
'ai.safety.commandTimeout': 'Command Timeout',
'ai.safety.commandTimeout.description': 'Maximum seconds a command can run before being terminated through Netcatty execution.',
'ai.safety.commandTimeout.unit': 'sec',
'ai.safety.maxIterations': 'Max Iterations',
'ai.safety.maxIterations.description': 'Maximum number of AI tool-use loops to prevent runaway execution. External agents may have their own internal iteration limits that take precedence.',
'ai.safety.blocklist': 'Command Blocklist',
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands executed through Netcatty.',
'ai.safety.blocklist.placeholder': 'Regex pattern...',
'ai.safety.blocklist.reset': 'Reset to defaults',
'ai.safety.blocklist.add': 'Add pattern',
'ai.safety.grants.title': 'Permission memory',
'ai.safety.grants.heading': 'Confirm-mode allow rules',
'ai.safety.grants.description': 'Confirm mode asks before running an operation. Saved rules automatically allow matching operations across all terminal sessions/nodes, and can be edited manually.',
'ai.safety.grants.empty': 'No saved rules yet. Approve a tool with “Always allow”, or add one manually.',
'ai.safety.grants.capability': 'Capability',
'ai.safety.grants.sessionPattern': 'Session pattern',
'ai.safety.grants.commandPattern': 'Command pattern (optional)',
'ai.safety.grants.note': 'Note (optional)',
'ai.safety.grants.add': 'Add rule',
'ai.safety.grants.remove': 'Remove',
'ai.safety.grants.export': 'Export JSON',
'ai.safety.grants.import': 'Import JSON',
'ai.safety.note': 'These safety settings are enforced for actions that go through Netcatty. External agent CLIs may also expose local tools that are governed by the agent itself.',
// Unified tooltips for terminal workspace and top tabs (issue #954)
'terminal.layer.addTerminal': 'Add Terminal',
'terminal.layer.switchToSplitView': 'Switch to Split View',
'terminal.layer.sftp': 'SFTP',
'terminal.layer.scripts': 'Scripts',
'terminal.layer.history': 'History',
'terminal.layer.theme': 'Theme',
'terminal.layer.notes': 'Notes',
'terminal.layer.aiChat': 'AI Chat',
'terminal.layer.movePanelLeft': 'Move panel to left',
'terminal.layer.movePanelRight': 'Move panel to right',
'terminal.layer.closePanel': 'Close panel',
'terminal.layer.hostTree.search': 'Search hosts...',
'terminal.layer.hostTree.searchButton': 'Search',
'terminal.layer.hostTree.tagsButton': 'Filter by tags',
'terminal.layer.hostTree.newGroup': 'New group',
'terminal.layer.hostTree.localShell': 'Local shell',
'terminal.layer.hostTree.tagsEmpty': 'No tags available',
'terminal.layer.hostTree.clearTags': 'Clear selection',
'terminal.layer.hostTree.collapse': 'Collapse host list',
'terminal.layer.hostTree.expand': 'Expand host list',
'terminal.layer.hostTree.empty': 'No hosts found',
'terminal.layer.hostTree.details.host': 'Host',
'terminal.layer.hostTree.details.user': 'User',
'terminal.layer.hostTree.details.port': 'Port',
'terminal.layer.hostTree.details.protocol': 'Protocol',
'terminal.layer.hostTree.details.group': 'Group',
'terminal.layer.hostTree.details.tags': 'Tags',
'terminal.layer.hostTree.details.lastConnected': 'Last connected',
'topTabs.openQuickSwitcher': 'Open quick switcher',
'topTabs.moreTabs': 'More tabs',
'topTabs.aiAssistant': 'AI Assistant',
'topTabs.windowOpacity': 'Window opacity',
'topTabs.toggleTheme': 'Toggle theme',
'topTabs.openSettings': 'Open Settings',
'ai.chat.sessionHistory': 'Session history',
'ai.chat.attach': 'Attach',
'ai.chat.terminalSelectionAttachment': 'Terminal selection',
'ai.chat.terminalSelectionLines': 'lines: {count}',
'ai.chat.collapse': 'Collapse',
'ai.chat.expand': 'Expand',
'ai.chat.enableAgent': 'Enable {name}',
'ai.chat.artifact.noteFallback': 'Vault note',
'ai.chat.artifact.openNotes': 'Open Notes',
'ai.chat.artifact.openHosts': 'Open Hosts',
'ai.chat.artifact.notesSummary': '{count} notes in Vault',
'ai.chat.artifact.hostsSummary': '{count} hosts in Vault',
'ai.chat.artifact.hostsAdded': 'Added {count} hosts',
'ai.chat.artifact.hostsPreview': 'Preview {count} hosts',
'ai.chat.artifact.failed': 'Vault operation failed',
'ai.chat.artifact.unavailableTitle': 'Unavailable',
'ai.chat.artifact.noteMissing': 'This note is no longer in your Vault.',
'ai.chat.artifact.hostMissing': 'This host is no longer in your Vault.',
'ai.chat.artifact.snippetMissing': 'This snippet or script is no longer in your Vault.',
'ai.chat.artifact.openSnippets': 'Open Snippets',
'ai.chat.artifact.snippetsSummary': '{count} snippets in Vault',
'ai.chat.artifact.scriptsSummary': '{count} scripts in Vault',
'ai.chat.artifact.snippetFallback': 'Vault snippet',
'ai.chat.artifact.scriptFallback': 'Automation script',
'ai.chat.artifact.scriptLanguage': '{language} script',
'ai.chat.artifact.snippetDeleted': 'Snippet deleted',
'ai.chat.artifact.scriptDeleted': 'Script deleted',
'ai.chat.artifact.snippetRan': 'Snippet executed',
'ai.chat.artifact.scriptStarted': 'Script run started',
'ai.chat.artifact.scriptRunStatus': 'Script run {status}',
'ai.chat.artifact.scriptRunsSummary': '{count} script runs',
'ai.chat.artifact.scriptRunStopped': 'Script run stopped',
'ai.chat.artifact.scriptRunPaused': 'Script run paused',
'ai.chat.artifact.scriptRunResumed': 'Script run resumed',
'ai.chat.artifact.scriptReference': 'nct API reference',
'zmodem.waitingForRemote': 'Waiting for remote...',
'zmodem.uploading': 'Uploading',
'zmodem.downloading': 'Downloading',
'zmodem.cancelTransfer': 'Cancel transfer (Ctrl+C)',
'zmodem.overwrite.title': 'Remote file already exists',
'zmodem.overwrite.applyToRest': 'Apply to remaining conflicts',
'zmodem.overwrite.overwrite': 'Overwrite',
'zmodem.overwrite.skip': 'Skip',
'zmodem.overwrite.cancel': 'Cancel',
'settings.shortcuts.resetToDefault': 'Reset to default',
};
+804
View File
@@ -0,0 +1,804 @@
import type { Messages } from '../types';
export const enCoreMessages: Messages = {
// Common
'common.save': 'Save',
'common.cancel': 'Cancel',
'common.close': 'Close',
'common.reset': 'Reset',
'common.zoomIn': 'Zoom in',
'common.zoomOut': 'Zoom out',
'common.settings': 'Settings',
'common.search': 'Search',
'common.searchPlaceholder': 'Search...',
'common.connect': 'Connect',
'common.terminal': 'Terminal',
'common.create': 'Create',
'common.import': 'Import',
'common.generate': 'Generate',
'common.delete': 'Delete',
'common.edit': 'Edit',
'common.clear': 'Clear',
'common.optional': 'Optional',
'common.selectPlaceholder': 'Select...',
'common.add': 'Add',
'common.rename': 'Rename',
'common.refresh': 'Refresh',
'common.continue': 'Continue',
'common.enabled': 'Enabled',
'common.disabled': 'Disabled',
'common.error': 'Error',
'common.validation': 'Validation',
'common.unknownError': 'Unknown error',
'common.noResultsFound': 'No results found',
'common.back': 'Back',
'common.apply': 'Apply',
'common.use': 'Use',
'common.useGlobal': 'Use global',
'common.saveChanges': 'Save Changes',
'common.advanced': 'Advanced',
'common.left': 'Left',
'common.right': 'Right',
'common.more': 'More',
'common.selectAHost': 'Select a host',
'common.selectAHostPlaceholder': 'Select a host...',
'sort.manual': 'Manual order',
'sort.az': 'A-z',
'sort.za': 'Z-a',
'sort.newest': 'Newest to oldest',
'sort.oldest': 'Oldest to newest',
'sort.group': 'By group',
'field.label': 'Label',
'field.type': 'Type',
'auth.keyType': 'Type {type}',
'auth.showAllKeys': 'Show all keys',
// Dialogs / prompts
'confirm.deleteHost': 'Delete Host "{name}"?',
'confirm.deleteIdentity': 'Delete Identity "{name}"?',
'confirm.removeProvider': 'Remove provider "{name}"?',
'confirm.closeBusyTerminal.title': 'Confirm close',
'confirm.closeBusyTerminal.message': 'Process "{command}" is still running and will be terminated.',
'confirm.closeBusyTerminal.messageWithMore': 'Process "{command}" and {count} other running process(es) will be terminated.',
'confirm.closeBusyTerminal.cancel': 'Cancel',
'confirm.closeBusyTerminal.close': 'Close',
'dialog.createWorkspace.title': 'Create Workspace',
'dialog.renameWorkspace.title': 'Rename workspace',
'dialog.renameSession.title': 'Rename session',
'field.name': 'Name',
'field.selectHosts': 'Select Hosts',
'placeholder.workspaceName': 'Workspace name',
'placeholder.sessionName': 'Session name',
'placeholder.searchHosts': 'Search hosts...',
'toast.settingsUnavailable': 'Settings window is unavailable on this platform.',
'credentials.protectionUnavailable.title': 'Credential Protection Unavailable',
'credentials.protectionUnavailable.message': 'Saved passwords and keys cannot be auto-decrypted on this device. Re-enter credentials before connecting.',
'credentials.protectionUnavailable.action': 'Open Settings',
// Settings shell
'settings.title': 'Settings',
'settings.tab.application': 'Application',
'settings.tab.appearance': 'Appearance',
'settings.tab.terminal': 'Terminal',
'settings.tab.shortcuts': 'Shortcuts',
'settings.tab.syncCloud': 'Sync & Cloud',
'settings.tab.system': 'System',
// Settings > System
'settings.system.title': 'System',
'settings.system.description': 'System information and temporary file management.',
'settings.system.tempDirectory': 'Temporary Files',
'settings.system.location': 'Location',
'settings.system.fileCount': 'Files',
'settings.system.totalSize': 'Size',
'settings.system.openFolder': 'Open folder',
'settings.system.refresh': 'Refresh',
'settings.system.clearTempFiles': 'Clear temp files',
'settings.system.clearing': 'Clearing...',
'settings.system.clearResult': 'Deleted {deleted} file(s), {failed} failed.',
'settings.system.tempDirectoryHint': 'Temporary files are created when opening remote files with external applications. They are automatically cleaned up when SFTP sessions close.',
'settings.system.credentials.title': 'Credential Protection',
'settings.system.credentials.status': 'Status',
'settings.system.credentials.checking': 'Checking...',
'settings.system.credentials.available': 'Available (OS keychain ready)',
'settings.system.credentials.unavailable': 'Unavailable (cannot decrypt saved credentials)',
'settings.system.credentials.unknown': 'Unknown (not supported in this environment)',
'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.',
'settings.system.crashLogs.noLogs': 'No crash logs found.',
'settings.system.crashLogs.entries': '{count} entries',
'settings.system.crashLogs.clear': 'Clear all logs',
'settings.system.crashLogs.cleared': 'Cleared {count} log file(s).',
'settings.system.crashLogs.source': 'Source',
'settings.system.crashLogs.time': 'Time',
'settings.system.crashLogs.message': 'Message',
'settings.system.crashLogs.stack': 'Stack Trace',
'settings.system.crashLogs.hint': 'Crash logs are retained for 30 days and automatically rotated.',
'settings.system.crashLogs.collapse': 'Collapse',
'settings.system.crashLogs.expand': 'Show details',
// Settings > System > Software Update
'settings.update.title': 'Software Update',
'settings.update.currentVersion': 'Current version',
'settings.update.checkForUpdates': 'Check for Updates',
'settings.update.checking': 'Checking...',
'settings.update.upToDate': 'You are using the latest version.',
'settings.update.available': 'New version {version} is available.',
'settings.update.download': 'Download Update',
'settings.update.downloading': 'Downloading... {percent}%',
'settings.update.readyToInstall': 'Update downloaded and ready to install.',
'settings.update.restartNow': 'Restart to Update',
'settings.update.error': 'Failed to check for updates.',
'settings.update.downloadError': 'Download failed.',
'settings.update.manualDownload': 'Download from GitHub',
'settings.update.manualDownloadHint': 'Auto-update is not available on this platform. Download the latest version from GitHub.',
'settings.update.hint': 'Netcatty checks for updates from GitHub Releases.',
'settings.update.lastCheckedJustNow': 'just now',
'settings.update.lastCheckedMinutesAgo': '{n} min ago',
'settings.update.lastCheckedHoursAgo': '{n} hr ago',
'settings.update.lastCheckedPrefix': 'Last checked: ',
'settings.update.autoUpdateEnabled': 'Automatic Updates',
'settings.update.autoUpdateEnabledDesc': 'Automatically check and download updates when available.',
// Settings > Session Restore
'settings.sessionRestore.title': 'Session Restore',
'settings.sessionRestore.restorePreviousSession': 'Restore previous tabs and workspaces',
'settings.sessionRestore.restorePreviousSessionDesc': 'Reopen terminal tabs and workspace layouts from the last app session.',
'settings.sessionRestore.restoreTerminalCwd': 'Restore terminal working directory',
'settings.sessionRestore.restoreTerminalCwdDesc': 'When reconnecting a restored terminal, try to return to its last known directory without blocking the connection.',
'terminal.restore.placeholder.title': 'Restored session',
'terminal.restore.placeholder.desc': 'This terminal is disconnected. Reconnect when you are ready to start a new shell for this tab.',
'terminal.restore.placeholder.reconnect': 'Reconnect',
'terminal.restore.cwdLog': 'Restoring working directory: {cwd}',
// Settings > Session Logs
'settings.sessionLogs.title': 'Session Logs',
'settings.sessionLogs.description': 'Configure session log export and auto-save settings.',
'settings.sessionLogs.autoSave': 'Auto-Save',
'settings.sessionLogs.enableAutoSave': 'Enable auto-save',
'settings.sessionLogs.enableAutoSaveDesc': 'Automatically save session logs when terminal sessions end.',
'settings.sessionLogs.directory': 'Save Directory',
'settings.sessionLogs.noDirectory': 'No directory selected',
'settings.sessionLogs.browse': 'Browse',
'settings.sessionLogs.openFolder': 'Open folder',
'settings.sessionLogs.directoryHint': 'Logs will be organized by host in subdirectories.',
'settings.sessionLogs.format': 'Log Format',
'settings.sessionLogs.formatDesc': 'Choose the format for saved log files.',
'settings.sessionLogs.formatTxt': 'Plain Text (.txt)',
'settings.sessionLogs.formatRaw': 'Raw with ANSI (.log)',
'settings.sessionLogs.formatHtml': 'HTML (.html)',
'settings.sessionLogs.timestamps': 'Add timestamps',
'settings.sessionLogs.timestampsDesc': 'Prefix each line in plain text and HTML logs with the local time.',
'settings.sessionLogs.hint': 'Session logs capture all terminal output for troubleshooting and auditing purposes.',
// Settings > SSH Debug Logs
'settings.sshDebugLogs.title': 'SSH Debug Logs',
'settings.sshDebugLogs.enable': 'Enable SSH debug logs',
'settings.sshDebugLogs.enableDesc': 'Record connection, auth, handshake, disconnect, and error reasons without saving terminal output.',
'settings.sshDebugLogs.location': 'Log Location',
'settings.sshDebugLogs.status': 'Status',
'settings.sshDebugLogs.statusOn': 'On',
'settings.sshDebugLogs.statusOff': 'Off',
'settings.sshDebugLogs.size': 'Size',
'settings.sshDebugLogs.hint': 'When enabled, newly started SSH connections write diagnostic events for bastion, auth, and unexpected disconnect troubleshooting.',
// Settings > Global Hotkey (Quake Mode)
'settings.globalHotkey.title': 'Global Hotkey',
'settings.globalHotkey.toggleWindow': 'Toggle Window',
'settings.globalHotkey.toggleWindowDesc': 'Press a key combination to set a global shortcut for showing/hiding the window.',
'settings.globalHotkey.notSet': 'Not set',
'settings.globalHotkey.reset': 'Reset to default',
'settings.globalHotkey.closeToTray': 'Close to System Tray',
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window will minimize to the system tray instead of quitting.',
'settings.globalHotkey.enabled': 'Enable Global Hotkey',
'settings.globalHotkey.enabledDesc': 'Register system-wide keyboard shortcuts. When disabled, all global hotkeys are unregistered.',
'settings.globalHotkey.hint': 'Global hotkey works system-wide to quickly show or hide the window (Quake-style terminal).',
// Tray Panel
'tray.openMainWindow': 'Open Main Window',
'tray.sessions': 'Sessions',
'tray.portForwarding': 'Port Forwarding',
'tray.status.connected': 'Connected',
'tray.status.connecting': 'Connecting',
'tray.status.disconnected': 'Disconnected',
'tray.status.active': 'Active',
'tray.status.inactive': 'Inactive',
'tray.status.error': 'Error',
'tray.recentHosts': 'Recent Hosts',
'tray.empty.title': 'Nothing here yet',
'tray.empty.subtitle': 'Go connect to a server, they miss you 🚀',
'tray.quit': 'Quit Netcatty',
// Vault Sidebar
'vault.sidebar.collapse': 'Collapse sidebar',
'vault.sidebar.expand': 'Expand sidebar',
'vault.sidebar.resize': 'Resize sidebar',
// Settings > Application
'settings.application.checkUpdates': 'Check for updates',
'settings.application.reportProblem': 'Report a problem',
'settings.application.reportProblem.subtitle': 'Generate a pre-filled GitHub issue',
'settings.application.community': 'Community',
'settings.application.community.subtitle': 'On GitHub Discussions',
'settings.application.github': 'GitHub',
'settings.application.github.subtitle': 'Source code',
'settings.application.whatsNew': "What's new",
'settings.application.whatsNew.subtitle': 'Show release notes',
'settings.application.openExternal.failedTitle': 'Cannot open link',
'settings.application.openExternal.failedBody': 'The link could not be opened in either the system browser or the built-in browser window.',
'settings.vault.title': 'Vault',
'settings.vault.showRecentHosts': 'Show recently connected hosts',
'settings.vault.showRecentHostsDesc': 'Display a section of recently connected hosts at the top of the vault',
'settings.vault.showOnlyUngroupedHostsInRoot': 'Only show ungrouped hosts at root',
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'When enabled, the root host list only shows hosts without a group. Open a group from the sidebar to see grouped hosts.',
'settings.vault.showSftpTab': 'Show SFTP tab',
'settings.vault.showSftpTabDesc': 'Display the standalone SFTP view in the top tab bar. When hidden, use the in-session SFTP side panel instead.',
'settings.vault.showHostTreeSidebar': 'Show host list sidebar',
'settings.vault.showHostTreeSidebarDesc': 'Display the host list sidebar and its top-bar toggle on terminal and editor tabs.',
// Update notifications
'update.available.title': 'Update Available',
'update.available.message': 'A new version {version} is available. Click to download.',
'update.checking': 'Checking for updates...',
'update.upToDate.title': 'Up to Date',
'update.upToDate.message': 'You are running the latest version ({version}).',
'update.error': 'Failed to check for updates',
'update.downloadNow': 'Download Now',
'update.viewInSettings': 'View in Settings',
'update.readyToInstall.title': 'Update Ready',
'update.readyToInstall.message': 'Version {version} downloaded and ready to install.',
'update.restartNow': 'Restart Now',
'update.downloadFailed.title': 'Update Failed',
'update.downloadFailed.message': 'Failed to download update. You can download it manually.',
'update.needsSave.title': 'Unsaved Changes',
'update.needsSave.message': 'Save your open editors first, then click Restart Now again to install the update.',
'update.openReleases': 'Open Releases',
'update.remindLater': 'Remind Later',
'update.skipVersion': 'Skip This Version',
// Settings > Appearance
'settings.appearance.uiTheme': 'UI Theme',
'settings.appearance.theme': 'Theme',
'settings.appearance.theme.desc': 'Choose light, dark, or follow system preference',
'settings.appearance.theme.light': 'Light',
'settings.appearance.theme.dark': 'Dark',
'settings.appearance.theme.system': 'System',
'settings.appearance.accentColor': 'Accent Color',
'settings.appearance.customColor': 'Custom color',
'settings.appearance.accentColor.mode': 'Use custom accent',
'settings.appearance.accentColor.mode.desc': 'Override the theme accent color',
'settings.appearance.accentColor.custom': 'Custom accent',
'settings.appearance.themeColor': 'Theme Color',
'settings.appearance.themeColor.desc': 'Pick a preset palette for each theme',
'settings.appearance.themeColor.light': 'Light palette',
'settings.appearance.themeColor.dark': 'Dark palette',
'settings.appearance.customCss': 'Custom CSS',
'settings.appearance.customCss.desc':
'Add custom CSS to personalize the app appearance. Changes apply immediately. Major UI regions expose a [data-section="..."] attribute you can target — e.g. snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (focus-mode terminal list), terminal-host-tree-sidebar, terminal-host-tree-sidebar-content, terminal-host-tree-sidebar-row, terminal-side-panel (SFTP/Scripts/Theme/AI panel, available while open), terminal-side-panel-tabs, terminal-side-panel-content, terminal-sftp-panel, terminal-sftp-host-header, terminal-sftp-pane, terminal-sftp-toolbar, terminal-sftp-path, terminal-sftp-filter-bar, terminal-sftp-list, terminal-sftp-list-header, terminal-sftp-list-row, terminal-sftp-tree, terminal-sftp-tree-row, terminal-sftp-transfer-queue, terminal-sftp-transfer-row, terminal-split-pane, terminal-split-resizer, top-tabs, top-tabs-host-tree-toggle, top-tabs-quick-switcher-toggle.',
'settings.appearance.customCss.placeholder':
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Hide the host-list toggle in the top tab bar */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Hide the plus button that opens the quick switcher */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Border around the SFTP / side panel (does not linger after closing) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Change the whole side panel background, not only the top tabs */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Style selected SFTP file rows */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */',
'settings.appearance.language': 'Language',
'settings.appearance.language.desc': 'Choose the UI language',
'settings.appearance.uiFont': 'Interface Font',
'settings.appearance.uiFont.desc': 'Choose the font for the application interface',
'settings.appearance.windowOpacity': 'Window Opacity',
'settings.appearance.windowOpacity.desc': 'Adjust the transparency of the entire application window. Lower values also fade terminal text. Some Linux desktop environments may not support this.',
'settings.appearance.appIcon': 'App Icon',
'settings.appearance.appIcon.desc': 'Choose a dock/taskbar icon style to make Netcatty easier to spot when switching apps. Applies at runtime; the installed app bundle icon is unchanged.',
'settings.appearance.appIcon.original': 'Original',
'settings.appearance.appIcon.bright': 'Bright',
'settings.appearance.appIcon.dark': 'Dark',
'settings.appearance.appIcon.colorful': 'Colorful',
'settings.appearance.appIcon.highContrast': 'High contrast',
'settings.appearance.appIcon.group.classic': 'Colored backgrounds',
'settings.appearance.appIcon.group.white': 'White background',
'settings.appearance.appIcon.group.special': 'Hidden',
'settings.appearance.appIcon.whiteNavy': 'White · Navy cat',
'settings.appearance.appIcon.whiteSky': 'White · Sky cat',
'settings.appearance.appIcon.whiteRose': 'White · Rose cat',
'settings.appearance.appIcon.whiteEmerald': 'White · Emerald cat',
'settings.appearance.appIcon.whiteAmber': 'White · Amber cat',
'settings.appearance.appIcon.whiteViolet': 'White · Violet cat',
'settings.appearance.appIcon.rainbow': 'Rainbow cat',
// Settings > Terminal
'settings.terminal.section.theme': 'Terminal Theme',
'settings.terminal.themeModal.title': 'Select Theme',
'settings.terminal.themeModal.darkThemes': 'Dark Themes',
'settings.terminal.themeModal.lightThemes': 'Light Themes',
'settings.terminal.theme.selectButton': 'Select Theme',
'settings.terminal.theme.followApp': 'Follow Application Theme',
'settings.terminal.theme.followApp.desc': 'Automatically match the terminal background to the current app theme for a seamless look.',
'settings.terminal.theme.darkTheme': 'Dark mode terminal theme',
'settings.terminal.theme.lightTheme': 'Light mode terminal theme',
'settings.terminal.theme.auto': 'Auto (match app theme)',
'settings.terminal.theme.autoDesc': 'Follows the active UI theme preset',
'settings.terminal.section.font': 'Font',
'settings.terminal.section.cursor': 'Cursor',
'settings.terminal.section.keyboard': 'Keyboard',
'settings.terminal.section.accessibility': 'Accessibility',
'settings.terminal.section.behavior': 'Behavior',
'settings.terminal.section.sidePanel': 'Side Panel',
'settings.terminal.section.scrollback': 'Scrollback',
'settings.terminal.section.keywordHighlight': 'Keyword highlighting',
'settings.terminal.font.family': 'Font',
'settings.terminal.font.family.desc': 'Terminal font family',
'settings.terminal.font.cjk': 'CJK font',
'settings.terminal.font.cjk.desc': 'Font used for Chinese / Japanese / Korean characters; "Auto" picks one based on the primary font',
'settings.terminal.font.cjk.option.auto': 'Auto · paired with the primary font',
'settings.terminal.font.cjk.option.sarasaSC': 'Sarasa Mono SC (Iosevka + Source Han SC)',
'settings.terminal.font.cjk.option.sarasaTC': 'Sarasa Mono TC (Iosevka + Source Han TC)',
'settings.terminal.font.cjk.option.mapleCN': 'Maple Mono CN',
'settings.terminal.font.cjk.option.sourceHan': 'Source Han Mono SC',
'settings.terminal.font.cjk.option.notoCJK': 'Noto Sans Mono CJK SC',
'settings.terminal.font.cjk.option.lxgwWenkai': 'LXGW WenKai Mono',
'settings.terminal.font.cjk.option.simSun': 'SimSun',
'settings.terminal.font.cjk.option.legacy': '{font} · not recommended (proportional font)',
'settings.terminal.font.size': 'Font size',
'settings.terminal.font.size.desc': 'Terminal text size',
'settings.terminal.font.weight': 'Font weight',
'settings.terminal.font.weight.desc': 'Weight for regular text (100-900)',
'settings.terminal.font.weight.thin': 'Thin',
'settings.terminal.font.weight.extraLight': 'Extra Light',
'settings.terminal.font.weight.light': 'Light',
'settings.terminal.font.weight.normal': 'Normal',
'settings.terminal.font.weight.medium': 'Medium',
'settings.terminal.font.weight.semiBold': 'Semi Bold',
'settings.terminal.font.weight.bold': 'Bold',
'settings.terminal.font.weight.extraBold': 'Extra Bold',
'settings.terminal.font.weight.black': 'Black',
'settings.terminal.font.weightBold': 'Bold font weight',
'settings.terminal.font.weightBold.desc': 'Weight for bold text (100-900)',
'settings.terminal.font.smoothing': 'Font smoothing',
'settings.terminal.font.smoothing.desc': 'Use native macOS font anti-aliasing',
'settings.terminal.font.linePadding': 'Line padding',
'settings.terminal.font.linePadding.desc': 'Additional space between lines (0-10)',
'settings.terminal.font.emulationType': 'Terminal emulation type',
'settings.terminal.cursor.style': 'Cursor style',
'settings.terminal.cursor.style.block': 'Block',
'settings.terminal.cursor.style.bar': 'Bar',
'settings.terminal.cursor.style.underline': 'Underline',
'settings.terminal.cursor.blink': 'Cursor blink',
'settings.terminal.keyboard.altAsMeta': 'Use Option as Meta key',
'settings.terminal.keyboard.altAsMeta.desc':
'Use Option (Alt) as the Meta key instead of for special characters',
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ jumps by word',
'settings.terminal.keyboard.optionArrowWordJump.desc':
'Send Meta-b / Meta-f on Option+Left/Right so the shell moves by word, instead of the default ^[[1;3D / ^[[1;3C',
'settings.terminal.accessibility.minimumContrastRatio': 'Minimum contrast ratio',
'settings.terminal.sidePanel.autoOpen': 'Open side panel with terminal',
'settings.terminal.sidePanel.autoOpen.desc': 'Automatically open a side panel when a terminal connects.',
'settings.terminal.sidePanel.autoOpenPane': 'Default side panel',
'settings.terminal.sidePanel.autoOpenPane.desc': 'Choose which panel opens first.',
'settings.terminal.sidePanel.pane.sftp': 'SFTP',
'settings.terminal.sidePanel.pane.scripts': 'Scripts',
'settings.terminal.sidePanel.pane.history': 'History',
'settings.terminal.sidePanel.pane.theme': 'Theme',
'settings.terminal.sidePanel.pane.system': 'System',
'settings.terminal.sidePanel.pane.notes': 'Notes',
'settings.terminal.sidePanel.pane.ai': 'AI',
'settings.terminal.accessibility.minimumContrastRatio.desc':
'Adjust colors to meet contrast requirements (1 = disabled, 21 = max)',
'settings.terminal.behavior.rightClick': 'Right-click behavior',
'settings.terminal.behavior.rightClick.desc': 'Action when right-clicking in terminal',
'settings.terminal.behavior.rightClick.menu': 'Show menu',
'settings.terminal.behavior.rightClick.paste': 'Paste',
'settings.terminal.behavior.rightClick.selectWord': 'Select word',
'settings.terminal.behavior.copyOnSelect': 'Copy on select',
'settings.terminal.behavior.copyOnSelect.desc': 'Automatically copy selected text. In tmux/vim with mouse mode, hold Option on macOS or Shift on Windows/Linux to select',
'settings.terminal.behavior.middleClickPaste': 'Middle-click paste',
'settings.terminal.behavior.middleClickPaste.desc':
'Paste clipboard content on middle-click',
'settings.terminal.behavior.middleClick': 'Middle-click behavior',
'settings.terminal.behavior.middleClick.desc': 'Action when middle-clicking in terminal',
'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`.',
'settings.terminal.behavior.preserveSelectionOnInput': 'Keep selection while typing',
'settings.terminal.behavior.preserveSelectionOnInput.desc':
'Don\'t clear mouse-selected text when typing — useful for selecting a path then pasting it after a command prefix like `sz `.',
'settings.terminal.behavior.forcePromptNewLine': 'Prompt on a new line',
'settings.terminal.behavior.forcePromptNewLine.desc':
'When the final line of command output is not terminated by a newline, move the recognized shell prompt to the next visual line.',
'settings.terminal.behavior.dynamicTabTitle': 'Dynamic tab title',
'settings.terminal.behavior.dynamicTabTitle.desc':
'Choose when session tabs should follow shell-reported window titles.',
'settings.terminal.behavior.dynamicTabTitle.off': 'Disabled',
'settings.terminal.behavior.dynamicTabTitle.agent': 'Agents only',
'settings.terminal.behavior.dynamicTabTitle.all': 'All sessions',
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 clipboard',
'settings.terminal.behavior.osc52Clipboard.desc':
'Allow remote programs (tmux, vim, etc.) to access the local clipboard via OSC-52 escape sequences.',
'settings.terminal.behavior.osc52Clipboard.off': 'Disabled',
'settings.terminal.behavior.osc52Clipboard.writeOnly': 'Write only',
'settings.terminal.behavior.osc52Clipboard.readWrite': 'Read & Write',
'settings.terminal.behavior.osc52Clipboard.prompt': 'Write + Prompt on Read',
'terminal.osc52.readPrompt.title': 'Clipboard Read Request',
'terminal.osc52.readPrompt.desc': 'A remote program is requesting to read your clipboard. Allow?',
'terminal.osc52.readPrompt.allow': 'Allow',
'terminal.osc52.readPrompt.deny': 'Deny',
'settings.terminal.behavior.scrollOnInput': 'Scroll on input',
'settings.terminal.behavior.scrollOnInput.desc': 'Scroll terminal to bottom when typing',
'settings.terminal.behavior.scrollOnOutput': 'Scroll on output',
'settings.terminal.behavior.scrollOnOutput.desc':
'Scroll terminal to bottom when new output arrives',
'settings.terminal.behavior.scrollOnKeyPress': 'Scroll on key press',
'settings.terminal.behavior.scrollOnKeyPress.desc':
'Scroll terminal to bottom when pressing a key (e.g., Enter)',
'settings.terminal.behavior.scrollOnPaste': 'Scroll on paste',
'settings.terminal.behavior.scrollOnPaste.desc':
'Scroll terminal to bottom when pasting text',
'settings.terminal.behavior.smoothScrolling': 'Smooth scrolling',
'settings.terminal.behavior.smoothScrolling.desc':
'Animate terminal viewport scrolling instead of jumping instantly',
'settings.terminal.behavior.linkModifier': 'Link modifier key',
'settings.terminal.behavior.linkModifier.desc': 'Hold this key to click on links in terminal',
'settings.terminal.behavior.linkModifier.none': 'None (click directly)',
'settings.terminal.behavior.linkModifier.ctrl': 'Ctrl',
'settings.terminal.behavior.linkModifier.alt': 'Alt / Option',
'settings.terminal.behavior.linkModifier.meta': 'Cmd / Win',
'settings.terminal.scrollback.desc': 'Limit number of terminal rows. Set to 0 for no limit.',
'settings.terminal.scrollback.rows': 'Number of rows *',
'settings.terminal.section.startupCommand': 'Startup command',
'settings.terminal.startupCommandDelay.label': 'Startup command delay (ms)',
'settings.terminal.startupCommandDelay.desc': 'How long to wait after connecting before sending the startup command. Also used between lines when the startup command has multiple lines. Increase for slow connections.',
'settings.terminal.keywordHighlight.title': 'Keyword highlighting',
'settings.terminal.keywordHighlight.resetColors': 'Reset to default colors',
'settings.terminal.keywordHighlight.resetDefaults': 'Reset built-ins to defaults',
'settings.terminal.keywordHighlight.resetBuiltIn': 'Restore default label and patterns',
'settings.terminal.keywordHighlight.addCustom': 'Add Custom Rule',
'settings.terminal.keywordHighlight.editCustom': 'Edit Rule',
'settings.terminal.keywordHighlight.editBuiltIn': 'Edit Built-in Rule',
'settings.terminal.keywordHighlight.labelField': 'Label & Color',
'settings.terminal.keywordHighlight.labelPlaceholder': 'Label (e.g., Down)',
'settings.terminal.keywordHighlight.patternField': 'Regex Patterns',
'settings.terminal.keywordHighlight.patternPlaceholder': 'One regex per line (e.g., \\bdown\\b)',
'settings.terminal.keywordHighlight.patternHint': 'One regex per line. Patterns are matched case-insensitively with the global flag.',
'settings.terminal.keywordHighlight.invalidPattern': 'Invalid regex pattern',
'settings.terminal.keywordHighlight.preview': 'Preview',
'settings.terminal.section.localShell': 'Local Shell',
'settings.terminal.localShell.shell': 'Shell executable',
'settings.terminal.localShell.shell.desc': 'Path to the shell executable (e.g., /bin/zsh, pwsh.exe). Leave empty for system default.',
'settings.terminal.localShell.shell.placeholder': 'System default',
'settings.terminal.localShell.shell.detected': 'Detected',
'settings.terminal.localShell.shell.notFound': 'Shell executable not found',
'settings.terminal.localShell.shell.isDirectory': 'Path is a directory, not an executable',
'settings.terminal.localShell.shell.default': 'System Default',
'settings.terminal.localShell.shell.custom': 'Custom...',
'settings.terminal.localShell.shell.customPath': 'Shell executable path',
'settings.terminal.localShell.shell.customArgs': 'Launch arguments',
'settings.terminal.localShell.shell.customArgs.placeholder': 'e.g. --login -i',
'settings.terminal.localShell.shell.customArgs.desc': 'Arguments passed to the shell. Some shells need them to work — e.g. msys2 bash requires --login -i to load the environment.',
'settings.terminal.localShell.shell.commonPaths': 'Common paths',
'settings.terminal.localShell.shell.pathValid': 'Path valid',
'settings.terminal.localShell.startDir': 'Starting directory',
'settings.terminal.localShell.startDir.desc': 'Directory to start in when opening a local terminal. Leave empty for home directory.',
'settings.terminal.localShell.startDir.placeholder': 'Home directory',
'settings.terminal.localShell.startDir.notFound': 'Directory not found',
'settings.terminal.localShell.startDir.isFile': 'Path is a file, not a directory',
'settings.terminal.section.connection': 'Connection',
'settings.terminal.connection.verifyHostKeys': 'Verify SSH host keys',
'settings.terminal.connection.verifyHostKeys.desc': 'Ask before connecting to a new or changed SSH host key. Turn this off only for trusted private networks.',
'settings.terminal.connection.sshAutoReconnectEnabled': 'Automatically reconnect SSH sessions',
'settings.terminal.connection.sshAutoReconnectEnabled.desc': 'When turned on, established SSH sessions that drop unexpectedly try to reconnect every 5 seconds until the tab is closed or the connection succeeds.',
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
'settings.terminal.connection.keepaliveInterval.desc': 'How often (in seconds) to send SSH-level keepalive packets. Set to 0 to disable globally — note that individual hosts can override this in their own settings.',
'settings.terminal.connection.keepaliveCountMax': 'Max unanswered keepalives',
'settings.terminal.connection.keepaliveCountMax.desc': 'Unanswered keepalives before the connection is declared dead. Higher values are more forgiving of brief network glitches and SSH servers that respond slowly.',
'settings.terminal.connection.x11Display': 'X11 display',
'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.',
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux session refresh',
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'How often to refresh the tmux session list.',
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker container list refresh',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh the Docker container list.',
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker stats refresh',
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'How often to refresh Docker container CPU/memory/network stats.',
'settings.terminal.serverStats.show': 'Show Server Stats',
'settings.terminal.serverStats.show.desc': 'Display CPU, memory, and disk usage in the terminal statusbar (Linux servers only).',
'settings.terminal.serverStats.refreshInterval': 'Refresh Interval',
'settings.terminal.serverStats.refreshInterval.desc': 'How often to refresh server stats.',
'settings.terminal.serverStats.seconds': 'seconds',
// Settings > Terminal > Rendering
'settings.terminal.section.rendering': 'Rendering',
'settings.terminal.rendering.renderer': 'Renderer',
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use DOM on low-memory devices. Changes take effect on new terminal sessions.',
'settings.terminal.rendering.auto': 'Auto',
'settings.terminal.rendering.hibernateHiddenTabs': 'Hibernate hidden tabs',
'settings.terminal.rendering.hibernateHiddenTabs.desc': 'Dispose the terminal renderer for off-screen tabs to save memory while keeping the SSH session connected. Skipped during file transfers.',
'settings.terminal.rendering.hibernateHiddenTabsDelay': 'Hibernate delay',
'settings.terminal.rendering.hibernateHiddenTabsDelay.desc': 'How long a tab must stay off-screen before its renderer is released (5–600 seconds).',
'settings.terminal.rendering.hibernateSkipAltScreen': 'Skip hibernate on full-screen apps',
'settings.terminal.rendering.hibernateSkipAltScreen.desc': 'Keep the terminal renderer alive while vim, htop, or agent TUIs own the alternate screen buffer.',
'settings.terminal.rendering.hibernateKeepRendererCount': 'Keep hidden renderers',
'settings.terminal.rendering.hibernateKeepRendererCount.desc': 'How many off-screen tabs keep their renderer alive (WebGL suspended) before full hibernate.',
'settings.terminal.rendering.hibernateReplayChunkBytes': 'Wake replay chunk size',
'settings.terminal.rendering.hibernateReplayChunkBytes.desc': 'Bytes replayed per animation frame when restoring a hibernated terminal (4–64 KB).',
'settings.terminal.rendering.hibernatePreferWasmSerialize': 'Prefer WASM serialize',
'settings.terminal.rendering.hibernatePreferWasmSerialize.desc': 'Use WASM terminal serialization when available; falls back to the JS serializer.',
// Settings > Terminal > Workspace Focus Indicator
'settings.terminal.section.workspaceFocus': 'Workspace Focus Indicator',
'settings.terminal.workspaceFocus.style': 'Focus indicator style',
'settings.terminal.workspaceFocus.style.desc': 'How to indicate which pane is focused in split view.',
'settings.terminal.workspaceFocus.dim': 'Dim unfocused panes',
'settings.terminal.workspaceFocus.border': 'Border on focused pane',
// Settings > Terminal > Autocomplete
'settings.terminal.section.autocomplete': 'Autocomplete',
'settings.terminal.autocomplete.enabled': 'Enable autocomplete',
'settings.terminal.autocomplete.enabled.desc': 'Show command suggestions based on history and command specs as you type.',
'settings.terminal.autocomplete.ghostText': 'Ghost text',
'settings.terminal.autocomplete.ghostText.desc': 'Show inline gray suggestion text after the cursor (like fish shell).',
'settings.terminal.autocomplete.popupMenu': 'Popup menu',
'settings.terminal.autocomplete.popupMenu.desc': 'Show a floating list of multiple suggestions.',
// Settings > Shortcuts
'settings.shortcuts.section.scheme': 'Hotkey Scheme',
'settings.shortcuts.scheme.label': 'Keyboard shortcuts',
'settings.shortcuts.scheme.desc': 'Choose which keyboard layout to use for shortcuts',
'settings.shortcuts.scheme.disabled': 'Disabled',
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
'settings.shortcuts.disableTerminalFontZoom.label': 'Disable terminal zoom',
'settings.shortcuts.disableTerminalFontZoom.desc': 'Turn off terminal font zoom shortcuts, including Cmd/Ctrl + mouse wheel.',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Tab shortcuts skip pinned tabs',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'When enabled, Cmd/Ctrl+[1...9] and Ctrl+Tab switch only work tabs (terminals, workspaces, editors), not the pinned Vault or SFTP tabs.',
'settings.shortcuts.section.custom': 'Custom Shortcuts',
'settings.shortcuts.resetAll': 'Reset All',
'settings.shortcuts.recording': 'Press keys...',
'settings.shortcuts.none': 'None',
'settings.shortcuts.setDisabled': 'Set to disabled',
'settings.shortcuts.category.tabs': 'Tabs',
'settings.shortcuts.category.terminal': 'Terminal',
'settings.shortcuts.category.navigation': 'Navigation',
'settings.shortcuts.category.app': 'App',
'settings.shortcuts.category.sftp': 'SFTP',
// Context menus / common actions
'action.newHost': 'New Host',
'action.newSubfolder': 'New Subfolder',
'action.copyPublicKey': 'Copy Public Key',
'action.keyExport': 'Key Export',
'action.edit': 'Edit',
'action.delete': 'Delete',
'action.duplicate': 'Duplicate',
'action.open': 'Open',
'action.copy': 'Copy',
'action.run': 'Run',
'action.start': 'Start',
'action.stop': 'Stop',
'action.remove': 'Remove',
'action.convertToHost': 'Convert to Host',
// Sync
'sync.cloudSync': 'Cloud Sync',
'sync.settings': 'Sync Settings',
'sync.active': 'Cloud Sync Active',
'sync.pending': 'Changes pending sync',
'sync.syncing': 'Syncing...',
'sync.error': 'Sync Error',
'sync.notConfigured': 'Not Configured',
'sync.failed': 'Sync failed',
'sync.connected': 'Connected',
'sync.syncNow': 'Sync Now',
'sync.recentActivity': 'Recent activity',
'sync.history.uploaded': 'Uploaded',
'sync.history.downloaded': 'Downloaded',
'sync.history.resolved': 'Resolved',
'sync.toast.completedMessage': 'Sync completed successfully',
'sync.toast.errorTitle': 'Sync Error',
'sync.autoSync.failedTitle': 'Sync failed',
'sync.autoSync.inspectFailedTitle': 'Sync paused',
'sync.autoSync.inspectFailedMessage': 'Could not reach the cloud to check for changes. Auto-sync will retry when data changes or the app is restarted.',
'sync.autoSync.syncedTitle': 'Synced from cloud',
'sync.autoSync.syncedMessage': 'Your data has been updated from the cloud.',
'sync.autoSync.noProvider': 'No cloud provider connected. Open Settings → Sync & Cloud to connect one.',
'sync.autoSync.alreadySyncing': 'Sync is already in progress.',
'sync.autoSync.restoreInProgress': 'A vault restore is in progress in another window. Please wait for it to finish.',
'sync.autoSync.interruptedApplyTitle': 'Sync paused — previous restore interrupted',
'sync.autoSync.interruptedApplyMessage': 'A previous restore did not finish cleanly, so the local vault may be inconsistent. Open Settings → Sync & Cloud → Restore and apply a protective backup before auto-sync resumes.',
'sync.autoSync.vaultLocked': 'Vault is locked. Open Settings → Sync & Cloud to unlock.',
'sync.autoSync.conflictDetected': 'Sync conflict detected. Open Settings → Sync & Cloud to resolve.',
'sync.autoSync.syncFailed': 'Sync failed',
'sync.autoSync.restoredTitle': 'Vault restored',
'sync.autoSync.restoredMessage': 'Your vault has been restored from the cloud.',
'sync.autoSync.keptLocalTitle': 'Kept local vault',
'sync.autoSync.keptLocalMessage': 'Your empty local vault was kept. Cloud data was not applied.',
'sync.autoSync.emptyVaultConflict.title': 'Empty Vault Detected',
'sync.autoSync.emptyVaultConflict.description': 'Your local vault is empty, but the cloud has data. This usually happens after an update or storage reset. What would you like to do?',
'sync.autoSync.emptyVaultConflict.cloudLabel': 'Cloud',
'sync.autoSync.emptyVaultConflict.restore': 'Restore from Cloud',
'sync.autoSync.emptyVaultConflict.restoreDesc': 'Recommended — recover your hosts, keys, and snippets from the cloud backup',
'sync.autoSync.emptyVaultConflict.keepEmpty': 'Keep Empty',
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': 'Start fresh with an empty vault',
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} hosts, {keys} keys, {snippets} snippets, {notes} notes, {proxyProfiles} proxies',
'sync.autoSync.emptyVaultManual': 'Cannot sync: the local vault is empty. Restore from a local backup or enable Force Push in the sync panel first.',
'sync.blocked.title': 'Sync paused',
'sync.blocked.reason.bulkShrink': 'Would delete {lost} of {baseCount} {entityType} from cloud ({percent}% reduction).',
'sync.blocked.reason.largeShrink': 'Would delete {lost} {entityType} from cloud.',
'sync.blocked.detail': 'This is usually caused by a degraded local state (keychain failure, partial data load). Restore from a local backup, or force-push if you truly meant to remove these entries.',
'sync.blocked.restoreButton': 'Restore from local backup',
'sync.blocked.forcePushButton': 'Force push anyway',
'sync.forcePush.title': 'Confirm force push',
'sync.forcePush.body': 'You are about to remove {lost} {entityType} from the cloud. This cannot be undone. Proceed?',
'sync.forcePush.confirm': 'Yes, push anyway',
'sync.forcePush.cancel': 'Cancel',
'sync.entityType.hosts': 'hosts',
'sync.entityType.keys': 'keys',
'sync.entityType.identities': 'identities',
'sync.entityType.proxyProfiles': 'proxy profiles',
'sync.entityType.snippets': 'snippets',
'sync.entityType.customGroups': 'groups',
'sync.entityType.snippetPackages': 'snippet packages',
'sync.entityType.knownHosts': 'known-host entries',
'sync.entityType.portForwardingRules': 'port-forwarding rules',
'sync.entityType.groupConfigs': 'group configs',
'sync.credentialsUnavailable': 'This device cannot decrypt some saved credentials. Re-enter credentials locally before syncing.',
'time.never': 'Never',
'time.justNow': 'Just now',
'time.minutesAgo': '{minutes}m ago',
// Vault navigation
'vault.nav.hosts': 'Hosts',
'vault.nav.keychain': 'Keychain',
'vault.nav.proxies': 'Proxies',
'vault.nav.portForwarding': 'Port Forwarding',
'vault.nav.snippets': 'Scripts',
'vault.nav.notes': 'Notes',
'vault.nav.knownHosts': 'Known Hosts',
'vault.nav.logs': 'Logs',
'proxyProfiles.action.add': 'Add Proxy',
'proxyProfiles.search.placeholder': 'Search proxies…',
'proxyProfiles.section.proxies': 'Proxies',
'proxyProfiles.count.items': '{count} items',
'proxyProfiles.empty.title': 'No Proxies',
'proxyProfiles.empty.desc': 'Create reusable HTTP, SOCKS5, or command proxies and select them from host details.',
'proxyProfiles.usage': '{count} linked',
'proxyProfiles.copyName': '{name} Copy',
'proxyProfiles.panel.newTitle': 'New Proxy',
'proxyProfiles.field.name': 'Proxy name',
'proxyProfiles.error.required': 'Name and proxy details are required.',
'proxyProfiles.error.port': 'Port must be between 1 and 65535.',
'proxyProfiles.viewMode': 'Proxy view mode',
'proxyProfiles.delete.title': 'Delete proxy?',
'proxyProfiles.delete.desc': 'Deleting "{name}" will unlink it from {count} host or group settings.',
'vault.groups.title': 'Groups',
'vault.groups.total': '{count} total',
'vault.groups.hostsCount': '{count} Hosts',
'vault.groups.newSubgroup': 'New Subgroup',
'vault.groups.rename': 'Rename Group',
'vault.groups.unnamed': 'Unnamed Group',
'vault.groups.delete': 'Delete Group',
'vault.groups.createSubfolder': 'Create Subfolder',
'vault.groups.createRoot': 'Create Root Group',
'vault.groups.createDialog.desc': 'Create a new group for organizing hosts.',
'vault.groups.renameDialogTitle': 'Rename Group',
'vault.groups.renameDialog.desc': 'Rename an existing group.',
'vault.groups.deleteDialogTitle': 'Delete Group',
'vault.groups.deleteDialog.desc': 'This will permanently delete the group and move all hosts to the root level.',
'vault.groups.deleteDialog.managedDesc': 'This is a managed SSH config group. Deleting it will also delete all hosts and unlink from the source file.',
'vault.groups.deleteDialog.deleteHosts': 'Also delete all hosts in this group',
'vault.groups.ungrouped': 'Ungrouped',
'vault.groups.field.name': 'Group Name',
'vault.groups.placeholder.example': 'e.g. Production',
'vault.groups.parentLabel': 'Parent',
'vault.groups.pathLabel': 'Path',
'vault.groups.settings': 'Group Settings',
'vault.groups.details': 'Group Details',
'vault.groups.details.general': 'General',
'vault.groups.details.ssh': 'SSH',
'vault.groups.details.telnet': 'Telnet',
'vault.groups.details.advanced': 'Advanced',
'vault.groups.details.appearance': 'Appearance',
'vault.groups.details.mosh': 'Mosh',
'vault.groups.details.parentGroup': 'Parent Group',
'vault.groups.details.none': 'None',
'vault.groups.details.inherited': 'Inherited from group',
'vault.groups.details.addProtocol': 'Add Protocol',
'vault.groups.details.removeProtocol': 'Remove Protocol',
'vault.groups.details.fontFamily': 'Font Family',
'vault.groups.details.fontSize': 'Font Size',
'vault.groups.errors.required': 'Group name is required.',
'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',
'vault.hosts.header.entries': '{count} entries',
'vault.hosts.header.live': '{count} live',
// Vault hosts header/actions
'vault.hosts.search.placeholder': 'Find a host or ssh user@hostname / ssh -p 2222 user@hostname...',
'vault.hosts.connect': 'Connect',
'vault.view.grid': 'Grid',
'vault.view.list': 'List',
'vault.view.tree': 'Tree',
'vault.tree.expandAll': 'Expand All',
'vault.tree.collapseAll': 'Collapse All',
'vault.hosts.newHost': 'New Host',
'vault.hosts.newGroup': 'New Group',
'vault.hosts.import': 'Import',
'vault.hosts.export': 'Export',
'vault.hosts.export.toast.success': 'Exported {count} hosts to CSV',
'vault.hosts.export.toast.successWithSkipped': 'Exported {count} hosts to CSV ({skipped} unsupported hosts skipped)',
'vault.hosts.export.toast.noHosts': 'No hosts to export',
'vault.hosts.allHosts': 'All hosts',
'vault.hosts.pinned': 'Pinned',
'vault.hosts.recentlyConnected': 'Recently Connected',
'vault.hosts.pinToTop': 'Pin to Top',
'vault.hosts.unpin': 'Unpin',
'vault.hosts.copyCredentials': 'Copy Credentials',
'vault.hosts.copyCredentials.toast.success': 'Credentials copied to clipboard',
'vault.hosts.copyCredentials.toast.noPassword': 'No password saved for this host',
'vault.hosts.multiSelect': 'Multi-select',
'vault.hosts.selected': '{count} selected',
'vault.hosts.selectAll': 'Select All',
'vault.hosts.deselectAll': 'Deselect All',
'vault.hosts.deleteSelected': 'Delete ({count})',
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
'vault.hosts.connectSelected': 'Connect ({count})',
'vault.hosts.connectMultiple.success': 'Connecting {count} hosts',
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
'vault.hosts.errors.nameRequired': 'Host name is required.',
'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}}',
};
+128
View File
@@ -0,0 +1,128 @@
export const enScriptsMessages = {
'scripts.meta.name': 'Name',
'scripts.meta.language': 'Language',
'scripts.meta.description': 'Description',
'scripts.meta.descriptionPlaceholder': 'Optional notes about this script',
'scripts.meta.trigger': 'Trigger',
'scripts.meta.triggerPattern': 'Output pattern (regex)',
'scripts.meta.code': 'Script',
'scripts.trigger.manual': 'Manual run',
'scripts.trigger.onConnect': 'Run on connect',
'scripts.trigger.onOutput': 'Run on output match',
'scripts.trigger.onOutputHint': 'Fires when server output matches the pattern (user keystroke echo is ignored). With no target hosts configured, listens on the current connected session; with targets, only those hosts. Disabled in alternate-screen apps such as vim or htop. Suppressed while another script is running on this session; rechecked after it finishes. For in-flow waits, use nct.screen.waitForText or nct.screen.waitForRegex in script code.',
'scripts.actions.save': 'Save',
'scripts.actions.runNow': 'Run now',
'scripts.actions.openEditor': 'Open editor',
'scripts.actions.openEditorHint': 'Edit script in a larger window',
'scripts.editor.modalTitle': 'Script editor',
'scripts.editor.modalSubtitle': 'Edit metadata and script code in a larger workspace.',
'scripts.editor.lineCount': '{count} lines',
'scripts.editor.resize': 'Resize editor',
'scripts.targets.hint': 'Automatic triggers apply to selected hosts, or to all hosts when that option is enabled.',
'scripts.targets.connectOrderHint': 'Run order for connect scripts is configured per host under Host details → Automation.',
'scripts.actions.runNowHint': 'Run on selected targets, or on all connectable hosts when that option is enabled.',
'scripts.actions.runParallel': 'Run on all tabs (parallel)',
'scripts.actions.runSequential': 'Run on all tabs (sequential)',
'scripts.actions.skippedConnectingSessions': '{count} tab(s) still connecting and were skipped',
'scripts.actions.noRunnableHosts': 'No connectable hosts match this script\'s targets',
'scripts.sidePanel.library': 'Library',
'scripts.sidePanel.running': 'Running',
'scripts.sidePanel.newScript': 'New script',
'scripts.running.empty': 'No scripts are running in this session.',
'scripts.running.unnamed': 'Untitled script',
'scripts.running.status.running': 'Running',
'scripts.running.status.paused': 'Paused',
'scripts.running.status.completed': 'Completed',
'scripts.running.status.failed': 'Failed',
'scripts.running.waitingFor': 'Waiting for {pattern}',
'scripts.running.waitingForLabel': 'Waiting for',
'scripts.running.waitingForShellPrompt': 'shell prompt (# or $)',
'scripts.running.lastSent': 'Sent: {command}',
'scripts.recording.start': 'Start recording',
'scripts.recording.active': 'Stop recording',
'scripts.recording.startHint': 'Record actions in the focused terminal and generate nct script code',
'scripts.recording.unavailableHint': 'Recording is not available here — use the scripts sidebar on the right',
'scripts.recording.activeHint': 'Recording this terminal. Type commands as usual; click Stop or use the REC control in the toolbar to finish and save.',
'scripts.recording.started': 'Recording started — operate in the terminal',
'scripts.recording.noSession': 'Connect a terminal first (single tab or workspace)',
'scripts.recording.alreadyActive': 'Another terminal is already recording',
'scripts.recording.stop': 'Stop recording',
'scripts.recording.pause': 'Pause recording',
'scripts.recording.resume': 'Resume recording',
'scripts.recording.saveTitle': 'Save recorded script',
'scripts.recording.namePlaceholder': 'Script name',
'scripts.recording.packagePlaceholder': 'Save to folder',
'scripts.recording.rootPackage': 'Root',
'scripts.recording.save': 'Save',
'scripts.recording.saveAndEdit': 'Save and edit',
'scripts.recording.helpTitle': 'How to record a script',
'scripts.recording.helpIntro': 'Recording turns what you do in the terminal into a reusable automation script — useful for deploys, health checks, and other repetitive tasks.',
'scripts.recording.helpStep1': 'Connect to a host. A standalone terminal tab or a terminal inside a workspace both work — open the scripts sidebar on the right.',
'scripts.recording.helpStep2': 'Click Start recording. A red REC badge in the terminal toolbar means recording is active.',
'scripts.recording.helpStep3': 'Type commands in the terminal as you normally would. Each Enter press is captured as one step.',
'scripts.recording.helpStep4': 'When you are done, click Stop recording, or use the stop control next to REC in the terminal toolbar.',
'scripts.recording.helpStep5': 'Name the script and save. Choose Save and edit if you want to fine-tune the generated code in the script editor.',
'scripts.recording.helpTipsTitle': 'Tips',
'scripts.recording.helpTip1': 'Pauses longer than 1 second between actions are recorded as wait time so playback is not too fast.',
'scripts.recording.helpTip2': 'After each command, recording waits for a shell prompt (such as $ or #) before the next step.',
'scripts.recording.helpTip3': 'Password input is marked sensitive and is not stored in plain text in the script.',
'scripts.recording.helpTip4': 'For vim, menus, or other interactive flows, record the main commands first, then refine the script manually.',
'scripts.dialog.title': 'Script',
'scripts.dialog.ok': 'OK',
'scripts.dialog.required': 'Required',
'scripts.dialog.numberInvalid': 'Enter a valid number',
'scripts.dialog.numberMin': 'Must be at least {min}',
'scripts.dialog.numberMax': 'Must be at most {max}',
'scripts.dialog.numberStep': 'Must use increments of {step}',
'scripts.dialog.waitForTimeoutTitle': 'Wait timed out',
'scripts.dialog.retry': 'Retry',
'scripts.dialog.skip': 'Skip',
'scripts.dialog.abort': 'Abort',
'scripts.running.stepProgress': 'Step {current} / {total}',
'scripts.running.determinateProgress': '{label} {current}/{total}',
'scripts.running.progressFallback': 'Progress',
'scripts.running.operationsCount': '{count} operations',
'scripts.running.opsPrefix': '',
'scripts.running.opsSuffix': ' operations',
'scripts.running.elapsedLabel': 'Elapsed',
'scripts.running.lastSentLabel': 'Sent:',
'scripts.running.elapsed': '{elapsed}',
'scripts.running.completedSummary': 'Completed · {count} operations · {elapsed}',
'scripts.running.dismissHint': 'Tap close to dismiss',
'scripts.running.dismiss': 'Close',
'scripts.running.viewLogs': 'View logs',
'scripts.running.logTitle': '{name} · Run log',
'scripts.running.logEmpty': 'No log output yet',
'scripts.running.pause': 'Pause',
'scripts.running.resume': 'Resume',
'scripts.running.stop': 'Stop',
'scripts.recording.saved': 'Script saved',
'scripts.recording.savedNamed': 'Saved "{name}"',
'scripts.observer.blocked': 'Observer mode blocks scripts that write to the terminal.',
'vault.section.scripts': 'Scripts',
'vault.nav.scripts': 'Scripts',
'snippets.action.newScript': 'New automation script',
'hostDetails.section.automation': 'Automation',
'hostDetails.automation.loginScript': 'Login script',
'hostDetails.automation.loginScriptPlaceholder': 'Select a script',
'hostDetails.automation.none': 'None',
'hostDetails.automation.outputTriggers': 'Output triggers',
'hostDetails.automation.addTrigger': 'Add trigger',
'hostDetails.automation.triggerPatternPlaceholder': 'Regex pattern',
'hostDetails.automation.linkedScripts': 'Linked scripts',
'hostDetails.automation.linkedScriptsEmpty': 'No scripts linked to this host yet.',
'hostDetails.automation.linkScriptPlaceholder': 'Link existing script…',
'hostDetails.automation.unlink': 'Unlink',
'hostDetails.automation.equivalenceHint': 'Links add this host to the script\'s target list in Scripts. Both views stay in sync.',
'hostDetails.automation.queueHint': 'Scripts run in order when this host connects. Global scripts run first, then this queue.',
'hostDetails.automation.globalScripts': 'Global connect scripts',
'hostDetails.automation.globalScriptsHint': 'Runs first on every connect. Reorder in Scripts library sort order.',
'hostDetails.automation.connectQueue': 'This host\'s run queue',
'hostDetails.automation.connectQueueEmpty': 'No connect scripts queued for this host yet.',
'hostDetails.automation.addToQueuePlaceholder': 'Add script to queue…',
'hostDetails.automation.moveUp': 'Move up',
'hostDetails.automation.moveDown': 'Move down',
'hostDetails.automation.removeFromQueue': 'Remove from queue',
'hostDetails.automation.dragHandle': 'Drag to reorder',
'hostDetails.automation.queueDragHint': 'Drag items to reorder the connect queue.',
};
@@ -0,0 +1,209 @@
import type { Messages } from '../types';
export const enSystemManagerMessages: Messages = {
'terminal.layer.system': 'System',
'systemManager.noSession': 'No active terminal session.',
'systemManager.notConnected': 'Connect to a host to manage processes and services.',
'systemManager.empty': 'No data available.',
'systemManager.tabs.overview': 'Overview',
'systemManager.tabs.processes': 'Processes',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.popup.loading': 'Opening terminal…',
'systemManager.popup.startupFailed': 'The startup command did not complete successfully. Check that the target is still available and try again.',
'systemManager.errors.loadProcesses': 'Failed to load processes',
'systemManager.errors.loadTmux': 'Failed to load tmux sessions',
'systemManager.errors.loadTmuxWindows': 'Failed to load tmux windows',
'systemManager.errors.loadTmuxPanes': 'Failed to load tmux panes',
'systemManager.errors.loadTmuxClients': 'Failed to load tmux clients',
'systemManager.errors.actionFailed': 'Action failed',
'systemManager.errors.loadDocker': 'Failed to load containers',
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
'systemManager.errors.loadDockerImages': 'Failed to load images',
'systemManager.errors.loadOverview': 'Failed to load system overview',
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
'systemManager.overview.empty': 'No system overview data yet.',
'systemManager.overview.loading': 'Loading system overview…',
'systemManager.overview.memory': 'Memory',
'systemManager.overview.disk': 'Disk',
'systemManager.overview.network': 'Network',
'systemManager.overview.rx': 'RX',
'systemManager.overview.tx': 'TX',
'systemManager.overview.cores': '{{count}} cores',
'systemManager.overview.load': 'Load',
'systemManager.overview.uptime': 'Uptime',
'systemManager.overview.duration.daysHours': '{{days}}d {{hours}}h',
'systemManager.overview.duration.hoursMinutes': '{{hours}}h {{minutes}}m',
'systemManager.overview.duration.minutes': '{{minutes}}m',
'systemManager.overview.system': 'System',
'systemManager.overview.kernel': 'Kernel',
'systemManager.overview.swap': 'Swap',
'systemManager.overview.latency': 'SSH network latency',
'systemManager.overview.cpuCores': 'CPU cores',
'systemManager.overview.disks': 'Disks',
'systemManager.overview.interfaces': 'Network interfaces',
'systemManager.overview.topProcesses': 'Top memory processes',
'systemManager.overview.noData': 'No data',
'systemManager.overview.noDisks': 'No disk data',
'systemManager.overview.noInterfaces': 'No interface data',
'systemManager.overview.noTopProcesses': 'No process data',
'systemManager.processes.search': 'Search processes…',
'systemManager.processes.command': 'Command',
'systemManager.processes.user': 'User',
'systemManager.processes.term': 'Terminate',
'systemManager.processes.kill': 'Kill',
'systemManager.processes.stop': 'Stop (SIGSTOP)',
'systemManager.processes.cont': 'Continue (SIGCONT)',
'systemManager.processes.hup': 'Hang up (SIGHUP)',
'systemManager.processes.renice': 'Renice',
'systemManager.processes.renicePrompt': 'Nice value (-20 to 19)',
'systemManager.processes.reniceInvalid': 'Nice value must be between -20 and 19',
'systemManager.processes.confirmKill': 'Send SIGKILL to process {{pid}}?',
'systemManager.processes.confirmSignal': 'Send SIG{{signal}} to process {{pid}}?',
'systemManager.processes.filter.all': 'All',
'systemManager.processes.filter.running': 'Running',
'systemManager.processes.ppid': 'Parent PID',
'systemManager.processes.rss': 'RSS',
'systemManager.processes.vsz': 'Virtual size',
'systemManager.processes.elapsed': 'Elapsed',
'systemManager.processes.stat': 'State',
'systemManager.processes.meta': '{{count}} process(es)',
'systemManager.processes.loading': 'Loading processes…',
'systemManager.processes.loadingMore': 'Loading more processes…',
'systemManager.processes.state.running': 'Running',
'systemManager.processes.state.sleeping': 'Sleeping',
'systemManager.processes.state.stopped': 'Stopped',
'systemManager.processes.state.zombie': 'Zombie',
'systemManager.processes.sort.cpu': 'CPU',
'systemManager.processes.sort.mem': 'MEM',
'systemManager.processes.sort.pid': 'PID',
'systemManager.processes.sort.command': 'Command',
'systemManager.processes.sort.user': 'User',
'systemManager.common.dismiss': 'Dismiss',
'systemManager.common.checkingAvailability': 'Checking availability…',
'systemManager.common.loading': 'Loading…',
'systemManager.common.loadingDetails': 'Loading details…',
'systemManager.common.loadingStats': 'Loading stats…',
'systemManager.tmux.new': 'New',
'systemManager.tmux.search': 'Search sessions…',
'systemManager.tmux.newSessionTitle': 'New tmux session',
'systemManager.tmux.newSessionDesc': 'Name the session and optionally run a script on start.',
'systemManager.tmux.newSessionTabCustom': 'Custom command',
'systemManager.tmux.newSessionTabSnippet': 'From snippet',
'systemManager.tmux.pickSnippet': 'From snippets',
'systemManager.tmux.pickSnippetEmpty': 'No snippets yet — add some in the Scripts panel or Vault.',
'systemManager.tmux.selectedSnippet': 'Using snippet: {{label}}',
'systemManager.tmux.newSessionName': 'Session name',
'systemManager.tmux.newSessionCommand': 'Start command',
'systemManager.tmux.newSessionCommandPlaceholder': 'e.g. htop or npm run dev (optional)',
'systemManager.tmux.newSessionCommandHint': 'Leave empty for a default shell session.',
'systemManager.tmux.creating': 'Creating…',
'systemManager.tmux.newSessionPlaceholder': 'my-session',
'systemManager.tmux.newSessionRequired': 'Enter a session name first',
'systemManager.tmux.empty': 'No tmux sessions',
'systemManager.tmux.attach': 'Attach',
'systemManager.tmux.attached': 'Attached',
'systemManager.tmux.detached': 'Detached',
'systemManager.tmux.windows': '{{count}} window(s)',
'systemManager.tmux.created': 'Created',
'systemManager.tmux.activity': 'Activity',
'systemManager.tmux.rename': 'Rename',
'systemManager.tmux.detach': 'Detach all',
'systemManager.tmux.killSession': 'Kill session',
'systemManager.tmux.killServer': 'Kill server',
'systemManager.tmux.loadingDetails': 'Loading details…',
'systemManager.tmux.clients': 'Attached clients',
'systemManager.tmux.windowList': 'Windows',
'systemManager.tmux.newWindow': 'New window',
'systemManager.tmux.newWindowPlaceholder': 'Window name (optional)',
'systemManager.tmux.noWindows': 'No windows',
'systemManager.tmux.unavailable': 'tmux is not available on this host',
'systemManager.docker.unavailable': 'Docker is not available on this host',
'systemManager.tmux.windowsMismatch': 'Session reports {{count}} window(s) but list-windows returned none',
'systemManager.tmux.lastCommand': 'last command: {{command}}',
'systemManager.tmux.noPanes': 'No panes',
'systemManager.tmux.panes': '{{count}} pane(s)',
'systemManager.tmux.active': 'active',
'systemManager.tmux.unnamedWindow': 'Unnamed window',
'systemManager.tmux.unnamedPane': 'Unnamed pane',
'systemManager.tmux.attachWindow': 'Attach to window',
'systemManager.tmux.selectWindow': 'Select window',
'systemManager.tmux.killWindow': 'Kill window',
'systemManager.tmux.killPane': 'Kill pane',
'systemManager.tmux.splitHorizontal': 'Split horizontal',
'systemManager.tmux.splitVertical': 'Split vertical',
'systemManager.tmux.sendKeys': 'Send keys',
'systemManager.tmux.sendKeysTo': 'Send keys to window {{window}} pane {{pane}}',
'systemManager.tmux.sendKeysPlaceholder': 'Command or text…',
'systemManager.tmux.renameSessionPrompt': 'Rename session',
'systemManager.tmux.renameWindowPrompt': 'Rename window',
'systemManager.tmux.windowName': 'Window name',
'systemManager.tmux.confirmKillSession': 'Kill tmux session "{{name}}"?',
'systemManager.tmux.confirmDetachSession': 'Detach all clients from "{{name}}"?',
'systemManager.tmux.confirmKillWindow': 'Kill window "{{name}}"?',
'systemManager.tmux.confirmKillPane': 'Kill pane #{{index}}?',
'systemManager.tmux.confirmKillServer': 'Kill tmux server? All sessions will be terminated.',
'systemManager.tmux.meta': '{{count}} session(s)',
'systemManager.docker.title': 'Containers',
'systemManager.docker.subTabs.containers': 'Containers',
'systemManager.docker.subTabs.images': 'Images',
'systemManager.docker.empty': 'No containers found',
'systemManager.docker.imagesEmpty': 'No images found',
'systemManager.docker.search': 'Search containers…',
'systemManager.docker.searchImages': 'Search images…',
'systemManager.docker.filter.all': 'All',
'systemManager.docker.filter.running': 'Running',
'systemManager.docker.filter.stopped': 'Stopped',
'systemManager.docker.filter.paused': 'Paused',
'systemManager.docker.shell': 'Shell',
'systemManager.docker.logs': 'Logs',
'systemManager.docker.details': 'Details',
'systemManager.docker.inspect': 'Inspect',
'systemManager.docker.imageInspect': 'Image inspect',
'systemManager.docker.confirmRemove': 'Remove this container?',
'systemManager.docker.confirmKill': 'Force kill this container?',
'systemManager.docker.confirmRemoveImage': 'Remove image "{{name}}"?',
'systemManager.docker.confirmPrune': 'Remove dangling images?',
'systemManager.docker.confirmPruneAll': 'Remove all unused images?',
'systemManager.docker.pause': 'Pause',
'systemManager.docker.unpause': 'Unpause',
'systemManager.docker.restart': 'Restart',
'systemManager.docker.kill': 'Kill',
'systemManager.docker.renamePrompt': 'Container name',
'systemManager.docker.prune': 'Prune',
'systemManager.docker.pruneAll': 'Prune all',
'systemManager.docker.tag': 'Tag',
'systemManager.docker.tagRepoPrompt': 'Repository name',
'systemManager.docker.tagNamePrompt': 'Tag name',
'systemManager.docker.meta': '{{count}} container(s)',
'systemManager.docker.imagesMeta': '{{count}} image(s)',
'systemManager.docker.start': 'Start',
'systemManager.docker.stop': 'Stop',
'systemManager.inspect.status': 'Status',
'systemManager.inspect.image': 'Image',
'systemManager.inspect.created': 'Created',
'systemManager.inspect.started': 'Started',
'systemManager.inspect.restartPolicy': 'Restart policy',
'systemManager.inspect.command': 'Command',
'systemManager.inspect.ports': 'Ports',
'systemManager.inspect.networks': 'Networks',
'systemManager.inspect.mounts': 'Mounts',
'systemManager.inspect.env': 'Environment',
'systemManager.inspect.labels': 'Labels',
'systemManager.inspect.tags': 'Tags',
'systemManager.inspect.digests': 'Digests',
'systemManager.inspect.size': 'Size',
'systemManager.inspect.platform': 'Platform',
'systemManager.inspect.workdir': 'Working dir',
'systemManager.inspect.exposedPorts': 'Exposed ports',
'systemManager.inspect.showRaw': 'JSON',
'systemManager.inspect.hideRaw': 'Hide JSON',
};
+782
View File
@@ -0,0 +1,782 @@
import type { Messages } from '../types';
export const enTerminalMessages: Messages = {
'terminal.sudoHint.pressEnter': 'Press Enter to paste sudo password',
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': 'Open SFTP',
'terminal.toolbar.availableAfterConnect': 'Available after connect',
'terminal.toolbar.sendYmodem': 'Send with YMODEM',
'terminal.toolbar.receiveYmodem': 'Receive with YMODEM',
'terminal.toolbar.sftp': 'SFTP',
'terminal.toolbar.more': 'More actions',
'terminal.toolbar.scripts': 'Scripts',
'terminal.toolbar.history': 'Command history',
'terminal.toolbar.configureOsc7': 'Configure directory tracking',
'history.scope.label': 'History scope',
'history.tab.host': 'Host',
'history.tab.global': 'Global',
'history.searchPlaceholder': 'Search history...',
'history.loading': 'Loading remote history...',
'history.meta.count': '{count} commands',
'history.empty.noSession': 'Open a remote session to view its command history.',
'history.empty.unsupportedProtocol': 'Command history is only available for SSH/Mosh/ET sessions.',
'history.empty.noHistory': 'No command history found on this host.',
'history.empty.noGlobalHistory': 'No global command history yet. Commands you run will appear here.',
'history.action.refresh': 'Refresh',
'history.action.retry': 'Retry',
'history.action.paste': 'Paste to terminal',
'history.action.run': 'Run in terminal',
'history.action.saveAsSnippet': 'Save as snippet',
'terminal.toolbar.library': 'Library',
'terminal.toolbar.noSnippets': 'No snippets available',
'terminal.toolbar.terminalSettings': 'Terminal settings',
'terminal.toolbar.searchTerminal': 'Search terminal',
'terminal.toolbar.search': 'Search',
'terminal.toolbar.startSessionLog': 'Start session log',
'terminal.toolbar.stopSessionLog': 'Stop session log',
'terminal.toolbar.timestampsEnable': 'Show timestamps',
'terminal.toolbar.timestampsDisable': 'Hide timestamps',
'terminal.toolbar.broadcast': 'Broadcast',
'terminal.toolbar.broadcastEnable': 'Enable Broadcast Mode',
'terminal.toolbar.broadcastDisable': 'Disable Broadcast Mode',
'terminal.toolbar.composeBar': 'Compose Bar',
'terminal.composeBar.placeholder': 'Type command here, press Enter to send...',
'terminal.composeBar.send': 'Send',
'terminal.composeBar.close': 'Close compose bar',
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
'terminal.composeBar.resize': 'Resize compose bar height',
'terminal.composeBar.manageSnippets': 'Manage quick snippets',
'terminal.composeBar.searchSnippets': 'Search snippets...',
'terminal.composeBar.noPinnedSnippets': 'Pin snippets with + for quick access',
'terminal.composeBar.noMatchingSnippets': 'No matching snippets',
'terminal.composeBar.pinnedCount': '{count} pinned',
'terminal.composeBar.unpinSnippet': 'Remove {label} from quick bar',
'terminal.composeBar.snippetClickHint': 'Click to insert · Shift+Click to send',
'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',
'terminal.toolbar.closeSession': 'Close session',
'terminal.toolbar.hostHighlight.title': 'Host Keyword Highlighting',
'terminal.toolbar.hostHighlight.noRules': 'No custom highlight rules defined for this host',
'terminal.toolbar.hostHighlight.addRule': 'Add New Rule',
'terminal.toolbar.hostHighlight.labelPlaceholder': 'Label (e.g., Error)',
'terminal.toolbar.hostHighlight.patternPlaceholder': 'Regex pattern (e.g., \\bfailed\\b)',
'terminal.toolbar.hostHighlight.invalidPattern': 'Invalid regex pattern',
'terminal.toolbar.hostHighlight.clearAll': 'Clear All',
'terminal.toolbar.hostHighlight.changeColor': 'Change highlight color for',
'terminal.toolbar.hostHighlight.selectColor': 'Select color for new rule',
'terminal.statusbar.copyHostname.label': 'Copy host address',
'terminal.statusbar.copyHostname.tooltip': 'Copy host address ({hostname})',
'terminal.statusbar.copyHostname.toast': 'Copied host address: {hostname}',
'terminal.statusbar.copyHostname.error': 'Failed to copy host address to clipboard',
'terminal.serverStats.cpu': 'CPU Usage',
'terminal.serverStats.cpuCores': 'CPU Core Usage',
'terminal.serverStats.memory': 'Memory Usage',
'terminal.serverStats.memoryDetails': 'Memory Details',
'terminal.serverStats.memUsed': 'Used',
'terminal.serverStats.memBuffers': 'Buffers',
'terminal.serverStats.memCached': 'Cache',
'terminal.serverStats.memFree': 'Free',
'terminal.serverStats.swap': 'Swap',
'terminal.serverStats.swapUsed': 'Swap Used',
'terminal.serverStats.swapFree': 'Swap Free',
'terminal.serverStats.swapTotal': 'Total',
'terminal.serverStats.topProcesses': 'Top Processes by Memory',
'terminal.serverStats.disk': 'Disk Usage (Root)',
'terminal.serverStats.diskDetails': 'Mounted Disks',
'terminal.serverStats.network': 'Network Speed',
'terminal.serverStats.latency': 'SSH network latency',
'terminal.serverStats.networkDetails': 'Network Interfaces',
'terminal.serverStats.noData': 'No data available',
'terminal.dragDrop.localTitle': 'Drop to Insert Paths',
'terminal.dragDrop.localMessage': 'File paths will be inserted into the terminal',
'terminal.dragDrop.remoteTitle': 'Drop to Upload Files',
'terminal.dragDrop.remoteZmodemMessage': 'Files will be uploaded via ZMODEM (PTY)',
'terminal.dragDrop.remoteSftpMessage': 'Files will be uploaded via SFTP',
'terminal.dragDrop.noFiles': 'No files to upload',
'terminal.dragDrop.notConnected': 'Cannot drop files - terminal is not connected',
'terminal.dragDrop.errorTitle': 'Drop Error',
'terminal.dragDrop.errorMessage': 'Failed to process dropped files',
'terminal.search.placeholder': 'Search...',
'terminal.search.noResults': 'No results',
'terminal.search.prevMatch': 'Previous match (Shift+Enter)',
'terminal.search.nextMatch': 'Next match (Enter)',
'terminal.menu.copy': 'Copy',
'terminal.menu.paste': 'Paste',
'terminal.menu.uploadClipboardImage': 'Upload clipboard image',
'terminal.menu.addSelectionToAI': 'Add to Conversation',
'terminal.menu.pasteSelection': 'Paste Selection',
'terminal.menu.selectAll': 'Select All',
'terminal.menu.reconnect': 'Reconnect',
'terminal.menu.sendYmodem': 'Send with YMODEM',
'terminal.menu.receiveYmodem': 'Receive with YMODEM',
'terminal.menu.splitHorizontal': 'Split Horizontal',
'terminal.menu.splitVertical': 'Split Vertical',
'terminal.menu.clearBuffer': 'Clear Buffer',
'terminal.menu.closeTerminal': 'Close terminal',
'terminal.menu.rename': 'Rename',
'terminal.menu.detach': 'Detach from workspace',
'terminal.menu.detachSession': 'Detach {name}',
'terminal.clipboardImageUpload.noImage': 'Clipboard does not contain an image',
'terminal.clipboardImageUpload.failed': 'Could not upload clipboard image',
'terminal.osc7Setup.title': 'Configure directory tracking',
'terminal.osc7Setup.desc': 'Netcatty will add OSC 7 prompt hooks for the current remote user. This helps SFTP follow the terminal directory after sudo or su.',
'terminal.osc7Setup.targets': 'Possible files to update',
'terminal.osc7Setup.command': 'Command to run',
'terminal.osc7Setup.run': 'Run setup',
'terminal.osc7Setup.running': 'Configuring...',
'terminal.osc7Setup.configured': 'Directory tracking configured',
'terminal.osc7Setup.failed': 'Directory tracking setup failed',
'terminal.osc7Setup.sent': 'Directory tracking setup sent to terminal',
'terminal.ymodem.selectFile': 'Select file to send',
'terminal.ymodem.allFiles': 'All files',
'terminal.ymodem.started': 'YMODEM sending {fileName}',
'terminal.ymodem.complete': 'YMODEM sent {fileName}',
'terminal.ymodem.failed': 'YMODEM send failed',
'terminal.ymodem.selectReceiveDirectory': 'Select folder to save received files',
'terminal.ymodem.receiveStarted': 'YMODEM receiving...',
'terminal.ymodem.receiveComplete': 'YMODEM received {fileName}',
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM received {count} files',
'terminal.ymodem.receiveEmpty': 'No YMODEM files received',
'terminal.ymodem.receiveFailed': 'YMODEM receive failed',
'terminal.ymodem.unavailable': 'YMODEM is unavailable',
'terminal.selection.addToAI': 'Add to Conversation',
'terminal.selection.addToAIDesc': 'Attach selected terminal output to the AI draft',
'terminal.auth.password': 'Password',
'terminal.auth.sshKey': 'SSH Key',
'terminal.auth.username': 'Username',
'terminal.auth.username.placeholder': 'root',
'terminal.auth.passwordLabel': 'Password',
'terminal.auth.password.placeholder': 'Enter password',
'terminal.auth.passphrase': 'Passphrase',
'terminal.auth.passphrase.placeholder': 'Optional passphrase for the selected private key',
'terminal.auth.certificate': 'Certificate',
'terminal.auth.selectKey': 'Select Key',
'terminal.auth.retryMessage': 'Authentication failed. Please check your credentials and try again.',
'terminal.auth.retryLog': 'Authentication failed. Please try again.',
'terminal.auth.noKeysHint': 'No keys available. Add keys in Keychain.',
'terminal.auth.continueSave': 'Continue & Save',
'terminal.auth.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Please re-enter and save them again.',
'terminal.auth.jumpCredentialsUnavailable': 'A jump host has saved credentials that cannot be decrypted on this device. Open host settings and re-enter them.',
'terminal.auth.proxyCredentialsUnavailable': 'Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.',
'terminal.auth.keyUnavailableFallbackPassword': 'Saved SSH key is unavailable on this device. Falling back to password authentication.',
'terminal.progress.timeoutIn': 'Timeout in {seconds}s',
'terminal.progress.waitingForUserInput': 'Waiting for user input',
'terminal.progress.disconnected': 'Disconnected',
'terminal.progress.cancelling': 'Cancelling...',
'terminal.progress.startOver': 'Start over',
'terminal.progress.enterReconnectHint': 'Press Enter to reconnect',
'terminal.progress.autoReconnectScheduled': 'Connection lost. Reconnecting in {seconds}s (attempt {attempt}).',
'terminal.progress.autoReconnectAttempt': 'Auto reconnect attempt {attempt}...',
'terminal.connection.dismissDisconnectedDialog': 'Dismiss disconnected notice',
'terminal.connection.chainOf': 'Chain {current} of {total}',
'terminal.connection.showLogs': 'Show logs',
'terminal.connection.hideLogs': 'Hide logs',
'terminal.connection.protocol.ssh': 'SSH',
'terminal.connection.protocol.telnet': 'Telnet',
'terminal.connection.protocol.mosh': 'Mosh',
'terminal.connection.protocol.et': 'EternalTerminal',
'terminal.et.proxyUnsupported': 'EternalTerminal does not currently support Netcatty proxy settings. Use SSH or remove the proxy for this host.',
'terminal.et.multiJumpUnsupported': 'EternalTerminal currently supports at most one jump host in Netcatty.',
'terminal.connection.protocol.serial': 'Serial',
'terminal.connection.protocol.local': 'Local Shell',
'terminal.hostKey.unknownTitle': 'Confirm this host key',
'terminal.hostKey.changedTitle': 'Host key changed',
'terminal.hostKey.unknownDescription': 'The authenticity of {host} cannot be established yet.',
'terminal.hostKey.changedDescription': 'The saved key for {host} no longer matches this server.',
'terminal.hostKey.fingerprintLabel': '{keyType} fingerprint is SHA256:',
'terminal.hostKey.savedFingerprintLabel': 'Saved fingerprint',
'terminal.hostKey.unknownHint': 'Remember it if this fingerprint belongs to the server you expected.',
'terminal.hostKey.changedHint': 'Only continue if you expected this host to change.',
'terminal.hostKey.addAndContinue': 'Add and continue',
'terminal.hostKey.updateAndContinue': 'Update and continue',
'terminal.themeModal.title': 'Terminal Appearance',
'terminal.themeModal.tab.theme': 'Theme',
'terminal.themeModal.tab.font': 'Font',
'terminal.themeModal.tab.custom': 'Custom',
'terminal.themeModal.globalTheme': 'Global Theme',
'terminal.themeModal.globalFont': 'Global Font',
'terminal.themeModal.fontSize': 'Font Size',
'terminal.themeModal.fontWeight': 'Font Weight',
'terminal.themeModal.livePreview': 'Live Preview',
'terminal.themeModal.themeType': '{type} theme',
'terminal.hiddenTheme.title': 'Current hidden theme',
'terminal.hiddenTheme.desc': 'This theme is hidden from manual picks and will be replaced when you choose another theme.',
'topTabs.toggleTheme.systemExitTitle': 'System theme is active',
'topTabs.toggleTheme.systemExitMessage': 'Open Settings to choose a fixed Light or Dark theme.',
'topTabs.toggleTheme.openSettings': 'Open Settings',
// Custom Themes
'terminal.customTheme.section': 'Custom Themes',
'terminal.customTheme.yourThemes': 'Your Themes',
'terminal.customTheme.new': 'New Theme',
'terminal.customTheme.newDesc': 'Clone current theme and customize',
'terminal.customTheme.newTitle': 'New Custom Theme',
'terminal.customTheme.editTitle': 'Edit Theme',
'terminal.customTheme.import': 'Import .itermcolors',
'terminal.customTheme.importDesc': 'Import from iTerm2 color scheme file',
'terminal.customTheme.importError': 'Failed to parse the selected file. Please ensure it is a valid .itermcolors XML file.',
'terminal.customTheme.delete': 'Delete Theme',
'terminal.customTheme.confirmDelete': 'Confirm Delete',
'terminal.customTheme.name': 'Name',
'terminal.customTheme.namePlaceholder': 'My Custom Theme',
'terminal.customTheme.type': 'Type',
'terminal.customTheme.group.general': 'General',
'terminal.customTheme.group.normal': 'Normal Colors',
'terminal.customTheme.group.bright': 'Bright Colors',
'terminal.customTheme.color.background': 'Background',
'terminal.customTheme.color.foreground': 'Foreground',
'terminal.customTheme.color.cursor': 'Cursor',
'terminal.customTheme.color.selection': 'Selection',
'terminal.customTheme.color.black': 'Black',
'terminal.customTheme.color.red': 'Red',
'terminal.customTheme.color.green': 'Green',
'terminal.customTheme.color.yellow': 'Yellow',
'terminal.customTheme.color.blue': 'Blue',
'terminal.customTheme.color.magenta': 'Magenta',
'terminal.customTheme.color.cyan': 'Cyan',
'terminal.customTheme.color.white': 'White',
'terminal.customTheme.color.brightBlack': 'Bright Black',
'terminal.customTheme.color.brightRed': 'Bright Red',
'terminal.customTheme.color.brightGreen': 'Bright Green',
'terminal.customTheme.color.brightYellow': 'Bright Yellow',
'terminal.customTheme.color.brightBlue': 'Bright Blue',
'terminal.customTheme.color.brightMagenta': 'Bright Magenta',
'terminal.customTheme.color.brightCyan': 'Bright Cyan',
'terminal.customTheme.color.brightWhite': 'Bright White',
// Cloud Sync Settings
'cloudSync.gate.title': 'End-to-End Encrypted Sync',
'cloudSync.gate.desc':
'Your data is encrypted locally before syncing. Cloud providers never see your plaintext data. Set a master key to enable secure sync.',
'cloudSync.gate.masterKey': 'Master Key',
'cloudSync.gate.confirmMasterKey': 'Confirm Master Key',
'cloudSync.gate.placeholder': 'Enter a strong password',
'cloudSync.gate.confirmPlaceholder': 'Confirm your password',
'cloudSync.gate.mismatch': 'Passwords do not match',
'cloudSync.gate.warning':
'I understand that if I forget my master key, my data cannot be recovered. There is no password reset.',
'cloudSync.gate.enableVault': 'Enable Encrypted Vault',
'cloudSync.gate.enabledToast': 'Encrypted vault enabled',
'cloudSync.gate.setupFailed': 'Failed to set up master key',
'cloudSync.passwordStrength.tooShort': 'Too short',
'cloudSync.passwordStrength.weak': 'Weak',
'cloudSync.passwordStrength.moderate': 'Moderate',
'cloudSync.passwordStrength.strong': 'Strong',
'cloudSync.passwordStrength.veryStrong': 'Very Strong',
'cloudSync.provider.notConnected': 'Not connected',
'cloudSync.provider.sync': 'Sync',
'cloudSync.provider.connect': 'Connect',
'cloudSync.provider.connecting': 'Connecting...',
'cloudSync.provider.webdav': 'WebDAV',
'cloudSync.provider.webdav.desc': 'Connect to a self-hosted WebDAV endpoint',
'cloudSync.provider.s3': 'S3 Compatible',
'cloudSync.provider.s3.desc': 'Connect to S3-compatible object storage',
'cloudSync.provider.comingSoon': 'Coming soon',
'cloudSync.webdav.title': 'WebDAV Settings',
'cloudSync.webdav.desc': 'Configure a WebDAV endpoint for encrypted sync.',
'cloudSync.webdav.endpoint': 'Endpoint URL',
'cloudSync.webdav.authType': 'Auth Type',
'cloudSync.webdav.auth.basic': 'Basic',
'cloudSync.webdav.auth.digest': 'Digest',
'cloudSync.webdav.auth.token': 'Token',
'cloudSync.webdav.username': 'Username',
'cloudSync.webdav.password': 'Password',
'cloudSync.webdav.token': 'Token',
'cloudSync.webdav.showSecret': 'Show secret',
'cloudSync.webdav.allowInsecure': 'Allow insecure connection (ignore certificate errors)',
'cloudSync.webdav.validation.endpoint': 'Enter a valid WebDAV endpoint.',
'cloudSync.webdav.validation.credentials': 'Username and password are required.',
'cloudSync.webdav.validation.token': 'Token is required.',
'cloudSync.s3.title': 'S3 Settings',
'cloudSync.s3.desc': 'Connect to S3-compatible object storage for encrypted sync.',
'cloudSync.s3.endpoint': 'Endpoint URL',
'cloudSync.s3.region': 'Region',
'cloudSync.s3.bucket': 'Bucket',
'cloudSync.s3.accessKeyId': 'Access Key ID',
'cloudSync.s3.secretAccessKey': 'Secret Access Key',
'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',
'cloudSync.smb.desc': 'Connect to an SMB/CIFS file share for encrypted sync.',
'cloudSync.smb.share': 'Share Path',
'cloudSync.smb.username': 'Username',
'cloudSync.smb.password': 'Password',
'cloudSync.smb.domain': 'Domain (optional)',
'cloudSync.smb.domainPlaceholder': 'e.g., WORKGROUP',
'cloudSync.smb.port': 'Port (optional)',
'cloudSync.smb.showSecret': 'Show password',
'cloudSync.smb.validation.share': 'Share path is required.',
'cloudSync.smb.validation.port': 'Port must be a number between 1 and 65535.',
'cloudSync.connect.smb.success': 'SMB connected successfully',
'cloudSync.connect.smb.failedTitle': 'SMB connection failed',
'cloudSync.provider.smb': 'SMB Share',
'cloudSync.connect.webdav.success': 'WebDAV connected successfully',
'cloudSync.connect.webdav.failedTitle': 'WebDAV connection failed',
'cloudSync.connect.s3.success': 'S3 connected successfully',
'cloudSync.connect.s3.failedTitle': 'S3 connection failed',
'cloudSync.lastSync.never': 'Never',
'cloudSync.lastSync.justNow': 'Just now',
'cloudSync.lastSync.minutesAgo': '{minutes} min ago',
'cloudSync.changeKey': 'Change Key',
'cloudSync.providers.title': 'Cloud Providers',
'cloudSync.syncAll': 'Sync All Connected Providers',
'cloudSync.autoSync.title': 'Auto-sync',
'cloudSync.autoSync.desc': 'Automatically sync when changes are made',
'cloudSync.strategy.title': 'Sync strategy',
'cloudSync.strategy.desc': 'Choose what happens when local and cloud data both changed.',
'cloudSync.strategy.smartMerge': 'Smart merge (recommended)',
'cloudSync.strategy.smartMergeDesc': 'Combine changes from both sides when possible; if Netcatty cannot decide safely, ask you to choose.',
'cloudSync.strategy.preferCloud': 'Cloud wins',
'cloudSync.strategy.preferCloudDesc': 'When both sides changed, download the cloud version and replace local changes.',
'cloudSync.strategy.preferLocal': 'Local wins',
'cloudSync.strategy.preferLocalDesc': 'When both sides changed, upload the local version and replace cloud changes.',
'cloudSync.status.title': 'Sync Status',
'cloudSync.status.localVersion': 'Local Version',
'cloudSync.status.remoteVersion': 'Remote Version',
'cloudSync.history.title': 'Sync History',
'cloudSync.history.upload': 'Upload',
'cloudSync.history.download': 'Download',
'cloudSync.history.resolved': 'Resolved',
'cloudSync.history.error': 'Error',
'cloudSync.localBackups.title': 'Local Backup History',
'cloudSync.localBackups.desc': 'Netcatty keeps local restore points before app version changes and before vault restores.',
'cloudSync.localBackups.retentionTitle': 'Backup Retention',
'cloudSync.localBackups.retentionDesc': 'Choose how many local backups Netcatty should keep.',
'cloudSync.localBackups.maxCount': 'Max backups',
'cloudSync.localBackups.maxSaved': 'Saved backup retention: {count}',
'cloudSync.localBackups.maxInvalid': 'Please enter a number between 1 and 100.',
'cloudSync.localBackups.empty': 'No local backups yet.',
'cloudSync.localBackups.reason.appVersionChange': 'Before app version change',
'cloudSync.localBackups.reason.beforeRestore': 'Before restore',
'cloudSync.localBackups.versionChange': '{from} -> {to}',
'cloudSync.localBackups.counts': '{hosts} hosts, {keys} keys, {snippets} snippets, {notes} notes',
'cloudSync.localBackups.restore': 'Restore',
'cloudSync.localBackups.restoreSuccess': 'Local backup restored.',
'cloudSync.localBackups.restoreFailedTitle': 'Restore failed',
'cloudSync.localBackups.restoreMissing': 'Backup not found.',
'cloudSync.localBackups.protectiveBackupFailed': 'Safety backup could not be created, so the restore was aborted to protect your current data. Resolve the underlying issue (e.g. keychain access) and try again. Details: {message}',
'cloudSync.localBackups.restoreConfirmTitle': 'Restore this backup?',
'cloudSync.localBackups.restoreConfirmDesc': 'Your current hosts, keys, snippets and settings will be replaced with the contents of this backup. A protective snapshot of your current data is taken automatically first.',
'cloudSync.localBackups.restoreConfirmButton': 'Restore',
'cloudSync.localBackups.restoreConfirmCancel': 'Cancel',
'cloudSync.localBackups.unavailableTitle': 'Local backups unavailable',
'cloudSync.localBackups.unavailableDesc': 'This platform does not expose a secure keychain to Netcatty, so local backups cannot be written safely. Install Netcatty on a system with a supported keychain to enable the local backup history.',
'cloudSync.localBackups.lockedTitle': 'Master key required',
'cloudSync.localBackups.lockedDesc': 'Set up or unlock your master key before restoring a backup, so restored credentials remain encrypted.',
'cloudSync.revisionHistory.viewButton': 'History',
'cloudSync.revisionHistory.title': 'Vault Version History',
'cloudSync.revisionHistory.description': 'Browse and restore previous versions of your vault from the Gist revision history.',
'cloudSync.revisionHistory.empty': 'No revisions found.',
'cloudSync.revisionHistory.current': 'Current',
'cloudSync.revisionHistory.revision': 'Revision',
'cloudSync.revisionHistory.revisionPreview': 'Revision Contents',
'cloudSync.revisionHistory.device': 'Device',
'cloudSync.revisionHistory.hosts': 'Hosts',
'cloudSync.revisionHistory.keys': 'Keys',
'cloudSync.revisionHistory.snippets': 'Snippets',
'cloudSync.revisionHistory.notes': 'Notes',
'cloudSync.revisionHistory.identities': 'Identities',
'cloudSync.revisionHistory.restoreButton': 'Restore This Version',
'cloudSync.revisionHistory.restored': 'Vault restored from selected revision.',
'cloudSync.revisionHistory.revisionNotFound': 'Revision not found or does not contain vault data.',
'cloudSync.revisionHistory.decryptFailed': 'Cannot decrypt this revision. It may have been encrypted with a different master password.',
'cloudSync.changeKey.title': 'Change Master Key',
'cloudSync.changeKey.current': 'Current Master Key',
'cloudSync.changeKey.new': 'New Master Key',
'cloudSync.changeKey.confirmNew': 'Confirm New Master Key',
'cloudSync.changeKey.currentPlaceholder': 'Enter current master key',
'cloudSync.changeKey.newPlaceholder': 'Enter new master key',
'cloudSync.changeKey.confirmPlaceholder': 'Confirm new master key',
'cloudSync.changeKey.fillAll': 'Please fill in all fields',
'cloudSync.changeKey.minLength': 'New master key must be at least 8 characters',
'cloudSync.changeKey.notMatch': 'New master keys do not match',
'cloudSync.changeKey.incorrectCurrent': 'Incorrect current master key',
'cloudSync.changeKey.failed': 'Failed to change master key',
'cloudSync.changeKey.desc': 'This will re-encrypt your vault. Make sure you remember the new key.',
'cloudSync.changeKey.showKeys': 'Show keys',
'cloudSync.changeKey.updatedToast': 'Master key updated',
'cloudSync.changeKey.updateButton': 'Update Key',
'cloudSync.unlock.title': 'Enter Master Key',
'cloudSync.unlock.masterKey': 'Master Key',
'cloudSync.unlock.desc':
'Enter your master key once to enable encrypted sync. It will be stored securely using your OS keychain.',
'cloudSync.unlock.placeholder': 'Enter your master key',
'cloudSync.unlock.empty': 'Please enter your master key',
'cloudSync.unlock.incorrect': 'Incorrect master key',
'cloudSync.unlock.failed': 'Failed to unlock vault',
'cloudSync.unlock.showKey': 'Show key',
'cloudSync.unlock.notNow': 'Not now',
'cloudSync.unlock.readyToast': 'Vault ready',
'cloudSync.unlock.unlockButton': 'Unlock',
'cloudSync.header.vaultReady': 'Vault ready',
'cloudSync.header.preparingVault': 'Preparing vault...',
'cloudSync.header.providersConnected': '{count} provider(s) connected',
'cloudSync.githubFlow.title': 'Connect to GitHub',
'cloudSync.githubFlow.desc': 'Copy the code below and enter it on GitHub to authorize Netcatty.',
'cloudSync.githubFlow.copyCode': 'Copy code',
'cloudSync.githubFlow.copied': 'Copied!',
'cloudSync.githubFlow.openGitHub': 'Open GitHub',
'cloudSync.githubFlow.waiting': 'Waiting for authorization...',
'cloudSync.conflict.title': 'Version conflict detected',
'cloudSync.conflict.desc': 'Choose which version to keep',
'cloudSync.conflict.local': 'LOCAL',
'cloudSync.conflict.cloud': 'CLOUD',
'cloudSync.conflict.detailsTitle': 'Changed data',
'cloudSync.conflict.detailsCounts': 'Local {local} · Cloud {cloud} · Conflicts {conflicts}',
'cloudSync.conflict.entity.hosts': 'Hosts',
'cloudSync.conflict.entity.keys': 'Keys',
'cloudSync.conflict.entity.identities': 'Identities',
'cloudSync.conflict.entity.proxyProfiles': 'Proxy profiles',
'cloudSync.conflict.entity.snippets': 'Snippets',
'cloudSync.conflict.entity.notes': 'Notes',
'cloudSync.conflict.entity.noteGroups': 'Note groups',
'cloudSync.conflict.entity.customGroups': 'Groups',
'cloudSync.conflict.entity.snippetPackages': 'Snippet packages',
'cloudSync.conflict.entity.portForwardingRules': 'Port forwarding',
'cloudSync.conflict.entity.groupConfigs': 'Group settings',
'cloudSync.conflict.entity.settings': 'Settings',
'cloudSync.conflict.keepLocal': 'Overwrite cloud (keep local)',
'cloudSync.conflict.useCloud': 'Download cloud (overwrite local)',
'cloudSync.connect.browserContinue': 'Complete authorization in browser',
'cloudSync.connect.browserCancelled': 'Previous browser authorization was cancelled',
'cloudSync.connect.github.success': 'GitHub connected successfully',
'cloudSync.connect.github.failedTitle': 'GitHub connection failed',
'cloudSync.connect.github.timeout': 'GitHub connection timed out. Check your network or proxy settings.',
'cloudSync.connect.github.networkError': 'Unable to reach GitHub. Check your network or proxy settings.',
'cloudSync.connect.google.failedTitle': 'Google connection failed',
'cloudSync.connect.onedrive.failedTitle': 'OneDrive connection failed',
'cloudSync.sync.success': 'Synced to {provider}',
'cloudSync.sync.failed': 'Sync failed',
'cloudSync.sync.failedTitle': 'Sync failed',
'cloudSync.sync.errorTitle': 'Sync error',
'cloudSync.resolve.downloaded': 'Downloaded cloud data',
'cloudSync.resolve.uploaded': 'Uploaded local data',
'cloudSync.resolve.failedTitle': 'Conflict resolution failed',
'cloudSync.clearLocal.title': 'Clear Local Data',
'cloudSync.clearLocal.desc': 'Reset local version and sync history. Next sync will download from cloud.',
'cloudSync.clearLocal.button': 'Clear',
'cloudSync.clearLocal.dialog.title': 'Clear Local Vault Data?',
'cloudSync.clearLocal.dialog.desc': 'This will reset local version to 0 and clear sync history. Your next sync will download data from the cloud, replacing local data.',
'cloudSync.clearLocal.dialog.cancel': 'Cancel',
'cloudSync.clearLocal.dialog.confirm': 'Clear Local Data',
'cloudSync.clearLocal.toast.title': 'Local data cleared',
'cloudSync.clearLocal.toast.desc': 'Local version reset to 0. Sync to download from cloud.',
// Keychain
'keychain.filter.key': 'KEY',
'keychain.filter.certificate': 'CERTIFICATE',
'keychain.action.generateKey': 'Generate Key',
'keychain.action.importKey': 'Import Key',
'keychain.action.newIdentity': 'New Identity',
'keychain.action.importCertificate': 'Import Certificate',
'keychain.view.grid': 'Grid',
'keychain.view.list': 'List',
'keychain.section.keys': 'Keys',
'keychain.section.identities': 'Identities',
'keychain.count.items': '{count} items',
'keychain.empty.title': 'Set up your keys',
'keychain.empty.desc': 'Import or generate SSH keys for secure authentication.',
'keychain.panel.generateKey': 'Generate Key',
'keychain.panel.newKey': 'New Key',
'keychain.panel.keyDetails': 'Key Details',
'keychain.panel.editKey': 'Edit Key',
'keychain.panel.editIdentity': 'Edit Identity',
'keychain.panel.newIdentity': 'New Identity',
'keychain.panel.keyExport': 'Key Export',
'keychain.validation.labelRequired': 'Please enter a label for the key',
'keychain.validation.labelAndPrivateKeyRequired': 'Label and private key are required',
'keychain.validation.labelAndUsernameRequired': 'Label and username are required',
'keychain.error.generationUnavailable':
'Key generation not available - please ensure the app is running in Electron',
'keychain.error.generateKeyPairFailed': 'Failed to generate key pair',
'keychain.error.generateKeyFailed': 'Failed to generate key',
'keychain.error.keyGenerationTitle': 'Key Generation',
'keychain.export.exportTo': 'Export to *',
'keychain.export.selectHost': 'Select Host',
'keychain.export.location': 'Location ~ $1 *',
'keychain.export.filename': 'Filename ~ $2 *',
'keychain.export.note':
'Key export currently supports only {unix} systems. Use the {advanced} section to customize the export script.',
'keychain.export.script': 'Script *',
'keychain.export.scriptPlaceholder': 'Export script...',
'keychain.export.missingCredentials':
'Host has no saved password or key. Please add password credentials to the host first.',
'keychain.export.successTitle': 'Export Successful',
'keychain.export.successMessage': 'Public key exported and attached to {host}',
'keychain.export.failedTitle': 'Export Failed',
'keychain.export.failedMessage': 'Failed to export key: {error}',
'keychain.export.failedPrefix': 'Export failed: {error}',
'keychain.export.exitCode': 'Command exited with code {code}',
'keychain.export.exporting': 'Exporting...',
'keychain.export.exportAndAttach': 'Export and Attach',
'keychain.export.title': 'Key export',
'keychain.export.exportToRequired': 'Export to *',
'keychain.export.selectHostPlaceholder': 'Select a host...',
'keychain.export.locationLabel': 'Location ~ $1 *',
'keychain.export.filenameLabel': 'Filename ~ $2 *',
'keychain.export.advanced': 'Advanced',
'keychain.export.note.supportsOnly': 'Key export currently supports only',
'keychain.export.note.systems': 'systems.',
'keychain.export.note.use': 'Use',
'keychain.export.note.customize': 'section to customize the export script.',
'keychain.export.scriptRequired': 'Script *',
'keychain.export.exportToHost': 'Export to host',
'keychain.export.failedGeneric': 'Export failed: {message}',
'keychain.field.label': 'Label',
'keychain.field.labelRequired': 'Label *',
'keychain.field.labelPlaceholder': 'Key label',
'keychain.field.privateKeyRequired': 'Private key *',
'keychain.field.publicKey': 'Public key',
'keychain.field.certificatePlaceholder': 'Certificate content (optional)',
'keychain.generate.keyType': 'Key type',
'keychain.generate.keySize': 'Key size',
'keychain.generate.labelPlaceholder': 'Key label',
'keychain.generate.passphrasePlaceholder': 'Passphrase (optional)',
'keychain.generate.savePassphrase': 'Save passphrase',
'keychain.generate.generate': 'Generate',
'keychain.generate.generateSave': 'Generate & Save',
'keychain.import.dropHint': 'Drop a key file here',
'keychain.import.importFromFile': 'Import from file',
'keychain.import.saveKey': 'Save Key',
'keychain.import.importedKeyLabel': 'Imported Key',
'keychain.identity.usernameRequired': 'Username *',
'keychain.identity.method.passwordOnly': 'Password',
'keychain.identity.summary.password': 'Auth password',
'keychain.identity.summary.key': 'Auth key',
'keychain.identity.summary.certificate': 'Auth certificate',
'keychain.identity.summary.passwordAndKey': 'Auth password and key',
'keychain.identity.summary.passwordAndCertificate': 'Auth password and certificate',
'keychain.identity.summary.none': 'No credentials',
'keychain.identity.selectCredential': 'Select {kind}',
'keychain.identity.save': 'Save',
'keychain.identity.update': 'Update',
'keychain.keyDialog.newTitle': 'New Key',
'keychain.keyDialog.newDesc': 'Add a new SSH key',
'keychain.keyDialog.editTitle': 'Edit Key',
'keychain.keyDialog.editDesc': 'Update this SSH key',
'keychain.keyDialog.updateKey': 'Update Key',
// Tabs
'tabs.closeSessionAria': 'Close session',
'tabs.closeLogViewAria': 'Close log view',
'tabs.logPrefix': 'Log:',
'tabs.logLocal': 'Local',
'tabs.copyTab': 'Copy Tab',
'tabs.copyTabToNewWindow': 'Copy Tab to New Window',
'tabs.copyTabToNewWindowFailed': 'Failed to open tab in a new window',
'tabs.closeOthers': 'Close Others',
'tabs.closeToRight': 'Close Tabs to the Right',
'tabs.closeAll': 'Close All',
'keychain.edit.labelRequired': 'Label *',
'keychain.edit.keyLabelPlaceholder': 'Key label',
'keychain.edit.privateKeyRequired': 'Private key *',
'keychain.edit.publicKey': 'Public key',
'keychain.edit.certificate': 'Certificate',
'keychain.edit.certificatePlaceholder': 'Certificate content (optional)',
'keychain.edit.filePath': 'File path',
'keychain.edit.keyExport': 'Key export',
'keychain.edit.exportToHost': 'Export to host',
// Snippets
'snippets.searchPlaceholder': 'Search scripts...',
'snippets.action.newSnippet': 'New Snippet',
'snippets.action.newPackage': 'New Script Package',
'snippets.action.import': 'Import',
'snippets.action.selectSnippets': 'Select snippets',
'snippets.panel.newTitle': 'New Snippet',
'snippets.panel.editTitle': 'Edit Snippet',
'snippets.panel.newAutomationTitle': 'New Automation Script',
'snippets.panel.editAutomationTitle': 'Edit Automation Script',
'snippets.panel.resizeWidth': 'Resize panel width',
'snippets.field.description': 'Action description',
'snippets.field.descriptionPlaceholder': 'Example: check network load',
'snippets.field.package': 'Add a script package',
'snippets.field.packagePlaceholder': 'Select or create script package',
'snippets.field.createPackage': 'Create Script Package',
'snippets.field.scriptRequired': 'Script *',
'snippets.scriptEditor.expand': 'Open in dialog',
'snippets.scriptEditor.resize': 'Resize editor height',
'snippets.scriptEditor.modalTitle': 'Edit script',
'snippets.targets.title': 'Targets',
'snippets.targets.add': 'Add targets',
'snippets.targets.allHosts': 'Apply to all hosts',
'snippets.targets.allHostsShort': 'All hosts',
'snippets.targets.allHostsActive': 'Applies to every connectable host.',
'snippets.history.title': 'Shell History',
'snippets.history.subtitle': '{count} commands',
'snippets.history.emptyTitle': 'No shell history yet',
'snippets.history.emptyDesc': 'Commands you execute will appear here',
'snippets.history.loadMore': 'Load more',
'snippets.history.separator': '•',
'snippets.history.labelPlaceholder': 'Set a label for this snippet',
'snippets.history.saveAsSnippet': 'Save as Snippet',
'snippets.history.time.justNow': 'just now',
'snippets.history.time.minutesAgo': '{count}m ago',
'snippets.history.time.hoursAgo': '{count}h ago',
'snippets.history.time.daysAgo': '{count}d ago',
'snippets.breadcrumb.allPackages': 'All script packages',
'snippets.breadcrumb.separator': '›',
'snippets.empty.title': 'Create scripts',
'snippets.empty.desc': 'Save common commands as code snippets, or write automation scripts for repeatable operations.',
'snippets.search.noResults.title': 'No matches',
'snippets.search.noResults.desc': 'No scripts or script packages match "{query}". Try a different search term or clear the search to browse.',
'snippets.section.packages': 'Script Packages',
'snippets.section.snippets': 'Scripts',
'snippets.kind.codeSnippet': 'Code snippet',
'snippets.kind.automationScript': 'Automation script',
'snippets.package.count': '{count} script(s)',
'snippets.commandFallback': 'Command',
'snippets.view.grid': 'Grid',
'snippets.view.list': 'List',
'snippets.selection.selected': '{count} selected',
'snippets.selection.selectVisible': 'Select visible',
'snippets.selection.deselectAll': 'Deselect all',
'snippets.selection.exportSelected': 'Export selected ({count})',
'snippets.export.snippet': 'Export snippet',
'snippets.export.package': 'Export script package',
'snippets.export.toast.empty': 'No snippets to export.',
'snippets.export.toast.successTitle': 'Export ready',
'snippets.export.toast.success': 'Exported {count} snippet(s).',
'snippets.import.toast.empty': 'No importable snippets found.',
'snippets.import.toast.failedTitle': 'Import failed',
'snippets.import.toast.invalidDesc': 'This is not a valid Netcatty snippets file.',
'snippets.import.toast.successTitle': 'Import completed',
'snippets.import.toast.summary': 'Imported {imported}, overwritten {overwritten}, skipped {skipped}.',
'snippets.import.modal.title': 'Import snippets',
'snippets.import.modal.desc': 'Choose one or more Netcatty snippets JSON files. Netcatty will preview them before anything is imported.',
'snippets.import.modal.exampleTitle': 'Example JSON',
'snippets.import.modal.noFile': 'No file selected yet. Use the example format below, or a plain JSON array of snippets, then choose one or more files.',
'snippets.import.modal.chooseFile': 'Choose file',
'snippets.import.modal.downloadExamples': 'Download samples',
'snippets.import.modal.multipleFiles': '{count} files selected',
'snippets.import.modal.parsedSummary': '{files} file(s), {total} script(s), {packages} script package(s), {conflicts} duplicate command(s). Host bindings are ignored.',
'snippets.import.modal.confirm': 'Confirm import',
'snippets.import.conflict.title': 'Import snippets?',
'snippets.import.conflict.desc': '{file} contains {total} snippet(s). {conflicts} duplicate command(s) already exist.',
'snippets.import.conflict.hostBindingsNote': 'Host bindings are not imported or exported with snippets.',
'snippets.import.conflict.skip': 'Skip duplicates',
'snippets.import.conflict.overwrite': 'Overwrite duplicates',
'snippets.packageDialog.title': 'New Script Package',
'snippets.packageDialog.parent': 'Parent: {parent}',
'snippets.packageDialog.root': 'Root',
'snippets.packageDialog.placeholder': 'e.g. ops/maintenance',
'snippets.packageDialog.hint': 'Use "/" to create nested script packages.',
// Snippets Rename Dialog
'snippets.renameDialog.title': 'Rename Script Package',
'snippets.renameDialog.currentPath': 'Current path: {path}',
'snippets.renameDialog.placeholder': 'Enter new name',
'snippets.renameDialog.error.empty': 'Script package name cannot be empty',
'snippets.renameDialog.error.duplicate': 'A script package with this name already exists',
'snippets.renameDialog.error.invalidChars': 'Script package name can only contain letters, numbers, hyphens, and underscores',
'snippets.field.noAutoRun': 'Paste only (do not auto-execute)',
'snippets.field.multiLineRunMode': 'Multi-line run',
'snippets.field.multiLineRunMode.paste': 'Send all at once',
'snippets.field.multiLineRunMode.lineDelay': 'Send line by line',
'snippets.field.multiLineRunModeHint': 'Use line by line for prompt-based logins or device macros.',
// Snippet Shortkey
'snippets.field.shortkey': 'Keyboard Shortcut',
'snippets.shortkey.placeholder': 'Click to set shortcut',
'snippets.shortkey.recording': 'Press a key combination...',
'snippets.shortkey.hint': 'Press this shortcut in terminal to quickly send the command.',
'snippets.shortkey.clear': 'Clear shortcut',
'snippets.shortkey.error.systemConflict': 'This shortcut conflicts with a system shortcut',
'snippets.shortkey.error.snippetConflict': 'This shortcut is already used by snippet: {name}',
'snippets.variables.dialogTitle': 'Snippet variables',
'snippets.variables.dialogDesc': 'Fill in values for "{label}" before running.',
'snippets.variables.hint': 'Values are inserted as-is into the script (not shell-escaped).',
'snippets.variables.preview': 'Preview',
'snippets.variables.placeholder': 'Enter a value',
'snippets.variables.placeholderDefault': 'Default: {value}',
'snippets.variables.required': 'This variable is required',
'snippets.variables.run': 'Run',
'snippets.field.variablesHelp': 'Use {{name}} or {{name:default}} for placeholders in the script.',
'snippets.field.variablesDetected': 'Variables',
'snippets.field.variableDefault': 'default {value}',
// Serial Port
'serial.button': 'Serial',
'serial.modal.title': 'Connect to Serial Port',
'serial.modal.desc': 'Configure serial port connection settings',
'serial.field.port': 'Serial Port',
'serial.field.selectPort': 'Select a port...',
'serial.field.baudRate': 'Baud Rate',
'serial.field.dataBits': 'Data Bits',
'serial.field.stopBits': 'Stop Bits',
'serial.field.stopBits15Warning': '1.5 stop bits may not be supported on all Windows devices',
'serial.field.parity': 'Parity',
'serial.field.flowControl': 'Flow Control',
'serial.noPorts': 'No serial ports detected. Connect a device and refresh.',
'serial.field.customPort': 'Custom Port Path',
'serial.field.customPortPlaceholder': 'e.g. /dev/ttys001 or COM1',
'serial.type.hardware': 'Hardware',
'serial.type.pseudo': 'Pseudo Terminal',
'serial.type.custom': 'Custom',
'serial.parity.none': 'None',
'serial.parity.even': 'Even',
'serial.parity.odd': 'Odd',
'serial.parity.mark': 'Mark',
'serial.parity.space': 'Space',
'serial.flowControl.none': 'None',
'serial.flowControl.xon/xoff': 'XON/XOFF (Software)',
'serial.flowControl.rts/cts': 'RTS/CTS (Hardware)',
'serial.field.localEcho': 'Force Local Echo',
'serial.field.localEchoDesc': 'Echo typed characters locally (for devices without remote echo)',
'serial.field.lineMode': 'Line Mode',
'serial.field.lineModeDesc': 'Buffer input and send on Enter (instead of character-by-character)',
'serial.field.charset': 'Charset',
'serial.connectionError': 'Failed to connect to serial port',
'serial.field.baudRatePlaceholder': 'Select or enter baud rate...',
'serial.field.baudRateEmpty': 'Enter a custom baud rate',
'serial.field.customBaudRate': 'Using custom baud rate',
'serial.field.saveConfig': 'Save Configuration',
'serial.field.saveConfigDesc': 'Save this serial configuration to hosts for quick access',
'serial.field.configLabel': 'Configuration Name',
'serial.field.configLabelPlaceholder': 'e.g. Arduino Uno',
'serial.connectAndSave': 'Connect & Save',
'serial.edit.title': 'Serial Port Settings',
// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': 'Authentication Required',
'keyboard.interactive.desc': 'The server requires additional authentication.',
'keyboard.interactive.descWithHost': 'The server {hostname} requires additional authentication.',
'keyboard.interactive.response': 'Response',
'keyboard.interactive.enterCode': 'Enter verification code',
'keyboard.interactive.enterResponse': 'Enter response',
'keyboard.interactive.submit': 'Submit',
'keyboard.interactive.verifying': 'Verifying...',
'keyboard.interactive.savePassword': 'Save password',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH Key Passphrase',
'passphrase.desc': 'Enter the passphrase for {keyName}',
'passphrase.descWithHost': 'Enter the passphrase for {keyName} to connect to {hostname}',
'passphrase.label': 'Passphrase',
'passphrase.keyPath': 'Key',
'passphrase.unlock': 'Unlock',
'passphrase.unlocking': 'Unlocking...',
'passphrase.skip': 'Skip',
'passphrase.remember': 'Remember this passphrase',
// Text Editor
'sftp.editor.wordWrap': 'Word Wrap',
'sftp.editor.maximize': 'Maximize',
'sftp.editor.unsavedTitle': 'Unsaved changes',
'sftp.editor.unsavedMessage': '{fileName} has unsaved changes. Save before closing?',
'sftp.editor.discardChanges': 'Discard',
'sftp.editor.saveAndClose': 'Save and close',
'sftp.editor.quitBlockedByDirty': 'Unsaved editors — please save or discard before quitting',
};
+827
View File
@@ -0,0 +1,827 @@
import type { Messages } from '../types';
export const enVaultMessages: Messages = {
// Notes
'notes.search.placeholder': 'Search notes, hosts, tags...',
'notes.action.newNote': 'New Note',
'notes.action.newGroup': 'New Folder',
'notes.action.importMarkdown': 'Import Markdown',
'notes.action.exportNote': 'Export Note',
'notes.action.exportGroup': 'Export Folder',
'notes.action.exportAll': 'Export Notes',
'notes.import.toast.success': 'Imported {count} note(s)',
'notes.import.toast.noNotes': 'No Markdown files were imported.',
'notes.import.toast.failed': 'Failed to import Markdown files.',
'notes.import.toast.inProgress': 'A Markdown import is already in progress.',
'notes.import.toast.skipped': 'Skipped {count} unsupported file(s).',
'notes.export.toast.success': 'Exported {count} note(s)',
'notes.export.toast.empty': 'No notes to export.',
'notes.export.toast.failed': 'Failed to export notes.',
'notes.codeBlock.copied': 'Copied',
'notes.codeBlock.copyFailed': 'Failed to copy code block.',
'notes.group.prompt': 'Folder path',
'notes.group.all': 'Notebook',
'notes.group.ungrouped': 'Ungrouped',
'notes.section.notebooks': 'Notebook',
'notes.section.notes': 'Notes',
'notes.mode.edit': 'Edit',
'notes.mode.split': 'Split',
'notes.mode.preview': 'Preview',
'notes.title.placeholder': 'Note title',
'notes.editor.placeholder': 'Write Markdown notes here...',
'notes.preview.empty': 'Nothing to preview yet.',
'notes.empty.title': 'No notes yet',
'notes.empty.desc': 'Create Markdown notes for runbooks, reminders, and server handoff details.',
'notes.empty.group': 'No notes in this notebook.',
'notes.search.noResults': 'No notes found.',
// Vault import
'vault.import.title': 'Add data to your vault',
'vault.import.desc':
'Transfer your connections from popular clients. Select a file format to start the migration.',
'vault.import.chooseFormat': 'Select a file format',
'vault.import.csv.tip': 'Bulk import: use the CSV template.',
'vault.import.csv.downloadTemplate': 'Download CSV template',
'vault.import.toast.start': 'Importing from {format}...',
'vault.import.toast.completedTitle': 'Import completed',
'vault.import.toast.failedTitle': 'Import failed',
'vault.import.toast.noEntries': 'No importable entries found in {format}.',
'vault.import.toast.noNewHosts': 'No new hosts imported from {format}.',
'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',
'vault.import.sshConfig.importOnlyDesc': 'One-time import. Changes won\'t sync back to the file.',
'vault.import.sshConfig.managed': 'Managed Sync',
'vault.import.sshConfig.managedDesc': 'Keep in sync. Changes will be saved back to the file.',
'vault.import.sshConfig.managedGroup': 'ssh config',
'vault.import.sshConfig.managedSuccess': 'Imported {count} hosts. File is now managed.',
'vault.import.sshConfig.alreadyManaged': 'This file is already being managed.',
'vault.import.sshConfig.alreadyManagedDesc': 'This file is already managed under group "{group}". Remove the existing managed source first if you want to re-import.',
'vault.import.sshConfig.noFilePath': 'Cannot manage this file.',
'vault.import.sshConfig.noFilePathDesc': 'Unable to determine the file path. Managed sync requires access to the file system.',
// Known Hosts
'knownHosts.search.placeholder': 'Search known hosts...',
'knownHosts.action.scanSystem': 'Scan System',
'knownHosts.action.importFile': 'Import File',
'knownHosts.action.browseFile': 'Browse File',
'knownHosts.empty.title': 'No Known Hosts',
'knownHosts.empty.desc':
"Known hosts are SSH servers you've connected to before. Import from your system's known_hosts file to get started.",
'knownHosts.results.showingLimited':
'Showing {shown} of {total} hosts. Use search to find specific hosts.',
'knownHosts.toast.scanUnavailable': 'System scan is unavailable on this platform.',
'knownHosts.toast.scanNoFile': 'No system known_hosts file found.',
'knownHosts.toast.scanNoEntries': 'No usable entries found in known_hosts.',
'knownHosts.toast.scanImported': 'Imported {count} new hosts.',
'knownHosts.toast.scanNoNew': 'No new hosts found.',
'knownHosts.toast.scanFailed': 'Failed to scan system known_hosts.',
// Port Forwarding
'pf.empty.title': 'Set up port forwarding',
'pf.empty.desc': 'Save port forwarding to access databases, web apps, and other services.',
'pf.title': 'Port Forwarding',
'pf.rulesCount': '{count} rules',
'pf.wizard.editTitle': 'Edit Port Forwarding',
'pf.wizard.newTitle': 'New Port Forwarding',
'pf.wizard.saveChanges': 'Save Changes',
'pf.wizard.done': 'Done',
'pf.wizard.continue': 'Continue',
'pf.wizard.cancel': 'Cancel',
'pf.wizard.skipWizard': 'Skip wizard',
'pf.error.hostNotFound': 'Host not found',
'pf.toast.titleWithLabel': 'Port Forwarding: {label}',
'pf.type.local': 'Local',
'pf.type.remote': 'Remote',
'pf.type.dynamic': 'Dynamic',
'pf.type.menu.local': 'Local Forwarding',
'pf.type.menu.remote': 'Remote Forwarding',
'pf.type.menu.dynamic': 'Dynamic Forwarding',
'pf.type.local.desc': "Local forwarding lets you access a remote server's listening port as though it were local.",
'pf.type.remote.desc': 'Remote forwarding opens a port on the remote machine and forwards connections to the local (current) host.',
'pf.type.dynamic.desc': 'Dynamic port forwarding turns Netcatty into a SOCKS proxy server.',
'pf.wizard.type.title': 'Select the port forwarding type:',
'pf.wizard.localConfig.title': 'Set the local port and binding address:',
'pf.wizard.localConfig.desc': 'This port will be open on the local (current) device, and it will receive the traffic.',
'pf.wizard.localConfig.localPort': 'Local port number *',
'pf.wizard.bindAddress': 'Bind address',
'pf.wizard.remoteHost.title': 'Select the remote host:',
'pf.wizard.remoteHost.desc': 'Select a host where the port will be open. Traffic from this port will be forwarded to the destination host.',
'pf.wizard.remoteConfig.title': 'Set the port and binding address:',
'pf.wizard.remoteConfig.desc': 'Traffic will be forwarded from the specified port and interface address of the selected host.',
'pf.wizard.remoteConfig.remotePort': 'Remote port number *',
'pf.wizard.destination.title': 'Select the destination host:',
'pf.wizard.destination.desc.local': 'Enter the remote destination that you want to access through the tunnel.',
'pf.wizard.destination.desc.remote': 'The destination address and port where the traffic will be forwarded.',
'pf.wizard.destination.address': 'Destination address *',
'pf.wizard.destination.addressPlaceholder': 'e.g. 127.0.0.1 or 192.168.1.100',
'pf.wizard.destination.port': 'Destination port number *',
'pf.wizard.sshServer.title': 'Select the SSH server:',
'pf.wizard.sshServer.desc.dynamic': 'Select the SSH server that will act as your SOCKS proxy.',
'pf.wizard.sshServer.desc.default': 'Select the SSH server that will tunnel your traffic to the destination.',
'pf.wizard.label.title': 'Select the label:',
'pf.wizard.label.placeholder.dynamic': 'e.g. SOCKS Proxy',
'pf.wizard.label.placeholder.default': 'e.g. MySQL Production',
'pf.wizard.label.placeholder.remoteRule': 'e.g. Remote Rule',
'pf.wizard.placeholders.portExample': 'e.g. {port}',
'pf.action.newForwarding': 'New Forwarding',
'pf.form.labelPlaceholder': 'Rule label',
'pf.form.intermediateHost': 'Intermediate host *',
'pf.form.createRule': 'Create Rule',
'pf.form.openWizard': 'Open Wizard',
'pf.form.openWizardTitle': 'Open Port Forwarding Wizard',
'pf.view.grid': 'Grid',
'pf.view.list': 'List',
'pf.rule.summary.local': 'Local {bindAddress}:{localPort} -> SSH tunnel -> {remoteHost}:{remotePort}',
'pf.rule.summary.remote': 'Remote {bindAddress}:{localPort} -> SSH tunnel -> local {remoteHost}:{remotePort}',
'pf.rule.summary.dynamic': 'SOCKS on {bindAddress}:{localPort}',
'pf.tooltip.relayHost': 'Relay Host',
'pf.tooltip.hostLabel': 'Host',
'pf.tooltip.hostAddress': 'Address',
'pf.tooltip.noHost': 'No relay host configured',
'pf.tooltip.localDesc': 'Local port forwarding: Access remote services through SSH tunnel',
'pf.tooltip.remoteDesc': 'Remote port forwarding: Expose local services to remote host',
'pf.tooltip.dynamicDesc': 'Dynamic SOCKS proxy: Route traffic through SSH tunnel',
'pf.deleteActive.title': 'Delete Active Port Forwarding?',
'pf.deleteActive.desc': 'This port forwarding rule "{label}" is currently active. Deleting it will stop the tunnel first.',
'pf.deleteActive.confirm': 'Stop and Delete',
'pf.form.autoStart': 'Auto Start',
'pf.form.autoStartDesc': 'Automatically start this rule when the app launches',
// SFTP
'sftp.newFolder': 'New Folder',
'sftp.newFile': 'New File',
'sftp.filter': 'Filter',
'sftp.filter.placeholder': 'Filter by filename...',
'sftp.bookmark.add': 'Bookmark this path',
'sftp.bookmark.remove': 'Remove bookmark',
'sftp.bookmark.list': 'Bookmarked paths',
'sftp.bookmark.addGlobal': '+Global',
'sftp.bookmark.addGlobalTooltip': 'Save as global bookmark (shared across all hosts)',
'sftp.bookmark.empty': 'No bookmarks yet',
'sftp.columns.name': 'Name',
'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',
'sftp.nav.home': 'Go to home',
'sftp.nav.refresh': 'Refresh',
'sftp.upload': 'Upload',
'sftp.uploadFiles': 'Upload files',
'sftp.uploadFolder': 'Upload folder',
'sftp.dragDropToUpload': 'Drag and drop files here to upload',
'sftp.retry': 'Retry',
'sftp.context.open': 'Open',
'sftp.context.navigateTo': 'Navigate to',
'sftp.context.moveTo': 'Move to...',
'sftp.context.moveToParent': 'Move to parent directory',
'sftp.moveTo.title': 'Move to directory',
'sftp.moveTo.placeholder': 'Enter target directory path',
'sftp.moveTo.confirm': 'Move',
'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',
'sftp.viewMode.label': 'View mode',
'sftp.viewMode.list': 'List view',
'sftp.viewMode.tree': 'Tree view',
'sftp.viewMode.switchToList': 'Switch to list view',
'sftp.viewMode.switchToTree': 'Switch to tree view',
'sftp.tree.loadError': 'Failed to load directory',
'sftp.tree.loading': 'Loading...',
'sftp.kind.folder': 'Folder',
'sftp.context.rename': 'Rename',
'sftp.context.permissions': 'Permissions',
'sftp.context.delete': 'Delete',
'sftp.context.refresh': 'Refresh',
'sftp.context.uploadFiles': 'Upload File(s)...',
'sftp.context.uploadFilesHere': 'Upload File(s) Here...',
'sftp.context.uploadFolder': 'Upload Folder...',
'sftp.context.uploadFolderHere': 'Upload Folder Here...',
'sftp.context.downloadSelected': 'Download selected ({count})',
'sftp.context.deleteSelected': 'Delete selected ({count})',
'sftp.dropFilesHere': 'Drop files here',
'sftp.itemsCount': '{count} items',
'sftp.selectedCount': '{count} selected',
'sftp.path.doubleClickToEdit': 'Double-click to edit path',
'sftp.showHiddenPaths': 'Hidden paths',
'sftp.task.waiting': 'Waiting...',
'sftp.transfer.preparing': 'preparing...',
'sftp.status.loading': 'Loading...',
'sftp.status.uploading': 'Uploading...',
'sftp.status.ready': 'Ready',
'sftp.transfers': 'Transfers',
'sftp.transfers.active': '{count} active',
'sftp.transfers.clearCompleted': 'Clear completed',
'sftp.transfers.calculatingTotal': 'Calculating total size...',
'sftp.transfers.filesCount': '{count} files',
'sftp.transfers.filesProgress': '{current}/{total} files',
'sftp.transfers.expandChildren': 'Show files',
'sftp.transfers.collapseChildren': 'Hide files',
'sftp.transfers.expandChildList': 'Show detail',
'sftp.transfers.collapseChildList': 'Hide',
'sftp.transfers.retryAction': 'Retry',
'sftp.transfers.dismissAction': 'Dismiss',
'sftp.transfers.openTargetFolder': 'Open target folder',
'sftp.transfers.openTargetFolderError': 'Could not open target folder',
'sftp.transfers.copyTargetPath': 'Copy target path',
'sftp.transfers.copyTargetPathSuccess': 'Target path copied',
'sftp.transfers.copyTargetPathError': 'Could not copy target path',
'sftp.transfers.resizeNameColumn': 'Resize file name column',
'sftp.transfers.dragToResize': 'Drag to resize',
'sftp.goUp': 'Go up',
'sftp.goToTerminalCwd': 'Go to terminal directory',
'sftp.followTerminalCwd': 'Follow terminal directory',
'sftp.followTerminalCwd.enable': 'Enable follow terminal directory',
'sftp.followTerminalCwd.disable': 'Disable follow terminal directory',
'sftp.encoding.label': 'Filename Encoding',
'sftp.encoding.auto': 'Auto',
'sftp.encoding.utf8': 'UTF-8',
'sftp.encoding.gb18030': 'GB18030',
'sftp.goHome': 'Go to home',
'sftp.folderName': 'Folder name',
'sftp.folderName.placeholder': 'Enter folder name',
'sftp.fileName': 'File name',
'sftp.fileName.placeholder': 'Enter file name',
'sftp.prompt.newFolderName': 'New folder name?',
'sftp.rename.title': 'Rename',
'sftp.rename.newName': 'New name',
'sftp.rename.placeholder': 'Enter new name',
'sftp.confirm.deleteOne': 'Delete "{name}"?',
'sftp.deleteConfirm.single': 'Delete "{name}"?',
'sftp.deleteConfirm.title': 'Delete {count} item(s)?',
'sftp.deleteConfirm.desc': 'This action cannot be undone. The following will be deleted:',
'sftp.deleteConfirm.descSingle': 'This action cannot be undone.',
'sftp.deleteConfirm.host': 'Host',
'sftp.deleteConfirm.path': 'Path',
'sftp.error.loadFailed': 'Failed to load directory',
'sftp.error.downloadFailed': 'Download failed',
'sftp.error.uploadFailed': 'Upload failed',
'sftp.error.deleteFailed': 'Delete failed',
'sftp.error.createFolderFailed': 'Failed to create folder',
'sftp.error.createFileFailed': 'Failed to create file',
'sftp.error.invalidFileName': 'Filename contains invalid characters: {chars}',
'sftp.error.reservedName': 'This filename is reserved by the system',
'sftp.overwrite.title': 'File Already Exists',
'sftp.overwrite.desc': 'A file named "{name}" already exists. Do you want to replace it?',
'sftp.overwrite.confirm': 'Replace',
'sftp.error.renameFailed': 'Failed to rename',
'sftp.picker.title': 'Select Host',
'sftp.picker.desc': 'Pick a host for the {side} pane',
'sftp.picker.searchPlaceholder': 'Search hosts...',
'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',
'sftp.permissions.group': 'Group',
'sftp.permissions.others': 'Others',
'sftp.permissions.octal': 'Octal',
'sftp.permissions.symbolic': 'Symbolic',
'sftp.permissions.success': 'Permissions updated successfully',
'sftp.permissions.failed': 'Failed to update permissions',
'sftp.pane.local': 'Local',
'sftp.pane.remote': 'Remote',
'sftp.pane.selectHost': 'Select host',
'sftp.pane.selectHostToStart': 'Select a host to start',
'sftp.pane.chooseFilesystem': 'Choose a local or remote filesystem to browse',
'sftp.tabs.addTab': 'Add new tab',
'sftp.tabs.closeTab': 'Close tab',
'sftp.tabs.newTab': 'New Tab',
'sftp.tabs.copyDefaultPath': 'Copy tab (default path)',
'sftp.tabs.copyCurrentPath': 'Copy and go to current path',
'sftp.conflict.title': 'File Conflict',
'sftp.conflict.desc': 'A file with the same name already exists at the destination',
'sftp.conflict.alreadyExistsSuffix': 'already exists',
'sftp.conflict.existingFile': 'Existing file',
'sftp.conflict.newFile': 'New file',
'sftp.conflict.size': 'Size:',
'sftp.conflict.modified': 'Modified:',
'sftp.conflict.applyToAll': 'Apply this action to all {count} remaining conflicts',
'sftp.conflict.action.stop': 'Stop',
'sftp.conflict.action.skip': 'Skip',
'sftp.conflict.action.keepBoth': 'Keep Both',
'sftp.conflict.action.duplicate': 'Duplicate',
'sftp.conflict.action.merge': 'Merge',
'sftp.conflict.action.replace': 'Replace',
// SFTP Upload Phases
'sftp.upload.phase.compressing': 'Compressing',
'sftp.upload.phase.uploading': 'Uploading',
'sftp.upload.phase.extracting': 'Extracting',
'sftp.upload.phase.compressed': 'Compressed',
// SFTP File Opener
'sftp.context.copyPath': 'Copy file path',
'sftp.context.openWithDefault': 'Open with system default',
'sftp.context.openWith': 'Open with...',
'sftp.context.edit': 'Edit',
'sftp.context.preview': 'Preview',
'sftp.opener.title': 'Open with',
'sftp.opener.desc': 'Choose an application to open this file',
'sftp.opener.builtInEditor': 'Built-in Editor',
'sftp.opener.editDescription': 'Edit text files',
'sftp.opener.builtInImageViewer': 'Built-in Image Viewer',
'sftp.opener.previewDescription': 'Preview images',
'sftp.opener.systemApp': 'Choose Application...',
'sftp.opener.systemAppDescription': 'Select an application from your computer',
'sftp.opener.onlySystemApp': 'This file can only be opened with an external application',
'sftp.opener.noAppsAvailable': 'No applications available',
'sftp.opener.noExtension': 'files without extension',
'sftp.opener.setDefault': 'Always use this for {ext} files',
'sftp.opener.confirmTitle': 'Set as Default?',
'sftp.opener.confirmDescription': 'Do you want to always use {app} for {ext} files?',
'sftp.opener.yesRemember': 'Yes, remember this choice',
'sftp.opener.justOnce': 'Just this once',
'sftp.opener.confirm.title': 'Set Default Application',
'sftp.opener.confirm.desc': 'Do you want to always open .{ext} files with this application?',
'sftp.editor.title': 'Text Editor',
'sftp.editor.save': 'Save to Remote',
'sftp.editor.saving': 'Saving...',
'sftp.editor.saved': 'Saved successfully',
'sftp.editor.saveFailed': 'Failed to save file',
'sftp.editor.unsavedChanges': 'You have unsaved changes. Close anyway?',
'sftp.editor.syntaxHighlight': 'Syntax Highlighting',
'sftp.preview.title': 'Image Preview',
'sftp.preview.zoomIn': 'Zoom In',
'sftp.preview.zoomOut': 'Zoom Out',
'sftp.preview.resetZoom': 'Reset Zoom',
'sftp.preview.fitToWindow': 'Fit to Window',
// Settings > SFTP File Associations
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftp.transferConcurrency': 'Transfer Concurrency',
'settings.sftp.transferConcurrency.desc': 'Number of files to transfer in parallel when uploading or downloading folders. Higher values may improve speed but can overwhelm some servers.',
'settings.sftp.defaultOpener': 'Default File Opener',
'settings.sftp.defaultOpener.desc': 'Choose the default application for opening files without a specific file association',
'settings.sftp.defaultOpener.ask': 'Always ask',
'settings.sftp.defaultOpener.askDesc': 'Show a dialog to choose an application each time',
'settings.sftp.defaultOpener.builtInDesc': 'Open text files in the built-in editor by default',
'settings.sftp.defaultOpener.systemApp': 'Choose Application...',
'settings.sftp.defaultOpener.systemAppDesc': 'Open files with a specific application by default',
'settings.sftpFileAssociations.title': 'SFTP File Associations',
'settings.sftpFileAssociations.desc': 'Configure default applications for opening files by extension',
'settings.sftpFileAssociations.extension': 'Extension',
'settings.sftpFileAssociations.application': 'Application',
'settings.sftpFileAssociations.noAssociations': 'No file associations configured',
'settings.sftpFileAssociations.remove': 'Remove',
'settings.sftpFileAssociations.removeConfirm': 'Remove association for .{ext}?',
// Settings > SFTP Behavior
'settings.sftp.doubleClickBehavior': 'Double-click behavior',
'settings.sftp.doubleClickBehavior.desc': 'Choose the action when double-clicking a file in SFTP View',
'settings.sftp.doubleClickBehavior.open': 'Open file',
'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 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',
'settings.sftp.autoSync.desc': 'Automatically sync file changes back to the remote server when opening files with external applications',
'settings.sftp.autoSync.enable': 'Enable auto-sync',
'settings.sftp.autoSync.enableDesc': 'When you save a file in an external application, changes will be automatically uploaded to the remote server',
// Settings > SFTP Auto Open Sidebar
'settings.sftp.autoOpenSidebar': 'Auto-open sidebar on connect',
'settings.sftp.autoOpenSidebar.desc': 'Automatically open the SFTP file browser sidebar when connecting to a host',
'settings.sftp.autoOpenSidebar.enable': 'Enable auto-open sidebar',
'settings.sftp.autoOpenSidebar.enableDesc': 'The SFTP sidebar will open automatically when a terminal session connects to a remote host',
'settings.sftp.followTerminalCwd': 'Follow terminal directory',
'settings.sftp.followTerminalCwd.desc': 'Automatically sync the sidebar SFTP browser with the terminal working directory (toggle in toolbar)',
'settings.sftp.followTerminalCwd.enable': 'Enable follow terminal directory by default',
'settings.sftp.followTerminalCwd.enableDesc': 'When the SFTP sidebar is open, follow mode stays on by default and updates after terminal cd commands',
'settings.sftp.defaultViewMode': 'Default View Mode',
'settings.sftp.defaultViewMode.desc': 'Choose the default view mode when opening a new SFTP tab. Per-host preferences override this setting.',
'settings.sftp.defaultViewMode.list': 'List View',
'settings.sftp.defaultViewMode.listDesc': 'Display files in a flat list for the current directory',
'settings.sftp.defaultViewMode.tree': 'Tree View',
'settings.sftp.defaultViewMode.treeDesc': 'Display files in a hierarchical tree structure',
'sftp.autoSync.success': 'File synced to remote: {fileName}',
'sftp.autoSync.error': 'Failed to sync file: {error}',
// SFTP Folder Upload Progress
'sftp.upload.progress': 'Uploading {current} of {total} files...',
'sftp.upload.uploading': 'Uploading...',
'sftp.upload.compressing': 'Compressing...',
'sftp.upload.extracting': 'Extracting...',
'sftp.upload.scanning': 'Scanning files...',
'sftp.upload.completed': 'Completed',
'sftp.upload.compressed': 'Compressed Transfer',
'sftp.upload.currentFile': 'Current: {fileName}',
'sftp.upload.cancelled': 'Upload cancelled',
'sftp.upload.cancel': 'Cancel',
'sftp.upload.completedToPath': 'Uploaded to {path}',
// SFTP Download
'sftp.download.completed': 'Downloaded',
'sftp.download.cancelled': 'Download cancelled',
// SFTP Reconnecting
'sftp.reconnecting.title': 'Reconnecting...',
'sftp.reconnecting.desc': 'Connection lost, attempting to reconnect',
'sftp.reconnected': 'Connection restored',
'sftp.error.reconnectFailed': 'Failed to reconnect. Please try again.',
'sftp.error.connectionLostManual': 'Connection lost. Please reconnect manually.',
'sftp.error.connectionLostReconnecting': 'Connection lost. Reconnecting...',
'sftp.error.sessionLost': 'SFTP session lost. Please reconnect.',
// Settings > SFTP Show Hidden Files
'settings.sftp.showHiddenFiles': 'Show hidden files',
'settings.sftp.showHiddenFiles.desc': 'Display hidden files (dotfiles on Unix/macOS and files with the hidden attribute on Windows) in the SFTP file browser.',
'settings.sftp.showHiddenFiles.enable': 'Show hidden files',
'settings.sftp.showHiddenFiles.enableDesc': 'Display hidden files when browsing both local and remote filesystems',
// Settings > SFTP Compressed Upload
'settings.sftp.compressedUpload': 'Folder Compression Transfer',
'settings.sftp.compressedUpload.desc': 'Compress folders before uploading to significantly reduce transfer time.',
'settings.sftp.compressedUpload.enable': 'Enable folder compression',
'settings.sftp.compressedUpload.enableDesc': 'Automatically compress folders using tar before transfer. Requires tar support on the server. Falls back to regular transfer if not available.',
// Quick Switcher
'qs.search.placeholder': 'Search hosts or tabs',
'qs.jumpTo': 'Jump To',
'qs.localTerminal': 'Local Terminal',
'qs.localShells': 'Local Shells',
'qs.default': 'Default',
// Select Host panel
'selectHost.title': 'Select Host',
'selectHost.noHostsFound': 'No hosts found',
'selectHost.newHost': 'New Host',
'selectHost.continue': 'Continue',
'selectHost.continueWithCount': 'Continue ({count} selected)',
// Quick Connect
'quickConnect.knownHost.title': 'Are you sure you want to connect?',
'quickConnect.knownHost.authenticity': 'The authenticity of {hostname} can not be established.',
'quickConnect.knownHost.fingerprintLabel': '{keyType} fingerprint is SHA256:',
'quickConnect.knownHost.addQuestion': 'Do you want to add it to the list of known hosts?',
'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',
// Protocol select dialog
'protocolSelect.chooseProtocol': 'Choose protocol',
'protocolSelect.port': 'port:',
// Host Details
'hostDetails.title.details': 'Host Details',
'hostDetails.title.new': 'New Host',
'vault.panel.resizeWidth': 'Resize panel width',
'hostDetails.saveAria': 'Save',
'hostDetails.section.address': 'Address',
'hostDetails.hostname.placeholder': 'IP or Hostname',
'hostDetails.section.general': 'General',
'hostDetails.section.sftp': 'SFTP Settings',
'hostDetails.sftp.sudo': 'Sudo Mode',
'hostDetails.sftp.sudo.desc': 'Automatically acquire Root privileges using stored password',
'hostDetails.sftp.sudo.passwordWarning': 'Sudo mode requires a password. Configure one above, or ensure the server allows passwordless sudo.',
'hostDetails.sftp.encoding': 'Filename Encoding',
'hostDetails.sftp.encoding.desc': 'Select the encoding used to decode and send SFTP filenames.',
'hostDetails.label.placeholder': 'Label (e.g., Production Server)',
'hostDetails.notes.label': 'Notes',
'hostDetails.notes.placeholder': 'Hardware, project, customer, region, role...',
'hostDetails.notes.help': 'Supports Markdown. Do not store passwords or private keys here.',
'hostDetails.notes.tab.edit': 'Edit',
'hostDetails.notes.tab.preview': 'Preview',
'hostDetails.notes.preview.empty': 'Nothing to preview yet.',
'hostDetails.notes.toggle.show': 'Show notes editor',
'hostDetails.notes.toggle.hide': 'Hide notes editor',
'hostDetails.group.placeholder': 'Parent Group',
'hostDetails.section.credentials': 'Credentials',
'hostDetails.section.portCredentials': 'Port & Credentials',
'hostDetails.section.appearance': 'Appearance',
'hostDetails.distro.title': 'Linux Distribution',
'hostDetails.distro.desc': 'Auto-detect on connect, or override the distro icon manually.',
'hostDetails.icon.title': 'Host Icon',
'hostDetails.icon.sectionTitle': 'Color & Icon',
'hostDetails.icon.desc': 'Choose the icon source and color for this host.',
'hostDetails.icon.source': 'Source',
'hostDetails.icon.manualLabel': 'Manual icon',
'hostDetails.icon.tab.brand': 'Brand',
'hostDetails.icon.tab.type': 'Type',
'hostDetails.icon.mode.auto': 'Automatic',
'hostDetails.icon.mode.custom': 'Custom',
'hostDetails.icon.reset': 'Reset host icon',
'hostDetails.icon.colorLabel': 'Icon color',
'hostDetails.icon.colorDefault': 'Default',
'hostDetails.icon.colorAuto': 'Automatic',
'hostDetails.icon.colorManual': 'Manual',
'hostDetails.icon.colorChoice': 'Color',
'hostDetails.icon.autoColorLabel': 'Current color',
'hostDetails.icon.preview': 'Icon preview',
'hostDetails.icon.showLibrary': 'Show icon library',
'hostDetails.icon.hideLibrary': 'Hide icon library',
'hostDetails.icon.autoUsesDistro': 'Use Linux Distribution icon and selected color for this host.',
'hostDetails.icon.customOverridesDistro': 'Built-in icon replaces Linux Distribution for this host.',
'hostDetails.icon.option.server': 'Server',
'hostDetails.icon.option.terminal': 'Terminal',
'hostDetails.icon.option.database': 'Database',
'hostDetails.icon.option.cloud': 'Cloud',
'hostDetails.icon.option.router': 'Router',
'hostDetails.icon.option.shield': 'Shield',
'hostDetails.icon.option.code': 'Code',
'hostDetails.icon.option.box': 'Box',
'hostDetails.icon.option.globe': 'Globe',
'hostDetails.icon.option.cpu': 'CPU',
'hostDetails.icon.option.hard-drive': 'Storage',
'hostDetails.icon.option.network': 'Network',
'hostDetails.icon.option.wifi': 'Wireless',
'hostDetails.icon.option.lock': 'Lock',
'hostDetails.icon.option.key': 'Key',
'hostDetails.icon.option.monitor': 'Monitor',
'hostDetails.icon.option.container': 'Container',
'hostDetails.icon.option.activity': 'Activity',
'hostDetails.icon.option.zap': 'Fast',
'hostDetails.icon.option.server-cog': 'Server settings',
'hostDetails.icon.color.blue': 'Blue',
'hostDetails.icon.color.green': 'Green',
'hostDetails.icon.color.red': 'Red',
'hostDetails.icon.color.amber': 'Amber',
'hostDetails.icon.color.purple': 'Purple',
'hostDetails.icon.color.cyan': 'Cyan',
'hostDetails.icon.color.orange': 'Orange',
'hostDetails.icon.color.slate': 'Slate',
'hostDetails.icon.color.violet': 'Violet',
'hostDetails.icon.color.pink': 'Pink',
'hostDetails.icon.color.rose': 'Rose',
'hostDetails.icon.color.lime': 'Lime',
'hostDetails.icon.color.teal': 'Teal',
'hostDetails.icon.color.sky': 'Sky',
'hostDetails.icon.color.indigo': 'Indigo',
'hostDetails.icon.color.zinc': 'Zinc',
'hostDetails.icon.color.custom': 'Custom color',
'hostDetails.distro.mode': 'Source',
'hostDetails.distro.mode.auto': 'Auto-detect',
'hostDetails.distro.mode.manual': 'Manual override',
'hostDetails.distro.detectedLabel': 'Current',
'hostDetails.distro.manualLabel': 'Override',
'hostDetails.distro.pending': 'Detect after first connection',
'hostDetails.distro.unknown': 'Unknown',
'hostDetails.distro.option.linux': 'Generic Linux',
'hostDetails.distro.option.ubuntu': 'Ubuntu',
'hostDetails.distro.option.debian': 'Debian',
'hostDetails.distro.option.centos': 'CentOS',
'hostDetails.distro.option.rocky': 'Rocky Linux',
'hostDetails.distro.option.fedora': 'Fedora',
'hostDetails.distro.option.arch': 'Arch Linux',
'hostDetails.distro.option.alpine': 'Alpine',
'hostDetails.distro.option.amazon': 'Amazon Linux',
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
'hostDetails.distro.option.almalinux': 'AlmaLinux',
'hostDetails.distro.option.alinux': 'Alibaba Cloud Linux',
'hostDetails.distro.option.openeuler': 'openEuler',
'hostDetails.distro.option.oracle': 'Oracle Linux',
'hostDetails.distro.option.kali': 'Kali Linux',
'hostDetails.distro.option.cisco': 'Cisco',
'hostDetails.distro.option.juniper': 'Juniper Networks',
'hostDetails.distro.option.huawei': 'Huawei',
'hostDetails.distro.option.h3c': 'H3C',
'hostDetails.distro.option.hpe': 'HPE',
'hostDetails.distro.option.mikrotik': 'MikroTik',
'hostDetails.distro.option.fortinet': 'Fortinet',
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
'hostDetails.distro.option.zyxel': 'ZyXEL',
'hostDetails.distro.option.ruijie': 'Ruijie',
'hostDetails.section.mosh': 'Mosh',
'hostDetails.section.et': 'EternalTerminal',
'hostDetails.et.port': 'ET server port',
'hostDetails.et.port.desc': 'Port etserver listens on (default 2022)',
'hostDetails.username.placeholder': 'Username',
'hostDetails.password.placeholder': 'Password',
'hostDetails.password.show': 'Show password',
'hostDetails.password.hide': 'Hide password',
'hostDetails.password.save': 'Save password',
'hostDetails.identity.suggestions': 'Identities',
'hostDetails.identity.missing': 'Identity not found',
'hostDetails.credential.keyCertificate': 'Key, Certificate, Local Key File',
'hostDetails.credential.key': 'Key',
'hostDetails.credential.certificate': 'Certificate',
'hostDetails.credential.localKeyFile': 'Local Key File',
'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...',
'hostDetails.certs.empty': 'No certificates available',
'hostDetails.agentForwarding': 'Forward SSH Agent',
'hostDetails.agentForwarding.desc': 'Allow remote server to use your local SSH keys (e.g., for git operations)',
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not available',
'hostDetails.agentForwarding.agentNotRunningHint': 'No SSH agent detected. Enable OpenSSH Authentication Agent in Windows Services, or use a compatible agent such as Bitwarden, 1Password, or gpg-agent.',
'hostDetails.section.agentForwarding': 'SSH Agent',
'hostDetails.x11Forwarding': 'Forward X11 apps',
'hostDetails.x11Forwarding.desc': 'Show remote graphical apps on your local desktop when a local X server is running.',
'hostDetails.section.x11Forwarding': 'X11 Forwarding',
'hostDetails.section.deviceType': 'Device Type',
'hostDetails.deviceType': 'Network Device Mode',
'hostDetails.deviceType.desc': 'Enable for network equipment (switches, routers, firewalls) connected via SSH. Commands are sent as-is without shell wrapping, compatible with vendor CLIs like Huawei VRP and Cisco IOS.',
'hostDetails.deviceType.warning': 'AI agent commands will be sent directly without exit code tracking. Only enable for devices that do not run a standard shell.',
'hostDetails.section.sshAlgorithms': 'SSH Algorithms',
'hostDetails.section.terminalBehavior': 'Terminal Behavior',
'hostDetails.lineTimestamps': 'Show output timestamps',
'hostDetails.lineTimestamps.desc': 'Show local time beside visible output lines for this host without changing terminal text.',
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',
'hostDetails.skipEcdsaHostKey': 'Skip ECDSA host key',
'hostDetails.skipEcdsaHostKey.desc': 'Some old Huawei / Cisco switches produce non-standard ECDSA host-key signatures that cause "signature verification failed". Turning this on drops every ecdsa-sha2-* from the client offer so negotiation falls back to RSA / Ed25519.',
'hostDetails.algorithms.advanced': 'Advanced algorithm overrides',
'hostDetails.algorithms.advanced.desc': 'Replace the offered algorithm list for any category on a per-host basis. Leaving a category untouched uses the default; selecting a subset fully replaces the default list. Incorrect values can make the host unreachable.',
'hostDetails.algorithms.inheritedNotice': 'The current group has algorithm overrides set for: {categories}. The "Reset" button here falls back to the group\'s lists, not NetCatty\'s defaults. To ignore the group restriction, clear the override in the group\'s algorithm settings.',
'hostDetails.algorithms.customized': 'customized',
'hostDetails.algorithms.reset': 'Reset',
'hostDetails.algorithms.category.kex': 'Key Exchange (KEX)',
'hostDetails.algorithms.category.cipher': 'Cipher',
'hostDetails.algorithms.category.hmac': 'MAC (HMAC)',
'hostDetails.algorithms.category.serverHostKey': 'Host Key',
'hostDetails.algorithms.category.compress': 'Compression',
'hostDetails.section.keepalive': 'Keepalive',
'hostDetails.keepalive.override': 'Override global keepalive',
'hostDetails.keepalive.desc': 'Use a custom keepalive policy for this host instead of the global setting. Useful for older routers or switches whose SSH server does not reply to keepalive@openssh.com requests — set interval to 0 to disable keepalive entirely on this host.',
'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',
'hostDetails.jumpHosts.hops': '{count} hop(s)',
'hostDetails.jumpHosts.direct': 'Direct',
'hostDetails.jumpHosts.configure': 'Configure Proxy Hosts',
'hostDetails.proxy': 'Proxy via HTTP/SOCKS5/Command',
'hostDetails.proxy.none': 'None',
'hostDetails.proxy.edit': 'Edit Proxy',
'hostDetails.proxy.configure': 'Configure Proxy',
'hostDetails.proxyPanel.title': 'Proxy via HTTP/SOCKS5/Command',
'hostDetails.proxyPanel.hostPlaceholder': 'Proxy host',
'hostDetails.proxyPanel.command': 'ProxyCommand',
'hostDetails.proxyPanel.commandPlaceholder': 'cloudflared access ssh --hostname %h',
'hostDetails.proxyPanel.commandHelp': 'Use %h for the target host, %p for the target port, and %% for a literal percent.',
'hostDetails.proxyPanel.credentials': 'Credentials',
'hostDetails.proxyPanel.usernamePlaceholder': 'Username',
'hostDetails.proxyPanel.passwordPlaceholder': 'Password',
'hostDetails.proxyPanel.identities': 'Identities',
'hostDetails.proxyPanel.keychainIdentity': 'Keychain identity',
'hostDetails.proxyPanel.manualCredentials': 'Manual credentials',
'hostDetails.proxyPanel.remove': 'Remove Proxy',
'hostDetails.proxyPanel.savedProxy': 'Saved proxy',
'hostDetails.proxyPanel.selectSaved': 'Select saved proxy',
'hostDetails.proxyPanel.customProxy': 'Custom proxy',
'hostDetails.proxyPanel.missing': 'Missing',
'hostDetails.proxyPanel.missingSaved': 'Missing saved proxy',
'hostDetails.proxyPanel.missingIdentity': 'Missing keychain identity',
'hostDetails.proxyPanel.incompleteIdentity': 'Proxy identity needs a username and password',
'hostDetails.proxyPanel.unreadableIdentity': 'Proxy identity password cannot be read on this device',
'hostDetails.proxyPanel.error.required': 'Proxy host and port, or a ProxyCommand, are required.',
'hostDetails.envVars': 'Environment Variables',
'hostDetails.envVars.add': 'Add Environment Variable',
'hostDetails.envVars.title': 'Environment Variables',
'hostDetails.envVars.desc': 'Set an environment variable for {host}.',
'hostDetails.envVars.note':
'Some SSH servers by default only allow variables with prefix LC_ and LANG_.',
'hostDetails.envVars.variable': 'Variable',
'hostDetails.envVars.value': 'Value',
'hostDetails.envVars.newVariable': 'New Variable',
'hostDetails.envVars.variableName': 'Variable name',
'hostDetails.chain.title': 'Edit Chain',
'hostDetails.chain.desc': 'Adding another host will create a connection to {host}.',
'hostDetails.chain.addHost': 'Add a Host',
'hostDetails.chain.target': 'Target',
'hostDetails.chain.availableHosts': 'Available Hosts',
'hostDetails.chain.clear': 'Clear',
'hostDetails.group.title': 'New Group',
'hostDetails.group.general': 'General',
'hostDetails.group.namePlaceholder': 'Group name',
'hostDetails.group.parentPlaceholder': 'Parent Group',
'hostDetails.group.cloudSync': 'Cloud Sync',
'hostDetails.group.addProtocol': 'Add protocol',
'hostDetails.startupCommand': 'Startup Command',
'hostDetails.startupCommand.placeholder': 'Command to run on connect (e.g., cd /app && ls)',
'hostDetails.startupCommand.help':
'This command will be executed automatically after SSH connection is established.',
'hostDetails.startupCommand.runMode': 'Multi-line run',
'hostDetails.startupCommand.runMode.help': 'Choose how multi-line startup commands are sent after connection.',
'hostDetails.startupCommand.runMode.lineDelay': 'Send line by line',
'hostDetails.startupCommand.runMode.paste': 'Send all at once',
'hostDetails.otherProtocols': 'Other Protocols',
'hostDetails.telnetOn': 'Telnet on',
'hostDetails.port': 'port',
'hostDetails.telnet.credentials': 'Credentials',
'hostDetails.telnet.identity.placeholder': 'Manual identity',
'hostDetails.telnet.username': 'Telnet Username',
'hostDetails.telnet.password': 'Telnet Password',
'hostDetails.charset.placeholder': 'Charset (e.g. UTF-8)',
'hostDetails.telnet.add': 'Add Telnet Protocol',
'hostDetails.telnet.setDefault': 'Connect with Telnet by default',
'hostDetails.tags': 'Tags',
'hostDetails.group': 'Group',
'hostDetails.selectGroup': 'Select Group',
'hostDetails.addTag': 'Add a tag...',
'hostDetails.createTag': 'Create tag',
'hostDetails.createGroup': 'Create group',
// Host form (legacy modal)
'hostForm.title.edit': 'Edit Host',
'hostForm.title.new': 'New Host',
'hostForm.desc.edit': 'Update connection details for this host',
'hostForm.desc.new': 'Create a new SSH host entry',
'hostForm.field.label': 'Label',
'hostForm.placeholder.label': 'My Production Server',
'hostForm.field.hostname': 'Hostname / IP',
'hostForm.placeholder.hostname': '192.168.1.1',
'hostForm.field.port': 'Port',
'hostForm.field.username': 'Username',
'hostForm.field.osType': 'OS Type',
'hostForm.placeholder.selectOs': 'Select OS',
'hostForm.field.group': 'Group',
'hostForm.placeholder.group': 'e.g. AWS, DigitalOcean',
'hostForm.field.tags': 'Tags',
'hostForm.placeholder.addTag': 'Add a tag...',
'hostForm.auth.method': 'Authentication Method',
'hostForm.auth.password': 'Password',
'hostForm.auth.sshKey': 'SSH Key',
'hostForm.auth.selectKey': 'Select an SSH Key',
'hostForm.auth.noKeys': 'No keys available',
'hostForm.auth.noKeysHint': 'No SSH keys found in Keychain. Please create one first.',
'hostForm.saveHost': 'Save Host',
// Connection logs
'logs.table.date': 'Date',
'logs.table.user': 'User',
'logs.table.host': 'Host',
'logs.table.saved': 'Saved',
'logs.empty.title': 'No Connection Logs',
'logs.empty.desc':
'Your connection history will appear here when you connect to hosts or open local terminals.',
'logs.loadMore': 'Load {count} more logs',
'logs.ongoing': 'ongoing',
'logs.localTerminal': 'Local Terminal',
'logs.action.save': 'Save',
'logs.action.unsave': 'Unsave',
'logs.action.delete': 'Delete',
// Log view
'logView.customizeAppearance': 'Customize appearance',
'logView.appearance': 'Appearance',
'logView.readOnly': 'Read-only',
'logView.export': 'Export',
};
+18
View File
@@ -0,0 +1,18 @@
import type { Messages } from './types';
import { ruCoreMessages } from './ru/core';
import { ruVaultMessages } from './ru/vault';
import { ruTerminalMessages } from './ru/terminal';
import { ruAiMessages } from './ru/ai';
import { ruSystemManagerMessages } from './ru/systemManager';
export type { Messages } from './types';
const ru: Messages = {
...ruCoreMessages,
...ruVaultMessages,
...ruTerminalMessages,
...ruAiMessages,
...ruSystemManagerMessages,
};
export default ru;
+468
View File
@@ -0,0 +1,468 @@
import type { Messages } from '../types';
export const ruAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Настройки агента',
'ai.chat.preparing': 'Подготовка…',
'ai.title': 'AI',
'ai.description': 'Настройка AI-провайдеров, агентов и параметров безопасности',
'ai.providers': 'Провайдеры',
'ai.agents': 'Агенты',
'ai.providers.empty': 'Провайдеры не настроены. Добавьте провайдера, чтобы начать.',
'ai.providers.add': 'Добавить провайдера',
'ai.providers.active': 'Активен',
'ai.providers.apiKeyConfigured': 'API-ключ настроен',
'ai.providers.noApiKey': 'Нет API-ключа',
'ai.providers.configure': 'Настроить',
'ai.providers.remove': 'Удалить',
'ai.providers.name': 'Отображаемое имя',
'ai.providers.name.placeholder': 'например, Мой провайдер',
'ai.providers.style': 'Стиль протокола',
'ai.providers.style.anthropic': 'Совместимый с Anthropic',
'ai.providers.style.openai': 'Совместимый с OpenAI',
'ai.providers.style.google': 'Совместимый с Google',
'ai.providers.style.inherited': 'авто',
'ai.providers.style.help': 'Определяет, какой формат API используется для запросов. Переопределите, если стороннее API использует другой диалект.',
'ai.providers.icon.change': 'Изменить иконку',
'ai.providers.icon.upload': 'Загрузить изображение',
'ai.providers.icon.reset': 'Сбросить',
'ai.providers.icon.close': 'Свернуть',
'ai.providers.icon.uploadedNote': 'Своя иконка (64×64 WebP)',
'ai.providers.icon.errorType': 'Пожалуйста, выберите файл изображения.',
'ai.providers.apiKey': 'API-ключ',
'ai.providers.apiKey.placeholder': 'Введите API-ключ',
'ai.providers.apiKey.decrypting': 'Расшифровка...',
'ai.providers.baseUrl': 'Базовый URL',
'ai.providers.skipTLSVerify': 'Пропустить проверку TLS-сертификата (для самоподписанных сертификатов)',
'ai.providers.defaultModel': 'Модель по умолчанию',
'ai.providers.defaultModel.placeholder': 'например, gpt-4o, claude-sonnet-4-20250514',
'ai.providers.contextWindow': 'Контекстное окно',
'ai.providers.contextWindow.placeholder': 'например, 128000',
'ai.providers.contextWindow.help': 'Оставьте пустым, чтобы использовать значение из списка моделей, если оно доступно; иначе Netcatty применит безопасное значение по умолчанию.',
'ai.providers.contextWindow.error': 'Введите положительное целое число или оставьте поле пустым.',
'ai.providers.refreshModels': 'Обновить модели',
'ai.providers.searchModel': 'Искать или ввести ID модели...',
'ai.providers.filterModels': 'Фильтровать модели...',
'ai.providers.loadingModels': 'Загрузка моделей...',
'ai.providers.noMatchingModels': 'Нет подходящих моделей',
'ai.providers.clickToLoadModels': 'Нажмите, чтобы загрузить модели',
'ai.providers.showingModels': 'Показаны первые 100 из {count} моделей. Введите текст для фильтрации.',
'ai.providers.advancedParams': 'Дополнительные параметры',
'ai.providers.advancedParams.hint': 'Оставьте пустым, чтобы использовать настройки провайдера по умолчанию.',
'ai.providers.advancedParams.maxTokens.placeholder': 'например, 4096',
'ai.providers.advancedParams.default': 'По умолчанию у провайдера',
// AI Codex
'ai.codex': 'Codex',
'ai.codex.title': 'Codex CLI',
'ai.codex.description': 'Подключение OpenAI Codex. Здесь можно войти через ChatGPT или включить API-ключ OpenAI-совместимого провайдера и пользовательский endpoint в настройках.',
'ai.codex.detecting': 'Обнаружение...',
'ai.codex.notFound': 'Не найден',
'ai.codex.awaitingLogin': 'Ожидание входа',
'ai.codex.connectedChatGPT': 'Подключено через ChatGPT',
'ai.codex.connectedApiKey': 'Подключено через API-ключ',
'ai.codex.connectedCustomConfig': 'Подключено через ~/.codex/config.toml',
'ai.codex.customConfigIncomplete': 'Обнаружен пользовательский конфиг (отсутствует переменная окружения)',
'ai.codex.customConfigHint': 'Используется пользовательский провайдер "{provider}", настроенный в ~/.codex/config.toml — вход через ChatGPT не требуется.',
'ai.codex.customConfigMissingEnvKey': 'Предупреждение: {envKey} не задана в переменных окружения вашей оболочки. Экспортируйте её (или запустите netcatty из оболочки, где она задана), чтобы Codex мог пройти аутентификацию.',
'ai.codex.notConnected': 'Не подключено',
'ai.codex.statusUnknown': 'Статус неизвестен',
'ai.codex.path': 'Путь:',
'ai.codex.notFoundHint': 'Не удалось найти codex в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
'ai.codex.customPathPlaceholder': 'например, /usr/local/bin/codex',
'ai.codex.check': 'Проверить',
'ai.codex.resetPath': 'Сбросить',
'ai.codex.openLogin': 'Открыть вход',
'ai.codex.logout': 'Выйти',
'ai.codex.connectChatGPT': 'Подключить ChatGPT',
'ai.codex.refreshStatus': 'Обновить статус',
// AI Claude Code
'ai.claude.title': 'Claude Code',
'ai.claude.description': 'Агентный помощник для программирования от Anthropic. Требует установленный в системе Claude Code CLI.',
'ai.claude.detecting': 'Обнаружение...',
'ai.claude.detected': 'Обнаружен',
'ai.claude.notFound': 'Не найден',
'ai.claude.path': 'Путь:',
'ai.claude.notFoundHint': 'Не удалось найти claude в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
'ai.claude.customPathPlaceholder': 'например, /usr/local/bin/claude',
'ai.claude.configSection': 'Аутентификация и конфигурация (опционально)',
'ai.claude.configDir': 'Каталог конфигурации',
'ai.claude.configDir.placeholder': '~/.claude (пусто — по умолчанию)',
'ai.claude.configDir.hint': 'Задаёт CLAUDE_CONFIG_DIR — укажите папку, где выполнен вход `claude` (содержит settings.json и учётные данные).',
'ai.claude.settings': 'Файл настроек',
'ai.claude.settings.placeholder': '~/team-settings.json (путь или встроенный {"model":"..."})',
'ai.claude.settings.hint': 'Опционально. Путь к settings.json или встроенный JSON, передаётся в SDK как `settings`. Дополняет «Каталог конфигурации» выше и независим от него (накладывается сверху, не заменяет).',
'ai.claude.envVars': 'Переменные окружения',
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
'ai.claude.envVars.hint': 'По одному KEY=VALUE в строке, передаётся агенту Claude. Хранится локально в открытом виде — для API-ключей и учётных данных используйте «Каталог конфигурации» выше (вход `claude`).',
'ai.claude.check': 'Проверить',
'ai.claude.resetPath': 'Сбросить',
// AI GitHub Copilot CLI
'ai.copilot.title': 'GitHub Copilot CLI',
'ai.copilot.description': 'Использует GitHub Copilot CLI. После обнаружения может быть выбран как внешний агент для программирования.',
'ai.copilot.detecting': 'Обнаружение...',
'ai.copilot.detected': 'Обнаружен',
'ai.copilot.notFound': 'Не найден',
'ai.copilot.path': 'Путь:',
'ai.copilot.notFoundHint': 'Не удалось найти copilot в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
'ai.copilot.customPathPlaceholder': 'например, /usr/local/bin/copilot',
'ai.copilot.check': 'Проверить',
'ai.copilot.resetPath': 'Сбросить',
// AI Cursor SDK
'ai.cursor.title': 'Cursor',
'ai.cursor.description': 'Использует Cursor SDK.',
'ai.cursor.detecting': 'Обнаружение...',
'ai.cursor.detected': 'Доступен',
'ai.cursor.notFound': 'Недоступен',
'ai.cursor.path': 'Среда:',
'ai.cursor.notFoundHint': 'Укажите API-ключ, чтобы включить Cursor.',
'ai.cursor.notInstalledHint': 'Cursor SDK не обнаружен.',
'ai.cursor.installStatus': 'Cursor SDK',
'ai.cursor.installed': 'Обнаружено',
'ai.cursor.notInstalled': 'Не обнаружено',
'ai.cursor.apiKeyStatus': 'API-ключ',
'ai.cursor.apiKeyConfigured': 'Настроен',
'ai.cursor.apiKeyMissing': 'Не указан',
'ai.cursor.apiKeyFromEnv': 'Из окружения',
'ai.cursor.apiKey': 'API-ключ',
'ai.cursor.apiKeyPlaceholder': 'Введите API-ключ Cursor',
'ai.cursor.apiKeyPlaceholder.env': 'Используется CURSOR_API_KEY; введите ключ для замены',
'ai.cursor.apiKeyEnvHint': 'Cursor может использовать CURSOR_API_KEY из shell. Сохраняйте ключ здесь только если хотите переопределить его в Netcatty.',
'ai.cursor.apiKeyOverrideHint': 'Netcatty сначала использует сохранённый здесь ключ, затем CURSOR_API_KEY.',
'ai.cursor.saveApiKey': 'Сохранить',
'ai.cursor.saved': 'Сохранено',
'ai.cursor.showApiKey': 'Показать API-ключ',
'ai.cursor.hideApiKey': 'Скрыть API-ключ',
'ai.cursor.customPathPlaceholder': 'например, /usr/local/bin/cursor',
'ai.cursor.check': 'Проверить',
// AI CodeBuddy Code
'ai.codebuddy.title': 'CodeBuddy Code',
'ai.codebuddy.description': 'Использует CodeBuddy Code через официальный Agent SDK (`@tencent-ai/agent-sdk`). После обнаружения может быть выбран как внешний агент для программирования.',
'ai.codebuddy.detecting': 'Обнаружение...',
'ai.codebuddy.detected': 'Обнаружен',
'ai.codebuddy.notFound': 'Не найден',
'ai.codebuddy.path': 'Путь:',
'ai.codebuddy.notFoundHint': 'Не удалось найти codebuddy в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
'ai.codebuddy.customPathPlaceholder': 'например, /usr/local/bin/codebuddy',
'ai.codebuddy.check': 'Проверить',
'ai.codebuddy.resetPath': 'Сбросить',
'ai.codebuddy.configSection': 'Аутентификация и конфигурация (необязательно)',
'ai.codebuddy.internetEnv': 'Сетевая среда',
'ai.codebuddy.internetEnv.default': 'По умолчанию (зарубежная)',
'ai.codebuddy.internetEnv.internal': 'Internal',
'ai.codebuddy.internetEnv.ioa': 'IOA',
'ai.codebuddy.internetEnv.hint': 'Устанавливает CODEBUDDY_INTERNET_ENVIRONMENT — выберите Internal или IOA для ограниченных сетевых сред.',
'ai.codebuddy.envVars': 'Переменные окружения',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': 'По одной записи KEY=VALUE на строку, передаются агенту CodeBuddy. Укажите CODEBUDDY_API_KEY или CODEBUDDY_AUTH_TOKEN для аутентификации. Хранятся локально в открытом виде.',
// AI Default Agent
'ai.defaultAgent': 'Агент по умолчанию',
'ai.defaultAgent.description': 'Агент, который будет использоваться при запуске новой AI-сессии',
'ai.defaultAgent.catty': 'Catty (встроенный)',
'ai.toolAccess.title': 'Доступ к инструментам',
'ai.toolAccess.mode': 'Режим доступа Netcatty',
'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',
'ai.userSkills.reload': 'Перезагрузить skills',
'ai.userSkills.location': 'Расположение',
'ai.userSkills.loading': 'Сканирование пользовательских skills...',
'ai.userSkills.summary': '{ready} готово, {warnings} предупреждений',
'ai.userSkills.empty': 'Пользовательские skills пока не найдены. Откройте папку, чтобы добавить каталоги skills с файлом SKILL.md.',
'ai.userSkills.unavailable': 'Пользовательские skills недоступны в этой среде.',
'ai.userSkills.status.ready': 'Готово',
'ai.userSkills.status.warning': 'Предупреждение',
// AI Quick Messages
'ai.quickMessages.title': 'Быстрые сообщения',
'ai.quickMessages.description': 'Создавайте часто используемые подсказки и вставляйте их в AI-чат через / или кнопку быстрых сообщений. В отличие от user skills, быстрые сообщения заполняют поле ввода текстом.',
'ai.quickMessages.add': 'Добавить быстрое сообщение',
'ai.quickMessages.createTitle': 'Новое быстрое сообщение',
'ai.quickMessages.editTitle': 'Редактировать быстрое сообщение',
'ai.quickMessages.name': 'Название',
'ai.quickMessages.name.placeholder': 'например: Проверить диск',
'ai.quickMessages.slug': 'Команда',
'ai.quickMessages.slug.placeholder': 'disk-check',
'ai.quickMessages.descriptionField': 'Описание (необязательно)',
'ai.quickMessages.descriptionField.placeholder': 'Краткая подсказка о назначении',
'ai.quickMessages.content': 'Текст сообщения',
'ai.quickMessages.content.placeholder': 'Полный текст подсказки для вставки...',
'ai.quickMessages.empty': 'Быстрых сообщений пока нет. Добавьте несколько часто используемых подсказок.',
'ai.quickMessages.confirmDelete': 'Удалить быстрое сообщение «{name}»?',
'ai.quickMessages.error.nameRequired': 'Укажите название.',
'ai.quickMessages.error.invalidSlug': 'Команда может содержать только строчные буквы, цифры и дефисы.',
'ai.quickMessages.error.contentRequired': 'Укажите текст сообщения.',
'ai.quickMessages.error.slugTaken': 'Эта команда уже используется другим быстрым сообщением.',
'ai.quickMessages.error.slugConflictsWithSkill': 'Команда конфликтует с user skill «/{slug}». Выберите другую.',
'ai.quickMessages.error.maxItems': 'Можно сохранить не более {max} быстрых сообщений.',
// AI Chat
'ai.chat.noProvider': 'AI-провайдер не настроен. Перейдите в **Настройки → AI → Провайдеры**, чтобы добавить и включить провайдера.',
'ai.chat.toolDenied': 'Действие было отклонено пользователем.',
'ai.chat.toolApproved': 'Одобрено',
'ai.chat.toolApprovalHint': 'Enter — разрешить один раз, Escape — отклонить',
'ai.chat.approve': 'Одобрить',
'ai.chat.approveOnce': 'Разрешить один раз',
'ai.chat.alwaysAllow': 'Всегда разрешать',
'ai.chat.slashStopDesc': 'Остановить текущий ход AI и отменить выполняющиеся инструменты',
'ai.chat.reject': 'Отклонить',
'ai.chat.toolLabel': 'Инструмент',
'ai.chat.targetLabel': 'Цель',
'ai.chat.permissionRequired': 'Требуется разрешение',
'ai.chat.permissionDescription': 'AI-агент хочет выполнить вызов инструмента, для которого требуется ваше одобрение.',
'ai.chat.commandBlocked': 'Эта команда заблокирована вашей политикой безопасности и не может быть выполнена.',
'ai.chat.recommendAllow': 'Разрешить',
'ai.chat.recommendConfirm': 'Подтвердить',
'ai.chat.recommendDeny': 'Запретить',
'ai.chat.exportConversation': 'Экспортировать разговор',
'ai.chat.exportAs': 'Экспортировать как',
'ai.chat.exportMarkdown': 'Markdown',
'ai.chat.exportJSON': 'JSON',
'ai.chat.exportPlainText': 'Обычный текст',
'ai.chat.thinking': 'Размышляет',
'ai.chat.thoughtFor': 'Размышлял {duration}',
'ai.chat.thought': 'Мысль',
'ai.chat.agents': 'Агенты',
'ai.chat.detectedOnMachine': 'Обнаружено на этом устройстве',
'ai.chat.rescan': 'Пересканировать',
'ai.chat.permObserver': 'Наблюдатель',
'ai.chat.permConfirm': 'Подтверждение',
'ai.chat.permAuto': 'Авто',
'ai.chat.permObserverDesc': 'Только чтение',
'ai.chat.permConfirmDesc': 'Спрашивать перед действиями',
'ai.chat.permAutoDesc': 'Выполнять свободно',
'ai.chat.emptyHint': 'Спрашивайте о ваших серверах, запускайте команды или получайте помощь с конфигурациями.',
'ai.chat.placeholder': 'Сообщение {agent} — @ для добавления контекста, / для команд',
'ai.chat.placeholderDefault': 'Сообщение агенту Catty...',
'ai.chat.noModel': 'Нет модели',
'ai.chat.noProviderModel': 'Модель по умолчанию не задана — настройте её в Настройки → AI → Провайдеры.',
'ai.chat.selectProvider': 'Выберите провайдера',
'ai.chat.recent': 'Недавние',
'ai.chat.viewAll': 'Показать всё',
'ai.chat.untitled': 'Без названия',
'ai.chat.justNow': 'Только что',
'ai.chat.minutesAgo': '{n}м назад',
'ai.chat.hoursAgo': '{n}ч назад',
'ai.chat.daysAgo': '{n}д назад',
'ai.chat.newChat': 'Новый чат',
'ai.chat.allSessions': 'Все сессии',
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
'ai.chat.usedTools': 'Использовано инструментов: {n}',
'ai.chat.loadMoreSessions': 'Загрузить больше сессий (ещё {n})',
'ai.chat.noSessions': 'Предыдущих сессий нет',
'ai.chat.retryHint': 'Вы можете повторить попытку, отправив сообщение ещё раз.',
'ai.chat.approvalTimeout': 'Время ожидания одобрения инструмента истекло через 5 минут. Вы можете повторить попытку, отправив сообщение ещё раз.',
'ai.chat.menuHosts': 'Хосты',
'ai.chat.menuContext': 'Контекст',
'ai.chat.menuFiles': 'Файлы',
'ai.chat.menuImage': 'Изображение',
'ai.chat.menuMentionHost': 'Упомянуть хост',
'ai.chat.menuUserSkills': 'Пользовательские skills',
'ai.chat.menuSlashCommands': 'Команды /',
'ai.chat.slashCommands': 'Команды /',
'ai.chat.slashQuickMessages': 'Быстрые сообщения',
'ai.chat.slashUserSkills': 'User skills',
'ai.chat.quickMessages': 'Команды /',
'ai.chat.slashNoResults': 'Нет подходящих команд',
'ai.chat.slashEmptyHint': 'Добавьте подсказки в Настройки → AI → Быстрые сообщения.',
// AI Chat Shortcuts
'ai.chatShortcuts.title': 'Быстрые действия чата',
'ai.chatShortcuts.selectionAction': 'Показывать «Добавить в чат» при выделении в терминале',
'ai.chatShortcuts.selectionAction.description': 'Показывать небольшую кнопку AI рядом с выделенным текстом терминала.',
// AI Error
'ai.codex.bridgeError': 'Обработчики главного процесса Codex ещё не загружены. Полностью перезапустите Netcatty или dev-процесс Electron и попробуйте снова.',
// AI Web Search
'ai.webSearch.title': 'Веб-поиск',
'ai.webSearch.enable': 'Включить веб-поиск',
'ai.webSearch.enable.description': 'Разрешить AI-агенту искать в интернете актуальную информацию.',
'ai.webSearch.provider': 'Провайдер поиска',
'ai.webSearch.provider.description': 'Выберите провайдера API веб-поиска.',
'ai.webSearch.apiKey': 'API-ключ',
'ai.webSearch.apiKey.description': 'API-ключ для выбранного провайдера поиска.',
'ai.webSearch.apiKey.placeholder': 'Введите API-ключ...',
'ai.webSearch.apiHost': 'API Host',
'ai.webSearch.apiHost.description': 'Пользовательский API endpoint. Оставьте по умолчанию, если не используете прокси.',
'ai.webSearch.apiHost.searxngDescription': 'URL вашего экземпляра SearXNG (обязательно).',
'ai.webSearch.maxResults': 'Макс. число результатов',
'ai.webSearch.maxResults.description': 'Максимальное количество результатов поиска для возврата (1-20).',
// AI Safety Settings
'ai.safety.title': 'Безопасность',
'ai.safety.permissionMode': 'Режим разрешений',
'ai.safety.permissionMode.description': 'Управляет тем, как AI взаимодействует с вашими терминалами. Режим наблюдателя блокирует все операции записи через Netcatty и применяется как к встроенным, так и к внешним агентам. Режим подтверждения носит рекомендательный характер для внешних агентов (они управляют собственным потоком одобрения инструментов).',
'ai.safety.permissionMode.observer': 'Наблюдатель — только чтение, без действий',
'ai.safety.permissionMode.confirm': 'Подтверждение — спрашивать перед действиями',
'ai.safety.permissionMode.auto': 'Авто — выполнять свободно',
'ai.safety.commandTimeout': 'Тайм-аут команды',
'ai.safety.commandTimeout.description': 'Максимальное число секунд, которое команда может выполняться до принудительного завершения. Применяется как к встроенным, так и к внешним агентам.',
'ai.safety.commandTimeout.unit': 'с',
'ai.safety.maxIterations': 'Макс. число итераций',
'ai.safety.maxIterations.description': 'Максимальное число циклов использования инструментов AI, чтобы предотвратить бесконтрольное выполнение. У внешних агентов могут быть собственные внутренние лимиты итераций, имеющие приоритет.',
'ai.safety.blocklist': 'Чёрный список команд',
'ai.safety.blocklist.description': 'Regex-шаблоны для блокировки опасных команд. Применяется как к встроенным, так и к внешним агентам через механизм выполнения Netcatty.',
'ai.safety.blocklist.placeholder': 'Regex-шаблон...',
'ai.safety.blocklist.reset': 'Сбросить по умолчанию',
'ai.safety.blocklist.add': 'Добавить шаблон',
'ai.safety.grants.title': 'Память разрешений',
'ai.safety.grants.heading': 'Правила для режима confirm',
'ai.safety.grants.description': 'В режиме confirm действие сначала требует подтверждения. Сохранённые правила автоматически разрешают похожие действия во всех терминальных сессиях, их можно редактировать вручную.',
'ai.safety.grants.empty': 'Правил пока нет. Выберите «Всегда разрешать» при одобрении или добавьте вручную.',
'ai.safety.grants.capability': 'Capability ID',
'ai.safety.grants.sessionPattern': 'Шаблон сессии',
'ai.safety.grants.commandPattern': 'Шаблон команды (необяз.)',
'ai.safety.grants.note': 'Заметка (необяз.)',
'ai.safety.grants.add': 'Добавить правило',
'ai.safety.grants.remove': 'Удалить',
'ai.safety.grants.export': 'Экспорт JSON',
'ai.safety.grants.import': 'Импорт JSON',
'ai.safety.note': 'Эти настройки безопасности применяются к действиям, выполняемым через Netcatty. Внешние CLI-агенты могут иметь собственные локальные инструменты и собственные правила управления ими.',
// Unified tooltips for terminal workspace and top tabs (issue #954)
'terminal.layer.addTerminal': 'Добавить терминал',
'terminal.layer.switchToSplitView': 'Переключить в режим разделения',
'terminal.layer.sftp': 'SFTP',
'terminal.layer.scripts': 'Скрипты',
'terminal.layer.history': 'История',
'terminal.layer.theme': 'Тема',
'terminal.layer.notes': 'Заметки',
'terminal.layer.aiChat': 'AI-чат',
'terminal.layer.movePanelLeft': 'Переместить панель влево',
'terminal.layer.movePanelRight': 'Переместить панель вправо',
'terminal.layer.closePanel': 'Закрыть панель',
'terminal.layer.hostTree.search': 'Поиск хостов...',
'terminal.layer.hostTree.searchButton': 'Поиск',
'terminal.layer.hostTree.tagsButton': 'Фильтр по тегам',
'terminal.layer.hostTree.newGroup': 'Новая группа',
'terminal.layer.hostTree.localShell': 'Локальная оболочка',
'terminal.layer.hostTree.tagsEmpty': 'Нет доступных тегов',
'terminal.layer.hostTree.clearTags': 'Сбросить выбор',
'terminal.layer.hostTree.collapse': 'Свернуть список хостов',
'terminal.layer.hostTree.expand': 'Развернуть список хостов',
'terminal.layer.hostTree.empty': 'Хосты не найдены',
'topTabs.openQuickSwitcher': 'Открыть быстрый переключатель',
'topTabs.moreTabs': 'Больше вкладок',
'topTabs.aiAssistant': 'AI-помощник',
'topTabs.windowOpacity': 'Прозрачность окна',
'topTabs.toggleTheme': 'Переключить тему',
'topTabs.openSettings': 'Открыть настройки',
'ai.chat.sessionHistory': 'История сессий',
'ai.chat.attach': 'Прикрепить',
'ai.chat.terminalSelectionAttachment': 'Выделение терминала',
'ai.chat.terminalSelectionLines': 'строк: {count}',
'ai.chat.collapse': 'Свернуть',
'ai.chat.expand': 'Развернуть',
'ai.chat.enableAgent': 'Включить {name}',
'ai.chat.artifact.noteFallback': 'Заметка Vault',
'ai.chat.artifact.openNotes': 'Открыть заметки',
'ai.chat.artifact.openHosts': 'Открыть хосты',
'ai.chat.artifact.notesSummary': '{count} заметок в Vault',
'ai.chat.artifact.hostsSummary': '{count} хостов в Vault',
'ai.chat.artifact.hostsAdded': 'Добавлено хостов: {count}',
'ai.chat.artifact.hostsPreview': 'Предпросмотр хостов: {count}',
'ai.chat.artifact.failed': 'Операция Vault не удалась',
'ai.chat.artifact.unavailableTitle': 'Недоступно',
'ai.chat.artifact.noteMissing': 'Эта заметка больше не в Vault.',
'ai.chat.artifact.hostMissing': 'Этот хост больше не в Vault.',
'ai.chat.artifact.snippetMissing': 'Этот snippet или скрипт больше не в Vault.',
'ai.chat.artifact.openSnippets': 'Открыть Snippets',
'ai.chat.artifact.snippetsSummary': '{count} snippets в Vault',
'ai.chat.artifact.scriptsSummary': '{count} scripts в Vault',
'ai.chat.artifact.snippetFallback': 'Snippet Vault',
'ai.chat.artifact.scriptFallback': 'Automation script',
'ai.chat.artifact.scriptLanguage': 'Script {language}',
'ai.chat.artifact.snippetDeleted': 'Snippet удален',
'ai.chat.artifact.scriptDeleted': 'Script удален',
'ai.chat.artifact.snippetRan': 'Snippet выполнен',
'ai.chat.artifact.scriptStarted': 'Запуск script начат',
'ai.chat.artifact.scriptRunStatus': 'Script run {status}',
'ai.chat.artifact.scriptRunsSummary': '{count} script runs',
'ai.chat.artifact.scriptRunStopped': 'Script run остановлен',
'ai.chat.artifact.scriptRunPaused': 'Script run приостановлен',
'ai.chat.artifact.scriptRunResumed': 'Script run возобновлен',
'ai.chat.artifact.scriptReference': 'Справка nct API',
'zmodem.waitingForRemote': 'Ожидание удалённой стороны...',
'zmodem.uploading': 'Загрузка',
'zmodem.downloading': 'Скачивание',
'zmodem.cancelTransfer': 'Отменить передачу (Ctrl+C)',
'zmodem.overwrite.title': 'Remote file already exists',
'zmodem.overwrite.applyToRest': 'Apply to remaining conflicts',
'zmodem.overwrite.overwrite': 'Overwrite',
'zmodem.overwrite.skip': 'Skip',
'zmodem.overwrite.cancel': 'Cancel',
'settings.shortcuts.resetToDefault': 'Сбросить по умолчанию',
};
+770
View File
@@ -0,0 +1,770 @@
import type { Messages } from '../types';
export const ruCoreMessages: Messages = {
// Common
'common.save': 'Сохранить',
'common.cancel': 'Отмена',
'common.close': 'Закрыть',
'common.reset': 'Сбросить',
'common.zoomIn': 'Увеличить',
'common.zoomOut': 'Уменьшить',
'common.settings': 'Настройки',
'common.search': 'Поиск',
'common.searchPlaceholder': 'Поиск...',
'common.connect': 'Подключиться',
'common.terminal': 'Терминал',
'common.create': 'Создать',
'common.import': 'Импорт',
'common.generate': 'Сгенерировать',
'common.delete': 'Удалить',
'common.edit': 'Редактировать',
'common.clear': 'Очистить',
'common.optional': 'Необязательно',
'common.selectPlaceholder': 'Выбрать...',
'common.add': 'Добавить',
'common.rename': 'Переименовать',
'common.refresh': 'Обновить',
'common.continue': 'Продолжить',
'common.enabled': 'Включено',
'common.disabled': 'Отключено',
'common.error': 'Ошибка',
'common.validation': 'Проверка',
'common.unknownError': 'Неизвестная ошибка',
'common.noResultsFound': 'Ничего не найдено',
'common.back': 'Назад',
'common.apply': 'Применить',
'common.use': 'Использовать',
'common.useGlobal': 'Использовать глобальное',
'common.saveChanges': 'Сохранить изменения',
'common.advanced': 'Дополнительно',
'common.left': 'Слева',
'common.right': 'Справа',
'common.more': 'Ещё',
'common.selectAHost': 'Выберите хост',
'common.selectAHostPlaceholder': 'Выберите хост...',
'sort.manual': 'Ручной порядок',
'sort.az': 'А-Я',
'sort.za': 'Я-А',
'sort.newest': 'Сначала новые',
'sort.oldest': 'Сначала старые',
'sort.group': 'По группе',
'field.label': 'Метка',
'field.type': 'Тип',
'auth.keyType': 'Тип {type}',
'auth.showAllKeys': 'Показать все ключи',
// Dialogs / prompts
'confirm.deleteHost': 'Удалить хост "{name}"?',
'confirm.deleteIdentity': 'Удалить идентификатор "{name}"?',
'confirm.removeProvider': 'Удалить провайдера "{name}"?',
'confirm.closeBusyTerminal.title': 'Подтвердите закрытие',
'confirm.closeBusyTerminal.message': 'Процесс "{command}" всё ещё выполняется и будет завершён.',
'confirm.closeBusyTerminal.messageWithMore': 'Процесс "{command}" и ещё {count} выполняющихся процесс(ов) будут завершены.',
'confirm.closeBusyTerminal.cancel': 'Отмена',
'confirm.closeBusyTerminal.close': 'Закрыть',
'dialog.createWorkspace.title': 'Создать рабочее пространство',
'dialog.renameWorkspace.title': 'Переименовать рабочее пространство',
'dialog.renameSession.title': 'Переименовать сессию',
'field.name': 'Имя',
'field.selectHosts': 'Выбрать хосты',
'placeholder.workspaceName': 'Имя рабочего пространства',
'placeholder.sessionName': 'Имя сессии',
'placeholder.searchHosts': 'Поиск хостов...',
'toast.settingsUnavailable': 'Окно настроек недоступно на этой платформе.',
'credentials.protectionUnavailable.title': 'Защита учётных данных недоступна',
'credentials.protectionUnavailable.message': 'Сохранённые пароли и ключи не могут быть автоматически расшифрованы на этом устройстве. Перед подключением введите учётные данные заново.',
'credentials.protectionUnavailable.action': 'Открыть настройки',
// Settings shell
'settings.title': 'Настройки',
'settings.tab.application': 'Приложение',
'settings.tab.appearance': 'Внешний вид',
'settings.tab.terminal': 'Терминал',
'settings.tab.shortcuts': 'Горячие клавиши',
'settings.tab.syncCloud': 'Синхронизация и облако',
'settings.tab.system': 'Система',
// Settings > System
'settings.system.title': 'Система',
'settings.system.description': 'Системная информация и управление временными файлами.',
'settings.system.tempDirectory': 'Временные файлы',
'settings.system.location': 'Расположение',
'settings.system.fileCount': 'Файлы',
'settings.system.totalSize': 'Размер',
'settings.system.openFolder': 'Открыть папку',
'settings.system.refresh': 'Обновить',
'settings.system.clearTempFiles': 'Очистить временные файлы',
'settings.system.clearing': 'Очистка...',
'settings.system.clearResult': 'Удалено файлов: {deleted}, ошибок: {failed}.',
'settings.system.tempDirectoryHint': 'Временные файлы создаются при открытии удалённых файлов во внешних приложениях. Они автоматически очищаются при закрытии SFTP-сессий.',
'settings.system.credentials.title': 'Защита учётных данных',
'settings.system.credentials.status': 'Статус',
'settings.system.credentials.checking': 'Проверка...',
'settings.system.credentials.available': 'Доступно (системное хранилище ключей готово)',
'settings.system.credentials.unavailable': 'Недоступно (невозможно расшифровать сохранённые учётные данные)',
'settings.system.credentials.unknown': 'Неизвестно (не поддерживается в этой среде)',
'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': 'Просмотр журналов ошибок основного процесса для диагностики неожиданного поведения.',
'settings.system.crashLogs.noLogs': 'Журналы сбоев не найдены.',
'settings.system.crashLogs.entries': 'Записей: {count}',
'settings.system.crashLogs.clear': 'Очистить все журналы',
'settings.system.crashLogs.cleared': 'Очищено файлов журналов: {count}.',
'settings.system.crashLogs.source': 'Источник',
'settings.system.crashLogs.time': 'Время',
'settings.system.crashLogs.message': 'Сообщение',
'settings.system.crashLogs.stack': 'Трассировка стека',
'settings.system.crashLogs.hint': 'Журналы сбоев хранятся 30 дней и автоматически ротируются.',
'settings.system.crashLogs.collapse': 'Свернуть',
'settings.system.crashLogs.expand': 'Показать детали',
// Settings > System > Software Update
'settings.update.title': 'Обновление программы',
'settings.update.currentVersion': 'Текущая версия',
'settings.update.checkForUpdates': 'Проверить обновления',
'settings.update.checking': 'Проверка...',
'settings.update.upToDate': 'Вы используете последнюю версию.',
'settings.update.available': 'Доступна новая версия {version}.',
'settings.update.download': 'Скачать обновление',
'settings.update.downloading': 'Загрузка... {percent}%',
'settings.update.readyToInstall': 'Обновление загружено и готово к установке.',
'settings.update.restartNow': 'Перезапустить для обновления',
'settings.update.error': 'Не удалось проверить наличие обновлений.',
'settings.update.downloadError': 'Не удалось загрузить обновление.',
'settings.update.manualDownload': 'Скачать с GitHub',
'settings.update.manualDownloadHint': 'Автообновление недоступно на этой платформе. Скачайте последнюю версию с GitHub.',
'settings.update.hint': 'Netcatty проверяет обновления через GitHub Releases.',
'settings.update.lastCheckedJustNow': 'только что',
'settings.update.lastCheckedMinutesAgo': '{n} мин назад',
'settings.update.lastCheckedHoursAgo': '{n} ч назад',
'settings.update.lastCheckedPrefix': 'Последняя проверка: ',
'settings.update.autoUpdateEnabled': 'Автоматические обновления',
'settings.update.autoUpdateEnabledDesc': 'Автоматически проверять и загружать обновления, когда они доступны.',
// Settings > Session Logs
'settings.sessionLogs.title': 'Журналы сессий',
'settings.sessionLogs.description': 'Настройка экспорта журналов сессий и параметров автосохранения.',
'settings.sessionLogs.autoSave': 'Автосохранение',
'settings.sessionLogs.enableAutoSave': 'Включить автосохранение',
'settings.sessionLogs.enableAutoSaveDesc': 'Автоматически сохранять журналы сессий после завершения терминальных сессий.',
'settings.sessionLogs.directory': 'Папка сохранения',
'settings.sessionLogs.noDirectory': 'Папка не выбрана',
'settings.sessionLogs.browse': 'Обзор',
'settings.sessionLogs.openFolder': 'Открыть папку',
'settings.sessionLogs.directoryHint': 'Журналы будут организованы по хостам во вложенных папках.',
'settings.sessionLogs.format': 'Формат журнала',
'settings.sessionLogs.formatDesc': 'Выберите формат сохраняемых файлов журналов.',
'settings.sessionLogs.formatTxt': 'Обычный текст (.txt)',
'settings.sessionLogs.formatRaw': 'Сырые данные с ANSI (.log)',
'settings.sessionLogs.formatHtml': 'HTML (.html)',
'settings.sessionLogs.timestamps': 'Добавлять метки времени',
'settings.sessionLogs.timestampsDesc': 'Добавлять локальное время в начало каждой строки в текстовых и HTML-журналах.',
'settings.sessionLogs.hint': 'Журналы сессий сохраняют весь вывод терминала для диагностики и аудита.',
// Settings > SSH Debug Logs
'settings.sshDebugLogs.title': 'Отладочные журналы SSH',
'settings.sshDebugLogs.enable': 'Включить отладочные журналы SSH',
'settings.sshDebugLogs.enableDesc': 'Записывать подключение, аутентификацию, рукопожатие, отключение и причины ошибок без вывода терминала.',
'settings.sshDebugLogs.location': 'Расположение журнала',
'settings.sshDebugLogs.status': 'Статус',
'settings.sshDebugLogs.statusOn': 'Включено',
'settings.sshDebugLogs.statusOff': 'Отключено',
'settings.sshDebugLogs.size': 'Размер',
'settings.sshDebugLogs.hint': 'Когда включено, новые SSH-подключения записывают диагностические события для разбора бастионов, аутентификации и неожиданных отключений.',
// Settings > Global Hotkey (Quake Mode)
'settings.globalHotkey.title': 'Глобальная горячая клавиша',
'settings.globalHotkey.toggleWindow': 'Переключение окна',
'settings.globalHotkey.toggleWindowDesc': 'Нажмите сочетание клавиш, чтобы задать глобальную горячую клавишу для показа или скрытия окна.',
'settings.globalHotkey.notSet': 'Не задано',
'settings.globalHotkey.reset': 'Сбросить по умолчанию',
'settings.globalHotkey.closeToTray': 'Сворачивать в системный трей',
'settings.globalHotkey.closeToTrayDesc': 'Если включено, при закрытии окно будет сворачиваться в системный трей вместо выхода из приложения.',
'settings.globalHotkey.enabled': 'Включить глобальную горячую клавишу',
'settings.globalHotkey.enabledDesc': 'Регистрировать системные сочетания клавиш. Когда отключено, все глобальные горячие клавиши снимаются с регистрации.',
'settings.globalHotkey.hint': 'Глобальная горячая клавиша работает на уровне всей системы и позволяет быстро показывать или скрывать окно (терминал в стиле Quake).',
// Tray Panel
'tray.openMainWindow': 'Открыть главное окно',
'tray.sessions': 'Сессии',
'tray.portForwarding': 'Проброс портов',
'tray.status.connected': 'Подключено',
'tray.status.connecting': 'Подключение',
'tray.status.disconnected': 'Отключено',
'tray.status.active': 'Активно',
'tray.status.inactive': 'Неактивно',
'tray.status.error': 'Ошибка',
'tray.recentHosts': 'Недавние хосты',
'tray.empty.title': 'Пока здесь ничего нет',
'tray.empty.subtitle': 'Подключитесь к серверу, они по вам скучают 🚀',
'tray.quit': 'Выйти из Netcatty',
// Vault Sidebar
'vault.sidebar.collapse': 'Свернуть боковую панель',
'vault.sidebar.expand': 'Развернуть боковую панель',
'vault.sidebar.resize': 'Изменить ширину боковой панели',
// Settings > Application
'settings.application.checkUpdates': 'Проверить обновления',
'settings.application.reportProblem': 'Сообщить о проблеме',
'settings.application.reportProblem.subtitle': 'Создать заранее заполненную задачу на GitHub',
'settings.application.community': 'Сообщество',
'settings.application.community.subtitle': 'На GitHub Discussions',
'settings.application.github': 'GitHub',
'settings.application.github.subtitle': 'Исходный код',
'settings.application.whatsNew': 'Что нового',
'settings.application.whatsNew.subtitle': 'Показать примечания к релизу',
'settings.application.openExternal.failedTitle': 'Не удалось открыть ссылку',
'settings.application.openExternal.failedBody': 'Не удалось открыть ссылку ни в системном браузере, ни во встроенном окне браузера.',
'settings.vault.title': 'Хранилище',
'settings.vault.showRecentHosts': 'Показывать недавно подключённые хосты',
'settings.vault.showRecentHostsDesc': 'Показывать раздел недавно подключённых хостов в верхней части хранилища',
'settings.vault.showOnlyUngroupedHostsInRoot': 'Показывать в корне только хосты без группы',
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'Если включено, в корневом списке хостов будут показаны только хосты без группы. Откройте группу на боковой панели, чтобы увидеть сгруппированные хосты.',
'settings.vault.showSftpTab': 'Показывать вкладку SFTP',
'settings.vault.showSftpTabDesc': 'Показывать отдельный SFTP-вид в верхней панели вкладок. Если скрыто, используйте боковую панель SFTP внутри сессии.',
'settings.vault.showHostTreeSidebar': 'Показывать боковую панель хостов',
'settings.vault.showHostTreeSidebarDesc': 'Показывать список хостов и кнопку в верхней панели для вкладок терминала и редактора.',
// Update notifications
'update.available.title': 'Доступно обновление',
'update.available.message': 'Доступна новая версия {version}. Нажмите, чтобы скачать.',
'update.checking': 'Проверка обновлений...',
'update.upToDate.title': 'Актуальная версия',
'update.upToDate.message': 'У вас установлена последняя версия ({version}).',
'update.error': 'Не удалось проверить наличие обновлений',
'update.downloadNow': 'Скачать сейчас',
'update.viewInSettings': 'Открыть в настройках',
'update.readyToInstall.title': 'Обновление готово',
'update.readyToInstall.message': 'Версия {version} загружена и готова к установке.',
'update.restartNow': 'Перезапустить сейчас',
'update.downloadFailed.title': 'Ошибка обновления',
'update.downloadFailed.message': 'Не удалось скачать обновление. Вы можете скачать его вручную.',
'update.needsSave.title': 'Несохранённые изменения',
'update.needsSave.message': 'Сначала сохраните открытые редакторы, затем снова нажмите «Перезапустить сейчас», чтобы установить обновление.',
'update.openReleases': 'Открыть релизы',
'update.remindLater': 'Напомнить позже',
'update.skipVersion': 'Пропустить эту версию',
// Settings > Appearance
'settings.appearance.uiTheme': 'Тема интерфейса',
'settings.appearance.theme': 'Тема',
'settings.appearance.theme.desc': 'Выберите светлую, тёмную тему или следование системным настройкам',
'settings.appearance.theme.light': 'Светлая',
'settings.appearance.theme.dark': 'Тёмная',
'settings.appearance.theme.system': 'Системная',
'settings.appearance.accentColor': 'Акцентный цвет',
'settings.appearance.customColor': 'Пользовательский цвет',
'settings.appearance.accentColor.mode': 'Использовать свой акцент',
'settings.appearance.accentColor.mode.desc': 'Переопределить акцентный цвет темы',
'settings.appearance.accentColor.custom': 'Пользовательский акцент',
'settings.appearance.themeColor': 'Цвет темы',
'settings.appearance.themeColor.desc': 'Выберите готовую палитру для каждой темы',
'settings.appearance.themeColor.light': 'Палитра светлой темы',
'settings.appearance.themeColor.dark': 'Палитра тёмной темы',
'settings.appearance.customCss': 'Пользовательский CSS',
'settings.appearance.customCss.desc':
'Добавьте пользовательский CSS, чтобы настроить внешний вид приложения. Изменения применяются сразу. Основные области интерфейса имеют атрибут [data-section="..."], который можно использовать для выбора элементов, например: snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (список терминалов в режиме Focus), terminal-host-tree-sidebar, terminal-host-tree-sidebar-content, terminal-host-tree-sidebar-row, terminal-side-panel (панель SFTP/скриптов/темы/AI, доступна пока открыта), terminal-side-panel-tabs, terminal-side-panel-content, terminal-sftp-panel, terminal-sftp-host-header, terminal-sftp-pane, terminal-sftp-toolbar, terminal-sftp-path, terminal-sftp-filter-bar, terminal-sftp-list, terminal-sftp-list-header, terminal-sftp-list-row, terminal-sftp-tree, terminal-sftp-tree-row, terminal-sftp-transfer-queue, terminal-sftp-transfer-row, terminal-split-pane, terminal-split-resizer, top-tabs, top-tabs-host-tree-toggle, top-tabs-quick-switcher-toggle.',
'settings.appearance.customCss.placeholder':
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Скрыть переключатель списка хостов в верхней панели вкладок */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Скрыть кнопку плюса, открывающую быстрый переключатель */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Рамка вокруг боковой панели SFTP (не остаётся после закрытия) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Изменить фон всей боковой панели, а не только верхних вкладок */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Настроить выбранные строки SFTP */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Более заметные разделители сплита */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Подсветка активной панели сплита */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
'settings.appearance.language': 'Язык',
'settings.appearance.language.desc': 'Выберите язык интерфейса',
'settings.appearance.uiFont': 'Шрифт интерфейса',
'settings.appearance.uiFont.desc': 'Выберите шрифт для интерфейса приложения',
'settings.appearance.windowOpacity': 'Прозрачность окна',
'settings.appearance.windowOpacity.desc': 'Настройте прозрачность всего окна приложения. При низких значениях текст терминала тоже бледнеет. В некоторых средах Linux это может не поддерживаться.',
'settings.appearance.appIcon': 'Значок приложения',
'settings.appearance.appIcon.desc': 'Выберите стиль значка для Dock/панели задач, чтобы Netcatty было проще найти при переключении приложений. Применяется во время работы; значок установленного пакета не меняется.',
'settings.appearance.appIcon.original': 'Оригинал',
'settings.appearance.appIcon.bright': 'Яркий',
'settings.appearance.appIcon.dark': 'Тёмный',
'settings.appearance.appIcon.colorful': 'Цветной',
'settings.appearance.appIcon.highContrast': 'Высокий контраст',
'settings.appearance.appIcon.group.classic': 'Цветной фон',
'settings.appearance.appIcon.group.white': 'Белый фон',
'settings.appearance.appIcon.group.special': 'Скрытые',
'settings.appearance.appIcon.whiteNavy': 'Белый · синий кот',
'settings.appearance.appIcon.whiteSky': 'Белый · голубой кот',
'settings.appearance.appIcon.whiteRose': 'Белый · розовый кот',
'settings.appearance.appIcon.whiteEmerald': 'Белый · изумрудный кот',
'settings.appearance.appIcon.whiteAmber': 'Белый · янтарный кот',
'settings.appearance.appIcon.whiteViolet': 'Белый · фиолетовый кот',
'settings.appearance.appIcon.rainbow': 'Радужный кот',
// Settings > Terminal
'settings.terminal.section.theme': 'Тема терминала',
'settings.terminal.themeModal.title': 'Выберите тему',
'settings.terminal.themeModal.darkThemes': 'Тёмные темы',
'settings.terminal.themeModal.lightThemes': 'Светлые темы',
'settings.terminal.theme.selectButton': 'Выбрать тему',
'settings.terminal.theme.followApp': 'Следовать теме приложения',
'settings.terminal.theme.followApp.desc': 'Автоматически подбирать фон терминала под текущую тему приложения для более цельного вида.',
'settings.terminal.theme.darkTheme': 'Тема терминала для тёмного режима',
'settings.terminal.theme.lightTheme': 'Тема терминала для светлого режима',
'settings.terminal.theme.auto': 'Авто (как тема приложения)',
'settings.terminal.theme.autoDesc': 'Следует активному пресету темы интерфейса',
'settings.terminal.section.font': 'Шрифт',
'settings.terminal.section.cursor': 'Курсор',
'settings.terminal.section.keyboard': 'Клавиатура',
'settings.terminal.section.accessibility': 'Доступность',
'settings.terminal.section.behavior': 'Поведение',
'settings.terminal.section.scrollback': 'Буфер прокрутки',
'settings.terminal.section.keywordHighlight': 'Подсветка ключевых слов',
'settings.terminal.font.family': 'Шрифт',
'settings.terminal.font.family.desc': 'Семейство шрифта терминала',
'settings.terminal.font.cjk': 'Шрифт CJK',
'settings.terminal.font.cjk.desc': 'Шрифт для китайских, японских и корейских символов; вариант "Авто" выбирает подходящий шрифт на основе основного',
'settings.terminal.font.cjk.option.auto': 'Авто · в паре с основным шрифтом',
'settings.terminal.font.cjk.option.sarasaSC': 'Sarasa Mono SC (Iosevka + Source Han SC)',
'settings.terminal.font.cjk.option.sarasaTC': 'Sarasa Mono TC (Iosevka + Source Han TC)',
'settings.terminal.font.cjk.option.mapleCN': 'Maple Mono CN',
'settings.terminal.font.cjk.option.sourceHan': 'Source Han Mono SC',
'settings.terminal.font.cjk.option.notoCJK': 'Noto Sans Mono CJK SC',
'settings.terminal.font.cjk.option.lxgwWenkai': 'LXGW WenKai Mono',
'settings.terminal.font.cjk.option.simSun': 'SimSun',
'settings.terminal.font.cjk.option.legacy': '{font} · не рекомендуется (пропорциональный шрифт)',
'settings.terminal.font.size': 'Размер шрифта',
'settings.terminal.font.size.desc': 'Размер текста терминала',
'settings.terminal.font.weight': 'Толщина шрифта',
'settings.terminal.font.weight.desc': 'Толщина обычного текста (100-900)',
'settings.terminal.font.weight.thin': 'Тонкий',
'settings.terminal.font.weight.extraLight': 'Очень светлый',
'settings.terminal.font.weight.light': 'Светлый',
'settings.terminal.font.weight.normal': 'Обычный',
'settings.terminal.font.weight.medium': 'Средний',
'settings.terminal.font.weight.semiBold': 'Полужирный',
'settings.terminal.font.weight.bold': 'Жирный',
'settings.terminal.font.weight.extraBold': 'Очень жирный',
'settings.terminal.font.weight.black': 'Максимально жирный',
'settings.terminal.font.weightBold': 'Толщина жирного шрифта',
'settings.terminal.font.weightBold.desc': 'Толщина жирного текста (100-900)',
'settings.terminal.font.smoothing': 'Сглаживание шрифта',
'settings.terminal.font.smoothing.desc': 'Использовать родное сглаживание шрифтов macOS',
'settings.terminal.font.linePadding': 'Межстрочный отступ',
'settings.terminal.font.linePadding.desc': 'Дополнительное пространство между строками (0-10)',
'settings.terminal.font.emulationType': 'Тип эмуляции терминала',
'settings.terminal.cursor.style': 'Стиль курсора',
'settings.terminal.cursor.style.block': 'Блок',
'settings.terminal.cursor.style.bar': 'Полоса',
'settings.terminal.cursor.style.underline': 'Подчёркивание',
'settings.terminal.cursor.blink': 'Мигание курсора',
'settings.terminal.keyboard.altAsMeta': 'Использовать Option как клавишу Meta',
'settings.terminal.keyboard.altAsMeta.desc':
'Использовать Option (Alt) как клавишу Meta вместо ввода специальных символов',
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ переход по словам',
'settings.terminal.keyboard.optionArrowWordJump.desc':
'Отправлять Meta-b / Meta-f при Option+Влево/Вправо, чтобы оболочка перемещалась по словам, вместо стандартного ^[[1;3D / ^[[1;3C',
'settings.terminal.accessibility.minimumContrastRatio': 'Минимальный коэффициент контрастности',
'settings.terminal.accessibility.minimumContrastRatio.desc':
'Подстраивать цвета под требования контрастности (1 = отключено, 21 = максимум)',
'settings.terminal.behavior.rightClick': 'Поведение правой кнопки мыши',
'settings.terminal.behavior.rightClick.desc': 'Действие при щелчке правой кнопкой в терминале',
'settings.terminal.behavior.rightClick.menu': 'Показать меню',
'settings.terminal.behavior.rightClick.paste': 'Вставить',
'settings.terminal.behavior.rightClick.selectWord': 'Выбрать слово',
'settings.terminal.behavior.copyOnSelect': 'Копировать при выделении',
'settings.terminal.behavior.copyOnSelect.desc': 'Автоматически копировать выделенный текст. В tmux/vim с режимом мыши удерживайте Option на macOS или Shift на Windows/Linux для выделения',
'settings.terminal.behavior.middleClickPaste': 'Вставка средней кнопкой мыши',
'settings.terminal.behavior.middleClickPaste.desc':
'Вставлять содержимое буфера обмена по щелчку средней кнопкой',
'settings.terminal.behavior.middleClick': 'Поведение средней кнопки мыши',
'settings.terminal.behavior.middleClick.desc': 'Действие при щелчке средней кнопкой в терминале',
'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`.',
'settings.terminal.behavior.preserveSelectionOnInput': 'Сохранять выделение при вводе',
'settings.terminal.behavior.preserveSelectionOnInput.desc':
'Не сбрасывать выделенный мышью текст при вводе. Это удобно, например, чтобы выделить путь и вставить его после префикса команды вроде `sz `.',
'settings.terminal.behavior.forcePromptNewLine': 'Переносить приглашение на новую строку',
'settings.terminal.behavior.forcePromptNewLine.desc':
'Если последняя строка вывода команды не завершена переводом строки, переносить распознанное приглашение оболочки на следующую визуальную строку.',
'settings.terminal.behavior.dynamicTabTitle': 'Динамический заголовок вкладки',
'settings.terminal.behavior.dynamicTabTitle.desc':
'Выберите, когда вкладки сеансов должны следовать заголовкам окна, сообщаемым оболочкой.',
'settings.terminal.behavior.dynamicTabTitle.off': 'Отключено',
'settings.terminal.behavior.dynamicTabTitle.agent': 'Только агенты',
'settings.terminal.behavior.dynamicTabTitle.all': 'Все сеансы',
'settings.terminal.behavior.osc52Clipboard': 'Буфер обмена OSC-52',
'settings.terminal.behavior.osc52Clipboard.desc':
'Разрешить удалённым программам (tmux, vim и т. д.) доступ к локальному буферу обмена через escape-последовательности OSC-52.',
'settings.terminal.behavior.osc52Clipboard.off': 'Отключено',
'settings.terminal.behavior.osc52Clipboard.writeOnly': 'Только запись',
'settings.terminal.behavior.osc52Clipboard.readWrite': 'Чтение и запись',
'settings.terminal.behavior.osc52Clipboard.prompt': 'Запись + запрос при чтении',
'terminal.osc52.readPrompt.title': 'Запрос чтения буфера обмена',
'terminal.osc52.readPrompt.desc': 'Удалённая программа запрашивает чтение вашего буфера обмена. Разрешить?',
'terminal.osc52.readPrompt.allow': 'Разрешить',
'terminal.osc52.readPrompt.deny': 'Запретить',
'settings.terminal.behavior.scrollOnInput': 'Прокручивать при вводе',
'settings.terminal.behavior.scrollOnInput.desc': 'Прокручивать терминал вниз при наборе текста',
'settings.terminal.behavior.scrollOnOutput': 'Прокручивать при выводе',
'settings.terminal.behavior.scrollOnOutput.desc':
'Прокручивать терминал вниз при появлении нового вывода',
'settings.terminal.behavior.scrollOnKeyPress': 'Прокручивать при нажатии клавиш',
'settings.terminal.behavior.scrollOnKeyPress.desc':
'Прокручивать терминал вниз при нажатии клавиши (например, Enter)',
'settings.terminal.behavior.scrollOnPaste': 'Прокручивать при вставке',
'settings.terminal.behavior.scrollOnPaste.desc':
'Прокручивать терминал вниз при вставке текста',
'settings.terminal.behavior.smoothScrolling': 'Плавная прокрутка',
'settings.terminal.behavior.smoothScrolling.desc':
'Анимировать прокрутку области терминала вместо мгновенного перехода',
'settings.terminal.behavior.linkModifier': 'Клавиша-модификатор для ссылок',
'settings.terminal.behavior.linkModifier.desc': 'Удерживайте эту клавишу, чтобы нажимать на ссылки в терминале',
'settings.terminal.behavior.linkModifier.none': 'Нет (нажимать напрямую)',
'settings.terminal.behavior.linkModifier.ctrl': 'Ctrl',
'settings.terminal.behavior.linkModifier.alt': 'Alt / Option',
'settings.terminal.behavior.linkModifier.meta': 'Cmd / Win',
'settings.terminal.scrollback.desc': 'Ограничение количества строк терминала. Установите 0, чтобы снять ограничение.',
'settings.terminal.scrollback.rows': 'Количество строк *',
'settings.terminal.section.startupCommand': 'Команда запуска',
'settings.terminal.startupCommandDelay.label': 'Задержка команды запуска (мс)',
'settings.terminal.startupCommandDelay.desc': 'Сколько ждать после подключения перед отправкой команды запуска. Также используется между строками, если команда запуска многострочная. Увеличьте для медленных соединений.',
'settings.terminal.keywordHighlight.title': 'Подсветка ключевых слов',
'settings.terminal.keywordHighlight.resetColors': 'Сбросить цвета по умолчанию',
'settings.terminal.keywordHighlight.resetDefaults': 'Сбросить встроенные правила по умолчанию',
'settings.terminal.keywordHighlight.resetBuiltIn': 'Восстановить стандартную метку и шаблоны',
'settings.terminal.keywordHighlight.addCustom': 'Добавить своё правило',
'settings.terminal.keywordHighlight.editCustom': 'Редактировать правило',
'settings.terminal.keywordHighlight.editBuiltIn': 'Редактировать встроенное правило',
'settings.terminal.keywordHighlight.labelField': 'Метка и цвет',
'settings.terminal.keywordHighlight.labelPlaceholder': 'Метка (например, Down)',
'settings.terminal.keywordHighlight.patternField': 'Шаблоны Regex',
'settings.terminal.keywordHighlight.patternPlaceholder': 'Один regex на строку (например, \\bdown\\b)',
'settings.terminal.keywordHighlight.patternHint': 'Один regex на строку. Шаблоны сопоставляются без учёта регистра с глобальным флагом.',
'settings.terminal.keywordHighlight.invalidPattern': 'Некорректный regex-шаблон',
'settings.terminal.keywordHighlight.preview': 'Предпросмотр',
'settings.terminal.section.localShell': 'Локальная оболочка',
'settings.terminal.localShell.shell': 'Исполняемый файл оболочки',
'settings.terminal.localShell.shell.desc': 'Путь к исполняемому файлу оболочки (например, /bin/zsh, pwsh.exe). Оставьте пустым, чтобы использовать системную оболочку по умолчанию.',
'settings.terminal.localShell.shell.placeholder': 'Системная по умолчанию',
'settings.terminal.localShell.shell.detected': 'Обнаружено',
'settings.terminal.localShell.shell.notFound': 'Исполняемый файл оболочки не найден',
'settings.terminal.localShell.shell.isDirectory': 'Путь указывает на каталог, а не на исполняемый файл',
'settings.terminal.localShell.shell.default': 'Системная по умолчанию',
'settings.terminal.localShell.shell.custom': 'Пользовательская...',
'settings.terminal.localShell.shell.customPath': 'Путь к исполняемому файлу оболочки',
'settings.terminal.localShell.shell.customArgs': 'Аргументы запуска',
'settings.terminal.localShell.shell.customArgs.placeholder': 'напр. --login -i',
'settings.terminal.localShell.shell.customArgs.desc': 'Аргументы, передаваемые оболочке. Некоторым оболочкам они необходимы — например, msys2 bash требует --login -i для загрузки окружения.',
'settings.terminal.localShell.shell.commonPaths': 'Частые пути',
'settings.terminal.localShell.shell.pathValid': 'Путь корректен',
'settings.terminal.localShell.startDir': 'Начальный каталог',
'settings.terminal.localShell.startDir.desc': 'Каталог, в котором будет открываться локальный терминал. Оставьте пустым, чтобы использовать домашний каталог.',
'settings.terminal.localShell.startDir.placeholder': 'Домашний каталог',
'settings.terminal.localShell.startDir.notFound': 'Каталог не найден',
'settings.terminal.localShell.startDir.isFile': 'Путь указывает на файл, а не на каталог',
'settings.terminal.section.connection': 'Подключение',
'settings.terminal.connection.keepaliveInterval': 'Интервал keepalive',
'settings.terminal.connection.keepaliveInterval.desc': 'Как часто (в секундах) отправлять keepalive-пакеты на уровне SSH. Установите 0, чтобы отключить глобально. Учтите, что отдельные хосты могут переопределять это значение в своих настройках.',
'settings.terminal.connection.keepaliveCountMax': 'Макс. число пропущенных keepalive',
'settings.terminal.connection.keepaliveCountMax.desc': 'Количество пропущенных keepalive, после которого соединение считается мёртвым. Более высокие значения лучше переносят краткие сетевые сбои и медленные ответы SSH-серверов.',
'settings.terminal.connection.x11Display': 'Дисплей X11',
'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': 'Как часто обновлять список процессов в боковой панели системного менеджера.',
'settings.terminal.systemManager.tmuxRefreshInterval': 'Обновление сессий tmux',
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'Как часто обновлять список сессий tmux.',
'settings.terminal.systemManager.dockerListRefreshInterval': 'Обновление списка контейнеров Docker',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Как часто обновлять список контейнеров Docker.',
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Обновление статистики Docker',
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Как часто обновлять CPU/память/сеть контейнеров Docker.',
'settings.terminal.serverStats.show': 'Показывать статистику сервера',
'settings.terminal.serverStats.show.desc': 'Показывать загрузку CPU, памяти и диска в строке состояния терминала (только для Linux-серверов).',
'settings.terminal.serverStats.refreshInterval': 'Интервал обновления',
'settings.terminal.serverStats.refreshInterval.desc': 'Как часто обновлять статистику сервера.',
'settings.terminal.serverStats.seconds': 'секунд',
// Settings > Terminal > Rendering
'settings.terminal.section.rendering': 'Рендеринг',
'settings.terminal.rendering.renderer': 'Рендерер',
'settings.terminal.rendering.renderer.desc': 'Выберите технологию рендеринга терминала. В режиме "Авто" на устройствах с малым объёмом памяти будет использоваться DOM. Изменения применяются к новым терминальным сессиям.',
'settings.terminal.rendering.auto': 'Авто',
// Settings > Terminal > Workspace Focus Indicator
'settings.terminal.section.workspaceFocus': 'Индикатор фокуса рабочей области',
'settings.terminal.workspaceFocus.style': 'Стиль индикатора фокуса',
'settings.terminal.workspaceFocus.style.desc': 'Как показывать, какая панель активна в режиме разделённого вида.',
'settings.terminal.workspaceFocus.dim': 'Затемнять неактивные панели',
'settings.terminal.workspaceFocus.border': 'Рамка вокруг активной панели',
// Settings > Terminal > Autocomplete
'settings.terminal.section.autocomplete': 'Автодополнение',
'settings.terminal.autocomplete.enabled': 'Включить автодополнение',
'settings.terminal.autocomplete.enabled.desc': 'Показывать подсказки команд на основе истории и описаний команд во время ввода.',
'settings.terminal.autocomplete.ghostText': 'Призрачный текст',
'settings.terminal.autocomplete.ghostText.desc': 'Показывать серую встроенную подсказку после курсора (как в fish shell).',
'settings.terminal.autocomplete.popupMenu': 'Всплывающее меню',
'settings.terminal.autocomplete.popupMenu.desc': 'Показывать плавающий список из нескольких подсказок.',
// Settings > Shortcuts
'settings.shortcuts.section.scheme': 'Схема горячих клавиш',
'settings.shortcuts.scheme.label': 'Сочетания клавиш',
'settings.shortcuts.scheme.desc': 'Выберите раскладку клавиш для использования в сочетаниях',
'settings.shortcuts.scheme.disabled': 'Отключено',
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
'settings.shortcuts.disableTerminalFontZoom.label': 'Отключить масштаб терминала',
'settings.shortcuts.disableTerminalFontZoom.desc': 'Отключает быстрый масштаб текста в терминале, включая Cmd/Ctrl + колесо мыши.',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Сочетания без закреплённых вкладок',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'Если включено, Cmd/Ctrl+[1...9] и Ctrl+Tab переключают только рабочие вкладки (терминалы, рабочие области, редакторы), а не закреплённые Vault и SFTP.',
'settings.shortcuts.section.custom': 'Пользовательские сочетания',
'settings.shortcuts.resetAll': 'Сбросить все',
'settings.shortcuts.recording': 'Нажмите клавиши...',
'settings.shortcuts.none': 'Нет',
'settings.shortcuts.setDisabled': 'Отключить',
'settings.shortcuts.category.tabs': 'Вкладки',
'settings.shortcuts.category.terminal': 'Терминал',
'settings.shortcuts.category.navigation': 'Навигация',
'settings.shortcuts.category.app': 'Приложение',
'settings.shortcuts.category.sftp': 'SFTP',
// Settings > Shortcuts -> key bings
'settings.shortcuts.binding.switch-tab-1-9': 'Переключиться на вкладку [1...9]',
'settings.shortcuts.binding.next-tab': 'Следующая вкладка',
'settings.shortcuts.binding.prev-tab': 'Предыдущая вкладка',
'settings.shortcuts.binding.close-tab': 'Закрыть вкладку',
'settings.shortcuts.binding.close-session': 'Закрыть панель сессии',
'settings.shortcuts.binding.new-tab': 'Новая локальная вкладка',
'settings.shortcuts.binding.copy': 'Копировать из терминала',
'settings.shortcuts.binding.paste': 'Вставить в терминал',
'settings.shortcuts.binding.paste-selection': 'Вставить выделение в терминал',
'settings.shortcuts.binding.select-all': 'Выделить всё содержимое терминала',
'settings.shortcuts.binding.clear-buffer': 'Очистить буфер терминала',
'settings.shortcuts.binding.search-terminal': 'Открыть поиск по терминалу',
'settings.shortcuts.binding.increase-terminal-font-size': 'Увеличить размер шрифта терминала',
'settings.shortcuts.binding.decrease-terminal-font-size': 'Уменьшить размер шрифта терминала',
'settings.shortcuts.binding.reset-terminal-font-size': 'Сбросить размер шрифта терминала',
'settings.shortcuts.binding.move-focus': 'Переместить фокус между разделёнными окнами',
'settings.shortcuts.binding.split-horizontal': 'Горизонтальное разделение',
'settings.shortcuts.binding.split-vertical': 'Вертикальное разделение',
'settings.shortcuts.binding.toggle-pane-zoom': 'Переключить масштаб панели',
'settings.shortcuts.binding.open-hosts': 'Открыть список хостов',
'settings.shortcuts.binding.open-local': 'Открыть локальный терминал',
'settings.shortcuts.binding.open-sftp': 'Открыть SFTP',
'settings.shortcuts.binding.open-settings': 'Открыть настройки',
'settings.shortcuts.binding.port-forwarding': 'Открыть перенаправление портов',
'settings.shortcuts.binding.command-palette': 'Открыть палитру команд',
'settings.shortcuts.binding.quick-switch': 'Быстрое переключение',
'settings.shortcuts.binding.new-workspace': 'Новая рабочая область',
'settings.shortcuts.binding.snippets': 'Открыть сниппеты',
'settings.shortcuts.binding.broadcast': 'Переключить режим трансляции',
'settings.shortcuts.binding.toggle-side-panel': 'Переключить боковую панель',
'settings.shortcuts.binding.sftp-copy': 'Копировать файл',
'settings.shortcuts.binding.sftp-cut': 'Вырезать файл',
'settings.shortcuts.binding.sftp-paste': 'Вставить файл',
'settings.shortcuts.binding.sftp-select-all': 'Выделить все файлы',
'settings.shortcuts.binding.sftp-rename': 'Переименовать файл',
'settings.shortcuts.binding.sftp-delete': 'Удалить файл',
'settings.shortcuts.binding.sftp-refresh': 'Обновить',
'settings.shortcuts.binding.sftp-new-folder': 'Создать новую папку',
'settings.shortcuts.binding.sftp-open': 'Открыть файл / Войти в директорию',
'settings.shortcuts.binding.sftp-go-parent': 'Перейти в родительскую директорию',
'settings.shortcuts.binding.sftp-navigate-to': 'Перейти в выбранную директорию',
// Context menus / common actions
'action.newHost': 'Новый хост',
'action.newSubfolder': 'Новая подпапка',
'action.copyPublicKey': 'Копировать публичный ключ',
'action.keyExport': 'Экспорт ключа',
'action.edit': 'Редактировать',
'action.delete': 'Удалить',
'action.duplicate': 'Дублировать',
'action.open': 'Открыть',
'action.copy': 'Копировать',
'action.run': 'Запустить',
'action.start': 'Старт',
'action.stop': 'Остановить',
'action.remove': 'Убрать',
'action.convertToHost': 'Преобразовать в хост',
// Sync
'sync.cloudSync': 'Облачная синхронизация',
'sync.settings': 'Настройки синхронизации',
'sync.active': 'Облачная синхронизация активна',
'sync.pending': 'Есть несинхронизированные изменения',
'sync.syncing': 'Синхронизация...',
'sync.error': 'Ошибка синхронизации',
'sync.notConfigured': 'Не настроено',
'sync.failed': 'Синхронизация не удалась',
'sync.connected': 'Подключено',
'sync.syncNow': 'Синхронизировать сейчас',
'sync.recentActivity': 'Недавняя активность',
'sync.history.uploaded': 'Загружено',
'sync.history.downloaded': 'Скачано',
'sync.history.resolved': 'Разрешено',
'sync.toast.completedMessage': 'Синхронизация успешно завершена',
'sync.toast.errorTitle': 'Ошибка синхронизации',
'sync.autoSync.failedTitle': 'Синхронизация не удалась',
'sync.autoSync.inspectFailedTitle': 'Синхронизация приостановлена',
'sync.autoSync.inspectFailedMessage': 'Не удалось подключиться к облаку для проверки изменений. Автосинхронизация повторит попытку при изменении данных или после перезапуска приложения.',
'sync.autoSync.syncedTitle': 'Синхронизировано из облака',
'sync.autoSync.syncedMessage': 'Ваши данные были обновлены из облака.',
'sync.autoSync.noProvider': 'Облачный провайдер не подключён. Откройте Настройки → Синхронизация и облако, чтобы подключить его.',
'sync.autoSync.alreadySyncing': 'Синхронизация уже выполняется.',
'sync.autoSync.restoreInProgress': 'В другом окне уже выполняется восстановление хранилища. Подождите, пока оно завершится.',
'sync.autoSync.interruptedApplyTitle': 'Синхронизация приостановлена — предыдущее восстановление прервано',
'sync.autoSync.interruptedApplyMessage': 'Предыдущее восстановление завершилось некорректно, поэтому локальное хранилище может быть в несогласованном состоянии. Откройте Настройки → Синхронизация и облако → Восстановление и примените защитную резервную копию перед возобновлением автосинхронизации.',
'sync.autoSync.vaultLocked': 'Хранилище заблокировано. Откройте Настройки → Синхронизация и облако, чтобы разблокировать его.',
'sync.autoSync.conflictDetected': 'Обнаружен конфликт синхронизации. Откройте Настройки → Синхронизация и облако, чтобы разрешить его.',
'sync.autoSync.syncFailed': 'Синхронизация не удалась',
'sync.autoSync.restoredTitle': 'Хранилище восстановлено',
'sync.autoSync.restoredMessage': 'Ваше хранилище было восстановлено из облака.',
'sync.autoSync.keptLocalTitle': 'Локальное хранилище сохранено',
'sync.autoSync.keptLocalMessage': 'Ваше пустое локальное хранилище было сохранено. Облачные данные не применялись.',
'sync.autoSync.emptyVaultConflict.title': 'Обнаружено пустое хранилище',
'sync.autoSync.emptyVaultConflict.description': 'Ваше локальное хранилище пусто, но в облаке есть данные. Обычно это происходит после обновления или сброса хранилища. Что вы хотите сделать?',
'sync.autoSync.emptyVaultConflict.cloudLabel': 'Облако',
'sync.autoSync.emptyVaultConflict.restore': 'Восстановить из облака',
'sync.autoSync.emptyVaultConflict.restoreDesc': 'Рекомендуется — восстановить ваши хосты, ключи и сниппеты из облачной резервной копии',
'sync.autoSync.emptyVaultConflict.keepEmpty': 'Оставить пустым',
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': 'Начать заново с пустым хранилищем',
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} хостов, {keys} ключей, {snippets} сниппетов, {notes} заметок, {proxyProfiles} прокси',
'sync.autoSync.emptyVaultManual': 'Синхронизация невозможна: локальное хранилище пусто. Сначала восстановите его из локальной резервной копии или включите принудительную отправку в панели синхронизации.',
'sync.blocked.title': 'Синхронизация приостановлена',
'sync.blocked.reason.bulkShrink': 'Будет удалено {lost} из {baseCount} сущностей типа {entityType} из облака (сокращение на {percent}%).',
'sync.blocked.reason.largeShrink': 'Будет удалено {lost} сущностей типа {entityType} из облака.',
'sync.blocked.detail': 'Обычно это вызвано повреждённым локальным состоянием (сбой keychain, частичная загрузка данных). Восстановите данные из локальной резервной копии или выполните принудительную отправку, если вы действительно хотели удалить эти записи.',
'sync.blocked.restoreButton': 'Восстановить из локальной резервной копии',
'sync.blocked.forcePushButton': 'Всё равно отправить принудительно',
'sync.forcePush.title': 'Подтвердите принудительную отправку',
'sync.forcePush.body': 'Вы собираетесь удалить {lost} сущностей типа {entityType} из облака. Это действие нельзя отменить. Продолжить?',
'sync.forcePush.confirm': 'Да, всё равно отправить',
'sync.forcePush.cancel': 'Отмена',
'sync.entityType.hosts': 'хостов',
'sync.entityType.keys': 'ключей',
'sync.entityType.identities': 'идентификаторов',
'sync.entityType.proxyProfiles': 'профилей прокси',
'sync.entityType.snippets': 'сниппетов',
'sync.entityType.customGroups': 'групп',
'sync.entityType.snippetPackages': 'пакетов сниппетов',
'sync.entityType.knownHosts': 'записей known_hosts',
'sync.entityType.portForwardingRules': 'правил проброса портов',
'sync.entityType.groupConfigs': 'конфигураций групп',
'sync.credentialsUnavailable': 'Это устройство не может расшифровать некоторые сохранённые учётные данные. Перед синхронизацией повторно введите их локально.',
'time.never': 'Никогда',
'time.justNow': 'Только что',
'time.minutesAgo': '{minutes} мин назад',
// Vault navigation
'vault.nav.hosts': 'Хосты',
'vault.nav.keychain': 'Связка ключей',
'vault.nav.proxies': 'Прокси',
'vault.nav.portForwarding': 'Проброс портов',
'vault.nav.snippets': 'Сниппеты',
'vault.nav.notes': 'Заметки',
'vault.nav.knownHosts': 'Известные хосты',
'vault.nav.logs': 'Журналы',
'proxyProfiles.action.add': 'Добавить прокси',
'proxyProfiles.search.placeholder': 'Поиск прокси…',
'proxyProfiles.section.proxies': 'Прокси',
'proxyProfiles.count.items': 'Элементов: {count}',
'proxyProfiles.empty.title': 'Нет прокси',
'proxyProfiles.empty.desc': 'Создавайте переиспользуемые HTTP-, SOCKS5- или командные прокси и выбирайте их в настройках хоста.',
'proxyProfiles.usage': 'Связано: {count}',
'proxyProfiles.copyName': '{name} Копия',
'proxyProfiles.panel.newTitle': 'Новый прокси',
'proxyProfiles.field.name': 'Имя прокси',
'proxyProfiles.error.required': 'Имя и параметры прокси обязательны.',
'proxyProfiles.error.port': 'Порт должен быть в диапазоне от 1 до 65535.',
'proxyProfiles.viewMode': 'Режим просмотра прокси',
'proxyProfiles.delete.title': 'Удалить прокси?',
'proxyProfiles.delete.desc': 'Удаление "{name}" отвяжет его от {count} настроек хостов или групп.',
'vault.groups.title': 'Группы',
'vault.groups.total': 'Всего: {count}',
'vault.groups.hostsCount': 'Хостов: {count}',
'vault.groups.newSubgroup': 'Новая подгруппа',
'vault.groups.rename': 'Переименовать группу',
'vault.groups.delete': 'Удалить группу',
'vault.groups.createSubfolder': 'Создать подпапку',
'vault.groups.createRoot': 'Создать корневую группу',
'vault.groups.createDialog.desc': 'Создайте новую группу для организации хостов.',
'vault.groups.renameDialogTitle': 'Переименовать группу',
'vault.groups.renameDialog.desc': 'Переименуйте существующую группу.',
'vault.groups.deleteDialogTitle': 'Удалить группу',
'vault.groups.deleteDialog.desc': 'Группа будет безвозвратно удалена, а все хосты будут перемещены в корень.',
'vault.groups.deleteDialog.managedDesc': 'Это управляемая группа SSH-конфига. При её удалении также будут удалены все хосты и снята связь с исходным файлом.',
'vault.groups.deleteDialog.deleteHosts': 'Также удалить все хосты в этой группе',
'vault.groups.ungrouped': 'Без группы',
'vault.groups.field.name': 'Имя группы',
'vault.groups.placeholder.example': 'например, Production',
'vault.groups.parentLabel': 'Родитель',
'vault.groups.pathLabel': 'Путь',
'vault.groups.settings': 'Настройки группы',
'vault.groups.details': 'Сведения о группе',
'vault.groups.details.general': 'Общие',
'vault.groups.details.ssh': 'SSH',
'vault.groups.details.telnet': 'Telnet',
'vault.groups.details.advanced': 'Дополнительно',
'vault.groups.details.appearance': 'Внешний вид',
'vault.groups.details.mosh': 'Mosh',
'vault.groups.details.parentGroup': 'Родительская группа',
'vault.groups.details.none': 'Нет',
'vault.groups.details.inherited': 'Унаследовано от группы',
'vault.groups.details.addProtocol': 'Добавить протокол',
'vault.groups.details.removeProtocol': 'Удалить протокол',
'vault.groups.details.fontFamily': 'Семейство шрифта',
'vault.groups.details.fontSize': 'Размер шрифта',
'vault.groups.errors.required': 'Имя группы обязательно.',
'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}}',
};
@@ -0,0 +1,209 @@
import type { Messages } from '../types';
export const ruSystemManagerMessages: Messages = {
'terminal.layer.system': 'Система',
'systemManager.noSession': 'Нет активного терминального сеанса.',
'systemManager.notConnected': 'Подключитесь к хосту для управления процессами и сервисами.',
'systemManager.empty': 'Нет данных.',
'systemManager.tabs.overview': 'Обзор',
'systemManager.tabs.processes': 'Процессы',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.popup.loading': 'Открытие терминала…',
'systemManager.popup.startupFailed': 'Команда запуска не была выполнена успешно. Проверьте, что цель доступна, и повторите попытку.',
'systemManager.errors.loadProcesses': 'Не удалось загрузить процессы',
'systemManager.errors.loadTmux': 'Не удалось загрузить сессии tmux',
'systemManager.errors.loadTmuxWindows': 'Не удалось загрузить окна tmux',
'systemManager.errors.loadTmuxPanes': 'Не удалось загрузить панели tmux',
'systemManager.errors.loadTmuxClients': 'Не удалось загрузить клиентов tmux',
'systemManager.errors.actionFailed': 'Не удалось выполнить действие',
'systemManager.errors.loadDocker': 'Не удалось загрузить контейнеры',
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы',
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
'systemManager.overview.empty': 'Данных обзора пока нет.',
'systemManager.overview.loading': 'Загрузка обзора системы…',
'systemManager.overview.memory': 'Память',
'systemManager.overview.disk': 'Диск',
'systemManager.overview.network': 'Сеть',
'systemManager.overview.rx': 'RX',
'systemManager.overview.tx': 'TX',
'systemManager.overview.cores': '{{count}} ядер',
'systemManager.overview.load': 'Нагрузка',
'systemManager.overview.uptime': 'Время работы',
'systemManager.overview.duration.daysHours': '{{days}} д {{hours}} ч',
'systemManager.overview.duration.hoursMinutes': '{{hours}} ч {{minutes}} мин',
'systemManager.overview.duration.minutes': '{{minutes}} мин',
'systemManager.overview.system': 'Система',
'systemManager.overview.kernel': 'Ядро',
'systemManager.overview.swap': 'Swap',
'systemManager.overview.latency': 'Сетевая задержка SSH',
'systemManager.overview.cpuCores': 'Ядра CPU',
'systemManager.overview.disks': 'Диски',
'systemManager.overview.interfaces': 'Сетевые интерфейсы',
'systemManager.overview.topProcesses': 'Процессы по памяти',
'systemManager.overview.noData': 'Нет данных',
'systemManager.overview.noDisks': 'Нет данных дисков',
'systemManager.overview.noInterfaces': 'Нет данных интерфейсов',
'systemManager.overview.noTopProcesses': 'Нет данных процессов',
'systemManager.processes.search': 'Поиск процессов…',
'systemManager.processes.command': 'Команда',
'systemManager.processes.user': 'Пользователь',
'systemManager.processes.term': 'Завершить',
'systemManager.processes.kill': 'Убить',
'systemManager.processes.stop': 'Остановить (SIGSTOP)',
'systemManager.processes.cont': 'Продолжить (SIGCONT)',
'systemManager.processes.hup': 'Сигнал SIGHUP',
'systemManager.processes.renice': 'Renice',
'systemManager.processes.renicePrompt': 'Значение nice (-20 до 19)',
'systemManager.processes.reniceInvalid': 'Nice должно быть от -20 до 19',
'systemManager.processes.confirmKill': 'Отправить SIGKILL процессу {{pid}}?',
'systemManager.processes.confirmSignal': 'Отправить SIG{{signal}} процессу {{pid}}?',
'systemManager.processes.filter.all': 'Все',
'systemManager.processes.filter.running': 'Активные',
'systemManager.processes.ppid': 'Родительский PID',
'systemManager.processes.rss': 'RSS',
'systemManager.processes.vsz': 'Виртуальный размер',
'systemManager.processes.elapsed': 'Время работы',
'systemManager.processes.stat': 'Состояние',
'systemManager.processes.meta': '{{count}} проц.',
'systemManager.processes.loading': 'Загрузка процессов…',
'systemManager.processes.loadingMore': 'Загрузка следующих процессов…',
'systemManager.processes.state.running': 'Активен',
'systemManager.processes.state.sleeping': 'Сон',
'systemManager.processes.state.stopped': 'Остановлен',
'systemManager.processes.state.zombie': 'Зомби',
'systemManager.processes.sort.cpu': 'CPU',
'systemManager.processes.sort.mem': 'Память',
'systemManager.processes.sort.pid': 'PID',
'systemManager.processes.sort.command': 'Команда',
'systemManager.processes.sort.user': 'Пользователь',
'systemManager.common.dismiss': 'Закрыть',
'systemManager.common.checkingAvailability': 'Проверка доступности…',
'systemManager.common.loading': 'Загрузка…',
'systemManager.common.loadingDetails': 'Загрузка деталей…',
'systemManager.common.loadingStats': 'Загрузка статистики…',
'systemManager.tmux.new': 'Создать',
'systemManager.tmux.search': 'Поиск сессий…',
'systemManager.tmux.newSessionTitle': 'Новая сессия tmux',
'systemManager.tmux.newSessionDesc': 'Задайте имя сессии и при необходимости команду запуска.',
'systemManager.tmux.newSessionTabCustom': 'Своя команда',
'systemManager.tmux.newSessionTabSnippet': 'Из сниппета',
'systemManager.tmux.pickSnippet': 'Из сниппетов',
'systemManager.tmux.pickSnippetEmpty': 'Сниппетов пока нет — добавьте их на панели скриптов или в хранилище.',
'systemManager.tmux.selectedSnippet': 'Выбран сниппет: {{label}}',
'systemManager.tmux.newSessionName': 'Имя сессии',
'systemManager.tmux.newSessionCommand': 'Команда запуска',
'systemManager.tmux.newSessionCommandPlaceholder': 'например htop или npm run dev (необяз.)',
'systemManager.tmux.newSessionCommandHint': 'Оставьте пустым для сессии с shell по умолчанию.',
'systemManager.tmux.creating': 'Создание…',
'systemManager.tmux.newSessionPlaceholder': 'my-session',
'systemManager.tmux.newSessionRequired': 'Сначала введите имя сессии',
'systemManager.tmux.empty': 'Нет сессий tmux',
'systemManager.tmux.attach': 'Подключить',
'systemManager.tmux.attached': 'Подключена',
'systemManager.tmux.detached': 'Отключена',
'systemManager.tmux.windows': '{{count}} окон',
'systemManager.tmux.created': 'Создана',
'systemManager.tmux.activity': 'Активность',
'systemManager.tmux.rename': 'Переименовать',
'systemManager.tmux.detach': 'Отключить всех',
'systemManager.tmux.killSession': 'Завершить сессию',
'systemManager.tmux.killServer': 'Остановить сервер',
'systemManager.tmux.loadingDetails': 'Загрузка деталей…',
'systemManager.tmux.clients': 'Подключённые клиенты',
'systemManager.tmux.windowList': 'Окна',
'systemManager.tmux.newWindow': 'Новое окно',
'systemManager.tmux.newWindowPlaceholder': 'Имя окна (необязательно)',
'systemManager.tmux.noWindows': 'Нет окон',
'systemManager.tmux.unavailable': 'tmux недоступен на этом хосте',
'systemManager.docker.unavailable': 'Docker недоступен на этом хосте',
'systemManager.tmux.windowsMismatch': 'В сессии указано {{count}} окон, но list-windows ничего не вернул',
'systemManager.tmux.lastCommand': 'последняя команда: {{command}}',
'systemManager.tmux.noPanes': 'Нет панелей',
'systemManager.tmux.panes': '{{count}} пан.',
'systemManager.tmux.active': 'активно',
'systemManager.tmux.unnamedWindow': 'Безымянное окно',
'systemManager.tmux.unnamedPane': 'Безымянная панель',
'systemManager.tmux.attachWindow': 'Подключить к окну',
'systemManager.tmux.selectWindow': 'Выбрать окно',
'systemManager.tmux.killWindow': 'Закрыть окно',
'systemManager.tmux.killPane': 'Закрыть панель',
'systemManager.tmux.splitHorizontal': 'Разделить горизонтально',
'systemManager.tmux.splitVertical': 'Разделить вертикально',
'systemManager.tmux.sendKeys': 'Отправить клавиши',
'systemManager.tmux.sendKeysTo': 'Отправить клавиши в окно {{window}} панель {{pane}}',
'systemManager.tmux.sendKeysPlaceholder': 'Команда или текст…',
'systemManager.tmux.renameSessionPrompt': 'Переименовать сессию',
'systemManager.tmux.renameWindowPrompt': 'Переименовать окно',
'systemManager.tmux.windowName': 'Имя окна',
'systemManager.tmux.confirmKillSession': 'Завершить сессию tmux «{{name}}»?',
'systemManager.tmux.confirmDetachSession': 'Отключить всех клиентов от «{{name}}»?',
'systemManager.tmux.confirmKillWindow': 'Закрыть окно «{{name}}»?',
'systemManager.tmux.confirmKillPane': 'Закрыть панель #{{index}}?',
'systemManager.tmux.confirmKillServer': 'Остановить сервер tmux? Все сессии будут завершены.',
'systemManager.tmux.meta': '{{count}} сессий',
'systemManager.docker.title': 'Контейнеры',
'systemManager.docker.subTabs.containers': 'Контейнеры',
'systemManager.docker.subTabs.images': 'Образы',
'systemManager.docker.empty': 'Контейнеры не найдены',
'systemManager.docker.imagesEmpty': 'Образы не найдены',
'systemManager.docker.search': 'Поиск контейнеров…',
'systemManager.docker.searchImages': 'Поиск образов…',
'systemManager.docker.filter.all': 'Все',
'systemManager.docker.filter.running': 'Запущены',
'systemManager.docker.filter.stopped': 'Остановлены',
'systemManager.docker.filter.paused': 'На паузе',
'systemManager.docker.shell': 'Shell',
'systemManager.docker.logs': 'Логи',
'systemManager.docker.details': 'Детали',
'systemManager.docker.inspect': 'Inspect',
'systemManager.docker.imageInspect': 'Inspect образа',
'systemManager.docker.confirmRemove': 'Удалить этот контейнер?',
'systemManager.docker.confirmKill': 'Принудительно завершить контейнер?',
'systemManager.docker.confirmRemoveImage': 'Удалить образ «{{name}}»?',
'systemManager.docker.confirmPrune': 'Удалить dangling-образы?',
'systemManager.docker.confirmPruneAll': 'Удалить все неиспользуемые образы?',
'systemManager.docker.pause': 'Пауза',
'systemManager.docker.unpause': 'Возобновить',
'systemManager.docker.restart': 'Перезапустить',
'systemManager.docker.kill': 'Kill',
'systemManager.docker.renamePrompt': 'Имя контейнера',
'systemManager.docker.prune': 'Prune',
'systemManager.docker.pruneAll': 'Prune all',
'systemManager.docker.tag': 'Tag',
'systemManager.docker.tagRepoPrompt': 'Имя репозитория',
'systemManager.docker.tagNamePrompt': 'Имя тега',
'systemManager.docker.meta': '{{count}} конт.',
'systemManager.docker.imagesMeta': '{{count}} образов',
'systemManager.docker.start': 'Запустить',
'systemManager.docker.stop': 'Остановить',
'systemManager.inspect.status': 'Статус',
'systemManager.inspect.image': 'Образ',
'systemManager.inspect.created': 'Создан',
'systemManager.inspect.started': 'Запущен',
'systemManager.inspect.restartPolicy': 'Перезапуск',
'systemManager.inspect.command': 'Команда',
'systemManager.inspect.ports': 'Порты',
'systemManager.inspect.networks': 'Сети',
'systemManager.inspect.mounts': 'Тома',
'systemManager.inspect.env': 'Окружение',
'systemManager.inspect.labels': 'Метки',
'systemManager.inspect.tags': 'Теги',
'systemManager.inspect.digests': 'Дайджесты',
'systemManager.inspect.size': 'Размер',
'systemManager.inspect.platform': 'Платформа',
'systemManager.inspect.workdir': 'Рабочий каталог',
'systemManager.inspect.exposedPorts': 'Открытые порты',
'systemManager.inspect.showRaw': 'JSON',
'systemManager.inspect.hideRaw': 'Скрыть JSON',
};
+787
View File
@@ -0,0 +1,787 @@
import type { Messages } from '../types';
export const ruTerminalMessages: Messages = {
'terminal.sudoHint.pressEnter': 'Нажмите Enter, чтобы вставить пароль sudo',
// Connection logs
'logs.table.date': 'Дата',
'logs.table.user': 'Пользователь',
'logs.table.host': 'Хост',
'logs.table.saved': 'Сохранено',
'logs.empty.title': 'Нет журналов подключений',
'logs.empty.desc':
'История ваших подключений будет отображаться здесь, когда вы подключаетесь к хостам или открываете локальные терминалы.',
'logs.loadMore': 'Загрузить ещё {count} журналов',
'logs.ongoing': 'в процессе',
'logs.localTerminal': 'Локальный терминал',
'logs.action.save': 'Сохранить',
'logs.action.unsave': 'Убрать из сохранённых',
'logs.action.delete': 'Удалить',
// Log view
'logView.customizeAppearance': 'Настроить внешний вид',
'logView.appearance': 'Внешний вид',
'logView.readOnly': 'Только чтение',
'logView.export': 'Экспорт',
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': 'Открыть SFTP',
'terminal.toolbar.availableAfterConnect': 'Доступно после подключения',
'terminal.toolbar.sendYmodem': 'Отправить через YMODEM',
'terminal.toolbar.receiveYmodem': 'Получить через YMODEM',
'terminal.toolbar.sftp': 'SFTP',
'terminal.toolbar.more': 'Другие действия',
'terminal.toolbar.scripts': 'Скрипты',
'terminal.toolbar.history': 'История команд',
'terminal.toolbar.configureOsc7': 'Настроить отслеживание каталога',
'history.scope.label': 'Область истории',
'history.tab.host': 'Хост',
'history.tab.global': 'Глобальная',
'history.searchPlaceholder': 'Поиск по истории...',
'history.loading': 'Загрузка удалённой истории...',
'history.meta.count': '{count} команд',
'history.empty.noSession': 'Откройте удалённую сессию, чтобы просмотреть историю команд.',
'history.empty.unsupportedProtocol': 'История команд доступна только для сессий SSH/Mosh/ET.',
'history.empty.noHistory': 'История команд на этом хосте не найдена.',
'history.empty.noGlobalHistory': 'Глобальной истории команд пока нет. Выполненные команды появятся здесь.',
'history.action.refresh': 'Обновить',
'history.action.retry': 'Повторить',
'history.action.paste': 'Вставить в терминал',
'history.action.run': 'Выполнить в терминале',
'history.action.saveAsSnippet': 'Сохранить как сниппет',
'terminal.toolbar.library': 'Библиотека',
'terminal.toolbar.noSnippets': 'Нет доступных сниппетов',
'terminal.toolbar.terminalSettings': 'Настройки терминала',
'terminal.toolbar.searchTerminal': 'Поиск по терминалу',
'terminal.toolbar.search': 'Поиск',
'terminal.toolbar.startSessionLog': 'Начать журнал сессии',
'terminal.toolbar.stopSessionLog': 'Остановить журнал сессии',
'terminal.toolbar.timestampsEnable': 'Показать время',
'terminal.toolbar.timestampsDisable': 'Скрыть время',
'terminal.toolbar.broadcast': 'Трансляция',
'terminal.toolbar.broadcastEnable': 'Включить режим трансляции',
'terminal.toolbar.broadcastDisable': 'Отключить режим трансляции',
'terminal.toolbar.composeBar': 'Строка ввода',
'terminal.composeBar.placeholder': 'Введите команду здесь и нажмите Enter для отправки...',
'terminal.composeBar.send': 'Отправить',
'terminal.composeBar.close': 'Закрыть строку ввода',
'terminal.composeBar.broadcasting': 'Трансляция во все сессии',
'terminal.composeBar.resize': 'Изменить высоту строки ввода',
'terminal.composeBar.manageSnippets': 'Управление быстрыми сниппетами',
'terminal.composeBar.searchSnippets': 'Поиск сниппетов...',
'terminal.composeBar.noPinnedSnippets': 'Закрепите сниппеты через + для быстрого доступа',
'terminal.composeBar.noMatchingSnippets': 'Сниппеты не найдены',
'terminal.composeBar.pinnedCount': 'Закреплено: {count}',
'terminal.composeBar.unpinSnippet': 'Убрать {label} из панели',
'terminal.composeBar.snippetClickHint': 'Клик — вставить · Shift+клик — отправить',
'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',
'terminal.toolbar.closeSession': 'Закрыть сессию',
'terminal.toolbar.hostHighlight.title': 'Подсветка ключевых слов хоста',
'terminal.toolbar.hostHighlight.noRules': 'Для этого хоста не задано пользовательских правил подсветки',
'terminal.toolbar.hostHighlight.addRule': 'Добавить новое правило',
'terminal.toolbar.hostHighlight.labelPlaceholder': 'Метка (например, Error)',
'terminal.toolbar.hostHighlight.patternPlaceholder': 'Regex-шаблон (например, \\bfailed\\b)',
'terminal.toolbar.hostHighlight.invalidPattern': 'Некорректный regex-шаблон',
'terminal.toolbar.hostHighlight.clearAll': 'Очистить все',
'terminal.toolbar.hostHighlight.changeColor': 'Изменить цвет подсветки для',
'terminal.toolbar.hostHighlight.selectColor': 'Выбрать цвет для нового правила',
'terminal.statusbar.copyHostname.label': 'Копировать адрес хоста',
'terminal.statusbar.copyHostname.tooltip': 'Копировать адрес хоста ({hostname})',
'terminal.statusbar.copyHostname.toast': 'Адрес хоста скопирован: {hostname}',
'terminal.statusbar.copyHostname.error': 'Не удалось скопировать адрес хоста в буфер обмена',
'terminal.serverStats.cpu': 'Использование CPU',
'terminal.serverStats.cpuCores': 'Использование ядер CPU',
'terminal.serverStats.memory': 'Использование памяти',
'terminal.serverStats.memoryDetails': 'Сведения о памяти',
'terminal.serverStats.memUsed': 'Использовано',
'terminal.serverStats.memBuffers': 'Буферы',
'terminal.serverStats.memCached': 'Кэш',
'terminal.serverStats.memFree': 'Свободно',
'terminal.serverStats.swap': 'Swap',
'terminal.serverStats.swapUsed': 'Использовано swap',
'terminal.serverStats.swapFree': 'Свободный swap',
'terminal.serverStats.swapTotal': 'Всего',
'terminal.serverStats.topProcesses': 'Топ процессов по памяти',
'terminal.serverStats.disk': 'Использование диска (корень)',
'terminal.serverStats.diskDetails': 'Смонтированные диски',
'terminal.serverStats.network': 'Скорость сети',
'terminal.serverStats.latency': 'Сетевая задержка SSH',
'terminal.serverStats.networkDetails': 'Сетевые интерфейсы',
'terminal.serverStats.noData': 'Данные недоступны',
'terminal.dragDrop.localTitle': 'Перетащите для вставки путей',
'terminal.dragDrop.localMessage': 'Пути к файлам будут вставлены в терминал',
'terminal.dragDrop.remoteTitle': 'Перетащите для загрузки файлов',
'terminal.dragDrop.remoteZmodemMessage': 'Файлы будут загружены через ZMODEM (PTY)',
'terminal.dragDrop.remoteSftpMessage': 'Файлы будут загружены через SFTP',
'terminal.dragDrop.noFiles': 'Нет файлов для загрузки',
'terminal.dragDrop.notConnected': 'Нельзя перетащить файлы — терминал не подключён',
'terminal.dragDrop.errorTitle': 'Ошибка перетаскивания',
'terminal.dragDrop.errorMessage': 'Не удалось обработать перетащенные файлы',
'terminal.search.placeholder': 'Поиск...',
'terminal.search.noResults': 'Ничего не найдено',
'terminal.search.prevMatch': 'Предыдущее совпадение (Shift+Enter)',
'terminal.search.nextMatch': 'Следующее совпадение (Enter)',
'terminal.menu.copy': 'Копировать',
'terminal.menu.paste': 'Вставить',
'terminal.menu.uploadClipboardImage': 'Загрузить изображение из буфера',
'terminal.menu.addSelectionToAI': 'Добавить в чат',
'terminal.menu.pasteSelection': 'Вставить выделенное',
'terminal.menu.selectAll': 'Выбрать всё',
'terminal.menu.reconnect': 'Переподключиться',
'terminal.menu.sendYmodem': 'Отправить через YMODEM',
'terminal.menu.receiveYmodem': 'Получить через YMODEM',
'terminal.menu.splitHorizontal': 'Разделить по горизонтали',
'terminal.menu.splitVertical': 'Разделить по вертикали',
'terminal.menu.clearBuffer': 'Очистить буфер',
'terminal.menu.closeTerminal': 'Закрыть терминал',
'terminal.menu.rename': 'Переименовать',
'terminal.menu.detach': 'Открепить из рабочей области',
'terminal.menu.detachSession': 'Открепить {name}',
'terminal.osc7Setup.title': 'Настроить отслеживание каталога',
'terminal.osc7Setup.desc': 'Netcatty добавит хуки OSC 7 для текущего удалённого пользователя. Это помогает SFTP следовать за каталогом терминала после sudo или su.',
'terminal.osc7Setup.targets': 'Возможные файлы для обновления',
'terminal.osc7Setup.command': 'Команда для запуска',
'terminal.osc7Setup.run': 'Запустить настройку',
'terminal.osc7Setup.running': 'Настройка...',
'terminal.osc7Setup.configured': 'Отслеживание каталога настроено',
'terminal.osc7Setup.failed': 'Не удалось настроить отслеживание каталога',
'terminal.osc7Setup.sent': 'Настройка отслеживания каталога отправлена в терминал',
'terminal.ymodem.selectFile': 'Выберите файл для отправки',
'terminal.ymodem.allFiles': 'Все файлы',
'terminal.ymodem.started': 'YMODEM отправляет {fileName}',
'terminal.ymodem.complete': 'YMODEM отправил {fileName}',
'terminal.ymodem.failed': 'Не удалось отправить через YMODEM',
'terminal.ymodem.selectReceiveDirectory': 'Выберите папку для полученных файлов',
'terminal.ymodem.receiveStarted': 'YMODEM получает...',
'terminal.ymodem.receiveComplete': 'YMODEM получил {fileName}',
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM получил файлов: {count}',
'terminal.ymodem.receiveEmpty': 'Файлы YMODEM не получены',
'terminal.ymodem.receiveFailed': 'Не удалось получить через YMODEM',
'terminal.ymodem.unavailable': 'YMODEM недоступен',
'terminal.clipboardImageUpload.noImage': 'В буфере обмена нет изображения',
'terminal.clipboardImageUpload.failed': 'Не удалось загрузить изображение из буфера обмена',
'terminal.selection.addToAI': 'Добавить в чат',
'terminal.selection.addToAIDesc': 'Прикрепить выбранный вывод терминала к черновику AI',
'terminal.auth.password': 'Пароль',
'terminal.auth.sshKey': 'SSH-ключ',
'terminal.auth.username': 'Имя пользователя',
'terminal.auth.username.placeholder': 'root',
'terminal.auth.passwordLabel': 'Пароль',
'terminal.auth.password.placeholder': 'Введите пароль',
'terminal.auth.passphrase': 'Парольная фраза',
'terminal.auth.passphrase.placeholder': 'Необязательная парольная фраза для выбранного приватного ключа',
'terminal.auth.certificate': 'Сертификат',
'terminal.auth.selectKey': 'Выбрать ключ',
'terminal.auth.retryMessage': 'Ошибка аутентификации. Проверьте учётные данные и повторите попытку.',
'terminal.auth.retryLog': 'Ошибка аутентификации. Повторите попытку.',
'terminal.auth.noKeysHint': 'Нет доступных ключей. Добавьте ключи в связке ключей.',
'terminal.auth.continueSave': 'Продолжить и сохранить',
'terminal.auth.credentialsUnavailable': 'Сохранённые учётные данные не могут быть расшифрованы на этом устройстве. Пожалуйста, введите и сохраните их заново.',
'terminal.auth.jumpCredentialsUnavailable': 'У jump-хоста сохранены учётные данные, которые нельзя расшифровать на этом устройстве. Откройте настройки хоста и введите их заново.',
'terminal.auth.proxyCredentialsUnavailable': 'Учётные данные прокси не могут быть расшифрованы на этом устройстве. Откройте настройки хоста и заново введите пароль прокси.',
'terminal.auth.keyUnavailableFallbackPassword': 'Сохранённый SSH-ключ недоступен на этом устройстве. Выполняется переход на аутентификацию по паролю.',
'terminal.progress.timeoutIn': 'Тайм-аут через {seconds}с',
'terminal.progress.waitingForUserInput': 'Ожидание ввода пользователя',
'terminal.progress.disconnected': 'Отключено',
'terminal.progress.cancelling': 'Отмена...',
'terminal.progress.startOver': 'Начать заново',
'terminal.progress.enterReconnectHint': 'Нажмите Enter, чтобы подключиться снова',
'terminal.connection.dismissDisconnectedDialog': 'Закрыть уведомление об отключении',
'terminal.connection.chainOf': 'Цепочка {current} из {total}',
'terminal.connection.showLogs': 'Показать журналы',
'terminal.connection.hideLogs': 'Скрыть журналы',
'terminal.connection.protocol.ssh': 'SSH',
'terminal.connection.protocol.telnet': 'Telnet',
'terminal.connection.protocol.mosh': 'Mosh',
'terminal.connection.protocol.serial': 'Serial',
'terminal.connection.protocol.local': 'Локальная оболочка',
'terminal.hostKey.unknownTitle': 'Подтвердите этот ключ хоста',
'terminal.hostKey.changedTitle': 'Ключ хоста изменился',
'terminal.hostKey.unknownDescription': 'Подлинность {host} пока не может быть установлена.',
'terminal.hostKey.changedDescription': 'Сохранённый ключ для {host} больше не совпадает с этим сервером.',
'terminal.hostKey.fingerprintLabel': 'Отпечаток {keyType} — SHA256:',
'terminal.hostKey.savedFingerprintLabel': 'Сохранённый отпечаток',
'terminal.hostKey.unknownHint': 'Запомните его, если этот отпечаток принадлежит серверу, к которому вы ожидали подключиться.',
'terminal.hostKey.changedHint': 'Продолжайте только если вы ожидали, что этот хост изменится.',
'terminal.hostKey.addAndContinue': 'Добавить и продолжить',
'terminal.hostKey.updateAndContinue': 'Обновить и продолжить',
'terminal.themeModal.title': 'Внешний вид терминала',
'terminal.themeModal.tab.theme': 'Тема',
'terminal.themeModal.tab.font': 'Шрифт',
'terminal.themeModal.tab.custom': 'Пользовательское',
'terminal.themeModal.globalTheme': 'Глобальная тема',
'terminal.themeModal.globalFont': 'Глобальный шрифт',
'terminal.themeModal.fontSize': 'Размер шрифта',
'terminal.themeModal.fontWeight': 'Толщина шрифта',
'terminal.themeModal.livePreview': 'Предпросмотр в реальном времени',
'terminal.themeModal.themeType': 'Тема {type}',
'terminal.hiddenTheme.title': 'Текущая скрытая тема',
'terminal.hiddenTheme.desc': 'Эта тема скрыта из ручного выбора и будет заменена, когда вы выберете другую тему.',
'topTabs.toggleTheme.systemExitTitle': 'Активна системная тема',
'topTabs.toggleTheme.systemExitMessage': 'Откройте настройки, чтобы выбрать фиксированную светлую или тёмную тему.',
'topTabs.toggleTheme.openSettings': 'Открыть настройки',
// Custom Themes
'terminal.customTheme.section': 'Пользовательские темы',
'terminal.customTheme.yourThemes': 'Ваши темы',
'terminal.customTheme.new': 'Новая тема',
'terminal.customTheme.newDesc': 'Клонировать текущую тему и настроить её',
'terminal.customTheme.newTitle': 'Новая пользовательская тема',
'terminal.customTheme.editTitle': 'Редактировать тему',
'terminal.customTheme.import': 'Импорт .itermcolors',
'terminal.customTheme.importDesc': 'Импорт из файла цветовой схемы iTerm2',
'terminal.customTheme.importError': 'Не удалось разобрать выбранный файл. Убедитесь, что это корректный XML-файл .itermcolors.',
'terminal.customTheme.delete': 'Удалить тему',
'terminal.customTheme.confirmDelete': 'Подтвердить удаление',
'terminal.customTheme.name': 'Название',
'terminal.customTheme.namePlaceholder': 'Моя пользовательская тема',
'terminal.customTheme.type': 'Тип',
'terminal.customTheme.group.general': 'Общие',
'terminal.customTheme.group.normal': 'Обычные цвета',
'terminal.customTheme.group.bright': 'Яркие цвета',
'terminal.customTheme.color.background': 'Фон',
'terminal.customTheme.color.foreground': 'Текст',
'terminal.customTheme.color.cursor': 'Курсор',
'terminal.customTheme.color.selection': 'Выделение',
'terminal.customTheme.color.black': 'Чёрный',
'terminal.customTheme.color.red': 'Красный',
'terminal.customTheme.color.green': 'Зелёный',
'terminal.customTheme.color.yellow': 'Жёлтый',
'terminal.customTheme.color.blue': 'Синий',
'terminal.customTheme.color.magenta': 'Пурпурный',
'terminal.customTheme.color.cyan': 'Голубой',
'terminal.customTheme.color.white': 'Белый',
'terminal.customTheme.color.brightBlack': 'Яркий чёрный',
'terminal.customTheme.color.brightRed': 'Яркий красный',
'terminal.customTheme.color.brightGreen': 'Яркий зелёный',
'terminal.customTheme.color.brightYellow': 'Яркий жёлтый',
'terminal.customTheme.color.brightBlue': 'Яркий синий',
'terminal.customTheme.color.brightMagenta': 'Яркий пурпурный',
'terminal.customTheme.color.brightCyan': 'Яркий голубой',
'terminal.customTheme.color.brightWhite': 'Яркий белый',
// Cloud Sync Settings
'cloudSync.gate.title': 'Синхронизация с end-to-end шифрованием',
'cloudSync.gate.desc':
'Ваши данные шифруются локально перед синхронизацией. Облачные провайдеры никогда не видят ваши данные в открытом виде. Задайте мастер-ключ, чтобы включить безопасную синхронизацию.',
'cloudSync.gate.masterKey': 'Мастер-ключ',
'cloudSync.gate.confirmMasterKey': 'Подтвердите мастер-ключ',
'cloudSync.gate.placeholder': 'Введите надёжный пароль',
'cloudSync.gate.confirmPlaceholder': 'Подтвердите пароль',
'cloudSync.gate.mismatch': 'Пароли не совпадают',
'cloudSync.gate.warning':
'Я понимаю, что если забуду мастер-ключ, мои данные нельзя будет восстановить. Сброс пароля невозможен.',
'cloudSync.gate.enableVault': 'Включить зашифрованное хранилище',
'cloudSync.gate.enabledToast': 'Зашифрованное хранилище включено',
'cloudSync.gate.setupFailed': 'Не удалось настроить мастер-ключ',
'cloudSync.passwordStrength.tooShort': 'Слишком короткий',
'cloudSync.passwordStrength.weak': 'Слабый',
'cloudSync.passwordStrength.moderate': 'Средний',
'cloudSync.passwordStrength.strong': 'Сильный',
'cloudSync.passwordStrength.veryStrong': 'Очень сильный',
'cloudSync.provider.notConnected': 'Не подключено',
'cloudSync.provider.sync': 'Синхронизация',
'cloudSync.provider.connect': 'Подключить',
'cloudSync.provider.connecting': 'Подключение...',
'cloudSync.provider.webdav': 'WebDAV',
'cloudSync.provider.webdav.desc': 'Подключение к самостоятельно размещённому WebDAV endpoint',
'cloudSync.provider.s3': 'Совместимое с S3',
'cloudSync.provider.s3.desc': 'Подключение к объектному хранилищу, совместимому с S3',
'cloudSync.provider.comingSoon': 'Скоро',
'cloudSync.webdav.title': 'Настройки WebDAV',
'cloudSync.webdav.desc': 'Настройка WebDAV endpoint для зашифрованной синхронизации.',
'cloudSync.webdav.endpoint': 'URL endpoint',
'cloudSync.webdav.authType': 'Тип аутентификации',
'cloudSync.webdav.auth.basic': 'Basic',
'cloudSync.webdav.auth.digest': 'Digest',
'cloudSync.webdav.auth.token': 'Токен',
'cloudSync.webdav.username': 'Имя пользователя',
'cloudSync.webdav.password': 'Пароль',
'cloudSync.webdav.token': 'Токен',
'cloudSync.webdav.showSecret': 'Показать секрет',
'cloudSync.webdav.allowInsecure': 'Разрешить небезопасное соединение (игнорировать ошибки сертификата)',
'cloudSync.webdav.validation.endpoint': 'Введите корректный WebDAV endpoint.',
'cloudSync.webdav.validation.credentials': 'Имя пользователя и пароль обязательны.',
'cloudSync.webdav.validation.token': 'Токен обязателен.',
'cloudSync.s3.title': 'Настройки S3',
'cloudSync.s3.desc': 'Подключение к объектному хранилищу, совместимому с S3, для зашифрованной синхронизации.',
'cloudSync.s3.endpoint': 'URL endpoint',
'cloudSync.s3.region': 'Регион',
'cloudSync.s3.bucket': 'Бакет',
'cloudSync.s3.accessKeyId': 'ID ключа доступа',
'cloudSync.s3.secretAccessKey': 'Секретный ключ доступа',
'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',
'cloudSync.smb.desc': 'Подключение к файловой SMB/CIFS-шаре для зашифрованной синхронизации.',
'cloudSync.smb.share': 'Путь к шаре',
'cloudSync.smb.username': 'Имя пользователя',
'cloudSync.smb.password': 'Пароль',
'cloudSync.smb.domain': 'Домен (необязательно)',
'cloudSync.smb.domainPlaceholder': 'например, WORKGROUP',
'cloudSync.smb.port': 'Порт (необязательно)',
'cloudSync.smb.showSecret': 'Показать пароль',
'cloudSync.smb.validation.share': 'Путь к шаре обязателен.',
'cloudSync.smb.validation.port': 'Порт должен быть числом от 1 до 65535.',
'cloudSync.connect.smb.success': 'SMB успешно подключён',
'cloudSync.connect.smb.failedTitle': 'Ошибка подключения SMB',
'cloudSync.provider.smb': 'SMB-шара',
'cloudSync.connect.webdav.success': 'WebDAV успешно подключён',
'cloudSync.connect.webdav.failedTitle': 'Ошибка подключения WebDAV',
'cloudSync.connect.s3.success': 'S3 успешно подключён',
'cloudSync.connect.s3.failedTitle': 'Ошибка подключения S3',
'cloudSync.lastSync.never': 'Никогда',
'cloudSync.lastSync.justNow': 'Только что',
'cloudSync.lastSync.minutesAgo': '{minutes} мин назад',
'cloudSync.changeKey': 'Изменить ключ',
'cloudSync.providers.title': 'Облачные провайдеры',
'cloudSync.syncAll': 'Синхронизировать всех подключённых провайдеров',
'cloudSync.autoSync.title': 'Автосинхронизация',
'cloudSync.autoSync.desc': 'Автоматически синхронизировать при внесении изменений',
'cloudSync.strategy.title': 'Стратегия синхронизации',
'cloudSync.strategy.desc': 'Выберите, что делать, когда изменились и локальные, и облачные данные.',
'cloudSync.strategy.smartMerge': 'Умное объединение (рекомендуется)',
'cloudSync.strategy.smartMergeDesc': 'По возможности объединять изменения с обеих сторон; если Netcatty не сможет безопасно выбрать, он попросит вас решить вручную.',
'cloudSync.strategy.preferCloud': 'Приоритет облака',
'cloudSync.strategy.preferCloudDesc': 'Когда изменились обе стороны, скачать облачную версию и заменить локальные изменения.',
'cloudSync.strategy.preferLocal': 'Приоритет локальных данных',
'cloudSync.strategy.preferLocalDesc': 'Когда изменились обе стороны, загрузить локальную версию и заменить облачные изменения.',
'cloudSync.status.title': 'Статус синхронизации',
'cloudSync.status.localVersion': 'Локальная версия',
'cloudSync.status.remoteVersion': 'Удалённая версия',
'cloudSync.history.title': 'История синхронизации',
'cloudSync.history.upload': 'Загрузка',
'cloudSync.history.download': 'Скачивание',
'cloudSync.history.resolved': 'Разрешено',
'cloudSync.history.error': 'Ошибка',
'cloudSync.localBackups.title': 'История локальных резервных копий',
'cloudSync.localBackups.desc': 'Netcatty сохраняет локальные точки восстановления перед сменой версии приложения и перед восстановлением хранилища.',
'cloudSync.localBackups.retentionTitle': 'Хранение резервных копий',
'cloudSync.localBackups.retentionDesc': 'Выберите, сколько локальных резервных копий должен хранить Netcatty.',
'cloudSync.localBackups.maxCount': 'Макс. число копий',
'cloudSync.localBackups.maxSaved': 'Хранение резервных копий: {count}',
'cloudSync.localBackups.maxInvalid': 'Введите число от 1 до 100.',
'cloudSync.localBackups.empty': 'Локальных резервных копий пока нет.',
'cloudSync.localBackups.reason.appVersionChange': 'Перед сменой версии приложения',
'cloudSync.localBackups.reason.beforeRestore': 'Перед восстановлением',
'cloudSync.localBackups.versionChange': '{from} -> {to}',
'cloudSync.localBackups.counts': '{hosts} хостов, {keys} ключей, {snippets} сниппетов, {notes} заметок',
'cloudSync.localBackups.restore': 'Восстановить',
'cloudSync.localBackups.restoreSuccess': 'Локальная резервная копия восстановлена.',
'cloudSync.localBackups.restoreFailedTitle': 'Ошибка восстановления',
'cloudSync.localBackups.restoreMissing': 'Резервная копия не найдена.',
'cloudSync.localBackups.protectiveBackupFailed': 'Не удалось создать защитную резервную копию, поэтому восстановление было прервано для защиты ваших текущих данных. Устраните основную проблему (например, доступ к keychain) и попробуйте снова. Подробности: {message}',
'cloudSync.localBackups.restoreConfirmTitle': 'Восстановить эту резервную копию?',
'cloudSync.localBackups.restoreConfirmDesc': 'Ваши текущие хосты, ключи, сниппеты и настройки будут заменены содержимым этой резервной копии. Перед этим автоматически создаётся защитный снимок текущих данных.',
'cloudSync.localBackups.restoreConfirmButton': 'Восстановить',
'cloudSync.localBackups.restoreConfirmCancel': 'Отмена',
'cloudSync.localBackups.unavailableTitle': 'Локальные резервные копии недоступны',
'cloudSync.localBackups.unavailableDesc': 'Эта платформа не предоставляет Netcatty безопасное хранилище ключей, поэтому локальные резервные копии нельзя записывать безопасно. Установите Netcatty в систему с поддерживаемым keychain, чтобы включить историю локальных резервных копий.',
'cloudSync.localBackups.lockedTitle': 'Требуется мастер-ключ',
'cloudSync.localBackups.lockedDesc': 'Настройте или разблокируйте мастер-ключ перед восстановлением резервной копии, чтобы восстановленные учётные данные оставались зашифрованными.',
'cloudSync.revisionHistory.viewButton': 'История',
'cloudSync.revisionHistory.title': 'История версий хранилища',
'cloudSync.revisionHistory.description': 'Просматривайте и восстанавливайте предыдущие версии вашего хранилища из истории ревизий Gist.',
'cloudSync.revisionHistory.empty': 'Ревизии не найдены.',
'cloudSync.revisionHistory.current': 'Текущая',
'cloudSync.revisionHistory.revision': 'Ревизия',
'cloudSync.revisionHistory.revisionPreview': 'Содержимое ревизии',
'cloudSync.revisionHistory.device': 'Устройство',
'cloudSync.revisionHistory.hosts': 'Хосты',
'cloudSync.revisionHistory.keys': 'Ключи',
'cloudSync.revisionHistory.snippets': 'Сниппеты',
'cloudSync.revisionHistory.notes': 'Заметки',
'cloudSync.revisionHistory.identities': 'Идентификаторы',
'cloudSync.revisionHistory.restoreButton': 'Восстановить эту версию',
'cloudSync.revisionHistory.restored': 'Хранилище восстановлено из выбранной ревизии.',
'cloudSync.revisionHistory.revisionNotFound': 'Ревизия не найдена или не содержит данных хранилища.',
'cloudSync.revisionHistory.decryptFailed': 'Не удалось расшифровать эту ревизию. Возможно, она была зашифрована другим мастер-паролем.',
'cloudSync.changeKey.title': 'Изменить мастер-ключ',
'cloudSync.changeKey.current': 'Текущий мастер-ключ',
'cloudSync.changeKey.new': 'Новый мастер-ключ',
'cloudSync.changeKey.confirmNew': 'Подтвердите новый мастер-ключ',
'cloudSync.changeKey.currentPlaceholder': 'Введите текущий мастер-ключ',
'cloudSync.changeKey.newPlaceholder': 'Введите новый мастер-ключ',
'cloudSync.changeKey.confirmPlaceholder': 'Подтвердите новый мастер-ключ',
'cloudSync.changeKey.fillAll': 'Пожалуйста, заполните все поля',
'cloudSync.changeKey.minLength': 'Новый мастер-ключ должен содержать не менее 8 символов',
'cloudSync.changeKey.notMatch': 'Новые мастер-ключи не совпадают',
'cloudSync.changeKey.incorrectCurrent': 'Неверный текущий мастер-ключ',
'cloudSync.changeKey.failed': 'Не удалось изменить мастер-ключ',
'cloudSync.changeKey.desc': 'Это заново зашифрует ваше хранилище. Убедитесь, что вы помните новый ключ.',
'cloudSync.changeKey.showKeys': 'Показать ключи',
'cloudSync.changeKey.updatedToast': 'Мастер-ключ обновлён',
'cloudSync.changeKey.updateButton': 'Обновить ключ',
'cloudSync.unlock.title': 'Введите мастер-ключ',
'cloudSync.unlock.masterKey': 'Мастер-ключ',
'cloudSync.unlock.desc':
'Введите мастер-ключ один раз, чтобы включить зашифрованную синхронизацию. Он будет безопасно сохранён в системном keychain.',
'cloudSync.unlock.placeholder': 'Введите мастер-ключ',
'cloudSync.unlock.empty': 'Пожалуйста, введите мастер-ключ',
'cloudSync.unlock.incorrect': 'Неверный мастер-ключ',
'cloudSync.unlock.failed': 'Не удалось разблокировать хранилище',
'cloudSync.unlock.showKey': 'Показать ключ',
'cloudSync.unlock.notNow': 'Не сейчас',
'cloudSync.unlock.readyToast': 'Хранилище готово',
'cloudSync.unlock.unlockButton': 'Разблокировать',
'cloudSync.header.vaultReady': 'Хранилище готово',
'cloudSync.header.preparingVault': 'Подготовка хранилища...',
'cloudSync.header.providersConnected': 'Подключено провайдеров: {count}',
'cloudSync.githubFlow.title': 'Подключить GitHub',
'cloudSync.githubFlow.desc': 'Скопируйте код ниже и введите его на GitHub, чтобы авторизовать Netcatty.',
'cloudSync.githubFlow.copyCode': 'Скопировать код',
'cloudSync.githubFlow.copied': 'Скопировано!',
'cloudSync.githubFlow.openGitHub': 'Открыть GitHub',
'cloudSync.githubFlow.waiting': 'Ожидание авторизации...',
'cloudSync.conflict.title': 'Обнаружен конфликт версий',
'cloudSync.conflict.desc': 'Выберите, какую версию сохранить',
'cloudSync.conflict.local': 'ЛОКАЛЬНАЯ',
'cloudSync.conflict.cloud': 'ОБЛАЧНАЯ',
'cloudSync.conflict.detailsTitle': 'Изменённые данные',
'cloudSync.conflict.detailsCounts': 'Локально {local} · Облако {cloud} · Конфликты {conflicts}',
'cloudSync.conflict.entity.hosts': 'Хосты',
'cloudSync.conflict.entity.keys': 'Ключи',
'cloudSync.conflict.entity.identities': 'Идентификаторы',
'cloudSync.conflict.entity.proxyProfiles': 'Профили прокси',
'cloudSync.conflict.entity.snippets': 'Сниппеты',
'cloudSync.conflict.entity.notes': 'Заметки',
'cloudSync.conflict.entity.noteGroups': 'Группы заметок',
'cloudSync.conflict.entity.customGroups': 'Группы',
'cloudSync.conflict.entity.snippetPackages': 'Пакеты сниппетов',
'cloudSync.conflict.entity.portForwardingRules': 'Проброс портов',
'cloudSync.conflict.entity.groupConfigs': 'Настройки групп',
'cloudSync.conflict.entity.settings': 'Настройки',
'cloudSync.conflict.keepLocal': 'Перезаписать облако (сохранить локальную)',
'cloudSync.conflict.useCloud': 'Скачать из облака (перезаписать локальную)',
'cloudSync.connect.browserContinue': 'Завершите авторизацию в браузере',
'cloudSync.connect.browserCancelled': 'Предыдущая авторизация в браузере была отменена',
'cloudSync.connect.github.success': 'GitHub успешно подключён',
'cloudSync.connect.github.failedTitle': 'Ошибка подключения GitHub',
'cloudSync.connect.github.timeout': 'Время подключения к GitHub истекло. Проверьте сеть или настройки прокси.',
'cloudSync.connect.github.networkError': 'Не удалось связаться с GitHub. Проверьте сеть или настройки прокси.',
'cloudSync.connect.google.failedTitle': 'Ошибка подключения Google',
'cloudSync.connect.onedrive.failedTitle': 'Ошибка подключения OneDrive',
'cloudSync.sync.success': 'Синхронизировано с {provider}',
'cloudSync.sync.failed': 'Синхронизация не удалась',
'cloudSync.sync.failedTitle': 'Синхронизация не удалась',
'cloudSync.sync.errorTitle': 'Ошибка синхронизации',
'cloudSync.resolve.downloaded': 'Скачаны данные из облака',
'cloudSync.resolve.uploaded': 'Загружены локальные данные',
'cloudSync.resolve.failedTitle': 'Не удалось разрешить конфликт',
'cloudSync.clearLocal.title': 'Очистить локальные данные',
'cloudSync.clearLocal.desc': 'Сбросить локальную версию и историю синхронизации. При следующей синхронизации данные будут скачаны из облака.',
'cloudSync.clearLocal.button': 'Очистить',
'cloudSync.clearLocal.dialog.title': 'Очистить локальные данные хранилища?',
'cloudSync.clearLocal.dialog.desc': 'Локальная версия будет сброшена до 0, а история синхронизации очищена. При следующей синхронизации данные будут скачаны из облака и заменят локальные.',
'cloudSync.clearLocal.dialog.cancel': 'Отмена',
'cloudSync.clearLocal.dialog.confirm': 'Очистить локальные данные',
'cloudSync.clearLocal.toast.title': 'Локальные данные очищены',
'cloudSync.clearLocal.toast.desc': 'Локальная версия сброшена до 0. Выполните синхронизацию для загрузки из облака.',
// Keychain
'keychain.filter.key': 'Ключ',
'keychain.filter.certificate': 'Сертификат',
'keychain.action.generateKey': 'Создать ключ',
'keychain.action.importKey': 'Импорт. ключ',
'keychain.action.newIdentity': 'Новый ид-катор',
'keychain.action.importCertificate': 'Импорт. сертификат',
'keychain.view.grid': 'Сетка',
'keychain.view.list': 'Список',
'keychain.section.keys': 'Ключи',
'keychain.section.identities': 'Идентификаторы',
'keychain.count.items': '{count} запис(ей)',
'keychain.empty.title': 'Настройте свои ключи',
'keychain.empty.desc': 'Импортируйте или создайте SSH-ключи для безопасной аутентификации.',
'keychain.panel.generateKey': 'Сгенерировать ключ',
'keychain.panel.newKey': 'Новый ключ',
'keychain.panel.keyDetails': 'Сведения о ключе',
'keychain.panel.editKey': 'Редактировать ключ',
'keychain.panel.editIdentity': 'Редактировать идентификатор',
'keychain.panel.newIdentity': 'Новый идентификатор',
'keychain.panel.keyExport': 'Экспорт ключа',
'keychain.validation.labelRequired': 'Пожалуйста, введите метку для ключа',
'keychain.validation.labelAndPrivateKeyRequired': 'Метка и приватный ключ обязательны',
'keychain.validation.labelAndUsernameRequired': 'Метка и имя пользователя обязательны',
'keychain.error.generationUnavailable': 'Генератор ключей не работает - пожалуйста, убедитесь, что приложение работает в Electron',
'keychain.error.generateKeyPairFailed': 'Не удалось сгенерировать пару ключей',
'keychain.error.generateKeyFailed': 'Не удалось сгенерировать ключ',
'keychain.error.keyGenerationTitle': 'Генерация ключа',
'keychain.export.exportTo': 'Экспортировать в *',
'keychain.export.selectHost': 'Выберите хост',
'keychain.export.location': 'Расположение ~ $1 *',
'keychain.export.filename': 'Имя файла ~ $2 *',
'keychain.export.note': 'Экспорт ключей сейчас поддерживается только в системах {unix}. Используйте раздел {advanced} для настройки скрипта экспорта.',
'keychain.export.script': 'Скрипт *',
'keychain.export.scriptPlaceholder': 'Скрипт экспорта...',
'keychain.export.missingCredentials': 'У хоста нет сохранённого пароля или ключа. Сначала добавьте в хост учётные данные с паролем.',
'keychain.export.successTitle': 'Экспорт выполнен успешно',
'keychain.export.successMessage': 'Публичный ключ экспортирован и привязан к {host}',
'keychain.export.failedTitle': 'Ошибка экспорта',
'keychain.export.failedMessage': 'Не удалось экспортировать ключ: {error}',
'keychain.export.failedPrefix': 'Ошибка экспорта: {error}',
'keychain.export.exitCode': 'Команда завершилась с кодом {code}',
'keychain.export.exporting': 'Экспорт...',
'keychain.export.exportAndAttach': 'Экспортировать и привязать',
'keychain.export.title': 'Экспорт ключа',
'keychain.export.exportToRequired': 'Экспортировать в *',
'keychain.export.selectHostPlaceholder': 'Выберите хост...',
'keychain.export.locationLabel': 'Расположение ~ $1 *',
'keychain.export.filenameLabel': 'Имя файла ~ $2 *',
'keychain.export.advanced': 'Дополнительно',
'keychain.export.note.supportsOnly': 'Экспорт ключей сейчас поддерживается только в',
'keychain.export.note.systems': 'системах.',
'keychain.export.note.use': 'Используйте',
'keychain.export.note.customize': 'раздел для настройки скрипта экспорта.',
'keychain.export.scriptRequired': 'Скрипт *',
'keychain.export.exportToHost': 'Экспортировать на хост',
'keychain.export.failedGeneric': 'Ошибка экспорта: {message}',
'keychain.field.label': 'Метка',
'keychain.field.labelRequired': 'Метка *',
'keychain.field.labelPlaceholder': 'Метка ключа',
'keychain.field.privateKeyRequired': 'Приватный ключ *',
'keychain.field.publicKey': 'Публичный ключ',
'keychain.field.certificatePlaceholder': 'Содержимое сертификата (необязательно)',
'keychain.generate.keyType': 'Тип ключа',
'keychain.generate.keySize': 'Размер ключа',
'keychain.generate.labelPlaceholder': 'Метка ключа',
'keychain.generate.passphrasePlaceholder': 'Парольная фраза (необязательно)',
'keychain.generate.savePassphrase': 'Сохранить парольную фразу',
'keychain.generate.generate': 'Сгенерировать',
'keychain.generate.generateSave': 'Сгенерировать и сохранить',
'keychain.import.dropHint': 'Перетащите сюда файл ключа',
'keychain.import.importFromFile': 'Импортировать из файла',
'keychain.import.saveKey': 'Сохранить ключ',
'keychain.import.importedKeyLabel': 'Импортированный ключ',
'keychain.identity.usernameRequired': 'Имя пользователя *',
'keychain.identity.method.passwordOnly': 'Пароль',
'keychain.identity.summary.password': 'Пароль аутентификации',
'keychain.identity.summary.key': 'Ключ аутентификации',
'keychain.identity.summary.certificate': 'Сертификат аутентификации',
'keychain.identity.summary.passwordAndKey': 'Пароль и ключ аутентификации',
'keychain.identity.summary.passwordAndCertificate': 'Пароль и сертификат аутентификации',
'keychain.identity.summary.none': 'Нет учётных данных',
'keychain.identity.selectCredential': 'Выберите {kind}',
'keychain.identity.save': 'Сохранить',
'keychain.identity.update': 'Обновить',
'keychain.keyDialog.newTitle': 'Новый ключ',
'keychain.keyDialog.newDesc': 'Добавить новый SSH-ключ',
'keychain.keyDialog.editTitle': 'Редактировать ключ',
'keychain.keyDialog.editDesc': 'Обновить этот SSH-ключ',
'keychain.keyDialog.updateKey': 'Обновить ключ',
// Tabs
'tabs.closeSessionAria': 'Закрыть сессию',
'tabs.closeLogViewAria': 'Закрыть просмотр журнала',
'tabs.logPrefix': 'Журнал:',
'tabs.logLocal': 'Локальный',
'tabs.copyTab': 'Копировать вкладку',
'tabs.copyTabToNewWindow': 'Копировать вкладку в новое окно',
'tabs.copyTabToNewWindowFailed': 'Не удалось открыть вкладку в новом окне',
'tabs.closeOthers': 'Закрыть остальные',
'tabs.closeToRight': 'Закрыть вкладки справа',
'tabs.closeAll': 'Закрыть все',
'keychain.edit.labelRequired': 'Метка *',
'keychain.edit.keyLabelPlaceholder': 'Метка ключа',
'keychain.edit.privateKeyRequired': 'Приватный ключ *',
'keychain.edit.publicKey': 'Публичный ключ',
'keychain.edit.certificate': 'Сертификат',
'keychain.edit.certificatePlaceholder': 'Содержимое сертификата (необязательно)',
'keychain.edit.filePath': 'Путь к файлу',
'keychain.edit.keyExport': 'Экспорт ключа',
'keychain.edit.exportToHost': 'Экспортировать на хост',
// Snippets
'snippets.searchPlaceholder': 'Поиск сниппетов...',
'snippets.action.newSnippet': 'Новый сниппет',
'snippets.action.newPackage': 'Новый пакет',
'snippets.action.import': 'Импорт',
'snippets.action.selectSnippets': 'Выбрать сниппеты',
'snippets.panel.newTitle': 'Новый сниппет',
'snippets.panel.editTitle': 'Редактировать сниппет',
'snippets.panel.resizeWidth': 'Изменить ширину панели',
'snippets.field.description': 'Описание действия',
'snippets.field.descriptionPlaceholder': 'Например: проверить сетевую нагрузку',
'snippets.field.package': 'Добавить пакет',
'snippets.field.packagePlaceholder': 'Выберите или создайте пакет',
'snippets.field.createPackage': 'Создать пакет',
'snippets.field.scriptRequired': 'Скрипт *',
'snippets.scriptEditor.expand': 'Открыть в окне',
'snippets.scriptEditor.resize': 'Изменить высоту редактора',
'snippets.scriptEditor.modalTitle': 'Редактировать скрипт',
'snippets.variables.dialogTitle': 'Переменные сниппета',
'snippets.variables.dialogDesc': 'Заполните значения для "{label}" перед запуском.',
'snippets.variables.hint': 'Значения вставляются в скрипт как есть (без shell-экранирования).',
'snippets.variables.preview': 'Предпросмотр',
'snippets.variables.placeholder': 'Введите значение',
'snippets.variables.placeholderDefault': 'По умолчанию: {value}',
'snippets.variables.required': 'Эта переменная обязательна',
'snippets.variables.run': 'Запустить',
'snippets.field.variablesHelp': 'Используйте {{name}} или {{name:default}} для плейсхолдеров в скрипте.',
'snippets.field.variablesDetected': 'Переменные',
'snippets.field.variableDefault': 'по умолчанию {value}',
'snippets.targets.title': 'Цели',
'snippets.targets.add': 'Добавить цели',
'snippets.history.title': 'История оболочки',
'snippets.history.subtitle': '{count} команд',
'snippets.history.emptyTitle': 'История оболочки пока пуста',
'snippets.history.emptyDesc': 'Здесь будут появляться выполненные вами команды',
'snippets.history.loadMore': 'Загрузить ещё',
'snippets.history.separator': '•',
'snippets.history.labelPlaceholder': 'Задайте метку для этого сниппета',
'snippets.history.saveAsSnippet': 'Сохранить как сниппет',
'snippets.history.time.justNow': 'только что',
'snippets.history.time.minutesAgo': '{count}м назад',
'snippets.history.time.hoursAgo': '{count}ч назад',
'snippets.history.time.daysAgo': '{count}д назад',
'snippets.breadcrumb.allPackages': 'Все пакеты',
'snippets.breadcrumb.separator': '›',
'snippets.empty.title': 'Создать сниппет',
'snippets.empty.desc': 'Сохраняйте самые используемые команды как сниппеты, чтобы повторно использовать их в один клик.',
'snippets.search.noResults.title': 'Нет совпадений',
'snippets.search.noResults.desc': 'Ни один сниппет или пакет не соответствует запросу "{query}". Попробуйте другой поисковый запрос или очистите поиск для просмотра.',
'snippets.section.packages': 'Пакеты',
'snippets.section.snippets': 'Сниппеты',
'snippets.package.count': '{count} сниппет(ов)',
'snippets.commandFallback': 'Команда',
'snippets.view.grid': 'Сетка',
'snippets.view.list': 'Список',
'snippets.selection.selected': 'Выбрано: {count}',
'snippets.selection.selectVisible': 'Выбрать видимые',
'snippets.selection.deselectAll': 'Снять выбор',
'snippets.selection.exportSelected': 'Экспорт выбранных ({count})',
'snippets.export.snippet': 'Экспорт сниппета',
'snippets.export.package': 'Экспорт пакета',
'snippets.export.toast.empty': 'Нет сниппетов для экспорта.',
'snippets.export.toast.successTitle': 'Экспорт готов',
'snippets.export.toast.success': 'Экспортировано сниппетов: {count}.',
'snippets.import.toast.empty': 'Не найдено сниппетов для импорта.',
'snippets.import.toast.failedTitle': 'Импорт не удался',
'snippets.import.toast.invalidDesc': 'Это невалидный файл сниппетов Netcatty.',
'snippets.import.toast.successTitle': 'Импорт завершён',
'snippets.import.toast.summary': 'Импортировано: {imported}, перезаписано: {overwritten}, пропущено: {skipped}.',
'snippets.import.modal.title': 'Импорт сниппетов',
'snippets.import.modal.desc': 'Выберите один или несколько JSON-файлов сниппетов Netcatty. Netcatty сначала покажет результат разбора и ничего не импортирует без подтверждения.',
'snippets.import.modal.exampleTitle': 'Пример JSON',
'snippets.import.modal.noFile': 'Файл ещё не выбран. Используйте пример ниже или обычный JSON-массив сниппетов, затем выберите один или несколько файлов.',
'snippets.import.modal.chooseFile': 'Выбрать файл',
'snippets.import.modal.downloadExamples': 'Скачать примеры',
'snippets.import.modal.multipleFiles': 'Выбрано файлов: {count}',
'snippets.import.modal.parsedSummary': '{files} файл(ов), {total} сниппет(ов), {packages} пакет(ов), повторяющихся команд: {conflicts}. Привязки к хостам игнорируются.',
'snippets.import.modal.confirm': 'Подтвердить импорт',
'snippets.import.conflict.title': 'Импортировать сниппеты?',
'snippets.import.conflict.desc': 'Файл «{file}» содержит {total} сниппет(ов), из них повторяющихся команд: {conflicts}.',
'snippets.import.conflict.hostBindingsNote': 'Привязки сниппетов к хостам не импортируются и не экспортируются.',
'snippets.import.conflict.skip': 'Пропустить повторы',
'snippets.import.conflict.overwrite': 'Перезаписать повторы',
'snippets.packageDialog.title': 'Новый пакет',
'snippets.packageDialog.parent': 'Родитель: {parent}',
'snippets.packageDialog.root': 'Корень',
'snippets.packageDialog.placeholder': 'например, ops/maintenance',
'snippets.packageDialog.hint': 'Используйте "/" для создания вложенных пакетов.',
// Snippets Rename Dialog
'snippets.renameDialog.title': 'Переименовать пакет',
'snippets.renameDialog.currentPath': 'Текущий путь: {path}',
'snippets.renameDialog.placeholder': 'Введите новое имя',
'snippets.renameDialog.error.empty': 'Имя пакета не может быть пустым',
'snippets.renameDialog.error.duplicate': 'Пакет с таким именем уже существует',
'snippets.renameDialog.error.invalidChars': 'Имя пакета может содержать только буквы, цифры, дефисы и подчёркивания',
'snippets.field.noAutoRun': 'Только вставить (не выполнять автоматически)',
'snippets.field.multiLineRunMode': 'Многострочный запуск',
'snippets.field.multiLineRunMode.paste': 'Отправить сразу',
'snippets.field.multiLineRunMode.lineDelay': 'Отправлять построчно',
'snippets.field.multiLineRunModeHint': 'Построчный режим подходит для логинов с подсказками и макросов устройств.',
// Snippet Shortkey
'snippets.field.shortkey': 'Сочетание клавиш',
'snippets.shortkey.placeholder': 'Нажмите, чтобы задать сочетание',
'snippets.shortkey.recording': 'Нажмите сочетание клавиш...',
'snippets.shortkey.hint': 'Нажмите это сочетание в терминале, чтобы быстро отправить команду.',
'snippets.shortkey.clear': 'Очистить сочетание',
'snippets.shortkey.error.systemConflict': 'Это сочетание конфликтует с системным сочетанием',
'snippets.shortkey.error.snippetConflict': 'Это сочетание уже используется сниппетом: {name}',
// Serial Port
'serial.button': 'Серийный',
'serial.modal.title': 'Подключение к последовательному порту',
'serial.modal.desc': 'Настройте параметры подключения к последовательному порту',
'serial.field.port': 'Последовательный порт',
'serial.field.selectPort': 'Выберите порт...',
'serial.field.baudRate': 'Скорость передачи',
'serial.field.dataBits': 'Биты данных',
'serial.field.stopBits': 'Стоп-биты',
'serial.field.stopBits15Warning': '1.5 stop bits may not be supported on all Windows devices',
'serial.field.parity': 'Чётность',
'serial.field.flowControl': 'Управление потоком',
'serial.noPorts': 'Последовательные порты не обнаружены. Подключите устройство и обновите список.',
'serial.field.customPort': 'Путь к пользовательскому порту',
'serial.field.customPortPlaceholder': 'например, /dev/ttys001 или COM1',
'serial.type.hardware': 'Аппаратный',
'serial.type.pseudo': 'Псевдотерминал',
'serial.type.custom': 'Пользовательский',
'serial.parity.none': 'Нет',
'serial.parity.even': 'Чётная',
'serial.parity.odd': 'Нечётная',
'serial.parity.mark': 'Mark',
'serial.parity.space': 'Space',
'serial.flowControl.none': 'Нет',
'serial.flowControl.xon/xoff': 'XON/XOFF (программный)',
'serial.flowControl.rts/cts': 'RTS/CTS (аппаратный)',
'serial.field.localEcho': 'Принудительное локальное эхо',
'serial.field.localEchoDesc': 'Локально отображать вводимые символы (для устройств без удалённого эха)',
'serial.field.lineMode': 'Построчный режим',
'serial.field.lineModeDesc': 'Буферизовать ввод и отправлять по Enter (вместо посимвольной отправки)',
'serial.field.charset': 'Кодировка',
'serial.connectionError': 'Не удалось подключиться к последовательному порту',
'serial.field.baudRatePlaceholder': 'Выберите или введите скорость...',
'serial.field.baudRateEmpty': 'Введите пользовательскую скорость передачи',
'serial.field.customBaudRate': 'Используется пользовательская скорость передачи',
'serial.field.saveConfig': 'Сохранить конфигурацию',
'serial.field.saveConfigDesc': 'Сохраните эту последовательную конфигурацию в хостах для быстрого доступа',
'serial.field.configLabel': 'Имя конфигурации',
'serial.field.configLabelPlaceholder': 'например, Arduino Uno',
'serial.connectAndSave': 'Подключить и сохранить',
'serial.edit.title': 'Настройки последовательного порта',
// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': 'Требуется аутентификация',
'keyboard.interactive.desc': 'Сервер требует дополнительную аутентификацию.',
'keyboard.interactive.descWithHost': 'Сервер {hostname} требует дополнительную аутентификацию.',
'keyboard.interactive.response': 'Ответ',
'keyboard.interactive.enterCode': 'Введите код подтверждения',
'keyboard.interactive.enterResponse': 'Введите ответ',
'keyboard.interactive.submit': 'Отправить',
'keyboard.interactive.verifying': 'Проверка...',
'keyboard.interactive.savePassword': 'Сохранить пароль',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'Парольная фраза SSH-ключа',
'passphrase.desc': 'Введите парольную фразу для {keyName}',
'passphrase.descWithHost': 'Введите парольную фразу для {keyName}, чтобы подключиться к {hostname}',
'passphrase.label': 'Парольная фраза',
'passphrase.keyPath': 'Ключ',
'passphrase.unlock': 'Разблокировать',
'passphrase.unlocking': 'Разблокировка...',
'passphrase.skip': 'Пропустить',
'passphrase.remember': 'Запомнить эту парольную фразу',
// Text Editor
'sftp.editor.wordWrap': 'Перенос строк',
'sftp.editor.maximize': 'Развернуть',
'sftp.editor.unsavedTitle': 'Несохранённые изменения',
'sftp.editor.unsavedMessage': 'В файле {fileName} есть несохранённые изменения. Сохранить перед закрытием?',
'sftp.editor.discardChanges': 'Отбросить',
'sftp.editor.saveAndClose': 'Сохранить и закрыть',
'sftp.editor.quitBlockedByDirty': 'Есть несохранённые редакторы — перед выходом сохраните изменения или отбросьте их',
};
+837
View File
@@ -0,0 +1,837 @@
import type { Messages } from '../types';
export const ruVaultMessages: Messages = {
// Notes
'notes.search.placeholder': 'Поиск заметок, хостов, тегов...',
'notes.action.newNote': 'Новая заметка',
'notes.action.newGroup': 'Новая папка',
'notes.action.importMarkdown': 'Импорт Markdown',
'notes.action.exportNote': 'Экспорт заметки',
'notes.action.exportGroup': 'Экспорт папки',
'notes.action.exportAll': 'Экспорт заметок',
'notes.import.toast.success': 'Импортировано заметок: {count}',
'notes.import.toast.noNotes': 'Markdown-файлы не были импортированы.',
'notes.import.toast.failed': 'Не удалось импортировать Markdown-файлы.',
'notes.import.toast.inProgress': 'Импорт Markdown уже выполняется.',
'notes.import.toast.skipped': 'Пропущено неподдерживаемых файлов: {count}.',
'notes.export.toast.success': 'Экспортировано заметок: {count}',
'notes.export.toast.empty': 'Нет заметок для экспорта.',
'notes.export.toast.failed': 'Не удалось экспортировать заметки.',
'notes.codeBlock.copied': 'Скопировано',
'notes.codeBlock.copyFailed': 'Не удалось скопировать блок кода.',
'notes.group.prompt': 'Путь папки',
'notes.group.all': 'Блокнот',
'notes.group.ungrouped': 'Без группы',
'notes.section.notebooks': 'Блокнот',
'notes.section.notes': 'Заметки',
'notes.mode.edit': 'Правка',
'notes.mode.split': 'Разделить',
'notes.mode.preview': 'Просмотр',
'notes.title.placeholder': 'Заголовок заметки',
'notes.editor.placeholder': 'Пишите Markdown-заметки здесь...',
'notes.preview.empty': 'Пока нечего просматривать.',
'notes.empty.title': 'Заметок пока нет',
'notes.empty.desc': 'Создавайте Markdown-заметки для инструкций, напоминаний и передачи серверов.',
'notes.empty.group': 'В этом блокноте пока нет заметок.',
'notes.search.noResults': 'Заметки не найдены.',
// Vault hosts header/actions
'vault.hosts.search.placeholder': 'Найти хост или ssh user@hostname / ssh -p 2222 user@hostname...',
'vault.hosts.connect': 'Подключиться',
'vault.view.grid': 'Сетка',
'vault.view.list': 'Список',
'vault.view.tree': 'Дерево',
'vault.tree.expandAll': 'Развернуть все',
'vault.tree.collapseAll': 'Свернуть все',
'vault.hosts.newHost': 'Новый хост',
'vault.hosts.newGroup': 'Новая группа',
'vault.hosts.import': 'Импорт',
'vault.hosts.export': 'Экспорт',
'vault.hosts.export.toast.success': 'Экспортировано {count} хостов в CSV',
'vault.hosts.export.toast.successWithSkipped': 'Экспортировано {count} хостов в CSV ({skipped} неподдерживаемых хостов пропущено)',
'vault.hosts.export.toast.noHosts': 'Нет хостов для экспорта',
'vault.hosts.allHosts': 'Все хосты',
'vault.hosts.pinned': 'Закреплённые',
'vault.hosts.recentlyConnected': 'Недавно подключённые',
'vault.hosts.pinToTop': 'Закрепить сверху',
'vault.hosts.unpin': 'Открепить',
'vault.hosts.copyCredentials': 'Копировать учётные данные',
'vault.hosts.copyCredentials.toast.success': 'Учётные данные скопированы в буфер обмена',
'vault.hosts.copyCredentials.toast.noPassword': 'Для этого хоста нет сохранённого пароля',
'vault.hosts.multiSelect': 'Множественный выбор',
'vault.hosts.selected': 'Выбрано: {count}',
'vault.hosts.selectAll': 'Выбрать все',
'vault.hosts.deselectAll': 'Снять выделение',
'vault.hosts.deleteSelected': 'Удалить ({count})',
'vault.hosts.deleteMultiple.success': 'Удалено хостов: {count}',
'vault.hosts.connectSelected': 'Подключить ({count})',
'vault.hosts.connectMultiple.success': 'Подключение хостов: {count}',
'vault.hosts.moveToGroup.success': 'Хост {host} перемещён в {group}',
'vault.hosts.empty.title': 'Настройте свои хосты',
'vault.hosts.empty.desc': 'Сохраняйте хосты, чтобы быстро подключаться к серверам, виртуальным машинам и контейнерам.',
// Vault import
'vault.import.title': 'Добавить данные в хранилище',
'vault.import.desc':
'Перенесите свои подключения из популярных клиентов. Выберите формат файла, чтобы начать миграцию.',
'vault.import.chooseFormat': 'Выберите формат файла',
'vault.import.csv.tip': 'Массовый импорт: используйте шаблон CSV.',
'vault.import.csv.downloadTemplate': 'Скачать шаблон CSV',
'vault.import.toast.start': 'Импорт из {format}...',
'vault.import.toast.completedTitle': 'Импорт завершён',
'vault.import.toast.failedTitle': 'Ошибка импорта',
'vault.import.toast.noEntries': 'В {format} не найдено импортируемых записей.',
'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, если он корректен, иначе китайская кодировка 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': 'Только импорт',
'vault.import.sshConfig.importOnlyDesc': 'Одноразовый импорт. Изменения не будут синхронизироваться обратно в файл.',
'vault.import.sshConfig.managed': 'Управляемая синхронизация',
'vault.import.sshConfig.managedDesc': 'Поддерживать синхронизацию. Изменения будут сохраняться обратно в файл.',
'vault.import.sshConfig.managedGroup': 'ssh config',
'vault.import.sshConfig.managedSuccess': 'Импортировано {count} хостов. Файл теперь находится под управлением.',
'vault.import.sshConfig.alreadyManaged': 'Этот файл уже находится под управлением.',
'vault.import.sshConfig.alreadyManagedDesc': 'Этот файл уже управляется в группе "{group}". Если хотите импортировать его заново, сначала удалите существующий управляемый источник.',
'vault.import.sshConfig.noFilePath': 'Невозможно управлять этим файлом.',
'vault.import.sshConfig.noFilePathDesc': 'Не удалось определить путь к файлу. Для управляемой синхронизации нужен доступ к файловой системе.',
// Known Hosts
'knownHosts.search.placeholder': 'Поиск известных хостов...',
'knownHosts.action.scanSystem': 'Сканировать систему',
'knownHosts.action.importFile': 'Импортировать файл',
'knownHosts.action.browseFile': 'Выбрать файл',
'knownHosts.empty.title': 'Нет известных хостов',
'knownHosts.empty.desc':
'Известные хосты — это SSH-серверы, к которым вы подключались раньше. Импортируйте системный файл known_hosts, чтобы начать.',
'knownHosts.results.showingLimited':
'Показано {shown} из {total} хостов. Используйте поиск, чтобы найти нужные хосты.',
'knownHosts.toast.scanUnavailable': 'Сканирование системы недоступно на этой платформе.',
'knownHosts.toast.scanNoFile': 'Системный файл known_hosts не найден.',
'knownHosts.toast.scanNoEntries': 'В known_hosts не найдено пригодных записей.',
'knownHosts.toast.scanImported': 'Импортировано новых хостов: {count}.',
'knownHosts.toast.scanNoNew': 'Новых хостов не найдено.',
'knownHosts.toast.scanFailed': 'Не удалось просканировать системный known_hosts.',
// Port Forwarding
'pf.empty.title': 'Настройте проброс портов',
'pf.empty.desc': 'Сохраняйте правила проброса портов для доступа к базам данных, веб-приложениям и другим сервисам.',
'pf.title': 'Проброс портов',
'pf.rulesCount': 'Правил: {count}',
'pf.wizard.editTitle': 'Редактировать проброс портов',
'pf.wizard.newTitle': 'Новый проброс портов',
'pf.wizard.saveChanges': 'Сохранить изменения',
'pf.wizard.done': 'Готово',
'pf.wizard.continue': 'Продолжить',
'pf.wizard.cancel': 'Отмена',
'pf.wizard.skipWizard': 'Пропустить мастер',
'pf.error.hostNotFound': 'Хост не найден',
'pf.toast.titleWithLabel': 'Проброс портов: {label}',
'pf.type.local': 'Локальный',
'pf.type.remote': 'Удалённый',
'pf.type.dynamic': 'Динамический',
'pf.type.menu.local': 'Локальный проброс',
'pf.type.menu.remote': 'Удалённый проброс',
'pf.type.menu.dynamic': 'Динамический проброс',
'pf.type.local.desc': 'Локальный проброс позволяет обращаться к прослушиваемому порту удалённого сервера так, как будто он локальный.',
'pf.type.remote.desc': 'Удалённый проброс открывает порт на удалённой машине и перенаправляет подключения на локальный (текущий) хост.',
'pf.type.dynamic.desc': 'Динамический проброс портов превращает Netcatty в SOCKS-прокси-сервер.',
'pf.wizard.type.title': 'Выберите тип проброса портов:',
'pf.wizard.localConfig.title': 'Укажите локальный порт и адрес привязки:',
'pf.wizard.localConfig.desc': 'Этот порт будет открыт на локальном (текущем) устройстве и будет принимать трафик.',
'pf.wizard.localConfig.localPort': 'Номер локального порта *',
'pf.wizard.bindAddress': 'Адрес привязки',
'pf.wizard.remoteHost.title': 'Выберите удалённый хост:',
'pf.wizard.remoteHost.desc': 'Выберите хост, на котором будет открыт порт. Трафик с этого порта будет перенаправляться на конечный хост.',
'pf.wizard.remoteConfig.title': 'Укажите порт и адрес привязки:',
'pf.wizard.remoteConfig.desc': 'Трафик будет перенаправляться с указанного порта и адреса интерфейса выбранного хоста.',
'pf.wizard.remoteConfig.remotePort': 'Номер удалённого порта *',
'pf.wizard.destination.title': 'Выберите конечный хост:',
'pf.wizard.destination.desc.local': 'Введите удалённый адрес назначения, к которому вы хотите получить доступ через туннель.',
'pf.wizard.destination.desc.remote': 'Адрес назначения и порт, на которые будет перенаправляться трафик.',
'pf.wizard.destination.address': 'Адрес назначения *',
'pf.wizard.destination.addressPlaceholder': 'например, 127.0.0.1 или 192.168.1.100',
'pf.wizard.destination.port': 'Номер порта назначения *',
'pf.wizard.sshServer.title': 'Выберите SSH-сервер:',
'pf.wizard.sshServer.desc.dynamic': 'Выберите SSH-сервер, который будет работать как SOCKS-прокси.',
'pf.wizard.sshServer.desc.default': 'Выберите SSH-сервер, который будет туннелировать ваш трафик к адресу назначения.',
'pf.wizard.label.title': 'Выберите метку:',
'pf.wizard.label.placeholder.dynamic': 'например, SOCKS Proxy',
'pf.wizard.label.placeholder.default': 'например, MySQL Production',
'pf.wizard.label.placeholder.remoteRule': 'например, Remote Rule',
'pf.wizard.placeholders.portExample': 'например, {port}',
'pf.action.newForwarding': 'Новое правило',
'pf.form.labelPlaceholder': 'Метка правила',
'pf.form.intermediateHost': 'Промежуточный хост *',
'pf.form.createRule': 'Создать правило',
'pf.form.openWizard': 'Открыть мастер',
'pf.form.openWizardTitle': 'Открыть мастер проброса портов',
'pf.view.grid': 'Сетка',
'pf.view.list': 'Список',
'pf.rule.summary.local': 'Локально {bindAddress}:{localPort} -> SSH-туннель -> удалённо {remoteHost}:{remotePort}',
'pf.rule.summary.remote': 'Удалённо {bindAddress}:{localPort} -> SSH-туннель -> локально {remoteHost}:{remotePort}',
'pf.rule.summary.dynamic': 'SOCKS на {bindAddress}:{localPort}',
'pf.tooltip.relayHost': 'Промежуточный хост',
'pf.tooltip.hostLabel': 'Хост',
'pf.tooltip.hostAddress': 'Адрес',
'pf.tooltip.noHost': 'Промежуточный хост не настроен',
'pf.tooltip.localDesc': 'Локальный проброс портов: доступ к удалённым сервисам через SSH-туннель',
'pf.tooltip.remoteDesc': 'Удалённый проброс портов: публикация локальных сервисов на удалённом хосте',
'pf.tooltip.dynamicDesc': 'Динамический SOCKS-прокси: маршрутизация трафика через SSH-туннель',
'pf.deleteActive.title': 'Удалить активное правило проброса портов?',
'pf.deleteActive.desc': 'Правило проброса портов "{label}" сейчас активно. При удалении туннель будет сначала остановлен.',
'pf.deleteActive.confirm': 'Остановить и удалить',
'pf.form.autoStart': 'Автозапуск',
'pf.form.autoStartDesc': 'Автоматически запускать это правило при запуске приложения',
// SFTP
'sftp.newFolder': 'Новая папка',
'sftp.newFile': 'Новый файл',
'sftp.filter': 'Фильтр',
'sftp.filter.placeholder': 'Фильтр по имени файла...',
'sftp.bookmark.add': 'Добавить путь в закладки',
'sftp.bookmark.remove': 'Удалить закладку',
'sftp.bookmark.list': 'Закладки путей',
'sftp.bookmark.addGlobal': '+Глобальная',
'sftp.bookmark.addGlobalTooltip': 'Сохранить как глобальную закладку (общую для всех хостов)',
'sftp.bookmark.empty': 'Пока нет закладок',
'sftp.columns.name': 'Имя',
'sftp.columns.modified': 'Изменён',
'sftp.columns.size': 'Размер',
'sftp.columns.kind': 'Тип',
'sftp.columns.configure': 'Выбрать видимые столбцы',
'sftp.columns.actions': 'Действия',
'sftp.emptyDirectory': 'Пустой каталог',
'sftp.nav.up': 'Наверх',
'sftp.nav.home': 'Перейти в домашний каталог',
'sftp.nav.refresh': 'Обновить',
'sftp.upload': 'Загрузить',
'sftp.uploadFiles': 'Загрузить файлы',
'sftp.uploadFolder': 'Загрузить папку',
'sftp.dragDropToUpload': 'Перетащите сюда файлы для загрузки',
'sftp.retry': 'Повторить',
'sftp.context.open': 'Открыть',
'sftp.context.navigateTo': 'Перейти к',
'sftp.context.moveTo': 'Переместить в...',
'sftp.context.moveToParent': 'Переместить в родительский каталог',
'sftp.moveTo.title': 'Переместить в каталог',
'sftp.moveTo.placeholder': 'Введите путь к целевому каталогу',
'sftp.moveTo.confirm': 'Переместить',
'sftp.moveTo.pathNotFound': 'Каталог не найден или недоступен',
'sftp.context.download': 'Скачать',
'sftp.context.copyToOtherPane': 'Копировать в другую панель',
'sftp.copyToOtherPane.unavailable': 'Откройте двухпанельный режим SFTP и подключите панель назначения перед копированием файлов.',
'sftp.copyCurrentPath': 'Копировать текущий путь',
'sftp.copyCurrentPath.success': 'Текущий путь скопирован',
'sftp.copyCurrentPath.error': 'Не удалось скопировать текущий путь',
'sftp.viewMode.label': 'Режим просмотра',
'sftp.viewMode.list': 'Список',
'sftp.viewMode.tree': 'Дерево',
'sftp.viewMode.switchToList': 'Переключиться на список',
'sftp.viewMode.switchToTree': 'Переключиться на дерево',
'sftp.tree.loadError': 'Не удалось загрузить каталог',
'sftp.tree.loading': 'Загрузка...',
'sftp.kind.folder': 'Папка',
'sftp.context.rename': 'Переименовать',
'sftp.context.permissions': 'Права доступа',
'sftp.context.delete': 'Удалить',
'sftp.context.refresh': 'Обновить',
'sftp.context.uploadFiles': 'Загрузить файл(ы)...',
'sftp.context.uploadFilesHere': 'Загрузить файлы сюда...',
'sftp.context.uploadFolder': 'Загрузить папку...',
'sftp.context.uploadFolderHere': 'Загрузить папку сюда...',
'sftp.context.downloadSelected': 'Скачать выбранное ({count})',
'sftp.context.deleteSelected': 'Удалить выбранное ({count})',
'sftp.dropFilesHere': 'Перетащите сюда файлы',
'sftp.itemsCount': '{count} записей',
'sftp.selectedCount': '{count} выбрано',
'sftp.path.doubleClickToEdit': 'Дважды щёлкните, чтобы изменить путь',
'sftp.showHiddenPaths': 'Скрытые пути',
'sftp.task.waiting': 'Ожидание...',
'sftp.transfer.preparing': 'подготовка...',
'sftp.status.loading': 'Загрузка...',
'sftp.status.uploading': 'Загрузка...',
'sftp.status.ready': 'Готово',
'sftp.transfers': 'Передачи',
'sftp.transfers.active': '{count} активн(ый/ых)',
'sftp.transfers.clearCompleted': 'Очистить завершённые',
'sftp.transfers.calculatingTotal': 'Вычисление общего размера...',
'sftp.transfers.filesCount': '{count} файл(ов)',
'sftp.transfers.filesProgress': '{current}/{total} файл(ов)',
'sftp.transfers.expandChildren': 'Показать файлы',
'sftp.transfers.collapseChildren': 'Скрыть файлы',
'sftp.transfers.expandChildList': 'Показать детали',
'sftp.transfers.collapseChildList': 'Скрыть',
'sftp.transfers.retryAction': 'Повторить',
'sftp.transfers.dismissAction': 'Скрыть',
'sftp.transfers.openTargetFolder': 'Открыть целевую папку',
'sftp.transfers.openTargetFolderError': 'Не удалось открыть целевую папку',
'sftp.transfers.copyTargetPath': 'Копировать целевой путь',
'sftp.transfers.copyTargetPathSuccess': 'Целевой путь скопирован',
'sftp.transfers.copyTargetPathError': 'Не удалось скопировать целевой путь',
'sftp.transfers.resizeNameColumn': 'Изменить ширину столбца имени файла',
'sftp.transfers.dragToResize': 'Перетащите для изменения размера',
'sftp.goUp': 'Наверх',
'sftp.goToTerminalCwd': 'Перейти в каталог терминала',
'sftp.followTerminalCwd': 'Следовать за каталогом терминала',
'sftp.followTerminalCwd.enable': 'Включить следование за каталогом терминала',
'sftp.followTerminalCwd.disable': 'Отключить следование за каталогом терминала',
'sftp.encoding.label': 'Кодировка имён файлов',
'sftp.encoding.auto': 'Авто',
'sftp.encoding.utf8': 'UTF-8',
'sftp.encoding.gb18030': 'GB18030',
'sftp.goHome': 'Перейти в домашний каталог',
'sftp.folderName': 'Имя папки',
'sftp.folderName.placeholder': 'Введите имя папки',
'sftp.fileName': 'Имя файла',
'sftp.fileName.placeholder': 'Введите имя файла',
'sftp.prompt.newFolderName': 'Имя новой папки?',
'sftp.rename.title': 'Переименовать',
'sftp.rename.newName': 'Новое имя',
'sftp.rename.placeholder': 'Введите новое имя',
'sftp.confirm.deleteOne': 'Удалить "{name}"?',
'sftp.deleteConfirm.single': 'Удалить "{name}"?',
'sftp.deleteConfirm.title': 'Удалить {count} элемент(ов)?',
'sftp.deleteConfirm.desc': 'Это действие нельзя отменить. Будет удалено следующее:',
'sftp.deleteConfirm.descSingle': 'Это действие нельзя отменить.',
'sftp.deleteConfirm.host': 'Хост',
'sftp.deleteConfirm.path': 'Путь',
'sftp.error.loadFailed': 'Не удалось загрузить каталог',
'sftp.error.downloadFailed': 'Ошибка скачивания',
'sftp.error.uploadFailed': 'Ошибка загрузки',
'sftp.error.deleteFailed': 'Ошибка удаления',
'sftp.error.createFolderFailed': 'Не удалось создать папку',
'sftp.error.createFileFailed': 'Не удалось создать файл',
'sftp.error.invalidFileName': 'Имя файла содержит недопустимые символы: {chars}',
'sftp.error.reservedName': 'Это имя файла зарезервировано системой',
'sftp.overwrite.title': 'Файл уже существует',
'sftp.overwrite.desc': 'Файл с именем "{name}" уже существует. Хотите заменить его?',
'sftp.overwrite.confirm': 'Заменить',
'sftp.error.renameFailed': 'Не удалось переименовать',
'sftp.picker.title': 'Выберите хост',
'sftp.picker.desc': 'Выберите хост для панели {side}',
'sftp.picker.searchPlaceholder': 'Поиск хостов...',
'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': 'Владелец',
'sftp.permissions.group': 'Группа',
'sftp.permissions.others': 'Остальные',
'sftp.permissions.octal': 'Восьмеричный',
'sftp.permissions.symbolic': 'Символьный',
'sftp.permissions.success': 'Права доступа успешно обновлены',
'sftp.permissions.failed': 'Не удалось обновить права доступа',
'sftp.pane.local': 'Локально',
'sftp.pane.remote': 'Удалённо',
'sftp.pane.selectHost': 'Выберите хост',
'sftp.pane.selectHostToStart': 'Выберите хост для начала',
'sftp.pane.chooseFilesystem': 'Выберите локальную или удалённую файловую систему для просмотра',
'sftp.tabs.addTab': 'Добавить новую вкладку',
'sftp.tabs.closeTab': 'Закрыть вкладку',
'sftp.tabs.newTab': 'Новая вкладка',
'sftp.tabs.copyDefaultPath': 'Копировать вкладку (путь по умолчанию)',
'sftp.tabs.copyCurrentPath': 'Копировать и перейти к текущему пути',
'sftp.conflict.title': 'Конфликт файлов',
'sftp.conflict.desc': 'В месте назначения уже существует файл с таким именем',
'sftp.conflict.alreadyExistsSuffix': 'уже существует',
'sftp.conflict.existingFile': 'Существующий файл',
'sftp.conflict.newFile': 'Новый файл',
'sftp.conflict.size': 'Размер:',
'sftp.conflict.modified': 'Изменён:',
'sftp.conflict.applyToAll': 'Применить это действие ко всем оставшимся конфликтам ({count})',
'sftp.conflict.action.stop': 'Остановить',
'sftp.conflict.action.skip': 'Пропустить',
'sftp.conflict.action.keepBoth': 'Сохранить оба',
'sftp.conflict.action.duplicate': 'Дублировать',
'sftp.conflict.action.merge': 'Объединить',
'sftp.conflict.action.replace': 'Заменить',
// SFTP Upload Phases
'sftp.upload.phase.compressing': 'Сжатие',
'sftp.upload.phase.uploading': 'Загрузка',
'sftp.upload.phase.extracting': 'Распаковка',
'sftp.upload.phase.compressed': 'Сжато',
// SFTP File Opener
'sftp.context.copyPath': 'Копировать путь к файлу',
'sftp.context.openWithDefault': 'Открыть в системном приложении',
'sftp.context.openWith': 'Открыть с помощью...',
'sftp.context.edit': 'Редактировать',
'sftp.context.preview': 'Предпросмотр',
'sftp.opener.title': 'Открыть с помощью',
'sftp.opener.desc': 'Выберите приложение для открытия этого файла',
'sftp.opener.builtInEditor': 'Встроенный редактор',
'sftp.opener.editDescription': 'Редактировать текстовые файлы',
'sftp.opener.builtInImageViewer': 'Встроенный просмотрщик изображений',
'sftp.opener.previewDescription': 'Просмотр изображений',
'sftp.opener.systemApp': 'Выбрать приложение...',
'sftp.opener.systemAppDescription': 'Выберите приложение на вашем компьютере',
'sftp.opener.onlySystemApp': 'Этот файл можно открыть только во внешнем приложении',
'sftp.opener.noAppsAvailable': 'Нет доступных приложений',
'sftp.opener.noExtension': 'файлы без расширения',
'sftp.opener.setDefault': 'Всегда использовать это для файлов {ext}',
'sftp.opener.confirmTitle': 'Установить по умолчанию?',
'sftp.opener.confirmDescription': 'Хотите всегда использовать {app} для файлов {ext}?',
'sftp.opener.yesRemember': 'Да, запомнить выбор',
'sftp.opener.justOnce': 'Только один раз',
'sftp.opener.confirm.title': 'Установить приложение по умолчанию',
'sftp.opener.confirm.desc': 'Хотите всегда открывать файлы .{ext} этим приложением?',
'sftp.editor.title': 'Текстовый редактор',
'sftp.editor.save': 'Сохранить на удалённый сервер',
'sftp.editor.saving': 'Сохранение...',
'sftp.editor.saved': 'Успешно сохранено',
'sftp.editor.saveFailed': 'Не удалось сохранить файл',
'sftp.editor.unsavedChanges': 'У вас есть несохранённые изменения. Всё равно закрыть?',
'sftp.editor.syntaxHighlight': 'Подсветка синтаксиса',
'sftp.preview.title': 'Просмотр изображения',
'sftp.preview.zoomIn': 'Увеличить',
'sftp.preview.zoomOut': 'Уменьшить',
'sftp.preview.resetZoom': 'Сбросить масштаб',
'sftp.preview.fitToWindow': 'Подогнать по окну',
// Settings > SFTP File Associations
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftp.transferConcurrency': 'Параллелизм передачи',
'settings.sftp.transferConcurrency.desc': 'Количество файлов, передаваемых параллельно при загрузке или скачивании папок. Более высокие значения могут ускорить работу, но способны перегрузить некоторые серверы.',
'settings.sftp.defaultOpener': 'Приложение для открытия по умолчанию',
'settings.sftp.defaultOpener.desc': 'Выберите приложение по умолчанию для открытия файлов без конкретной ассоциации',
'settings.sftp.defaultOpener.ask': 'Всегда спрашивать',
'settings.sftp.defaultOpener.askDesc': 'Каждый раз показывать диалог выбора приложения',
'settings.sftp.defaultOpener.builtInDesc': 'По умолчанию открывать текстовые файлы во встроенном редакторе',
'settings.sftp.defaultOpener.systemApp': 'Выбрать приложение...',
'settings.sftp.defaultOpener.systemAppDesc': 'По умолчанию открывать файлы в конкретном приложении',
'settings.sftpFileAssociations.title': 'Ассоциации файлов SFTP',
'settings.sftpFileAssociations.desc': 'Настройка приложений по умолчанию для открытия файлов по расширению',
'settings.sftpFileAssociations.extension': 'Расширение',
'settings.sftpFileAssociations.application': 'Приложение',
'settings.sftpFileAssociations.noAssociations': 'Ассоциации файлов не настроены',
'settings.sftpFileAssociations.remove': 'Удалить',
'settings.sftpFileAssociations.removeConfirm': 'Удалить ассоциацию для .{ext}?',
// Settings > SFTP Behavior
'settings.sftp.doubleClickBehavior': 'Поведение двойного щелчка',
'settings.sftp.doubleClickBehavior.desc': 'Выберите действие при двойном щелчке по файлу в SFTP-режиме',
'settings.sftp.doubleClickBehavior.open': 'Открыть файл',
'settings.sftp.doubleClickBehavior.transfer': 'Передать в другую панель',
'settings.sftp.doubleClickBehavior.openDesc': 'Открыть файл в приложении по умолчанию',
'settings.sftp.doubleClickBehavior.transferDesc': 'Передать файл на активный хост другой панели',
'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': 'Автосинхронизация с удалённым сервером',
'settings.sftp.autoSync.desc': 'Автоматически синхронизировать изменения файлов обратно на удалённый сервер при открытии файлов во внешних приложениях',
'settings.sftp.autoSync.enable': 'Включить автосинхронизацию',
'settings.sftp.autoSync.enableDesc': 'Когда вы сохраняете файл во внешнем приложении, изменения автоматически загружаются на удалённый сервер',
// Settings > SFTP Auto Open Sidebar
'settings.sftp.autoOpenSidebar': 'Автооткрытие боковой панели при подключении',
'settings.sftp.autoOpenSidebar.desc': 'Автоматически открывать боковую панель файлового браузера SFTP при подключении к хосту',
'settings.sftp.autoOpenSidebar.enable': 'Включить автооткрытие боковой панели',
'settings.sftp.autoOpenSidebar.enableDesc': 'Боковая панель SFTP будет автоматически открываться при подключении терминальной сессии к удалённому хосту',
'settings.sftp.followTerminalCwd': 'Следовать за каталогом терминала',
'settings.sftp.followTerminalCwd.desc': 'Автоматически синхронизировать боковую панель SFTP с рабочим каталогом терминала (переключатель на панели инструментов)',
'settings.sftp.followTerminalCwd.enable': 'Включать следование по умолчанию',
'settings.sftp.followTerminalCwd.enableDesc': 'При открытой боковой панели SFTP режим следования включён по умолчанию и обновляется после команд cd в терминале',
'settings.sftp.defaultViewMode': 'Режим просмотра по умолчанию',
'settings.sftp.defaultViewMode.desc': 'Выберите режим просмотра по умолчанию при открытии новой вкладки SFTP. Настройки конкретного хоста имеют приоритет.',
'settings.sftp.defaultViewMode.list': 'Список',
'settings.sftp.defaultViewMode.listDesc': 'Показывать файлы в виде плоского списка для текущего каталога',
'settings.sftp.defaultViewMode.tree': 'Дерево',
'settings.sftp.defaultViewMode.treeDesc': 'Показывать файлы в иерархической древовидной структуре',
'sftp.autoSync.success': 'Файл синхронизирован с удалённым сервером: {fileName}',
'sftp.autoSync.error': 'Не удалось синхронизировать файл: {error}',
// SFTP Folder Upload Progress
'sftp.upload.progress': 'Загрузка файлов {current} из {total}...',
'sftp.upload.uploading': 'Загрузка...',
'sftp.upload.compressing': 'Сжатие...',
'sftp.upload.extracting': 'Распаковка...',
'sftp.upload.scanning': 'Сканирование файлов...',
'sftp.upload.completed': 'Завершено',
'sftp.upload.compressed': 'Сжатая передача',
'sftp.upload.currentFile': 'Текущий: {fileName}',
'sftp.upload.cancelled': 'Загрузка отменена',
'sftp.upload.cancel': 'Отмена',
'sftp.upload.completedToPath': 'Загружено в {path}',
// SFTP Download
'sftp.download.completed': 'Скачано',
'sftp.download.cancelled': 'Скачивание отменено',
// SFTP Reconnecting
'sftp.reconnecting.title': 'Переподключение...',
'sftp.reconnecting.desc': 'Соединение потеряно, выполняется попытка переподключения',
'sftp.reconnected': 'Соединение восстановлено',
'sftp.error.reconnectFailed': 'Не удалось переподключиться. Попробуйте ещё раз.',
'sftp.error.connectionLostManual': 'Соединение потеряно. Пожалуйста, переподключитесь вручную.',
'sftp.error.connectionLostReconnecting': 'Соединение потеряно. Переподключение...',
'sftp.error.sessionLost': 'SFTP-сессия потеряна. Пожалуйста, переподключитесь.',
// Settings > SFTP Show Hidden Files
'settings.sftp.showHiddenFiles': 'Показывать скрытые файлы',
'settings.sftp.showHiddenFiles.desc': 'Показывать скрытые файлы (dotfiles в Unix/macOS и файлы с атрибутом hidden в Windows) в файловом браузере SFTP.',
'settings.sftp.showHiddenFiles.enable': 'Показывать скрытые файлы',
'settings.sftp.showHiddenFiles.enableDesc': 'Показывать скрытые файлы при просмотре как локальной, так и удалённой файловой системы',
// Settings > SFTP Compressed Upload
'settings.sftp.compressedUpload': 'Передача со сжатием папок',
'settings.sftp.compressedUpload.desc': 'Сжимать папки перед загрузкой, чтобы значительно сократить время передачи.',
'settings.sftp.compressedUpload.enable': 'Включить сжатие папок',
'settings.sftp.compressedUpload.enableDesc': 'Автоматически сжимать папки с помощью tar перед передачей. Требует поддержки tar на сервере. Если она недоступна, будет использована обычная передача.',
// Quick Switcher
'qs.search.placeholder': 'Поиск хостов или вкладок',
'qs.jumpTo': 'Перейти к',
'qs.localTerminal': 'Локальный терминал',
'qs.localShells': 'Локальные оболочки',
'qs.default': 'По умолчанию',
// Select Host panel
'selectHost.title': 'Выберите хост',
'selectHost.noHostsFound': 'Хосты не найдены',
'selectHost.newHost': 'Новый хост',
'selectHost.continue': 'Продолжить',
'selectHost.continueWithCount': 'Продолжить (выбрано: {count})',
// Quick Connect
'quickConnect.knownHost.title': 'Вы уверены, что хотите подключиться?',
'quickConnect.knownHost.authenticity': 'Подлинность {hostname} не может быть установлена.',
'quickConnect.knownHost.fingerprintLabel': '{keyType} fingerprint is SHA256:',
'quickConnect.knownHost.addQuestion': 'Хотите добавить его в список известных хостов?',
'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': 'Ошибка подключения',
// Protocol select dialog
'protocolSelect.chooseProtocol': 'Выберите протокол',
'protocolSelect.port': 'порт:',
// Host Details
'hostDetails.title.details': 'Сведения о хосте',
'hostDetails.title.new': 'Новый хост',
'vault.panel.resizeWidth': 'Изменить ширину панели',
'hostDetails.saveAria': 'Сохранить',
'hostDetails.section.address': 'Адрес',
'hostDetails.hostname.placeholder': 'IP или имя хоста',
'hostDetails.section.general': 'Общие',
'hostDetails.section.sftp': 'Настройки SFTP',
'hostDetails.sftp.sudo': 'Режим sudo',
'hostDetails.sftp.sudo.desc': 'Автоматически получать привилегии Root с помощью сохранённого пароля',
'hostDetails.sftp.sudo.passwordWarning': 'Для режима sudo требуется пароль. Укажите его выше или убедитесь, что сервер разрешает sudo без пароля.',
'hostDetails.sftp.encoding': 'Кодировка имён файлов',
'hostDetails.sftp.encoding.desc': 'Выберите кодировку, используемую для декодирования и отправки имён файлов SFTP.',
'hostDetails.label.placeholder': 'Метка (например, Production Server)',
'hostDetails.notes.label': 'Заметки',
'hostDetails.notes.placeholder': 'Оборудование, проект, клиент, регион, роль...',
'hostDetails.notes.help': 'Поддерживается Markdown. Не храните здесь пароли и закрытые ключи.',
'hostDetails.notes.tab.edit': 'Редактировать',
'hostDetails.notes.tab.preview': 'Просмотр',
'hostDetails.notes.preview.empty': 'Пока нечего просматривать.',
'hostDetails.notes.toggle.show': 'Показать редактор заметок',
'hostDetails.notes.toggle.hide': 'Скрыть редактор заметок',
'hostDetails.group.placeholder': 'Родительская группа',
'hostDetails.section.credentials': 'Учётные данные',
'hostDetails.section.portCredentials': 'Порт и учётные данные',
'hostDetails.section.appearance': 'Внешний вид',
'hostDetails.distro.title': 'Дистрибутив Linux',
'hostDetails.distro.desc': 'Автоопределение при подключении или ручная замена значка дистрибутива.',
'hostDetails.icon.title': 'Значок хоста',
'hostDetails.icon.sectionTitle': 'Цвет и значок',
'hostDetails.icon.desc': 'Выберите источник значка и цвет для этого хоста.',
'hostDetails.icon.source': 'Источник',
'hostDetails.icon.manualLabel': 'Значок вручную',
'hostDetails.icon.tab.brand': 'Бренд',
'hostDetails.icon.tab.type': 'Тип',
'hostDetails.icon.mode.auto': 'Авто',
'hostDetails.icon.mode.custom': 'Свой',
'hostDetails.icon.reset': 'Сбросить значок',
'hostDetails.icon.colorLabel': 'Цвет значка',
'hostDetails.icon.colorDefault': 'По умолчанию',
'hostDetails.icon.colorAuto': 'Авто',
'hostDetails.icon.colorManual': 'Вручную',
'hostDetails.icon.colorChoice': 'Цвет',
'hostDetails.icon.autoColorLabel': 'Текущий цвет',
'hostDetails.icon.preview': 'Предпросмотр значка',
'hostDetails.icon.showLibrary': 'Показать библиотеку значков',
'hostDetails.icon.hideLibrary': 'Скрыть библиотеку значков',
'hostDetails.icon.autoUsesDistro': 'Использует значок дистрибутива Linux и выбранный цвет для этого хоста.',
'hostDetails.icon.customOverridesDistro': 'Встроенный значок заменяет значок дистрибутива Linux для этого хоста.',
'hostDetails.icon.option.server': 'Сервер',
'hostDetails.icon.option.terminal': 'Терминал',
'hostDetails.icon.option.database': 'База данных',
'hostDetails.icon.option.cloud': 'Облако',
'hostDetails.icon.option.router': 'Маршрутизатор',
'hostDetails.icon.option.shield': 'Защита',
'hostDetails.icon.option.code': 'Код',
'hostDetails.icon.option.box': 'Узел',
'hostDetails.icon.option.globe': 'Глобус',
'hostDetails.icon.option.cpu': 'CPU',
'hostDetails.icon.option.hard-drive': 'Хранилище',
'hostDetails.icon.option.network': 'Сеть',
'hostDetails.icon.option.wifi': 'Wi-Fi',
'hostDetails.icon.option.lock': 'Замок',
'hostDetails.icon.option.key': 'Ключ',
'hostDetails.icon.option.monitor': 'Монитор',
'hostDetails.icon.option.container': 'Контейнер',
'hostDetails.icon.option.activity': 'Активность',
'hostDetails.icon.option.zap': 'Быстрый',
'hostDetails.icon.option.server-cog': 'Настройки сервера',
'hostDetails.icon.color.blue': 'Синий',
'hostDetails.icon.color.green': 'Зеленый',
'hostDetails.icon.color.red': 'Красный',
'hostDetails.icon.color.amber': 'Янтарный',
'hostDetails.icon.color.purple': 'Фиолетовый',
'hostDetails.icon.color.cyan': 'Голубой',
'hostDetails.icon.color.orange': 'Оранжевый',
'hostDetails.icon.color.slate': 'Серый',
'hostDetails.icon.color.violet': 'Фиолетово-синий',
'hostDetails.icon.color.pink': 'Розовый',
'hostDetails.icon.color.rose': 'Розово-красный',
'hostDetails.icon.color.lime': 'Лаймовый',
'hostDetails.icon.color.teal': 'Бирюзовый',
'hostDetails.icon.color.sky': 'Небесный',
'hostDetails.icon.color.indigo': 'Индиго',
'hostDetails.icon.color.zinc': 'Цинковый',
'hostDetails.icon.color.custom': 'Свой цвет',
'hostDetails.distro.mode': 'Источник',
'hostDetails.distro.mode.auto': 'Автоопределение',
'hostDetails.distro.mode.manual': 'Ручное переопределение',
'hostDetails.distro.detectedLabel': 'Текущий',
'hostDetails.distro.manualLabel': 'Переопределить',
'hostDetails.distro.pending': 'Определится после первого подключения',
'hostDetails.distro.unknown': 'Неизвестно',
'hostDetails.distro.option.linux': 'Обычный Linux',
'hostDetails.distro.option.ubuntu': 'Ubuntu',
'hostDetails.distro.option.debian': 'Debian',
'hostDetails.distro.option.centos': 'CentOS',
'hostDetails.distro.option.rocky': 'Rocky Linux',
'hostDetails.distro.option.fedora': 'Fedora',
'hostDetails.distro.option.arch': 'Arch Linux',
'hostDetails.distro.option.alpine': 'Alpine',
'hostDetails.distro.option.amazon': 'Amazon Linux',
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
'hostDetails.distro.option.almalinux': 'AlmaLinux',
'hostDetails.distro.option.alinux': 'Alibaba Cloud Linux',
'hostDetails.distro.option.openeuler': 'openEuler',
'hostDetails.distro.option.oracle': 'Oracle Linux',
'hostDetails.distro.option.kali': 'Kali Linux',
'hostDetails.distro.option.cisco': 'Cisco',
'hostDetails.distro.option.juniper': 'Juniper Networks',
'hostDetails.distro.option.huawei': 'Huawei',
'hostDetails.distro.option.h3c': 'H3C',
'hostDetails.distro.option.hpe': 'HPE',
'hostDetails.distro.option.mikrotik': 'MikroTik',
'hostDetails.distro.option.fortinet': 'Fortinet',
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
'hostDetails.distro.option.zyxel': 'ZyXEL',
'hostDetails.distro.option.ruijie': 'Ruijie',
'hostDetails.section.mosh': 'Mosh',
'hostDetails.username.placeholder': 'Имя пользователя',
'hostDetails.password.placeholder': 'Пароль',
'hostDetails.password.show': 'Показать пароль',
'hostDetails.password.hide': 'Скрыть пароль',
'hostDetails.password.save': 'Сохранить пароль',
'hostDetails.identity.suggestions': 'Идентификаторы',
'hostDetails.identity.missing': 'Идентификатор не найден',
'hostDetails.credential.keyCertificate': 'Ключ, сертификат, локальный файл ключа',
'hostDetails.credential.key': 'Ключ',
'hostDetails.credential.certificate': 'Сертификат',
'hostDetails.credential.localKeyFile': 'Локальный файл ключа',
'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': 'Поиск сертификатов...',
'hostDetails.certs.empty': 'Нет доступных сертификатов',
'hostDetails.agentForwarding': 'Проброс SSH Agent',
'hostDetails.agentForwarding.desc': 'Разрешить удалённому серверу использовать ваши локальные SSH-ключи (например, для операций git)',
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent недоступен',
'hostDetails.agentForwarding.agentNotRunningHint': 'SSH Agent не обнаружен. Включите OpenSSH Authentication Agent в службах Windows или используйте совместимый агент, например Bitwarden, 1Password или gpg-agent.',
'hostDetails.section.agentForwarding': 'SSH Agent',
'hostDetails.x11Forwarding': 'Проброс X11-приложений',
'hostDetails.x11Forwarding.desc': 'Показывать удалённые графические приложения на вашем локальном рабочем столе, если запущен локальный X-сервер.',
'hostDetails.section.x11Forwarding': 'Проброс X11',
'hostDetails.section.deviceType': 'Тип устройства',
'hostDetails.deviceType': 'Режим сетевого устройства',
'hostDetails.deviceType.desc': 'Включайте для сетевого оборудования (коммутаторов, маршрутизаторов, межсетевых экранов), подключённого по SSH. Команды отправляются как есть, без обёртки оболочки, что совместимо с CLI вендоров вроде Huawei VRP и Cisco IOS.',
'hostDetails.deviceType.warning': 'Команды AI-агента будут отправляться напрямую без отслеживания кода выхода. Включайте только для устройств, на которых нет стандартной оболочки.',
'hostDetails.section.sshAlgorithms': 'SSH-алгоритмы',
'hostDetails.section.terminalBehavior': 'Поведение терминала',
'hostDetails.lineTimestamps': 'Показывать время вывода',
'hostDetails.lineTimestamps.desc': 'Показывать локальное время рядом с видимыми строками вывода для этого хоста, не изменяя текст терминала.',
'hostDetails.legacyAlgorithms': 'Разрешить устаревшие алгоритмы',
'hostDetails.legacyAlgorithms.desc': 'Включить устаревшие SSH-алгоритмы (diffie-hellman-group1, ssh-dss, 3des-cbc и т. д.) для подключения к старому сетевому оборудованию.',
'hostDetails.legacyAlgorithms.warning': 'У этих алгоритмов есть известные слабые места безопасности. Включайте только для устаревших устройств, которые не поддерживают современную криптографию.',
'hostDetails.skipEcdsaHostKey': 'Пропустить ECDSA host key',
'hostDetails.skipEcdsaHostKey.desc': 'Некоторые старые коммутаторы Huawei / Cisco выдают нестандартные подписи ECDSA host-key, из-за чего соединение падает с "signature verification failed". Включение этой опции убирает все ecdsa-sha2-* из предложения клиента, и согласование переходит к RSA / Ed25519.',
'hostDetails.algorithms.advanced': 'Дополнительные настройки алгоритмов',
'hostDetails.algorithms.advanced.desc': 'Заменить предлагаемый список алгоритмов для любой категории для конкретного хоста. Не трогать категорию = использовать значение по умолчанию; выбранное подмножество полностью заменяет список по умолчанию. Неверные значения могут сделать хост недоступным.',
'hostDetails.algorithms.inheritedNotice': 'В текущей группе заданы переопределения алгоритмов для: {categories}. Кнопка «Сбросить» здесь возвращает к спискам группы, а не к значениям NetCatty по умолчанию. Чтобы игнорировать ограничение группы, очистите переопределение в настройках алгоритмов группы.',
'hostDetails.algorithms.customized': 'настроено',
'hostDetails.algorithms.reset': 'Сбросить',
'hostDetails.algorithms.category.kex': 'Обмен ключами (KEX)',
'hostDetails.algorithms.category.cipher': 'Шифр',
'hostDetails.algorithms.category.hmac': 'MAC (HMAC)',
'hostDetails.algorithms.category.serverHostKey': 'Host Key',
'hostDetails.algorithms.category.compress': 'Сжатие',
'hostDetails.section.keepalive': 'Keepalive',
'hostDetails.keepalive.override': 'Переопределить глобальный keepalive',
'hostDetails.keepalive.desc': 'Использовать для этого хоста собственную политику keepalive вместо глобальной настройки. Полезно для старых маршрутизаторов и коммутаторов, чей SSH-сервер не отвечает на запросы keepalive@openssh.com. Установите интервал 0, чтобы полностью отключить keepalive для этого хоста.',
'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': 'Прокси через хосты',
'hostDetails.jumpHosts.hops': '{count} hop(s)',
'hostDetails.jumpHosts.direct': 'Напрямую',
'hostDetails.jumpHosts.configure': 'Настроить прокси-хосты',
'hostDetails.proxy': 'Прокси через HTTP/SOCKS5/Command',
'hostDetails.proxy.none': 'Нет',
'hostDetails.proxy.edit': 'Редактировать прокси',
'hostDetails.proxy.configure': 'Настроить прокси',
'hostDetails.proxyPanel.title': 'Прокси через HTTP/SOCKS5/Command',
'hostDetails.proxyPanel.hostPlaceholder': 'Прокси-хост',
'hostDetails.proxyPanel.command': 'ProxyCommand',
'hostDetails.proxyPanel.commandPlaceholder': 'cloudflared access ssh --hostname %h',
'hostDetails.proxyPanel.commandHelp': 'Используйте %h для целевого хоста, %p для целевого порта и %% для символа процента.',
'hostDetails.proxyPanel.credentials': 'Учётные данные',
'hostDetails.proxyPanel.usernamePlaceholder': 'Имя пользователя',
'hostDetails.proxyPanel.passwordPlaceholder': 'Пароль',
'hostDetails.proxyPanel.identities': 'Идентификаторы',
'hostDetails.proxyPanel.keychainIdentity': 'Идентификатор из связки ключей',
'hostDetails.proxyPanel.manualCredentials': 'Ввести вручную',
'hostDetails.proxyPanel.remove': 'Удалить прокси',
'hostDetails.proxyPanel.savedProxy': 'Сохранённый прокси',
'hostDetails.proxyPanel.selectSaved': 'Выбрать сохранённый прокси',
'hostDetails.proxyPanel.customProxy': 'Пользовательский прокси',
'hostDetails.proxyPanel.missing': 'Отсутствует',
'hostDetails.proxyPanel.missingSaved': 'Сохранённый прокси отсутствует',
'hostDetails.proxyPanel.missingIdentity': 'Идентификатор из связки ключей отсутствует',
'hostDetails.proxyPanel.incompleteIdentity': 'Для идентификатора прокси нужны имя пользователя и пароль',
'hostDetails.proxyPanel.unreadableIdentity': 'Пароль идентификатора прокси нельзя прочитать на этом устройстве',
'hostDetails.proxyPanel.error.required': 'Требуются хост и порт прокси или ProxyCommand.',
'hostDetails.envVars': 'Переменные окружения',
'hostDetails.envVars.add': 'Добавить переменную окружения',
'hostDetails.envVars.title': 'Переменные окружения',
'hostDetails.envVars.desc': 'Задайте переменную окружения для {host}.',
'hostDetails.envVars.note': 'Некоторые SSH-серверы по умолчанию разрешают только переменные с префиксом LC_ и LANG_.',
'hostDetails.envVars.variable': 'Переменная',
'hostDetails.envVars.value': 'Значение',
'hostDetails.envVars.newVariable': 'Новая переменная',
'hostDetails.envVars.variableName': 'Имя переменной',
'hostDetails.chain.title': 'Редактировать цепочку',
'hostDetails.chain.desc': 'Добавление ещё одного хоста создаст подключение к {host}.',
'hostDetails.chain.addHost': 'Добавить хост',
'hostDetails.chain.target': 'Цель',
'hostDetails.chain.availableHosts': 'Доступные хосты',
'hostDetails.chain.clear': 'Очистить',
'hostDetails.group.title': 'Новая группа',
'hostDetails.group.general': 'Общие',
'hostDetails.group.namePlaceholder': 'Имя группы',
'hostDetails.group.parentPlaceholder': 'Родительская группа',
'hostDetails.group.cloudSync': 'Облачная синхронизация',
'hostDetails.group.addProtocol': 'Добавить протокол',
'hostDetails.startupCommand': 'Команда запуска',
'hostDetails.startupCommand.placeholder': 'Команда для запуска при подключении (например, cd /app && ls)',
'hostDetails.startupCommand.help':
'This command will be executed automatically after SSH connection is established.',
'hostDetails.startupCommand.runMode': 'Многострочный запуск',
'hostDetails.startupCommand.runMode.help': 'Выберите, как отправлять многострочные команды запуска после подключения.',
'hostDetails.startupCommand.runMode.lineDelay': 'Отправлять построчно',
'hostDetails.startupCommand.runMode.paste': 'Отправить сразу',
'hostDetails.otherProtocols': 'Другие протоколы',
'hostDetails.telnetOn': 'Telnet на',
'hostDetails.port': 'порт',
'hostDetails.telnet.credentials': 'Учётные данные',
'hostDetails.telnet.identity.placeholder': 'Ручной идентификатор',
'hostDetails.telnet.username': 'Имя пользователя Telnet',
'hostDetails.telnet.password': 'Пароль Telnet',
'hostDetails.charset.placeholder': 'Кодировка (например, UTF-8)',
'hostDetails.telnet.add': 'Добавить протокол Telnet',
'hostDetails.telnet.setDefault': 'Подключаться по Telnet по умолчанию',
'hostDetails.tags': 'Теги',
'hostDetails.group': 'Группа',
'hostDetails.selectGroup': 'Выберите группу',
'hostDetails.addTag': 'Добавить тег...',
'hostDetails.createTag': 'Создать тег',
'hostDetails.createGroup': 'Создать группу',
// Host form (legacy modal)
'hostForm.title.edit': 'Редактировать хост',
'hostForm.title.new': 'Новый хост',
'hostForm.desc.edit': 'Обновите параметры подключения для этого хоста',
'hostForm.desc.new': 'Создайте новую запись SSH-хоста',
'hostForm.field.label': 'Метка',
'hostForm.placeholder.label': 'Мой production-сервер',
'hostForm.field.hostname': 'Имя хоста / IP',
'hostForm.placeholder.hostname': '192.168.1.1',
'hostForm.field.port': 'Порт',
'hostForm.field.username': 'Имя пользователя',
'hostForm.field.osType': 'Тип ОС',
'hostForm.placeholder.selectOs': 'Выберите ОС',
'hostForm.field.group': 'Группа',
'hostForm.placeholder.group': 'например, AWS, DigitalOcean',
'hostForm.field.tags': 'Теги',
'hostForm.placeholder.addTag': 'Добавить тег...',
'hostForm.auth.method': 'Метод аутентификации',
'hostForm.auth.password': 'Пароль',
'hostForm.auth.sshKey': 'SSH-ключ',
'hostForm.auth.selectKey': 'Выберите SSH-ключ',
'hostForm.auth.noKeys': 'Нет доступных ключей',
'hostForm.auth.noKeysHint': 'В связке ключей не найдено SSH-ключей. Сначала создайте один.',
'hostForm.saveHost': 'Сохранить хост',
};
@@ -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);
}
});
@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
import { HOST_ICON_COLORS, HOST_ICON_IDS } from "../../../domain/hostIcon.ts";
import zhCN from "./zh-CN.ts";
import ru from "./ru.ts";
const LOCALIZED_SETTINGS_LOCALES = [
{ name: "zh-CN", messages: zhCN },
{ name: "ru", messages: ru },
];
test("localized settings include names for every default shortcut", () => {
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
const missing = DEFAULT_KEY_BINDINGS
.map((binding) => `settings.shortcuts.binding.${binding.id}`)
.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing shortcut labels`);
}
});
test("localized settings include workspace focus indicator labels", () => {
const keys = [
"settings.terminal.section.workspaceFocus",
"settings.terminal.workspaceFocus.style",
"settings.terminal.workspaceFocus.style.desc",
"settings.terminal.workspaceFocus.dim",
"settings.terminal.workspaceFocus.border",
];
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
const missing = keys.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing workspace focus 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",
"settings.terminal.font.weight.extraLight",
"settings.terminal.font.weight.light",
"settings.terminal.font.weight.normal",
"settings.terminal.font.weight.medium",
"settings.terminal.font.weight.semiBold",
"settings.terminal.font.weight.bold",
"settings.terminal.font.weight.extraBold",
"settings.terminal.font.weight.black",
];
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
const missing = keys.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing font weight labels`);
}
});
test("localized vault messages include host icon labels", () => {
const keys = [
"hostDetails.icon.title",
"hostDetails.icon.desc",
"hostDetails.icon.mode.auto",
"hostDetails.icon.mode.custom",
"hostDetails.icon.reset",
"hostDetails.icon.showLibrary",
"hostDetails.icon.hideLibrary",
"hostDetails.icon.autoUsesDistro",
"hostDetails.icon.customOverridesDistro",
...HOST_ICON_IDS.map((id) => `hostDetails.icon.option.${id}`),
...HOST_ICON_COLORS.map((color) => `hostDetails.icon.color.${color.id}`),
];
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
const missing = keys.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing host icon labels`);
}
});
@@ -0,0 +1,28 @@
import test from "node:test";
import assert from "node:assert/strict";
import en from "./en.ts";
import ru from "./ru.ts";
import zhCN from "./zh-CN.ts";
const osc7Keys = [
"terminal.toolbar.configureOsc7",
"terminal.osc7Setup.title",
"terminal.osc7Setup.desc",
"terminal.osc7Setup.targets",
"terminal.osc7Setup.command",
"terminal.osc7Setup.run",
"terminal.osc7Setup.running",
"terminal.osc7Setup.configured",
"terminal.osc7Setup.failed",
"terminal.osc7Setup.sent",
] as const;
test("OSC 7 setup copy exists in every bundled locale", () => {
for (const [locale, messages] of Object.entries({ en, ru, zhCN })) {
for (const key of osc7Keys) {
assert.equal(typeof messages[key], "string", `${locale} is missing ${key}`);
assert.notEqual(messages[key], "", `${locale} has empty ${key}`);
}
}
});
+1
View File
@@ -0,0 +1 @@
export type Messages = Record<string, string>;
File diff suppressed because it is too large. Load diff
+492
View File
@@ -0,0 +1,492 @@
import type { Messages } from '../types';
export const zhCNAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Agent 设置',
'ai.chat.preparing': '准备中…',
'ai.chat.compactingContext': '正在压缩较早的上下文…',
'ai.chat.compactingStep': '正在为下一步整理上下文…',
'ai.chat.compactionRetry': '请求过大,正在压缩上下文并重试…',
'ai.chat.compactionBanner': '上下文已压缩:{before}K → {after}K tokens',
'ai.title': 'AI',
'ai.description': '配置 AI 提供商、Agent 和安全设置',
'ai.providers': '提供商',
'ai.agents': 'Agent',
'ai.providers.empty': '尚未配置提供商。添加一个提供商以开始使用。',
'ai.providers.add': '添加提供商',
'ai.providers.active': '活跃',
'ai.providers.apiKeyConfigured': 'API Key 已配置',
'ai.providers.noApiKey': '未设置 API Key',
'ai.providers.configure': '配置',
'ai.providers.remove': '移除',
'ai.providers.name': '显示名称',
'ai.providers.name.placeholder': '例如 我的提供商',
'ai.providers.style': '协议风格',
'ai.providers.style.anthropic': 'Anthropic 兼容',
'ai.providers.style.openai': 'OpenAI 兼容',
'ai.providers.style.google': 'Google 兼容',
'ai.providers.style.inherited': '默认',
'ai.providers.style.help': '决定请求使用哪种 API 格式。当第三方端点的协议与其提供商类型不一致时,可手动覆盖。',
'ai.providers.icon.change': '修改图标',
'ai.providers.icon.upload': '上传图片',
'ai.providers.icon.reset': '恢复默认',
'ai.providers.icon.close': '收起',
'ai.providers.icon.uploadedNote': '自定义图标(64×64 WebP)',
'ai.providers.icon.errorType': '请选择图片文件。',
'ai.providers.apiKey': 'API Key',
'ai.providers.apiKey.placeholder': '输入 API Key',
'ai.providers.apiKey.decrypting': '解密中...',
'ai.providers.baseUrl': 'Base URL',
'ai.providers.skipTLSVerify': '跳过 TLS 证书验证(用于自签名证书)',
'ai.providers.defaultModel': '默认模型',
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
'ai.providers.contextWindow': '上下文窗口',
'ai.providers.contextWindow.placeholder': '例如 128000',
'ai.providers.contextWindow.help': '留空时优先使用模型列表返回的值;如果没有,Netcatty 会使用安全默认值。',
'ai.providers.contextWindow.error': '请输入正整数,或留空。',
'ai.providers.refreshModels': '刷新模型列表',
'ai.providers.searchModel': '搜索或输入模型 ID...',
'ai.providers.filterModels': '筛选模型...',
'ai.providers.loadingModels': '加载模型中...',
'ai.providers.noMatchingModels': '没有匹配的模型',
'ai.providers.clickToLoadModels': '点击加载模型',
'ai.providers.showingModels': '显示前 100 个,共 {count} 个模型。输入以筛选。',
'ai.providers.advancedParams': '高级参数',
'ai.providers.advancedParams.hint': '留空则使用提供商默认值。',
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
'ai.providers.advancedParams.default': '提供商默认',
// AI Codex
'ai.codex': 'Codex',
'ai.codex.title': 'Codex CLI',
'ai.codex.description': '接入 OpenAI Codex。可以在这里登录 ChatGPT,也可以在设置里启用兼容 OpenAI 的 API Key 和自定义接口地址。',
'ai.codex.detecting': '检测中...',
'ai.codex.notFound': '未找到',
'ai.codex.awaitingLogin': '等待登录',
'ai.codex.connectedChatGPT': '已通过 ChatGPT 连接',
'ai.codex.connectedApiKey': '已通过 API Key 连接',
'ai.codex.connectedCustomConfig': '使用 ~/.codex/config.toml 自定义 provider',
'ai.codex.customConfigIncomplete': '检测到自定义配置(缺少环境变量)',
'ai.codex.customConfigHint': '使用 ~/.codex/config.toml 中配置的自定义 provider "{provider}",无需 ChatGPT 登录。',
'ai.codex.customConfigMissingEnvKey': '警告:环境变量 {envKey} 未在当前 shell 中设置。请 export 它(或从包含该变量的 shell 启动 netcatty),否则 Codex 无法鉴权。',
'ai.codex.notConnected': '未连接',
'ai.codex.statusUnknown': '状态未知',
'ai.codex.path': '路径:',
'ai.codex.notFoundHint': '在 PATH 中未找到 codex。请安装或在下方指定可执行文件路径。',
'ai.codex.customPathPlaceholder': '例如 /usr/local/bin/codex',
'ai.codex.check': '检查',
'ai.codex.resetPath': '重置',
'ai.codex.openLogin': '打开登录',
'ai.codex.logout': '退出登录',
'ai.codex.connectChatGPT': '连接 ChatGPT',
'ai.codex.refreshStatus': '刷新状态',
// AI Claude Code
'ai.claude.title': 'Claude Code',
'ai.claude.description': 'Anthropic 的智能编程助手。需要系统中已安装 Claude Code CLI。',
'ai.claude.detecting': '检测中...',
'ai.claude.detected': '已检测到',
'ai.claude.notFound': '未找到',
'ai.claude.path': '路径:',
'ai.claude.notFoundHint': '在 PATH 中未找到 claude。请安装或在下方指定可执行文件路径。',
'ai.claude.customPathPlaceholder': '例如 /usr/local/bin/claude',
'ai.claude.configSection': '认证与配置(可选)',
'ai.claude.configDir': '配置目录',
'ai.claude.configDir.placeholder': '~/.claude(留空用默认)',
'ai.claude.configDir.hint': '设置 CLAUDE_CONFIG_DIR —— 指向你已运行 `claude` 登录的目录(含 settings.json 和凭据)。',
'ai.claude.settings': 'Settings 文件',
'ai.claude.settings.placeholder': '~/team-settings.json(路径,或内联 {"model":"..."})',
'ai.claude.settings.hint': '可选。settings.json 路径或内联 JSON,作为 SDK 的 `settings` 传入。与上面的「配置目录」互补且独立(叠加合并,不是替换)。',
'ai.claude.envVars': '环境变量',
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
'ai.claude.envVars.hint': '每行一个 KEY=VALUE,传给 Claude agent。明文存在本地——API key/凭据建议用上面的「配置目录」(claude 登录),不要放这里。',
'ai.claude.check': '检查',
'ai.claude.resetPath': '重置',
// AI GitHub Copilot CLI
'ai.copilot.title': 'GitHub Copilot CLI',
'ai.copilot.description': '接入 GitHub Copilot CLI。检测到后即可作为外部编程 Agent 使用。',
'ai.copilot.detecting': '检测中...',
'ai.copilot.detected': '已检测到',
'ai.copilot.notFound': '未找到',
'ai.copilot.path': '路径:',
'ai.copilot.notFoundHint': '在 PATH 中未找到 copilot。请安装或在下方指定可执行文件路径。',
'ai.copilot.customPathPlaceholder': '例如 /usr/local/bin/copilot',
'ai.copilot.check': '检查',
'ai.copilot.resetPath': '重置',
// AI Cursor SDK
'ai.cursor.title': 'Cursor',
'ai.cursor.description': '使用 Cursor SDK。',
'ai.cursor.detecting': '检测中...',
'ai.cursor.detected': '可用',
'ai.cursor.notFound': '不可用',
'ai.cursor.path': '运行环境:',
'ai.cursor.notFoundHint': '填写 API Key 后即可使用。',
'ai.cursor.notInstalledHint': '未检测到 Cursor SDK。',
'ai.cursor.installStatus': 'Cursor SDK',
'ai.cursor.installed': '已检测到',
'ai.cursor.notInstalled': '未检测到',
'ai.cursor.apiKeyStatus': 'API Key',
'ai.cursor.apiKeyConfigured': '已填写',
'ai.cursor.apiKeyMissing': '未填写',
'ai.cursor.apiKeyFromEnv': '来自环境变量',
'ai.cursor.apiKey': 'API Key',
'ai.cursor.apiKeyPlaceholder': '输入 Cursor API Key',
'ai.cursor.apiKeyPlaceholder.env': '已使用 CURSOR_API_KEY;填写后会覆盖',
'ai.cursor.apiKeyEnvHint': '已检测到本机 CURSOR_API_KEY。留空即可继续使用,填写保存后会覆盖它。',
'ai.cursor.apiKeyOverrideHint': '当前优先使用这里保存的 Key;清空保存后会回到 CURSOR_API_KEY。',
'ai.cursor.saveApiKey': '保存',
'ai.cursor.saved': '已保存',
'ai.cursor.showApiKey': '显示 API Key',
'ai.cursor.hideApiKey': '隐藏 API Key',
'ai.cursor.customPathPlaceholder': '例如 /usr/local/bin/cursor',
'ai.cursor.check': '检查',
// AI CodeBuddy Code
'ai.codebuddy.title': 'CodeBuddy Code',
'ai.codebuddy.description': '通过官方 Agent SDK(`@tencent-ai/agent-sdk`)接入 CodeBuddy Code。检测到后即可作为外部编程 Agent 使用。',
'ai.codebuddy.detecting': '检测中...',
'ai.codebuddy.detected': '已检测到',
'ai.codebuddy.notFound': '未找到',
'ai.codebuddy.path': '路径:',
'ai.codebuddy.notFoundHint': '在 PATH 中未找到 codebuddy。请安装或在下方指定可执行文件路径。',
'ai.codebuddy.customPathPlaceholder': '例如 /usr/local/bin/codebuddy',
'ai.codebuddy.check': '检查',
'ai.codebuddy.resetPath': '重置',
'ai.codebuddy.configSection': '认证与配置(可选)',
'ai.codebuddy.internetEnv': '网络环境',
'ai.codebuddy.internetEnv.default': '默认(海外)',
'ai.codebuddy.internetEnv.internal': 'Internal',
'ai.codebuddy.internetEnv.ioa': 'IOA',
'ai.codebuddy.internetEnv.hint': '设置 CODEBUDDY_INTERNET_ENVIRONMENT —— 受限网络环境请选择 Internal 或 IOA。',
'ai.codebuddy.envVars': '环境变量',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': '每行一个 KEY=VALUE,传给 CodeBuddy agent。可在此设置 CODEBUDDY_API_KEY 或 CODEBUDDY_AUTH_TOKEN 完成认证。明文存在本地。',
// AI OpenCode
'ai.opencode.title': 'OpenCode',
'ai.opencode.description': '通过官方 SDK 接入 OpenCode。先在 OpenCode 里配置 provider 和密钥,检测到后即可作为外部编程 Agent 使用。',
'ai.opencode.detecting': '检测中...',
'ai.opencode.detected': '已检测到',
'ai.opencode.notFound': '未找到',
'ai.opencode.path': '路径:',
'ai.opencode.notFoundHint': '在 PATH 中未找到 opencode。请安装或在下方指定可执行文件路径。',
'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode',
'ai.opencode.check': '检查',
'ai.opencode.resetPath': '重置',
// AI Default Agent
'ai.defaultAgent': '默认 Agent',
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
'ai.defaultAgent.catty': 'Catty(内置)',
'ai.toolAccess.title': '工具接入',
'ai.toolAccess.mode': 'Netcatty 接入模式',
'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 文件夹',
'ai.userSkills.reload': '重新加载 Skills',
'ai.userSkills.location': '位置',
'ai.userSkills.loading': '正在扫描用户 skills...',
'ai.userSkills.summary': '已就绪 {ready} 个,警告 {warnings} 个',
'ai.userSkills.empty': '暂未发现用户 skills。打开文件夹后可添加包含 SKILL.md 的技能目录。',
'ai.userSkills.unavailable': '当前环境不支持用户 skills。',
'ai.userSkills.status.ready': '正常',
'ai.userSkills.status.warning': '警告',
// AI Quick Messages
'ai.quickMessages.title': '快捷消息',
'ai.quickMessages.description': '创建常用提示词,在 AI 聊天框输入 / 或点击快捷按钮即可插入到输入框。与用户 Skills 不同,快捷消息会直接填入消息内容。',
'ai.quickMessages.add': '添加快捷消息',
'ai.quickMessages.createTitle': '新建快捷消息',
'ai.quickMessages.editTitle': '编辑快捷消息',
'ai.quickMessages.name': '名称',
'ai.quickMessages.name.placeholder': '例如:检查磁盘空间',
'ai.quickMessages.slug': '命令',
'ai.quickMessages.slug.placeholder': 'disk-check',
'ai.quickMessages.descriptionField': '说明(可选)',
'ai.quickMessages.descriptionField.placeholder': '简短描述这条快捷消息的用途',
'ai.quickMessages.content': '消息内容',
'ai.quickMessages.content.placeholder': '输入选择后要插入的完整提示词...',
'ai.quickMessages.empty': '还没有快捷消息。添加几条常用提示,聊天时就能一键插入。',
'ai.quickMessages.confirmDelete': '确定删除快捷消息「{name}」吗?',
'ai.quickMessages.error.nameRequired': '请填写名称。',
'ai.quickMessages.error.invalidSlug': '命令只能包含小写字母、数字和连字符。',
'ai.quickMessages.error.contentRequired': '请填写消息内容。',
'ai.quickMessages.error.slugTaken': '该命令已被其他快捷消息使用。',
'ai.quickMessages.error.slugConflictsWithSkill': '该命令与用户 Skill「/{slug}」冲突,请换一个命令。',
'ai.quickMessages.error.maxItems': '最多只能保存 {max} 条快捷消息。',
// AI Chat
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
'ai.chat.toolDenied': '操作已被用户拒绝。',
'ai.chat.toolApproved': '已批准',
'ai.chat.toolApprovalHint': 'Enter 允许 · Esc 拒绝',
'ai.chat.approve': '批准',
'ai.chat.approveOnce': '允许',
'ai.chat.alwaysAllow': '始终',
'ai.chat.slashStopDesc': '停止当前 AI 回合并取消进行中的工具',
'ai.chat.reject': '拒绝',
'ai.chat.toolLabel': '工具',
'ai.chat.targetLabel': '目标',
'ai.chat.permissionRequired': '需要权限',
'ai.chat.permissionDescription': 'AI Agent 希望执行一个需要你批准的工具调用。',
'ai.chat.commandBlocked': '此命令已被安全策略拦截,无法执行。',
'ai.chat.recommendAllow': '允许',
'ai.chat.recommendConfirm': '确认',
'ai.chat.recommendDeny': '拒绝',
'ai.chat.exportConversation': '导出对话',
'ai.chat.exportAs': '导出为',
'ai.chat.exportMarkdown': 'Markdown',
'ai.chat.exportJSON': 'JSON',
'ai.chat.exportPlainText': '纯文本',
'ai.chat.thinking': '思考中',
'ai.chat.thoughtFor': '思考了 {duration}',
'ai.chat.thought': '思考',
'ai.chat.agents': 'Agents',
'ai.chat.detectedOnMachine': '在本机检测到',
'ai.chat.rescan': '重新扫描',
'ai.chat.permObserver': '观察',
'ai.chat.permConfirm': '确认',
'ai.chat.permAuto': '自动',
'ai.chat.permObserverDesc': '只读模式',
'ai.chat.permConfirmDesc': '操作前询问',
'ai.chat.permAutoDesc': '自由执行',
'ai.chat.emptyHint': '询问服务器相关问题、执行命令或获取配置帮助。',
'ai.chat.placeholder': '向 {agent} 发送消息 — @ 引用上下文,/ 使用命令',
'ai.chat.placeholderDefault': '向 Catty Agent 发送消息...',
'ai.chat.noModel': '未选择模型',
'ai.chat.noProviderModel': '未配置默认模型——前往 设置 → AI → 提供商 设置。',
'ai.chat.selectProvider': '选择提供商',
'ai.chat.recent': '最近',
'ai.chat.viewAll': '查看全部',
'ai.chat.untitled': '无标题',
'ai.chat.justNow': '刚刚',
'ai.chat.minutesAgo': '{n}分钟前',
'ai.chat.hoursAgo': '{n}小时前',
'ai.chat.daysAgo': '{n}天前',
'ai.chat.newChat': '新对话',
'ai.chat.allSessions': '所有会话',
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
'ai.chat.usedTools': '已使用 {n} 个工具',
'ai.chat.loadMoreSessions': '加载更多会话(还有 {n} 条)',
'ai.chat.noSessions': '没有历史会话',
'ai.chat.retryHint': '你可以重新发送消息来重试。',
'ai.chat.approvalTimeout': '工具审批已超时(5 分钟)。你可以重新发送消息来重试。',
'ai.chat.menuHosts': '主机',
'ai.chat.menuContext': '上下文',
'ai.chat.menuFiles': '文件',
'ai.chat.menuImage': '图片',
'ai.chat.menuMentionHost': '提及主机',
'ai.chat.menuUserSkills': '用户 Skills',
'ai.chat.menuSlashCommands': '快捷命令',
'ai.chat.slashCommands': '快捷命令',
'ai.chat.slashQuickMessages': '快捷消息',
'ai.chat.slashUserSkills': '用户 Skills',
'ai.chat.quickMessages': '快捷命令',
'ai.chat.slashNoResults': '没有匹配的命令',
'ai.chat.slashEmptyHint': '可在 设置 → AI → 快捷消息 中添加常用提示词。',
// AI 聊天快捷入口
'ai.chatShortcuts.title': '聊天快捷入口',
'ai.chatShortcuts.selectionAction': '选中终端内容时显示“添加到对话”',
'ai.chatShortcuts.selectionAction.description': '在终端里选中文本后显示 AI 快捷按钮。',
// AI Error
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
// AI Web Search
'ai.webSearch.title': '网络搜索',
'ai.webSearch.enable': '启用网络搜索',
'ai.webSearch.enable.description': '允许 AI 代理搜索互联网获取最新信息。',
'ai.webSearch.provider': '搜索供应商',
'ai.webSearch.provider.description': '选择一个网络搜索 API 供应商。',
'ai.webSearch.apiKey': 'API 密钥',
'ai.webSearch.apiKey.description': '所选搜索供应商的 API 密钥。',
'ai.webSearch.apiKey.placeholder': '输入 API 密钥...',
'ai.webSearch.apiHost': 'API 地址',
'ai.webSearch.apiHost.description': '自定义 API 端点。除非使用代理,否则保持默认值。',
'ai.webSearch.apiHost.searxngDescription': 'SearXNG 实例的 URL(必填)。',
'ai.webSearch.maxResults': '最大结果数',
'ai.webSearch.maxResults.description': '搜索返回的最大结果数(1-20)。',
// AI Safety Settings
'ai.safety.title': '安全',
'ai.safety.permissionMode': '权限模式',
'ai.safety.permissionMode.description': '控制 AI 通过 Netcatty 访问终端会话的方式。观察者模式会阻止经由 Netcatty 的写操作;外部 Agent CLI 可能仍有自己的本机工具和审批流程。',
'ai.safety.permissionMode.observer': '观察者 - 只读,禁止操作',
'ai.safety.permissionMode.confirm': '确认 - 操作前询问',
'ai.safety.permissionMode.auto': '自动 - 自由执行',
'ai.safety.commandTimeout': '命令超时',
'ai.safety.commandTimeout.description': '通过 Netcatty 执行命令时允许运行的最长秒数,超时将被终止。',
'ai.safety.commandTimeout.unit': '秒',
'ai.safety.maxIterations': '最大迭代次数',
'ai.safety.maxIterations.description': '防止 AI 失控执行的最大工具调用循环次数。外部 Agent 可能有自己的内部迭代限制,以其为准。',
'ai.safety.blocklist': '命令黑名单',
'ai.safety.blocklist.description': '用于拦截通过 Netcatty 执行的危险命令的正则表达式。',
'ai.safety.blocklist.placeholder': '正则表达式...',
'ai.safety.blocklist.reset': '恢复默认',
'ai.safety.blocklist.add': '添加规则',
'ai.safety.grants.title': '许可记忆表',
'ai.safety.grants.heading': 'Confirm 模式放行规则',
'ai.safety.grants.description': 'Confirm 模式会先询问再执行;保存为规则后,匹配的同类操作会自动放行。规则对所有终端节点/会话生效,也可手动编辑。',
'ai.safety.grants.empty': '尚无规则。可在审批时选择「一律许可」,或手动添加。',
'ai.safety.grants.capability': '能力',
'ai.safety.grants.sessionPattern': '节点/会话模式',
'ai.safety.grants.commandPattern': '命令模式(可选)',
'ai.safety.grants.note': '备注(可选)',
'ai.safety.grants.add': '添加规则',
'ai.safety.grants.remove': '删除',
'ai.safety.grants.export': '导出 JSON',
'ai.safety.grants.import': '导入 JSON',
'ai.safety.note': '这些安全设置会约束经由 Netcatty 执行的操作。外部 Agent CLI 也可能提供本机工具,那部分由 Agent 自己的控制规则约束。',
// 统一终端工作区和顶部标签的 tooltip 文案 (issue #954)
'terminal.layer.addTerminal': '添加终端',
'terminal.layer.switchToSplitView': '切换到分屏视图',
'terminal.layer.sftp': '文件传输',
'terminal.layer.scripts': '脚本',
'terminal.layer.history': '命令历史',
'terminal.layer.theme': '主题',
'terminal.layer.notes': '笔记',
'terminal.layer.aiChat': 'AI 助手',
'terminal.layer.movePanelLeft': '面板移至左侧',
'terminal.layer.movePanelRight': '面板移至右侧',
'terminal.layer.closePanel': '关闭面板',
'terminal.layer.hostTree.search': '搜索主机...',
'terminal.layer.hostTree.searchButton': '搜索',
'terminal.layer.hostTree.tagsButton': '按标签筛选',
'terminal.layer.hostTree.newGroup': '新建分组',
'terminal.layer.hostTree.localShell': '本地 Shell',
'terminal.layer.hostTree.tagsEmpty': '暂无标签',
'terminal.layer.hostTree.clearTags': '清除筛选',
'terminal.layer.hostTree.collapse': '收起主机列表',
'terminal.layer.hostTree.expand': '展开主机列表',
'terminal.layer.hostTree.empty': '没有匹配的主机',
'terminal.layer.hostTree.details.host': '主机',
'terminal.layer.hostTree.details.user': '用户',
'terminal.layer.hostTree.details.port': '端口',
'terminal.layer.hostTree.details.protocol': '协议',
'terminal.layer.hostTree.details.group': '分组',
'terminal.layer.hostTree.details.tags': '标签',
'terminal.layer.hostTree.details.lastConnected': '最近连接',
'topTabs.openQuickSwitcher': '打开快速切换',
'topTabs.moreTabs': '更多标签页',
'topTabs.aiAssistant': 'AI 助手',
'topTabs.windowOpacity': '窗口透明度',
'topTabs.toggleTheme': '切换主题',
'topTabs.openSettings': '打开设置',
'ai.chat.sessionHistory': '会话历史',
'ai.chat.attach': '附件',
'ai.chat.terminalSelectionAttachment': '终端选区',
'ai.chat.terminalSelectionLines': '{count} 行',
'ai.chat.collapse': '收起',
'ai.chat.expand': '展开',
'ai.chat.enableAgent': '启用 {name}',
'ai.chat.artifact.noteFallback': 'Vault 笔记',
'ai.chat.artifact.openNotes': '打开笔记',
'ai.chat.artifact.openHosts': '打开主机',
'ai.chat.artifact.notesSummary': 'Vault 中有 {count} 条笔记',
'ai.chat.artifact.hostsSummary': 'Vault 中有 {count} 台主机',
'ai.chat.artifact.hostsAdded': '已添加 {count} 台主机',
'ai.chat.artifact.hostsPreview': '预览 {count} 台主机',
'ai.chat.artifact.failed': 'Vault 操作失败',
'ai.chat.artifact.unavailableTitle': '不可用',
'ai.chat.artifact.noteMissing': '该笔记已不在 Vault 中。',
'ai.chat.artifact.hostMissing': '该主机已不在 Vault 中。',
'ai.chat.artifact.snippetMissing': '该 Snippet 或脚本已不在 Vault 中。',
'ai.chat.artifact.openSnippets': '打开 Snippets',
'ai.chat.artifact.snippetsSummary': 'Vault 中有 {count} 个 Snippet',
'ai.chat.artifact.scriptsSummary': 'Vault 中有 {count} 个脚本',
'ai.chat.artifact.snippetFallback': 'Vault Snippet',
'ai.chat.artifact.scriptFallback': '自动化脚本',
'ai.chat.artifact.scriptLanguage': '{language} 脚本',
'ai.chat.artifact.snippetDeleted': 'Snippet 已删除',
'ai.chat.artifact.scriptDeleted': '脚本已删除',
'ai.chat.artifact.snippetRan': 'Snippet 已执行',
'ai.chat.artifact.scriptStarted': '脚本运行已启动',
'ai.chat.artifact.scriptRunStatus': '脚本运行 {status}',
'ai.chat.artifact.scriptRunsSummary': '{count} 个脚本运行',
'ai.chat.artifact.scriptRunStopped': '脚本运行已停止',
'ai.chat.artifact.scriptRunPaused': '脚本运行已暂停',
'ai.chat.artifact.scriptRunResumed': '脚本运行已恢复',
'ai.chat.artifact.scriptReference': 'nct API 参考',
'zmodem.waitingForRemote': '等待远端...',
'zmodem.uploading': '上传中',
'zmodem.downloading': '下载中',
'zmodem.cancelTransfer': '取消传输 (Ctrl+C)',
'zmodem.overwrite.title': '远端已存在同名文件',
'zmodem.overwrite.applyToRest': '应用到其余冲突文件',
'zmodem.overwrite.overwrite': '覆盖',
'zmodem.overwrite.skip': '跳过',
'zmodem.overwrite.cancel': '取消',
'settings.shortcuts.resetToDefault': '重置为默认',
};
+768
View File
@@ -0,0 +1,768 @@
import type { Messages } from '../types';
export const zhCNCoreMessages: Messages = {
// Common
'common.save': '保存',
'common.cancel': '取消',
'common.close': '关闭',
'common.reset': '重置',
'common.zoomIn': '放大',
'common.zoomOut': '缩小',
'common.settings': '设置',
'common.search': '搜索',
'common.connect': '连接',
'common.terminal': '终端',
'common.create': '创建',
'common.add': '添加',
'common.rename': '重命名',
'common.refresh': '刷新',
'common.continue': '继续',
'common.enabled': '已启用',
'common.disabled': '已禁用',
'common.unknownError': '未知错误',
'common.noResultsFound': '没有匹配结果',
'common.back': '返回',
'common.apply': '应用',
'common.use': '使用',
'common.useGlobal': '跟随全局',
'common.left': '左侧',
'common.right': '右侧',
'common.more': '更多',
'common.selectAHost': '选择主机',
'sort.manual': '手动顺序',
'sort.az': 'A-z',
'sort.za': 'Z-a',
'sort.newest': '从新到旧',
'sort.oldest': '从旧到新',
'sort.group': '按分组',
'field.label': 'Label',
'field.type': '类型',
'auth.keyType': '类型 {type}',
'auth.showAllKeys': '显示全部 keys',
// Dialogs / prompts
'confirm.deleteHost': '删除主机 "{name}"?',
'confirm.deleteIdentity': '删除身份 "{name}"?',
'confirm.removeProvider': '移除提供商 "{name}"?',
'confirm.closeBusyTerminal.title': '确认关闭',
'confirm.closeBusyTerminal.message': '进程 "{command}" 仍在运行,关闭后会被终止。',
'confirm.closeBusyTerminal.messageWithMore': '进程 "{command}" 及其他 {count} 个正在运行的进程将被终止。',
'confirm.closeBusyTerminal.cancel': '取消',
'confirm.closeBusyTerminal.close': '关闭',
'dialog.renameWorkspace.title': '重命名工作区',
'dialog.renameSession.title': '重命名会话',
'field.name': '名称',
'placeholder.workspaceName': '工作区名称',
'placeholder.sessionName': '会话名称',
'toast.settingsUnavailable': '当前平台无法打开设置窗口。',
'credentials.protectionUnavailable.title': '凭据保护不可用',
'credentials.protectionUnavailable.message': '当前设备无法自动解密已保存的密码和密钥。连接前请重新输入凭据。',
'credentials.protectionUnavailable.action': '打开设置',
// Settings shell
'settings.title': '设置',
'settings.tab.application': '应用',
'settings.tab.appearance': '外观',
'settings.tab.terminal': '终端',
'settings.tab.shortcuts': '快捷键',
'settings.tab.syncCloud': '同步与云',
'settings.tab.system': '系统',
'settings.terminal.connection.verifyHostKeys': '校验 SSH 主机密钥',
'settings.terminal.connection.verifyHostKeys.desc': '连接到新的或已变化的 SSH 主机密钥前先询问。只建议在可信内网中关闭。',
'settings.terminal.section.sidePanel': '侧边栏',
'settings.terminal.sidePanel.autoOpen': '打开终端时展开侧边栏',
'settings.terminal.sidePanel.autoOpen.desc': '终端连接成功后自动打开一个侧边栏。',
'settings.terminal.sidePanel.autoOpenPane': '默认侧边栏',
'settings.terminal.sidePanel.autoOpenPane.desc': '选择优先打开哪个面板。',
'settings.terminal.sidePanel.pane.sftp': 'SFTP',
'settings.terminal.sidePanel.pane.scripts': '脚本',
'settings.terminal.sidePanel.pane.history': '历史',
'settings.terminal.sidePanel.pane.theme': '主题',
'settings.terminal.sidePanel.pane.system': '系统',
'settings.terminal.sidePanel.pane.notes': '笔记',
'settings.terminal.sidePanel.pane.ai': 'AI',
// Settings > System
'settings.system.title': '系统',
'settings.system.description': '系统信息与临时文件管理。',
'settings.system.tempDirectory': '临时文件',
'settings.system.location': '位置',
'settings.system.fileCount': '文件数量',
'settings.system.totalSize': '占用空间',
'settings.system.openFolder': '打开文件夹',
'settings.system.refresh': '刷新',
'settings.system.clearTempFiles': '清理临时文件',
'settings.system.clearing': '清理中...',
'settings.system.clearResult': '已删除 {deleted} 个文件,{failed} 个失败。',
'settings.system.tempDirectoryHint': '临时文件在使用外部应用打开远程文件时创建。SFTP 会话关闭时会自动清理。',
'settings.system.credentials.title': '凭据保护',
'settings.system.credentials.status': '状态',
'settings.system.credentials.checking': '检查中...',
'settings.system.credentials.available': '可用(系统钥匙串正常)',
'settings.system.credentials.unavailable': '不可用(无法解密已保存凭据)',
'settings.system.credentials.unknown': '未知(当前环境不支持)',
'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': '查看主进程错误日志,帮助诊断异常行为。',
'settings.system.crashLogs.noLogs': '未找到崩溃日志。',
'settings.system.crashLogs.entries': '{count} 条记录',
'settings.system.crashLogs.clear': '清除所有日志',
'settings.system.crashLogs.cleared': '已清除 {count} 个日志文件。',
'settings.system.crashLogs.source': '来源',
'settings.system.crashLogs.time': '时间',
'settings.system.crashLogs.message': '消息',
'settings.system.crashLogs.stack': '堆栈跟踪',
'settings.system.crashLogs.hint': '崩溃日志保留 30 天,超期自动清理。',
'settings.system.crashLogs.collapse': '收起',
'settings.system.crashLogs.expand': '查看详情',
// Settings > System > Software Update
'settings.update.title': '软件更新',
'settings.update.currentVersion': '当前版本',
'settings.update.checkForUpdates': '检查更新',
'settings.update.checking': '检查中...',
'settings.update.upToDate': '当前已是最新版本。',
'settings.update.available': '新版本 {version} 已发布。',
'settings.update.download': '下载更新',
'settings.update.downloading': '正在下载... {percent}%',
'settings.update.readyToInstall': '更新已下载,准备安装。',
'settings.update.restartNow': '重启并更新',
'settings.update.error': '检查更新失败。',
'settings.update.downloadError': '下载失败。',
'settings.update.manualDownload': '前往 GitHub 下载',
'settings.update.manualDownloadHint': '当前平台不支持自动更新,请前往 GitHub 下载最新版本。',
'settings.update.hint': 'Netcatty 从 GitHub Releases 检查更新。',
'settings.update.lastCheckedJustNow': '刚刚',
'settings.update.lastCheckedMinutesAgo': '{n} 分钟前',
'settings.update.lastCheckedHoursAgo': '{n} 小时前',
'settings.update.lastCheckedPrefix': '上次检查:',
'settings.update.autoUpdateEnabled': '自动更新',
'settings.update.autoUpdateEnabledDesc': '有新版本时自动检查并下载更新。',
// Settings > Session Restore
'settings.sessionRestore.title': '会话恢复',
'settings.sessionRestore.restorePreviousSession': '恢复上次的标签页和工作区',
'settings.sessionRestore.restorePreviousSessionDesc': '启动时重新打开上次的终端标签页和工作区布局。',
'settings.sessionRestore.restoreTerminalCwd': '恢复终端工作目录',
'settings.sessionRestore.restoreTerminalCwdDesc': '重新连接恢复的终端时,尝试回到上次记录的目录;失败时不会阻塞连接。',
'terminal.restore.placeholder.title': '已恢复的会话',
'terminal.restore.placeholder.desc': '此终端当前已断开。准备好后可以重新连接,为这个标签页启动新的 shell。',
'terminal.restore.placeholder.reconnect': '重新连接',
'terminal.restore.cwdLog': '正在恢复工作目录:{cwd}',
// Settings > Session Logs
'settings.sessionLogs.title': '会话日志',
'settings.sessionLogs.description': '配置会话日志导出和自动保存设置。',
'settings.sessionLogs.autoSave': '自动保存',
'settings.sessionLogs.enableAutoSave': '启用自动保存',
'settings.sessionLogs.enableAutoSaveDesc': '在终端会话结束时自动保存会话日志。',
'settings.sessionLogs.directory': '保存目录',
'settings.sessionLogs.noDirectory': '未选择目录',
'settings.sessionLogs.browse': '浏览',
'settings.sessionLogs.openFolder': '打开文件夹',
'settings.sessionLogs.directoryHint': '日志将按主机名组织在子目录中。',
'settings.sessionLogs.format': '日志格式',
'settings.sessionLogs.formatDesc': '选择保存日志文件的格式。',
'settings.sessionLogs.formatTxt': '纯文本 (.txt)',
'settings.sessionLogs.formatRaw': '原始格式 (.log)',
'settings.sessionLogs.formatHtml': 'HTML (.html)',
'settings.sessionLogs.timestamps': '添加时间戳',
'settings.sessionLogs.timestampsDesc': '为纯文本和 HTML 日志的每一行添加本地时间。',
'settings.sessionLogs.hint': '会话日志用于记录终端输出,便于故障排查和审计。',
// Settings > SSH Debug Logs
'settings.sshDebugLogs.title': 'SSH 调试日志',
'settings.sshDebugLogs.enable': '启用 SSH 调试日志',
'settings.sshDebugLogs.enableDesc': '记录连接、认证、握手、断开和错误原因,不记录终端输出。',
'settings.sshDebugLogs.location': '日志位置',
'settings.sshDebugLogs.status': '状态',
'settings.sshDebugLogs.statusOn': '已开启',
'settings.sshDebugLogs.statusOff': '未开启',
'settings.sshDebugLogs.size': '大小',
'settings.sshDebugLogs.hint': '开启后,新发起的 SSH 连接会写入诊断信息,方便排查堡垒机、认证和异常断开问题。',
// Settings > Global Hotkey (Quake Mode)
'settings.globalHotkey.title': '全局快捷键',
'settings.globalHotkey.toggleWindow': '切换窗口',
'settings.globalHotkey.toggleWindowDesc': '按下组合键以设置显示/隐藏窗口的全局快捷键。',
'settings.globalHotkey.notSet': '未设置',
'settings.globalHotkey.reset': '恢复默认',
'settings.globalHotkey.closeToTray': '关闭时最小化到托盘',
'settings.globalHotkey.closeToTrayDesc': '启用后,关闭窗口将最小化到系统托盘而不是退出程序。',
'settings.globalHotkey.enabled': '启用全局快捷键',
'settings.globalHotkey.enabledDesc': '注册系统级键盘快捷键。禁用后将取消所有全局快捷键注册。',
'settings.globalHotkey.hint': '全局快捷键在系统范围内工作,可快速显示或隐藏窗口(下拉式终端风格)。',
// Tray Panel
'tray.openMainWindow': '打开主窗口',
'tray.sessions': '会话',
'tray.portForwarding': '端口转发',
'tray.status.connected': '已连接',
'tray.status.connecting': '连接中',
'tray.status.disconnected': '已断开',
'tray.status.active': '已启用',
'tray.status.inactive': '未启用',
'tray.status.error': '错误',
'tray.recentHosts': '最近连接的主机',
'tray.empty.title': '一切都很安静',
'tray.empty.subtitle': '去连接个服务器吧,它们想念你了 🚀',
'tray.quit': '退出 Netcatty',
// Vault Sidebar
'vault.sidebar.collapse': '收起侧边栏',
'vault.sidebar.expand': '展开侧边栏',
'vault.sidebar.resize': '调整侧边栏宽度',
// Settings > Application
'settings.application.checkUpdates': '检查更新',
'settings.application.reportProblem': '反馈问题',
'settings.application.reportProblem.subtitle': '生成预填的 GitHub issue',
'settings.application.community': '社区',
'settings.application.community.subtitle': 'GitHub Discussions',
'settings.application.github': 'GitHub',
'settings.application.github.subtitle': '源代码',
'settings.application.whatsNew': '更新内容',
'settings.application.whatsNew.subtitle': '查看发布说明',
'settings.application.openExternal.failedTitle': '无法打开链接',
'settings.application.openExternal.failedBody': '系统浏览器和内置浏览器窗口都无法打开该链接。',
'settings.vault.title': '主机库',
'settings.vault.showRecentHosts': '显示最近连接的主机',
'settings.vault.showRecentHostsDesc': '在主机列表顶部显示最近连接过的主机',
'settings.vault.showOnlyUngroupedHostsInRoot': '根目录只显示未分组主机',
'settings.vault.showOnlyUngroupedHostsInRootDesc': '开启后,主机库根目录的主机列表只显示没有分组的主机,已分组主机请从左侧分组进入查看。',
'settings.vault.showSftpTab': '显示 SFTP 标签页',
'settings.vault.showSftpTabDesc': '在顶部标签栏显示独立的 SFTP 视图。关闭后可改用会话内左侧的 SFTP 侧栏。',
'settings.vault.showHostTreeSidebar': '显示主机列表侧栏',
'settings.vault.showHostTreeSidebarDesc': '在终端和编辑器标签页显示主机列表侧栏及顶部开关。',
// Update notifications
'update.available.title': '发现新版本',
'update.available.message': '新版本 {version} 已发布,点击前往下载。',
'update.checking': '正在检查更新...',
'update.upToDate.title': '已是最新版本',
'update.upToDate.message': '当前版本 ({version}) 已是最新。',
'update.error': '检查更新失败',
'update.downloadNow': '立即下载',
'update.viewInSettings': '在设置中查看',
'update.readyToInstall.title': '更新已就绪',
'update.readyToInstall.message': '版本 {version} 已下载完成,准备安装。',
'update.restartNow': '立即重启',
'update.downloadFailed.title': '更新失败',
'update.downloadFailed.message': '下载更新失败,可前往 GitHub 手动下载。',
'update.needsSave.title': '有未保存内容',
'update.needsSave.message': '请先保存已打开的编辑器,然后再次点击「立即重启」以安装更新。',
'update.openReleases': '打开 Releases',
'update.remindLater': '稍后提醒',
'update.skipVersion': '跳过此版本',
// Settings > Appearance
'settings.appearance.uiTheme': '界面主题',
'settings.appearance.theme': '主题',
'settings.appearance.theme.desc': '选择浅色、深色或跟随系统设置',
'settings.appearance.theme.light': '浅色',
'settings.appearance.theme.dark': '深色',
'settings.appearance.theme.system': '系统',
'settings.appearance.accentColor': '强调色',
'settings.appearance.customColor': '自定义颜色',
'settings.appearance.accentColor.mode': '使用自定义强调色',
'settings.appearance.accentColor.mode.desc': '覆盖主题自带的强调色',
'settings.appearance.accentColor.custom': '自定义强调色',
'settings.appearance.themeColor': '主题色',
'settings.appearance.themeColor.desc': '为浅色与深色主题选择预设配色',
'settings.appearance.themeColor.light': '浅色主题',
'settings.appearance.themeColor.dark': '深色主题',
'settings.appearance.customCss': '自定义 CSS',
'settings.appearance.customCss.desc':
'使用自定义 CSS 个性化界面,修改会立即生效。主要 UI 区块都暴露了 [data-section="..."] 属性供你定位,比如:snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebar(Focus 模式终端列表)、terminal-host-tree-sidebar、terminal-host-tree-sidebar-content、terminal-host-tree-sidebar-row、terminal-side-panel(SFTP/脚本/主题/AI 侧栏,打开时生效)、terminal-side-panel-tabs、terminal-side-panel-content、terminal-sftp-panel、terminal-sftp-host-header、terminal-sftp-pane、terminal-sftp-toolbar、terminal-sftp-path、terminal-sftp-filter-bar、terminal-sftp-list、terminal-sftp-list-header、terminal-sftp-list-row、terminal-sftp-tree、terminal-sftp-tree-row、terminal-sftp-transfer-queue、terminal-sftp-transfer-row、terminal-split-pane、terminal-split-resizer、top-tabs、top-tabs-host-tree-toggle、top-tabs-quick-switcher-toggle。',
'settings.appearance.customCss.placeholder':
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 隐藏顶部标签栏里的主机列表开关 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隐藏打开快速切换器的加号按钮 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作侧栏边框(关闭侧栏后不会残留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整个操作侧栏背景,而不只是顶部标签 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改选中的 SFTP 文件行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
'settings.appearance.language': '语言',
'settings.appearance.language.desc': '选择界面语言',
'settings.appearance.uiFont': '界面字体',
'settings.appearance.uiFont.desc': '选择软件界面使用的字体',
'settings.appearance.windowOpacity': '窗口透明度',
'settings.appearance.windowOpacity.desc': '调节整个应用窗口的透明度,方便叠在其他内容上方。较低时终端文字也会变淡;部分 Linux 桌面环境可能不支持。',
'settings.appearance.appIcon': '应用图标',
'settings.appearance.appIcon.desc': '选择 Dock/任务栏图标风格,切换应用时更容易找到 Netcatty。运行时生效,安装包内的图标不会改变。',
'settings.appearance.appIcon.original': '原始',
'settings.appearance.appIcon.bright': '明亮',
'settings.appearance.appIcon.dark': '深色',
'settings.appearance.appIcon.colorful': '彩色',
'settings.appearance.appIcon.highContrast': '高对比',
'settings.appearance.appIcon.group.classic': '彩色底',
'settings.appearance.appIcon.group.white': '白底',
'settings.appearance.appIcon.group.special': '隐藏款',
'settings.appearance.appIcon.whiteNavy': '白底 · 海军蓝猫',
'settings.appearance.appIcon.whiteSky': '白底 · 天蓝猫',
'settings.appearance.appIcon.whiteRose': '白底 · 玫红猫',
'settings.appearance.appIcon.whiteEmerald': '白底 · 翡翠猫',
'settings.appearance.appIcon.whiteAmber': '白底 · 琥珀猫',
'settings.appearance.appIcon.whiteViolet': '白底 · 紫罗兰猫',
'settings.appearance.appIcon.rainbow': '彩虹猫',
// Context menus / common actions
'action.newHost': '新建主机',
'action.newSubfolder': '新建文件夹',
'action.copyPublicKey': '复制公钥',
'action.keyExport': '导出密钥',
'action.edit': '编辑',
'action.delete': '删除',
'action.remove': '移除',
'action.convertToHost': '转换为主机',
// Sync
'sync.cloudSync': '云同步',
'sync.settings': '同步设置',
'sync.active': '云同步已启用',
'sync.pending': '有待同步的更改',
'sync.syncing': '正在同步…',
'sync.error': '同步错误',
'sync.notConfigured': '未配置',
'sync.failed': '同步失败',
'sync.connected': '已连接',
'sync.syncNow': '立即同步',
'sync.recentActivity': '最近活动',
'sync.history.uploaded': '已 Upload',
'sync.history.downloaded': '已 Download',
'sync.history.resolved': '已处理',
'sync.toast.completedMessage': '同步完成',
'sync.toast.errorTitle': '同步错误',
'sync.autoSync.failedTitle': '同步失败',
'sync.autoSync.inspectFailedTitle': '同步已暂停',
'sync.autoSync.inspectFailedMessage': '无法访问云端以检查变更。数据改动或下次启动时会自动重试。',
'sync.autoSync.syncedTitle': '已从云端同步',
'sync.autoSync.syncedMessage': '你的数据已从云端更新。',
'sync.autoSync.noProvider': '未连接云同步 provider。请打开 设置 → Sync & Cloud 进行连接。',
'sync.autoSync.alreadySyncing': '同步正在进行中。',
'sync.autoSync.restoreInProgress': '另一个窗口中的本地备份恢复正在进行中,请等待其完成。',
'sync.autoSync.interruptedApplyTitle': '同步已暂停 — 上次恢复未完成',
'sync.autoSync.interruptedApplyMessage': '上次本地恢复过程未正常结束,本地数据可能处于半应用状态。请打开「设置 → Sync & Cloud → 恢复」,从保护性备份中恢复后再让自动同步继续。',
'sync.autoSync.vaultLocked': 'Vault 处于锁定状态。请打开 设置 → Sync & Cloud 解锁。',
'sync.autoSync.conflictDetected': '检测到同步冲突。请打开 设置 → Sync & Cloud 处理。',
'sync.autoSync.syncFailed': '同步失败',
'sync.autoSync.restoredTitle': '已恢复',
'sync.autoSync.restoredMessage': '已从云端恢复主机库数据。',
'sync.autoSync.keptLocalTitle': '已保留本地数据',
'sync.autoSync.keptLocalMessage': '保留了空的本地主机库,未应用云端数据。',
'sync.autoSync.emptyVaultConflict.title': '检测到空主机库',
'sync.autoSync.emptyVaultConflict.description': '本地主机库为空,但云端有数据。这通常发生在应用更新或存储重置之后。请选择如何处理:',
'sync.autoSync.emptyVaultConflict.cloudLabel': '云端',
'sync.autoSync.emptyVaultConflict.restore': '从云端恢复',
'sync.autoSync.emptyVaultConflict.restoreDesc': '推荐 — 从云端备份恢复主机、密钥和代码片段',
'sync.autoSync.emptyVaultConflict.keepEmpty': '保持为空',
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': '从头开始,使用空的主机库',
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段,{notes} 条笔记,{proxyProfiles} 个代理',
'sync.autoSync.emptyVaultManual': '无法同步:本地 vault 为空。请先从本地备份恢复,或在同步面板里使用"强制推送"。',
'sync.blocked.title': '同步已暂停',
'sync.blocked.reason.bulkShrink': '即将从云端删除 {baseCount} 条 {entityType} 中的 {lost} 条(缩减 {percent}%)。',
'sync.blocked.reason.largeShrink': '即将从云端删除 {lost} 条 {entityType}。',
'sync.blocked.detail': '通常是本地状态异常(钥匙串故障、数据加载不全)导致。请从本地备份恢复,如果确实要删这些条目请使用强制推送。',
'sync.blocked.restoreButton': '从本地备份恢复',
'sync.blocked.forcePushButton': '强制推送',
'sync.forcePush.title': '确认强制推送',
'sync.forcePush.body': '你将从云端移除 {lost} 条 {entityType},此操作不可撤销。继续?',
'sync.forcePush.confirm': '确认推送',
'sync.forcePush.cancel': '取消',
'sync.entityType.hosts': '主机',
'sync.entityType.keys': '密钥',
'sync.entityType.identities': '身份',
'sync.entityType.proxyProfiles': '代理配置',
'sync.entityType.snippets': '代码片段',
'sync.entityType.customGroups': '分组',
'sync.entityType.snippetPackages': '片段包',
'sync.entityType.knownHosts': '主机密钥记录',
'sync.entityType.portForwardingRules': '端口转发规则',
'sync.entityType.groupConfigs': '分组配置',
'sync.credentialsUnavailable': '当前设备无法解密部分已保存凭据。请先在本地重新输入凭据后再同步。',
'time.never': '从未',
'time.justNow': '刚刚',
'time.minutesAgo': '{minutes} 分钟前',
// Vault navigation
'vault.nav.hosts': '主机',
'vault.nav.keychain': '钥匙串',
'vault.nav.proxies': '代理',
'vault.nav.portForwarding': '端口转发',
'vault.nav.snippets': '脚本',
'vault.nav.notes': '笔记',
'vault.nav.knownHosts': '已知主机',
'vault.nav.logs': '日志',
'proxyProfiles.action.add': '添加代理',
'proxyProfiles.search.placeholder': '搜索代理…',
'proxyProfiles.section.proxies': '代理',
'proxyProfiles.count.items': '{count} 项',
'proxyProfiles.empty.title': '暂无代理',
'proxyProfiles.empty.desc': '创建可复用的 HTTP、SOCKS5 或命令代理,然后在主机详情里选择。',
'proxyProfiles.usage': '已关联 {count} 处',
'proxyProfiles.copyName': '{name} 副本',
'proxyProfiles.panel.newTitle': '新建代理',
'proxyProfiles.field.name': '代理名称',
'proxyProfiles.error.required': '名称和代理详情不能为空。',
'proxyProfiles.error.port': '端口必须在 1 到 65535 之间。',
'proxyProfiles.viewMode': '代理显示方式',
'proxyProfiles.delete.title': '删除代理?',
'proxyProfiles.delete.desc': '删除 "{name}" 会同时从 {count} 个主机或分组设置中解除关联。',
'vault.groups.title': '分组',
'vault.groups.total': '共 {count} 个',
'vault.groups.hostsCount': '{count} 台主机',
'vault.groups.newSubgroup': '新建子分组',
'vault.groups.rename': '重命名分组',
'vault.groups.unnamed': '未命名分组',
'vault.groups.delete': '删除分组',
'vault.groups.createSubfolder': '创建子分组',
'vault.groups.createRoot': '创建根分组',
'vault.groups.createDialog.desc': '创建新的分组用于组织主机。',
'vault.groups.renameDialogTitle': '重命名分组',
'vault.groups.renameDialog.desc': '重命名已有分组。',
'vault.groups.deleteDialogTitle': '删除分组',
'vault.groups.deleteDialog.desc': '这将永久删除该分组并将所有主机移动到根级别。',
'vault.groups.deleteDialog.managedDesc': '这是一个托管的 SSH config 分组。删除后将同时删除所有主机并断开与源文件的连接。',
'vault.groups.deleteDialog.deleteHosts': '同时删除该分组下的所有主机',
'vault.groups.ungrouped': '未分组',
'vault.groups.field.name': '分组名称',
'vault.groups.placeholder.example': '例如:Production',
'vault.groups.parentLabel': '父级',
'vault.groups.pathLabel': '路径',
'vault.groups.settings': '分组设置',
'vault.groups.details': '分组详情',
'vault.groups.details.general': '常规',
'vault.groups.details.ssh': 'SSH',
'vault.groups.details.telnet': 'Telnet',
'vault.groups.details.advanced': '高级',
'vault.groups.details.appearance': '外观',
'vault.groups.details.mosh': 'Mosh',
'vault.groups.details.parentGroup': '父分组',
'vault.groups.details.none': '无',
'vault.groups.details.inherited': '继承自分组',
'vault.groups.details.addProtocol': '添加协议',
'vault.groups.details.removeProtocol': '移除协议',
'vault.groups.details.fontFamily': '字体',
'vault.groups.details.fontSize': '字号',
'vault.groups.errors.required': '分组名称不能为空。',
'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} 个在线',
// Vault hosts header/actions
'vault.hosts.search.placeholder': '查找主机或 ssh user@hostname / ssh -p 2222 user@hostname…',
'vault.hosts.connect': '连接',
'vault.view.grid': '网格',
'vault.view.list': '列表',
'vault.view.tree': '树形',
'vault.tree.expandAll': '展开全部',
'vault.tree.collapseAll': '折叠全部',
'vault.hosts.newHost': '新建主机',
'vault.hosts.newGroup': '新建分组',
'vault.hosts.import': '导入',
'vault.hosts.export': '导出',
'vault.hosts.export.toast.success': '已导出 {count} 个主机到 CSV',
'vault.hosts.export.toast.successWithSkipped': '已导出 {count} 个主机到 CSV(跳过 {skipped} 个不支持的主机)',
'vault.hosts.export.toast.noHosts': '没有主机可导出',
'vault.hosts.allHosts': '全部主机',
'vault.hosts.pinned': '已置顶',
'vault.hosts.recentlyConnected': '最近连接',
'vault.hosts.pinToTop': '置顶',
'vault.hosts.unpin': '取消置顶',
'vault.hosts.copyCredentials': '复制账密信息',
'vault.hosts.copyCredentials.toast.success': '账密信息已复制到剪贴板',
'vault.hosts.copyCredentials.toast.noPassword': '该主机未保存密码',
'vault.hosts.multiSelect': '多选',
'vault.hosts.selected': '已选择 {count} 项',
'vault.hosts.selectAll': '全选',
'vault.hosts.deselectAll': '取消全选',
'vault.hosts.deleteSelected': '删除 ({count})',
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
'vault.hosts.connectSelected': '连接 ({count})',
'vault.hosts.connectMultiple.success': '正在连接 {count} 个主机',
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
'vault.hosts.errors.nameRequired': '主机名称不能为空。',
'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',
'vault.import.desc': '从常见工具迁移连接信息。选择一种格式开始导入。',
'vault.import.chooseFormat': '选择文件格式',
'vault.import.csv.tip': '批量导入:可使用 CSV 模板填写后导入。',
'vault.import.csv.downloadTemplate': '下载 CSV 模板',
'vault.import.toast.start': '正在从 {format} 导入...',
'vault.import.toast.completedTitle': '导入完成',
'vault.import.toast.failedTitle': '导入失败',
'vault.import.toast.noEntries': '{format} 文件中没有可导入的条目。',
'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': '仅导入',
'vault.import.sshConfig.importOnlyDesc': '一次性导入,修改不会同步回文件。',
'vault.import.sshConfig.managed': '托管同步',
'vault.import.sshConfig.managedDesc': '保持同步,修改会自动保存回文件。',
'vault.import.sshConfig.managedGroup': 'ssh config',
'vault.import.sshConfig.managedSuccess': '已导入 {count} 个主机,文件已托管。',
'vault.import.sshConfig.alreadyManaged': '该文件已被托管。',
'vault.import.sshConfig.alreadyManagedDesc': '该文件已在分组 "{group}" 下托管。如需重新导入,请先移除现有的托管源。',
'vault.import.sshConfig.noFilePath': '无法托管此文件。',
'vault.import.sshConfig.noFilePathDesc': '无法确定文件路径。托管同步需要访问文件系统。',
// Known Hosts
'knownHosts.search.placeholder': '搜索已知主机...',
'knownHosts.action.scanSystem': '扫描系统',
'knownHosts.action.importFile': '导入文件',
'knownHosts.action.browseFile': '浏览文件',
'knownHosts.empty.title': '暂无已知主机',
'knownHosts.empty.desc':
'Known Hosts 是你之前连接过的 SSH server。导入系统的 known_hosts 文件以开始。',
'knownHosts.results.showingLimited': '显示 {shown}/{total} 个主机。使用搜索查找特定主机。',
'knownHosts.toast.scanUnavailable': '当前平台无法扫描系统 known_hosts。',
'knownHosts.toast.scanNoFile': '未找到系统 known_hosts 文件。',
'knownHosts.toast.scanNoEntries': 'known_hosts 中没有可用条目。',
'knownHosts.toast.scanImported': '已导入 {count} 个新主机。',
'knownHosts.toast.scanNoNew': '没有发现新的主机。',
'knownHosts.toast.scanFailed': '扫描系统 known_hosts 失败。',
// Port Forwarding
'pf.empty.title': '配置端口转发规则',
'pf.empty.desc': '保存端口转发规则,用于访问数据库、Web 应用等服务。',
'pf.title': '端口转发规则',
'pf.rulesCount': '{count} 条规则',
'pf.wizard.editTitle': '编辑端口转发规则',
'pf.wizard.newTitle': '新建端口转发规则',
'pf.wizard.saveChanges': '保存修改',
'pf.wizard.done': '完成',
'pf.wizard.continue': '继续',
'pf.wizard.cancel': '取消',
'pf.wizard.skipWizard': '跳过向导',
'pf.error.hostNotFound': '未找到主机',
'pf.toast.titleWithLabel': '端口转发规则: {label}',
'pf.type.local': '本地转发',
'pf.type.remote': '远程转发',
'pf.type.dynamic': '动态转发',
'pf.type.menu.local': '本地转发',
'pf.type.menu.remote': '远程转发',
'pf.type.menu.dynamic': '动态转发',
'pf.type.local.desc': '本地转发让你像访问本地一样访问远程服务端口。',
'pf.type.remote.desc': '远程转发在远端开启端口,并将连接转发到本地(当前)主机。',
'pf.type.dynamic.desc': '动态转发将 Netcatty 作为 SOCKS 代理使用。',
'pf.wizard.type.title': '选择端口转发类型:',
'pf.wizard.localConfig.title': '设置本地端口与绑定地址:',
'pf.wizard.localConfig.desc': '该端口会在本地(当前设备)打开,并接收流量。',
'pf.wizard.localConfig.localPort': '本地端口 *',
'pf.wizard.bindAddress': '绑定地址',
'pf.wizard.remoteHost.title': '选择远端主机:',
'pf.wizard.remoteHost.desc': '选择要打开端口的远端主机。该端口的流量将转发到目标地址。',
'pf.wizard.remoteConfig.title': '设置端口与绑定地址:',
'pf.wizard.remoteConfig.desc': '将从所选主机的指定端口与网卡地址转发流量。',
'pf.wizard.remoteConfig.remotePort': '远端端口 *',
'pf.wizard.destination.title': '设置目标地址:',
'pf.wizard.destination.desc.local': '输入你希望通过 tunnel 访问的远端目标地址。',
'pf.wizard.destination.desc.remote': '要转发流量到的目标地址与端口。',
'pf.wizard.destination.address': '目标地址 *',
'pf.wizard.destination.addressPlaceholder': '例如:127.0.0.1 或 192.168.1.100',
'pf.wizard.destination.port': '目标端口 *',
'pf.wizard.sshServer.title': '选择 SSH server:',
'pf.wizard.sshServer.desc.dynamic': '选择作为 SOCKS proxy 的 SSH server。',
'pf.wizard.sshServer.desc.default': '选择用于将流量 tunnel 到目标地址的 SSH server。',
'pf.wizard.label.title': '设置 Label:',
'pf.wizard.label.placeholder.dynamic': '例如:SOCKS Proxy',
'pf.wizard.label.placeholder.default': '例如:MySQL Production',
'pf.wizard.label.placeholder.remoteRule': '例如:Remote Rule',
'pf.wizard.placeholders.portExample': '例如:{port}',
// SFTP
'sftp.newFolder': '新建文件夹',
'sftp.newFile': '新建文件',
'sftp.filter': '筛选',
'sftp.filter.placeholder': '按文件名筛选...',
'sftp.bookmark.add': '收藏此路径',
'sftp.bookmark.remove': '取消收藏',
'sftp.bookmark.list': '收藏路径',
'sftp.bookmark.addGlobal': '+全局',
'sftp.bookmark.addGlobalTooltip': '保存为全局收藏(所有主机共享)',
'sftp.bookmark.empty': '暂无收藏路径',
'sftp.columns.name': '名称',
'sftp.columns.configure': '选择显示的列',
'sftp.columns.modified': '修改时间',
'sftp.columns.size': '大小',
'sftp.columns.kind': '类型',
'sftp.columns.actions': '操作',
'sftp.emptyDirectory': '空目录',
'sftp.nav.up': '返回上层',
'sftp.nav.home': '返回主目录',
'sftp.nav.refresh': '刷新',
'sftp.upload': '上传',
'sftp.uploadFiles': '上传文件',
'sftp.uploadFolder': '上传文件夹',
'sftp.dragDropToUpload': '拖拽文件到这里上传',
'sftp.retry': '重试',
'sftp.context.open': '打开',
'sftp.context.navigateTo': '跳转到这里',
'sftp.context.moveTo': '移动到...',
'sftp.context.moveToParent': '移动到上级目录',
'sftp.moveTo.title': '移动到目录',
'sftp.moveTo.placeholder': '输入目标目录路径',
'sftp.moveTo.confirm': '移动',
'sftp.moveTo.pathNotFound': '目录不存在或无法访问',
'sftp.context.download': '下载',
'sftp.context.copyToOtherPane': '复制到另一侧',
'sftp.copyToOtherPane.unavailable': '请打开双栏 SFTP 文件管理,并连接目标侧后再复制文件。',
'sftp.copyCurrentPath': '复制当前路径',
'sftp.copyCurrentPath.success': '已复制当前路径',
'sftp.copyCurrentPath.error': '无法复制当前路径',
'sftp.viewMode.label': '视图模式',
'sftp.viewMode.list': '列表视图',
'sftp.viewMode.tree': '树形视图',
'sftp.viewMode.switchToList': '切换到列表视图',
'sftp.viewMode.switchToTree': '切换到树形视图',
'sftp.tree.loadError': '加载目录失败',
'sftp.tree.loading': '加载中...',
'sftp.kind.folder': '文件夹',
'sftp.context.rename': '重命名',
'sftp.context.permissions': '权限',
'sftp.context.delete': '删除',
'sftp.context.refresh': '刷新',
'sftp.context.uploadFiles': '上传文件...',
'sftp.context.uploadFilesHere': '上传文件到这里...',
'sftp.context.uploadFolder': '上传文件夹...',
'sftp.context.uploadFolderHere': '上传文件夹到这里...',
'sftp.context.downloadSelected': '下载选中项({count})',
'sftp.context.deleteSelected': '删除选中项({count})',
'sftp.dropFilesHere': '拖拽文件到这里',
'sftp.itemsCount': '{count} 个项目',
'sftp.selectedCount': '已选 {count} 个',
'sftp.path.doubleClickToEdit': '双击编辑路径',
'sftp.showHiddenPaths': '隐藏的路径',
'sftp.task.waiting': '等待中...',
'sftp.transfer.preparing': '准备中...',
'sftp.status.loading': '加载中...',
'sftp.status.uploading': '上传中...',
'sftp.status.ready': '就绪',
'sftp.transfers': '传输',
'sftp.transfers.active': '{count} 个进行中',
'sftp.transfers.clearCompleted': '清除已完成',
'sftp.transfers.calculatingTotal': '正在统计总大小...',
'sftp.transfers.filesCount': '{count} 个文件',
'sftp.transfers.filesProgress': '{current}/{total} 个文件',
'sftp.transfers.expandChildren': '展开文件',
'sftp.transfers.collapseChildren': '收起文件',
'sftp.transfers.expandChildList': '展开详情',
'sftp.transfers.collapseChildList': '收起',
'sftp.transfers.retryAction': '重试',
'sftp.transfers.dismissAction': '移除',
'sftp.transfers.openTargetFolder': '打开目标目录',
'sftp.transfers.openTargetFolderError': '无法打开目标目录',
'sftp.transfers.copyTargetPath': '复制目标路径',
'sftp.transfers.copyTargetPathSuccess': '已复制目标路径',
'sftp.transfers.copyTargetPathError': '无法复制目标路径',
'sftp.transfers.resizeNameColumn': '调整文件名列宽',
'sftp.transfers.dragToResize': '拖拽调整高度',
'sftp.goUp': '上一级',
'sftp.goToTerminalCwd': '定位到终端当前目录',
'sftp.followTerminalCwd': '追随终端目录',
'sftp.followTerminalCwd.enable': '开启追随终端目录',
'sftp.followTerminalCwd.disable': '关闭追随终端目录',
'sftp.encoding.label': '文件名编码',
'sftp.encoding.auto': '自动',
'sftp.encoding.utf8': 'UTF-8',
'sftp.encoding.gb18030': 'GB18030',
'sftp.goHome': '返回主目录',
'sftp.folderName': '文件夹名称',
'sftp.folderName.placeholder': '输入文件夹名称',
'sftp.fileName': '文件名称',
'sftp.fileName.placeholder': '输入文件名称',
'sftp.prompt.newFolderName': '新建文件夹名称?',
'sftp.rename.title': '重命名',
'sftp.rename.newName': '新名称',
'sftp.rename.placeholder': '输入新名称',
'sftp.confirm.deleteOne': '删除 "{name}"?',
'sftp.deleteConfirm.single': '删除 "{name}"?',
'sftp.deleteConfirm.title': '删除 {count} 个项目?',
'sftp.deleteConfirm.desc': '此操作不可撤销,将删除以下内容:',
'sftp.deleteConfirm.descSingle': '此操作不可撤销。',
'sftp.deleteConfirm.host': '主机',
'sftp.deleteConfirm.path': '路径',
'sftp.error.loadFailed': '加载目录失败',
'sftp.error.downloadFailed': '下载失败',
'sftp.error.uploadFailed': '上传失败',
'sftp.error.deleteFailed': '删除失败',
'sftp.error.createFolderFailed': '创建文件夹失败',
'sftp.error.createFileFailed': '创建文件失败',
'sftp.error.invalidFileName': '文件名包含非法字符:{chars}',
'sftp.error.reservedName': '此文件名是系统保留名称',
'sftp.overwrite.title': '文件已存在',
'sftp.overwrite.desc': '名为"{name}"的文件已存在。是否要替换它?',
'sftp.overwrite.confirm': '替换',
'sftp.error.renameFailed': '重命名失败',
'sftp.picker.title': '选择主机',
'sftp.picker.desc': '为{side}窗格选择主机',
'sftp.picker.searchPlaceholder': '搜索主机...',
'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': '所有者',
'sftp.permissions.group': '群组',
'sftp.permissions.others': '其他',
'sftp.permissions.octal': '八进制',
'sftp.permissions.symbolic': '符号',
'sftp.permissions.success': '权限已更新',
'sftp.permissions.failed': '权限更新失败',
// Quick Switcher
'qs.search.placeholder': '搜索主机或标签页',
'qs.jumpTo': '跳转到',
'qs.localTerminal': '本地终端',
'qs.localShells': '本地 Shell',
'qs.default': '默认',
};
+128
View File
@@ -0,0 +1,128 @@
export const zhCNScriptsMessages = {
'scripts.meta.name': '名称',
'scripts.meta.language': '语言',
'scripts.meta.description': '描述',
'scripts.meta.descriptionPlaceholder': '可选,脚本的简要说明',
'scripts.meta.trigger': '触发方式',
'scripts.meta.triggerPattern': '输出匹配(正则)',
'scripts.meta.code': '脚本代码',
'scripts.trigger.manual': '手动运行',
'scripts.trigger.onConnect': '连接时运行',
'scripts.trigger.onOutput': '输出匹配时运行',
'scripts.trigger.onOutputHint': '仅在服务端输出匹配正则时触发(会忽略本地按键回显)。未配置目标主机时,对当前已连接会话生效;配置目标后仅监听对应主机。进入备用屏幕的应用(如 vim、htop)期间不会触发。本 session 有其他脚本运行期间会抑制;脚本结束后再检查 buffer。流程内等待普通文本用 nct.screen.waitForText,正则用 nct.screen.waitForRegex。',
'scripts.actions.save': '保存',
'scripts.actions.runNow': '立即运行',
'scripts.actions.openEditor': '弹窗编辑',
'scripts.actions.openEditorHint': '在更大的窗口中编辑脚本',
'scripts.editor.modalTitle': '脚本编辑器',
'scripts.editor.modalSubtitle': '在更大的工作区中编辑脚本元数据和代码。',
'scripts.editor.lineCount': '{count} 行',
'scripts.editor.resize': '调整编辑器高度',
'scripts.targets.hint': '自动触发作用于所选主机;勾选「对所有主机生效」则作用于全部可连接主机。',
'scripts.targets.connectOrderHint': '连接脚本的执行顺序请在主机详情 → 自动化 中调整。',
'scripts.actions.runNowHint': '在所选目标主机上运行;勾选「对所有主机生效」时在所有可连接主机上运行。',
'scripts.actions.runParallel': '在所有标签页并行运行',
'scripts.actions.runSequential': '在所有标签页顺序运行',
'scripts.actions.skippedConnectingSessions': '有 {count} 个标签仍在连接中,已跳过',
'scripts.actions.noRunnableHosts': '没有与脚本目标匹配的可连接主机',
'scripts.sidePanel.library': '脚本库',
'scripts.sidePanel.running': '运行中',
'scripts.sidePanel.newScript': '新建脚本',
'scripts.running.empty': '当前会话没有正在运行的脚本。',
'scripts.running.unnamed': '未命名脚本',
'scripts.running.status.running': '运行中',
'scripts.running.status.paused': '已暂停',
'scripts.running.status.completed': '已完成',
'scripts.running.status.failed': '失败',
'scripts.running.waitingFor': '等待 {pattern}',
'scripts.running.waitingForLabel': '等待',
'scripts.running.waitingForShellPrompt': 'Shell 提示符(# 或 $)',
'scripts.running.lastSent': '已发送:{command}',
'scripts.recording.start': '开始录制',
'scripts.recording.active': '停止录制',
'scripts.recording.startHint': '在当前终端会话中录制操作,自动生成 nct 脚本代码',
'scripts.recording.unavailableHint': '此入口不支持录制,请用终端右侧脚本侧栏',
'scripts.recording.activeHint': '正在录制当前终端。在终端中正常输入命令;点「停止录制」或终端工具栏的 REC 结束并保存。',
'scripts.recording.started': '已开始录制,请在终端中操作',
'scripts.recording.noSession': '请先连接终端(单机标签或工作区均可)',
'scripts.recording.alreadyActive': '已有其他终端正在录制',
'scripts.recording.stop': '停止录制',
'scripts.recording.pause': '暂停录制',
'scripts.recording.resume': '继续录制',
'scripts.recording.saveTitle': '保存录制的脚本',
'scripts.recording.namePlaceholder': '脚本名称',
'scripts.recording.packagePlaceholder': '保存到文件夹',
'scripts.recording.rootPackage': '根目录',
'scripts.recording.save': '保存',
'scripts.recording.saveAndEdit': '保存并编辑',
'scripts.recording.helpTitle': '如何录制脚本',
'scripts.recording.helpIntro': '录制会把你在终端里的操作自动转成可重复运行的自动化脚本,适合部署、巡检等重复任务。',
'scripts.recording.helpStep1': '连接主机。单机终端标签或工作区里的终端都可以,打开右侧脚本侧栏即可。',
'scripts.recording.helpStep2': '点击「开始录制」。终端工具栏出现红色 REC 表示已开始。',
'scripts.recording.helpStep3': '在终端里像平时一样输入命令,按 Enter 执行。每次回车都会记为一步。',
'scripts.recording.helpStep4': '全部操作完成后,点击「停止录制」,或点终端工具栏 REC 旁的停止按钮。',
'scripts.recording.helpStep5': '填写脚本名称并保存。需要微调时可选「保存并编辑」,在脚本编辑器里修改生成的代码。',
'scripts.recording.helpTipsTitle': '小提示',
'scripts.recording.helpTip1': '两次操作之间停顿超过 1 秒,会自动加入等待时间,避免回放太快。',
'scripts.recording.helpTip2': '每条命令后会等待 shell 提示符(如 $ 或 #)出现,再记录下一步,减少乱序。',
'scripts.recording.helpTip3': '密码输入会被标记为敏感内容,不会明文写入脚本。',
'scripts.recording.helpTip4': 'vim、交互式菜单等复杂操作,建议先录主干命令,再在编辑器里手动补充。',
'scripts.dialog.title': '脚本',
'scripts.dialog.ok': '确定',
'scripts.dialog.required': '必填',
'scripts.dialog.numberInvalid': '请输入有效数字',
'scripts.dialog.numberMin': '不能小于 {min}',
'scripts.dialog.numberMax': '不能大于 {max}',
'scripts.dialog.numberStep': '步进必须为 {step} 的倍数',
'scripts.dialog.waitForTimeoutTitle': '等待超时',
'scripts.dialog.retry': '重试',
'scripts.dialog.skip': '跳过',
'scripts.dialog.abort': '终止',
'scripts.running.stepProgress': '步骤 {current} / {total}',
'scripts.running.determinateProgress': '{label} {current}/{total}',
'scripts.running.progressFallback': '进度',
'scripts.running.operationsCount': '已执行 {count} 个操作',
'scripts.running.opsPrefix': '已执行 ',
'scripts.running.opsSuffix': ' 个操作',
'scripts.running.elapsedLabel': '耗时',
'scripts.running.lastSentLabel': '已发送:',
'scripts.running.elapsed': '耗时 {elapsed}',
'scripts.running.completedSummary': '已完成 · 共 {count} 个操作 · {elapsed}',
'scripts.running.dismissHint': '点击关闭',
'scripts.running.dismiss': '关闭',
'scripts.running.viewLogs': '查看日志',
'scripts.running.logTitle': '{name} · 运行日志',
'scripts.running.logEmpty': '暂无日志输出',
'scripts.running.pause': '暂停',
'scripts.running.resume': '继续',
'scripts.running.stop': '停止',
'scripts.recording.saved': '脚本已保存',
'scripts.recording.savedNamed': '已保存「{name}」',
'scripts.observer.blocked': 'Observer 模式禁止执行会写入终端的脚本。',
'vault.section.scripts': '脚本',
'vault.nav.scripts': '脚本',
'snippets.action.newScript': '新建自动化脚本',
'hostDetails.section.automation': '自动化',
'hostDetails.automation.loginScript': '登录脚本',
'hostDetails.automation.loginScriptPlaceholder': '选择脚本',
'hostDetails.automation.none': '无',
'hostDetails.automation.outputTriggers': '输出触发器',
'hostDetails.automation.addTrigger': '添加触发器',
'hostDetails.automation.triggerPatternPlaceholder': '正则表达式',
'hostDetails.automation.linkedScripts': '已关联脚本',
'hostDetails.automation.linkedScriptsEmpty': '尚未关联任何脚本。',
'hostDetails.automation.linkScriptPlaceholder': '关联已有脚本…',
'hostDetails.automation.unlink': '取消关联',
'hostDetails.automation.equivalenceHint': '此处关联会将本主机加入 Scripts 中该脚本的目标列表,两侧视图同步。',
'hostDetails.automation.queueHint': '连接本主机时按顺序运行脚本。全局脚本最先执行,再执行下方队列。',
'hostDetails.automation.globalScripts': '全局连接脚本',
'hostDetails.automation.globalScriptsHint': '每次连接时最先运行。顺序在脚本库排序中调整。',
'hostDetails.automation.connectQueue': '本主机运行队列',
'hostDetails.automation.connectQueueEmpty': '尚未为本主机配置连接脚本队列。',
'hostDetails.automation.addToQueuePlaceholder': '添加脚本到队列…',
'hostDetails.automation.moveUp': '上移',
'hostDetails.automation.moveDown': '下移',
'hostDetails.automation.removeFromQueue': '从队列移除',
'hostDetails.automation.dragHandle': '拖拽排序',
'hostDetails.automation.queueDragHint': '拖动条目即可调整连接时的运行顺序。',
};
@@ -0,0 +1,209 @@
import type { Messages } from '../types';
export const zhCnSystemManagerMessages: Messages = {
'terminal.layer.system': '系统',
'systemManager.noSession': '没有活动的终端会话。',
'systemManager.notConnected': '请先连接到主机以管理进程与服务。',
'systemManager.empty': '暂无数据。',
'systemManager.tabs.overview': '概览',
'systemManager.tabs.processes': '进程',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.popup.loading': '正在打开终端…',
'systemManager.popup.startupFailed': '启动命令未成功。请确认目标仍然可用后重试。',
'systemManager.errors.loadProcesses': '加载进程列表失败',
'systemManager.errors.loadTmux': '加载 tmux 会话失败',
'systemManager.errors.loadTmuxWindows': '加载 tmux 窗口失败',
'systemManager.errors.loadTmuxPanes': '加载 tmux 面板失败',
'systemManager.errors.loadTmuxClients': '加载 tmux 客户端失败',
'systemManager.errors.actionFailed': '操作失败',
'systemManager.errors.loadDocker': '加载容器列表失败',
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
'systemManager.errors.loadOverview': '加载系统概览失败',
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
'systemManager.overview.empty': '暂无系统概览数据。',
'systemManager.overview.loading': '正在加载系统概览…',
'systemManager.overview.memory': '内存',
'systemManager.overview.disk': '磁盘',
'systemManager.overview.network': '网络',
'systemManager.overview.rx': '下行',
'systemManager.overview.tx': '上行',
'systemManager.overview.cores': '{{count}} 核',
'systemManager.overview.load': '负载',
'systemManager.overview.uptime': '运行时间',
'systemManager.overview.duration.daysHours': '{{days}}天 {{hours}}小时',
'systemManager.overview.duration.hoursMinutes': '{{hours}}小时 {{minutes}}分钟',
'systemManager.overview.duration.minutes': '{{minutes}}分钟',
'systemManager.overview.system': '系统',
'systemManager.overview.kernel': '内核',
'systemManager.overview.swap': '交换区',
'systemManager.overview.latency': 'SSH 网络延迟',
'systemManager.overview.cpuCores': 'CPU 核心',
'systemManager.overview.disks': '磁盘',
'systemManager.overview.interfaces': '网络接口',
'systemManager.overview.topProcesses': '内存占用最高进程',
'systemManager.overview.noData': '暂无数据',
'systemManager.overview.noDisks': '暂无磁盘数据',
'systemManager.overview.noInterfaces': '暂无网卡数据',
'systemManager.overview.noTopProcesses': '暂无进程数据',
'systemManager.processes.search': '搜索进程…',
'systemManager.processes.command': '命令',
'systemManager.processes.user': '用户',
'systemManager.processes.term': '终止',
'systemManager.processes.kill': '强杀',
'systemManager.processes.stop': '暂停 (SIGSTOP)',
'systemManager.processes.cont': '继续 (SIGCONT)',
'systemManager.processes.hup': '挂断 (SIGHUP)',
'systemManager.processes.renice': '调整优先级',
'systemManager.processes.renicePrompt': 'Nice 值 (-20 到 19)',
'systemManager.processes.reniceInvalid': 'Nice 值必须在 -20 到 19 之间',
'systemManager.processes.confirmKill': '向进程 {{pid}} 发送 SIGKILL?',
'systemManager.processes.confirmSignal': '向进程 {{pid}} 发送 SIG{{signal}}?',
'systemManager.processes.filter.all': '全部',
'systemManager.processes.filter.running': '运行中',
'systemManager.processes.ppid': '父进程 PID',
'systemManager.processes.rss': '物理内存',
'systemManager.processes.vsz': '虚拟内存',
'systemManager.processes.elapsed': '运行时长',
'systemManager.processes.stat': '状态',
'systemManager.processes.meta': '{{count}} 个进程',
'systemManager.processes.loading': '正在加载进程…',
'systemManager.processes.loadingMore': '正在显示更多进程…',
'systemManager.processes.state.running': '运行中',
'systemManager.processes.state.sleeping': '睡眠',
'systemManager.processes.state.stopped': '已暂停',
'systemManager.processes.state.zombie': '僵尸',
'systemManager.processes.sort.cpu': 'CPU',
'systemManager.processes.sort.mem': '内存',
'systemManager.processes.sort.pid': 'PID',
'systemManager.processes.sort.command': '命令',
'systemManager.processes.sort.user': '用户',
'systemManager.common.dismiss': '关闭',
'systemManager.common.checkingAvailability': '正在检查可用状态…',
'systemManager.common.loading': '正在加载…',
'systemManager.common.loadingDetails': '正在加载详情…',
'systemManager.common.loadingStats': '正在加载性能数据…',
'systemManager.tmux.new': '新建',
'systemManager.tmux.search': '搜索会话…',
'systemManager.tmux.newSessionTitle': '新建 tmux 会话',
'systemManager.tmux.newSessionDesc': '为会话命名,并可选在启动时执行脚本。',
'systemManager.tmux.newSessionTabCustom': '自定义命令',
'systemManager.tmux.newSessionTabSnippet': '从代码片段',
'systemManager.tmux.pickSnippet': '从代码片段选择',
'systemManager.tmux.pickSnippetEmpty': '暂无代码片段,可在脚本侧栏或仓库中添加。',
'systemManager.tmux.selectedSnippet': '已选片段:{{label}}',
'systemManager.tmux.newSessionName': '会话名称',
'systemManager.tmux.newSessionCommand': '启动命令',
'systemManager.tmux.newSessionCommandPlaceholder': '例如 htop 或 npm run dev(可选)',
'systemManager.tmux.newSessionCommandHint': '留空则创建默认 shell 会话。',
'systemManager.tmux.creating': '创建中…',
'systemManager.tmux.newSessionPlaceholder': 'my-session',
'systemManager.tmux.newSessionRequired': '请先输入会话名称',
'systemManager.tmux.empty': '没有 tmux 会话',
'systemManager.tmux.attach': '附加',
'systemManager.tmux.attached': '已附加',
'systemManager.tmux.detached': '未附加',
'systemManager.tmux.windows': '{{count}} 个窗口',
'systemManager.tmux.created': '创建时间',
'systemManager.tmux.activity': '活动时间',
'systemManager.tmux.rename': '重命名',
'systemManager.tmux.detach': '全部分离',
'systemManager.tmux.killSession': '结束会话',
'systemManager.tmux.killServer': '结束 tmux 服务',
'systemManager.tmux.loadingDetails': '正在加载详情…',
'systemManager.tmux.clients': '已附加客户端',
'systemManager.tmux.windowList': '窗口',
'systemManager.tmux.newWindow': '新建窗口',
'systemManager.tmux.newWindowPlaceholder': '窗口名称(可选)',
'systemManager.tmux.noWindows': '没有窗口',
'systemManager.tmux.unavailable': '此主机未检测到 tmux',
'systemManager.docker.unavailable': '此主机未检测到 Docker',
'systemManager.tmux.windowsMismatch': '会话显示有 {{count}} 个窗口,但 list-windows 未返回任何窗口',
'systemManager.tmux.lastCommand': '最后执行的命令:{{command}}',
'systemManager.tmux.noPanes': '没有面板',
'systemManager.tmux.panes': '{{count}} 个面板',
'systemManager.tmux.active': '当前',
'systemManager.tmux.unnamedWindow': '未命名窗口',
'systemManager.tmux.unnamedPane': '未命名面板',
'systemManager.tmux.attachWindow': '附加到窗口',
'systemManager.tmux.selectWindow': '选中窗口',
'systemManager.tmux.killWindow': '关闭窗口',
'systemManager.tmux.killPane': '关闭面板',
'systemManager.tmux.splitHorizontal': '水平分屏',
'systemManager.tmux.splitVertical': '垂直分屏',
'systemManager.tmux.sendKeys': '发送按键',
'systemManager.tmux.sendKeysTo': '向窗口 {{window}} 面板 {{pane}} 发送按键',
'systemManager.tmux.sendKeysPlaceholder': '命令或文本…',
'systemManager.tmux.renameSessionPrompt': '重命名会话',
'systemManager.tmux.renameWindowPrompt': '重命名窗口',
'systemManager.tmux.windowName': '窗口名称',
'systemManager.tmux.confirmKillSession': '确定结束 tmux 会话「{{name}}」?',
'systemManager.tmux.confirmDetachSession': '确定将所有客户端从「{{name}}」分离?',
'systemManager.tmux.confirmKillWindow': '确定关闭窗口「{{name}}」?',
'systemManager.tmux.confirmKillPane': '确定关闭面板 #{{index}}?',
'systemManager.tmux.confirmKillServer': '确定结束 tmux 服务?所有会话将被终止。',
'systemManager.tmux.meta': '{{count}} 个会话',
'systemManager.docker.title': '容器',
'systemManager.docker.subTabs.containers': '容器',
'systemManager.docker.subTabs.images': '镜像',
'systemManager.docker.empty': '未找到容器',
'systemManager.docker.imagesEmpty': '未找到镜像',
'systemManager.docker.search': '搜索容器…',
'systemManager.docker.searchImages': '搜索镜像…',
'systemManager.docker.filter.all': '全部',
'systemManager.docker.filter.running': '运行中',
'systemManager.docker.filter.stopped': '已停止',
'systemManager.docker.filter.paused': '已暂停',
'systemManager.docker.shell': 'Shell',
'systemManager.docker.logs': '日志',
'systemManager.docker.details': '详情',
'systemManager.docker.inspect': 'Inspect',
'systemManager.docker.imageInspect': '镜像 Inspect',
'systemManager.docker.confirmRemove': '确定删除此容器?',
'systemManager.docker.confirmKill': '确定强制终止此容器?',
'systemManager.docker.confirmRemoveImage': '确定删除镜像「{{name}}」?',
'systemManager.docker.confirmPrune': '确定清理悬空镜像?',
'systemManager.docker.confirmPruneAll': '确定清理所有未使用镜像?',
'systemManager.docker.pause': '暂停',
'systemManager.docker.unpause': '恢复',
'systemManager.docker.restart': '重启',
'systemManager.docker.kill': '强杀',
'systemManager.docker.renamePrompt': '容器名称',
'systemManager.docker.prune': '清理悬空',
'systemManager.docker.pruneAll': '清理全部',
'systemManager.docker.tag': '打标签',
'systemManager.docker.tagRepoPrompt': '仓库名',
'systemManager.docker.tagNamePrompt': '标签名',
'systemManager.docker.meta': '{{count}} 个容器',
'systemManager.docker.imagesMeta': '{{count}} 个镜像',
'systemManager.docker.start': '启动',
'systemManager.docker.stop': '停止',
'systemManager.inspect.status': '状态',
'systemManager.inspect.image': '镜像',
'systemManager.inspect.created': '创建时间',
'systemManager.inspect.started': '启动时间',
'systemManager.inspect.restartPolicy': '重启策略',
'systemManager.inspect.command': '启动命令',
'systemManager.inspect.ports': '端口映射',
'systemManager.inspect.networks': '网络',
'systemManager.inspect.mounts': '挂载',
'systemManager.inspect.env': '环境变量',
'systemManager.inspect.labels': '标签',
'systemManager.inspect.tags': '镜像标签',
'systemManager.inspect.digests': '摘要',
'systemManager.inspect.size': '大小',
'systemManager.inspect.platform': '平台',
'systemManager.inspect.workdir': '工作目录',
'systemManager.inspect.exposedPorts': '暴露端口',
'systemManager.inspect.showRaw': 'JSON',
'systemManager.inspect.hideRaw': '收起 JSON',
};
+822
View File
@@ -0,0 +1,822 @@
import type { Messages } from '../types';
export const zhCNTerminalMessages: Messages = {
'terminal.sudoHint.pressEnter': '按 Enter 粘贴 sudo 密码',
'terminal.menu.rename': '重命名',
'terminal.menu.uploadClipboardImage': '上传剪贴板图片',
'terminal.clipboardImageUpload.noImage': '剪贴板中没有图片',
'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 也能继续跟随终端目录。',
'terminal.osc7Setup.targets': '可能写入的文件',
'terminal.osc7Setup.command': '将执行的命令',
'terminal.osc7Setup.run': '执行配置',
'terminal.osc7Setup.running': '正在配置...',
'terminal.osc7Setup.configured': '目录追踪已配置',
'terminal.osc7Setup.failed': '目录追踪配置失败',
'terminal.osc7Setup.sent': '目录追踪配置已发送到终端',
'terminal.toolbar.timestampsEnable': '显示时间戳',
'terminal.toolbar.timestampsDisable': '隐藏时间戳',
'terminal.progress.enterReconnectHint': '按 Enter 重新连接',
'terminal.progress.autoReconnectScheduled': '连接已断开,将在 {seconds} 秒后自动重连(第 {attempt} 次)。',
'terminal.progress.autoReconnectAttempt': '正在自动重连(第 {attempt} 次)...',
'terminal.connection.protocol.et': 'EternalTerminal',
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支持 Netcatty 的代理设置。请改用 SSH,或移除该主机的代理。',
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支持一个跳板机。',
// Command history side panel
'history.scope.label': '历史范围',
'history.tab.host': '主机',
'history.tab.global': '全局',
'history.searchPlaceholder': '搜索历史命令...',
'history.loading': '正在读取远程历史...',
'history.meta.count': '{count} 条',
'history.empty.noSession': '请先打开一个远程会话以查看其命令历史。',
'history.empty.unsupportedProtocol': '仅 SSH/Mosh/ET 会话支持命令历史。',
'history.empty.noHistory': '该主机上未找到命令历史。',
'history.empty.noGlobalHistory': '暂无全局命令历史。你执行的命令会记录在这里。',
'history.action.refresh': '刷新',
'history.action.retry': '重试',
'history.action.paste': '粘贴到终端',
'history.action.run': '在终端执行',
'history.action.saveAsSnippet': '保存为代码片段',
// SFTP File Opener
'sftp.context.copyPath': '复制文件路径',
'sftp.context.openWith': '打开方式...',
'sftp.context.edit': '编辑',
'sftp.context.preview': '预览',
'sftp.opener.title': '打开方式',
'sftp.opener.desc': '选择一个应用程序来打开此文件',
'sftp.opener.builtInEditor': '内置编辑器',
'sftp.opener.editDescription': '编辑文本文件',
'sftp.opener.builtInImageViewer': '内置图片预览',
'sftp.opener.previewDescription': '预览图片',
'sftp.opener.systemApp': '选择应用程序...',
'sftp.opener.systemAppDescription': '从本地选择一个应用程序',
'sftp.opener.onlySystemApp': '此文件只能用外部应用程序打开',
'sftp.opener.noAppsAvailable': '无可用应用程序',
'sftp.opener.noExtension': '无扩展名文件',
'sftp.opener.setDefault': '始终使用此方式打开 {ext} 文件',
'sftp.opener.confirmTitle': '设为默认?',
'sftp.opener.confirmDescription': '是否始终使用 {app} 打开 {ext} 文件?',
'sftp.opener.yesRemember': '是,记住此选择',
'sftp.opener.justOnce': '仅此一次',
'sftp.opener.confirm.title': '设置默认应用程序',
'sftp.opener.confirm.desc': '是否始终使用此应用程序打开 .{ext} 文件?',
'sftp.editor.title': '文本编辑器',
'sftp.editor.save': '保存到远程',
'sftp.editor.saving': '保存中...',
'sftp.editor.saved': '保存成功',
'sftp.editor.saveFailed': '保存文件失败',
'sftp.editor.unsavedChanges': '您有未保存的更改。确定要关闭吗?',
'sftp.editor.syntaxHighlight': '语法高亮',
'sftp.preview.title': '图片预览',
'sftp.preview.zoomIn': '放大',
'sftp.preview.zoomOut': '缩小',
'sftp.preview.resetZoom': '重置缩放',
'sftp.preview.fitToWindow': '适应窗口',
// Settings > SFTP File Associations
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftp.transferConcurrency': '传输并发数',
'settings.sftp.transferConcurrency.desc': '上传或下载文件夹时并行传输的文件数量。较高的值可能提高速度,但可能导致某些服务器过载。',
'settings.sftp.defaultOpener': '默认文件打开方式',
'settings.sftp.defaultOpener.desc': '选择没有特定文件关联时的默认打开方式',
'settings.sftp.defaultOpener.ask': '每次询问',
'settings.sftp.defaultOpener.askDesc': '每次打开文件时弹出选择对话框',
'settings.sftp.defaultOpener.builtInDesc': '默认使用内置编辑器打开文本文件',
'settings.sftp.defaultOpener.systemApp': '选择应用程序...',
'settings.sftp.defaultOpener.systemAppDesc': '默认使用指定的外部应用程序打开文件',
'settings.sftpFileAssociations.title': 'SFTP 文件关联',
'settings.sftpFileAssociations.desc': '配置按扩展名打开文件的默认应用程序',
'settings.sftpFileAssociations.extension': '扩展名',
'settings.sftpFileAssociations.application': '应用程序',
'settings.sftpFileAssociations.noAssociations': '未配置文件关联',
'settings.sftpFileAssociations.remove': '移除',
'settings.sftpFileAssociations.removeConfirm': '确定移除 .{ext} 的关联吗?',
// Settings > SFTP Behavior
'settings.sftp.doubleClickBehavior': '双击行为',
'settings.sftp.doubleClickBehavior.desc': '选择在 SFTP 视图中双击文件时的操作',
'settings.sftp.doubleClickBehavior.open': '打开文件',
'settings.sftp.doubleClickBehavior.transfer': '传输到另一侧',
'settings.sftp.doubleClickBehavior.openDesc': '使用默认应用程序打开文件',
'settings.sftp.doubleClickBehavior.transferDesc': '将文件传输到另一窗格的活动主机',
'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': '自动同步到远程',
'settings.sftp.autoSync.desc': '使用外部应用程序打开文件时,自动将文件更改同步回远程服务器',
'settings.sftp.autoSync.enable': '启用自动同步',
'settings.sftp.autoSync.enableDesc': '在外部应用程序中保存文件时,更改将自动上传到远程服务器',
// Settings > SFTP 自动打开侧栏
'settings.sftp.autoOpenSidebar': '连接时自动打开侧栏',
'settings.sftp.autoOpenSidebar.desc': '连接到主机时自动打开 SFTP 文件浏览器侧栏',
'settings.sftp.autoOpenSidebar.enable': '启用自动打开侧栏',
'settings.sftp.autoOpenSidebar.enableDesc': '当终端会话连接到远程主机时,SFTP 侧栏将自动打开',
'settings.sftp.followTerminalCwd': '追随终端目录',
'settings.sftp.followTerminalCwd.desc': '在侧栏 SFTP 中自动跟随终端当前工作目录变化(可在工具栏切换)',
'settings.sftp.followTerminalCwd.enable': '默认开启追随终端目录',
'settings.sftp.followTerminalCwd.enableDesc': '打开侧栏 SFTP 时默认启用追随模式,终端执行 cd 后文件浏览器会自动跳转',
'settings.sftp.defaultViewMode': '默认视图模式',
'settings.sftp.defaultViewMode.desc': '选择打开新 SFTP 标签页时的默认视图模式。每个主机的偏好设置会覆盖此全局设置。',
'settings.sftp.defaultViewMode.list': '列表视图',
'settings.sftp.defaultViewMode.listDesc': '以平面列表显示当前目录的文件',
'settings.sftp.defaultViewMode.tree': '树形视图',
'settings.sftp.defaultViewMode.treeDesc': '以层级树形结构显示文件',
'sftp.autoSync.success': '文件已同步到远程:{fileName}',
'sftp.autoSync.error': '同步文件失败:{error}',
// SFTP Folder Upload Progress
'sftp.upload.progress': '正在上传 {current}/{total} 个文件...',
'sftp.upload.uploading': '正在上传...',
'sftp.upload.compressing': '正在压缩...',
'sftp.upload.extracting': '正在解压...',
'sftp.upload.scanning': '正在扫描文件...',
'sftp.upload.completed': '已完成',
'sftp.upload.compressed': '压缩传输',
'sftp.upload.currentFile': '当前: {fileName}',
'sftp.upload.cancelled': '上传已取消',
'sftp.upload.cancel': '取消',
'sftp.upload.completedToPath': '已上传至 {path}',
// SFTP Download
'sftp.download.completed': '已下载',
'sftp.download.cancelled': '下载已取消',
// SFTP Reconnecting
'sftp.reconnecting.title': '正在重连...',
'sftp.reconnecting.desc': '连接已断开,正在尝试重新连接',
'sftp.reconnected': '连接已恢复',
'sftp.error.reconnectFailed': '重连失败,请重试。',
'sftp.error.connectionLostManual': '连接已断开,请手动重新连接。',
'sftp.error.connectionLostReconnecting': '连接已断开,正在重连...',
'sftp.error.sessionLost': 'SFTP 会话已断开,请重新连接。',
// Settings > SFTP Show Hidden Files
'settings.sftp.showHiddenFiles': '显示隐藏文件',
'settings.sftp.showHiddenFiles.desc': '在 SFTP 文件浏览器中显示隐藏文件(Unix/macOS 点文件和 Windows 隐藏属性文件)。',
'settings.sftp.showHiddenFiles.enable': '显示隐藏文件',
'settings.sftp.showHiddenFiles.enableDesc': '浏览本地和远程文件系统时显示隐藏文件',
// Settings > SFTP Compressed Upload
'settings.sftp.compressedUpload': '文件夹压缩传输',
'settings.sftp.compressedUpload.desc': '上传前压缩文件夹,可大幅减少传输时间。',
'settings.sftp.compressedUpload.enable': '启用文件夹压缩',
'settings.sftp.compressedUpload.enableDesc': '自动使用 tar 压缩文件夹后再传输。需要服务器支持 tar 命令,不支持时自动回退到普通传输。',
// Settings > Terminal
'settings.terminal.section.theme': '终端主题',
'settings.terminal.themeModal.title': '选择主题',
'settings.terminal.themeModal.darkThemes': '深色主题',
'settings.terminal.themeModal.lightThemes': '浅色主题',
'settings.terminal.theme.selectButton': '选择主题',
'settings.terminal.theme.followApp': '跟随应用主题',
'settings.terminal.theme.followApp.desc': '终端背景色自动匹配当前应用主题,保持视觉一致性。',
'settings.terminal.theme.darkTheme': '深色模式终端主题',
'settings.terminal.theme.lightTheme': '浅色模式终端主题',
'settings.terminal.theme.auto': '自动(跟随界面主题)',
'settings.terminal.theme.autoDesc': '跟随当前界面主题预设',
'settings.terminal.section.font': '字体',
'settings.terminal.section.cursor': '光标',
'settings.terminal.section.keyboard': '键盘',
'settings.terminal.section.accessibility': '无障碍',
'settings.terminal.section.behavior': '行为',
'settings.terminal.section.scrollback': '回滚',
'settings.terminal.section.keywordHighlight': '关键字高亮',
'settings.terminal.font.family': '字体',
'settings.terminal.font.family.desc': '终端字体',
'settings.terminal.font.cjk': '中文 / CJK 字体',
'settings.terminal.font.cjk.desc': '用于渲染中 / 日 / 韩字符的字体;"Auto" 会按主字体智能搭配',
'settings.terminal.font.cjk.option.auto': 'Auto · 按主字体智能搭配',
'settings.terminal.font.cjk.option.sarasaSC': 'Sarasa Mono SC (更纱黑体 简)',
'settings.terminal.font.cjk.option.sarasaTC': 'Sarasa Mono TC (更纱黑体 繁)',
'settings.terminal.font.cjk.option.mapleCN': 'Maple Mono CN',
'settings.terminal.font.cjk.option.sourceHan': 'Source Han Mono SC (思源等宽)',
'settings.terminal.font.cjk.option.notoCJK': 'Noto Sans Mono CJK SC',
'settings.terminal.font.cjk.option.lxgwWenkai': 'LXGW WenKai Mono (霞鹜文楷等宽)',
'settings.terminal.font.cjk.option.simSun': 'SimSun (宋体)',
'settings.terminal.font.cjk.option.legacy': '{font} · 不推荐(非等宽字体)',
'settings.terminal.font.size': '字体大小',
'settings.terminal.font.size.desc': '终端文字大小',
'settings.terminal.font.weight': '字重',
'settings.terminal.font.weight.desc': '常规文本字重 (100-900)',
'settings.terminal.font.weight.thin': '极细',
'settings.terminal.font.weight.extraLight': '特细',
'settings.terminal.font.weight.light': '细',
'settings.terminal.font.weight.normal': '常规',
'settings.terminal.font.weight.medium': '中等',
'settings.terminal.font.weight.semiBold': '半粗',
'settings.terminal.font.weight.bold': '粗',
'settings.terminal.font.weight.extraBold': '特粗',
'settings.terminal.font.weight.black': '黑体',
'settings.terminal.font.weightBold': '粗体字重',
'settings.terminal.font.weightBold.desc': '粗体文本字重 (100-900)',
'settings.terminal.font.smoothing': '字体平滑',
'settings.terminal.font.smoothing.desc': '使用 macOS 原生字体抗锯齿',
'settings.terminal.font.linePadding': '行间距',
'settings.terminal.font.linePadding.desc': '行之间的额外间距 (0-10)',
'settings.terminal.font.emulationType': '终端仿真类型',
'settings.terminal.cursor.style': '光标样式',
'settings.terminal.cursor.style.block': '块',
'settings.terminal.cursor.style.bar': '竖线',
'settings.terminal.cursor.style.underline': '下划线',
'settings.terminal.cursor.blink': '光标闪烁',
'settings.terminal.keyboard.altAsMeta': '将 Option 作为 Meta 键',
'settings.terminal.keyboard.altAsMeta.desc': '使用 Option (Alt) 作为 Meta 键,而不是用于输入特殊字符',
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ 按单词跳转',
'settings.terminal.keyboard.optionArrowWordJump.desc': '按 Option+左/右 时发送 Meta-b / Meta-f,让 Shell 按单词移动光标(而非默认的 ^[[1;3D / ^[[1;3C)',
'settings.terminal.accessibility.minimumContrastRatio': '最小对比度',
'settings.terminal.accessibility.minimumContrastRatio.desc': '调整颜色以满足对比度要求 (1 = 禁用, 21 = 最大)',
'settings.terminal.behavior.rightClick': '右键行为',
'settings.terminal.behavior.rightClick.desc': '在终端中右键时执行的操作',
'settings.terminal.behavior.rightClick.menu': '显示菜单',
'settings.terminal.behavior.rightClick.paste': '粘贴',
'settings.terminal.behavior.rightClick.selectWord': '选择单词',
'settings.terminal.behavior.copyOnSelect': '选择即复制',
'settings.terminal.behavior.copyOnSelect.desc': '自动复制选中的文本。在 tmux/vim 鼠标模式下,macOS 按住 Option,Windows/Linux 按住 Shift 拖选即可选中文本',
'settings.terminal.behavior.middleClickPaste': '中键粘贴',
'settings.terminal.behavior.middleClickPaste.desc': '中键点击时粘贴剪贴板内容',
'settings.terminal.behavior.middleClick': '中键行为',
'settings.terminal.behavior.middleClick.desc': '在终端中点击鼠标中键时执行的操作',
'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 默认行为)。关闭则保留历史。',
'settings.terminal.behavior.preserveSelectionOnInput': '输入时保留选区',
'settings.terminal.behavior.preserveSelectionOnInput.desc':
'键盘输入时不清除鼠标选中的文本,方便选中路径后输入 `sz ` 之类命令再粘贴。',
'settings.terminal.behavior.forcePromptNewLine': '提示符另起一行',
'settings.terminal.behavior.forcePromptNewLine.desc':
'当命令输出的最后一行未以换行符结束时,将识别到的 shell 提示符移动到下一行显示。',
'settings.terminal.behavior.dynamicTabTitle': '动态标签页标题',
'settings.terminal.behavior.dynamicTabTitle.desc':
'选择标签页什么时候跟随 Shell 报告的窗口标题。',
'settings.terminal.behavior.dynamicTabTitle.off': '关闭',
'settings.terminal.behavior.dynamicTabTitle.agent': '仅 Agent',
'settings.terminal.behavior.dynamicTabTitle.all': '全部会话',
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪贴板',
'settings.terminal.behavior.osc52Clipboard.desc':
'允许远程程序(tmux、vim 等)通过 OSC-52 转义序列访问本地剪贴板。',
'settings.terminal.behavior.osc52Clipboard.off': '关闭',
'settings.terminal.behavior.osc52Clipboard.writeOnly': '仅写入',
'settings.terminal.behavior.osc52Clipboard.readWrite': '读写',
'settings.terminal.behavior.osc52Clipboard.prompt': '写入 + 读取时询问',
'terminal.osc52.readPrompt.title': '剪贴板读取请求',
'terminal.osc52.readPrompt.desc': '远程程序正在请求读取您的剪贴板,是否允许?',
'terminal.osc52.readPrompt.allow': '允许',
'terminal.osc52.readPrompt.deny': '拒绝',
'settings.terminal.behavior.scrollOnInput': '输入时自动滚动',
'settings.terminal.behavior.scrollOnInput.desc': '输入时将终端滚动到底部',
'settings.terminal.behavior.scrollOnOutput': '输出时自动滚动',
'settings.terminal.behavior.scrollOnOutput.desc': '有新输出时将终端滚动到底部',
'settings.terminal.behavior.scrollOnKeyPress': '按键时自动滚动',
'settings.terminal.behavior.scrollOnKeyPress.desc': '按键(例如 Enter)时将终端滚动到底部',
'settings.terminal.behavior.scrollOnPaste': '粘贴时自动滚动',
'settings.terminal.behavior.scrollOnPaste.desc': '粘贴文本时将终端滚动到底部',
'settings.terminal.behavior.smoothScrolling': '平滑滚动',
'settings.terminal.behavior.smoothScrolling.desc': '滚动终端视口时使用平滑动画',
'settings.terminal.behavior.linkModifier': '链接修饰键',
'settings.terminal.behavior.linkModifier.desc': '按住此键再点击终端中的链接',
'settings.terminal.behavior.linkModifier.none': '无(直接点击)',
'settings.terminal.behavior.linkModifier.ctrl': 'Ctrl',
'settings.terminal.behavior.linkModifier.alt': 'Alt / Option',
'settings.terminal.behavior.linkModifier.meta': 'Cmd / Win',
'settings.terminal.scrollback.desc': '限制终端行数。设为 0 表示不限制。',
'settings.terminal.scrollback.rows': '行数 *',
'settings.terminal.section.startupCommand': '启动命令',
'settings.terminal.startupCommandDelay.label': '启动命令延迟(毫秒)',
'settings.terminal.startupCommandDelay.desc': '连接建立后等待多久再发送启动命令;启动命令为多行时,行与行之间也使用该间隔。慢连接可调大。',
'settings.terminal.keywordHighlight.title': '关键字高亮',
'settings.terminal.keywordHighlight.resetColors': '重置为默认颜色',
'settings.terminal.keywordHighlight.resetDefaults': '把内置规则恢复为默认',
'settings.terminal.keywordHighlight.resetBuiltIn': '恢复内置标签与正则',
'settings.terminal.keywordHighlight.addCustom': '添加自定义规则',
'settings.terminal.keywordHighlight.editCustom': '编辑规则',
'settings.terminal.keywordHighlight.editBuiltIn': '编辑内置规则',
'settings.terminal.keywordHighlight.labelField': '标签与颜色',
'settings.terminal.keywordHighlight.labelPlaceholder': '标签(如 Down)',
'settings.terminal.keywordHighlight.patternField': '正则表达式',
'settings.terminal.keywordHighlight.patternPlaceholder': '每行一个正则(如 \\bdown\\b)',
'settings.terminal.keywordHighlight.patternHint': '每行一个正则。匹配忽略大小写,全局匹配。',
'settings.terminal.keywordHighlight.invalidPattern': '无效的正则表达式',
'settings.terminal.keywordHighlight.preview': '预览',
'settings.terminal.section.localShell': '本地 Shell',
'settings.terminal.localShell.shell': 'Shell 可执行文件',
'settings.terminal.localShell.shell.desc': 'Shell 可执行文件的路径(例如 /bin/zsh、pwsh.exe)。留空使用系统默认。',
'settings.terminal.localShell.shell.placeholder': '系统默认',
'settings.terminal.localShell.shell.detected': '检测到',
'settings.terminal.localShell.shell.notFound': '未找到 Shell 可执行文件',
'settings.terminal.localShell.shell.isDirectory': '路径是目录,不是可执行文件',
'settings.terminal.localShell.shell.default': '系统默认',
'settings.terminal.localShell.shell.custom': '自定义...',
'settings.terminal.localShell.shell.customPath': 'Shell 可执行文件路径',
'settings.terminal.localShell.shell.customArgs': '启动参数',
'settings.terminal.localShell.shell.customArgs.placeholder': '例如 --login -i',
'settings.terminal.localShell.shell.customArgs.desc': '传给 Shell 的启动参数。部分 Shell 必须指定才能正常工作,例如 msys2 bash 需要 --login -i 才能加载环境变量。',
'settings.terminal.localShell.shell.commonPaths': '常用路径',
'settings.terminal.localShell.shell.pathValid': '路径有效',
'settings.terminal.localShell.startDir': '起始目录',
'settings.terminal.localShell.startDir.desc': '打开本地终端时的起始目录。留空使用用户主目录。',
'settings.terminal.localShell.startDir.placeholder': '用户主目录',
'settings.terminal.localShell.startDir.notFound': '目录不存在',
'settings.terminal.localShell.startDir.isFile': '路径是文件,不是目录',
'settings.terminal.section.connection': '连接',
'settings.terminal.connection.sshAutoReconnectEnabled': 'SSH 断线后自动重连',
'settings.terminal.connection.sshAutoReconnectEnabled.desc': '打开后,已连上的 SSH 会话意外断开时会每 5 秒尝试重新连接,直到关闭标签页或连接恢复。',
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
'settings.terminal.connection.keepaliveInterval.desc': '向服务器发送 SSH 保活数据包的频率(秒)。设为 0 表示全局禁用——单个主机可在自己的设置里覆盖此值。',
'settings.terminal.connection.keepaliveCountMax': '最大无响应保活次数',
'settings.terminal.connection.keepaliveCountMax.desc': '判定连接死亡前允许的无响应保活次数。值越大对短暂网络抖动和响应慢的 SSH 服务越宽容。',
'settings.terminal.connection.x11Display': 'X11 显示地址',
'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': '刷新间隔',
'settings.terminal.serverStats.refreshInterval.desc': '服务器状态刷新的频率。',
'settings.terminal.serverStats.seconds': '秒',
'settings.terminal.section.systemManager': '系统管理',
'settings.terminal.systemManager.processRefreshInterval': '进程列表刷新间隔',
'settings.terminal.systemManager.processRefreshInterval.desc': '系统管理侧栏中进程列表的刷新频率。',
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 会话刷新间隔',
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 会话列表的刷新频率。',
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表刷新间隔',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的刷新频率。',
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 性能数据刷新间隔',
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/内存/网络指标的刷新频率。',
// Settings > Terminal > Rendering
'settings.terminal.section.rendering': '渲染',
'settings.terminal.rendering.renderer': '渲染器',
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 DOM 渲染。更改将在新终端会话中生效。',
'settings.terminal.rendering.auto': '自动',
'settings.terminal.rendering.hibernateHiddenTabs': '隐藏标签页休眠',
'settings.terminal.rendering.hibernateHiddenTabs.desc': '标签页离开视野后释放终端渲染器以节省内存,SSH 连接保持不断。文件传输期间不会休眠。',
'settings.terminal.rendering.hibernateHiddenTabsDelay': '休眠延迟',
'settings.terminal.rendering.hibernateHiddenTabsDelay.desc': '标签页离开视野多久后释放渲染器(5–600 秒)。',
'settings.terminal.rendering.hibernateSkipAltScreen': '全屏 TUI 跳过休眠',
'settings.terminal.rendering.hibernateSkipAltScreen.desc': 'vim、htop 或 Agent TUI 占用 alternate screen 时保持终端渲染器,避免慢恢复。',
'settings.terminal.rendering.hibernateKeepRendererCount': '保留隐藏渲染器数量',
'settings.terminal.rendering.hibernateKeepRendererCount.desc': '多少个不可见标签页仅暂停 WebGL、不销毁 xterm,超出后才完整休眠。',
'settings.terminal.rendering.hibernateReplayChunkBytes': '唤醒分片大小',
'settings.terminal.rendering.hibernateReplayChunkBytes.desc': '恢复休眠终端时每帧回放的 ANSI 字节数(4–64 KB)。',
'settings.terminal.rendering.hibernatePreferWasmSerialize': '优先 WASM 序列化',
'settings.terminal.rendering.hibernatePreferWasmSerialize.desc': '在可用时使用 WASM 终端序列化,否则回退到 JS SerializeAddon。',
// Settings > Terminal > Workspace Focus Indicator
'settings.terminal.section.workspaceFocus': '工作区焦点提示',
'settings.terminal.workspaceFocus.style': '焦点提示样式',
'settings.terminal.workspaceFocus.style.desc': '在分屏视图中如何标识当前聚焦的窗格。',
'settings.terminal.workspaceFocus.dim': '淡化未聚焦窗格',
'settings.terminal.workspaceFocus.border': '为聚焦窗格显示边框',
// Settings > Terminal > Autocomplete
'settings.terminal.section.autocomplete': '自动补全',
'settings.terminal.autocomplete.enabled': '启用自动补全',
'settings.terminal.autocomplete.enabled.desc': '输入时根据历史命令和命令规范显示补全建议。',
'settings.terminal.autocomplete.ghostText': '行内建议',
'settings.terminal.autocomplete.ghostText.desc': '在光标后显示灰色的建议文本(类似 fish shell)。',
'settings.terminal.autocomplete.popupMenu': '弹出菜单',
'settings.terminal.autocomplete.popupMenu.desc': '显示包含多个建议的浮动列表。',
// Settings > Shortcuts
'settings.shortcuts.section.scheme': '快捷键方案',
'settings.shortcuts.scheme.label': '键盘快捷键',
'settings.shortcuts.scheme.desc': '选择快捷键使用的键盘布局',
'settings.shortcuts.scheme.disabled': '禁用',
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
'settings.shortcuts.disableTerminalFontZoom.label': '禁用终端缩放',
'settings.shortcuts.disableTerminalFontZoom.desc': '关闭终端文字缩放快捷操作,包括 Cmd/Ctrl 加滚轮。',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '标签快捷键跳过固定标签',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '开启后,Cmd/Ctrl+[1...9] 和 Ctrl+Tab 仅在终端、工作区、编辑器等可关闭标签页之间切换,不包括固定的 Vault 和 SFTP 标签页。',
'settings.shortcuts.section.custom': '自定义快捷键',
'settings.shortcuts.resetAll': '全部重置',
'settings.shortcuts.recording': '请按键...',
'settings.shortcuts.none': '无',
'settings.shortcuts.setDisabled': '设为禁用',
'settings.shortcuts.category.tabs': '标签页',
'settings.shortcuts.category.terminal': '终端',
'settings.shortcuts.category.navigation': '导航',
'settings.shortcuts.category.app': '应用',
'settings.shortcuts.category.sftp': 'SFTP',
'settings.shortcuts.binding.switch-tab-1-9': '切换到标签页 [1...9]',
'settings.shortcuts.binding.next-tab': '下一个标签页',
'settings.shortcuts.binding.prev-tab': '上一个标签页',
'settings.shortcuts.binding.close-tab': '关闭标签页',
'settings.shortcuts.binding.close-session': '关闭会话窗格',
'settings.shortcuts.binding.new-tab': '新建本地标签页',
'settings.shortcuts.binding.copy': '从终端复制',
'settings.shortcuts.binding.paste': '粘贴到终端',
'settings.shortcuts.binding.paste-selection': '将选区粘贴到终端',
'settings.shortcuts.binding.select-all': '全选终端内容',
'settings.shortcuts.binding.clear-buffer': '清空终端缓冲区',
'settings.shortcuts.binding.search-terminal': '打开终端搜索',
'settings.shortcuts.binding.increase-terminal-font-size': '增大终端字号',
'settings.shortcuts.binding.decrease-terminal-font-size': '减小终端字号',
'settings.shortcuts.binding.reset-terminal-font-size': '重置终端字号',
'settings.shortcuts.binding.move-focus': '在分屏间移动焦点',
'settings.shortcuts.binding.split-horizontal': '水平分屏',
'settings.shortcuts.binding.split-vertical': '垂直分屏',
'settings.shortcuts.binding.toggle-pane-zoom': '切换窗格缩放',
'settings.shortcuts.binding.open-hosts': '打开主机列表',
'settings.shortcuts.binding.open-local': '打开本地终端',
'settings.shortcuts.binding.open-sftp': '打开 SFTP',
'settings.shortcuts.binding.open-settings': '打开设置',
'settings.shortcuts.binding.port-forwarding': '打开端口转发',
'settings.shortcuts.binding.command-palette': '打开命令面板',
'settings.shortcuts.binding.quick-switch': '快速切换',
'settings.shortcuts.binding.new-workspace': '新建工作区',
'settings.shortcuts.binding.snippets': '打开代码片段',
'settings.shortcuts.binding.broadcast': '切换广播模式',
'settings.shortcuts.binding.toggle-side-panel': '切换侧边栏',
'settings.shortcuts.binding.sftp-copy': '复制文件',
'settings.shortcuts.binding.sftp-cut': '剪切文件',
'settings.shortcuts.binding.sftp-paste': '粘贴文件',
'settings.shortcuts.binding.sftp-select-all': '全选文件',
'settings.shortcuts.binding.sftp-rename': '重命名文件',
'settings.shortcuts.binding.sftp-delete': '删除文件',
'settings.shortcuts.binding.sftp-refresh': '刷新',
'settings.shortcuts.binding.sftp-new-folder': '新建文件夹',
'settings.shortcuts.binding.sftp-open': '打开文件 / 进入目录',
'settings.shortcuts.binding.sftp-go-parent': '转到上级目录',
'settings.shortcuts.binding.sftp-navigate-to': '转到选中的目录',
// Host Details (sub-panels)
'hostDetails.proxyPanel.title': '通过 HTTP/SOCKS5/命令代理',
'hostDetails.proxyPanel.hostPlaceholder': '代理主机',
'hostDetails.proxyPanel.command': 'ProxyCommand',
'hostDetails.proxyPanel.commandPlaceholder': 'cloudflared access ssh --hostname %h',
'hostDetails.proxyPanel.commandHelp': '使用 %h 表示目标主机,%p 表示目标端口,%% 表示字面百分号。',
'hostDetails.proxyPanel.credentials': '凭据',
'hostDetails.proxyPanel.usernamePlaceholder': '用户名',
'hostDetails.proxyPanel.passwordPlaceholder': '密码',
'hostDetails.proxyPanel.identities': '身份',
'hostDetails.proxyPanel.keychainIdentity': '钥匙串身份',
'hostDetails.proxyPanel.manualCredentials': '手动凭据',
'hostDetails.proxyPanel.remove': '移除代理',
'hostDetails.proxyPanel.savedProxy': '已保存代理',
'hostDetails.proxyPanel.selectSaved': '选择已保存代理',
'hostDetails.proxyPanel.customProxy': '自定义代理',
'hostDetails.proxyPanel.missing': '缺失',
'hostDetails.proxyPanel.missingSaved': '保存的代理不存在',
'hostDetails.proxyPanel.missingIdentity': '钥匙串身份不存在',
'hostDetails.proxyPanel.incompleteIdentity': '代理身份需要用户名和密码',
'hostDetails.proxyPanel.unreadableIdentity': '此设备无法读取代理身份密码',
'hostDetails.proxyPanel.error.required': '代理主机和端口,或 ProxyCommand 不能为空。',
'hostDetails.envVars.title': '环境变量',
'hostDetails.envVars.desc': '为 {host} 设置环境变量。',
'hostDetails.envVars.note': '部分 SSH 服务器默认只允许以 LC_ 和 LANG_ 为前缀的变量。',
'hostDetails.envVars.variable': '变量',
'hostDetails.envVars.value': '值',
'hostDetails.envVars.newVariable': '新变量',
'hostDetails.envVars.variableName': '变量名',
'hostDetails.chain.title': '编辑链路',
'hostDetails.chain.desc': '添加另一台主机将创建到 {host} 的连接。',
'hostDetails.chain.addHost': '添加主机',
'hostDetails.chain.target': '目标',
'hostDetails.chain.availableHosts': '可用主机',
'hostDetails.chain.clear': '清空',
'hostDetails.group.title': '新建分组',
'hostDetails.group.general': '常规',
'hostDetails.group.namePlaceholder': '分组名称',
'hostDetails.group.parentPlaceholder': '父分组',
'hostDetails.group.cloudSync': '云同步',
'hostDetails.group.addProtocol': '添加协议',
// Keychain
'keychain.filter.key': '密钥',
'keychain.filter.certificate': '证书',
'keychain.action.generateKey': '生成密钥',
'keychain.action.importKey': '导入密钥',
'keychain.action.newIdentity': '新建身份',
'keychain.action.importCertificate': '导入证书',
'keychain.view.grid': '网格',
'keychain.view.list': '列表',
'keychain.section.keys': '密钥',
'keychain.section.identities': '身份',
'keychain.count.items': '{count} 项',
'keychain.empty.title': '设置密钥',
'keychain.empty.desc': '导入或生成 SSH 密钥用于安全认证。',
'keychain.panel.generateKey': '生成密钥',
'keychain.panel.newKey': '新建密钥',
'keychain.panel.keyDetails': '密钥详情',
'keychain.panel.editKey': '编辑密钥',
'keychain.panel.editIdentity': '编辑身份',
'keychain.panel.newIdentity': '新建身份',
'keychain.panel.keyExport': '密钥导出',
'keychain.validation.labelRequired': '请填写密钥的 Label',
'keychain.validation.labelAndPrivateKeyRequired': 'Label 和私钥为必填项',
'keychain.validation.labelAndUsernameRequired': 'Label 和用户名为必填项',
'keychain.error.generationUnavailable': '无法生成密钥:请确保应用运行在 Electron 环境',
'keychain.error.generateKeyPairFailed': '生成密钥对失败',
'keychain.error.generateKeyFailed': '生成密钥失败',
'keychain.error.keyGenerationTitle': '密钥生成',
'keychain.export.exportTo': '导出到 *',
'keychain.export.selectHost': '选择主机',
'keychain.export.location': '位置 ~ $1 *',
'keychain.export.filename': '文件名 ~ $2 *',
'keychain.export.note': '密钥导出目前仅支持 {unix} 系统。请在 {advanced} 部分自定义导出脚本。',
'keychain.export.script': '脚本 *',
'keychain.export.scriptPlaceholder': '导出脚本...',
'keychain.export.missingCredentials': '主机未保存密码或密钥。请先为该主机添加密码凭据。',
'keychain.export.successTitle': '导出成功',
'keychain.export.successMessage': '已导出公钥并绑定到 {host}',
'keychain.export.failedTitle': '导出失败',
'keychain.export.failedMessage': '导出密钥失败:{error}',
'keychain.export.failedPrefix': '导出失败:{error}',
'keychain.export.exitCode': '命令退出码 {code}',
'keychain.export.exporting': '导出中...',
'keychain.export.exportAndAttach': '导出并绑定',
'keychain.export.title': '密钥导出',
'keychain.export.exportToRequired': '导出到 *',
'keychain.export.selectHostPlaceholder': '选择主机...',
'keychain.export.locationLabel': '位置 ~ $1 *',
'keychain.export.filenameLabel': '文件名 ~ $2 *',
'keychain.export.advanced': '高级',
'keychain.export.note.supportsOnly': '密钥导出目前仅支持',
'keychain.export.note.systems': '系统。',
'keychain.export.note.use': '请使用',
'keychain.export.note.customize': '部分自定义导出脚本。',
'keychain.export.scriptRequired': '脚本 *',
'keychain.export.exportToHost': '导出到主机',
'keychain.export.failedGeneric': '导出失败:{message}',
'keychain.field.label': 'Label',
'keychain.field.labelRequired': 'Label *',
'keychain.field.labelPlaceholder': '密钥 Label',
'keychain.field.privateKeyRequired': '私钥 *',
'keychain.field.publicKey': '公钥',
'keychain.field.certificatePlaceholder': '证书内容(可选)',
'keychain.generate.keyType': '密钥类型',
'keychain.generate.keySize': '密钥长度',
'keychain.generate.labelPlaceholder': '密钥 Label',
'keychain.generate.passphrasePlaceholder': 'Passphrase(可选)',
'keychain.generate.savePassphrase': '保存 Passphrase',
'keychain.generate.generate': '生成',
'keychain.generate.generateSave': '生成并保存',
'keychain.import.dropHint': '将密钥文件拖到这里',
'keychain.import.importFromFile': '从文件导入',
'keychain.import.saveKey': '保存密钥',
'keychain.import.importedKeyLabel': '已导入密钥',
'keychain.identity.usernameRequired': '用户名 *',
'keychain.identity.method.passwordOnly': '密码',
'keychain.identity.summary.password': '认证密码',
'keychain.identity.summary.key': '认证密钥',
'keychain.identity.summary.certificate': '认证证书',
'keychain.identity.summary.passwordAndKey': '认证密码与密钥',
'keychain.identity.summary.passwordAndCertificate': '认证密码与证书',
'keychain.identity.summary.none': '无凭据',
'keychain.identity.selectCredential': '选择{kind}',
'keychain.identity.save': '保存',
'keychain.identity.update': '更新',
'keychain.keyDialog.newTitle': '新建密钥',
'keychain.keyDialog.newDesc': '添加新的 SSH 密钥',
'keychain.keyDialog.editTitle': '编辑密钥',
'keychain.keyDialog.editDesc': '更新此 SSH 密钥',
'keychain.keyDialog.updateKey': '更新密钥',
// Tabs
'tabs.closeSessionAria': '关闭会话',
'tabs.closeLogViewAria': '关闭日志视图',
'tabs.logPrefix': '日志:',
'tabs.logLocal': '本地',
'tabs.copyTab': '复制标签页',
'tabs.copyTabToNewWindow': '复制标签页到新窗口',
'tabs.copyTabToNewWindowFailed': '无法在新窗口打开标签页',
'tabs.closeOthers': '关闭其他标签',
'tabs.closeToRight': '关闭右侧标签',
'tabs.closeAll': '关闭所有标签',
'keychain.edit.labelRequired': 'Label *',
'keychain.edit.keyLabelPlaceholder': '密钥 Label',
'keychain.edit.privateKeyRequired': '私钥 *',
'keychain.edit.publicKey': '公钥',
'keychain.edit.certificate': '证书',
'keychain.edit.certificatePlaceholder': '证书内容(可选)',
'keychain.edit.filePath': '文件路径',
'keychain.edit.keyExport': '密钥导出',
'keychain.edit.exportToHost': '导出到主机',
// Snippets
'snippets.searchPlaceholder': '搜索脚本...',
'snippets.action.newSnippet': '新建代码片段',
'snippets.action.newPackage': '新建脚本包',
'snippets.action.import': '导入',
'snippets.action.selectSnippets': '选择代码片段',
'snippets.panel.newTitle': '新建代码片段',
'snippets.panel.editTitle': '编辑代码片段',
'snippets.panel.newAutomationTitle': '新建自动化脚本',
'snippets.panel.editAutomationTitle': '编辑自动化脚本',
'snippets.panel.resizeWidth': '调整面板宽度',
'snippets.field.description': '描述',
'snippets.field.descriptionPlaceholder': '例如:check network load',
'snippets.field.package': '添加脚本包',
'snippets.field.packagePlaceholder': '选择或创建脚本包',
'snippets.field.createPackage': '创建脚本包',
'snippets.field.scriptRequired': '脚本 *',
'snippets.scriptEditor.expand': '弹窗编辑',
'snippets.scriptEditor.resize': '调整编辑器高度',
'snippets.scriptEditor.modalTitle': '编辑脚本',
'snippets.targets.title': '目标主机',
'snippets.targets.add': '添加目标主机',
'snippets.targets.allHosts': '对所有主机生效',
'snippets.targets.allHostsShort': '所有主机',
'snippets.targets.allHostsActive': '将作用于所有可连接的主机。',
'snippets.history.title': 'Shell 历史',
'snippets.history.subtitle': '{count} 条命令',
'snippets.history.emptyTitle': '暂无 Shell 历史',
'snippets.history.emptyDesc': '你执行过的命令会显示在这里',
'snippets.history.loadMore': '加载更多',
'snippets.history.separator': '•',
'snippets.history.labelPlaceholder': '为此代码片段设置一个 Label',
'snippets.history.saveAsSnippet': '保存为代码片段',
'snippets.history.time.justNow': '刚刚',
'snippets.history.time.minutesAgo': '{count} 分钟前',
'snippets.history.time.hoursAgo': '{count} 小时前',
'snippets.history.time.daysAgo': '{count} 天前',
'snippets.breadcrumb.allPackages': '全部脚本包',
'snippets.breadcrumb.separator': '›',
'snippets.empty.title': '创建脚本',
'snippets.empty.desc': '将常用命令保存为代码片段,或编写自动化脚本来处理重复运维任务。',
'snippets.search.noResults.title': '无匹配结果',
'snippets.search.noResults.desc': '没有脚本或脚本包与"{query}"匹配。换一个关键字,或清除搜索进行浏览。',
'snippets.section.packages': '脚本包',
'snippets.section.snippets': '脚本',
'snippets.kind.codeSnippet': '代码片段',
'snippets.kind.automationScript': '自动化脚本',
'snippets.package.count': '{count} 个脚本',
'snippets.commandFallback': '命令',
'snippets.view.grid': '网格',
'snippets.view.list': '列表',
'snippets.selection.selected': '已选择 {count} 个',
'snippets.selection.selectVisible': '选择当前显示',
'snippets.selection.deselectAll': '取消选择',
'snippets.selection.exportSelected': '导出选中({count})',
'snippets.export.snippet': '导出代码片段',
'snippets.export.package': '导出脚本包',
'snippets.export.toast.empty': '没有可导出的代码片段。',
'snippets.export.toast.successTitle': '导出已准备好',
'snippets.export.toast.success': '已导出 {count} 个代码片段。',
'snippets.import.toast.empty': '没有找到可导入的代码片段。',
'snippets.import.toast.failedTitle': '导入失败',
'snippets.import.toast.invalidDesc': '这不是有效的 Netcatty 代码片段文件。',
'snippets.import.toast.successTitle': '导入完成',
'snippets.import.toast.summary': '已导入 {imported} 个,覆盖 {overwritten} 个,跳过 {skipped} 个。',
'snippets.import.modal.title': '导入代码片段',
'snippets.import.modal.desc': '选择一个或多个 Netcatty 代码片段 JSON 文件。导入前会先解析并展示结果,不会直接写入。',
'snippets.import.modal.exampleTitle': '示例 JSON',
'snippets.import.modal.noFile': '还没有选择文件。可以参考下面的格式,也可以直接使用代码片段 JSON 数组,然后选择一个或多个文件。',
'snippets.import.modal.chooseFile': '选择文件',
'snippets.import.modal.downloadExamples': '下载示例文件',
'snippets.import.modal.multipleFiles': '已选择 {count} 个文件',
'snippets.import.modal.parsedSummary': '{files} 个文件,{total} 个脚本,{packages} 个脚本包,{conflicts} 个重复命令。主机绑定会被忽略。',
'snippets.import.modal.confirm': '确认导入',
'snippets.import.conflict.title': '导入代码片段?',
'snippets.import.conflict.desc': '“{file}”包含 {total} 个代码片段,其中 {conflicts} 个命令已存在。',
'snippets.import.conflict.hostBindingsNote': '代码片段的主机绑定不会被导入或导出。',
'snippets.import.conflict.skip': '跳过重复项',
'snippets.import.conflict.overwrite': '覆盖重复项',
'snippets.packageDialog.title': '新建脚本包',
'snippets.packageDialog.parent': '父级:{parent}',
'snippets.packageDialog.root': '根目录',
'snippets.packageDialog.placeholder': '例如:ops/maintenance',
'snippets.packageDialog.hint': '使用 "/" 创建嵌套脚本包。',
// Snippets Rename Dialog
'snippets.renameDialog.title': '重命名脚本包',
'snippets.renameDialog.currentPath': '当前路径:{path}',
'snippets.renameDialog.placeholder': '输入新名称',
'snippets.renameDialog.error.empty': '脚本包名称不能为空',
'snippets.renameDialog.error.duplicate': '已存在同名的脚本包',
'snippets.renameDialog.error.invalidChars': '脚本包名称只能包含字母、数字、连字符和下划线',
'snippets.field.noAutoRun': '仅粘贴(不自动执行)',
'snippets.field.multiLineRunMode': '多行运行方式',
'snippets.field.multiLineRunMode.paste': '一次性发送',
'snippets.field.multiLineRunMode.lineDelay': '逐行发送',
'snippets.field.multiLineRunModeHint': '登录提示、网络设备宏等场景可用逐行发送。',
// Snippet Shortkey
'snippets.field.shortkey': '快捷键',
'snippets.shortkey.placeholder': '点击设置快捷键',
'snippets.shortkey.recording': '请按下快捷键组合...',
'snippets.shortkey.hint': '在终端中按下此快捷键可快速发送命令。',
'snippets.shortkey.clear': '清除快捷键',
'snippets.shortkey.error.systemConflict': '此快捷键与系统快捷键冲突',
'snippets.shortkey.error.snippetConflict': '此快捷键已被代码片段使用:{name}',
'snippets.variables.dialogTitle': '填写变量',
'snippets.variables.dialogDesc': '运行「{label}」前请填写以下变量。',
'snippets.variables.hint': '变量值将原样插入脚本(不会进行 shell 转义)。',
'snippets.variables.preview': '预览',
'snippets.variables.placeholder': '请输入',
'snippets.variables.placeholderDefault': '默认:{value}',
'snippets.variables.required': '请填写此变量',
'snippets.variables.run': '运行',
'snippets.field.variablesHelp': '在脚本中使用 {{名称}} 或 {{名称:默认值}} 定义变量。',
'snippets.field.variablesDetected': '变量',
'snippets.field.variableDefault': '默认 {value}',
// Serial Port
'serial.button': '串口',
'serial.modal.title': '连接串口',
'serial.modal.desc': '配置串口连接参数',
'serial.field.port': '串口',
'serial.field.selectPort': '选择串口...',
'serial.field.baudRate': '波特率',
'serial.field.dataBits': '数据位',
'serial.field.stopBits': '停止位',
'serial.field.stopBits15Warning': '1.5 停止位在 Windows 下可能不被所有设备支持',
'serial.field.parity': '校验位',
'serial.field.flowControl': '流控制',
'serial.noPorts': '未检测到串口设备。请连接设备后刷新。',
'serial.field.customPort': '自定义串口路径',
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
'serial.type.hardware': '硬件',
'serial.type.pseudo': '虚拟终端',
'serial.type.custom': '自定义',
'serial.parity.none': '无',
'serial.parity.even': '偶校验',
'serial.parity.odd': '奇校验',
'serial.parity.mark': 'Mark',
'serial.parity.space': 'Space',
'serial.flowControl.none': '无',
'serial.flowControl.xon/xoff': 'XON/XOFF (软件)',
'serial.flowControl.rts/cts': 'RTS/CTS (硬件)',
'serial.field.localEcho': '强制本地回显',
'serial.field.localEchoDesc': '本地回显输入字符(用于没有远程回显的设备)',
'serial.field.lineMode': '行模式',
'serial.field.lineModeDesc': '缓冲输入,按回车后发送(而不是逐字符发送)',
'serial.field.charset': '字符编码',
'serial.connectionError': '连接串口失败',
'serial.field.baudRatePlaceholder': '选择或输入波特率...',
'serial.field.baudRateEmpty': '输入自定义波特率',
'serial.field.customBaudRate': '使用自定义波特率',
'serial.field.saveConfig': '保存配置',
'serial.field.saveConfigDesc': '将此串口配置保存到主机列表以便快速访问',
'serial.field.configLabel': '配置名称',
'serial.field.configLabelPlaceholder': '例如 Arduino Uno',
'serial.connectAndSave': '连接并保存',
'serial.edit.title': '串口设置',
// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': '需要验证',
'keyboard.interactive.desc': '服务器需要额外的身份验证。',
'keyboard.interactive.descWithHost': '服务器 {hostname} 需要额外的身份验证。',
'keyboard.interactive.response': '响应',
'keyboard.interactive.enterCode': '输入验证码',
'keyboard.interactive.enterResponse': '输入响应',
'keyboard.interactive.submit': '提交',
'keyboard.interactive.verifying': '验证中...',
'keyboard.interactive.savePassword': '保存密码',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH 密钥密码',
'passphrase.desc': '请输入 {keyName} 的密码',
'passphrase.descWithHost': '请输入 {keyName} 的密码以连接到 {hostname}',
'passphrase.label': '密码',
'passphrase.keyPath': '密钥',
'passphrase.unlock': '解锁',
'passphrase.unlocking': '解锁中...',
'passphrase.skip': '跳过',
'passphrase.remember': '记住此密码',
// Text Editor
'sftp.editor.wordWrap': '自动换行',
'sftp.editor.maximize': '最大化',
'sftp.editor.unsavedTitle': '未保存的修改',
'sftp.editor.unsavedMessage': '{fileName} 有未保存的修改,是否保存后关闭?',
'sftp.editor.discardChanges': '不保存',
'sftp.editor.saveAndClose': '保存并关闭',
'sftp.editor.quitBlockedByDirty': '存在未保存的编辑器,请先处理后再退出',
};
+854
View File
@@ -0,0 +1,854 @@
import type { Messages } from '../types';
export const zhCNVaultMessages: Messages = {
// Notes
'notes.search.placeholder': '搜索笔记、主机、标签...',
'notes.action.newNote': '新建笔记',
'notes.action.newGroup': '新建文件夹',
'notes.action.importMarkdown': '导入 Markdown',
'notes.action.exportNote': '导出笔记',
'notes.action.exportGroup': '导出文件夹',
'notes.action.exportAll': '导出全部笔记',
'notes.import.toast.success': '已导入 {count} 条笔记',
'notes.import.toast.noNotes': '没有导入任何 Markdown 文件。',
'notes.import.toast.failed': '导入 Markdown 文件失败。',
'notes.import.toast.inProgress': '正在导入 Markdown 文件,请稍候。',
'notes.import.toast.skipped': '已跳过 {count} 个不支持的文件。',
'notes.export.toast.success': '已导出 {count} 条笔记',
'notes.export.toast.empty': '没有可导出的笔记。',
'notes.export.toast.failed': '导出笔记失败。',
'notes.codeBlock.copied': '已复制',
'notes.codeBlock.copyFailed': '复制代码块失败。',
'notes.group.prompt': '文件夹路径',
'notes.group.all': '笔记本',
'notes.group.ungrouped': '未分组',
'notes.section.notebooks': '笔记本',
'notes.section.notes': '笔记',
'notes.mode.edit': '编辑',
'notes.mode.split': '分屏',
'notes.mode.preview': '预览',
'notes.title.placeholder': '笔记标题',
'notes.editor.placeholder': '在这里写 Markdown 笔记...',
'notes.preview.empty': '暂无内容可预览。',
'notes.empty.title': '还没有笔记',
'notes.empty.desc': '可以记录运维手册、临时备忘、服务器交接信息。',
'notes.empty.group': '这个笔记本里还没有笔记。',
'notes.search.noResults': '没有找到笔记。',
// Select Host panel
'selectHost.title': '选择主机',
'selectHost.noHostsFound': '未找到主机',
'selectHost.newHost': '新建主机',
'selectHost.continue': '继续',
'selectHost.continueWithCount': '继续(已选 {count} 个)',
// Quick Connect
'quickConnect.knownHost.title': '确认要连接吗?',
'quickConnect.knownHost.authenticity': '无法验证 {hostname} 的真实性。',
'quickConnect.knownHost.fingerprintLabel': '{keyType} fingerprint (SHA256):',
'quickConnect.knownHost.addQuestion': '是否将它加入 Known Hosts?',
'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': '选择协议',
'protocolSelect.port': '端口:',
// Host Details
'hostDetails.title.details': '主机详情',
'hostDetails.title.new': '新建主机',
'vault.panel.resizeWidth': '调整面板宽度',
'hostDetails.saveAria': '保存',
'hostDetails.section.address': '地址',
'hostDetails.hostname.placeholder': 'IP 或 主机名',
'hostDetails.section.general': '通用',
'hostDetails.section.sftp': 'SFTP 设置',
'hostDetails.sftp.sudo': 'Sudo 提权模式',
'hostDetails.sftp.sudo.desc': '使用保存的密码自动获取 Root 权限',
'hostDetails.sftp.sudo.passwordWarning': 'Sudo 模式需要密码。请在上方配置密码,或确保服务器允许免密 sudo。',
'hostDetails.sftp.encoding': '文件名编码',
'hostDetails.sftp.encoding.desc': '选择用于解码和发送 SFTP 文件名的编码。',
'hostDetails.label.placeholder': '名称(例如:Production Server)',
'hostDetails.notes.label': '备注',
'hostDetails.notes.placeholder': '硬件配置、项目、客户、地域、角色...',
'hostDetails.notes.help': '支持 Markdown。请勿在此存放密码或私钥。',
'hostDetails.notes.tab.edit': '编辑',
'hostDetails.notes.tab.preview': '预览',
'hostDetails.notes.preview.empty': '暂无内容可预览。',
'hostDetails.notes.toggle.show': '显示备注编辑框',
'hostDetails.notes.toggle.hide': '隐藏备注编辑框',
'hostDetails.group.placeholder': '父级 Group',
'hostDetails.section.credentials': '凭据',
'hostDetails.section.portCredentials': '端口与凭据',
'hostDetails.section.appearance': '外观',
'hostDetails.distro.title': 'Linux 发行版',
'hostDetails.distro.desc': '可在连接后自动探测,也可以手动覆盖图标所用的发行版。',
'hostDetails.icon.title': '主机图标',
'hostDetails.icon.sectionTitle': '颜色与图标',
'hostDetails.icon.desc': '选择此主机使用的图标来源和颜色。',
'hostDetails.icon.source': '来源',
'hostDetails.icon.manualLabel': '手动指定',
'hostDetails.icon.tab.brand': '品牌',
'hostDetails.icon.tab.type': '类型',
'hostDetails.icon.mode.auto': '自动',
'hostDetails.icon.mode.custom': '自定义',
'hostDetails.icon.reset': '重置主机图标',
'hostDetails.icon.colorLabel': '图标颜色',
'hostDetails.icon.colorDefault': '默认',
'hostDetails.icon.colorAuto': '自动',
'hostDetails.icon.colorManual': '手动',
'hostDetails.icon.colorChoice': '颜色',
'hostDetails.icon.autoColorLabel': '当前颜色',
'hostDetails.icon.preview': '图标预览',
'hostDetails.icon.showLibrary': '展开图标库',
'hostDetails.icon.hideLibrary': '收起图标库',
'hostDetails.icon.autoUsesDistro': '使用 Linux 发行版图标和所选颜色显示此主机。',
'hostDetails.icon.customOverridesDistro': '内置图标会替换此主机的 Linux 发行版图标。',
'hostDetails.icon.option.server': '服务器',
'hostDetails.icon.option.terminal': '终端',
'hostDetails.icon.option.database': '数据库',
'hostDetails.icon.option.cloud': '云主机',
'hostDetails.icon.option.router': '路由器',
'hostDetails.icon.option.shield': '安全',
'hostDetails.icon.option.code': '代码',
'hostDetails.icon.option.box': '节点',
'hostDetails.icon.option.globe': '公网',
'hostDetails.icon.option.cpu': '计算',
'hostDetails.icon.option.hard-drive': '存储',
'hostDetails.icon.option.network': '网络',
'hostDetails.icon.option.wifi': '无线',
'hostDetails.icon.option.lock': '锁定',
'hostDetails.icon.option.key': '密钥',
'hostDetails.icon.option.monitor': '显示器',
'hostDetails.icon.option.container': '容器',
'hostDetails.icon.option.activity': '活动',
'hostDetails.icon.option.zap': '高速',
'hostDetails.icon.option.server-cog': '服务器设置',
'hostDetails.icon.color.blue': '蓝色',
'hostDetails.icon.color.green': '绿色',
'hostDetails.icon.color.red': '红色',
'hostDetails.icon.color.amber': '琥珀色',
'hostDetails.icon.color.purple': '紫色',
'hostDetails.icon.color.cyan': '青色',
'hostDetails.icon.color.orange': '橙色',
'hostDetails.icon.color.slate': '石板灰',
'hostDetails.icon.color.violet': '紫罗兰',
'hostDetails.icon.color.pink': '粉色',
'hostDetails.icon.color.rose': '玫瑰红',
'hostDetails.icon.color.lime': '青柠',
'hostDetails.icon.color.teal': '蓝绿色',
'hostDetails.icon.color.sky': '天蓝',
'hostDetails.icon.color.indigo': '靛蓝',
'hostDetails.icon.color.zinc': '锌灰',
'hostDetails.icon.color.custom': '自定义颜色',
'hostDetails.distro.mode': '来源',
'hostDetails.distro.mode.auto': '自动探测',
'hostDetails.distro.mode.manual': '手动覆盖',
'hostDetails.distro.detectedLabel': '当前值',
'hostDetails.distro.manualLabel': '手动指定',
'hostDetails.distro.pending': '首次连接后自动探测',
'hostDetails.distro.unknown': '未知',
'hostDetails.distro.option.linux': '通用 Linux',
'hostDetails.distro.option.ubuntu': 'Ubuntu',
'hostDetails.distro.option.debian': 'Debian',
'hostDetails.distro.option.centos': 'CentOS',
'hostDetails.distro.option.rocky': 'Rocky Linux',
'hostDetails.distro.option.fedora': 'Fedora',
'hostDetails.distro.option.arch': 'Arch Linux',
'hostDetails.distro.option.alpine': 'Alpine',
'hostDetails.distro.option.amazon': 'Amazon Linux',
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
'hostDetails.distro.option.almalinux': 'AlmaLinux',
'hostDetails.distro.option.alinux': '阿里云 Linux',
'hostDetails.distro.option.openeuler': 'openEuler',
'hostDetails.distro.option.oracle': 'Oracle Linux',
'hostDetails.distro.option.kali': 'Kali Linux',
'hostDetails.distro.option.cisco': '思科',
'hostDetails.distro.option.juniper': '瞻博网络',
'hostDetails.distro.option.huawei': '华为',
'hostDetails.distro.option.h3c': '新华三 / H3C',
'hostDetails.distro.option.hpe': '慧与 / HPE',
'hostDetails.distro.option.mikrotik': 'MikroTik',
'hostDetails.distro.option.fortinet': '飞塔',
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
'hostDetails.distro.option.zyxel': '合勤',
'hostDetails.distro.option.ruijie': '锐捷',
'hostDetails.section.mosh': 'Mosh',
'hostDetails.section.et': 'EternalTerminal',
'hostDetails.et.port': 'ET 服务端口',
'hostDetails.et.port.desc': 'etserver 监听端口(默认 2022)',
'hostDetails.username.placeholder': '用户名',
'hostDetails.password.placeholder': '密码',
'hostDetails.password.show': '显示密码',
'hostDetails.password.hide': '隐藏密码',
'hostDetails.password.save': '保存密码',
'hostDetails.identity.suggestions': '身份',
'hostDetails.identity.missing': '身份不存在',
'hostDetails.credential.keyCertificate': '密钥 / 证书 / 本地密钥',
'hostDetails.credential.key': '密钥',
'hostDetails.credential.certificate': '证书',
'hostDetails.credential.localKeyFile': '本地密钥文件',
'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': '搜索证书…',
'hostDetails.certs.empty': '暂无证书',
'hostDetails.agentForwarding': '转发 SSH 密钥',
'hostDetails.agentForwarding.desc': '允许远程服务器使用本地 SSH 密钥(例如用于 git 操作)',
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 不可用',
'hostDetails.agentForwarding.agentNotRunningHint': '未检测到 SSH Agent。请启用 Windows OpenSSH Authentication Agent 服务,或使用兼容的 Agent(如 Bitwarden、1Password、gpg-agent)。',
'hostDetails.section.agentForwarding': 'SSH 代理',
'hostDetails.x11Forwarding': '转发 X11 图形应用',
'hostDetails.x11Forwarding.desc': '本机运行 X 服务时,让远程图形程序显示在本地桌面。',
'hostDetails.section.x11Forwarding': 'X11 转发',
'hostDetails.section.deviceType': '设备类型',
'hostDetails.deviceType': '网络设备模式',
'hostDetails.deviceType.desc': '适用于通过 SSH 连接的网络设备(交换机、路由器、防火墙)。命令将原样发送,不进行 Shell 包装,兼容华为 VRP、Cisco IOS 等厂商 CLI。',
'hostDetails.deviceType.warning': 'AI 代理命令将直接发送,无法获取退出码。仅建议在设备不运行标准 Shell 时启用。',
'hostDetails.section.sshAlgorithms': 'SSH 算法',
'hostDetails.section.terminalBehavior': '终端行为',
'hostDetails.lineTimestamps': '显示输出时间',
'hostDetails.lineTimestamps.desc': '在终端输出行旁边显示本地时间,不改变终端文本内容。',
'hostDetails.legacyAlgorithms': '允许旧版算法',
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法(diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
'hostDetails.skipEcdsaHostKey': '跳过 ECDSA 主机密钥',
'hostDetails.skipEcdsaHostKey.desc': '某些老款华为 / 思科交换机的 ECDSA 主机密钥签名不规范,会导致连接报 "signature verification failed"。开启后客户端不再 advertise ecdsa-sha2-*,强制使用 RSA / Ed25519。',
'hostDetails.algorithms.advanced': '高级算法配置',
'hostDetails.algorithms.advanced.desc': '针对单个 host 自定义各分类的算法清单。不勾选 = 使用默认;勾选子集后将完全替换默认列表。配置错误可能导致无法连接。',
'hostDetails.algorithms.inheritedNotice': '当前组已设置以下分类的算法 override:{categories}。本面板的"恢复默认"只会回到组的设置,而不是 NetCatty 默认列表。若要忽略组的限制,请到组的算法设置里取消。',
'hostDetails.algorithms.customized': '已自定义',
'hostDetails.algorithms.reset': '恢复默认',
'hostDetails.algorithms.category.kex': '密钥交换 (KEX)',
'hostDetails.algorithms.category.cipher': '加密算法 (Cipher)',
'hostDetails.algorithms.category.hmac': '完整性算法 (HMAC)',
'hostDetails.algorithms.category.serverHostKey': '主机密钥 (Host Key)',
'hostDetails.algorithms.category.compress': '压缩 (Compression)',
'hostDetails.section.keepalive': '会话保活',
'hostDetails.keepalive.override': '为此主机单独配置',
'hostDetails.keepalive.desc': '为该主机使用专属的保活策略,而不是跟随全局设置。适用于不响应 keepalive@openssh.com 请求的老旧路由器 / 交换机——将间隔设为 0 可对该主机彻底关闭保活。',
'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': '通过主机代理',
'hostDetails.jumpHosts.hops': '{count} 跳',
'hostDetails.jumpHosts.direct': '直连',
'hostDetails.jumpHosts.configure': '配置代理主机',
'hostDetails.proxy': '通过 HTTP/SOCKS5/命令代理',
'hostDetails.proxy.none': '无',
'hostDetails.proxy.edit': '编辑代理',
'hostDetails.proxy.configure': '配置代理',
'hostDetails.envVars': '环境变量',
'hostDetails.envVars.add': '添加环境变量',
'hostDetails.startupCommand': '启动命令',
'hostDetails.startupCommand.placeholder': '连接后执行的命令(例如:cd /app && ls)',
'hostDetails.startupCommand.help': 'SSH 连接建立后将自动执行该命令。',
'hostDetails.startupCommand.runMode': '多行运行方式',
'hostDetails.startupCommand.runMode.help': '选择连接后如何发送多行启动命令。',
'hostDetails.startupCommand.runMode.lineDelay': '逐行发送',
'hostDetails.startupCommand.runMode.paste': '一次性发送',
'hostDetails.otherProtocols': '其他协议',
'hostDetails.telnetOn': 'Telnet on',
'hostDetails.port': '端口',
'hostDetails.telnet.credentials': '凭据',
'hostDetails.telnet.identity.placeholder': '手动身份',
'hostDetails.telnet.username': 'Telnet 用户名',
'hostDetails.telnet.password': 'Telnet 密码',
'hostDetails.charset.placeholder': '字符集(例如 UTF-8)',
'hostDetails.telnet.add': '添加 Telnet 协议',
'hostDetails.telnet.setDefault': '默认用 Telnet 连接',
'hostDetails.tags': '标签',
'hostDetails.group': '分组',
'hostDetails.selectGroup': '选择分组',
'hostDetails.addTag': '添加标签...',
'hostDetails.createTag': '创建标签',
'hostDetails.createGroup': '创建分组',
// Host form (legacy modal)
'hostForm.title.edit': '编辑主机',
'hostForm.title.new': '新建主机',
'hostForm.desc.edit': '更新该主机的连接信息',
'hostForm.desc.new': '创建一个新的 SSH 主机条目',
'hostForm.field.label': '名称',
'hostForm.placeholder.label': 'My Production Server',
'hostForm.field.hostname': 'Hostname / IP',
'hostForm.placeholder.hostname': '192.168.1.1',
'hostForm.field.port': '端口',
'hostForm.field.username': '用户名',
'hostForm.field.osType': '操作系统类型',
'hostForm.placeholder.selectOs': '选择操作系统',
'hostForm.field.group': '分组',
'hostForm.placeholder.group': '例如:AWS、DigitalOcean',
'hostForm.field.tags': '标签',
'hostForm.placeholder.addTag': '添加标签…',
'hostForm.auth.method': '认证方式',
'hostForm.auth.password': '密码',
'hostForm.auth.sshKey': 'SSH密钥',
'hostForm.auth.selectKey': '选择 SSH密钥',
'hostForm.auth.noKeys': '暂无密钥',
'hostForm.auth.noKeysHint': '钥匙串中未找到 SSH密钥,请先创建一个。',
'hostForm.saveHost': '保存主机',
// Connection logs
'logs.table.date': '日期',
'logs.table.user': '用户',
'logs.table.host': '主机',
'logs.table.saved': '收藏',
'logs.empty.title': '暂无连接日志',
'logs.empty.desc': '当你连接主机或打开本地终端后,这里会显示连接历史。',
'logs.loadMore': '加载更多 ({count} 条)',
'logs.ongoing': '进行中',
'logs.localTerminal': '本地终端',
'logs.action.save': '收藏',
'logs.action.unsave': '取消收藏',
'logs.action.delete': '删除',
// Log view
'logView.customizeAppearance': '自定义外观',
'logView.appearance': '外观',
'logView.readOnly': '只读',
'logView.export': '导出',
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': '打开 SFTP',
'terminal.toolbar.availableAfterConnect': '连接后可用',
'terminal.toolbar.sendYmodem': 'YMODEM 发送',
'terminal.toolbar.receiveYmodem': 'YMODEM 接收',
'terminal.toolbar.sftp': 'SFTP',
'terminal.toolbar.more': '更多操作',
'terminal.toolbar.scripts': '脚本',
'terminal.toolbar.history': '命令历史',
'terminal.toolbar.library': '库',
'terminal.toolbar.noSnippets': '暂无代码片段',
'terminal.toolbar.terminalSettings': '终端设置',
'terminal.toolbar.searchTerminal': '搜索终端',
'terminal.toolbar.search': '搜索',
'terminal.toolbar.startSessionLog': '开始会话日志',
'terminal.toolbar.stopSessionLog': '停止会话日志',
'terminal.toolbar.broadcast': '广播',
'terminal.toolbar.broadcastEnable': '启用广播模式',
'terminal.toolbar.broadcastDisable': '关闭广播模式',
'terminal.toolbar.composeBar': '撰写栏',
'terminal.composeBar.placeholder': '在此输入命令,按回车发送...',
'terminal.composeBar.send': '发送',
'terminal.composeBar.close': '关闭撰写栏',
'terminal.composeBar.broadcasting': '正在广播到所有会话',
'terminal.composeBar.resize': '拖拽调整撰写栏高度',
'terminal.composeBar.manageSnippets': '管理快捷代码片段',
'terminal.composeBar.searchSnippets': '搜索代码片段...',
'terminal.composeBar.noPinnedSnippets': '点击 + 固定常用代码片段',
'terminal.composeBar.noMatchingSnippets': '没有匹配的代码片段',
'terminal.composeBar.pinnedCount': '已固定 {count} 个',
'terminal.composeBar.unpinSnippet': '从快捷栏移除 {label}',
'terminal.composeBar.snippetClickHint': '单击插入 · Shift+单击直接发送',
'terminal.toolbar.focus': '聚焦',
'terminal.toolbar.focusMode': '聚焦模式',
'terminal.toolbar.detach': '移出到独立标签',
'terminal.toolbar.encoding': '终端编码',
'terminal.toolbar.encoding.utf8': 'UTF-8',
'terminal.toolbar.encoding.gb18030': 'GB18030',
'terminal.toolbar.closeSession': '关闭会话',
'terminal.toolbar.hostHighlight.title': '主机关键字高亮',
'terminal.toolbar.hostHighlight.noRules': '此主机未定义自定义高亮规则',
'terminal.toolbar.hostHighlight.addRule': '添加新规则',
'terminal.toolbar.hostHighlight.labelPlaceholder': '标签(例如:错误)',
'terminal.toolbar.hostHighlight.patternPlaceholder': '正则表达式(例如:\\bfailed\\b)',
'terminal.toolbar.hostHighlight.invalidPattern': '无效的正则表达式',
'terminal.toolbar.hostHighlight.clearAll': '清除全部',
'terminal.toolbar.hostHighlight.changeColor': '更改高亮颜色',
'terminal.toolbar.hostHighlight.selectColor': '选择新规则的颜色',
'terminal.statusbar.copyHostname.label': '复制主机地址',
'terminal.statusbar.copyHostname.tooltip': '复制主机地址({hostname})',
'terminal.statusbar.copyHostname.toast': '已复制主机地址:{hostname}',
'terminal.statusbar.copyHostname.error': '复制主机地址失败',
'terminal.serverStats.cpu': 'CPU 使用率',
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
'terminal.serverStats.memory': '内存使用',
'terminal.serverStats.memoryDetails': '内存详情',
'terminal.serverStats.memUsed': '已用',
'terminal.serverStats.memBuffers': '缓冲区',
'terminal.serverStats.memCached': '缓存',
'terminal.serverStats.memFree': '空闲',
'terminal.serverStats.swap': '交换空间',
'terminal.serverStats.swapUsed': '已用交换',
'terminal.serverStats.swapFree': '空闲交换',
'terminal.serverStats.swapTotal': '总计',
'terminal.serverStats.topProcesses': '内存占用前十进程',
'terminal.serverStats.disk': '磁盘使用(根分区)',
'terminal.serverStats.diskDetails': '已挂载磁盘',
'terminal.serverStats.network': '网络速度',
'terminal.serverStats.latency': 'SSH 网络延迟',
'terminal.serverStats.networkDetails': '网络接口',
'terminal.serverStats.noData': '暂无数据',
'terminal.dragDrop.localTitle': '拖放以插入路径',
'terminal.dragDrop.localMessage': '文件路径将被插入到终端',
'terminal.dragDrop.remoteTitle': '拖放以上传文件',
'terminal.dragDrop.remoteZmodemMessage': '文件将通过 ZMODEM(PTY)上传',
'terminal.dragDrop.remoteSftpMessage': '文件将通过 SFTP 上传',
'terminal.dragDrop.noFiles': '没有可上传的文件',
'terminal.dragDrop.notConnected': '无法拖放文件 - 终端未连接',
'terminal.dragDrop.errorTitle': '拖放错误',
'terminal.dragDrop.errorMessage': '处理拖放文件失败',
'terminal.search.placeholder': '搜索…',
'terminal.search.noResults': '无结果',
'terminal.search.prevMatch': '上一个匹配 (Shift+Enter)',
'terminal.search.nextMatch': '下一个匹配 (Enter)',
'terminal.menu.copy': '复制',
'terminal.menu.paste': '粘贴',
'terminal.menu.addSelectionToAI': '添加到对话',
'terminal.menu.pasteSelection': '粘贴选中文本',
'terminal.menu.selectAll': '全选',
'terminal.menu.reconnect': '重新连接',
'terminal.menu.sendYmodem': 'YMODEM 发送',
'terminal.menu.receiveYmodem': 'YMODEM 接收',
'terminal.menu.splitHorizontal': '水平分屏',
'terminal.menu.splitVertical': '垂直分屏',
'terminal.menu.clearBuffer': '清空缓冲区',
'terminal.menu.closeTerminal': '关闭终端',
'terminal.menu.rename': '重命名',
'terminal.menu.detach': '从工作区移出',
'terminal.menu.detachSession': '移出 {name}',
'terminal.ymodem.selectFile': '选择要发送的文件',
'terminal.ymodem.allFiles': '所有文件',
'terminal.ymodem.started': '正在通过 YMODEM 发送 {fileName}',
'terminal.ymodem.complete': 'YMODEM 已发送 {fileName}',
'terminal.ymodem.failed': 'YMODEM 发送失败',
'terminal.ymodem.selectReceiveDirectory': '选择接收文件保存位置',
'terminal.ymodem.receiveStarted': '正在通过 YMODEM 接收...',
'terminal.ymodem.receiveComplete': 'YMODEM 已接收 {fileName}',
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM 已接收 {count} 个文件',
'terminal.ymodem.receiveEmpty': '没有接收到 YMODEM 文件',
'terminal.ymodem.receiveFailed': 'YMODEM 接收失败',
'terminal.ymodem.unavailable': 'YMODEM 当前不可用',
'terminal.selection.addToAI': '添加到对话',
'terminal.selection.addToAIDesc': '将选中的终端输出作为附件加入 AI 草稿',
'terminal.auth.password': '密码',
'terminal.auth.sshKey': 'SSH Key',
'terminal.auth.username': '用户名',
'terminal.auth.username.placeholder': 'root',
'terminal.auth.passwordLabel': '密码',
'terminal.auth.password.placeholder': '输入密码',
'terminal.auth.passphrase': '密码短语',
'terminal.auth.passphrase.placeholder': '可选:所选私钥的密码短语',
'terminal.auth.certificate': '证书',
'terminal.auth.selectKey': '选择密钥',
'terminal.auth.retryMessage': '身份验证失败。请检查凭据后重试。',
'terminal.auth.retryLog': '身份验证失败,请重试。',
'terminal.auth.noKeysHint': '暂无密钥,请先在钥匙串中添加。',
'terminal.auth.continueSave': '继续并保存',
'terminal.auth.credentialsUnavailable': '当前设备无法解密已保存凭据,请重新输入并再次保存。',
'terminal.auth.jumpCredentialsUnavailable': '某个跳板机的已保存凭据无法在当前设备解密,请到主机设置中重新填写。',
'terminal.auth.proxyCredentialsUnavailable': '代理凭据无法在当前设备解密,请到主机设置中重新填写代理密码。',
'terminal.auth.keyUnavailableFallbackPassword': '已保存 SSH 密钥在当前设备不可用,改用密码认证。',
'terminal.connectionErrorTitle': '连接错误',
'terminal.progress.timeoutIn': '将在 {seconds}s 后超时',
'terminal.progress.waitingForUserInput': '等待用户输入',
'terminal.progress.disconnected': '已断开',
'terminal.progress.cancelling': '正在取消...',
'terminal.progress.startOver': '重新开始',
'terminal.connection.dismissDisconnectedDialog': '关闭断连提示',
'terminal.connection.chainOf': 'Chain {current} / {total}',
'terminal.connection.showLogs': '显示日志',
'terminal.connection.hideLogs': '隐藏日志',
'terminal.connection.protocol.ssh': 'SSH',
'terminal.connection.protocol.telnet': 'Telnet',
'terminal.connection.protocol.mosh': 'Mosh',
'terminal.connection.protocol.serial': '串口',
'terminal.connection.protocol.local': '本地终端',
'terminal.hostKey.unknownTitle': '确认主机指纹',
'terminal.hostKey.changedTitle': '主机指纹已变化',
'terminal.hostKey.unknownDescription': '尚未确认 {host} 的真实性。',
'terminal.hostKey.changedDescription': '{host} 的已保存指纹与当前服务器不一致。',
'terminal.hostKey.fingerprintLabel': '{keyType} 指纹为 SHA256:',
'terminal.hostKey.savedFingerprintLabel': '已保存的指纹',
'terminal.hostKey.unknownHint': '如果这个指纹属于你预期连接的服务器,可以记住它。',
'terminal.hostKey.changedHint': '只有在你确认这台主机确实变更过时才继续。',
'terminal.hostKey.addAndContinue': '记住并继续',
'terminal.hostKey.updateAndContinue': '更新并继续',
'terminal.themeModal.title': 'Terminal 外观',
'terminal.themeModal.tab.theme': '主题',
'terminal.themeModal.tab.font': '字体',
'terminal.themeModal.tab.custom': '自定义',
'terminal.themeModal.globalTheme': '全局主题',
'terminal.themeModal.globalFont': '全局字体',
'terminal.themeModal.fontSize': '字体大小',
'terminal.themeModal.fontWeight': '字体粗细',
'terminal.themeModal.livePreview': '实时预览',
'terminal.themeModal.themeType': '{type} 主题',
'terminal.hiddenTheme.title': '当前隐藏主题',
'terminal.hiddenTheme.desc': '这个主题已从手动选择列表中隐藏;当你选择其他可见主题后,它会被替换。',
'topTabs.toggleTheme.systemExitTitle': '当前正在跟随系统主题',
'topTabs.toggleTheme.systemExitMessage': '请到设置里选择固定的浅色或深色主题。',
'topTabs.toggleTheme.openSettings': '打开设置',
// Custom Themes
'terminal.customTheme.section': '自定义主题',
'terminal.customTheme.yourThemes': '我的主题',
'terminal.customTheme.new': '新建主题',
'terminal.customTheme.newDesc': '克隆当前主题并自定义',
'terminal.customTheme.newTitle': '新建自定义主题',
'terminal.customTheme.editTitle': '编辑主题',
'terminal.customTheme.import': '导入 .itermcolors',
'terminal.customTheme.importDesc': '从 iTerm2 配色方案文件导入',
'terminal.customTheme.importError': '无法解析所选文件,请确保它是有效的 .itermcolors XML 文件。',
'terminal.customTheme.delete': '删除主题',
'terminal.customTheme.confirmDelete': '确认删除',
'terminal.customTheme.name': '名称',
'terminal.customTheme.namePlaceholder': '我的自定义主题',
'terminal.customTheme.type': '类型',
'terminal.customTheme.group.general': '通用',
'terminal.customTheme.group.normal': '标准色',
'terminal.customTheme.group.bright': '高亮色',
'terminal.customTheme.color.background': '背景',
'terminal.customTheme.color.foreground': '前景',
'terminal.customTheme.color.cursor': '光标',
'terminal.customTheme.color.selection': '选区',
'terminal.customTheme.color.black': '黑色',
'terminal.customTheme.color.red': '红色',
'terminal.customTheme.color.green': '绿色',
'terminal.customTheme.color.yellow': '黄色',
'terminal.customTheme.color.blue': '蓝色',
'terminal.customTheme.color.magenta': '品红',
'terminal.customTheme.color.cyan': '青色',
'terminal.customTheme.color.white': '白色',
'terminal.customTheme.color.brightBlack': '亮黑',
'terminal.customTheme.color.brightRed': '亮红',
'terminal.customTheme.color.brightGreen': '亮绿',
'terminal.customTheme.color.brightYellow': '亮黄',
'terminal.customTheme.color.brightBlue': '亮蓝',
'terminal.customTheme.color.brightMagenta': '亮品红',
'terminal.customTheme.color.brightCyan': '亮青色',
'terminal.customTheme.color.brightWhite': '亮白',
'cloudSync.gate.title': '端到端加密同步',
'cloudSync.gate.desc':
'数据会在本地加密后再同步,云端不会看到明文。设置主密钥以启用安全同步。',
'cloudSync.gate.masterKey': '主密钥',
'cloudSync.gate.confirmMasterKey': '确认主密钥',
'cloudSync.gate.placeholder': '输入一个强密码',
'cloudSync.gate.confirmPlaceholder': '再次输入密码',
'cloudSync.gate.mismatch': '两次输入的密码不一致',
'cloudSync.gate.warning':
'我已了解:如果忘记主密钥,数据无法恢复,且没有密码重置功能。',
'cloudSync.gate.enableVault': '启用加密 Vault',
'cloudSync.gate.enabledToast': '已启用加密 Vault',
'cloudSync.gate.setupFailed': '设置主密钥失败',
'cloudSync.passwordStrength.tooShort': '太短',
'cloudSync.passwordStrength.weak': '弱',
'cloudSync.passwordStrength.moderate': '一般',
'cloudSync.passwordStrength.strong': '强',
'cloudSync.passwordStrength.veryStrong': '非常强',
'cloudSync.provider.notConnected': '未连接',
'cloudSync.provider.sync': '同步',
'cloudSync.provider.connect': '连接',
'cloudSync.provider.connecting': '连接中...',
'cloudSync.provider.webdav': 'WebDAV',
'cloudSync.provider.webdav.desc': '连接到自建 WebDAV 端点',
'cloudSync.provider.s3': 'S3 兼容存储',
'cloudSync.provider.s3.desc': '连接到 S3 兼容对象存储',
'cloudSync.provider.comingSoon': '即将支持',
'cloudSync.webdav.title': 'WebDAV 设置',
'cloudSync.webdav.desc': '配置 WebDAV 端点用于加密同步。',
'cloudSync.webdav.endpoint': '端点地址',
'cloudSync.webdav.authType': '认证方式',
'cloudSync.webdav.auth.basic': 'Basic',
'cloudSync.webdav.auth.digest': 'Digest',
'cloudSync.webdav.auth.token': 'Token',
'cloudSync.webdav.username': '用户名',
'cloudSync.webdav.password': '密码',
'cloudSync.webdav.token': 'Token',
'cloudSync.webdav.showSecret': '显示密钥',
'cloudSync.webdav.allowInsecure': '允许不安全的连接(忽略证书错误)',
'cloudSync.webdav.validation.endpoint': '请输入有效的 WebDAV 端点。',
'cloudSync.webdav.validation.credentials': '请输入用户名和密码。',
'cloudSync.webdav.validation.token': '请输入 Token。',
'cloudSync.s3.title': 'S3 设置',
'cloudSync.s3.desc': '连接到 S3 兼容对象存储以进行加密同步。',
'cloudSync.s3.endpoint': '端点地址',
'cloudSync.s3.region': 'Region',
'cloudSync.s3.bucket': 'Bucket',
'cloudSync.s3.accessKeyId': 'Access Key ID',
'cloudSync.s3.secretAccessKey': 'Secret Access Key',
'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 设置',
'cloudSync.smb.desc': '连接到 SMB/CIFS 文件共享以进行加密同步。',
'cloudSync.smb.share': '共享路径',
'cloudSync.smb.username': '用户名',
'cloudSync.smb.password': '密码',
'cloudSync.smb.domain': '域(可选)',
'cloudSync.smb.domainPlaceholder': '例如:WORKGROUP',
'cloudSync.smb.port': '端口(可选)',
'cloudSync.smb.showSecret': '显示密码',
'cloudSync.smb.validation.share': '共享路径必填。',
'cloudSync.smb.validation.port': '端口必须是 1 到 65535 之间的数字。',
'cloudSync.connect.smb.success': 'SMB 已连接',
'cloudSync.connect.smb.failedTitle': 'SMB 连接失败',
'cloudSync.provider.smb': 'SMB 共享',
'cloudSync.connect.webdav.success': 'WebDAV 已连接',
'cloudSync.connect.webdav.failedTitle': 'WebDAV 连接失败',
'cloudSync.connect.s3.success': 'S3 已连接',
'cloudSync.connect.s3.failedTitle': 'S3 连接失败',
'cloudSync.lastSync.never': '从未',
'cloudSync.lastSync.justNow': '刚刚',
'cloudSync.lastSync.minutesAgo': '{minutes} 分钟前',
'cloudSync.changeKey': '更改 Key',
'cloudSync.providers.title': '云服务',
'cloudSync.syncAll': '同步所有已连接的服务',
'cloudSync.autoSync.title': '自动同步',
'cloudSync.autoSync.desc': '发生变更时自动同步',
'cloudSync.strategy.title': '同步策略',
'cloudSync.strategy.desc': '当本地和云端都发生变化时,选择如何处理。',
'cloudSync.strategy.smartMerge': '智能合并(推荐)',
'cloudSync.strategy.smartMergeDesc': '尽量保留两边的变化;如果无法安全判断,会再让你手动选择。',
'cloudSync.strategy.preferCloud': '云端优先',
'cloudSync.strategy.preferCloudDesc': '两边都有变化时,下载云端版本,并替换本地变化。',
'cloudSync.strategy.preferLocal': '本地优先',
'cloudSync.strategy.preferLocalDesc': '两边都有变化时,上传本地版本,并替换云端变化。',
'cloudSync.status.title': '同步状态',
'cloudSync.status.localVersion': '本地版本',
'cloudSync.status.remoteVersion': '远端版本',
'cloudSync.history.title': '同步历史',
'cloudSync.history.upload': '上传',
'cloudSync.history.download': '下载',
'cloudSync.history.resolved': '已解决',
'cloudSync.history.error': '错误',
'cloudSync.localBackups.title': '本地备份历史',
'cloudSync.localBackups.desc': 'Netcatty 会在版本变化前,以及恢复主机库前,自动留下一份本地恢复点。',
'cloudSync.localBackups.retentionTitle': '备份保留数量',
'cloudSync.localBackups.retentionDesc': '设置 Netcatty 最多保留多少份本地备份。',
'cloudSync.localBackups.maxCount': '最多保留',
'cloudSync.localBackups.maxSaved': '已保存保留数量:{count}',
'cloudSync.localBackups.maxInvalid': '请输入 1 到 100 之间的数字。',
'cloudSync.localBackups.empty': '还没有本地备份。',
'cloudSync.localBackups.reason.appVersionChange': '版本变化前',
'cloudSync.localBackups.reason.beforeRestore': '恢复前',
'cloudSync.localBackups.versionChange': '{from} -> {to}',
'cloudSync.localBackups.counts': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段,{notes} 条笔记',
'cloudSync.localBackups.restore': '恢复',
'cloudSync.localBackups.restoreSuccess': '已恢复本地备份。',
'cloudSync.localBackups.restoreFailedTitle': '恢复失败',
'cloudSync.localBackups.restoreMissing': '找不到这份备份。',
'cloudSync.localBackups.protectiveBackupFailed': '无法创建保护性备份,已中止恢复以避免覆盖当前数据。请先解决底层问题(例如钥匙串访问)后重试。详情:{message}',
'cloudSync.localBackups.restoreConfirmTitle': '确认恢复此备份?',
'cloudSync.localBackups.restoreConfirmDesc': '当前的主机、密钥、代码片段与设置将被替换为此备份中的内容。系统会先自动创建一个保护性快照,便于撤销。',
'cloudSync.localBackups.restoreConfirmButton': '恢复',
'cloudSync.localBackups.restoreConfirmCancel': '取消',
'cloudSync.localBackups.unavailableTitle': '无法使用本地备份',
'cloudSync.localBackups.unavailableDesc': '当前平台未提供受支持的安全密钥库,Netcatty 无法安全地写入本地备份。请在支持系统钥匙串的环境中运行,或改用云同步保留恢复点。',
'cloudSync.localBackups.lockedTitle': '需要主密钥',
'cloudSync.localBackups.lockedDesc': '请先配置或解锁主密钥再恢复备份,以确保恢复后的凭据仍保持加密。',
'cloudSync.revisionHistory.viewButton': '历史版本',
'cloudSync.revisionHistory.title': '主机库版本历史',
'cloudSync.revisionHistory.description': '浏览并恢复 Gist 修订历史中的旧版主机库数据。',
'cloudSync.revisionHistory.empty': '未找到修订记录。',
'cloudSync.revisionHistory.current': '当前版本',
'cloudSync.revisionHistory.revision': '修订',
'cloudSync.revisionHistory.revisionPreview': '修订内容',
'cloudSync.revisionHistory.device': '设备',
'cloudSync.revisionHistory.hosts': '主机',
'cloudSync.revisionHistory.keys': '密钥',
'cloudSync.revisionHistory.snippets': '代码片段',
'cloudSync.revisionHistory.notes': '笔记',
'cloudSync.revisionHistory.identities': '身份',
'cloudSync.revisionHistory.restoreButton': '恢复此版本',
'cloudSync.revisionHistory.restored': '已从选中的修订恢复主机库数据。',
'cloudSync.revisionHistory.revisionNotFound': '修订未找到或不包含主机库数据。',
'cloudSync.revisionHistory.decryptFailed': '无法解密此修订。可能是使用了不同的主密钥加密的。',
'cloudSync.changeKey.title': '更改主密钥',
'cloudSync.changeKey.current': '当前主密钥',
'cloudSync.changeKey.new': '新的主密钥',
'cloudSync.changeKey.confirmNew': '确认新的主密钥',
'cloudSync.changeKey.currentPlaceholder': '输入当前主密钥',
'cloudSync.changeKey.newPlaceholder': '输入新的主密钥',
'cloudSync.changeKey.confirmPlaceholder': '再次输入新的主密钥',
'cloudSync.changeKey.fillAll': '请填写所有字段',
'cloudSync.changeKey.minLength': '新的主密钥至少 8 个字符',
'cloudSync.changeKey.notMatch': '两次输入的主密钥不一致',
'cloudSync.changeKey.incorrectCurrent': '当前主密钥不正确',
'cloudSync.changeKey.failed': '更改主密钥失败',
'cloudSync.changeKey.desc': '这将重新加密 Vault,请务必记住新的主密钥。',
'cloudSync.changeKey.showKeys': '显示主密钥',
'cloudSync.changeKey.updatedToast': '主密钥已更新',
'cloudSync.changeKey.updateButton': '更新主密钥',
'cloudSync.unlock.title': '输入主密钥',
'cloudSync.unlock.masterKey': '主密钥',
'cloudSync.unlock.desc': '仅需输入一次主密钥以启用加密同步,之后会通过系统 Keychain 安全存储。',
'cloudSync.unlock.placeholder': '输入你的主密钥',
'cloudSync.unlock.empty': '请输入主密钥',
'cloudSync.unlock.incorrect': '主密钥不正确',
'cloudSync.unlock.failed': '解锁 Vault 失败',
'cloudSync.unlock.showKey': '显示主密钥',
'cloudSync.unlock.notNow': '暂不',
'cloudSync.unlock.readyToast': 'Vault 已就绪',
'cloudSync.unlock.unlockButton': '解锁',
'cloudSync.header.vaultReady': 'Vault 已就绪',
'cloudSync.header.preparingVault': '正在准备 Vault...',
'cloudSync.header.providersConnected': '已连接 {count} 个 provider',
'cloudSync.githubFlow.title': '连接到 GitHub',
'cloudSync.githubFlow.desc': '复制下面的 code,并在 GitHub 页面输入以授权 Netcatty。',
'cloudSync.githubFlow.copyCode': '复制 code',
'cloudSync.githubFlow.copied': '已复制',
'cloudSync.githubFlow.openGitHub': '打开 GitHub',
'cloudSync.githubFlow.waiting': '等待授权...',
'cloudSync.conflict.title': '检测到版本冲突',
'cloudSync.conflict.desc': '选择保留哪个版本',
'cloudSync.conflict.local': '本地',
'cloudSync.conflict.cloud': '云端',
'cloudSync.conflict.detailsTitle': '发生变化的数据',
'cloudSync.conflict.detailsCounts': '本地 {local} · 云端 {cloud} · 冲突 {conflicts}',
'cloudSync.conflict.entity.hosts': '主机',
'cloudSync.conflict.entity.keys': '密钥',
'cloudSync.conflict.entity.identities': '身份',
'cloudSync.conflict.entity.proxyProfiles': '代理配置',
'cloudSync.conflict.entity.snippets': '片段',
'cloudSync.conflict.entity.notes': '笔记',
'cloudSync.conflict.entity.noteGroups': '笔记分组',
'cloudSync.conflict.entity.customGroups': '分组',
'cloudSync.conflict.entity.snippetPackages': '片段包',
'cloudSync.conflict.entity.portForwardingRules': '端口转发',
'cloudSync.conflict.entity.groupConfigs': '分组设置',
'cloudSync.conflict.entity.settings': '设置',
'cloudSync.conflict.keepLocal': '覆盖云端(保留本地)',
'cloudSync.conflict.useCloud': '下载云端(覆盖本地)',
'cloudSync.connect.browserContinue': '请在浏览器中完成授权',
'cloudSync.connect.browserCancelled': '已取消上一个浏览器授权流程',
'cloudSync.connect.github.success': 'GitHub 已连接',
'cloudSync.connect.github.failedTitle': 'GitHub 连接失败',
'cloudSync.connect.github.timeout': '连接 GitHub 超时,请检查网络或代理设置。',
'cloudSync.connect.github.networkError': '无法访问 GitHub,请检查网络或代理设置。',
'cloudSync.connect.google.failedTitle': 'Google 连接失败',
'cloudSync.connect.onedrive.failedTitle': 'OneDrive 连接失败',
'cloudSync.sync.success': '已同步到 {provider}',
'cloudSync.sync.failed': '同步失败',
'cloudSync.sync.failedTitle': '同步失败',
'cloudSync.sync.errorTitle': '同步错误',
'cloudSync.resolve.downloaded': '已下载云端数据',
'cloudSync.resolve.uploaded': '已上传本地数据',
'cloudSync.resolve.failedTitle': '冲突处理失败',
'cloudSync.clearLocal.title': '清空本地数据',
'cloudSync.clearLocal.desc': '重置本地版本和同步历史。下次同步将从云端下载。',
'cloudSync.clearLocal.button': '清空',
'cloudSync.clearLocal.dialog.title': '清空本地 Vault 数据?',
'cloudSync.clearLocal.dialog.desc': '这将重置本地版本为 0 并清除同步历史。下次同步时会从云端下载数据,替换本地数据。',
'cloudSync.clearLocal.dialog.cancel': '取消',
'cloudSync.clearLocal.dialog.confirm': '确认清空',
'cloudSync.clearLocal.toast.title': '本地数据已清空',
'cloudSync.clearLocal.toast.desc': '本地版本已重置为 0。同步以从云端下载数据。',
// Common (additional)
'common.searchPlaceholder': '搜索...',
'common.import': '导入',
'common.generate': '生成',
'common.delete': '删除',
'common.edit': '编辑',
'sftp.context.openWithDefault': '系统默认程序打开',
'common.clear': '清除',
'common.optional': '可选',
'common.selectPlaceholder': '请选择...',
'common.error': '错误',
'common.validation': '验证',
'common.saveChanges': '保存修改',
'common.advanced': '高级',
'common.selectAHostPlaceholder': '选择主机...',
// Actions
'action.duplicate': '复制',
'action.open': '打开',
'action.copy': '复制',
'action.run': '运行',
'action.start': '启动',
'action.stop': '停止',
// Port Forwarding (form)
'pf.form.labelPlaceholder': '规则标签',
'pf.form.intermediateHost': '中转主机 *',
'pf.form.createRule': '创建规则',
'pf.form.openWizard': '打开向导',
'pf.form.openWizardTitle': '打开端口转发向导',
'pf.action.newForwarding': '新建转发',
'pf.view.grid': '网格',
'pf.view.list': '列表',
'pf.rule.summary.local': '本机 {bindAddress}:{localPort} -> SSH 隧道 -> 远端 {remoteHost}:{remotePort}',
'pf.rule.summary.remote': '远端 {bindAddress}:{localPort} -> SSH 隧道 -> 本机 {remoteHost}:{remotePort}',
'pf.rule.summary.dynamic': 'SOCKS 监听于 {bindAddress}:{localPort}',
'pf.tooltip.relayHost': '中转主机',
'pf.tooltip.hostLabel': '主机',
'pf.tooltip.hostAddress': '地址',
'pf.tooltip.noHost': '未配置中转主机',
'pf.tooltip.localDesc': '本地端口转发:通过 SSH 隧道访问远程服务',
'pf.tooltip.remoteDesc': '远程端口转发:将本地服务暴露给远程主机',
'pf.tooltip.dynamicDesc': '动态 SOCKS 代理:通过 SSH 隧道转发流量',
'pf.deleteActive.title': '删除正在运行的端口转发?',
'pf.deleteActive.desc': '端口转发规则 "{label}" 当前正在运行。删除前将先关闭转发连接。',
'pf.deleteActive.confirm': '关闭并删除',
'pf.form.autoStart': '自动启动',
'pf.form.autoStartDesc': '应用启动时自动开启此规则',
// SFTP (pane + conflict)
'sftp.pane.local': '本地',
'sftp.pane.remote': '远端',
'sftp.pane.selectHost': '选择主机',
'sftp.pane.selectHostToStart': '先选择一个主机',
'sftp.pane.chooseFilesystem': '选择要浏览的本地或远端文件系统',
'sftp.tabs.addTab': '新建标签页',
'sftp.tabs.closeTab': '关闭标签页',
'sftp.tabs.newTab': '新标签页',
'sftp.tabs.copyDefaultPath': '复制标签页(默认路径)',
'sftp.tabs.copyCurrentPath': '复制并跳转到当前路径',
'sftp.conflict.title': '文件冲突',
'sftp.conflict.desc': '目标位置已存在同名文件',
'sftp.conflict.alreadyExistsSuffix': '已存在',
'sftp.conflict.existingFile': '已有文件',
'sftp.conflict.newFile': '新文件',
'sftp.conflict.size': '大小:',
'sftp.conflict.modified': '修改时间:',
'sftp.conflict.applyToAll': '将此操作应用到剩余的 {count} 个冲突',
'sftp.conflict.action.stop': '停止',
'sftp.conflict.action.skip': '跳过',
'sftp.conflict.action.keepBoth': '保留两者',
'sftp.conflict.action.duplicate': '创建副本',
'sftp.conflict.action.merge': '合并',
'sftp.conflict.action.replace': '替换',
// SFTP Upload Phases
'sftp.upload.phase.compressing': '正在压缩',
'sftp.upload.phase.uploading': '正在上传',
'sftp.upload.phase.extracting': '正在解压',
'sftp.upload.phase.compressed': '压缩传输',
};
+20
View File
@@ -0,0 +1,20 @@
import type { Messages } from './types';
import { zhTWCoreMessages } from './zh-TW/core';
import { zhTWVaultMessages } from './zh-TW/vault';
import { zhTWTerminalMessages } from './zh-TW/terminal';
import { zhTWAiMessages } from './zh-TW/ai';
import { zhTwSystemManagerMessages } from './zh-TW/systemManager';
import { zhTWScriptsMessages } from './zh-TW/scripts';
export type { Messages } from './types';
const zhTW: Messages = {
...zhTWCoreMessages,
...zhTWVaultMessages,
...zhTWTerminalMessages,
...zhTWAiMessages,
...zhTwSystemManagerMessages,
...zhTWScriptsMessages,
};
export default zhTW;
+491
View File
@@ -0,0 +1,491 @@
import type { Messages } from '../types';
export const zhTWAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Agent 設定',
'ai.chat.preparing': '準備中…',
'ai.chat.compactingContext': '正在壓縮較早的上下文…',
'ai.chat.compactingStep': '正在為下一步整理上下文…',
'ai.chat.compactionRetry': '請求過大,正在壓縮上下文並重試…',
'ai.chat.compactionBanner': '上下文已壓縮:{before}K → {after}K tokens',
'ai.title': 'AI',
'ai.description': '設定 AI 供應商、Agent 和安全設定',
'ai.providers': '供應商',
'ai.agents': 'Agent',
'ai.providers.empty': '尚未設定供應商。新增一個供應商以開始使用。',
'ai.providers.add': '新增供應商',
'ai.providers.active': '使用中',
'ai.providers.apiKeyConfigured': 'API Key 已設定',
'ai.providers.noApiKey': '未設定 API Key',
'ai.providers.configure': '設定',
'ai.providers.remove': '移除',
'ai.providers.name': '顯示名稱',
'ai.providers.name.placeholder': '例如 我的供應商',
'ai.providers.style': '協定風格',
'ai.providers.style.anthropic': 'Anthropic 相容',
'ai.providers.style.openai': 'OpenAI 相容',
'ai.providers.style.google': 'Google 相容',
'ai.providers.style.inherited': '預設',
'ai.providers.style.help': '決定請求使用哪種 API 格式。當第三方端點的協定與其供應商型別不一致時,可手動覆蓋。',
'ai.providers.icon.change': '修改圖示',
'ai.providers.icon.upload': '上傳圖片',
'ai.providers.icon.reset': '恢復預設',
'ai.providers.icon.close': '收起',
'ai.providers.icon.uploadedNote': '自訂圖示(64×64 WebP)',
'ai.providers.icon.errorType': '請選擇圖片檔案。',
'ai.providers.apiKey': 'API Key',
'ai.providers.apiKey.placeholder': '輸入 API Key',
'ai.providers.apiKey.decrypting': '解密中...',
'ai.providers.baseUrl': 'Base URL',
'ai.providers.skipTLSVerify': '跳過 TLS 憑證驗證(用於自我簽署憑證)',
'ai.providers.defaultModel': '預設模型',
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
'ai.providers.contextWindow': '上下文視窗',
'ai.providers.contextWindow.placeholder': '例如 128000',
'ai.providers.contextWindow.help': '留空時優先使用模型列表傳回的值;如果沒有,Netcatty 會使用安全預設值。',
'ai.providers.contextWindow.error': '請輸入正整數,或留空。',
'ai.providers.refreshModels': '重新整理模型列表',
'ai.providers.searchModel': '搜尋或輸入模型 ID...',
'ai.providers.filterModels': '篩選模型...',
'ai.providers.loadingModels': '載入模型中...',
'ai.providers.noMatchingModels': '沒有相符的模型',
'ai.providers.clickToLoadModels': '點選載入模型',
'ai.providers.showingModels': '顯示前 100 個,共 {count} 個模型。輸入以篩選。',
'ai.providers.advancedParams': '高階引數',
'ai.providers.advancedParams.hint': '留空則使用供應商預設值。',
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
'ai.providers.advancedParams.default': '供應商預設',
// AI Codex
'ai.codex': 'Codex',
'ai.codex.title': 'Codex CLI',
'ai.codex.description': '介接 OpenAI Codex。可以在這裡登入 ChatGPT,也可以在設定裡啟用相容 OpenAI 的 API Key 和自訂介面地址。',
'ai.codex.detecting': '偵測中...',
'ai.codex.notFound': '未找到',
'ai.codex.awaitingLogin': '等待登入',
'ai.codex.connectedChatGPT': '已透過 ChatGPT 連線',
'ai.codex.connectedApiKey': '已透過 API Key 連線',
'ai.codex.connectedCustomConfig': '使用 ~/.codex/config.toml 自訂 provider',
'ai.codex.customConfigIncomplete': '偵測到自訂設定(缺少環境變數)',
'ai.codex.customConfigHint': '使用 ~/.codex/config.toml 中設定的自訂 provider "{provider}",無需 ChatGPT 登入。',
'ai.codex.customConfigMissingEnvKey': '警告:環境變數 {envKey} 未在目前 shell 中設定。請 export 它(或從包含該變數的 shell 啟動 netcatty),否則 Codex 無法完成認證。',
'ai.codex.notConnected': '未連線',
'ai.codex.statusUnknown': '狀態未知',
'ai.codex.path': '路徑:',
'ai.codex.notFoundHint': '在 PATH 中未找到 codex。請安裝或在下方指定執行檔路徑。',
'ai.codex.customPathPlaceholder': '例如 /usr/local/bin/codex',
'ai.codex.check': '檢查',
'ai.codex.resetPath': '重置',
'ai.codex.openLogin': '開啟登入',
'ai.codex.logout': '登出',
'ai.codex.connectChatGPT': '連線 ChatGPT',
'ai.codex.refreshStatus': '重新整理狀態',
// AI Claude Code
'ai.claude.title': 'Claude Code',
'ai.claude.description': 'Anthropic 的智慧程式設計助手。需要系統中已安裝 Claude Code CLI。',
'ai.claude.detecting': '偵測中...',
'ai.claude.detected': '已偵測到',
'ai.claude.notFound': '未找到',
'ai.claude.path': '路徑:',
'ai.claude.notFoundHint': '在 PATH 中未找到 claude。請安裝或在下方指定執行檔路徑。',
'ai.claude.customPathPlaceholder': '例如 /usr/local/bin/claude',
'ai.claude.configSection': '認證與設定(可選)',
'ai.claude.configDir': '設定目錄',
'ai.claude.configDir.placeholder': '~/.claude(留空用預設)',
'ai.claude.configDir.hint': '設定 CLAUDE_CONFIG_DIR —— 指向你已執行 `claude` 登入的目錄(含 settings.json 和憑證)。',
'ai.claude.settings': 'Settings 檔案',
'ai.claude.settings.placeholder': '~/team-settings.json(路徑,或內聯 {"model":"..."})',
'ai.claude.settings.hint': '可選。settings.json 路徑或內聯 JSON,作為 SDK 的 `settings` 傳入。與上面的「設定目錄」互補且獨立(疊加合併,不是替換)。',
'ai.claude.envVars': '環境變數',
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
'ai.claude.envVars.hint': '每行一個 KEY=VALUE,傳給 Claude agent。明文存在本機——API key/憑證建議用上面的「設定目錄」(claude 登入),不要放這裡。',
'ai.claude.check': '檢查',
'ai.claude.resetPath': '重置',
// AI GitHub Copilot CLI
'ai.copilot.title': 'GitHub Copilot CLI',
'ai.copilot.description': '介接 GitHub Copilot CLI。偵測到後即可作為外部程式設計 Agent 使用。',
'ai.copilot.detecting': '偵測中...',
'ai.copilot.detected': '已偵測到',
'ai.copilot.notFound': '未找到',
'ai.copilot.path': '路徑:',
'ai.copilot.notFoundHint': '在 PATH 中未找到 copilot。請安裝或在下方指定執行檔路徑。',
'ai.copilot.customPathPlaceholder': '例如 /usr/local/bin/copilot',
'ai.copilot.check': '檢查',
'ai.copilot.resetPath': '重置',
// AI Cursor SDK
'ai.cursor.title': 'Cursor',
'ai.cursor.description': '使用 Cursor SDK。',
'ai.cursor.detecting': '偵測中...',
'ai.cursor.detected': '可用',
'ai.cursor.notFound': '不可用',
'ai.cursor.path': '執行環境:',
'ai.cursor.notFoundHint': '填寫 API Key 後即可使用。',
'ai.cursor.notInstalledHint': '未偵測到 Cursor SDK。',
'ai.cursor.installStatus': 'Cursor SDK',
'ai.cursor.installed': '已偵測到',
'ai.cursor.notInstalled': '未偵測到',
'ai.cursor.apiKeyStatus': 'API Key',
'ai.cursor.apiKeyConfigured': '已填寫',
'ai.cursor.apiKeyMissing': '未填寫',
'ai.cursor.apiKeyFromEnv': '來自環境變數',
'ai.cursor.apiKey': 'API Key',
'ai.cursor.apiKeyPlaceholder': '輸入 Cursor API Key',
'ai.cursor.apiKeyPlaceholder.env': '已使用 CURSOR_API_KEY;填寫後會覆蓋',
'ai.cursor.apiKeyEnvHint': '已偵測到本機 CURSOR_API_KEY。留空即可繼續使用,填寫儲存後會覆蓋它。',
'ai.cursor.apiKeyOverrideHint': '目前優先使用這裡儲存的 Key;清空儲存後會回到 CURSOR_API_KEY。',
'ai.cursor.saveApiKey': '儲存',
'ai.cursor.saved': '已儲存',
'ai.cursor.showApiKey': '顯示 API Key',
'ai.cursor.hideApiKey': '隱藏 API Key',
'ai.cursor.customPathPlaceholder': '例如 /usr/local/bin/cursor',
'ai.cursor.check': '檢查',
// AI CodeBuddy Code
'ai.codebuddy.title': 'CodeBuddy Code',
'ai.codebuddy.description': '透過官方 Agent SDK(`@tencent-ai/agent-sdk`)介接 CodeBuddy Code。偵測到後即可作為外部程式設計 Agent 使用。',
'ai.codebuddy.detecting': '偵測中...',
'ai.codebuddy.detected': '已偵測到',
'ai.codebuddy.notFound': '未找到',
'ai.codebuddy.path': '路徑:',
'ai.codebuddy.notFoundHint': '在 PATH 中未找到 codebuddy。請安裝或在下方指定執行檔路徑。',
'ai.codebuddy.customPathPlaceholder': '例如 /usr/local/bin/codebuddy',
'ai.codebuddy.check': '檢查',
'ai.codebuddy.resetPath': '重置',
'ai.codebuddy.configSection': '認證與設定(可選)',
'ai.codebuddy.internetEnv': '網路環境',
'ai.codebuddy.internetEnv.default': '預設(海外)',
'ai.codebuddy.internetEnv.internal': 'Internal',
'ai.codebuddy.internetEnv.ioa': 'IOA',
'ai.codebuddy.internetEnv.hint': '設定 CODEBUDDY_INTERNET_ENVIRONMENT —— 受限網路環境請選擇 Internal 或 IOA。',
'ai.codebuddy.envVars': '環境變數',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': '每行一個 KEY=VALUE,傳給 CodeBuddy agent。可在此設定 CODEBUDDY_API_KEY 或 CODEBUDDY_AUTH_TOKEN 完成認證。明文存在本機。',
// AI OpenCode
'ai.opencode.title': 'OpenCode',
'ai.opencode.description': '透過官方 SDK 介接 OpenCode。先在 OpenCode 裡設定 provider 和金鑰,偵測到後即可作為外部程式設計 Agent 使用。',
'ai.opencode.detecting': '偵測中...',
'ai.opencode.detected': '已偵測到',
'ai.opencode.notFound': '未找到',
'ai.opencode.path': '路徑:',
'ai.opencode.notFoundHint': '在 PATH 中未找到 opencode。請安裝或在下方指定執行檔路徑。',
'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode',
'ai.opencode.check': '檢查',
'ai.opencode.resetPath': '重置',
// AI Default Agent
'ai.defaultAgent': '預設 Agent',
'ai.defaultAgent.description': '建立新 AI 工作階段時使用的 Agent',
'ai.defaultAgent.catty': 'Catty(內建)',
'ai.toolAccess.title': '工具介接',
'ai.toolAccess.mode': 'Netcatty 介接模式',
'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 資料夾',
'ai.userSkills.reload': '重新載入 Skills',
'ai.userSkills.location': '位置',
'ai.userSkills.loading': '正在掃描使用者 skills...',
'ai.userSkills.summary': '已就緒 {ready} 個,警告 {warnings} 個',
'ai.userSkills.empty': '暫未發現使用者 skills。開啟資料夾後可新增包含 SKILL.md 的技能目錄。',
'ai.userSkills.unavailable': '目前環境不支援使用者 skills。',
'ai.userSkills.status.ready': '正常',
'ai.userSkills.status.warning': '警告',
// AI Quick Messages
'ai.quickMessages.title': '快捷訊息',
'ai.quickMessages.description': '建立常用提示詞,在 AI 聊天框輸入 / 或點選快捷按鈕即可插入到輸入框。與使用者 Skills 不同,快捷訊息會直接填入訊息內容。',
'ai.quickMessages.add': '新增快捷訊息',
'ai.quickMessages.createTitle': '新建快捷訊息',
'ai.quickMessages.editTitle': '編輯快捷訊息',
'ai.quickMessages.name': '名稱',
'ai.quickMessages.name.placeholder': '例如:檢查磁碟空間',
'ai.quickMessages.slug': '指令',
'ai.quickMessages.slug.placeholder': 'disk-check',
'ai.quickMessages.descriptionField': '說明(可選)',
'ai.quickMessages.descriptionField.placeholder': '簡短描述這條快捷訊息的用途',
'ai.quickMessages.content': '訊息內容',
'ai.quickMessages.content.placeholder': '輸入選擇後要插入的完整提示詞...',
'ai.quickMessages.empty': '還沒有快捷訊息。新增幾條常用提示,聊天時就能一鍵插入。',
'ai.quickMessages.confirmDelete': '確定刪除快捷訊息「{name}」嗎?',
'ai.quickMessages.error.nameRequired': '請填寫名稱。',
'ai.quickMessages.error.invalidSlug': '指令只能包含小寫字母、數字和連字元。',
'ai.quickMessages.error.contentRequired': '請填寫訊息內容。',
'ai.quickMessages.error.slugTaken': '該指令已被其他快捷訊息使用。',
'ai.quickMessages.error.slugConflictsWithSkill': '該指令與使用者 Skill「/{slug}」衝突,請換一個指令。',
'ai.quickMessages.error.maxItems': '最多只能儲存 {max} 條快捷訊息。',
// AI Chat
'ai.chat.noProvider': '尚未設定 AI 供應商。請前往 **設定 → AI → 供應商** 新增並啟用一個供應商。',
'ai.chat.toolDenied': '操作已被使用者拒絕。',
'ai.chat.toolApproved': '已批准',
'ai.chat.toolApprovalHint': 'Enter 允許 · Esc 拒絕',
'ai.chat.approve': '批准',
'ai.chat.approveOnce': '允許',
'ai.chat.alwaysAllow': '始終',
'ai.chat.slashStopDesc': '停止目前 AI 回合並取消進行中的工具',
'ai.chat.reject': '拒絕',
'ai.chat.toolLabel': '工具',
'ai.chat.targetLabel': '目標',
'ai.chat.permissionRequired': '需要權限',
'ai.chat.permissionDescription': 'AI Agent 希望執行一個需要你批准的工具呼叫。',
'ai.chat.commandBlocked': '此指令已被安全策略攔截,無法執行。',
'ai.chat.recommendAllow': '允許',
'ai.chat.recommendConfirm': '確認',
'ai.chat.recommendDeny': '拒絕',
'ai.chat.exportConversation': '匯出對話',
'ai.chat.exportAs': '匯出為',
'ai.chat.exportMarkdown': 'Markdown',
'ai.chat.exportJSON': 'JSON',
'ai.chat.exportPlainText': '純文字',
'ai.chat.thinking': '思考中',
'ai.chat.thoughtFor': '思考了 {duration}',
'ai.chat.thought': '思考',
'ai.chat.agents': 'Agents',
'ai.chat.detectedOnMachine': '在本機偵測到',
'ai.chat.rescan': '重新掃描',
'ai.chat.permObserver': '觀察',
'ai.chat.permConfirm': '確認',
'ai.chat.permAuto': '自動',
'ai.chat.permObserverDesc': '只讀模式',
'ai.chat.permConfirmDesc': '操作前詢問',
'ai.chat.permAutoDesc': '自由執行',
'ai.chat.emptyHint': '詢問伺服器相關問題、執行指令或取得設定協助。',
'ai.chat.placeholder': '向 {agent} 傳送訊息 — @ 引用上下文,/ 使用指令',
'ai.chat.placeholderDefault': '向 Catty Agent 傳送訊息...',
'ai.chat.noModel': '未選擇模型',
'ai.chat.noProviderModel': '未設定預設模型——前往 設定 → AI → 供應商 設定。',
'ai.chat.selectProvider': '選擇供應商',
'ai.chat.recent': '最近',
'ai.chat.viewAll': '檢視全部',
'ai.chat.untitled': '無標題',
'ai.chat.justNow': '剛剛',
'ai.chat.minutesAgo': '{n}分鐘前',
'ai.chat.hoursAgo': '{n}小時前',
'ai.chat.daysAgo': '{n}天前',
'ai.chat.newChat': '新對話',
'ai.chat.allSessions': '所有工作階段',
'ai.chat.loadEarlierMessages': '載入更早的訊息(還有 {n} 條)',
'ai.chat.usedTools': '已使用 {n} 個工具',
'ai.chat.loadMoreSessions': '載入更多工作階段(還有 {n} 條)',
'ai.chat.noSessions': '沒有歷史工作階段',
'ai.chat.retryHint': '你可以重新發送訊息來重試。',
'ai.chat.approvalTimeout': '工具審批已超時(5 分鐘)。你可以重新發送訊息來重試。',
'ai.chat.menuHosts': '主機',
'ai.chat.menuContext': '上下文',
'ai.chat.menuFiles': '檔案',
'ai.chat.menuImage': '圖片',
'ai.chat.menuMentionHost': '提及主機',
'ai.chat.menuUserSkills': '使用者 Skills',
'ai.chat.menuSlashCommands': '快捷指令',
'ai.chat.slashCommands': '快捷指令',
'ai.chat.slashQuickMessages': '快捷訊息',
'ai.chat.slashUserSkills': '使用者 Skills',
'ai.chat.quickMessages': '快捷指令',
'ai.chat.slashNoResults': '沒有相符的指令',
'ai.chat.slashEmptyHint': '可在 設定 → AI → 快捷訊息 中新增常用提示詞。',
// AI 聊天快捷入口
'ai.chatShortcuts.title': '聊天快捷入口',
'ai.chatShortcuts.selectionAction': '選取終端內容時顯示「新增到對話」',
'ai.chatShortcuts.selectionAction.description': '在終端裡選取文字後顯示 AI 快捷按鈕。',
// AI Error
'ai.codex.bridgeError': 'Codex 主程序處理器尚未載入。請完全重啟 Netcatty 或重啟 Electron 開發程序,然後重試。',
// AI Web Search
'ai.webSearch.title': '網路搜尋',
'ai.webSearch.enable': '啟用網路搜尋',
'ai.webSearch.enable.description': '允許 AI 代理搜尋網際網路取得最新資訊。',
'ai.webSearch.provider': '搜尋供應商',
'ai.webSearch.provider.description': '選擇一個網路搜尋 API 供應商。',
'ai.webSearch.apiKey': 'API 金鑰',
'ai.webSearch.apiKey.description': '所選搜尋供應商的 API 金鑰。',
'ai.webSearch.apiKey.placeholder': '輸入 API 金鑰...',
'ai.webSearch.apiHost': 'API 地址',
'ai.webSearch.apiHost.description': '自訂 API 端點。除非使用代理,否則保持預設值。',
'ai.webSearch.apiHost.searxngDescription': 'SearXNG 例項的 URL(必填)。',
'ai.webSearch.maxResults': '最大結果數',
'ai.webSearch.maxResults.description': '搜尋傳回的最大結果數(1-20)。',
// AI Safety Settings
'ai.safety.title': '安全',
'ai.safety.permissionMode': '權限模式',
'ai.safety.permissionMode.description': '控制 AI 透過 Netcatty 存取終端工作階段的方式。觀察者模式會阻止經由 Netcatty 的寫操作;外部 Agent CLI 可能仍有自己的本機工具和審批流程。',
'ai.safety.permissionMode.observer': '觀察者 - 只讀,禁止操作',
'ai.safety.permissionMode.confirm': '確認 - 操作前詢問',
'ai.safety.permissionMode.auto': '自動 - 自由執行',
'ai.safety.commandTimeout': '指令超時',
'ai.safety.commandTimeout.description': '透過 Netcatty 執行指令時允許執行的最長秒數,超時將被終止。',
'ai.safety.commandTimeout.unit': '秒',
'ai.safety.maxIterations': '最大迭代次數',
'ai.safety.maxIterations.description': '防止 AI 失控執行的最大工具呼叫迴圈次數。外部 Agent 可能有自己的內部迭代限制,以其為準。',
'ai.safety.blocklist': '指令黑名單',
'ai.safety.blocklist.description': '用於攔截透過 Netcatty 執行的危險指令的正規表示式。',
'ai.safety.blocklist.placeholder': '正規表示式...',
'ai.safety.blocklist.reset': '恢復預設',
'ai.safety.blocklist.add': '新增規則',
'ai.safety.grants.title': '許可記憶表',
'ai.safety.grants.heading': 'Confirm 模式放行規則',
'ai.safety.grants.description': 'Confirm 模式會先詢問再執行;儲存為規則後,相符的同類操作會自動放行。規則對所有終端節點/工作階段生效,也可手動編輯。',
'ai.safety.grants.empty': '尚無規則。可在審批時選擇「一律許可」,或手動新增。',
'ai.safety.grants.capability': '能力',
'ai.safety.grants.sessionPattern': '節點/工作階段模式',
'ai.safety.grants.commandPattern': '指令模式(可選)',
'ai.safety.grants.note': '備註(可選)',
'ai.safety.grants.add': '新增規則',
'ai.safety.grants.remove': '刪除',
'ai.safety.grants.export': '匯出 JSON',
'ai.safety.grants.import': '匯入 JSON',
'ai.safety.note': '這些安全設定會約束經由 Netcatty 執行的操作。外部 Agent CLI 也可能提供本機工具,那部分由 Agent 自己的控制規則約束。',
// 統一終端工作區和頂部標籤的 tooltip 文案 (issue #954)
'terminal.layer.addTerminal': '新增終端',
'terminal.layer.switchToSplitView': '切換到分割檢視',
'terminal.layer.sftp': '檔案傳輸',
'terminal.layer.scripts': '指令碼',
'terminal.layer.history': '指令歷史',
'terminal.layer.theme': '主題',
'terminal.layer.notes': '筆記',
'terminal.layer.aiChat': 'AI 助手',
'terminal.layer.movePanelLeft': '面板移至左側',
'terminal.layer.movePanelRight': '面板移至右側',
'terminal.layer.closePanel': '關閉面板',
'terminal.layer.hostTree.search': '搜尋主機...',
'terminal.layer.hostTree.searchButton': '搜尋',
'terminal.layer.hostTree.tagsButton': '按標籤篩選',
'terminal.layer.hostTree.newGroup': '新建分組',
'terminal.layer.hostTree.localShell': '本機 Shell',
'terminal.layer.hostTree.tagsEmpty': '暫無標籤',
'terminal.layer.hostTree.clearTags': '清除篩選',
'terminal.layer.hostTree.collapse': '收起主機列表',
'terminal.layer.hostTree.expand': '展開主機列表',
'terminal.layer.hostTree.empty': '沒有相符的主機',
'terminal.layer.hostTree.details.host': '主機',
'terminal.layer.hostTree.details.user': '使用者',
'terminal.layer.hostTree.details.port': '埠',
'terminal.layer.hostTree.details.protocol': '協定',
'terminal.layer.hostTree.details.group': '分組',
'terminal.layer.hostTree.details.tags': '標籤',
'terminal.layer.hostTree.details.lastConnected': '最近連線',
'topTabs.openQuickSwitcher': '開啟快速切換',
'topTabs.moreTabs': '更多標籤頁',
'topTabs.aiAssistant': 'AI 助手',
'topTabs.windowOpacity': '視窗透明度',
'topTabs.toggleTheme': '切換主題',
'topTabs.openSettings': '開啟設定',
'ai.chat.sessionHistory': '工作階段歷史',
'ai.chat.attach': '附件',
'ai.chat.terminalSelectionAttachment': '終端選區',
'ai.chat.terminalSelectionLines': '{count} 行',
'ai.chat.collapse': '收起',
'ai.chat.expand': '展開',
'ai.chat.enableAgent': '啟用 {name}',
'ai.chat.artifact.noteFallback': 'Vault 筆記',
'ai.chat.artifact.openNotes': '開啟筆記',
'ai.chat.artifact.openHosts': '開啟主機',
'ai.chat.artifact.notesSummary': 'Vault 中有 {count} 條筆記',
'ai.chat.artifact.hostsSummary': 'Vault 中有 {count} 台主機',
'ai.chat.artifact.hostsAdded': '已新增 {count} 台主機',
'ai.chat.artifact.hostsPreview': '預覽 {count} 台主機',
'ai.chat.artifact.failed': 'Vault 操作失敗',
'ai.chat.artifact.unavailableTitle': '不可用',
'ai.chat.artifact.noteMissing': '該筆記已不在 Vault 中。',
'ai.chat.artifact.hostMissing': '該主機已不在 Vault 中。',
'ai.chat.artifact.snippetMissing': '該 Snippet 或指令碼已不在 Vault 中。',
'ai.chat.artifact.openSnippets': '開啟 Snippets',
'ai.chat.artifact.snippetsSummary': 'Vault 中有 {count} 個 Snippet',
'ai.chat.artifact.scriptsSummary': 'Vault 中有 {count} 個指令碼',
'ai.chat.artifact.snippetFallback': 'Vault Snippet',
'ai.chat.artifact.scriptFallback': '自動化指令碼',
'ai.chat.artifact.scriptLanguage': '{language} 指令碼',
'ai.chat.artifact.snippetDeleted': 'Snippet 已刪除',
'ai.chat.artifact.scriptDeleted': '指令碼已刪除',
'ai.chat.artifact.snippetRan': 'Snippet 已執行',
'ai.chat.artifact.scriptStarted': '指令碼執行已啟動',
'ai.chat.artifact.scriptRunStatus': '指令碼執行 {status}',
'ai.chat.artifact.scriptRunsSummary': '{count} 個指令碼執行',
'ai.chat.artifact.scriptRunStopped': '指令碼執行已停止',
'ai.chat.artifact.scriptRunPaused': '指令碼執行已暫停',
'ai.chat.artifact.scriptRunResumed': '指令碼執行已恢復',
'ai.chat.artifact.scriptReference': 'nct API 參考',
'zmodem.waitingForRemote': '等待遠端...',
'zmodem.uploading': '上傳中',
'zmodem.downloading': '下載中',
'zmodem.cancelTransfer': '取消傳輸 (Ctrl+C)',
'zmodem.overwrite.title': '遠端已存在同名檔案',
'zmodem.overwrite.applyToRest': '套用到其餘衝突檔案',
'zmodem.overwrite.overwrite': '覆蓋',
'zmodem.overwrite.skip': '跳過',
'zmodem.overwrite.cancel': '取消',
'settings.shortcuts.resetToDefault': '重置為預設',
};
+768
View File
@@ -0,0 +1,768 @@
import type { Messages } from '../types';
export const zhTWCoreMessages: Messages = {
// Common
'common.save': '儲存',
'common.cancel': '取消',
'common.close': '關閉',
'common.reset': '重置',
'common.zoomIn': '放大',
'common.zoomOut': '縮小',
'common.settings': '設定',
'common.search': '搜尋',
'common.connect': '連線',
'common.terminal': '終端',
'common.create': '建立',
'common.add': '新增',
'common.rename': '重新命名',
'common.refresh': '重新整理',
'common.continue': '繼續',
'common.enabled': '已啟用',
'common.disabled': '已停用',
'common.unknownError': '未知錯誤',
'common.noResultsFound': '沒有相符結果',
'common.back': '返回',
'common.apply': '套用',
'common.use': '使用',
'common.useGlobal': '跟隨全域',
'common.left': '左側',
'common.right': '右側',
'common.more': '更多',
'common.selectAHost': '選擇主機',
'sort.manual': '手動順序',
'sort.az': 'A-z',
'sort.za': 'Z-a',
'sort.newest': '從新到舊',
'sort.oldest': '從舊到新',
'sort.group': '按分組',
'field.label': 'Label',
'field.type': '型別',
'auth.keyType': '型別 {type}',
'auth.showAllKeys': '顯示全部 keys',
// Dialogs / prompts
'confirm.deleteHost': '刪除主機 "{name}"?',
'confirm.deleteIdentity': '刪除身份 "{name}"?',
'confirm.removeProvider': '移除供應商 "{name}"?',
'confirm.closeBusyTerminal.title': '確認關閉',
'confirm.closeBusyTerminal.message': '程序 "{command}" 仍在執行,關閉後會被終止。',
'confirm.closeBusyTerminal.messageWithMore': '程序 "{command}" 及其他 {count} 個正在執行的程序將被終止。',
'confirm.closeBusyTerminal.cancel': '取消',
'confirm.closeBusyTerminal.close': '關閉',
'dialog.renameWorkspace.title': '重新命名工作區',
'dialog.renameSession.title': '重新命名工作階段',
'field.name': '名稱',
'placeholder.workspaceName': '工作區名稱',
'placeholder.sessionName': '工作階段名稱',
'toast.settingsUnavailable': '目前平台無法開啟設定視窗。',
'credentials.protectionUnavailable.title': '憑證保護不可用',
'credentials.protectionUnavailable.message': '目前裝置無法自動解密已儲存的密碼和金鑰。連線前請重新輸入憑證。',
'credentials.protectionUnavailable.action': '開啟設定',
// Settings shell
'settings.title': '設定',
'settings.tab.application': '應用程式',
'settings.tab.appearance': '外觀',
'settings.tab.terminal': '終端',
'settings.tab.shortcuts': '快捷鍵',
'settings.tab.syncCloud': '同步與雲',
'settings.tab.system': '系統',
'settings.terminal.connection.verifyHostKeys': '驗證 SSH 主機金鑰',
'settings.terminal.connection.verifyHostKeys.desc': '連線到新的或已變化的 SSH 主機金鑰前先詢問。只建議在可信內網中關閉。',
'settings.terminal.section.sidePanel': '側邊欄',
'settings.terminal.sidePanel.autoOpen': '開啟終端時展開側邊欄',
'settings.terminal.sidePanel.autoOpen.desc': '終端連線成功後自動開啟一個側邊欄。',
'settings.terminal.sidePanel.autoOpenPane': '預設側邊欄',
'settings.terminal.sidePanel.autoOpenPane.desc': '選擇優先開啟哪個面板。',
'settings.terminal.sidePanel.pane.sftp': 'SFTP',
'settings.terminal.sidePanel.pane.scripts': '指令碼',
'settings.terminal.sidePanel.pane.history': '歷史',
'settings.terminal.sidePanel.pane.theme': '主題',
'settings.terminal.sidePanel.pane.system': '系統',
'settings.terminal.sidePanel.pane.notes': '筆記',
'settings.terminal.sidePanel.pane.ai': 'AI',
// Settings > System
'settings.system.title': '系統',
'settings.system.description': '系統資訊與臨時檔案管理。',
'settings.system.tempDirectory': '臨時檔案',
'settings.system.location': '位置',
'settings.system.fileCount': '檔案數量',
'settings.system.totalSize': '佔用空間',
'settings.system.openFolder': '開啟資料夾',
'settings.system.refresh': '重新整理',
'settings.system.clearTempFiles': '清理臨時檔案',
'settings.system.clearing': '清理中...',
'settings.system.clearResult': '已刪除 {deleted} 個檔案,{failed} 個失敗。',
'settings.system.tempDirectoryHint': '臨時檔案在使用外部應用程式開啟遠端檔案時建立。SFTP 工作階段關閉時會自動清理。',
'settings.system.credentials.title': '憑證保護',
'settings.system.credentials.status': '狀態',
'settings.system.credentials.checking': '檢查中...',
'settings.system.credentials.available': '可用(系統鑰匙串正常)',
'settings.system.credentials.unavailable': '不可用(無法解密已儲存憑證)',
'settings.system.credentials.unknown': '未知(目前環境不支援)',
'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': '檢視主程序錯誤日誌,協助診斷異常行為。',
'settings.system.crashLogs.noLogs': '未找到崩潰日誌。',
'settings.system.crashLogs.entries': '{count} 條記錄',
'settings.system.crashLogs.clear': '清除所有日誌',
'settings.system.crashLogs.cleared': '已清除 {count} 個日誌檔案。',
'settings.system.crashLogs.source': '來源',
'settings.system.crashLogs.time': '時間',
'settings.system.crashLogs.message': '訊息',
'settings.system.crashLogs.stack': '堆疊追蹤',
'settings.system.crashLogs.hint': '崩潰日誌保留 30 天,超期自動清理。',
'settings.system.crashLogs.collapse': '收起',
'settings.system.crashLogs.expand': '檢視詳情',
// Settings > System > Software Update
'settings.update.title': '軟體更新',
'settings.update.currentVersion': '目前版本',
'settings.update.checkForUpdates': '檢查更新',
'settings.update.checking': '檢查中...',
'settings.update.upToDate': '目前已是最新版本。',
'settings.update.available': '新版本 {version} 已釋出。',
'settings.update.download': '下載更新',
'settings.update.downloading': '正在下載... {percent}%',
'settings.update.readyToInstall': '更新已下載,準備安裝。',
'settings.update.restartNow': '重啟並更新',
'settings.update.error': '檢查更新失敗。',
'settings.update.downloadError': '下載失敗。',
'settings.update.manualDownload': '前往 GitHub 下載',
'settings.update.manualDownloadHint': '目前平台不支援自動更新,請前往 GitHub 下載最新版本。',
'settings.update.hint': 'Netcatty 從 GitHub Releases 檢查更新。',
'settings.update.lastCheckedJustNow': '剛剛',
'settings.update.lastCheckedMinutesAgo': '{n} 分鐘前',
'settings.update.lastCheckedHoursAgo': '{n} 小時前',
'settings.update.lastCheckedPrefix': '上次檢查:',
'settings.update.autoUpdateEnabled': '自動更新',
'settings.update.autoUpdateEnabledDesc': '有新版本時自動檢查並下載更新。',
// Settings > Session Restore
'settings.sessionRestore.title': '工作階段恢復',
'settings.sessionRestore.restorePreviousSession': '恢復上次的標籤頁和工作區',
'settings.sessionRestore.restorePreviousSessionDesc': '啟動時重新開啟上次的終端標籤頁和工作區佈局。',
'settings.sessionRestore.restoreTerminalCwd': '恢復終端工作目錄',
'settings.sessionRestore.restoreTerminalCwdDesc': '重新連線恢復的終端時,嘗試回到上次記錄的目錄;失敗時不會阻塞連線。',
'terminal.restore.placeholder.title': '已恢復的工作階段',
'terminal.restore.placeholder.desc': '此終端目前已斷開。準備好後可以重新連線,為這個標籤頁啟動新的 shell。',
'terminal.restore.placeholder.reconnect': '重新連線',
'terminal.restore.cwdLog': '正在恢復工作目錄:{cwd}',
// Settings > Session Logs
'settings.sessionLogs.title': '工作階段日誌',
'settings.sessionLogs.description': '設定工作階段日誌匯出和自動儲存設定。',
'settings.sessionLogs.autoSave': '自動儲存',
'settings.sessionLogs.enableAutoSave': '啟用自動儲存',
'settings.sessionLogs.enableAutoSaveDesc': '在終端工作階段結束時自動儲存工作階段日誌。',
'settings.sessionLogs.directory': '儲存目錄',
'settings.sessionLogs.noDirectory': '未選擇目錄',
'settings.sessionLogs.browse': '瀏覽',
'settings.sessionLogs.openFolder': '開啟資料夾',
'settings.sessionLogs.directoryHint': '日誌將按主機名組織在子目錄中。',
'settings.sessionLogs.format': '日誌格式',
'settings.sessionLogs.formatDesc': '選擇儲存日誌檔案的格式。',
'settings.sessionLogs.formatTxt': '純文字 (.txt)',
'settings.sessionLogs.formatRaw': '原始格式 (.log)',
'settings.sessionLogs.formatHtml': 'HTML (.html)',
'settings.sessionLogs.timestamps': '新增時間戳',
'settings.sessionLogs.timestampsDesc': '為純文字和 HTML 日誌的每一行新增本機時間。',
'settings.sessionLogs.hint': '工作階段日誌用於記錄終端輸出,便於故障排查和審計。',
// Settings > SSH Debug Logs
'settings.sshDebugLogs.title': 'SSH 除錯日誌',
'settings.sshDebugLogs.enable': '啟用 SSH 除錯日誌',
'settings.sshDebugLogs.enableDesc': '記錄連線、認證、握手、斷開和錯誤原因,不記錄終端輸出。',
'settings.sshDebugLogs.location': '日誌位置',
'settings.sshDebugLogs.status': '狀態',
'settings.sshDebugLogs.statusOn': '已開啟',
'settings.sshDebugLogs.statusOff': '未開啟',
'settings.sshDebugLogs.size': '大小',
'settings.sshDebugLogs.hint': '開啟後,新發起的 SSH 連線會寫入診斷資訊,方便排查堡壘機、認證和異常斷開問題。',
// Settings > Global Hotkey (Quake Mode)
'settings.globalHotkey.title': '全域快捷鍵',
'settings.globalHotkey.toggleWindow': '切換視窗',
'settings.globalHotkey.toggleWindowDesc': '按下組合鍵以設定顯示/隱藏視窗的全域快捷鍵。',
'settings.globalHotkey.notSet': '未設定',
'settings.globalHotkey.reset': '恢復預設',
'settings.globalHotkey.closeToTray': '關閉時最小化到系統匣',
'settings.globalHotkey.closeToTrayDesc': '啟用後,關閉視窗將最小化到系統匣而不是結束程式。',
'settings.globalHotkey.enabled': '啟用全域快捷鍵',
'settings.globalHotkey.enabledDesc': '註冊系統級鍵盤快捷鍵。停用後將取消所有全域快捷鍵註冊。',
'settings.globalHotkey.hint': '全域快捷鍵在系統範圍內工作,可快速顯示或隱藏視窗(下拉式終端風格)。',
// Tray Panel
'tray.openMainWindow': '開啟主視窗',
'tray.sessions': '工作階段',
'tray.portForwarding': '埠轉發',
'tray.status.connected': '已連線',
'tray.status.connecting': '連線中',
'tray.status.disconnected': '已斷開',
'tray.status.active': '已啟用',
'tray.status.inactive': '未啟用',
'tray.status.error': '錯誤',
'tray.recentHosts': '最近連線的主機',
'tray.empty.title': '一切都很安靜',
'tray.empty.subtitle': '去連線個伺服器吧,它們想念你了 🚀',
'tray.quit': '結束 Netcatty',
// Vault Sidebar
'vault.sidebar.collapse': '收起側邊欄',
'vault.sidebar.expand': '展開側邊欄',
'vault.sidebar.resize': '調整側邊欄寬度',
// Settings > Application
'settings.application.checkUpdates': '檢查更新',
'settings.application.reportProblem': '反饋問題',
'settings.application.reportProblem.subtitle': '生成預填的 GitHub issue',
'settings.application.community': '社群',
'settings.application.community.subtitle': 'GitHub Discussions',
'settings.application.github': 'GitHub',
'settings.application.github.subtitle': '原始碼',
'settings.application.whatsNew': '更新內容',
'settings.application.whatsNew.subtitle': '檢視釋出說明',
'settings.application.openExternal.failedTitle': '無法開啟連結',
'settings.application.openExternal.failedBody': '系統瀏覽器和內建瀏覽器視窗都無法開啟該連結。',
'settings.vault.title': '主機庫',
'settings.vault.showRecentHosts': '顯示最近連線的主機',
'settings.vault.showRecentHostsDesc': '在主機列表頂部顯示最近連線過的主機',
'settings.vault.showOnlyUngroupedHostsInRoot': '根目錄只顯示未分組主機',
'settings.vault.showOnlyUngroupedHostsInRootDesc': '開啟後,主機庫根目錄的主機列表只顯示沒有分組的主機,已分組主機請從左側分組進入檢視。',
'settings.vault.showSftpTab': '顯示 SFTP 標籤頁',
'settings.vault.showSftpTabDesc': '在頂部標籤欄顯示獨立的 SFTP 檢視。關閉後可改用工作階段內左側的 SFTP 側欄。',
'settings.vault.showHostTreeSidebar': '顯示主機列表側欄',
'settings.vault.showHostTreeSidebarDesc': '在終端和編輯器標籤頁顯示主機列表側欄及頂部開關。',
// Update notifications
'update.available.title': '發現新版本',
'update.available.message': '新版本 {version} 已釋出,點選前往下載。',
'update.checking': '正在檢查更新...',
'update.upToDate.title': '已是最新版本',
'update.upToDate.message': '目前版本 ({version}) 已是最新。',
'update.error': '檢查更新失敗',
'update.downloadNow': '立即下載',
'update.viewInSettings': '在設定中檢視',
'update.readyToInstall.title': '更新已就緒',
'update.readyToInstall.message': '版本 {version} 已下載完成,準備安裝。',
'update.restartNow': '立即重啟',
'update.downloadFailed.title': '更新失敗',
'update.downloadFailed.message': '下載更新失敗,可前往 GitHub 手動下載。',
'update.needsSave.title': '有未儲存內容',
'update.needsSave.message': '請先儲存已開啟的編輯器,然後再次點選「立即重啟」以安裝更新。',
'update.openReleases': '開啟 Releases',
'update.remindLater': '稍後提醒',
'update.skipVersion': '跳過此版本',
// Settings > Appearance
'settings.appearance.uiTheme': '介面主題',
'settings.appearance.theme': '主題',
'settings.appearance.theme.desc': '選擇淺色、深色或跟隨系統設定',
'settings.appearance.theme.light': '淺色',
'settings.appearance.theme.dark': '深色',
'settings.appearance.theme.system': '系統',
'settings.appearance.accentColor': '強調色',
'settings.appearance.customColor': '自訂顏色',
'settings.appearance.accentColor.mode': '使用自訂強調色',
'settings.appearance.accentColor.mode.desc': '覆蓋主題自帶的強調色',
'settings.appearance.accentColor.custom': '自訂強調色',
'settings.appearance.themeColor': '主題色',
'settings.appearance.themeColor.desc': '為淺色與深色主題選擇預設配色',
'settings.appearance.themeColor.light': '淺色主題',
'settings.appearance.themeColor.dark': '深色主題',
'settings.appearance.customCss': '自訂 CSS',
'settings.appearance.customCss.desc':
'使用自訂 CSS 個人化介面,修改會立即生效。主要 UI 區塊都公開了 [data-section="..."] 屬性供你定位,比如:snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebar(Focus 模式終端列表)、terminal-host-tree-sidebar、terminal-host-tree-sidebar-content、terminal-host-tree-sidebar-row、terminal-side-panel(SFTP/指令碼/主題/AI 側欄,開啟時生效)、terminal-side-panel-tabs、terminal-side-panel-content、terminal-sftp-panel、terminal-sftp-host-header、terminal-sftp-pane、terminal-sftp-toolbar、terminal-sftp-path、terminal-sftp-filter-bar、terminal-sftp-list、terminal-sftp-list-header、terminal-sftp-list-row、terminal-sftp-tree、terminal-sftp-tree-row、terminal-sftp-transfer-queue、terminal-sftp-transfer-row、terminal-split-pane、terminal-split-resizer、top-tabs、top-tabs-host-tree-toggle、top-tabs-quick-switcher-toggle。',
'settings.appearance.customCss.placeholder':
'/* 範例 — 由於 Tailwind 優先順序較高,需要使用 !important */\n\n/* 隱藏頂部標籤欄裡的主機列表開關 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隱藏開啟快速切換器的加號按鈕 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作側欄邊框(關閉側欄後不會殘留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整個操作側欄背景,而不只是頂部標籤 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改選取的 SFTP 檔案行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分割分割線 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 標示目前聚焦的分割 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 設定 → 終端 → 工作區聚焦指示 → 聚焦窗格顯示邊框 */',
'settings.appearance.language': '語言',
'settings.appearance.language.desc': '選擇介面語言',
'settings.appearance.uiFont': '介面字型',
'settings.appearance.uiFont.desc': '選擇軟體介面使用的字型',
'settings.appearance.windowOpacity': '視窗透明度',
'settings.appearance.windowOpacity.desc': '調節整個應用程式視窗的透明度,方便疊在其他內容上方。較低時終端文字也會變淡;部分 Linux 桌面環境可能不支援。',
'settings.appearance.appIcon': '應用程式圖示',
'settings.appearance.appIcon.desc': '選擇 Dock/工作列圖示風格,切換應用程式時更容易找到 Netcatty。執行時生效,安裝包內的圖示不會改變。',
'settings.appearance.appIcon.original': '原始',
'settings.appearance.appIcon.bright': '明亮',
'settings.appearance.appIcon.dark': '深色',
'settings.appearance.appIcon.colorful': '彩色',
'settings.appearance.appIcon.highContrast': '高對比',
'settings.appearance.appIcon.group.classic': '彩色底',
'settings.appearance.appIcon.group.white': '白底',
'settings.appearance.appIcon.group.special': '隱藏款',
'settings.appearance.appIcon.whiteNavy': '白底 · 海軍藍貓',
'settings.appearance.appIcon.whiteSky': '白底 · 天藍貓',
'settings.appearance.appIcon.whiteRose': '白底 · 玫紅貓',
'settings.appearance.appIcon.whiteEmerald': '白底 · 翡翠貓',
'settings.appearance.appIcon.whiteAmber': '白底 · 琥珀貓',
'settings.appearance.appIcon.whiteViolet': '白底 · 紫羅蘭貓',
'settings.appearance.appIcon.rainbow': '彩虹貓',
// Context menus / common actions
'action.newHost': '新建主機',
'action.newSubfolder': '新建資料夾',
'action.copyPublicKey': '複製公鑰',
'action.keyExport': '匯出金鑰',
'action.edit': '編輯',
'action.delete': '刪除',
'action.remove': '移除',
'action.convertToHost': '轉換為主機',
// Sync
'sync.cloudSync': '雲同步',
'sync.settings': '同步設定',
'sync.active': '雲同步已啟用',
'sync.syncing': '正在同步…',
'sync.error': '同步錯誤',
'sync.notConfigured': '未設定',
'sync.failed': '同步失敗',
'sync.connected': '已連線',
'sync.syncNow': '立即同步',
'sync.recentActivity': '最近活動',
'sync.pending': '有待同步的變更',
'sync.history.uploaded': '已 Upload',
'sync.history.downloaded': '已 Download',
'sync.history.resolved': '已處理',
'sync.toast.completedMessage': '同步完成',
'sync.toast.errorTitle': '同步錯誤',
'sync.autoSync.failedTitle': '同步失敗',
'sync.autoSync.inspectFailedTitle': '同步已暫停',
'sync.autoSync.inspectFailedMessage': '無法存取雲端以檢查變更。資料改動或下次啟動時會自動重試。',
'sync.autoSync.syncedTitle': '已從雲端同步',
'sync.autoSync.syncedMessage': '你的資料已從雲端更新。',
'sync.autoSync.noProvider': '未連線雲同步 provider。請開啟 設定 → Sync & Cloud 進行連線。',
'sync.autoSync.alreadySyncing': '同步正在進行中。',
'sync.autoSync.restoreInProgress': '另一個視窗中的本機備份恢復正在進行中,請等待其完成。',
'sync.autoSync.interruptedApplyTitle': '同步已暫停 — 上次恢復未完成',
'sync.autoSync.interruptedApplyMessage': '上次本機恢復過程未正常結束,本機資料可能處於半套用狀態。請開啟「設定 → Sync & Cloud → 恢復」,從保護性備份中恢復後再讓自動同步繼續。',
'sync.autoSync.vaultLocked': 'Vault 處於鎖定狀態。請開啟 設定 → Sync & Cloud 解鎖。',
'sync.autoSync.conflictDetected': '偵測到同步衝突。請開啟 設定 → Sync & Cloud 處理。',
'sync.autoSync.syncFailed': '同步失敗',
'sync.autoSync.restoredTitle': '已恢復',
'sync.autoSync.restoredMessage': '已從雲端恢復主機庫資料。',
'sync.autoSync.keptLocalTitle': '已保留本機資料',
'sync.autoSync.keptLocalMessage': '保留了空的本機主機庫,未套用雲端資料。',
'sync.autoSync.emptyVaultConflict.title': '偵測到空主機庫',
'sync.autoSync.emptyVaultConflict.description': '本機主機庫為空,但云端有資料。這通常發生在應用程式更新或儲存重置之後。請選擇如何處理:',
'sync.autoSync.emptyVaultConflict.cloudLabel': '雲端',
'sync.autoSync.emptyVaultConflict.restore': '從雲端恢復',
'sync.autoSync.emptyVaultConflict.restoreDesc': '推薦 — 從雲端備份恢復主機、金鑰和程式碼片段',
'sync.autoSync.emptyVaultConflict.keepEmpty': '保持為空',
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': '從頭開始,使用空的主機庫',
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} 台主機,{keys} 個金鑰,{snippets} 個程式碼片段,{notes} 條筆記,{proxyProfiles} 個代理',
'sync.autoSync.emptyVaultManual': '無法同步:本機 vault 為空。請先從本機備份恢復,或在同步面板裡使用"強制推送"。',
'sync.blocked.title': '同步已暫停',
'sync.blocked.reason.bulkShrink': '即將從雲端刪除 {baseCount} 條 {entityType} 中的 {lost} 條(縮減 {percent}%)。',
'sync.blocked.reason.largeShrink': '即將從雲端刪除 {lost} 條 {entityType}。',
'sync.blocked.detail': '通常是本機狀態異常(鑰匙串故障、資料載入不全)導致。請從本機備份恢復,如果確實要刪這些條目請使用強制推送。',
'sync.blocked.restoreButton': '從本機備份恢復',
'sync.blocked.forcePushButton': '強制推送',
'sync.forcePush.title': '確認強制推送',
'sync.forcePush.body': '你將從雲端移除 {lost} 條 {entityType},此操作不可復原。繼續?',
'sync.forcePush.confirm': '確認推送',
'sync.forcePush.cancel': '取消',
'sync.entityType.hosts': '主機',
'sync.entityType.keys': '金鑰',
'sync.entityType.identities': '身份',
'sync.entityType.proxyProfiles': '代理設定',
'sync.entityType.snippets': '程式碼片段',
'sync.entityType.customGroups': '分組',
'sync.entityType.snippetPackages': '片段包',
'sync.entityType.knownHosts': '主機金鑰記錄',
'sync.entityType.portForwardingRules': '埠轉發規則',
'sync.entityType.groupConfigs': '分組設定',
'sync.credentialsUnavailable': '目前裝置無法解密部分已儲存憑證。請先在本機重新輸入憑證後再同步。',
'time.never': '從未',
'time.justNow': '剛剛',
'time.minutesAgo': '{minutes} 分鐘前',
// Vault navigation
'vault.nav.hosts': '主機',
'vault.nav.keychain': '鑰匙串',
'vault.nav.proxies': '代理',
'vault.nav.portForwarding': '埠轉發',
'vault.nav.snippets': '程式碼片段',
'vault.nav.notes': '筆記',
'vault.nav.knownHosts': '已知主機',
'vault.nav.logs': '日誌',
'proxyProfiles.action.add': '新增代理',
'proxyProfiles.search.placeholder': '搜尋代理…',
'proxyProfiles.section.proxies': '代理',
'proxyProfiles.count.items': '{count} 項',
'proxyProfiles.empty.title': '暫無代理',
'proxyProfiles.empty.desc': '建立可重複使用的 HTTP、SOCKS5 或指令代理,然後在主機詳情裡選擇。',
'proxyProfiles.usage': '已關聯 {count} 處',
'proxyProfiles.copyName': '{name} 副本',
'proxyProfiles.panel.newTitle': '新建代理',
'proxyProfiles.field.name': '代理名稱',
'proxyProfiles.error.required': '名稱和代理詳情不能為空。',
'proxyProfiles.error.port': '埠必須在 1 到 65535 之間。',
'proxyProfiles.viewMode': '代理顯示方式',
'proxyProfiles.delete.title': '刪除代理?',
'proxyProfiles.delete.desc': '刪除 "{name}" 會同時從 {count} 個主機或分組設定中解除關聯。',
'vault.groups.title': '分組',
'vault.groups.total': '共 {count} 個',
'vault.groups.hostsCount': '{count} 台主機',
'vault.groups.newSubgroup': '新建子分組',
'vault.groups.rename': '重新命名分組',
'vault.groups.unnamed': '未命名分組',
'vault.groups.delete': '刪除分組',
'vault.groups.createSubfolder': '建立子分組',
'vault.groups.createRoot': '建立根分組',
'vault.groups.createDialog.desc': '建立新的分組用於組織主機。',
'vault.groups.renameDialogTitle': '重新命名分組',
'vault.groups.renameDialog.desc': '重新命名已有分組。',
'vault.groups.deleteDialogTitle': '刪除分組',
'vault.groups.deleteDialog.desc': '這將永久刪除該分組並將所有主機移動到根級別。',
'vault.groups.deleteDialog.managedDesc': '這是一個託管的 SSH config 分組。刪除後將同時刪除所有主機並斷開與原始檔的連線。',
'vault.groups.deleteDialog.deleteHosts': '同時刪除該分組下的所有主機',
'vault.groups.ungrouped': '未分組',
'vault.groups.field.name': '分組名稱',
'vault.groups.placeholder.example': '例如:Production',
'vault.groups.parentLabel': '父級',
'vault.groups.pathLabel': '路徑',
'vault.groups.settings': '分組設定',
'vault.groups.details': '分組詳情',
'vault.groups.details.general': '常規',
'vault.groups.details.ssh': 'SSH',
'vault.groups.details.telnet': 'Telnet',
'vault.groups.details.advanced': '高階',
'vault.groups.details.appearance': '外觀',
'vault.groups.details.mosh': 'Mosh',
'vault.groups.details.parentGroup': '父分組',
'vault.groups.details.none': '無',
'vault.groups.details.inherited': '繼承自分組',
'vault.groups.details.addProtocol': '新增協定',
'vault.groups.details.removeProtocol': '移除協定',
'vault.groups.details.fontFamily': '字型',
'vault.groups.details.fontSize': '字號',
'vault.groups.errors.required': '分組名稱不能為空。',
'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} 個線上',
// Vault hosts header/actions
'vault.hosts.search.placeholder': '查詢主機或 ssh user@hostname / ssh -p 2222 user@hostname…',
'vault.hosts.connect': '連線',
'vault.view.grid': '網格',
'vault.view.list': '列表',
'vault.view.tree': '樹形',
'vault.tree.expandAll': '展開全部',
'vault.tree.collapseAll': '摺疊全部',
'vault.hosts.newHost': '新建主機',
'vault.hosts.newGroup': '新建分組',
'vault.hosts.import': '匯入',
'vault.hosts.export': '匯出',
'vault.hosts.export.toast.success': '已匯出 {count} 個主機到 CSV',
'vault.hosts.export.toast.successWithSkipped': '已匯出 {count} 個主機到 CSV(跳過 {skipped} 個不支援的主機)',
'vault.hosts.export.toast.noHosts': '沒有主機可匯出',
'vault.hosts.allHosts': '全部主機',
'vault.hosts.pinned': '已置頂',
'vault.hosts.recentlyConnected': '最近連線',
'vault.hosts.pinToTop': '置頂',
'vault.hosts.unpin': '取消置頂',
'vault.hosts.copyCredentials': '複製賬密資訊',
'vault.hosts.copyCredentials.toast.success': '賬密資訊已複製到剪貼簿',
'vault.hosts.copyCredentials.toast.noPassword': '該主機未儲存密碼',
'vault.hosts.multiSelect': '多選',
'vault.hosts.selected': '已選擇 {count} 項',
'vault.hosts.selectAll': '全選',
'vault.hosts.deselectAll': '取消全選',
'vault.hosts.deleteSelected': '刪除 ({count})',
'vault.hosts.deleteMultiple.success': '已刪除 {count} 個主機',
'vault.hosts.connectSelected': '連線 ({count})',
'vault.hosts.connectMultiple.success': '正在連線 {count} 個主機',
'vault.hosts.moveToGroup.success': '已將 {host} 移動到 {group}',
'vault.hosts.errors.nameRequired': '主機名稱不能為空。',
'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',
'vault.import.desc': '從常見工具遷移連線資訊。選擇一種格式開始匯入。',
'vault.import.chooseFormat': '選擇檔案格式',
'vault.import.csv.tip': '批次匯入:可使用 CSV 模板填寫後匯入。',
'vault.import.csv.downloadTemplate': '下載 CSV 模板',
'vault.import.toast.start': '正在從 {format} 匯入...',
'vault.import.toast.completedTitle': '匯入完成',
'vault.import.toast.failedTitle': '匯入失敗',
'vault.import.toast.noEntries': '{format} 檔案中沒有可匯入的條目。',
'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': '僅匯入',
'vault.import.sshConfig.importOnlyDesc': '一次性匯入,修改不會同步回檔案。',
'vault.import.sshConfig.managed': '託管同步',
'vault.import.sshConfig.managedDesc': '保持同步,修改會自動儲存回檔案。',
'vault.import.sshConfig.managedGroup': 'ssh config',
'vault.import.sshConfig.managedSuccess': '已匯入 {count} 個主機,檔案已託管。',
'vault.import.sshConfig.alreadyManaged': '該檔案已被託管。',
'vault.import.sshConfig.alreadyManagedDesc': '該檔案已在分組 "{group}" 下託管。如需重新匯入,請先移除現有的託管源。',
'vault.import.sshConfig.noFilePath': '無法託管此檔案。',
'vault.import.sshConfig.noFilePathDesc': '無法確定檔案路徑。託管同步需要存取檔案系統。',
// Known Hosts
'knownHosts.search.placeholder': '搜尋已知主機...',
'knownHosts.action.scanSystem': '掃描系統',
'knownHosts.action.importFile': '匯入檔案',
'knownHosts.action.browseFile': '瀏覽檔案',
'knownHosts.empty.title': '暫無已知主機',
'knownHosts.empty.desc':
'Known Hosts 是你之前連線過的 SSH server。匯入系統的 known_hosts 檔案以開始。',
'knownHosts.results.showingLimited': '顯示 {shown}/{total} 個主機。使用搜索查詢特定主機。',
'knownHosts.toast.scanUnavailable': '目前平台無法掃描系統 known_hosts。',
'knownHosts.toast.scanNoFile': '未找到系統 known_hosts 檔案。',
'knownHosts.toast.scanNoEntries': 'known_hosts 中沒有可用條目。',
'knownHosts.toast.scanImported': '已匯入 {count} 個新主機。',
'knownHosts.toast.scanNoNew': '沒有發現新的主機。',
'knownHosts.toast.scanFailed': '掃描系統 known_hosts 失敗。',
// Port Forwarding
'pf.empty.title': '設定埠轉發規則',
'pf.empty.desc': '儲存埠轉發規則,用於存取資料庫、Web 應用程式等服務。',
'pf.title': '埠轉發規則',
'pf.rulesCount': '{count} 條規則',
'pf.wizard.editTitle': '編輯埠轉發規則',
'pf.wizard.newTitle': '新建埠轉發規則',
'pf.wizard.saveChanges': '儲存修改',
'pf.wizard.done': '完成',
'pf.wizard.continue': '繼續',
'pf.wizard.cancel': '取消',
'pf.wizard.skipWizard': '跳過精靈',
'pf.error.hostNotFound': '未找到主機',
'pf.toast.titleWithLabel': '埠轉發規則: {label}',
'pf.type.local': '本機轉發',
'pf.type.remote': '遠端轉發',
'pf.type.dynamic': '動態轉發',
'pf.type.menu.local': '本機轉發',
'pf.type.menu.remote': '遠端轉發',
'pf.type.menu.dynamic': '動態轉發',
'pf.type.local.desc': '本機轉發讓你像存取本機一樣存取遠端服務埠。',
'pf.type.remote.desc': '遠端轉發在遠端開啟埠,並將連線轉發到本機(目前)主機。',
'pf.type.dynamic.desc': '動態轉發將 Netcatty 作為 SOCKS 代理使用。',
'pf.wizard.type.title': '選擇埠轉發型別:',
'pf.wizard.localConfig.title': '設定本機埠與繫結地址:',
'pf.wizard.localConfig.desc': '該埠會在本機(目前裝置)開啟,並接收流量。',
'pf.wizard.localConfig.localPort': '本機埠 *',
'pf.wizard.bindAddress': '繫結地址',
'pf.wizard.remoteHost.title': '選擇遠端主機:',
'pf.wizard.remoteHost.desc': '選擇要開啟埠的遠端主機。該埠的流量將轉發到目標地址。',
'pf.wizard.remoteConfig.title': '設定埠與繫結地址:',
'pf.wizard.remoteConfig.desc': '將從所選主機的指定埠與網路卡地址轉發流量。',
'pf.wizard.remoteConfig.remotePort': '遠端埠 *',
'pf.wizard.destination.title': '設定目標地址:',
'pf.wizard.destination.desc.local': '輸入你希望透過 tunnel 存取的遠端目標地址。',
'pf.wizard.destination.desc.remote': '要轉發流量到的目標地址與埠。',
'pf.wizard.destination.address': '目標地址 *',
'pf.wizard.destination.addressPlaceholder': '例如:127.0.0.1 或 192.168.1.100',
'pf.wizard.destination.port': '目標埠 *',
'pf.wizard.sshServer.title': '選擇 SSH server:',
'pf.wizard.sshServer.desc.dynamic': '選擇作為 SOCKS proxy 的 SSH server。',
'pf.wizard.sshServer.desc.default': '選擇用於將流量 tunnel 到目標地址的 SSH server。',
'pf.wizard.label.title': '設定 Label:',
'pf.wizard.label.placeholder.dynamic': '例如:SOCKS Proxy',
'pf.wizard.label.placeholder.default': '例如:MySQL Production',
'pf.wizard.label.placeholder.remoteRule': '例如:Remote Rule',
'pf.wizard.placeholders.portExample': '例如:{port}',
// SFTP
'sftp.newFolder': '新建資料夾',
'sftp.newFile': '新建檔案',
'sftp.filter': '篩選',
'sftp.filter.placeholder': '按檔名篩選...',
'sftp.bookmark.add': '收藏此路徑',
'sftp.bookmark.remove': '取消收藏',
'sftp.bookmark.list': '收藏路徑',
'sftp.bookmark.addGlobal': '+全域',
'sftp.bookmark.addGlobalTooltip': '儲存為全域收藏(所有主機共享)',
'sftp.bookmark.empty': '暫無收藏路徑',
'sftp.columns.name': '名稱',
'sftp.columns.configure': '選擇顯示的欄位',
'sftp.columns.modified': '修改時間',
'sftp.columns.size': '大小',
'sftp.columns.kind': '型別',
'sftp.columns.actions': '操作',
'sftp.emptyDirectory': '空目錄',
'sftp.nav.up': '返回上層',
'sftp.nav.home': '返回主目錄',
'sftp.nav.refresh': '重新整理',
'sftp.upload': '上傳',
'sftp.uploadFiles': '上傳檔案',
'sftp.uploadFolder': '上傳資料夾',
'sftp.dragDropToUpload': '拖拽檔案到這裡上傳',
'sftp.retry': '重試',
'sftp.context.open': '開啟',
'sftp.context.navigateTo': '跳轉到這裡',
'sftp.context.moveTo': '移動到...',
'sftp.context.moveToParent': '移動到上級目錄',
'sftp.moveTo.title': '移動到目錄',
'sftp.moveTo.placeholder': '輸入目標目錄路徑',
'sftp.moveTo.confirm': '移動',
'sftp.moveTo.pathNotFound': '目錄不存在或無法存取',
'sftp.context.download': '下載',
'sftp.context.copyToOtherPane': '複製到另一側',
'sftp.copyToOtherPane.unavailable': '請開啟雙欄 SFTP 檔案管理,並連線目標側後再複製檔案。',
'sftp.copyCurrentPath': '複製目前路徑',
'sftp.copyCurrentPath.success': '已複製目前路徑',
'sftp.copyCurrentPath.error': '無法複製目前路徑',
'sftp.viewMode.label': '檢視模式',
'sftp.viewMode.list': '列表檢視',
'sftp.viewMode.tree': '樹形檢視',
'sftp.viewMode.switchToList': '切換到列表檢視',
'sftp.viewMode.switchToTree': '切換到樹形檢視',
'sftp.tree.loadError': '載入目錄失敗',
'sftp.tree.loading': '載入中...',
'sftp.kind.folder': '資料夾',
'sftp.context.rename': '重新命名',
'sftp.context.permissions': '權限',
'sftp.context.delete': '刪除',
'sftp.context.refresh': '重新整理',
'sftp.context.uploadFiles': '上傳檔案...',
'sftp.context.uploadFilesHere': '上傳檔案到這裡...',
'sftp.context.uploadFolder': '上傳資料夾...',
'sftp.context.uploadFolderHere': '上傳資料夾到這裡...',
'sftp.context.downloadSelected': '下載選取項({count})',
'sftp.context.deleteSelected': '刪除選取項({count})',
'sftp.dropFilesHere': '拖拽檔案到這裡',
'sftp.itemsCount': '{count} 個專案',
'sftp.selectedCount': '已選 {count} 個',
'sftp.path.doubleClickToEdit': '雙擊編輯路徑',
'sftp.showHiddenPaths': '隱藏的路徑',
'sftp.task.waiting': '等待中...',
'sftp.transfer.preparing': '準備中...',
'sftp.status.loading': '載入中...',
'sftp.status.uploading': '上傳中...',
'sftp.status.ready': '就緒',
'sftp.transfers': '傳輸',
'sftp.transfers.active': '{count} 個進行中',
'sftp.transfers.clearCompleted': '清除已完成',
'sftp.transfers.calculatingTotal': '正在統計總大小...',
'sftp.transfers.filesCount': '{count} 個檔案',
'sftp.transfers.filesProgress': '{current}/{total} 個檔案',
'sftp.transfers.expandChildren': '展開檔案',
'sftp.transfers.collapseChildren': '收起檔案',
'sftp.transfers.expandChildList': '展開詳情',
'sftp.transfers.collapseChildList': '收起',
'sftp.transfers.retryAction': '重試',
'sftp.transfers.dismissAction': '移除',
'sftp.transfers.openTargetFolder': '開啟目標目錄',
'sftp.transfers.openTargetFolderError': '無法開啟目標目錄',
'sftp.transfers.copyTargetPath': '複製目標路徑',
'sftp.transfers.copyTargetPathSuccess': '已複製目標路徑',
'sftp.transfers.copyTargetPathError': '無法複製目標路徑',
'sftp.transfers.resizeNameColumn': '調整檔名列寬',
'sftp.transfers.dragToResize': '拖拽調整高度',
'sftp.goUp': '上一級',
'sftp.goToTerminalCwd': '定位到終端目前目錄',
'sftp.followTerminalCwd': '追隨終端目錄',
'sftp.followTerminalCwd.enable': '開啟追隨終端目錄',
'sftp.followTerminalCwd.disable': '關閉追隨終端目錄',
'sftp.encoding.label': '檔名編碼',
'sftp.encoding.auto': '自動',
'sftp.encoding.utf8': 'UTF-8',
'sftp.encoding.gb18030': 'GB18030',
'sftp.goHome': '返回主目錄',
'sftp.folderName': '資料夾名稱',
'sftp.folderName.placeholder': '輸入資料夾名稱',
'sftp.fileName': '檔名稱',
'sftp.fileName.placeholder': '輸入檔名稱',
'sftp.prompt.newFolderName': '新建資料夾名稱?',
'sftp.rename.title': '重新命名',
'sftp.rename.newName': '新名稱',
'sftp.rename.placeholder': '輸入新名稱',
'sftp.confirm.deleteOne': '刪除 "{name}"?',
'sftp.deleteConfirm.single': '刪除 "{name}"?',
'sftp.deleteConfirm.title': '刪除 {count} 個專案?',
'sftp.deleteConfirm.desc': '此操作不可復原,將刪除以下內容:',
'sftp.deleteConfirm.descSingle': '此操作不可復原。',
'sftp.deleteConfirm.host': '主機',
'sftp.deleteConfirm.path': '路徑',
'sftp.error.loadFailed': '載入目錄失敗',
'sftp.error.downloadFailed': '下載失敗',
'sftp.error.uploadFailed': '上傳失敗',
'sftp.error.deleteFailed': '刪除失敗',
'sftp.error.createFolderFailed': '建立資料夾失敗',
'sftp.error.createFileFailed': '建立檔案失敗',
'sftp.error.invalidFileName': '檔名包含非法字元:{chars}',
'sftp.error.reservedName': '此檔名是系統保留名稱',
'sftp.overwrite.title': '檔案已存在',
'sftp.overwrite.desc': '名為"{name}"的檔案已存在。是否要替換它?',
'sftp.overwrite.confirm': '替換',
'sftp.error.renameFailed': '重新命名失敗',
'sftp.picker.title': '選擇主機',
'sftp.picker.desc': '為{side}窗格選擇主機',
'sftp.picker.searchPlaceholder': '搜尋主機...',
'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': '所有者',
'sftp.permissions.group': '群組',
'sftp.permissions.others': '其他',
'sftp.permissions.octal': '八進位制',
'sftp.permissions.symbolic': '符號',
'sftp.permissions.success': '權限已更新',
'sftp.permissions.failed': '權限更新失敗',
// Quick Switcher
'qs.search.placeholder': '搜尋主機或標籤頁',
'qs.jumpTo': '跳轉到',
'qs.localTerminal': '本機終端',
'qs.localShells': '本機 Shell',
'qs.default': '預設',
};
+128
View File
@@ -0,0 +1,128 @@
export const zhTWScriptsMessages = {
'scripts.meta.name': '名稱',
'scripts.meta.language': '語言',
'scripts.meta.description': '描述',
'scripts.meta.descriptionPlaceholder': '可選,指令碼的簡要說明',
'scripts.meta.trigger': '觸發方式',
'scripts.meta.triggerPattern': '輸出比對(正規表示式)',
'scripts.meta.code': '指令碼程式碼',
'scripts.trigger.manual': '手動執行',
'scripts.trigger.onConnect': '連線時執行',
'scripts.trigger.onOutput': '輸出比對時執行',
'scripts.trigger.onOutputHint': '僅在伺服器端輸出比對到正規表示式時觸發(會忽略本機按鍵回顯)。未設定目標主機時,對目前已連線的工作階段生效;設定目標後僅監聽對應主機。進入備用畫面的應用程式(如 vim、htop)期間不會觸發。本 session 有其他指令碼執行期間會抑制;指令碼結束後再檢查 buffer。流程內等待一般文字用 nct.screen.waitForText,正規表示式用 nct.screen.waitForRegex。',
'scripts.actions.save': '儲存',
'scripts.actions.runNow': '立即執行',
'scripts.actions.openEditor': '彈出視窗編輯',
'scripts.actions.openEditorHint': '在更大的視窗中編輯指令碼',
'scripts.editor.modalTitle': '指令碼編輯器',
'scripts.editor.modalSubtitle': '在更大的工作區中編輯指令碼的中繼資料與程式碼。',
'scripts.editor.lineCount': '{count} 行',
'scripts.editor.resize': '調整編輯器高度',
'scripts.targets.hint': '自動觸發會作用於所選主機;勾選「對所有主機生效」則作用於全部可連線的主機。',
'scripts.targets.connectOrderHint': '連線指令碼的執行順序請在主機詳情 → 自動化 中調整。',
'scripts.actions.runNowHint': '在所選目標主機上執行;勾選「對所有主機生效」時在所有可連線的主機上執行。',
'scripts.actions.runParallel': '在所有標籤頁並行執行',
'scripts.actions.runSequential': '在所有標籤頁依序執行',
'scripts.actions.skippedConnectingSessions': '有 {count} 個標籤仍在連線中,已跳過',
'scripts.actions.noRunnableHosts': '沒有與指令碼目標相符的可連線主機',
'scripts.sidePanel.library': '指令碼庫',
'scripts.sidePanel.running': '執行中',
'scripts.sidePanel.newScript': '新建指令碼',
'scripts.running.empty': '目前工作階段沒有正在執行的指令碼。',
'scripts.running.unnamed': '未命名指令碼',
'scripts.running.status.running': '執行中',
'scripts.running.status.paused': '已暫停',
'scripts.running.status.completed': '已完成',
'scripts.running.status.failed': '失敗',
'scripts.running.waitingFor': '等待 {pattern}',
'scripts.running.waitingForLabel': '等待',
'scripts.running.waitingForShellPrompt': 'Shell 提示字元(# 或 $)',
'scripts.running.lastSent': '已傳送:{command}',
'scripts.recording.start': '開始錄製',
'scripts.recording.active': '停止錄製',
'scripts.recording.startHint': '在目前終端工作階段中錄製操作,自動產生 nct 指令碼程式碼',
'scripts.recording.unavailableHint': '此入口不支援錄製,請用終端右側的指令碼側欄',
'scripts.recording.activeHint': '正在錄製目前終端。在終端中正常輸入指令;點「停止錄製」或終端工具列的 REC 結束並儲存。',
'scripts.recording.started': '已開始錄製,請在終端中操作',
'scripts.recording.noSession': '請先連線終端(單一標籤或工作區皆可)',
'scripts.recording.alreadyActive': '已有其他終端正在錄製',
'scripts.recording.stop': '停止錄製',
'scripts.recording.pause': '暫停錄製',
'scripts.recording.resume': '繼續錄製',
'scripts.recording.saveTitle': '儲存錄製的指令碼',
'scripts.recording.namePlaceholder': '指令碼名稱',
'scripts.recording.packagePlaceholder': '儲存到資料夾',
'scripts.recording.rootPackage': '根目錄',
'scripts.recording.save': '儲存',
'scripts.recording.saveAndEdit': '儲存並編輯',
'scripts.recording.helpTitle': '如何錄製指令碼',
'scripts.recording.helpIntro': '錄製會把你在終端裡的操作自動轉成可重複執行的自動化指令碼,適合部署、巡檢等重複性任務。',
'scripts.recording.helpStep1': '連線主機。單一終端標籤或工作區裡的終端都可以,開啟右側的指令碼側欄即可。',
'scripts.recording.helpStep2': '點選「開始錄製」。終端工具列出現紅色 REC 表示已開始。',
'scripts.recording.helpStep3': '在終端裡像平常一樣輸入指令,按 Enter 執行。每次按 Enter 都會記為一步。',
'scripts.recording.helpStep4': '全部操作完成後,點選「停止錄製」,或點終端工具列 REC 旁的停止按鈕。',
'scripts.recording.helpStep5': '填寫指令碼名稱並儲存。需要微調時可選「儲存並編輯」,在指令碼編輯器裡修改產生的程式碼。',
'scripts.recording.helpTipsTitle': '小提示',
'scripts.recording.helpTip1': '兩次操作之間停頓超過 1 秒,會自動加入等待時間,避免回放太快。',
'scripts.recording.helpTip2': '每條指令後會等待 shell 提示字元(如 $ 或 #)出現,再記錄下一步,減少亂序。',
'scripts.recording.helpTip3': '密碼輸入會被標記為敏感內容,不會以明文寫入指令碼。',
'scripts.recording.helpTip4': 'vim、互動式選單等複雜操作,建議先錄主幹指令,再在編輯器裡手動補充。',
'scripts.dialog.title': '指令碼',
'scripts.dialog.ok': '確定',
'scripts.dialog.required': '必填',
'scripts.dialog.numberInvalid': '請輸入有效數字',
'scripts.dialog.numberMin': '不能小於 {min}',
'scripts.dialog.numberMax': '不能大於 {max}',
'scripts.dialog.numberStep': '步進必須為 {step} 的倍數',
'scripts.dialog.waitForTimeoutTitle': '等待逾時',
'scripts.dialog.retry': '重試',
'scripts.dialog.skip': '跳過',
'scripts.dialog.abort': '終止',
'scripts.running.stepProgress': '步驟 {current} / {total}',
'scripts.running.determinateProgress': '{label} {current}/{total}',
'scripts.running.progressFallback': '進度',
'scripts.running.operationsCount': '已執行 {count} 個操作',
'scripts.running.opsPrefix': '已執行 ',
'scripts.running.opsSuffix': ' 個操作',
'scripts.running.elapsedLabel': '耗時',
'scripts.running.lastSentLabel': '已傳送:',
'scripts.running.elapsed': '耗時 {elapsed}',
'scripts.running.completedSummary': '已完成 · 共 {count} 個操作 · {elapsed}',
'scripts.running.dismissHint': '點選關閉',
'scripts.running.dismiss': '關閉',
'scripts.running.viewLogs': '檢視日誌',
'scripts.running.logTitle': '{name} · 執行日誌',
'scripts.running.logEmpty': '暫無日誌輸出',
'scripts.running.pause': '暫停',
'scripts.running.resume': '繼續',
'scripts.running.stop': '停止',
'scripts.recording.saved': '指令碼已儲存',
'scripts.recording.savedNamed': '已儲存「{name}」',
'scripts.observer.blocked': 'Observer 模式禁止執行會寫入終端的指令碼。',
'vault.section.scripts': '指令碼',
'vault.nav.scripts': '指令碼',
'snippets.action.newScript': '新建自動化指令碼',
'hostDetails.section.automation': '自動化',
'hostDetails.automation.loginScript': '登入指令碼',
'hostDetails.automation.loginScriptPlaceholder': '選擇指令碼',
'hostDetails.automation.none': '無',
'hostDetails.automation.outputTriggers': '輸出觸發器',
'hostDetails.automation.addTrigger': '新增觸發器',
'hostDetails.automation.triggerPatternPlaceholder': '正規表示式',
'hostDetails.automation.linkedScripts': '已關聯指令碼',
'hostDetails.automation.linkedScriptsEmpty': '尚未關聯任何指令碼。',
'hostDetails.automation.linkScriptPlaceholder': '關聯既有指令碼…',
'hostDetails.automation.unlink': '取消關聯',
'hostDetails.automation.equivalenceHint': '此處關聯會將本主機加入 Scripts 中該指令碼的目標列表,兩側檢視同步。',
'hostDetails.automation.queueHint': '連線本主機時依序執行指令碼。全域指令碼最先執行,再執行下方佇列。',
'hostDetails.automation.globalScripts': '全域連線指令碼',
'hostDetails.automation.globalScriptsHint': '每次連線時最先執行。順序在指令碼庫排序中調整。',
'hostDetails.automation.connectQueue': '本主機執行佇列',
'hostDetails.automation.connectQueueEmpty': '尚未為本主機設定連線指令碼佇列。',
'hostDetails.automation.addToQueuePlaceholder': '新增指令碼到佇列…',
'hostDetails.automation.moveUp': '上移',
'hostDetails.automation.moveDown': '下移',
'hostDetails.automation.removeFromQueue': '從佇列移除',
'hostDetails.automation.dragHandle': '拖曳排序',
'hostDetails.automation.queueDragHint': '拖動項目即可調整連線時的執行順序。',
};
@@ -0,0 +1,209 @@
import type { Messages } from '../types';
export const zhTwSystemManagerMessages: Messages = {
'terminal.layer.system': '系統',
'systemManager.noSession': '沒有使用中的終端工作階段。',
'systemManager.notConnected': '請先連線到主機以管理程序與服務。',
'systemManager.empty': '暫無資料。',
'systemManager.tabs.overview': '概覽',
'systemManager.tabs.processes': '程序',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.popup.loading': '正在開啟終端…',
'systemManager.popup.startupFailed': '啟動指令未順利完成。請確認目標仍可使用後再試一次。',
'systemManager.errors.loadProcesses': '載入程序列表失敗',
'systemManager.errors.loadTmux': '載入 tmux 工作階段失敗',
'systemManager.errors.loadTmuxWindows': '載入 tmux 視窗失敗',
'systemManager.errors.loadTmuxPanes': '載入 tmux 窗格失敗',
'systemManager.errors.loadTmuxClients': '載入 tmux 用戶端失敗',
'systemManager.errors.actionFailed': '操作失敗',
'systemManager.errors.loadDocker': '載入容器列表失敗',
'systemManager.errors.loadDockerStats': '載入容器效能資料失敗',
'systemManager.errors.loadDockerImages': '載入映像列表失敗',
'systemManager.errors.loadOverview': '載入系統概覽失敗',
'systemManager.errors.sshChannelUnavailable': '伺服器拒絕開啟新的執行通道。請稍後重試,或重新連線目前主機。',
'systemManager.overview.empty': '暫無系統概覽資料。',
'systemManager.overview.loading': '正在載入系統概覽…',
'systemManager.overview.memory': '記憶體',
'systemManager.overview.disk': '磁碟',
'systemManager.overview.network': '網路',
'systemManager.overview.rx': '下行',
'systemManager.overview.tx': '上行',
'systemManager.overview.cores': '{{count}} 核心',
'systemManager.overview.load': '負載',
'systemManager.overview.uptime': '執行時間',
'systemManager.overview.duration.daysHours': '{{days}}天 {{hours}}小時',
'systemManager.overview.duration.hoursMinutes': '{{hours}}小時 {{minutes}}分鐘',
'systemManager.overview.duration.minutes': '{{minutes}}分鐘',
'systemManager.overview.system': '系統',
'systemManager.overview.kernel': '核心',
'systemManager.overview.swap': '交換區',
'systemManager.overview.latency': 'SSH 網路延遲',
'systemManager.overview.cpuCores': 'CPU 核心',
'systemManager.overview.disks': '磁碟',
'systemManager.overview.interfaces': '網路介面',
'systemManager.overview.topProcesses': '記憶體占用最高程序',
'systemManager.overview.noData': '暫無資料',
'systemManager.overview.noDisks': '暫無磁碟資料',
'systemManager.overview.noInterfaces': '暫無網卡資料',
'systemManager.overview.noTopProcesses': '暫無程序資料',
'systemManager.processes.search': '搜尋程序…',
'systemManager.processes.command': '指令',
'systemManager.processes.user': '使用者',
'systemManager.processes.term': '終止',
'systemManager.processes.kill': '強制終止',
'systemManager.processes.stop': '暫停 (SIGSTOP)',
'systemManager.processes.cont': '繼續 (SIGCONT)',
'systemManager.processes.hup': '掛斷 (SIGHUP)',
'systemManager.processes.renice': '調整優先順序',
'systemManager.processes.renicePrompt': 'Nice 值 (-20 到 19)',
'systemManager.processes.reniceInvalid': 'Nice 值必須在 -20 到 19 之間',
'systemManager.processes.confirmKill': '向程序 {{pid}} 傳送 SIGKILL?',
'systemManager.processes.confirmSignal': '向程序 {{pid}} 傳送 SIG{{signal}}?',
'systemManager.processes.filter.all': '全部',
'systemManager.processes.filter.running': '執行中',
'systemManager.processes.ppid': '父程序 PID',
'systemManager.processes.rss': '實體記憶體',
'systemManager.processes.vsz': '虛擬記憶體',
'systemManager.processes.elapsed': '執行時長',
'systemManager.processes.stat': '狀態',
'systemManager.processes.meta': '{{count}} 個程序',
'systemManager.processes.loading': '正在載入程序…',
'systemManager.processes.loadingMore': '正在載入更多程序…',
'systemManager.processes.state.running': '執行中',
'systemManager.processes.state.sleeping': '睡眠',
'systemManager.processes.state.stopped': '已暫停',
'systemManager.processes.state.zombie': '殭屍',
'systemManager.processes.sort.cpu': 'CPU',
'systemManager.processes.sort.mem': '記憶體',
'systemManager.processes.sort.pid': 'PID',
'systemManager.processes.sort.command': '指令',
'systemManager.processes.sort.user': '使用者',
'systemManager.common.dismiss': '關閉',
'systemManager.common.checkingAvailability': '正在檢查可用狀態…',
'systemManager.common.loading': '正在載入…',
'systemManager.common.loadingDetails': '正在載入詳情…',
'systemManager.common.loadingStats': '正在載入效能資料…',
'systemManager.tmux.new': '新建',
'systemManager.tmux.search': '搜尋工作階段…',
'systemManager.tmux.newSessionTitle': '新建 tmux 工作階段',
'systemManager.tmux.newSessionDesc': '為工作階段命名,並可選在啟動時執行指令碼。',
'systemManager.tmux.newSessionTabCustom': '自訂指令',
'systemManager.tmux.newSessionTabSnippet': '從程式碼片段',
'systemManager.tmux.pickSnippet': '從程式碼片段選擇',
'systemManager.tmux.pickSnippetEmpty': '暫無程式碼片段,可在指令碼側欄或倉庫中新增。',
'systemManager.tmux.selectedSnippet': '已選片段:{{label}}',
'systemManager.tmux.newSessionName': '工作階段名稱',
'systemManager.tmux.newSessionCommand': '啟動指令',
'systemManager.tmux.newSessionCommandPlaceholder': '例如 htop 或 npm run dev(可選)',
'systemManager.tmux.newSessionCommandHint': '留空則建立預設 shell 工作階段。',
'systemManager.tmux.creating': '建立中…',
'systemManager.tmux.newSessionPlaceholder': 'my-session',
'systemManager.tmux.newSessionRequired': '請先輸入工作階段名稱',
'systemManager.tmux.empty': '沒有 tmux 工作階段',
'systemManager.tmux.attach': '附加',
'systemManager.tmux.attached': '已附加',
'systemManager.tmux.detached': '未附加',
'systemManager.tmux.windows': '{{count}} 個視窗',
'systemManager.tmux.created': '建立時間',
'systemManager.tmux.activity': '活動時間',
'systemManager.tmux.rename': '重新命名',
'systemManager.tmux.detach': '全部分離',
'systemManager.tmux.killSession': '結束工作階段',
'systemManager.tmux.killServer': '結束 tmux 伺服器',
'systemManager.tmux.loadingDetails': '正在載入詳情…',
'systemManager.tmux.clients': '已附加用戶端',
'systemManager.tmux.windowList': '視窗',
'systemManager.tmux.newWindow': '新建視窗',
'systemManager.tmux.newWindowPlaceholder': '視窗名稱(可選)',
'systemManager.tmux.noWindows': '沒有視窗',
'systemManager.tmux.unavailable': '此主機未偵測到 tmux',
'systemManager.docker.unavailable': '此主機未偵測到 Docker',
'systemManager.tmux.windowsMismatch': '工作階段顯示有 {{count}} 個視窗,但 list-windows 未傳回任何視窗',
'systemManager.tmux.lastCommand': '最後執行的指令:{{command}}',
'systemManager.tmux.noPanes': '沒有窗格',
'systemManager.tmux.panes': '{{count}} 個窗格',
'systemManager.tmux.active': '使用中',
'systemManager.tmux.unnamedWindow': '未命名視窗',
'systemManager.tmux.unnamedPane': '未命名窗格',
'systemManager.tmux.attachWindow': '附加到視窗',
'systemManager.tmux.selectWindow': '選擇視窗',
'systemManager.tmux.killWindow': '關閉視窗',
'systemManager.tmux.killPane': '關閉窗格',
'systemManager.tmux.splitHorizontal': '水平分割',
'systemManager.tmux.splitVertical': '垂直分割',
'systemManager.tmux.sendKeys': '傳送按鍵',
'systemManager.tmux.sendKeysTo': '向視窗 {{window}} 窗格 {{pane}} 傳送按鍵',
'systemManager.tmux.sendKeysPlaceholder': '指令或文字…',
'systemManager.tmux.renameSessionPrompt': '重新命名工作階段',
'systemManager.tmux.renameWindowPrompt': '重新命名視窗',
'systemManager.tmux.windowName': '視窗名稱',
'systemManager.tmux.confirmKillSession': '確定結束 tmux 工作階段「{{name}}」?',
'systemManager.tmux.confirmDetachSession': '確定將所有用戶端從「{{name}}」分離?',
'systemManager.tmux.confirmKillWindow': '確定關閉視窗「{{name}}」?',
'systemManager.tmux.confirmKillPane': '確定關閉窗格 #{{index}}?',
'systemManager.tmux.confirmKillServer': '確定結束 tmux 伺服器?所有工作階段將被終止。',
'systemManager.tmux.meta': '{{count}} 個工作階段',
'systemManager.docker.title': '容器',
'systemManager.docker.subTabs.containers': '容器',
'systemManager.docker.subTabs.images': '映像',
'systemManager.docker.empty': '未找到容器',
'systemManager.docker.imagesEmpty': '未找到映像',
'systemManager.docker.search': '搜尋容器…',
'systemManager.docker.searchImages': '搜尋映像…',
'systemManager.docker.filter.all': '全部',
'systemManager.docker.filter.running': '執行中',
'systemManager.docker.filter.stopped': '已停止',
'systemManager.docker.filter.paused': '已暫停',
'systemManager.docker.shell': 'Shell',
'systemManager.docker.logs': '日誌',
'systemManager.docker.details': '詳情',
'systemManager.docker.inspect': 'Inspect',
'systemManager.docker.imageInspect': '映像 Inspect',
'systemManager.docker.confirmRemove': '確定刪除此容器?',
'systemManager.docker.confirmKill': '確定強制終止此容器?',
'systemManager.docker.confirmRemoveImage': '確定刪除映像「{{name}}」?',
'systemManager.docker.confirmPrune': '確定清理懸空映像?',
'systemManager.docker.confirmPruneAll': '確定清理所有未使用映像?',
'systemManager.docker.pause': '暫停',
'systemManager.docker.unpause': '恢復',
'systemManager.docker.restart': '重新啟動',
'systemManager.docker.kill': '強制終止',
'systemManager.docker.renamePrompt': '容器名稱',
'systemManager.docker.prune': '清理懸空',
'systemManager.docker.pruneAll': '清理全部',
'systemManager.docker.tag': '標記',
'systemManager.docker.tagRepoPrompt': '儲存庫名稱',
'systemManager.docker.tagNamePrompt': '標籤名稱',
'systemManager.docker.meta': '{{count}} 個容器',
'systemManager.docker.imagesMeta': '{{count}} 個映像',
'systemManager.docker.start': '啟動',
'systemManager.docker.stop': '停止',
'systemManager.inspect.status': '狀態',
'systemManager.inspect.image': '映像',
'systemManager.inspect.created': '建立時間',
'systemManager.inspect.started': '啟動時間',
'systemManager.inspect.restartPolicy': '重新啟動原則',
'systemManager.inspect.command': '啟動指令',
'systemManager.inspect.ports': '連接埠',
'systemManager.inspect.networks': '網路',
'systemManager.inspect.mounts': '掛載',
'systemManager.inspect.env': '環境變數',
'systemManager.inspect.labels': '標籤',
'systemManager.inspect.tags': '映像標籤',
'systemManager.inspect.digests': '摘要',
'systemManager.inspect.size': '大小',
'systemManager.inspect.platform': '平台',
'systemManager.inspect.workdir': '工作目錄',
'systemManager.inspect.exposedPorts': '對外開放的連接埠',
'systemManager.inspect.showRaw': 'JSON',
'systemManager.inspect.hideRaw': '收起 JSON',
};
+822
View File
@@ -0,0 +1,822 @@
import type { Messages } from '../types';
export const zhTWTerminalMessages: Messages = {
'terminal.sudoHint.pressEnter': '按 Enter 貼上 sudo 密碼',
'terminal.menu.rename': '重新命名',
'terminal.menu.uploadClipboardImage': '上傳剪貼簿圖片',
'terminal.clipboardImageUpload.noImage': '剪貼簿中沒有圖片',
'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 也能繼續跟隨終端目錄。',
'terminal.osc7Setup.targets': '可能寫入的檔案',
'terminal.osc7Setup.command': '將執行的指令',
'terminal.osc7Setup.run': '執行設定',
'terminal.osc7Setup.running': '正在設定...',
'terminal.osc7Setup.configured': '目錄追蹤已設定',
'terminal.osc7Setup.failed': '目錄追蹤設定失敗',
'terminal.osc7Setup.sent': '目錄追蹤設定已傳送到終端',
'terminal.toolbar.timestampsEnable': '顯示時間戳',
'terminal.toolbar.timestampsDisable': '隱藏時間戳',
'terminal.progress.enterReconnectHint': '按 Enter 重新連線',
'terminal.progress.autoReconnectScheduled': '連線已斷開,將在 {seconds} 秒後自動重連(第 {attempt} 次)。',
'terminal.progress.autoReconnectAttempt': '正在自動重連(第 {attempt} 次)...',
'terminal.connection.protocol.et': 'EternalTerminal',
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支援 Netcatty 的代理設定。請改用 SSH,或移除該主機的代理。',
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支援一個跳板機。',
// Command history side panel
'history.scope.label': '歷史範圍',
'history.tab.host': '主機',
'history.tab.global': '全域',
'history.searchPlaceholder': '搜尋歷史指令...',
'history.loading': '正在讀取遠端歷史...',
'history.meta.count': '{count} 條',
'history.empty.noSession': '請先開啟一個遠端工作階段以檢視其指令歷史。',
'history.empty.unsupportedProtocol': '僅 SSH/Mosh/ET 工作階段支援指令歷史。',
'history.empty.noHistory': '該主機上未找到指令歷史。',
'history.empty.noGlobalHistory': '暫無全域指令歷史。你執行的指令會記錄在這裡。',
'history.action.refresh': '重新整理',
'history.action.retry': '重試',
'history.action.paste': '貼上到終端',
'history.action.run': '在終端執行',
'history.action.saveAsSnippet': '儲存為程式碼片段',
// SFTP File Opener
'sftp.context.copyPath': '複製檔案路徑',
'sftp.context.openWith': '開啟方式...',
'sftp.context.edit': '編輯',
'sftp.context.preview': '預覽',
'sftp.opener.title': '開啟方式',
'sftp.opener.desc': '選擇一個應用程式來開啟此檔案',
'sftp.opener.builtInEditor': '內建編輯器',
'sftp.opener.editDescription': '編輯文字檔案',
'sftp.opener.builtInImageViewer': '內建圖片預覽',
'sftp.opener.previewDescription': '預覽圖片',
'sftp.opener.systemApp': '選擇應用程式...',
'sftp.opener.systemAppDescription': '從本機選擇一個應用程式',
'sftp.opener.onlySystemApp': '此檔案只能用外部應用程式開啟',
'sftp.opener.noAppsAvailable': '無可用應用程式',
'sftp.opener.noExtension': '無副檔名檔案',
'sftp.opener.setDefault': '始終使用此方式開啟 {ext} 檔案',
'sftp.opener.confirmTitle': '設為預設?',
'sftp.opener.confirmDescription': '是否始終使用 {app} 開啟 {ext} 檔案?',
'sftp.opener.yesRemember': '是,記住此選擇',
'sftp.opener.justOnce': '僅此一次',
'sftp.opener.confirm.title': '設定預設應用程式',
'sftp.opener.confirm.desc': '是否始終使用此應用程式開啟 .{ext} 檔案?',
'sftp.editor.title': '文字編輯器',
'sftp.editor.save': '儲存到遠端',
'sftp.editor.saving': '儲存中...',
'sftp.editor.saved': '儲存成功',
'sftp.editor.saveFailed': '儲存檔案失敗',
'sftp.editor.unsavedChanges': '你有未儲存的更改。確定要關閉嗎?',
'sftp.editor.syntaxHighlight': '語法標示',
'sftp.preview.title': '圖片預覽',
'sftp.preview.zoomIn': '放大',
'sftp.preview.zoomOut': '縮小',
'sftp.preview.resetZoom': '重置縮放',
'sftp.preview.fitToWindow': '適應視窗',
// Settings > SFTP File Associations
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftp.transferConcurrency': '傳輸併發數',
'settings.sftp.transferConcurrency.desc': '上傳或下載資料夾時並行傳輸的檔案數量。較高的值可能提高速度,但可能導致某些伺服器過載。',
'settings.sftp.defaultOpener': '預設檔案開啟方式',
'settings.sftp.defaultOpener.desc': '選擇沒有特定檔案關聯時的預設開啟方式',
'settings.sftp.defaultOpener.ask': '每次詢問',
'settings.sftp.defaultOpener.askDesc': '每次開啟檔案時彈出選擇對話方塊',
'settings.sftp.defaultOpener.builtInDesc': '預設使用內建編輯器開啟文字檔案',
'settings.sftp.defaultOpener.systemApp': '選擇應用程式...',
'settings.sftp.defaultOpener.systemAppDesc': '預設使用指定的外部應用程式開啟檔案',
'settings.sftpFileAssociations.title': 'SFTP 檔案關聯',
'settings.sftpFileAssociations.desc': '設定按副檔名開啟檔案的預設應用程式',
'settings.sftpFileAssociations.extension': '副檔名',
'settings.sftpFileAssociations.application': '應用程式',
'settings.sftpFileAssociations.noAssociations': '未設定檔案關聯',
'settings.sftpFileAssociations.remove': '移除',
'settings.sftpFileAssociations.removeConfirm': '確定移除 .{ext} 的關聯嗎?',
// Settings > SFTP Behavior
'settings.sftp.doubleClickBehavior': '雙擊行為',
'settings.sftp.doubleClickBehavior.desc': '選擇在 SFTP 檢視中雙擊檔案時的操作',
'settings.sftp.doubleClickBehavior.open': '開啟檔案',
'settings.sftp.doubleClickBehavior.transfer': '傳輸到另一側',
'settings.sftp.doubleClickBehavior.openDesc': '使用預設應用程式開啟檔案',
'settings.sftp.doubleClickBehavior.transferDesc': '將檔案傳輸到另一窗格的使用中主機',
'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': '自動同步到遠端',
'settings.sftp.autoSync.desc': '使用外部應用程式開啟檔案時,自動將檔案更改同步回遠端伺服器',
'settings.sftp.autoSync.enable': '啟用自動同步',
'settings.sftp.autoSync.enableDesc': '在外部應用程式中儲存檔案時,更改將自動上傳到遠端伺服器',
// Settings > SFTP 自動開啟側欄
'settings.sftp.autoOpenSidebar': '連線時自動開啟側欄',
'settings.sftp.autoOpenSidebar.desc': '連線到主機時自動開啟 SFTP 檔案瀏覽器側欄',
'settings.sftp.autoOpenSidebar.enable': '啟用自動開啟側欄',
'settings.sftp.autoOpenSidebar.enableDesc': '當終端工作階段連線到遠端主機時,SFTP 側欄將自動開啟',
'settings.sftp.followTerminalCwd': '追隨終端目錄',
'settings.sftp.followTerminalCwd.desc': '在側欄 SFTP 中自動跟隨終端目前工作目錄變化(可在工具欄切換)',
'settings.sftp.followTerminalCwd.enable': '預設開啟追隨終端目錄',
'settings.sftp.followTerminalCwd.enableDesc': '開啟側欄 SFTP 時預設啟用追隨模式,終端執行 cd 後檔案瀏覽器會自動跳轉',
'settings.sftp.defaultViewMode': '預設檢視模式',
'settings.sftp.defaultViewMode.desc': '選擇開啟新 SFTP 標籤頁時的預設檢視模式。每個主機的偏好設定會覆蓋此全域設定。',
'settings.sftp.defaultViewMode.list': '列表檢視',
'settings.sftp.defaultViewMode.listDesc': '以平面列表顯示目前目錄的檔案',
'settings.sftp.defaultViewMode.tree': '樹形檢視',
'settings.sftp.defaultViewMode.treeDesc': '以層級樹形結構顯示檔案',
'sftp.autoSync.success': '檔案已同步到遠端:{fileName}',
'sftp.autoSync.error': '同步檔案失敗:{error}',
// SFTP Folder Upload Progress
'sftp.upload.progress': '正在上傳 {current}/{total} 個檔案...',
'sftp.upload.uploading': '正在上傳...',
'sftp.upload.compressing': '正在壓縮...',
'sftp.upload.extracting': '正在解壓...',
'sftp.upload.scanning': '正在掃描檔案...',
'sftp.upload.completed': '已完成',
'sftp.upload.compressed': '壓縮傳輸',
'sftp.upload.currentFile': '目前: {fileName}',
'sftp.upload.cancelled': '上傳已取消',
'sftp.upload.cancel': '取消',
'sftp.upload.completedToPath': '已上傳至 {path}',
// SFTP Download
'sftp.download.completed': '已下載',
'sftp.download.cancelled': '下載已取消',
// SFTP Reconnecting
'sftp.reconnecting.title': '正在重連...',
'sftp.reconnecting.desc': '連線已斷開,正在嘗試重新連線',
'sftp.reconnected': '連線已恢復',
'sftp.error.reconnectFailed': '重連失敗,請重試。',
'sftp.error.connectionLostManual': '連線已斷開,請手動重新連線。',
'sftp.error.connectionLostReconnecting': '連線已斷開,正在重連...',
'sftp.error.sessionLost': 'SFTP 工作階段已斷開,請重新連線。',
// Settings > SFTP Show Hidden Files
'settings.sftp.showHiddenFiles': '顯示隱藏檔案',
'settings.sftp.showHiddenFiles.desc': '在 SFTP 檔案瀏覽器中顯示隱藏檔案(Unix/macOS 點檔案和 Windows 隱藏屬性檔案)。',
'settings.sftp.showHiddenFiles.enable': '顯示隱藏檔案',
'settings.sftp.showHiddenFiles.enableDesc': '瀏覽本機和遠端檔案系統時顯示隱藏檔案',
// Settings > SFTP Compressed Upload
'settings.sftp.compressedUpload': '資料夾壓縮傳輸',
'settings.sftp.compressedUpload.desc': '上傳前壓縮資料夾,可大幅減少傳輸時間。',
'settings.sftp.compressedUpload.enable': '啟用資料夾壓縮',
'settings.sftp.compressedUpload.enableDesc': '自動使用 tar 壓縮資料夾後再傳輸。需要伺服器支援 tar 指令,不支援時自動回退到普通傳輸。',
// Settings > Terminal
'settings.terminal.section.theme': '終端主題',
'settings.terminal.themeModal.title': '選擇主題',
'settings.terminal.themeModal.darkThemes': '深色主題',
'settings.terminal.themeModal.lightThemes': '淺色主題',
'settings.terminal.theme.selectButton': '選擇主題',
'settings.terminal.theme.followApp': '跟隨應用程式主題',
'settings.terminal.theme.followApp.desc': '終端背景色自動配合目前應用程式主題,保持視覺一致性。',
'settings.terminal.theme.darkTheme': '深色模式終端主題',
'settings.terminal.theme.lightTheme': '淺色模式終端主題',
'settings.terminal.theme.auto': '自動(跟隨介面主題)',
'settings.terminal.theme.autoDesc': '跟隨目前介面主題預設',
'settings.terminal.section.font': '字型',
'settings.terminal.section.cursor': '游標',
'settings.terminal.section.keyboard': '鍵盤',
'settings.terminal.section.accessibility': '無障礙',
'settings.terminal.section.behavior': '行為',
'settings.terminal.section.scrollback': '捲動緩衝區',
'settings.terminal.section.keywordHighlight': '關鍵字標示',
'settings.terminal.font.family': '字型',
'settings.terminal.font.family.desc': '終端字型',
'settings.terminal.font.cjk': '中文 / CJK 字型',
'settings.terminal.font.cjk.desc': '用於渲染中 / 日 / 韓字元的字型;"Auto" 會按主字型智慧搭配',
'settings.terminal.font.cjk.option.auto': 'Auto · 按主字型智慧搭配',
'settings.terminal.font.cjk.option.sarasaSC': 'Sarasa Mono SC (更紗黑體 簡)',
'settings.terminal.font.cjk.option.sarasaTC': 'Sarasa Mono TC (更紗黑體 繁)',
'settings.terminal.font.cjk.option.mapleCN': 'Maple Mono CN',
'settings.terminal.font.cjk.option.sourceHan': 'Source Han Mono SC (思源等寬)',
'settings.terminal.font.cjk.option.notoCJK': 'Noto Sans Mono CJK SC',
'settings.terminal.font.cjk.option.lxgwWenkai': 'LXGW WenKai Mono (霞鶩文楷等寬)',
'settings.terminal.font.cjk.option.simSun': 'SimSun (宋體)',
'settings.terminal.font.cjk.option.legacy': '{font} · 不推薦(非等寬字型)',
'settings.terminal.font.size': '字型大小',
'settings.terminal.font.size.desc': '終端文字大小',
'settings.terminal.font.weight': '字重',
'settings.terminal.font.weight.desc': '常規文字字重 (100-900)',
'settings.terminal.font.weight.thin': '極細',
'settings.terminal.font.weight.extraLight': '特細',
'settings.terminal.font.weight.light': '細',
'settings.terminal.font.weight.normal': '常規',
'settings.terminal.font.weight.medium': '中等',
'settings.terminal.font.weight.semiBold': '半粗',
'settings.terminal.font.weight.bold': '粗',
'settings.terminal.font.weight.extraBold': '特粗',
'settings.terminal.font.weight.black': '黑體',
'settings.terminal.font.weightBold': '粗體字重',
'settings.terminal.font.weightBold.desc': '粗體文字字重 (100-900)',
'settings.terminal.font.smoothing': '字型平滑',
'settings.terminal.font.smoothing.desc': '使用 macOS 原生字型抗鋸齒',
'settings.terminal.font.linePadding': '行間距',
'settings.terminal.font.linePadding.desc': '行之間的額外間距 (0-10)',
'settings.terminal.font.emulationType': '終端模擬型別',
'settings.terminal.cursor.style': '游標樣式',
'settings.terminal.cursor.style.block': '塊',
'settings.terminal.cursor.style.bar': '豎線',
'settings.terminal.cursor.style.underline': '下劃線',
'settings.terminal.cursor.blink': '游標閃爍',
'settings.terminal.keyboard.altAsMeta': '將 Option 作為 Meta 鍵',
'settings.terminal.keyboard.altAsMeta.desc': '使用 Option (Alt) 作為 Meta 鍵,而不是用於輸入特殊字元',
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ 按單字跳轉',
'settings.terminal.keyboard.optionArrowWordJump.desc': '按 Option+左/右 時傳送 Meta-b / Meta-f,讓 Shell 按單字移動游標(而非預設的 ^[[1;3D / ^[[1;3C)',
'settings.terminal.accessibility.minimumContrastRatio': '最小對比度',
'settings.terminal.accessibility.minimumContrastRatio.desc': '調整顏色以滿足對比度要求 (1 = 停用, 21 = 最大)',
'settings.terminal.behavior.rightClick': '右鍵行為',
'settings.terminal.behavior.rightClick.desc': '在終端中右鍵時執行的操作',
'settings.terminal.behavior.rightClick.menu': '顯示選單',
'settings.terminal.behavior.rightClick.paste': '貼上',
'settings.terminal.behavior.rightClick.selectWord': '選擇單字',
'settings.terminal.behavior.copyOnSelect': '選擇即複製',
'settings.terminal.behavior.copyOnSelect.desc': '自動複製選取的文字。在 tmux/vim 滑鼠模式下,macOS 按住 Option,Windows/Linux 按住 Shift 拖選即可選取文字',
'settings.terminal.behavior.middleClickPaste': '中鍵貼上',
'settings.terminal.behavior.middleClickPaste.desc': '中鍵點選時貼上剪貼簿內容',
'settings.terminal.behavior.middleClick': '中鍵行為',
'settings.terminal.behavior.middleClick.desc': '在終端中點選滑鼠中鍵時執行的操作',
'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 預設行為)。關閉則保留歷史。',
'settings.terminal.behavior.preserveSelectionOnInput': '輸入時保留選區',
'settings.terminal.behavior.preserveSelectionOnInput.desc':
'鍵盤輸入時不清除滑鼠選取的文字,方便選取路徑後輸入 `sz ` 之類指令再貼上。',
'settings.terminal.behavior.forcePromptNewLine': '提示字元另起一行',
'settings.terminal.behavior.forcePromptNewLine.desc':
'當指令輸出的最後一行未以換行符結束時,將識別到的 shell 提示字元移動到下一行顯示。',
'settings.terminal.behavior.dynamicTabTitle': '動態標籤頁標題',
'settings.terminal.behavior.dynamicTabTitle.desc':
'選擇標籤頁什麼時候跟隨 Shell 報告的視窗標題。',
'settings.terminal.behavior.dynamicTabTitle.off': '關閉',
'settings.terminal.behavior.dynamicTabTitle.agent': '僅 Agent',
'settings.terminal.behavior.dynamicTabTitle.all': '全部工作階段',
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪貼簿',
'settings.terminal.behavior.osc52Clipboard.desc':
'允許遠端程式(tmux、vim 等)透過 OSC-52 轉義序列存取本機剪貼簿。',
'settings.terminal.behavior.osc52Clipboard.off': '關閉',
'settings.terminal.behavior.osc52Clipboard.writeOnly': '僅寫入',
'settings.terminal.behavior.osc52Clipboard.readWrite': '讀寫',
'settings.terminal.behavior.osc52Clipboard.prompt': '寫入 + 讀取時詢問',
'terminal.osc52.readPrompt.title': '剪貼簿讀取請求',
'terminal.osc52.readPrompt.desc': '遠端程式正在請求讀取你的剪貼簿,是否允許?',
'terminal.osc52.readPrompt.allow': '允許',
'terminal.osc52.readPrompt.deny': '拒絕',
'settings.terminal.behavior.scrollOnInput': '輸入時自動捲動',
'settings.terminal.behavior.scrollOnInput.desc': '輸入時將終端捲動到底部',
'settings.terminal.behavior.scrollOnOutput': '輸出時自動捲動',
'settings.terminal.behavior.scrollOnOutput.desc': '有新輸出時將終端捲動到底部',
'settings.terminal.behavior.scrollOnKeyPress': '按鍵時自動捲動',
'settings.terminal.behavior.scrollOnKeyPress.desc': '按鍵(例如 Enter)時將終端捲動到底部',
'settings.terminal.behavior.scrollOnPaste': '貼上時自動捲動',
'settings.terminal.behavior.scrollOnPaste.desc': '貼上文字時將終端捲動到底部',
'settings.terminal.behavior.smoothScrolling': '平滑捲動',
'settings.terminal.behavior.smoothScrolling.desc': '捲動終端可視區域時使用平滑動畫',
'settings.terminal.behavior.linkModifier': '連結修飾鍵',
'settings.terminal.behavior.linkModifier.desc': '按住此鍵再點選終端中的連結',
'settings.terminal.behavior.linkModifier.none': '無(直接點選)',
'settings.terminal.behavior.linkModifier.ctrl': 'Ctrl',
'settings.terminal.behavior.linkModifier.alt': 'Alt / Option',
'settings.terminal.behavior.linkModifier.meta': 'Cmd / Win',
'settings.terminal.scrollback.desc': '限制終端行數。設為 0 表示不限制。',
'settings.terminal.scrollback.rows': '行數 *',
'settings.terminal.section.startupCommand': '啟動指令',
'settings.terminal.startupCommandDelay.label': '啟動指令延遲(毫秒)',
'settings.terminal.startupCommandDelay.desc': '連線建立後等待多久再發送啟動指令;啟動指令為多行時,行與行之間也使用該間隔。慢連線可調大。',
'settings.terminal.keywordHighlight.title': '關鍵字標示',
'settings.terminal.keywordHighlight.resetColors': '重置為預設顏色',
'settings.terminal.keywordHighlight.resetDefaults': '把內建規則恢復為預設',
'settings.terminal.keywordHighlight.resetBuiltIn': '恢復內建標籤與正規表示式',
'settings.terminal.keywordHighlight.addCustom': '新增自訂規則',
'settings.terminal.keywordHighlight.editCustom': '編輯規則',
'settings.terminal.keywordHighlight.editBuiltIn': '編輯內建規則',
'settings.terminal.keywordHighlight.labelField': '標籤與顏色',
'settings.terminal.keywordHighlight.labelPlaceholder': '標籤(如 Down)',
'settings.terminal.keywordHighlight.patternField': '正規表示式',
'settings.terminal.keywordHighlight.patternPlaceholder': '每行一個正規表示式(如 \\bdown\\b)',
'settings.terminal.keywordHighlight.patternHint': '每行一個正規表示式。比對時忽略大小寫,全域比對。',
'settings.terminal.keywordHighlight.invalidPattern': '無效的正規表示式',
'settings.terminal.keywordHighlight.preview': '預覽',
'settings.terminal.section.localShell': '本機 Shell',
'settings.terminal.localShell.shell': 'Shell 執行檔',
'settings.terminal.localShell.shell.desc': 'Shell 執行檔的路徑(例如 /bin/zsh、pwsh.exe)。留空使用系統預設。',
'settings.terminal.localShell.shell.placeholder': '系統預設',
'settings.terminal.localShell.shell.detected': '偵測到',
'settings.terminal.localShell.shell.notFound': '未找到 Shell 執行檔',
'settings.terminal.localShell.shell.isDirectory': '路徑是目錄,不是執行檔',
'settings.terminal.localShell.shell.default': '系統預設',
'settings.terminal.localShell.shell.custom': '自訂...',
'settings.terminal.localShell.shell.customPath': 'Shell 執行檔路徑',
'settings.terminal.localShell.shell.customArgs': '啟動引數',
'settings.terminal.localShell.shell.customArgs.placeholder': '例如 --login -i',
'settings.terminal.localShell.shell.customArgs.desc': '傳給 Shell 的啟動引數。部分 Shell 必須指定才能正常工作,例如 msys2 bash 需要 --login -i 才能載入環境變數。',
'settings.terminal.localShell.shell.commonPaths': '常用路徑',
'settings.terminal.localShell.shell.pathValid': '路徑有效',
'settings.terminal.localShell.startDir': '起始目錄',
'settings.terminal.localShell.startDir.desc': '開啟本機終端時的起始目錄。留空使用使用者主目錄。',
'settings.terminal.localShell.startDir.placeholder': '使用者主目錄',
'settings.terminal.localShell.startDir.notFound': '目錄不存在',
'settings.terminal.localShell.startDir.isFile': '路徑是檔案,不是目錄',
'settings.terminal.section.connection': '連線',
'settings.terminal.connection.sshAutoReconnectEnabled': 'SSH 斷線後自動重連',
'settings.terminal.connection.sshAutoReconnectEnabled.desc': '開啟後,已連上的 SSH 工作階段意外斷開時會每 5 秒嘗試重新連線,直到關閉標籤頁或連線恢復。',
'settings.terminal.connection.keepaliveInterval': '工作階段保持間隔',
'settings.terminal.connection.keepaliveInterval.desc': '向伺服器傳送 SSH 保活資料包的頻率(秒)。設為 0 表示全域停用——單個主機可在自己的設定裡覆蓋此值。',
'settings.terminal.connection.keepaliveCountMax': '最大無響應保活次數',
'settings.terminal.connection.keepaliveCountMax.desc': '判定連線死亡前允許的無響應保活次數。值越大對短暫網路抖動和響應慢的 SSH 服務越寬容。',
'settings.terminal.connection.x11Display': 'X11 顯示地址',
'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': '重新整理間隔',
'settings.terminal.serverStats.refreshInterval.desc': '伺服器狀態重新整理的頻率。',
'settings.terminal.serverStats.seconds': '秒',
'settings.terminal.section.systemManager': '系統管理',
'settings.terminal.systemManager.processRefreshInterval': '程序列表重新整理間隔',
'settings.terminal.systemManager.processRefreshInterval.desc': '系統管理側欄中程序列表的重新整理頻率。',
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 工作階段重新整理間隔',
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 工作階段列表的重新整理頻率。',
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表重新整理間隔',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的重新整理頻率。',
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 效能資料重新整理間隔',
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/記憶體/網路指標的重新整理頻率。',
// Settings > Terminal > Rendering
'settings.terminal.section.rendering': '渲染',
'settings.terminal.rendering.renderer': '渲染器',
'settings.terminal.rendering.renderer.desc': '選擇終端渲染技術。自動模式會在低記憶體裝置上使用 DOM 渲染。更改將在新終端工作階段中生效。',
'settings.terminal.rendering.auto': '自動',
'settings.terminal.rendering.hibernateHiddenTabs': '隱藏標籤頁休眠',
'settings.terminal.rendering.hibernateHiddenTabs.desc': '標籤頁離開視野後釋放終端渲染器以節省記憶體,SSH 連線保持不斷。檔案傳輸期間不會休眠。',
'settings.terminal.rendering.hibernateHiddenTabsDelay': '休眠延遲',
'settings.terminal.rendering.hibernateHiddenTabsDelay.desc': '標籤頁離開視野多久後釋放渲染器(5–600 秒)。',
'settings.terminal.rendering.hibernateSkipAltScreen': '全屏 TUI 跳過休眠',
'settings.terminal.rendering.hibernateSkipAltScreen.desc': 'vim、htop 或 Agent TUI 佔用 alternate screen 時保持終端渲染器,避免慢恢復。',
'settings.terminal.rendering.hibernateKeepRendererCount': '保留隱藏渲染器數量',
'settings.terminal.rendering.hibernateKeepRendererCount.desc': '多少個不可見標籤頁僅暫停 WebGL、不銷燬 xterm,超出後才完整休眠。',
'settings.terminal.rendering.hibernateReplayChunkBytes': '喚醒分片大小',
'settings.terminal.rendering.hibernateReplayChunkBytes.desc': '恢復休眠終端時每幀回放的 ANSI 位元組數(4–64 KB)。',
'settings.terminal.rendering.hibernatePreferWasmSerialize': '優先 WASM 序列化',
'settings.terminal.rendering.hibernatePreferWasmSerialize.desc': '在可用時使用 WASM 終端序列化,否則回退到 JS SerializeAddon。',
// Settings > Terminal > Workspace Focus Indicator
'settings.terminal.section.workspaceFocus': '工作區焦點提示',
'settings.terminal.workspaceFocus.style': '焦點提示樣式',
'settings.terminal.workspaceFocus.style.desc': '在分割檢視中如何標識目前聚焦的窗格。',
'settings.terminal.workspaceFocus.dim': '淡化未聚焦窗格',
'settings.terminal.workspaceFocus.border': '為聚焦窗格顯示邊框',
// Settings > Terminal > Autocomplete
'settings.terminal.section.autocomplete': '自動補全',
'settings.terminal.autocomplete.enabled': '啟用自動補全',
'settings.terminal.autocomplete.enabled.desc': '輸入時根據歷史指令和指令規範顯示補全建議。',
'settings.terminal.autocomplete.ghostText': '行內建議',
'settings.terminal.autocomplete.ghostText.desc': '在游標後顯示灰色的建議文字(類似 fish shell)。',
'settings.terminal.autocomplete.popupMenu': '彈出選單',
'settings.terminal.autocomplete.popupMenu.desc': '顯示包含多個建議的浮動列表。',
// Settings > Shortcuts
'settings.shortcuts.section.scheme': '快捷鍵方案',
'settings.shortcuts.scheme.label': '鍵盤快捷鍵',
'settings.shortcuts.scheme.desc': '選擇快捷鍵使用的鍵盤佈局',
'settings.shortcuts.scheme.disabled': '停用',
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
'settings.shortcuts.disableTerminalFontZoom.label': '停用終端縮放',
'settings.shortcuts.disableTerminalFontZoom.desc': '關閉終端文字縮放快捷操作,包括 Cmd/Ctrl 加滾輪。',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '標籤快捷鍵跳過固定標籤',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '開啟後,Cmd/Ctrl+[1...9] 和 Ctrl+Tab 僅在終端、工作區、編輯器等可關閉標籤頁之間切換,不包括固定的 Vault 和 SFTP 標籤頁。',
'settings.shortcuts.section.custom': '自訂快捷鍵',
'settings.shortcuts.resetAll': '全部重置',
'settings.shortcuts.recording': '請按鍵...',
'settings.shortcuts.none': '無',
'settings.shortcuts.setDisabled': '設為停用',
'settings.shortcuts.category.tabs': '標籤頁',
'settings.shortcuts.category.terminal': '終端',
'settings.shortcuts.category.navigation': '導航',
'settings.shortcuts.category.app': '應用程式',
'settings.shortcuts.category.sftp': 'SFTP',
'settings.shortcuts.binding.switch-tab-1-9': '切換到標籤頁 [1...9]',
'settings.shortcuts.binding.next-tab': '下一個標籤頁',
'settings.shortcuts.binding.prev-tab': '上一個標籤頁',
'settings.shortcuts.binding.close-tab': '關閉標籤頁',
'settings.shortcuts.binding.close-session': '關閉工作階段窗格',
'settings.shortcuts.binding.new-tab': '新建本機標籤頁',
'settings.shortcuts.binding.copy': '從終端複製',
'settings.shortcuts.binding.paste': '貼上到終端',
'settings.shortcuts.binding.paste-selection': '將選區貼上到終端',
'settings.shortcuts.binding.select-all': '全選終端內容',
'settings.shortcuts.binding.clear-buffer': '清空終端緩衝區',
'settings.shortcuts.binding.search-terminal': '開啟終端搜尋',
'settings.shortcuts.binding.increase-terminal-font-size': '增大終端字號',
'settings.shortcuts.binding.decrease-terminal-font-size': '減小終端字號',
'settings.shortcuts.binding.reset-terminal-font-size': '重置終端字號',
'settings.shortcuts.binding.move-focus': '在分割間移動焦點',
'settings.shortcuts.binding.split-horizontal': '水平分割',
'settings.shortcuts.binding.split-vertical': '垂直分割',
'settings.shortcuts.binding.toggle-pane-zoom': '切換窗格縮放',
'settings.shortcuts.binding.open-hosts': '開啟主機列表',
'settings.shortcuts.binding.open-local': '開啟本機終端',
'settings.shortcuts.binding.open-sftp': '開啟 SFTP',
'settings.shortcuts.binding.open-settings': '開啟設定',
'settings.shortcuts.binding.port-forwarding': '開啟埠轉發',
'settings.shortcuts.binding.command-palette': '開啟指令面板',
'settings.shortcuts.binding.quick-switch': '快速切換',
'settings.shortcuts.binding.new-workspace': '新建工作區',
'settings.shortcuts.binding.snippets': '開啟程式碼片段',
'settings.shortcuts.binding.broadcast': '切換廣播模式',
'settings.shortcuts.binding.toggle-side-panel': '切換側邊欄',
'settings.shortcuts.binding.sftp-copy': '複製檔案',
'settings.shortcuts.binding.sftp-cut': '剪下檔案',
'settings.shortcuts.binding.sftp-paste': '貼上檔案',
'settings.shortcuts.binding.sftp-select-all': '全選檔案',
'settings.shortcuts.binding.sftp-rename': '重新命名檔案',
'settings.shortcuts.binding.sftp-delete': '刪除檔案',
'settings.shortcuts.binding.sftp-refresh': '重新整理',
'settings.shortcuts.binding.sftp-new-folder': '新建資料夾',
'settings.shortcuts.binding.sftp-open': '開啟檔案 / 進入目錄',
'settings.shortcuts.binding.sftp-go-parent': '轉到上級目錄',
'settings.shortcuts.binding.sftp-navigate-to': '轉到選取的目錄',
// Host Details (sub-panels)
'hostDetails.proxyPanel.title': '透過 HTTP/SOCKS5/指令代理',
'hostDetails.proxyPanel.hostPlaceholder': '代理主機',
'hostDetails.proxyPanel.command': 'ProxyCommand',
'hostDetails.proxyPanel.commandPlaceholder': 'cloudflared access ssh --hostname %h',
'hostDetails.proxyPanel.commandHelp': '使用 %h 表示目標主機,%p 表示目標埠,%% 表示字面百分號。',
'hostDetails.proxyPanel.credentials': '憑證',
'hostDetails.proxyPanel.usernamePlaceholder': '使用者名稱',
'hostDetails.proxyPanel.passwordPlaceholder': '密碼',
'hostDetails.proxyPanel.identities': '身份',
'hostDetails.proxyPanel.keychainIdentity': '鑰匙串身份',
'hostDetails.proxyPanel.manualCredentials': '手動憑證',
'hostDetails.proxyPanel.remove': '移除代理',
'hostDetails.proxyPanel.savedProxy': '已儲存代理',
'hostDetails.proxyPanel.selectSaved': '選擇已儲存代理',
'hostDetails.proxyPanel.customProxy': '自訂代理',
'hostDetails.proxyPanel.missing': '缺失',
'hostDetails.proxyPanel.missingSaved': '儲存的代理不存在',
'hostDetails.proxyPanel.missingIdentity': '鑰匙串身份不存在',
'hostDetails.proxyPanel.incompleteIdentity': '代理身份需要使用者名稱和密碼',
'hostDetails.proxyPanel.unreadableIdentity': '此裝置無法讀取代理身份密碼',
'hostDetails.proxyPanel.error.required': '代理主機和埠,或 ProxyCommand 不能為空。',
'hostDetails.envVars.title': '環境變數',
'hostDetails.envVars.desc': '為 {host} 設定環境變數。',
'hostDetails.envVars.note': '部分 SSH 伺服器預設只允許以 LC_ 和 LANG_ 為字首的變數。',
'hostDetails.envVars.variable': '變數',
'hostDetails.envVars.value': '值',
'hostDetails.envVars.newVariable': '新變數',
'hostDetails.envVars.variableName': '變數名',
'hostDetails.chain.title': '編輯鏈路',
'hostDetails.chain.desc': '新增另一台主機將建立到 {host} 的連線。',
'hostDetails.chain.addHost': '新增主機',
'hostDetails.chain.target': '目標',
'hostDetails.chain.availableHosts': '可用主機',
'hostDetails.chain.clear': '清空',
'hostDetails.group.title': '新建分組',
'hostDetails.group.general': '常規',
'hostDetails.group.namePlaceholder': '分組名稱',
'hostDetails.group.parentPlaceholder': '父分組',
'hostDetails.group.cloudSync': '雲同步',
'hostDetails.group.addProtocol': '新增協定',
// Keychain
'keychain.filter.key': '金鑰',
'keychain.filter.certificate': '憑證',
'keychain.action.generateKey': '生成金鑰',
'keychain.action.importKey': '匯入金鑰',
'keychain.action.newIdentity': '新建身份',
'keychain.action.importCertificate': '匯入憑證',
'keychain.view.grid': '網格',
'keychain.view.list': '列表',
'keychain.section.keys': '金鑰',
'keychain.section.identities': '身份',
'keychain.count.items': '{count} 項',
'keychain.empty.title': '設定金鑰',
'keychain.empty.desc': '匯入或生成 SSH 金鑰用於安全認證。',
'keychain.panel.generateKey': '生成金鑰',
'keychain.panel.newKey': '新建金鑰',
'keychain.panel.keyDetails': '金鑰詳情',
'keychain.panel.editKey': '編輯金鑰',
'keychain.panel.editIdentity': '編輯身份',
'keychain.panel.newIdentity': '新建身份',
'keychain.panel.keyExport': '金鑰匯出',
'keychain.validation.labelRequired': '請填寫金鑰的 Label',
'keychain.validation.labelAndPrivateKeyRequired': 'Label 和私鑰為必填項',
'keychain.validation.labelAndUsernameRequired': 'Label 和使用者名稱為必填項',
'keychain.error.generationUnavailable': '無法生成金鑰:請確保應用程式執行在 Electron 環境',
'keychain.error.generateKeyPairFailed': '生成金鑰對失敗',
'keychain.error.generateKeyFailed': '生成金鑰失敗',
'keychain.error.keyGenerationTitle': '金鑰生成',
'keychain.export.exportTo': '匯出到 *',
'keychain.export.selectHost': '選擇主機',
'keychain.export.location': '位置 ~ $1 *',
'keychain.export.filename': '檔名 ~ $2 *',
'keychain.export.note': '金鑰匯出目前僅支援 {unix} 系統。請在 {advanced} 部分自訂匯出指令碼。',
'keychain.export.script': '指令碼 *',
'keychain.export.scriptPlaceholder': '匯出指令碼...',
'keychain.export.missingCredentials': '主機未儲存密碼或金鑰。請先為該主機新增密碼憑證。',
'keychain.export.successTitle': '匯出成功',
'keychain.export.successMessage': '已匯出公鑰並繫結到 {host}',
'keychain.export.failedTitle': '匯出失敗',
'keychain.export.failedMessage': '匯出金鑰失敗:{error}',
'keychain.export.failedPrefix': '匯出失敗:{error}',
'keychain.export.exitCode': '指令結束代碼 {code}',
'keychain.export.exporting': '匯出中...',
'keychain.export.exportAndAttach': '匯出並繫結',
'keychain.export.title': '金鑰匯出',
'keychain.export.exportToRequired': '匯出到 *',
'keychain.export.selectHostPlaceholder': '選擇主機...',
'keychain.export.locationLabel': '位置 ~ $1 *',
'keychain.export.filenameLabel': '檔名 ~ $2 *',
'keychain.export.advanced': '高階',
'keychain.export.note.supportsOnly': '金鑰匯出目前僅支援',
'keychain.export.note.systems': '系統。',
'keychain.export.note.use': '請使用',
'keychain.export.note.customize': '部分自訂匯出指令碼。',
'keychain.export.scriptRequired': '指令碼 *',
'keychain.export.exportToHost': '匯出到主機',
'keychain.export.failedGeneric': '匯出失敗:{message}',
'keychain.field.label': 'Label',
'keychain.field.labelRequired': 'Label *',
'keychain.field.labelPlaceholder': '金鑰 Label',
'keychain.field.privateKeyRequired': '私鑰 *',
'keychain.field.publicKey': '公鑰',
'keychain.field.certificatePlaceholder': '憑證內容(可選)',
'keychain.generate.keyType': '金鑰型別',
'keychain.generate.keySize': '金鑰長度',
'keychain.generate.labelPlaceholder': '金鑰 Label',
'keychain.generate.passphrasePlaceholder': 'Passphrase(可選)',
'keychain.generate.savePassphrase': '儲存 Passphrase',
'keychain.generate.generate': '生成',
'keychain.generate.generateSave': '生成並儲存',
'keychain.import.dropHint': '將金鑰檔案拖到這裡',
'keychain.import.importFromFile': '從檔案匯入',
'keychain.import.saveKey': '儲存金鑰',
'keychain.import.importedKeyLabel': '已匯入金鑰',
'keychain.identity.usernameRequired': '使用者名稱 *',
'keychain.identity.method.passwordOnly': '密碼',
'keychain.identity.summary.password': '認證密碼',
'keychain.identity.summary.key': '認證金鑰',
'keychain.identity.summary.certificate': '認證憑證',
'keychain.identity.summary.passwordAndKey': '認證密碼與金鑰',
'keychain.identity.summary.passwordAndCertificate': '認證密碼與憑證',
'keychain.identity.summary.none': '無憑證',
'keychain.identity.selectCredential': '選擇{kind}',
'keychain.identity.save': '儲存',
'keychain.identity.update': '更新',
'keychain.keyDialog.newTitle': '新建金鑰',
'keychain.keyDialog.newDesc': '新增新的 SSH 金鑰',
'keychain.keyDialog.editTitle': '編輯金鑰',
'keychain.keyDialog.editDesc': '更新此 SSH 金鑰',
'keychain.keyDialog.updateKey': '更新金鑰',
// Tabs
'tabs.closeSessionAria': '關閉工作階段',
'tabs.closeLogViewAria': '關閉日誌檢視',
'tabs.logPrefix': '日誌:',
'tabs.logLocal': '本機',
'tabs.copyTab': '複製標籤頁',
'tabs.copyTabToNewWindow': '複製標籤頁到新視窗',
'tabs.copyTabToNewWindowFailed': '無法在新視窗開啟標籤頁',
'tabs.closeOthers': '關閉其他標籤',
'tabs.closeToRight': '關閉右側標籤',
'tabs.closeAll': '關閉所有標籤',
'keychain.edit.labelRequired': 'Label *',
'keychain.edit.keyLabelPlaceholder': '金鑰 Label',
'keychain.edit.privateKeyRequired': '私鑰 *',
'keychain.edit.publicKey': '公鑰',
'keychain.edit.certificate': '憑證',
'keychain.edit.certificatePlaceholder': '憑證內容(可選)',
'keychain.edit.filePath': '檔案路徑',
'keychain.edit.keyExport': '金鑰匯出',
'keychain.edit.exportToHost': '匯出到主機',
// Snippets
'snippets.searchPlaceholder': '搜尋程式碼片段...',
'snippets.action.newSnippet': '新建程式碼片段',
'snippets.action.newPackage': '新建程式碼包',
'snippets.action.import': '匯入',
'snippets.action.selectSnippets': '選擇程式碼片段',
'snippets.panel.newTitle': '新建程式碼片段',
'snippets.panel.editTitle': '編輯程式碼片段',
'snippets.field.description': '描述',
'snippets.field.descriptionPlaceholder': '例如:check network load',
'snippets.field.package': '新增程式碼包',
'snippets.field.packagePlaceholder': '選擇或建立程式碼包',
'snippets.field.createPackage': '建立程式碼包',
'snippets.field.scriptRequired': '指令碼 *',
'snippets.scriptEditor.expand': '彈出視窗編輯',
'snippets.scriptEditor.resize': '調整編輯器高度',
'snippets.scriptEditor.modalTitle': '編輯指令碼',
'snippets.targets.title': '目標主機',
'snippets.targets.add': '新增目標主機',
'snippets.history.title': 'Shell 歷史',
'snippets.history.subtitle': '{count} 條指令',
'snippets.history.emptyTitle': '暫無 Shell 歷史',
'snippets.history.emptyDesc': '你執行過的指令會顯示在這裡',
'snippets.history.loadMore': '載入更多',
'snippets.history.separator': '•',
'snippets.history.labelPlaceholder': '為此程式碼片段設定一個 Label',
'snippets.history.saveAsSnippet': '儲存為程式碼片段',
'snippets.history.time.justNow': '剛剛',
'snippets.history.time.minutesAgo': '{count} 分鐘前',
'snippets.history.time.hoursAgo': '{count} 小時前',
'snippets.history.time.daysAgo': '{count} 天前',
'snippets.breadcrumb.allPackages': '全部程式碼包',
'snippets.breadcrumb.separator': '›',
'snippets.empty.title': '建立程式碼片段',
'snippets.empty.desc': '將常用指令儲存為程式碼片段,一鍵重複使用。',
'snippets.panel.newAutomationTitle': '新建自動化指令碼',
'snippets.panel.editAutomationTitle': '編輯自動化指令碼',
'snippets.panel.resizeWidth': '調整面板寬度',
'snippets.targets.allHosts': '對所有主機生效',
'snippets.targets.allHostsShort': '所有主機',
'snippets.targets.allHostsActive': '將作用於所有可連線的主機。',
'snippets.kind.codeSnippet': '程式碼片段',
'snippets.kind.automationScript': '自動化指令碼',
'snippets.search.noResults.title': '無相符結果',
'snippets.search.noResults.desc': '沒有程式碼片段或程式碼包與"{query}"相符。換一個關鍵字,或清除搜尋進行瀏覽。',
'snippets.section.packages': '程式碼包',
'snippets.section.snippets': '程式碼片段',
'snippets.package.count': '{count} 個程式碼片段',
'snippets.commandFallback': '指令',
'snippets.view.grid': '網格',
'snippets.view.list': '列表',
'snippets.selection.selected': '已選擇 {count} 個',
'snippets.selection.selectVisible': '選擇目前顯示',
'snippets.selection.deselectAll': '取消選擇',
'snippets.selection.exportSelected': '匯出選取({count})',
'snippets.export.snippet': '匯出程式碼片段',
'snippets.export.package': '匯出程式碼包',
'snippets.export.toast.empty': '沒有可匯出的程式碼片段。',
'snippets.export.toast.successTitle': '匯出已準備好',
'snippets.export.toast.success': '已匯出 {count} 個程式碼片段。',
'snippets.import.toast.empty': '沒有找到可匯入的程式碼片段。',
'snippets.import.toast.failedTitle': '匯入失敗',
'snippets.import.toast.invalidDesc': '這不是有效的 Netcatty 程式碼片段檔案。',
'snippets.import.toast.successTitle': '匯入完成',
'snippets.import.toast.summary': '已匯入 {imported} 個,覆蓋 {overwritten} 個,跳過 {skipped} 個。',
'snippets.import.modal.title': '匯入程式碼片段',
'snippets.import.modal.desc': '選擇一個或多個 Netcatty 程式碼片段 JSON 檔案。匯入前會先解析並展示結果,不會直接寫入。',
'snippets.import.modal.exampleTitle': '範例 JSON',
'snippets.import.modal.noFile': '還沒有選擇檔案。可以參考下面的格式,也可以直接使用程式碼片段 JSON 陣列,然後選擇一個或多個檔案。',
'snippets.import.modal.chooseFile': '選擇檔案',
'snippets.import.modal.downloadExamples': '下載範例檔案',
'snippets.import.modal.multipleFiles': '已選擇 {count} 個檔案',
'snippets.import.modal.parsedSummary': '{files} 個檔案,{total} 個程式碼片段,{packages} 個程式碼包,{conflicts} 個重複指令。主機繫結會被忽略。',
'snippets.import.modal.confirm': '確認匯入',
'snippets.import.conflict.title': '匯入程式碼片段?',
'snippets.import.conflict.desc': '“{file}”包含 {total} 個程式碼片段,其中 {conflicts} 個指令已存在。',
'snippets.import.conflict.hostBindingsNote': '程式碼片段的主機繫結不會被匯入或匯出。',
'snippets.import.conflict.skip': '跳過重複項',
'snippets.import.conflict.overwrite': '覆蓋重複項',
'snippets.packageDialog.title': '新建程式碼包',
'snippets.packageDialog.parent': '父級:{parent}',
'snippets.packageDialog.root': '根目錄',
'snippets.packageDialog.placeholder': '例如:ops/maintenance',
'snippets.packageDialog.hint': '使用 "/" 建立巢狀程式碼包。',
// Snippets Rename Dialog
'snippets.renameDialog.title': '重新命名程式碼包',
'snippets.renameDialog.currentPath': '目前路徑:{path}',
'snippets.renameDialog.placeholder': '輸入新名稱',
'snippets.renameDialog.error.empty': '程式碼包名稱不能為空',
'snippets.renameDialog.error.duplicate': '已存在同名的程式碼包',
'snippets.renameDialog.error.invalidChars': '程式碼包名稱只能包含字母、數字、連字元和下劃線',
'snippets.field.noAutoRun': '僅貼上(不自動執行)',
'snippets.field.multiLineRunMode': '多行執行方式',
'snippets.field.multiLineRunMode.paste': '一次性傳送',
'snippets.field.multiLineRunMode.lineDelay': '逐行傳送',
'snippets.field.multiLineRunModeHint': '登入提示、網路設備巨集等情境可用逐行傳送。',
// Snippet Shortkey
'snippets.field.shortkey': '快捷鍵',
'snippets.shortkey.placeholder': '點選設定快捷鍵',
'snippets.shortkey.recording': '請按下快捷鍵組合...',
'snippets.shortkey.hint': '在終端中按下此快捷鍵可快速傳送指令。',
'snippets.shortkey.clear': '清除快捷鍵',
'snippets.shortkey.error.systemConflict': '此快捷鍵與系統快捷鍵衝突',
'snippets.shortkey.error.snippetConflict': '此快捷鍵已被程式碼片段使用:{name}',
'snippets.variables.dialogTitle': '填寫變數',
'snippets.variables.dialogDesc': '執行「{label}」前請填寫以下變數。',
'snippets.variables.hint': '變數值將原樣插入指令碼(不會進行 shell 轉義)。',
'snippets.variables.preview': '預覽',
'snippets.variables.placeholder': '請輸入',
'snippets.variables.placeholderDefault': '預設:{value}',
'snippets.variables.required': '請填寫此變數',
'snippets.variables.run': '執行',
'snippets.field.variablesHelp': '在指令碼中使用 {{名稱}} 或 {{名稱:預設值}} 定義變數。',
'snippets.field.variablesDetected': '變數',
'snippets.field.variableDefault': '預設 {value}',
// Serial Port
'serial.button': '序列埠',
'serial.modal.title': '連線序列埠',
'serial.modal.desc': '設定序列埠連線引數',
'serial.field.port': '序列埠',
'serial.field.selectPort': '選擇序列埠...',
'serial.field.baudRate': '波特率',
'serial.field.dataBits': '資料位',
'serial.field.stopBits': '停止位',
'serial.field.stopBits15Warning': '1.5 停止位在 Windows 下可能不被所有裝置支援',
'serial.field.parity': '同位位元',
'serial.field.flowControl': '流控制',
'serial.noPorts': '未偵測到序列埠裝置。請連線裝置後重新整理。',
'serial.field.customPort': '自訂序列埠路徑',
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
'serial.type.hardware': '硬體',
'serial.type.pseudo': '虛擬終端',
'serial.type.custom': '自訂',
'serial.parity.none': '無',
'serial.parity.even': '偶同位',
'serial.parity.odd': '奇同位',
'serial.parity.mark': 'Mark',
'serial.parity.space': 'Space',
'serial.flowControl.none': '無',
'serial.flowControl.xon/xoff': 'XON/XOFF (軟體)',
'serial.flowControl.rts/cts': 'RTS/CTS (硬體)',
'serial.field.localEcho': '強制本機回顯',
'serial.field.localEchoDesc': '本機回顯輸入字元(用於沒有遠端回顯的裝置)',
'serial.field.lineMode': '行模式',
'serial.field.lineModeDesc': '緩衝輸入,按回車後傳送(而不是逐字元傳送)',
'serial.field.charset': '字元編碼',
'serial.connectionError': '連線序列埠失敗',
'serial.field.baudRatePlaceholder': '選擇或輸入波特率...',
'serial.field.baudRateEmpty': '輸入自訂波特率',
'serial.field.customBaudRate': '使用自訂波特率',
'serial.field.saveConfig': '儲存設定',
'serial.field.saveConfigDesc': '將此序列埠設定儲存到主機列表以便快速存取',
'serial.field.configLabel': '設定名稱',
'serial.field.configLabelPlaceholder': '例如 Arduino Uno',
'serial.connectAndSave': '連線並儲存',
'serial.edit.title': '序列埠設定',
// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': '需要驗證',
'keyboard.interactive.desc': '伺服器需要額外的身份驗證。',
'keyboard.interactive.descWithHost': '伺服器 {hostname} 需要額外的身份驗證。',
'keyboard.interactive.response': '響應',
'keyboard.interactive.enterCode': '輸入驗證碼',
'keyboard.interactive.enterResponse': '輸入響應',
'keyboard.interactive.submit': '提交',
'keyboard.interactive.verifying': '驗證中...',
'keyboard.interactive.savePassword': '儲存密碼',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH 金鑰密碼',
'passphrase.desc': '請輸入 {keyName} 的密碼',
'passphrase.descWithHost': '請輸入 {keyName} 的密碼以連線到 {hostname}',
'passphrase.label': '密碼',
'passphrase.keyPath': '金鑰',
'passphrase.unlock': '解鎖',
'passphrase.unlocking': '解鎖中...',
'passphrase.skip': '跳過',
'passphrase.remember': '記住此密碼',
// Text Editor
'sftp.editor.wordWrap': '自動換行',
'sftp.editor.maximize': '最大化',
'sftp.editor.unsavedTitle': '未儲存的修改',
'sftp.editor.unsavedMessage': '{fileName} 有未儲存的修改,是否儲存後關閉?',
'sftp.editor.discardChanges': '不儲存',
'sftp.editor.saveAndClose': '儲存並關閉',
'sftp.editor.quitBlockedByDirty': '存在未儲存的編輯器,請先處理後再離開',
};
+854
View File
@@ -0,0 +1,854 @@
import type { Messages } from '../types';
export const zhTWVaultMessages: Messages = {
// Notes
'notes.search.placeholder': '搜尋筆記、主機、標籤...',
'notes.action.newNote': '新建筆記',
'notes.action.newGroup': '新建資料夾',
'notes.action.importMarkdown': '匯入 Markdown',
'notes.action.exportNote': '匯出筆記',
'notes.action.exportGroup': '匯出資料夾',
'notes.action.exportAll': '匯出全部筆記',
'notes.import.toast.success': '已匯入 {count} 條筆記',
'notes.import.toast.noNotes': '沒有匯入任何 Markdown 檔案。',
'notes.import.toast.failed': '匯入 Markdown 檔案失敗。',
'notes.import.toast.inProgress': '正在匯入 Markdown 檔案,請稍候。',
'notes.import.toast.skipped': '已跳過 {count} 個不支援的檔案。',
'notes.export.toast.success': '已匯出 {count} 條筆記',
'notes.export.toast.empty': '沒有可匯出的筆記。',
'notes.export.toast.failed': '匯出筆記失敗。',
'notes.codeBlock.copied': '已複製',
'notes.codeBlock.copyFailed': '複製程式碼塊失敗。',
'notes.group.prompt': '資料夾路徑',
'notes.group.all': '筆記本',
'notes.group.ungrouped': '未分組',
'notes.section.notebooks': '筆記本',
'notes.section.notes': '筆記',
'notes.mode.edit': '編輯',
'notes.mode.split': '分割',
'notes.mode.preview': '預覽',
'notes.title.placeholder': '筆記標題',
'notes.editor.placeholder': '在這裡寫 Markdown 筆記...',
'notes.preview.empty': '暫無內容可預覽。',
'notes.empty.title': '還沒有筆記',
'notes.empty.desc': '可以記錄運維手冊、臨時備忘、伺服器交接資訊。',
'notes.empty.group': '這個筆記本里還沒有筆記。',
'notes.search.noResults': '沒有找到筆記。',
// Select Host panel
'selectHost.title': '選擇主機',
'selectHost.noHostsFound': '未找到主機',
'selectHost.newHost': '新建主機',
'selectHost.continue': '繼續',
'selectHost.continueWithCount': '繼續(已選 {count} 個)',
// Quick Connect
'quickConnect.knownHost.title': '確認要連線嗎?',
'quickConnect.knownHost.authenticity': '無法驗證 {hostname} 的真實性。',
'quickConnect.knownHost.fingerprintLabel': '{keyType} fingerprint (SHA256):',
'quickConnect.knownHost.addQuestion': '是否將它加入 Known Hosts?',
'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': '選擇協定',
'protocolSelect.port': '埠:',
// Host Details
'hostDetails.title.details': '主機詳情',
'hostDetails.title.new': '新建主機',
'hostDetails.saveAria': '儲存',
'hostDetails.section.address': '地址',
'hostDetails.hostname.placeholder': 'IP 或 主機名',
'hostDetails.section.general': '通用',
'hostDetails.section.sftp': 'SFTP 設定',
'hostDetails.sftp.sudo': 'Sudo 提權模式',
'hostDetails.sftp.sudo.desc': '使用儲存的密碼自動取得 Root 權限',
'hostDetails.sftp.sudo.passwordWarning': 'Sudo 模式需要密碼。請在上方設定密碼,或確保伺服器允許免密 sudo。',
'hostDetails.sftp.encoding': '檔名編碼',
'hostDetails.sftp.encoding.desc': '選擇用於解碼和傳送 SFTP 檔名的編碼。',
'hostDetails.label.placeholder': '名稱(例如:Production Server)',
'hostDetails.notes.label': '備註',
'hostDetails.notes.placeholder': '硬體設定、專案、客戶、地域、角色...',
'hostDetails.notes.help': '支援 Markdown。請勿在此存放密碼或私鑰。',
'hostDetails.notes.tab.edit': '編輯',
'hostDetails.notes.tab.preview': '預覽',
'hostDetails.notes.preview.empty': '暫無內容可預覽。',
'hostDetails.notes.toggle.show': '顯示備註編輯框',
'hostDetails.notes.toggle.hide': '隱藏備註編輯框',
'hostDetails.group.placeholder': '父級 Group',
'hostDetails.section.credentials': '憑證',
'hostDetails.section.portCredentials': '埠與憑證',
'hostDetails.section.appearance': '外觀',
'hostDetails.distro.title': 'Linux 發行版',
'hostDetails.distro.desc': '可在連線後自動探測,也可以手動覆蓋圖示所用的發行版。',
'hostDetails.icon.title': '主機圖示',
'hostDetails.icon.preview': '圖示預覽',
'vault.panel.resizeWidth': '調整面板寬度',
'hostDetails.icon.sectionTitle': '顏色與圖示',
'hostDetails.icon.desc': '選擇此主機使用的圖示來源和顏色。',
'hostDetails.icon.source': '來源',
'hostDetails.icon.manualLabel': '手動指定',
'hostDetails.icon.tab.brand': '品牌',
'hostDetails.icon.tab.type': '型別',
'hostDetails.icon.mode.auto': '自動',
'hostDetails.icon.mode.custom': '自訂',
'hostDetails.icon.reset': '重置主機圖示',
'hostDetails.icon.colorLabel': '圖示顏色',
'hostDetails.icon.colorDefault': '預設',
'hostDetails.icon.colorAuto': '自動',
'hostDetails.icon.colorManual': '手動',
'hostDetails.icon.colorChoice': '顏色',
'hostDetails.icon.autoColorLabel': '目前顏色',
'hostDetails.icon.showLibrary': '展開圖示庫',
'hostDetails.icon.hideLibrary': '收起圖示庫',
'hostDetails.icon.autoUsesDistro': '使用 Linux 發行版圖示和所選顏色顯示此主機。',
'hostDetails.icon.customOverridesDistro': '內建圖示會替換此主機的 Linux 發行版圖示。',
'hostDetails.icon.option.server': '伺服器',
'hostDetails.icon.option.terminal': '終端',
'hostDetails.icon.option.database': '資料庫',
'hostDetails.icon.option.cloud': '雲主機',
'hostDetails.icon.option.router': '路由器',
'hostDetails.icon.option.shield': '安全',
'hostDetails.icon.option.code': '程式碼',
'hostDetails.icon.option.box': '節點',
'hostDetails.icon.option.globe': '公網',
'hostDetails.icon.option.cpu': '計算',
'hostDetails.icon.option.hard-drive': '儲存',
'hostDetails.icon.option.network': '網路',
'hostDetails.icon.option.wifi': '無線',
'hostDetails.icon.option.lock': '鎖定',
'hostDetails.icon.option.key': '金鑰',
'hostDetails.icon.option.monitor': '顯示器',
'hostDetails.icon.option.container': '容器',
'hostDetails.icon.option.activity': '活動',
'hostDetails.icon.option.zap': '高速',
'hostDetails.icon.option.server-cog': '伺服器設定',
'hostDetails.icon.color.blue': '藍色',
'hostDetails.icon.color.green': '綠色',
'hostDetails.icon.color.red': '紅色',
'hostDetails.icon.color.amber': '琥珀色',
'hostDetails.icon.color.purple': '紫色',
'hostDetails.icon.color.cyan': '青色',
'hostDetails.icon.color.orange': '橙色',
'hostDetails.icon.color.slate': '石板灰',
'hostDetails.icon.color.violet': '紫羅蘭',
'hostDetails.icon.color.pink': '粉色',
'hostDetails.icon.color.rose': '玫瑰紅',
'hostDetails.icon.color.lime': '青檸',
'hostDetails.icon.color.teal': '藍綠色',
'hostDetails.icon.color.sky': '天藍',
'hostDetails.icon.color.indigo': '靛藍',
'hostDetails.icon.color.zinc': '鋅灰',
'hostDetails.icon.color.custom': '自訂顏色',
'hostDetails.distro.mode': '來源',
'hostDetails.distro.mode.auto': '自動探測',
'hostDetails.distro.mode.manual': '手動覆蓋',
'hostDetails.distro.detectedLabel': '目前值',
'hostDetails.distro.manualLabel': '手動指定',
'hostDetails.distro.pending': '首次連線後自動探測',
'hostDetails.distro.unknown': '未知',
'hostDetails.distro.option.linux': '通用 Linux',
'hostDetails.distro.option.ubuntu': 'Ubuntu',
'hostDetails.distro.option.debian': 'Debian',
'hostDetails.distro.option.centos': 'CentOS',
'hostDetails.distro.option.rocky': 'Rocky Linux',
'hostDetails.distro.option.fedora': 'Fedora',
'hostDetails.distro.option.arch': 'Arch Linux',
'hostDetails.distro.option.alpine': 'Alpine',
'hostDetails.distro.option.amazon': 'Amazon Linux',
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
'hostDetails.distro.option.almalinux': 'AlmaLinux',
'hostDetails.distro.option.alinux': '阿里雲 Linux',
'hostDetails.distro.option.openeuler': 'openEuler',
'hostDetails.distro.option.oracle': 'Oracle Linux',
'hostDetails.distro.option.kali': 'Kali Linux',
'hostDetails.distro.option.cisco': '思科',
'hostDetails.distro.option.juniper': '瞻博網路',
'hostDetails.distro.option.huawei': '華為',
'hostDetails.distro.option.h3c': '新華三 / H3C',
'hostDetails.distro.option.hpe': '慧與 / HPE',
'hostDetails.distro.option.mikrotik': 'MikroTik',
'hostDetails.distro.option.fortinet': '飛塔',
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
'hostDetails.distro.option.zyxel': '合勤',
'hostDetails.distro.option.ruijie': '銳捷',
'hostDetails.section.mosh': 'Mosh',
'hostDetails.section.et': 'EternalTerminal',
'hostDetails.et.port': 'ET 服務埠',
'hostDetails.et.port.desc': 'etserver 監聽埠(預設 2022)',
'hostDetails.username.placeholder': '使用者名稱',
'hostDetails.password.placeholder': '密碼',
'hostDetails.password.show': '顯示密碼',
'hostDetails.password.hide': '隱藏密碼',
'hostDetails.password.save': '儲存密碼',
'hostDetails.identity.suggestions': '身份',
'hostDetails.identity.missing': '身份不存在',
'hostDetails.credential.keyCertificate': '金鑰 / 憑證 / 本機金鑰',
'hostDetails.credential.key': '金鑰',
'hostDetails.credential.certificate': '憑證',
'hostDetails.credential.localKeyFile': '本機金鑰檔案',
'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': '搜尋憑證…',
'hostDetails.certs.empty': '暫無憑證',
'hostDetails.agentForwarding': '轉發 SSH 金鑰',
'hostDetails.agentForwarding.desc': '允許遠端伺服器使用本機 SSH 金鑰(例如用於 git 操作)',
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 不可用',
'hostDetails.agentForwarding.agentNotRunningHint': '未偵測到 SSH Agent。請啟用 Windows OpenSSH Authentication Agent 服務,或使用相容的 Agent(如 Bitwarden、1Password、gpg-agent)。',
'hostDetails.section.agentForwarding': 'SSH 代理',
'hostDetails.x11Forwarding': '轉發 X11 圖形應用程式',
'hostDetails.x11Forwarding.desc': '本機執行 X 服務時,讓遠端圖形程式顯示在本機桌面。',
'hostDetails.section.x11Forwarding': 'X11 轉發',
'hostDetails.section.deviceType': '裝置型別',
'hostDetails.deviceType': '網路裝置模式',
'hostDetails.deviceType.desc': '適用於透過 SSH 連線的網路裝置(交換機、路由器、防火牆)。指令將原樣傳送,不進行 Shell 包裝,相容華為 VRP、Cisco IOS 等廠商 CLI。',
'hostDetails.deviceType.warning': 'AI 代理指令將直接傳送,無法取得結束代碼。僅建議在裝置不執行標準 Shell 時啟用。',
'hostDetails.section.sshAlgorithms': 'SSH 演算法',
'hostDetails.section.terminalBehavior': '終端行為',
'hostDetails.lineTimestamps': '顯示輸出時間',
'hostDetails.lineTimestamps.desc': '在終端輸出行旁邊顯示本機時間,不改變終端文字內容。',
'hostDetails.legacyAlgorithms': '允許舊版演算法',
'hostDetails.legacyAlgorithms.desc': '啟用已棄用的 SSH 演算法(diffie-hellman-group1、ssh-dss、3des-cbc 等)以連線老舊網路裝置。',
'hostDetails.legacyAlgorithms.warning': '這些演算法存在已知安全漏洞,僅建議在老舊裝置不支援現代加密時啟用。',
'hostDetails.skipEcdsaHostKey': '跳過 ECDSA 主機金鑰',
'hostDetails.skipEcdsaHostKey.desc': '某些老款華為 / 思科交換機的 ECDSA 主機金鑰簽名不規範,會導致連線報 "signature verification failed"。開啟後用戶端不再 advertise ecdsa-sha2-*,強制使用 RSA / Ed25519。',
'hostDetails.algorithms.advanced': '高階演算法設定',
'hostDetails.algorithms.advanced.desc': '針對單個 host 自訂各分類的演算法清單。不勾選 = 使用預設;勾選子集後將完全替換預設列表。設定錯誤可能導致無法連線。',
'hostDetails.algorithms.inheritedNotice': '目前組已設定以下分類的演算法 override:{categories}。本面板的"恢復預設"只會回到組的設定,而不是 NetCatty 預設列表。若要忽略組的限制,請到組的演算法設定裡取消。',
'hostDetails.algorithms.customized': '已自訂',
'hostDetails.algorithms.reset': '恢復預設',
'hostDetails.algorithms.category.kex': '金鑰交換 (KEX)',
'hostDetails.algorithms.category.cipher': '加密演算法 (Cipher)',
'hostDetails.algorithms.category.hmac': '完整性演算法 (HMAC)',
'hostDetails.algorithms.category.serverHostKey': '主機金鑰 (Host Key)',
'hostDetails.algorithms.category.compress': '壓縮 (Compression)',
'hostDetails.section.keepalive': '工作階段保活',
'hostDetails.keepalive.override': '為此主機單獨設定',
'hostDetails.keepalive.desc': '為該主機使用專屬的保活策略,而不是跟隨全域設定。適用於不響應 keepalive@openssh.com 請求的老舊路由器 / 交換機——將間隔設為 0 可對該主機徹底關閉保活。',
'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': '透過主機代理',
'hostDetails.jumpHosts.hops': '{count} 跳',
'hostDetails.jumpHosts.direct': '直連',
'hostDetails.jumpHosts.configure': '設定代理主機',
'hostDetails.proxy': '透過 HTTP/SOCKS5/指令代理',
'hostDetails.proxy.none': '無',
'hostDetails.proxy.edit': '編輯代理',
'hostDetails.proxy.configure': '設定代理',
'hostDetails.envVars': '環境變數',
'hostDetails.envVars.add': '新增環境變數',
'hostDetails.startupCommand': '啟動指令',
'hostDetails.startupCommand.placeholder': '連線後執行的指令(例如:cd /app && ls)',
'hostDetails.startupCommand.help': 'SSH 連線建立後將自動執行該指令。',
'hostDetails.startupCommand.runMode': '多行執行方式',
'hostDetails.startupCommand.runMode.help': '選擇連線後如何傳送多行啟動指令。',
'hostDetails.startupCommand.runMode.lineDelay': '逐行傳送',
'hostDetails.startupCommand.runMode.paste': '一次性傳送',
'hostDetails.otherProtocols': '其他協定',
'hostDetails.telnetOn': 'Telnet on',
'hostDetails.port': '埠',
'hostDetails.telnet.credentials': '憑證',
'hostDetails.telnet.identity.placeholder': '手動身份',
'hostDetails.telnet.username': 'Telnet 使用者名稱',
'hostDetails.telnet.password': 'Telnet 密碼',
'hostDetails.charset.placeholder': '字元集(例如 UTF-8)',
'hostDetails.telnet.add': '新增 Telnet 協定',
'hostDetails.telnet.setDefault': '預設用 Telnet 連線',
'hostDetails.tags': '標籤',
'hostDetails.group': '分組',
'hostDetails.selectGroup': '選擇分組',
'hostDetails.addTag': '新增標籤...',
'hostDetails.createTag': '建立標籤',
'hostDetails.createGroup': '建立分組',
// Host form (legacy modal)
'hostForm.title.edit': '編輯主機',
'hostForm.title.new': '新建主機',
'hostForm.desc.edit': '更新該主機的連線資訊',
'hostForm.desc.new': '建立一個新的 SSH 主機條目',
'hostForm.field.label': '名稱',
'hostForm.placeholder.label': 'My Production Server',
'hostForm.field.hostname': 'Hostname / IP',
'hostForm.placeholder.hostname': '192.168.1.1',
'hostForm.field.port': '埠',
'hostForm.field.username': '使用者名稱',
'hostForm.field.osType': '作業系統型別',
'hostForm.placeholder.selectOs': '選擇作業系統',
'hostForm.field.group': '分組',
'hostForm.placeholder.group': '例如:AWS、DigitalOcean',
'hostForm.field.tags': '標籤',
'hostForm.placeholder.addTag': '新增標籤…',
'hostForm.auth.method': '認證方式',
'hostForm.auth.password': '密碼',
'hostForm.auth.sshKey': 'SSH金鑰',
'hostForm.auth.selectKey': '選擇 SSH金鑰',
'hostForm.auth.noKeys': '暫無金鑰',
'hostForm.auth.noKeysHint': '鑰匙串中未找到 SSH金鑰,請先建立一個。',
'hostForm.saveHost': '儲存主機',
// Connection logs
'logs.table.date': '日期',
'logs.table.user': '使用者',
'logs.table.host': '主機',
'logs.table.saved': '收藏',
'logs.empty.title': '暫無連線日誌',
'logs.empty.desc': '當你連線主機或開啟本機終端後,這裡會顯示連線歷史。',
'logs.loadMore': '載入更多 ({count} 條)',
'logs.ongoing': '進行中',
'logs.localTerminal': '本機終端',
'logs.action.save': '收藏',
'logs.action.unsave': '取消收藏',
'logs.action.delete': '刪除',
// Log view
'logView.customizeAppearance': '自訂外觀',
'logView.appearance': '外觀',
'logView.readOnly': '只讀',
'logView.export': '匯出',
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': '開啟 SFTP',
'terminal.toolbar.availableAfterConnect': '連線後可用',
'terminal.toolbar.sendYmodem': 'YMODEM 傳送',
'terminal.toolbar.receiveYmodem': 'YMODEM 接收',
'terminal.toolbar.sftp': 'SFTP',
'terminal.toolbar.more': '更多操作',
'terminal.toolbar.scripts': '指令碼',
'terminal.toolbar.history': '指令歷史',
'terminal.toolbar.library': '庫',
'terminal.toolbar.noSnippets': '暫無程式碼片段',
'terminal.toolbar.terminalSettings': '終端設定',
'terminal.toolbar.searchTerminal': '搜尋終端',
'terminal.toolbar.search': '搜尋',
'terminal.toolbar.startSessionLog': '開始工作階段日誌',
'terminal.toolbar.stopSessionLog': '停止工作階段日誌',
'terminal.toolbar.broadcast': '廣播',
'terminal.toolbar.broadcastEnable': '啟用廣播模式',
'terminal.toolbar.broadcastDisable': '關閉廣播模式',
'terminal.toolbar.composeBar': '撰寫欄',
'terminal.composeBar.placeholder': '在此輸入指令,按回車傳送...',
'terminal.composeBar.send': '傳送',
'terminal.composeBar.close': '關閉撰寫欄',
'terminal.composeBar.broadcasting': '正在廣播到所有工作階段',
'terminal.composeBar.resize': '拖拽調整撰寫欄高度',
'terminal.composeBar.manageSnippets': '管理快捷程式碼片段',
'terminal.composeBar.searchSnippets': '搜尋程式碼片段...',
'terminal.composeBar.noPinnedSnippets': '點選 + 固定常用程式碼片段',
'terminal.composeBar.noMatchingSnippets': '沒有相符的程式碼片段',
'terminal.composeBar.pinnedCount': '已固定 {count} 個',
'terminal.composeBar.unpinSnippet': '從快捷欄移除 {label}',
'terminal.composeBar.snippetClickHint': '單擊插入 · Shift+單擊直接傳送',
'terminal.toolbar.focus': '聚焦',
'terminal.toolbar.focusMode': '聚焦模式',
'terminal.toolbar.detach': '移出到獨立標籤',
'terminal.toolbar.encoding': '終端編碼',
'terminal.toolbar.encoding.utf8': 'UTF-8',
'terminal.toolbar.encoding.gb18030': 'GB18030',
'terminal.toolbar.closeSession': '關閉工作階段',
'terminal.toolbar.hostHighlight.title': '主機關鍵字標示',
'terminal.toolbar.hostHighlight.noRules': '此主機未定義自訂標示規則',
'terminal.toolbar.hostHighlight.addRule': '新增新規則',
'terminal.toolbar.hostHighlight.labelPlaceholder': '標籤(例如:錯誤)',
'terminal.toolbar.hostHighlight.patternPlaceholder': '正規表示式(例如:\\bfailed\\b)',
'terminal.toolbar.hostHighlight.invalidPattern': '無效的正規表示式',
'terminal.toolbar.hostHighlight.clearAll': '清除全部',
'terminal.toolbar.hostHighlight.changeColor': '更改標示顏色',
'terminal.toolbar.hostHighlight.selectColor': '選擇新規則的顏色',
'terminal.statusbar.copyHostname.label': '複製主機地址',
'terminal.statusbar.copyHostname.tooltip': '複製主機地址({hostname})',
'terminal.statusbar.copyHostname.toast': '已複製主機地址:{hostname}',
'terminal.statusbar.copyHostname.error': '複製主機地址失敗',
'terminal.serverStats.cpu': 'CPU 使用率',
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
'terminal.serverStats.memory': '記憶體使用',
'terminal.serverStats.memoryDetails': '記憶體詳情',
'terminal.serverStats.memUsed': '已用',
'terminal.serverStats.memBuffers': '緩衝區',
'terminal.serverStats.memCached': '快取',
'terminal.serverStats.memFree': '空閒',
'terminal.serverStats.swap': '交換空間',
'terminal.serverStats.swapUsed': '已用交換',
'terminal.serverStats.swapFree': '空閒交換',
'terminal.serverStats.swapTotal': '總計',
'terminal.serverStats.topProcesses': '記憶體佔用前十程序',
'terminal.serverStats.disk': '磁碟使用(根分割槽)',
'terminal.serverStats.diskDetails': '已掛載磁碟',
'terminal.serverStats.network': '網路速度',
'terminal.serverStats.latency': 'SSH 網路延遲',
'terminal.serverStats.networkDetails': '網路介面',
'terminal.serverStats.noData': '暫無資料',
'terminal.dragDrop.localTitle': '拖放以插入路徑',
'terminal.dragDrop.localMessage': '檔案路徑將被插入到終端',
'terminal.dragDrop.remoteTitle': '拖放以上傳檔案',
'terminal.dragDrop.remoteZmodemMessage': '檔案將透過 ZMODEM(PTY)上傳',
'terminal.dragDrop.remoteSftpMessage': '檔案將透過 SFTP 上傳',
'terminal.dragDrop.noFiles': '沒有可上傳的檔案',
'terminal.dragDrop.notConnected': '無法拖放檔案 - 終端未連線',
'terminal.dragDrop.errorTitle': '拖放錯誤',
'terminal.dragDrop.errorMessage': '處理拖放檔案失敗',
'terminal.search.placeholder': '搜尋…',
'terminal.search.noResults': '無結果',
'terminal.search.prevMatch': '上一個相符 (Shift+Enter)',
'terminal.search.nextMatch': '下一個相符 (Enter)',
'terminal.menu.copy': '複製',
'terminal.menu.paste': '貼上',
'terminal.menu.addSelectionToAI': '新增到對話',
'terminal.menu.pasteSelection': '貼上選取文字',
'terminal.menu.selectAll': '全選',
'terminal.menu.reconnect': '重新連線',
'terminal.menu.sendYmodem': 'YMODEM 傳送',
'terminal.menu.receiveYmodem': 'YMODEM 接收',
'terminal.menu.splitHorizontal': '水平分割',
'terminal.menu.splitVertical': '垂直分割',
'terminal.menu.clearBuffer': '清空緩衝區',
'terminal.menu.closeTerminal': '關閉終端',
'terminal.menu.rename': '重新命名',
'terminal.menu.detach': '從工作區移出',
'terminal.menu.detachSession': '移出 {name}',
'terminal.ymodem.selectFile': '選擇要傳送的檔案',
'terminal.ymodem.allFiles': '所有檔案',
'terminal.ymodem.started': '正在透過 YMODEM 傳送 {fileName}',
'terminal.ymodem.complete': 'YMODEM 已傳送 {fileName}',
'terminal.ymodem.failed': 'YMODEM 傳送失敗',
'terminal.ymodem.selectReceiveDirectory': '選擇接收檔案儲存位置',
'terminal.ymodem.receiveStarted': '正在透過 YMODEM 接收...',
'terminal.ymodem.receiveComplete': 'YMODEM 已接收 {fileName}',
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM 已接收 {count} 個檔案',
'terminal.ymodem.receiveEmpty': '沒有接收到 YMODEM 檔案',
'terminal.ymodem.receiveFailed': 'YMODEM 接收失敗',
'terminal.ymodem.unavailable': 'YMODEM 目前不可用',
'terminal.selection.addToAI': '新增到對話',
'terminal.selection.addToAIDesc': '將選取的終端輸出作為附件加入 AI 草稿',
'terminal.auth.password': '密碼',
'terminal.auth.sshKey': 'SSH Key',
'terminal.auth.username': '使用者名稱',
'terminal.auth.username.placeholder': 'root',
'terminal.auth.passwordLabel': '密碼',
'terminal.auth.password.placeholder': '輸入密碼',
'terminal.auth.passphrase': '密碼短語',
'terminal.auth.passphrase.placeholder': '可選:所選私鑰的密碼短語',
'terminal.auth.certificate': '憑證',
'terminal.auth.selectKey': '選擇金鑰',
'terminal.auth.retryMessage': '驗證失敗。請檢查憑證後再試一次。',
'terminal.auth.retryLog': '驗證失敗,請再試一次。',
'terminal.auth.noKeysHint': '暫無金鑰,請先在鑰匙串中新增。',
'terminal.auth.continueSave': '繼續並儲存',
'terminal.auth.credentialsUnavailable': '目前裝置無法解密已儲存憑證,請重新輸入並再次儲存。',
'terminal.auth.jumpCredentialsUnavailable': '某個跳板機的已儲存憑證無法在目前裝置解密,請到主機設定中重新填寫。',
'terminal.auth.proxyCredentialsUnavailable': '代理憑證無法在目前裝置解密,請到主機設定中重新填寫代理密碼。',
'terminal.auth.keyUnavailableFallbackPassword': '已儲存 SSH 金鑰在目前裝置不可用,改用密碼認證。',
'terminal.connectionErrorTitle': '連線錯誤',
'terminal.progress.timeoutIn': '將在 {seconds}s 後超時',
'terminal.progress.waitingForUserInput': '等待使用者輸入',
'terminal.progress.disconnected': '已斷開',
'terminal.progress.cancelling': '正在取消...',
'terminal.progress.startOver': '重新開始',
'terminal.connection.dismissDisconnectedDialog': '關閉斷連提示',
'terminal.connection.chainOf': 'Chain {current} / {total}',
'terminal.connection.showLogs': '顯示日誌',
'terminal.connection.hideLogs': '隱藏日誌',
'terminal.connection.protocol.ssh': 'SSH',
'terminal.connection.protocol.telnet': 'Telnet',
'terminal.connection.protocol.mosh': 'Mosh',
'terminal.connection.protocol.serial': '序列埠',
'terminal.connection.protocol.local': '本機終端',
'terminal.hostKey.unknownTitle': '確認主機指紋',
'terminal.hostKey.changedTitle': '主機指紋已變化',
'terminal.hostKey.unknownDescription': '尚未確認 {host} 的真實性。',
'terminal.hostKey.changedDescription': '{host} 的已儲存指紋與目前伺服器不一致。',
'terminal.hostKey.fingerprintLabel': '{keyType} 指紋為 SHA256:',
'terminal.hostKey.savedFingerprintLabel': '已儲存的指紋',
'terminal.hostKey.unknownHint': '如果這個指紋屬於你預期連線的伺服器,可以記住它。',
'terminal.hostKey.changedHint': '只有在你確認這台主機確實變更過時才繼續。',
'terminal.hostKey.addAndContinue': '記住並繼續',
'terminal.hostKey.updateAndContinue': '更新並繼續',
'terminal.themeModal.title': 'Terminal 外觀',
'terminal.themeModal.tab.theme': '主題',
'terminal.themeModal.tab.font': '字型',
'terminal.themeModal.tab.custom': '自訂',
'terminal.themeModal.globalTheme': '全域主題',
'terminal.themeModal.globalFont': '全域字型',
'terminal.themeModal.fontSize': '字型大小',
'terminal.themeModal.fontWeight': '字型粗細',
'terminal.themeModal.livePreview': '即時預覽',
'terminal.themeModal.themeType': '{type} 主題',
'terminal.hiddenTheme.title': '目前隱藏主題',
'terminal.hiddenTheme.desc': '這個主題已從手動選擇列表中隱藏;當你選擇其他可見主題後,它會被替換。',
'topTabs.toggleTheme.systemExitTitle': '目前正在跟隨系統主題',
'topTabs.toggleTheme.systemExitMessage': '請到設定裡選擇固定的淺色或深色主題。',
'topTabs.toggleTheme.openSettings': '開啟設定',
// Custom Themes
'terminal.customTheme.section': '自訂主題',
'terminal.customTheme.yourThemes': '我的主題',
'terminal.customTheme.new': '新建主題',
'terminal.customTheme.newDesc': '克隆目前主題並自訂',
'terminal.customTheme.newTitle': '新建自訂主題',
'terminal.customTheme.editTitle': '編輯主題',
'terminal.customTheme.import': '匯入 .itermcolors',
'terminal.customTheme.importDesc': '從 iTerm2 配色方案檔案匯入',
'terminal.customTheme.importError': '無法解析所選檔案,請確保它是有效的 .itermcolors XML 檔案。',
'terminal.customTheme.delete': '刪除主題',
'terminal.customTheme.confirmDelete': '確認刪除',
'terminal.customTheme.name': '名稱',
'terminal.customTheme.namePlaceholder': '我的自訂主題',
'terminal.customTheme.type': '型別',
'terminal.customTheme.group.general': '通用',
'terminal.customTheme.group.normal': '標準色',
'terminal.customTheme.group.bright': '標示色',
'terminal.customTheme.color.background': '背景',
'terminal.customTheme.color.foreground': '前景',
'terminal.customTheme.color.cursor': '游標',
'terminal.customTheme.color.selection': '選區',
'terminal.customTheme.color.black': '黑色',
'terminal.customTheme.color.red': '紅色',
'terminal.customTheme.color.green': '綠色',
'terminal.customTheme.color.yellow': '黃色',
'terminal.customTheme.color.blue': '藍色',
'terminal.customTheme.color.magenta': '品紅',
'terminal.customTheme.color.cyan': '青色',
'terminal.customTheme.color.white': '白色',
'terminal.customTheme.color.brightBlack': '亮黑',
'terminal.customTheme.color.brightRed': '亮紅',
'terminal.customTheme.color.brightGreen': '亮綠',
'terminal.customTheme.color.brightYellow': '亮黃',
'terminal.customTheme.color.brightBlue': '亮藍',
'terminal.customTheme.color.brightMagenta': '亮品紅',
'terminal.customTheme.color.brightCyan': '亮青色',
'terminal.customTheme.color.brightWhite': '亮白',
'cloudSync.gate.title': '端到端加密同步',
'cloudSync.gate.desc':
'資料會在本機加密後再同步,雲端不會看到明文。設定主金鑰以啟用安全同步。',
'cloudSync.gate.masterKey': '主金鑰',
'cloudSync.gate.confirmMasterKey': '確認主金鑰',
'cloudSync.gate.placeholder': '輸入一個強密碼',
'cloudSync.gate.confirmPlaceholder': '再次輸入密碼',
'cloudSync.gate.mismatch': '兩次輸入的密碼不一致',
'cloudSync.gate.warning':
'我已瞭解:如果忘記主金鑰,資料無法恢復,且沒有密碼重置功能。',
'cloudSync.gate.enableVault': '啟用加密 Vault',
'cloudSync.gate.enabledToast': '已啟用加密 Vault',
'cloudSync.gate.setupFailed': '設定主金鑰失敗',
'cloudSync.passwordStrength.tooShort': '太短',
'cloudSync.passwordStrength.weak': '弱',
'cloudSync.passwordStrength.moderate': '一般',
'cloudSync.passwordStrength.strong': '強',
'cloudSync.passwordStrength.veryStrong': '非常強',
'cloudSync.provider.notConnected': '未連線',
'cloudSync.provider.sync': '同步',
'cloudSync.provider.connect': '連線',
'cloudSync.provider.connecting': '連線中...',
'cloudSync.provider.webdav': 'WebDAV',
'cloudSync.provider.webdav.desc': '連線到自建 WebDAV 端點',
'cloudSync.provider.s3': 'S3 相容儲存',
'cloudSync.provider.s3.desc': '連線到 S3 相容物件儲存',
'cloudSync.provider.comingSoon': '即將支援',
'cloudSync.webdav.title': 'WebDAV 設定',
'cloudSync.webdav.desc': '設定 WebDAV 端點用於加密同步。',
'cloudSync.webdav.endpoint': '端點地址',
'cloudSync.webdav.authType': '認證方式',
'cloudSync.webdav.auth.basic': 'Basic',
'cloudSync.webdav.auth.digest': 'Digest',
'cloudSync.webdav.auth.token': 'Token',
'cloudSync.webdav.username': '使用者名稱',
'cloudSync.webdav.password': '密碼',
'cloudSync.webdav.token': 'Token',
'cloudSync.webdav.showSecret': '顯示金鑰',
'cloudSync.webdav.allowInsecure': '允許不安全的連線(忽略憑證錯誤)',
'cloudSync.webdav.validation.endpoint': '請輸入有效的 WebDAV 端點。',
'cloudSync.webdav.validation.credentials': '請輸入使用者名稱和密碼。',
'cloudSync.webdav.validation.token': '請輸入 Token。',
'cloudSync.s3.title': 'S3 設定',
'cloudSync.s3.desc': '連線到 S3 相容物件儲存以進行加密同步。',
'cloudSync.s3.endpoint': '端點地址',
'cloudSync.s3.region': 'Region',
'cloudSync.s3.bucket': 'Bucket',
'cloudSync.s3.accessKeyId': 'Access Key ID',
'cloudSync.s3.secretAccessKey': 'Secret Access Key',
'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 設定',
'cloudSync.smb.desc': '連線到 SMB/CIFS 檔案共享以進行加密同步。',
'cloudSync.smb.share': '共享路徑',
'cloudSync.smb.username': '使用者名稱',
'cloudSync.smb.password': '密碼',
'cloudSync.smb.domain': '域(可選)',
'cloudSync.smb.domainPlaceholder': '例如:WORKGROUP',
'cloudSync.smb.port': '埠(可選)',
'cloudSync.smb.showSecret': '顯示密碼',
'cloudSync.smb.validation.share': '共享路徑必填。',
'cloudSync.smb.validation.port': '埠必須是 1 到 65535 之間的數字。',
'cloudSync.connect.smb.success': 'SMB 已連線',
'cloudSync.connect.smb.failedTitle': 'SMB 連線失敗',
'cloudSync.provider.smb': 'SMB 共享',
'cloudSync.connect.webdav.success': 'WebDAV 已連線',
'cloudSync.connect.webdav.failedTitle': 'WebDAV 連線失敗',
'cloudSync.connect.s3.success': 'S3 已連線',
'cloudSync.connect.s3.failedTitle': 'S3 連線失敗',
'cloudSync.lastSync.never': '從未',
'cloudSync.lastSync.justNow': '剛剛',
'cloudSync.lastSync.minutesAgo': '{minutes} 分鐘前',
'cloudSync.changeKey': '更改 Key',
'cloudSync.providers.title': '雲服務',
'cloudSync.syncAll': '同步所有已連線的服務',
'cloudSync.autoSync.title': '自動同步',
'cloudSync.autoSync.desc': '發生變更時自動同步',
'cloudSync.strategy.title': '同步策略',
'cloudSync.strategy.desc': '當本機和雲端都發生變化時,選擇如何處理。',
'cloudSync.strategy.smartMerge': '智慧合併(推薦)',
'cloudSync.strategy.smartMergeDesc': '儘量保留兩邊的變化;如果無法安全判斷,會再讓你手動選擇。',
'cloudSync.strategy.preferCloud': '雲端優先',
'cloudSync.strategy.preferCloudDesc': '兩邊都有變化時,下載雲端版本,並替換本機變化。',
'cloudSync.strategy.preferLocal': '本機優先',
'cloudSync.strategy.preferLocalDesc': '兩邊都有變化時,上傳本機版本,並替換雲端變化。',
'cloudSync.status.title': '同步狀態',
'cloudSync.status.localVersion': '本機版本',
'cloudSync.status.remoteVersion': '遠端版本',
'cloudSync.history.title': '同步歷史',
'cloudSync.history.upload': '上傳',
'cloudSync.history.download': '下載',
'cloudSync.history.resolved': '已解決',
'cloudSync.history.error': '錯誤',
'cloudSync.localBackups.title': '本機備份歷史',
'cloudSync.localBackups.desc': 'Netcatty 會在版本變化前,以及恢復主機庫前,自動留下一份本機恢復點。',
'cloudSync.localBackups.retentionTitle': '備份保留數量',
'cloudSync.localBackups.retentionDesc': '設定 Netcatty 最多保留多少份本機備份。',
'cloudSync.localBackups.maxCount': '最多保留',
'cloudSync.localBackups.maxSaved': '已儲存保留數量:{count}',
'cloudSync.localBackups.maxInvalid': '請輸入 1 到 100 之間的數字。',
'cloudSync.localBackups.empty': '還沒有本機備份。',
'cloudSync.localBackups.reason.appVersionChange': '版本變化前',
'cloudSync.localBackups.reason.beforeRestore': '恢復前',
'cloudSync.localBackups.versionChange': '{from} -> {to}',
'cloudSync.localBackups.counts': '{hosts} 台主機,{keys} 個金鑰,{snippets} 個程式碼片段,{notes} 條筆記',
'cloudSync.localBackups.restore': '恢復',
'cloudSync.localBackups.restoreSuccess': '已恢復本機備份。',
'cloudSync.localBackups.restoreFailedTitle': '恢復失敗',
'cloudSync.localBackups.restoreMissing': '找不到這份備份。',
'cloudSync.localBackups.protectiveBackupFailed': '無法建立保護性備份,已中止恢復以避免覆蓋目前資料。請先解決底層問題(例如鑰匙串存取)後重試。詳情:{message}',
'cloudSync.localBackups.restoreConfirmTitle': '確認恢復此備份?',
'cloudSync.localBackups.restoreConfirmDesc': '目前的主機、金鑰、程式碼片段與設定將被替換為此備份中的內容。系統會先自動建立一個保護性快照,便於復原。',
'cloudSync.localBackups.restoreConfirmButton': '恢復',
'cloudSync.localBackups.restoreConfirmCancel': '取消',
'cloudSync.localBackups.unavailableTitle': '無法使用本機備份',
'cloudSync.localBackups.unavailableDesc': '目前平台未提供受支援的安全金鑰庫,Netcatty 無法安全地寫入本機備份。請在支援系統鑰匙串的環境中執行,或改用雲同步保留恢復點。',
'cloudSync.localBackups.lockedTitle': '需要主金鑰',
'cloudSync.localBackups.lockedDesc': '請先設定或解鎖主金鑰再恢復備份,以確保恢復後的憑證仍保持加密。',
'cloudSync.revisionHistory.viewButton': '歷史版本',
'cloudSync.revisionHistory.title': '主機庫版本歷史',
'cloudSync.revisionHistory.description': '瀏覽並恢復 Gist 修訂歷史中的舊版主機庫資料。',
'cloudSync.revisionHistory.empty': '未找到修訂記錄。',
'cloudSync.revisionHistory.current': '目前版本',
'cloudSync.revisionHistory.revision': '修訂',
'cloudSync.revisionHistory.revisionPreview': '修訂內容',
'cloudSync.revisionHistory.device': '裝置',
'cloudSync.revisionHistory.hosts': '主機',
'cloudSync.revisionHistory.keys': '金鑰',
'cloudSync.revisionHistory.snippets': '程式碼片段',
'cloudSync.revisionHistory.notes': '筆記',
'cloudSync.revisionHistory.identities': '身份',
'cloudSync.revisionHistory.restoreButton': '恢復此版本',
'cloudSync.revisionHistory.restored': '已從選取的修訂恢復主機庫資料。',
'cloudSync.revisionHistory.revisionNotFound': '修訂未找到或不包含主機庫資料。',
'cloudSync.revisionHistory.decryptFailed': '無法解密此修訂。可能是使用了不同的主金鑰加密的。',
'cloudSync.changeKey.title': '更改主金鑰',
'cloudSync.changeKey.current': '目前主金鑰',
'cloudSync.changeKey.new': '新的主金鑰',
'cloudSync.changeKey.confirmNew': '確認新的主金鑰',
'cloudSync.changeKey.currentPlaceholder': '輸入目前主金鑰',
'cloudSync.changeKey.newPlaceholder': '輸入新的主金鑰',
'cloudSync.changeKey.confirmPlaceholder': '再次輸入新的主金鑰',
'cloudSync.changeKey.fillAll': '請填寫所有欄位',
'cloudSync.changeKey.minLength': '新的主金鑰至少 8 個字元',
'cloudSync.changeKey.notMatch': '兩次輸入的主金鑰不一致',
'cloudSync.changeKey.incorrectCurrent': '目前主金鑰不正確',
'cloudSync.changeKey.failed': '更改主金鑰失敗',
'cloudSync.changeKey.desc': '這將重新加密 Vault,請務必記住新的主金鑰。',
'cloudSync.changeKey.showKeys': '顯示主金鑰',
'cloudSync.changeKey.updatedToast': '主金鑰已更新',
'cloudSync.changeKey.updateButton': '更新主金鑰',
'cloudSync.unlock.title': '輸入主金鑰',
'cloudSync.unlock.masterKey': '主金鑰',
'cloudSync.unlock.desc': '僅需輸入一次主金鑰以啟用加密同步,之後會透過系統 Keychain 安全儲存。',
'cloudSync.unlock.placeholder': '輸入你的主金鑰',
'cloudSync.unlock.empty': '請輸入主金鑰',
'cloudSync.unlock.incorrect': '主金鑰不正確',
'cloudSync.unlock.failed': '解鎖 Vault 失敗',
'cloudSync.unlock.showKey': '顯示主金鑰',
'cloudSync.unlock.notNow': '暫不',
'cloudSync.unlock.readyToast': 'Vault 已就緒',
'cloudSync.unlock.unlockButton': '解鎖',
'cloudSync.header.vaultReady': 'Vault 已就緒',
'cloudSync.header.preparingVault': '正在準備 Vault...',
'cloudSync.header.providersConnected': '已連線 {count} 個 provider',
'cloudSync.githubFlow.title': '連線到 GitHub',
'cloudSync.githubFlow.desc': '複製下面的 code,並在 GitHub 頁面輸入以授權 Netcatty。',
'cloudSync.githubFlow.copyCode': '複製 code',
'cloudSync.githubFlow.copied': '已複製',
'cloudSync.githubFlow.openGitHub': '開啟 GitHub',
'cloudSync.githubFlow.waiting': '等待授權...',
'cloudSync.conflict.title': '偵測到版本衝突',
'cloudSync.conflict.desc': '選擇保留哪個版本',
'cloudSync.conflict.local': '本機',
'cloudSync.conflict.cloud': '雲端',
'cloudSync.conflict.detailsTitle': '發生變化的資料',
'cloudSync.conflict.detailsCounts': '本機 {local} · 雲端 {cloud} · 衝突 {conflicts}',
'cloudSync.conflict.entity.hosts': '主機',
'cloudSync.conflict.entity.keys': '金鑰',
'cloudSync.conflict.entity.identities': '身份',
'cloudSync.conflict.entity.proxyProfiles': '代理設定',
'cloudSync.conflict.entity.snippets': '片段',
'cloudSync.conflict.entity.notes': '筆記',
'cloudSync.conflict.entity.noteGroups': '筆記分組',
'cloudSync.conflict.entity.customGroups': '分組',
'cloudSync.conflict.entity.snippetPackages': '片段包',
'cloudSync.conflict.entity.portForwardingRules': '埠轉發',
'cloudSync.conflict.entity.groupConfigs': '分組設定',
'cloudSync.conflict.entity.settings': '設定',
'cloudSync.conflict.keepLocal': '覆蓋雲端(保留本機)',
'cloudSync.conflict.useCloud': '下載雲端(覆蓋本機)',
'cloudSync.connect.browserContinue': '請在瀏覽器中完成授權',
'cloudSync.connect.browserCancelled': '已取消上一個瀏覽器授權流程',
'cloudSync.connect.github.success': 'GitHub 已連線',
'cloudSync.connect.github.failedTitle': 'GitHub 連線失敗',
'cloudSync.connect.github.timeout': '連線 GitHub 超時,請檢查網路或代理設定。',
'cloudSync.connect.github.networkError': '無法存取 GitHub,請檢查網路或代理設定。',
'cloudSync.connect.google.failedTitle': 'Google 連線失敗',
'cloudSync.connect.onedrive.failedTitle': 'OneDrive 連線失敗',
'cloudSync.sync.success': '已同步到 {provider}',
'cloudSync.sync.failed': '同步失敗',
'cloudSync.sync.failedTitle': '同步失敗',
'cloudSync.sync.errorTitle': '同步錯誤',
'cloudSync.resolve.downloaded': '已下載雲端資料',
'cloudSync.resolve.uploaded': '已上傳本機資料',
'cloudSync.resolve.failedTitle': '衝突處理失敗',
'cloudSync.clearLocal.title': '清空本機資料',
'cloudSync.clearLocal.desc': '重置本機版本和同步歷史。下次同步將從雲端下載。',
'cloudSync.clearLocal.button': '清空',
'cloudSync.clearLocal.dialog.title': '清空本機 Vault 資料?',
'cloudSync.clearLocal.dialog.desc': '這將重置本機版本為 0 並清除同步歷史。下次同步時會從雲端下載資料,替換本機資料。',
'cloudSync.clearLocal.dialog.cancel': '取消',
'cloudSync.clearLocal.dialog.confirm': '確認清空',
'cloudSync.clearLocal.toast.title': '本機資料已清空',
'cloudSync.clearLocal.toast.desc': '本機版本已重置為 0。同步以從雲端下載資料。',
// Common (additional)
'common.searchPlaceholder': '搜尋...',
'common.import': '匯入',
'common.generate': '生成',
'common.delete': '刪除',
'common.edit': '編輯',
'sftp.context.openWithDefault': '系統預設程式開啟',
'common.clear': '清除',
'common.optional': '可選',
'common.selectPlaceholder': '請選擇...',
'common.error': '錯誤',
'common.validation': '驗證',
'common.saveChanges': '儲存修改',
'common.advanced': '高階',
'common.selectAHostPlaceholder': '選擇主機...',
// Actions
'action.duplicate': '複製',
'action.open': '開啟',
'action.copy': '複製',
'action.run': '執行',
'action.start': '啟動',
'action.stop': '停止',
// Port Forwarding (form)
'pf.form.labelPlaceholder': '規則標籤',
'pf.form.intermediateHost': '中轉主機 *',
'pf.form.createRule': '建立規則',
'pf.form.openWizard': '打開向導',
'pf.form.openWizardTitle': '開啟埠轉發精靈',
'pf.action.newForwarding': '新建轉發',
'pf.view.grid': '網格',
'pf.view.list': '列表',
'pf.rule.summary.local': '本機 {bindAddress}:{localPort} -> SSH 隧道 -> 遠端 {remoteHost}:{remotePort}',
'pf.rule.summary.remote': '遠端 {bindAddress}:{localPort} -> SSH 隧道 -> 本機 {remoteHost}:{remotePort}',
'pf.rule.summary.dynamic': 'SOCKS 監聽於 {bindAddress}:{localPort}',
'pf.tooltip.relayHost': '中轉主機',
'pf.tooltip.hostLabel': '主機',
'pf.tooltip.hostAddress': '地址',
'pf.tooltip.noHost': '未設定中轉主機',
'pf.tooltip.localDesc': '本機埠轉發:透過 SSH 隧道存取遠端服務',
'pf.tooltip.remoteDesc': '遠端埠轉發:將本機服務公開給遠端主機',
'pf.tooltip.dynamicDesc': '動態 SOCKS 代理:透過 SSH 隧道轉發流量',
'pf.deleteActive.title': '刪除正在執行的埠轉發?',
'pf.deleteActive.desc': '埠轉發規則 "{label}" 目前正在執行。刪除前將先關閉轉發連線。',
'pf.deleteActive.confirm': '關閉並刪除',
'pf.form.autoStart': '自動啟動',
'pf.form.autoStartDesc': '應用程式啟動時自動開啟此規則',
// SFTP (pane + conflict)
'sftp.pane.local': '本機',
'sftp.pane.remote': '遠端',
'sftp.pane.selectHost': '選擇主機',
'sftp.pane.selectHostToStart': '先選擇一個主機',
'sftp.pane.chooseFilesystem': '選擇要瀏覽的本機或遠端檔案系統',
'sftp.tabs.addTab': '新建標籤頁',
'sftp.tabs.closeTab': '關閉標籤頁',
'sftp.tabs.newTab': '新標籤頁',
'sftp.tabs.copyDefaultPath': '複製標籤頁(預設路徑)',
'sftp.tabs.copyCurrentPath': '複製並跳轉到目前路徑',
'sftp.conflict.title': '檔案衝突',
'sftp.conflict.desc': '目標位置已存在同名檔案',
'sftp.conflict.alreadyExistsSuffix': '已存在',
'sftp.conflict.existingFile': '已有檔案',
'sftp.conflict.newFile': '新檔案',
'sftp.conflict.size': '大小:',
'sftp.conflict.modified': '修改時間:',
'sftp.conflict.applyToAll': '將此操作套用到剩餘的 {count} 個衝突',
'sftp.conflict.action.stop': '停止',
'sftp.conflict.action.skip': '跳過',
'sftp.conflict.action.keepBoth': '保留兩者',
'sftp.conflict.action.duplicate': '建立副本',
'sftp.conflict.action.merge': '合併',
'sftp.conflict.action.replace': '替換',
// SFTP Upload Phases
'sftp.upload.phase.compressing': '正在壓縮',
'sftp.upload.phase.uploading': '正在上傳',
'sftp.upload.phase.extracting': '正在解壓',
'sftp.upload.phase.compressed': '壓縮傳輸',
};
+4
View File
@@ -1,11 +1,15 @@
import en, { type Messages } from './locales/en';
import zhCN from './locales/zh-CN';
import zhTW from './locales/zh-TW';
import ru from './locales/ru';
// Keep keys stable; add new locales by adding another import and map entry.
export { type Messages };
export const MESSAGES_BY_LOCALE: Record<string, Messages> = {
en,
ru,
'zh-CN': zhCN,
'zh-TW': zhTW,
};
+496
View File
@@ -0,0 +1,496 @@
import type { SyncPayload } from '../domain/sync';
import {
STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION,
STORAGE_KEY_LOCAL_VAULT_BACKUP_MAX_COUNT,
STORAGE_KEY_VAULT_APPLY_IN_PROGRESS,
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
} from '../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
import { getCloudSyncManager } from '../infrastructure/services/CloudSyncManager';
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
import { hasMeaningfulSyncData } from './syncPayload';
/**
* Snapshot the current sync data version (the integer that increments
* on each successful cloud sync). Returns undefined when the value is
* 0 (never synced) or unavailable, so the UI can fall back to timestamp.
*/
function captureCurrentSyncDataVersion(): number | undefined {
try {
const state = getCloudSyncManager().getState();
const v = state.localVersion;
return typeof v === 'number' && v > 0 ? v : undefined;
} catch {
return undefined;
}
}
export type LocalVaultBackupReason = 'app_version_change' | 'before_restore';
export interface LocalVaultBackupPreview {
id: string;
createdAt: number;
reason: LocalVaultBackupReason;
/** Sync-data version at the time the snapshot was taken (the integer
* that the CloudSyncManager increments on each successful cloud sync).
* Undefined when the user had never synced yet, or for legacy backups
* persisted before this field was added. */
syncDataVersion?: number;
/** App version transition fields, only for `app_version_change` records.
* Kept for backward compatibility with already-persisted backups. */
sourceAppVersion?: string;
targetAppVersion?: string;
fingerprint: string;
preview: {
hostCount: number;
keyCount: number;
snippetCount: number;
noteCount: number;
identityCount: number;
portForwardingRuleCount: number;
};
}
export interface LocalVaultBackupDetails {
backup: LocalVaultBackupPreview;
payload: SyncPayload;
}
export const DEFAULT_LOCAL_VAULT_BACKUP_MAX_COUNT = 20;
export const MIN_LOCAL_VAULT_BACKUP_MAX_COUNT = 1;
export const MAX_LOCAL_VAULT_BACKUP_MAX_COUNT = 100;
export const sanitizeLocalVaultBackupMaxCount = (value: number): number => {
if (!Number.isFinite(value)) return DEFAULT_LOCAL_VAULT_BACKUP_MAX_COUNT;
return Math.max(
MIN_LOCAL_VAULT_BACKUP_MAX_COUNT,
Math.min(MAX_LOCAL_VAULT_BACKUP_MAX_COUNT, Math.round(value)),
);
};
export const getLocalVaultBackupMaxCount = (): number => {
const stored = localStorageAdapter.readNumber(STORAGE_KEY_LOCAL_VAULT_BACKUP_MAX_COUNT);
return sanitizeLocalVaultBackupMaxCount(
stored ?? DEFAULT_LOCAL_VAULT_BACKUP_MAX_COUNT,
);
};
export const setLocalVaultBackupMaxCount = (value: number): number => {
const sanitized = sanitizeLocalVaultBackupMaxCount(value);
localStorageAdapter.writeNumber(STORAGE_KEY_LOCAL_VAULT_BACKUP_MAX_COUNT, sanitized);
return sanitized;
};
export async function trimLocalVaultBackups(maxCount = getLocalVaultBackupMaxCount()): Promise<void> {
const bridge = netcattyBridge.get();
await bridge?.trimVaultBackups?.({ maxCount });
}
export async function getLocalVaultBackupCapabilities(): Promise<{
encryptionAvailable: boolean;
}> {
const bridge = netcattyBridge.get();
const caps = await bridge?.getVaultBackupCapabilities?.();
// Conservatively treat a missing bridge (non-Electron environments, early
// boot) as unavailable so callers fall back to the locked-down UI path
// instead of assuming capabilities they can't verify.
return { encryptionAvailable: Boolean(caps?.encryptionAvailable) };
}
export async function listLocalVaultBackups(): Promise<LocalVaultBackupPreview[]> {
const bridge = netcattyBridge.get();
const entries = await bridge?.listVaultBackups?.();
return Array.isArray(entries) ? entries : [];
}
export async function readLocalVaultBackup(id: string): Promise<LocalVaultBackupDetails | null> {
const bridge = netcattyBridge.get();
if (!bridge?.readVaultBackup) return null;
return bridge.readVaultBackup({ id });
}
export async function openLocalVaultBackupDir(): Promise<void> {
const bridge = netcattyBridge.get();
await bridge?.openVaultBackupDir?.();
}
export async function createLocalVaultBackup(
payload: SyncPayload,
options: {
reason: LocalVaultBackupReason;
syncDataVersion?: number;
sourceAppVersion?: string;
targetAppVersion?: string;
maxCount?: number;
},
): Promise<LocalVaultBackupPreview | null> {
// Intentional: an empty-vault backup has nothing to restore from, so we
// early-return instead of writing a zero-entry record. Callers that rely
// on a backup (protective-before-restore, version-change on first run)
// must treat `null` as "no safety net this time" and continue — blocking
// the user's flow on a missing backup would be worse than allowing the
// apply to proceed without one.
if (!hasMeaningfulSyncData(payload)) {
return null;
}
const bridge = netcattyBridge.get();
if (!bridge?.createVaultBackup) {
return null;
}
try {
const result = await bridge.createVaultBackup({
payload,
reason: options.reason,
// Default to the live cloud-sync version so every new backup carries
// it even when the caller didn't pass one explicitly. Bridge sanitizer
// drops invalid values (non-positive / non-finite), so this is safe.
syncDataVersion: options.syncDataVersion ?? captureCurrentSyncDataVersion(),
sourceAppVersion: options.sourceAppVersion,
targetAppVersion: options.targetAppVersion,
maxCount: options.maxCount ?? getLocalVaultBackupMaxCount(),
});
return result?.backup ?? null;
} catch (error) {
// The main-process bridge refuses to write backups when safeStorage is
// unavailable (VAULT_BACKUP_ENCRYPTION_UNAVAILABLE) because SyncPayload
// carries plaintext credentials that must never touch disk unencrypted.
// Callers (startup version-change, protective-before-restore) intentionally
// continue without a backup rather than blocking the user's flow, so we
// log and return null here.
const message = error instanceof Error ? error.message : String(error);
console.warn('[localVaultBackups] Backup skipped:', message);
return null;
}
}
/**
* Thrown when a caller requires a protective backup and the backup
* couldn't be written — safeStorage unavailable, bridge missing,
* main-process rejection, disk error.
*
* Callers should surface this as a user-visible abort rather than
* proceeding with the destructive apply. Separate from "nothing to
* back up" (empty vault) which is returned as `null`.
*/
export class ProtectiveBackupUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = 'ProtectiveBackupUnavailableError';
}
}
/**
* Create a protective local backup before a destructive apply (restore
* from backup list, restore from Gist revision, cloud download applied
* over meaningful local state).
*
* Returns `null` when there is nothing meaningful to back up — in that
* case the caller can safely proceed with the apply, because there is
* no local data to lose.
*
* Throws `ProtectiveBackupUnavailableError` when pre-apply state IS
* meaningful but the backup attempt failed. Callers MUST abort the
* destructive apply in that case and surface the error to the user,
* otherwise we regress the exact safety contract the backup system
* was added to enforce (the `console.error`-and-proceed pattern that
* previously swallowed safeStorage/keychain failures and continued).
*/
export async function createRequiredProtectiveLocalVaultBackup(
payload: SyncPayload,
): Promise<LocalVaultBackupPreview | null> {
if (!hasMeaningfulSyncData(payload)) {
// Nothing to protect — an empty-vault backup would produce a
// useless record, not a safety net.
return null;
}
const bridge = netcattyBridge.get();
if (!bridge?.createVaultBackup) {
throw new ProtectiveBackupUnavailableError(
'Vault backup bridge is not available in this environment.',
);
}
try {
const result = await bridge.createVaultBackup({
payload,
reason: 'before_restore',
maxCount: getLocalVaultBackupMaxCount(),
});
return result?.backup ?? null;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new ProtectiveBackupUnavailableError(message);
}
}
/**
* How long each heartbeat extends the cross-window restore barrier.
* Short enough that an abandoned lock (crashed window, hung task)
* clears itself quickly without user intervention. The heartbeat
* interval below refreshes the deadline as long as the caller's task
* is still running, so large vaults or slow keychain unlocks cannot
* expose a mid-apply window to concurrent auto-sync even when the
* total apply time exceeds this value.
*/
const RESTORE_BARRIER_HOLD_MS = 60_000;
/**
* How often the heartbeat refreshes the barrier. Picked to ensure at
* least two refreshes land before the current deadline would expire,
* so a single missed tick (event-loop stall, GC pause) cannot drop
* the barrier prematurely.
*/
const RESTORE_BARRIER_HEARTBEAT_MS = Math.max(1_000, Math.floor(RESTORE_BARRIER_HOLD_MS / 3));
/**
* Run `task` while holding a cross-window "restore in progress" barrier.
*
* The barrier is a localStorage key readable by every window of the same
* origin. useAutoSync reads it on each auto-sync and on each data-change
* debounce tick, refusing to push while the deadline is still in the
* future. We write a time-bounded deadline (rather than a boolean) so a
* crashed window can never leave sync permanently wedged.
*
* While the task runs, a heartbeat timer re-writes the deadline so a
* slow apply (large vault, slow keychain) keeps the barrier held rather
* than exposing a post-deadline window to concurrent auto-sync. The
* heartbeat is cleared and the barrier is released in a finally block
* so success, throw, and unexpected early-return all converge on the
* same cleanup.
*/
export async function withRestoreBarrier<T>(
task: () => Promise<T>,
holdMs: number = RESTORE_BARRIER_HOLD_MS,
): Promise<T> {
const writeDeadline = () => {
try {
localStorageAdapter.writeNumber(
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
Date.now() + holdMs,
);
} catch (error) {
// If we can't write the barrier we still proceed — the UI-side
// `isSyncBusy` guard and same-window debounce cancellation are a
// secondary defense. Better to complete the restore than refuse on
// a broken localStorage.
console.warn('[localVaultBackups] Failed to set restore barrier:', error);
}
};
writeDeadline();
const heartbeat = setInterval(
writeDeadline,
Math.max(1_000, Math.min(holdMs / 3, RESTORE_BARRIER_HEARTBEAT_MS)),
);
try {
return await task();
} finally {
clearInterval(heartbeat);
try {
localStorageAdapter.writeNumber(STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL, 0);
} catch {
/* ignore — the deadline will expire naturally */
}
}
}
/**
* Shape of the apply-in-progress sentinel record. Persisted as JSON in
* `STORAGE_KEY_VAULT_APPLY_IN_PROGRESS` so the next session can
* distinguish "the last apply completed cleanly" from "the last apply
* crashed mid-way and the local vault is a partial mix of states."
*/
export interface VaultApplyInProgressRecord {
startedAt: number;
protectiveBackupId: string | null;
}
/**
* Returns the persisted apply-in-progress record if a previous apply
* was interrupted before clearing it. Callers (notably auto-sync) use
* this to refuse to push a partial-apply local state over an intact
* cloud copy. See `applyProtectedSyncPayload` for the write side.
*
* `null` here means "no interrupted apply detected" — either nothing
* was ever applied, or the last apply finished cleanly.
*/
export function readInterruptedVaultApply(): VaultApplyInProgressRecord | null {
try {
const raw = localStorageAdapter.readString(STORAGE_KEY_VAULT_APPLY_IN_PROGRESS);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return null;
const startedAt = typeof parsed.startedAt === 'number' ? parsed.startedAt : 0;
const protectiveBackupId =
typeof parsed.protectiveBackupId === 'string' ? parsed.protectiveBackupId : null;
if (!startedAt) return null;
return { startedAt, protectiveBackupId };
} catch {
return null;
}
}
/**
* Clears the apply-in-progress sentinel. The normal completion path
* inside `applyProtectedSyncPayload` clears it automatically; this
* export exists so the user's explicit recovery action ("I've restored
* from a backup, resume sync") can acknowledge the interrupted state
* from the UI without re-running an apply.
*/
export function clearInterruptedVaultApply(): void {
try {
localStorageAdapter.remove(STORAGE_KEY_VAULT_APPLY_IN_PROGRESS);
} catch {
/* ignore — next clean apply will overwrite */
}
}
function writeApplyInProgressSentinel(record: VaultApplyInProgressRecord): void {
try {
localStorageAdapter.writeString(
STORAGE_KEY_VAULT_APPLY_IN_PROGRESS,
JSON.stringify(record),
);
} catch (error) {
// Sentinel write is best-effort: a failure here means a later crash
// won't be detected, but does NOT compromise the apply itself.
// Log so a systematic storage outage is diagnosable.
console.warn('[localVaultBackups] Failed to set apply-in-progress sentinel:', error);
}
}
/**
* Shared "apply a remote-sourced payload safely" helper.
*
* Holds the cross-window restore barrier, snapshots the pre-apply vault
* into a protective backup, persists an apply-in-progress sentinel, and
* only then runs the supplied `applyPayload` callback. Every destructive
* apply path (startup merge, conflict resolution, empty-vault restore,
* manual Gist-revision restore) must go through this so the protections
* can't drift out of sync between the main window and the settings
* window.
*
* The sentinel closes the partial-apply-then-crash window: `applyPayload`
* writes to several localStorage keys non-atomically (hosts, keys, port-
* forwarding rules, settings). A crash mid-sequence leaves the vault in
* a state that is neither pre-apply nor post-apply, and the next
* auto-sync would otherwise push that partial state over an intact cloud
* copy. The sentinel flags "local may be inconsistent" for the next
* session; `readInterruptedVaultApply` exposes that to callers that
* enforce "don't auto-push a half-applied vault."
*
* `buildPreApplyPayload` is invoked *before* the apply to snapshot the
* current vault. Callers pass their own React-closure builder (hosts,
* keys, port-forwarding rules) because the caller owns that state.
*
* `translateProtectiveBackupFailure` converts the
* `ProtectiveBackupUnavailableError` into a user-visible message in the
* caller's locale. It runs only on the thrown-and-caught path.
*/
export function applyProtectedSyncPayload(options: {
buildPreApplyPayload: () => SyncPayload;
applyPayload: () => void | Promise<void>;
translateProtectiveBackupFailure: (message: string) => string;
}): Promise<void> {
const { buildPreApplyPayload, applyPayload, translateProtectiveBackupFailure } = options;
return withRestoreBarrier(async () => {
const pre = buildPreApplyPayload();
let protectiveBackupId: string | null = null;
try {
const backup = await createRequiredProtectiveLocalVaultBackup(pre);
protectiveBackupId = backup?.id ?? null;
} catch (error) {
// Destructive apply without a working safety net is exactly the
// overwrite-without-recovery regression this module was added to
// prevent. Surface the failure to the caller; every call site
// currently aborts the apply and shows a user-visible error.
if (error instanceof ProtectiveBackupUnavailableError) {
throw new Error(translateProtectiveBackupFailure(error.message));
}
throw error;
}
// Mark the apply as in-progress. If the renderer crashes between
// the first localStorage write inside `applyPayload` and the
// successful completion below, the next session will observe this
// sentinel and refuse to auto-sync the partial state.
writeApplyInProgressSentinel({
startedAt: Date.now(),
protectiveBackupId,
});
// Only clear the sentinel on successful completion. A throw from
// `applyPayload` deliberately leaves the sentinel set: the partial
// write is still on disk, and the next session must observe the
// flag so auto-sync refuses to push the half-applied state.
await applyPayload();
clearInterruptedVaultApply();
});
}
export async function ensureVersionChangeBackup(
payload: SyncPayload,
currentAppVersion: string | null | undefined,
): Promise<{ created: boolean; backup: LocalVaultBackupPreview | null }> {
const normalizedVersion = currentAppVersion?.trim() || '';
if (!normalizedVersion) {
return { created: false, backup: null };
}
const previousVersion =
localStorageAdapter.readString(STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION)?.trim() || '';
if (!previousVersion) {
localStorageAdapter.writeString(STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION, normalizedVersion);
return { created: false, backup: null };
}
if (previousVersion === normalizedVersion) {
return { created: false, backup: null };
}
let backup: LocalVaultBackupPreview | null = null;
const payloadIsMeaningful = hasMeaningfulSyncData(payload);
if (payloadIsMeaningful) {
backup = await createLocalVaultBackup(payload, {
reason: 'app_version_change',
sourceAppVersion: previousVersion,
targetAppVersion: normalizedVersion,
});
}
// Only advance the stored version stamp when we actually wrote a
// backup. Two failure modes we must NOT collapse into "advance":
//
// 1. Meaningful payload + backup failed (transient keychain lock,
// disk error) — leaving the stamp unchanged means the next
// launch retries, instead of turning a transient error into a
// permanent "the version-change backup never happened" hole.
//
// 2. Non-meaningful payload at the moment we checked — on startup
// the async vault rehydrate may not have finished yet, so
// `hasMeaningfulSyncData` can return false transiently even
// though the user has real data. Advancing in that window would
// burn the one-shot upgrade opportunity; holding keeps the
// retry available on the next launch when rehydrate has
// completed (or when the user genuinely starts from empty and
// the next migration-boundary arrives).
//
// Trade-off: a user who truly starts empty and never adds data will
// hit this branch on every launch until they do. That's cheap (a
// single meaningful-data check) and strictly safer than silently
// skipping the first real upgrade backup.
const shouldAdvanceVersion = payloadIsMeaningful && backup !== null;
if (shouldAdvanceVersion) {
localStorageAdapter.writeString(STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION, normalizedVersion);
}
return {
created: Boolean(backup),
backup,
};
}
@@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
test("active tab changes notify chrome theme before react subscribers", () => {
const storeSource = readFileSync(new URL("./activeTabStore.ts", import.meta.url), "utf8");
const syncSource = readFileSync(new URL("./activeChromeThemeSync.ts", import.meta.url), "utf8");
const setActiveTabIdBody = storeSource.match(/setActiveTabId = \(id: string\) => \{[\s\S]*?\n {2}\};/)?.[0] ?? "";
assert.match(setActiveTabIdBody, /this\.syncListeners\.forEach\(\(listener\) => listener\(id\)\)/);
assert.match(setActiveTabIdBody, /this\.scheduleNotify\(\)/);
assert.ok(
setActiveTabIdBody.indexOf("syncListeners.forEach") < setActiveTabIdBody.indexOf("scheduleNotify"),
"sync chrome theme listeners must run before deferred react notify",
);
assert.match(syncSource, /activeTabStore\.subscribeSync\(notifyActiveChromeThemeForTab\)/);
assert.match(syncSource, /isActiveChromeThemeResolvable/);
assert.match(syncSource, /clearTopTabsChromeThemeVars/);
});
@@ -0,0 +1,41 @@
import { isActiveChromeThemeResolvable, resolveActiveChromeTheme } from '../app/activeChromeTheme';
import { clearTopTabsChromeThemeVars } from '../app/topTabsChromeTheme';
import type { TerminalAppearanceHostScope, ResolvedAppearance } from '../../domain/terminalAppearanceRuntime';
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
import { activeTabStore } from './activeTabStore';
import type { EditorTab } from './editorTabStore';
import type { LogView } from './logViewState';
import { syncActiveChromeTheme } from './useActiveChromeTheme';
export type ActiveChromeThemeDeps = {
accentMode: 'theme' | 'custom';
applyAppTheme: () => void;
currentTerminalTheme: TerminalTheme;
customAccent: string;
editorTabs: readonly EditorTab[];
followAppTerminalTheme: boolean;
hostById: Map<string, Host>;
logViews: readonly LogView[];
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
sessionById: Map<string, TerminalSession>;
themeById: Map<string, TerminalTheme>;
workspaceById: Map<string, Workspace>;
};
let depsRef: ActiveChromeThemeDeps | null = null;
export function updateActiveChromeThemeDeps(deps: ActiveChromeThemeDeps): void {
depsRef = deps;
}
export function notifyActiveChromeThemeForTab(activeTabId: string): void {
if (!depsRef || typeof document === 'undefined') return;
if (activeTabId === 'vault' || activeTabId === 'sftp') {
clearTopTabsChromeThemeVars();
}
if (!isActiveChromeThemeResolvable({ ...depsRef, activeTabId })) return;
const activeTheme = resolveActiveChromeTheme({ ...depsRef, activeTabId });
syncActiveChromeTheme(activeTheme, depsRef.applyAppTheme);
}
activeTabStore.subscribeSync(notifyActiveChromeThemeForTab);
+54
View File
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { activeTabStore, fromEditorTabId, isEditorTabId, toEditorTabId } from './activeTabStore';
import { terminalLayoutSuppressStore } from './terminalLayoutSuppressStore';
test('editor tab helpers round trip ids', () => {
assert.equal(toEditorTabId('file-1'), 'editor:file-1');
assert.equal(fromEditorTabId('editor:file-1'), 'file-1');
});
test('editor tab helper detects editor top-tab ids', () => {
assert.equal(isEditorTabId('editor:file-1'), true);
assert.equal(isEditorTabId('session-1'), false);
});
test('active tab changes do not start terminal layout suppression', async () => {
const previousWindow = (globalThis as { window?: unknown }).window;
(globalThis as { window?: unknown }).window ??= globalThis;
while (terminalLayoutSuppressStore.getActive()) {
terminalLayoutSuppressStore.end();
}
await new Promise((resolve) => setTimeout(resolve, 0));
let activeNotifyCount = 0;
let suppressNotifyCount = 0;
const unsubscribeActiveTab = activeTabStore.subscribe(() => {
activeNotifyCount += 1;
});
const unsubscribeSuppress = terminalLayoutSuppressStore.subscribe(() => {
suppressNotifyCount += 1;
});
try {
activeTabStore.setActiveTabId(`no-suppress-test-${Date.now()}`);
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(activeNotifyCount, 1);
assert.equal(suppressNotifyCount, 0);
assert.equal(terminalLayoutSuppressStore.getActive(), false);
} finally {
unsubscribeActiveTab();
unsubscribeSuppress();
if (previousWindow === undefined) {
delete (globalThis as { window?: unknown }).window;
} else {
(globalThis as { window?: unknown }).window = previousWindow;
}
while (terminalLayoutSuppressStore.getActive()) {
terminalLayoutSuppressStore.end();
}
}
});
+48 -23
View File
@@ -1,25 +1,47 @@
import { useCallback,useSyncExternalStore } from 'react';
import { useCallback, useSyncExternalStore } from 'react';
// Simple store for active tab that allows fine-grained subscriptions
type Listener = () => void;
type SyncListener = (activeTabId: string) => void;
// ----- Editor tab id helpers -----
export const EDITOR_PREFIX = 'editor:';
/** Returns true when `id` is an editor tab id (starts with "editor:"). */
export const isEditorTabId = (id: string): boolean => id.startsWith(EDITOR_PREFIX);
/** Convert an editorTab's internal id to a top-tab id understood by the tab bar. */
export const toEditorTabId = (editorId: string): string => `${EDITOR_PREFIX}${editorId}`;
/** Strip the "editor:" prefix to recover the internal editorTab id. */
export const fromEditorTabId = (tabId: string): string => tabId.slice(EDITOR_PREFIX.length);
class ActiveTabStore {
private activeTabId: string = 'vault';
private listeners = new Set<Listener>();
private pendingNotify = false;
private syncListeners = new Set<SyncListener>();
private notifyRafId: number | null = null;
getActiveTabId = () => this.activeTabId;
private scheduleNotify = () => {
if (this.notifyRafId !== null) return;
const schedule = typeof requestAnimationFrame === 'function'
? requestAnimationFrame
: (cb: () => void) => window.setTimeout(cb, 0) as unknown as number;
this.notifyRafId = schedule(() => {
this.notifyRafId = null;
this.listeners.forEach((listener) => listener());
});
};
setActiveTabId = (id: string) => {
if (this.activeTabId !== id) {
this.activeTabId = id;
// Defer listener notification to avoid "setState during render" if called from a render phase
if (this.pendingNotify) return;
this.pendingNotify = true;
Promise.resolve().then(() => {
this.pendingNotify = false;
this.listeners.forEach(listener => listener());
});
this.syncListeners.forEach((listener) => listener(id));
// Coalesce rapid tab switches into one notification per frame and avoid
// "setState during render" if called from a render phase.
this.scheduleNotify();
}
};
@@ -27,6 +49,11 @@ class ActiveTabStore {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
subscribeSync = (listener: SyncListener) => {
this.syncListeners.add(listener);
return () => this.syncListeners.delete(listener);
};
}
export const activeTabStore = new ActiveTabStore();
@@ -35,19 +62,15 @@ export const activeTabStore = new ActiveTabStore();
export const useActiveTabId = () => {
return useSyncExternalStore(
activeTabStore.subscribe,
activeTabStore.getActiveTabId
activeTabStore.getActiveTabId,
activeTabStore.getActiveTabId,
);
};
// Hook to get setter - never causes re-render
export const useSetActiveTabId = () => {
return activeTabStore.setActiveTabId;
};
// Check if a specific tab is active - only re-renders when this specific tab's active state changes
export const useIsTabActive = (tabId: string) => {
const getSnapshot = useCallback(() => activeTabStore.getActiveTabId() === tabId, [tabId]);
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot);
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot, getSnapshot);
};
// Stable snapshot functions - defined once outside components
@@ -58,7 +81,8 @@ const getIsSftpActive = () => activeTabStore.getActiveTabId() === 'sftp';
export const useIsVaultActive = () => {
return useSyncExternalStore(
activeTabStore.subscribe,
getIsVaultActive
getIsVaultActive,
getIsVaultActive,
);
};
@@ -66,13 +90,14 @@ export const useIsVaultActive = () => {
export const useIsSftpActive = () => {
return useSyncExternalStore(
activeTabStore.subscribe,
getIsSftpActive
getIsSftpActive,
getIsSftpActive,
);
};
// Check if terminal layer should be visible
export const useIsTerminalLayerVisible = (draggingSessionId: string | null) => {
const activeTabId = useActiveTabId();
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp';
return isTerminalTab || !!draggingSessionId;
// Check if a specific editor tab is currently active
export const useIsEditorTabActive = (tabId: string): boolean => {
const editorTopId = toEditorTabId(tabId);
const getSnapshot = useCallback(() => activeTabStore.getActiveTabId() === editorTopId, [editorTopId]);
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot, getSnapshot);
};
+383
View File
@@ -0,0 +1,383 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
activateDraftView,
bumpDraftMutationVersionState,
bumpDraftUploadGenerationState,
clearScopeDraftState,
createEmptyDraft,
ensureDraftForScopeState,
getDraftMutationVersionState,
getDraftUploadGenerationState,
pruneStaleSessionPanelViews,
pruneTerminalScopeState,
pruneTerminalTransientState,
resolvePanelView,
selectDraftForAgentSwitch,
setDraftView,
setSessionView,
updateDraftForScope,
} from "./aiDraftState.ts";
test("createEmptyDraft seeds selected agent and empty inputs", () => {
const draft = createEmptyDraft("agent-alpha");
assert.equal(draft.agentId, "agent-alpha");
assert.equal(draft.text, "");
assert.deepEqual(draft.attachments, []);
assert.deepEqual(draft.selectedUserSkillSlugs, []);
assert.equal(typeof draft.updatedAt, "number");
});
test("resolvePanelView defaults to draft when no explicit view exists", () => {
assert.deepEqual(resolvePanelView({}, "terminal:123"), { mode: "draft" });
});
test("setDraftView records draft mode", () => {
assert.deepEqual(setDraftView({}, "terminal:123"), {
"terminal:123": { mode: "draft" },
});
});
test("activateDraftView clears the terminal scope's active session owner", () => {
const activeSessionIdMap = {
"terminal:123": "session-123",
"workspace:abc": "session-workspace",
};
const panelViewByScope = {
"terminal:123": { mode: "session", sessionId: "session-123" },
"workspace:abc": { mode: "session", sessionId: "session-workspace" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = activateDraftView(
activeSessionIdMap,
panelViewByScope,
"terminal:123",
);
assert.deepEqual(next.activeSessionIdMap, {
"workspace:abc": "session-workspace",
});
assert.deepEqual(next.panelViewByScope, {
"terminal:123": { mode: "draft" },
"workspace:abc": panelViewByScope["workspace:abc"],
});
});
test("activateDraftView is a no-op when the scope already has explicit draft view", () => {
const activeSessionIdMap = {
"workspace:abc": "session-workspace",
};
const panelViewByScope = {
"terminal:123": { mode: "draft" },
"workspace:abc": { mode: "session", sessionId: "session-workspace" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = activateDraftView(
activeSessionIdMap,
panelViewByScope,
"terminal:123",
);
assert.equal(next.activeSessionIdMap, activeSessionIdMap);
assert.equal(next.panelViewByScope, panelViewByScope);
});
test("setSessionView records target session id", () => {
assert.deepEqual(setSessionView({}, "workspace:abc", "session-123"), {
"workspace:abc": { mode: "session", sessionId: "session-123" },
});
});
test("pruneStaleSessionPanelViews resets session views that no longer exist", () => {
const panelViewByScope = {
"terminal:1": { mode: "session", sessionId: "deleted-session" },
"workspace:2": { mode: "session", sessionId: "session-keep" },
"terminal:3": { mode: "draft" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneStaleSessionPanelViews(
panelViewByScope,
new Set(["session-keep"]),
);
assert.deepEqual(next, {
"terminal:1": { mode: "draft" },
"workspace:2": { mode: "session", sessionId: "session-keep" },
"terminal:3": { mode: "draft" },
});
});
test("pruneStaleSessionPanelViews returns the original ref when nothing is stale", () => {
const panelViewByScope = {
"terminal:1": { mode: "session", sessionId: "session-keep" },
"terminal:2": { mode: "draft" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneStaleSessionPanelViews(
panelViewByScope,
new Set(["session-keep"]),
);
assert.equal(next, panelViewByScope);
});
test("clearScopeDraftState removes both the draft and current panel view", () => {
const draftsByScope = {
"terminal:1": createEmptyDraft("agent-alpha"),
"workspace:2": createEmptyDraft("agent-beta"),
};
const panelViewByScope = {
"terminal:1": { mode: "session", sessionId: "session-123" },
"workspace:2": { mode: "draft" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = clearScopeDraftState(draftsByScope, panelViewByScope, "terminal:1");
assert.deepEqual(next.draftsByScope, {
"workspace:2": draftsByScope["workspace:2"],
});
assert.deepEqual(next.panelViewByScope, {
"workspace:2": panelViewByScope["workspace:2"],
});
});
test("clearScopeDraftState is a no-op when the scope is already cleared", () => {
const draftsByScope = {
"workspace:2": createEmptyDraft("agent-beta"),
};
const panelViewByScope = {
"workspace:2": { mode: "draft" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = clearScopeDraftState(draftsByScope, panelViewByScope, "terminal:closed");
assert.equal(next.draftsByScope, draftsByScope);
assert.equal(next.panelViewByScope, panelViewByScope);
});
test("updateDraftForScope creates a draft on first write and keeps other scopes untouched", () => {
const draftsByScope = {
"workspace:2": createEmptyDraft("agent-beta"),
};
const next = updateDraftForScope(
draftsByScope,
"terminal:1",
"agent-alpha",
(draft) => ({
...draft,
text: "hello world",
}),
);
assert.equal(next["terminal:1"].agentId, "agent-alpha");
assert.equal(next["terminal:1"].text, "hello world");
assert.equal(next["workspace:2"], draftsByScope["workspace:2"]);
});
test("ensureDraftForScopeState adds the missing scope without dropping siblings", () => {
const draftsByScope = {
"workspace:2": createEmptyDraft("agent-beta"),
};
const next = ensureDraftForScopeState(
draftsByScope,
"terminal:1",
"agent-alpha",
);
assert.equal(next["terminal:1"].agentId, "agent-alpha");
assert.equal(next["terminal:1"].text, "");
assert.equal(next["workspace:2"], draftsByScope["workspace:2"]);
});
test("ensureDraftForScopeState returns the original ref when the scope already exists", () => {
const draftsByScope = {
"terminal:1": createEmptyDraft("agent-alpha"),
};
const next = ensureDraftForScopeState(
draftsByScope,
"terminal:1",
"agent-beta",
);
assert.equal(next, draftsByScope);
});
test("selectDraftForAgentSwitch preserves hidden draft content when leaving a populated chat session", () => {
const currentDraft = {
...createEmptyDraft("agent-alpha"),
text: "keep me only if I was already drafting",
attachments: [{ id: "file-1", filename: "note.txt", dataUrl: "", base64Data: "", mediaType: "text/plain" }],
selectedUserSkillSlugs: ["skill-a"],
};
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", true);
assert.equal(next.agentId, "agent-beta");
assert.equal(next.text, "keep me only if I was already drafting");
assert.deepEqual(next.attachments, currentDraft.attachments);
assert.deepEqual(next.selectedUserSkillSlugs, ["skill-a"]);
});
test("selectDraftForAgentSwitch resets to an empty draft when leaving a populated chat session without pending draft content", () => {
const currentDraft = createEmptyDraft("agent-alpha");
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", true);
assert.equal(next.agentId, "agent-beta");
assert.equal(next.text, "");
assert.deepEqual(next.attachments, []);
assert.deepEqual(next.selectedUserSkillSlugs, []);
});
test("selectDraftForAgentSwitch preserves an existing draft while only changing agent", () => {
const currentDraft = {
...createEmptyDraft("agent-alpha"),
text: "unfinished prompt",
selectedUserSkillSlugs: ["skill-a"],
};
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", false);
assert.equal(next.agentId, "agent-beta");
assert.equal(next.text, "unfinished prompt");
assert.deepEqual(next.selectedUserSkillSlugs, ["skill-a"]);
});
test("draft mutation version increments on every mutation for the same scope", () => {
const scopeKey = "terminal:1";
const initialVersion = getDraftMutationVersionState({}, scopeKey);
const nextVersions = bumpDraftMutationVersionState({}, scopeKey);
const finalVersions = bumpDraftMutationVersionState(nextVersions, scopeKey);
assert.equal(initialVersion, 0);
assert.equal(getDraftMutationVersionState(nextVersions, scopeKey), 1);
assert.equal(getDraftMutationVersionState(finalVersions, scopeKey), 2);
});
test("draft upload generation only increments when the draft lifecycle rolls over", () => {
const scopeKey = "terminal:1";
const initialGeneration = getDraftUploadGenerationState({}, scopeKey);
const nextGenerations = bumpDraftUploadGenerationState({}, scopeKey);
const finalGenerations = bumpDraftUploadGenerationState(nextGenerations, scopeKey);
assert.equal(initialGeneration, 0);
assert.equal(getDraftUploadGenerationState(nextGenerations, scopeKey), 1);
assert.equal(getDraftUploadGenerationState(finalGenerations, scopeKey), 2);
});
test("pruneTerminalScopeState removes closed terminal drafts and views only", () => {
const draftsByScope = {
"terminal:closed": createEmptyDraft("agent-alpha"),
"terminal:open": createEmptyDraft("agent-beta"),
"workspace:keep": createEmptyDraft("agent-gamma"),
};
const panelViewByScope = {
"terminal:closed": { mode: "draft" },
"terminal:open": { mode: "session", sessionId: "session-open" },
"workspace:keep": { mode: "session", sessionId: "session-workspace" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneTerminalScopeState(
draftsByScope,
panelViewByScope,
new Set(["open"]),
);
assert.deepEqual(next.draftsByScope, {
"terminal:open": draftsByScope["terminal:open"],
"workspace:keep": draftsByScope["workspace:keep"],
});
assert.deepEqual(next.panelViewByScope, {
"terminal:open": panelViewByScope["terminal:open"],
"workspace:keep": panelViewByScope["workspace:keep"],
});
});
test("pruneTerminalScopeState returns original refs when nothing is pruned", () => {
const draftsByScope = {
"terminal:open": createEmptyDraft("agent-alpha"),
"workspace:keep": createEmptyDraft("agent-beta"),
};
const panelViewByScope = {
"terminal:open": { mode: "draft" },
"workspace:keep": { mode: "session", sessionId: "session-1" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneTerminalScopeState(
draftsByScope,
panelViewByScope,
new Set(["open"]),
);
assert.equal(next.draftsByScope, draftsByScope);
assert.equal(next.panelViewByScope, panelViewByScope);
});
test("pruneTerminalTransientState clears closed terminal active session, draft, and view state only", () => {
const activeSessionIdMap = {
"terminal:closed": "session-closed",
"terminal:open": "session-open",
"workspace:keep": "session-workspace",
};
const draftsByScope = {
"terminal:closed": createEmptyDraft("agent-alpha"),
"terminal:open": createEmptyDraft("agent-beta"),
"workspace:keep": createEmptyDraft("agent-gamma"),
};
const panelViewByScope = {
"terminal:closed": { mode: "draft" },
"terminal:open": { mode: "session", sessionId: "session-open" },
"workspace:keep": { mode: "session", sessionId: "session-workspace" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneTerminalTransientState(
activeSessionIdMap,
draftsByScope,
panelViewByScope,
new Set(["open"]),
);
assert.deepEqual(next.activeSessionIdMap, {
"terminal:open": "session-open",
"workspace:keep": "session-workspace",
});
assert.deepEqual(next.draftsByScope, {
"terminal:open": draftsByScope["terminal:open"],
"workspace:keep": draftsByScope["workspace:keep"],
});
assert.deepEqual(next.panelViewByScope, {
"terminal:open": panelViewByScope["terminal:open"],
"workspace:keep": panelViewByScope["workspace:keep"],
});
});
test("pruneTerminalTransientState returns original refs when no terminal scopes close", () => {
const activeSessionIdMap = {
"terminal:open": "session-open",
"workspace:keep": "session-workspace",
};
const draftsByScope = {
"terminal:open": createEmptyDraft("agent-alpha"),
"workspace:keep": createEmptyDraft("agent-beta"),
};
const panelViewByScope = {
"terminal:open": { mode: "draft" },
"workspace:keep": { mode: "session", sessionId: "session-workspace" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneTerminalTransientState(
activeSessionIdMap,
draftsByScope,
panelViewByScope,
new Set(["open"]),
);
assert.equal(next.activeSessionIdMap, activeSessionIdMap);
assert.equal(next.draftsByScope, draftsByScope);
assert.equal(next.panelViewByScope, panelViewByScope);
});
+301
View File
@@ -0,0 +1,301 @@
import type {
AIDraft,
AIPanelView,
} from '../../infrastructure/ai/types';
type DraftsByScope = Partial<Record<string, AIDraft>>;
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
type ActiveSessionIdMap = Record<string, string | null>;
type DraftMutationVersionByScope = Record<string, number>;
type DraftUploadGenerationByScope = Record<string, number>;
const DEFAULT_PANEL_VIEW: AIPanelView = { mode: 'draft' };
export function createEmptyDraft(agentId: string): AIDraft {
return {
text: '',
agentId,
attachments: [],
selectedUserSkillSlugs: [],
updatedAt: Date.now(),
};
}
export function getDraftMutationVersionState(
versionsByScope: DraftMutationVersionByScope,
scopeKey: string,
): number {
return versionsByScope[scopeKey] ?? 0;
}
export function bumpDraftMutationVersionState(
versionsByScope: DraftMutationVersionByScope,
scopeKey: string,
): DraftMutationVersionByScope {
return {
...versionsByScope,
[scopeKey]: getDraftMutationVersionState(versionsByScope, scopeKey) + 1,
};
}
export function getDraftUploadGenerationState(
generationsByScope: DraftUploadGenerationByScope,
scopeKey: string,
): number {
return generationsByScope[scopeKey] ?? 0;
}
export function bumpDraftUploadGenerationState(
generationsByScope: DraftUploadGenerationByScope,
scopeKey: string,
): DraftUploadGenerationByScope {
return {
...generationsByScope,
[scopeKey]: getDraftUploadGenerationState(generationsByScope, scopeKey) + 1,
};
}
export function resolvePanelView(
panelViewByScope: PanelViewByScope,
scopeKey: string,
): AIPanelView {
return panelViewByScope[scopeKey] ?? DEFAULT_PANEL_VIEW;
}
export function setDraftView(
panelViewByScope: PanelViewByScope,
scopeKey: string,
): PanelViewByScope {
const currentPanelView = panelViewByScope[scopeKey];
if (currentPanelView?.mode === 'draft') {
return panelViewByScope;
}
return {
...panelViewByScope,
[scopeKey]: DEFAULT_PANEL_VIEW,
};
}
export function activateDraftView(
activeSessionIdMap: ActiveSessionIdMap,
panelViewByScope: PanelViewByScope,
scopeKey: string,
): {
activeSessionIdMap: ActiveSessionIdMap;
panelViewByScope: PanelViewByScope;
} {
const nextPanelViewByScope = setDraftView(panelViewByScope, scopeKey);
const hasActiveSession = activeSessionIdMap[scopeKey] != null;
if (!hasActiveSession) {
return {
activeSessionIdMap,
panelViewByScope: nextPanelViewByScope,
};
}
const nextActiveSessionIdMap = { ...activeSessionIdMap };
delete nextActiveSessionIdMap[scopeKey];
return {
activeSessionIdMap: nextActiveSessionIdMap,
panelViewByScope: nextPanelViewByScope,
};
}
export function setSessionView(
panelViewByScope: PanelViewByScope,
scopeKey: string,
sessionId: string,
): PanelViewByScope {
return {
...panelViewByScope,
[scopeKey]: { mode: 'session', sessionId },
};
}
export function pruneStaleSessionPanelViews(
panelViewByScope: PanelViewByScope,
validSessionIds: Set<string>,
): PanelViewByScope {
let next = panelViewByScope;
for (const [scopeKey, panelView] of Object.entries(panelViewByScope)) {
if (panelView?.mode !== 'session' || validSessionIds.has(panelView.sessionId)) {
continue;
}
const updated = setDraftView(next, scopeKey);
if (updated !== next) {
next = updated;
}
}
return next;
}
export function updateDraftForScope(
draftsByScope: DraftsByScope,
scopeKey: string,
fallbackAgentId: string,
updater: (draft: AIDraft) => AIDraft,
): DraftsByScope {
const currentDraft = draftsByScope[scopeKey] ?? createEmptyDraft(fallbackAgentId);
const nextDraft = updater(currentDraft);
return {
...draftsByScope,
[scopeKey]: nextDraft,
};
}
export function ensureDraftForScopeState(
draftsByScope: DraftsByScope,
scopeKey: string,
agentId: string,
): DraftsByScope {
if (draftsByScope[scopeKey]) {
return draftsByScope;
}
return {
...draftsByScope,
[scopeKey]: createEmptyDraft(agentId),
};
}
export function selectDraftForAgentSwitch(
currentDraft: AIDraft | null | undefined,
agentId: string,
startFresh: boolean,
): AIDraft {
const hasPendingDraftContent = Boolean(
currentDraft
&& (
currentDraft.text.length > 0
|| currentDraft.attachments.length > 0
|| currentDraft.selectedUserSkillSlugs.length > 0
),
);
if (startFresh && !hasPendingDraftContent) {
return createEmptyDraft(agentId);
}
const baseDraft = currentDraft ?? createEmptyDraft(agentId);
return {
...baseDraft,
agentId,
};
}
export function clearScopeDraftState(
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,
scopeKey: string,
): {
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
} {
const hasDraft = Object.prototype.hasOwnProperty.call(draftsByScope, scopeKey);
const hasPanelView = Object.prototype.hasOwnProperty.call(panelViewByScope, scopeKey);
if (!hasDraft && !hasPanelView) {
return {
draftsByScope,
panelViewByScope,
};
}
return {
draftsByScope: hasDraft
? (() => {
const nextDrafts = { ...draftsByScope };
delete nextDrafts[scopeKey];
return nextDrafts;
})()
: draftsByScope,
panelViewByScope: hasPanelView
? (() => {
const nextPanelViews = { ...panelViewByScope };
delete nextPanelViews[scopeKey];
return nextPanelViews;
})()
: panelViewByScope,
};
}
function isClosedTerminalScope(scopeKey: string, activeTerminalTargetIds: Set<string>) {
if (!scopeKey.startsWith('terminal:')) return false;
const targetId = scopeKey.slice('terminal:'.length);
if (!targetId) return false;
return !activeTerminalTargetIds.has(targetId);
}
export function pruneTerminalScopeState(
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,
activeTerminalTargetIds: Set<string>,
): {
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
} {
const nextDraftsByScope = { ...draftsByScope };
const nextPanelViewByScope = { ...panelViewByScope };
let draftsChanged = false;
let panelViewsChanged = false;
for (const scopeKey of Object.keys(nextDraftsByScope)) {
if (!isClosedTerminalScope(scopeKey, activeTerminalTargetIds)) continue;
delete nextDraftsByScope[scopeKey];
draftsChanged = true;
}
for (const scopeKey of Object.keys(nextPanelViewByScope)) {
if (!isClosedTerminalScope(scopeKey, activeTerminalTargetIds)) continue;
delete nextPanelViewByScope[scopeKey];
panelViewsChanged = true;
}
return {
draftsByScope: draftsChanged ? nextDraftsByScope : draftsByScope,
panelViewByScope: panelViewsChanged ? nextPanelViewByScope : panelViewByScope,
};
}
export function pruneTerminalTransientState(
activeSessionIdMap: ActiveSessionIdMap,
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,
activeTerminalTargetIds: Set<string>,
): {
activeSessionIdMap: ActiveSessionIdMap;
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
} {
let activeSessionMapChanged = false;
const nextActiveSessionIdMap: ActiveSessionIdMap = {};
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
if (isClosedTerminalScope(scopeKey, activeTerminalTargetIds)) {
activeSessionMapChanged = true;
continue;
}
nextActiveSessionIdMap[scopeKey] = sessionId;
}
const nextTerminalScopeState = pruneTerminalScopeState(
draftsByScope,
panelViewByScope,
activeTerminalTargetIds,
);
return {
activeSessionIdMap: activeSessionMapChanged ? nextActiveSessionIdMap : activeSessionIdMap,
draftsByScope: nextTerminalScopeState.draftsByScope,
panelViewByScope: nextTerminalScopeState.panelViewByScope,
};
}
+39
View File
@@ -0,0 +1,39 @@
export function removeProviderReferences(
removedProviderId: string,
agentProviderMap: Record<string, string>,
agentModelMap: Record<string, string>,
): {
agentProviderMap: Record<string, string>;
agentModelMap: Record<string, string>;
providerMapChanged: boolean;
modelMapChanged: boolean;
} {
let providerMapChanged = false;
let modelMapChanged = false;
const orphanedAgents = new Set<string>();
const nextAgentProviderMap: Record<string, string> = {};
for (const [agentId, providerId] of Object.entries(agentProviderMap)) {
if (providerId === removedProviderId) {
providerMapChanged = true;
orphanedAgents.add(agentId);
} else {
nextAgentProviderMap[agentId] = providerId;
}
}
const nextAgentModelMap: Record<string, string> = { ...agentModelMap };
for (const agentId of orphanedAgents) {
if (agentId in nextAgentModelMap) {
delete nextAgentModelMap[agentId];
modelMapChanged = true;
}
}
return {
agentProviderMap: providerMapChanged ? nextAgentProviderMap : agentProviderMap,
agentModelMap: modelMapChanged ? nextAgentModelMap : agentModelMap,
providerMapChanged,
modelMapChanged,
};
}
+182
View File
@@ -0,0 +1,182 @@
import test from "node:test";
import assert from "node:assert/strict";
import type {
AIPanelView,
AISession,
} from "../../infrastructure/ai/types.ts";
import { createEmptyDraft } from "./aiDraftState.ts";
import {
pruneInactiveScopedSessions,
pruneInactiveScopedTransientState,
} from "./aiScopeCleanup.ts";
function createSession(id: string, scope: AISession["scope"], externalSessionId?: string): AISession {
return {
id,
title: id,
agentId: "catty",
scope,
messages: [],
externalSessionId,
createdAt: 1,
updatedAt: 1,
};
}
test("pruneInactiveScopedTransientState removes closed workspace and terminal scope state", () => {
const activeSessionIdMap = {
"terminal:open-terminal": "session-open",
"terminal:closed-terminal": "session-closed-terminal",
"workspace:open-workspace": "session-open-workspace",
"workspace:closed-workspace": "session-closed-workspace",
};
const draftsByScope = {
"terminal:open-terminal": createEmptyDraft("catty"),
"terminal:closed-terminal": createEmptyDraft("catty"),
"workspace:open-workspace": createEmptyDraft("catty"),
"workspace:closed-workspace": createEmptyDraft("catty"),
};
const panelViewByScope = {
"terminal:open-terminal": { mode: "draft" },
"terminal:closed-terminal": { mode: "session", sessionId: "session-closed-terminal" },
"workspace:open-workspace": { mode: "draft" },
"workspace:closed-workspace": { mode: "session", sessionId: "session-closed-workspace" },
} satisfies Record<string, AIPanelView>;
const next = pruneInactiveScopedTransientState(
activeSessionIdMap,
draftsByScope,
panelViewByScope,
new Set(["open-terminal", "open-workspace"]),
);
assert.deepEqual(next.activeSessionIdMap, {
"terminal:open-terminal": "session-open",
"workspace:open-workspace": "session-open-workspace",
});
assert.deepEqual(next.draftsByScope, {
"terminal:open-terminal": draftsByScope["terminal:open-terminal"],
"workspace:open-workspace": draftsByScope["workspace:open-workspace"],
});
assert.deepEqual(next.panelViewByScope, {
"terminal:open-terminal": panelViewByScope["terminal:open-terminal"],
"workspace:open-workspace": panelViewByScope["workspace:open-workspace"],
});
});
test("pruneInactiveScopedSessions reports inactive targets without deleting persisted history", () => {
const sessions = [
createSession("terminal-restorable", {
type: "terminal",
targetId: "closed-restorable",
hostIds: ["host-1"],
}, "ext-1"),
createSession("terminal-local", {
type: "terminal",
targetId: "closed-local",
hostIds: ["local-shell"],
}, "ext-2"),
createSession("workspace-closed", {
type: "workspace",
targetId: "closed-workspace",
}, "ext-3"),
createSession("terminal-open", {
type: "terminal",
targetId: "open-terminal",
hostIds: ["host-2"],
}, "ext-4"),
];
const next = pruneInactiveScopedSessions(
sessions,
new Set(["open-terminal"]),
);
assert.deepEqual(next.orphanedSessionIds, [
"terminal-restorable",
"terminal-local",
"workspace-closed",
]);
assert.equal(next.sessions, sessions);
});
test("pruneInactiveScopedSessions preserves inactive workspace and local terminal history after restart", () => {
const sessions = [
createSession("terminal-local", {
type: "terminal",
targetId: "closed-local",
hostIds: ["local-shell"],
}, "ext-local"),
createSession("workspace-closed", {
type: "workspace",
targetId: "closed-workspace",
}, "ext-workspace"),
];
const next = pruneInactiveScopedSessions(
sessions,
new Set(),
);
assert.deepEqual(next.orphanedSessionIds, [
"terminal-local",
"workspace-closed",
]);
assert.equal(next.sessions, sessions);
});
test("pruneInactiveScopedSessions preserves original sessions when orphaned restorable chats are already detached", () => {
const sessions = [
createSession("terminal-restorable", {
type: "terminal",
targetId: "closed-restorable",
hostIds: ["host-1"],
}),
createSession("terminal-open", {
type: "terminal",
targetId: "open-terminal",
hostIds: ["host-2"],
}, "ext-4"),
];
const next = pruneInactiveScopedSessions(
sessions,
new Set(["open-terminal"]),
);
assert.deepEqual(next.orphanedSessionIds, ["terminal-restorable"]);
assert.equal(next.sessions, sessions);
});
test("pruneInactiveScopedSessions treats sessions displayed elsewhere as in-use, not orphaned", () => {
// terminal-restorable's original scope (terminal-closed-A) is gone, but
// the user resumed it into terminal-open-B from history. The session's
// externalSessionId must be preserved and it must not appear in the
// orphaned list, otherwise the active chat loses external agent continuity.
const resumedElsewhere = createSession("terminal-restorable", {
type: "terminal",
targetId: "terminal-closed-A",
hostIds: ["host-1"],
}, "ext-resumed");
const trulyOrphaned = createSession("terminal-stale", {
type: "terminal",
targetId: "terminal-closed-C",
hostIds: ["host-2"],
}, "ext-stale");
const sessions = [resumedElsewhere, trulyOrphaned];
const next = pruneInactiveScopedSessions(
sessions,
new Set(["terminal-open-B"]),
new Set(["terminal-restorable"]),
);
// Only the one not being displayed anywhere should show up as orphaned.
assert.deepEqual(next.orphanedSessionIds, ["terminal-stale"]);
// The resumed session must retain its externalSessionId.
const resumedNext = next.sessions.find((s) => s.id === "terminal-restorable");
assert.equal(resumedNext?.externalSessionId, "ext-resumed");
});
+124
View File
@@ -0,0 +1,124 @@
import type {
AIDraft,
AIPanelView,
AISession,
} from "../../infrastructure/ai/types";
type DraftsByScope = Partial<Record<string, AIDraft>>;
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
type ActiveSessionIdMap = Record<string, string | null>;
function isInactiveScopedTarget(
scopeKey: string,
activeTargetIds: Set<string>,
): boolean {
const separatorIndex = scopeKey.indexOf(":");
if (separatorIndex === -1) return false;
const scopeType = scopeKey.slice(0, separatorIndex);
if (scopeType !== "terminal" && scopeType !== "workspace") return false;
const targetId = scopeKey.slice(separatorIndex + 1);
if (!targetId) return false;
return !activeTargetIds.has(targetId);
}
export function pruneInactiveScopedState(
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,
activeTargetIds: Set<string>,
): {
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
} {
const nextDraftsByScope = { ...draftsByScope };
const nextPanelViewByScope = { ...panelViewByScope };
let draftsChanged = false;
let panelViewsChanged = false;
for (const scopeKey of Object.keys(nextDraftsByScope)) {
if (!isInactiveScopedTarget(scopeKey, activeTargetIds)) continue;
delete nextDraftsByScope[scopeKey];
draftsChanged = true;
}
for (const scopeKey of Object.keys(nextPanelViewByScope)) {
if (!isInactiveScopedTarget(scopeKey, activeTargetIds)) continue;
delete nextPanelViewByScope[scopeKey];
panelViewsChanged = true;
}
return {
draftsByScope: draftsChanged ? nextDraftsByScope : draftsByScope,
panelViewByScope: panelViewsChanged ? nextPanelViewByScope : panelViewByScope,
};
}
export function pruneInactiveScopedTransientState(
activeSessionIdMap: ActiveSessionIdMap,
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,
activeTargetIds: Set<string>,
): {
activeSessionIdMap: ActiveSessionIdMap;
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
} {
let activeSessionMapChanged = false;
const nextActiveSessionIdMap: ActiveSessionIdMap = {};
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
if (isInactiveScopedTarget(scopeKey, activeTargetIds)) {
activeSessionMapChanged = true;
continue;
}
nextActiveSessionIdMap[scopeKey] = sessionId;
}
const nextScopedState = pruneInactiveScopedState(
draftsByScope,
panelViewByScope,
activeTargetIds,
);
return {
activeSessionIdMap: activeSessionMapChanged ? nextActiveSessionIdMap : activeSessionIdMap,
draftsByScope: nextScopedState.draftsByScope,
panelViewByScope: nextScopedState.panelViewByScope,
};
}
export function pruneInactiveScopedSessions(
sessions: AISession[],
activeTargetIds: Set<string>,
/**
* Session ids currently displayed by any live scope. A session whose
* `scope.targetId` is inactive but whose id is still in use somewhere
* (e.g. resumed from history into a different terminal) must not be
* treated as orphaned — deleting it outright would break the chat the
* user is actively continuing.
*/
activeSessionIds: Set<string> = new Set(),
): {
sessions: AISession[];
orphanedSessionIds: string[];
} {
const orphanedSessionIds = sessions
.filter((session) => session.scope.targetId && !activeTargetIds.has(session.scope.targetId))
.filter((session) => !activeSessionIds.has(session.id))
.map((session) => session.id);
if (orphanedSessionIds.length === 0) {
return {
sessions,
orphanedSessionIds,
};
}
return {
sessions,
orphanedSessionIds,
};
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Same-window AI-state-changed event plumbing.
*
* `localStorage` writes only emit `storage` events in *other* windows; the
* window doing the write never gets notified. That's a problem for code
* that mutates AI storage outside of `useAIState`'s setters (e.g. sync
* apply): without a manual nudge, mounted components keep showing stale
* AI state until reload.
*
* Both the dispatcher and `useAIState`'s listener live here so non-React
* call sites (sync, IPC handlers, etc.) can fire the event without
* pulling in the hook.
*/
export const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
export function emitAIStateChanged(key: string): void {
if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent<{ key: string }>(AI_STATE_CHANGED_EVENT, { detail: { key } }));
}
+241
View File
@@ -0,0 +1,241 @@
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
STORAGE_KEY_AI_SESSIONS,
} from '../../infrastructure/config/storageKeys';
import type {
AIDraft,
AIPanelView,
AISession,
AIPermissionMode,
AIToolIntegrationMode,
} from '../../infrastructure/ai/types';
import {
bumpDraftMutationVersionState,
bumpDraftUploadGenerationState,
getDraftUploadGenerationState,
} from './aiDraftState';
import {
pruneInactiveScopedSessions,
pruneInactiveScopedTransientState,
} from './aiScopeCleanup';
import { emitAIStateChanged } from './aiStateEvents';
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
export interface AIBridge {
aiSdkAgentCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
aiMcpSetToolIntegrationMode?: (mode: AIToolIntegrationMode) => Promise<unknown> | unknown;
aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown;
aiMcpSetCommandTimeout?: (timeout: number) => Promise<unknown> | unknown;
aiMcpSetMaxIterations?: (maxIterations: number) => Promise<unknown> | unknown;
}
export function getAIBridge() {
return (window as unknown as { netcatty?: AIBridge }).netcatty;
}
export const AI_STATE_CHANGED_DRAFTS_BY_SCOPE = 'netcatty:ai-drafts-by-scope';
export const AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE = 'netcatty:ai-panel-view-by-scope';
export type DraftsByScope = Partial<Record<string, AIDraft>>;
export type PanelViewByScope = Partial<Record<string, AIPanelView>>;
export function cleanupSdkAgentSessions(sessionIds: string[]) {
const bridge = getAIBridge();
if (!bridge?.aiSdkAgentCleanup || sessionIds.length === 0) return;
for (const sessionId of sessionIds) {
void bridge.aiSdkAgentCleanup(sessionId).catch(() => {});
}
}
function isScopeKeyActive(scopeKey: string, activeTargetIds: Set<string>) {
const separatorIndex = scopeKey.indexOf(':');
if (separatorIndex === -1) return true;
const targetId = scopeKey.slice(separatorIndex + 1);
if (!targetId) return true;
return activeTargetIds.has(targetId);
}
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
const currentSessions = latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
// Sessions shown by a still-live scope must be protected from cleanup
// even when their own `scope.targetId` points at a closed terminal —
// history can be resumed into a different terminal and we must not
// delete it outright while it's actively being used.
const preCleanupActiveSessionMap = latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {};
const activeSessionIds = new Set<string>();
for (const [scopeKey, sessionId] of Object.entries(preCleanupActiveSessionMap)) {
if (!sessionId) continue;
if (!isScopeKeyActive(scopeKey, activeTargetIds)) continue;
activeSessionIds.add(sessionId);
}
const nextSessionCleanup = pruneInactiveScopedSessions(
currentSessions,
activeTargetIds,
activeSessionIds,
);
if (nextSessionCleanup.orphanedSessionIds.length > 0) {
cleanupSdkAgentSessions(nextSessionCleanup.orphanedSessionIds);
}
if (nextSessionCleanup.sessions !== currentSessions) {
setLatestAISessionsSnapshot(nextSessionCleanup.sessions);
localStorageAdapter.write(
STORAGE_KEY_AI_SESSIONS,
pruneSessionsForStorage(nextSessionCleanup.sessions),
);
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
}
const activeSessionIdMap = preCleanupActiveSessionMap;
let activeSessionMapChanged = false;
const nextActiveSessionIdMap = { ...activeSessionIdMap };
for (const scopeKey of Object.keys(activeSessionIdMap)) {
if (isScopeKeyActive(scopeKey, activeTargetIds)) continue;
delete nextActiveSessionIdMap[scopeKey];
activeSessionMapChanged = true;
}
if (activeSessionMapChanged) {
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
const currentActiveSessionIdMap = activeSessionMapChanged
? nextActiveSessionIdMap
: activeSessionIdMap;
const currentDraftsByScope = latestAIDraftsByScopeSnapshot ?? {};
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
const prunedScopedTransientState = pruneInactiveScopedTransientState(
currentActiveSessionIdMap,
currentDraftsByScope,
currentPanelViewByScope,
activeTargetIds,
);
if (prunedScopedTransientState.activeSessionIdMap !== currentActiveSessionIdMap) {
setLatestAIActiveSessionMapSnapshot(prunedScopedTransientState.activeSessionIdMap);
localStorageAdapter.write(
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
prunedScopedTransientState.activeSessionIdMap,
);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
if (prunedScopedTransientState.draftsByScope !== currentDraftsByScope) {
for (const scopeKey of Object.keys(currentDraftsByScope)) {
if (scopeKey in prunedScopedTransientState.draftsByScope) continue;
bumpDraftMutationVersion(scopeKey);
bumpDraftUploadGeneration(scopeKey);
}
setLatestAIDraftsByScopeSnapshot(prunedScopedTransientState.draftsByScope);
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
}
if (prunedScopedTransientState.panelViewByScope !== currentPanelViewByScope) {
for (const scopeKey of Object.keys(currentPanelViewByScope)) {
if (scopeKey in prunedScopedTransientState.panelViewByScope) continue;
bumpDraftMutationVersion(scopeKey);
}
setLatestAIPanelViewByScopeSnapshot(prunedScopedTransientState.panelViewByScope);
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
}
}
/** Maximum number of sessions to keep in localStorage. */
const MAX_STORED_SESSIONS = 50;
/** Maximum number of messages per session when persisting to localStorage. */
const MAX_SESSION_MESSAGES = 200;
/**
* Prune sessions before writing to localStorage to prevent hitting the
* ~5-10 MB storage quota. Only affects what is persisted — the in-memory
* state retains all messages until the session is reloaded.
*
* - Keeps only the MAX_STORED_SESSIONS most-recently-updated sessions.
* - Trims each session's messages to the last MAX_SESSION_MESSAGES.
*/
export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
// Sort by updatedAt descending so we keep the newest
const sorted = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt);
const limited = sorted.slice(0, MAX_STORED_SESSIONS);
return limited.map(s => {
if (s.messages.length > MAX_SESSION_MESSAGES) {
return { ...s, messages: s.messages.slice(-MAX_SESSION_MESSAGES) };
}
return s;
});
}
export let latestAISessionsSnapshot: AISession[] | null = null;
export let latestAIActiveSessionMapSnapshot: Record<string, string | null> | null = null;
export let latestAIDraftsByScopeSnapshot: DraftsByScope | null = null;
export let latestAIPanelViewByScopeSnapshot: PanelViewByScope | null = null;
let latestAIDraftMutationVersionByScopeSnapshot: Record<string, number> = {};
let latestAIDraftUploadGenerationByScopeSnapshot: Record<string, number> = {};
export function setLatestAISessionsSnapshot(sessions: AISession[]) {
latestAISessionsSnapshot = sessions;
}
export function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<string, string | null>) {
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
}
export function prewarmAIStateStorageSnapshots() {
try {
if (latestAISessionsSnapshot === null) {
latestAISessionsSnapshot =
localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? [];
}
if (latestAIActiveSessionMapSnapshot === null) {
latestAIActiveSessionMapSnapshot =
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
}
} catch (error) {
console.warn('[AIState] Failed to prewarm AI state storage snapshots:', error);
}
}
export function setLatestAIDraftsByScopeSnapshot(draftsByScope: DraftsByScope) {
latestAIDraftsByScopeSnapshot = draftsByScope;
}
export function setLatestAIPanelViewByScopeSnapshot(panelViewByScope: PanelViewByScope) {
latestAIPanelViewByScopeSnapshot = panelViewByScope;
}
export function bumpDraftMutationVersion(scopeKey: string) {
latestAIDraftMutationVersionByScopeSnapshot = bumpDraftMutationVersionState(
latestAIDraftMutationVersionByScopeSnapshot,
scopeKey,
);
}
export function getDraftUploadGeneration(scopeKey: string) {
return getDraftUploadGenerationState(
latestAIDraftUploadGenerationByScopeSnapshot,
scopeKey,
);
}
export function bumpDraftUploadGeneration(scopeKey: string) {
latestAIDraftUploadGenerationByScopeSnapshot = bumpDraftUploadGenerationState(
latestAIDraftUploadGenerationByScopeSnapshot,
scopeKey,
);
}
+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,37 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveAutoSyncHashDecision } from "./autoSyncHashDecision.ts";
test("remote-applied data is skipped only when the current hash still matches it", () => {
assert.equal(
resolveAutoSyncHashDecision({
currentHash: "remote-applied",
lastSyncedHash: "old-local",
appliedSkipHash: "remote-applied",
}),
"skip-applied",
);
});
test("user edits after remote apply still sync even when they match the old baseline", () => {
assert.equal(
resolveAutoSyncHashDecision({
currentHash: "old-local",
lastSyncedHash: "old-local",
appliedSkipHash: "remote-applied",
}),
"sync",
);
});
test("unchanged data is skipped only when there is no pending remote-apply hash", () => {
assert.equal(
resolveAutoSyncHashDecision({
currentHash: "same",
lastSyncedHash: "same",
appliedSkipHash: null,
}),
"unchanged",
);
});
+16
View File
@@ -0,0 +1,16 @@
export type AutoSyncHashDecision = 'skip-applied' | 'unchanged' | 'sync';
export function resolveAutoSyncHashDecision({
currentHash,
lastSyncedHash,
appliedSkipHash,
}: {
currentHash: string;
lastSyncedHash: string;
appliedSkipHash: string | null;
}): AutoSyncHashDecision {
if (appliedSkipHash !== null) {
return currentHash === appliedSkipHash ? 'skip-applied' : 'sync';
}
return currentHash === lastSyncedHash ? 'unchanged' : 'sync';
}
@@ -0,0 +1,111 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
getRuntimeRemoteCheckIntervalMs,
shouldRunRuntimeRemoteCheck,
} from './autoSyncRemoteSchedule';
test("runtime remote checks wait for the startup check to finish", () => {
assert.equal(
shouldRunRuntimeRemoteCheck({
hasAnyConnectedProvider: true,
autoSyncEnabled: true,
isUnlocked: true,
startupRemoteCheckDone: false,
isSyncing: false,
isSyncRunning: false,
remoteCheckInFlight: false,
now: 10_000,
lastRemoteCheckAt: null,
minIntervalMs: 30_000,
}),
false,
);
});
test("runtime remote checks run immediately after startup gate opens", () => {
assert.equal(
shouldRunRuntimeRemoteCheck({
hasAnyConnectedProvider: true,
autoSyncEnabled: true,
isUnlocked: true,
startupRemoteCheckDone: true,
isSyncing: false,
isSyncRunning: false,
remoteCheckInFlight: false,
now: 10_000,
lastRemoteCheckAt: null,
minIntervalMs: 30_000,
}),
true,
);
});
test("runtime remote checks respect the minimum interval", () => {
const common = {
hasAnyConnectedProvider: true,
autoSyncEnabled: true,
isUnlocked: true,
startupRemoteCheckDone: true,
isSyncing: false,
isSyncRunning: false,
remoteCheckInFlight: false,
minIntervalMs: 30_000,
};
assert.equal(
shouldRunRuntimeRemoteCheck({
...common,
now: 35_000,
lastRemoteCheckAt: 10_000,
}),
false,
);
assert.equal(
shouldRunRuntimeRemoteCheck({
...common,
now: 40_000,
lastRemoteCheckAt: 10_000,
}),
true,
);
});
test("forced runtime remote checks bypass only the interval gate", () => {
const common = {
hasAnyConnectedProvider: true,
autoSyncEnabled: true,
isUnlocked: true,
startupRemoteCheckDone: true,
isSyncing: false,
isSyncRunning: false,
remoteCheckInFlight: false,
minIntervalMs: 30_000,
force: true,
};
assert.equal(
shouldRunRuntimeRemoteCheck({
...common,
now: 35_000,
lastRemoteCheckAt: 10_000,
}),
true,
);
assert.equal(
shouldRunRuntimeRemoteCheck({
...common,
isSyncing: true,
now: 35_000,
lastRemoteCheckAt: 10_000,
}),
false,
);
});
test("configured auto-sync intervals map to bounded remote recheck intervals", () => {
assert.equal(getRuntimeRemoteCheckIntervalMs(1), 30_000);
assert.equal(getRuntimeRemoteCheckIntervalMs(10), 300_000);
assert.equal(getRuntimeRemoteCheckIntervalMs(120), 300_000);
});
@@ -0,0 +1,35 @@
const MIN_RUNTIME_REMOTE_CHECK_MS = 30_000;
const MAX_RUNTIME_REMOTE_CHECK_MS = 5 * 60_000;
export function getRuntimeRemoteCheckIntervalMs(autoSyncIntervalMinutes: number): number {
const configuredMs = Math.max(1, Number(autoSyncIntervalMinutes) || 1) * 60_000;
return Math.max(
MIN_RUNTIME_REMOTE_CHECK_MS,
Math.min(MAX_RUNTIME_REMOTE_CHECK_MS, Math.floor(configuredMs / 2)),
);
}
export interface RuntimeRemoteCheckInput {
hasAnyConnectedProvider: boolean;
autoSyncEnabled: boolean;
isUnlocked: boolean;
startupRemoteCheckDone: boolean;
isSyncing: boolean;
isSyncRunning: boolean;
remoteCheckInFlight: boolean;
force?: boolean;
now: number;
lastRemoteCheckAt: number | null;
minIntervalMs: number;
}
export function shouldRunRuntimeRemoteCheck(input: RuntimeRemoteCheckInput): boolean {
if (!input.hasAnyConnectedProvider) return false;
if (!input.autoSyncEnabled) return false;
if (!input.isUnlocked) return false;
if (!input.startupRemoteCheckDone) return false;
if (input.isSyncing || input.isSyncRunning || input.remoteCheckInFlight) return false;
if (input.force === true) return true;
if (input.lastRemoteCheckAt == null) return true;
return input.now - input.lastRemoteCheckAt >= input.minIntervalMs;
}
+12 -16
View File
@@ -75,7 +75,6 @@ class CustomThemeStore {
if (payload.key === STORAGE_KEY_CUSTOM_THEMES) {
// Another window changed custom themes — reload from localStorage
this.loadFromStorage();
this.notify();
}
});
} catch {
@@ -129,6 +128,13 @@ class CustomThemeStore {
this.notify();
this.broadcastChange();
};
replaceThemes = (themes: TerminalTheme[]) => {
this.themes = themes.map((theme) => ({ ...theme, colors: { ...theme.colors }, isCustom: true }));
this.saveToStorage();
this.notify();
this.broadcastChange();
};
}
// Singleton
@@ -136,14 +142,6 @@ export const customThemeStore = new CustomThemeStore();
// ============== Hooks ==============
/** Get all themes (built-in + custom) */
export const useAllThemes = (): TerminalTheme[] => {
return useSyncExternalStore(
customThemeStore.subscribe,
customThemeStore.getAllThemes
);
};
/** Get custom themes only */
export const useCustomThemes = (): TerminalTheme[] => {
return useSyncExternalStore(
@@ -152,12 +150,6 @@ export const useCustomThemes = (): TerminalTheme[] => {
);
};
/** Get theme by ID (built-in or custom) with fallback */
export const useThemeById = (id: string): TerminalTheme => {
const allThemes = useAllThemes();
return allThemes.find(t => t.id === id) || TERMINAL_THEMES[0];
};
/** Theme mutation actions */
export const useCustomThemeActions = () => {
const addTheme = useCallback((theme: TerminalTheme) => {
@@ -172,5 +164,9 @@ export const useCustomThemeActions = () => {
customThemeStore.deleteTheme(id);
}, []);
return { addTheme, updateTheme, deleteTheme };
const replaceThemes = useCallback((themes: TerminalTheme[]) => {
customThemeStore.replaceThemes(themes);
}, []);
return { addTheme, updateTheme, deleteTheme, replaceThemes };
};
@@ -0,0 +1,194 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
clearKeyPassphrasesByIds,
clearReferenceKeyPassphrases,
loadDefaultKeyPassphrase,
rememberKeyPassphrase,
shouldUpdateReferenceKeyPassphrase,
} from "../defaultKeyPassphrases";
import { STORAGE_KEY_DEFAULT_KEY_PASSPHRASES } from "../../infrastructure/config/storageKeys";
import type { SSHKey } from "../../domain/models";
function installLocalStorage(t: test.TestContext): void {
const store = new Map<string, string>();
const storage: Storage = {
get length() {
return store.size;
},
clear() {
store.clear();
},
getItem(key: string) {
return store.get(key) ?? null;
},
key(index: number) {
return Array.from(store.keys())[index] ?? null;
},
removeItem(key: string) {
store.delete(key);
},
setItem(key: string, value: string) {
store.set(key, value);
},
};
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: storage,
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { netcatty: undefined },
});
t.after(() => {
Reflect.deleteProperty(globalThis, "localStorage");
Reflect.deleteProperty(globalThis, "window");
});
}
const referenceKey = (): SSHKey => ({
id: "reference-key",
label: "id_ed25519",
type: "ED25519",
category: "key",
source: "reference",
filePath: "/Users/alice/.ssh/id_ed25519",
privateKey: "",
created: 1,
});
test("loadDefaultKeyPassphrase removes undecryptable credential placeholders", async (t) => {
installLocalStorage(t);
const keyPath = "/Users/alice/.ssh/id_ed25519";
globalThis.localStorage.setItem(
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({
[keyPath]: "enc:v1:djEwYWJj",
"/Users/alice/.ssh/id_rsa": "still-valid",
}),
);
const result = await loadDefaultKeyPassphrase(keyPath);
assert.equal(result, null);
assert.deepEqual(
JSON.parse(globalThis.localStorage.getItem(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? "{}"),
{ "/Users/alice/.ssh/id_rsa": "still-valid" },
);
});
test("loadDefaultKeyPassphrase returns plain stored passphrases", async (t) => {
installLocalStorage(t);
const keyPath = "/Users/alice/.ssh/id_ed25519";
globalThis.localStorage.setItem(
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({ [keyPath]: "correct horse battery staple" }),
);
assert.equal(await loadDefaultKeyPassphrase(keyPath), "correct horse battery staple");
});
test("clearReferenceKeyPassphrases clears matching reference key paths only", () => {
const keys: SSHKey[] = [
{
...referenceKey(),
passphrase: "bad",
savePassphrase: true,
},
{
...referenceKey(),
id: "other-key",
label: "other",
filePath: "/Users/alice/.ssh/other",
passphrase: "keep",
savePassphrase: true,
},
];
const updated = clearReferenceKeyPassphrases(keys, ["/Users/alice/.ssh/id_ed25519"]);
assert.equal(updated[0].passphrase, undefined);
assert.equal(updated[0].savePassphrase, false);
assert.equal(updated[1].passphrase, "keep");
});
test("clearKeyPassphrasesByIds clears matching saved key passphrases", () => {
const keys: SSHKey[] = [
{
...referenceKey(),
id: "inline-key",
source: "imported",
filePath: undefined,
privateKey: "PRIVATE KEY",
passphrase: "bad",
savePassphrase: true,
},
{
...referenceKey(),
id: "other-key",
label: "other",
passphrase: "keep",
savePassphrase: true,
},
];
const updated = clearKeyPassphrasesByIds(keys, ["inline-key"]);
assert.equal(updated[0].passphrase, undefined);
assert.equal(updated[0].savePassphrase, false);
assert.equal(updated[1].passphrase, "keep");
});
test("shouldUpdateReferenceKeyPassphrase replaces missing or undecryptable passphrases", () => {
assert.equal(shouldUpdateReferenceKeyPassphrase(null), false);
assert.equal(shouldUpdateReferenceKeyPassphrase(referenceKey()), true);
assert.equal(
shouldUpdateReferenceKeyPassphrase({
...referenceKey(),
passphrase: "enc:v1:djEwAAAA",
}),
true,
);
assert.equal(
shouldUpdateReferenceKeyPassphrase({
...referenceKey(),
passphrase: "saved",
}),
false,
);
});
test("rememberKeyPassphrase updates reference key state before completing", async (t) => {
installLocalStorage(t);
const keys = [referenceKey()];
let currentKeys = keys;
let releaseUpdate: (() => void) | undefined;
let rememberPromise: Promise<void> | undefined;
const updateStarted = new Promise<void>((resolve) => {
const updateKeys = async (updated: SSHKey[]) => {
assert.equal(currentKeys[0].passphrase, "saved");
assert.equal(updated[0].passphrase, "saved");
resolve();
await new Promise<void>((release) => {
releaseUpdate = release;
});
};
rememberPromise = rememberKeyPassphrase({
keyPath: "/Users/alice/.ssh/id_ed25519",
passphrase: "saved",
keys,
updateKeys,
setCurrentKeys: (updated) => {
currentKeys = updated;
},
});
});
await updateStarted;
assert.equal(currentKeys[0].passphrase, "saved");
releaseUpdate?.();
await rememberPromise;
});
Loaded 100 of 1941 files, more files were not shown because too many files have changed in this diff. Show more