Compare commits

...
481 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
822 changed files with 92111 additions and 8987 deletions

No files matched your search

+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).
+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}`);
-233
View File
@@ -1,233 +0,0 @@
name: build-mosh-binaries
# Trigger philosophy (mirrors build.yml):
# - Pushes that touch the mosh build pipeline + PRs run the matrix
# so we can validate workflow / script changes without tagging.
# Artifacts upload as workflow artifacts only; *no* release.
# - Manual `workflow_dispatch` with `release_tag` publishes the
# binaries + SHA256SUMS to the dedicated binary repository
# (`binaricat/Netcatty-mosh-bin` by default).
#
# `paths` keeps unrelated commits (UI, bridges, etc) from rebuilding
# or refreshing mosh binaries on every push.
on:
workflow_dispatch:
inputs:
mosh_ref:
description: "mosh upstream git ref (tag/branch/commit) — see https://github.com/mobile-shell/mosh"
type: string
default: "mosh-1.4.0"
release_tag:
description: "Optional release tag to attach binaries to (e.g. mosh-bin-1.4.0-1). Empty = artifacts only."
type: string
default: ""
release_repo:
description: "Repository that stores mosh-client binary releases."
type: string
default: "binaricat/Netcatty-mosh-bin"
push:
branches:
- "**"
paths:
- ".gitattributes"
- ".github/workflows/build-mosh-binaries.yml"
- "electron-builder.config.cjs"
- "package.json"
- "scripts/build-mosh/**"
- "scripts/fetch-mosh-binaries.cjs"
- "scripts/mosh-extra-resources.cjs"
pull_request:
paths:
- ".gitattributes"
- ".github/workflows/build-mosh-binaries.yml"
- "electron-builder.config.cjs"
- "package.json"
- "scripts/build-mosh/**"
- "scripts/fetch-mosh-binaries.cjs"
- "scripts/mosh-extra-resources.cjs"
# Cancel superseded branch / PR builds.
concurrency:
group: build-mosh-binaries-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
MOSH_REF: ${{ inputs.mosh_ref || 'mosh-1.4.0' }}
jobs:
# ------------------------------------------------------------------
# Linux x64 (manylinux2014 / glibc 2.17, broad distro compatibility).
# Static-links the heavy third-party deps where possible; the resulting
# mosh-client still depends on baseline Linux system libraries.
# ------------------------------------------------------------------
build-linux-x64:
name: build-linux-x64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build mosh-client (linux-x64)
run: |
# Run only the compiler inside manylinux2014. JavaScript actions
# need the host runner's newer glibc.
docker run --rm \
-e MOSH_REF="${MOSH_REF}" \
-e OUT_DIR=/work/out \
-e ARCH=x64 \
-v "${GITHUB_WORKSPACE}:/work" \
-w /work \
quay.io/pypa/manylinux2014_x86_64 \
bash scripts/build-mosh/build-linux.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: mosh-client-linux-x64
path: out/
build-linux-arm64:
name: build-linux-arm64
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- name: Build mosh-client (linux-arm64)
run: |
# Run only the compiler inside manylinux2014. JavaScript actions
# need the host runner's newer glibc.
docker run --rm \
-e MOSH_REF="${MOSH_REF}" \
-e OUT_DIR=/work/out \
-e ARCH=arm64 \
-v "${GITHUB_WORKSPACE}:/work" \
-w /work \
quay.io/pypa/manylinux2014_aarch64 \
bash scripts/build-mosh/build-linux.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: mosh-client-linux-arm64
path: out/
# ------------------------------------------------------------------
# macOS universal2 (arm64 + x86_64 lipo).
# Min deployment target: macOS 11 (Big Sur) — covers arm64 hardware.
# Static-links OpenSSL, protobuf, ncurses for both arches.
# ------------------------------------------------------------------
build-macos-universal:
name: build-macos-universal
runs-on: macos-15-intel
steps:
- uses: actions/checkout@v4
- name: Build mosh-client (darwin-universal)
env:
MOSH_REF: ${{ env.MOSH_REF }}
OUT_DIR: ${{ github.workspace }}/out
MACOSX_DEPLOYMENT_TARGET: "11.0"
run: bash scripts/build-mosh/build-macos.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: mosh-client-darwin-universal
path: out/
# ------------------------------------------------------------------
# Windows x64 pinned standalone client.
# Do not compile this in CI: the upstream Cygwin build can clear the
# terminal and never render output on Windows. Ship the SHA256-pinned
# FluentTerminal standalone binary verified by fetch-windows.sh.
# ------------------------------------------------------------------
fetch-windows-x64:
name: fetch-windows-x64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Fetch pinned mosh-client.exe (win32-x64)
run: |
set -euo pipefail
export OUT_DIR="${GITHUB_WORKSPACE}/out"
mkdir -p "$OUT_DIR"
bash scripts/build-mosh/fetch-windows.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: mosh-client-win32-x64
path: out/
# ------------------------------------------------------------------
# Windows arm64 — intentionally not built.
# The pinned upstream source only provides x64. arm64 Windows builds
# should be added only after we have a tested standalone arm64 client.
# ------------------------------------------------------------------
# ------------------------------------------------------------------
# Aggregate + optional release to the dedicated binary repository.
# ------------------------------------------------------------------
release:
name: release
needs:
- build-linux-x64
- build-linux-arm64
- build-macos-universal
- fetch-windows-x64
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' && inputs.release_tag != ''
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Stage release files
run: |
set -euo pipefail
mkdir -p release
for d in artifacts/*/; do
find "$d" -maxdepth 1 -type f -exec cp {} release/ \;
done
(cd release && find . -maxdepth 1 -type f ! -name SHA256SUMS -printf '%P\n' | sort | xargs sha256sum > SHA256SUMS)
ls -la release
cat release/SHA256SUMS
- name: Determine tag
id: tag
env:
RELEASE_TAG: ${{ inputs.release_tag }}
run: |
tag="${RELEASE_TAG}"
if [[ ! "$tag" =~ ^mosh-bin-[A-Za-z0-9._-]+$ ]]; then
echo "Invalid mosh binary release tag: $tag" >&2
exit 1
fi
printf 'name=%s\n' "$tag" >> "$GITHUB_OUTPUT"
- name: Create / update release
env:
GH_TOKEN: ${{ secrets.MOSH_BIN_RELEASE_TOKEN }}
RELEASE_REPO: ${{ inputs.release_repo }}
RELEASE_TAG: ${{ steps.tag.outputs.name }}
run: |
set -euo pipefail
if [[ -z "${GH_TOKEN:-}" ]]; then
echo "::error::MOSH_BIN_RELEASE_TOKEN is required to publish into ${RELEASE_REPO}."
exit 1
fi
{
printf '%s\n' 'Pre-built `mosh-client` binaries consumed by `scripts/fetch-mosh-binaries.cjs` during `npm run pack`.'
printf 'Linux/macOS artifacts are built from `mobile-shell/mosh` upstream ref `%s`.\n' "${MOSH_REF}"
printf '%s\n\n' 'Windows x64 is the SHA256-pinned FluentTerminal standalone `mosh-client.exe` fallback.'
printf 'Source workflow: %s/%s/actions/runs/%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_RUN_ID}"
printf 'Source commit: `%s`\n\n' "${GITHUB_SHA}"
printf '%s\n' 'All artifacts are GPL-3.0; see `resources/mosh/README.md` for source provenance.'
} > release-notes.md
if gh release view "${RELEASE_TAG}" --repo "${RELEASE_REPO}" >/dev/null 2>&1; then
gh release edit "${RELEASE_TAG}" \
--repo "${RELEASE_REPO}" \
--title "${RELEASE_TAG}" \
--notes-file release-notes.md
gh release upload "${RELEASE_TAG}" release/* \
--repo "${RELEASE_REPO}" \
--clobber
else
gh release create "${RELEASE_TAG}" release/* \
--repo "${RELEASE_REPO}" \
--title "${RELEASE_TAG}" \
--notes-file release-notes.md
fi
+228 -17
View File
@@ -354,11 +354,17 @@ jobs:
release/*.blockmap
if-no-files-found: ignore
# Linux x64 — pin to ubuntu-22.04 for broader glibc compatibility.
# ubuntu-latest (24.04) links native modules against glibc 2.39 which
# can cause dlopen failures on some distros. 22.04 uses glibc 2.35,
# compatible with most current Linux distributions including Arch.
# See #264.
# Dedicated job for Linux x64 — builds inside AlmaLinux 8 (GLIBC 2.28)
# so native modules (node-pty, serialport) stay compatible with RHEL 8 /
# CentOS 8 / UOS / Deepin. Ubuntu 22.04 links against GLIBC 2.32–2.35,
# which crashes on those older hosts (see #2062).
#
# We previously used debian:buster for the same glibc floor, but Buster's
# g++ 8 cannot compile Electron 42 natives (`-std=gnu++20`). AlmaLinux 8
# keeps glibc 2.28 while gcc-toolset-13 provides a real C++20 toolchain.
# Link natives with -static-libstdc++ so the result does not require a
# newer system libstdc++ on RHEL 8 derivatives.
# Key: GLIBC < 2.34 also avoids the libpthread-merge symbol requirement.
build-linux-x64:
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }}
needs: [dedupe, resolve-mosh, resolve-et]
@@ -366,12 +372,16 @@ jobs:
always()
&& needs.dedupe.result == 'success'
&& needs.dedupe.outputs.skip_heavy_ci != 'true'
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
container:
image: almalinux:8
env:
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
ET_BIN_RELEASE: ${{ needs.resolve-et.outputs.et_bin_release }}
npm_config_arch: x64
npm_config_target_arch: x64
# Keep rebuilt natives portable to stock RHEL 8 libstdc++.
LDFLAGS: "-static-libstdc++ -static-libgcc"
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
@@ -399,20 +409,159 @@ jobs:
exit 1
fi
- name: Install build dependencies
shell: bash
run: |
set -euo pipefail
dnf -y install epel-release
dnf -y install \
curl ca-certificates \
gcc-toolset-13-gcc gcc-toolset-13-gcc-c++ make \
python3.11 python3.11-pip \
git file rpm-build binutils cpio elfutils \
libarchive gzip bzip2 xz zstd tar which findutils \
dpkg fakeroot \
fuse fuse-libs gtk3 nss libXScrnSaver libXtst alsa-lib \
at-spi2-atk libdrm mesa-libgbm libX11 libX11-xcb libxcb \
mesa-libGL pango cairo
# gcc-toolset-13: C++20 for Electron 42 natives; still links glibc 2.28.
GCC_TOOLSET_ROOT="/opt/rh/gcc-toolset-13/root"
echo "PATH=${GCC_TOOLSET_ROOT}/usr/bin:${PATH}" >> "${GITHUB_ENV}"
echo "CC=${GCC_TOOLSET_ROOT}/usr/bin/gcc" >> "${GITHUB_ENV}"
echo "CXX=${GCC_TOOLSET_ROOT}/usr/bin/g++" >> "${GITHUB_ENV}"
echo "DEVTOOLSET_ROOTPATH=${GCC_TOOLSET_ROOT}" >> "${GITHUB_ENV}"
export PATH="${GCC_TOOLSET_ROOT}/usr/bin:${PATH}"
gcc --version
g++ --version
# AlmaLinux 8's libarchive package is often library-only in minimal
# images (no /usr/bin/bsdtar). electron-builder pacman packaging needs
# bsdtar; pull a Debian tools package that runs on glibc 2.28.
# Prefer Buster (published as +deb10u1 on archive.debian.org).
if [[ ! -x /usr/bin/bsdtar ]]; then
echo "bsdtar missing; installing CLI from Debian libarchive-tools"
mkdir -p /tmp/bsdtar-deb
BSDTAR_DEB_URLS=(
"http://archive.debian.org/debian/pool/main/liba/libarchive/libarchive-tools_3.3.3-4+deb10u1_amd64.deb"
"http://archive.debian.org/debian/pool/main/liba/libarchive/libarchive-tools_3.2.2-2+deb9u2_amd64.deb"
)
BSDTAR_DEB_OK=0
for url in "${BSDTAR_DEB_URLS[@]}"; do
echo "trying ${url}"
if curl -fsSL -o /tmp/libarchive-tools.deb "${url}"; then
BSDTAR_DEB_OK=1
break
fi
echo "download failed for ${url}"
done
if [[ "${BSDTAR_DEB_OK}" -ne 1 ]]; then
echo "::error::Could not download Debian libarchive-tools for bsdtar"
exit 1
fi
dpkg-deb -x /tmp/libarchive-tools.deb /tmp/bsdtar-deb
install -m 0755 /tmp/bsdtar-deb/usr/bin/bsdtar /usr/local/bin/bsdtar-bin
# Prefer system libarchive.so if present; fall back to the deb's.
if [[ ! -e /usr/lib64/libarchive.so.13 && -d /tmp/bsdtar-deb/usr/lib/x86_64-linux-gnu ]]; then
mkdir -p /usr/local/lib/bsdtar-libs
cp -a /tmp/bsdtar-deb/usr/lib/x86_64-linux-gnu/libarchive.so* /usr/local/lib/bsdtar-libs/ 2>/dev/null || true
fi
printf '%s\n' \
'#!/bin/bash' \
'unset LD_LIBRARY_PATH' \
'if [[ -d /usr/local/lib/bsdtar-libs ]]; then' \
' export LD_LIBRARY_PATH=/usr/local/lib/bsdtar-libs' \
'fi' \
'exec /usr/local/bin/bsdtar-bin "$@"' \
> /usr/local/bin/bsdtar
chmod +x /usr/local/bin/bsdtar
fi
# Some fpm paths still look for xzmt even when payload compression
# is gzip; provide a multi-thread xz shim.
printf '%s\n' '#!/bin/sh' 'exec /usr/bin/xz -T0 "$@"' > /usr/local/bin/xzmt
chmod +x /usr/local/bin/xzmt
# electron-builder's portable fpm injects its own LD_LIBRARY_PATH.
# System tools (rpmbuild, bsdtar, ...) then exit 127 under that path.
# Wrap every packaging helper so child processes see a clean env.
# Use printf (not a bare heredoc): unindented heredoc body breaks GHA YAML.
wrap_clean_env() {
local name="$1"
local real=""
if [[ -x "/usr/bin/${name}" ]]; then
real="/usr/bin/${name}"
elif [[ -x "/usr/local/bin/${name}-bin" ]]; then
real="/usr/local/bin/${name}-bin"
elif command -v "${name}" >/dev/null 2>&1; then
real="$(command -v "${name}")"
else
return 0
fi
# Don't wrap our own wrapper recursively.
if [[ "${real}" == /usr/local/bin/* && "${name}" != "bsdtar" ]]; then
return 0
fi
if [[ "${name}" == "bsdtar" && -x /usr/local/bin/bsdtar-bin ]]; then
real="/usr/local/bin/bsdtar-bin"
fi
printf '%s\n' \
'#!/bin/bash' \
'unset LD_LIBRARY_PATH' \
"exec ${real} \"\$@\"" \
> "/usr/local/bin/${name}"
chmod +x "/usr/local/bin/${name}"
}
for cmd in rpmbuild bsdtar bsdcpio tar gzip xz strip cpio; do
wrap_clean_env "${cmd}"
done
# Re-apply bsdtar wrapper with optional lib path for the deb-extracted binary.
if [[ -x /usr/local/bin/bsdtar-bin ]]; then
printf '%s\n' \
'#!/bin/bash' \
'unset LD_LIBRARY_PATH' \
'if [[ -d /usr/local/lib/bsdtar-libs ]]; then' \
' export LD_LIBRARY_PATH=/usr/local/lib/bsdtar-libs' \
'fi' \
'exec /usr/local/bin/bsdtar-bin "$@"' \
> /usr/local/bin/bsdtar
chmod +x /usr/local/bin/bsdtar
fi
# Fail early with diagnostics if packaging tools are missing.
for tool in rpmbuild bsdtar strip gzip xz xzmt; do
if ! command -v "${tool}" >/dev/null 2>&1; then
echo "::error::Required packaging tool not found: ${tool}"
ls -la /usr/bin/bsd* /usr/local/bin/bsd* 2>/dev/null || true
rpm -ql libarchive 2>/dev/null | head -n 40 || true
exit 1
fi
echo "found ${tool}: $(command -v "${tool}")"
done
rpmbuild --version
bsdtar --version | head -n 1
# Official Node 22 linux-x64 tarball (same major as other package jobs).
NODE_VERSION="$(curl -fsSL https://nodejs.org/dist/latest-v22.x/SHASUMS256.txt \
| awk '/node-v[0-9.]+-linux-x64\.tar\.xz$/{print $2; exit}' \
| sed -E 's/^node-(v[0-9.]+)-linux-x64\.tar\.xz$/\1/')"
if [[ -z "${NODE_VERSION}" ]]; then
echo "::error::Could not resolve latest Node 22 linux-x64 tarball version."
exit 1
fi
echo "Installing Node ${NODE_VERSION} from nodejs.org"
curl -fsSL "https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.xz" \
| tar -xJ -C /usr/local --strip-components=1
node -v
npm -v
# node-gyp 12 needs Python >=3.8; AlmaLinux 8 default is older.
alternatives --set python3 /usr/bin/python3.11 || ln -sfn /usr/bin/python3.11 /usr/local/bin/python3
python3 --version
echo "PYTHON=/usr/bin/python3.11" >> "${GITHUB_ENV}"
echo "npm_config_python=/usr/bin/python3.11" >> "${GITHUB_ENV}"
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install deps
run: npm ci
- name: Install pacman packaging dependencies
run: sudo apt-get update && sudo apt-get install -y libarchive-tools
run: |
python3 --version
g++ --version
npm ci
- name: Set version
shell: bash
@@ -445,7 +594,23 @@ jobs:
env:
npm_config_arch: x64
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:linux-x64
# Natives are already rebuilt. Prefer system packaging tools so
# rpmbuild brp scripts do not pick up a toolset-only binary, and so
# /usr/local/bin/rpmbuild (clean-env wrapper) wins over /usr/bin.
PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
run: |
set -euo pipefail
command -v node
command -v rpmbuild
command -v bsdtar
command -v gzip
command -v xzmt
for cmd in rpmbuild bsdtar; do
if ! head -n 2 "$(command -v "${cmd}")" | grep -q 'unset LD_LIBRARY_PATH'; then
echo "::warning::${cmd} on PATH is not the clean-env wrapper; packaging may fail with exit 127"
fi
done
npm run pack:linux-x64
- name: Verify packaged node-pty Linux runtime
run: bash scripts/ensure-node-pty-linux.sh verify x64
@@ -453,6 +618,9 @@ jobs:
- name: Verify packaged deb artifact
run: bash scripts/verify-linux-deb-artifact.sh amd64
- name: Verify packaged rpm artifact
run: bash scripts/verify-linux-rpm-artifact.sh x86_64
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
@@ -566,6 +734,9 @@ jobs:
- name: Verify packaged deb artifact
run: bash scripts/verify-linux-deb-artifact.sh arm64
- name: Verify packaged rpm artifact
run: bash scripts/verify-linux-rpm-artifact.sh aarch64
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
@@ -687,6 +858,46 @@ jobs:
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
+50
View File
@@ -0,0 +1,50 @@
name: spam-comments
on:
issue_comment:
types: [created]
permissions:
contents: read
issues: write
pull-requests: write
jobs:
delete-spam:
runs-on: ubuntu-latest
steps:
- name: Checkout filter rules
uses: actions/checkout@v4
- name: Delete suspected malware spam
uses: actions/github-script@v7
with:
script: |
const path = require('node:path');
const { detectSpamComment } = require(
path.join(process.env.GITHUB_WORKSPACE, 'scripts/spam-comment-filter.cjs')
);
const comment = context.payload.comment;
const result = detectSpamComment({
body: comment.body || '',
authorAssociation: comment.author_association,
userType: comment.user && comment.user.type,
});
core.info(JSON.stringify(result, null, 2));
if (!result.spam) {
core.info('Comment did not match the zip/dangerous-file spam filter.');
return;
}
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: comment.id,
});
core.notice(
`Deleted comment with zip/dangerous attachment by @${comment.user.login} on #${context.payload.issue.number}: ${result.reasons.join('; ')}`
);
+6 -8
View File
@@ -42,6 +42,9 @@ coverage
# Codex
/.codex/
# OMO / local agent run-continuation state
/.omo/
# Qoder
.qoder
@@ -74,16 +77,11 @@ Directory.Build.targets
build_with_vs.bat
build_with_vs2022.bat
# Bundled mosh-client binaries fetched at pack time by
# scripts/fetch-mosh-binaries.cjs. resources/mosh/README.md is
# committed; the actual binaries, the Cygwin DLL bundle (Windows),
# and the bundled ncurses terminfo database are all pulled from the
# dedicated mosh binary repository, never committed.
# Bundled MoshCatty mosh-client binaries fetched at pack time by
# scripts/fetch-mosh-binaries.cjs. resources/mosh/README.md is committed;
# pure single-binary artifacts from binaricat/MoshCatty are never committed.
/resources/mosh/*/mosh-client
/resources/mosh/*/mosh-client.exe
/resources/mosh/*/mosh-client-*-dlls/
/resources/mosh/*/*.dll
/resources/mosh/*/terminfo/
# Bundled EternalTerminal `et` client binaries fetched at pack time by
# scripts/fetch-et-binaries.cjs. resources/et/README.md is committed; the
+310 -27
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useEffectEvent, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { activeTabStore, toEditorTabId, fromEditorTabId, isEditorTabId } from './application/state/activeTabStore';
import { useAutoSync } from './application/state/useAutoSync';
import { useManagedSourceSync } from './application/state/useManagedSourceSync';
@@ -25,11 +25,13 @@ import { matchesKeyBinding } from './domain/models';
import { resolveGroupDefaults, applyGroupDefaults } from './domain/groupConfig';
import { upsertKnownHost } from './domain/knownHosts';
import { materializeHostProxyProfile } from './domain/proxyProfiles';
import { buildSshDeepLinkConnectionHost, buildSshDeepLinkHostDraft, findSshDeepLinkHost, parseSshDeepLink } from './domain/sshDeepLink';
import { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSshDeepLinkEphemeralHostFromSaved, buildSshDeepLinkHostDraft, findSshDeepLinkHost, parseSshDeepLink } from './domain/sshDeepLink';
import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from './domain/telnetDeepLink';
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from './domain/jmsDeepLink';
import { applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from './domain/ephemeralHosts';
import { resolveHostAuth } from './domain/sshAuth';
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
import {
getFollowAppTerminalThemeSelectionUpdate,
mergeTerminalHostUpdate,
TERMINAL_THEME_AUTO,
type TerminalHostUpdate,
@@ -40,6 +42,7 @@ import { resolveCloseIntent } from './application/state/resolveCloseIntent';
import { resolveSnippetsShortcutIntent } from './application/state/resolveSnippetsShortcutIntent';
import { resolveWindowCommandCloseIntent } from './application/state/windowCommandClose';
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
import { useThemeRuntime, useTerminalAppearanceInjection } from './application/state/useThemeRuntime';
import { useCustomThemes } from './application/state/customThemeStore';
import type { SyncPayload } from './domain/sync';
import { applySyncPayload, buildLocalVaultPayload, hasMeaningfulSyncData } from './application/syncPayload';
@@ -50,6 +53,8 @@ import {
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
import { localStorageAdapter } from './infrastructure/persistence/localStorageAdapter';
import { syncExternalMcpStartupState } from './application/state/useExternalMcpToggleState';
import { useExternalMcpSessionSync } from './application/state/useExternalMcpSessionSync';
import {
STORAGE_KEY_DEBUG_HOTKEYS,
STORAGE_KEY_PORT_FORWARDING,
@@ -57,6 +62,7 @@ import {
import { getEffectiveKnownHosts } from './infrastructure/syncHelpers';
import { ToastProvider, toast } from './components/ui/toast';
import { TooltipProvider } from './components/ui/tooltip';
import { ConfirmDialog } from './components/ui/confirm-dialog';
import { PortForwardHostKeyDialog } from './components/port-forwarding';
import { VaultSection } from './components/VaultView';
import { KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
@@ -66,11 +72,16 @@ import { getHostSearchMatch } from './lib/searchMatcher';
import { useDiscoveredShells, resolveShellSetting } from './lib/useDiscoveredShells';
import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession } from './types';
import { resolveSnippetCommand } from './components/SnippetExecutionProvider';
import { AppView } from './application/app/AppView';
import { isScriptSnippet } from './domain/snippetScript.ts';
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost';
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
import { setupMcpApprovalBridge } from './infrastructure/ai/shared/approvalGate';
import { AppActiveTabChrome } from './application/app/AppActiveTabChrome';
import { AppView } from './application/app/AppView';
import { useAppStartupEffects } from './application/app/useAppStartupEffects';
import { LogViewWrapper, SftpViewMount, TerminalLayerMount, VaultViewContainer } from './application/app/AppMounts';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleToggleThemeImpl, handleRootContextMenuImpl } from './application/app/AppHandlers';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleToggleThemeImpl, handleRootContextMenuImpl } from './application/app/AppHandlers';
// Initialize fonts eagerly at app startup
initializeFonts();
@@ -107,11 +118,14 @@ function App({ settings }: { settings: SettingsState }) {
// Navigation state for VaultView sections
const [navigateToSection, setNavigateToSection] = useState<VaultSection | null>(null);
const [deepLinkHostDraft, setDeepLinkHostDraft] = useState<Host | null>(null);
const [ephemeralHosts, setEphemeralHosts] = useState<Host[]>([]);
// Keyboard-interactive authentication queue (2FA/MFA) - queue-based to handle multiple concurrent sessions
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
// Passphrase request queue for encrypted SSH keys
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
const [deleteHostConfirm, setDeleteHostConfirm] = useState<{ hostId: string; name: string } | null>(null);
const [pendingNewWindowSession, setPendingNewWindowSession] = useState<OpenSessionInNewWindowPayload | null>(null);
const [pendingTrayPanelConnectHostIds, setPendingTrayPanelConnectHostIds] = useState<string[]>([]);
const isPeerSessionWindow = typeof window !== 'undefined' && window.location.hash.startsWith('#/session-window');
const {
@@ -119,6 +133,8 @@ function App({ settings }: { settings: SettingsState }) {
setTheme,
setLightUiThemeId,
setDarkUiThemeId,
lightUiThemeId,
darkUiThemeId,
resolvedTheme,
accentMode,
customAccent,
@@ -126,8 +142,9 @@ function App({ settings }: { settings: SettingsState }) {
setTerminalThemeId,
setTerminalThemeDarkId,
setTerminalThemeLightId,
terminalThemeDarkId,
terminalThemeLightId,
followAppTerminalTheme,
currentTerminalTheme,
terminalFontFamilyId,
setTerminalFontFamilyId,
terminalFontSize,
@@ -166,6 +183,16 @@ function App({ settings }: { settings: SettingsState }) {
}
}, [workspaceFocusStyle]);
// External MCP: only persistent+enabled restores at app startup.
// Keep this on the App mount path (not Settings) so temporary mode is not
// accidentally disabled when the AI settings page remounts.
// Skip peer session windows — they also mount App and must not clear a
// temporary-mode runtime that the main window already started.
useEffect(() => {
if (isPeerSessionWindow) return;
syncExternalMcpStartupState(netcattyBridge.get());
}, [isPeerSessionWindow]);
const {
isInitialized: isVaultInitialized,
hosts,
@@ -281,6 +308,11 @@ function App({ settings }: { settings: SettingsState }) {
const handleRunSnippet = useCallback(
async (snippet: Snippet, targetHosts: Host[]) => {
if (targetHosts.length === 0) return;
if (isScriptSnippet(snippet)) {
runSnippet(snippet, targetHosts);
return;
}
const command = await resolveSnippetCommand(snippet);
if (command === null) return;
runSnippet(snippet, targetHosts, command);
@@ -295,12 +327,46 @@ function App({ settings }: { settings: SettingsState }) {
// Active tab lookup maps
// ---------------------------------------------------------------------------
const customThemes = useCustomThemes();
const themeRuntime = useThemeRuntime({
terminalThemeId,
terminalThemeDarkId,
terminalThemeLightId,
followAppTerminalTheme,
resolvedTheme,
lightUiThemeId,
darkUiThemeId,
accentMode,
customAccent,
customThemes,
setTheme,
setLightUiThemeId,
setDarkUiThemeId,
});
useTerminalAppearanceInjection(themeRuntime.globalAppearance, {
includeChromeSurfaces: followAppTerminalTheme,
});
const prevFollowAppTerminalThemeRef = useRef(followAppTerminalTheme);
const clearThemeIntent = themeRuntime.clearIntent;
useLayoutEffect(() => {
if (prevFollowAppTerminalThemeRef.current === followAppTerminalTheme) return;
prevFollowAppTerminalThemeRef.current = followAppTerminalTheme;
clearThemeIntent();
}, [followAppTerminalTheme, clearThemeIntent]);
const currentTerminalTheme = themeRuntime.currentTerminalTheme;
const editorTabs = useEditorTabs();
const hostById = useMemo(
() => new Map(hosts.map((host) => [host.id, host])),
[hosts],
);
const terminalHosts = useMemo(
() => (ephemeralHosts.length > 0 ? [...hosts, ...ephemeralHosts] : hosts),
[hosts, ephemeralHosts],
);
const ephemeralHostIds = useMemo(
() => new Set(ephemeralHosts.map((host) => host.id)),
[ephemeralHosts],
);
const sessionById = useMemo(
() => new Map(sessions.map((session) => [session.id, session])),
[sessions],
@@ -338,6 +404,13 @@ function App({ settings }: { settings: SettingsState }) {
// Get port forwarding rules and import function
const { rules: portForwardingRules, importRules: importPortForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
// App-level External MCP session sync (before TerminalLayer lazy-mount).
useExternalMcpSessionSync({
sessions,
hosts: terminalHosts,
portForwardingRules,
});
const portForwardingRulesForSync = useMemo(
() =>
portForwardingRules.map((rule) => ({
@@ -521,6 +594,7 @@ function App({ settings }: { settings: SettingsState }) {
const _handleTrayJumpToSession = useEffectEvent((sessionId: string) => { return handleTrayJumpToSessionImpl(() => ({ sessionId, sessions, setActiveTabId, setWorkspaceFocusedSession }), sessionId); });
const _handleTrayTogglePortForward = useEffectEvent((ruleId: string, start: boolean) => { return handleTrayTogglePortForwardImpl(() => ({ hosts, identities, keys, knownHosts: effectiveKnownHosts, portForwardingRules, resolveEffectiveHost, ruleId, start, startTunnel, stopTunnel, t, terminalSettings, toast, undefined }), ruleId, start); });
const _handleTrayPanelConnect = useEffectEvent((hostId: string) => { return handleTrayPanelConnectImpl(() => ({ addConnectionLog, connectToHost, hostId, hosts, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef, t, toast }), hostId); });
const _handleTrayPanelConnectRequest = useEffectEvent((hostId: string) => { return handleTrayPanelConnectRequestImpl(() => ({ connectNow: _handleTrayPanelConnect, hostId, isVaultInitialized, queueConnect: (queuedHostId: string) => setPendingTrayPanelConnectHostIds((prev) => [...prev, queuedHostId]) }), hostId); });
const _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleGlobalHotkeyKeyDownImpl(() => ({ HOTKEY_DEBUG, closeTabKeyStr, e, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding }), e); });
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleEscapeKeyDownImpl(() => ({ e, isQuickSwitcherOpen, setIsQuickSwitcherOpen }), e); });
@@ -553,7 +627,7 @@ function App({ settings }: { settings: SettingsState }) {
_handleTrayJumpToSession(sessionId);
});
const unsubscribeConnect = bridge.onTrayPanelConnectToHost((hostId) => {
_handleTrayPanelConnect(hostId);
_handleTrayPanelConnectRequest(hostId);
});
return () => {
unsubscribeJump?.();
@@ -561,6 +635,15 @@ function App({ settings }: { settings: SettingsState }) {
};
}, [isPeerSessionWindow]);
useEffect(() => {
if (!isVaultInitialized || pendingTrayPanelConnectHostIds.length === 0) return;
flushQueuedTrayPanelConnectHostsImpl(() => ({
connectNow: _handleTrayPanelConnect,
pendingHostIds: pendingTrayPanelConnectHostIds,
setPendingHostIds: setPendingTrayPanelConnectHostIds,
}));
}, [isVaultInitialized, pendingTrayPanelConnectHostIds]);
// Handle keyboard-interactive submit
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, updateHosts }), requestId, responses, savePassword); }, [keyboardInteractiveQueue, sessions, hosts, updateHosts]);
@@ -711,6 +794,10 @@ function App({ settings }: { settings: SettingsState }) {
type: 'note';
noteId: string;
requestId: number;
} | {
type: 'snippet';
snippetId: string;
requestId: number;
} | null>(null);
// Populated below so the hotkey dispatcher can open the Settings window
// even though `handleOpenSettings` is declared further down in the file.
@@ -853,10 +940,14 @@ function App({ settings }: { settings: SettingsState }) {
const handleDeleteHost = useCallback((hostId: string) => {
const target = hosts.find(h => h.id === hostId);
const confirmed = window.confirm(t('confirm.deleteHost', { name: target?.label || hostId }));
if (!confirmed) return;
updateHosts(hosts.filter(h => h.id !== hostId));
}, [hosts, updateHosts, t]);
setDeleteHostConfirm({ hostId, name: target?.label || hostId });
}, [hosts]);
const handleConfirmDeleteHost = useCallback(() => {
if (!deleteHostConfirm) return;
updateHosts(hosts.filter(h => h.id !== deleteHostConfirm.hostId));
setDeleteHostConfirm(null);
}, [deleteHostConfirm, hosts, updateHosts]);
const handleAddKnownHost = useCallback((kh: KnownHost) => {
const nextKnownHosts = upsertKnownHost(knownHostsRef.current, kh);
@@ -889,7 +980,16 @@ function App({ settings }: { settings: SettingsState }) {
}, []);
// Wrapper to create local terminal with logging
const handleCreateLocalTerminal = useCallback((shell?: { command: string; args?: string[]; name?: string; icon?: string }) => { return handleCreateLocalTerminalImpl(() => ({ addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, shell, systemInfoRef, terminalSettings, undefined }), shell); }, [addConnectionLog, createLocalTerminal, terminalSettings, discoveredShells]);
const handleCreateLocalTerminal = useCallback((
shell?: { command: string; args?: string[]; name?: string; icon?: string },
options?: { localStartDir?: string },
) => {
return handleCreateLocalTerminalImpl(
() => ({ addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, shell, systemInfoRef, terminalSettings, undefined }),
shell,
options,
);
}, [addConnectionLog, createLocalTerminal, terminalSettings, discoveredShells]);
const proxyProfileIdSet = useMemo(
() => new Set(proxyProfiles.map((profile) => profile.id)),
@@ -907,6 +1007,32 @@ function App({ settings }: { settings: SettingsState }) {
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
}, [groupConfigs, proxyProfileIdSet, proxyProfiles]);
// Wrapper to connect to host with logging
const handleConnectToHost = useCallback((host: Host) => {
if (host.ephemeral) {
setEphemeralHosts((previous) => {
const existingIndex = previous.findIndex((candidate) => candidate.id === host.id);
if (existingIndex < 0) return [...previous, host];
return previous.map((candidate, index) => index === existingIndex ? host : candidate);
});
}
return handleConnectToHostImpl(() => ({ addConnectionLog, connectToHost, host, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef }), host);
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys]);
const openHostForVaultAgent = useCallback((hostId: string) => {
const host = hosts.find((item) => item.id === hostId);
if (!host) {
return { ok: false as const, error: `Host "${hostId}" was not found.` };
}
const sessionId = handleConnectToHost(host);
if (!sessionId) {
return { ok: false as const, error: `Failed to open host "${hostId}".` };
}
// Surface the main window for external MCP / CLI open requests.
void netcattyBridge.get()?.openMainWindow?.();
return { ok: true as const, sessionId, host };
}, [handleConnectToHost, hosts]);
useVaultAgentBridge({
hosts,
snippets,
@@ -916,17 +1042,16 @@ function App({ settings }: { settings: SettingsState }) {
terminalSettings,
resolveEffectiveHost,
updateHosts,
updateSnippets,
customGroups,
updateCustomGroups,
notes,
updateNotes,
startTunnel,
stopTunnel,
openHost: openHostForVaultAgent,
});
// Wrapper to connect to host with logging
const handleConnectToHost = useCallback((host: Host) => { return handleConnectToHostImpl(() => ({ addConnectionLog, connectToHost, host, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef }), host); }, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys]);
const _handleSshDeepLink = useEffectEvent((payload: { url?: string }) => {
const rawUrl = payload?.url || '';
const target = parseSshDeepLink(rawUrl);
@@ -944,6 +1069,23 @@ function App({ settings }: { settings: SettingsState }) {
};
});
const matchedEffectiveHost = findSshDeepLinkHost(effectiveHosts, target);
if (target.password) {
// One-time-password link: connect ephemerally with exactly the URL
// credentials. A uniquely matched saved host still contributes its
// non-credential settings (proxy, jump chain, charset, ...). Build
// from the group-resolved effective host so the builder can clear
// `group` and block group credential inheritance from later
// effective-host resolution.
const draftOptions = { id: crypto.randomUUID(), now: Date.now() };
const ephemeralHost = matchedEffectiveHost
? buildSshDeepLinkEphemeralHostFromSaved(matchedEffectiveHost, target, draftOptions)
: buildSshDeepLinkEphemeralHost(target, draftOptions);
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
handleConnectToHost(ephemeralHost);
return;
}
if (matchedEffectiveHost) {
const originalHost = hosts.find((host) => host.id === matchedEffectiveHost.id) ?? matchedEffectiveHost;
handleConnectToHost(buildSshDeepLinkConnectionHost(originalHost));
@@ -967,6 +1109,106 @@ function App({ settings }: { settings: SettingsState }) {
});
}, [isPeerSessionWindow]);
const _handleTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
const rawUrl = payload?.url || '';
const target = parseTelnetDeepLink(rawUrl);
if (!target) {
toast.warning(t('deepLink.telnet.invalid'));
return;
}
const effectiveHosts = hosts.map((host) =>
materializeTelnetDeepLinkMatchHost(resolveEffectiveHost(host), identities),
);
const matchedEffectiveHost = findTelnetDeepLinkHost(effectiveHosts, target, {
ignoreTargetUsername: Boolean(target.password),
});
if (matchedEffectiveHost) {
if (target.password) {
const ephemeralHost = buildTelnetDeepLinkEphemeralHostFromSaved(matchedEffectiveHost, target, {
id: crypto.randomUUID(),
now: Date.now(),
});
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
handleConnectToHost(ephemeralHost);
return;
}
handleConnectToHost(buildTelnetDeepLinkConnectionHost(matchedEffectiveHost));
return;
}
const ephemeralHost = buildTelnetDeepLinkOpenHost(effectiveHosts, target, {
id: crypto.randomUUID(),
now: Date.now(),
});
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
handleConnectToHost(ephemeralHost);
});
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get();
if (!bridge?.onTelnetDeepLink) return;
return bridge.onTelnetDeepLink((payload) => {
_handleTelnetDeepLink(payload);
});
}, [isPeerSessionWindow]);
const _handleJmsDeepLink = useEffectEvent((payload: { url?: string }) => {
const rawUrl = payload?.url || '';
const target = parseJmsDeepLink(rawUrl);
if (!target) {
toast.warning(t('deepLink.jms.invalid'));
return;
}
if (!isSupportedJmsProtocol(target.protocol)) {
toast.warning(t('deepLink.jms.unsupported', { protocol: target.protocol }));
return;
}
const ephemeralHost = buildJmsDeepLinkEphemeralHost(target, {
id: crypto.randomUUID(),
now: Date.now(),
});
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
handleConnectToHost(ephemeralHost);
});
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get();
if (!bridge?.onJmsDeepLink) return;
return bridge.onJmsDeepLink((payload) => {
_handleJmsDeepLink(payload);
});
}, [isPeerSessionWindow]);
useEffect(() => {
setEphemeralHosts((prev) => {
if (prev.length === 0) return prev;
const referencedHostIds = new Set(
sessions.map((session) => session.hostId).filter((id): id is string => Boolean(id)),
);
const next = prev.filter((host) => referencedHostIds.has(host.id));
return next.length === prev.length ? prev : next;
});
}, [sessions]);
const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
const localStartDir = typeof payload?.path === 'string' ? payload.path : '';
if (!localStartDir.trim()) return;
handleCreateLocalTerminal(undefined, { localStartDir });
});
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get();
if (!bridge?.onOpenTerminalPath) return;
return bridge.onOpenTerminalPath((payload) => {
_handleOpenTerminalPath(payload);
});
}, [isPeerSessionWindow]);
const handleOpenHostFromVaultNote = useCallback((host: Host, source?: { noteId?: string }) => {
const tabId = handleConnectToHost(host);
if (source?.noteId && typeof tabId === 'string' && tabId) {
@@ -999,11 +1241,22 @@ function App({ settings }: { settings: SettingsState }) {
setActiveTabId('vault');
}, [hosts, setActiveTabId]);
const handleOpenVaultSectionFromChat = useCallback((section: 'notes' | 'hosts') => {
const handleOpenVaultSectionFromChat = useCallback((section: 'notes' | 'hosts' | 'snippets') => {
setNavigateToSection(section);
setActiveTabId('vault');
}, [setActiveTabId]);
const handleOpenVaultSnippetFromChat = useCallback((snippetId: string) => {
vaultFocusRequestIdRef.current += 1;
setVaultFocusRequest({
type: 'snippet',
snippetId,
requestId: vaultFocusRequestIdRef.current,
});
setNavigateToSection('snippets');
setActiveTabId('vault');
}, [setActiveTabId]);
// Wrap updateSessionStatus to track lastConnectedAt on successful connection
const handleSessionStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
updateSessionStatus(sessionId, status);
@@ -1021,6 +1274,17 @@ function App({ settings }: { settings: SettingsState }) {
)));
}, [hosts, updateHosts]);
// Terminal-layer host updates may include ephemeral deep-link hosts; keep
// those in memory only and never let them reach the persisted vault.
const updateTerminalHosts = useCallback((nextHosts: Host[]) => {
const { vaultHosts, ephemeralHosts: updatedEphemeralHosts } =
splitHostsUpdateByEphemeral(nextHosts, ephemeralHostIds);
if (updatedEphemeralHosts.length > 0) {
setEphemeralHosts((prev) => applyEphemeralHostsUpdate(prev, updatedEphemeralHosts));
}
updateHosts(vaultHosts);
}, [ephemeralHostIds, updateHosts]);
// Wrapper to create serial session with logging
const handleConnectSerial = useCallback((config: SerialConfig, options?: { charset?: string }) => {
const { username, hostname } = systemInfoRef.current;
@@ -1055,15 +1319,8 @@ function App({ settings }: { settings: SettingsState }) {
const handleToggleTheme = useCallback(() => { return handleToggleThemeImpl(() => ({ openSettingsWindow, resolvedTheme, setTheme, t, theme, toast })); }, [openSettingsWindow, resolvedTheme, setTheme, t, theme]);
const handleFollowAppTerminalThemeChange = useCallback((themeId: string) => {
const update = getFollowAppTerminalThemeSelectionUpdate(themeId);
if (!update) return;
if (update.appTheme === 'dark') {
setDarkUiThemeId(update.uiThemeId);
} else {
setLightUiThemeId(update.uiThemeId);
}
setTheme(update.appTheme);
}, [setDarkUiThemeId, setLightUiThemeId, setTheme]);
themeRuntime.pickTheme(themeId);
}, [themeRuntime]);
const handleDefaultTerminalThemeChange = useCallback((themeId: string) => {
setTerminalThemeId(themeId);
@@ -1133,6 +1390,16 @@ function App({ settings }: { settings: SettingsState }) {
return (
<>
<PortForwardHostKeyDialog onAddKnownHost={handleAddKnownHost} />
<ConfirmDialog
open={deleteHostConfirm !== null}
title={deleteHostConfirm ? t('confirm.deleteHost', { name: deleteHostConfirm.name }) : ''}
confirmLabel={t('action.delete')}
destructive
onOpenChange={(open) => {
if (!open) setDeleteHostConfirm(null);
}}
onConfirm={handleConfirmDeleteHost}
/>
<AppActiveTabChrome
showSftpTab={settings.showSftpTab}
setActiveTabId={setActiveTabId}
@@ -1147,9 +1414,10 @@ function App({ settings }: { settings: SettingsState }) {
customAccent={customAccent}
editorTabs={editorTabs}
logViews={logViews}
resolveSessionAppearance={themeRuntime.resolveFocusedAppearance}
t={t}
/>
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost, handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleOpenHostFromVaultNote, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, clearThemeIntent: themeRuntime.clearIntent, settleManualThemeIntent: themeRuntime.settleManualIntent, pickTerminalTheme: themeRuntime.pickTheme, resolveSessionAppearance: themeRuntime.resolveFocusedAppearance, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost, handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleOpenHostFromVaultNote, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
</>
);
}
@@ -1162,25 +1430,40 @@ function AppWithProviders() {
});
useEffect(() => {
let splashRemovalTimer: ReturnType<typeof setTimeout> | undefined;
try {
// Hide splash screen with a fade-out animation
const splash = document.getElementById('splash');
if (splash) {
splash.classList.add('fade-out');
// Remove from DOM after animation completes
setTimeout(() => splash.remove(), 200);
splashRemovalTimer = setTimeout(() => splash.remove(), 200);
}
// Notify main process that renderer is ready
netcattyBridge.get()?.rendererReady?.();
} catch {
// ignore
}
return () => {
if (splashRemovalTimer !== undefined) clearTimeout(splashRemovalTimer);
};
}, []);
// Keep MCP/SDK approval IPC alive for the whole app lifetime — including the
// ~5s before TerminalLayer lazy-mounts, and when External MCP is used without
// opening the Catty AI panel.
useEffect(() => {
return setupMcpApprovalBridge();
}, []);
useExternalMcpGrantPersister();
return (
<I18nProvider locale={settings.uiLanguage}>
<ToastProvider>
<TooltipProvider delayDuration={300}>
<ScriptAutomationRoot />
<ExternalMcpApprovalsHost />
<App settings={settings} />
</TooltipProvider>
</ToastProvider>
+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).
+28 -25
View File
@@ -8,37 +8,42 @@
> 后端 + UI 重新落到上游重构后的目录结构上,并让它启动**捆绑的** `et`。
>
> 旧实现参考:`git show 67e81616`(共 7 个 ET 提交,见 `feat/eternal-terminal`)。
> Mosh 模板参考:`resources/mosh/README.md`、`scripts/*mosh*`、
> `electron/bridges/terminalBridge/moshSession.cjs`、`.github/workflows/build-mosh-binaries.yml`。
> Mosh 模板参考(**仅 MoshCatty 纯二进制路径**):`resources/mosh/README.md`、
> `scripts/fetch-mosh-binaries.cjs`、`scripts/resolve-mosh-bin-release.cjs`、
> `scripts/mosh-extra-resources.cjs`、`electron/bridges/terminalBridge/moshSession.cjs`。
> 客户端本体在独立仓库 [binaricat/MoshCatty](https://github.com/binaricat/MoshCatty)
> (`moshcatty-*` releases);Netcatty 内已无 Cygwin 构建流水线 / FluentTerminal 回退。
## 关键设计差异(ET vs Mosh)
- **协议**:Mosh 需要 Node 重写 Perl 包装器(SSH bootstrap + 抓 `MOSH CONNECT` +
换 PTY)。**ET 不需要** —— `et` 客户端自己完成 SSH 引导 + 协议握手,我们只要
把 `et` 当作普通 PTY 进程 `pty.spawn` 即可。所以**没有** `etHandshake.cjs`。
- **协议**:Mosh 需要 Node 做 SSH bootstrap + 抓 `MOSH CONNECT` + 换 PTY
(`moshHandshake` + `moshSession`)。**ET 不需要** —— `et` 客户端自己完成 SSH
引导 + 协议握手,我们只要把 `et` 当作普通 PTY 进程 `pty.spawn` 即可。所以**没有**
`etHandshake.cjs`。
- **凭证注入**:Mosh 自己驱动 ssh、直接往 PTY 里敲密码;ET 内部驱动 ssh,需用
**SSH_ASKPASS + 临时 ~/.ssh 环境**把保存的密码/密钥/跳板/算法喂给 et 内部的 ssh
(旧实现 `prepareEtSshEnvironment` 已完整实现,直接搬运)。
- **terminfo**:`et` 是纯传输客户端、本地不渲染终端,**无需** 捆绑 terminfo
(Mosh 因静态 ncurses 才需要)。打包目录里只放 `et[.exe]`(+ Windows DLL)。
- **构建系统**:Mosh 用 autotools;**ET 用 CMake + Ninja + vcpkg**
- **纯二进制**:MoshCatty 与理想 ET 打包都是「每平台一个客户端文件」。Mosh 侧已
无 terminfo / Cygwin DLL 袋;`et` 同样本地不渲染终端。Windows 若动态链 CRT
才考虑可选 DLL 目录,否则只放 `et[.exe]`。
- **构建系统**:Mosh 客户端在 **MoshCatty** 仓库用 Rust 构建并发布;Netcatty 只
`fetch`。**ET** 用 CMake + Ninja + vcpkg
(`cmake -DDISABLE_TELEMETRY=ON -GNinja -DCMAKE_BUILD_TYPE=RelWithDebInfo`),
产物是单个 `et`(Windows `et.exe`)。
产物是单个 `et`(Windows `et.exe`),由 `scripts/build-et/` + `build-et-binaries.yml` 发布。
## 命名约定(镜像 Mosh)
## 命名约定(镜像 Mosh / MoshCatty)
| Mosh | ET |
| Mosh (MoshCatty) | ET |
|------|----|
| `resources/mosh/<plat-arch>/mosh-client[.exe]` | `resources/et/<plat-arch>/et[.exe]` |
| 打包后 `<Resources>/mosh/mosh-client` | 打包后 `<Resources>/et/et` |
| `scripts/build-mosh/` | `scripts/build-et/` |
| 上游构建:`binaricat/MoshCatty` CI releases | `scripts/build-et/` + `build-et-binaries.yml` |
| `scripts/fetch-mosh-binaries.cjs` | `scripts/fetch-et-binaries.cjs` |
| `scripts/resolve-mosh-bin-release.cjs` | `scripts/resolve-et-bin-release.cjs` |
| `scripts/mosh-extra-resources.cjs` | `scripts/et-extra-resources.cjs` |
| env `MOSH_BIN_RELEASE` / 仓库 `Netcatty-mosh-bin` / tag `mosh-bin-*` | env `ET_BIN_RELEASE` / 仓库 `Netcatty-et-bin` / tag `et-bin-*` |
| env `MOSH_BIN_RELEASE` / 仓库 `MoshCatty` / tag `moshcatty-*` | env `ET_BIN_RELEASE` / 仓库 `Netcatty-et-bin` / tag `et-bin-*` |
| `npm run fetch:mosh[:dev]` | `npm run fetch:et[:dev]` |
| `bundledMoshClient()` / `resolveBareMoshClient()` | `bundledEtClient()` / `resolveBareEtClient()` |
| `.github/workflows/build-mosh-binaries.yml` | `.github/workflows/build-et-binaries.yml` |
---
@@ -52,7 +57,7 @@
`/resources/et/*/et-win32-*-dlls/`。保留 `resources/et/README.md`。
- [x] **1.3** `scripts/build-et/build-linux.sh` —— manylinux2014 + vcpkg 静态三元组
构建 `et`(x64/arm64),产物 `et-linux-<arch>.tar.gz`(+.sha256),内含单个 `et`。
校验非系统动态库(同 mosh 的 ldd 白名单)。
校验非系统动态库(ldd 白名单)。
- [x] **1.4** `scripts/build-et/build-macos.sh` —— arm64 + x86_64 分别构建后 `lipo`
成 universal,`MACOSX_DEPLOYMENT_TARGET=11.0`,产物 `et-darwin-universal.tar.gz`。
- [x] **1.5** `scripts/build-et/build-windows.ps1`(或 `.sh`)—— MSVC + vcpkg
@@ -60,13 +65,12 @@
则随附 DLL 目录 `et-win32-x64-dlls/`,否则纯静态无 DLL)。
- [x] **1.6** `scripts/et-extra-resources.cjs` —— 镜像 `mosh-extra-resources.cjs`:
按平台/arch 仅当 `resources/et/<plat-arch>/et[.exe]` 存在时才产出 extraResources
指令(`to: "et/"`);Windows 额外处理可选 DLL 目录。**去掉 terminfo 分支**。
指令(`to: "et/"`);Windows 额外处理可选 DLL 目录。纯客户端文件为主。
- [x] **1.7** `scripts/resolve-et-bin-release.cjs` —— 镜像 `resolve-mosh-bin-release.cjs`:
`TAG_RE=/^et-bin-.../`,默认仓库 `Netcatty-et-bin`,env `ET_BIN_RELEASE` 优先。
- [x] **1.8** `scripts/fetch-et-binaries.cjs` —— 镜像 `fetch-mosh-binaries.cjs`:
`TARGETS` 四项(linux-x64/arm64、darwin-universal、win32-x64),全部 tar.gz;
SHA256SUMS 校验;解包到 `resources/et/<plat-arch>/`。**Windows 用自建产物**
(ET 官方有 Windows 构建,无需 FluentTerminal 那种 fallback)。去掉 terminfo 校验。
SHA256SUMS 校验;解包到 `resources/et/<plat-arch>/`。**Windows 用自建产物**。
- [x] **1.9** 单元测试:`scripts/fetch-et-binaries.test.cjs`、
`scripts/resolve-et-bin-release.test.cjs`、`scripts/et-extra-resources.test.cjs`
(镜像对应 mosh 测试,改名/改路径)。
@@ -77,13 +81,12 @@
`scripts/*.test.cjs`(确认即可)。
- [x] **1.11** `electron-builder.config.cjs`:引入 `etExtraResources`,在 darwin/win32/
linux 三处把 `etExtraResources(plat)` 合并进 `extraResources`(与 mosh 数组拼接)。
- [x] **1.12** `.github/workflows/build-et-binaries.yml` —— 镜像
`build-mosh-binaries.yml`:四个构建 job + 一个 `release` job(dispatch 且
`release_tag` 非空时发布到 `Netcatty-et-bin`,附 `SHA256SUMS`)。`paths` 过滤
指向 `scripts/build-et/**`、`scripts/fetch-et-binaries.cjs`、`scripts/et-extra-resources.cjs`。
env 用 `ET_REF`(默认 ET release tag,如 `et-v6.2.x`)。
- [x] **1.12** `.github/workflows/build-et-binaries.yml` —— 四个构建 job + 一个
`release` job(dispatch 且 `release_tag` 非空时发布到 `Netcatty-et-bin`,附
`SHA256SUMS`)。`paths` 过滤指向 `scripts/build-et/**`、`scripts/fetch-et-binaries.cjs`、
`scripts/et-extra-resources.cjs`。env 用 `ET_REF`(默认 ET release tag,如 `et-v6.2.x`)。
> 注:实际二进制由用户手动 `workflow_dispatch` 触发产出;本地/CI 未设
> `ET_BIN_RELEASE` 时 fetch 步骤安静跳过(同 mosh)。
> `ET_BIN_RELEASE` 时 fetch 步骤安静跳过(同 mosh 的 `MOSH_BIN_RELEASE`)。
## Phase 2 — 运行时定位捆绑客户端
@@ -100,7 +103,7 @@
`cleanupSessionExternalAuthArtifacts`、`execOnEtSession`、`startEtSession`。
**改动点**:`etCmd` 由 `findExecutable('et')` 改为 `resolveBareEtClient()`
(取捆绑二进制);找不到时抛错(同 mosh:提示跑 `npm run fetch:et:dev`)。
Windows 若有 DLL 目录,复用 `prependEnvPath` 思路把 DLL 目录加进 PATH。
Windows 若有动态链接 DLL 目录,可把该目录加进 PATH(MoshCatty 路径已无此需求)。
- [x] **3.2** `terminalBridge.cjs` 接线 `createEtSessionApi(ctx)`(镜像 moshSessionApi
的 ctx),传入 `bundledEtClient`、`tempDirBridge`、`execFile/execFileSync` 等;
解构出 `startEtSession`、`execOnEtSession`、`cleanupStaleEtTempDirs`、
+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 -114
View File
@@ -40,8 +40,7 @@
---
<img width="2868" height="1784" alt="netcatty SSH (Window) 2026-04-23 11:19 PM" src="https://github.com/user-attachments/assets/d6df734f-9ebc-452a-8b7d-e8a0fdc9463a" />
<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" />
---
@@ -63,25 +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/f819a1b6-8cba-4910-8017-97dfc080b477
<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/52fd30b8-9f02-43d4-a3b2-142691e8e3ec
<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/52fd30b8-9f02-43d4-a3b2-142691e8e3ec
- [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,76 +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/1ff1f3f1-e5ae-40ea-b35a-0e5148c3afeb
### 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/9c24b519-4b4b-4910-a22a-590d04c9af31
### 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/f3afdb36-399d-4330-b9f3-4678f178f6db
### Drag file upload
Drop files into the app to kick off uploads without hunting through dialogs.
https://github.com/user-attachments/assets/e1e26f7a-3489-41cc-975e-8dccba56ea85
### Custom themes
Make Netcatty yours: customize themes and UI appearance.
https://github.com/user-attachments/assets/1a6049aa-9a4c-4d52-a13d-0b007a791b00
### Keyword highlighting
Highlight important terminal output so errors, warnings, and key events stand out at a glance.
https://github.com/user-attachments/assets/1a1db7bd-948b-4f3c-97cd-8fd0cbe7cce7
---
<a name="screenshots"></a>
@@ -230,29 +146,27 @@ https://github.com/user-attachments/assets/1a1db7bd-948b-4f3c-97cd-8fd0cbe7cce7
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" />
---
@@ -294,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
@@ -312,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)
```
---
+55 -1
View File
@@ -1,7 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { handleConnectToHostImpl } from './app/AppHandlers.ts';
import {
flushQueuedTrayPanelConnectHostsImpl,
handleConnectToHostImpl,
handleTrayPanelConnectRequestImpl,
} from './app/AppHandlers.ts';
import type { Host } from '../types';
const baseHost: Host = {
@@ -62,3 +66,53 @@ test('connect serial host handler returns the created terminal tab id', () => {
assert.equal(result, 'serial-session');
});
test('tray panel connect request queues until the vault is initialized', () => {
const queuedHostIds: string[] = [];
const connectedHostIds: string[] = [];
handleTrayPanelConnectRequestImpl(
() => ({
connectNow: (hostId: string) => connectedHostIds.push(hostId),
isVaultInitialized: false,
queueConnect: (hostId: string) => queuedHostIds.push(hostId),
}),
'host-1',
);
assert.deepEqual(queuedHostIds, ['host-1']);
assert.deepEqual(connectedHostIds, []);
});
test('tray panel connect request runs immediately after the vault is initialized', () => {
const queuedHostIds: string[] = [];
const connectedHostIds: string[] = [];
handleTrayPanelConnectRequestImpl(
() => ({
connectNow: (hostId: string) => connectedHostIds.push(hostId),
isVaultInitialized: true,
queueConnect: (hostId: string) => queuedHostIds.push(hostId),
}),
'host-1',
);
assert.deepEqual(queuedHostIds, []);
assert.deepEqual(connectedHostIds, ['host-1']);
});
test('queued tray panel connects flush in order', () => {
const connectedHostIds: string[] = [];
let pendingHostIds = ['host-1', 'host-2'];
flushQueuedTrayPanelConnectHostsImpl(() => ({
connectNow: (hostId: string) => connectedHostIds.push(hostId),
pendingHostIds,
setPendingHostIds: (nextHostIds: string[]) => {
pendingHostIds = nextHostIds;
},
}));
assert.deepEqual(connectedHostIds, ['host-1', 'host-2']);
assert.deepEqual(pendingHostIds, []);
});
+36
View File
@@ -46,6 +46,42 @@ test("copySessionToNewWindowWithCurrentShellImpl asks Electron to open a peer wi
});
});
test("copySessionToNewWindowWithCurrentShellImpl preserves local start directory in the source session", async () => {
const openedPayloads: unknown[] = [];
const localSession = sourceSession({
hostLabel: "Local Terminal",
hostname: "localhost",
protocol: "local",
localStartDir: "/Users/alice/project with spaces ",
});
await copySessionToNewWindowWithCurrentShellImpl(
() => ({
classifyLocalShellType: () => "zsh",
discoveredShells: [],
netcattyBridge: {
get: () => ({
openSessionInNewWindow: async (payload: unknown) => {
openedPayloads.push(payload);
return { success: true };
},
}),
},
resolveShellSetting: () => ({ command: "/bin/zsh" }),
sessions: [localSession],
terminalSettings: { localShell: "system-default" },
}),
"session-1",
);
assert.equal(openedPayloads.length, 1);
assert.deepEqual(openedPayloads[0], {
title: "Local Terminal",
sourceSession: localSession,
localShellType: "zsh",
});
});
test("copySessionToNewWindowWithCurrentShellImpl does nothing when the source session is gone", async () => {
let called = false;
+5
View File
@@ -9,6 +9,7 @@ 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,
@@ -32,6 +33,7 @@ interface AppActiveTabChromeProps {
customAccent: string;
editorTabs: readonly EditorTab[];
logViews: readonly LogView[];
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
t: (key: string) => string;
}
@@ -57,6 +59,7 @@ export function AppActiveTabChrome({
customAccent,
editorTabs,
logViews,
resolveSessionAppearance,
t,
}: AppActiveTabChromeProps) {
const activeTabId = useActiveTabId();
@@ -76,6 +79,7 @@ export function AppActiveTabChrome({
followAppTerminalTheme,
hostById,
logViews,
resolveSessionAppearance,
sessionById,
themeById,
workspaceById,
@@ -88,6 +92,7 @@ export function AppActiveTabChrome({
followAppTerminalTheme,
hostById,
logViews,
resolveSessionAppearance,
sessionById,
themeById,
workspaceById,
+19 -6
View File
@@ -4,13 +4,26 @@ 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", () => {
assert.match(appSource, /const update = getFollowAppTerminalThemeSelectionUpdate\(themeId\)/);
assert.match(appSource, /setDarkUiThemeId\(update\.uiThemeId\)/);
assert.match(appSource, /setLightUiThemeId\(update\.uiThemeId\)/);
assert.match(appSource, /setTheme\(update\.appTheme\)/);
assert.doesNotMatch(appSource, /customThemeStore\.getThemeById\(themeId\)/);
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", () => {
+25 -2
View File
@@ -61,7 +61,7 @@ export function handleTrayTogglePortForwardImpl(getCtx: AppContextGetter, ruleId
export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: string) {
const { addConnectionLog, connectToHost, hosts, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef, t, toast } = getCtx();
{
{
const host = hosts.find((item) => item.id === hostId);
if (!host) {
toast.error(t("pf.error.hostNotFound"));
@@ -110,6 +110,24 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
}
}
export function handleTrayPanelConnectRequestImpl(getCtx: AppContextGetter, hostId: string) {
const { connectNow, isVaultInitialized, queueConnect } = getCtx();
if (!hostId) return;
if (!isVaultInitialized) {
queueConnect(hostId);
return;
}
connectNow(hostId);
}
export function flushQueuedTrayPanelConnectHostsImpl(getCtx: AppContextGetter) {
const { connectNow, pendingHostIds, setPendingHostIds } = getCtx();
if (!Array.isArray(pendingHostIds) || pendingHostIds.length === 0) return;
const hostIds = [...pendingHostIds];
setPendingHostIds([]);
hostIds.forEach((hostId: string) => connectNow(hostId));
}
export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEvent) {
const { HOTKEY_DEBUG, closeTabKeyStr, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding } = getCtx();
{
@@ -727,7 +745,11 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
}
}
export function handleCreateLocalTerminalImpl(getCtx: AppContextGetter, shell?: { command: string; args?: string[]; name?: string; icon?: string }) {
export function handleCreateLocalTerminalImpl(
getCtx: AppContextGetter,
shell?: { command: string; args?: string[]; name?: string; icon?: string },
options?: { localStartDir?: string },
) {
const { addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, systemInfoRef, terminalSettings } = getCtx();
{
const { username, hostname } = systemInfoRef.current;
@@ -742,6 +764,7 @@ export function handleCreateLocalTerminalImpl(getCtx: AppContextGetter, shell?:
shellArgs: resolved?.args,
shellName,
shellIcon,
localStartDir: options?.localStartDir,
});
addConnectionLog({
sessionId,
+6
View File
@@ -42,3 +42,9 @@ 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/);
});
+43 -11
View File
@@ -3,12 +3,17 @@ 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,
resolveWorkTabHostTreeTheme,
} from './workTabSurface';
interface AppHostTreeLayerProps {
@@ -27,6 +32,7 @@ interface AppHostTreeLayerProps {
followAppTerminalTheme: boolean;
hostById: ReadonlyMap<string, Host>;
themeById: ReadonlyMap<string, TerminalTheme>;
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
onConnect: (host: Host) => void;
onCreateLocalTerminal?: () => void;
}
@@ -55,6 +61,7 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
followAppTerminalTheme,
hostById,
themeById,
resolveSessionAppearance,
onConnect,
onCreateLocalTerminal,
}) => {
@@ -62,6 +69,14 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
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,
@@ -78,24 +93,41 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
workspaces,
}), [activeTabId, editorTabs, sessions, workspaces]);
const hostTreeTheme = useMemo(() => resolveWorkTabHostTreeTheme({
activeHostId,
accentMode,
currentTerminalTheme,
customAccent,
followAppTerminalTheme,
hostById,
themeById,
}), [
activeHostId,
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"
+37 -9
View File
@@ -8,6 +8,7 @@ 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';
@@ -89,14 +90,15 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
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,
followAppTerminalTheme,
groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost,
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal,
hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
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,
@@ -170,7 +172,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
return (
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" data-terminal-appearance-root onContextMenu={handleRootContextMenu}>
<TopTabs
theme={resolvedTheme}
hosts={hosts}
@@ -224,6 +226,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
followAppTerminalTheme={followAppTerminalTheme}
hostById={hostById}
themeById={themeById}
resolveSessionAppearance={resolveSessionAppearance}
onConnect={handleConnectToHost}
onCreateLocalTerminal={handleCreateLocalTerminal}
/>
@@ -291,13 +294,15 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
</VaultViewContainer>
<SftpViewMount
hosts={hosts}
hosts={terminalHosts}
writableHosts={hosts}
sessions={sessions}
keys={keys}
identities={identities}
knownHosts={effectiveKnownHosts}
proxyProfiles={proxyProfiles}
groupConfigs={groupConfigs}
updateHosts={updateHosts}
updateHosts={updateTerminalHosts}
onAddKnownHost={handleAddKnownHost}
sftpDefaultViewMode={sftpDefaultViewMode}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
@@ -312,7 +317,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
/>
<TerminalLayerMount
hosts={hosts}
hosts={terminalHosts}
portForwardingRules={portForwardingRules}
customGroups={customGroups}
groupConfigs={groupConfigs}
@@ -330,6 +335,10 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
terminalTheme={currentTerminalTheme}
terminalThemeId={terminalThemeId}
followAppTerminalTheme={followAppTerminalTheme}
pickTerminalTheme={ctx.pickTerminalTheme}
clearThemeIntent={ctx.clearThemeIntent}
settleManualThemeIntent={ctx.settleManualThemeIntent}
resolveSessionAppearance={ctx.resolveSessionAppearance}
accentMode={accentMode}
customAccent={customAccent}
terminalSettings={terminalSettings}
@@ -379,10 +388,11 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
onOpenVaultNoteFromChat={handleOpenVaultNoteFromChat}
onOpenVaultHostFromChat={handleOpenVaultHostFromChat}
onOpenVaultSectionFromChat={handleOpenVaultSectionFromChat}
onOpenVaultSnippetFromChat={handleOpenVaultSnippetFromChat}
onCreateLocalTerminal={handleCreateLocalTerminal}
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}
updateHosts={updateHosts}
updateHosts={updateTerminalHosts}
updateSnippets={updateSnippets}
updateSnippetPackages={updateSnippetPackages}
updateNotes={updateNotes}
@@ -393,6 +403,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
sftpShowHiddenFiles={sftpShowHiddenFiles}
sftpUseCompressedUpload={sftpUseCompressedUpload}
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
terminalSidePanelAutoOpen={settings.terminalSidePanelAutoOpen}
terminalSidePanelAutoOpenTab={settings.terminalSidePanelAutoOpenTab}
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
setSftpFollowTerminalCwd={setSftpFollowTerminalCwd}
editorWordWrap={editorWordWrap}
@@ -457,6 +469,21 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
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"
@@ -533,6 +560,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
setQuickSearch('');
}}
keyBindings={keyBindings}
terminalSettings={terminalSettings}
/>
</Suspense>
</LazyLoadBoundary>
+140
View File
@@ -101,6 +101,146 @@ test("root pages use the normal application theme", () => {
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({
+28 -11
View File
@@ -1,5 +1,16 @@
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;
@@ -9,6 +20,7 @@ export type ResolveActiveChromeThemeInput = {
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>;
@@ -33,11 +45,6 @@ export function isActiveChromeThemeResolvable({
if (sessionById.has(activeTabId)) return true;
return false;
}
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from "../../domain/terminalAppearance";
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";
export function resolveActiveChromeTheme({
accentMode,
@@ -48,14 +55,23 @@ export function resolveActiveChromeTheme({
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;
@@ -79,12 +95,10 @@ export function resolveActiveChromeTheme({
const workspace = workspaceById.get(activeTabId);
if (workspace) {
if (followAppTerminalTheme) return currentTerminalTheme;
if (workspace.viewMode === "focus") {
const workspaceSessionIds = collectSessionIds(workspace.root);
const focusedSession = (workspace.focusedSessionId
? sessionById.get(workspace.focusedSessionId)
: null)
?? workspaceSessionIds.map((id) => sessionById.get(id)).find(Boolean);
const focusedSession = resolveWorkspaceTargetSessionFromMap(workspace, sessionById);
return focusedSession ? resolveSessionTheme(focusedSession) : null;
}
@@ -95,7 +109,10 @@ export function resolveActiveChromeTheme({
const firstTheme = resolveSessionTheme(workspaceSessions[0]);
const allSame = workspaceSessions.every((session) => resolveSessionTheme(session).id === firstTheme.id);
return allSame ? firstTheme : null;
if (allSame) return firstTheme;
const focusedSession = resolveWorkspaceTargetSessionFromMap(workspace, sessionById);
return focusedSession ? resolveSessionTheme(focusedSession) : null;
}
const session = sessionById.get(activeTabId);
+5 -5
View File
@@ -70,13 +70,17 @@ const TOP_TABS_THEME_PROPERTIES = [
'--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 {
@@ -107,9 +111,5 @@ export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
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))');
}
export function hasActiveChromeThemeDataset(): boolean {
if (typeof document === 'undefined') return false;
return Boolean(document.documentElement.dataset.activeChromeTheme);
topTabsChromeThemeVarsApplied = true;
}
+14 -1
View File
@@ -131,9 +131,22 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
};
});
const hostsForSystemMenu = hosts
.filter((host: any) => typeof host?.id === "string" && host.id.length > 0)
.map((host: any) => ({
id: host.id,
label: host.label,
hostname: host.hostname,
group: host.group,
pinned: host.pinned,
lastConnectedAt: host.lastConnectedAt,
protocol: host.protocol,
}));
void bridge.updateTrayMenuData({
sessions: sessionsForTray,
portForwardRules: portForwardingRules,
hosts: hostsForSystemMenu,
});
}, 250);
@@ -141,7 +154,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
cancelled = true;
clearTimeout(timer);
};
}, [enabled, sessions, portForwardingRules, workspaces]);
}, [enabled, hosts, sessions, portForwardingRules, workspaces]);
// Quit guard: block app exit while any editor tab has unsaved changes.
// Main process sends "app:query-dirty-editors"; we respond with the result.
+62 -4
View File
@@ -119,11 +119,13 @@ test('terminal content surface is limited to sessions and workspaces', () => {
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' },
{ id: 'session-2', hostId: 'host-2', workspaceId: 'workspace-1' },
] as TerminalSession[];
const workspaces = [
{ id: 'workspace-1', focusedSessionId: 'session-2' },
] as Workspace[];
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[];
@@ -134,6 +136,62 @@ test('shared host tree resolves active host ids across work tab types', () => {
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');
+31 -6
View File
@@ -3,6 +3,7 @@ import {
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';
@@ -97,6 +98,34 @@ export function isTerminalContentTabSurface({
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,
@@ -119,12 +148,8 @@ export function resolveWorkTabActiveHostId({
const activeWorkspace = workspaces.find((workspace) => workspace.id === activeTabId);
if (!activeWorkspace) return null;
const focusedSessionId = activeWorkspace.focusedSessionId;
if (focusedSessionId) {
return sessions.find((session) => session.id === focusedSessionId)?.hostId ?? null;
}
return null;
const targetSession = resolveWorkspaceTargetSession(activeWorkspace, sessions);
return targetSession?.hostId ?? null;
}
export function resolveWorkTabHostTreeTheme({
+2
View File
@@ -4,6 +4,7 @@ import { enVaultMessages } from './en/vault';
import { enTerminalMessages } from './en/terminal';
import { enAiMessages } from './en/ai';
import { enSystemManagerMessages } from './en/systemManager';
import { enScriptsMessages } from './en/scripts';
export type { Messages } from './types';
@@ -13,6 +14,7 @@ const en: Messages = {
...enTerminalMessages,
...enAiMessages,
...enSystemManagerMessages,
...enScriptsMessages,
};
export default en;
+82
View File
@@ -185,6 +185,71 @@ export const enAiMessages: Messages = {
'ai.toolAccess.description': 'Choose how external agents access Netcatty sessions. MCP exposes the built-in server, while Skills + CLI points agents to the local Netcatty skill and CLI commands.',
'ai.toolAccess.mode.mcp': 'MCP',
'ai.toolAccess.mode.skills': 'Skills + CLI',
// External MCP (productized catalog MCP for Codex / Claude Code / Cursor)
'ai.externalMcp.title': 'External MCP',
'ai.externalMcp.description': 'Expose Netcatty as an MCP server for external clients such as Codex, Claude Code, Cursor, and Grok. Uses the same catalog tools as in-app agents (terminal, SFTP, Vault, port forwarding). Keep Netcatty running while clients are connected.',
'ai.externalMcp.sessionsExposed': 'Sessions in scope: {count}',
'ai.externalMcp.mode': 'Availability mode',
'ai.externalMcp.mode.temporary': 'Temporary',
'ai.externalMcp.mode.persistent': 'Always on',
'ai.externalMcp.mode.description': 'Temporary mode auto-disables after idle timeout. Always-on restores External MCP when Netcatty starts.',
'ai.externalMcp.idleTimeout': 'Idle timeout',
'ai.externalMcp.idleTimeout.description': 'In temporary mode, disable External MCP after this many minutes with no MCP operations.',
'ai.externalMcp.idleTimeout.minutes': 'min',
'ai.externalMcp.usage.title': 'How to use',
'ai.externalMcp.usage.keepRunning': '1. Turn on External MCP and keep Netcatty running.',
'ai.externalMcp.usage.localhost': '2. Clients connect via the local launcher (127.0.0.1 only). Discovery is removed when you disable the switch.',
'ai.externalMcp.usage.permissions': '3. Write operations follow Settings → AI → Safety (observer / confirm / auto) and the command blocklist.',
'ai.externalMcp.usage.capabilities': '4. Full catalog tools are available: terminal, SFTP, Vault, and port forwarding. Secrets (passwords / private keys) are never returned.',
'ai.externalMcp.help.ariaLabel': 'External MCP help',
'ai.externalMcp.security': 'Security',
'ai.externalMcp.security.description': 'Listens on 127.0.0.1 with a rotating token, reuses AI Permission Mode for writes, and removes discovery when disabled. No OAuth — this is a local desktop bridge.',
'ai.externalMcp.permissionMode': 'Current permission mode: {mode}',
'ai.externalMcp.permissionMode.label': 'Write permission mode',
'ai.externalMcp.permissionMode.hint': 'Same setting as Settings → AI → Safety. Auto runs Netcatty write tools without Netcatty approval prompts; Confirm asks each time. External clients (Codex / Claude / Grok) may still show their own tool approval UI.',
'ai.externalMcp.permissionMode.unknown': 'Unknown',
'ai.externalMcp.discovery': 'Discovery',
'ai.externalMcp.launcher': 'Launcher',
'ai.externalMcp.unavailable': 'Unavailable',
'ai.externalMcp.bridgeUnavailable': 'External MCP bridge unavailable',
'ai.externalMcp.copy': 'Copy',
'ai.externalMcp.copied': 'Copied',
'ai.externalMcp.copyFailed': 'Copy failed. Try copying manually.',
'ai.externalMcp.refresh': 'Refresh',
'ai.externalMcp.clientConfiguration': 'Client configuration',
'ai.externalMcp.clientConfiguration.description': 'Pick a client to one-click install, or copy CLI / config snippets.',
'ai.externalMcp.client.codex': 'Codex',
'ai.externalMcp.client.claude': 'Claude Code',
'ai.externalMcp.client.grok': 'Grok',
'ai.externalMcp.client.cursor': 'Cursor',
'ai.externalMcp.cliCommand': 'CLI command',
'ai.externalMcp.configSnippet': 'Config snippet',
'ai.externalMcp.addToCodex': 'Add to Codex',
'ai.externalMcp.addToClaude': 'Add to Claude Code',
'ai.externalMcp.addToGrok': 'Add to Grok',
'ai.externalMcp.codexAdded': 'Codex MCP entry added. Restart Codex or open a new Codex session.',
'ai.externalMcp.claudeAdded': 'Claude Code MCP entry added. Restart Claude Code or open a new Claude Code session.',
'ai.externalMcp.grokAdded': 'Grok MCP entry added. Restart Grok or open a new Grok session.',
'ai.externalMcp.installCodex': 'Install Codex separately, then click Refresh.',
'ai.externalMcp.installClaude': 'Install Claude Code separately, then click Refresh.',
'ai.externalMcp.installGrok': 'Install the Grok CLI separately, then click Refresh.',
'ai.externalMcp.conflict.description': 'A netcatty-external entry already exists and points elsewhere. Remove or edit it manually.',
'ai.externalMcp.enableForLauncher': 'Enable External MCP to get a usable launcher path.',
'ai.externalMcp.cursor.title': 'Cursor / other clients',
'ai.externalMcp.cursor.description': 'Merge this into your MCP config (for example ~/.cursor/mcp.json). Do not replace the whole file if you already have other servers.',
'ai.externalMcp.status.unavailable': 'Unavailable',
'ai.externalMcp.status.disabled': 'Disabled',
'ai.externalMcp.status.running': 'Running',
'ai.externalMcp.status.starting': 'Starting',
'ai.externalMcp.status.error': 'Error',
'ai.externalMcp.status.configured': 'Configured',
'ai.externalMcp.status.notConfigured': 'Not configured',
'ai.externalMcp.status.checking': 'Checking',
'ai.externalMcp.status.codexNotFound': 'Codex not found',
'ai.externalMcp.status.claudeNotFound': 'Claude Code not found',
'ai.externalMcp.status.grokNotFound': 'Grok not found',
'ai.externalMcp.status.conflict': 'Conflict',
'ai.userSkills.title': 'User Skills',
'ai.userSkills.description': 'Open the Netcatty skills folder to add your own skill directories. Netcatty scans these skills automatically and injects only lightweight indexes unless a skill clearly matches the current request.',
'ai.userSkills.openFolder': 'Open Skills Folder',
@@ -397,6 +462,23 @@ export const enAiMessages: Messages = {
'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',
+51 -1
View File
@@ -106,6 +106,21 @@ export const enCoreMessages: Messages = {
'settings.system.credentials.unavailableHint': 'Credentials encrypted on another user profile or machine cannot be decrypted here. Re-enter and save credentials on this device.',
'settings.system.credentials.portabilityHint': 'Cloud Sync is portable because it uses your master key encryption. Local safeStorage encryption is device/user scoped.',
// Settings > System > Network Proxy
'settings.system.networkProxy.title': 'Network Proxy',
'settings.system.networkProxy.description': 'HTTP(S) proxy for cloud sync and AI providers. This is separate from SSH ProxyJump / ProxyCommand profiles.',
'settings.system.networkProxy.mode': 'Proxy mode',
'settings.system.networkProxy.mode.system': 'Use system proxy',
'settings.system.networkProxy.mode.direct': 'Direct (no proxy)',
'settings.system.networkProxy.mode.custom': 'Custom proxy',
'settings.system.networkProxy.url': 'Proxy URL',
'settings.system.networkProxy.url.placeholder': 'http://127.0.0.1:7890',
'settings.system.networkProxy.url.desc': 'Supports http://, https://, and socks5:// host:port URLs. Proxy credentials in the URL are not supported.',
'settings.system.networkProxy.bypass': 'Bypass list',
'settings.system.networkProxy.bypass.placeholder': '<local>,localhost,127.0.0.1',
'settings.system.networkProxy.bypass.desc': 'Comma-separated hosts that skip the proxy. Use <local> for loopback and LAN names.',
'settings.system.networkProxy.hint': 'Affects Google Drive / OneDrive / GitHub sync login and AI provider requests. Does not change SSH connections.',
// Settings > System > Crash Logs
'settings.system.crashLogs.title': 'Crash Logs',
'settings.system.crashLogs.description': 'View error logs from the main process to help diagnose unexpected behavior.',
@@ -321,6 +336,7 @@ export const enCoreMessages: Messages = {
'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',
@@ -368,6 +384,17 @@ export const enCoreMessages: Messages = {
'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',
@@ -385,9 +412,18 @@ export const enCoreMessages: Messages = {
'settings.terminal.behavior.middleClick.menu': 'Show menu',
'settings.terminal.behavior.middleClick.paste': 'Paste',
'settings.terminal.behavior.middleClick.disabled': 'Do nothing',
'settings.terminal.behavior.wordSeparators': 'Word separators',
'settings.terminal.behavior.wordSeparators.desc':
'Characters treated as word boundaries for double-click selection. Spaces count too; add =, comma, or : for strings like uid=name.',
'settings.terminal.behavior.bracketedPaste': 'Bracketed paste mode',
'settings.terminal.behavior.bracketedPaste.desc':
'Wrap pasted text with escape sequences so the shell can distinguish paste from typed input. Disable if you see ^[[200~ artifacts.',
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter text',
'settings.terminal.behavior.shiftEnterNewline.desc':
'Send configured text instead of normal Enter when pressing Shift+Enter in the terminal.',
'settings.terminal.behavior.shiftEnterNewlineText': 'Text to send',
'settings.terminal.behavior.shiftEnterNewlineText.desc':
'Use \\n for newline, \\t for tab, and \\\\ for backslash.',
'settings.terminal.behavior.clearWipesScrollback': '`clear` wipes scrollback',
'settings.terminal.behavior.clearWipesScrollback.desc':
'Make `clear` also wipe the scrollback buffer (POSIX default). Disable to keep history visible after `clear`.',
@@ -476,6 +512,8 @@ export const enCoreMessages: Messages = {
'settings.terminal.section.connection': 'Connection',
'settings.terminal.connection.verifyHostKeys': 'Verify SSH host keys',
'settings.terminal.connection.verifyHostKeys.desc': 'Ask before connecting to a new or changed SSH host key. Turn this off only for trusted private networks.',
'settings.terminal.connection.sshAutoReconnectEnabled': 'Automatically reconnect SSH sessions',
'settings.terminal.connection.sshAutoReconnectEnabled.desc': 'When turned on, established SSH sessions that drop unexpectedly try to reconnect every 5 seconds until the tab is closed or the connection succeeds.',
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
'settings.terminal.connection.keepaliveInterval.desc': 'How often (in seconds) to send SSH-level keepalive packets. Set to 0 to disable globally — note that individual hosts can override this in their own settings.',
'settings.terminal.connection.keepaliveCountMax': 'Max unanswered keepalives',
@@ -484,6 +522,8 @@ export const enCoreMessages: Messages = {
'settings.terminal.connection.x11Display.desc': 'Optional local display address for X11 forwarding. Leave empty to use the system default.',
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 or DISPLAY)',
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
'settings.terminal.hostInfoBar.show': 'Show host information bar',
'settings.terminal.hostInfoBar.show.desc': 'Show the host address and server information above the terminal. Action buttons remain available when hidden.',
'settings.terminal.section.systemManager': 'System Manager',
'settings.terminal.systemManager.processRefreshInterval': 'Process list refresh',
'settings.terminal.systemManager.processRefreshInterval.desc': 'How often to refresh the process list in the system manager side panel.',
@@ -575,6 +615,7 @@ export const enCoreMessages: Messages = {
'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',
@@ -647,7 +688,7 @@ export const enCoreMessages: Messages = {
'vault.nav.keychain': 'Keychain',
'vault.nav.proxies': 'Proxies',
'vault.nav.portForwarding': 'Port Forwarding',
'vault.nav.snippets': 'Snippets',
'vault.nav.snippets': 'Scripts',
'vault.nav.notes': 'Notes',
'vault.nav.knownHosts': 'Known Hosts',
'vault.nav.logs': 'Logs',
@@ -708,6 +749,12 @@ export const enCoreMessages: Messages = {
'vault.groups.errors.invalidChars': "Group name cannot include '/' or '\\\\'.",
'vault.groups.errors.duplicatePath': 'A group with this name already exists at this location.',
'vault.deleteConfirm.title': 'Delete "{name}"?',
'vault.deleteConfirm.desc': 'This action cannot be undone.',
'vault.deleteConfirm.packageDesc': 'This will remove the script package. Scripts inside it will stay saved and move out of the package.',
'vault.deleteConfirm.noteGroupDesc': 'This will remove the folder. Notes inside it will stay saved and move out of the folder.',
'vault.deleteConfirm.portForwardingDesc': 'This will delete the port forwarding rule.',
'vault.managedSource.unmanage': 'Unmanage',
'vault.managedSource.unmanageSuccess': 'Successfully unmanaged group',
@@ -750,5 +797,8 @@ export const enCoreMessages: Messages = {
'vault.hosts.empty.title': 'Set up your hosts',
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
'deepLink.ssh.invalid': 'Invalid ssh:// link',
'deepLink.telnet.invalid': 'Invalid telnet:// link',
'deepLink.jms.invalid': 'Invalid JumpServer link',
'deepLink.jms.unsupported': 'Unsupported JumpServer protocol: {{protocol}}',
};
+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.',
};
@@ -6,6 +6,7 @@ export const enSystemManagerMessages: Messages = {
'systemManager.noSession': 'No active terminal session.',
'systemManager.notConnected': 'Connect to a host to manage processes and services.',
'systemManager.empty': 'No data available.',
'systemManager.tabs.overview': 'Overview',
'systemManager.tabs.processes': 'Processes',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
@@ -21,8 +22,35 @@ export const enSystemManagerMessages: Messages = {
'systemManager.errors.loadDocker': 'Failed to load containers',
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
'systemManager.errors.loadDockerImages': 'Failed to load images',
'systemManager.errors.loadOverview': 'Failed to load system overview',
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
'systemManager.overview.empty': 'No system overview data yet.',
'systemManager.overview.loading': 'Loading system overview…',
'systemManager.overview.memory': 'Memory',
'systemManager.overview.disk': 'Disk',
'systemManager.overview.network': 'Network',
'systemManager.overview.rx': 'RX',
'systemManager.overview.tx': 'TX',
'systemManager.overview.cores': '{{count}} cores',
'systemManager.overview.load': 'Load',
'systemManager.overview.uptime': 'Uptime',
'systemManager.overview.duration.daysHours': '{{days}}d {{hours}}h',
'systemManager.overview.duration.hoursMinutes': '{{hours}}h {{minutes}}m',
'systemManager.overview.duration.minutes': '{{minutes}}m',
'systemManager.overview.system': 'System',
'systemManager.overview.kernel': 'Kernel',
'systemManager.overview.swap': 'Swap',
'systemManager.overview.latency': 'SSH network latency',
'systemManager.overview.cpuCores': 'CPU cores',
'systemManager.overview.disks': 'Disks',
'systemManager.overview.interfaces': 'Network interfaces',
'systemManager.overview.topProcesses': 'Top memory processes',
'systemManager.overview.noData': 'No data',
'systemManager.overview.noDisks': 'No disk data',
'systemManager.overview.noInterfaces': 'No interface data',
'systemManager.overview.noTopProcesses': 'No process data',
'systemManager.processes.search': 'Search processes…',
'systemManager.processes.command': 'Command',
'systemManager.processes.user': 'User',
+42 -21
View File
@@ -55,6 +55,8 @@ export const enTerminalMessages: Messages = {
'terminal.toolbar.focus': 'Focus',
'terminal.toolbar.focusMode': 'Focus Mode',
'terminal.toolbar.detach': 'Detach to standalone tab',
'terminal.toolbar.dragPane': 'Drag terminal pane',
'terminal.toolbar.showActions': 'Show terminal actions',
'terminal.toolbar.encoding': 'Terminal Encoding',
'terminal.toolbar.encoding.utf8': 'UTF-8',
'terminal.toolbar.encoding.gb18030': 'GB18030',
@@ -88,7 +90,7 @@ export const enTerminalMessages: Messages = {
'terminal.serverStats.disk': 'Disk Usage (Root)',
'terminal.serverStats.diskDetails': 'Mounted Disks',
'terminal.serverStats.network': 'Network Speed',
'terminal.serverStats.latency': 'Latency',
'terminal.serverStats.latency': 'SSH network latency',
'terminal.serverStats.networkDetails': 'Network Interfaces',
'terminal.serverStats.noData': 'No data available',
'terminal.dragDrop.localTitle': 'Drop to Insert Paths',
@@ -155,6 +157,8 @@ export const enTerminalMessages: Messages = {
'terminal.auth.passphrase.placeholder': 'Optional passphrase for the selected private key',
'terminal.auth.certificate': 'Certificate',
'terminal.auth.selectKey': 'Select Key',
'terminal.auth.retryMessage': 'Authentication failed. Please check your credentials and try again.',
'terminal.auth.retryLog': 'Authentication failed. Please try again.',
'terminal.auth.noKeysHint': 'No keys available. Add keys in Keychain.',
'terminal.auth.continueSave': 'Continue & Save',
'terminal.auth.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Please re-enter and save them again.',
@@ -162,9 +166,13 @@ export const enTerminalMessages: Messages = {
'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',
@@ -295,6 +303,7 @@ export const enTerminalMessages: Messages = {
'cloudSync.s3.sessionToken': 'Session Token (optional)',
'cloudSync.s3.prefix': 'Key Prefix (optional)',
'cloudSync.s3.forcePathStyle': 'Force path-style URLs (for MinIO/R2, etc.)',
'cloudSync.s3.allowInsecure': 'Allow insecure connection (ignore certificate errors)',
'cloudSync.s3.showSecret': 'Show secrets',
'cloudSync.s3.validation.required': 'Endpoint, region, bucket, access key, and secret are required.',
'cloudSync.smb.title': 'SMB Settings',
@@ -579,24 +588,30 @@ export const enTerminalMessages: Messages = {
'keychain.edit.exportToHost': 'Export to host',
// Snippets
'snippets.searchPlaceholder': 'Search snippets...',
'snippets.searchPlaceholder': 'Search scripts...',
'snippets.action.newSnippet': 'New Snippet',
'snippets.action.newPackage': 'New Package',
'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 Package',
'snippets.field.packagePlaceholder': 'Select or create package',
'snippets.field.createPackage': 'Create Package',
'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',
@@ -609,15 +624,17 @@ export const enTerminalMessages: Messages = {
'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 packages',
'snippets.breadcrumb.allPackages': 'All script packages',
'snippets.breadcrumb.separator': '›',
'snippets.empty.title': 'Create snippet',
'snippets.empty.desc': 'Save your most used commands as snippets to reuse them in one click.',
'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 snippets or packages match "{query}". Try a different search term or clear the search to browse.',
'snippets.section.packages': 'Packages',
'snippets.section.snippets': 'Snippets',
'snippets.package.count': '{count} snippet(s)',
'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',
@@ -626,7 +643,7 @@ export const enTerminalMessages: Messages = {
'snippets.selection.deselectAll': 'Deselect all',
'snippets.selection.exportSelected': 'Export selected ({count})',
'snippets.export.snippet': 'Export snippet',
'snippets.export.package': 'Export package',
'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).',
@@ -642,28 +659,32 @@ export const enTerminalMessages: Messages = {
'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} snippet(s), {packages} package(s), {conflicts} duplicate command(s). Host bindings are ignored.',
'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 Package',
'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 packages.',
'snippets.packageDialog.hint': 'Use "/" to create nested script packages.',
// Snippets Rename Dialog
'snippets.renameDialog.title': 'Rename Package',
'snippets.renameDialog.title': 'Rename Script Package',
'snippets.renameDialog.currentPath': 'Current path: {path}',
'snippets.renameDialog.placeholder': 'Enter new name',
'snippets.renameDialog.error.empty': 'Package name cannot be empty',
'snippets.renameDialog.error.duplicate': 'A package with this name already exists',
'snippets.renameDialog.error.invalidChars': 'Package name can only contain letters, numbers, hyphens, and underscores',
'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',
+58 -3
View File
@@ -50,6 +50,14 @@ export const enVaultMessages: Messages = {
'vault.import.toast.summary':
'Imported {count} hosts (skipped {skipped}, duplicates {duplicates}).',
'vault.import.toast.firstIssue': 'First issue: {issue}',
'vault.import.mobaxterm.chooseEncoding': 'Choose how to read your MobaXterm file.',
'vault.import.mobaxterm.encodingQuestion': 'Which text encoding does this file use?',
'vault.import.mobaxterm.auto': 'Auto Detect',
'vault.import.mobaxterm.autoDesc': 'Recommended. Uses UTF-8 when valid, otherwise Chinese Windows encoding.',
'vault.import.mobaxterm.utf8': 'UTF-8',
'vault.import.mobaxterm.utf8Desc': 'Use for files saved or converted as UTF-8.',
'vault.import.mobaxterm.gb18030': 'Chinese Windows',
'vault.import.mobaxterm.gb18030Desc': 'Use for legacy files exported on Chinese Windows.',
'vault.import.sshConfig.chooseMode': 'Choose how to import your SSH config file.',
'vault.import.sshConfig.modeQuestion': 'How would you like to import?',
'vault.import.sshConfig.importOnly': 'Import Only',
@@ -166,6 +174,7 @@ export const enVaultMessages: Messages = {
'sftp.columns.modified': 'Modified',
'sftp.columns.size': 'Size',
'sftp.columns.kind': 'Kind',
'sftp.columns.configure': 'Choose visible columns',
'sftp.columns.actions': 'Actions',
'sftp.emptyDirectory': 'Empty directory',
'sftp.nav.up': 'Go up',
@@ -186,6 +195,7 @@ export const enVaultMessages: Messages = {
'sftp.moveTo.pathNotFound': 'Directory not found or inaccessible',
'sftp.context.download': 'Download',
'sftp.context.copyToOtherPane': 'Copy to other pane',
'sftp.copyToOtherPane.unavailable': 'Open the two-pane SFTP view and connect the destination pane before copying files.',
'sftp.copyCurrentPath': 'Copy current path',
'sftp.copyCurrentPath.success': 'Current path copied',
'sftp.copyCurrentPath.error': 'Could not copy current path',
@@ -279,6 +289,9 @@ export const enVaultMessages: Messages = {
'sftp.picker.local.title': 'Local filesystem',
'sftp.picker.local.desc': 'Browse local files',
'sftp.picker.local.badge': 'Local',
'sftp.picker.connected.section': 'Connected',
'sftp.picker.connected.badge': 'Live',
'sftp.picker.connected.connecting': 'Connecting',
'sftp.picker.noMatch': 'No matching hosts',
'sftp.permissions.title': 'Edit Permissions',
'sftp.permissions.owner': 'Owner',
@@ -382,9 +395,12 @@ export const enVaultMessages: Messages = {
'settings.sftp.doubleClickBehavior.transfer': 'Transfer to other pane',
'settings.sftp.doubleClickBehavior.openDesc': 'Open the file in the default application',
'settings.sftp.doubleClickBehavior.transferDesc': 'Transfer the file to the other pane\'s active host',
'settings.sshDeepLink.title': 'SSH links',
'settings.sshDeepLink.enable': 'Open ssh:// links with Netcatty',
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// links from browsers and other apps.',
'settings.sshDeepLink.title': 'SSH and Telnet links',
'settings.sshDeepLink.enable': 'Open ssh:// and telnet:// links with Netcatty',
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// and telnet:// links from browsers and other apps.',
'settings.jmsDeepLink.title': 'JumpServer Links',
'settings.jmsDeepLink.enable': 'Handle jms:// links',
'settings.jmsDeepLink.enableDesc': 'Register Netcatty as the handler for JumpServer jms:// client links. May conflict with the official JumpServer client.',
// Settings > SFTP Auto Sync
'settings.sftp.autoSync': 'Auto-sync to remote',
@@ -472,6 +488,17 @@ export const enVaultMessages: Messages = {
'quickConnect.knownHost.addAndContinue': 'Add and continue',
'quickConnect.addKey': 'Add key',
'quickConnect.warning.unparsedOptions': 'Some SSH arguments were ignored: {options}',
'quickConnect.identity.label': 'Credential preset',
'quickConnect.identity.placeholder': 'Choose a saved identity',
'quickConnect.identity.empty': 'No saved identities found',
'quickConnect.identity.hint': 'Choose an identity to fill in its username and authentication automatically.',
'quickConnect.identity.selectedHint': 'Ready to connect as {username}.',
'quickConnect.identity.method.password': 'Password',
'quickConnect.identity.method.key': 'SSH key',
'quickConnect.identity.method.certificate': 'Certificate',
'quickConnect.identity.unavailable': 'This identity is missing usable credentials on this device.',
'quickConnect.identity.useCustom': 'Use custom credentials',
'quickConnect.identity.connect': 'Connect with preset',
// Terminal
'terminal.connectionErrorTitle': 'Connection Error',
@@ -483,6 +510,7 @@ export const enVaultMessages: Messages = {
// 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',
@@ -500,6 +528,8 @@ export const enVaultMessages: Messages = {
'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',
@@ -522,6 +552,7 @@ export const enVaultMessages: Messages = {
'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.',
@@ -614,6 +645,15 @@ export const enVaultMessages: Messages = {
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
'hostDetails.credential.browseKeyFile': 'Browse...',
'hostDetails.credential.missing': 'Credential not found',
'hostDetails.section.systemSshAgent': 'System SSH Agent',
'hostDetails.systemSshAgent': 'Use System SSH Agent to Sign In',
'hostDetails.systemSshAgent.desc': 'Use keys already unlocked by macOS, Linux, Windows, 1Password, or another compatible SSH agent',
'hostDetails.systemSshAgent.socket': 'Agent Socket',
'hostDetails.systemSshAgent.socket.desc': 'Leave empty to use SSH_AUTH_SOCK',
'hostDetails.systemSshAgent.identitiesOnly': 'Only Use Configured Keys',
'hostDetails.systemSshAgent.identitiesOnly.desc': 'Do not try unrelated keys currently loaded in the agent',
'hostDetails.systemSshAgent.notRunning': 'System SSH agent is unavailable',
'hostDetails.systemSshAgent.notRunningHint': 'Start or unlock your SSH agent, or provide its socket path above.',
'hostDetails.keys.search': 'Search keys...',
'hostDetails.keys.empty': 'No keys available',
'hostDetails.certs.search': 'Search certificates...',
@@ -655,6 +695,11 @@ export const enVaultMessages: Messages = {
'hostDetails.keepalive.interval': 'Interval (seconds)',
'hostDetails.keepalive.countMax': 'Max unanswered keepalives',
'hostDetails.keepalive.disabledHint': 'Interval = 0 disables keepalive for this host. The session will rely on TCP-level timeouts to detect a dead connection.',
'hostDetails.section.sshTimeouts': 'SSH Connection Timeouts',
'hostDetails.sshTimeouts.tcpConnect': 'TCP timeout',
'hostDetails.sshTimeouts.tcpConnect.desc': 'Seconds to wait for the network connection to this host.',
'hostDetails.sshTimeouts.authReady': 'SSH auth timeout',
'hostDetails.sshTimeouts.authReady.desc': 'Seconds to wait after connecting for SSH negotiation and authentication to finish.',
'hostDetails.backspaceBehavior': 'Backspace Behavior',
'hostDetails.backspaceBehavior.default': 'Default',
'hostDetails.jumpHosts': 'Proxy via Hosts',
@@ -674,12 +719,17 @@ export const enVaultMessages: Messages = {
'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',
@@ -707,10 +757,15 @@ export const enVaultMessages: Messages = {
'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)',
+81
View File
@@ -169,6 +169,70 @@ export const ruAiMessages: Messages = {
'ai.toolAccess.description': 'Выберите, как внешние агенты получают доступ к сессиям Netcatty. MCP предоставляет встроенный сервер, а Skills + CLI указывает агентам на локальный skill Netcatty и команды CLI.',
'ai.toolAccess.mode.mcp': 'MCP',
'ai.toolAccess.mode.skills': 'Skills + CLI',
'ai.externalMcp.title': 'Внешний MCP',
'ai.externalMcp.description': 'Откройте Netcatty как MCP-сервер для Codex, Claude Code, Cursor и Grok. Набор инструментов тот же, что у встроенных агентов. Держите Netcatty запущенным, пока клиенты подключены.',
'ai.externalMcp.sessionsExposed': 'Сессий в области: {count}',
'ai.externalMcp.mode': 'Режим доступности',
'ai.externalMcp.mode.temporary': 'Временный',
'ai.externalMcp.mode.persistent': 'Постоянный',
'ai.externalMcp.mode.description': 'Временный режим отключается после простоя. Постоянный восстанавливает External MCP при запуске Netcatty.',
'ai.externalMcp.idleTimeout': 'Таймаут простоя',
'ai.externalMcp.idleTimeout.description': 'В временном режиме отключить External MCP после указанного числа минут без MCP-операций.',
'ai.externalMcp.idleTimeout.minutes': 'мин',
'ai.externalMcp.usage.title': 'Как пользоваться',
'ai.externalMcp.usage.keepRunning': '1. Включите External MCP и держите Netcatty запущенным.',
'ai.externalMcp.usage.localhost': '2. Клиенты подключаются через локальный launcher (только 127.0.0.1). Discovery удаляется при отключении.',
'ai.externalMcp.usage.permissions': '3. Запись следует Settings → AI → Safety и списку блокировки команд.',
'ai.externalMcp.usage.capabilities': '4. Доступен полный catalog: терминал, SFTP, Vault и проброс портов. Секреты не возвращаются.',
'ai.externalMcp.help.ariaLabel': 'Справка External MCP',
'ai.externalMcp.security': 'Безопасность',
'ai.externalMcp.security.description': 'Слушает только 127.0.0.1 с ротацией token, использует AI Permission Mode для записи и удаляет discovery при отключении.',
'ai.externalMcp.permissionMode': 'Текущий режим разрешений: {mode}',
'ai.externalMcp.permissionMode.label': 'Режим разрешений на запись',
'ai.externalMcp.permissionMode.hint': 'Тот же параметр, что Settings → AI → Safety. Auto выполняет write-инструменты Netcatty без запросов; Confirm спрашивает каждый раз. Внешние клиенты (Codex / Claude / Grok) могут показывать своё подтверждение инструментов.',
'ai.externalMcp.permissionMode.unknown': 'Неизвестно',
'ai.externalMcp.discovery': 'Discovery',
'ai.externalMcp.launcher': 'Launcher',
'ai.externalMcp.unavailable': 'Недоступно',
'ai.externalMcp.bridgeUnavailable': 'Мост External MCP недоступен',
'ai.externalMcp.copy': 'Копировать',
'ai.externalMcp.copied': 'Скопировано',
'ai.externalMcp.copyFailed': 'Не удалось скопировать. Скопируйте вручную.',
'ai.externalMcp.refresh': 'Обновить',
'ai.externalMcp.clientConfiguration': 'Настройка клиента',
'ai.externalMcp.clientConfiguration.description': 'Выберите клиент для установки в один клик или скопируйте CLI / фрагмент конфига.',
'ai.externalMcp.client.codex': 'Codex',
'ai.externalMcp.client.claude': 'Claude Code',
'ai.externalMcp.client.grok': 'Grok',
'ai.externalMcp.client.cursor': 'Cursor',
'ai.externalMcp.cliCommand': 'CLI-команда',
'ai.externalMcp.configSnippet': 'Фрагмент конфига',
'ai.externalMcp.addToCodex': 'Добавить в Codex',
'ai.externalMcp.addToClaude': 'Добавить в Claude Code',
'ai.externalMcp.addToGrok': 'Добавить в Grok',
'ai.externalMcp.codexAdded': 'Запись MCP для Codex добавлена. Перезапустите Codex или откройте новую сессию.',
'ai.externalMcp.claudeAdded': 'Запись MCP для Claude Code добавлена. Перезапустите Claude Code или откройте новую сессию.',
'ai.externalMcp.grokAdded': 'Запись MCP для Grok добавлена. Перезапустите Grok или откройте новую сессию.',
'ai.externalMcp.installCodex': 'Сначала установите Codex, затем нажмите Обновить.',
'ai.externalMcp.installClaude': 'Сначала установите Claude Code, затем нажмите Обновить.',
'ai.externalMcp.installGrok': 'Сначала установите Grok CLI, затем нажмите Обновить.',
'ai.externalMcp.conflict.description': 'Запись netcatty-external уже существует и указывает в другое место. Удалите или измените её вручную.',
'ai.externalMcp.enableForLauncher': 'Включите External MCP, чтобы получить путь launcher.',
'ai.externalMcp.cursor.title': 'Cursor / другие клиенты',
'ai.externalMcp.cursor.description': 'Объедините с MCP-конфигом (например ~/.cursor/mcp.json). Не заменяйте весь файл, если там уже есть другие серверы.',
'ai.externalMcp.status.unavailable': 'Недоступно',
'ai.externalMcp.status.disabled': 'Выключено',
'ai.externalMcp.status.running': 'Работает',
'ai.externalMcp.status.starting': 'Запуск',
'ai.externalMcp.status.error': 'Ошибка',
'ai.externalMcp.status.configured': 'Настроено',
'ai.externalMcp.status.notConfigured': 'Не настроено',
'ai.externalMcp.status.checking': 'Проверка',
'ai.externalMcp.status.codexNotFound': 'Codex не найден',
'ai.externalMcp.status.claudeNotFound': 'Claude Code не найден',
'ai.externalMcp.status.grokNotFound': 'Grok не найден',
'ai.externalMcp.status.conflict': 'Конфликт',
'ai.userSkills.title': 'Пользовательские skills',
'ai.userSkills.description': 'Откройте папку skills Netcatty, чтобы добавить свои каталоги skills. Netcatty автоматически сканирует их и добавляет только лёгкие индексы, если skill явно не соответствует текущему запросу.',
'ai.userSkills.openFolder': 'Открыть папку skills',
@@ -374,6 +438,23 @@ export const ruAiMessages: Messages = {
'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': 'Скачивание',
+36
View File
@@ -106,6 +106,21 @@ export const ruCoreMessages: Messages = {
'settings.system.credentials.unavailableHint': 'Учётные данные, зашифрованные в другом профиле пользователя или на другой машине, здесь расшифровать нельзя. Повторно введите и сохраните их на этом устройстве.',
'settings.system.credentials.portabilityHint': 'Облачная синхронизация переносима, потому что использует шифрование вашим мастер-ключом. Локальное шифрование safeStorage привязано к устройству и пользователю.',
// Settings > System > Network Proxy
'settings.system.networkProxy.title': 'Сетевой прокси',
'settings.system.networkProxy.description': 'HTTP(S)-прокси для облачной синхронизации и AI-провайдеров. Не связан с SSH ProxyJump / ProxyCommand.',
'settings.system.networkProxy.mode': 'Режим прокси',
'settings.system.networkProxy.mode.system': 'Системный прокси',
'settings.system.networkProxy.mode.direct': 'Напрямую (без прокси)',
'settings.system.networkProxy.mode.custom': 'Свой прокси',
'settings.system.networkProxy.url': 'URL прокси',
'settings.system.networkProxy.url.placeholder': 'http://127.0.0.1:7890',
'settings.system.networkProxy.url.desc': 'Поддерживаются URL вида http://, https:// и socks5:// host:port. Учётные данные в URL прокси не поддерживаются.',
'settings.system.networkProxy.bypass': 'Список исключений',
'settings.system.networkProxy.bypass.placeholder': '<local>,localhost,127.0.0.1',
'settings.system.networkProxy.bypass.desc': 'Хосты через запятую, для которых прокси не используется. <local> — localhost и локальные имена.',
'settings.system.networkProxy.hint': 'Влияет на вход в Google Drive / OneDrive / GitHub sync и запросы к AI-провайдерам. SSH-подключения не меняет.',
// Settings > System > Crash Logs
'settings.system.crashLogs.title': 'Журналы сбоев',
'settings.system.crashLogs.description': 'Просмотр журналов ошибок основного процесса для диагностики неожиданного поведения.',
@@ -374,9 +389,18 @@ export const ruCoreMessages: Messages = {
'settings.terminal.behavior.middleClick.menu': 'Показать меню',
'settings.terminal.behavior.middleClick.paste': 'Вставить',
'settings.terminal.behavior.middleClick.disabled': 'Ничего не делать',
'settings.terminal.behavior.wordSeparators': 'Разделители слов',
'settings.terminal.behavior.wordSeparators.desc':
'Символы, которые считаются границами слова при выделении двойным щелчком. Пробел тоже учитывается; добавьте =, запятую или : для строк вроде uid=name.',
'settings.terminal.behavior.bracketedPaste': 'Режим bracketed paste',
'settings.terminal.behavior.bracketedPaste.desc':
'Оборачивать вставляемый текст escape-последовательностями, чтобы оболочка отличала вставку от обычного ввода. Отключите, если видите артефакты вида ^[[200~.',
'settings.terminal.behavior.shiftEnterNewline': 'Текст для Shift+Enter',
'settings.terminal.behavior.shiftEnterNewline.desc':
'Отправлять настроенный текст вместо обычного Enter при нажатии Shift+Enter в терминале.',
'settings.terminal.behavior.shiftEnterNewlineText': 'Отправляемый текст',
'settings.terminal.behavior.shiftEnterNewlineText.desc':
'Используйте \\n для перевода строки, \\t для табуляции и \\\\ для обратной косой черты.',
'settings.terminal.behavior.clearWipesScrollback': '`clear` очищает буфер прокрутки',
'settings.terminal.behavior.clearWipesScrollback.desc':
'Команда `clear` также будет очищать буфер прокрутки (поведение POSIX по умолчанию). Отключите, чтобы история оставалась видимой после `clear`.',
@@ -471,6 +495,8 @@ export const ruCoreMessages: Messages = {
'settings.terminal.connection.x11Display.desc': 'Необязательный адрес локального дисплея для перенаправления X11. Оставьте пустым, чтобы использовать системное значение по умолчанию.',
'settings.terminal.connection.x11Display.placeholder': 'Авто (:0 или DISPLAY)',
'settings.terminal.section.serverStats': 'Статистика сервера (Linux)',
'settings.terminal.hostInfoBar.show': 'Показывать сведения о хосте',
'settings.terminal.hostInfoBar.show.desc': 'Показывать адрес хоста и сведения о сервере над терминалом. Кнопки действий остаются доступными после скрытия.',
'settings.terminal.section.systemManager': 'Системный менеджер',
'settings.terminal.systemManager.processRefreshInterval': 'Обновление списка процессов',
'settings.terminal.systemManager.processRefreshInterval.desc': 'Как часто обновлять список процессов в боковой панели системного менеджера.',
@@ -592,6 +618,7 @@ export const ruCoreMessages: Messages = {
'sync.cloudSync': 'Облачная синхронизация',
'sync.settings': 'Настройки синхронизации',
'sync.active': 'Облачная синхронизация активна',
'sync.pending': 'Есть несинхронизированные изменения',
'sync.syncing': 'Синхронизация...',
'sync.error': 'Ошибка синхронизации',
'sync.notConfigured': 'Не настроено',
@@ -724,11 +751,20 @@ export const ruCoreMessages: Messages = {
'vault.groups.errors.invalidChars': "Имя группы не может содержать '/' или '\\\\'.",
'vault.groups.errors.duplicatePath': 'Группа с таким именем уже существует в этом расположении.',
'vault.deleteConfirm.title': 'Удалить "{name}"?',
'vault.deleteConfirm.desc': 'Это действие нельзя отменить.',
'vault.deleteConfirm.packageDesc': 'Пакет скриптов будет удалён. Скрипты внутри него сохранятся и будут вынесены из пакета.',
'vault.deleteConfirm.noteGroupDesc': 'Папка будет удалена. Заметки внутри неё сохранятся и будут вынесены из папки.',
'vault.deleteConfirm.portForwardingDesc': 'Правило проброса портов будет удалено.',
'vault.managedSource.unmanage': 'Снять управление',
'vault.managedSource.unmanageSuccess': 'Управление группой успешно снято',
'vault.hosts.header.entries': 'Записей: {count}',
'vault.hosts.header.live': 'Активных: {count}',
'deepLink.ssh.invalid': 'Недопустимая ссылка ssh://',
'deepLink.telnet.invalid': 'Недопустимая ссылка telnet://',
'deepLink.jms.invalid': 'Недопустимая ссылка JumpServer',
'deepLink.jms.unsupported': 'Неподдерживаемый протокол JumpServer: {{protocol}}',
};
@@ -6,6 +6,7 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.noSession': 'Нет активного терминального сеанса.',
'systemManager.notConnected': 'Подключитесь к хосту для управления процессами и сервисами.',
'systemManager.empty': 'Нет данных.',
'systemManager.tabs.overview': 'Обзор',
'systemManager.tabs.processes': 'Процессы',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
@@ -21,8 +22,35 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.errors.loadDocker': 'Не удалось загрузить контейнеры',
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы',
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
'systemManager.overview.empty': 'Данных обзора пока нет.',
'systemManager.overview.loading': 'Загрузка обзора системы…',
'systemManager.overview.memory': 'Память',
'systemManager.overview.disk': 'Диск',
'systemManager.overview.network': 'Сеть',
'systemManager.overview.rx': 'RX',
'systemManager.overview.tx': 'TX',
'systemManager.overview.cores': '{{count}} ядер',
'systemManager.overview.load': 'Нагрузка',
'systemManager.overview.uptime': 'Время работы',
'systemManager.overview.duration.daysHours': '{{days}} д {{hours}} ч',
'systemManager.overview.duration.hoursMinutes': '{{hours}} ч {{minutes}} мин',
'systemManager.overview.duration.minutes': '{{minutes}} мин',
'systemManager.overview.system': 'Система',
'systemManager.overview.kernel': 'Ядро',
'systemManager.overview.swap': 'Swap',
'systemManager.overview.latency': 'Сетевая задержка SSH',
'systemManager.overview.cpuCores': 'Ядра CPU',
'systemManager.overview.disks': 'Диски',
'systemManager.overview.interfaces': 'Сетевые интерфейсы',
'systemManager.overview.topProcesses': 'Процессы по памяти',
'systemManager.overview.noData': 'Нет данных',
'systemManager.overview.noDisks': 'Нет данных дисков',
'systemManager.overview.noInterfaces': 'Нет данных интерфейсов',
'systemManager.overview.noTopProcesses': 'Нет данных процессов',
'systemManager.processes.search': 'Поиск процессов…',
'systemManager.processes.command': 'Команда',
'systemManager.processes.user': 'Пользователь',
+13
View File
@@ -76,6 +76,8 @@ export const ruTerminalMessages: Messages = {
'terminal.toolbar.focus': 'Фокус',
'terminal.toolbar.focusMode': 'Режим фокуса',
'terminal.toolbar.detach': 'Открепить в отдельную вкладку',
'terminal.toolbar.dragPane': 'Перетащить панель терминала',
'terminal.toolbar.showActions': 'Показать действия терминала',
'terminal.toolbar.encoding': 'Кодировка терминала',
'terminal.toolbar.encoding.utf8': 'UTF-8',
'terminal.toolbar.encoding.gb18030': 'GB18030',
@@ -109,6 +111,7 @@ export const ruTerminalMessages: Messages = {
'terminal.serverStats.disk': 'Использование диска (корень)',
'terminal.serverStats.diskDetails': 'Смонтированные диски',
'terminal.serverStats.network': 'Скорость сети',
'terminal.serverStats.latency': 'Сетевая задержка SSH',
'terminal.serverStats.networkDetails': 'Сетевые интерфейсы',
'terminal.serverStats.noData': 'Данные недоступны',
'terminal.dragDrop.localTitle': 'Перетащите для вставки путей',
@@ -175,6 +178,8 @@ export const ruTerminalMessages: Messages = {
'terminal.auth.passphrase.placeholder': 'Необязательная парольная фраза для выбранного приватного ключа',
'terminal.auth.certificate': 'Сертификат',
'terminal.auth.selectKey': 'Выбрать ключ',
'terminal.auth.retryMessage': 'Ошибка аутентификации. Проверьте учётные данные и повторите попытку.',
'terminal.auth.retryLog': 'Ошибка аутентификации. Повторите попытку.',
'terminal.auth.noKeysHint': 'Нет доступных ключей. Добавьте ключи в связке ключей.',
'terminal.auth.continueSave': 'Продолжить и сохранить',
'terminal.auth.credentialsUnavailable': 'Сохранённые учётные данные не могут быть расшифрованы на этом устройстве. Пожалуйста, введите и сохраните их заново.',
@@ -182,9 +187,11 @@ export const ruTerminalMessages: Messages = {
'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': 'Показать журналы',
@@ -312,6 +319,7 @@ export const ruTerminalMessages: Messages = {
'cloudSync.s3.sessionToken': 'Токен сессии (необязательно)',
'cloudSync.s3.prefix': 'Префикс ключа (необязательно)',
'cloudSync.s3.forcePathStyle': 'Принудительно использовать path-style URL (для MinIO/R2 и т. д.)',
'cloudSync.s3.allowInsecure': 'Разрешить небезопасное соединение (игнорировать ошибки сертификата)',
'cloudSync.s3.showSecret': 'Показать секреты',
'cloudSync.s3.validation.required': 'Endpoint, регион, бакет, access key и secret обязательны.',
'cloudSync.smb.title': 'Настройки SMB',
@@ -600,6 +608,7 @@ export const ruTerminalMessages: Messages = {
'snippets.action.selectSnippets': 'Выбрать сниппеты',
'snippets.panel.newTitle': 'Новый сниппет',
'snippets.panel.editTitle': 'Редактировать сниппет',
'snippets.panel.resizeWidth': 'Изменить ширину панели',
'snippets.field.description': 'Описание действия',
'snippets.field.descriptionPlaceholder': 'Например: проверить сетевую нагрузку',
'snippets.field.package': 'Добавить пакет',
@@ -689,6 +698,10 @@ export const ruTerminalMessages: Messages = {
'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': 'Нажмите, чтобы задать сочетание',
+58 -3
View File
@@ -85,6 +85,14 @@ export const ruVaultMessages: Messages = {
'vault.import.toast.summary':
'Импортировано {count} хостов (пропущено {skipped}, дубликатов {duplicates}).',
'vault.import.toast.firstIssue': 'Первая проблема: {issue}',
'vault.import.mobaxterm.chooseEncoding': 'Выберите способ чтения файла MobaXterm.',
'vault.import.mobaxterm.encodingQuestion': 'Какая кодировка используется в этом файле?',
'vault.import.mobaxterm.auto': 'Определить автоматически',
'vault.import.mobaxterm.autoDesc': 'Рекомендуется. UTF-8, если он корректен, иначе китайская кодировка Windows.',
'vault.import.mobaxterm.utf8': 'UTF-8',
'vault.import.mobaxterm.utf8Desc': 'Для файлов, сохранённых или преобразованных в UTF-8.',
'vault.import.mobaxterm.gb18030': 'Китайская Windows',
'vault.import.mobaxterm.gb18030Desc': 'Для старых файлов, экспортированных в китайской Windows.',
'vault.import.sshConfig.chooseMode': 'Выберите, как импортировать ваш файл SSH-конфига.',
'vault.import.sshConfig.modeQuestion': 'Как вы хотите выполнить импорт?',
'vault.import.sshConfig.importOnly': 'Только импорт',
@@ -201,6 +209,7 @@ export const ruVaultMessages: Messages = {
'sftp.columns.modified': 'Изменён',
'sftp.columns.size': 'Размер',
'sftp.columns.kind': 'Тип',
'sftp.columns.configure': 'Выбрать видимые столбцы',
'sftp.columns.actions': 'Действия',
'sftp.emptyDirectory': 'Пустой каталог',
'sftp.nav.up': 'Наверх',
@@ -221,6 +230,7 @@ export const ruVaultMessages: Messages = {
'sftp.moveTo.pathNotFound': 'Каталог не найден или недоступен',
'sftp.context.download': 'Скачать',
'sftp.context.copyToOtherPane': 'Копировать в другую панель',
'sftp.copyToOtherPane.unavailable': 'Откройте двухпанельный режим SFTP и подключите панель назначения перед копированием файлов.',
'sftp.copyCurrentPath': 'Копировать текущий путь',
'sftp.copyCurrentPath.success': 'Текущий путь скопирован',
'sftp.copyCurrentPath.error': 'Не удалось скопировать текущий путь',
@@ -314,6 +324,9 @@ export const ruVaultMessages: Messages = {
'sftp.picker.local.title': 'Локальная файловая система',
'sftp.picker.local.desc': 'Просмотр локальных файлов',
'sftp.picker.local.badge': 'Локально',
'sftp.picker.connected.section': 'Подключённые',
'sftp.picker.connected.badge': 'Онлайн',
'sftp.picker.connected.connecting': 'Подключение',
'sftp.picker.noMatch': 'Подходящие хосты не найдены',
'sftp.permissions.title': 'Изменить права доступа',
'sftp.permissions.owner': 'Владелец',
@@ -417,9 +430,12 @@ export const ruVaultMessages: Messages = {
'settings.sftp.doubleClickBehavior.transfer': 'Передать в другую панель',
'settings.sftp.doubleClickBehavior.openDesc': 'Открыть файл в приложении по умолчанию',
'settings.sftp.doubleClickBehavior.transferDesc': 'Передать файл на активный хост другой панели',
'settings.sshDeepLink.title': 'SSH-ссылки',
'settings.sshDeepLink.enable': 'Открывать ssh:// ссылки в Netcatty',
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// ссылки из браузеров и других приложений.',
'settings.sshDeepLink.title': 'SSH- и Telnet-ссылки',
'settings.sshDeepLink.enable': 'Открывать ssh:// и telnet:// ссылки в Netcatty',
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// и telnet:// ссылки из браузеров и других приложений.',
'settings.jmsDeepLink.title': 'Ссылки JumpServer',
'settings.jmsDeepLink.enable': 'Обрабатывать ссылки jms://',
'settings.jmsDeepLink.enableDesc': 'Зарегистрировать Netcatty как обработчик клиентских ссылок JumpServer jms://. Может конфликтовать с официальным клиентом JumpServer.',
// Settings > SFTP Auto Sync
'settings.sftp.autoSync': 'Автосинхронизация с удалённым сервером',
@@ -507,6 +523,17 @@ export const ruVaultMessages: Messages = {
'quickConnect.knownHost.addAndContinue': 'Добавить и продолжить',
'quickConnect.addKey': 'Добавить ключ',
'quickConnect.warning.unparsedOptions': 'Некоторые аргументы SSH были проигнорированы: {options}',
'quickConnect.identity.label': 'Набор учётных данных',
'quickConnect.identity.placeholder': 'Выберите сохранённый профиль',
'quickConnect.identity.empty': 'Сохранённые профили не найдены',
'quickConnect.identity.hint': 'Выберите профиль, чтобы автоматически подставить имя пользователя и способ входа.',
'quickConnect.identity.selectedHint': 'Готово к подключению как {username}.',
'quickConnect.identity.method.password': 'Пароль',
'quickConnect.identity.method.key': 'Ключ SSH',
'quickConnect.identity.method.certificate': 'Сертификат',
'quickConnect.identity.unavailable': 'Для этого профиля нет доступных учётных данных на устройстве.',
'quickConnect.identity.useCustom': 'Использовать другие данные',
'quickConnect.identity.connect': 'Подключиться с профилем',
// Terminal
'terminal.connectionErrorTitle': 'Ошибка подключения',
@@ -518,6 +545,7 @@ export const ruVaultMessages: Messages = {
// Host Details
'hostDetails.title.details': 'Сведения о хосте',
'hostDetails.title.new': 'Новый хост',
'vault.panel.resizeWidth': 'Изменить ширину панели',
'hostDetails.saveAria': 'Сохранить',
'hostDetails.section.address': 'Адрес',
'hostDetails.hostname.placeholder': 'IP или имя хоста',
@@ -535,6 +563,8 @@ export const ruVaultMessages: Messages = {
'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': 'Порт и учётные данные',
@@ -557,6 +587,7 @@ export const ruVaultMessages: Messages = {
'hostDetails.icon.colorManual': 'Вручную',
'hostDetails.icon.colorChoice': 'Цвет',
'hostDetails.icon.autoColorLabel': 'Текущий цвет',
'hostDetails.icon.preview': 'Предпросмотр значка',
'hostDetails.icon.showLibrary': 'Показать библиотеку значков',
'hostDetails.icon.hideLibrary': 'Скрыть библиотеку значков',
'hostDetails.icon.autoUsesDistro': 'Использует значок дистрибутива Linux и выбранный цвет для этого хоста.',
@@ -646,6 +677,15 @@ export const ruVaultMessages: Messages = {
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
'hostDetails.credential.browseKeyFile': 'Обзор...',
'hostDetails.credential.missing': 'Учётные данные не найдены',
'hostDetails.section.systemSshAgent': 'Системный SSH-агент',
'hostDetails.systemSshAgent': 'Использовать системный SSH-агент для входа',
'hostDetails.systemSshAgent.desc': 'Использовать ключи, разблокированные macOS, Linux, Windows, 1Password или другим совместимым агентом',
'hostDetails.systemSshAgent.socket': 'Сокет агента',
'hostDetails.systemSshAgent.socket.desc': 'Оставьте пустым, чтобы использовать SSH_AUTH_SOCK',
'hostDetails.systemSshAgent.identitiesOnly': 'Только настроенные ключи',
'hostDetails.systemSshAgent.identitiesOnly.desc': 'Не пробовать другие ключи, загруженные в агент',
'hostDetails.systemSshAgent.notRunning': 'Системный SSH-агент недоступен',
'hostDetails.systemSshAgent.notRunningHint': 'Запустите или разблокируйте SSH-агент либо укажите путь к его сокету выше.',
'hostDetails.keys.search': 'Поиск ключей...',
'hostDetails.keys.empty': 'Нет доступных ключей',
'hostDetails.certs.search': 'Поиск сертификатов...',
@@ -687,6 +727,11 @@ export const ruVaultMessages: Messages = {
'hostDetails.keepalive.interval': 'Интервал (секунды)',
'hostDetails.keepalive.countMax': 'Макс. число пропущенных keepalive',
'hostDetails.keepalive.disabledHint': 'Интервал = 0 отключает keepalive для этого хоста. Для определения разорванного соединения сессия будет полагаться на TCP-таймауты.',
'hostDetails.section.sshTimeouts': 'Тайм-ауты SSH-подключения',
'hostDetails.sshTimeouts.tcpConnect': 'Тайм-аут TCP',
'hostDetails.sshTimeouts.tcpConnect.desc': 'Сколько секунд ждать сетевого подключения к этому хосту.',
'hostDetails.sshTimeouts.authReady': 'Тайм-аут SSH-входа',
'hostDetails.sshTimeouts.authReady.desc': 'Сколько секунд ждать согласования SSH и завершения аутентификации после подключения.',
'hostDetails.backspaceBehavior': 'Поведение Backspace',
'hostDetails.backspaceBehavior.default': 'По умолчанию',
'hostDetails.jumpHosts': 'Прокси через хосты',
@@ -706,12 +751,17 @@ export const ruVaultMessages: Messages = {
'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': 'Добавить переменную окружения',
@@ -738,10 +788,15 @@ export const ruVaultMessages: Messages = {
'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)',
@@ -0,0 +1,25 @@
import test from "node:test";
import assert from "node:assert/strict";
import { enSystemManagerMessages } from "./en/systemManager";
import { enTerminalMessages } from "./en/terminal";
import { ruSystemManagerMessages } from "./ru/systemManager";
import { ruTerminalMessages } from "./ru/terminal";
import { zhCnSystemManagerMessages } from "./zh-CN/systemManager";
import { zhCNVaultMessages } from "./zh-CN/vault";
import { zhTwSystemManagerMessages } from "./zh-TW/systemManager";
import { zhTWVaultMessages } from "./zh-TW/vault";
test("SSH network latency is explicit in every locale and UI surface", () => {
const labels = [
[enTerminalMessages, enSystemManagerMessages, "SSH network latency"],
[ruTerminalMessages, ruSystemManagerMessages, "Сетевая задержка SSH"],
[zhCNVaultMessages, zhCnSystemManagerMessages, "SSH 网络延迟"],
[zhTWVaultMessages, zhTwSystemManagerMessages, "SSH 網路延遲"],
] as const;
for (const [terminalMessages, systemManagerMessages, expected] of labels) {
assert.equal(terminalMessages["terminal.serverStats.latency"], expected);
assert.equal(systemManagerMessages["systemManager.overview.latency"], expected);
}
});
@@ -36,6 +36,29 @@ test("localized settings include workspace focus indicator labels", () => {
}
});
test("localized settings include network proxy labels", () => {
const keys = [
"settings.system.networkProxy.title",
"settings.system.networkProxy.description",
"settings.system.networkProxy.mode",
"settings.system.networkProxy.mode.system",
"settings.system.networkProxy.mode.direct",
"settings.system.networkProxy.mode.custom",
"settings.system.networkProxy.url",
"settings.system.networkProxy.url.placeholder",
"settings.system.networkProxy.url.desc",
"settings.system.networkProxy.bypass",
"settings.system.networkProxy.bypass.placeholder",
"settings.system.networkProxy.bypass.desc",
"settings.system.networkProxy.hint",
];
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
const missing = keys.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing network proxy labels`);
}
});
test("localized settings include terminal font weight option labels", () => {
const keys = [
"settings.terminal.font.weight.thin",
+2
View File
@@ -4,6 +4,7 @@ import { zhCNVaultMessages } from './zh-CN/vault';
import { zhCNTerminalMessages } from './zh-CN/terminal';
import { zhCNAiMessages } from './zh-CN/ai';
import { zhCnSystemManagerMessages } from './zh-CN/systemManager';
import { zhCNScriptsMessages } from './zh-CN/scripts';
export type { Messages } from './types';
@@ -13,6 +14,7 @@ const zhCN: Messages = {
...zhCNTerminalMessages,
...zhCNAiMessages,
...zhCnSystemManagerMessages,
...zhCNScriptsMessages,
};
export default zhCN;
+82
View File
@@ -185,6 +185,71 @@ export const zhCNAiMessages: Messages = {
'ai.toolAccess.description': '选择外部 Agent 访问 Netcatty 会话的方式。MCP 会暴露内置服务器,Skills + CLI 会引导 Agent 读取本地 Skill 并调用 Netcatty CLI。',
'ai.toolAccess.mode.mcp': 'MCP',
'ai.toolAccess.mode.skills': 'Skills + CLI',
// External MCP
'ai.externalMcp.title': '对外 MCP',
'ai.externalMcp.description': '把 Netcatty 作为 MCP 服务器暴露给 Codex、Claude Code、Cursor、Grok 等外部客户端。工具面与应用内 Agent 相同(终端、SFTP、Vault、端口转发)。客户端连接期间请保持 Netcatty 运行。',
'ai.externalMcp.sessionsExposed': '作用域内会话:{count}',
'ai.externalMcp.mode': '可用模式',
'ai.externalMcp.mode.temporary': '临时',
'ai.externalMcp.mode.persistent': '常开',
'ai.externalMcp.mode.description': '临时模式会在空闲超时后自动关闭;常开模式会在 Netcatty 启动时恢复对外 MCP。',
'ai.externalMcp.idleTimeout': '空闲超时',
'ai.externalMcp.idleTimeout.description': '临时模式下,若超过该分钟数没有 MCP 操作,将自动关闭对外 MCP。',
'ai.externalMcp.idleTimeout.minutes': '分钟',
'ai.externalMcp.usage.title': '使用说明',
'ai.externalMcp.usage.keepRunning': '1. 打开对外 MCP 开关,并保持 Netcatty 运行。',
'ai.externalMcp.usage.localhost': '2. 客户端通过本地 launcher 连接(仅 127.0.0.1)。关闭开关后会删除 discovery。',
'ai.externalMcp.usage.permissions': '3. 写操作遵循「设置 → AI → 安全」(观察 / 确认 / 自动)以及命令黑名单。',
'ai.externalMcp.usage.capabilities': '4. 完整 catalog 工具可用:终端、SFTP、Vault、端口转发。密码 / 私钥不会返回。',
'ai.externalMcp.help.ariaLabel': '对外 MCP 使用说明',
'ai.externalMcp.security': '安全',
'ai.externalMcp.security.description': '仅监听 127.0.0.1,使用轮换 token,写操作复用 AI 权限模式,关闭时删除 discovery。不做 OAuth——这是本机桌面桥接。',
'ai.externalMcp.permissionMode': '当前权限模式:{mode}',
'ai.externalMcp.permissionMode.label': '写操作权限模式',
'ai.externalMcp.permissionMode.hint': '与「设置 → AI → 安全」为同一项。自动:Netcatty 写操作不再弹本应用审批;确认:每次询问。外部客户端(Codex / Claude / Grok)仍可能有自己的工具审批界面。',
'ai.externalMcp.permissionMode.unknown': '未知',
'ai.externalMcp.discovery': 'Discovery',
'ai.externalMcp.launcher': 'Launcher',
'ai.externalMcp.unavailable': '不可用',
'ai.externalMcp.bridgeUnavailable': '对外 MCP 桥接不可用',
'ai.externalMcp.copy': '复制',
'ai.externalMcp.copied': '已复制',
'ai.externalMcp.copyFailed': '复制失败,请手动复制。',
'ai.externalMcp.refresh': '刷新',
'ai.externalMcp.clientConfiguration': '客户端配置',
'ai.externalMcp.clientConfiguration.description': '选择客户端一键安装,或复制 CLI / 配置片段。',
'ai.externalMcp.client.codex': 'Codex',
'ai.externalMcp.client.claude': 'Claude Code',
'ai.externalMcp.client.grok': 'Grok',
'ai.externalMcp.client.cursor': 'Cursor',
'ai.externalMcp.cliCommand': 'CLI 命令',
'ai.externalMcp.configSnippet': '配置片段',
'ai.externalMcp.addToCodex': '添加到 Codex',
'ai.externalMcp.addToClaude': '添加到 Claude Code',
'ai.externalMcp.addToGrok': '添加到 Grok',
'ai.externalMcp.codexAdded': '已添加 Codex MCP 条目。请重启 Codex 或打开新会话。',
'ai.externalMcp.claudeAdded': '已添加 Claude Code MCP 条目。请重启 Claude Code 或打开新会话。',
'ai.externalMcp.grokAdded': '已添加 Grok MCP 条目。请重启 Grok 或打开新会话。',
'ai.externalMcp.installCodex': '请先单独安装 Codex,然后点击刷新。',
'ai.externalMcp.installClaude': '请先单独安装 Claude Code,然后点击刷新。',
'ai.externalMcp.installGrok': '请先单独安装 Grok CLI,然后点击刷新。',
'ai.externalMcp.conflict.description': '已存在指向其他位置的 netcatty-external 条目,请手动删除或修改。',
'ai.externalMcp.enableForLauncher': '请先启用对外 MCP,以获取可用的 launcher 路径。',
'ai.externalMcp.cursor.title': 'Cursor / 其他客户端',
'ai.externalMcp.cursor.description': '合并到 MCP 配置文件(例如 ~/.cursor/mcp.json)。若已有其他服务器,请勿整文件覆盖。',
'ai.externalMcp.status.unavailable': '不可用',
'ai.externalMcp.status.disabled': '已关闭',
'ai.externalMcp.status.running': '运行中',
'ai.externalMcp.status.starting': '启动中',
'ai.externalMcp.status.error': '错误',
'ai.externalMcp.status.configured': '已配置',
'ai.externalMcp.status.notConfigured': '未配置',
'ai.externalMcp.status.checking': '检查中',
'ai.externalMcp.status.codexNotFound': '未找到 Codex',
'ai.externalMcp.status.claudeNotFound': '未找到 Claude Code',
'ai.externalMcp.status.grokNotFound': '未找到 Grok',
'ai.externalMcp.status.conflict': '冲突',
'ai.userSkills.title': '用户 Skills',
'ai.userSkills.description': '打开 Netcatty 的 Skills 文件夹以添加你自己的技能目录。Netcatty 会自动扫描这些 skills,默认只注入轻量索引,只有在请求明显命中某个 skill 时才展开正文。',
'ai.userSkills.openFolder': '打开 Skills 文件夹',
@@ -397,6 +462,23 @@ export const zhCNAiMessages: Messages = {
'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': '下载中',
+51 -1
View File
@@ -69,6 +69,18 @@ export const zhCNCoreMessages: Messages = {
'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': '系统',
@@ -92,6 +104,21 @@ export const zhCNCoreMessages: Messages = {
'settings.system.credentials.unavailableHint': '在其他用户或机器上加密的凭据无法在此处解密。请在当前设备重新输入并保存凭据。',
'settings.system.credentials.portabilityHint': '云同步可跨设备,因为使用主密钥加密;本地 safeStorage 加密仅绑定当前系统用户/设备。',
// Settings > System > Network Proxy
'settings.system.networkProxy.title': '网络代理',
'settings.system.networkProxy.description': '用于云同步与 AI 服务商的 HTTP(S) 代理。与 SSH 的 ProxyJump / ProxyCommand 配置无关。',
'settings.system.networkProxy.mode': '代理模式',
'settings.system.networkProxy.mode.system': '系统代理',
'settings.system.networkProxy.mode.direct': '直连(不使用代理)',
'settings.system.networkProxy.mode.custom': '自定义代理',
'settings.system.networkProxy.url': '代理地址',
'settings.system.networkProxy.url.placeholder': 'http://127.0.0.1:7890',
'settings.system.networkProxy.url.desc': '支持 http://、https://、socks5:// 的 host:port 地址。代理 URL 中不支持账号密码。',
'settings.system.networkProxy.bypass': '绕过列表',
'settings.system.networkProxy.bypass.placeholder': '<local>,localhost,127.0.0.1',
'settings.system.networkProxy.bypass.desc': '逗号分隔、不走代理的主机。可用 <local> 表示本机与局域网名称。',
'settings.system.networkProxy.hint': '影响 Google Drive / OneDrive / GitHub 同步登录与 AI 服务商请求,不会改变 SSH 连接。',
// Settings > System > Crash Logs
'settings.system.crashLogs.title': '崩溃日志',
'settings.system.crashLogs.description': '查看主进程错误日志,帮助诊断异常行为。',
@@ -304,6 +331,7 @@ export const zhCNCoreMessages: Messages = {
'sync.cloudSync': '云同步',
'sync.settings': '同步设置',
'sync.active': '云同步已启用',
'sync.pending': '有待同步的更改',
'sync.syncing': '正在同步…',
'sync.error': '同步错误',
'sync.notConfigured': '未配置',
@@ -376,7 +404,7 @@ export const zhCNCoreMessages: Messages = {
'vault.nav.keychain': '钥匙串',
'vault.nav.proxies': '代理',
'vault.nav.portForwarding': '端口转发',
'vault.nav.snippets': '代码片段',
'vault.nav.snippets': '脚本',
'vault.nav.notes': '笔记',
'vault.nav.knownHosts': '已知主机',
'vault.nav.logs': '日志',
@@ -437,6 +465,12 @@ export const zhCNCoreMessages: Messages = {
'vault.groups.errors.invalidChars': "分组名称不能包含 '/' 或 '\\\\'.",
'vault.groups.errors.duplicatePath': '该位置已存在同名分组。',
'vault.deleteConfirm.title': '删除“{name}”?',
'vault.deleteConfirm.desc': '此操作不可撤销。',
'vault.deleteConfirm.packageDesc': '这会删除脚本包,包内脚本会保留,并移出该脚本包。',
'vault.deleteConfirm.noteGroupDesc': '这会删除文件夹,文件夹内笔记会保留,并移出该文件夹。',
'vault.deleteConfirm.portForwardingDesc': '这会删除该端口转发规则。',
'vault.managedSource.unmanage': '取消托管',
'vault.managedSource.unmanageSuccess': '已取消托管分组',
@@ -479,6 +513,9 @@ export const zhCNCoreMessages: Messages = {
'vault.hosts.empty.title': '设置你的主机',
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
'deepLink.ssh.invalid': '无效的 ssh:// 链接',
'deepLink.telnet.invalid': '无效的 telnet:// 链接',
'deepLink.jms.invalid': '无效的 JumpServer 链接',
'deepLink.jms.unsupported': '不支持的 JumpServer 协议:{{protocol}}',
// Vault import
'vault.import.title': '添加数据到你的 Vault',
@@ -493,6 +530,14 @@ export const zhCNCoreMessages: Messages = {
'vault.import.toast.noNewHosts': '从 {format} 没有导入到新的主机。',
'vault.import.toast.summary': '已导入 {count} 个主机(跳过 {skipped},重复 {duplicates})。',
'vault.import.toast.firstIssue': '首个问题:{issue}',
'vault.import.mobaxterm.chooseEncoding': '选择如何读取 MobaXterm 文件。',
'vault.import.mobaxterm.encodingQuestion': '这个文件使用哪种文字编码?',
'vault.import.mobaxterm.auto': '自动检测',
'vault.import.mobaxterm.autoDesc': '推荐。能按 UTF-8 读取时使用 UTF-8,否则使用中文 Windows 编码。',
'vault.import.mobaxterm.utf8': 'UTF-8',
'vault.import.mobaxterm.utf8Desc': '适用于已保存或转换为 UTF-8 的文件。',
'vault.import.mobaxterm.gb18030': '中文 Windows',
'vault.import.mobaxterm.gb18030Desc': '适用于中文 Windows 上导出的旧版文件。',
'vault.import.sshConfig.chooseMode': '选择如何导入你的 SSH config 文件。',
'vault.import.sshConfig.modeQuestion': '你希望如何导入?',
'vault.import.sshConfig.importOnly': '仅导入',
@@ -582,6 +627,7 @@ export const zhCNCoreMessages: Messages = {
'sftp.bookmark.addGlobalTooltip': '保存为全局收藏(所有主机共享)',
'sftp.bookmark.empty': '暂无收藏路径',
'sftp.columns.name': '名称',
'sftp.columns.configure': '选择显示的列',
'sftp.columns.modified': '修改时间',
'sftp.columns.size': '大小',
'sftp.columns.kind': '类型',
@@ -605,6 +651,7 @@ export const zhCNCoreMessages: Messages = {
'sftp.moveTo.pathNotFound': '目录不存在或无法访问',
'sftp.context.download': '下载',
'sftp.context.copyToOtherPane': '复制到另一侧',
'sftp.copyToOtherPane.unavailable': '请打开双栏 SFTP 文件管理,并连接目标侧后再复制文件。',
'sftp.copyCurrentPath': '复制当前路径',
'sftp.copyCurrentPath.success': '已复制当前路径',
'sftp.copyCurrentPath.error': '无法复制当前路径',
@@ -698,6 +745,9 @@ export const zhCNCoreMessages: Messages = {
'sftp.picker.local.title': '本地文件系统',
'sftp.picker.local.desc': '浏览本地文件',
'sftp.picker.local.badge': '本地',
'sftp.picker.connected.section': '已连接',
'sftp.picker.connected.badge': '已连接',
'sftp.picker.connected.connecting': '连接中',
'sftp.picker.noMatch': '没有匹配的主机',
'sftp.permissions.title': '编辑权限',
'sftp.permissions.owner': '所有者',
+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': '拖动条目即可调整连接时的运行顺序。',
};
@@ -6,6 +6,7 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.noSession': '没有活动的终端会话。',
'systemManager.notConnected': '请先连接到主机以管理进程与服务。',
'systemManager.empty': '暂无数据。',
'systemManager.tabs.overview': '概览',
'systemManager.tabs.processes': '进程',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
@@ -21,8 +22,35 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.errors.loadDocker': '加载容器列表失败',
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
'systemManager.errors.loadOverview': '加载系统概览失败',
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
'systemManager.overview.empty': '暂无系统概览数据。',
'systemManager.overview.loading': '正在加载系统概览…',
'systemManager.overview.memory': '内存',
'systemManager.overview.disk': '磁盘',
'systemManager.overview.network': '网络',
'systemManager.overview.rx': '下行',
'systemManager.overview.tx': '上行',
'systemManager.overview.cores': '{{count}} 核',
'systemManager.overview.load': '负载',
'systemManager.overview.uptime': '运行时间',
'systemManager.overview.duration.daysHours': '{{days}}天 {{hours}}小时',
'systemManager.overview.duration.hoursMinutes': '{{hours}}小时 {{minutes}}分钟',
'systemManager.overview.duration.minutes': '{{minutes}}分钟',
'systemManager.overview.system': '系统',
'systemManager.overview.kernel': '内核',
'systemManager.overview.swap': '交换区',
'systemManager.overview.latency': 'SSH 网络延迟',
'systemManager.overview.cpuCores': 'CPU 核心',
'systemManager.overview.disks': '磁盘',
'systemManager.overview.interfaces': '网络接口',
'systemManager.overview.topProcesses': '内存占用最高进程',
'systemManager.overview.noData': '暂无数据',
'systemManager.overview.noDisks': '暂无磁盘数据',
'systemManager.overview.noInterfaces': '暂无网卡数据',
'systemManager.overview.noTopProcesses': '暂无进程数据',
'systemManager.processes.search': '搜索进程…',
'systemManager.processes.command': '命令',
'systemManager.processes.user': '用户',
+61 -23
View File
@@ -8,6 +8,8 @@ export const zhCNTerminalMessages: Messages = {
'terminal.clipboardImageUpload.failed': '无法上传剪贴板图片',
'terminal.toolbar.configureOsc7': '配置目录追踪',
'terminal.toolbar.detach': '移出到独立标签',
'terminal.toolbar.dragPane': '拖动终端窗格',
'terminal.toolbar.showActions': '显示终端操作',
'terminal.menu.detach': '从工作区移出',
'terminal.osc7Setup.title': '配置目录追踪',
'terminal.osc7Setup.desc': 'Netcatty 会为当前远端用户添加 OSC 7 提示符配置。这样在 sudo 或 su 之后,SFTP 也能继续跟随终端目录。',
@@ -20,6 +22,9 @@ export const zhCNTerminalMessages: Messages = {
'terminal.osc7Setup.sent': '目录追踪配置已发送到终端',
'terminal.toolbar.timestampsEnable': '显示时间戳',
'terminal.toolbar.timestampsDisable': '隐藏时间戳',
'terminal.progress.enterReconnectHint': '按 Enter 重新连接',
'terminal.progress.autoReconnectScheduled': '连接已断开,将在 {seconds} 秒后自动重连(第 {attempt} 次)。',
'terminal.progress.autoReconnectAttempt': '正在自动重连(第 {attempt} 次)...',
'terminal.connection.protocol.et': 'EternalTerminal',
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支持 Netcatty 的代理设置。请改用 SSH,或移除该主机的代理。',
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支持一个跳板机。',
@@ -101,9 +106,12 @@ export const zhCNTerminalMessages: Messages = {
'settings.sftp.doubleClickBehavior.transfer': '传输到另一侧',
'settings.sftp.doubleClickBehavior.openDesc': '使用默认应用程序打开文件',
'settings.sftp.doubleClickBehavior.transferDesc': '将文件传输到另一窗格的活动主机',
'settings.sshDeepLink.title': 'SSH 链接',
'settings.sshDeepLink.enable': '用 Netcatty 打开 ssh:// 链接',
'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh:// 链接。',
'settings.sshDeepLink.title': 'SSH 和 Telnet 链接',
'settings.sshDeepLink.enable': '用 Netcatty 打开 ssh:// 和 telnet:// 链接',
'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh://、telnet:// 链接。',
'settings.jmsDeepLink.title': 'JumpServer 链接',
'settings.jmsDeepLink.enable': '处理 jms:// 链接',
'settings.jmsDeepLink.enableDesc': '将 Netcatty 注册为 JumpServer jms:// 客户端链接的处理程序,可能与官方 JumpServer 客户端冲突。',
// Settings > SFTP Auto Sync
'settings.sftp.autoSync': '自动同步到远程',
@@ -247,9 +255,18 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.behavior.middleClick.menu': '显示菜单',
'settings.terminal.behavior.middleClick.paste': '粘贴',
'settings.terminal.behavior.middleClick.disabled': '无动作',
'settings.terminal.behavior.wordSeparators': '单词分隔符',
'settings.terminal.behavior.wordSeparators.desc':
'双击选择文本时作为边界的字符。空格也算;可添加 =、逗号或 :,方便选择 uid=name 里的部分内容。',
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
'settings.terminal.behavior.bracketedPaste.desc':
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter 发送文本',
'settings.terminal.behavior.shiftEnterNewline.desc':
'在终端中按 Shift+Enter 时发送配置的文本,而不是普通回车。',
'settings.terminal.behavior.shiftEnterNewlineText': '发送内容',
'settings.terminal.behavior.shiftEnterNewlineText.desc':
'使用 \\n 表示换行,\\t 表示 Tab,\\\\ 表示反斜杠。',
'settings.terminal.behavior.clearWipesScrollback': '`clear` 同时清空回滚历史',
'settings.terminal.behavior.clearWipesScrollback.desc':
'`clear` 命令同时清空回滚历史(POSIX 默认行为)。关闭则保留历史。',
@@ -332,6 +349,8 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.localShell.startDir.notFound': '目录不存在',
'settings.terminal.localShell.startDir.isFile': '路径是文件,不是目录',
'settings.terminal.section.connection': '连接',
'settings.terminal.connection.sshAutoReconnectEnabled': 'SSH 断线后自动重连',
'settings.terminal.connection.sshAutoReconnectEnabled.desc': '打开后,已连上的 SSH 会话意外断开时会每 5 秒尝试重新连接,直到关闭标签页或连接恢复。',
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
'settings.terminal.connection.keepaliveInterval.desc': '向服务器发送 SSH 保活数据包的频率(秒)。设为 0 表示全局禁用——单个主机可在自己的设置里覆盖此值。',
'settings.terminal.connection.keepaliveCountMax': '最大无响应保活次数',
@@ -340,6 +359,8 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.connection.x11Display.desc': '可选的本机 X11 显示地址。留空则使用系统默认值。',
'settings.terminal.connection.x11Display.placeholder': '自动(:0 或 DISPLAY)',
'settings.terminal.section.serverStats': '服务器状态(Linux)',
'settings.terminal.hostInfoBar.show': '显示主机信息栏',
'settings.terminal.hostInfoBar.show.desc': '在终端上方显示主机地址和服务器信息。隐藏后仍可使用操作按钮。',
'settings.terminal.serverStats.show': '显示服务器状态',
'settings.terminal.serverStats.show.desc': '在终端状态栏显示 CPU、内存和磁盘使用情况(仅限 Linux 服务器)。',
'settings.terminal.serverStats.refreshInterval': '刷新间隔',
@@ -462,12 +483,17 @@ export const zhCNTerminalMessages: Messages = {
'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} 设置环境变量。',
@@ -602,24 +628,30 @@ export const zhCNTerminalMessages: Messages = {
'keychain.edit.exportToHost': '导出到主机',
// Snippets
'snippets.searchPlaceholder': '搜索代码片段...',
'snippets.searchPlaceholder': '搜索脚本...',
'snippets.action.newSnippet': '新建代码片段',
'snippets.action.newPackage': '新建代码包',
'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.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 历史',
@@ -632,15 +664,17 @@ export const zhCNTerminalMessages: Messages = {
'snippets.history.time.minutesAgo': '{count} 分钟前',
'snippets.history.time.hoursAgo': '{count} 小时前',
'snippets.history.time.daysAgo': '{count} 天前',
'snippets.breadcrumb.allPackages': '全部代码包',
'snippets.breadcrumb.allPackages': '全部脚本包',
'snippets.breadcrumb.separator': '›',
'snippets.empty.title': '创建代码片段',
'snippets.empty.desc': '将常用命令保存为代码片段,一键复用。',
'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.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': '列表',
@@ -649,7 +683,7 @@ export const zhCNTerminalMessages: Messages = {
'snippets.selection.deselectAll': '取消选择',
'snippets.selection.exportSelected': '导出选中({count})',
'snippets.export.snippet': '导出代码片段',
'snippets.export.package': '导出代码包',
'snippets.export.package': '导出脚本包',
'snippets.export.toast.empty': '没有可导出的代码片段。',
'snippets.export.toast.successTitle': '导出已准备好',
'snippets.export.toast.success': '已导出 {count} 个代码片段。',
@@ -665,28 +699,32 @@ export const zhCNTerminalMessages: Messages = {
'snippets.import.modal.chooseFile': '选择文件',
'snippets.import.modal.downloadExamples': '下载示例文件',
'snippets.import.modal.multipleFiles': '已选择 {count} 个文件',
'snippets.import.modal.parsedSummary': '{files} 个文件,{total} 个代码片段,{packages} 个代码包,{conflicts} 个重复命令。主机绑定会被忽略。',
'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.title': '新建脚本包',
'snippets.packageDialog.parent': '父级:{parent}',
'snippets.packageDialog.root': '根目录',
'snippets.packageDialog.placeholder': '例如:ops/maintenance',
'snippets.packageDialog.hint': '使用 "/" 创建嵌套代码包。',
'snippets.packageDialog.hint': '使用 "/" 创建嵌套脚本包。',
// Snippets Rename Dialog
'snippets.renameDialog.title': '重命名代码包',
'snippets.renameDialog.title': '重命名脚本包',
'snippets.renameDialog.currentPath': '当前路径:{path}',
'snippets.renameDialog.placeholder': '输入新名称',
'snippets.renameDialog.error.empty': '代码包名称不能为空',
'snippets.renameDialog.error.duplicate': '已存在同名的代码包',
'snippets.renameDialog.error.invalidChars': '代码包名称只能包含字母、数字、连字符和下划线',
'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': '点击设置快捷键',
+39 -1
View File
@@ -50,6 +50,17 @@ export const zhCNVaultMessages: Messages = {
'quickConnect.knownHost.addAndContinue': '加入并继续',
'quickConnect.addKey': '添加 key',
'quickConnect.warning.unparsedOptions': '部分 SSH 参数已被忽略: {options}',
'quickConnect.identity.label': '凭据预设',
'quickConnect.identity.placeholder': '选择已保存的身份',
'quickConnect.identity.empty': '没有可用的身份',
'quickConnect.identity.hint': '选择身份后会自动带入用户名和认证信息。',
'quickConnect.identity.selectedHint': '将以 {username} 连接。',
'quickConnect.identity.method.password': '密码',
'quickConnect.identity.method.key': 'SSH 密钥',
'quickConnect.identity.method.certificate': '证书',
'quickConnect.identity.unavailable': '此身份在当前设备上缺少可用凭据。',
'quickConnect.identity.useCustom': '改用自定义凭据',
'quickConnect.identity.connect': '使用预设连接',
// Protocol select dialog
'protocolSelect.chooseProtocol': '选择协议',
@@ -57,6 +68,7 @@ export const zhCNVaultMessages: Messages = {
// Host Details
'hostDetails.title.details': '主机详情',
'hostDetails.title.new': '新建主机',
'vault.panel.resizeWidth': '调整面板宽度',
'hostDetails.saveAria': '保存',
'hostDetails.section.address': '地址',
'hostDetails.hostname.placeholder': 'IP 或 主机名',
@@ -74,6 +86,8 @@ export const zhCNVaultMessages: Messages = {
'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': '端口与凭据',
@@ -96,6 +110,7 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.icon.colorManual': '手动',
'hostDetails.icon.colorChoice': '颜色',
'hostDetails.icon.autoColorLabel': '当前颜色',
'hostDetails.icon.preview': '图标预览',
'hostDetails.icon.showLibrary': '展开图标库',
'hostDetails.icon.hideLibrary': '收起图标库',
'hostDetails.icon.autoUsesDistro': '使用 Linux 发行版图标和所选颜色显示此主机。',
@@ -188,6 +203,15 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
'hostDetails.credential.browseKeyFile': '浏览…',
'hostDetails.credential.missing': '凭据不存在',
'hostDetails.section.systemSshAgent': '系统 SSH 密钥',
'hostDetails.systemSshAgent': '使用系统 SSH 密钥登录',
'hostDetails.systemSshAgent.desc': '使用已由 macOS、Linux、Windows、1Password 或其他兼容服务解锁的密钥',
'hostDetails.systemSshAgent.socket': '代理地址',
'hostDetails.systemSshAgent.socket.desc': '留空时使用 SSH_AUTH_SOCK',
'hostDetails.systemSshAgent.identitiesOnly': '仅使用已配置的密钥',
'hostDetails.systemSshAgent.identitiesOnly.desc': '不尝试代理中其他无关密钥',
'hostDetails.systemSshAgent.notRunning': '系统 SSH 密钥服务不可用',
'hostDetails.systemSshAgent.notRunningHint': '请启动或解锁密钥服务,或者在上方填写它的地址。',
'hostDetails.keys.search': '搜索密钥…',
'hostDetails.keys.empty': '暂无密钥',
'hostDetails.certs.search': '搜索证书…',
@@ -229,6 +253,11 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.keepalive.interval': '间隔(秒)',
'hostDetails.keepalive.countMax': '最大无响应保活次数',
'hostDetails.keepalive.disabledHint': '间隔为 0 时该主机不发送保活包,仅依赖 TCP 层超时检测断连。',
'hostDetails.section.sshTimeouts': 'SSH 连接超时',
'hostDetails.sshTimeouts.tcpConnect': 'TCP 连接超时(秒)',
'hostDetails.sshTimeouts.tcpConnect.desc': '等待与该主机建立网络连接的最长时间。',
'hostDetails.sshTimeouts.authReady': '认证超时(秒)',
'hostDetails.sshTimeouts.authReady.desc': '网络连接建立后,等待 SSH 协商和认证完成的最长时间。',
'hostDetails.backspaceBehavior': 'Backspace 行为',
'hostDetails.backspaceBehavior.default': '默认',
'hostDetails.jumpHosts': '通过主机代理',
@@ -244,10 +273,15 @@ export const zhCNVaultMessages: Messages = {
'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)',
@@ -373,7 +407,7 @@ export const zhCNVaultMessages: Messages = {
'terminal.serverStats.disk': '磁盘使用(根分区)',
'terminal.serverStats.diskDetails': '已挂载磁盘',
'terminal.serverStats.network': '网络速度',
'terminal.serverStats.latency': '延迟',
'terminal.serverStats.latency': 'SSH 网络延迟',
'terminal.serverStats.networkDetails': '网络接口',
'terminal.serverStats.noData': '暂无数据',
'terminal.dragDrop.localTitle': '拖放以插入路径',
@@ -428,6 +462,8 @@ export const zhCNVaultMessages: Messages = {
'terminal.auth.passphrase.placeholder': '可选:所选私钥的密码短语',
'terminal.auth.certificate': '证书',
'terminal.auth.selectKey': '选择密钥',
'terminal.auth.retryMessage': '身份验证失败。请检查凭据后重试。',
'terminal.auth.retryLog': '身份验证失败,请重试。',
'terminal.auth.noKeysHint': '暂无密钥,请先在钥匙串中添加。',
'terminal.auth.continueSave': '继续并保存',
'terminal.auth.credentialsUnavailable': '当前设备无法解密已保存凭据,请重新输入并再次保存。',
@@ -436,6 +472,7 @@ export const zhCNVaultMessages: Messages = {
'terminal.auth.keyUnavailableFallbackPassword': '已保存 SSH 密钥在当前设备不可用,改用密码认证。',
'terminal.connectionErrorTitle': '连接错误',
'terminal.progress.timeoutIn': '将在 {seconds}s 后超时',
'terminal.progress.waitingForUserInput': '等待用户输入',
'terminal.progress.disconnected': '已断开',
'terminal.progress.cancelling': '正在取消...',
'terminal.progress.startOver': '重新开始',
@@ -565,6 +602,7 @@ export const zhCNVaultMessages: Messages = {
'cloudSync.s3.sessionToken': 'Session Token(可选)',
'cloudSync.s3.prefix': 'Key 前缀(可选)',
'cloudSync.s3.forcePathStyle': '强制使用 path-style URL(适用于 MinIO/R2 等)',
'cloudSync.s3.allowInsecure': '允许不安全的连接(忽略证书错误)',
'cloudSync.s3.showSecret': '显示密钥',
'cloudSync.s3.validation.required': '端点、Region、Bucket、Access Key 与 Secret 必填。',
'cloudSync.smb.title': 'SMB 设置',
+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': '壓縮傳輸',
};
+2
View File
@@ -1,5 +1,6 @@
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.
@@ -9,5 +10,6 @@ export const MESSAGES_BY_LOCALE: Record<string, Messages> = {
en,
ru,
'zh-CN': zhCN,
'zh-TW': zhTW,
};
@@ -1,5 +1,6 @@
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';
@@ -15,6 +16,7 @@ export type ActiveChromeThemeDeps = {
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>;
+41 -1
View File
@@ -1,7 +1,8 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { fromEditorTabId, isEditorTabId, toEditorTabId } from './activeTabStore';
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');
@@ -12,3 +13,42 @@ 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();
}
}
});
-16
View File
@@ -1,7 +1,5 @@
import { useCallback, useSyncExternalStore } from 'react';
import { terminalLayoutSuppressStore } from './terminalLayoutSuppressStore';
// Simple store for active tab that allows fine-grained subscriptions
type Listener = () => void;
type SyncListener = (activeTabId: string) => void;
@@ -39,20 +37,11 @@ class ActiveTabStore {
setActiveTabId = (id: string) => {
if (this.activeTabId !== id) {
terminalLayoutSuppressStore.begin();
this.activeTabId = id;
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();
const schedule = typeof requestAnimationFrame === 'function'
? requestAnimationFrame
: (cb: () => void) => window.setTimeout(cb, 0) as unknown as number;
schedule(() => {
schedule(() => {
terminalLayoutSuppressStore.end();
});
});
}
};
@@ -78,11 +67,6 @@ export const useActiveTabId = () => {
);
};
// Hook to get setter - never causes re-render
export const useSetActiveTabId = () => {
return activeTabStore.setActiveTabId;
};
// Check if a specific tab is active - only re-renders when this specific tab's active state changes
export const useIsTabActive = (tabId: string) => {
const getSnapshot = useCallback(() => activeTabStore.getActiveTabId() === tabId, [tabId]);
+323
View File
@@ -0,0 +1,323 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { idleThemeUserIntent, resolveGlobalTerminalAppearance } from "../../domain/terminalAppearanceRuntime.ts";
import {
hasPersistedAppearanceChanged,
resolveAppearanceStorageEvent,
resolveAppearanceSyncState,
resolveIncomingAppearanceValue,
type AppearanceState,
type AppearanceRenderSnapshot,
type StoredAppearanceValues,
} from "./appearanceSync.ts";
import {
STORAGE_KEY_ACCENT_MODE,
STORAGE_KEY_COLOR,
STORAGE_KEY_THEME,
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_UI_THEME_LIGHT,
} from "../../infrastructure/config/storageKeys.ts";
const systemLight: AppearanceRenderSnapshot = {
theme: "system",
resolvedTheme: "light",
lightUiThemeId: "snow",
darkUiThemeId: "midnight",
accentMode: "theme",
customAccent: "208 100% 50%",
};
test("an OS color event cannot persist a stale System choice over a newer Dark choice", () => {
const systemDark = { ...systemLight, resolvedTheme: "dark" as const };
let storedTheme: "light" | "dark" | "system" = "dark";
if (hasPersistedAppearanceChanged(systemLight, systemDark)) {
storedTheme = systemDark.theme;
}
assert.equal(storedTheme, "dark");
});
test("an explicit theme choice remains a persisted appearance change", () => {
assert.equal(
hasPersistedAppearanceChanged(systemLight, {
...systemLight,
theme: "dark",
resolvedTheme: "dark",
}),
true,
);
});
test("an appearance IPC value wins over stale local storage for the changed key", () => {
const incoming = { key: STORAGE_KEY_THEME, value: "dark" };
assert.equal(
resolveIncomingAppearanceValue(
incoming,
STORAGE_KEY_THEME,
"system",
"system",
(value): value is "light" | "dark" | "system" => (
value === "light" || value === "dark" || value === "system"
),
),
"dark",
);
// Non-matching keyed IPC must keep the current in-memory value, not storage.
assert.equal(
resolveIncomingAppearanceValue(
incoming,
STORAGE_KEY_UI_THEME_DARK,
"stale-from-storage",
"midnight",
(value): value is string => typeof value === "string",
),
"midnight",
);
// Full rehydrate (no incoming) still prefers storage.
assert.equal(
resolveIncomingAppearanceValue(
undefined,
STORAGE_KEY_UI_THEME_DARK,
"from-storage",
"midnight",
(value): value is string => typeof value === "string",
),
"from-storage",
);
});
test("a non-theme appearance IPC value wins over stale local storage for that key", () => {
const current: AppearanceState = {
theme: "dark",
lightUiThemeId: "snow",
darkUiThemeId: "midnight",
accentMode: "theme",
customAccent: "208 100% 50%",
};
// Storage still lags for every non-theme field (the race the review covers).
const staleStored: StoredAppearanceValues = {
theme: "dark",
lightUiThemeId: "snow",
darkUiThemeId: "midnight",
accentMode: "theme",
customAccent: "208 100% 50%",
};
// Picking a follow-app terminal theme updates darkUiThemeId; only that key is
// announced on the IPC payload, so the reducer must prefer the payload.
const darkUiSelection = {
key: STORAGE_KEY_UI_THEME_DARK,
value: "github",
};
const nextDarkUi = resolveAppearanceSyncState(current, {
...staleStored,
darkUiThemeId: "midnight",
}, darkUiSelection);
assert.equal(nextDarkUi.darkUiThemeId, "github");
assert.equal(nextDarkUi.theme, "dark");
assert.equal(nextDarkUi.lightUiThemeId, "snow");
const lightUiSelection = {
key: STORAGE_KEY_UI_THEME_LIGHT,
value: "flexoki",
};
const nextLightUi = resolveAppearanceSyncState(current, {
...staleStored,
lightUiThemeId: "snow",
}, lightUiSelection);
assert.equal(nextLightUi.lightUiThemeId, "flexoki");
const accentModeSelection = {
key: STORAGE_KEY_ACCENT_MODE,
value: "custom",
};
const nextAccentMode = resolveAppearanceSyncState(current, {
...staleStored,
accentMode: "theme",
}, accentModeSelection);
assert.equal(nextAccentMode.accentMode, "custom");
const customAccentSelection = {
key: STORAGE_KEY_COLOR,
value: "221.2 83.2% 53.3%",
};
const nextCustomAccent = resolveAppearanceSyncState(current, {
...staleStored,
customAccent: "208 100% 50%",
}, customAccentSelection);
assert.equal(nextCustomAccent.customAccent, "221.2 83.2% 53.3%");
// Without an announced key, full rehydrate still reads storage.
const noIncoming = resolveAppearanceSyncState(current, {
...staleStored,
darkUiThemeId: "github",
});
assert.equal(noIncoming.darkUiThemeId, "github");
});
test("sequential keyed appearance IPC updates compose without stale-storage clobber", () => {
// One action changes theme + dark UI theme; the sender emits two keyed notifies.
// Storage still holds the pre-change values for the entire sequence.
const initial: AppearanceState = {
theme: "system",
lightUiThemeId: "snow",
darkUiThemeId: "midnight",
accentMode: "theme",
customAccent: "208 100% 50%",
};
const staleStored: StoredAppearanceValues = { ...initial };
let next = resolveAppearanceSyncState(initial, staleStored, {
key: STORAGE_KEY_THEME,
value: "dark",
});
assert.equal(next.theme, "dark");
assert.equal(next.darkUiThemeId, "midnight");
// Later theme-id message must not revert theme back to the stale stored System.
next = resolveAppearanceSyncState(next, staleStored, {
key: STORAGE_KEY_UI_THEME_DARK,
value: "github",
});
assert.equal(next.theme, "dark");
assert.equal(next.darkUiThemeId, "github");
assert.equal(next.lightUiThemeId, "snow");
assert.equal(next.accentMode, "theme");
});
test("System on a light OS changes to Dark in every open follow-app terminal", () => {
const initialState: AppearanceState = {
theme: systemLight.theme,
lightUiThemeId: systemLight.lightUiThemeId,
darkUiThemeId: systemLight.darkUiThemeId,
accentMode: systemLight.accentMode,
customAccent: systemLight.customAccent,
};
const terminalAppearance = (appearance: AppearanceState, resolvedTheme: "light" | "dark") => (
resolveGlobalTerminalAppearance({
userIntent: idleThemeUserIntent(),
settings: {
terminalThemeId: "netcatty-dark",
terminalThemeDarkId: "auto",
terminalThemeLightId: "auto",
followAppTerminalTheme: true,
resolvedTheme,
lightUiThemeId: appearance.lightUiThemeId,
darkUiThemeId: appearance.darkUiThemeId,
accentMode: appearance.accentMode,
customAccent: appearance.customAccent,
},
customThemes: [],
})
);
const persistRender = (
stored: StoredAppearanceValues,
previous: AppearanceRenderSnapshot,
current: AppearanceRenderSnapshot,
): StoredAppearanceValues => {
if (!hasPersistedAppearanceChanged(previous, current)) return stored;
return {
theme: current.theme,
lightUiThemeId: current.lightUiThemeId,
darkUiThemeId: current.darkUiThemeId,
accentMode: current.accentMode,
customAccent: current.customAccent,
};
};
let stored: StoredAppearanceValues = { ...initialState };
let main = { ...initialState };
let detachedTerminal = { ...initialState };
const initialMainTerminal = terminalAppearance(main, "light");
const initialDetachedTerminal = terminalAppearance(detachedTerminal, "light");
const settingsDark: AppearanceRenderSnapshot = {
...systemLight,
theme: "dark",
resolvedTheme: "dark",
};
stored = persistRender(stored, systemLight, settingsDark);
const darkSelection = { key: STORAGE_KEY_THEME, value: "dark" };
// A stale peer receives an OS color event before the Dark IPC message. Its
// semantic choice is still System, so it must not write System back.
stored = persistRender(stored, systemLight, { ...systemLight, resolvedTheme: "dark" });
// Main windows run the production IPC reducer. Deliberately pass its lagging
// System storage read so only the ordered Dark payload can win.
main = resolveAppearanceSyncState(main, {
...stored,
theme: "system",
}, darkSelection);
// Detached terminal and tray renderers run the production storage-event
// reducer because they are not direct IPC broadcast targets.
const detachedUpdate = resolveAppearanceStorageEvent(
detachedTerminal,
darkSelection.key,
String(stored.theme),
);
detachedTerminal = detachedUpdate.next;
const finalMainTerminal = terminalAppearance(main, "dark");
const finalDetachedTerminal = terminalAppearance(detachedTerminal, "dark");
assert.equal(stored.theme, "dark");
assert.equal(settingsDark.theme, "dark");
assert.equal(main.theme, "dark");
assert.equal(detachedUpdate.handled, true);
assert.equal(detachedTerminal.theme, "dark");
assert.equal(initialMainTerminal.theme.type, "light");
assert.equal(initialDetachedTerminal.theme.type, "light");
assert.equal(finalMainTerminal.theme.type, "dark");
assert.equal(finalDetachedTerminal.theme.type, "dark");
assert.notEqual(finalMainTerminal.theme.colors.background, initialMainTerminal.theme.colors.background);
assert.notEqual(finalDetachedTerminal.theme.colors.background, initialDetachedTerminal.theme.colors.background);
});
test("the race guards are wired into the real settings paths", () => {
const stateSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
const ipcSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
const storageSource = readFileSync(new URL("./settingsStorageSync.ts", import.meta.url), "utf8");
const popupSource = readFileSync(new URL("../../components/TerminalPopupPage.tsx", import.meta.url), "utf8");
const guardIndex = stateSource.indexOf("hasPersistedAppearanceChanged(");
const returnIndex = stateSource.indexOf("if (!persistedAppearanceChanged && persistMountedRef.current)", guardIndex);
const writeIndex = stateSource.indexOf("localStorageAdapter.writeString(STORAGE_KEY_THEME", guardIndex);
const themeNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_THEME, theme)", writeIndex);
const lightNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId)", writeIndex);
const darkNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId)", writeIndex);
const accentModeNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode)", writeIndex);
const colorNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_COLOR, customAccent)", writeIndex);
assert.ok(guardIndex >= 0, "the settings effect must compare persisted appearance fields");
assert.ok(returnIndex > guardIndex && returnIndex < writeIndex, "the stale render must stop before storage is written");
assert.ok(themeNotifyIndex > writeIndex, "theme changes must be announced over IPC with the new value");
assert.ok(lightNotifyIndex > writeIndex, "light UI theme changes must be announced over IPC with the new value");
assert.ok(darkNotifyIndex > writeIndex, "dark UI theme changes must be announced over IPC with the new value");
assert.ok(accentModeNotifyIndex > writeIndex, "accent mode changes must be announced over IPC with the new value");
assert.ok(colorNotifyIndex > writeIndex, "custom accent changes must be announced over IPC with the new value");
// Source still only notifies fields that actually changed (keyed, not a single theme-only broadcast).
assert.match(
stateSource,
/previousAppearance\.theme !== theme[\s\S]*notifySettingsChanged\(STORAGE_KEY_THEME, theme\)/,
);
assert.match(
stateSource,
/previousAppearance\.lightUiThemeId !== lightUiThemeId[\s\S]*notifySettingsChanged\(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId\)/,
);
assert.match(
stateSource,
/previousAppearance\.darkUiThemeId !== darkUiThemeId[\s\S]*notifySettingsChanged\(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId\)/,
);
// Sequential keyed IPC must compose via a ref, not a render-stale closure.
assert.match(stateSource, /appearanceStateRef\.current = nextAppearance/);
assert.match(ipcSource, /syncAppearanceFromStorage\(\{ key, value \}\)/);
assert.match(storageSource, /resolveAppearanceStorageEvent\(s, e\.key, e\.newValue\)/);
assert.match(popupSource, /terminalTheme=\{settings\.currentTerminalTheme\}/);
assert.match(popupSource, /followAppTerminalTheme=\{settings\.followAppTerminalTheme\}/);
});
+176
View File
@@ -0,0 +1,176 @@
import {
STORAGE_KEY_ACCENT_MODE,
STORAGE_KEY_COLOR,
STORAGE_KEY_THEME,
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_UI_THEME_LIGHT,
} from '../../infrastructure/config/storageKeys';
import {
isValidHslToken,
isValidTheme,
isValidUiThemeId,
} from './settingsStateDefaults';
export type AppearanceState = {
theme: "light" | "dark" | "system";
lightUiThemeId: string;
darkUiThemeId: string;
accentMode: "theme" | "custom";
customAccent: string;
};
export type AppearanceRenderSnapshot = {
theme: AppearanceState["theme"];
resolvedTheme: "light" | "dark";
} & Omit<AppearanceState, "theme">;
export type AppearanceSyncEvent = {
key: string;
value: unknown;
};
export type StoredAppearanceValues = {
theme: unknown;
lightUiThemeId: unknown;
darkUiThemeId: unknown;
accentMode: unknown;
customAccent: unknown;
};
export type AppearanceStorageEventResolution = {
handled: boolean;
next: AppearanceState;
};
export function hasPersistedAppearanceChanged(
previous: AppearanceRenderSnapshot,
current: AppearanceRenderSnapshot,
): boolean {
return previous.theme !== current.theme
|| previous.lightUiThemeId !== current.lightUiThemeId
|| previous.darkUiThemeId !== current.darkUiThemeId
|| previous.accentMode !== current.accentMode
|| previous.customAccent !== current.customAccent;
}
export function resolveIncomingAppearanceValue<T>(
incoming: AppearanceSyncEvent | undefined,
key: string,
storedValue: T,
currentValue: T,
isValid: (value: unknown) => value is T,
): T {
if (incoming?.key === key && isValid(incoming.value)) {
return incoming.value;
}
// Keyed IPC updates only trust the announced key. Non-matching fields keep
// the in-memory current value so sequential notifies for one multi-field
// change cannot clobber each other with a still-stale storage read.
// Full rehydrate (no incoming) continues to prefer shared storage.
if (incoming) {
return currentValue;
}
return storedValue;
}
export function resolveAppearanceSyncState(
current: AppearanceState,
stored: StoredAppearanceValues,
incoming?: AppearanceSyncEvent,
): AppearanceState {
const theme = resolveIncomingAppearanceValue(
incoming,
STORAGE_KEY_THEME,
stored.theme,
current.theme,
isValidTheme,
);
const lightUiThemeId = resolveIncomingAppearanceValue(
incoming,
STORAGE_KEY_UI_THEME_LIGHT,
stored.lightUiThemeId,
current.lightUiThemeId,
(value): value is string => typeof value === 'string' && isValidUiThemeId('light', value),
);
const darkUiThemeId = resolveIncomingAppearanceValue(
incoming,
STORAGE_KEY_UI_THEME_DARK,
stored.darkUiThemeId,
current.darkUiThemeId,
(value): value is string => typeof value === 'string' && isValidUiThemeId('dark', value),
);
const accentMode = resolveIncomingAppearanceValue(
incoming,
STORAGE_KEY_ACCENT_MODE,
stored.accentMode,
current.accentMode,
(value): value is AppearanceState['accentMode'] => value === 'theme' || value === 'custom',
);
const customAccent = resolveIncomingAppearanceValue(
incoming,
STORAGE_KEY_COLOR,
stored.customAccent,
current.customAccent,
(value): value is string => typeof value === 'string' && isValidHslToken(value),
);
return {
theme: isValidTheme(theme) ? theme : current.theme,
lightUiThemeId: typeof lightUiThemeId === 'string' && isValidUiThemeId('light', lightUiThemeId)
? lightUiThemeId
: current.lightUiThemeId,
darkUiThemeId: typeof darkUiThemeId === 'string' && isValidUiThemeId('dark', darkUiThemeId)
? darkUiThemeId
: current.darkUiThemeId,
accentMode: accentMode === 'theme' || accentMode === 'custom' ? accentMode : current.accentMode,
customAccent: typeof customAccent === 'string' && isValidHslToken(customAccent)
? customAccent.trim()
: current.customAccent,
};
}
export function resolveAppearanceStorageEvent(
current: AppearanceState,
key: string | null,
newValue: string | null,
): AppearanceStorageEventResolution {
if (key === STORAGE_KEY_THEME) {
return {
handled: true,
next: newValue && isValidTheme(newValue) ? { ...current, theme: newValue } : current,
};
}
if (key === STORAGE_KEY_UI_THEME_LIGHT) {
return {
handled: true,
next: newValue && isValidUiThemeId('light', newValue)
? { ...current, lightUiThemeId: newValue }
: current,
};
}
if (key === STORAGE_KEY_UI_THEME_DARK) {
return {
handled: true,
next: newValue && isValidUiThemeId('dark', newValue)
? { ...current, darkUiThemeId: newValue }
: current,
};
}
if (key === STORAGE_KEY_ACCENT_MODE) {
return {
handled: true,
next: newValue === 'theme' || newValue === 'custom'
? { ...current, accentMode: newValue }
: current,
};
}
if (key === STORAGE_KEY_COLOR) {
return {
handled: true,
next: newValue && isValidHslToken(newValue)
? { ...current, customAccent: newValue.trim() }
: current,
};
}
return { handled: false, next: current };
}
-14
View File
@@ -142,14 +142,6 @@ export const customThemeStore = new CustomThemeStore();
// ============== Hooks ==============
/** Get all themes (built-in + custom) */
export const useAllThemes = (): TerminalTheme[] => {
return useSyncExternalStore(
customThemeStore.subscribe,
customThemeStore.getAllThemes
);
};
/** Get custom themes only */
export const useCustomThemes = (): TerminalTheme[] => {
return useSyncExternalStore(
@@ -158,12 +150,6 @@ export const useCustomThemes = (): TerminalTheme[] => {
);
};
/** Get theme by ID (built-in or custom) with fallback */
export const useThemeById = (id: string): TerminalTheme => {
const allThemes = useAllThemes();
return allThemes.find(t => t.id === id) || TERMINAL_THEMES[0];
};
/** Theme mutation actions */
export const useCustomThemeActions = () => {
const addTheme = useCallback((theme: TerminalTheme) => {
-18
View File
@@ -134,24 +134,6 @@ export const useAvailableFonts = (): TerminalFont[] => {
);
};
/**
* Get font loading state
*/
export const useFontsLoading = (): boolean => {
return useSyncExternalStore(
fontStore.subscribe,
fontStore.getIsLoading
);
};
/**
* Get font by ID with fallback - useful for components that need a specific font
*/
export const useFontById = (fontId: string): TerminalFont => {
const fonts = useAvailableFonts();
return fonts.find(f => f.id === fontId) || fonts[0] || TERMINAL_FONTS[0];
};
/**
* Initialize fonts eagerly (call at app startup)
*/
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
setScriptRuns,
waitForScriptRun,
} from './scriptAutomationCoordinator.ts';
test('waitForScriptRun resolves when run is already completed on subscribe', async () => {
const runId = 'run-already-done';
setScriptRuns([{
runId,
scriptId: 's1',
sessionId: 'sess1',
status: 'completed',
startedAt: Date.now() - 1000,
endedAt: Date.now(),
logs: [],
}]);
const run = await waitForScriptRun(runId, { timeoutMs: 5000 });
assert.equal(run.runId, runId);
assert.equal(run.status, 'completed');
});
test('waitForScriptRun rejects when run already failed on subscribe', async () => {
const runId = 'run-already-failed';
setScriptRuns([{
runId,
sessionId: 'sess1',
status: 'failed',
startedAt: Date.now() - 1000,
endedAt: Date.now(),
error: 'boom',
logs: [],
}]);
await assert.rejects(
() => waitForScriptRun(runId, { timeoutMs: 5000 }),
/boom/,
);
});
@@ -0,0 +1,192 @@
import type { Snippet } from '@/domain/models';
import { isScriptSnippet, scriptContainsWriteOperations } from '@/domain/snippetScript.ts';
import { localStorageAdapter } from '@/infrastructure/persistence/localStorageAdapter.ts';
import { STORAGE_KEY_AI_PERMISSION_MODE } from '@/infrastructure/config/storageKeys.ts';
import type { AIPermissionMode } from '@/infrastructure/ai/types.ts';
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
type RunsListener = (runs: ScriptRun[]) => void;
let runs: ScriptRun[] = [];
const runsListeners = new Set<RunsListener>();
function readPermissionMode(): AIPermissionMode {
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
if (stored === 'observer' || stored === 'confirm' || stored === 'auto') return stored;
return 'confirm';
}
export function subscribeScriptRuns(listener: RunsListener): () => void {
runsListeners.add(listener);
queueMicrotask(() => {
if (runsListeners.has(listener)) {
listener(runs);
}
});
return () => runsListeners.delete(listener);
}
export function setScriptRuns(nextRuns: ScriptRun[]) {
runs = nextRuns;
runsListeners.forEach((listener) => listener(runs));
}
export function getActiveScriptRunForSession(sessionId: string): ScriptRun | undefined {
return runs.find((run) =>
run.sessionId === sessionId && (run.status === 'running' || run.status === 'paused'),
);
}
export async function runAutomationScript(params: {
snippet: Snippet;
sessionId: string;
sessionIds?: string[];
mode?: 'sequential' | 'parallel';
sessionMeta?: {
connected?: boolean;
hostname?: string;
username?: string;
};
}): Promise<{ runId: string; runIds: string[] }> {
const permissionMode = readPermissionMode();
if (permissionMode === 'observer' && scriptContainsWriteOperations(params.snippet.command)) {
throw new Error('Observer mode blocks scripts that write to the terminal.');
}
const bridge = netcattyBridge.get();
if (!bridge?.scriptRun) {
throw new Error('Script bridge unavailable');
}
return bridge.scriptRun({
scriptId: params.snippet.id,
scriptLabel: params.snippet.label,
content: params.snippet.command,
sessionId: params.sessionId,
sessionIds: params.sessionIds,
mode: params.mode,
permissionMode,
sessionMeta: params.sessionMeta,
});
}
const TERMINAL_SCRIPT_STATUSES = new Set<ScriptRun['status']>(['completed', 'failed']);
export function waitForScriptRun(
runId: string,
options: { signal?: AbortSignal; timeoutMs?: number } = {},
): Promise<ScriptRun> {
const existing = runs.find((entry) => entry.runId === runId);
if (existing && TERMINAL_SCRIPT_STATUSES.has(existing.status)) {
if (existing.status === 'completed') {
return Promise.resolve(existing);
}
return Promise.reject(new Error(existing.error || 'Script failed'));
}
const timeoutMs = options.timeoutMs ?? 3_600_000;
return new Promise((resolve, reject) => {
let settled = false;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
let unsubscribe: () => void = () => {};
const finish = (handler: () => void) => {
if (settled) return;
settled = true;
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
unsubscribe();
options.signal?.removeEventListener('abort', onAbort);
handler();
};
const onAbort = () => {
finish(() => reject(new Error('Aborted')));
};
const settleRun = (run: ScriptRun | undefined) => {
if (!run || !TERMINAL_SCRIPT_STATUSES.has(run.status)) return;
if (run.status === 'completed') {
finish(() => resolve(run));
return;
}
finish(() => reject(new Error(run.error || 'Script failed')));
};
unsubscribe = subscribeScriptRuns((currentRuns) => {
settleRun(currentRuns.find((entry) => entry.runId === runId));
});
timeoutId = setTimeout(() => {
finish(() => reject(new Error('Script run timed out')));
}, timeoutMs);
options.signal?.addEventListener('abort', onAbort, { once: true });
});
}
export async function runConnectScriptsSequential(params: {
scripts: Snippet[];
sessionId: string;
signal?: AbortSignal;
onScriptStart?: (snippet: Snippet) => void;
onScriptComplete?: (snippet: Snippet) => void;
sessionMeta?: {
connected?: boolean;
hostname?: string;
username?: string;
};
}): Promise<void> {
for (const snippet of params.scripts) {
if (params.signal?.aborted) {
throw new Error('Aborted');
}
params.onScriptStart?.(snippet);
const { runId } = await runAutomationScript({
snippet,
sessionId: params.sessionId,
sessionMeta: params.sessionMeta,
});
await waitForScriptRun(runId, { signal: params.signal });
params.onScriptComplete?.(snippet);
}
}
export async function runSnippetOrScript(params: {
snippet: Snippet;
sessionId: string;
runSnippetText: (
command: string,
noAutoRun?: boolean,
options?: { multiLineRunMode?: Snippet["multiLineRunMode"] },
) => void;
command: string;
}) {
if (isScriptSnippet(params.snippet)) {
await runAutomationScript({
snippet: params.snippet,
sessionId: params.sessionId,
});
return;
}
params.runSnippetText(params.command, params.snippet.noAutoRun, {
multiLineRunMode: params.snippet.multiLineRunMode,
});
}
export async function stopScriptRun(runId: string): Promise<{ ok: boolean }> {
const result = await netcattyBridge.get()?.scriptStop?.(runId);
return { ok: result?.ok !== false };
}
export async function pauseScriptRun(runId: string): Promise<{ ok: boolean }> {
const result = await netcattyBridge.get()?.scriptPause?.(runId);
return { ok: result?.ok !== false };
}
export async function resumeScriptRun(runId: string): Promise<{ ok: boolean }> {
const result = await netcattyBridge.get()?.scriptResume?.(runId);
return { ok: result?.ok !== false };
}
+30
View File
@@ -0,0 +1,30 @@
type ScriptRecordingSnapshot = {
sessionId: string | null;
isPaused: boolean;
};
type Listener = () => void;
let snapshot: ScriptRecordingSnapshot = { sessionId: null, isPaused: false };
const listeners = new Set<Listener>();
function emit() {
for (const listener of listeners) {
listener();
}
}
export function getScriptRecordingSnapshot(): ScriptRecordingSnapshot {
return snapshot;
}
export function subscribeScriptRecording(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function setScriptRecordingState(sessionId: string | null, isPaused = false): void {
if (snapshot.sessionId === sessionId && snapshot.isPaused === isPaused) return;
snapshot = { sessionId, isPaused };
emit();
}
@@ -0,0 +1,17 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
LOCAL_TERMINAL_HOST_ID,
createLocalTerminalSession,
} from "./sessionFactories.ts";
test("createLocalTerminalSession uses a stable hostId across sessions", () => {
const a = createLocalTerminalSession("session-a");
const b = createLocalTerminalSession("session-b");
assert.equal(a.hostId, LOCAL_TERMINAL_HOST_ID);
assert.equal(b.hostId, LOCAL_TERMINAL_HOST_ID);
assert.notEqual(a.id, b.id);
assert.equal(a.protocol, "local");
});
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { Host } from "../../domain/models";
import { buildTelnetDeepLinkConnectionHost } from "../../domain/telnetDeepLink";
import { createHostTerminalSession } from "./sessionFactories";
const host = (overrides: Partial<Host>): Host => ({
id: "host-1",
label: "Host",
hostname: "example.com",
username: "alice",
port: 22,
group: "",
tags: [],
os: "linux",
protocol: "ssh",
createdAt: 1,
...overrides,
});
test("createHostTerminalSession keeps telnet deep-link default port for ssh hosts with telnet enabled", () => {
const connectionHost = buildTelnetDeepLinkConnectionHost(
host({
protocol: "ssh",
telnetEnabled: true,
telnetPort: undefined,
}),
);
const session = createHostTerminalSession("session-1", connectionHost);
assert.equal(session.protocol, "telnet");
assert.equal(session.port, 23);
});
+14 -1
View File
@@ -6,14 +6,24 @@ export interface LocalTerminalOptions {
shellArgs?: string[];
shellName?: string;
shellIcon?: string;
localStartDir?: string;
}
/**
* Stable hostId for all Local Terminal sessions.
*
* Autocomplete command history is keyed by hostId. Using `local-${sessionId}`
* made every new Local Terminal look like a brand-new host, so history
* suggestions never accumulated across opens (issue #2037).
*/
export const LOCAL_TERMINAL_HOST_ID = "local-terminal";
export const createLocalTerminalSession = (
sessionId: string,
options?: LocalTerminalOptions,
): TerminalSession => ({
id: sessionId,
hostId: `local-${sessionId}`,
hostId: LOCAL_TERMINAL_HOST_ID,
hostLabel: options?.shellName || "Local Terminal",
hostname: "localhost",
username: "local",
@@ -24,6 +34,7 @@ export const createLocalTerminalSession = (
localShellArgs: options?.shellArgs,
localShellName: options?.shellName,
localShellIcon: options?.shellIcon,
localStartDir: options?.localStartDir,
});
export const createSerialTerminalSession = (
@@ -86,5 +97,7 @@ export const createHostTerminalSession = (
moshEnabled: host.moshEnabled,
etEnabled: host.etEnabled,
charset: host.charset,
...(host.ephemeral ? { ephemeralHost: true } : {}),
...(host.autoOpenSftpPanel ? { autoOpenSidePanel: "sftp" as const } : {}),
};
};
@@ -2,9 +2,11 @@ import assert from "node:assert/strict";
import test from "node:test";
import type { TerminalSession, Workspace } from "../../domain/models";
import {
applyCloseSessionToSessions,
closeSessionWorkspaceLayoutState,
detachSessionFromWorkspaceState,
replaceDissolvedWorkspaceTabOrder,
resolveActiveTabAfterCloseSession,
} from "./sessionWorkspaceDetach";
const session = (id: string, workspaceId = "ws-1"): TerminalSession => ({
@@ -121,3 +123,57 @@ test("closing a workspace session dissolves the workspace when one terminal rema
["log-1", "s2", "session-3"],
);
});
test("closing one split pane keeps the other terminal as an orphan tab", () => {
const layoutResult = closeSessionWorkspaceLayoutState(
[workspace(["s1", "s2"])],
"ws-1",
"s1",
);
const nextSessions = applyCloseSessionToSessions(
[session("s1"), session("s2")],
"s1",
layoutResult,
);
assert.deepEqual(nextSessions.map((s) => [s.id, s.workspaceId]), [
["s2", undefined],
]);
assert.equal(
resolveActiveTabAfterCloseSession({
currentActiveTabId: "ws-1",
closedSessionId: "s1",
workspaceId: "ws-1",
layoutResult,
remainingSessions: nextSessions,
}),
"s2",
);
});
test("closing the last workspace session does not invent a surviving terminal", () => {
const layoutResult = closeSessionWorkspaceLayoutState(
[workspace(["s1"])],
"ws-1",
"s1",
);
const nextSessions = applyCloseSessionToSessions(
[session("s1")],
"s1",
layoutResult,
);
assert.deepEqual(nextSessions, []);
assert.equal(layoutResult.removedWorkspaceId, "ws-1");
assert.equal(layoutResult.lastRemainingSessionId, undefined);
assert.equal(
resolveActiveTabAfterCloseSession({
currentActiveTabId: "ws-1",
closedSessionId: "s1",
workspaceId: "ws-1",
layoutResult,
remainingSessions: nextSessions,
}),
"vault",
);
});
@@ -101,6 +101,59 @@ export function closeSessionWorkspaceLayoutState(
};
}
/**
* Apply a close-session action to the session list using the layout result.
* When a 2-pane workspace dissolves to one terminal, that remaining session
* must become an orphan tab — not stay bound to a deleted workspaceId.
*/
export function applyCloseSessionToSessions(
sessions: readonly TerminalSession[],
sessionId: string,
layoutResult: Pick<CloseSessionWorkspaceLayoutResult, "lastRemainingSessionId">,
): TerminalSession[] {
const remaining = sessions.filter((session) => session.id !== sessionId);
const lastRemainingSessionId = layoutResult.lastRemainingSessionId;
if (!lastRemainingSessionId) return remaining;
return remaining.map((session) => (
session.id === lastRemainingSessionId
? { ...session, workspaceId: undefined }
: session
));
}
export function resolveActiveTabAfterCloseSession({
currentActiveTabId,
closedSessionId,
workspaceId,
layoutResult,
remainingSessions,
}: {
currentActiveTabId: string | null;
closedSessionId: string;
workspaceId: string | undefined;
layoutResult: CloseSessionWorkspaceLayoutResult;
remainingSessions: readonly TerminalSession[];
}): string | null {
const fallbackWorkspace = layoutResult.workspaces[layoutResult.workspaces.length - 1];
const fallbackSolo = remainingSessions.filter((session) => !session.workspaceId).slice(-1)[0];
const fallback = layoutResult.lastRemainingSessionId
?? fallbackWorkspace?.id
?? fallbackSolo?.id
?? "vault";
if (
currentActiveTabId === closedSessionId
|| (layoutResult.dissolvedWorkspaceId && currentActiveTabId === layoutResult.dissolvedWorkspaceId)
|| (layoutResult.removedWorkspaceId && currentActiveTabId === layoutResult.removedWorkspaceId)
|| (workspaceId && currentActiveTabId === workspaceId && !layoutResult.workspaces.some((ws) => ws.id === workspaceId))
) {
return fallback;
}
return null;
}
export function detachSessionFromWorkspaceState({
sessions,
workspaces,
+43 -7
View File
@@ -21,6 +21,7 @@ import {
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
STORAGE_KEY_JMS_DEEP_LINK_ENABLED,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
@@ -39,20 +40,29 @@ import {
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_APP_ICON_VARIANT,
STORAGE_KEY_HTTP_NETWORK_PROXY,
} from '../../infrastructure/config/storageKeys';
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
import {
areHttpNetworkProxySettingsEqual,
normalizeHttpNetworkProxySettings,
type HttpNetworkProxySettings,
} from '../../domain/httpNetworkProxy';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
clampWindowOpacity,
isValidUiFontId,
migrateIncomingTerminalFontId,
} from './settingsStateDefaults';
import { isTerminalSidePanelAutoOpenTab, type TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
import type { AppearanceSyncEvent } from './appearanceSync';
interface UseSettingsIpcSyncParams {
enabled?: boolean;
syncAppearanceFromStorage: () => void;
syncAppearanceFromStorage: (incoming?: AppearanceSyncEvent) => void;
syncCustomCssFromStorage: () => void;
setUiLanguage: Dispatch<SetStateAction<UILanguage>>;
setUiFontFamilyId: Dispatch<SetStateAction<string>>;
@@ -70,18 +80,22 @@ interface UseSettingsIpcSyncParams {
setSessionLogsTimestampsEnabled: Dispatch<SetStateAction<boolean>>;
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
setSshDeepLinkEnabledState: (enabled: boolean) => void;
setJmsDeepLinkEnabledState: (enabled: boolean) => void;
setHotkeyScheme: Dispatch<SetStateAction<HotkeyScheme>>;
applyIncomingCustomKeyBindings: (incoming: { bindings: CustomKeyBindings; version: number; origin: string }) => void;
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setWindowOpacity: Dispatch<SetStateAction<number>>;
setWindowOpacity: (raw: unknown) => void;
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setHttpNetworkProxy: Dispatch<SetStateAction<HttpNetworkProxySettings>>;
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
setRestoreTerminalCwdState: Dispatch<SetStateAction<boolean>>;
@@ -108,6 +122,7 @@ export function useSettingsIpcSync({
setSessionLogsTimestampsEnabled,
setSshDebugLogsEnabled,
setSshDeepLinkEnabledState,
setJmsDeepLinkEnabledState,
setHotkeyScheme,
applyIncomingCustomKeyBindings,
setIsHotkeyRecordingState,
@@ -115,11 +130,14 @@ export function useSettingsIpcSync({
setWindowOpacity,
setAppIconVariant,
setAutoUpdateEnabled,
setHttpNetworkProxy,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setWorkspaceFocusStyleState,
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
setTerminalSidePanelAutoOpenTabState,
setDisableTerminalFontZoomState,
setRestorePreviousSessionState,
setRestoreTerminalCwdState,
@@ -139,7 +157,7 @@ export function useSettingsIpcSync({
key === STORAGE_KEY_ACCENT_MODE ||
key === STORAGE_KEY_COLOR
) {
syncAppearanceFromStorage();
syncAppearanceFromStorage({ key, value });
return;
}
if (key === STORAGE_KEY_UI_LANGUAGE && typeof value === 'string') {
@@ -211,6 +229,9 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_SSH_DEEP_LINK_ENABLED && typeof value === 'boolean') {
setSshDeepLinkEnabledState(value);
}
if (key === STORAGE_KEY_JMS_DEEP_LINK_ENABLED && typeof value === 'boolean') {
setJmsDeepLinkEnabledState(value);
}
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
setHotkeyScheme(value);
}
@@ -226,9 +247,8 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && typeof value === 'boolean') {
setGlobalHotkeyEnabled((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_WINDOW_OPACITY && (typeof value === 'number' || typeof value === 'string')) {
const nextOpacity = clampWindowOpacity(value);
setWindowOpacity((prev) => (prev === nextOpacity ? prev : nextOpacity));
if (key === STORAGE_KEY_WINDOW_OPACITY) {
setWindowOpacity(value);
}
if (key === STORAGE_KEY_APP_ICON_VARIANT) {
const nextVariant = resolveAppIconVariant(value);
@@ -237,6 +257,12 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_AUTO_UPDATE_ENABLED && typeof value === 'boolean') {
setAutoUpdateEnabled((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_HTTP_NETWORK_PROXY) {
const next = normalizeHttpNetworkProxySettings(value);
setHttpNetworkProxy((prev) => (
areHttpNetworkProxySettingsEqual(prev, next) ? prev : next
));
}
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
}
@@ -254,6 +280,12 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && typeof value === 'boolean') {
setShowHostTreeSidebarState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN && typeof value === 'boolean') {
setTerminalSidePanelAutoOpenState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB && isTerminalSidePanelAutoOpenTab(value)) {
setTerminalSidePanelAutoOpenTabState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM && typeof value === 'boolean') {
setDisableTerminalFontZoomState((prev) => (prev === value ? prev : value));
}
@@ -279,6 +311,7 @@ export function useSettingsIpcSync({
applyIncomingCustomKeyBindings,
mergeIncomingTerminalSettings,
setAutoUpdateEnabled,
setHttpNetworkProxy,
setEditorWordWrapState,
setFollowAppTerminalThemeState,
setGlobalHotkeyEnabled,
@@ -291,11 +324,14 @@ export function useSettingsIpcSync({
setSessionLogsFormat,
setSessionLogsTimestampsEnabled,
setSshDeepLinkEnabledState,
setJmsDeepLinkEnabledState,
setSshDebugLogsEnabled,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
setTerminalSidePanelAutoOpenTabState,
setDisableTerminalFontZoomState,
setRestorePreviousSessionState,
setRestoreTerminalCwdState,
@@ -92,6 +92,7 @@ export const DEFAULT_SESSION_LOGS_FORMAT: SessionLogFormat = 'txt';
export const DEFAULT_SESSION_LOGS_TIMESTAMPS_ENABLED = false;
export const DEFAULT_SSH_DEBUG_LOGS_ENABLED = false;
export const DEFAULT_SSH_DEEP_LINK_ENABLED = true;
export const DEFAULT_JMS_DEEP_LINK_ENABLED = false;
export const readStoredString = (key: string): string | null => {
const raw = localStorageAdapter.readString(key);
+49 -46
View File
@@ -3,9 +3,7 @@ import type { CustomKeyBindings, HotkeyScheme, SessionLogFormat, TerminalSetting
import { parseCustomKeyBindingsStorageRecord } from '../../domain/customKeyBindings';
import { resolveSupportedLocale } from '../../infrastructure/config/i18n';
import {
STORAGE_KEY_ACCENT_MODE,
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_COLOR,
STORAGE_KEY_CUSTOM_CSS,
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
STORAGE_KEY_EDITOR_WORD_WRAP,
@@ -17,6 +15,7 @@ import {
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
STORAGE_KEY_JMS_DEEP_LINK_ENABLED,
STORAGE_KEY_RESTORE_PREVIOUS_SESSION,
STORAGE_KEY_RESTORE_TERMINAL_CWD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
@@ -31,6 +30,8 @@ import {
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
@@ -40,24 +41,19 @@ import {
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_TERM_THEME_DARK,
STORAGE_KEY_TERM_THEME_LIGHT,
STORAGE_KEY_THEME,
STORAGE_KEY_UI_FONT_FAMILY,
STORAGE_KEY_UI_LANGUAGE,
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_APP_ICON_VARIANT,
} from '../../infrastructure/config/storageKeys';
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
import { resolveAppearanceStorageEvent } from './appearanceSync';
import {
clampWindowOpacity,
isValidHslToken,
isValidTheme,
isValidUiFontId,
isValidUiThemeId,
migrateIncomingTerminalFontId,
} from './settingsStateDefaults';
import { isTerminalSidePanelAutoOpenTab, type TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
interface UseSettingsStorageSyncParams {
enabled?: boolean;
@@ -85,6 +81,8 @@ interface UseSettingsStorageSyncParams {
showOnlyUngroupedHostsInRoot: boolean;
showSftpTab: boolean;
showHostTreeSidebar: boolean;
terminalSidePanelAutoOpen: boolean;
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
shellOnlyTabNumberShortcuts: boolean;
disableTerminalFontZoom: boolean;
restorePreviousSession: boolean;
@@ -96,6 +94,7 @@ interface UseSettingsStorageSyncParams {
sessionLogsTimestampsEnabled: boolean;
sshDebugLogsEnabled: boolean;
sshDeepLinkEnabled: boolean;
jmsDeepLinkEnabled: boolean;
globalHotkeyEnabled: boolean;
autoUpdateEnabled: boolean;
windowOpacity: number;
@@ -126,6 +125,8 @@ interface UseSettingsStorageSyncParams {
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
setShellOnlyTabNumberShortcutsState: Dispatch<SetStateAction<boolean>>;
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
@@ -137,8 +138,9 @@ interface UseSettingsStorageSyncParams {
setSessionLogsTimestampsEnabled: Dispatch<SetStateAction<boolean>>;
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
setSshDeepLinkEnabledState: (enabled: boolean) => void;
setJmsDeepLinkEnabledState: (enabled: boolean) => void;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setWindowOpacity: Dispatch<SetStateAction<number>>;
setWindowOpacity: (raw: unknown) => void;
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
@@ -154,8 +156,8 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
@@ -163,8 +165,8 @@ export function useSettingsStorageSync({
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState,
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
}: UseSettingsStorageSyncParams) {
@@ -177,8 +179,8 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
});
settingsSnapshotRef.current = {
@@ -187,8 +189,8 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
};
@@ -197,30 +199,14 @@ export function useSettingsStorageSync({
if (!enabled) return;
const handleStorageChange = (e: StorageEvent) => {
const s = settingsSnapshotRef.current;
if (e.key === STORAGE_KEY_THEME && e.newValue) {
if (isValidTheme(e.newValue) && e.newValue !== s.theme) {
setTheme(e.newValue);
}
}
if (e.key === STORAGE_KEY_UI_THEME_LIGHT && e.newValue) {
if (isValidUiThemeId('light', e.newValue) && e.newValue !== s.lightUiThemeId) {
setLightUiThemeId(e.newValue);
}
}
if (e.key === STORAGE_KEY_UI_THEME_DARK && e.newValue) {
if (isValidUiThemeId('dark', e.newValue) && e.newValue !== s.darkUiThemeId) {
setDarkUiThemeId(e.newValue);
}
}
if (e.key === STORAGE_KEY_ACCENT_MODE && e.newValue) {
if ((e.newValue === 'theme' || e.newValue === 'custom') && e.newValue !== s.accentMode) {
setAccentMode(e.newValue);
}
}
if (e.key === STORAGE_KEY_COLOR && e.newValue) {
if (isValidHslToken(e.newValue) && e.newValue !== s.customAccent) {
setCustomAccent(e.newValue.trim());
}
const appearance = resolveAppearanceStorageEvent(s, e.key, e.newValue);
if (appearance.handled) {
if (appearance.next.theme !== s.theme) setTheme(appearance.next.theme);
if (appearance.next.lightUiThemeId !== s.lightUiThemeId) setLightUiThemeId(appearance.next.lightUiThemeId);
if (appearance.next.darkUiThemeId !== s.darkUiThemeId) setDarkUiThemeId(appearance.next.darkUiThemeId);
if (appearance.next.accentMode !== s.accentMode) setAccentMode(appearance.next.accentMode);
if (appearance.next.customAccent !== s.customAccent) setCustomAccent(appearance.next.customAccent);
return;
}
if (e.key === STORAGE_KEY_CUSTOM_CSS && e.newValue !== null) {
if (e.newValue !== s.customCSS) {
@@ -358,6 +344,12 @@ export function useSettingsStorageSync({
setSshDeepLinkEnabledState(newValue);
}
}
if (e.key === STORAGE_KEY_JMS_DEEP_LINK_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.jmsDeepLinkEnabled) {
setJmsDeepLinkEnabledState(newValue);
}
}
// Sync SFTP compressed upload setting from other windows
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
@@ -408,6 +400,17 @@ export function useSettingsStorageSync({
setShowHostTreeSidebarState(newValue);
}
}
if (e.key === STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.terminalSidePanelAutoOpen) {
setTerminalSidePanelAutoOpenState(newValue);
}
}
if (e.key === STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB && e.newValue !== null) {
if (isTerminalSidePanelAutoOpenTab(e.newValue) && e.newValue !== s.terminalSidePanelAutoOpenTab) {
setTerminalSidePanelAutoOpenTabState(e.newValue);
}
}
if (e.key === STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.shellOnlyTabNumberShortcuts) {
@@ -447,10 +450,7 @@ export function useSettingsStorageSync({
}
}
if (e.key === STORAGE_KEY_WINDOW_OPACITY && e.newValue !== null) {
const newValue = clampWindowOpacity(e.newValue);
if (newValue !== s.windowOpacity) {
setWindowOpacity(newValue);
}
setWindowOpacity(e.newValue);
}
if (e.key === STORAGE_KEY_APP_ICON_VARIANT && e.newValue !== null) {
const newValue = resolveAppIconVariant(e.newValue);
@@ -496,6 +496,7 @@ export function useSettingsStorageSync({
setSessionLogsFormat,
setSessionLogsTimestampsEnabled,
setSshDeepLinkEnabledState,
setJmsDeepLinkEnabledState,
setSshDebugLogsEnabled,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
@@ -507,6 +508,8 @@ export function useSettingsStorageSync({
setSftpUseCompressedUpload,
setShowOnlyUngroupedHostsInRootState,
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
setTerminalSidePanelAutoOpenTabState,
setShowRecentHostsState,
setShowSftpTabState,
setShellOnlyTabNumberShortcutsState,
@@ -1,48 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveCurrentTerminalTheme } from "./settingsTerminalTheme";
const commonArgs = {
terminalThemeId: "netcatty-dark",
customThemes: [],
resolvedTheme: "dark" as const,
lightUiThemeId: "snow",
darkUiThemeId: "github",
accentMode: "theme" as const,
customAccent: "",
};
test("follow-app terminal theme ignores manual per-mode terminal picks", () => {
const theme = resolveCurrentTerminalTheme({
...commonArgs,
followAppTerminalTheme: true,
terminalThemeDarkId: "dracula",
terminalThemeLightId: "solarized-light",
});
assert.equal(theme.id, "system-github-dark");
});
test("manual terminal theme uses the active per-mode pick", () => {
const theme = resolveCurrentTerminalTheme({
...commonArgs,
followAppTerminalTheme: false,
terminalThemeDarkId: "dracula",
terminalThemeLightId: "solarized-light",
});
assert.equal(theme.id, "dracula");
});
test("manual terminal theme preserves the saved global theme until a per-mode pick exists", () => {
const theme = resolveCurrentTerminalTheme({
...commonArgs,
terminalThemeId: "dracula",
followAppTerminalTheme: false,
terminalThemeDarkId: "auto",
terminalThemeLightId: "auto",
});
assert.equal(theme.id, "dracula");
});
@@ -1,61 +0,0 @@
import type { TerminalTheme } from '../../domain/models';
import { TERMINAL_THEMES, getBuiltinTerminalThemeById } from '../../infrastructure/config/terminalThemes';
import {
applyCustomAccentToTerminalTheme,
resolveFollowedTerminalThemeId,
resolveManualTerminalThemeId,
} from '../../domain/terminalAppearance';
interface ResolveCurrentTerminalThemeParams {
terminalThemeId: string;
terminalThemeDarkId: string;
terminalThemeLightId: string;
customThemes: TerminalTheme[];
followAppTerminalTheme: boolean;
resolvedTheme: 'light' | 'dark';
lightUiThemeId: string;
darkUiThemeId: string;
accentMode: 'theme' | 'custom';
customAccent: string;
}
export function resolveCurrentTerminalTheme({
terminalThemeId,
terminalThemeDarkId,
terminalThemeLightId,
customThemes,
followAppTerminalTheme,
resolvedTheme,
lightUiThemeId,
darkUiThemeId,
accentMode,
customAccent,
}: ResolveCurrentTerminalThemeParams): TerminalTheme {
if (followAppTerminalTheme) {
const followedId = resolveFollowedTerminalThemeId({
resolvedTheme,
lightUiThemeId,
darkUiThemeId,
fallbackThemeId: terminalThemeId,
});
const followed = getBuiltinTerminalThemeById(followedId)
|| customThemes.find(t => t.id === followedId);
if (followed) {
return applyCustomAccentToTerminalTheme(followed, accentMode, customAccent);
}
}
const manualThemeId = resolveManualTerminalThemeId({
resolvedTheme,
terminalThemeDarkId,
terminalThemeLightId,
lightUiThemeId,
darkUiThemeId,
fallbackThemeId: terminalThemeId,
});
const baseTheme = getBuiltinTerminalThemeById(manualThemeId)
|| customThemes.find(t => t.id === manualThemeId)
|| getBuiltinTerminalThemeById(terminalThemeId)
|| customThemes.find(t => t.id === terminalThemeId)
|| TERMINAL_THEMES[0];
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}
@@ -0,0 +1,122 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
getSftpCurrentPathMemoryKey,
getSftpReopenMemoryKey,
resolveSftpOpenLocation,
} from "./sftpReopenLocation.ts";
test("first open of a terminal lands on the terminal cwd", () => {
const location = resolveSftpOpenLocation({
hostId: "host-1",
connectionKey: "host-1:server-a:22:ssh::deploy",
terminalCwd: "/home/deploy",
remembered: null,
});
assert.equal(location, "/home/deploy");
});
test("reopening the same terminal restores the last browsed path", () => {
const location = resolveSftpOpenLocation({
hostId: "host-1",
connectionKey: "host-1:server-a:22:ssh::deploy",
terminalCwd: "/home/deploy",
remembered: {
hostId: "host-1",
connectionKey: "host-1:server-a:22:ssh::deploy",
path: "/home/deploy/projects/app",
},
});
assert.equal(location, "/home/deploy/projects/app");
});
test("remembered path for a different host falls back to terminal cwd", () => {
const location = resolveSftpOpenLocation({
hostId: "host-2",
connectionKey: "host-2:server-b:22:ssh::root",
terminalCwd: "/srv",
remembered: {
hostId: "host-1",
connectionKey: "host-1:server-a:22:ssh::deploy",
path: "/home/deploy/projects/app",
},
});
assert.equal(location, "/srv");
});
test("explicit upload target wins over remembered path", () => {
const location = resolveSftpOpenLocation({
hostId: "host-1",
connectionKey: "host-1:server-a:22:ssh::deploy",
terminalCwd: "/home/deploy",
explicitTargetPath: "/tmp/upload-here",
hasPendingUpload: true,
remembered: {
hostId: "host-1",
connectionKey: "host-1:server-a:22:ssh::deploy",
path: "/home/deploy/projects/app",
},
});
assert.equal(location, "/tmp/upload-here");
});
test("untargeted upload does not inherit a remembered reopen path", () => {
const location = resolveSftpOpenLocation({
hostId: "host-1",
connectionKey: "host-1:server-a:22:ssh::deploy",
terminalCwd: undefined,
hasPendingUpload: true,
remembered: {
hostId: "host-1",
connectionKey: "host-1:server-a:22:ssh::deploy",
path: "/home/deploy/projects/app",
},
});
assert.equal(location, undefined);
});
test("remembered path for the same saved host but different endpoint falls back to terminal cwd", () => {
const location = resolveSftpOpenLocation({
hostId: "host-1",
connectionKey: "host-1:server-a:2200:ssh::root",
terminalCwd: "/srv/root",
remembered: {
hostId: "host-1",
connectionKey: "host-1:server-a:22:ssh::deploy",
path: "/home/deploy/projects/app",
},
});
assert.equal(location, "/srv/root");
});
test("workspace sftp memory is keyed by source session rather than workspace tab", () => {
const workspaceTabId = "workspace-1";
assert.equal(
getSftpReopenMemoryKey({ tabId: workspaceTabId, sourceSessionId: "session-a" }),
"session-a",
);
assert.equal(
getSftpReopenMemoryKey({ tabId: workspaceTabId, sourceSessionId: "session-b" }),
"session-b",
);
assert.equal(getSftpReopenMemoryKey({ tabId: "terminal-tab-1" }), "terminal-tab-1");
});
test("path changes for non-reusable sessions still use the focused terminal session", () => {
assert.equal(
getSftpCurrentPathMemoryKey({
tabId: "workspace-1",
activeTerminalSessionIdForSftp: null,
focusedSessionId: "mosh-session-1",
}),
"mosh-session-1",
);
});
@@ -0,0 +1,50 @@
export interface SftpRememberedLocation {
hostId: string;
connectionKey: string;
path: string;
}
export function getSftpReopenMemoryKey(params: {
tabId: string;
sourceSessionId?: string | null;
}): string {
return params.sourceSessionId || params.tabId;
}
export function getSftpCurrentPathMemoryKey(params: {
tabId: string;
activeTerminalSessionIdForSftp?: string | null;
focusedSessionId?: string | null;
}): string {
return params.activeTerminalSessionIdForSftp || params.focusedSessionId || params.tabId;
}
export function resolveSftpOpenLocation(params: {
hostId: string;
connectionKey: string;
terminalCwd?: string;
explicitTargetPath?: string;
hasPendingUpload?: boolean;
remembered?: SftpRememberedLocation | null;
}): string | undefined {
const { hostId, connectionKey, terminalCwd, explicitTargetPath, hasPendingUpload, remembered } = params;
if (explicitTargetPath) {
return explicitTargetPath;
}
if (hasPendingUpload) {
return terminalCwd && terminalCwd.length > 0 ? terminalCwd : undefined;
}
if (
remembered &&
remembered.hostId === hostId &&
remembered.connectionKey === connectionKey &&
remembered.path
) {
return remembered.path;
}
return terminalCwd && terminalCwd.length > 0 ? terminalCwd : undefined;
}
+54 -33
View File
@@ -4,7 +4,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
import { useSftpHostCredentials } from "./useSftpHostCredentials";
import { buildSftpReuseCredentials, useSftpHostCredentials } from "./useSftpHostCredentials";
import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
import { resolveRemoteSftpStartState } from "./sftpConnectStartPath";
@@ -421,7 +421,6 @@ export const useSftpConnections = ({
}
try {
const credentials = getHostCredentials(host);
const openSftp = bridge?.openSftp;
if (!openSftp) throw new Error("SFTP bridge unavailable");
@@ -436,47 +435,69 @@ export const useSftpConnections = ({
);
};
const hasKey = !!credentials.privateKey || !!credentials.identityFilePaths?.length;
const hasPassword = !!credentials.password;
let credentials: NetcattySSHOptions | null = null;
let sftpId: string | undefined;
if (hasKey) {
// Live Connected rows: try reuse with endpoint-only options first so
// missing/undecryptable vault credentials do not block an already-
// authenticated terminal session. Fall back to full credentials if
// reuse fails (bridge may also fall through to a fresh connect).
if (options?.sourceSessionId && !host.sftpSudo) {
const reuseCredentials = buildSftpReuseCredentials(host, options.sourceSessionId);
try {
const keyFirstCredentials = {
sftpId = await openSftp({
sessionId: sftpSessionId,
...credentials,
sourceSessionId: options?.sourceSessionId,
};
if (!credentials.sudo) {
keyFirstCredentials.password = undefined;
}
sftpId = await openSftp(keyFirstCredentials);
} catch (err) {
if (hasPassword && isAuthError(err)) {
sftpId = await openSftp({
...reuseCredentials,
});
credentials = reuseCredentials;
} catch {
sftpId = undefined;
}
}
if (!sftpId) {
credentials = getHostCredentials(host);
const hasKey = !!credentials.privateKey || !!credentials.identityFilePaths?.length;
const hasPassword = !!credentials.password;
if (hasKey) {
try {
const keyFirstCredentials = {
sessionId: sftpSessionId,
...credentials,
sourceSessionId: options?.sourceSessionId,
privateKey: undefined,
certificate: undefined,
publicKey: undefined,
keyId: undefined,
keySource: undefined,
identityFilePaths: undefined,
});
} else {
throw err;
};
if (!credentials.sudo) {
keyFirstCredentials.password = undefined;
}
sftpId = await openSftp(keyFirstCredentials);
} catch (err) {
if (hasPassword && isAuthError(err)) {
sftpId = await openSftp({
sessionId: sftpSessionId,
...credentials,
sourceSessionId: options?.sourceSessionId,
privateKey: undefined,
certificate: undefined,
publicKey: undefined,
keyId: undefined,
keySource: undefined,
identityFilePaths: undefined,
});
} else {
throw err;
}
}
} else {
sftpId = await openSftp({
sessionId: sftpSessionId,
...credentials,
sourceSessionId: options?.sourceSessionId,
});
}
} else {
sftpId = await openSftp({
sessionId: sftpSessionId,
...credentials,
sourceSessionId: options?.sourceSessionId,
});
}
if (!sftpId) throw new Error("Failed to open SFTP session");
if (!sftpId || !credentials) throw new Error("Failed to open SFTP session");
sftpSessionsRef.current.set(connectionId, sftpId);
if (!isTargetConnectionCurrent()) {
@@ -1,8 +1,11 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildSftpHostCredentials } from "./useSftpHostCredentials.ts";
import type { Host, KnownHost, SSHKey } from "../../../domain/models.ts";
import {
buildSftpHostCredentials,
buildSftpReuseCredentials,
} from "./useSftpHostCredentials.ts";
import type { Host, Identity, KnownHost, SSHKey } from "../../../domain/models.ts";
const host = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
@@ -14,6 +17,91 @@ const host = (overrides: Partial<Host> = {}): Host => ({
...overrides,
});
test("buildSftpReuseCredentials only needs the live endpoint and sourceSessionId", () => {
const credentials = buildSftpReuseCredentials(
host({ hostname: "live.example.com", username: "alice", port: 2222 }),
"session-live",
);
assert.deepEqual(credentials, {
hostname: "live.example.com",
username: "alice",
port: 2222,
sourceSessionId: "session-live",
reuseOnly: true,
sudo: false,
});
});
test("buildSftpHostCredentials forwards system agent settings for target and jump hosts", () => {
const jump = host({
id: "jump-1",
hostname: "jump.example.com",
useSshAgent: true,
identityFilePaths: ["~/.ssh/jump"],
identitiesOnly: true,
});
const credentials = buildSftpHostCredentials({
host: host({
hostChain: { hostIds: ["jump-1"] },
useSshAgent: true,
identityAgent: "$SSH_AUTH_SOCK",
identityFilePaths: ["~/.ssh/aws_root"],
identitiesOnly: true,
addKeysToAgent: "yes",
useKeychain: true,
}),
hosts: [jump],
keys: [],
identities: [],
});
assert.deepEqual(
{
useSshAgent: credentials.useSshAgent,
identityAgent: credentials.identityAgent,
identityFilePaths: credentials.identityFilePaths,
identitiesOnly: credentials.identitiesOnly,
addKeysToAgent: credentials.addKeysToAgent,
useKeychain: credentials.useKeychain,
},
{
useSshAgent: true,
identityAgent: "$SSH_AUTH_SOCK",
identityFilePaths: ["~/.ssh/aws_root"],
identitiesOnly: true,
addKeysToAgent: "yes",
useKeychain: true,
},
);
assert.equal(credentials.jumpHosts?.[0]?.useSshAgent, true);
assert.deepEqual(credentials.jumpHosts?.[0]?.identityFilePaths, ["~/.ssh/jump"]);
assert.equal(credentials.jumpHosts?.[0]?.identitiesOnly, true);
});
test("buildSftpHostCredentials forwards target and jump-host timeouts", () => {
const jumpHost = host({
id: "jump-1",
sshTcpConnectTimeoutSeconds: 75,
sshAuthReadyTimeoutSeconds: 360,
});
const credentials = buildSftpHostCredentials({
host: host({
hostChain: { hostIds: ["jump-1"] },
sshTcpConnectTimeoutSeconds: 45,
sshAuthReadyTimeoutSeconds: 300,
}),
hosts: [jumpHost],
keys: [],
identities: [],
});
assert.equal(credentials.sshTcpConnectTimeoutMs, 45_000);
assert.equal(credentials.sshAuthReadyTimeoutMs, 300_000);
assert.equal(credentials.jumpHosts?.[0]?.sshTcpConnectTimeoutMs, 75_000);
assert.equal(credentials.jumpHosts?.[0]?.sshAuthReadyTimeoutMs, 360_000);
});
test("buildSftpHostCredentials rejects missing jump hosts", () => {
assert.throws(
() => buildSftpHostCredentials({
@@ -52,6 +140,129 @@ test("buildSftpHostCredentials rejects missing saved proxy profiles on jump host
);
});
test("buildSftpHostCredentials rejects missing proxy identities", () => {
assert.throws(
() => buildSftpHostCredentials({
host: host({
proxyConfig: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "missing-identity",
},
}),
hosts: [],
keys: [],
identities: [],
}),
/Proxy identity for "Host" is missing/,
);
});
test("buildSftpHostCredentials rejects incomplete proxy identities", () => {
assert.throws(
() => buildSftpHostCredentials({
host: host({
proxyConfig: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "identity-1",
},
}),
hosts: [],
keys: [],
identities: [{
id: "identity-1",
label: "Proxy login",
username: "proxy-user",
authMethod: "password",
created: 1,
}],
}),
/Proxy identity for "Host" is incomplete/,
);
});
test("buildSftpHostCredentials rejects proxy identities with blank usernames even when passwords are encrypted", () => {
assert.throws(
() => buildSftpHostCredentials({
host: host({
proxyConfig: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "identity-1",
},
}),
hosts: [],
keys: [],
identities: [{
id: "identity-1",
label: "Proxy login",
username: " ",
authMethod: "password",
password: "enc:v1:djEwAAAA",
created: 1,
}],
}),
/Proxy identity for "Host" is incomplete/,
);
});
test("buildSftpHostCredentials rejects missing proxy identities on jump hosts", () => {
const jumpHost = host({
id: "jump-1",
label: "Jump",
proxyConfig: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "missing-identity",
},
});
assert.throws(
() => buildSftpHostCredentials({
host: host({ hostChain: { hostIds: ["jump-1"] } }),
hosts: [jumpHost],
keys: [],
identities: [],
}),
/Proxy identity for "Jump" is missing/,
);
});
test("buildSftpHostCredentials rejects incomplete proxy identities on jump hosts", () => {
const jumpHost = host({
id: "jump-1",
label: "Jump",
proxyConfig: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "identity-1",
},
});
assert.throws(
() => buildSftpHostCredentials({
host: host({ hostChain: { hostIds: ["jump-1"] } }),
hosts: [jumpHost],
keys: [],
identities: [{
id: "identity-1",
label: "Proxy login",
username: "",
authMethod: "password",
password: "enc:v1:djEwAAAA",
created: 1,
}],
}),
/Proxy identity for "Jump" is incomplete/,
);
});
test("buildSftpHostCredentials forwards custom ProxyCommand settings", () => {
const credentials = buildSftpHostCredentials({
host: host({
@@ -77,6 +288,130 @@ test("buildSftpHostCredentials forwards custom ProxyCommand settings", () => {
});
});
test("buildSftpHostCredentials resolves proxy credentials from a selected identity", () => {
const identity: Identity = {
id: "identity-1",
label: "Proxy login",
username: "proxy-user",
authMethod: "password",
password: "proxy-secret",
created: 1,
};
const credentials = buildSftpHostCredentials({
host: host({
proxyConfig: {
type: "socks5",
host: "proxy.example.com",
port: 1080,
identityId: identity.id,
},
}),
hosts: [],
keys: [],
identities: [identity],
});
assert.deepEqual(credentials.proxy, {
type: "socks5",
host: "proxy.example.com",
port: 1080,
username: "proxy-user",
password: "proxy-secret",
});
});
test("buildSftpHostCredentials rejects undecryptable proxy identity passwords", () => {
assert.throws(
() => buildSftpHostCredentials({
host: host({
proxyConfig: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "identity-1",
},
}),
hosts: [],
keys: [],
identities: [{
id: "identity-1",
label: "Proxy login",
username: "proxy-user",
authMethod: "password",
password: "enc:v1:djEwAAAA",
created: 1,
}],
}),
/Proxy credentials cannot be decrypted/,
);
});
test("buildSftpHostCredentials resolves jump host proxy credentials from a selected identity", () => {
const identity: Identity = {
id: "identity-1",
label: "Proxy login",
username: "proxy-user",
authMethod: "password",
password: "proxy-secret",
created: 1,
};
const jumpHost = host({
id: "jump-1",
label: "Jump",
proxyConfig: {
type: "socks5",
host: "jump-proxy.example.com",
port: 1080,
identityId: identity.id,
},
});
const credentials = buildSftpHostCredentials({
host: host({ hostChain: { hostIds: ["jump-1"] } }),
hosts: [jumpHost],
keys: [],
identities: [identity],
});
assert.deepEqual(credentials.jumpHosts?.[0]?.proxy, {
type: "socks5",
host: "jump-proxy.example.com",
port: 1080,
username: "proxy-user",
password: "proxy-secret",
});
});
test("buildSftpHostCredentials rejects undecryptable jump host proxy identity passwords", () => {
const jumpHost = host({
id: "jump-1",
label: "Jump",
proxyConfig: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "identity-1",
},
});
assert.throws(
() => buildSftpHostCredentials({
host: host({ hostChain: { hostIds: ["jump-1"] } }),
hosts: [jumpHost],
keys: [],
identities: [{
id: "identity-1",
label: "Proxy login",
username: "proxy-user",
authMethod: "password",
password: "enc:v1:djEwAAAA",
created: 1,
}],
}),
/Proxy credentials for jump host "Jump" cannot be decrypted/,
);
});
test("buildSftpHostCredentials passes reference keys as identity file paths", () => {
const key: SSHKey = {
id: "key-1",
@@ -248,3 +583,47 @@ test("buildSftpHostCredentials does not use stale local key paths when a selecte
/Saved credentials cannot be decrypted/,
);
});
test("buildSftpHostCredentials uses the system agent when a synced key cannot be decrypted", () => {
const key: SSHKey = {
id: "key-1",
label: "Synced key",
type: "ED25519",
publicKey: "ssh-ed25519 AAAASELECTED",
privateKey: "enc:v1:djEwAAAA",
source: "imported",
category: "key",
created: 1,
};
const credentials = buildSftpHostCredentials({
host: host({
authMethod: "key",
identityFileId: "key-1",
useSshAgent: true,
}),
hosts: [],
keys: [key],
identities: [],
});
assert.equal(credentials.useSshAgent, true);
assert.deepEqual(credentials.agentPublicKeys, ["ssh-ed25519 AAAASELECTED"]);
assert.equal(credentials.privateKey, undefined);
});
test("imported IdentityFile paths remain usable when agent login is disabled", () => {
const credentials = buildSftpHostCredentials({
host: host({
identityFilePaths: ["~/.ssh/id_work"],
useSshAgent: false,
identityAgent: "none",
}),
hosts: [],
keys: [],
identities: [],
});
assert.equal(credentials.useSshAgent, false);
assert.deepEqual(credentials.identityFilePaths, ["~/.ssh/id_work"]);
});
@@ -1,9 +1,18 @@
import { useCallback } from "react";
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from "../../../domain/models";
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
import { resolveBridgeKeyAuth, resolveHostAuth } from "../../../domain/sshAuth";
import { resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from "../../../domain/sshAuth";
import { resolveHostKeepalive } from "../../../domain/host";
import { hasUsableProxyConfig } from "../../../domain/proxyProfiles";
import { resolveHostSshConnectionTimeouts } from "../../../domain/sshConnectionTimeouts";
import {
findIncompleteProxyIdentityId,
findMissingProxyIdentityId,
formatIncompleteProxyIdentityMessage,
formatMissingProxyIdentityMessage,
hasUnreadableProxyCredential,
hasUsableProxyConfig,
resolveProxyConfigAuth,
} from "../../../domain/proxyProfiles";
// Fallback used when no global TerminalSettings are wired through (older
// call sites or tests). Matches DEFAULT_TERMINAL_SETTINGS so behavior is
@@ -22,6 +31,23 @@ interface UseSftpHostCredentialsParams {
terminalSettings?: Pick<TerminalSettings, 'verifyHostKeys' | 'keepaliveInterval' | 'keepaliveCountMax'>;
}
/**
* Minimal options for reusing a live terminal SSH connection for SFTP.
* Must not require vault password/key material — the source session is already
* authenticated. Endpoint fields must match the session for findReusableSession.
*/
export const buildSftpReuseCredentials = (
host: Pick<Host, "hostname" | "username" | "port">,
sourceSessionId: string,
): NetcattySSHOptions => ({
hostname: host.hostname,
username: host.username || "root",
port: host.port || 22,
sourceSessionId,
reuseOnly: true,
sudo: false,
});
export const buildSftpHostCredentials = ({
host,
hosts,
@@ -34,19 +60,18 @@ export const buildSftpHostCredentials = ({
if (host.proxyProfileId && !host.proxyConfig) {
throw new Error(`Saved proxy for host "${host.label || host.hostname}" is missing. Open host settings and select a valid proxy.`);
}
if (findMissingProxyIdentityId(host.proxyConfig, identities)) {
throw new Error(formatMissingProxyIdentityMessage(host.label || host.hostname));
}
if (findIncompleteProxyIdentityId(host.proxyConfig, identities)) {
throw new Error(formatIncompleteProxyIdentityMessage(host.label || host.hostname));
}
const resolved = resolveHostAuth({ host, keys, identities });
const key = resolved.key || null;
const proxyConfig = host.proxyConfig
? {
type: host.proxyConfig.type,
host: host.proxyConfig.host,
port: host.proxyConfig.port,
command: host.proxyConfig.command,
username: host.proxyConfig.username,
password: sanitizeCredentialValue(host.proxyConfig.password),
}
? resolveProxyConfigAuth(host.proxyConfig, identities)
: undefined;
let jumpHosts: NetcattyJumpHost[] | undefined;
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
@@ -58,6 +83,12 @@ export const buildSftpHostCredentials = ({
if (jumpHost.proxyProfileId && !jumpHost.proxyConfig) {
throw new Error(`Saved proxy for jump host "${jumpHost.label || jumpHost.hostname}" is missing. Open host settings and select a valid proxy.`);
}
if (findMissingProxyIdentityId(jumpHost.proxyConfig, identities)) {
throw new Error(formatMissingProxyIdentityMessage(jumpHost.label || jumpHost.hostname));
}
if (findIncompleteProxyIdentityId(jumpHost.proxyConfig, identities)) {
throw new Error(formatIncompleteProxyIdentityMessage(jumpHost.label || jumpHost.hostname));
}
return jumpHost;
}).map((jumpHost, index) => {
const jumpAuth = resolveHostAuth({
@@ -69,20 +100,21 @@ export const buildSftpHostCredentials = ({
const jumpPassword = sanitizeCredentialValue(jumpAuth.password);
const jumpKeyAuth = resolveBridgeKeyAuth({
key: jumpKey,
fallbackIdentityFilePaths: jumpAuth.authMethod === "password" || jumpAuth.keyId
fallbackIdentityFilePaths: (!jumpHost.useSshAgent && jumpAuth.authMethod === "password") || jumpAuth.keyId
? undefined
: jumpHost.identityFilePaths,
passphrase: jumpAuth.passphrase,
});
const hasJumpKeyMaterial = Boolean(jumpKeyAuth.privateKey || jumpKeyAuth.identityFilePaths?.length);
const jumpAgentAuth = resolveBridgeSshAgentAuth(jumpHost, jumpKey);
const hasJumpKeyMaterial = Boolean(
jumpAgentAuth.useSshAgent || jumpKeyAuth.privateKey || jumpKeyAuth.identityFilePaths?.length,
);
const hasConfiguredJumpProxyEndpoint =
index === 0 &&
hasUsableProxyConfig(jumpHost.proxyConfig);
if (
hasConfiguredJumpProxyEndpoint &&
jumpHost.proxyConfig?.username &&
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig.password) &&
!sanitizeCredentialValue(jumpHost.proxyConfig.password)
hasUnreadableProxyCredential(jumpHost.proxyConfig, identities)
) {
throw new Error(`Proxy credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter the proxy password.`);
}
@@ -97,6 +129,7 @@ export const buildSftpHostCredentials = ({
throw new Error(`Saved credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter them.`);
}
const hopKeepalive = resolveHostKeepalive(jumpHost, globalTerminalSettings);
const hopConnectionTimeouts = resolveHostSshConnectionTimeouts(jumpHost);
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
@@ -110,18 +143,14 @@ export const buildSftpHostCredentials = ({
keySource: jumpKey?.source,
label: jumpHost.label,
proxy: hasUsableProxyConfig(jumpHost.proxyConfig)
? {
type: jumpHost.proxyConfig.type,
host: jumpHost.proxyConfig.host,
port: jumpHost.proxyConfig.port,
command: jumpHost.proxyConfig.command,
username: jumpHost.proxyConfig.username,
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
}
? resolveProxyConfigAuth(jumpHost.proxyConfig, identities)
: undefined,
identityFilePaths: jumpKeyAuth.identityFilePaths,
...jumpAgentAuth,
keepaliveInterval: hopKeepalive.interval,
keepaliveCountMax: hopKeepalive.countMax,
sshTcpConnectTimeoutMs: hopConnectionTimeouts.tcpConnectTimeoutSeconds * 1000,
sshAuthReadyTimeoutMs: hopConnectionTimeouts.authReadyTimeoutSeconds * 1000,
verifyHostKeys: globalTerminalSettings.verifyHostKeys,
legacyAlgorithms: jumpHost.legacyAlgorithms,
skipEcdsaHostKey: jumpHost.skipEcdsaHostKey,
@@ -130,19 +159,22 @@ export const buildSftpHostCredentials = ({
});
}
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts?.[0]?.proxy;
if (usesTargetProxyForFirstHop && host.proxyConfig?.username && isEncryptedCredentialPlaceholder(host.proxyConfig.password) && !proxyConfig?.password) {
if (usesTargetProxyForFirstHop && hasUnreadableProxyCredential(host.proxyConfig, identities)) {
throw new Error("Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.");
}
const keyAuth = resolveBridgeKeyAuth({
key,
fallbackIdentityFilePaths: resolved.authMethod === "password" || resolved.keyId
fallbackIdentityFilePaths: (!host.useSshAgent && resolved.authMethod === "password") || resolved.keyId
? undefined
: host.identityFilePaths,
passphrase: resolved.passphrase,
});
const targetAgentAuth = resolveBridgeSshAgentAuth(host, key);
const password = sanitizeCredentialValue(resolved.password);
const hasKeyMaterial = Boolean(keyAuth.privateKey || keyAuth.identityFilePaths?.length);
const hasKeyMaterial = Boolean(
targetAgentAuth.useSshAgent || keyAuth.privateKey || keyAuth.identityFilePaths?.length,
);
const hasUnreadableCredential =
isEncryptedCredentialPlaceholder(resolved.password) ||
isEncryptedCredentialPlaceholder(key?.privateKey) ||
@@ -155,6 +187,7 @@ export const buildSftpHostCredentials = ({
}
const targetKeepalive = resolveHostKeepalive(host, globalTerminalSettings);
const targetConnectionTimeouts = resolveHostSshConnectionTimeouts(host);
return {
hostname: host.hostname,
username: resolved.username,
@@ -170,8 +203,11 @@ export const buildSftpHostCredentials = ({
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
sudo: host.sftpSudo,
identityFilePaths: keyAuth.identityFilePaths,
...targetAgentAuth,
keepaliveInterval: targetKeepalive.interval,
keepaliveCountMax: targetKeepalive.countMax,
sshTcpConnectTimeoutMs: targetConnectionTimeouts.tcpConnectTimeoutSeconds * 1000,
sshAuthReadyTimeoutMs: targetConnectionTimeouts.authReadyTimeoutSeconds * 1000,
knownHosts,
verifyHostKeys: globalTerminalSettings.verifyHostKeys,
// Algorithm settings — must reach the SFTP bridge or hosts that need
+63 -7
View File
@@ -43,10 +43,15 @@ interface UseSftpPaneActionsParams {
dirCacheTtlMs: number;
}
export type SftpNavigateResult = "reached" | "failed" | "aborted";
export type SftpNavigateResult = "reached" | "failed" | "aborted" | "superseded";
export type SftpNavigateOptions = {
force?: boolean;
tabId?: string;
shouldApply?: () => boolean;
};
interface UseSftpPaneActionsResult {
navigateTo: (side: "left" | "right", path: string, options?: { force?: boolean; tabId?: string }) => Promise<SftpNavigateResult>;
navigateTo: (side: "left" | "right", path: string, options?: SftpNavigateOptions) => Promise<SftpNavigateResult>;
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
navigateUp: (side: "left" | "right") => Promise<void>;
openEntry: (side: "left" | "right", entry: SftpFileEntry) => Promise<void>;
@@ -161,7 +166,7 @@ export const useSftpPaneActions = ({
async (
side: "left" | "right",
path: string,
options?: { force?: boolean; tabId?: string },
options?: SftpNavigateOptions,
): Promise<SftpNavigateResult> => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
// When tabId is specified, target that specific tab instead of the active one.
@@ -176,6 +181,10 @@ export const useSftpPaneActions = ({
return "aborted";
}
if (options?.shouldApply?.() === false) {
return "aborted";
}
const connectionId = pane.connection.id;
const requestId = ++navSeqRef.current[side];
const cacheKey = makeCacheKey(connectionId, path, pane.filenameEncoding);
@@ -244,6 +253,31 @@ export const useSftpPaneActions = ({
const previousFiles = confirmed.files;
const previousSelection = confirmed.selectedFiles;
const previousFilter = confirmed.filter;
const previousError = pane.error;
const isTargetRequestCurrent = () => tabNavSeqRef.current.get(targetTabId) === requestId;
const getTargetPane = () => {
const currentSideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
return currentSideTabs.tabs.find((t) => t.id === targetTabId) ?? null;
};
const shouldApplyNavigationState = () => (
getTargetPane()?.connection?.id === connectionId
&& (options?.shouldApply?.() ?? true)
);
const restoreConfirmedStateIfCurrent = () => {
if (!isTargetRequestCurrent()) return;
updateTab(side, targetTabId, (prev) => {
if (prev.connection?.id !== connectionId || !isTargetRequestCurrent()) return prev;
return {
...prev,
connection: { ...prev.connection, currentPath: previousPath },
files: previousFiles,
selectedFiles: previousSelection,
filter: previousFilter,
error: previousError,
loading: false,
};
});
};
tabNavSeqRef.current.set(targetTabId, requestId);
// Keep existing files visible during loading — the loading overlay
// (pointer-events-none) prevents interaction. This avoids blanking a tab
@@ -267,6 +301,13 @@ export const useSftpPaneActions = ({
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
if (!isTargetRequestCurrent()) {
return "superseded";
}
if (!shouldApplyNavigationState()) {
restoreConfirmedStateIfCurrent();
return "aborted";
}
clearCacheForConnection(pane.connection.id);
// For background tabs (explicit tabId), update that tab directly
// instead of handleSessionError which targets the active tab.
@@ -286,6 +327,13 @@ export const useSftpPaneActions = ({
files = await listRemoteFiles(sftpId, path, pane.filenameEncoding);
} catch (err) {
if (isSessionError(err)) {
if (!isTargetRequestCurrent()) {
return "superseded";
}
if (!shouldApplyNavigationState()) {
restoreConfirmedStateIfCurrent();
return "aborted";
}
sftpSessionsRef.current.delete(pane.connection.id);
clearCacheForConnection(pane.connection.id);
if (options?.tabId) {
@@ -306,14 +354,18 @@ export const useSftpPaneActions = ({
if (navSeqRef.current[side] !== requestId) {
// Side-level sequence was bumped by another tab's navigation or
// a connect/disconnect. Check if THIS tab's request is still current.
if (tabNavSeqRef.current.get(targetTabId) !== requestId) {
if (!isTargetRequestCurrent()) {
// This tab also has a newer navigation — drop completely.
return "aborted";
return "superseded";
}
// Side was superseded by another tab, but this tab's request is
// still current. The fetched files are valid — fall through to
// apply them instead of restoring previousPath.
}
if (!shouldApplyNavigationState()) {
restoreConfirmedStateIfCurrent();
return "aborted";
}
dirCacheRef.current.set(cacheKey, {
files,
@@ -349,12 +401,16 @@ export const useSftpPaneActions = ({
return "reached";
} catch (err) {
if (navSeqRef.current[side] !== requestId) {
if (tabNavSeqRef.current.get(targetTabId) !== requestId) {
return "aborted";
if (!isTargetRequestCurrent()) {
return "superseded";
}
// Side superseded by another tab, but this tab's request is
// current — fall through to show the error on this tab.
}
if (!shouldApplyNavigationState()) {
restoreConfirmedStateIfCurrent();
return "aborted";
}
let navigationFailed = false;
updateTab(side, targetTabId, (prev) => {
if (prev.connection?.id !== connectionId) {
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getSidePanelLiveSnapshot,
sidePanelLiveStore,
SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
} from './sidePanelLiveStore.ts';
test('sidePanelLiveStore skips notify when snapshot is unchanged', () => {
let notifications = 0;
const unsubscribe = sidePanelLiveStore.subscribe(() => {
notifications += 1;
});
const snapshot = {
...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
activeTerminalCwd: '/tmp',
};
sidePanelLiveStore.update(snapshot);
sidePanelLiveStore.update({ ...snapshot });
unsubscribe();
assert.equal(notifications, 1);
});
test('getSidePanelLiveSnapshot returns inactive snapshot when disabled', () => {
sidePanelLiveStore.update({
...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
activeTerminalCwd: '/var/log',
});
assert.equal(getSidePanelLiveSnapshot(false), SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT);
assert.equal(getSidePanelLiveSnapshot(true).activeTerminalCwd, '/var/log');
});
+101
View File
@@ -0,0 +1,101 @@
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
export type SidePanelLiveSnapshot = {
sftpActiveHost: Host | null;
activeTerminalSessionIdForSftp: string | null;
activeTerminalCwd: string | null;
activeWorkspace: Workspace | undefined;
activeTerminalSessionForSystem: TerminalSession | null;
activeSystemSessionHost: Host | null;
focusedHost: Host | null;
focusedSessionId: string | null;
historySessionId: string | null;
resolvedPreviewTheme: TerminalTheme | null;
previewedOrVisibleThemeId: string | undefined;
focusedFontFamilyId: string | undefined;
focusedFontFamilyOverridden: boolean;
focusedFontSize: number | undefined;
focusedFontSizeOverridden: boolean;
focusedFontWeight: number | undefined;
focusedFontWeightOverridden: boolean;
focusedThemeOverridden: boolean;
};
const EMPTY_SNAPSHOT: SidePanelLiveSnapshot = {
sftpActiveHost: null,
activeTerminalSessionIdForSftp: null,
activeTerminalCwd: null,
activeWorkspace: undefined,
activeTerminalSessionForSystem: null,
activeSystemSessionHost: null,
focusedHost: null,
focusedSessionId: null,
historySessionId: null,
resolvedPreviewTheme: null,
previewedOrVisibleThemeId: undefined,
focusedFontFamilyId: undefined,
focusedFontFamilyOverridden: false,
focusedFontSize: undefined,
focusedFontSizeOverridden: false,
focusedFontWeight: undefined,
focusedFontWeightOverridden: false,
focusedThemeOverridden: false,
};
export const SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT = EMPTY_SNAPSHOT;
type Listener = () => void;
function liveSnapshotEqual(a: SidePanelLiveSnapshot, b: SidePanelLiveSnapshot): boolean {
return a.sftpActiveHost === b.sftpActiveHost
&& a.activeTerminalSessionIdForSftp === b.activeTerminalSessionIdForSftp
&& a.activeTerminalCwd === b.activeTerminalCwd
&& a.activeWorkspace === b.activeWorkspace
&& a.activeTerminalSessionForSystem === b.activeTerminalSessionForSystem
&& a.activeSystemSessionHost === b.activeSystemSessionHost
&& a.focusedHost === b.focusedHost
&& a.focusedSessionId === b.focusedSessionId
&& a.historySessionId === b.historySessionId
&& a.resolvedPreviewTheme === b.resolvedPreviewTheme
&& a.previewedOrVisibleThemeId === b.previewedOrVisibleThemeId
&& a.focusedFontFamilyId === b.focusedFontFamilyId
&& a.focusedFontFamilyOverridden === b.focusedFontFamilyOverridden
&& a.focusedFontSize === b.focusedFontSize
&& a.focusedFontSizeOverridden === b.focusedFontSizeOverridden
&& a.focusedFontWeight === b.focusedFontWeight
&& a.focusedFontWeightOverridden === b.focusedFontWeightOverridden
&& a.focusedThemeOverridden === b.focusedThemeOverridden;
}
class SidePanelLiveStore {
private snapshot: SidePanelLiveSnapshot = EMPTY_SNAPSHOT;
private listeners = new Set<Listener>();
getSnapshot = (): SidePanelLiveSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
update(next: SidePanelLiveSnapshot): void {
if (liveSnapshotEqual(this.snapshot, next)) return;
this.snapshot = next;
for (const listener of this.listeners) {
listener();
}
}
}
export const sidePanelLiveStore = new SidePanelLiveStore();
const noopSubscribe = () => () => {};
export function subscribeSidePanelLiveSnapshot(enabled: boolean, listener: Listener): () => void {
if (!enabled) return noopSubscribe();
return sidePanelLiveStore.subscribe(listener);
}
export function getSidePanelLiveSnapshot(enabled: boolean): SidePanelLiveSnapshot {
return enabled ? sidePanelLiveStore.getSnapshot() : SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT;
}
+64 -7
View File
@@ -6,19 +6,34 @@ import {
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_APP_ICON_VARIANT,
STORAGE_KEY_HTTP_NETWORK_PROXY,
} from '../../infrastructure/config/storageKeys';
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
import {
normalizeHttpNetworkProxySettings,
type HttpNetworkProxySettings,
} from '../../domain/httpNetworkProxy';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
parseWindowOpacityRecord,
serializeWindowOpacityRecord,
shouldApplyWindowOpacityRecord,
shouldBroadcastWindowOpacityChange,
type WindowOpacityMutationSource,
type WindowOpacityRecord,
} from './windowOpacitySync';
interface UseSystemSettingsEffectsParams {
enabled?: boolean;
toggleWindowHotkey: string;
globalHotkeyEnabled: boolean;
closeToTray: boolean;
windowOpacity: number;
windowOpacityRecord: WindowOpacityRecord;
windowOpacityMutationSourceRef: MutableRefObject<WindowOpacityMutationSource>;
appIconVariant: AppIconVariant;
autoUpdateEnabled: boolean;
httpNetworkProxy: HttpNetworkProxySettings;
persistMountedRef: MutableRefObject<boolean>;
setHotkeyRegistrationError: (error: string | null) => void;
setAutoUpdateEnabled: (enabled: boolean | ((prev: boolean) => boolean)) => void;
@@ -31,9 +46,11 @@ export function useSystemSettingsEffects({
toggleWindowHotkey,
globalHotkeyEnabled,
closeToTray,
windowOpacity,
windowOpacityRecord,
windowOpacityMutationSourceRef,
appIconVariant,
autoUpdateEnabled,
httpNetworkProxy,
persistMountedRef,
setHotkeyRegistrationError,
setAutoUpdateEnabled,
@@ -106,17 +123,57 @@ export function useSystemSettingsEffects({
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
}, [enabled, closeToTray, notifySettingsChanged, persistMountedRef]);
// Persist and apply app-level HTTP(S) network proxy (cloud sync / AI)
useEffect(() => {
if (!enabled) return;
const normalized = normalizeHttpNetworkProxySettings(httpNetworkProxy);
localStorageAdapter.write(STORAGE_KEY_HTTP_NETWORK_PROXY, normalized);
const bridge = netcattyBridge.get();
if (bridge?.setHttpNetworkProxy) {
// Apply to main process; empty custom is treated as system there.
// Persist draft custom+empty so the URL field remains visible.
bridge.setHttpNetworkProxy(normalized).catch((err) => {
console.warn('[NetworkProxy] Failed to apply HTTP network proxy:', err);
});
}
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_HTTP_NETWORK_PROXY, normalized);
}, [enabled, httpNetworkProxy, notifySettingsChanged, persistMountedRef]);
// Persist and sync window opacity
useEffect(() => {
if (!enabled) return;
const bridge = netcattyBridge.get();
bridge?.setWindowOpacity?.(windowOpacity).catch((err) => {
bridge?.setWindowOpacity?.(windowOpacityRecord.opacity).catch((err) => {
console.warn('[WindowOpacity] Failed to apply window opacity:', err);
});
localStorageAdapter.writeString(STORAGE_KEY_WINDOW_OPACITY, String(windowOpacity));
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_WINDOW_OPACITY, windowOpacity);
}, [enabled, windowOpacity, notifySettingsChanged, persistMountedRef]);
// Never let a stale effect overwrite a newer revision already on disk.
const stored = parseWindowOpacityRecord(
localStorageAdapter.readString(STORAGE_KEY_WINDOW_OPACITY),
);
if (
shouldApplyWindowOpacityRecord(stored, windowOpacityRecord)
|| stored.version === windowOpacityRecord.version
) {
localStorageAdapter.writeString(
STORAGE_KEY_WINDOW_OPACITY,
serializeWindowOpacityRecord(windowOpacityRecord),
);
}
const decision = shouldBroadcastWindowOpacityChange(
windowOpacityMutationSourceRef.current,
persistMountedRef.current,
);
windowOpacityMutationSourceRef.current = decision.nextSource;
if (!decision.shouldBroadcast) return;
notifySettingsChanged(STORAGE_KEY_WINDOW_OPACITY, windowOpacityRecord);
}, [
enabled,
windowOpacityRecord,
windowOpacityMutationSourceRef,
notifySettingsChanged,
persistMountedRef,
]);
// Persist and sync app icon variant
useEffect(() => {
@@ -0,0 +1,127 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
TERMINAL_AUTO_RECONNECT_DELAY_MS,
canAttemptTerminalAutoReconnect,
isTerminalAutoReconnectEnabled,
shouldAutoReconnectAfterExit,
shouldContinueAutoReconnectAfterFailure,
} from "./terminalAutoReconnect";
const sshHost = {
protocol: "ssh" as const,
hostname: "example.com",
};
test("terminal auto reconnect uses a five second retry delay", () => {
assert.equal(TERMINAL_AUTO_RECONNECT_DELAY_MS, 5000);
});
test("terminal auto reconnect is disabled unless the setting is explicitly true", () => {
assert.equal(isTerminalAutoReconnectEnabled(undefined), false);
assert.equal(isTerminalAutoReconnectEnabled({ sshAutoReconnectEnabled: true }), true);
assert.equal(isTerminalAutoReconnectEnabled({ sshAutoReconnectEnabled: false }), false);
});
test("unexpected SSH exits reconnect only after the tab has connected before", () => {
assert.equal(
shouldAutoReconnectAfterExit({
evt: { reason: "closed" },
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: true },
hasEverConnected: true,
}),
true,
);
assert.equal(
shouldAutoReconnectAfterExit({
evt: { reason: "closed" },
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: true },
hasEverConnected: false,
}),
false,
);
});
test("normal shell exits do not auto reconnect", () => {
assert.equal(
shouldAutoReconnectAfterExit({
evt: { reason: "exited", exitCode: 0 },
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: true },
hasEverConnected: true,
}),
false,
);
});
test("auto reconnect ignores protocols that already have different lifecycle semantics", () => {
const variants = [
{ protocol: "local" as const, hostname: "localhost" },
{ protocol: "serial" as const, hostname: "/dev/tty.usbserial" },
{ protocol: "telnet" as const, hostname: "router.local" },
{ protocol: "ssh" as const, hostname: "example.com", moshEnabled: true },
{ protocol: "ssh" as const, hostname: "example.com", etEnabled: true },
];
for (const host of variants) {
assert.equal(
shouldAutoReconnectAfterExit({
evt: { reason: "error", error: "connection reset" },
host,
terminalSettings: { sshAutoReconnectEnabled: true },
hasEverConnected: true,
}),
false,
);
}
});
test("an active auto reconnect loop continues after failed retry attempts", () => {
assert.equal(
shouldContinueAutoReconnectAfterFailure({
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: true },
loopActive: true,
}),
true,
);
assert.equal(
shouldContinueAutoReconnectAfterFailure({
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: false },
loopActive: true,
}),
false,
);
});
test("terminal auto reconnect can start from live or fully hibernated runtimes", () => {
assert.equal(
canAttemptTerminalAutoReconnect({
hasTerminalRuntime: true,
isHibernated: false,
}),
true,
);
assert.equal(
canAttemptTerminalAutoReconnect({
hasTerminalRuntime: false,
isHibernated: true,
}),
true,
);
assert.equal(
canAttemptTerminalAutoReconnect({
hasTerminalRuntime: false,
isHibernated: false,
}),
false,
);
});
@@ -0,0 +1,66 @@
import type { TerminalSettings } from "../../domain/models";
import type { TerminalSessionExitEvent } from "./resolveTerminalSessionExitIntent";
export const TERMINAL_AUTO_RECONNECT_DELAY_MS = 5000;
type AutoReconnectHost = {
protocol?: "ssh" | "telnet" | "local" | "serial" | "mosh" | "et";
hostname?: string;
moshEnabled?: boolean;
etEnabled?: boolean;
};
type AutoReconnectSettings = Pick<TerminalSettings, "sshAutoReconnectEnabled"> | undefined | null;
export function isTerminalAutoReconnectEnabled(settings: AutoReconnectSettings): boolean {
return settings?.sshAutoReconnectEnabled === true;
}
export function isAutoReconnectableSshHost(host: AutoReconnectHost): boolean {
const protocol = host.protocol ?? "ssh";
return (
protocol === "ssh" &&
host.hostname !== "localhost" &&
host.moshEnabled !== true &&
host.etEnabled !== true
);
}
export function shouldAutoReconnectAfterExit({
evt,
host,
terminalSettings,
hasEverConnected,
}: {
evt: TerminalSessionExitEvent;
host: AutoReconnectHost;
terminalSettings?: AutoReconnectSettings;
hasEverConnected: boolean;
}): boolean {
if (!hasEverConnected) return false;
if (!isTerminalAutoReconnectEnabled(terminalSettings)) return false;
if (!isAutoReconnectableSshHost(host)) return false;
return evt.reason !== "exited";
}
export function shouldContinueAutoReconnectAfterFailure({
host,
terminalSettings,
loopActive,
}: {
host: AutoReconnectHost;
terminalSettings?: AutoReconnectSettings;
loopActive: boolean;
}): boolean {
return loopActive && isTerminalAutoReconnectEnabled(terminalSettings) && isAutoReconnectableSshHost(host);
}
export function canAttemptTerminalAutoReconnect({
hasTerminalRuntime,
isHibernated,
}: {
hasTerminalRuntime: boolean;
isHibernated: boolean;
}): boolean {
return hasTerminalRuntime || isHibernated;
}
@@ -49,6 +49,21 @@ test("split session clones reuse only connected SSH sources", () => {
);
});
test("session clones preserve the ephemeral-host marker", () => {
assert.equal(
createSplitTerminalSessionClone(session({ ephemeralHost: true }), { id: "split-1" }).ephemeralHost,
true,
);
assert.equal(
createCopiedTerminalSessionClone(session({ ephemeralHost: true }), { id: "copy-1" }).ephemeralHost,
true,
);
assert.equal(
createSplitTerminalSessionClone(session(), { id: "split-2" }).ephemeralHost,
undefined,
);
});
test("copy session clones reuse SSH sources and preserve serial config", () => {
const copied = createCopiedTerminalSessionClone(
session({
@@ -60,3 +75,19 @@ test("copy session clones reuse SSH sources and preserve serial config", () => {
assert.equal(copied.reuseConnectionFromSessionId, "session-1");
assert.deepEqual(copied.serialConfig, { path: "/dev/tty.usbserial", baudRate: 115200 });
});
test("split and copy session clones preserve local start directory", () => {
const source = session({
protocol: "local",
localStartDir: "/Users/alice/project with spaces ",
});
assert.equal(
createSplitTerminalSessionClone(source, { id: "split-local" }).localStartDir,
"/Users/alice/project with spaces ",
);
assert.equal(
createCopiedTerminalSessionClone(source, { id: "copy-local" }).localStartDir,
"/Users/alice/project with spaces ",
);
});
@@ -43,8 +43,10 @@ function createTerminalSessionClone(
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
localStartDir: session.localStartDir,
fontSize: session.fontSize,
fontSizeOverride: session.fontSizeOverride,
...(session.ephemeralHost ? { ephemeralHost: true } : {}),
reuseConnectionFromSessionId: canReuseTerminalConnection(session) ? session.id : undefined,
};
-18
View File
@@ -162,16 +162,6 @@ export const useAvailableUIFonts = (): UIFont[] => {
);
};
/**
* Get UI font loading state
*/
export const useUIFontsLoading = (): boolean => {
return useSyncExternalStore(
uiFontStore.subscribe,
uiFontStore.getIsLoading
);
};
/**
* Get UI font loaded state
*/
@@ -182,14 +172,6 @@ export const useUIFontsLoaded = (): boolean => {
);
};
/**
* Get UI font by ID with fallback
*/
export const useUIFontById = (fontId: string): UIFont => {
const fonts = useAvailableUIFonts();
return fonts.find(f => f.id === fontId) || fonts[0] || UI_FONTS[0];
};
/**
* Check if a font ID is valid
*/
+16 -8
View File
@@ -26,7 +26,11 @@ import type {
ProviderConfig,
WebSearchConfig,
} from '../../infrastructure/ai/types';
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
import {
DEFAULT_COMMAND_BLOCKLIST,
DEFAULT_COMMAND_TIMEOUT_SECONDS,
normalizeCommandTimeoutSeconds,
} from '../../infrastructure/ai/types';
import { removeProviderReferences } from './aiProviderCleanup';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
import { getAIBridge } from './aiStateSnapshots';
@@ -66,7 +70,9 @@ export function useAISettingsState() {
localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST]
);
const [commandTimeout, setCommandTimeoutRaw] = useState<number>(() =>
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60
normalizeCommandTimeoutSeconds(
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
)
);
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
@@ -163,9 +169,10 @@ export function useAISettingsState() {
}, []);
const setCommandTimeout = useCallback((value: number) => {
setCommandTimeoutRaw(value);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, value);
getAIBridge()?.aiMcpSetCommandTimeout?.(value);
const normalizedValue = normalizeCommandTimeoutSeconds(value);
setCommandTimeoutRaw(normalizedValue);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, normalizedValue);
getAIBridge()?.aiMcpSetCommandTimeout?.(normalizedValue);
}, []);
const setMaxIterations = useCallback((value: number) => {
@@ -235,10 +242,11 @@ export function useAISettingsState() {
break;
}
case STORAGE_KEY_AI_COMMAND_TIMEOUT: {
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60;
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS;
if (!Number.isFinite(timeout)) break;
setCommandTimeoutRaw(timeout);
getAIBridge()?.aiMcpSetCommandTimeout?.(timeout);
const normalizedTimeout = normalizeCommandTimeoutSeconds(timeout);
setCommandTimeoutRaw(normalizedTimeout);
getAIBridge()?.aiMcpSetCommandTimeout?.(normalizedTimeout);
break;
}
case STORAGE_KEY_AI_MAX_ITERATIONS: {
+19 -9
View File
@@ -33,7 +33,11 @@ import type {
AISessionScope,
WebSearchConfig,
} from '../../infrastructure/ai/types';
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
import {
DEFAULT_COMMAND_BLOCKLIST,
DEFAULT_COMMAND_TIMEOUT_SECONDS,
normalizeCommandTimeoutSeconds,
} from '../../infrastructure/ai/types';
import {
activateDraftView,
clearScopeDraftState,
@@ -107,7 +111,9 @@ export function useAIState() {
localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST]
);
const [commandTimeout, setCommandTimeoutRaw] = useState<number>(() =>
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60
normalizeCommandTimeoutSeconds(
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
)
);
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
@@ -361,11 +367,12 @@ export function useAIState() {
}, []);
const setCommandTimeout = useCallback((value: number) => {
setCommandTimeoutRaw(value);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, value);
const normalizedValue = normalizeCommandTimeoutSeconds(value);
setCommandTimeoutRaw(normalizedValue);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, normalizedValue);
// Sync to MCP Server bridge
const bridge = getAIBridge();
bridge?.aiMcpSetCommandTimeout?.(value);
bridge?.aiMcpSetCommandTimeout?.(normalizedValue);
}, []);
const setMaxIterations = useCallback((value: number) => {
@@ -438,13 +445,14 @@ export function useAIState() {
break;
}
case STORAGE_KEY_AI_COMMAND_TIMEOUT: {
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60;
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS;
if (!Number.isFinite(timeout)) {
console.warn('[useAIState] Cross-window sync: AI_COMMAND_TIMEOUT is not a finite number, skipping');
break;
}
setCommandTimeoutRaw(timeout);
getAIBridge()?.aiMcpSetCommandTimeout?.(timeout);
const normalizedTimeout = normalizeCommandTimeoutSeconds(timeout);
setCommandTimeoutRaw(normalizedTimeout);
getAIBridge()?.aiMcpSetCommandTimeout?.(normalizedTimeout);
break;
}
case STORAGE_KEY_AI_MAX_ITERATIONS: {
@@ -539,7 +547,9 @@ export function useAIState() {
const bridge = getAIBridge();
const initialBlocklist = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST];
bridge?.aiMcpSetCommandBlocklist?.(initialBlocklist);
const initialTimeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60;
const initialTimeout = normalizeCommandTimeoutSeconds(
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
);
bridge?.aiMcpSetCommandTimeout?.(initialTimeout);
const initialMaxIter = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
bridge?.aiMcpSetMaxIterations?.(initialMaxIter);
@@ -61,7 +61,7 @@ test("chrome layout animations wait until theme settle frames complete", () => {
cancel();
});
test("syncActiveChromeTheme refreshes top tabs when the active theme fingerprint is unchanged", () => {
test("syncActiveChromeTheme skips surface refresh when the active theme fingerprint is unchanged", () => {
const globalWithDocument = globalThis as typeof globalThis & { document?: Document };
const originalDocument = globalWithDocument.document;
const theme = TERMINAL_THEMES[0];
@@ -83,9 +83,9 @@ test("syncActiveChromeTheme refreshes top tabs when the active theme fingerprint
throw new Error("app theme should not be restored for an unchanged active chrome theme");
});
assert.notEqual(topTabsRoot.style.getPropertyValue("--top-tabs-bg"), "");
assert.notEqual(topTabsRoot.style.getPropertyValue("--top-tabs-active-bg"), "");
assert.notEqual(topTabsRoot.style.getPropertyValue("--top-tabs-accent"), "");
assert.equal(topTabsRoot.style.getPropertyValue("--top-tabs-bg"), "");
assert.equal(topTabsRoot.style.getPropertyValue("--top-tabs-active-bg"), "");
assert.equal(topTabsRoot.style.getPropertyValue("--top-tabs-accent"), "");
} finally {
if (originalDocument) {
globalWithDocument.document = originalDocument;
@@ -228,11 +228,6 @@ export function syncActiveChromeTheme(
const nextFingerprint = activeTheme ? themeFingerprint(activeTheme) : null;
const appliedFingerprint = getAppliedChromeFingerprint();
if (nextFingerprint === appliedFingerprint) {
if (activeTheme) {
refreshActiveChromeThemeSurfaces(activeTheme);
} else {
clearTopTabsChromeThemeVars();
}
return;
}
+7 -3
View File
@@ -33,12 +33,16 @@ export const useApplicationBackend = () => {
return info ?? null;
}, []);
const checkSshAgent = useCallback(async (): Promise<SshAgentStatus | null> => {
const checkSshAgent = useCallback(async (options?: {
identityAgent?: string;
hostname?: string;
port?: number;
username?: string;
}): Promise<SshAgentStatus | null> => {
const bridge = netcattyBridge.get();
const status = await bridge?.checkSshAgent?.();
const status = await bridge?.checkSshAgent?.(options);
return status ?? null;
}, []);
return { openExternal, getApplicationInfo, checkSshAgent };
};
+39
View File
@@ -385,6 +385,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
lastSyncedDataRef.current = dataHash;
manager.setPendingLocalSync(false);
// Successful sync implies a successful per-provider
// `checkProviderConflict` (which inspects remote) — equivalent
@@ -459,6 +460,28 @@ export const useAutoSync = (config: AutoSyncConfig) => {
getDataHashRef.current = getDataHash;
}, [getDataHash]);
const refreshPendingLocalSync = useCallback(async () => {
if (!enabled || !sync.hasAnyConnectedProvider || !sync.isUnlocked) {
manager.setPendingLocalSync(false);
return;
}
if (!remoteCheckDoneRef.current) {
return;
}
const currentHash = await getDataHashRef.current();
const hashDecision = resolveAutoSyncHashDecision({
currentHash,
lastSyncedHash: lastSyncedDataRef.current,
appliedSkipHash: skipNextSyncHashRef.current,
});
manager.setPendingLocalSync(hashDecision === 'sync');
}, [enabled, sync.hasAnyConnectedProvider, sync.isUnlocked]);
const refreshPendingLocalSyncRef = useRef(refreshPendingLocalSync);
useEffect(() => {
refreshPendingLocalSyncRef.current = refreshPendingLocalSync;
}, [refreshPendingLocalSync]);
// Serialize `checkRemoteVersion` invocations. Overlapping runs would
// race on `commitRemoteInspection` + `onApplyPayload`: two merges
// could both write-then-clear the apply-in-progress sentinel around
@@ -713,6 +736,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// unlock/startupReady transition, and a manual sync from
// Settings remains available as an escape hatch.
remoteCheckDoneRef.current = true;
await refreshPendingLocalSyncRef.current();
}
checkRemoteInFlightRef.current = false;
}
@@ -843,6 +867,21 @@ export const useAutoSync = (config: AutoSyncConfig) => {
bookmarksVersion,
syncableSettingsStorageVersion,
]);
// Reflect unsynced local edits in the top-bar cloud indicator.
useEffect(() => {
void refreshPendingLocalSync();
}, [
refreshPendingLocalSync,
getDataHash,
sync.hasAnyConnectedProvider,
sync.isUnlocked,
sync.isSyncing,
config.settingsVersion,
enabled,
bookmarksVersion,
syncableSettingsStorageVersion,
]);
// Check remote version on startup/unlock, then retry with backoff
// while the inspect keeps failing. Without the timer-based retry,
+2
View File
@@ -58,6 +58,7 @@ export interface CloudSyncHook {
remoteVersion: number;
remoteUpdatedAt: number;
syncHistory: SyncHistoryEntry[];
pendingLocalSync: boolean;
pendingBrowserAuthProvider: 'google' | 'onedrive' | null;
// Computed
@@ -731,6 +732,7 @@ export const useCloudSync = (): CloudSyncHook => {
remoteVersion: state.remoteVersion,
remoteUpdatedAt: state.remoteUpdatedAt,
syncHistory: state.syncHistory,
pendingLocalSync: state.pendingLocalSync,
pendingBrowserAuthProvider: pendingBrowserAuth?.provider ?? null,
// Computed
@@ -0,0 +1,114 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { buildAITerminalSessionInfo } from '../../components/terminalLayer/TerminalLayerSupport';
import { detectLocalOs } from '../../lib/localShell';
import type { Host, PortForwardingRule, TerminalSession } from '../../types';
import { STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } from '../../infrastructure/config/storageKeys';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { AI_STATE_CHANGED_EVENT } from './aiStateEvents';
import { readExternalMcpStoredEnabled } from './useExternalMcpToggleState';
const EXTERNAL_MCP_CHAT_SESSION_ID = '__external_mcp__';
type UseExternalMcpSessionSyncOptions = {
sessions: TerminalSession[];
sessionHostsMap?: Map<string, Host>;
hosts: Host[];
portForwardingRules: PortForwardingRule[];
};
function isMainAppWindow(): boolean {
if (typeof window === 'undefined') return false;
const hash = window.location.hash || '';
// Peer session windows and settings-only windows must not own the
// app-wide External MCP scope — they only see a partial session set.
if (hash.startsWith('#/session-window')) return false;
if (hash.startsWith('#/settings')) return false;
return true;
}
/**
* Keep the reserved External MCP scope aligned with every live terminal
* session, independent of whether the Catty AI side panel / TerminalLayer
* has mounted yet.
*/
export function useExternalMcpSessionSync({
sessions,
sessionHostsMap,
hosts,
portForwardingRules,
}: UseExternalMcpSessionSyncOptions) {
const [enabledTick, setEnabledTick] = useState(0);
const enabled = useMemo(() => {
void enabledTick;
return readExternalMcpStoredEnabled();
}, [enabledTick]);
const syncGenerationRef = useRef(0);
useEffect(() => {
const bump = () => setEnabledTick((value) => value + 1);
const handleStorage = (event: StorageEvent) => {
if (event.key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) return;
bump();
};
const handleLocalStateChanged = (event: Event) => {
const detail = (event as CustomEvent<{ key?: string }>).detail;
if (detail?.key && detail.key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) return;
bump();
};
window.addEventListener('storage', handleStorage);
window.addEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged as EventListener);
return () => {
window.removeEventListener('storage', handleStorage);
window.removeEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged as EventListener);
};
}, []);
const payload = useMemo(() => {
const localOs = detectLocalOs(navigator.userAgent || navigator.platform);
const hostById = new Map(hosts.map((host) => [host.id, host]));
return sessions.map((session) => {
const host = sessionHostsMap?.get(session.id)
|| (session.hostId ? hostById.get(session.hostId) : undefined);
return buildAITerminalSessionInfo(session, host, localOs, {
allHosts: hosts,
portForwardingRules,
});
});
}, [sessions, sessionHostsMap, hosts, portForwardingRules]);
const lastSentSerializedRef = useRef('');
useEffect(() => {
if (!isMainAppWindow()) return;
if (!enabled) {
syncGenerationRef.current += 1;
lastSentSerializedRef.current = '';
return;
}
const bridge = netcattyBridge.get();
if (!bridge?.aiMcpUpdateSessions) return;
const serialized = JSON.stringify(payload);
if (serialized === lastSentSerializedRef.current) return;
const generation = ++syncGenerationRef.current;
const timeoutId = window.setTimeout(() => {
void Promise.resolve(bridge.aiMcpUpdateSessions?.(payload, EXTERNAL_MCP_CHAT_SESSION_ID))
.then((result) => {
if (generation !== syncGenerationRef.current) return;
if (result && typeof result === 'object' && 'ok' in result && (result as { ok?: boolean }).ok === false) {
return;
}
lastSentSerializedRef.current = serialized;
})
.catch(() => {
// Leave the ref unset so the next effect can retry.
});
}, 250);
return () => {
window.clearTimeout(timeoutId);
};
}, [enabled, payload]);
}
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
createExternalMcpStartupSyncPlan,
normalizeExternalMcpIdleTimeoutMinutes,
normalizeExternalMcpMode,
shouldStartExternalMcpOnStartup,
} from './useExternalMcpToggleState.ts';
describe('useExternalMcpToggleState helpers', () => {
it('normalizes mode and idle timeout', () => {
assert.equal(normalizeExternalMcpMode('persistent'), 'persistent');
assert.equal(normalizeExternalMcpMode('other'), 'temporary');
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(null), 10);
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(0), 1);
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(99999), 24 * 60);
});
it('only starts on launch for persistent+enabled', () => {
assert.equal(shouldStartExternalMcpOnStartup({ enabled: true, mode: 'persistent' }), true);
assert.equal(shouldStartExternalMcpOnStartup({ enabled: true, mode: 'temporary' }), false);
assert.equal(shouldStartExternalMcpOnStartup({ enabled: false, mode: 'persistent' }), false);
});
it('startup sync plan clears temporary stored enabled', () => {
const plan = createExternalMcpStartupSyncPlan({
enabled: true,
mode: 'temporary',
idleTimeoutMinutes: 15,
});
assert.equal(plan.runtimeEnabled, false);
assert.equal(plan.storedEnabled, false);
assert.equal(plan.shouldPersistStoredEnabled, true);
assert.equal(plan.config.idleTimeoutMinutes, 15);
});
it('startup sync plan keeps persistent enabled', () => {
const plan = createExternalMcpStartupSyncPlan({
enabled: true,
mode: 'persistent',
idleTimeoutMinutes: 20,
});
assert.equal(plan.runtimeEnabled, true);
assert.equal(plan.storedEnabled, true);
assert.equal(plan.shouldPersistStoredEnabled, false);
});
});
Loaded 100 of 822 files, more files were not shown because too many files have changed in this diff. Show more