Compare commits

...

307 Commits

Author SHA1 Message Date
陈大猫
646e7ce001 fix(tray): update Windows tray click and right-click behaviors to match conventions (#1152) (#1268)
Some checks failed
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
2026-06-06 11:08:14 +08:00
陈大猫
21da34187e Merge pull request #1267 from binaricat/codex/fix-cmd-pty-wrapper 2026-06-06 02:59:35 +08:00
LAPTOP-O016UC3M\Qi Chen
d2fa4f1cd9 Fix cmd PTY wrapper expansion 2026-06-06 02:28:16 +08:00
陈大猫
72a6fc14f9 Merge pull request #1263 from binaricat/codex/domestic-ai-provider-presets
Add domestic AI provider presets
2026-06-05 16:52:04 +08:00
bincxz
97c2cb1f86 Keep OpenRouter model discovery covered 2026-06-05 16:49:00 +08:00
bincxz
73fd091b80 Cover provider dropdown presentation 2026-06-05 16:43:52 +08:00
bincxz
5bb4052f3d Keep preset model suggestions visible 2026-06-05 16:39:39 +08:00
bincxz
36e7e3cb7f Keep add provider menu inside settings panel 2026-06-05 16:37:54 +08:00
bincxz
25b73187f5 Update domestic provider model suggestions 2026-06-05 16:35:29 +08:00
bincxz
75a9600089 Document Xiaomi provider icon source 2026-06-05 16:30:32 +08:00
bincxz
c9216b32ab Add domestic AI provider presets 2026-06-05 16:28:21 +08:00
陈大猫
70e374ef11 Merge pull request #1262 from binaricat/codex/proxy-type-dropdown
Use dropdown for proxy type selection
2026-06-05 16:02:25 +08:00
bincxz
24840c539c fix(proxy): use dropdown for proxy type 2026-06-05 15:59:21 +08:00
陈大猫
461be76821 Merge pull request #1260 from binaricat/codex/proxy-command-1257
feat(ssh): support ProxyCommand connections
2026-06-05 15:49:38 +08:00
bincxz
28a7184cc4 fix(proxy): harden ProxyCommand summaries 2026-06-05 15:35:21 +08:00
陈奇
13f1453276 test(proxy): avoid ProxyCommand stdin race 2026-06-05 06:42:49 +00:00
陈奇
ff25c36ede test(proxy): harden ProxyCommand stream coverage 2026-06-05 06:34:12 +00:00
陈奇
092aa45fd9 Fix ProxyCommand shell substitution 2026-06-05 06:11:31 +00:00
陈大猫
64acf80024 Merge pull request #1259 from binaricat/codex/chacha20-poly1305-1256
fix(ssh): support chacha20-poly1305 cipher
2026-06-05 14:08:52 +08:00
陈奇
099beb8438 feat(ssh): support proxy command connections 2026-06-05 06:03:45 +00:00
陈奇
8bee13c3f9 fix(ssh): offer chacha20-poly1305 cipher 2026-06-05 05:54:23 +00:00
陈大猫
65cd8aba79 Merge pull request #1258 from binaricat/feat/sftp-skip-loading-on-reuse
feat(sftp): skip loading animation when reusing terminal SSH connection
2026-06-05 12:07:08 +08:00
陈奇
37856e5608 fix: set reusedConnection on initial connect, tolerate bridge fallback
When sourceSessionId is requested but the bridge falls back to a
fresh connection, the pane remains non-interactive (loading=true)
with stale cached files shown — acceptable trade-off vs always
showing the distracting spinner for near-instant reused connections.
2026-06-05 04:05:04 +00:00
陈奇
5b1deaa08a fix: clear reusedConnection flag after SFTP connection established
The flag was persisting forever, suppressing loading UI for all
subsequent navigations and refreshes. Clear it once status
becomes 'connected' so only the initial reuse skips the spinner.
2026-06-05 03:58:54 +00:00
陈奇
a41bced1d7 feat(sftp): skip loading animation when reusing terminal SSH connection
When SFTP reuses an existing terminal SSH connection, the
connection is near-instant so the loading spinner and overlay
are distracting noise. Added a reusedConnection flag to
SftpConnection and skip the loading UI when set.

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

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

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

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

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

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

Closes #1251
2026-06-05 02:09:09 +00:00
陈大猫
9e2b8093fb fix terminal split connection reuse (#1249)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
2026-06-04 23:44:27 +08:00
陈大猫
7b2f66000c Support pasting local files into SFTP (#1248)
* feat(sftp): paste local clipboard files

* fix(sftp): handle native clipboard paste event

* fix(sftp): harden clipboard paste upload
2026-06-04 23:08:32 +08:00
陈大猫
6e7593dee2 improve codex live progress (#1247) 2026-06-04 22:14:10 +08:00
陈大猫
2098b2b09d fix copilot thinking stream (#1243) 2026-06-04 21:57:30 +08:00
陈大猫
8181fe71cf fix(ai): make SDK deps optional, degrade gracefully when missing (#1242)
* fix(ai): make SDK deps optional, degrade gracefully when missing

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

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

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

* fixup: guard runTurn against missing SDK modules

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

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

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

Closes #1236

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

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

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

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

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

* fix: tighten context compaction review issues

* fix: preserve tool context during compaction

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

Make input symmetric with output:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

codex review round 2:

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

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

Full suite: 1400 tests, 0 failures.

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

* test: drop unmatched services test glob from npm test

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two functional gaps in the stats companion:

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

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

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

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

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

Move the gate from the transport to the auth layer:

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

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

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

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

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

Keep the companion strictly on session.moshStatsConn:

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

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

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

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

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

Introduce a `pending` result:

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:17:37 +08:00
bincxz
9e7f6d98fd fix(et): fail loudly on missing/over-long jump chain in startEt
Copilot review: startEt only checked resolvedChainHosts.length, so a
configured jump chain that failed to resolve (missing/invalid host ID) would
silently fall back to a direct connection — possibly to the wrong target —
unlike startSSH, which explicitly rejects missing chain host IDs.

Add the same getMissingChainHostIds check startSSH uses, erroring early with
a clear message when a configured jump host cannot be resolved. Also base the
"at most one jump host" limit on the configured chain (host.hostChain.hostIds)
rather than only the resolved list, so a second hop whose ID fails to resolve
cannot slip past the check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:17:32 +08:00
bincxz
ceda20510f fix(et): run Unix askpass via Electron exec and route jumps through ET --jumphost
Addresses two correctness issues from the codex review of the ET protocol
support.

codex P1 #1 (Unix askpass helper): on macOS/Linux the SSH_ASKPASS helper
was the askpass .cjs itself, relying on its `#!/usr/bin/env node` shebang.
Packaged Electron builds put no `node` on the user's PATH, so ssh could not
run the helper and ET could not supply the saved password / key passphrase,
failing the connection outright. Mirror the existing Windows .cmd wrapper:
write a small /bin/sh wrapper that execs the helper through process.execPath
with ELECTRON_RUN_AS_NODE=1 (process.execPath is POSIX single-quoted to
survive spaces in an .app path).

codex P1 #2 (jump host routing): a jumped ET host only got an ssh
ProxyCommand, which fixes the SSH bootstrap but leaves ET opening its TCP
socket straight at the destination etserver — which fails whenever the
destination ET port is not directly reachable. Use ET's own
--jumphost/--jport so ET connects its socket to the jumphost's etserver and
reaches the destination over the SSH tunnel ET sets up (`ssh -J`). Per-hop
jump credentials move from the ProxyCommand into a `Host <jumphost>` ssh_config
block (OpenSSH applies command-line -o only to the final hop, so jump settings
must come from config); the destination's comma/space options are scoped under
a `Host <dest>` block with a ProxyJump so the standalone ssh used for distro
detection tunnels through the jump too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:17:26 +08:00
lateautumn233
82f3250b5b Bundle ET client binaries in package build workflow
Mirror the existing mosh bundling pipeline so that release and tag
builds automatically fetch and package the EternalTerminal `et`
client alongside mosh-client.
2026-06-03 12:35:50 +08:00
陈大猫
c37e087332 Merge pull request #1209 from binaricat/feat/duplicate-tab-reuse-session-1204 2026-06-03 11:26:31 +08:00
bincxz
f282c58edc fix(ssh): validate reuse target and handle synchronous shell failures
Two more review findings on the connection-reuse path:

- Verify the source connection's endpoint matches the duplicate's
  requested hostname/port/username before reusing it. A saved host edited
  after the source tab connected would otherwise let the copy silently
  open on the old connection and run commands on the wrong machine.
  findReusableSession now takes the requested target and requires an exact
  endpoint match; the session records its actual SSH endpoint at connect.

- Wrap conn.shell() in the reuse path with try/catch. ssh2 can throw
  synchronously (e.g. "Not connected") if the borrowed transport dropped
  between findReusableSession and the shell request; without this the
  up-front connection ref hold would leak. On a synchronous throw we now
  drop the error listener, release the ref, and fall back to a fresh
  connection.

Adds tests for endpoint mismatch and synchronous shell failure.

Refs #1204

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:07:58 +08:00
bincxz
45e208f1d8 fix(ssh): harden connection-reuse lifecycle against teardown races
Two reference-counting races found in review:

- Reuse path read the source's connRef only inside the async conn.shell()
  callback. If the source tab closed while the shell was opening,
  releaseConnectionRef could drop the count to zero and end the shared
  connection out from under the opening channel. Now pin the connection
  (acquireConnectionRef) before issuing the async shell request and hand
  the hold over to the real session once the channel opens; release it on
  any failure so fallback to a fresh connection doesn't leak the count.

- closeSession read session.connRef *after* stream.close(), but closing the
  channel can synchronously fire the stream "close" handler that nulls
  connRef and releases the connection — so the post-close check fell into
  the legacy path and ended the shared connection a second time. Snapshot
  the multiplexing flag before closing the channel.

Adds a regression test covering "source closed while the copied shell is
still opening".

Refs #1204

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:59:49 +08:00
bincxz
132c597d1e fix(ssh): scope session-log stop to the owning connection
Reading _logStreamToken back off the session map in the connection-level
close/error/timeout handlers could let a late close from an old transport
stop a newer same-sessionId log stream after a reconnect, regressing the
token guard from #916. Capture the owner channel's log stream token in the
connection closure and pass it to stopStream, matching the original
closure-scoped behavior.

Refs #1204

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:51:09 +08:00
bincxz
85f486e6cd feat(ssh): reuse authenticated connection for Copy Tab to skip re-MFA
Duplicating an SSH tab ("Copy Tab") used to open a brand-new SSH
connection, forcing a second MFA prompt on hosts with multi-factor auth.
Like Tabby's session multiplexing, open a new shell *channel* on the
source tab's already-authenticated connection instead, so the duplicate
reuses the existing transport and skips key exchange + authentication.

- copySession records the source session id (reuseConnectionFromSessionId)
  for connected, non-mosh SSH sessions; it is threaded down to the SSH
  bridge as options.sourceSessionId.
- startSession.cjs gains a reuse path that opens conn.shell() on the
  source connection. The shell wiring is extracted into a shared
  setupShellSession helper used by both the fresh and reuse paths.
- Connection lifecycle is reference-counted via a new sshConnectionPool
  module (mirrors Tabby ref/unref/destroy): the shared transport + jump
  host chain are torn down only when the last channel closes, so closing
  a copy — or the original while a copy is open — never kills siblings.
  terminalBridge.closeSession routes SSH teardown through the same release.
- Reuse falls back to a fresh connection when the source is gone, and is
  skipped for X11 hosts (X11 is negotiated per channel).

Tests: sshConnectionPool refcount unit tests, bridge reuse integration
tests (reuse vs fresh, sibling survival, X11 skip, fallback), and
terminalBridge close lifecycle tests.

Refs #1204

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:43:49 +08:00
陈大猫
2c96773679 Merge pull request #1207 from binaricat/fix/autocomplete-popup-overflow-1202
fix(autocomplete): 补全弹窗边界翻转 + 配色跟随强调色 (#1202)
2026-06-03 10:41:16 +08:00
bincxz
a8f9fd7a56 fix(autocomplete): make popup panels border-box for exact width clamp
Codex P2: the panel maxWidth constants used by the horizontal clamp's
totalWidth excluded each panel's padding + border (inline styles default to
content-box), so a padded detail tooltip at its 280px max could still render
wider than reserved and spill off-screen.

Set box-sizing: border-box on the shared panel style so every maxWidth is the
true outer width and totalWidth is exact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:34:54 +08:00
陈大猫
aae4ad4da8 Merge pull request #1206 from binaricat/fix/topbar-ai-toggle-1194
顶部 AI 按钮支持 toggle 收起,移除无用的提醒按钮
2026-06-03 10:32:22 +08:00
bincxz
014d7b4d39 fix(autocomplete): reserve popup detail space per-set to avoid hover jump
Codex P2: totalWidth and the vertical height reservation depended on the
hovered/selected item's detail panel, so moving the mouse between a
no-detail row and a detail row could change the clamped position and shift
the popup under the pointer (flicker, unreliable clicks near the edge).

Reserve detail-panel width/height from a set-level flag (any non-path row
with a description) so placement stays stable while hovering; the tooltip
itself still renders only for the active row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:32:13 +08:00
陈大猫
5912339813 Merge pull request #1205 from binaricat/feat/alinux-distro-icon-1200
feat(distro): 新增 Alibaba Cloud Linux 发行版图标
2026-06-03 10:29:13 +08:00
bincxz
20bfa0e3bd fix(autocomplete): keep completion popup inside the window and follow accent
The terminal completion popup could spill past the window edges and ignored
the theme accent color (#1202):

- Horizontal: the left-edge clamp only reserved the main list width (400px),
  so expanding a directory near the right edge pushed the cascading sub-dir
  panels and the detail tooltip off-screen. Now the clamp accounts for the
  full assembly width (main list + sub-dir panels + detail tooltip) and pins
  to the left padding when it is wider than the viewport.
- Vertical: extracted the flip/clamp math into a pure, unit-tested
  computeAutocompletePopupPlacement() so "not enough room below -> flip up
  and bound the height" is verifiable. The detail tooltip is now height-
  bounded and scrolls instead of overflowing for long snippet descriptions.
- Accent: the selected/hover row background and the active-row accent rail now
  derive from the active terminal theme's cursor/selection colors (which track
  the user's accent setting) instead of a hardcoded blue.

Adds terminalAutocompleteLayout.test.ts covering downward/upward placement,
bottom-of-viewport flip, height clamping, and horizontal clamping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:28:45 +08:00
bincxz
41ebe0fa64 Make top-bar AI button toggle the side panel and drop reminder button
The top-bar AI button previously dispatched netcatty:toggle-ai-panel,
which was wired to handleOpenAI -> handleSwitchSidePanelTab('ai'). That
switch is a no-op when AI is already the open sub-panel, so the panel
could not be dismissed by clicking the button again.

Add a dedicated handleToggleAiFromTopBar that routes through a new
resolveAiSidePanelToggleIntent helper: a second click on an already-open
AI panel closes the side panel, while a click from a closed panel or a
different sub-panel switches to AI. The top-bar event listener now uses
this toggle handler. handleOpenAI stays a plain switch so the AI icon in
the side-panel rail remains idempotent like the other rail tabs.

Also remove the non-functional reminder (bell) button from the top bar;
it had no click handler and no backing feature.

Refs #1194

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:27:50 +08:00
bincxz
66cf610cc0 feat(distro): add Alibaba Cloud Linux (alinux) icon and detection
Alibaba Cloud Linux hosts (os-release ID="alinux") previously showed the
generic Linux icon because normalizeDistroId fell through to the catch-all
`linux` branch ('alinux'.includes('linux') is true).

- Add a dedicated `alinux` branch in normalizeDistroId, matching the
  os-release ID, the legacy `aliyun` ID, and the NAME/PRETTY_NAME text
  "Alibaba Cloud Linux"; place it before the generic linux fallback.
- Register `alinux` in LINUX_DISTRO_OPTIONS so it is selectable in the
  manual distro override and classified as linux-like.
- Add the brand SVG (public/distro/alinux.svg) plus logo/color mappings
  in DistroAvatar (brand color #FF6A00).
- Add the localized label in en / ru / zh-CN.
- Cover normalizeDistroId's alinux cases (ID, legacy aliyun, PRETTY_NAME)
  with unit tests, including a regression guard against the generic linux
  fallback.

Icon source: simple-icons "Alibaba Cloud" mark (CC0-1.0, public domain),
matching the existing distro icon set already sourced from simple-icons.

Closes #1200

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:24:44 +08:00
lateautumn233
62ec391523 Add ET integration checklist
Document the implementation status of EternalTerminal integration
across all layers (domain, electron, application, components, i18n)
with testing notes and known limitations.
2026-06-03 01:55:06 +08:00
lateautumn233
7927da2085 Build ET UI: host settings panel, protocol picker, and session starters
Add ET configuration section in HostDetailsAdvancedSections (etPort,
etTerminalPath). Add ET option to ProtocolSelectDialog. Wire ET session
creation in createTerminalSessionStarters with proxy and multi-jump
validation. Add ET badges and labels in Terminal, TerminalToolbar,
TerminalConnectionDialog, and TerminalLayerSupport. Propagate ET
settings through GroupDetailsPanel, GroupSshSettingsSection, and
VaultView.
2026-06-03 01:45:29 +08:00
lateautumn233
d45dea4bff Add ET localization strings for en and zh-CN
Terminal: protocol label, proxy-unsupported warning, multi-jump
limitation notice. Vault: ET settings section title, etPort and
etTerminalPath field labels with descriptions.
2026-06-03 01:45:28 +08:00
lateautumn233
816e274dfc Route ET protocol through application session state and backend
Resolve 'et' protocol in tray panel and host connection handlers with
priority over mosh. Propagate etEnabled through session factories and
useSessionState. Expose etAvailable guard and startEtSession wrapper
in useTerminalBackend.
2026-06-03 01:45:28 +08:00
lateautumn233
1a20a6a4a8 Expose startEtSession IPC channel via preload and bridge types
Add startEtSession to the preload API surface, routing through the
netcatty:et:start IPC channel. Define the startEtSession options type
in netcatty-bridge-session.d.ts with ET-specific parameters (etPort,
terminalPath, jumpHosts, etc.).
2026-06-03 01:45:28 +08:00
lateautumn233
910049b0ea Implement etSession process manager and bundled ET client resolver
Add etSession.cjs: full ET session lifecycle including SSH bootstrap with
host-key verification, etclient PTY spawning, temp directory management,
and external auth artifact cleanup. Wire the session API into
terminalBridge.cjs with IPC handler registration. Add bundledEtClient
resolver that locates the platform-specific et binary in both packaged
and dev environments. Include unit tests for both etSession and
bundledEtClient.
2026-06-03 01:45:28 +08:00
lateautumn233
173a83aafa Extend HostProtocol union and host models with ET fields
Add 'et' to HostProtocol union type. Add etEnabled, etPort, and
etTerminalPath fields to Host and GroupConfig interfaces. Update
vaultImport type guards to exclude 'et' from importable protocols.
Register ET fields in groupConfig inheritable keys.
2026-06-03 01:45:28 +08:00
陈大猫
b7093f88b1 Merge pull request #1203 from pyroch/main 2026-06-03 00:54:39 +08:00
pyroch
2e66bcf254 fix: hover feedback for window controls 2026-06-02 19:40:29 +03:00
lateautumn233
95208294b0 Bundle the prebuilt et client at pack time
Download and package the et binaries produced by build-et-binaries:

- resolve-et-bin-release picks the latest et-bin-* release; fetch-et-binaries
  downloads the platform client into resources/et/, verifying SHA256SUMS.
- et-extra-resources emits the electron-builder extraResources entry only
  when the binary is on disk, so pack still works without a bundled et.
- electron-builder.config.cjs wires et into the mac/win/linux bundles;
  package.json adds the fetch:et scripts.
2026-06-02 20:35:07 +08:00
lateautumn233
a4bf2234cd Add build-et-binaries workflow to compile the et client
Build the EternalTerminal `et` client in CI the way mosh-client is:

- build-et-binaries.yml builds et for linux x64/arm64, macOS universal
  and windows x64, uploads artifacts, and optionally publishes them to a
  dedicated binary repository on manual workflow_dispatch.
- scripts/build-et/{linux,macos,windows} compile et from upstream.
- .gitignore excludes the fetched binaries; resources/et/README.md
  documents source provenance.
2026-06-02 20:35:07 +08:00
陈大猫
e527e7233f Merge pull request #1197 from binaricat/codex/ssh-debug-logs
Add SSH debug logging setting
2026-06-02 12:05:00 +08:00
bincxz
afe959835d Add SSH debug log setting 2026-06-02 12:01:40 +08:00
陈大猫
3b2b05064b Merge pull request #1195 from binaricat/codex/fix-terminal-wheel-font-zoom
[codex] Fix terminal wheel font zoom capture
2026-06-02 10:49:52 +08:00
bincxz
1e94fe983f Fix terminal wheel font zoom capture 2026-06-02 10:46:37 +08:00
陈大猫
274ac4e0e1 Merge pull request #1192 from binaricat/codex/fix-catty-tool-result-repair
[codex] Fix Catty tool result history repair
2026-06-02 10:30:29 +08:00
bincxz
1ad4443e3b Fix Catty tool result history repair 2026-06-02 10:15:38 +08:00
陈大猫
031bf0ee45 Merge pull request #1188 from binaricat/codex/vault-sidebar-spacing
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
[codex] Balance vault sidebar spacing
2026-06-02 01:29:14 +08:00
bincxz
0efe80b06d Balance vault sidebar spacing 2026-06-02 01:28:39 +08:00
陈大猫
3fb7c6dd21 Merge pull request #1183 from pplulee/feature/codesnip-edit
feat: 优化代码片段脚本编辑
2026-06-02 01:17:36 +08:00
bincxz
c7e4ac82ca Fix snippet editor focus outline 2026-06-02 01:15:36 +08:00
bincxz
d5e29598d3 Merge main into PR 1183 and address review 2026-06-02 01:07:04 +08:00
陈大猫
fca7782634 Remove vault content left margin (#1187) 2026-06-02 01:00:19 +08:00
Pyro
42b23a9faa fix: blurry inline aside panel rendering (#1185) 2026-06-02 00:40:17 +08:00
Pyro
06011d01d6 fix: update RU localization (#1184) 2026-06-01 23:31:03 +08:00
pplulee
4bf4e65df8 fix(keychain): enhance key management UI with copy, edit, and delete actions (#1182) 2026-06-01 23:13:31 +08:00
pplulee
45e62ed43e feat(snippets): add SnippetScriptEditor component and update usage in dialogs 2026-06-01 23:05:56 +08:00
陈大猫
368c31e48d Remove default host seed data
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
Remove seeded example hosts so new installs start with an empty vault.
2026-06-01 21:09:04 +08:00
陈大猫
0fa926de26 Polish vault UI (#1177) 2026-06-01 21:03:43 +08:00
陈大猫
b9b7db2a4e fix kimi tool stream parsing (#1176) 2026-06-01 19:42:42 +08:00
陈大猫
e3f68e1a3f [codex] fix cloud sync master key refresh (#1175)
* fix cloud sync master key refresh

* Protect first sync from default settings

* Avoid recovery prompt for settings-only sync

* Cancel stale syncs after master key changes
2026-06-01 19:29:53 +08:00
陈大猫
3c4746aea0 [codex] add sync reliability metadata (#1174)
* feat: add sync reliability metadata

* fix: preserve tombstones from checked remotes

* fix: keep conflict change counts typed
2026-06-01 18:40:19 +08:00
陈大猫
463dd4464f [codex] add cloud sync strategies (#1171)
* feat: add cloud sync strategies

* chore: clarify cloud sync strategy options

* chore: keep selected sync strategy concise

* fix: sync cloud-wins payload to remaining providers

* fix: apply cloud sync strategy during startup

* fix: retry partial startup cloud syncs
2026-06-01 17:07:07 +08:00
陈大猫
63b95bb68e [codex] add terminal font size shortcuts 2026-06-01 15:25:31 +08:00
陈大猫
ea41389842 Fix Ruijie network device detection (#1168) 2026-06-01 13:52:22 +08:00
Jerry
429cb8d6e9 fix: keep macOS Dock icon consistent with bundle icon (#1166) 2026-06-01 13:05:07 +08:00
陈大猫
55faae767a feat(vault): show/hide toggle for the host Telnet password (#1164)
* feat(vault): show/hide toggle for the host Telnet password

Issue #1099 (part 2): the host-level Telnet password field was a bare type="password" with no way to reveal it. Add an eye toggle matching the SSH password field and the group-level Telnet password.

Refs #1099

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

* fix(vault): reset Telnet password reveal when the edited host changes

HostDetailsPanel is reused across hosts without remounting, so the new showTelnetPassword state has to be cleared in the initialData effect alongside showPassword — otherwise revealing one host's Telnet password leaves the next host's shown unmasked.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 12:56:51 +08:00
陈大猫
94b8f298ae feat(vault): connect with the host's default protocol, prompt only for Telnet (#1163)
Issue #1099: a Telnet host shows the protocol picker on every connect. Connect resolution is now explicit, and the picker only appears when it's genuinely a choice:

- Telnet set as default (protocol = telnet) -> connect Telnet (single-click and batch)
- Telnet enabled but not the default -> show the picker (unchanged)
- Mosh enabled -> connect Mosh
- otherwise -> connect SSH

A "Connect with Telnet by default" switch in the Telnet card sets the host's primary protocol. SSH+Mosh hosts now connect Mosh directly instead of prompting every time. Addresses part 1 of #1099.

Refs #1099

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 12:38:14 +08:00
陈大猫
1ef3f9f979 feat(vault): batch-connect selected hosts (#1162)
The multi-select toolbar already supported batch delete; add a "Connect" button next to it so connecting many hosts no longer has to be done one by one. Connects each selected host in list order with its configured protocol (multi-protocol hosts use their default rather than prompting per host).

Closes #870

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 11:16:01 +08:00
陈大猫
e88313eb84 fix(sftp): confirm delete/overwrite dialogs on Enter key (#1160)
The delete and overwrite confirmation dialogs are opened from a context menu, whose focus-return could leave focus outside the dialog, so pressing Enter did nothing. Focus the confirm button when each dialog opens via onOpenAutoFocus so Enter activates it. Esc and Tab+Enter behavior is unchanged.

Fixes #1150

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 10:21:55 +08:00
pplulee
03cd9bc968 feat(snippets): implement snippet variable handling and UI prompts (#1159) 2026-05-31 18:01:43 +08:00
pplulee
4d7c56e537 主机备注功能与脚本编辑体验优化 (#1158) 2026-05-31 15:39:26 +08:00
Jerry
4769668ff9 docs: add CLAUDE.md and untrack from .gitignore (#1149) 2026-05-29 16:23:59 +08:00
陈大猫
8ca36a695b fix(ssh): repair mangled PEM private keys before parsing (#1147)
* fix(ssh): repair mangled PEM private keys before parsing

A valid PEM key whose framing was damaged in transit — newlines
collapsed to spaces, turned into literal "\n", or lines indented —
fails ssh2's parser with "Unsupported key format" even though the key
material is intact. This commonly happens when a key is copy/pasted
through a field or app that strips line breaks. (follow-up to #1139)

When parsing fails, rebuild clean PEM framing from the BEGIN/END markers
(which survive newline loss) and the base64 body, then retry through the
existing parse and PKCS#8 conversion paths. The body is preserved
byte-for-byte and a repaired key is only used if it re-validates, so
this can never produce a different or invalid key. Encrypted legacy PEM
(Proc-Type/DEK-Info) and truncated keys are left untouched.

Refs #1139

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

* fix(ssh): detect encryption on mangled OpenSSH keys

A mangled encrypted OpenSSH key (line breaks flattened to literal "\n")
was not recognized as encrypted: the literal escapes corrupt the base64
decode used to read the cipher name, so isKeyEncrypted() returned false
and preparePrivateKeyForAuth routed the key to the unencrypted branch
with no passphrase prompt — and the repaired candidate was discarded
because it can't parse without one.

Repair the PEM framing before reading the OpenSSH cipher name, so such
keys are detected as encrypted and reach the passphrase prompt, where
normalizePrivateKeyForSsh2(key, passphrase) already repairs and
validates them. Addresses Codex review feedback on #1147.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 15:45:56 +08:00
陈大猫
053a976d37 fix(ssh): support PKCS#8 private keys by converting for ssh2 (#1146)
ssh2's key parser only accepts OpenSSH, legacy PKCS#1/SEC1 and PuTTY
keys, so PKCS#8 keys (-----BEGIN PRIVATE KEY----- / BEGIN ENCRYPTED
PRIVATE KEY) fail with "Cannot parse privateKey: Unsupported key
format" even though they are valid and work in other clients such as
Termius. (#1139)

Add privateKeyNormalizer: when ssh2 can't parse a key but it is PKCS#8,
read it with Node's crypto and re-export RSA->PKCS#1 / EC->SEC1, the
legacy PEM forms ssh2 understands. Encrypted PKCS#8 is decrypted with
the passphrase first. Ed25519 (and other) PKCS#8 keys, which have no
legacy PEM form, now surface a clear "convert with ssh-keygen" message
instead of ssh2's opaque error.

Wired into sshAuthHelper's preparePrivateKeyForAuth and
loadIdentityFileForAuth, so SSH, SFTP and port-forwarding all benefit.
Also fixes a latent bug where a correct passphrase on an encrypted
PKCS#8 key was rejected and re-prompted indefinitely.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 15:09:52 +08:00
陈大猫
40fb5b62a9 Fix storage change render warning (#1138)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
2026-05-28 15:40:04 +08:00
陈大猫
1fec5925eb Refactor large modules and fix runtime errors (#1136) 2026-05-28 15:12:19 +08:00
陈大猫
23d4b342b9 fix(vault): flat Vaults tab selection + fill host grid beside side panel (#1134)
- TopTabs: the Vaults root tab no longer paints an active background fill when
  selected (text/icon still brighten). Clear the imperatively-set hover fill on
  click so it can't get stuck when active bg stays transparent.
- VaultView: when the host side panel is open, drive the grid column count from
  the container width as a fixed N columns instead of viewport-based grid-cols-*
  (which can't see the narrowed area) or auto-fit+1fr (which stretched a lone
  card across the whole row). Fills the row with no trailing gap and keeps a
  single card at one column's width. The count rides on a CSS variable set
  imperatively via ResizeObserver, so reflowing the grid never re-renders this
  large component.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 11:54:30 +08:00
陈大猫
2c716cd74c fix packaged MCP blocklist assets (#1132) 2026-05-28 10:49:20 +08:00
陈大猫
6c23514d84 fix(ai): prefix wrapped AI commands with a leading space (#1129)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
Netcatty's AI / Skill+CLI integration sends marker-wrapped commands
(__NCMCP_xxx=0; { ... eval ...; }) straight into the user's interactive
shell. preload.cjs filters the PTY echo of those wrappers from the
visible terminal, but they still land in ~/.bash_history — making the
user's shell history hard to read after each AI session (#1126 user
report on v1.1.16).

Prefix the POSIX (bash/zsh/dash) and fish wrappers with a single space.
On the shells/configurations that already honor "ignore leading-space"
in history recording, those wrappers now skip the history file
entirely:

- bash with HISTCONTROL containing `ignorespace` (Debian/Ubuntu default
  via /etc/bash.bashrc, also part of `ignoreboth` which is the most
  common explicit setting)
- zsh with HIST_IGNORE_SPACE set (Oh-My-Zsh and most prezto templates
  enable this)
- fish with a user-defined fish_should_add_to_history function (opt-in
  via fish config)

Known limitations (no behavior change needed on netcatty's side):

- bash on bare RHEL/CentOS ships HISTCONTROL=ignoredups by default —
  leading space is not honored. Users on those distros can opt in with
  `HISTCONTROL=ignoreboth` in their ~/.bashrc.
- zsh without HIST_IGNORE_SPACE: same; add `setopt HIST_IGNORE_SPACE`.
- Fish without a custom history filter: leading space is not honored.
- PowerShell, cmd, network-device CLIs: unaffected (their wrappers are
  not changed, and the persistent-history semantics differ).

This is intentionally a minimal change — 4 characters of behavior plus
the explanatory comments. We rely on the user's existing shell config
instead of trying to mutate HISTCONTROL ourselves at session start,
which would either be visible in the terminal echo, mis-fire on hosts
that already had ignorespace (deleting a real previous history entry),
or error on non-POSIX shells.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:02:18 +08:00
陈大猫
456ddcfe68 chore(ai): upgrade ACP packages and unwrap Skill+CLI command in tool-call panel (#1128)
* chore(ai): upgrade ACP packages and unwrap Skill+CLI command in tool-call panel

Package bumps:
- @zed-industries/claude-agent-acp 0.22.2 → @agentclientprotocol/claude-agent-acp 0.37.0
  (old npm package is deprecated; scope rename)
- @zed-industries/codex-acp 0.10.0 → 0.15.0
- @mcpc-tech/acp-ai-provider 0.2.8 → 0.3.3
- electron-builder asarUnpack glob + bridge require.resolve switched to the new scope

After the upgrade Codex tool-call cards started showing the local
worktree path for every step — "Run /Users/.../netcatty-tool-cli session
--session …" — instead of the remote command. Three things lined up:

1. The new acp-ai-provider maps ACP's `title` to `toolName`, and Codex's
   title is the full shell invocation it's about to run.
2. Codex local_shell ships args as ["/bin/zsh","-lc","<full>"], so the
   old `typeof args.command === 'string'` branch in ToolCall never fired
   and we fell through to printing `name` (i.e. the title).
3. The bridge serializes tool args under `args`, but the ACP adapter
   only read `event.input`, so even when args were available the
   renderer received {}.

Fixes:
- acpAgentAdapter: read tool input from both `event.input` and
  `event.args` so bridge-serialized chunks and direct AI SDK chunks
  both work.
- ai-elements/tool-call: new extractDisplayCommand() unwraps the shell
  array, then the netcatty-tool-cli wrapper (exec/job-start … -- <cmd>),
  and renders the real remote command. session/env/job-poll/etc. fall
  back to short labels ("netcatty: inspect session", …) instead of
  exposing the binary path.
- shellUtils.cjs: defensive JSON-parse the ACP wrapper input in case
  the AI SDK ever stops auto-parsing it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(build): exclude bundled Claude CLI binaries from the installer

@anthropic-ai/claude-agent-sdk@0.3.x bundles the native Claude Code CLI
(~211MB per arch) as optional sibling packages. Including them would
silently regress Netcatty's "bring your own Claude" design — the project
has always required users to install Claude Code locally, and the entire
path-discovery flow exists precisely to honor that contract:

- useAgentDiscovery.ts scans the user's PATH for `claude` and writes
  the absolute path into the agent config's CLAUDE_CODE_EXECUTABLE env.
- aiBridge.cjs runs normalizeClaudeCodeExecutableEnvForAcp on every ACP
  spawn, forwarding the env var to the child process.
- The @agentclientprotocol/claude-agent-acp wrapper's claudeCliPath()
  (acp-agent.js) prefers process.env.CLAUDE_CODE_EXECUTABLE over the
  bundled binary and only falls back to sibling-package resolution when
  the env var is empty.

So the right place to enforce the design is electron-builder: exclude
node_modules/@anthropic-ai/claude-agent-sdk-* from `files`. Dev mode is
unaffected (optional deps still install for `npm run dev`); only the
packaged installer drops the binaries, saving ~150MB. Users without
Claude Code installed get the same SDK error they got pre-upgrade.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:26:14 +08:00
陈大猫
2a283a4f83 fix(ai): run bundled claude-agent-acp via Electron's Node (#1127)
@zed-industries/claude-agent-acp ships dist/index.js with a
`#!/usr/bin/env node` shebang. We bundle the package and unpack it from
asar, but Windows ignores the shebang entirely and macOS/Linux only
honours it when `node` is on the user's PATH. When `node` was missing,
the resolver fell back to spawning the bare `claude-agent-acp` command,
which only works if the user manually ran
`npm install -g @zed-industries/claude-agent-acp` — see #1118.

Run the bundled script through `process.execPath` with
`ELECTRON_RUN_AS_NODE=1` (matching `resolveMcpServerRuntimeCommand` in
the MCP server bridge) so the embedded Electron acts as the Node
runtime. This makes the bundled copy work with zero external deps on
every supported platform.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:50:54 +08:00
陈大猫
b29533259b fix(terminal): probe cwd via SSH_CONNECTION on older OpenSSH (#1123) (#1125) 2026-05-27 18:09:21 +08:00
Pyro
0f8aa08994 (fix) autocomplete popup visible after sending command (#1121)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
Co-authored-by: pyroch <cvdysh@gmail.com>
2026-05-27 16:37:21 +08:00
陈大猫
fb522c5016 fix(ai): keep chat input toolbar on one row when sidebar is narrow (#1122)
When the AI sidebar is dragged narrow, the model chip used to wrap
to its own line (and the perm chip to a third line), wasting vertical
space. Switch the toolbar from flex-wrap to a single-row flex container
where the +/perm chips stay shrink-0 and the model chip absorbs all
the squeeze via min-w-0, letting the existing truncate kick in and
ellipsize the model label instead of wrapping.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:35:31 +08:00
陈大猫
7272f2564d fix(ssh): per-host skipEcdsaHostKey toggle + advanced algorithm overrides (#1027) (#1116)
* fix(ssh): per-host skipEcdsaHostKey toggle + advanced algorithm overrides (#1027)

#1027 reported an old Huawei S7706 (SSH banner `SSH-2.0--`, empty
software-version field) where the legacy-algorithms toggle still
couldn't get a connection through. The debug log shows the handshake
makes it through every negotiation step, picks `ecdsa-sha2-nistp521`
for the host key, then dies at:

  Handshake failed: signature verification failed

i.e. ssh2's strict RFC verifier rejects the ECDSA signature the
switch produces. OpenSSH on the same machine connects because its
known_hosts is already pinned to an RSA fingerprint, so it never
advertises ECDSA in the first place.

This adds two layers of escape hatch:

A. `host.skipEcdsaHostKey` (one-click) — drops every `ecdsa-sha2-*`
   from the offered host-key list. Forces the fallback to
   ssh-rsa / ssh-dss / ssh-ed25519 that those old stacks implement
   correctly. Wired through ssh / sftp / port-forwarding bridges,
   inheritable from the group default.

B. `host.algorithms` (advanced) — per-category override lists
   (`kex`, `cipher`, `hmac`, `serverHostKey`, `compress`). When a
   category's array is non-empty, it fully replaces the negotiated
   list for that category. Exposed in a collapsible "Advanced
   algorithm overrides" panel on both host and group settings,
   inspired by Tabby's per-profile algorithm UI. Empty arrays
   normalize to "use default" so picking zero algorithms in a
   category doesn't bench the connection with
   "no matching algorithm".

Overrides apply BEFORE `skipEcdsaHostKey` so the latter stays an
unconditional kill switch even if the user explicitly puts
`ecdsa-*` back into the host-key list.

Behavior with default values (neither toggle set, no override list)
is identical to before — zero change for hosts that aren't opted in.

Tests cover the new options on both `buildAlgorithms` and
`buildSftpAlgorithms`, plus group→host inheritance for both fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): address Codex review on #1116 — proper seed + jump-host carry + missing call sites

Four review findings from the Codex pass on #1116, all real:

1. AlgorithmOverridesPanel was seeding the first customization in a
   category from `SUPPORTED_ALGORITHMS_BY_CATEGORY`, which contains
   legacy algorithms (CBC, arcfour, MD5, ssh-dss, group1-sha1...).
   Unchecking a single modern algorithm in a host that had legacy mode
   *off* would silently start offering those legacy algorithms. Now
   seeds from `effectiveDefaultAlgorithms(legacyEnabled)`, mirroring
   what `buildAlgorithms` actually emits at connect time.

   - New `effectiveDefaultAlgorithms` pure helper in
     `domain/sshAlgorithmList.ts` with its own test suite (also asserts
     the modern subset contains no SHA-1 KEX / CBC / arcfour / MD5)
   - `AlgorithmOverridesPanel` takes a `legacyEnabled` prop
   - `isChecked` now reflects the effective-default state for an
     untouched category, so unchecked rows visually represent
     algorithms the connection wouldn't currently advertise

2. The chain-mode jump-host loop in `sshBridge.cjs` was applying the
   target host's `skipEcdsaHostKey` / `algorithmOverrides` to every
   bastion hop, which is wrong when the bastion has its own settings.
   This was actually a pre-existing issue with `legacyAlgorithms` too
   — `NetcattyJumpHost` simply didn't carry any of these fields.

   - `NetcattyJumpHost` gains `legacyAlgorithms`, `skipEcdsaHostKey`,
     `algorithmOverrides`
   - `createTerminalSessionStarters` populates them from each jump
     host's own configuration
   - The jump-host bridge call now reads `jump.* ?? options.*` so a
     hop with its own setting wins, but unset hops still fall back to
     the target's settings (preserves historic chain-wide behavior
     when nothing is overridden)

3. `infrastructure/services/portForwardingService.ts` only forwarded
   `legacyAlgorithms` to the port-forwarding bridge, so a host that
   needed the ECDSA skip or advanced overrides could connect through
   the terminal but its auto-start tunnels would still hit the
   original handshake failure.

   - Forward `skipEcdsaHostKey` and `algorithmOverrides` at the target
     call site and at the jump-host map.

4. `application/state/sftp/useSftpHostCredentials.ts` built
   `NetcattySSHOptions` for `openSftp` without any of the algorithm
   fields — and on inspection it didn't even forward `legacyAlgorithms`
   to begin with, so SFTP panes for legacy-mode hosts were silently
   negotiating with the modern default list. Same gap at the jump-host
   map.

   - Forward all three fields at both the target and the jump-host map.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* polish(hosts): make the advanced-algorithm collapsible trigger a real button

The first cut used a plain underlined caption for the
"Advanced algorithm overrides" collapsible trigger. That blends into
the surrounding helper text and doesn't read as an interactive
control — users couldn't tell that the per-category checkbox editor
was reachable at all.

Match the project's existing collapsible-trigger pattern (see
`SerialConnectModal`): full-width ghost Button, label on the left,
ChevronDown / ChevronUp on the right that flips with open state,
controlled via `useState`. Applied in both `HostDetailsPanel` and
`GroupDetailsPanel`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* polish(hosts): rename Legacy Algorithms card to SSH Algorithms; split Backspace into its own section

The card now carries three algorithm controls (Allow Legacy / Skip
ECDSA / Advanced overrides) plus a Backspace Behavior dropdown that
doesn't belong with the rest. Two cleanups:

1. Rename the card from "Legacy Algorithms" to "SSH Algorithms".
   The original title only described the first toggle; the section
   now covers the whole algorithm-negotiation surface, including the
   ECDSA host-key skip and the per-category override editor.
   - i18n key renamed `hostDetails.section.legacyAlgorithms`
     -> `hostDetails.section.sshAlgorithms` in zh-CN / en / ru
   - `HostDetailsPanel` references the new key

2. Move the Backspace Behavior control out of the algorithm card.
   - `HostDetailsPanel`: new dedicated "Terminal Behavior" card
     (TerminalSquare icon) placed between SSH Algorithms and
     Keepalive. New i18n key `hostDetails.section.terminalBehavior`.
   - `GroupDetailsPanel`: no separate Card scaffolding for group
     defaults — moved Backspace to the bottom of the SSH section
     (after Mosh) so it visually separates from the algorithm block
     above without introducing a new card heading.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sftp): per-hop algorithm settings in SFTP chain (Codex review on #1116)

`sshBridge.cjs`'s jump loop already reads `jump.* ?? options.*` for
the three algorithm fields, but `sftpBridge.cjs#connectThroughChainForSftp`
was missed in that change and still applied the target host's
`options.legacyAlgorithms` / `skipEcdsaHostKey` / `algorithmOverrides`
to every bastion. A bastion that needed the ECDSA skip or a custom
algorithm list while the target didn't would still fail the SFTP
handshake before reaching the target.

Mirror the sshBridge fix: read each setting from the jump host first,
falling back to the target options when the hop didn't override.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): keychain SSH exec + advanced editor seed honor inherited algorithm settings

Two findings from a local Codex review pass on the branch:

1. The keychain "export public key to host" flow opens its own one-off
   SSH connection through `sshBridge#execCommand`, but the connectOpts
   built there didn't include any `algorithms`. ssh2 then negotiated
   with its built-in modern defaults regardless of what the host had
   set, so a host that needs the ECDSA skip (or legacy mode) would
   connect in the terminal but the keychain export would fail with
   the original signature-verification error.

   - `sshBridge.cjs#execCommand` now sets `algorithms` from
     `payload.legacyAlgorithms` / `skipEcdsaHostKey` /
     `algorithmOverrides`, mirroring `startSSHSession`.
   - `useKeychainBackend`'s `execCommand` typing gets the three new
     optional fields.
   - `KeychainManager` forwards the host's `legacyAlgorithms`,
     `skipEcdsaHostKey`, and `algorithms` when invoking the export.

2. The Advanced Algorithm Overrides editor seeded its first
   customization from `form.legacyAlgorithms`, which is the host's
   own field — it doesn't reflect a value the host *inherits* from
   its group's default. A user in a group with `legacyAlgorithms=true`
   editing a host that hadn't explicitly set the flag would see the
   editor seed in modern-only mode, and saving could silently drop
   the legacy algorithms the host actually needed.

   - `HostDetailsPanel` passes `form.legacyAlgorithms ?? groupDefaults?.legacyAlgorithms`.
   - `GroupDetailsPanel` adds an `inheritedLegacyAlgorithms` memo
     resolved from the parent group chain via `resolveGroupDefaults`,
     and the editor uses `form.legacyAlgorithms ?? inheritedLegacyAlgorithms`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(keychain): export-public-key honors group-inherited algorithm settings

Second-pass Codex review on PR #1116 flagged that the keychain
"export public key to host" flow now carries the three algorithm
fields end-to-end, but only reads them from `exportHost.*` directly —
which doesn't reflect values the host inherits from its group's
defaults. A host that left `legacyAlgorithms` / `skipEcdsaHostKey` /
`algorithms` unset but sat inside a group that turned them on would
work fine in the terminal (the terminal starter applies group
defaults before sending the IPC payload) but its keychain export
would silently fall back to ssh2's modern defaults and hit the
original signature-verification failure.

Resolve the effective host with `applyGroupDefaults` +
`resolveGroupDefaults` before the `execCommand` call, then read the
algorithm fields off the effective host. Requires plumbing
`groupConfigs` into `KeychainManager` (added as an optional prop,
forwarded by `VaultView`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(host-details): algorithm-overrides editor reads currently selected group's defaults

Third-pass Codex review caught that the editor seed was reading from
the `groupDefaults` prop, which is whatever group the host belonged
to when the panel opened. If a user switched the host into a different
group inside the panel and then opened the Advanced Algorithm
Overrides collapsible before saving, the editor would seed from the
old group's `legacyAlgorithms` flag and could save the wrong list.

The panel already memoizes `effectiveGroupDefaults` from
`form.group`/`defaultGroup`/`groupConfigs` for exactly this kind of
re-resolution (used by theme/font effective lookups). Read the
inherited flag from there instead of the stale prop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(algorithms): trim UI cipher list to algorithms ssh2 actually supports

Fourth-pass Codex review flagged that `SUPPORTED_CIPHER_ALGORITHMS`
included `blowfish-cbc`, `cast128-cbc`, and the `arcfour*` family,
but OpenSSL 3 disabled those primitives, so ssh2's `canUseCipher`
filter drops them from `SUPPORTED_CIPHER` at startup. Selecting any
of them in the Advanced Algorithm Overrides editor would make
`ssh2.Client.connect()` throw `Unsupported algorithm` synchronously,
turning the override into a "host now unreachable" footgun instead
of a narrowing knob.

Realigned each `SUPPORTED_*_ALGORITHMS` list to ssh2's actual
`SUPPORTED_*` constants:

- `SUPPORTED_CIPHER_ALGORITHMS`: dropped blowfish / cast128 / arcfour;
  added the no-suffix `aes128-gcm` / `aes256-gcm` variants ssh2 also
  accepts.
- `SUPPORTED_KEX_ALGORITHMS`: added `diffie-hellman-group15-sha512`
  and `diffie-hellman-group17-sha512` (present in ssh2 but missing
  from the UI list); reordered to ssh2's canonical order.
- `SUPPORTED_HMAC_ALGORITHMS`: reordered so the ETM/SHA-2 grouping
  matches ssh2's `DEFAULT_MAC` and lookups are predictable.

Locked the invariant in `sshAlgorithmList.test.ts` with a new test
that asserts every UI-offered algorithm is a member of ssh2's
`SUPPORTED_*` for its category. A future ssh2 bump that drops an
algorithm we still expose will fail this test instead of silently
becoming a connect-time error for users.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(algorithms): run KEX override through the runtime fixed-DH filter

Codex round-N flagged a gap in `applyAlgorithmOverrides`: when the
user supplies a custom KEX list via the advanced editor, the previous
implementation copied it verbatim into the negotiated `algorithms.kex`
field. The default builder already passes its KEX list through
`filterSupportedFixedDhKex` to drop fixed-DH groups the runtime
doesn't support (notably `diffie-hellman-group1-sha1` on
Electron/BoringSSL, which lacks modp2), but the override path
bypassed that filter — so an Electron user enabling legacy mode and
saving any KEX checkbox state would re-advertise group1-sha1 and the
handshake would crash with "Unknown DH group" instead of failing fast.

Apply the same `filterSupportedFixedDhKex` to the override list. New
test in `sshAlgorithms.test.cjs` exercises a simulated BoringSSL
runtime that lacks modp2 and asserts the override-filtered KEX no
longer includes group1-sha1, while group14-sha1 / group-exchange-sha1
remain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(algorithms): run HMAC override through the FIPS MD5 filter

Codex flagged the same runtime-bypass pattern Round-N-1 fixed for KEX,
now in HMAC: `applyLegacyHmacAlgorithms` gates `hmac-md5` behind
`md5Supported()` so FIPS Node builds don't get it, but the UI's
`effectiveDefaultAlgorithms(true)` seed adds `hmac-md5` /
`hmac-md5-96` unconditionally. A user with legacy mode on who saves
any HMAC checkbox change would push those MD5 entries through
`applyAlgorithmOverrides`, which previously copied the override
verbatim — bypassing the FIPS gate and making ssh2 throw
"Unsupported algorithm" before negotiation.

New `filterRuntimeUnsupportedHmac` helper applies the same
`md5Supported()` gate to a user override. `applyAlgorithmOverrides`
routes the HMAC override through it (mirrors how the same function
already routes KEX overrides through `filterSupportedFixedDhKex`).
New test simulates a FIPS-disabled MD5 runtime and asserts MD5
variants drop from the final HMAC list while SHA-1 / SHA-2 entries
remain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): jump-host overrides no longer inherit the leaf's algorithm overrides

Codex flagged that the jump-loop fallback I added for chain
convenience applied the target's per-host \`algorithmOverrides\` to
every bastion whose own override wasn't set, which is wrong: a target
restricted to e.g. \`serverHostKey: [\"ssh-rsa\"]\` would lock the hop
to ssh-rsa too and break negotiation against an Ed25519-only bastion.

The fallback IS still correct for \`legacyAlgorithms\` and
\`skipEcdsaHostKey\` — those are append/safety toggles that widen the
offered list, so propagating them to a bastion is safe and matches
the historic chain-wide behavior of \`options.legacyAlgorithms\`
(single-toggle convenience for a chain with one old leaf).

Treat \`algorithmOverrides\` strictly per-host instead. Same change in
both \`sshBridge.cjs\` and \`sftpBridge.cjs\` jump loops, with a
comment block explaining the asymmetry so a future refactor doesn't
"clean up" the distinction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ui(host-details): surface inherited algorithm overrides in the advanced editor

Codex flagged a real gap in the inheritance model: when a parent
group has set algorithm overrides (e.g. \`algorithms.kex: [...]\`),
a host or child group under it can't simply Reset a category back to
NetCatty's defaults — \`applyGroupDefaults\` treats an unset host
field as "inherit", so the local Reset falls back to the group's
list rather than to ssh2's defaults. Cleanly distinguishing "reset
to NetCatty defaults" from "inherit from group" needs a new schema
field or sentinel, which is a non-trivial design change well beyond
the scope of this PR.

For now, surface the situation in the UI so the user understands
why Reset doesn't behave the way they might expect and where to go
to actually clear the restriction:

- \`AlgorithmOverridesPanel\` accepts an optional \`inheritedFromGroup\`
  prop and, when populated, renders a blue inline notice listing the
  inherited categories and directing the user to the group's
  algorithm settings if they need to opt out.
- \`HostDetailsPanel\` passes \`effectiveGroupDefaults?.algorithms\`.
- \`GroupDetailsPanel\` adds a new \`inheritedAlgorithmOverrides\`
  memo that resolves the same way the existing
  \`inheritedLegacyAlgorithms\` does, and forwards it.
- i18n strings added in zh-CN / en / ru.

Follow-up (out of scope for this PR): if real users do hit this,
introduce a \`host.algorithms = null\` (or explicit
\`algorithmsOverride: boolean\`) sentinel and a Reset-to-defaults
button that uses it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): jump-host skipEcdsaHostKey is per-host; panel seeds from inherited overrides

Three findings from the latest Codex pass:

1 & 2. `skipEcdsaHostKey` on the leaf was still falling back onto
   jump hosts in both `sshBridge.cjs` and `sftpBridge.cjs`. I had
   classified it with `legacyAlgorithms` as a "safety widening" knob,
   but Codex is right that it actually *narrows* the offered host-key
   list by dropping every `ecdsa-sha2-*`. An ECDSA-only bastion (or
   one where the operator pinned ECDSA via known_hosts) would still
   negotiate when ECDSA is offered, but fails when the leaf's skip is
   propagated to it. Same fix as `algorithmOverrides` last round:
   strictly per-host. Only `legacyAlgorithms` keeps the chain-wide
   fallback (append-only — it widens the offer, can never break a
   hop that wasn't already failing).

3. `AlgorithmOverridesPanel` was seeding the first customization of a
   category from the NetCatty/legacy effective default, ignoring any
   list the host inherits from its group for OTHER categories.
   Because `applyGroupDefaults` treats `host.algorithms` as an
   all-or-nothing inherit boundary, the moment the user saved any
   host-local override `{ cipher: [...] }`, the group's
   `{ serverHostKey: ["ssh-rsa"] }` restriction silently dropped from
   the effective host. Now:
   - `toggleAlgorithm`'s first-click seed reads
     `inheritedFromGroup?.[category] ?? effectiveDefault[category]`,
     so customizing one category preserves the group's narrowing on
     the others.
   - `updateCategory` initializes `next` from
     `{ ...inheritedFromGroup, ...value }` so saved overrides carry
     the inherited categories alongside the host's own edits.
   - `isChecked` reflects the inherited list when there's no local
     value, so the visible checkbox state matches what would actually
     be advertised.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(host-details): Reset preserves the inherited list instead of silently widening

Codex caught a follow-on bug from the previous "carry inheritance"
fix: pressing Reset on a category that's inherited from the group
deleted that category from \`host.algorithms\` while other host-local
or carried-inherited categories remained. Because
\`applyGroupDefaults\` treats \`host.algorithms\` as all-or-nothing,
the moment any host-local entry exists the group's \`algorithms\`
object stops being inherited as a whole — so the freshly-deleted
category fell back to NetCatty defaults rather than the group's
narrower list. Effective result: Reset on an inherited category
*widened* the offer instead of restoring it.

Reset now persists the inherited list verbatim onto the host when
the group has an override for that category, so "Reset" means "use
what this host would otherwise inherit" in all cases.

Also tightened \`isCustomized\` to suppress the "customized" badge
(and the per-category Reset button) when the host's stored list is
identical to the inherited list — those rows haven't really been
customized by the user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ui(host-details): gate the inherited-notice / checkbox baseline on no host override

Codex caught the asymmetry between the read and write sides of the
panel. The write side (`updateCategory`, `toggleAlgorithm`, Reset)
intentionally carries `inheritedFromGroup` onto the host on the first
edit so the runtime's all-or-nothing inherit boundary in
`applyGroupDefaults` doesn't silently widen the offer. But the read
side (the inherited-notice banner and the `isChecked` baseline for
unfilled categories) was applying inherited values *unconditionally*
— including when the host already had any local `algorithms` object,
which makes `applyGroupDefaults` stop inheriting from the group as a
whole. Net effect on a host that only locally overrode `cipher`: the
UI claimed the group's `serverHostKey` restriction was still in
effect, while the runtime would actually use NetCatty's modern
defaults for that category.

Introduce `inheritedForDisplay`, defined as `value === undefined ?
inheritedFromGroup : undefined`, and route the notice + the
`isChecked` baseline through it. The write side keeps consulting the
unconditional `inheritedFromGroup` so the carry-over still happens on
first edit — that part is what makes the runtime behavior match what
the UI used to advertise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ui(host-details): legacy/skipEcdsa toggles reflect group-inherited value

Codex caught that the Skip ECDSA toggle in both the host and group
panels read `form.skipEcdsaHostKey` directly, so a host whose group
turned the flag on (and `applyGroupDefaults` therefore applied it to
the runtime SSH negotiation) still saw the toggle rendered as off.
Worse, clicking it computed `!form.skipEcdsaHostKey` — which is `!undefined`
= `true` — so the first click could not actually disable the
inherited setting on a per-host basis; it would just store `true`
explicitly, the same effective state.

The Allow Legacy Algorithms toggle had the same pre-existing issue.
Fix both at once: each toggle now derives its enabled state from
`form.<field> ?? <inherited>` and the onToggle handler flips that same
effective value, so the toggle accurately represents what the runtime
would do and a single click off correctly stores `false`
(which `applyGroupDefaults` then leaves alone, breaking inheritance
for this host).

- `HostDetailsPanel` reads from `effectiveGroupDefaults`.
- `GroupDetailsPanel` adds an `inheritedSkipEcdsaHostKey` memo
  alongside the existing `inheritedLegacyAlgorithms` and uses both.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 15:12:42 +08:00
Pyro
07a2f3a899 fix(shortcuts) keyboard shortcuts on non-English and non-QWERTY layouts (#1109)
* (fix) keyboard shortcuts on non-English layouts

* fix(shortcuts): respect Latin layout keys

* fix(shortcuts): preserve non-ASCII Latin keys

* fix(shortcuts): preserve punctuation layout keys

* fix(shortcuts): preserve shifted number-row symbols

---------

Co-authored-by: pyroch <cvdysh@gmail.com>
2026-05-27 11:20:08 +08:00
陈大猫
399e6a6f2d fix(terminal): use client OS for local-shell autocomplete clear sequence (#1112) (#1115)
* fix(terminal): use client OS for local-shell autocomplete clear sequence (#1112)

Synthetic fallback Host in TerminalLayer hardcoded os: 'linux', so the
'host.os || navigator.platform check' expression in Terminal.tsx never
ran the client-OS detection branch for local shells. Result: autocomplete
emitted Ctrl-U (\x15) for line-clear on Windows local PowerShell/cmd,
where it's rendered literally as ^U instead of erasing the input — the
popup live-preview, fuzzy-accept, and snippet-accept paths all leak it.

Fix both ends: Terminal.tsx prefers client OS detection for local
protocol via the existing detectLocalOs helper; TerminalLayer.tsx
populates the synthetic host's os field from detectLocalOs too, so
other host.os readers (notably the server-stats gate in
Terminal.tsx:642) also stop treating local Windows as Linux.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(terminal): keep non-local fallback host on 'linux'

The TerminalLayer fallback path also triggers for unsaved serial sessions
and orphaned remote sessions (where the saved host was deleted while the
session lived on). Terminal.tsx trusts host.os for non-local protocols,
so tagging the fallback with the Windows client OS would push POSIX
remote/serial shells onto the Windows autocomplete/path-completion
branch. Gate the client-OS detection on protocol === 'local'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:17:52 +08:00
陈大猫
46d1cf1696 chore(ai): drop unused eslint-disable around suppressed SDK warning (#1111)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
PR #1110 added an \`// eslint-disable-next-line no-console\` above
\`console.warn\` when suppressing SDK reasoning/text state-machine
errors, but this file already permits \`console.*\` calls (there are
several pre-existing \`console.error\` lines in the same hook) so the
directive is unused and ESLint flags it:

\`\`\`
666:13  warning  Unused eslint-disable directive (no problems were
reported from 'no-console')
\`\`\`

Removes the directive; the \`console.warn\` stays.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 01:29:33 +08:00
陈大猫
5be9bb58df fix(ai): suppress SDK reasoning/text state-machine error chunks (#1110)
Issue #1101 follow-up. When a third-party Anthropic-compat backend
streams thinking deltas without first emitting a `reasoning-start`
content-block signal (DeepSeek's \`-v4-flash\` is the canonical
offender), the Vercel AI SDK has nothing registered for the incoming
\`part.id\` and enqueues an \`error\` chunk on \`fullStream\` with the
text \`reasoning part <id> not found\` — once per orphan delta. The
analogous error exists for text parts.

These are internal SDK bookkeeping noise, not user-facing errors. Our
\`case 'error'\` handler was treating each one as a real error: it
appended an empty assistant message carrying the SDK string. The
visible damage was a wall of red banners in the chat panel — but the
worse damage was protocol-level. On the next turn we rebuild
\`sdkMessages\` from local history; the placeholder assistants slot
in between the assistant that holds the parallel \`tool_use\` blocks
and the subsequent \`role: 'tool'\` messages with their results. The
Anthropic SDK's \`groupIntoBlocks\` only merges *consecutive* tool
messages into a single user block, so the tool_results no longer
immediately follow their tool_use parent — and the backend rejects
the request as
\`400 messages.N: tool_use ids were found without tool_result blocks
immediately after\`. \`messages.N\` was literally counting our 12-ish
phantom assistants.

Adds \`isSdkStreamStateError(error)\` to
\`infrastructure/ai/shared/streamStateErrors.ts\` (matches
\`/^(reasoning|text)\\s+part\\s+\\S+\\s+not\\s+found$/i\` against
string / Error / \`{ message }\` shapes), and skips placeholder
emission for matching errors in the streaming hook. Drops a
\`console.warn\` so the noise is still visible in devtools when
debugging.

5 unit tests cover positive matches, the Error/object wrappings,
non-matches that should stay surfaced, and non-string inputs.

Refs #1101.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 01:02:26 +08:00
陈大猫
cab4fc36ab Serialize Catty terminal_execute per session to fix the parallel tool_use race (#1108)
* fix(ai): serialize Catty terminal_execute calls per session

Issue #1101 problem 3. When the LLM emits multiple tool_use blocks in
one assistant turn (DeepSeek's Anthropic-compat happily does this for
parallel info-gathering), the Vercel AI SDK dispatches every tool
through `Promise.all(toolCalls.map(execute))`. All of them then race
into the same per-session mutex inside the main-process bridge
(`mcpServerBridge.reserveSessionExecution`). One wins; the rest get
`{ ok: false, error: "Session already has another command in
progress..." }` synthesized back as the tool result. The LLM sees a
turn full of synthetic errors instead of the answers it asked for, the
UI cards look stuck on "executing", and the Anthropic API has
sometimes rejected the resulting trace as
`tool_use ids were found without tool_result blocks`.

Adds an in-renderer Promise-chain queue keyed by
`${chatSessionId}:${terminalSessionId}` so the actual
`bridge.aiExec()` calls are issued one at a time per terminal. The LLM
can still parallel-call as many tool_use blocks as it wants — they
just resolve sequentially with real output. Approval prompts are kept
*outside* the queue: three parallel tool_use blocks still surface
three approval cards together, so the user can dispatch them in any
order rather than waiting for each prior command to finish before the
next prompt appears. The bridge-side mutex stays as defense-in-depth
for non-LLM paths (terminal_start, MCP, etc.).

`chainBySessionKey` is a self-contained helper with cleanup logic so
the queue map doesn't leak across many short-lived sessions:
- Each task awaits the previous tail via `.then(task, task)` so a
  failure doesn't poison the chain.
- A non-rejecting wrapper is stored as the new tail to keep that
  contract regardless of how the task settles.
- The cleanup `finally` only deletes the map entry when the current
  tail is still ours, so a caller that arrived between
  `set` and `finally` doesn't have its tail evicted.

Four unit tests cover dispatch ordering, rejection isolation,
per-key parallelism, and the cleanup path. Full suite 1255/1255.

Refs #1101.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): preserve LLM emission order + honor abort in serialized queue

Codex local review on the first cut of this branch flagged two real
issues plus a weak cleanup test:

1. **Approval order vs queue order** — the previous version awaited
   approval *before* reserving a queue slot. With three parallel
   tool_use blocks the user could approve B's prompt first, and B
   would slip into the queue ahead of A, running out of LLM emission
   order. Reserving the slot synchronously up front fixes that:
   `Promise.all`-dispatched executes each grab a slot at the same
   instant in their dispatch order, then wait on approval while
   holding it, then await `slot.ready` and run.

2. **Abort not honored** — once approvals were settled, queued-but-
   not-started commands ran to completion even after the user hit
   Stop. Their results were ignored by the SDK but the side effects
   still happened. Two `abortSignal` checks now short-circuit before
   the queue wait and again after.

3. **Cleanup test was vacuous** — the previous "drains" test would
   pass even if the cleanup logic were removed. Exposed
   `getSessionExecutionQueueSizeForTests` and assert the Map is empty
   after the only queued task settles.

Queue API now has two entry points: high-level `chainBySessionKey`
for run-it-when-it's-your-turn callers, and lower-level
`reserveSessionSlot` for callers (like `terminal_execute`) that
need to do non-blocking pre-work in parallel with the queue wait.

Two new tests cover the reservation-order-vs-prework-order guarantee
and the skip-without-serialized-work path.

Refs #1101.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): re-issue cancel on abort to close IPC-transit race

Codex local review (round 2) flagged a small remaining race: between
the abort check after \`slot.ready\` and the main-process bridge
registering the new exec into \`activePtyExecs\`, the user can hit
Stop. \`handleStop\` does issue \`aiCattyCancelExec\`, but if that
cancel IPC arrives at main *before* the exec has finished
registering, the cancel finds nothing to cancel and the exec keeps
running. Result: the user already cancelled, but the command runs
anyway.

Plug the gap by re-issuing the cancel from the tool's own abort
listener. Once the exec has registered into \`activePtyExecs\` (a
synchronous step on the main side once the IPC lands), the duplicate
cancel finds the entry and cancels it. \`cancelPtyExecsForSession\`
is already idempotent — it iterates the live tracker and skips
entries with mismatched \`chatSessionId\` — so double-firing from
both \`handleStop\` and the tool is safe.

Extends \`NetcattyBridge\` with the optional
\`aiCattyCancelExec(chatSessionId)\` method. Already exposed in
\`electron/preload.cjs\` so the runtime call works on the live
bridge; the type addition just makes it visible to the tool.

Refs codex review on #1101 problem 3 fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): register pending-cancel marker before SSH exec-channel opens

Codex local review (round 3) caught a remaining race in
`execViaChannel`: the cancellation marker is only registered inside
`sshClient.exec`'s open callback, which is async. If a cancel arrives
in the window between `sshClient.exec(...)` being dispatched and the
callback firing, `cancelPtyExecsForSession` finds nothing for the
session and is a no-op. The channel then opens, the marker
registers, and the command runs to completion — exactly the "user
already cancelled, but the command still ran" failure mode.

Plug the window by registering a *pending* marker synchronously
before `sshClient.exec`. The pending marker carries a `cancel()` that
just latches a `cancelled` flag and a `cleanup()` that is a no-op.
When the open callback fires it removes the pending marker and
checks the latch: if it tripped, close the just-opened stream and
resolve with `{ ok: false, error: "Cancelled" }`. If not, the normal
post-open registration takes over with the real `execStream` close.

The PTY (`execViaPty`) and raw-serial (`execViaRawPty`) paths
already register their marker before any async wait, so they don't
share this race.

Two regression tests in `ptyExec.test.cjs`:
- The pending marker is present immediately after `execViaChannel`
  returns, before the open callback runs.
- A cancel during the pre-open window short-circuits the eventual
  callback with `{ ok: false, error: "Cancelled" }` and closes the
  now-unwanted stream.

Refs codex review on #1101 problem 3 fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): clean up pending-cancel marker when sshClient.exec throws sync

Codex local review (round 4) noted that if `sshClient.exec(...)`
itself throws synchronously — e.g. because the underlying ssh2 client
was destroyed between the session lookup and the actual `.exec`
call — the pending-cancel marker stays in `activePtyExecs`
indefinitely and the surrounding Promise rejects instead of
resolving with the normal `{ ok, error }` shape the tool layer
expects.

Wraps the `sshClient.exec` invocation in `try/catch` so:
- Pending marker is removed when the throw escapes.
- The Promise resolves cleanly with `{ ok: false, error: err.message }`
  so the tool layer gets the same shape it does for every other
  failure mode (closed stream, timeout, cancellation).

Adds a regression test covering this exact path: a fake client whose
`.exec` throws synchronously; the result is a clean failure and the
cancellation map is empty afterwards.

Refs codex review on #1101 problem 3 fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:45:37 +08:00
陈大猫
53d3e05bb4 Per-agent provider switcher chip in the Catty Agent chat input (#1107)
* feat(ai): provider switcher chip in the Catty Agent chat input

Catty Agent currently has no model chip in the chat input — modelPresets
is empty for the catty agentId so `hasModelPicker` is false. The
provider/model the chat ends up using comes from the global
`activeProviderId` / `activeModelId`, which means switching providers
requires opening Settings → AI → Providers. Closes the gap surfaced in
the original feedback on #1101 (problem 2) and partially addresses
#986 (per-context model switching) by going per-agent.

State layer adds an `agentProviderMap` (`Record<agentId, providerId>`)
alongside the existing `agentModelMap`. Both are written together when
the user picks from the new dropdown; together they form a per-agent
override that beats the global active provider/model when set. Cross-
window sync mirrors the agentModelMap pattern.

ChatInput grows a `providerSwitcher` prop carrying the enabled
ProviderConfigs, the selected (providerId, modelId), and an onSelect
callback. When supplied, the model chip switches from the Cpu glyph
to the provider's ProviderIconBadge + `providerName · modelId`, and
the popover renders a two-column layout — providers on the left
(icon + name + default model caption), that provider's known model(s)
on the right. ACP agents (Claude/Codex) are untouched because they
plumb their provider through the CLI, not the Vercel AI SDK.

AIChatSidePanel resolves `effectiveActiveProvider` /
`effectiveActiveModelId` for the catty agent from the per-agent map,
falling back to the global selection. handleSend now passes those
through to sendToCattyAgent so a provider picked in one Catty session
applies everywhere Catty runs.

For v1, each provider's right column shows just its configured
`defaultModel`. The two-level structure is in place to absorb richer
listings (cached /models, manual additions) later without UI
surgery — left a note in the popover code.

Refs #1101, #986.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* polish(ai): flatten provider picker to a single list

Two-level dropdown was theatre — each ProviderConfig exposes exactly
one model (its `defaultModel`), so the right column ended up showing
the same string already captioned on the left row. Picking a provider
implicitly picks its model; one click is enough.

Also drops the `p.enabled !== false` filter on the picker list. The
user's expectation, confirmed by feedback on the first cut, is that
the chat-input list mirrors what Settings → AI → Providers shows. The
per-provider `enabled` toggle is an "active-ish" flag, not a
visibility gate; hiding disabled providers in the picker silently
made everything but the one currently active disappear, which is what
the reviewer hit.

Pares the popover down to a vertical list — provider icon + name +
default-model caption (mono if set, italic "configure default model"
hint if not) + a Check on the bound row. Removes the `hoveredProviderId`
state along with the right-column rendering since it's no longer
driving anything.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* polish(ai): tune provider icon sizes in the chat input

Chip icon was sitting at sm (20px badge) and crowded the h-6 toolbar
chip alongside the truncated label; popover rows used the same sm, so
nothing differentiated the picker from the chip visually.

Adds an xs size to ProviderIconBadge (16px badge, 10px glyph, 9px
letter fallback) and uses it on the chip. Popover rows step up to md
(32px badge, 16px glyph) and the row padding grows accordingly so the
larger brand mark has room to breathe — the picker now reads like a
list of choices, the chip reads like a status line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): keep per-agent override honest in model fallback + chip label

Two codex P1/P2 findings on #1107:

P1 — When Catty has a per-agent provider override but no per-agent
model (agentModelMap['catty'] empty), cattyAgentModelId fell through
to the global `activeModelId`. That id belongs to whichever provider
was globally active, not the one Catty is now bound to, so e.g.
overriding to DeepSeek without a defaultModel happily sent gpt-4o
(global) to DeepSeek and produced a wrong-model error. The fallback
is now: stored agent model → override provider's defaultModel → empty.
Only the no-override path keeps the activeModelId tail; the
no-provider send guard catches the empty case for everyone else.

P2 — ChatInput's selectedSwitcherProvider used `?? providers[0]` so
the chip always displayed *some* provider even when none was actually
bound (selectedProviderId missing). The rest of the pipeline (send
guard, agentProviderMap) treats that as "no provider", so the chip
was lying. Dropped the fallback; when nothing is bound the chip now
shows a generic Cpu glyph + "Select provider" label until the user
picks one from the popover. Adds `ai.chat.selectProvider` in en /
zh-CN / ru.

Refs codex review on #1107.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): purge per-agent binding when the bound provider is deleted

Codex local review (round 2) flagged that a saved Catty model id could
outlive its provider. The chain:
- catty bound to provider A (DeepSeek): agentProviderMap['catty']='A',
  agentModelMap['catty']='deepseek-v4-flash'
- user deletes provider A
- cattyAgentProvider falls back to the global active provider (B, OpenAI)
- cattyAgentModelId still returned the saved 'deepseek-v4-flash'
- send dispatched the DeepSeek model id to OpenAI → wrong-model error

Two layers of fix:

1. **Defensive resolution** (AIChatSidePanel) — cattyAgentModelId now
   looks at the override provider before trusting the stored model id.
   If the override is stale (provider deleted), the stored model id is
   treated as orphan and the resolution falls back to the global active
   selection just like cattyAgentProvider already does.

2. **Cleanup on remove** (useAIState.removeProvider) — when a provider
   is deleted, any agent whose agentProviderMap entry pointed at it
   gets both maps cleared (provider override + saved model). Same
   rationale: that model id is now meaningless against every other
   provider. Mirrors the existing activeProviderId cleanup.

Adds a small agentProviderMapRef so removeProvider can snapshot which
agents to clean up without relying on the captured-at-callback-create
state (which would be stale).

Refs codex local review on #1107.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): include agentProviderMap in settings sync + block empty-model send

Codex local review (round 3) found two issues:

P2 — `agentProviderMap` was missing from the cross-device sync surface.
Added it to the sync key list, the build path, the apply path, and the
SyncPayload type alongside the existing `agentModelMap`. Sync tests in
`syncPayload.test.ts` now cover the new field on both the build and
apply sides.

P2 — Provider rows with no `defaultModel` were still clickable in the
chat-input picker, so selecting one would save a binding with empty
model id; the send path then dispatched an empty model name and the
SDK would surface a vague backend error. Two changes:

1. ChatInput disables the row (\`disabled\`, \`aria-disabled\`, dimmed
   styling, tooltip pointing the user to Settings) when the provider
   has no \`defaultModel\`. Click is suppressed.
2. AIChatSidePanel \`handleSend\` adds a model-required guard mirroring
   the existing no-provider guard — surfaces \`ai.chat.noProviderModel\`
   as an assistant message. Catches stale bindings (e.g. user edited
   the provider's \`defaultModel\` to empty after binding) before they
   reach the SDK.

Refs codex local review on #1107.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): trim whitespace-only model ids + reconcile orphan bindings on sync

Codex local review (round 4) found two more leaks:

P2 — Whitespace-only `defaultModel` passed the picker's `disabled` gate
(which trims) but every other layer (cattyAgentModelId resolution,
the send-guard `!sendActiveModelId` check, the SDK call) compared the
raw string. A provider whose default model was "   " could still reach
the SDK and surface a vague backend error. Normalized: trim at the
resolution boundary (catty model fallback chain) and trim before the
send-guard's existence check.

P2 — Sync apply did not reconcile per-agent bindings against the
incoming provider set. A payload could change `providers` without
shipping a fresh `agentProviderMap`, leaving local overrides pointing
to provider ids the synced set no longer includes — the same ghost-
binding bug that `removeProvider` already handles for explicit user
deletes. Added `pruneOrphanPerAgentBindings()` that runs after every
AI settings apply: it walks `agentProviderMap`, drops any entry whose
provider id isn't in the current `providers` list, and clears the
saved model id for those agents alongside it (mirroring removeProvider
cleanup). A new test in `syncPayload.test.ts` exercises the ghost-
binding case end-to-end.

Refs codex local review on #1107.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): notify open AI state of cross-device sync apply in the same window

Codex local review (round 4) flagged that `applySyncPayload` writes
straight to localStorage but `useAIState` only re-reads on browser
`storage` events. Those events only fire in *other* windows — the
window doing the apply (the one with the active chat panel) keeps
showing pre-sync providers and per-agent bindings until reload.
Concrete leak: cloud sync swaps in a new provider set, pruning runs,
localStorage is correct, but the open Catty chip still references the
ghost provider binding until the user reloads.

Extracts the existing `AI_STATE_CHANGED_EVENT` constant +
`emitAIStateChanged` helper out of `useAIState` and into a new
`application/state/aiStateEvents.ts` so non-React call sites (sync
apply, future IPC handlers) can fire it without pulling in the hook.
After AI settings apply, `syncPayload.ts` walks every AI key it
touched (including the agentProviderMap / agentModelMap that the
reconcile step may have mutated even when the payload didn't ship
them) and emits a same-window nudge. `useAIState`'s existing local
listener routes unknown keys back through its storage handler, so
each affected piece of React state rehydrates from localStorage.

Adds a regression test in `syncPayload.test.ts` that asserts the
expected `netcatty:ai-state-changed` keys are dispatched for the
providers + per-agent map keys.

Refs codex local review on #1107.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:03:08 +08:00
陈大猫
0c4de74c84 polish(ai): hint that the provider icon is clickable (#1106)
The icon badge next to the Display Name input opens the icon picker
but had no visual cue — users had no reason to suspect it was
clickable, and reviewer feedback flagged it as too hidden.

Adds a primary-tinted ring on hover/focus plus a small pencil glyph
overlaid on the bottom-right corner of the badge (also hover/focus
gated). The badge itself doesn't change shape, so the layout stays
stable; the affordance is purely additive.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:13:15 +08:00
陈大猫
2a4feea40f Customizable provider name, icon, and protocol style (#1105)
* feat(ai): customizable provider name, icon, and protocol style

Issue #1101 reported that the only way to wire DeepSeek through the
Catty Agent was to add an "Anthropic" provider with a DeepSeek base URL,
which (a) wasn't relabelable except on the "custom" providerId and
(b) implicitly conflated wire-protocol style with providerId, locking
"custom" to the OpenAI-compatible client.

ProviderConfig now carries three optional fields — `style`
(anthropic/openai/google), `iconId` (built-in brand glyph key), and
`iconDataUrl` (user upload). createModelFromConfig routes on the
resolved style first, falling back to providerId only for per-vendor
quirks (ollama's throwaway apiKey and openrouter's baseURL). Display
name becomes editable for every provider, not just custom.

The icon picker exposes a curated lobe-icons subset (MIT) covering the
common Anthropic/OpenAI-compatible third parties — DeepSeek, Moonshot,
Kimi, Qwen, Zhipu, Doubao, Mistral, Cohere, Grok, Perplexity, Groq,
Hugging Face — plus the existing six built-ins. Uploads are downsampled
to 64×64 WebP on a canvas so localStorage doesn't blow up. See
public/ai/providers/NOTICE.md for attribution.

Closes part of #1101 (problem 2). PR2 will reuse the new name/icon
plumbing in the Catty Agent chat input to surface a provider switcher.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* polish(ai): bigger labelled icon tiles and auto-fill display name

The first pass shipped the icon picker as an 8-column grid of bare
20px badges. Reviewer feedback: the icons are too small to read at a
glance, every preset should announce its brand name, and a second
click on a selected preset should let the user back out.

Switches the grid to auto-fill 120px tiles with a 32px icon plus a
truncated label so the picker reads like a brand list, not a sprite
sheet. Selecting a preset now also writes the brand's canonical
English display name into the Display Name field — the picker labels
keep their "/ 中文" suffix as a bilingual hint, but a new `name` field
on each catalog entry supplies a clean string for autofill (e.g.
"Qwen / 通义" → "Qwen"). Re-clicking the selected tile clears
iconId/iconDataUrl but leaves the typed name alone, so users who
already edited the name don't lose their edit on accidental toggle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* polish(ai): add explicit close button to icon picker

The picker only opened via the icon badge above the Display Name field,
which left no obvious affordance to collapse it once a preset was
chosen — users either had to scroll past or click back up to the icon.
Drops a ghost Close button in the bottom-right of the picker's action
row (next to Upload/Reset) so dismissing the panel is one click and
visible without hunting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): wire model discovery to the resolved provider style

Codex review on #1105 flagged that ModelSelector still derives auth
headers from `providerId` (`x-api-key` only when providerId is the
literal "anthropic"). Now that ProviderConfig lets the user override
`style` independently, the form persists the override but the model
discovery call ignored it — so picking an Anthropic providerId pointed
at an OpenAI-compatible backend (or vice versa) would send the wrong
header and fail to list models even though chat routing already used
the override.

Extracts `buildModelDiscoveryHeaders(style, apiKey)` as a pure helper
in infrastructure/ai/. ModelSelector now resolves the protocol family
via `resolveProviderStyle` with the explicit `style` prop taking
precedence, then asks the helper for headers. ProviderConfigForm
passes the resolved style through. The `needsApiKey = providerId !==
"ollama"` shortcut stays as-is — that's a per-vendor concession, not a
style decision.

Refs codex review on #1105.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): apply per-vendor URL fallbacks across every style override

Codex review on #1105 caught that the openrouter (and ollama) baseURL
fallback was sitting inside the `style === 'openai'` branch, so a user
who picked OpenRouter providerId + Anthropic/Google style with an
empty baseURL would skip the fallback entirely. The SDK then routed
to its own default endpoint (api.anthropic.com /
generativelanguage.googleapis.com) using the user's OpenRouter key —
silent misrouting plus auth failures.

Pulls the per-vendor quirks out into a new pure helper
`resolveProviderEndpoint(config, style, safeApiKey)`. The URL
fallback now fires for every style — the user picked openrouter for a
reason, even if they overrode the wire format. The ollama-only
`'ollama'` literal apiKey swap stays gated on `style === 'openai'`
because Anthropic/Google clients need a real key, not the throwaway
placeholder.

Refs codex review on #1105.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): send x-goog-api-key for google-style model discovery

Codex review on #1105 flagged that buildModelDiscoveryHeaders bucketed
google-style providers with the OpenAI-compat default, so a model
refresh against any google-routed endpoint was sending `Authorization:
Bearer …`. That's the wrong auth header — Google Generative AI
rejects Bearer entirely and expects `x-goog-api-key` (or `?key=`).
The runtime chat path already uses `createGoogleGenerativeAI`, which
authenticates with the Google header family, so discovery was the
odd one out.

Adds an explicit `google` branch returning `{ "x-goog-api-key":
<apiKey> }` and updates the unit test. The default google providerId
never hits this path today (PROVIDER_PRESETS["google"] has no
modelsEndpoint), but the style override surface created in this PR
lets users compose providerId + style pairs where discovery does
fire — e.g. an openai/anthropic-style providerId pointed at a Google
endpoint via baseURL override.

Refs codex review on #1105.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): pick discovery endpoint from resolved provider style

Codex review on #1105 pointed out the lopsided fix in ae7394c8: I
switched discovery *headers* to honor the resolved `style` but left
the URL path coming straight from `PROVIDER_PRESETS[providerId]`. So
the moment style and providerId disagree the request shape is half
correct — e.g. Anthropic providerId + style=openai sends Bearer
(right) at `/v1/models` (wrong; the OpenAI-compat backend exposes
/models). 404s either way.

Adds `STYLE_DEFAULT_MODELS_ENDPOINT` (`anthropic → /v1/models`,
`openai → /models`, `google → undefined`) and a
`resolveModelsDiscoveryEndpoint(style, presetEndpoint)` helper. The
style's convention wins; the caller-supplied `presetEndpoint` is the
fallback for styles with no listing convention (currently just
google).

Behavior for stock configs is unchanged — every preset's existing
modelsEndpoint matches its style's default. Mismatches now line up
(headers + path together), and `custom` providers gain a sensible
discovery attempt when the user sets a style. My earlier inline reply
ducking this was wrong; codex's call was right.

Added three unit tests covering style defaults, the override-on-flip,
and the google-style passthrough.

Refs codex review on #1105.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:41:19 +08:00
Pyro
faa90e1aa5 Reduce interactive terminal latency with TCP_NODELAY (#1103)
Co-authored-by: pyroch <cvdysh@gmail.com>
2026-05-26 15:31:14 +08:00
陈大猫
1aa96c3490 Fix Claude ACP Windows shim launch (#1102) 2026-05-26 14:25:03 +08:00
陈大猫
0e80955e96 fix(hosts): make group startup-command field multi-line typeable (#1100)
The group details panel rendered Startup Command as a single-line <Input>,
so users couldn't type newlines into it — only the first line ever made it
into the saved config, which then broke the multi-line sequencing behavior
shipped in #1096 for any host inheriting the command from its group.
Switch to a 3-row <Textarea> to match the per-host details panel, so a
multi-line command typed on the group is preserved end to end.

Refs #1083.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 10:27:00 +08:00
陈大猫
7771592cf2 feat(shortcuts): Ctrl+W closes the tab directly + add configurable side-panel toggle (#1098)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
* feat(shortcuts): add resolveSidePanelToggleIntent pure resolver

* feat(shortcuts): Ctrl+W closes the tab directly (drop side-panel priority)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(shortcuts): register toggle-side-panel binding (default ⌘/Ctrl+\)

* feat(shortcuts): add side-panel toggle handler + last-panel memory in TerminalLayer

* feat(shortcuts): dispatch toggleSidePanel hotkey to TerminalLayer

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 02:42:41 +08:00
陈大猫
6e9e8fc40d feat(autocomplete): make snippets a first-class terminal completion source (#1097)
* feat(autocomplete): add snippet completion source

* feat(autocomplete): merge snippets at the command position

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(autocomplete): place snippet tests where the test runner finds them

The npm test glob covers components/terminal/*.test.ts but not the
autocomplete/ subdirectory, so the snippet tests added in the previous two
commits weren't actually running in the suite. Move them up to
components/terminal/ (the existing convention for autocomplete tests) with
corrected import paths; the engine snippet cases go in a separate
completionEngineSnippets.test.ts to avoid colliding with the existing
completionEngine.test.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(autocomplete): snippet ghost-exclusion, preview skip, and accept path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(autocomplete): wire snippets into the terminal + preview snippet command

* fix(autocomplete): show only label in snippet popup row; keep snippet over colliding history

The popup row for a snippet now omits the inline command echo — the full
command lives in the detail preview only, matching the "label-only row"
design. The completion engine pushes snippet suggestions without the early
seen-text skip so that when a snippet's label collides with a history
entry's text, the higher-scored snippet survives the final dedup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): broadcast snippet command so popup acceptance mirrors peers

In broadcast mode, accepting a snippet from the autocomplete popup cleared
peer input (the line-clear keystrokes flow through the broadcast-aware path)
but never sent the command, since executeSnippetCommand wrote only to the
active session. Broadcast the normalized snippet data (matching the snippet
shortkey path) so peers receive both the clear and the command, keeping all
sessions in sync.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): broadcast wrapped snippet bytes to preserve noAutoRun

Broadcasting the raw normalized command sent un-wrapped newlines to peers,
so a multi-line noAutoRun snippet was pasted-but-not-run on the active
session yet executed line-by-line on broadcast peers (handleBroadcastInput
writes bytes directly without re-wrapping). Broadcast the exact bytes the
active session receives instead — bracketed-paste wrapping plus the auto-run
\r — so peers mirror the active session and noAutoRun is preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 02:03:33 +08:00
陈大猫
67448cea65 feat(terminal): configurable startup-command delay + multi-line sequencing (#1096)
* feat(terminal): add global startupCommandDelayMs setting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(terminal): add startup-command line-split and delay-clamp helpers

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(terminal): run multi-line startup commands in sequence with configurable delay

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(terminal): expose startup command delay in Terminal settings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(terminal): keep startup-command line content verbatim

splitStartupCommandLines now only drops blank/whitespace-only lines and
normalizes CRLF, but no longer trims each line's content. This keeps a
single-line startup command byte-identical to what the user typed (e.g. a
leading space for HISTCONTROL=ignorespace is preserved), while still
supporting multi-line sequencing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sync): include startupCommandDelayMs in synced terminal settings

Terminal settings sync via the SYNCABLE_TERMINAL_KEYS allowlist; the new
startupCommandDelayMs preference was missing, so it wouldn't propagate across
devices. Add it (it's a user preference like keepaliveInterval, not
device-specific).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 00:47:00 +08:00
陈大猫
770b06a9ee fix(ai): make Claude Code agent diagnosable + configurable (auth) (#1095)
* feat(ai): add Claude auth-presence detection helper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ai): surface actionable Claude auth errors and reap stuck agent processes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ai): add pure helpers for Claude config dir + env editor

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ai): let users set Claude config dir and env vars in settings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* polish(ai): harden Claude config env, de-dupe error text, label a11y

- buildClaudeEnv: drop managed keys (CLAUDE_CONFIG_DIR/CLAUDE_CODE_EXECUTABLE)
  if a user types them into the free-text env editor, so they can't clobber
  the config-dir field or the discovered executable path (+ regression test).
- bridge: only append error data fields not already shown as message/code,
  so the actionable error text doesn't echo the same code/message twice.
- ClaudeCodeCard: associate the new config-dir/env labels with their inputs
  via htmlFor/id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ai): make the Claude auth & config section collapsible

The optional "Authentication & config" section now has a collapsible
header (chevron toggle). Collapsed by default to keep the card tidy, but
auto-expanded when the user already has a config directory or env vars set
so existing config isn't hidden. Local UI state, not persisted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): preserve raw env editor text; don't encourage plaintext secrets

P1: the env textarea was bound to the persisted value, which is the parsed
env re-serialized — so typing a key before its "=" was erased mid-entry
(buildClaudeEnv drops lines without "="). Keep the raw typed text in local
draft state and only resync from the persisted value on genuine external
changes (not our own parse→serialize round-trip).

P2: the env editor persists to localStorage in plaintext (no credential
encryption). Stop suggesting ANTHROPIC_API_KEY in the placeholder and warn
that values are stored in plaintext, steering credentials to the config
directory (a `claude` login) — consistent with keeping Claude auth
CLI/config-owned (#705).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): expand ~ in Claude config directory before passing to the agent

CLAUDE_CONFIG_DIR is handed to the spawned agent as an env var, which is not
shell-expanded — so "~/.claude" was treated as a literal "~" directory.
Expand a leading ~ at consume time (normalizeAgentEnv + getClaudeConfigDir)
rather than on save, so the stored value stays portable across machines
(cloud sync) and each device expands to its own home.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): recreate cached Claude provider when its env config changes

Claude ACP providers are cached per chat session and reused unless one of
the fingerprinted dimensions changes. authFingerprint was null for Claude,
so editing the config directory / env vars in Settings didn't take effect on
an already-running session. Fingerprint the Claude agent env so a config
change invalidates the cached provider and the next turn respawns with it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 23:59:48 +08:00
陈大猫
1d50b2c4a1 feat(terminal): choose dark/light terminal theme when following app theme (#1094)
* feat(terminal): add per-mode follow-theme resolver and storage keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(terminal): persist per-mode follow-theme selections in settings state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sync): include per-mode follow terminal themes in cloud sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* i18n(terminal): add per-mode follow-theme picker strings

* feat(terminal): add type filter and auto option to theme picker

* feat(terminal): pick dark/light terminal theme when following app theme

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(terminal): don't flag the auto sentinel as a missing theme in the picker

The per-mode follow-theme pickers default to the 'auto' sentinel, which is
not a real theme id, so ThemeList's deletedSelectedTheme check classified it
as a deleted custom theme and rendered a spurious "Missing Theme" banner above
the Auto entry on first open. Exclude TERMINAL_THEME_AUTO from that check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* i18n(terminal): align zh-CN dark/light wording with app convention

Use 深色/浅色 (matching the theme picker section headers and global
appearance settings) instead of 暗色/亮色 for the per-mode terminal
theme labels, so the picker modal reads consistently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(terminal): match follow-theme preview fallback to runtime resolution

The per-mode preview memos fell back straight to TERMINAL_THEMES[0] when a
selection resolved to a deleted theme, while the runtime currentTerminalTheme
memo falls back to the manual terminalThemeId first. Mirror the runtime chain
so the Settings preview matches the actual terminal for users with a
non-default manual theme.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 22:20:45 +08:00
陈大猫
453202df8f perf(terminal): add output flow control / back-pressure for heavy streams (#1090)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
2026-05-25 15:19:27 +08:00
陈大猫
a78c052d86 perf(autocomplete): skip completion queries when nothing is shown (#1088)
fetchSuggestions ran the full completion pipeline (history scan, fig specs, remote path lookups) on the main thread even when both the popup and ghost text were disabled — the results were then discarded. Add a shouldQueryCompletions(settings) gate and bail out early (clearing any stale state) when neither display mode is on.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:39:49 +08:00
陈大猫
e6b0a551e8 perf(terminal): isolate autocomplete re-renders into a child component (#1089)
The autocomplete hook (useState) lived in Terminal, so every suggestion / selection / live-preview update re-rendered the whole ~2775-line Terminal component. Move the hook and its popup into a dedicated <TerminalAutocomplete> component so those frequent state updates re-render only that small subtree.

The hook's handlers are surfaced back to Terminal via refs (the same refs already used to wire the xterm runtime), and the component is mounted unconditionally so the hook keeps recording command history and intercepting completion keys for the session's lifetime. No behavior change intended.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:39:38 +08:00
陈大猫
38775245d2 perf(terminal): bound the connection log with a chunk ring buffer (#1087)
* perf(terminal): bound the connection log with a chunk ring buffer

The connection log kept the last 1,000,000 chars via `log += chunk; log = log.slice(-MAX)`. Once a session emits more than that, the slice flattens a ~1M-char string on every subsequent output chunk — on the render thread, for each echoed keystroke included — on long/busy sessions.

Replace the string with a small chunk-queue ring buffer that trims only the boundary chunk (amortized O(chunk) append) and materializes the full string once on read. Behavior is unchanged: it still retains exactly the last MAX_CONNECTION_LOG_DATA_CHARS characters.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* perf(terminal): coalesce connection log into bounded blocks (O(1) trim)

The first cut used one array entry per append and trimmed with chunks.shift(). For interactive output (many tiny chunks) the array grows toward the cap in entries, so once full, shift() reindexes ~N elements on every append — O(appends) per chunk, no better than the slice it replaced.

Coalesce appends into a small, bounded set of fixed-size blocks (~maxChars/blockSize). New data fills an open tail that seals into a block at blockSize; trimming only drops/slices a handful of blocks. Adds segmentCount() and a test asserting the segment count stays bounded across many tiny appends.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:39:02 +08:00
陈大猫
fcb699ffb9 chore(eslint): lint electron/bridges for undefined references (#1086) 2026-05-25 13:53:53 +08:00
陈大猫
e889d8fc20 perf(terminal): flush shell output on the event-loop turn instead of a fixed 8ms timer (#1085)
* perf(terminal): flush shell output on the event-loop turn, not a fixed 8ms timer

SSH/PTY output was coalesced and shipped to the renderer on a fixed 8ms timer. For interactive use that interval is pure added latency: every echoed keystroke waits out the timer before it can paint, so typing feels slightly behind.

Replace the timer with turn-based (setImmediate) coalescing in a single shared ptyOutputBuffer module, used by the SSH, local, telnet, and mosh paths. A single echoed keystroke is now forwarded almost immediately, while data arriving in the same turn still collapses into one IPC send, and a 16KB size cap still forces an immediate flush under heavy output.

Also de-duplicates two copies of the buffering logic (SSH had an inline copy; local/telnet/mosh shared another) and adds unit tests for the buffer.

Related to #1084.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(terminal): drop orphaned flushTimeout reference in SSH close handler

The SSH stream "close" handler still cleared `flushTimeout`, a variable that lived in the inline buffer removed when this path moved to the shared ptyOutputBuffer. Reading it now throws ReferenceError on every channel close, aborting the cleanup and exit signaling. The shared buffer's flush() cancels any pending flush internally, so the timer bookkeeping is removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 13:42:49 +08:00
陈大猫
bf1c95500a feat #826: optional Option+←/→ word jump on macOS (#1082)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
* feat #826: optional Option+←/→ word jump on macOS

Adds a Terminal → Keyboard toggle "Option+←/→ jumps by word" (off by default,
synced). When on, a bare Option+Left/Right sends Meta-b / Meta-f instead of
xterm's default ^[[1;3D / ^[[1;3C, so readline/zle moves by word without
per-host bindkey setup (Termius-style).

The key→sequence mapping is a tested pure function; the handler reads the
setting live (no reconnect) and runs after kitty mode + autocomplete so it
doesn't override them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix #826: gate Option+←/→ word jump to macOS

The setting is syncable, so without a platform gate, enabling it on a Mac
would also rewrite Alt+←/→ to Meta-b/f on synced Linux/Windows devices,
breaking apps/shells that expect the default ^[[1;3D / ^[[1;3C. Pass
isMacPlatform() into the mapping so it only applies on macOS; add a test
for the non-macOS case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:10:40 +08:00
陈大猫
f9d00c9d23 fix #1079: preserve remote file mode when rz overwrites a same-named file (#1081)
* fix #1079: preserve remote file mode when rz overwrites a same-named file

#1070's overwrite path rm's the remote file and lets rz re-create it, which
writes with the remote umask and drops the original permission bits — e.g. a
0755 script became 0644 after choosing "replace". (It didn't happen before
because rz used to skip same-named files, leaving the original untouched.)

Capture each conflicting file's mode during the pre-upload probe
(stat -c %a, BSD stat -f %Lp fallback) and chmod it back once the transfer
finishes and the files are on disk. Restore is best-effort: any failure
silently falls back to today's behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix #1079: probe file mode with `stat -- "$n"` for dash-prefixed names

Without `--`, `stat -c %a "-x.sh"` (and the BSD `-f %Lp` fallback) parse a
leading-dash filename as options, so the mode was never captured and overwrite
fell back to rz defaults — losing permission preservation for a valid filename
class. Mirrors the existing `rm -f --` handling. (chmod left as-is: its path is
always absolute, and BSD chmod doesn't accept `--`.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:44:41 +08:00
陈大猫
8fd7ff6475 fix #1078: send macOS Option as Meta (wire altAsMeta to xterm macOptionIsMeta) (#1080)
"Use Option as Meta key" was read into `altIsMeta` but only applied to the
mouse alt-click options (`altClickMovesCursor`). xterm.js's `macOptionIsMeta`
— the option that actually makes Option emit ESC-prefixed (Meta) sequences —
was never set, so on macOS Option kept producing layout characters (ƒ, ∫, …)
and readline/zle word shortcuts (Alt+f, Alt+b, Alt+Backspace) were dead.

Extract the altAsMeta→xterm mapping into one tested helper used by both the
terminal init path (createXTermRuntime) and the live settings sync
(Terminal.tsx) so the two can't drift again.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:30:35 +08:00
陈大猫
02c80ae7d2 chore: silence two production build warnings (#1072)
Some checks failed
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
- Drop the manualChunks 'vendor-react' entry: react/react-dom already land
  in another chunk, so it only ever produced an empty chunk + a build
  warning, with no caching benefit.
- Import domain/syncMerge statically in useAutoSync. It's already in the
  eager graph via CloudSyncManager's static import, so the dynamic
  `import()` couldn't be code-split anyway and only emitted a mixed
  static/dynamic-import warning.

No behavior change; production build is warning-free.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:50:33 +08:00
陈大猫
e5d3d02b17 fix #1063: give each terminal its own WebGL texture atlas (disable cross-terminal sharing) (#1071)
Root cause of the persistent split-view 花屏: xterm's WebGL addon shares
ONE TextureAtlas across terminal instances with equal config (font / size
/ theme / DPR) — acquireTextureAtlas does `if (configEquals) { ownedBy.push;
return atlas }`. Two split panes then share an atlas, so the
clearTextureAtlas calls netcatty makes to recover from glyph corruption
(on resize / DPR / font change / tab show, from #1049 and #1066) clobber
the *other* pane's rendering. That's why the earlier redraw/clear-based
recovery attempts didn't help and only bounced the garble between panes.

Disable the sharing: remove the "reuse a matching atlas" loop so every
terminal creates its own atlas. The published bundle is minified, so this
is done with a small idempotent postinstall script (a patch-package patch
would be a ~550KB unreadable blob of the whole minified line). It
string-replaces the exact loop in the CJS + ESM builds, runs after
patch-package, and warns without failing if @xterm/addon-webgl changes.

Verified: split-view WebGL no longer garbles; script is idempotent
(patched=2 → already=2) and the production build is unaffected.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:45:05 +08:00
陈大猫
78186d8d46 feat #1064: prompt to overwrite when rz upload hits a remote filename conflict (#1070)
* feat #1064: add buildUploadPlan for rz overwrite/skip/cancel resolution

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1064: handle remote filename conflicts in rz handleUpload

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1064: SSH exec probe + remove for rz upload conflicts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1064: IPC for rz overwrite-conflict prompt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1064: renderer prompt for rz overwrite conflicts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix #1064: repair sshBridge test mock (ipcMain.on) and i18n the overwrite dialog

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix #1064: make upload plan index-based to preserve per-file decisions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:20:20 +08:00
陈大猫
c899653621 fix #1065: resolve terminal cwd through su/sudo for the SFTP locate (#1068)
* fix #1065: resolve terminal cwd through su/sudo for the SFTP locate

The SFTP "locate to terminal's current directory" feature kept showing the
login user's home (e.g. /root) after the user switched accounts with su /
sudo -s and cd'd elsewhere.

getSessionPwd walks the remote process tree from a sibling exec channel to
find the interactive shell's cwd, but it only followed children whose comm
is a shell name (bash/zsh/...). su and sudo are named "su"/"sudo", so the
walk stopped at the login shell and read its cwd. The actual shell the user
is typing in lives *under* su/sudo as the controlling tty's foreground
process group.

Rewrite the walk to pick the deepest foreground shell ("+" in stat) within
the login shell's whole process subtree, which transparently follows
through su/sudo to the active shell, falling back to the login shell when
no foreground shell is found.

Verified on a real server (root -> su user -> cd /tmp):
  before: /root   after: /tmp
and confirmed the no-su case is unchanged (cd /var -> /var).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fall back to login shell cwd when the active shell's /proc is unreadable (Codex review)

When an unprivileged user runs `sudo -s` / `su root`, find_active_shell
correctly selects the root-owned foreground shell, but the exec channel
(running as the login user) cannot readlink another uid's /proc/<pid>/cwd
due to ptrace permissions. Without a fallback the script dropped straight
to the home directory, regressing user→root sessions.

Retry readlink on the same-uid login shell before falling back to home.

Verified live (user -> cd /var -> sudo -s -> cd /tmp): the root shell's
cwd is unreadable, and the result is now /var (login shell cwd) instead of
/home/<user>.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Select the interactive (tty-bearing) login shell deterministically (Codex review)

find_login_shell picked the first shell child of sshd and exited, but ps
output is unsorted, so when other exec channels (server-stats polls, etc.)
are running on the same connection their transient sh could be chosen,
making find_active_shell walk the wrong subtree.

Prefer the shell child that has a controlling tty: the interactive shell
has a pts, while non-PTY probe exec channels have tty "?". This is
deterministic regardless of ps order, in both the su and no-su cases (the
old "prefer foreground" heuristic was itself nondeterministic under su).
Falls back to any shell child if none has a tty.

Verified live with a concurrent no-tty `sh -c sleep` under the same sshd:
the pts/0 bash is selected and the result is /tmp, not the probe shell.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:26:20 +08:00
陈大猫
a91fbcdd68 fix #1062: treat SSH shell TMOUT auto-logout as a timeout, not a normal exit (#1067)
* fix #1062: treat SSH shell TMOUT auto-logout as a timeout, not a normal exit

A shell-level TMOUT idle auto-logout makes bash/csh exit cleanly (numeric
exit code, no signal), which is byte-for-byte indistinguishable from a
user-typed `exit` at the SSH protocol level. PR #1057 keyed the
close-vs-keep decision on `streamExited` (numeric code + no signal), so
TMOUT exits were reported as reason "exited" and the tab was auto-closed —
reintroducing the problem from #977.

Verified against a real server that bash TMOUT exits with code 0 / no
signal and prints "timed out waiting for input: auto-logout" to the
channel before it closes. Since exit code/signal can't distinguish it from
an intentional exit, detect that banner in the session's existing rolling
output tail (_promptTrackTail) and report reason "timeout" instead, which
routes to the existing markDisconnected path (keep tab + reconnect). A
normal `exit`/`logout` (no "auto-" prefix) still auto-closes the tab, so
PR #1057's behavior is preserved.

zsh's TMOUT raises SIGALRM (a signal), so it already took the
keep-tab/reconnect path and is unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Anchor TMOUT auto-logout match to the banner's final line (Codex review)

The detector matched "auto-logout" as an unanchored substring within the
last 256 chars, so command output that merely mentions it (e.g. `grep
auto-logout /etc/profile` while investigating TMOUT) followed by an
intentional `exit` could be misclassified as a timeout and wrongly keep
the tab open. Anchor on the final non-empty line of output instead — the
banner the shell prints right before exiting — which loses no true
positives (verified against the real-server output shape) while rejecting
mid-stream mentions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 10:53:03 +08:00
陈大猫
74b315e285 fix #1063: force WebGL redraw on tab show to recover from garbled multi-tab terminals (#1066)
Hidden tabs stay mounted off-screen (visibility:hidden) so each keeps a
live WebGL context. Creating another terminal's WebGL context — or the GPU
dropping a non-composited off-screen canvas — leaves the hidden terminals'
drawing buffers corrupted ("花屏"). This reproduces on both Windows and
macOS: opening 2 tabs garbles the 1st, opening 3 garbles the 1st and 2nd,
while the just-created (visible) one is always fine. The DOM renderer is
immune because it uses real DOM nodes.

A window resize recovers the display because it triggers a full repaint
(clearTextureAtlas + RenderService._renderRows). A tab switch did not:
the visibility effect only calls safeFit, which early-returns when the
pane's dimensions are unchanged, so no redraw happened.

Perform the same recovery a resize does when a tab becomes visible:
clear the texture atlas (no-op on the DOM renderer) and synchronously
repaint every row. Verified against xterm core that _renderRows draws
unconditionally, independent of dimension changes or dirty-row tracking.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 09:56:30 +08:00
陈大猫
60eeafe7a9 feat #1005: Termius-style live-preview popup autocomplete (free the Tab key) (#1059)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
* feat #1005: add live-preview keystroke calculator for popup autocomplete

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1005: live-render the selected popup suggestion on arrow navigation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1005: free Tab for the shell; Enter runs the rendered line; Esc reverts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1005: show key hint (→ expand / ↵ run) on the selected popup row

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1005: live-render full path while navigating sub-directory panels

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test #1005: move live-preview test into the npm test glob

The test runner only scans components/terminal/*.test.ts (not the
autocomplete/ subdir), matching where the other autocomplete-module tests
live (e.g. completionEngine.test.ts). Relocate so it actually runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix #1005: center and refine the popup key-cap hint

Use inline-flex centering (the ↵ glyph was vertically off with line-height +
padding), softer color-mixed border/background, a system-sans font so the
glyph renders consistently regardless of the terminal font, and the more
balanced ⏎ return symbol.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix #1005: record the actual executed line on Enter, not the stale suggestion

Codex review (P2): the popup Enter handler recorded selected.text and
suppressed handleInput's recorder, so editing a previewed command (select
docker, type ' ps', Enter before the re-query) logged the stale 'docker'
instead of 'docker ps'. Delegate to handleInput's Enter path, which records
lastAcceptedCommandRef on a clean select and falls back to the live buffer
after an edit (typing nulls that ref).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix #1005: don't revert user edits when Escape closes the popup

Codex review (P2): previewActiveRef stayed true after the user edited a
previewed command, so Escape (before the debounced re-query reset state)
called renderPreviewSelection(-1) and rewrote the line back to the stale
baseline, dropping the edits. Clear previewActiveRef when the user types
(alongside the existing lastAcceptedCommandRef reset), so Escape only reverts
a pristine preview and otherwise just dismisses the popup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:58:57 +08:00
陈大猫
ee2c21e712 feat #1044: close tabs with the middle mouse button (#1058)
Middle-clicking a tab (mouse wheel click) is a conventional "close tab"
gesture in browsers and editors. Wire it to every closeable tab strip:
the top session / workspace / log-view / editor tabs and the SFTP tab bar.

A small shared helper (lib/tabInteractions.ts) handles the gesture:
onAuxClick closes the tab when button === 1, and onMouseDown calls
preventDefault for the middle button so the Chromium/Electron autoscroll
overlay does not appear. Left-click activation and right-click context
menus are untouched.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:58:19 +08:00
陈大猫
e678ad3546 fix terminal exit auto close (#1057) 2026-05-22 22:49:15 +08:00
陈大猫
c47c780b48 fix s3 checksum compatibility (#1056) 2026-05-22 22:41:25 +08:00
陈大猫
88074ac9b3 fix auto sync remote checks (#1055) 2026-05-22 22:26:05 +08:00
陈大猫
59cb0c4b65 fix #1043: skip pwd probe on network devices to keep Huawei VRP sessions alive (#1052) 2026-05-22 22:06:03 +08:00
陈大猫
bf0bd193eb fix #1049: clear WebGL texture atlas to recover from garbled terminal (#1050)
Heavy full-screen TUIs (claude code / gemini cli / opencode), font changes,
and device pixel ratio changes can leave xterm.js's WebGL glyph texture atlas
in a corrupted state that persists for the life of the terminal — users see
persistent "garbled / 花屏" output that only clears when a brand-new terminal
is opened (most often on Windows with display scaling / multi-monitor setups).

Clear the texture atlas so glyphs re-rasterize at the correct scale instead of
forcing users to reopen the terminal:

- Add watchDevicePixelRatio() helper (TDD, unit-tested) that re-registers a
  matchMedia listener across DPI changes and fires a repair callback.
- Wire it into createXTermRuntime: on devicePixelRatio change, clear the atlas
  and refit; also clear the atlas on reflow (term.onResize). Watcher is torn
  down on dispose.
- Expose clearTextureAtlas() on XTermRuntime and call it after font changes in
  Terminal.tsx (xterm.js #3280). All calls are no-ops under the DOM renderer.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:25:38 +08:00
陈大猫
7661375925 fix huawei vrp ssh detection (#1046) 2026-05-22 01:05:46 +08:00
陈大猫
308fb45985 fix comware legacy ssh handshake (#1045) 2026-05-22 00:13:59 +08:00
陈大猫
f4aa6ddb46 fix #1013: stop ghost text from drawing over untracked echoed input (#1042)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
Inline (ghost-text) suggestions render suggestion.substring(trackedInput.length)
after the cursor, where trackedInput is a client-side reconstruction of the
command line (buffer heuristics + keystroke prediction, to mask SSH echo
latency). On hosts with non-standard echo — hardware bastion hosts / network OS
like `ecOS#` (#1013, previously #756 / #906) — that reconstruction drifts and
the ghost gets painted over characters the user already typed (`int` + ghost
`terface` -> `intterface`).

Add a fail-safe consistency check: on each post-echo render, if the real
terminal line before the cursor contains the tracked input followed by more
untracked, non-whitespace characters (reality is AHEAD of what we tracked),
hide the ghost instead of drawing it over real text. SSH echo latency is the
opposite case (the line is a prefix-behind of the tracked input) and is
deliberately not flagged, so the ghost stays responsive on slow links. The
check is ASCII-only (wide-char column mapping is ambiguous) and fail-open, so
it can only ever suppress a ghost that would otherwise corrupt — never change
correct behaviour.

This converts the recurring "ghost shows already-typed characters" bug into
"ghost simply doesn't show" on devices we can't track reliably.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:40:35 +08:00
陈大猫
f6cb73fdd6 fix #1040: unique macOS Mach-O LC_UUID for Local Network privacy (#1041)
macOS keys the "Local Network" privacy permission on the main executable's
Mach-O LC_UUID (Apple TN3179). Electron's prebuilt binary is linked with LLD,
which derives the UUID from a content hash, so every app built from the same
Electron version ships the *same* LC_UUID even with a different bundle id. That
collision makes the grant unreliable: a user who enables Local Network for
Netcatty can still hit `connect EHOSTUNREACH` on LAN / VMware host-only
addresses, while loopback-forwarded connections work.

Add an electron-builder afterPack hook that rewrites the packaged macOS
executable's LC_UUID to a value derived deterministically from the appId —
stable across builds (so the grant survives updates) but distinct from every
other app. It runs before code signing, so signature/notarization cover the
patched binary. No-op on Windows/Linux.

Verified the rewrite on a copy of Electron's binary (LC_UUID changes, file
stays a valid Mach-O, deterministic) and added unit tests for the Mach-O
patcher (thin + fat) and the UUID derivation.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:11:25 +08:00
陈大猫
3c100b0ae2 fix #1035: support diffie-hellman-group1-sha1 under BoringSSL (#1039)
Electron's BoringSSL dropped several standard MODP groups from the named
crypto.createDiffieHellmanGroup() API — notably the 1024-bit Oakley Group 2
(modp2) that backs SSH's diffie-hellman-group1-sha1. ssh2 calls
createDiffieHellmanGroup('modp2') for that kex, so connecting to legacy
network devices that only speak group1-sha1 failed with "Error: Unknown DH
group".

The underlying DH math still works on BoringSSL via createDiffieHellman()
with an explicit prime, so add a compatibility shim that wraps
createDiffieHellmanGroup and falls back to the well-known prime constants
when (and only when) the runtime can't resolve a group by name. On OpenSSL
builds the original call succeeds and the fallback is never used.

The shim is installed in main.cjs before any ssh2-using bridge loads, since
ssh2 destructures createDiffieHellmanGroup at module load. Once installed,
the existing legacy-group probe detects modp2 as supported again and offers
group1-sha1, so affected devices actually connect (still gated behind the
per-host legacy-algorithms toggle).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:51:50 +08:00
陈大猫
168e42b5fa fix slow first SSH connect from DH group probe under BoringSSL (#1038)
The fixed-DH-group support probe called crypto.createDiffieHellmanGroup()
for each MODP group to feature-detect runtime support. Under Electron's
BoringSSL, instantiating the large groups is pathologically slow
(modp18/8192-bit takes ~20s on first call), and the result is only cached
in-process, so the first connection after every app launch froze for ~24s.

The standard modern groups (modp14/16/18) are universally supported and
always pass the probe anyway, so treat them as supported without probing.
Only groups a runtime may genuinely drop (e.g. BoringSSL removed the weak
1024-bit group1/modp2) are still feature-detected; those fail instantly.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:35:20 +08:00
陈大猫
2ce6bd5ed1 [codex] Reorder legacy SSH group-exchange fallback (#1034)
* reorder legacy ssh kex fallback

* add ssh handshake debug logging
2026-05-21 11:30:24 +08:00
陈大猫
7bd5d6465a fix claude system cli detection (#1033) 2026-05-21 00:11:51 +08:00
陈大猫
65387d4c61 fix legacy group exchange sha1 (#1032) 2026-05-20 23:19:07 +08:00
yuzifu
6084e8e94f fix(terminal): handle forced prompt newline (#1025)
* fix(terminal): handle forced prompt newline

* fix review issue

* fix(terminal): harden prompt newline handling

---------

Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-05-20 23:08:42 +08:00
陈大猫
3ccc5c9fc6 Fix broadcast hotkey refresh (#1030) 2026-05-20 16:30:25 +08:00
陈大猫
d07859f604 [codex] Prevent terminal host preference pollution (#1026)
* Prevent terminal host preference pollution

* Preserve terminal host updates while isolating session ports
2026-05-20 11:51:54 +08:00
陈大猫
88a322a03b [codex] Filter terminal cursor replies from broadcast input (#1022)
* Filter terminal CPR from broadcast input

* Handle split cursor reports in broadcast
2026-05-20 11:12:27 +08:00
陈大猫
0e02bbc2fb [codex] Persist vault host sort mode (#1021)
* Persist vault host sort mode

* Harden vault host sort persistence tests
2026-05-20 10:53:20 +08:00
陈大猫
affd9217e2 Fix session log capture after reconnect (#1020) 2026-05-20 10:53:04 +08:00
陈大猫
7b4a349e3f [codex] Guard unsupported legacy SSH groups (#1023)
* Guard legacy SSH DH groups

* Align legacy SFTP algorithms
2026-05-20 10:52:52 +08:00
陈大猫
7dc5ab5035 [codex] Use terminal cwd when opening SFTP (#1024)
* Use terminal cwd when opening SFTP

* Clear stale terminal cwd for SFTP open
2026-05-20 10:52:35 +08:00
yuzifu
3e8965f9a9 Fix pr987 (#1010) 2026-05-19 20:13:16 +08:00
陈大猫
23a27bf544 Handle missing streamed tool call ids (#1007) 2026-05-19 11:29:50 +08:00
陈大猫
86a815ad46 [codex] Optimize terminal tab switching (#1003)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
* Optimize terminal tab switching

* Reduce themed tab switch repaint work
2026-05-18 22:19:54 +08:00
陈大猫
cb4fb091aa [codex] Fix browser loading of shared rule files (#1002)
* Fix local shell browser import

* Fix command blocklist browser import
2026-05-18 21:05:33 +08:00
陈大猫
b30696c98b Clean up dead code and duplicated helpers (#1001) 2026-05-18 20:00:10 +08:00
bincxz
6b8f05c65a Merge branch 'codex/fix-russian-settings-sync-icon' 2026-05-18 19:23:44 +08:00
bincxz
64dd3a4a2f Fix settings sidebar icon clipping 2026-05-18 19:23:36 +08:00
yuzifu
88732040aa fix(terminal): separate prompt after unterminated command output (#987)
* fix(terminal): separate prompt after unterminated command output
Add a display-layer prompt line break handler so recognized shell prompts move to the next visual line when the final command output line is not newline terminated.

Also add a terminal setting to toggle the behavior, sync support, i18n copy, and focused tests for prompt insertion.

* fix review issue

* Fix prompt cache initialization

* Serialize terminal output writes for prompt breaks

* Keep terminal status lines ordered with output

* Fix prompt arming without command callback

* Keep prompt display breaks out of session logs

* Avoid prompt breaks for output suffix matches

---------

Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-05-18 18:45:41 +08:00
ウィール スペース
b9f3bfa8bb Add i18n russian (#991)
* add i18n russian

* Added the Russian translation

* Complete Russian SFTP transfer translations

* Add Russian reconnect menu translation

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-05-18 16:55:02 +08:00
陈大猫
b7ec3c12f7 Handle ConPTY controls in Mosh password prompts (#1000) 2026-05-18 15:44:58 +08:00
DeepFal
d20a18b862 Fix AI code block rendering fallback (#983) 2026-05-18 13:19:30 +08:00
陈大猫
ff6b4a4625 Broadcast pasted terminal input (#927) (#996)
* Broadcast user paste to terminals

* Use workspace session id for context paste broadcast

* Consume paste broadcast suppression before toggle check
2026-05-18 11:53:14 +08:00
陈大猫
5a94b4cf39 Preserve Unicode session log names (#988) (#998)
* Preserve Unicode session log names

* Harden Windows session log name handling
2026-05-18 11:42:43 +08:00
陈大猫
3963cd4af9 Fix remote path completion cwd (#993) 2026-05-18 11:32:04 +08:00
陈大猫
5b2a048917 Add transfer target path actions (#997) 2026-05-18 11:31:50 +08:00
陈大猫
2414cb00e4 Keep terminal tab after remote exit (#994) 2026-05-18 11:31:28 +08:00
陈大猫
03f980e939 Add reconnect terminal context action (#995) 2026-05-18 11:30:27 +08:00
Bet4
ac819fd4fd feat(workspace): add focus sidebar drag reorder (#992) 2026-05-18 01:26:14 +08:00
yuzifu
fb9400a5fb fix #984: After running the clear command, the inline session log will be cleared (#990)
Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
2026-05-16 20:44:05 +08:00
陈大猫
7da983a56c ci: auto-bump Homebrew tap on stable release tags (#938) (#976)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
After the GitHub Release is published, push an updated Cask to
binaricat/homebrew-netcatty so `brew install binaricat/netcatty/netcatty`
stays current within minutes of the release. Stable tags only — prerelease
tags (v1.2.0-rc.1 etc.) are skipped to keep brew users on stable.

Implementation:
- New script .github/scripts/bump-homebrew-cask.sh computes SHA-256 of the
  arm64 + x64 DMGs already downloaded by the release job, sed-patches the
  Cask file in the tap repo, sanity-checks the result parses as Ruby, and
  pushes the bump. Idempotent on re-run when checksums match.
- New homebrew-tap job in build.yml runs after the release job on the same
  stable-tag gate, downloads the macOS artifact bundle, then runs the
  bump script with HOMEBREW_TAP_TOKEN.

Requires HOMEBREW_TAP_TOKEN secret with contents:write on
binaricat/homebrew-netcatty. With the secret missing the job will fail
fast at the env-var check with no side effects (no push attempted).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:01:26 +08:00
陈大猫
344b226ce8 Fix #969: auto-fill saved password into PAM-style keyboard-interactive prompts (#974)
* Fix #969: auto-fill saved password into PAM-style keyboard-interactive prompts

Servers running stock PAM Linux configurations (most distros) only advertise
`keyboard-interactive` as their auth method, not `password` — so even when
the user has saved a password on the host, Netcatty was popping a modal
asking them to type it again. Every connect ended up being a two-password
flow: one to dispatch, one in the modal.

The shared `createKeyboardInteractiveHandler` factory now recognizes the
classic "PAM-wrapped password" challenge (a single prompt with
`echo === false`) and finishes it with the saved password directly,
skipping the modal. Real multi-prompt or echo-visible challenges (2FA / OTP
/ security questions) still go to the modal as before, and a wrong-password
auto-fill on the first attempt falls back to the modal on the retry so the
user can correct it.

Also consolidated startSSHSession's inline keyboard-interactive handler —
which duplicated ~45 lines of the factory logic without the auto-fill
fix — to use the factory with progress callbacks. The chain / SFTP /
port-forwarding bridges already went through the factory and pick up the
auto-fill for free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address Codex review: only auto-fill prompts that mention a password

The previous heuristic ("single prompt + echo=false + saved password →
auto-fill") would also fire for OTP / Duo / hardware-token challenges,
which are single hidden-echo prompts too. That would burn one auth
attempt per reconnect on those servers and could trip pam_faillock /
pam_tally2 lockout policies before the user ever saw the modal.

Add a prompt-text gate: auto-fill only when the prompt contains a known
password keyword (Latin "password" / "passwd"; CJK "密码" / "口令").
Custom-localized prompts that don't match fall through to the modal,
which is the same behavior as the pre-#969 baseline — strictly no
worse than before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address Codex review (round 2): exclude OTP vocabulary from auto-fill

The previous PASSWORD_PROMPT_PATTERN matched anything containing "password"
/ "passwd" / "密码" / "口令", which still let through OTP shapes that
happen to include those words: "Enter your one-time password", "动态密码"
(Chinese for "dynamic password" = OTP), "动态口令", "一次性密码", etc.

Add an OTP/MFA vocabulary check that runs before the password keyword
check. Any prompt containing OTP terminology (one-time, OTP, verification,
passcode, token, 2FA, two-factor, MFA, Duo, 动态, 一次性, 验证码, 令牌,
双因素, 多因素, 短信验证, 手机验证) is disqualified from auto-fill even
if it also matches the password keywords.

Tests cover both English "One-time password" and the three common Chinese
OTP phrasings, plus a regression guard that normal sudo-style password
prompts still auto-fill.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:36:07 +08:00
陈大猫
86e47b5f9e Fix #972: stop false "fingerprint changed" warnings on every SSH connect (#973)
The host-key verifier was misclassifying connections as `changed` in three
situations that had nothing to do with a real key rotation:

1. Records imported from the system `~/.ssh/known_hosts` (or older builds)
   landed in localStorage without a `fingerprint` field. The verifier then
   re-derived the fingerprint from the stored `publicKey` blob on every
   connect — a brittle path that produced a different value than ssh2 if
   anything about the serialization differed by even one byte.
2. `classifyHostKey` had a loose "single candidate with unknown / empty
   keyType → changed" heuristic. Any imported record whose keyType failed
   to parse would be promoted to a rotation warning the first time the
   server presented a real algorithm, even though the user had never
   actually trusted any fingerprint for that algorithm.
3. A host that genuinely had multiple algorithms (e.g. one stored ssh-rsa
   record plus a live ssh-ed25519 handshake) was being reported as
   `changed` instead of `unknown`, even though we had no comparable
   record for the algorithm the server presented.

Tabby (`tabby-ssh/src/session/ssh.ts`) and OpenSSH both treat case (3) as a
first-time prompt rather than a mismatch; this change brings Netcatty in
line with that model.

Changes:
- `domain/knownHosts.ts` ports `fingerprintFromPublicKey` to TS and adds
  `normalizeKnownHost` / `normalizeKnownHosts` so the renderer can backfill
  legacy records on hydration. Pure-JS SHA-256 keeps the migration
  synchronous so it can run inline in `useVaultState` without async
  plumbing.
- `application/state/useVaultState.ts` runs the migration on hydration
  and on cross-window storage events. When anything changes on hydration
  the migrated list is written back to localStorage so the next launch
  starts clean.
- `components/KnownHostsManager.tsx` populates `fingerprint` at import
  time instead of leaving it for the verifier to re-derive.
- `electron/bridges/hostKeyVerifier.cjs` simplifies `classifyHostKey` to
  fingerprint-first, then strict (host, port, keyType) match for the
  changed branch, then fall through to `unknown`. Two existing tests
  that locked in the loose heuristic are updated to assert the new
  (safer) behavior, and a new test covers the multi-algorithm
  first-encounter case.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:41:36 +08:00
陈大猫
37012da26a Use shadcn Button for the settings gear in the top tab bar (#967)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
Follow-up on #966 which added `hover:bg-accent` to the existing raw
`<button>` element. That element is `h-full w-10`, so the new hover
fill spanned the entire title-bar height — a giant vertical accent
strip instead of the small icon-button highlight we wanted.

Replace the raw element with the same shadcn `Button variant="ghost"
size="icon" h-6 w-6` that every other icon on the same row already
uses. Wrap it in a centered container that keeps the title-bar height
for window-control alignment and carries `app-drag` so the empty
space around the icon still drags the window; the button itself stays
`app-no-drag`.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:28:29 +08:00
陈大猫
0fd6a8c31d Add hover background to settings gear in top tab bar (#966)
Hovering the gear icon in the top tab bar left no visual response while
every other icon on the same row (AI, theme toggle, sync) lights up on
hover with the accent fill. The gear button is a raw `<button>` rather
than the shadcn `Button variant="ghost"` because it spans the full
title-bar height to align with the window controls, so it never picked
up the ghost variant's `hover:bg-accent`.

Adds the matching `hover:bg-accent` class so the gear behaves the same
as its neighbours. The inline `color` style for the resting state stays
in place; the accent fill on hover is what was missing.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:22:19 +08:00
陈大猫
10af904681 Bring Duplicate / Copy Credentials to Pinned + Recently Connected menus (#965)
The right-click menu on host cards in the Pinned and Recently Connected
sections only exposed Connect / Edit / Pin-Unpin / Delete, while the
canonical "All hosts" listing also offers Duplicate and Copy Credentials.
There is no reason to omit those two for hosts you've pinned or recently
opened — the underlying handlers are already wired up.

Add the missing entries in the same order as the All-hosts menu so the
three context menus stay visually identical.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:17:28 +08:00
陈大猫
b02b83f225 Place per-host statusbar tooltips below their triggers (#964)
The copy-host-address, broadcast and focus-mode buttons sit on the
per-host statusbar directly under the top tab bar. With the default
top-side tooltip placement, hovering any of them paints the tooltip
on top of the tab title above (the visible "Copy host address …"
covering "Rainyun-114.66.26.174" in the bug report screenshot).

Drop the tooltips on the bottom side instead, matching the
HoverCardContent panels already used for the CPU/Memory/Disk stats
buttons on the same bar.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:13:07 +08:00
陈大猫
bca5d63a4e Fix #919: harden built-in Telnet handshake for legacy gear (#963)
* Fix #919: harden built-in Telnet handshake for legacy gear

The built-in Telnet client failed to advance past the welcome banner on
some older switch firmware (HP ProCurve 2610 reported in #919) and, in
the same session, leaked snippets of subnegotiation payloads into the
terminal display as random-looking characters. Three independent
correctness gaps in the old implementation, all rolled into one PR:

1. The negotiation parser was stateless per chunk. An IAC sequence
   split across TCP frames either dropped the lone IAC (lost command)
   or, for IAC SB...IAC SE blocks whose terminator landed in the next
   frame, fell through to "skip IAC SB and treat the rest as data" —
   spilling the subnegotiation payload (TERMINAL-TYPE strings,
   environment data) into the user's terminal as garbage.

2. The client was purely reactive — it only ever responded to options
   the server raised. Quite a bit of legacy equipment waits for the
   client to commit to SUPPRESS-GO-AHEAD / TERMINAL-TYPE / NAWS before
   it will continue past its banner, so connections silently hung at
   "Press any key to continue" forever.

3. Outbound user input was never IAC-escaped, so any 0xFF byte the user
   pastes (or that an alternate input encoding emits) would be read by
   the peer as the start of a command and eat the following byte.

Approach:

- New `electron/bridges/telnetProtocol.cjs` owns RFC 854 framing as a
  pure module. `createTelnetParser` is a stateful machine that buffers
  any partial command (lone IAC, IAC + verb, unterminated SB) across
  feeds and replays it once the rest arrives. Emits clean stream
  bytes, option commands and complete subnegotiations through
  callbacks. `escapeIacForWire` doubles 0xFF bytes on the way out with
  a cheap fast-path for the common (no 0xFF) case.

- `terminalBridge.cjs` flips telnet handling into a lazy mode: until
  the peer sends an IAC byte the connection is plain passthrough, so
  raw-TCP-on-port-23 services are not corrupted by the protocol layer.
  Once the protocol activates, we proactively request DO
  SUPPRESS-GO-AHEAD, WILL TERMINAL-TYPE and WILL NAWS, and track those
  in a `requestedOptions` Set so the peer's acknowledgement does not
  trigger another reply (the classic negotiation loop).

- TERMINAL-TYPE is now advertised as "XTERM-256COLOR" (upper-case);
  legacy boxes that case-sensitive-match termcap names recognise it.

- Resize-driven NAWS subnegotiations now only fire after the protocol
  has actually activated, so a passthrough session is never poisoned.

- Outbound writes for telnet sockets convert strings to UTF-8 buffers
  and run them through `escapeIacForWire`, so paste of binary content
  and non-ASCII input encodings round-trip safely.

Tests:
- 17 unit tests in `telnetProtocol.test.cjs` cover normal data,
  option commands, subnegotiation (including IAC IAC inside payload),
  every cross-frame split point (lone IAC, IAC + verb, mid-SB), the
  specific regression that previously leaked SB payload as data,
  ordering of data vs command callbacks, and the IAC escape helper.
- Existing 18 telnet auto-login tests still pass, exercising the
  end-to-end socket → parser → renderer path. Full suite: 825 / 0 / 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address review: per-direction Telnet negotiation tracking

RFC 858 §"Default Specification" treats WILL/WONT and DO/DONT as two
independent option streams. The first revision of this PR used a single
`requestedOptions` Set keyed by option byte, which incorrectly swallowed
a peer's independent request on the opposite direction whenever we had
our own request still pending for the same option.

Concrete failure mode (highlighted by code review on the PR): we send
`DO SGA` and the peer simultaneously sends `DO SGA` asking us to enable
SGA on our outgoing side. The old check matched the peer's DO against
our pending DO and returned silently, leaving the peer's request
unanswered — strict implementations would either time out or proceed in
the wrong mode.

Fix: split pending requests into `pendingDoRequests` (we sent DO,
awaiting WILL/WONT) and `pendingWillRequests` (we sent WILL, awaiting
DO/DONT). Acknowledgement matching is now direction-aware; the peer's
independent request on the orthogonal direction is treated as a fresh
negotiation and replied to.

While in there, the related bug uncovered by reviewing this code: when
the peer's `DO NAWS` acknowledges our own `WILL NAWS`, we previously
just dropped it on the floor — but the actual window-size SB payload
needs to follow the WILL handshake either way (whether the DO is an
acknowledgement of our WILL or an independent fresh request). The
negotiator now always pushes the size subnegotiation on `DO NAWS`.

Refactor: the negotiation policy lives in a new
`createTelnetNegotiator` factory inside `telnetProtocol.cjs`, separate
from the parser. That keeps `terminalBridge.cjs` thin and — more
importantly — makes the policy directly unit-testable. 13 new tests
cover the bidirectional-collision regression, the missing NAWS
follow-through, fresh vs ack handling for each verb, the canonical
handshake sequence, unsupported-option WONT/DONT replies, the
TERMINAL-TYPE SEND→IS roundtrip, and the 80×24 fallback for invalid
sizes.

Total: 30 parser+negotiator unit tests, 18 existing telnet auto-login
integration tests, full suite 838 / 0 / 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:07:51 +08:00
陈大猫
67c5571df5 Fix #958: highlight IPv6 + allow editing built-in keyword rules (#962)
Two changes addressing both halves of #958:

1. IPv6 highlighting
   The built-in 'URL, IP & MAC' rule only shipped URL, IPv4 and MAC
   patterns, so compressed IPv6 addresses such as 2001:11:22:33::5 or
   fe80::d2dd:bff:fe79:f2bb were never highlighted. Add an IPv6 regex
   covering full and compressed forms (including ::1 and leading-/trailing-
   :: variants) and merge it into the same 'ip-mac' rule's patterns. The
   normalizer's existing "fill missing defaults" path means existing users
   pick this up on next start with no migration step.

2. Editable built-in rules
   Add an optional `customized` flag to KeywordHighlightRule. When false /
   absent, normalize re-syncs the rule's label/patterns with the shipped
   defaults (so future default-pattern upgrades reach users automatically).
   When true, normalize keeps the user's label/patterns/color/enabled
   verbatim, allowing built-ins like 'ip-mac' to be tailored.

   SettingsTerminalTab:
   - Pencil icon now appears on built-ins too. Editing one routes through
     the same dialog and flips `customized` on save.
   - The pattern field becomes a Textarea so multi-pattern built-ins (e.g.
     'error' ships seven spellings) can all be edited in one go.
   - A per-rule "↺" reset icon appears on customized built-ins and restores
     the shipped label/patterns while preserving the user's color/enabled.
   - The footer's "Reset to default colors" button is broadened into
     "Reset built-ins to defaults", restoring every built-in to shipped
     label/patterns/color and clearing `customized`.

Tests:
   New domain/keywordHighlight.test.ts (6 tests) covers IPv6 matches for
   both #958 examples plus loopback and full-form, IPv4/MAC still match,
   normalize migrates legacy non-customized 'ip-mac' to include IPv6,
   normalize preserves customized patterns, and normalize keeps user
   custom rules verbatim. Full suite: 808/0/3.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:35:07 +08:00
陈大猫
ea5320d94a Fix #954: unify Tooltip styling + replace native selects (#961)
* Fix #954: unify Tooltip styling + replace native selects

Replace native HTML title= tooltips and native <select> dropdowns
with the existing Radix-based Tooltip / Select components so they
share the app's rounded styling, theme tokens and i18n pipeline.
Adds a global TooltipProvider in AppWithProviders so every
descendant Tooltip works without a per-file Provider wrapper.

Scope (driven by the issue #954 examples and "全部都处理" follow-up):

- TerminalLayer toolbar: Add Terminal / Split View / SFTP / Scripts
  / Theme / AI Chat / Move panel / Close panel.
- TopTabs middle bar: quick switcher, more tabs, AI assistant, theme
  toggle, settings; window-control buttons (min/max/close), tray
  close and hotkey reset/disable have their native title dropped per
  the user's explicit opt-out ("可以不用Tooltip,直接全局禁用
  原生title 属性").
- AI panels: AIChatSidePanel session history / new chat / delete,
  ConversationExport, AgentSelector, ChatInput attach / expand /
  permission, ModelSelector, ProviderCard, ai-elements/tool-call.
- SFTP: SftpSidePanel header, SftpBreadcrumb, SftpFileRow,
  SftpPaneToolbar, SftpTabBar, SftpTransferQueue.
- Settings: SettingsPage close, SettingsAppearanceTab theme/accent
  swatches, SettingsFileAssociationsTab edit/remove, SettingsSystemTab
  crash-log paths and global hotkey reset.
- Host vault: HostDetailsPanel (clear / suggestions / show-password /
  key path / browse key), GroupDetailsPanel, KnownHostsManager,
  ConnectionLogsManager, KeychainManager, SyncStatusButton,
  CloudSyncSettings, LogView, QuickSwitcher, ScriptsSidePanel,
  Terminal status bar copy-host + broadcast/focus, ZmodemProgressIndicator.
- Terminal subcomponents: HostKeywordHighlightPopover, TerminalComposeBar,
  TerminalConnectionDialog, TerminalSearchBar.
- Editor: TextEditorPane (subtitle, search, wrap, promote-to-tab).
- TrayPanel session rows and port-forwarding rows.

Native <select> migrated to custom Select component:
- SerialConnectModal (data bits, stop bits, parity, flow control)
- SerialHostDetailsPanel (same four fields)
- HostDetailsPanel backspace behavior
- GroupDetailsPanel backspace behavior
- SettingsTerminalTab local shell picker
- terminal/ThemeSidePanel font weight

Hardcoded English strings extracted to i18n. New keys for both
en and zh-CN: terminal.layer.*, topTabs.*, ai.chat.* (sessionHistory,
attach, collapse, expand, enableAgent), zmodem.*, settings.shortcuts.
resetToDefault. Inline help text on SnippetsManager package-name input
removed because the same hint is already shown in a visible <p> below
the input.

Existing per-file <TooltipProvider> wrappers (SnippetsManager,
ScriptsSidePanel, SelectHostPanel, RuleCard, HostDetailsPanel proxy
section) are left in place — they nest harmlessly under the global
provider and stay self-sufficient for component tests.

Tests:
- tsc clean for changed files (pre-existing repo-wide errors
  unrelated to this PR).
- All 802 tests pass (3 skipped pre-existing).
- HostDetailsPanel.proxyProfile.test and TextEditorPane.test
  updated to wrap with TooltipProvider, matching the runtime
  context now needed by the migrated components.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix #954: wrap Settings + Tray windows with TooltipProvider

Settings and the tray panel mount as separate Electron windows with
their own React root in index.tsx, so they do not inherit the global
TooltipProvider added under AppWithProviders. After the unified
Tooltip migration, any settings tab that used a Tooltip (Appearance,
Application, FileAssociations, System, Shortcuts, Terminal, AI
ProviderCard, AI ModelSelector) — and TrayPanel — threw
"Tooltip must be used within TooltipProvider" and rendered nothing.

Wrap both branches with TooltipProvider at the same level as
ToastProvider in index.tsx.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:14:24 +08:00
陈大猫
ffd3111b71 Fix #957: persist SSH known-host trust across app restarts (#960)
useVaultState hydrates knownHosts asynchronously — its init awaits the
decryption of hosts, keys, identities and proxyProfiles before reading
knownHosts from localStorage. The state is briefly [] at boot even when
localStorage has saved entries.

The host-key verifier introduced in bce33f34 reads the renderer's
knownHosts state at connect time. Any SSH connect that fires inside
that hydration window (manual click or auto-restored session) sees an
empty trust list, marks every host as unknown, and prompts again. The
fix accepted by the user is saved to localStorage, but next restart
the same race repeats, giving the impression that fingerprints are
never persisted.

Use the existing getEffectiveKnownHosts helper at the two sites that
feed the SSH connect path (VaultView + TerminalLayerMount). The helper
falls back to localStorage while state is still settling, mirroring
the same pattern already applied to sync payloads (App.tsx:479).

Memoised on the knownHosts state so the prop reference is stable and
the TerminalLayer/VaultView React.memo equality checks still hold.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:24:06 +08:00
penguinway
b0949f1a1e feat(sftp): add drive switcher dropdown for local Windows panes (#953)
* feat(sftp): add drive switcher dropdown for local Windows panes

On Windows, the SFTP breadcrumb's first segment (drive letter) now shows
a dropdown to switch between available drives. This makes it easy to
navigate across drives without manually editing the path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sftp): probe drives async to avoid blocking main process

fs.accessSync in the listDrives IPC handler could stall the Electron
main process for seconds per disconnected mapped drive or empty optical
drive. Use fs.promises.access with Promise.allSettled so the 26 probes
run in parallel without blocking the event loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-05-12 17:58:07 +08:00
陈大猫
84416d04bf [codex] Fix issue 957 long paste display (#959)
* Fix long paste display artifacts

* Fix serial line mode pasted chunks

* Narrow long paste display cleanup scope

* Strip only matched paste echo highlights

* Honor paste scroll setting through xterm paste
2026-05-12 17:33:31 +08:00
陈大猫
109d0a7ab7 feat(terminal): add copy-host-address button to per-host statusbar (#951) (#952)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
Adds a small clipboard-copy icon next to the host label / status dot in
the terminal pane's statusbar. Clicking copies the host's hostname
(IP or DNS name — what users called "machine IP" in #951) to the
clipboard and surfaces a toast.

The button only renders for non-local SSH/serial/telnet sessions —
local shells don't have an addressable hostname so showing it would
be confusing.

Placed in the pane statusbar (not the top tab) because the statusbar
is per-host: a workspace pane carries exactly one host, so the button
always identifies the right address. Top tabs in a workspace can share
multiple panes / hosts and would be ambiguous.

Visual treatment matches the surrounding stats buttons: 10px icon,
inline with the existing host label + status dot, opacity-60 →
opacity-100 on hover, `title` attribute for the tooltip to match the
pattern of the CPU/MEM/disk stats triggers right next to it.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:51:50 +08:00
陈大猫
92ecd84edf Fix #939: per-host SSH keepalive override + cloud-friendly defaults (#947)
* fix(ssh): per-host keepalive override + cloud-friendly defaults (#939, #581)

Issues #939 (cloud / Aliyun sessions silently freezing after 15-20 min idle
because no SSH keepalive packets are sent) and #581 (older routers like
NOKIA / ALCATEL being killed by ssh2 after a few unanswered keepalives) are
in direct tension at the global-setting level: cloud users want keepalive
ON, embedded-device users want it OFF, and any single global default hurts
the other group.

Resolves the conflict by moving keepalive to a per-host setting (mirroring
the existing `legacyAlgorithms` per-host pattern), with cloud-friendly
global defaults:

Domain:
  - Host gains `keepaliveOverride?: boolean` + `keepaliveInterval?: number`
    + `keepaliveCountMax?: number`. When override is true, the host's
    values are used; otherwise the global TerminalSettings values apply.
    Per-field fallback so a host can override interval only or countMax only.
  - TerminalSettings gains `keepaliveCountMax: number` so the second knob
    (number of unanswered keepalives before declaring dead) is no longer
    hardcoded at 3 in the bridge.
  - DEFAULT_TERMINAL_SETTINGS: keepaliveInterval bumped from 0 to 30, and
    keepaliveCountMax = 10. Cloud LBs / NAT tables stay populated; brief
    network glitches don't trip the dead-connection check; an actually
    dead session is detected within ~5 minutes. Existing users with 0
    saved keep their value (no migration) — they were the #581 router
    cohort and their setup still works untouched.

Plumbing:
  - domain/host.ts adds resolveHostKeepalive(host, globalSettings) with
    five unit tests covering both directions of the override flag and
    per-field fallback.
  - components/terminal/runtime/createTerminalSessionStarters.ts uses the
    resolver when building startSSHSession options.
  - electron/bridges/sshBridge.cjs reads keepaliveCountMax from options
    (defaulting to 10) at both connection sites (direct + jump host) and
    still routes interval=0 through to a fully disabled keepalive
    (preserving #581's escape hatch).

UI:
  - Settings → Terminal → Connection grows a second input next to the
    existing interval: "Max unanswered keepalives".
  - Host details panel gains a Keepalive section with a "Override global
    keepalive" toggle that, when on, exposes per-host interval +
    countMax inputs and an inline hint when interval = 0 (explaining
    the implications). Same visual pattern as the existing Legacy
    Algorithms section.

Sync:
  - keepaliveCountMax added to SYNCABLE_TERMINAL_KEYS so the new global
    field rides existing sync infrastructure. Per-host fields ride the
    hosts array passthrough automatically (older clients receiving them
    ignore unknown fields, per the existing lenient sync contract).

i18n: en + zh-CN strings for the new settings row, the host section
header, and the override toggle / inputs / disabled hint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): resolve keepalive per jump host, not just the final target

Addresses codex review on PR #947:
  https://github.com/binaricat/Netcatty/pull/947#discussion_r3217027xxx

The first cut only resolved keepalive for the final target host and
forwarded a single interval/countMax pair across the whole start-SSH
call. connectThroughChain in sshBridge.cjs then applied that one pair
to every hop, so a chain like:

   router (bastion, needs keepalive=0)  →  cloud target (needs 30s)

would either kill the router (with cloud-friendly defaults) or fail
to keep the target alive (with router-friendly 0). The per-host
override was effectively useless for bastion hosts.

Fix:
  - NetcattyJumpHost gains optional keepaliveInterval / keepaliveCountMax.
  - createTerminalSessionStarters runs resolveHostKeepalive() per
    jumpHost when building the chain, so each hop carries its own
    resolved pair.
  - sshBridge.cjs's chain connector reads jump.keepaliveInterval /
    jump.keepaliveCountMax for each hop, falling back to the call's
    target-level options for backward compatibility with older
    serializers that don't yet populate the per-hop fields.

The final target's keepalive path is unchanged — it still reads
options.keepaliveInterval / options.keepaliveCountMax that the
session starter resolves from the target host.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): per-host keepalive for SFTP + port forwarding too

Follow-up to the maintainer review on PR #947 — terminal SSH was the
only path that honored per-host keepalive overrides. SFTP and port
forwarding share the same NetcattyJumpHost type but their builders
weren't resolving keepalive per-hop, and their bridges hardcoded the
old 10s/3 defaults. Net result: a router-as-bastion in a chain still
got killed when reached via the SFTP file panel or a port-forwarding
tunnel, even though the user had toggled per-host override.

Plumbing:
  - useSftpHostCredentials / buildSftpHostCredentials: accept optional
    terminalSettings; call resolveHostKeepalive() for the target and
    each jump entry; emit keepaliveInterval / keepaliveCountMax in the
    returned NetcattySSHOptions.
  - useSftpConnections + useSftpState + SftpStateOptions thread the
    setting down. SftpSidePanel passes the global terminalSettings prop
    it already has from TerminalLayer.
  - portForwardingService.startPortForward: accepts terminalSettings
    as an 8th argument, resolves per-host (target + each jump), and
    populates the bridge payload.
  - usePortForwardingState.startTunnel and usePortForwardingAutoStart
    forward the new parameter; App.tsx supplies terminalSettings (via
    a ref in the once-on-launch auto-start effect so changing global
    keepalive later doesn't re-fire it).

Bridges:
  - sftpBridge.cjs target connect: now also reads keepaliveCountMax
    from options (was hardcoded 3). 10s/3 stays as the bridge-level
    fallback to preserve the #669 protection when the renderer hasn't
    supplied a value.
  - sftpBridge.cjs jump hop: reads jump.keepaliveInterval /
    jump.keepaliveCountMax, then falls back to the target-call options
    (matches the symmetric SSH bridge change).
  - portForwardingBridge.cjs: reads keepaliveInterval /
    keepaliveCountMax from the IPC payload; same 10s/3 fallback.

Types:
  - NetcattyJumpHost already grew keepalive fields earlier; this
    commit also adds them to PortForwardOptions so the IPC contract
    is explicit.

End-to-end: a chain `[router-as-bastion, cloud-host]` with the
router host's keepaliveOverride=true / interval=0 now correctly
disables keepalive on the router hop for terminal SSH AND SFTP AND
port forwarding, while the cloud target still gets the resolved
30s/10 default for each path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): honor explicit keepalive=0 in SFTP + port forwarding bridges

Addresses codex review on PR #947:
  - https://github.com/binaricat/Netcatty/pull/947#discussion_r3217448xxx
  - https://github.com/binaricat/Netcatty/pull/947#discussion_r3217449xxx

The previous follow-up commit (5c8bc923) plumbed per-host keepalive
into SFTP / port forwarding but kept the existing bridge-level
"if interval > 0 use it, else 10s" fallback. That collapsed two
semantically distinct inputs:

  - "user explicitly resolved interval = 0" (host with keepaliveOverride
    + interval=0; the whole point of the override)
  - "no value supplied at all" (legacy serializer)

Both ended up as 10s in the bridge, so a router-as-bastion / direct
router connection through SFTP or a port-forward tunnel still got
ssh2-killed after countMax unanswered probes — exactly the case
per-host override was supposed to fix.

Fix: bridges now distinguish on `== null`:
  - positive value → honor it
  - explicit 0 → truly disabled (0 ms, 0 countMax — ssh2 skips its
    dead-connection check entirely on this connection)
  - undefined / null → fall back to 10s/3 (preserves #669 idle-NAT
    protection for older callers that pre-date per-host plumbing)

Applies to both SFTP target connect and SFTP jump hop builders, plus
the port forwarding target builder. Terminal SSH bridge is unchanged
since it already treated 0 as disabled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): plumb terminalSettings to all remaining keepalive call sites

Addresses codex review on PR #947:
  - PortForwardingNew + TrayPanel were not passing terminalSettings into
    startTunnel, so tunnels started from the main port-forwarding UI or
    from the tray menu silently used the FALLBACK 30/10 instead of the
    user's actual global keepalive settings. Hosts inheriting global
    policy could see different behavior depending on the entry point.
  - SftpView was not threading terminalSettings into useSftpState, so
    SFTP connections opened from the main tab UI also fell back to the
    same hardcoded default and ignored the user's settings.

Wiring:
  - PortForwardingProps gains `terminalSettings`; VaultView accepts it
    on the same prop and forwards from its own new prop; App.tsx
    supplies it from useSettingsState. The startTunnel call site uses
    it directly and includes it in the useCallback dep list so the
    handler updates when settings change.
  - SftpViewProps gains `terminalSettings`; SftpViewMount accepts and
    forwards it; the sftpOptions memo includes it in its dep list.
  - TrayPanelContent gains a `terminalSettings` prop; the TrayPanel
    wrapper (which already calls useSettingsState for uiLanguage)
    passes it down so the standalone tray window agrees with the main
    window's settings.

Also updates the explicit `startTunnel` signature in
UsePortForwardingStateResult so callers see the new 8th parameter
through the hook's return type, not just through the implementation.

Net result: every place that starts an SSH-derived connection
(terminal session, SFTP browse, port-forward tunnel) now consistently
sees the user's configured global keepalive policy and any per-host
overrides; the FALLBACK_KEEPALIVE constants in the service /
credentials builder are now only reached by genuinely-decoupled call
sites (tests, headless usage) rather than masking missing wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): include terminalSettings keepalive fields in memo comparators

Addresses codex review on PR #947 — all three components that grew a
`terminalSettings` prop (SftpView, SftpSidePanel, VaultView) are wrapped
in React.memo with manual equality comparators, and none of those
comparators were updated to include the new prop. React would skip the
re-render when global keepalive changed, so new SFTP / port-forwarding
connections from those subtrees would silently keep using the old
keepalive policy until some other tracked prop happened to flip.

Each comparator now compares the keepalive fields directly rather than
the whole terminalSettings object — only those two fields drive
connection resolution in this subtree, and ignoring the rest avoids
unnecessary re-renders for unrelated terminal-setting changes (fonts,
themes, etc.) that already have their own targeted comparator entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:22:12 +08:00
DeepFal
311f44525b Fix AI export menu theme colors (#944) 2026-05-11 15:00:49 +08:00
陈大猫
b4e185e1c6 fix(terminal): restore right-click paste in mouse-tracking TUIs (#941) (#946)
When a TUI app enables SGR mouse tracking (opencode, tmux with
`mouse on`, vim with `set mouse=a`, etc.), Terminal.tsx attaches a
capture-phase contextmenu listener that calls
stopImmediatePropagation. The original purpose is to bypass xterm.js's
own right-click handler — which calls textarea.select() and dismisses
TUI popup menus — but stopImmediatePropagation also kills the bubble
that React's onContextMenu delegation relies on, so
TerminalContextMenu's handleRightClick never fires.

Result: with `rightClickBehavior` set to "paste" (or "select-word"),
right-click silently does nothing inside any mouse-tracking TUI. Menu
mode still works because Radix opens via pointerdown (not affected by
the contextmenu capture block). Middle-click paste works because its
auxclick listener in createXTermRuntime is also unrelated to
contextmenu.

Fix: have the capture handler itself dispatch the user's chosen
right-click action when it intercepts the event. terminalContextActions
already exposes onPaste / onSelectWord; mirror them into a ref so the
once-bound capture handler can call the current implementation
without re-binding on every action identity change.

'context-menu' mode is intentionally not handled in the capture path —
Radix's pointerdown listener opens the menu independently.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:00:09 +08:00
陈大猫
92dd898eb4 Fix #931: let users pick a CJK font + per-font smart pairing (#940)
* feat(fonts): add CJK font pairing composition module

Introduces composeFontFamilyStack() which builds the xterm fontFamily
CSS string at runtime from:
  - the user's primary Latin font
  - an explicit CJK font (TerminalSettings.fallbackFont) if set
  - otherwise a per-Latin-font recommended CJK pairing
  - a hardcoded system CJK fallback stack
  - a Nerd Font icon fallback stack
  - the universal monospace generic

14 unit tests cover composition order, deduplication, OS defaults,
quoting, and recommendation override behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(fonts): expose raw Latin families and add CJK-coverage entries

- TERMINAL_FONTS[].family no longer bakes in the CJK fallback stack;
  composition is deferred to runtime via composeFontFamilyStack().
- Drops withCjkFallback helper from this module and its caller in
  lib/localFonts.ts.
- Adds 6 CJK-coverage primary fonts to the dropdown: Sarasa Mono SC/TC,
  Maple Mono CN, LXGW WenKai Mono, Microsoft YaHei UI, PingFang SC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(terminal): compose font-family stack with user-configurable CJK fallback

resolvedFontFamily now passes through composeFontFamilyStack(), which
prepends the user's TerminalSettings.fallbackFont (if set) ahead of the
per-Latin-font recommended CJK pairing and the system fallback stack.

The platform argument is derived from navigator.platform inside the
useMemo, so the same Latin font may pair with PingFang SC on macOS and
Microsoft YaHei UI on Windows out of the box.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(settings): add CJK font picker to terminal settings

Adds a new "CJK font" select row right under the main font selector in
the Terminal settings tab. Bound to TerminalSettings.fallbackFont (an
already-existing-but-unused field), so this needs no schema or sync
payload change.

Default value "Auto" leaves fallbackFont empty, which lets the new
per-Latin-font pairing in cjkFonts.ts pick a CJK font automatically.
Selecting any explicit option (Sarasa Mono SC, PingFang SC, Microsoft
YaHei UI, etc.) takes precedence over the per-font pairing.

Includes en + zh-CN i18n strings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(sync): cover fallbackFont round-trip + legacy payload tolerance

Four new test cases verify cloud-sync compatibility for the new CJK
font setting:

  - buildSyncPayload includes fallbackFont when set
  - buildSyncPayload omits fallbackFont when unset
  - applySyncPayload writes incoming fallbackFont to TERM_SETTINGS
  - applySyncPayload from a legacy client (no fallbackFont) does NOT
    wipe the local value — critical for old-to-new upgrades

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(fonts): add font availability detection (canvas + document.fonts API)

Three-layer detection used by isFontInstalled(family):
  1. Known @fontsource-bundled families (e.g. JetBrains Mono) always
     count as installed.
  2. document.fonts.check() — picks up @font-face and system-loaded fonts.
  3. Canvas width measurement against serif / sans-serif / monospace
     fallbacks; only counts if the target font produces a width that
     differs from ALL three generics for a probe string.

detectInstalledWithContext is a pure function taking an injected
measurement context, which keeps the canvas / DOM behind a seam and
lets the logic be unit-tested without a browser. 11 tests cover
quoted-family parsing, the three-generic-fallback rule, bundled
short-circuit, and document.fonts.check fast-path.

Results are cached per process; clearFontAvailabilityCache() invalidates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(fonts): filter dropdowns to fonts actually installed on this machine

Layer 3 of #931 added Sarasa Mono SC / Maple Mono CN / Microsoft YaHei UI
/ PingFang SC etc. to the terminal font dropdown, but users who don't
have these installed would still see them and pick them — resulting in
"I changed the font and nothing happened" confusion.

This commit filters both dropdowns through isFontInstalled():

  - TerminalFontSelect: drops any built-in or system-discovered font
    that detection can't render. If filtering would leave fewer than 4
    fonts (detection misfire safety net), shows the full list.

  - TerminalCjkFontSelect: keeps the "Auto" sentinel always, drops
    concrete CJK choices that aren't present on this machine.

Both selects always keep the currently-selected value visible — even
when the underlying font is missing — so users can read and clear
their setting without surprise.

Also expands `npm test` globs to pick up infrastructure/config/*.test.ts
and lib/*.test.ts, which previously matched no patterns and meant the
new cjkFonts and fontAvailability suites were silently excluded from
CI runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): never recommend proportional CJK fonts for terminal use

The previous PingFang SC / Microsoft YaHei UI / Hiragino Sans GB choices
were proportional sans-serif fonts whose CJK glyphs aren't designed to
fit a terminal's 2x cell grid — the rendered Chinese ended up visibly
wider than its allocated cells, breaking grid alignment (reported on
macOS with PingFang SC selected as the CJK font).

Changes:
  - TerminalCjkFontSelect: drops PingFang SC / Microsoft YaHei UI /
    Hiragino Sans GB from the dropdown. Legacy explicit selections
    still surface as a synthetic "not recommended" option so users can
    see and re-pick.
  - CJK_SYSTEM_FALLBACK_FONTS: monospace-only list. Sarasa Mono SC/TC,
    Maple Mono CN, LXGW WenKai Mono, Noto Sans Mono CJK SC, Source Han
    Mono SC, NSimSun, SimSun. Proportional fonts removed.
  - PER_FONT_CJK_PAIRING: every entry now points at a true monospace
    CJK font. Cascadia / Consolas / Menlo etc. all recommend Sarasa
    Mono SC, which the next commit bundles via @font-face.
  - getDefaultCjkFallback: Windows = SimSun (always installed,
    monospace); macOS = Sarasa Mono SC (will be bundled); Linux =
    Noto Sans Mono CJK SC. A regression test enforces that no
    per-OS default is a known proportional font.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(fonts): bundle Sarasa Mono SC as the universal CJK monospace

Previous commit removed proportional CJK fonts (PingFang SC, etc.)
from the picker and switched per-OS defaults to true monospace, but
macOS ships NO system-installed monospace CJK font — leaving macOS
users with a broken default unless they manually install Sarasa or
similar. This commit closes that gap by bundling Sarasa Mono SC as
an @font-face webfont, so the recommended pairings and macOS default
"just work" out of the box.

Details:
  - public/fonts/SarasaMonoSC-Regular.woff2 (~4.8 MB): subsetted from
    be5invis/Sarasa-Gothic v1.0.37 SarasaMonoSC-Regular.ttf (24 MB).
    Covers ASCII, Latin-1, common punctuation/symbols, CJK Unified
    Ideographs main block, Hiragana/Katakana, halfwidth/fullwidth,
    box-drawing — the everyday-Chinese coverage that matters for a
    terminal. Rare CJK Ext-A/B/historical chars fall through to the
    system fallback stack.
  - public/fonts/SarasaMono-LICENSE.txt: OFL-1.1 verbatim, required
    by the license.
  - index.css: @font-face declaration with font-display: swap so the
    user doesn't see a flash of nothing while the woff2 loads.
  - KNOWN_BUNDLED_FAMILIES: "Sarasa Mono SC" added so the dropdown
    availability filter doesn't hide it.

Installer impact: ~+4.8 MB (vs current ~100-200 MB Electron baseline).
The font replaces what would otherwise have been "Chinese chars look
broken in the terminal" for every macOS user without a manually
installed CJK monospace font.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): use Local Font Access API as the authoritative install check

document.fonts.check() turned out to be unreliable as an installed-font
signal in Chromium — it returns true for any syntactically-valid family
name regardless of whether the font is actually installed, as a
deliberate fingerprinting-mitigation. The previous detector took it as
a positive signal and ended up keeping uninstalled fonts in the dropdown
(reported by a macOS user seeing dozens of fonts they don't have).

This commit pivots the detection chain:

  - lib/localFonts.ts: getAllSystemFontFamilies() exposes the unfiltered
    set of installed family names from queryLocalFonts(), reusing the
    same underlying call as getMonospaceFonts() via a shared cache.

  - lib/fontAvailability.ts: drops the document.fonts.check fast-path.
    Adds setSystemFamilies() / hasAuthoritativeData(). When the set has
    been populated, isFontInstalled answers from membership lookup
    directly — no canvas guessing. Canvas remains as a fallback for
    environments where the Local Font Access API is unavailable or
    permission is denied.

  - application/state/fontStore.ts: during initialize(), runs the
    monospace-only query and the full-system-families query together,
    then pipes the result into fontAvailability.

  - TerminalFontSelect: with authoritative data, drops the "if filtered
    list is suspiciously small, show all" safety net. Empty would now
    really mean empty (highly unlikely since Sarasa Mono SC is bundled).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): drop PingFang SC / Microsoft YaHei UI from primary dropdown

Step 1 of this PR removed proportional CJK fonts from the CJK fallback
picker but left them in BASE_TERMINAL_FONTS, so PingFang SC and
Microsoft YaHei UI were still selectable as the *primary* terminal
font. Picking PingFang SC as primary produced visibly bloated Latin
character spacing (xterm.js samples cell width from the primary font;
the wide proportional 'M' inflates every cell), reported by a macOS
user in the same thread that opened #931.

Both entries are removed from BASE_TERMINAL_FONTS. A new
infrastructure/config/fonts.test.ts asserts that no known proportional
CJK font name (including PingFang TC/HK, Microsoft YaHei variants,
Hiragino Sans GB, Heiti SC/TC) is ever shipped in TERMINAL_FONTS as a
primary choice.

Migration for users already saved to one of the removed ids:
useSettingsState rewrites STORAGE_KEY_TERM_FONT_FAMILY to the default
(Menlo) on read when it sees a deprecated id, so the bad value also
stops getting carried into cloud-sync uploads. Per-host fontFamily
overrides are NOT migrated automatically — they still gracefully
fall through to the dropdown's first entry via the existing
getFontById fallback; users can re-pick from the host settings UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): drop Comic Sans MS — it's a proportional handwriting font

Same symptom as the PingFang SC / Microsoft YaHei UI removal: Comic
Sans MS was historically in the primary font dropdown labeled
"Casual, non-traditional terminal font", but Comic Sans is a
handwriting-style proportional sans-serif. Picking it as the terminal
primary inflates cell width and spaces every Latin character far
apart (reported in the same #931 thread).

- BASE_TERMINAL_FONTS: comic-sans-ms entry removed.
- DEPRECATED_PRIMARY_FONT_IDS: gains comic-sans-ms so existing
  selections silently migrate to Menlo on read.
- fonts.test.ts: the proportional-font ban list now also covers
  Latin proportional fonts (Comic Sans MS, Arial, Helvetica, Times
  New Roman, Georgia, Verdana, Trebuchet MS, Tahoma) so the test
  catches any future mislabeled body-text font from being added to
  the terminal dropdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): keep monospace ahead of CJK fallbacks in composed stack

Addresses codex P1 review comment on PR #940
(https://github.com/binaricat/Netcatty/pull/940#discussion_r3216017737).

The previous behavior of withCjkFallback() had monospace immediately
after the primary family, before any CJK fallback. composeFontFamilyStack
had moved monospace to the very end, which means: when the primary
font isn't installed on the user's machine (common for Layer 3 CJK
choices that aren't bundled and not present on a given OS, or for any
built-in id like cascadia-code on a Linux system without it), CSS
per-glyph fallback resolves Latin glyphs from a CJK font's full-width
Latin variants before ever reaching monospace generic. That breaks
xterm.js's fixed cell-grid alignment.

The composed stack now reads:
  <primary>, monospace, <userFallback>, <recommended-cjk>,
  <system-cjk-stack>, <nerd-font-stack>

Per-glyph CSS fallback behavior:
  - Latin → primary if installed → monospace generic. Cell width
    stays consistent.
  - CJK → primary (no) → monospace (no Chinese glyphs) → walks into
    CJK fallbacks.
  - Nerd PUA → falls past all of the above into the Nerd Font stack.

Updates the position-invariant tests and adds a regression test that
explicitly asserts monospace appears before every CJK family in the
output stack.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): dedupe Local Font Access API calls under concurrent init

Addresses codex P2 review on PR #940:
  https://github.com/binaricat/Netcatty/pull/940#discussion_r3216246xxx

fontStore.initialize() runs getMonospaceFonts() and
getAllSystemFontFamilies() in Promise.all; both internally called
queryAllSystemFontsOnce(), whose cache check (`if (cache) return`) was
only useful once the result had been written. Concurrent callers both
passed the empty-cache check and fired their own queryLocalFonts()
request — two real Local Font Access API invocations on cold start,
with the risk of one succeeding while the other was denied (leaving
the authoritative set unset).

Fix: cache the *in-flight promise itself*, so subsequent callers
await the same single invocation. The first await populates the
family-set cache as a side effect, and the resolved promise keeps
returning the same value to every subsequent caller.

Adds lib/localFonts.test.ts with three regression tests:
  - concurrent getMonospaceFonts + getAllSystemFontFamilies = 1 API call
  - sequential repeats also reuse the resolved promise
  - missing API returns null authoritative set (canvas fallback signal)

Exports __resetLocalFontsCacheForTesting() so each test gets a fresh
module-level state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): retry LFA on transient failure + notify on availability changes

Two follow-up fixes from codex P2 review on PR #940:

1) queryAllSystemFontsOnce() previously kept its in-flight promise even
   when queryLocalFonts threw. Subsequent callers reused the cached
   empty result for the rest of the session, so any transient failure
   at boot (permission state not ready, AbortError, etc.) permanently
   blinded the rest of the app to installed fonts. Catch now clears
   queryPromise so the next caller retries. Regression test added.

2) TerminalCjkFontSelect.visibleOptions and TerminalFontSelect
   .visibleFonts were memoized on [value] / [fonts, value] only, but
   the filter calls isFontInstalled() which reads module-level
   systemFamilies — a value that arrives asynchronously after the
   initial render. The memos never recomputed when authoritative
   availability data landed, so the dropdowns could continue showing
   stale "filtered" results until the user changed selection.

   fontAvailability now exposes subscribeFontAvailability() and
   getFontAvailabilityVersion() (monotonic counter bumped on
   setSystemFamilies / clearFontAvailabilityCache). Both selects
   subscribe via useSyncExternalStore and include the version in
   their memo deps; tests cover subscriber notification and version
   monotonicity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): migrate host/group deprecated font ids + localize CJK labels

Two follow-up fixes from codex review on PR #940:

P2 — Host/group level font migration
====================================
The earlier deprecated-id migration only rewrote
STORAGE_KEY_TERM_FONT_FAMILY, so hosts and group configs that had
explicitly opted into a now-removed font id (e.g. pingfang-sc,
microsoft-yahei, comic-sans-ms) kept `fontFamily` set with
`fontFamilyOverride=true`. After the dropdown entries were dropped
in 9f2bd282/c9b622d8, those records silently fell through to the
first font in the registry (Menlo) while the override flag still
read "true" — users saw a host claiming a custom font but rendering
the global default with no way to tell what happened.

Fix:
  - infrastructure/config/fonts.ts gains migrateDeprecatedFontOverride(),
    a structurally-shared helper that drops fontFamily and clears
    fontFamilyOverride when the id is deprecated.
  - sanitizeHost now runs it on every host load.
  - domain/groupConfig.ts grows sanitizeGroupConfig(); useVaultState
    applies it both on initial load and on cross-tab storage events.
  - Existing decrypt → sanitize → encrypt round-trip in useVaultState
    means the migrated values are persisted back to localStorage and
    propagate through cloud sync naturally.

Tests: two each in domain/host.test.ts and domain/groupConfig.test.ts
covering deprecated-id reset and untouched-valid-id preservation.

P3 — Localize CJK font option labels
====================================
TerminalCjkFontSelect previously hardcoded Chinese option labels
("Auto · 按主字体智能搭配", "Sarasa Mono SC (更纱黑体 简)", etc.) and
the synthetic "not recommended" warning. Non-Chinese locales saw a
mixed-language UI despite the rest of the setting going through i18n.

OPTIONS now references i18n keys; the component looks them up via
useI18n(). Both en and zh-CN locales gain matching keys, including
`...option.legacy` with `{font}` interpolation for the synthetic
"not recommended" item that surfaces saved-but-removed values.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): also sanitize group configs on the write/import path

Addresses codex P2 review on PR #940:
  https://github.com/binaricat/Netcatty/pull/940#discussion_r3216314xxx

The previous commit (09c87820) added sanitizeGroupConfig() but only
plumbed it into the decrypt paths (initial load + storage event).
updateGroupConfigs() — which is also the write path used by
applySyncPayload / importVaultData when ingesting a legacy payload —
still set state from raw input. A sync from an older client carrying
{ fontFamily: "pingfang-sc", fontFamilyOverride: true } would land in
memory unsanitized AND be re-persisted with the bad override active
until the next reload re-ran the decrypt path.

Fix mirrors updateHosts → sanitizeHost: map every incoming entry
through sanitizeGroupConfig before both setGroupConfigs and the
encrypt-and-persist step. Same call site now feeds the cleaned data
to localStorage, so legacy values are scrubbed on first import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): migrate deprecated terminal font ids on every ingest path

Addresses codex P2 review on PR #940:
  https://github.com/binaricat/Netcatty/pull/940#discussion_r3216517xxx

The previous migration only ran in the initial useState() initializer
for terminalFontFamilyId, so deprecated ids (pingfang-sc /
microsoft-yahei / comic-sans-ms) could still re-enter state via:

  - rehydrateAllFromStorage() at line ~527 — runs on remote-import
    completion and re-reads STORAGE_KEY_TERM_FONT_FAMILY raw.
  - The notifySettingsChanged IPC handler at line ~663 — fires when a
    cloud sync or programmatic localStorage write announces a change.
  - The cross-window storage event handler at line ~873.

Any of these paths could pull a deprecated id back into state after
the initial migration ran, leaving the font selector with no matching
option and silently rendering the global default while continuing to
propagate the stale value through subsequent sync uploads.

Centralizes the migration in migrateIncomingTerminalFontId(raw):
  - returns null when raw is empty
  - if raw is deprecated, writes DEFAULT_FONT_FAMILY back to
    localStorage AND returns it
  - otherwise returns raw unchanged

All four ingest sites (initial init, rehydrate, IPC, storage event)
now route through this helper. The rewrite-on-deprecated semantics
also guarantee that the moment any path sees a bad value, the next
sync upload carries the cleaned default — not the deprecated id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): use bundled Latin-only fallback instead of monospace generic

Resolves the tension between codex's two P1 reviews on PR #940:

  Round 1 (da1fe4cd): "monospace must come BEFORE CJK fallbacks" —
    otherwise Latin glyphs fall into a CJK font's full-width Latin
    when the primary font is missing.

  Round 2 (this commit): "monospace must come AFTER CJK fallbacks" —
    otherwise on macOS Chrome, the generic `monospace` pulls in
    PingFang via Chromium's CJK system fallback and silently masks
    the user's CJK picker.

Both are right; using a single `monospace` token can't satisfy both
roles because `monospace` is a generic family whose CJK-glyph
coverage is platform-dependent.

Fix mirrors Tabby's approach (their "monospace-fallback" SourceCodePro
sitting before any CJK in the chain): insert a known Latin-only
bundled font between the primary and CJK fallbacks. JetBrains Mono is
already shipped via @fontsource/jetbrains-mono and carries no CJK
glyphs, so it catches Latin without intercepting Chinese.

New stack order:
  <primary>, "JetBrains Mono", <userFallback>, <recommended-cjk>,
  <system-cjk-stack>, <nerd-font-stack>, monospace

Per-glyph CSS fallback now behaves as intended on every platform:
  - Latin: primary (if installed) → JetBrains Mono. Cells stay aligned.
  - CJK: primary (no) → JetBrains Mono (no CJK glyphs) → user CJK pick.
  - Nerd PUA: all of the above → Nerd Font stack.

Replaces the two prior positional-invariant tests with one for each
codex review concern: JetBrains Mono precedes every CJK family
(Latin alignment), and user CJK precedes generic monospace (CJK
picker effectiveness).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): use OR-of-fallbacks for canvas font detection

Addresses codex P2 review on PR #940:
  https://github.com/binaricat/Netcatty/pull/940#discussion_r3216556xxx

detectInstalledWithContext required the target font to produce a
different rendered width from *all three* generic fallbacks (serif,
sans-serif, monospace) to be counted as installed. That's too strict:
on macOS the `monospace` generic resolves to Menlo itself, so
measure(`"Menlo", monospace`) === measure(`monospace`), and the
detector reported Menlo as missing even when it was clearly installed.
The same false-negative trap exists for any font that happens to
share metrics with one of the three generics on a given platform.

Switches to OR-of-fallbacks: a font counts as installed if its
rendered width differs from at least one generic baseline. A truly
uninstalled font still falls through to each generic in turn and
matches all three baselines, so this doesn't introduce false positives.

Regression tests added for both directions:
  - Menlo with metrics identical to `monospace` generic → installed.
  - "Definitely Not Installed" font → still reported missing.

The path only fires when the Local Font Access API is unavailable or
denied — when LFA succeeds, `setSystemFamilies` short-circuits ahead of
canvas — so this primarily improves the degraded-permission scenario.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): quote-aware tokenizer for font-family lists

Addresses codex P2 review on PR #940:
  https://github.com/binaricat/Netcatty/pull/940#discussion_r3216559xxx

composeFontFamilyStack and extractPrimaryFamily both tokenized their
input with a raw String.split(',') — which corrupts any CSS family
list whose quoted family name contains a comma (CSS allows that, e.g.
`"Foo, Inc. Mono"` is a single family). A naive split would shred
that into `"Foo` / `Inc. Mono"` and emit a malformed font-family back
out.

No current TERMINAL_FONTS entry hits this case, but lib/localFonts.ts
builds family strings from arbitrary system fonts via the Local Font
Access API — a user with a comma-bearing family name would have
silently broken filtering until now.

Adds splitFontFamilyList(css) in cjkFonts.ts: an exported quote-aware
tokenizer that splits on commas only when outside quoted segments
(handles both " and '). composeFontFamilyStack uses it instead of raw
split; extractPrimaryFamily in lib/fontAvailability.ts imports it for
symmetry so the two call sites can't drift.

Tests cover the tokenizer directly (simple list, quoted-with-comma,
single quotes, double commas) and end-to-end (a quoted primary with
an internal comma survives composition intact).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fonts): translate Layer 3 CJK font descriptions to English

The 4 CJK-coverage entries added in earlier commits (Sarasa Mono SC,
Sarasa Mono TC, Maple Mono CN, LXGW WenKai Mono) had hardcoded Chinese
description strings, while every other TERMINAL_FONTS entry uses
English ('Adobe's professional programming font', 'Iosevka variant
mimicking Berkeley Mono style', etc.). The dropdown rendered a
mixed-language list — flagged by the maintainer.

Converted the 4 descriptions to English in the same style as the
existing entries. No i18n scaffolding added; the existing convention
is "English-only `description` field, not routed through t()", and
the rest of the registry stays consistent with that.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:07:15 +08:00
bincxz
478e148b40 Drop noisy [XTerm] renderer=... boot log
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
The line printed once per terminal session and offered no diagnostic
value beyond what window.__xtermRenderer already exposes for ad-hoc
introspection. Keep the detection + retry + window publish; just
stop polluting the console. Rename logRenderer → trackRenderer to
match the now-narrowed responsibility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:11:58 +08:00
陈大猫
231fb9c74c Merge pull request #936 from binaricat/fix/stable-use-terminal-backend
Stabilize useTerminalBackend return identity
2026-05-11 00:05:21 +08:00
bincxz
8870eb4de9 Stabilize useTerminalBackend return identity
The hook returned a fresh object literal every render. The 26 methods
inside were already useCallback([])-stable, but the wrapping object
was not — so every consumer's effect with `terminalBackend` in deps
(e.g. cwd polling, lifecycle wiring, write-to-session) re-ran on
every parent render even though nothing semantic had changed, and
ESLint flagged the one site that depended on a property access
(`terminalBackend.onHostKeyVerification`) because it could not prove
that path safe.

Wrap the return in useMemo with all stable callbacks listed as deps
so the object is computed once and cached for the hook's lifetime.
Switch the host-key-verification effect's dep to the now-stable
`terminalBackend`, clearing the warning at the root rather than
patching it locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:04:23 +08:00
陈大猫
c9114eb198 Merge pull request #935 from binaricat/fix/906-ghost-text-after-tab
Fix ghost text duplicating glyphs after Tab completion (#906)
2026-05-10 23:59:03 +08:00
bincxz
938d1ef48b Fix ghost text duplicating glyphs after Tab completion (#906)
The reliability gate at handleInput's adjustToInput call froze the
ghost at its last show()-time tail in any path where the typed buffer
becomes unreliable (Tab pass-through to shell, history recall, cursor
moves). When the user kept typing into that gap, the next render
advanced the cursor past the ghost's anchor while the ghost text
stayed put — a → -accept then pasted the stale tail on top of the
just-typed glyphs (e.g. "systemctl s" + typing "t" → screen showed
"systemctl sttop firewalld").

Add GhostTextAddon.applyKeystroke so the ghost can evolve its own
currentInput off raw keystrokes (printable / Backspace / Ctrl-W),
seeded by whatever the last show() captured from the live xterm
reading. handleInput now uses the existing adjustToInput on the
reliable path (preserves multi-char paste re-alignment) and routes
single-keystroke events through applyKeystroke on the unreliable
path, fixing the visual misalignment and the duplication-on-accept
in one shot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:53:11 +08:00
陈大猫
52c097d9f8 Merge pull request #928 from binaricat/binaricat/fix-issue-920
Sync AI/UI settings and fix multi-display settings window placement
2026-05-10 23:24:15 +08:00
bincxz
684c094d40 Drop externalAgents from cloud sync (device-local config)
ExternalAgentConfig.command/acpCommand/args/env are OS- and
machine-specific (binary paths, .exe suffixes, platform-dependent
environment values). Pushing them to other devices either fails to
resolve or silently runs the wrong thing.

Stop collecting/applying STORAGE_KEY_AI_EXTERNAL_AGENTS and remove the
field from the SyncPayload type. apply silently ignores the field on
legacy snapshots that still carry it, so existing remote data is safe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:16:37 +08:00
bincxz
d84c2cc902 Preserve local AI apiKeys when applying synced settings
`collectSyncableSettings` strips device-bound encrypted apiKeys from
provider entries and webSearchConfig before upload, but
`applySyncableSettings` was writing them back wholesale, silently wiping
local credentials whenever any other setting changed on a second device.

Merge by id (providers) and by providerId (web search) so a synced
payload only overrides the apiKey when it explicitly carries one.

Also include `application/*.test.ts` in the npm test glob so the
syncPayload tests added in this PR actually run in CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:17:41 +08:00
陈大猫
3a233a3279 Merge pull request #934 from binaricat/claude/suspicious-bohr-32d9f2
Fix WebDAV Basic Auth for non-ASCII passwords (Hetzner #891)
2026-05-10 21:41:37 +08:00
bincxz
ba675fa944 Use UTF-8 for WebDAV Basic Auth credentials
The upstream `webdav` package builds the `Authorization: Basic …` header
through `base-64`, which Latin1-encodes the credentials. RFC 7617 (and
servers that follow it, like Hetzner Storage Box) expect UTF-8, so any
non-ASCII character in the password (e.g. `ö`, `ä`) produces a different
byte sequence on the wire than what the server stored, and the request
gets a 401 even though the credentials are correct (#891).

Skip the upstream auth path for password mode and pass an Authorization
header we built ourselves with UTF-8 encoding. ASCII-only passwords are
byte-identical, so existing setups are unaffected. Digest and token
modes are untouched.

Tested with a local HTTP server that enforces UTF-8-encoded Basic Auth
for a password containing umlauts (the exact failing case from #891).
2026-05-10 21:37:52 +08:00
bincxz
c9da2a5893 Sync AI/UI settings and fix multi-display settings window
Extend cloud sync to cover AI provider config, external agents,
permission/tool modes, command policy, web search settings,
workspace focus style, terminal follow-app theme, SFTP default view,
and additional terminal options. Device-bound encrypted apiKey
placeholders are stripped from providers and webSearchConfig before
upload. Auto-sync now reacts to syncable localStorage changes via a
new adapter-level event.

Center the Settings window on the display of the window that opened
it instead of always using the main window, fixing issue #920.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 00:41:14 +08:00
陈大猫
a377d39446 Merge pull request #926 from binaricat/codex/fix-ssh-known-host-verification
Fix SSH known host verification
2026-05-09 23:56:31 +08:00
bincxz
4b7249997f Update changed known hosts in place 2026-05-09 23:42:08 +08:00
bincxz
eb3f55b477 Integrate host key confirmation into connection dialog 2026-05-09 20:15:22 +08:00
bincxz
bce33f34ee Fix SSH known host verification 2026-05-09 19:44:21 +08:00
陈大猫
b6c59b9683 Merge pull request #924 from bet4it/shift-enter-support
Support Shift+Enter
2026-05-09 19:12:30 +08:00
bincxz
ff6b75aba7 Harden Shift+Enter keyboard support 2026-05-09 19:12:08 +08:00
陈大猫
b65ed74ced Merge pull request #922 from binaricat/feat/915-sftp-upload-context-menu
Add Upload File(s) item to SFTP context menu
2026-05-09 18:01:35 +08:00
bincxz
6c6a051c0c Fix SFTP upload context menu handling 2026-05-09 17:47:45 +08:00
陈大猫
621eae28f4 Merge pull request #918 from gorgiaxx/main
feat: Optimization of SSH Key Passphrase and Keychain
2026-05-09 16:17:46 +08:00
bincxz
2329014e22 fix: harden SSH key passphrase flows 2026-05-09 16:16:17 +08:00
Bet4
5c5ab21b10 support Shift+Enter 2026-05-09 14:56:17 +08:00
bincxz
a01ee1da61 Hide SFTP upload on local panes; add folder picker
The SFTP file-list "Upload File(s)" context menu items only make sense
on remote panes — local panes have no upload semantic. Plumb a new
`isLocal` prop into SftpPaneFileList and suppress both the menu items
and the hidden file inputs when the active pane is local.

Also add an "Upload Folder..." item alongside "Upload File(s)..." that
opens a `<input type="file" webkitdirectory>` picker. The resulting
FileList is routed through a new `uploadExternalFolder` /
`onUploadExternalFolder` callback that calls `uploadFromFileList`, so
folder structure is preserved via webkitRelativePath without any new
IPC. When invoked from a directory row, the folder is uploaded INTO
that directory (matching drag-and-drop semantics).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:57:56 +08:00
陈大猫
c94ded1a77 Merge pull request #923 from binaricat/fix/916-session-log-on-reconnect
Restart session log stream on reconnect
2026-05-09 12:52:08 +08:00
陈大猫
59de39e2ab Merge pull request #921 from binaricat/feat/912-settings-hotkey
Add hotkey to open Settings panel
2026-05-09 12:51:44 +08:00
bincxz
4a3869369e Restart session log stream on reconnect
Fixes #916.

When the user clicks "Restart" after a session disconnects, the
renderer reuses the same sessionId and the bridges call startStream
again to open a fresh log file for the new connection. The previous
connection's close handlers (e.g. SSH conn.once('close'),
stream.on('close'), serial 'close', telnet 'close', mosh PTY exit)
all still fire asynchronously and call stopStream(sessionId)
unconditionally. If they land after the new stream is already
active, they silently destroy it and subsequent terminal output for
the reconnected session is dropped, matching the bug report where
the first connection's IO is saved but the reconnect's is not.

Make startStream return a unique token and require stopStream
callers to pass it. A stale stop call carrying the previous
incarnation's token is now a no-op, so a late close handler from
the previous connection cannot kill the freshly-started stream.

Each reconnect therefore produces its own timestamped log file,
which mirrors the existing auto-save-on-close semantics and is the
simpler of the two options the issue offered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:38:50 +08:00
bincxz
11856b09e5 Add Settings gear button to top tab bar
Provides a discoverable entry point to the Settings panel for users
who don't use the Cmd/Ctrl+, hotkey. Sits at the right edge of the
title bar on macOS and immediately to the left of the custom window
controls on Windows/Linux. Reuses the existing onOpenSettings prop
already wired through from App.tsx.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:31:52 +08:00
bincxz
76b013f128 Add Upload File(s) item to SFTP context menu
Right-click on an SFTP pane now offers an "Upload File(s)" menu item
that opens a native multi-file picker, so users no longer have to drag
and drop to upload (issue #915). Selected files are wrapped in a
DataTransfer and dispatched through the existing onUploadExternalFiles
pipeline; right-clicking a directory uploads into that folder. Folder
upload via the picker is intentionally out of scope.

Fixes #915

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:31:13 +08:00
bincxz
44abf420c2 Add hotkey to open Settings panel
Adds Cmd+, on macOS and Ctrl+, on Windows/Linux to open Settings,
matching the platform convention. Previously Settings was only
reachable via Vaults -> Settings (#912).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:28:51 +08:00
gorgiaxx
cb98bdba2b fix: Improve passphrase handling by purging cached passphrases only on specific errors 2026-05-08 23:44:10 +08:00
gorgiaxx
18d411bb95 fix: preserve reference SSH keys and retry passphrase prompts
Keep file-backed SSH keys intact across app restarts and keep bad key passphrases in the dedicated retry flow instead of falling back to generic SSH auth. Also clear invalid saved passphrases from both legacy storage and reference-key records after auth failures.
2026-05-08 18:50:40 +08:00
gorgiaxx
1e80337a46 Merge branch 'main' of github.com:gorgiaxx/Netcatty 2026-05-08 17:26:55 +08:00
gorgiaxx
f1cfce45cf feat: Enhance SSH key management with reference key support and UI updates 2026-05-08 17:23:07 +08:00
Gorgias
833f9d2cac Merge branch 'binaricat:main' into main 2026-05-07 22:41:58 +08:00
gorgiaxx
72847a05af fix: Refactor passphrase handling: remove auto-responded keys tracking and related logic 2026-05-07 22:41:14 +08:00
陈大猫
0eccb2a252 Merge pull request #911 from yuzifu/allow-quick-edit 2026-05-07 19:52:31 +08:00
gorgiaxx
8a44152b36 Add support for remembering SSH key passphrases and update UI accordingly 2026-05-07 17:38:17 +08:00
yuzifu
c20abd86d9 allow quick edit for grid mode of keychain view 2026-05-07 16:23:38 +08:00
陈大猫
3fc9622695 Merge pull request #909 from binaricat/codex/telnet-auto-login
[codex] Improve Telnet credential login
2026-05-07 13:12:49 +08:00
bincxz
eb1fd9c127 Harden Telnet auto-login 2026-05-07 12:57:54 +08:00
bincxz
5cf1dd1de6 Match Telnet port field width to SSH 2026-05-07 11:46:59 +08:00
bincxz
137f8affbb Handle concatenated Telnet login prompts 2026-05-07 11:37:17 +08:00
bincxz
b9ac14f497 Improve Telnet credential login 2026-05-07 11:22:24 +08:00
陈大猫
43097c43b1 Merge pull request #905 from binaricat/fix/mosh-strip-lc-env
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
Strip LC_* before mosh ssh handshake
2026-05-07 02:03:21 +08:00
bincxz
329e94752b Strip LC_* before mosh ssh handshake
macOS Terminal/iTerm export LC_CTYPE=UTF-8 (a bare value, not a real
locale name). The system ssh_config has SendEnv LC_*, so the value
leaks to the remote and bash warns "cannot change locale (UTF-8)" on
every login. mosh-server sets its own locale separately, so dropping
LC_* from the spawned ssh's env is the cleanest fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 02:01:57 +08:00
陈大猫
b6a34131f6 Merge pull request #904 from binaricat/fix/mosh-windows-pinned-asset-check
Fix Windows mosh binary fallback selection
2026-05-07 01:42:18 +08:00
LAPTOP-O016UC3M\Qi Chen
3f16818d8d Fix Windows mosh binary fallback selection 2026-05-07 01:36:15 +08:00
陈大猫
3efc9ada8e Fix Windows mosh startup
Fix Windows mosh startup
2026-05-07 01:31:09 +08:00
陈大猫
8efdd1c9cb Merge pull request #901 from binaricat/codex/proxy-library
[codex] add reusable proxy profiles
2026-05-06 18:03:19 +08:00
bincxz
585a654668 Polish proxy form headings 2026-05-06 17:42:28 +08:00
bincxz
72e305fb7a Add reusable proxy profiles 2026-05-06 17:33:46 +08:00
bincxz
012a6bf521 Tone down proxy add button 2026-05-06 15:40:26 +08:00
陈大猫
4c72d5e0af Merge pull request #899 from yuzifu/fix-agent-path
fix: handle Windows agent paths with spaces
2026-05-06 15:36:32 +08:00
bincxz
cedc7f6c5f Align proxy profiles vault styles 2026-05-06 15:34:40 +08:00
bincxz
155463f77c add reusable proxy profiles 2026-05-06 15:20:23 +08:00
yuzifu
e5a74058ad add test unit 2026-05-06 15:12:17 +08:00
yuzifu
4ced32257e fix: handle Windows agent paths with spaces
When the executable file is installed in a directory containing spaces, the Codex and Claude path/version detection do not work.
2026-05-06 13:58:52 +08:00
陈大猫
64e7719715 Merge pull request #896 from yuzifu/fix-session-log
Fix session log
2026-05-06 12:34:07 +08:00
yuzifu
04b5aba62d fix: Preserve pending screen across redundant ED2 2026-05-04 17:27:04 +08:00
yuzifu
9f97f3870d fix: Preserve ED2-cleared screen when no trailing ED3 arrives 2026-05-04 17:15:41 +08:00
yuzifu
6bfd0e17a2 add ED3 test unit 2026-05-04 14:10:30 +08:00
yuzifu
1ac538eedc fix preserve terminal history during log sanitization 2026-05-04 14:07:22 +08:00
yuzifu
d34e23c7b3 preserve history while sanitizing terminal clears
Add a stateful terminal log sanitizer for txt/html session logs so saved output handles backspace, carriage-return overwrites, erase controls, split CSI/OSC sequences, and ANSI styling without leaking terminal control bytes.

Stream txt/html logs through a persistent renderer and write rendered snapshots directly to the final file, avoiding raw temp files and redundant full rewrites.
Preserve prior log history across clear-screen transitions while coalescing TUI repaint loops to avoid stale frame growth.

  Add regression coverage for tmux/zellij-style clears, repeated ED2/ED3 clears, home-clear repaint loops, and shell clear behavior.
2026-05-04 14:01:37 +08:00
陈大猫
31bf5396cb Bundle mosh terminfo on Linux and macOS (#890) (#894) 2026-05-04 11:09:12 +08:00
陈大猫
2feecaa9b6 Fix Windows mosh terminfo bundle (#889) 2026-05-01 22:51:15 +08:00
bincxz
1f0d3d8274 Handle cross-device mosh bundle moves
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
2026-05-01 17:10:13 +08:00
bincxz
d8c62a55f5 Fix Windows mosh bundle extraction 2026-05-01 16:54:57 +08:00
陈大猫
1b08e5ee88 [codex] Fix SFTP editor saved state (#887)
* Fix SFTP editor saved state

* Restore window input focus after SFTP editor

* Harden SFTP editor save flows
2026-05-01 16:31:58 +08:00
bincxz
de7057183c Increase AI code block top spacing 2026-05-01 13:48:42 +08:00
bincxz
dd910cc53d Tighten AI code block spacing 2026-05-01 13:43:06 +08:00
陈大猫
8ccefc821c [codex] Use dedicated mosh binary repository (#881)
* Use dedicated mosh binary repository

* Require bundled mosh client

* Auto-fill saved password for mosh SSH handshake

* Harden bundled mosh binary flow
2026-05-01 11:54:10 +08:00
陈大猫
863397fc7d Fix DeepSeek reasoning replay for tool loops (#882)
* Fix OpenAI-compatible reasoning replay for tool loops

* Fix reasoning continuation replay
2026-05-01 11:45:47 +08:00
陈大猫
6a39ed05a9 [codex] Tighten AI chat spacing (#883)
* Tighten AI chat spacing

* Scope AI table spacing styles
2026-05-01 11:33:07 +08:00
陈大猫
470d9b5aae [codex] Improve ACP agent error diagnostics (#880) 2026-05-01 08:00:50 +08:00
陈大猫
20694a47dd Fix Codex ACP model picker (#879) 2026-05-01 08:00:05 +08:00
陈大猫
d86c5ed05a [codex] Remove mosh client path setting (#878)
* fix(terminal): remove mosh client path setting

* fix(terminal): remove stale mosh detection bridge
2026-04-30 17:54:35 +08:00
陈大猫
fdaaaf62d8 [codex] Preserve provider reasoning context (#877)
* fix(ai): preserve provider reasoning context

* fix(ai): harden provider continuation replay
2026-04-30 17:08:19 +08:00
秋秋
2ceea46b50 feat(ssh): enhance getSessionPwd to support fish shell and improve cwd retrieval (#869)
* feat(ssh): enhance getSessionPwd to support fish shell and improve cwd retrieval

* fix ssh cwd detection review issues

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-04-30 15:27:45 +08:00
Eric Chan
5a1d6931a5 Fix Tab completion preferring history over local files (#867)
* Fix spec-aware path completion priority

Use resolved Fig spec args when deciding when filesystem suggestions should outrank command history. Add a regression test covering a spec-driven file argument command.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix generator-only spec path completion

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-04-30 14:42:01 +08:00
yuzifu
fb97e242ee feat: add SFTP upload conflict handling (#874)
* feat: add SFTP upload conflict handling
Add conflict resolution for SFTP uploads so files and folders can be stopped, skipped, replaced, duplicated, or merged depending on the target state. Support batch uploads with Apply to All behavior, route external upload conflicts through the shared SFTP conflict dialog, and add the bridge operations needed to stat and delete existing upload targets.

* fix review issue

* Fix SFTP conflict cancellation cleanup

---------

Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-04-30 14:22:00 +08:00
YumeSaku
68040ebdd7 fix(autocomplete): recognize Nerd Font / Powerline glyphs as prompt terminators (#871)
* fix(autocomplete): recognize Nerd Font / Powerline glyphs as prompt terminators

oh-my-posh and similar themed prompts end with PUA codepoints (e.g. U+F105
chevron, U+E0B0 powerline arrow) that aren't in the hardcoded PROMPT_CHARS
set, so findPromptBoundary returned -1 and both ghost-text and popup
autocomplete went silent. Treat any Private Use Area char (U+E000-U+F8FF)
followed by a space as a candidate prompt terminator — real shell commands
essentially never contain PUA codepoints, so this is high-confidence.

* Fix Powerline glyph prompt splitting

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-04-30 13:57:07 +08:00
Blossom
cca6dac543 fix(sftp): use custom tooltips in transfer queue (#872)
* fix(sftp): replace transfer queue native tooltips

* Fix SFTP transfer tooltip regressions

* Improve SFTP transfer tooltip accessibility

* Cover SFTP cancel tooltip label

---------

Co-authored-by: Mack Ding <mackding@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-04-30 13:23:51 +08:00
陈大猫
d86b720748 Run CI on every push/PR; gate release on strict v tags (#868)
* Run CI on every push/PR; gate release on strict v<X>.<Y>.<Z> tags

The build-packages workflow used to trigger only on `push: tags: v*`,
so branches and PRs never built and the only way to test the matrix
was to push a tag — which also auto-published a GitHub Release. That
made it impossible to verify a CI change without either skipping
testing or shipping a junk release.

Restructure the triggers:

- `push: branches: ['**']` + `pull_request` so any push or PR runs
  the build matrix and uploads workflow artifacts.
- `push: tags` accepts only strict semver: `v<MAJOR>.<MINOR>.<PATCH>`
  with an optional pre-release suffix like `v1.2.3-rc.1`. Loose tags
  (`v-test`, `vNEXT`, `v1.0`) no longer match.
- The release job's `if:` enforces the same rule independently — even
  if someone re-broadens the trigger later, branches and PRs can't
  publish a release.
- `Set version` produces semver-compliant `0.0.0-sha.<short>` for
  non-tag runs so `npm pkg set` / electron-builder don't choke on a
  bare commit SHA like `abc1234`.
- Add a concurrency group that cancels superseded branch/PR builds
  to save runner minutes; tag builds use a unique group so releases
  never get cancelled by a follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Apply strict-semver Set-version step to Linux jobs too

The previous commit only patched the matrix job's Set version step
(macOS/Windows) because the Linux legs had a slightly different
template (no comments). The Linux Set version step kept setting
package.json's version to a bare 7-char commit SHA like "812f296",
which electron-builder rejects with `Invalid version: "812f296"`
during normalizePackageData.

Replicate the same strict regex + 0.0.0-sha.<short> fallback in both
Linux jobs so non-tag runs produce a valid semver across the matrix.

Reproduced from build-linux-x64 logs of the run on 112bf3a1:
  Setting version to 812f296
  ⨯ Invalid version: "812f296"  failedTask=build

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix build workflow trigger review issues

* Address build workflow review findings

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 12:22:50 +08:00
陈大猫
aa192c66c3 Wire bundled mosh release flow
* Wire bundled mosh release flow

* Fix bundled mosh release flow review findings
2026-04-30 09:28:08 +08:00
陈大猫
7dd25a55bb Bundle mosh-client + Node-side PTY handshake
* Bundle mosh-client via CI build pipeline

Add a GitHub Actions workflow that builds a static, distro-portable
mosh-client for linux-x64, linux-arm64, darwin-universal (arm64+x86_64)
from upstream mobile-shell/mosh source, plus a pinned win32-x64 binary
sourced from FluentTerminal (GPL-3.0). Releases attach SHA256SUMS so
scripts/fetch-mosh-binaries.cjs can verify and pull the right binary
into resources/mosh/<platform-arch>/ during npm run pack.

electron-builder.config.cjs gains a moshExtraResources() helper that
adds the binary to extraResources only when present on disk, keeping
local dev packages working without bundled mosh.

terminalBridge.cjs now exports bundledMoshClient() and prefers the
bundled static client over whatever the system mosh wrapper would
resolve via PATH (via the MOSH_CLIENT env var). The Windows branch
throws a clear error pointing at Settings instead of silently falling
back to a literal "mosh.exe" string when no wrapper is installed.

This is Phase 1 — Phase 2 (follow-up) replaces the FluentTerminal
Windows binary with an in-CI Cygwin static build and adds a Node-side
mosh-server bootstrap so Mosh works out-of-the-box on Windows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Phase 2: Node-side Mosh handshake (no Perl wrapper required)

Reimplement what the upstream Mosh Perl wrapper does in pure Node:
spawn `ssh [user@]host -- mosh-server new`, sniff the byte stream
for `MOSH CONNECT <port> <key>`, then spawn `mosh-client` locally
with MOSH_KEY in the environment.

The new electron/bridges/moshHandshake.cjs module exposes the parser,
sniffer, and command builders as pure functions so they can be unit
tested without spawning real ssh. terminalBridge.startMoshSession now
prefers this path whenever a bare mosh-client (bundled, explicit, or
system) and ssh (in-box OpenSSH on Win10 1809+, system everywhere
else) are both detectable. The legacy path through the system mosh
Perl wrapper is preserved as a fallback so users with custom mosh
setups don't regress.

Auth is delegated to system ssh, so keys, agent, ssh_config, and
known_hosts all keep working. Password / 2FA need a controlling TTY
which the bootstrap doesn't provide; affected users keep the legacy
wrapper path until interactive UI lands.

Tests:
- moshHandshake.test.cjs (20 tests) — parser corner cases, command
  builders, sniffer split-chunk handling, ring-buffer trim, exec
  resolver
- terminalBridge.bareMoshClient.test.cjs (4 tests) — explicit-path
  basename gating

317 → 341 passing tests; lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Phase 3: in-CI Cygwin Windows build + visible PTY handshake

Phase 3a — in-CI Cygwin Windows build
- scripts/build-mosh/build-windows.sh builds mosh-client.exe from
  upstream mobile-shell/mosh source inside Cygwin, then walks the
  cygcheck import graph to bundle every required Cygwin DLL
  (cygwin1.dll, cygcrypto, cygprotobuf, cygncursesw, etc) into a
  tar.gz alongside the exe.
- The `build-mosh-binaries` workflow swaps the FluentTerminal-pinned
  fetch job for a real Cygwin build (windows-latest + cygwin-install-
  action). fetch-windows.sh is preserved as an emergency fallback but
  no longer wired into the matrix.
- fetch-mosh-binaries.cjs unpacks the tar.gz into resources/mosh/
  win32-x64/ so mosh-client.exe sits next to its DLLs.
- mosh-extra-resources.cjs ships the entire win32-x64/ dir
  (exe + DLL bundle) into Resources/mosh/, so the packaged installer
  runs on a stock Windows host with no Cygwin install.

Phase 3b — visible PTY handshake (password / 2FA prompts)
- terminalBridge.startMoshSession now spawns ssh inside node-pty so
  the user sees and can answer password / 2FA / known-hosts prompts
  in their terminal. When `MOSH CONNECT` is sniffed from the byte
  stream, session.proc is atomically swapped from the ssh PTY to a
  freshly-spawned mosh-client PTY. The MOSH CONNECT line itself is
  redacted from the visible output.
- writeToSession / resizeSession read session.proc lazily, so input
  arriving after the swap goes to mosh-client without extra wiring.
- The ZMODEM sentry is recreated for the new proc since its
  writeToRemote closure captured the previous handle.
- Removes the earlier non-PTY child_process.spawn handshake — the
  PTY-based one supersedes it.

Phase 3c — win32-arm64 deferred
- Cygwin's arm64 port has no stable cygwin1.dll release yet, so we
  do not attempt an arm64 Windows build. arm64 Windows installs fall
  through to the legacy `mosh` wrapper path that the bridge already
  handles. Documented in the workflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Allow branch/PR pushes to test the mosh-binaries workflow

Mirrors the build-packages workflow change in #868: any push or PR
that touches the mosh build pipeline triggers the matrix (artifacts
only, no release), while only `mosh-bin-*` tag pushes (or an
explicit workflow_dispatch with release_tag) publish a release.

`paths` filter keeps unrelated commits from running this expensive
workflow (~30min for the Cygwin leg). Concurrency group cancels
superseded branch/PR builds; tag builds use a unique group so a
follow-up commit can't kill an in-progress release.

Release job's `if:` enforces the same rule independently — even if
the trigger gets re-broadened, branches/PRs can't leak a release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix mosh binary workflow runners

* Fix Windows mosh workflow invocation

* Keep shell scripts LF in workflow checkouts

* Trigger mosh workflow on attributes changes

* Fix mosh build tool dependencies

* Fix Linux mosh static build

* Fix macOS mosh build tool lookup

* Skip macOS ncurses terminfo install

* Fix mosh PR review findings

* Allow Linux system mosh dependencies

* Fix Windows mosh DLL bundling

* Limit bundled Windows mosh DLLs

* Honor configured PATH for mosh handshake

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:25:57 +08:00
陈大猫
e4e1b54374 Fix terminal custom accent color (#864) 2026-04-29 11:21:29 +08:00
陈大猫
4dd2465388 Keep known hosts local during sync (#863) 2026-04-29 11:01:21 +08:00
704 changed files with 113229 additions and 43887 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.sh text eol=lf

89
.github/scripts/bump-homebrew-cask.sh vendored Executable file
View File

@@ -0,0 +1,89 @@
#!/usr/bin/env bash
#
# bump-homebrew-cask.sh — push a new version of the Netcatty cask to the
# binaricat/homebrew-netcatty tap.
#
# Called from the release pipeline (`build.yml` → `homebrew-tap` job) after
# the GitHub Release has been published with the signed + notarized DMGs.
# Computes SHA-256 of the arm64 and x64 DMGs, rewrites the cask file, and
# pushes the bump back to the tap repository using HOMEBREW_TAP_TOKEN.
#
# Required env vars:
# VERSION — semver without leading "v" (e.g. 1.1.6)
# HOMEBREW_TAP_TOKEN — PAT with contents:write on the tap repo
#
# Optional env vars:
# TAP_REPO — default: binaricat/homebrew-netcatty
# ARTIFACTS_DIR — default: artifacts
# CASK_PATH — default: Casks/netcatty.rb
set -euo pipefail
: "${VERSION:?VERSION env var required (no leading v)}"
: "${HOMEBREW_TAP_TOKEN:?HOMEBREW_TAP_TOKEN env var required}"
TAP_REPO="${TAP_REPO:-binaricat/homebrew-netcatty}"
ARTIFACTS_DIR="${ARTIFACTS_DIR:-artifacts}"
CASK_PATH="${CASK_PATH:-Casks/netcatty.rb}"
ARM_DMG="${ARTIFACTS_DIR}/Netcatty-${VERSION}-mac-arm64.dmg"
X64_DMG="${ARTIFACTS_DIR}/Netcatty-${VERSION}-mac-x64.dmg"
for f in "$ARM_DMG" "$X64_DMG"; do
if [[ ! -f "$f" ]]; then
echo "::error::Required DMG artifact not found: $f"
exit 1
fi
done
ARM_SHA=$(shasum -a 256 "$ARM_DMG" | awk '{print $1}')
X64_SHA=$(shasum -a 256 "$X64_DMG" | awk '{print $1}')
echo "Computed checksums:"
echo " arm64: ${ARM_SHA}"
echo " x64 : ${X64_SHA}"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
git clone --depth 1 \
"https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/${TAP_REPO}.git" \
"$TMP/tap"
cd "$TMP/tap"
if [[ ! -f "$CASK_PATH" ]]; then
echo "::error::Cask file not found in tap: $CASK_PATH"
exit 1
fi
# Patch the cask in place. The three lines we touch are anchored well enough
# that we don't need anything fancier than sed:
# - the `version "X.Y.Z"` line (single line, anchored to start)
# - the `sha256 arm: "..."` line
# - the ` intel: "..."` line (anchor on "intel:" at start, after the
# leading whitespace, so we don't accidentally match the `arch arm:
# "...", intel: "..."` line earlier in the file)
sed -i -E 's|^(\s*version)\s+"[^"]+"|\1 "'"$VERSION"'"|' "$CASK_PATH"
sed -i -E 's|(sha256\s+arm:\s+)"[^"]+"|\1"'"$ARM_SHA"'"|' "$CASK_PATH"
sed -i -E 's|^(\s*intel:\s+)"[^"]+"|\1"'"$X64_SHA"'"|' "$CASK_PATH"
# Sanity-check: parsed file should still be valid Ruby. Catches a broken
# substitution before we push.
if command -v ruby >/dev/null 2>&1; then
ruby -c "$CASK_PATH" >/dev/null
fi
if git diff --quiet; then
echo "Cask already at ${VERSION} with matching checksums — nothing to push."
exit 0
fi
echo "Cask diff:"
git --no-pager diff "$CASK_PATH"
git config user.email "github-actions[bot]@users.noreply.github.com"
git config user.name "github-actions[bot]"
git add "$CASK_PATH"
git commit -m "Bump netcatty to ${VERSION}"
git push origin HEAD:main
echo "Pushed bump for ${VERSION} to ${TAP_REPO}."

View File

@@ -56,8 +56,7 @@ const files = {
x64: `Netcatty-${version}-mac-x64.dmg`
},
win: {
x64: `Netcatty-${version}-win-x64.exe`,
arm64: `Netcatty-${version}-win-arm64.exe`
x64: `Netcatty-${version}-win-x64.exe`
},
linux: {
appimage: {
@@ -77,8 +76,7 @@ const files = {
const badges = {
win: {
setup_x64: `[![Setup x64](https://img.shields.io/badge/Setup-x64-0078D6?style=flat-square&logo=windows)](${baseUrl}/${files.win.x64})`,
setup_arm64: `[![Setup arm64](https://img.shields.io/badge/Setup-arm64-0078D6?style=flat-square&logo=windows)](${baseUrl}/${files.win.arm64})`
setup_x64: `[![Setup x64](https://img.shields.io/badge/Setup-x64-0078D6?style=flat-square&logo=windows)](${baseUrl}/${files.win.x64})`
},
mac: {
apple_silicon: `[![DMG Apple Silicon](https://img.shields.io/badge/DMG-Apple_Silicon-000000?style=flat-square&logo=apple)](${baseUrl}/${files.mac.arm64})`,
@@ -99,7 +97,7 @@ const content = `
| OS | Download |
| :--- | :--- |
| **Windows** | ${badges.win.setup_x64} ${badges.win.setup_arm64} |
| **Windows** | ${badges.win.setup_x64} |
| **macOS** | ${badges.mac.apple_silicon} ${badges.mac.intel} |
| **Linux** | ${badges.linux.appimage_x64} ${badges.linux.deb_x64} ${badges.linux.rpm_x64} <br> ${badges.linux.appimage_arm64} ${badges.linux.deb_arm64} ${badges.linux.rpm_arm64} |
`;

222
.github/workflows/build-et-binaries.yml vendored Normal file
View File

@@ -0,0 +1,222 @@
name: build-et-binaries
# Trigger philosophy (mirrors build-mosh-binaries.yml):
# - Pushes that touch the et build pipeline + PRs run the matrix so we can
# validate workflow / script changes without tagging. Artifacts upload as
# workflow artifacts only; *no* release.
# - Manual `workflow_dispatch` with `release_tag` publishes the binaries +
# SHA256SUMS to the dedicated binary repository
# (`binaricat/Netcatty-et-bin` by default).
#
# `paths` keeps unrelated commits (UI, bridges, etc) from rebuilding the et
# binaries on every push.
on:
workflow_dispatch:
inputs:
et_ref:
description: "EternalTerminal git ref (tag/branch/commit) — see https://github.com/MisterTea/EternalTerminal"
type: string
default: "et-v6.2.10"
release_tag:
description: "Optional release tag to attach binaries to (e.g. et-bin-6.2.10-1). Empty = artifacts only."
type: string
default: ""
release_repo:
description: "Repository that stores et binary releases."
type: string
default: "binaricat/Netcatty-et-bin"
push:
branches:
- "**"
paths:
- ".github/workflows/build-et-binaries.yml"
- "electron-builder.config.cjs"
- "package.json"
- "scripts/build-et/**"
- "scripts/fetch-et-binaries.cjs"
- "scripts/et-extra-resources.cjs"
pull_request:
paths:
- ".github/workflows/build-et-binaries.yml"
- "electron-builder.config.cjs"
- "package.json"
- "scripts/build-et/**"
- "scripts/fetch-et-binaries.cjs"
- "scripts/et-extra-resources.cjs"
concurrency:
group: build-et-binaries-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
ET_REF: ${{ inputs.et_ref || 'et-v6.2.10' }}
jobs:
# ------------------------------------------------------------------
# Linux x64 (manylinux2014 / glibc 2.17, broad distro compatibility).
# ------------------------------------------------------------------
build-linux-x64:
name: build-linux-x64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build et (linux-x64)
run: |
docker run --rm \
-e ET_REF="${ET_REF}" \
-e OUT_DIR=/work/out \
-e ARCH=x64 \
-v "${GITHUB_WORKSPACE}:/work" \
-w /work \
quay.io/pypa/manylinux2014_x86_64 \
bash scripts/build-et/build-linux.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: et-linux-x64
path: out/
build-linux-arm64:
name: build-linux-arm64
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- name: Build et (linux-arm64)
run: |
docker run --rm \
-e ET_REF="${ET_REF}" \
-e OUT_DIR=/work/out \
-e ARCH=arm64 \
-v "${GITHUB_WORKSPACE}:/work" \
-w /work \
quay.io/pypa/manylinux2014_aarch64 \
bash scripts/build-et/build-linux.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: et-linux-arm64
path: out/
# ------------------------------------------------------------------
# macOS universal2 (arm64 + x86_64 lipo). Min deployment target macOS 11.
# ------------------------------------------------------------------
build-macos-universal:
name: build-macos-universal
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Build et (darwin-universal)
env:
ET_REF: ${{ env.ET_REF }}
OUT_DIR: ${{ github.workspace }}/out
MACOSX_DEPLOYMENT_TARGET: "11.0"
run: bash scripts/build-et/build-macos.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: et-darwin-universal
path: out/
# ------------------------------------------------------------------
# Windows x64 — static MSVC build (no DLL bundle).
# ------------------------------------------------------------------
build-windows-x64:
name: build-windows-x64
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Install ninja
run: choco install -y ninja
- name: Set up MSVC developer command prompt
uses: ilammy/msvc-dev-cmd@v1
with:
arch: x64
- name: Build et (win32-x64)
env:
ET_REF: ${{ env.ET_REF }}
OUT_DIR: ${{ github.workspace }}\out
shell: pwsh
run: pwsh -File scripts/build-et/build-windows.ps1
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: et-win32-x64
path: out/
# ------------------------------------------------------------------
# Windows arm64 — intentionally not built until a tested client exists.
# ------------------------------------------------------------------
# ------------------------------------------------------------------
# Aggregate + optional release to the dedicated binary repository.
# ------------------------------------------------------------------
release:
name: release
needs:
- build-linux-x64
- build-linux-arm64
- build-macos-universal
- build-windows-x64
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' && inputs.release_tag != ''
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Stage release files
run: |
set -euo pipefail
mkdir -p release
for d in artifacts/*/; do
find "$d" -maxdepth 1 -type f -exec cp {} release/ \;
done
(cd release && find . -maxdepth 1 -type f ! -name SHA256SUMS -printf '%P\n' | sort | xargs sha256sum > SHA256SUMS)
ls -la release
cat release/SHA256SUMS
- name: Determine tag
id: tag
env:
RELEASE_TAG: ${{ inputs.release_tag }}
run: |
tag="${RELEASE_TAG}"
if [[ ! "$tag" =~ ^et-bin-[A-Za-z0-9._-]+$ ]]; then
echo "Invalid et binary release tag: $tag" >&2
exit 1
fi
printf 'name=%s\n' "$tag" >> "$GITHUB_OUTPUT"
- name: Create / update release
env:
GH_TOKEN: ${{ secrets.ET_BIN_RELEASE_TOKEN }}
RELEASE_REPO: ${{ inputs.release_repo }}
RELEASE_TAG: ${{ steps.tag.outputs.name }}
run: |
set -euo pipefail
if [[ -z "${GH_TOKEN:-}" ]]; then
echo "::error::ET_BIN_RELEASE_TOKEN is required to publish into ${RELEASE_REPO}."
exit 1
fi
{
printf '%s\n' 'Pre-built EternalTerminal `et` client binaries consumed by `scripts/fetch-et-binaries.cjs` during `npm run pack`.'
printf 'Built from `MisterTea/EternalTerminal` upstream ref `%s`.\n\n' "${ET_REF}"
printf 'Source workflow: %s/%s/actions/runs/%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_RUN_ID}"
printf 'Source commit: `%s`\n\n' "${GITHUB_SHA}"
printf '%s\n' 'All artifacts are Apache-2.0; see `resources/et/README.md` for source provenance.'
} > release-notes.md
if gh release view "${RELEASE_TAG}" --repo "${RELEASE_REPO}" >/dev/null 2>&1; then
gh release edit "${RELEASE_TAG}" \
--repo "${RELEASE_REPO}" \
--title "${RELEASE_TAG}" \
--notes-file release-notes.md
gh release upload "${RELEASE_TAG}" release/* \
--repo "${RELEASE_REPO}" \
--clobber
else
gh release create "${RELEASE_TAG}" release/* \
--repo "${RELEASE_REPO}" \
--title "${RELEASE_TAG}" \
--notes-file release-notes.md
fi

View File

@@ -0,0 +1,233 @@
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

View File

@@ -1,5 +1,23 @@
name: build-packages
# Trigger philosophy
# - Any push to any branch + any PR -> run the build matrix so CI is
# always testable. Same-repo PR runs own package validation; matching
# branch push runs become a lightweight mirror only after a current
# open PR run for the same commit is visible. If lookup is slow or
# unavailable, the push run falls back to the full matrix. Artifacts
# upload as workflow artifacts only; *no* GitHub Release is published.
# - Tag push matching `v<MAJOR>.<MINOR>.<PATCH>` (with optional
# pre-release suffix like `v1.2.3-rc.1`) -> run the matrix and
# publish a GitHub Release. Loose tags like `v-test`, `vNEXT`, or
# `v1.0` no longer auto-publish.
# - Manual `workflow_dispatch` -> run the matrix on the selected ref.
# `publish_release` only publishes when the selected ref is also a
# strict version tag.
#
# The release job validates the exact same rule before publishing, so
# adding branches/PRs above is safe; accidental tag-like branch names
# won't leak a release.
on:
workflow_dispatch:
inputs:
@@ -7,13 +25,214 @@ on:
description: "Publish GitHub Release after build"
type: boolean
default: false
mosh_bin_release:
description: "Release tag containing bundled mosh-client binaries"
type: string
default: ""
et_bin_release:
description: "Release tag containing bundled et (EternalTerminal) binaries"
type: string
default: ""
push:
branches:
- "**"
tags:
- "v*"
- "v[0-9]+.[0-9]+.[0-9]+"
- "v[0-9]+.[0-9]+.[0-9]+-[0-9A-Za-z]*"
pull_request:
# A newer run for the same push branch or PR cancels older in-progress
# work. Push and PR events stay in separate groups so deduped push runs
# can mirror PR results cleanly instead of leaving cancelled checks on
# the PR. Publishing tag runs share a release group across push and
# manual dispatch; non-publishing manual tag runs use their own group.
concurrency:
group: build-packages-${{ github.workflow }}-${{ startsWith(github.ref, 'refs/tags/') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release)) && 'release' || github.event_name }}-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.ref_type }}-${{ github.event.pull_request.head.ref || github.ref_name }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
permissions:
actions: read
contents: read
pull-requests: read
env:
MOSH_BIN_RELEASE: ${{ github.event.inputs.mosh_bin_release || vars.MOSH_BIN_RELEASE || '' }}
BUNDLE_MOSH: ${{ (startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))) || (github.event_name == 'workflow_dispatch' && inputs.mosh_bin_release != '') }}
ET_BIN_RELEASE: ${{ github.event.inputs.et_bin_release || vars.ET_BIN_RELEASE || '' }}
BUNDLE_ET: ${{ (startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))) || (github.event_name == 'workflow_dispatch' && inputs.et_bin_release != '') }}
STRICT_VERSION_REF_RE: '^refs/tags/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[A-Za-z][0-9A-Za-z-]*|[0-9A-Za-z][0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[A-Za-z][0-9A-Za-z-]*|[0-9A-Za-z][0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?$'
jobs:
dedupe:
name: dedupe push run
runs-on: ubuntu-latest
outputs:
skip_heavy_ci: ${{ steps.detect.outputs.skip_heavy_ci }}
heavy_ci_pr_run_id: ${{ steps.detect.outputs.heavy_ci_pr_run_id }}
steps:
- name: Detect duplicate heavy CI
id: detect
shell: bash
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
REPOSITORY_OWNER: ${{ github.repository_owner }}
EVENT_NAME: ${{ github.event_name }}
REF: ${{ github.ref }}
HEAD_REF: ${{ github.ref_name }}
HEAD_SHA: ${{ github.sha }}
run: |
skip_heavy_ci=false
if [[ "$EVENT_NAME" == "push" && "$REF" == refs/heads/* ]]; then
pr_count=0
if ! pr_count="$(gh api --method GET "repos/${REPOSITORY}/pulls" \
-f state=open \
-f "head=${REPOSITORY_OWNER}:${HEAD_REF}" \
-F per_page=1 \
--jq 'length')"; then
echo "::warning::Could not check open PRs; running full push CI."
pr_count=0
fi
pr_run_id=""
if [[ "$pr_count" != "0" ]]; then
cutoff="$(date -u -d '20 minutes ago' +'%Y-%m-%dT%H:%M:%SZ')"
for attempt in {1..18}; do
if ! pr_run_id="$(gh api --method GET "repos/${REPOSITORY}/actions/workflows/build.yml/runs" \
-f event=pull_request \
-f "branch=${HEAD_REF}" \
-f "head_sha=${HEAD_SHA}" \
-F per_page=20 \
--jq "[.workflow_runs[] | select(.created_at >= \"${cutoff}\" and .conclusion != \"cancelled\" and .conclusion != \"skipped\")] | sort_by(.created_at, .id) | .[0].id // \"\"")"; then
echo "::warning::Could not check PR workflow runs; running full push CI."
pr_run_id=""
break
fi
if [[ -n "$pr_run_id" ]]; then
skip_heavy_ci=true
break
fi
if [[ "$attempt" == "18" ]]; then
break
fi
sleep 10
done
fi
if [[ -n "$pr_run_id" ]]; then
echo "heavy_ci_pr_run_id=${pr_run_id}" >> "$GITHUB_OUTPUT"
echo "heavy_ci_pr_run_id=${pr_run_id}"
fi
fi
echo "skip_heavy_ci=${skip_heavy_ci}" >> "$GITHUB_OUTPUT"
echo "skip_heavy_ci=${skip_heavy_ci}"
dedupe-result:
name: dedupe result
needs: dedupe
if: needs.dedupe.outputs.skip_heavy_ci == 'true'
runs-on: ubuntu-latest
steps:
- name: Mirror PR build result
shell: bash
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
PR_RUN_ID: ${{ needs.dedupe.outputs.heavy_ci_pr_run_id }}
run: |
if [[ -z "$PR_RUN_ID" ]]; then
echo "::error::No PR workflow run was selected for dedupe."
exit 1
fi
for attempt in {1..360}; do
if ! result="$(gh run view "$PR_RUN_ID" --repo "$REPOSITORY" --json status,conclusion --jq '.status + "|" + (.conclusion // "")')"; then
echo "::warning::Could not read PR workflow run ${PR_RUN_ID}; retrying."
sleep 30
continue
fi
status="${result%%|*}"
conclusion="${result#*|}"
echo "PR run ${PR_RUN_ID}: status=${status} conclusion=${conclusion:-pending}"
if [[ "$status" == "completed" ]]; then
if [[ "$conclusion" == "success" ]]; then
exit 0
fi
echo "::error::PR workflow run ${PR_RUN_ID} completed with conclusion '${conclusion}'."
exit 1
fi
sleep 30
done
echo "::error::Timed out waiting for PR workflow run ${PR_RUN_ID}."
exit 1
resolve-mosh:
name: resolve bundled mosh-client
needs: dedupe
if: |
needs.dedupe.outputs.skip_heavy_ci != 'true'
&& (
(startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release)))
|| (github.event_name == 'workflow_dispatch' && inputs.mosh_bin_release != '')
)
runs-on: ubuntu-latest
outputs:
mosh_bin_release: ${{ steps.resolve.outputs.mosh_bin_release }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve bundled mosh-client release
id: resolve
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
node scripts/resolve-mosh-bin-release.cjs
release="$(grep '^MOSH_BIN_RELEASE=' "$GITHUB_ENV" | tail -n 1 | cut -d= -f2-)"
if [[ -z "$release" ]]; then
echo "::error::MOSH_BIN_RELEASE was not resolved."
exit 1
fi
echo "mosh_bin_release=${release}" >> "$GITHUB_OUTPUT"
resolve-et:
name: resolve bundled et-client
needs: dedupe
if: |
needs.dedupe.outputs.skip_heavy_ci != 'true'
&& (
(startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release)))
|| (github.event_name == 'workflow_dispatch' && inputs.et_bin_release != '')
)
runs-on: ubuntu-latest
outputs:
et_bin_release: ${{ steps.resolve.outputs.et_bin_release }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve bundled et-client release
id: resolve
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
node scripts/resolve-et-bin-release.cjs
release="$(grep '^ET_BIN_RELEASE=' "$GITHUB_ENV" | tail -n 1 | cut -d= -f2-)"
if [[ -z "$release" ]]; then
echo "::error::ET_BIN_RELEASE was not resolved."
exit 1
fi
echo "et_bin_release=${release}" >> "$GITHUB_OUTPUT"
build:
name: build-${{ matrix.name }}
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && format('deduped build-{0}', matrix.name) || format('build-{0}', matrix.name) }}
needs: [dedupe, resolve-mosh, resolve-et]
if: |
always()
&& needs.dedupe.result == 'success'
&& needs.dedupe.outputs.skip_heavy_ci != 'true'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@@ -24,13 +243,40 @@ jobs:
pack_script: pack:mac
- name: windows
os: windows-latest
pack_script: pack:win
# The mosh binary workflow currently produces win32-x64 only.
# Keep official packages aligned with bundled-mosh coverage
# until Cygwin arm64 is stable enough to build win32-arm64.
pack_script: pack:win-x64
env:
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
ET_BIN_RELEASE: ${{ needs.resolve-et.outputs.et_bin_release }}
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
steps:
- name: Validate bundled mosh-client release
if: env.BUNDLE_MOSH == 'true'
shell: bash
env:
RESOLVE_MOSH_RESULT: ${{ needs.resolve-mosh.result }}
run: |
if [[ "$RESOLVE_MOSH_RESULT" != "success" || -z "$MOSH_BIN_RELEASE" ]]; then
echo "::error::Bundled mosh-client release was not resolved for this package build."
exit 1
fi
- name: Validate bundled et-client release
if: env.BUNDLE_ET == 'true'
shell: bash
env:
RESOLVE_ET_RESULT: ${{ needs.resolve-et.result }}
run: |
if [[ "$RESOLVE_ET_RESULT" != "success" || -z "$ET_BIN_RELEASE" ]]; then
echo "::error::Bundled et-client release was not resolved for this package build."
exit 1
fi
- name: Checkout
uses: actions/checkout@v4
@@ -43,30 +289,38 @@ jobs:
- name: Install deps
run: npm ci
- name: Install cross-platform native binaries
- name: Fetch bundled mosh-client
if: env.BUNDLE_MOSH == 'true'
shell: bash
run: |
# npm ci only installs optional deps for the host platform, but
# electron-builder produces both arm64 and x64 binaries, so we
# need the native codex-acp binary for the other architecture too.
# Platform-specific codex-acp packages declare cpu/os constraints,
# so --force is needed to install the non-host-arch binary.
CODEX_VER=$(node -e "console.log(require('./node_modules/@zed-industries/codex-acp/package.json').version)")
if [[ "${{ matrix.name }}" == "macos" ]]; then
npm install "@zed-industries/codex-acp-darwin-x64@${CODEX_VER}" "@zed-industries/codex-acp-darwin-arm64@${CODEX_VER}" --no-save --force
npm run fetch:mosh -- --platform=darwin --arch=universal
elif [[ "${{ matrix.name }}" == "windows" ]]; then
npm install "@zed-industries/codex-acp-win32-x64@${CODEX_VER}" "@zed-industries/codex-acp-win32-arm64@${CODEX_VER}" --no-save --force
npm run fetch:mosh -- --platform=win32 --arch=x64
fi
- name: Fetch bundled et-client
if: env.BUNDLE_ET == 'true'
shell: bash
run: |
if [[ "${{ matrix.name }}" == "macos" ]]; then
npm run fetch:et -- --platform=darwin --arch=universal
elif [[ "${{ matrix.name }}" == "windows" ]]; then
npm run fetch:et -- --platform=win32 --arch=x64
fi
- name: Set version
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
# Tag release: use version from tag
# Strict semver matches v<MAJOR>.<MINOR>.<PATCH>[-pre]; loose
# tags / branches / PRs fall through to a semver-pre-release
# form (`0.0.0-sha-<short-sha>`) so npm pkg / electron-builder
# accept it. Non-semver versions (e.g. bare "abc1234") cause
# downstream tooling to error or pick weird codepaths.
if [[ "$GITHUB_REF" =~ $STRICT_VERSION_REF_RE ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
# workflow_dispatch: use short commit ID
VERSION="${GITHUB_SHA:0:7}"
VERSION="0.0.0-sha-${GITHUB_SHA:0:7}"
fi
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
@@ -105,9 +359,16 @@ jobs:
# compatible with most current Linux distributions including Arch.
# See #264.
build-linux-x64:
name: build-linux-x64
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }}
needs: [dedupe, resolve-mosh, resolve-et]
if: |
always()
&& needs.dedupe.result == 'success'
&& needs.dedupe.outputs.skip_heavy_ci != 'true'
runs-on: ubuntu-22.04
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
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
@@ -115,6 +376,28 @@ jobs:
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
steps:
- name: Validate bundled mosh-client release
if: env.BUNDLE_MOSH == 'true'
shell: bash
env:
RESOLVE_MOSH_RESULT: ${{ needs.resolve-mosh.result }}
run: |
if [[ "$RESOLVE_MOSH_RESULT" != "success" || -z "$MOSH_BIN_RELEASE" ]]; then
echo "::error::Bundled mosh-client release was not resolved for this package build."
exit 1
fi
- name: Validate bundled et-client release
if: env.BUNDLE_ET == 'true'
shell: bash
env:
RESOLVE_ET_RESULT: ${{ needs.resolve-et.result }}
run: |
if [[ "$RESOLVE_ET_RESULT" != "success" || -z "$ET_BIN_RELEASE" ]]; then
echo "::error::Bundled et-client release was not resolved for this package build."
exit 1
fi
- name: Checkout
uses: actions/checkout@v4
@@ -130,10 +413,13 @@ jobs:
- name: Set version
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
# See matrix job's Set version step for the strict-semver
# rationale; identical logic, duplicated because the Linux
# legs are standalone jobs.
if [[ "$GITHUB_REF" =~ $STRICT_VERSION_REF_RE ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="${GITHUB_SHA:0:7}"
VERSION="0.0.0-sha-${GITHUB_SHA:0:7}"
fi
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
@@ -143,6 +429,14 @@ jobs:
npm_config_arch: x64
run: bash scripts/ensure-node-pty-linux.sh prepare x64
- name: Fetch bundled mosh-client
if: env.BUNDLE_MOSH == 'true'
run: npm run fetch:mosh -- --platform=linux --arch=x64
- name: Fetch bundled et-client
if: env.BUNDLE_ET == 'true'
run: npm run fetch:et -- --platform=linux --arch=x64
- name: Build package
env:
npm_config_arch: x64
@@ -171,11 +465,18 @@ jobs:
# to ensure compatibility with older distros like UOS/Deepin (GLIBC 2.28).
# Key: GLIBC < 2.34 avoids the libpthread-merge symbol requirement.
build-linux-arm64:
name: build-linux-arm64
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }}
needs: [dedupe, resolve-mosh, resolve-et]
if: |
always()
&& needs.dedupe.result == 'success'
&& needs.dedupe.outputs.skip_heavy_ci != 'true'
runs-on: ubuntu-24.04-arm
container:
image: debian:bullseye
env:
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
ET_BIN_RELEASE: ${{ needs.resolve-et.outputs.et_bin_release }}
npm_config_arch: arm64
npm_config_target_arch: arm64
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
@@ -183,6 +484,28 @@ jobs:
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
steps:
- name: Validate bundled mosh-client release
if: env.BUNDLE_MOSH == 'true'
shell: bash
env:
RESOLVE_MOSH_RESULT: ${{ needs.resolve-mosh.result }}
run: |
if [[ "$RESOLVE_MOSH_RESULT" != "success" || -z "$MOSH_BIN_RELEASE" ]]; then
echo "::error::Bundled mosh-client release was not resolved for this package build."
exit 1
fi
- name: Validate bundled et-client release
if: env.BUNDLE_ET == 'true'
shell: bash
env:
RESOLVE_ET_RESULT: ${{ needs.resolve-et.result }}
run: |
if [[ "$RESOLVE_ET_RESULT" != "success" || -z "$ET_BIN_RELEASE" ]]; then
echo "::error::Bundled et-client release was not resolved for this package build."
exit 1
fi
- name: Install build dependencies
run: |
apt-get update
@@ -201,10 +524,13 @@ jobs:
- name: Set version
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
# See matrix job's Set version step for the strict-semver
# rationale; identical logic, duplicated because the Linux
# legs are standalone jobs.
if [[ "$GITHUB_REF" =~ $STRICT_VERSION_REF_RE ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="${GITHUB_SHA:0:7}"
VERSION="0.0.0-sha-${GITHUB_SHA:0:7}"
fi
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
@@ -214,6 +540,14 @@ jobs:
npm_config_arch: arm64
run: bash scripts/ensure-node-pty-linux.sh prepare arm64
- name: Fetch bundled mosh-client
if: env.BUNDLE_MOSH == 'true'
run: npm run fetch:mosh -- --platform=linux --arch=arm64
- name: Fetch bundled et-client
if: env.BUNDLE_ET == 'true'
run: npm run fetch:et -- --platform=linux --arch=arm64
- name: Build package
env:
npm_config_arch: arm64
@@ -242,7 +576,12 @@ jobs:
name: release
runs-on: ubuntu-latest
needs: [build, build-linux-x64, build-linux-arm64]
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
# Only release on a strict v<MAJOR>.<MINOR>.<PATCH>[-pre] tag.
# Manual workflow_dispatch can publish only when it is run from one
# of those tags. PRs and branch pushes skip this job.
if: |
startsWith(github.ref, 'refs/tags/v')
&& (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))
permissions:
contents: write
actions: read
@@ -250,6 +589,14 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Validate release tag
shell: bash
run: |
if [[ ! "$GITHUB_REF" =~ $STRICT_VERSION_REF_RE ]]; then
echo "::error::Release tags must be v<MAJOR>.<MINOR>.<PATCH> or v<MAJOR>.<MINOR>.<PATCH>-<prerelease>."
exit 1
fi
- name: Download artifacts
uses: actions/download-artifact@v4
with:
@@ -318,6 +665,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
body_path: release_notes.md
prerelease: ${{ contains(github.ref_name, '-') }}
files: |
artifacts/*.dmg
artifacts/*.zip
@@ -330,3 +678,33 @@ jobs:
generate_release_notes: true
fail_on_unmatched_files: false
token: ${{ secrets.RELEASE_TOKEN }}
homebrew-tap:
name: bump homebrew tap
runs-on: ubuntu-latest
needs: release
# Only stable release tags update the Cask. Prerelease tags
# (e.g. v1.2.0-rc.1) are skipped so brew users stay on stable.
if: |
startsWith(github.ref, 'refs/tags/v')
&& !contains(github.ref_name, '-')
&& (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download macOS artifacts
uses: actions/download-artifact@v4
with:
name: netcatty-macos
path: artifacts/
- name: Bump Cask in binaricat/homebrew-netcatty
env:
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
ARTIFACTS_DIR: artifacts
run: |
# Strip the leading "v" — Cask version is plain semver.
VERSION="${GITHUB_REF_NAME#v}"
export VERSION
bash .github/scripts/bump-homebrew-cask.sh

37
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: test
on:
pull_request:
push:
branches:
- "**"
concurrency:
group: test-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
name: lint-and-test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install deps
run: npm ci
- name: Lint
run: npm run lint
- name: Test
run: npm test

21
.gitignore vendored
View File

@@ -40,7 +40,6 @@ coverage
# Codex
/.codex/
/CLAUDE.md
# AI / Superpowers generated docs (local only)
/docs/superpowers/
@@ -63,3 +62,23 @@ Directory.Build.props
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.
/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
# actual binaries (and any DLL bundle for dynamically-linked Windows builds)
# are pulled from the dedicated et binary repository, never committed.
/resources/et/*/et
/resources/et/*/et.exe
/resources/et/*/et-*-dlls/
/resources/et/*/*.dll

1783
App.tsx

File diff suppressed because it is too large Load Diff

62
CLAUDE.md Normal file
View File

@@ -0,0 +1,62 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
# Install dependencies
npm install
# Start dev server (runs lint first, then Vite + Electron concurrently)
npm run dev
# Lint
npm run lint
npm run lint:fix
# Run all tests
npm test
# Run a single test file
node --test --import tsx path/to/file.test.ts
# Build renderer
npm run build
# Package for current platform
npm run pack
# Package for specific platforms
npm run pack:mac
npm run pack:win
npm run pack:linux
```
## Architecture
Netcatty is an Electron + React desktop app (SSH manager, terminal, SFTP browser). It has two runtimes:
### Electron Main Process (`electron/`)
- **`main.cjs`** — entry point; wires crash logging, process error guards, and delegates to `main/registerBridges.cjs`
- **`bridges/`** — one `.cjs` file per capability domain (sshBridge, sftpBridge, terminalBridge, portForwardingBridge, aiBridge, etc.). Each bridge exposes IPC handlers via `ipcMain`. Tests live alongside the bridge file (`*.test.cjs`).
- **`preload.cjs`** — exposes a typed `window.electron` API to the renderer via `contextBridge`. Uses `preload/api.cjs` for the generated API surface.
- **`cli/`** — `netcatty-tool-cli.cjs` is a separate internal binary for tool/MCP integration; treat as internal surface only.
### Renderer Process (React + Vite)
Three-layer architecture (see `AGENTS.md` for full detail):
- **`domain/`** — pure TypeScript logic, no side effects. Models (`models.ts`), host helpers, workspace tree operations.
- **`application/state/`** — React hooks that own state and persistence boundaries. Key hooks: `useVaultState` (hosts/keys/snippets), `useSessionState` (terminal sessions/workspace), `useSettingsState` (theme/config).
- **`infrastructure/`** — external edges: `persistence/localStorageAdapter.ts` for storage, `services/` for network calls (Gemini AI, GitHub Gist sync), `config/` for defaults, storage keys, and terminal themes.
- **`components/`** — presentation only. `App.tsx` wires hooks to components; no business logic in components.
### IPC Pattern
UI calls `window.electron.*` (preload API) → IPC → bridge handler in main process. Never call `ipcRenderer` directly from components.
### Key Conventions
- All storage reads/writes go through `localStorageAdapter`; storage keys are in `infrastructure/config/storageKeys.ts`.
- Temporary files must use `tempDirBridge.getTempFilePath(fileName)` — never `os.tmpdir()` directly.
- Aside panels (VaultView subpages) use the shared design system in `components/ui/aside-panel.tsx` — see `AGENTS.md` for usage patterns.
- Renderer code is TypeScript/ESM; Electron main/bridges are CommonJS (`.cjs`).
- Path alias `@/` resolves to the repo root (configured in `vite.config.ts` and `tsconfig.json`).

216
ET_INTEGRATION_CHECKLIST.md Normal file
View File

@@ -0,0 +1,216 @@
# EternalTerminal (ET) 集成清单 — 按 Mosh 方式重做
> 目标:在上游最新架构(分支 `feat/et-history-reapply`,基于 `031bf0ee`)上,
> **完全照搬 Mosh 的方式**重新集成 EternalTerminal
> 1. **打包客户端** —— 像 `mosh-client` 那样,把 `et` 客户端二进制构建 + 下载 +
> 捆绑进安装包,运行时只用捆绑的二进制(不依赖系统安装的 et
> 2. **接入协议** —— 把旧分支 `feat/eternal-terminal`tip `67e81616`)里的 ET
> 后端 + UI 重新落到上游重构后的目录结构上,并让它启动**捆绑的** `et`。
>
> 旧实现参考:`git show 67e81616`(共 7 个 ET 提交,见 `feat/eternal-terminal`)。
> Mosh 模板参考:`resources/mosh/README.md`、`scripts/*mosh*`、
> `electron/bridges/terminalBridge/moshSession.cjs`、`.github/workflows/build-mosh-binaries.yml`。
## 关键设计差异ET vs Mosh
- **协议**Mosh 需要 Node 重写 Perl 包装器SSH bootstrap + 抓 `MOSH CONNECT` +
换 PTY。**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**
`cmake -DDISABLE_TELEMETRY=ON -GNinja -DCMAKE_BUILD_TYPE=RelWithDebInfo`
产物是单个 `et`Windows `et.exe`)。
## 命名约定(镜像 Mosh
| Mosh | 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/` |
| `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-*` |
| `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` |
---
## Phase 1 — 打包基础设施(构建/下载/捆绑)
- [x] **1.1** `resources/et/README.md` —— 镜像 `resources/mosh/README.md`:说明
二进制来源、`Netcatty-et-bin` 发布仓库、`et-bin-*` tag、许可证ET 为
Apache-2.0,与 GPL-3.0 兼容)、可复现构建命令。
- [x] **1.2** `.gitignore` —— 追加 ET 段(镜像 mosh 段):
`/resources/et/*/et``/resources/et/*/et.exe``/resources/et/*/*.dll`
`/resources/et/*/et-win32-*-dlls/`。保留 `resources/et/README.md`
- [x] **1.3** `scripts/build-et/build-linux.sh` —— manylinux2014 + vcpkg 静态三元组
构建 `et`x64/arm64产物 `et-linux-<arch>.tar.gz`(+.sha256),内含单个 `et`
校验非系统动态库(同 mosh 的 ldd 白名单)。
- [x] **1.4** `scripts/build-et/build-macos.sh` —— arm64 + x86_64 分别构建后 `lipo`
成 universal`MACOSX_DEPLOYMENT_TARGET=11.0`,产物 `et-darwin-universal.tar.gz`
- [x] **1.5** `scripts/build-et/build-windows.ps1`(或 `.sh`)—— MSVC + vcpkg
`x64-windows-static`,产物 `et-win32-x64.tar.gz`(含 `et.exe`;若动态链接 CRT
则随附 DLL 目录 `et-win32-x64-dlls/`,否则纯静态无 DLL
- [x] **1.6** `scripts/et-extra-resources.cjs` —— 镜像 `mosh-extra-resources.cjs`
按平台/arch 仅当 `resources/et/<plat-arch>/et[.exe]` 存在时才产出 extraResources
指令(`to: "et/"`Windows 额外处理可选 DLL 目录。**去掉 terminfo 分支**。
- [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 校验。
- [x] **1.9** 单元测试:`scripts/fetch-et-binaries.test.cjs`
`scripts/resolve-et-bin-release.test.cjs``scripts/et-extra-resources.test.cjs`
(镜像对应 mosh 测试,改名/改路径)。
- [x] **1.10** `package.json` scripts新增
`"fetch:et": "node scripts/fetch-et-binaries.cjs"`
`"fetch:et:dev": "node scripts/fetch-et-binaries.cjs --host --resolve-release"`
`dev` 脚本改成先 `fetch:mosh:dev && fetch:et:dev``test` glob 已覆盖
`scripts/*.test.cjs`(确认即可)。
- [x] **1.11** `electron-builder.config.cjs`:引入 `etExtraResources`,在 darwin/win32/
linux 三处把 `etExtraResources(plat)` 合并进 `extraResources`(与 mosh 数组拼接)。
- [x] **1.12** `.github/workflows/build-et-binaries.yml` —— 镜像
`build-mosh-binaries.yml`:四个构建 job + 一个 `release` jobdispatch 且
`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
## Phase 2 — 运行时定位捆绑客户端
- [x] **2.1** `electron/bridges/terminalBridge.cjs` 新增 `bundledEtClient(opts)`
—— 镜像 `bundledMoshClient`:打包路径 `<Resources>/et/et[.exe]`dev 回退
`<projectRoot>/resources/et/<plat-arch>/et[.exe]`;导出到 module.exports。
## Phase 3 — ET 协议后端(搬运旧实现到新架构)
- [x] **3.1** 新建 `electron/bridges/terminalBridge/etSession.cjs` —— 用上游
`moshSession.cjs``createXxxSessionApi(ctx)` + `with(ctx)` 工厂模式,封装:
`ET_ASKPASS_SCRIPT``writeSecureFile``prepareEtSshEnvironment`
`createEtAskpassArtifacts``cleanupStaleEtTempDirs`
`cleanupSessionExternalAuthArtifacts``execOnEtSession``startEtSession`
**改动点**`etCmd``findExecutable('et')` 改为 `resolveBareEtClient()`
(取捆绑二进制);找不到时抛错(同 mosh提示跑 `npm run fetch:et:dev`)。
Windows 若有 DLL 目录,复用 `prependEnvPath` 思路把 DLL 目录加进 PATH。
- [x] **3.2** `terminalBridge.cjs` 接线 `createEtSessionApi(ctx)`(镜像 moshSessionApi
的 ctx传入 `bundledEtClient``tempDirBridge``execFile/execFileSync` 等;
解构出 `startEtSession``execOnEtSession``cleanupStaleEtTempDirs`
`cleanupSessionExternalAuthArtifacts``resolveBareEtClient`
- [x] **3.3** `init()``cleanupStaleEtTempDirs()``registerHandlers`
`ipcMain.handle("netcatty:et:start", startEtSession)``closeSession`
`cleanupAllSessions``cleanupSessionExternalAuthArtifacts(session)`
`module.exports` 导出 `startEtSession``execOnEtSession``bundledEtClient`
- [x] **3.4** 测试:`terminalBridge.bundledEt.test.cjs`(路径解析)+
`terminalBridge/etSession.test.cjs`prepareEtSshEnvironment 的端口/密钥/
askpass/跳板/legacy 算法分支)。可参考旧分支是否已有 ET 测试并搬运。
## Phase 4 — domain / 类型 / preload 接口面
- [x] **4.1** `domain/models.ts``HostProtocol``'et'``ProtocolConfig.etPort?`
`Host`/`GroupConfig``etEnabled?`/`etPort?`/`etTerminalPath?`
`TerminalSession.etEnabled?``ConnectionLog.protocol``'et'`
(照搬 `git show 794eecdf -- domain/models.ts`
- [x] **4.2** `domain/groupConfig.ts`:加 `etEnabled` 默认项(照搬旧 diff
- [x] **4.3** `global.d.ts``NetcattyBridge``startEtSession?(options): Promise<...>`
及相关 options 类型(照搬 `git show 794eecdf -- global.d.ts`,并补齐后续 ET 提交
新增的 etPort/terminalPath/jumpHosts/legacyAlgorithms 字段)。
- [x] **4.4** `electron/preload/api.cjs`:加 `startEtSession`(镜像第 26 行的
`startMoshSession`)→ `ipcRenderer.invoke("netcatty:et:start", options)`
**注意**:上游已把 preload 重构成 `createPreloadApi`,落点在 `preload/api.cjs`
不是旧的 `preload.cjs` 内联对象。
## Phase 5 — 渲染层 + UI + i18n
- [x] **5.1** `application/state/useTerminalBackend.ts`:加 `etAvailable`(查
`bridge?.startEtSession`+ `startEtSession`,并在返回对象/依赖数组里登记
(镜像 mosh 的第 10/42/198/205 行处)。
- [x] **5.2** `application/state/useSessionState.ts`:路由 ET 会话(照搬旧 diff+6 行)。
- [x] **5.3** `components/terminal/runtime/createTerminalSessionStarters.ts`:加
`startEt(term)`(镜像 `startMosh`,组装 optionsetPort/terminalPath/
jumpHosts/legacyAlgorithms/凭证/identityFilePaths
**注意**:上游把它从旧的 `infrastructure/runtime/` 移到了
`components/terminal/runtime/` —— 落点以上游为准。
- [x] **5.4** UI 组件(照搬 `git show b1a306f8 6c0d5bf3 55caa268` 的相应文件,
映射到上游同名组件):
- [ ] `components/ProtocolSelectDialog.tsx` —— 新增 ET 选项
- [ ] `components/QuickConnectWizard.tsx`
- [ ] `components/HostDetailsPanel.tsx` —— ET 设置启用、ET 端口、etterminal 路径)
- [ ] `components/GroupDetailsPanel.tsx`
- [ ] `components/VaultView.tsx`
- [ ] `components/Terminal.tsx` / `components/TerminalLayer.tsx`
- [ ] `components/terminal/TerminalConnectionDialog.tsx` / `TerminalToolbar.tsx`
- [ ] `App.tsx`
- [x] **5.5** i18n`application/i18n/locales/en.ts``zh-CN.ts` 加 ET 文案
(照搬旧 diff键名对齐上游现有 mosh 文案结构)。
## Phase 6 — 校验
- [x] **6.1** `npm run lint`(确保新 .cjs 在 scripts/ 下不受 ESLint 限制,
或按需加 eslint-disable与 mosh 脚本一致)。
- [x] **6.2** `npm test`(新增的 fetch/resolve/extra-resources/etSession 测试全绿)。
- [x] **6.3** `npm run build`(渲染层 TS 编译通过,无类型错误)。
- [ ] **6.4** 手动冒烟(需先有发布的二进制):
`ET_BIN_RELEASE=et-bin-... npm run fetch:et``npm run start`
新建 ET 会话连一台装了 etserver 的主机,验证连接/输入/退出/凭证注入。
---
## 进度记录
- 状态:**Phase 15 已完成并通过校验**(仅余 1 个可选项 + CI 产二进制)
- 验证结果:
- `npx eslint <所有改动文件>` → 干净0 错 0 警)
- `npx tsc --noEmit` → 我的改动 **0 个新增类型错误**
`TerminalConnectionDialog``case 'mosh'` 的 TS2678 是既有问题,行号因我插入 ET 早返回从 60→64非新增
- `node --test`ET 相关)→ etSession/bundledEt/3 个脚本测试 **全绿**
- `npm test` → 1383 通过 / 16 失败,**16 个全是既有的 Windows 环境失败**
mosh 打包测试的 GNU-tar `C:` 问题、`isExecutableFile` 无 x 位、ACP execPath、SKILL.md 权限、Comware DH 等;均在我未改动的文件里)
- `npm run build`Vite**构建成功**8.55s),渲染层打包通过
### 已完成
- **Phase 1**`scripts/et-extra-resources.cjs` / `resolve-et-bin-release.cjs` /
`fetch-et-binaries.cjs`+3 测试27 通过)、`scripts/build-et/{build-linux.sh,
build-macos.sh,build-windows.ps1}``.github/workflows/build-et-binaries.yml`
`resources/et/README.md``.gitignore``package.json``electron-builder.config.cjs`
- **Phase 2**`terminalBridge.cjs` 新增并导出 `bundledEtClient`
- **Phase 3**`terminalBridge/etSession.cjs`startEtSession + prepareEtSshEnvironment +
SSH_ASKPASS 机制 + execOnEtSession + 清理),接线进 terminalBridge.cjsctx/IPC
`netcatty:et:start`/init 清理/close/quit 清理/导出),+2 测试13 通过)。
**et 指向捆绑二进制**resolveBareEtClient→bundledEtClient找不到则报错。
- **Phase 4**domain `connection.ts`/`history.ts`/`terminal.ts``groupConfig.ts`
`types/global/netcatty-bridge-session.d.ts`startEtSession + NetcattyJumpHost[])、
`electron/preload/api.cjs``domain/vaultImport.ts`(排除 'et' 导入协议)。
- **Phase 5**
- 启动派发:`useTerminalEffects.ts``Terminal.tsx`(×3) → `startEt`
- 运行时 starter`createTerminalSessionStarters.ts` 新增 `startEt`(含单跳板/凭证/
legacy 算法/askpass 路径),`.types.ts``etAvailable`/`startEtSession`
- 后端 hook`useTerminalBackend.ts`etAvailable + startEtSession
- 会话透传 etEnabled`sessionFactories.ts``useSessionState.ts`(×6)、
`TerminalLayer.tsx`(×3)、`TerminalLayerSupport.tsx``AppHandlers.ts`(协议解析/日志/选择)
- UI`HostDetailsAdvancedSections.tsx`ET 开关+端口+etterminal 路径,与 Mosh 互斥)、
`HostDetailsPanel.tsx``ProtocolSelectDialog.tsx`ET 选项)、
`TerminalConnectionDialog.tsx`ET 标签)、`TerminalToolbar.tsx`(编码菜单门控)、
`GroupSshSettingsSection.tsx` + `GroupDetailsPanel.tsx`(组级 ET`VaultView.tsx`
- i18nen/zh-CN 的 `hostDetails.section.et``hostDetails.et.*`
`terminal.connection.protocol.et``terminal.et.*`
### 剩余(可选 / 非阻塞)
- [ ] **QuickConnectWizard.tsx**:把 ET 加为“快速连接”协议按钮type/端口/建主机映射 +
UI 按钮)。当前快速连接未列 ET保存主机后开启 ET 再连即可,故仅为便利项。
- [ ] **产出二进制**:手动 `workflow_dispatch``build-et-binaries.yml`(带
`release_tag=et-bin-<ver>-1`)发布到 `Netcatty-et-bin`,并配 `ET_BIN_RELEASE_TOKEN`
secret。之后 `ET_BIN_RELEASE=... npm run fetch:et` 即可本地/打包捆绑 `et`
build-et 脚本本机无法编译 C++,需在 CI 验证。
- [ ] **端到端冒烟**:有二进制后 `npm run dev`,对装有 etserver 的主机建 ET 会话验证。
- 当前分支:`feat/et-history-reapply`(基于上游 `031bf0ee`
- 旧 ET 实现参考分支:`feat/eternal-terminal`tip `67e81616`7 个 ET 提交)

View File

@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { handleGlobalHotkeyKeyDownImpl } from './app/AppHandlers.ts';
import { matchesKeyBinding } from '../domain/models.ts';
import { DEFAULT_KEY_BINDINGS } from '../domain/models/keyBindings.ts';
class FakeHTMLElement {
tagName = 'TEXTAREA';
isContentEditable = false;
classList = {
contains: (className: string) => className === 'xterm-helper-textarea',
};
closest(selector: string): FakeHTMLElement | null {
return selector.includes('xterm') ? this : null;
}
hasAttribute(name: string): boolean {
return name === 'data-session-id';
}
}
const previousHTMLElement = globalThis.HTMLElement;
globalThis.HTMLElement = FakeHTMLElement as unknown as typeof HTMLElement;
test.after(() => {
globalThis.HTMLElement = previousHTMLElement;
});
test('global hotkey handler lets terminal font size shortcuts reach xterm', () => {
const target = new FakeHTMLElement();
const handledActions: string[] = [];
let prevented = false;
let stopped = false;
const event = {
key: '=',
code: 'Equal',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
target,
composedPath: () => [target],
preventDefault: () => {
prevented = true;
},
stopPropagation: () => {
stopped = true;
},
} as unknown as KeyboardEvent;
handleGlobalHotkeyKeyDownImpl(
() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: 'Ctrl + W',
executeHotkeyAction: (action: string) => {
handledActions.push(action);
},
hotkeyScheme: 'pc',
keyBindings: DEFAULT_KEY_BINDINGS,
matchesKeyBinding,
}),
event,
);
assert.deepEqual(handledActions, []);
assert.equal(prevented, false);
assert.equal(stopped, false);
});

View File

@@ -0,0 +1,832 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type React from 'react';
import type { Host, HostProtocol } from '../../types';
import type { PassphraseRequest } from '../../components/PassphraseModal';
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
type AppContextGetter = () => Record<string, any>;
const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions();
export function handleTrayJumpToSessionImpl(getCtx: AppContextGetter, sessionId: string) {
const { sessions, setActiveTabId, setWorkspaceFocusedSession } = getCtx();
{
const session = sessions.find((item) => item.id === sessionId);
if (session?.workspaceId) {
setActiveTabId(session.workspaceId);
setWorkspaceFocusedSession(session.workspaceId, sessionId);
return;
}
setActiveTabId(sessionId);
}
}
export function handleTrayTogglePortForwardImpl(getCtx: AppContextGetter, ruleId: string, start: boolean) {
const { hosts, identities, keys, portForwardingRules, resolveEffectiveHost, startTunnel, stopTunnel, t, terminalSettings, toast } = getCtx();
{
const rule = portForwardingRules.find((item) => item.id === ruleId);
if (!rule) return;
const host = rule.hostId ? hosts.find((item) => item.id === rule.hostId) : undefined;
if (!host) {
toast.error(t("pf.error.hostNotFound"));
return;
}
if (start) {
const effectiveHost = resolveEffectiveHost(host);
void startTunnel(rule, effectiveHost, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart, terminalSettings);
return;
}
void stopTunnel(ruleId);
}
}
export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: string) {
const { addConnectionLog, connectToHost, hosts, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef, t, toast } = getCtx();
{
const host = hosts.find((item) => item.id === hostId);
if (!host) {
toast.error(t("pf.error.hostNotFound"));
return;
}
const effectiveHost = resolveEffectiveHost(host);
const { username, hostname: localHost } = systemInfoRef.current;
if (effectiveHost.protocol === 'serial') {
const portName = host.hostname.split('/').pop() || host.hostname;
const sessionId = connectToHost(effectiveHost);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: host.hostname,
username,
protocol: 'serial',
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
saved: false,
});
return;
}
const protocol = effectiveHost.etEnabled ? 'et' : effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host: effectiveHost, keys, identities });
const sessionId = connectToHost(effectiveHost);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: resolvedAuth.username || 'root',
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh' | 'et',
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
saved: false,
});
}
}
export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEvent) {
const { HOTKEY_DEBUG, closeTabKeyStr, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding } = getCtx();
{
const isMac = hotkeyScheme === 'mac';
const target = e.target as HTMLElement;
const isCloseTabHotkey = closeTabKeyStr ? matchesKeyBinding(e, closeTabKeyStr, isMac) : false;
const dialogHotkeyScope = target.closest?.('[data-hotkey-close-tab="true"]');
if (isCloseTabHotkey && dialogHotkeyScope) {
return;
}
if (isCloseTabHotkey) {
const openDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][data-state="open"]'));
const topmostOpenDialog = openDialogs[openDialogs.length - 1] ?? null;
const topmostDialogClose = topmostOpenDialog?.querySelector<HTMLElement>('[data-dialog-close="true"]');
if (topmostDialogClose) {
e.preventDefault();
e.stopPropagation();
topmostDialogClose.click();
return;
}
}
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
const isMonacoElement =
target instanceof HTMLElement &&
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
const isXtermInput =
target instanceof HTMLElement &&
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape') {
return;
}
const isTerminalElement =
target instanceof HTMLElement &&
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
const isTerminalInPath = Boolean(
e.composedPath?.().some(
(node) =>
node instanceof HTMLElement &&
(node.classList.contains("xterm") ||
node.classList.contains("xterm-helper-textarea") ||
node.classList.contains("xterm-screen") ||
node.classList.contains("xterm-viewport") ||
node.hasAttribute("data-session-id")),
),
);
for (const binding of keyBindings) {
const keyStr = isMac ? binding.mac : binding.pc;
if (!matchesKeyBinding(e, keyStr, isMac)) continue;
if (HOTKEY_DEBUG) console.log('[Hotkeys] Matched binding:', binding.action, keyStr);
if (binding.category === 'sftp') {
continue;
}
if (TERMINAL_PASSTHROUGH_ACTIONS.has(binding.action)) {
if (isTerminalElement) {
return;
}
continue;
}
e.preventDefault();
e.stopPropagation();
if (HOTKEY_DEBUG) {
console.log('[Hotkeys] Global handle', {
action: binding.action,
key: e.key,
meta: e.metaKey,
ctrl: e.ctrlKey,
alt: e.altKey,
shift: e.shiftKey,
targetTag: target?.tagName,
isTerminalElement,
isTerminalInPath,
});
}
executeHotkeyAction(binding.action, e);
return;
}
}
}
export function handleEscapeKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEvent) {
const { isQuickSwitcherOpen, setIsQuickSwitcherOpen } = getCtx();
{
if (e.key === 'Escape' && isQuickSwitcherOpen) {
setIsQuickSwitcherOpen(false);
}
}
}
export function handleKeyboardInteractiveSubmitImpl(getCtx: AppContextGetter, requestId: string, responses: string[], savePassword?: string) {
const { hosts, keyboardInteractiveQueue, netcattyBridge, sessions, setKeyboardInteractiveQueue, updateHosts } = getCtx();
{
const bridge = netcattyBridge.get();
if (bridge?.respondKeyboardInteractive) {
void bridge.respondKeyboardInteractive(requestId, responses, false);
}
// Save password to host if requested
if (savePassword) {
const request = keyboardInteractiveQueue.find(r => r.requestId === requestId);
if (request?.sessionId) {
const session = sessions.find(s => s.id === request.sessionId);
// Only save when the prompting hostname matches the session's host,
// to avoid overwriting the destination host's password with a jump host's password
if (session?.hostId && (!request.hostname || request.hostname === session.hostname)) {
const host = hosts.find(h => h.id === session.hostId);
if (host) {
updateHosts(hosts.map(h => h.id === host.id ? { ...h, password: savePassword } : h));
}
}
}
}
// Remove from queue by requestId
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
}
}
export function handleKeyboardInteractiveCancelImpl(getCtx: AppContextGetter, requestId: string) {
const { netcattyBridge, setKeyboardInteractiveQueue } = getCtx();
{
const bridge = netcattyBridge.get();
if (bridge?.respondKeyboardInteractive) {
void bridge.respondKeyboardInteractive(requestId, [], true);
}
// Remove from queue by requestId
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
}
}
export async function handlePassphraseSubmitImpl(getCtx: AppContextGetter, requestId: string, passphrase: string, remember: boolean) {
const { keysRef, netcattyBridge, passphraseQueue, rememberKeyPassphrase, setPassphraseQueue, updateKeys } = getCtx();
{
const bridge = netcattyBridge.get();
const request = passphraseQueue.find((r: PassphraseRequest) => r.requestId === requestId);
// Save passphrase if requested
if (remember && request?.keyPath) {
console.log('[App] Saving passphrase for:', request.keyPath);
try {
await rememberKeyPassphrase({
keyPath: request.keyPath,
passphrase,
keys: keysRef.current,
updateKeys,
setCurrentKeys: (updated) => {
keysRef.current = updated;
},
});
} catch (err) {
console.warn('[App] Failed to save passphrase:', err);
}
}
if (bridge?.respondPassphrase) {
void bridge.respondPassphrase(requestId, passphrase, false);
}
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
}
}
export function handlePassphraseCancelImpl(getCtx: AppContextGetter, requestId: string) {
const { netcattyBridge, setPassphraseQueue } = getCtx();
{
const bridge = netcattyBridge.get();
if (bridge?.respondPassphrase) {
// Cancel = stop the entire passphrase flow
void bridge.respondPassphrase(requestId, '', true);
}
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
}
}
export function handlePassphraseSkipImpl(getCtx: AppContextGetter, requestId: string) {
const { netcattyBridge, setPassphraseQueue } = getCtx();
{
const bridge = netcattyBridge.get();
if (bridge?.respondPassphraseSkip) {
// Skip = skip this key but continue asking for others
void bridge.respondPassphraseSkip(requestId);
} else if (bridge?.respondPassphrase) {
// Fallback for older API
void bridge.respondPassphrase(requestId, '', false);
}
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
}
}
export function createLocalTerminalWithCurrentShellImpl(getCtx: AppContextGetter) {
const { classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, terminalSettings } = getCtx();
{
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells, terminalSettings.localShellArgs);
const matchedShell = discoveredShells.find(s => s.id === terminalSettings.localShell);
return createLocalTerminal({
shellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
shell: resolved?.command,
shellArgs: resolved?.args,
shellName: matchedShell?.name,
shellIcon: matchedShell?.icon,
});
}
}
export function splitSessionWithCurrentShellImpl(getCtx: AppContextGetter, sessionId: string, direction: 'horizontal' | 'vertical') {
const { classifyLocalShellType, discoveredShells, resolveShellSetting, splitSession, terminalSettings } = getCtx();
{
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
return splitSession(sessionId, direction, {
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
});
}
}
export function copySessionWithCurrentShellImpl(getCtx: AppContextGetter, sessionId: string) {
const { classifyLocalShellType, copySession, discoveredShells, resolveShellSetting, terminalSettings } = getCtx();
{
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
return copySession(sessionId, {
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
});
}
}
export async function confirmIfBusyLocalTerminalImpl(getCtx: AppContextGetter, sessionIds: string[]) {
const { netcattyBridge, sessions, t } = getCtx();
{
const bridge = netcattyBridge.get();
const localIds = sessionIds.filter((id) => {
const s = sessions.find((x) => x.id === id);
return s?.protocol === 'local';
});
const busyCommands: string[] = [];
for (const id of localIds) {
const children = (await bridge?.ptyGetChildProcesses?.(id)) ?? [];
if (children.length > 0) {
busyCommands.push(children[0].command);
}
}
if (busyCommands.length === 0) return true;
const primary = busyCommands[0];
const extraCount = busyCommands.length - 1;
const message =
extraCount > 0
? t('confirm.closeBusyTerminal.messageWithMore', {
command: primary,
count: extraCount,
})
: t('confirm.closeBusyTerminal.message', { command: primary });
const ok = await bridge?.confirmCloseBusy?.({
command: primary,
title: t('confirm.closeBusyTerminal.title'),
message,
cancelLabel: t('confirm.closeBusyTerminal.cancel'),
closeLabel: t('confirm.closeBusyTerminal.close'),
});
return ok === true;
}
}
export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: string[]) {
const { closeLogView, closeSession, closeTabsInFlightRef, closeWorkspace, confirmIfBusyLocalTerminal, logViews, sessions, workspaces } = getCtx();
{
if (targetIds.length === 0) return;
if (closeTabsInFlightRef.current) return;
// Expand workspace ids into their constituent session ids so the busy
// probe sees every local shell that's about to be killed.
const sessionIdsToProbe: string[] = [];
for (const tabId of targetIds) {
const ws = workspaces.find((w) => w.id === tabId);
if (ws) {
for (const s of sessions) {
if (s.workspaceId === tabId) sessionIdsToProbe.push(s.id);
}
} else if (sessions.find((s) => s.id === tabId)) {
sessionIdsToProbe.push(tabId);
}
}
closeTabsInFlightRef.current = true;
try {
const ok = await confirmIfBusyLocalTerminal(sessionIdsToProbe);
if (!ok) return;
for (const tabId of targetIds) {
if (workspaces.find((w) => w.id === tabId)) {
closeWorkspace(tabId);
} else if (sessions.find((s) => s.id === tabId)) {
closeSession(tabId);
} else if (logViews.find((lv) => lv.id === tabId)) {
closeLogView(tabId);
}
}
} finally {
closeTabsInFlightRef.current = false;
}
}
}
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
{
// Build complete tab list: vault + (sftp when visible) + sessions/workspaces + editor tabs.
// Hiding the SFTP tab must also remove it from keyboard cycling so nextTab
// doesn't land on a hidden tab (which would get redirected back) and so
// number shortcuts don't shift.
const allTabs = settings.showSftpTab
? ['vault', 'sftp', ...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))]
: ['vault', ...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))];
switch (action) {
case 'switchToTab': {
// Get the number key pressed (1-9)
const num = parseInt(e.key, 10);
if (num >= 1 && num <= 9) {
if (num <= allTabs.length) {
setActiveTabId(allTabs[num - 1]);
}
}
break;
}
case 'nextTab': {
const currentId = activeTabStore.getActiveTabId();
const currentIdx = allTabs.indexOf(currentId);
if (currentIdx !== -1 && allTabs.length > 0) {
const nextIdx = (currentIdx + 1) % allTabs.length;
setActiveTabId(allTabs[nextIdx]);
} else if (allTabs.length > 0) {
setActiveTabId(allTabs[0]);
}
break;
}
case 'prevTab': {
const currentId = activeTabStore.getActiveTabId();
const currentIdx = allTabs.indexOf(currentId);
if (currentIdx !== -1 && allTabs.length > 0) {
const prevIdx = (currentIdx - 1 + allTabs.length) % allTabs.length;
setActiveTabId(allTabs[prevIdx]);
} else if (allTabs.length > 0) {
setActiveTabId(allTabs[allTabs.length - 1]);
}
break;
}
case 'closeTab': {
const currentId = activeTabStore.getActiveTabId();
if (!currentId || currentId === 'vault' || currentId === 'sftp') break;
if (closeTabInFlightRef.current) break;
// Editor tabs route through their own dirty-confirm close flow.
if (isEditorTabId(currentId)) {
const editorId = fromEditorTabId(currentId);
if (editorId) handleRequestCloseEditorTabRef.current(editorId);
break;
}
const session = sessions.find((s) => s.id === currentId) ?? null;
const workspace = workspaces.find((w) => w.id === currentId) ?? null;
const focusIsInsideTerminal = !!document.activeElement?.closest('[data-session-id]');
const intent = resolveCloseIntent({
activeTabId: currentId,
workspace: workspace ? { id: workspace.id, focusedSessionId: workspace.focusedSessionId } : null,
sessionForTab: session,
focusIsInsideTerminal,
});
closeTabInFlightRef.current = true;
(async () => {
try {
switch (intent.kind) {
case 'closeTerminal':
case 'closeSingleTab': {
const ok = await confirmIfBusyLocalTerminal([intent.sessionId]);
if (ok) closeSession(intent.sessionId);
return;
}
case 'closeWorkspace': {
const ids = sessions.filter((s) => s.workspaceId === intent.workspaceId).map((s) => s.id);
const ok = await confirmIfBusyLocalTerminal(ids);
if (ok) closeWorkspace(intent.workspaceId);
return;
}
case 'noop':
default:
return;
}
} finally {
closeTabInFlightRef.current = false;
}
})();
break;
}
case 'newTab':
case 'openLocal':
// Add connection log for local terminal
addConnectionLogRef.current({
hostId: '',
hostLabel: 'Local Terminal',
hostname: 'localhost',
username: systemInfoRef.current.username,
protocol: 'local',
startTime: Date.now(),
localUsername: systemInfoRef.current.username,
localHostname: systemInfoRef.current.hostname,
saved: false,
});
createLocalTerminalWithCurrentShell();
break;
case 'openHosts':
setActiveTabId('vault');
break;
case 'openSftp':
if (settings.showSftpTab) {
setActiveTabId('sftp');
}
break;
case 'quickSwitch':
case 'commandPalette':
setIsQuickSwitcherOpen(true);
break;
case 'newWorkspace':
// Dedicated shortcut to launch the AddToWorkspaceDialog in
// create mode — same entry as QuickSwitcher's "New Workspace"
// button, but without having to open QS first.
setAddToWorkspaceDialog({ mode: 'create' });
break;
case 'portForwarding':
// Navigate to vault and open port forwarding section
setActiveTabId('vault');
setNavigateToSection('port');
break;
case 'snippets':
{
const currentId = activeTabStore.getActiveTabId();
const intent = resolveSnippetsShortcutIntent({
activeTabId: currentId,
sessionForTab: sessions.find((s) => s.id === currentId) ?? null,
workspaceForTab: workspaces.find((w) => w.id === currentId) ?? null,
terminalScriptsToggleAvailable: !!toggleScriptsSidePanelRef.current,
});
if (intent.kind === 'toggleTerminalScripts') {
toggleScriptsSidePanelRef.current();
break;
}
setActiveTabId('vault');
setNavigateToSection('snippets');
}
break;
case 'toggleSidePanel':
toggleSidePanelRef.current?.();
break;
case 'broadcast': {
// Toggle broadcast mode for the active workspace
const currentId = activeTabStore.getActiveTabId();
const activeWs = workspaces.find(w => w.id === currentId);
if (activeWs) {
toggleBroadcast(activeWs.id);
}
break;
}
case 'openSettings':
handleOpenSettingsRef.current();
break;
case 'splitHorizontal': {
const currentId = activeTabStore.getActiveTabId();
const activeSession = sessions.find(s => s.id === currentId);
const activeWs = workspaces.find(w => w.id === currentId);
if (activeSession && !activeSession.workspaceId) {
splitSessionWithCurrentShell(activeSession.id, 'horizontal');
} else if (activeWs) {
const liveIds = collectSessionIds(activeWs.root);
const targetId = (activeWs.focusedSessionId && liveIds.includes(activeWs.focusedSessionId))
? activeWs.focusedSessionId
: liveIds[0];
if (targetId) splitSessionWithCurrentShell(targetId, 'horizontal');
}
break;
}
case 'splitVertical': {
const currentId = activeTabStore.getActiveTabId();
const activeSession = sessions.find(s => s.id === currentId);
const activeWs = workspaces.find(w => w.id === currentId);
if (activeSession && !activeSession.workspaceId) {
splitSessionWithCurrentShell(activeSession.id, 'vertical');
} else if (activeWs) {
const liveIds = collectSessionIds(activeWs.root);
const targetId = (activeWs.focusedSessionId && liveIds.includes(activeWs.focusedSessionId))
? activeWs.focusedSessionId
: liveIds[0];
if (targetId) splitSessionWithCurrentShell(targetId, 'vertical');
}
break;
}
case 'moveFocus': {
// Debounce to prevent double-triggering when focus switches between terminals
const now = Date.now();
if (now - lastMoveFocusTimeRef.current < MOVE_FOCUS_DEBOUNCE_MS) {
if (IS_DEV) console.log('[App] moveFocus debounced, ignoring');
break;
}
lastMoveFocusTimeRef.current = now;
// Move focus between split panes
if (IS_DEV) console.log('[App] moveFocus action triggered, key:', e.key);
const direction = e.key === 'ArrowUp' ? 'up'
: e.key === 'ArrowDown' ? 'down'
: e.key === 'ArrowLeft' ? 'left'
: e.key === 'ArrowRight' ? 'right'
: null;
if (IS_DEV) console.log('[App] moveFocus direction:', direction);
if (direction) {
// Find the active workspace
const currentId = activeTabStore.getActiveTabId();
if (IS_DEV) console.log('[App] Active tab ID:', currentId);
const activeWs = workspaces.find(w => w.id === currentId);
if (IS_DEV) console.log('[App] Active workspace:', activeWs?.id, activeWs?.title);
if (activeWs) {
const result = moveFocusInWorkspace(activeWs.id, direction as 'up' | 'down' | 'left' | 'right');
if (IS_DEV) console.log('[App] moveFocusInWorkspace result:', result);
} else {
if (IS_DEV) console.log('[App] No active workspace found');
}
}
break;
}
}
}
}
export function handleCreateLocalTerminalImpl(getCtx: AppContextGetter, shell?: { command: string; args?: string[]; name?: string; icon?: string }) {
const { addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, systemInfoRef, terminalSettings } = getCtx();
{
const { username, hostname } = systemInfoRef.current;
const resolved = shell ?? resolveShellSetting(terminalSettings.localShell, discoveredShells, terminalSettings.localShellArgs);
// Match by ID (not command) to avoid WSL distros all sharing wsl.exe
const matchedShell = !shell ? discoveredShells.find(s => s.id === terminalSettings.localShell) : undefined;
const shellName = shell?.name ?? matchedShell?.name;
const shellIcon = shell?.icon ?? matchedShell?.icon;
const sessionId = createLocalTerminal({
shellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
shell: resolved?.command,
shellArgs: resolved?.args,
shellName,
shellIcon,
});
addConnectionLog({
sessionId,
hostId: '',
hostLabel: shellName || 'Local Terminal',
hostname: 'localhost',
username: username,
protocol: 'local',
startTime: Date.now(),
localUsername: username,
localHostname: hostname,
saved: false,
});
}
}
export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host) {
const { addConnectionLog, connectToHost, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef } = getCtx();
{
const { username, hostname: localHost } = systemInfoRef.current;
const effectiveHost = resolveEffectiveHost(host);
// Handle serial hosts separately
if (effectiveHost.protocol === 'serial') {
const portName = host.hostname.split('/').pop() || host.hostname;
const sessionId = connectToHost(effectiveHost);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: host.hostname,
username: username,
protocol: 'serial',
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
saved: false,
});
return;
}
const protocol = effectiveHost.etEnabled ? 'et' : effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host: effectiveHost, keys, identities });
const sessionId = connectToHost(effectiveHost);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: resolvedAuth.username || 'root',
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh' | 'et',
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
saved: false,
});
}
}
export function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, sessionId: string, data: string) {
const { IS_DEV, connectionLogs, selectConnectionLogForTerminalDataCapture, sessions, updateConnectionLog } = getCtx();
{
if (IS_DEV) console.log('[handleTerminalDataCapture] Called', { sessionId, dataLength: data.length });
const session = sessions.find(s => s.id === sessionId);
if (IS_DEV) console.log('[handleTerminalDataCapture] Session', session);
if (IS_DEV) console.log('[handleTerminalDataCapture] All logs:', connectionLogs.map(l => ({ id: l.id, sessionId: l.sessionId, hostname: l.hostname, endTime: l.endTime, hasTerminalData: !!l.terminalData })));
const matchingLog = selectConnectionLogForTerminalDataCapture(
connectionLogs,
{ sessionId, hostname: session?.hostname },
);
if (IS_DEV) console.log('[handleTerminalDataCapture] Matching log', matchingLog);
if (matchingLog) {
updateConnectionLog(matchingLog.id, {
endTime: Date.now(),
terminalData: data,
});
if (IS_DEV) console.log('[handleTerminalDataCapture] Updated log with terminalData');
// Auto-save is now handled by real-time streaming in the main process
// via sessionLogStreamManager. No renderer-side fallback needed.
} else {
if (IS_DEV) console.log('[handleTerminalDataCapture] No matching log found!');
}
}
}
export function hasMultipleProtocolsImpl(getCtx: AppContextGetter, host: Host) {
const { resolveEffectiveHost } = getCtx();
{
// Gates the protocol picker (legacy name kept for its existing wiring).
// Only prompt when Telnet is available but isn't the host's default protocol;
// SSH-only, SSH+Mosh and Telnet-default all connect directly.
const effective = resolveEffectiveHost(host);
return Boolean(effective.telnetEnabled) && effective.protocol !== 'telnet';
}
}
export function handleHostConnectWithProtocolCheckImpl(getCtx: AppContextGetter, host: Host) {
const { handleConnectToHost, hasMultipleProtocols, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost, setQuickSearch } = getCtx();
{
if (hasMultipleProtocols(host)) {
setProtocolSelectHost(resolveEffectiveHost(host));
setIsQuickSwitcherOpen(false);
setQuickSearch('');
} else {
handleConnectToHost(host);
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}
}
}
export function handleProtocolSelectImpl(getCtx: AppContextGetter, protocol: HostProtocol, port: number) {
const { handleConnectToHost, protocolSelectHost, setProtocolSelectHost } = getCtx();
{
if (protocolSelectHost) {
const hostWithProtocol: Host = {
...protocolSelectHost,
protocol: (protocol === 'mosh' || protocol === 'et') ? 'ssh' : protocol,
port,
moshEnabled: protocol === 'mosh',
etEnabled: protocol === 'et',
};
handleConnectToHost(hostWithProtocol);
setProtocolSelectHost(null);
}
}
}
export function handleToggleThemeImpl(getCtx: AppContextGetter) {
const { openSettingsWindow, resolvedTheme, setTheme, t, theme, toast } = getCtx();
{
if (theme === 'system') {
toast.info(
t('topTabs.toggleTheme.systemExitMessage'),
{
title: t('topTabs.toggleTheme.systemExitTitle'),
actionLabel: t('topTabs.toggleTheme.openSettings'),
onClick: () => {
void (async () => {
const opened = await openSettingsWindow();
if (!opened) toast.error(t('toast.settingsUnavailable'), t('common.settings'));
})();
},
}
);
return;
}
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
}
}
export function handleRootContextMenuImpl(getCtx: AppContextGetter, e: React.MouseEvent<HTMLDivElement>) {
void getCtx;
{
const editableSelector =
"input, textarea, [contenteditable], .monaco-editor, .monaco-diff-editor, .monaco-inputbox, .monaco-menu-container";
const nativeEvent = e.nativeEvent;
const path = typeof nativeEvent.composedPath === "function" ? nativeEvent.composedPath() : [];
const allowFromPath = path.some(
(node) => node instanceof Element && !!node.closest(editableSelector),
);
const target = e.target;
const targetElement =
target instanceof Element
? target
: target instanceof Node
? target.parentElement
: null;
const allowFromTarget = !!targetElement?.closest(editableSelector);
const allowNativeContextMenu = allowFromPath || allowFromTarget;
if (allowNativeContextMenu) {
return;
}
e.preventDefault();
}
}

View File

@@ -0,0 +1,119 @@
import React, { Suspense, lazy, useEffect, useState } from 'react';
import { useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive } from '../state/activeTabStore';
import { cn } from '../../lib/utils';
import { ConnectionLog, TerminalTheme } from '../../types';
import type { LogView as LogViewType } from '../state/logViewState';
import type { SftpView as SftpViewComponent } from '../../components/SftpView';
import type { TerminalLayer as TerminalLayerComponent } from '../../components/TerminalLayer';
// Visibility container for VaultView - isolates isActive subscription
export const VaultViewContainer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const isActive = useIsVaultActive();
const containerStyle: React.CSSProperties = isActive
? {}
: { visibility: 'hidden', pointerEvents: 'none', position: 'absolute', zIndex: -1 };
return (
<div className={cn("absolute inset-0", isActive ? "z-20" : "")} style={containerStyle}>
{children}
</div>
);
};
// LogView wrapper - manages visibility based on active tab
interface LogViewWrapperProps {
logView: LogViewType;
defaultTerminalTheme: TerminalTheme;
defaultFontSize: number;
onClose: () => void;
onUpdateLog: (logId: string, updates: Partial<ConnectionLog>) => void;
}
export const LogViewWrapper: React.FC<LogViewWrapperProps> = ({ logView, defaultTerminalTheme, defaultFontSize, onClose, onUpdateLog }) => {
const activeTabId = useActiveTabId();
const isVisible = activeTabId === logView.id;
// Use same pattern as VaultViewContainer for visibility
const containerStyle: React.CSSProperties = isVisible
? {}
: { visibility: 'hidden', pointerEvents: 'none', position: 'absolute', zIndex: -1 };
return (
<div className={cn("absolute inset-0", isVisible ? "z-20" : "")} style={containerStyle}>
<Suspense fallback={null}>
<LazyLogView
log={logView.log}
defaultTerminalTheme={defaultTerminalTheme}
defaultFontSize={defaultFontSize}
isVisible={isVisible}
onClose={onClose}
onUpdateLog={onUpdateLog}
/>
</Suspense>
</div>
);
};
const LazyLogView = lazy(() => import('../../components/LogView'));
const LazySftpView = lazy(() =>
import('../../components/SftpView').then((m) => ({ default: m.SftpView })),
);
const LazyTerminalLayer = lazy(() =>
import('../../components/TerminalLayer').then((m) => ({ default: m.TerminalLayer })),
);
type SftpViewProps = React.ComponentProps<typeof SftpViewComponent>;
type TerminalLayerProps = React.ComponentProps<typeof TerminalLayerComponent>;
export const SftpViewMount: React.FC<SftpViewProps> = (props) => {
const isActive = useIsSftpActive();
const [shouldMount, setShouldMount] = useState(isActive);
useEffect(() => {
if (isActive) setShouldMount(true);
}, [isActive]);
if (!shouldMount) return null;
return (
<Suspense fallback={null}>
<LazySftpView {...props} />
</Suspense>
);
};
export const TerminalLayerMount: React.FC<TerminalLayerProps> = (props) => {
const isVisible = useIsTerminalLayerVisible(props.draggingSessionId);
const [shouldMount, setShouldMount] = useState(isVisible);
useEffect(() => {
if (isVisible) setShouldMount(true);
}, [isVisible]);
useEffect(() => {
if (shouldMount) return;
type IdleWindow = Window & {
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
cancelIdleCallback?: (id: number) => void;
};
const idleWindow = window as IdleWindow;
if (typeof idleWindow.requestIdleCallback === "function") {
const id = idleWindow.requestIdleCallback(() => setShouldMount(true), { timeout: 5000 });
return () => idleWindow.cancelIdleCallback?.(id);
}
const id = window.setTimeout(() => setShouldMount(true), 5000);
return () => window.clearTimeout(id);
}, [shouldMount]);
const shouldRender = shouldMount || isVisible;
if (!shouldRender) return null;
return (
<Suspense fallback={null}>
<LazyTerminalLayer {...props} />
</Suspense>
);
};

560
application/app/AppView.tsx Normal file
View File

@@ -0,0 +1,560 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { Suspense, lazy } from 'react';
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
import { activeTabStore, toEditorTabId } from '../state/activeTabStore';
import { editorTabStore } from '../state/editorTabStore';
import { releaseEditorTabSaveCoordinator, saveEditorTab } from '../state/editorTabSave';
import { TopTabs } from '../../components/TopTabs';
import { VaultView } from '../../components/VaultView';
import { QuickAddSnippetDialog } from '../../components/QuickAddSnippetDialog';
import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog';
import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal';
import { PassphraseModal } from '../../components/PassphraseModal';
import { TextEditorTabView } from '../../components/editor/TextEditorTabView';
import { UnsavedChangesProvider } from '../../components/editor/UnsavedChangesDialog';
import { SnippetExecutionProvider } from '../../components/SnippetExecutionProvider';
import { Button } from '../../components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../components/ui/dialog';
import { Input } from '../../components/ui/input';
import { Label } from '../../components/ui/label';
import { toast } from '../../components/ui/toast';
import { cn } from '../../lib/utils';
const LazyProtocolSelectDialog = lazy(() => import('../../components/ProtocolSelectDialog'));
const LazyQuickSwitcher = lazy(() =>
import('../../components/QuickSwitcher').then((m) => ({ default: m.QuickSwitcher })),
);
const LazyCreateWorkspaceDialog = lazy(() =>
import('../../components/CreateWorkspaceDialog').then((m) => ({ default: m.CreateWorkspaceDialog })),
);
type AppViewContext = Record<string, any>;
export function AppView({ ctx }: { ctx: AppViewContext }) {
const {
accentMode, activeTabId, activeTerminalTheme, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, closeWorkspace, copySessionWithCurrentShell,
connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent,
customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost,
handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, 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, openLogView, orderedTabsWithEditors, orphanSessions,
passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderTabs, reorderWorkspaceSessions, resetSessionRename,
resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionRenameTarget, sshDebugLogsEnabled,
sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen,
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId,
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId,
toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog,
updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources,
updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces,
VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper,
} = ctx;
return (
<SnippetExecutionProvider>
<UnsavedChangesProvider>
{({ prompt }) => {
// Helper: close an editor tab and activate the neighbor (left-preference), or vault.
const closeEditorAndActivateNeighbor = (id: string) => {
const closingTabId = toEditorTabId(id);
const list = orderedTabsWithEditors;
const idx = list.indexOf(closingTabId);
releaseEditorTabSaveCoordinator(id);
editorTabStore.close(id);
if (activeTabStore.getActiveTabId() !== closingTabId) return;
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
};
// Real dirty-confirm close handler.
const handleRequestCloseEditorTab = async (id: string): Promise<boolean> => {
const tab = editorTabStore.getTab(id);
if (!tab) return false;
const dirty = tab.content !== tab.baselineContent;
if (!dirty) {
closeEditorAndActivateNeighbor(id);
return true;
}
const choice = await prompt(tab.fileName);
if (choice === 'cancel') return false;
if (choice === 'discard') {
closeEditorAndActivateNeighbor(id);
return true;
}
if (choice === 'save') {
const ok = await saveEditorTab(id);
if (!ok) {
const msg = editorTabStore.getTab(id)?.saveError ?? 'Save failed';
toast.error(msg, 'SFTP');
return false;
}
const latest = editorTabStore.getTab(id);
if (!latest || latest.content !== latest.baselineContent) return false;
closeEditorAndActivateNeighbor(id);
return true;
}
return false;
};
// Expose to the hotkey dispatcher (Cmd/Ctrl+W).
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
return (
<div className={cn("flex flex-col h-screen text-foreground font-sans netcatty-shell", activeTerminalTheme && "immersive-transition")} onContextMenu={handleRootContextMenu}>
<TopTabs
theme={resolvedTheme}
followAppTerminalTheme={followAppTerminalTheme}
hosts={hosts}
sessions={sessions}
orphanSessions={orphanSessions}
workspaces={workspaces}
logViews={logViews}
orderedTabs={orderedTabsWithEditors}
draggingSessionId={draggingSessionId}
isMacClient={isMacClient}
onCloseSession={closeSession}
onRenameSession={startSessionRename}
onCopySession={copySessionWithCurrentShell}
onRenameWorkspace={startWorkspaceRename}
onCloseWorkspace={closeWorkspace}
onCloseLogView={closeLogView}
onCloseTabsBatch={closeTabsBatch}
onOpenQuickSwitcher={handleOpenQuickSwitcher}
onToggleTheme={handleToggleTheme}
onOpenSettings={handleOpenSettings}
onSyncNow={handleSyncNowManual}
isImmersiveActive={activeTerminalTheme !== null}
onStartSessionDrag={setDraggingSessionId}
onEndSessionDrag={handleEndSessionDrag}
onReorderTabs={reorderTabs}
showSftpTab={settings.showSftpTab}
editorTabs={editorTabs}
onRequestCloseEditorTab={handleRequestCloseEditorTab}
hostById={hostById}
/>
<div className="flex-1 relative min-h-0">
<VaultViewContainer>
<VaultView
hosts={hosts}
keys={keys}
identities={identities}
proxyProfiles={proxyProfiles}
snippets={snippets}
snippetPackages={snippetPackages}
customGroups={customGroups}
knownHosts={effectiveKnownHosts}
shellHistory={shellHistory}
connectionLogs={connectionLogs}
managedSources={managedSources}
sessionCount={sessions.length}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
terminalThemeId={terminalThemeId}
terminalFontSize={terminalFontSize}
onOpenSettings={handleOpenSettings}
onOpenQuickSwitcher={handleOpenQuickSwitcher}
onCreateLocalTerminal={handleCreateLocalTerminal}
onConnectSerial={handleConnectSerial}
onDeleteHost={handleDeleteHost}
onConnect={handleConnectToHost}
groupConfigs={groupConfigs}
onUpdateGroupConfigs={updateGroupConfigs}
onUpdateHosts={updateHosts}
onUpdateKeys={updateKeys}
onImportOrReuseKey={importOrReuseKey}
onUpdateIdentities={updateIdentities}
onUpdateProxyProfiles={updateProxyProfiles}
onUpdateSnippets={updateSnippets}
onUpdateSnippetPackages={updateSnippetPackages}
onUpdateCustomGroups={updateCustomGroups}
onUpdateKnownHosts={updateKnownHosts}
onUpdateManagedSources={updateManagedSources}
onClearAndRemoveManagedSource={clearAndRemoveSource}
onClearAndRemoveManagedSources={clearAndRemoveSources}
onUnmanageSource={unmanageSource}
onConvertKnownHost={convertKnownHostToHost}
onToggleConnectionLogSaved={toggleConnectionLogSaved}
onDeleteConnectionLog={deleteConnectionLog}
onClearUnsavedConnectionLogs={clearUnsavedConnectionLogs}
onRunSnippet={runSnippet}
onOpenLogView={openLogView}
showRecentHosts={settings.showRecentHosts}
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
navigateToSection={navigateToSection}
onNavigateToSectionHandled={() => setNavigateToSection(null)}
terminalSettings={terminalSettings}
/>
</VaultViewContainer>
<SftpViewMount
hosts={hosts}
keys={keys}
identities={identities}
proxyProfiles={proxyProfiles}
groupConfigs={groupConfigs}
updateHosts={updateHosts}
sftpDefaultViewMode={sftpDefaultViewMode}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={sftpAutoSync}
sftpShowHiddenFiles={sftpShowHiddenFiles}
sftpUseCompressedUpload={sftpUseCompressedUpload}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
terminalSettings={terminalSettings}
/>
<TerminalLayerMount
hosts={hosts}
groupConfigs={groupConfigs}
proxyProfiles={proxyProfiles}
keys={keys}
identities={identities}
snippets={snippets}
snippetPackages={snippetPackages}
sessions={sessions}
workspaces={workspaces}
knownHosts={effectiveKnownHosts}
draggingSessionId={draggingSessionId}
terminalTheme={currentTerminalTheme}
followAppTerminalTheme={followAppTerminalTheme}
accentMode={accentMode}
customAccent={customAccent}
terminalSettings={terminalSettings}
terminalFontFamilyId={terminalFontFamilyId}
fontSize={terminalFontSize}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
onHotkeyAction={handleHotkeyAction}
onUpdateTerminalThemeId={setTerminalThemeId}
onUpdateTerminalFontFamilyId={setTerminalFontFamilyId}
onUpdateTerminalFontSize={setTerminalFontSize}
onUpdateTerminalFontWeight={(w) => updateTerminalSetting('fontWeight', w)}
onCloseSession={closeSession}
onUpdateSessionStatus={handleSessionStatusChange}
onUpdateHostDistro={updateHostDistro}
onUpdateHost={handleUpdateHostFromTerminal}
onAddKnownHost={handleAddKnownHost}
onCommandExecuted={(command, hostId, hostLabel, sessionId) => {
addShellHistoryEntry({ command, hostId, hostLabel, sessionId });
}}
onTerminalDataCapture={handleTerminalDataCapture}
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
onAddSessionToWorkspace={addSessionToWorkspace}
onRequestAddToWorkspace={(workspaceId) =>
setAddToWorkspaceDialog({ mode: 'append', workspaceId })
}
onUpdateSplitSizes={updateSplitSizes}
onSetDraggingSessionId={setDraggingSessionId}
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
onReorderWorkspaceSessions={reorderWorkspaceSessions}
onSplitSession={splitSessionWithCurrentShell}
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}
updateHosts={updateHosts}
sftpDefaultViewMode={sftpDefaultViewMode}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={sftpAutoSync}
sftpShowHiddenFiles={sftpShowHiddenFiles}
sftpUseCompressedUpload={sftpUseCompressedUpload}
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
sessionLogsEnabled={sessionLogsEnabled}
sessionLogsDir={sessionLogsDir}
sessionLogsFormat={sessionLogsFormat}
sshDebugLogsEnabled={sshDebugLogsEnabled}
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
toggleSidePanelRef={toggleSidePanelRef}
/>
{/* Log Views - readonly terminal replays */}
{logViews.map(logView => {
// Get the latest log data from connectionLogs to reflect updates
const latestLog = connectionLogs.find(l => l.id === logView.connectionLogId) || logView.log;
return (
<LogViewWrapper
key={logView.id}
logView={{ ...logView, log: latestLog }}
defaultTerminalTheme={currentTerminalTheme}
defaultFontSize={terminalFontSize}
onClose={() => closeLogView(logView.id)}
onUpdateLog={updateConnectionLog}
/>
);
})}
{/* Editor Tabs — kept mounted for Monaco instance persistence; visibility toggled via CSS */}
{editorTabs.map((tab) => (
<TextEditorTabView
key={tab.id}
tabId={tab.id}
isVisible={activeTabId === toEditorTabId(tab.id)}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
hostById={hostById}
onRequestClose={(id) => handleRequestCloseEditorTabRef.current(id)}
/>
))}
</div>
{/* Global "quick add / edit snippet" dialog, triggered by the
netcatty:snippets:add and :edit window events (from ScriptsSidePanel
"+" button and right-click menu). Delete is handled by a sibling
useEffect above — it does not need a dialog. */}
<QuickAddSnippetDialog
snippets={snippets}
packages={snippetPackages}
onCreateSnippet={(snippet) => updateSnippets([...snippets, snippet])}
onUpdateSnippet={(snippet) =>
updateSnippets(snippets.map((s) => (s.id === snippet.id ? snippet : s)))
}
onCreatePackage={(pkg) =>
updateSnippetPackages(Array.from(new Set([...snippetPackages, pkg])))
}
/>
{/* Root-mounted AddToWorkspaceDialog — triggered by the focus-mode
"+" button (mode='append') or QuickSwitcher's "New Workspace"
button (mode='create'). Single instance so dialog state and
styling stay consistent across entry points. */}
{addToWorkspaceDialog && (
<AddToWorkspaceDialog
open
onOpenChange={(open) => { if (!open) setAddToWorkspaceDialog(null); }}
// Filter serial hosts only in append mode — appendHostToWorkspace
// has no serial code path. Create mode goes through
// createWorkspaceFromTargets, which builds a SerialConfig-backed
// session for serial hosts, so those should remain pickable.
hosts={addToWorkspaceDialog.mode === 'append'
? hosts.filter((h) => h.protocol !== 'serial')
: hosts}
workspaceTitle={
addToWorkspaceDialog.mode === 'append'
? workspaces.find((w) => w.id === addToWorkspaceDialog.workspaceId)?.title
: 'New Workspace'
}
onAdd={(targets) => {
if (addToWorkspaceDialog.mode === 'append') {
// Match the workspace root's current split direction so
// the new panes peer the existing siblings instead of
// wrapping the whole tree into one side of a fresh split
// (which would happen if we always passed the helper's
// default 'vertical').
const ws = workspaces.find((w) => w.id === addToWorkspaceDialog.workspaceId);
const rootDir = ws && ws.root.type === 'split' ? ws.root.direction : 'vertical';
for (const target of targets) {
if (target.kind === 'local') {
appendLocalTerminalToWorkspace(addToWorkspaceDialog.workspaceId, undefined, rootDir);
} else {
appendHostToWorkspace(addToWorkspaceDialog.workspaceId, target.host, rootDir);
}
}
} else {
createWorkspaceFromTargets(targets);
}
}}
/>
)}
{isQuickSwitcherOpen && (
<Suspense fallback={null}>
<LazyQuickSwitcher
isOpen={isQuickSwitcherOpen}
query={quickSearch}
results={quickResults}
sessions={sessions}
workspaces={workspaces}
showSftpTab={settings.showSftpTab}
onQueryChange={setQuickSearch}
onSelect={handleHostConnectWithProtocolCheck}
onSelectTab={(tabId) => {
setActiveTabId(tabId);
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}}
onCreateLocalTerminal={(shell) => {
handleCreateLocalTerminal(shell);
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}}
onCreateWorkspace={() => {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
setAddToWorkspaceDialog({ mode: 'create' });
}}
onClose={() => {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}}
keyBindings={keyBindings}
/>
</Suspense>
)}
<Dialog open={!!sessionRenameTarget} onOpenChange={(open) => {
if (!open) {
resetSessionRename();
}
}}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t('dialog.renameSession.title')}</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<Label htmlFor="session-name">{t('field.name')}</Label>
<Input
id="session-name"
value={sessionRenameValue}
onChange={(e) => setSessionRenameValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submitSessionRename(); }}
autoFocus
placeholder={t('placeholder.sessionName')}
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={resetSessionRename}>{t('common.cancel')}</Button>
<Button onClick={submitSessionRename} disabled={!sessionRenameValue.trim()}>{t('common.save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={!!workspaceRenameTarget} onOpenChange={(open) => {
if (!open) {
resetWorkspaceRename();
}
}}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t('dialog.renameWorkspace.title')}</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<Label htmlFor="workspace-name">{t('field.name')}</Label>
<Input
id="workspace-name"
value={workspaceRenameValue}
onChange={(e) => setWorkspaceRenameValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submitWorkspaceRename(); }}
autoFocus
placeholder={t('placeholder.workspaceName')}
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={resetWorkspaceRename}>{t('common.cancel')}</Button>
<Button onClick={submitWorkspaceRename} disabled={!workspaceRenameValue.trim()}>{t('common.save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{isCreateWorkspaceOpen && (
<Suspense fallback={null}>
<LazyCreateWorkspaceDialog
isOpen={isCreateWorkspaceOpen}
onClose={() => setIsCreateWorkspaceOpen(false)}
hosts={hosts}
onCreate={createWorkspaceWithHosts}
/>
</Suspense>
)}
{/* Protocol Select Dialog for QuickSwitcher */}
{protocolSelectHost && (
<Suspense fallback={null}>
<LazyProtocolSelectDialog
host={protocolSelectHost}
onSelect={handleProtocolSelect}
onCancel={() => setProtocolSelectHost(null)}
/>
</Suspense>
)}
{/* Global Keyboard-Interactive Authentication Modal (2FA/MFA) - processes queue */}
<KeyboardInteractiveModal
request={keyboardInteractiveQueue[0] || null}
onSubmit={handleKeyboardInteractiveSubmit}
onCancel={handleKeyboardInteractiveCancel}
/>
{/* Indicator when more 2FA requests are pending */}
{keyboardInteractiveQueue.length > 1 && (
<div className="fixed bottom-4 right-4 z-50 bg-muted/90 backdrop-blur-sm text-sm px-3 py-1.5 rounded-full border shadow-sm">
{keyboardInteractiveQueue.length - 1} more pending
</div>
)}
{/* Global Passphrase Modal for encrypted SSH keys */}
<PassphraseModal
request={passphraseQueue[0] || null}
onSubmit={handlePassphraseSubmit}
onCancel={handlePassphraseCancel}
onSkip={handlePassphraseSkip}
/>
{/* Empty vault vs cloud data confirmation dialog (#679).
This dialog intentionally cannot be dismissed — the user MUST
choose "Restore" or "Keep Empty" before the sync flow can
proceed. hideCloseButton removes the X button, onOpenChange
is a no-op so ESC also does nothing, and onInteractOutside
prevents click-away. */}
<Dialog open={!!emptyVaultConflict} onOpenChange={() => { /* intentionally non-dismissable */ }}>
<DialogContent className="max-w-md" hideCloseButton onInteractOutside={(e) => e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-amber-500" />
{t('sync.autoSync.emptyVaultConflict.title')}
</DialogTitle>
<DialogDescription>
{t('sync.autoSync.emptyVaultConflict.description')}
</DialogDescription>
</DialogHeader>
{emptyVaultConflict && (
<div className="bg-muted/30 rounded-lg p-3 text-sm">
<div className="font-medium text-muted-foreground mb-1">{t('sync.autoSync.emptyVaultConflict.cloudLabel')}</div>
<div>{t('sync.autoSync.emptyVaultConflict.cloudSummary', {
hosts: emptyVaultConflict.hostCount,
keys: emptyVaultConflict.keyCount,
snippets: emptyVaultConflict.snippetCount,
proxyProfiles: emptyVaultConflict.proxyProfileCount,
})}</div>
</div>
)}
<DialogFooter className="flex-col gap-2 sm:flex-col">
<Button
onClick={() => resolveEmptyVaultConflict('restore')}
className="w-full justify-start gap-2"
>
<Download className="w-4 h-4" />
<span>
{t('sync.autoSync.emptyVaultConflict.restore')}
<span className="text-xs opacity-70 ml-1"> {t('sync.autoSync.emptyVaultConflict.restoreDesc')}</span>
</span>
</Button>
<Button
variant="outline"
onClick={() => resolveEmptyVaultConflict('keep-empty')}
className="w-full justify-start gap-2"
>
<Trash2 className="w-4 h-4" />
<span>
{t('sync.autoSync.emptyVaultConflict.keepEmpty')}
<span className="text-xs opacity-70 ml-1"> {t('sync.autoSync.emptyVaultConflict.keepEmptyDesc')}</span>
</span>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}}
</UnsavedChangesProvider>
</SnippetExecutionProvider>
);
}

View File

@@ -0,0 +1,176 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useEffect, useRef } from 'react';
import { usePortForwardingAutoStart } from '../state/usePortForwardingAutoStart';
import { editorTabStore } from '../state/editorTabStore';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { toast } from '../../components/ui/toast';
type StartupEffectsContext = Record<string, any>;
export function useAppStartupEffects(ctx: StartupEffectsContext) {
const {dismissUpdate, groupConfigs, hosts, identities,
installUpdate, isVaultInitialized, keys, openSettingsWindow, portForwardingRules, proxyProfiles, sessions, setKeyboardInteractiveQueue,
t, terminalSettings, updateState, workspaces,
} = ctx;
// Show toast notification when update is available (only when auto-download is idle)
useEffect(() => {
// Skip "update available" toast if auto-download has already started or completed
if (updateState.autoDownloadStatus !== 'idle') return;
// Don't show automatic notification when auto-update is disabled
if (localStorageAdapter.readString('netcatty_auto_update_enabled_v1') === 'false') return;
if (updateState.hasUpdate && updateState.latestRelease) {
const version = updateState.latestRelease.version;
toast.info(
t('update.available.message', { version }),
{
title: t('update.available.title'),
duration: 8000, // Show longer for update notifications
onClick: () => {
void openSettingsWindow();
// Dismiss the update so the toast doesn't re-fire on every render.
// On unsupported platforms (where autoDownloadStatus stays 'idle')
// this is the only way to suppress the notification for this version.
// On supported platforms this toast only shows before auto-download
// starts, and the Settings window's own useUpdateCheck will pick up
// the download state via IPC events independently of the dismiss.
dismissUpdate();
},
actionLabel: t('update.viewInSettings'),
}
);
}
}, [updateState.hasUpdate, updateState.latestRelease, updateState.autoDownloadStatus, t, openSettingsWindow, dismissUpdate]);
// Track previous autoDownloadStatus so toast effects fire only on actual transitions,
// not when unrelated deps (installUpdate, openSettingsWindow) change their reference.
const prevAutoDownloadStatusRef = useRef(updateState.autoDownloadStatus);
useEffect(() => {
const prev = prevAutoDownloadStatusRef.current;
prevAutoDownloadStatusRef.current = updateState.autoDownloadStatus;
if (prev === updateState.autoDownloadStatus) return;
if (updateState.autoDownloadStatus === 'ready') {
const version = updateState.latestRelease?.version ?? '';
toast.info(
t('update.readyToInstall.message', { version }),
{
title: t('update.readyToInstall.title'),
duration: 0,
actionLabel: t('update.restartNow'),
onClick: () => installUpdate(),
}
);
} else if (updateState.autoDownloadStatus === 'error') {
toast.error(
t('update.downloadFailed.message'),
{
title: t('update.downloadFailed.title'),
actionLabel: t('update.viewInSettings'),
onClick: () => void openSettingsWindow(),
}
);
}
}, [updateState.autoDownloadStatus, updateState.latestRelease?.version, t, installUpdate, openSettingsWindow]);
// Auto-start port forwarding rules on app launch
usePortForwardingAutoStart({
isVaultInitialized,
hosts,
keys,
identities,
proxyProfiles,
groupConfigs,
terminalSettings,
});
// Sync tray menu data + handle tray actions
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.updateTrayMenuData) return;
let cancelled = false;
const timer = setTimeout(() => {
if (cancelled) return;
const sessionsForTray = sessions.map((s) => {
const ws = s.workspaceId ? workspaces.find((w) => w.id === s.workspaceId) : undefined;
return {
id: s.id,
label: s.hostname,
hostLabel: s.hostLabel,
status: s.status,
workspaceId: s.workspaceId,
workspaceTitle: ws?.title,
};
});
void bridge.updateTrayMenuData({
sessions: sessionsForTray,
portForwardRules: portForwardingRules,
});
}, 250);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [sessions, portForwardingRules, workspaces]);
// Quit guard: block app exit while any editor tab has unsaved changes.
// Main process sends "app:query-dirty-editors"; we respond with the result.
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onCheckDirtyEditors) return;
const unsub = bridge.onCheckDirtyEditors(() => {
// Always report SOMETHING so the main process doesn't time out for
// 5 s on an unhandled exception. If we can't determine the state,
// fail open — losing unsaved work is bad, but stranding the user
// on a slow quit and then quitting anyway after the timeout is
// exactly the same outcome.
let hasDirty = false;
try {
hasDirty = editorTabStore.getTabs().some((tab) => tab.content !== tab.baselineContent);
if (hasDirty) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
} catch (err) {
console.error('[App] dirty-editors check failed:', err);
}
try {
bridge.reportDirtyEditorsResult?.(hasDirty);
} catch (err) {
// Reporting itself shouldn't throw, but if the IPC bridge is in a
// bad state we'd rather log than bubble out of the listener and
// disable the quit guard for the rest of the session.
console.error('[App] reportDirtyEditorsResult failed:', err);
}
});
return unsub;
}, [t]);
// Keyboard-interactive authentication (2FA/MFA) event listener
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onKeyboardInteractive) return;
const unsubscribe = bridge.onKeyboardInteractive((request) => {
console.log('[App] Keyboard-interactive request received:', request);
// Add to queue instead of replacing - supports multiple concurrent sessions
setKeyboardInteractiveQueue(prev => [...prev, {
requestId: request.requestId,
sessionId: request.sessionId,
name: request.name,
instructions: request.instructions,
prompts: request.prompts,
hostname: request.hostname,
savedPassword: request.savedPassword,
}]);
});
return () => {
unsubscribe?.();
};
}, [setKeyboardInteractiveQueue]);
}

View File

@@ -0,0 +1,93 @@
import type { SSHKey } from "../domain/models";
import { isEncryptedCredentialPlaceholder } from "../domain/credentials";
import { STORAGE_KEY_DEFAULT_KEY_PASSPHRASES } from "../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../infrastructure/persistence/localStorageAdapter";
import { encryptField, decryptField } from "../infrastructure/persistence/secureFieldAdapter";
export async function saveDefaultKeyPassphrase(keyPath: string, passphrase: string): Promise<void> {
const store = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? {};
store[keyPath] = await encryptField(passphrase) ?? passphrase;
localStorageAdapter.write(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES, store);
}
export async function loadDefaultKeyPassphrase(keyPath: string): Promise<string | null> {
const store = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES);
const enc = store?.[keyPath];
if (!enc) return null;
const decrypted = await decryptField(enc);
if (!decrypted || isEncryptedCredentialPlaceholder(decrypted)) {
removeDefaultKeyPassphrases([keyPath]);
return null;
}
return decrypted;
}
export function removeDefaultKeyPassphrases(keyPaths: string[]): void {
const store = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES);
if (!store) return;
let changed = false;
for (const keyPath of keyPaths) {
if (keyPath in store) {
delete store[keyPath];
changed = true;
}
}
if (changed) {
localStorageAdapter.write(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES, store);
}
}
export function clearReferenceKeyPassphrases(keys: SSHKey[], keyPaths: string[]): SSHKey[] {
let changed = false;
const updated = keys.map((key) => {
if (key.source === "reference" && key.filePath && keyPaths.includes(key.filePath) && key.passphrase) {
changed = true;
return { ...key, passphrase: undefined, savePassphrase: false };
}
return key;
});
return changed ? updated : keys;
}
export function clearKeyPassphrasesByIds(keys: SSHKey[], keyIds: string[] = []): SSHKey[] {
if (keyIds.length === 0) return keys;
const ids = new Set(keyIds);
let changed = false;
const updated = keys.map((key) => {
if (ids.has(key.id) && key.passphrase) {
changed = true;
return { ...key, passphrase: undefined, savePassphrase: false };
}
return key;
});
return changed ? updated : keys;
}
export function shouldUpdateReferenceKeyPassphrase(key?: SSHKey | null): boolean {
return Boolean(
key &&
(!key.passphrase || isEncryptedCredentialPlaceholder(key.passphrase)),
);
}
export async function rememberKeyPassphrase(args: {
keyPath: string;
passphrase: string;
keys: SSHKey[];
updateKeys: (keys: SSHKey[]) => Promise<unknown> | unknown;
setCurrentKeys?: (keys: SSHKey[]) => void;
}): Promise<void> {
const { keyPath, passphrase, keys, updateKeys, setCurrentKeys } = args;
await saveDefaultKeyPassphrase(keyPath, passphrase);
const refKey = keys.find((key) => key.source === "reference" && key.filePath === keyPath);
if (!refKey) return;
const updated = keys.map((key) =>
key.id === refKey.id
? { ...key, passphrase, savePassphrase: true }
: key
);
setCurrentKeys?.(updated);
await updateKeys(updated);
}

View File

@@ -0,0 +1,30 @@
import test from "node:test";
import assert from "node:assert/strict";
import en from "../locales/en.ts";
import ru from "../locales/ru.ts";
import zhCN from "../locales/zh-CN.ts";
const strategyKeys = [
"cloudSync.strategy.title",
"cloudSync.strategy.desc",
"cloudSync.strategy.smartMerge",
"cloudSync.strategy.smartMergeDesc",
"cloudSync.strategy.preferCloud",
"cloudSync.strategy.preferCloudDesc",
"cloudSync.strategy.preferLocal",
"cloudSync.strategy.preferLocalDesc",
] as const;
test("cloud sync strategy copy exists in every bundled locale", () => {
for (const [locale, messages] of Object.entries({ en, ru, zhCN })) {
for (const key of strategyKeys) {
assert.equal(
typeof messages[key],
"string",
`${locale} is missing ${key}`,
);
assert.notEqual(messages[key], "", `${locale} has empty ${key}`);
}
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,254 @@
import type { Messages } from '../types';
export const enAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Agent Settings',
'ai.title': 'AI',
'ai.description': 'Configure AI providers, agents, and safety settings',
'ai.providers': 'Providers',
'ai.providers.empty': 'No providers configured. Add a provider to get started.',
'ai.providers.add': 'Add Provider',
'ai.providers.active': 'Active',
'ai.providers.apiKeyConfigured': 'API key configured',
'ai.providers.noApiKey': 'No API key',
'ai.providers.configure': 'Configure',
'ai.providers.remove': 'Remove',
'ai.providers.name': 'Display Name',
'ai.providers.name.placeholder': 'e.g. My Provider',
'ai.providers.style': 'Protocol style',
'ai.providers.style.anthropic': 'Anthropic-compatible',
'ai.providers.style.openai': 'OpenAI-compatible',
'ai.providers.style.google': 'Google-compatible',
'ai.providers.style.inherited': 'auto',
'ai.providers.style.help': 'Selects which API format requests use. Override when a third-party endpoint speaks a different dialect than its provider type suggests.',
'ai.providers.icon.change': 'Change icon',
'ai.providers.icon.upload': 'Upload image',
'ai.providers.icon.reset': 'Reset',
'ai.providers.icon.close': 'Close',
'ai.providers.icon.uploadedNote': 'Custom icon (64×64 WebP)',
'ai.providers.icon.errorType': 'Please choose an image file.',
'ai.providers.apiKey': 'API Key',
'ai.providers.apiKey.placeholder': 'Enter API key',
'ai.providers.apiKey.decrypting': 'Decrypting...',
'ai.providers.baseUrl': 'Base URL',
'ai.providers.skipTLSVerify': 'Skip TLS certificate verification (for self-signed certs)',
'ai.providers.defaultModel': 'Default Model',
'ai.providers.defaultModel.placeholder': 'e.g. gpt-4o, claude-sonnet-4-20250514',
'ai.providers.contextWindow': 'Context window',
'ai.providers.contextWindow.placeholder': 'e.g. 128000',
'ai.providers.contextWindow.help': 'Leave blank to use the model list value when available, otherwise Netcatty uses a safe default.',
'ai.providers.contextWindow.error': 'Enter a positive whole number, or leave it blank.',
'ai.providers.refreshModels': 'Refresh models',
'ai.providers.searchModel': 'Search or type model ID...',
'ai.providers.filterModels': 'Filter models...',
'ai.providers.loadingModels': 'Loading models...',
'ai.providers.noMatchingModels': 'No matching models',
'ai.providers.clickToLoadModels': 'Click to load models',
'ai.providers.showingModels': 'Showing first 100 of {count} models. Type to filter.',
'ai.providers.advancedParams': 'Advanced Parameters',
'ai.providers.advancedParams.hint': 'Leave blank to use provider defaults.',
'ai.providers.advancedParams.maxTokens.placeholder': 'e.g. 4096',
'ai.providers.advancedParams.default': 'Provider default',
// AI Codex
'ai.codex': 'Codex',
'ai.codex.title': 'Codex CLI',
'ai.codex.description': 'Connect OpenAI Codex. Sign in with ChatGPT here, or enable an OpenAI-compatible provider API key and custom endpoint in Settings.',
'ai.codex.detecting': 'Detecting...',
'ai.codex.notFound': 'Not found',
'ai.codex.awaitingLogin': 'Awaiting login',
'ai.codex.connectedChatGPT': 'Connected via ChatGPT',
'ai.codex.connectedApiKey': 'Connected via API key',
'ai.codex.connectedCustomConfig': 'Connected via ~/.codex/config.toml',
'ai.codex.customConfigIncomplete': 'Custom config detected (env var missing)',
'ai.codex.customConfigHint': 'Using custom provider "{provider}" configured in ~/.codex/config.toml — no ChatGPT login needed.',
'ai.codex.customConfigMissingEnvKey': 'Warning: {envKey} is not set in your shell environment. Export it (or launch netcatty from a shell that has it) so Codex can authenticate.',
'ai.codex.notConnected': 'Not connected',
'ai.codex.statusUnknown': 'Status unknown',
'ai.codex.path': 'Path:',
'ai.codex.notFoundHint': 'Could not find codex in PATH. Install it or specify the executable path below.',
'ai.codex.customPathPlaceholder': 'e.g. /usr/local/bin/codex',
'ai.codex.check': 'Check',
'ai.codex.openLogin': 'Open Login',
'ai.codex.logout': 'Logout',
'ai.codex.connectChatGPT': 'Connect ChatGPT',
'ai.codex.refreshStatus': 'Refresh Status',
// AI Claude Code
'ai.claude.title': 'Claude Code',
'ai.claude.description': "Anthropic's agentic coding assistant. Requires the system Claude Code CLI.",
'ai.claude.detecting': 'Detecting...',
'ai.claude.detected': 'Detected',
'ai.claude.notFound': 'Not found',
'ai.claude.path': 'Path:',
'ai.claude.notFoundHint': 'Could not find claude in PATH. Install it or specify the executable path below.',
'ai.claude.customPathPlaceholder': 'e.g. /usr/local/bin/claude',
'ai.claude.configSection': 'Authentication & config (optional)',
'ai.claude.configDir': 'Config directory',
'ai.claude.configDir.placeholder': '~/.claude (leave blank for default)',
'ai.claude.configDir.hint': 'Sets CLAUDE_CONFIG_DIR — point at a folder where you have run `claude` login (contains settings.json + credentials).',
'ai.claude.settings': 'Settings file',
'ai.claude.settings.placeholder': '~/team-settings.json (path, or inline {"model":"..."})',
'ai.claude.settings.hint': 'Optional. A settings.json path or inline JSON, passed to the SDK as `settings`. Additive to — and independent of — the config directory above (merged on top, not a replacement).',
'ai.claude.envVars': 'Environment variables',
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
'ai.claude.envVars.hint': 'One KEY=VALUE per line, passed to the Claude agent. Stored locally in plaintext — for API keys / credentials, prefer the config directory above (a `claude` login).',
'ai.claude.check': 'Check',
// AI GitHub Copilot CLI
'ai.copilot.title': 'GitHub Copilot CLI',
'ai.copilot.description': 'Uses the GitHub Copilot CLI. Once detected, it can be selected as an external coding agent.',
'ai.copilot.detecting': 'Detecting...',
'ai.copilot.detected': 'Detected',
'ai.copilot.notFound': 'Not found',
'ai.copilot.path': 'Path:',
'ai.copilot.notFoundHint': 'Could not find copilot in PATH. Install it or specify the executable path below.',
'ai.copilot.customPathPlaceholder': 'e.g. /usr/local/bin/copilot',
'ai.copilot.check': 'Check',
// AI Default Agent
'ai.defaultAgent': 'Default Agent',
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
'ai.defaultAgent.catty': 'Catty (Built-in)',
'ai.toolAccess.title': 'Tool Access',
'ai.toolAccess.mode': 'Netcatty Access Mode',
'ai.toolAccess.description': 'Choose how external agents access Netcatty sessions. MCP exposes the built-in server, while Skills + CLI points agents to the local Netcatty skill and CLI commands.',
'ai.toolAccess.mode.mcp': 'MCP',
'ai.toolAccess.mode.skills': 'Skills + CLI',
'ai.userSkills.title': 'User Skills',
'ai.userSkills.description': 'Open the Netcatty skills folder to add your own skill directories. Netcatty scans these skills automatically and injects only lightweight indexes unless a skill clearly matches the current request.',
'ai.userSkills.openFolder': 'Open Skills Folder',
'ai.userSkills.reload': 'Reload Skills',
'ai.userSkills.location': 'Location',
'ai.userSkills.loading': 'Scanning user skills...',
'ai.userSkills.summary': '{ready} ready, {warnings} warnings',
'ai.userSkills.empty': 'No user skills found yet. Open the folder to add skill directories with a SKILL.md file.',
'ai.userSkills.unavailable': 'User skills are unavailable in this environment.',
'ai.userSkills.status.ready': 'Ready',
'ai.userSkills.status.warning': 'Warning',
// AI Chat
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
'ai.chat.toolDenied': 'Action was rejected by the user.',
'ai.chat.toolApproved': 'Approved',
'ai.chat.toolApprovalHint': 'Press Enter to approve, Escape to reject',
'ai.chat.approve': 'Approve',
'ai.chat.reject': 'Reject',
'ai.chat.toolLabel': 'Tool',
'ai.chat.targetLabel': 'Target',
'ai.chat.permissionRequired': 'Permission Required',
'ai.chat.permissionDescription': 'The AI agent wants to execute a tool call that requires your approval.',
'ai.chat.commandBlocked': 'This command is blocked by your security policy and cannot be executed.',
'ai.chat.recommendAllow': 'Allow',
'ai.chat.recommendConfirm': 'Confirm',
'ai.chat.recommendDeny': 'Deny',
'ai.chat.exportConversation': 'Export conversation',
'ai.chat.exportAs': 'Export As',
'ai.chat.exportMarkdown': 'Markdown',
'ai.chat.exportJSON': 'JSON',
'ai.chat.exportPlainText': 'Plain Text',
'ai.chat.thinking': 'Thinking',
'ai.chat.thoughtFor': 'Thought for {duration}',
'ai.chat.thought': 'Thought',
'ai.chat.agents': 'Agents',
'ai.chat.detectedOnMachine': 'Detected on this machine',
'ai.chat.rescan': 'Re-scan',
'ai.chat.permObserver': 'Observer',
'ai.chat.permConfirm': 'Confirm',
'ai.chat.permAuto': 'Auto',
'ai.chat.permObserverDesc': 'Read only',
'ai.chat.permConfirmDesc': 'Ask before actions',
'ai.chat.permAutoDesc': 'Execute freely',
'ai.chat.emptyHint': 'Ask about your servers, run commands, or get help with configurations.',
'ai.chat.placeholder': 'Message {agent} — @ to include context, / for commands',
'ai.chat.placeholderDefault': 'Message Catty Agent...',
'ai.chat.noModel': 'No model',
'ai.chat.noProviderModel': 'No default model — set one in Settings → AI → Providers.',
'ai.chat.selectProvider': 'Select provider',
'ai.chat.recent': 'Recent',
'ai.chat.viewAll': 'View All',
'ai.chat.untitled': 'Untitled',
'ai.chat.justNow': 'Just now',
'ai.chat.minutesAgo': '{n}m ago',
'ai.chat.hoursAgo': '{n}h ago',
'ai.chat.daysAgo': '{n}d ago',
'ai.chat.newChat': 'New Chat',
'ai.chat.allSessions': 'All Sessions',
'ai.chat.noSessions': 'No previous sessions',
'ai.chat.retryHint': 'You can retry by sending your message again.',
'ai.chat.approvalTimeout': 'Tool approval timed out after 5 minutes. You can retry by sending your message again.',
'ai.chat.menuHosts': 'Hosts',
'ai.chat.menuContext': 'Context',
'ai.chat.menuFiles': 'Files',
'ai.chat.menuImage': 'Image',
'ai.chat.menuMentionHost': 'Mention Host',
'ai.chat.menuUserSkills': 'User Skills',
// AI Error
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
// AI Web Search
'ai.webSearch.title': 'Web Search',
'ai.webSearch.enable': 'Enable Web Search',
'ai.webSearch.enable.description': 'Allow the AI agent to search the web for current information.',
'ai.webSearch.provider': 'Search Provider',
'ai.webSearch.provider.description': 'Choose a web search API provider.',
'ai.webSearch.apiKey': 'API Key',
'ai.webSearch.apiKey.description': 'API key for the selected search provider.',
'ai.webSearch.apiKey.placeholder': 'Enter API key...',
'ai.webSearch.apiHost': 'API Host',
'ai.webSearch.apiHost.description': 'Custom API endpoint. Leave default unless you use a proxy.',
'ai.webSearch.apiHost.searxngDescription': 'URL of your SearXNG instance (required).',
'ai.webSearch.maxResults': 'Max Results',
'ai.webSearch.maxResults.description': 'Maximum number of search results to return (1-20).',
// AI Safety Settings
'ai.safety.title': 'Safety',
'ai.safety.permissionMode': 'Permission Mode',
'ai.safety.permissionMode.description': 'Controls how the AI interacts with your Netcatty terminal sessions. Observer mode blocks write operations that go through Netcatty. External agent CLIs may still have their own local tools and approval flow.',
'ai.safety.permissionMode.observer': 'Observer - Read only, no actions',
'ai.safety.permissionMode.confirm': 'Confirm - Ask before actions',
'ai.safety.permissionMode.autonomous': 'Autonomous - Execute freely',
'ai.safety.commandTimeout': 'Command Timeout',
'ai.safety.commandTimeout.description': 'Maximum seconds a command can run before being terminated through Netcatty execution.',
'ai.safety.commandTimeout.unit': 'sec',
'ai.safety.maxIterations': 'Max Iterations',
'ai.safety.maxIterations.description': 'Maximum number of AI tool-use loops to prevent runaway execution. External agents may have their own internal iteration limits that take precedence.',
'ai.safety.blocklist': 'Command Blocklist',
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands executed through Netcatty.',
'ai.safety.blocklist.placeholder': 'Regex pattern...',
'ai.safety.blocklist.reset': 'Reset to defaults',
'ai.safety.blocklist.add': 'Add pattern',
'ai.safety.note': 'These safety settings are enforced for actions that go through Netcatty. External agent CLIs may also expose local tools that are governed by the agent itself.',
// Unified tooltips for terminal workspace and top tabs (issue #954)
'terminal.layer.addTerminal': 'Add Terminal',
'terminal.layer.switchToSplitView': 'Switch to Split View',
'terminal.layer.sftp': 'SFTP',
'terminal.layer.scripts': 'Scripts',
'terminal.layer.theme': 'Theme',
'terminal.layer.aiChat': 'AI Chat',
'terminal.layer.movePanelLeft': 'Move panel to left',
'terminal.layer.movePanelRight': 'Move panel to right',
'terminal.layer.closePanel': 'Close panel',
'topTabs.openQuickSwitcher': 'Open quick switcher',
'topTabs.moreTabs': 'More tabs',
'topTabs.aiAssistant': 'AI Assistant',
'topTabs.toggleTheme': 'Toggle theme',
'topTabs.openSettings': 'Open Settings',
'ai.chat.sessionHistory': 'Session history',
'ai.chat.attach': 'Attach',
'ai.chat.collapse': 'Collapse',
'ai.chat.expand': 'Expand',
'ai.chat.enableAgent': 'Enable {name}',
'zmodem.waitingForRemote': 'Waiting for remote...',
'zmodem.uploading': 'Uploading',
'zmodem.downloading': 'Downloading',
'zmodem.cancelTransfer': 'Cancel transfer (Ctrl+C)',
'zmodem.overwrite.title': 'Remote file already exists',
'zmodem.overwrite.applyToRest': 'Apply to remaining conflicts',
'zmodem.overwrite.overwrite': 'Overwrite',
'zmodem.overwrite.skip': 'Skip',
'zmodem.overwrite.cancel': 'Cancel',
'settings.shortcuts.resetToDefault': 'Reset to default',
};

View File

@@ -0,0 +1,667 @@
import type { Messages } from '../types';
export const enCoreMessages: Messages = {
// Common
'common.save': 'Save',
'common.cancel': 'Cancel',
'common.close': 'Close',
'common.reset': 'Reset',
'common.zoomIn': 'Zoom in',
'common.zoomOut': 'Zoom out',
'common.settings': 'Settings',
'common.search': 'Search',
'common.searchPlaceholder': 'Search...',
'common.connect': 'Connect',
'common.terminal': 'Terminal',
'common.create': 'Create',
'common.import': 'Import',
'common.generate': 'Generate',
'common.delete': 'Delete',
'common.edit': 'Edit',
'common.clear': 'Clear',
'common.optional': 'Optional',
'common.selectPlaceholder': 'Select...',
'common.add': 'Add',
'common.rename': 'Rename',
'common.refresh': 'Refresh',
'common.continue': 'Continue',
'common.enabled': 'Enabled',
'common.disabled': 'Disabled',
'common.error': 'Error',
'common.validation': 'Validation',
'common.unknownError': 'Unknown error',
'common.noResultsFound': 'No results found',
'common.back': 'Back',
'common.apply': 'Apply',
'common.use': 'Use',
'common.useGlobal': 'Use global',
'common.saveChanges': 'Save Changes',
'common.advanced': 'Advanced',
'common.left': 'Left',
'common.right': 'Right',
'common.more': 'More',
'common.selectAHost': 'Select a host',
'common.selectAHostPlaceholder': 'Select a host...',
'sort.az': 'A-z',
'sort.za': 'Z-a',
'sort.newest': 'Newest to oldest',
'sort.oldest': 'Oldest to newest',
'sort.group': 'By group',
'field.label': 'Label',
'field.type': 'Type',
'auth.keyType': 'Type {type}',
'auth.showAllKeys': 'Show all keys',
// Dialogs / prompts
'confirm.deleteHost': 'Delete Host "{name}"?',
'confirm.deleteIdentity': 'Delete Identity "{name}"?',
'confirm.removeProvider': 'Remove provider "{name}"?',
'confirm.closeBusyTerminal.title': 'Confirm close',
'confirm.closeBusyTerminal.message': 'Process "{command}" is still running and will be terminated.',
'confirm.closeBusyTerminal.messageWithMore': 'Process "{command}" and {count} other running process(es) will be terminated.',
'confirm.closeBusyTerminal.cancel': 'Cancel',
'confirm.closeBusyTerminal.close': 'Close',
'dialog.createWorkspace.title': 'Create Workspace',
'dialog.renameWorkspace.title': 'Rename workspace',
'dialog.renameSession.title': 'Rename session',
'field.name': 'Name',
'field.selectHosts': 'Select Hosts',
'placeholder.workspaceName': 'Workspace name',
'placeholder.sessionName': 'Session name',
'placeholder.searchHosts': 'Search hosts...',
'toast.settingsUnavailable': 'Settings window is unavailable on this platform.',
'credentials.protectionUnavailable.title': 'Credential Protection Unavailable',
'credentials.protectionUnavailable.message': 'Saved passwords and keys cannot be auto-decrypted on this device. Re-enter credentials before connecting.',
'credentials.protectionUnavailable.action': 'Open Settings',
// Settings shell
'settings.title': 'Settings',
'settings.tab.application': 'Application',
'settings.tab.appearance': 'Appearance',
'settings.tab.terminal': 'Terminal',
'settings.tab.shortcuts': 'Shortcuts',
'settings.tab.syncCloud': 'Sync & Cloud',
'settings.tab.system': 'System',
// Settings > System
'settings.system.title': 'System',
'settings.system.description': 'System information and temporary file management.',
'settings.system.tempDirectory': 'Temporary Files',
'settings.system.location': 'Location',
'settings.system.fileCount': 'Files',
'settings.system.totalSize': 'Size',
'settings.system.openFolder': 'Open folder',
'settings.system.refresh': 'Refresh',
'settings.system.clearTempFiles': 'Clear temp files',
'settings.system.clearing': 'Clearing...',
'settings.system.clearResult': 'Deleted {deleted} file(s), {failed} failed.',
'settings.system.tempDirectoryHint': 'Temporary files are created when opening remote files with external applications. They are automatically cleaned up when SFTP sessions close.',
'settings.system.credentials.title': 'Credential Protection',
'settings.system.credentials.status': 'Status',
'settings.system.credentials.checking': 'Checking...',
'settings.system.credentials.available': 'Available (OS keychain ready)',
'settings.system.credentials.unavailable': 'Unavailable (cannot decrypt saved credentials)',
'settings.system.credentials.unknown': 'Unknown (not supported in this environment)',
'settings.system.credentials.unavailableHint': 'Credentials encrypted on another user profile or machine cannot be decrypted here. Re-enter and save credentials on this device.',
'settings.system.credentials.portabilityHint': 'Cloud Sync is portable because it uses your master key encryption. Local safeStorage encryption is device/user scoped.',
// Settings > System > Crash Logs
'settings.system.crashLogs.title': 'Crash Logs',
'settings.system.crashLogs.description': 'View error logs from the main process to help diagnose unexpected behavior.',
'settings.system.crashLogs.noLogs': 'No crash logs found.',
'settings.system.crashLogs.entries': '{count} entries',
'settings.system.crashLogs.clear': 'Clear all logs',
'settings.system.crashLogs.cleared': 'Cleared {count} log file(s).',
'settings.system.crashLogs.source': 'Source',
'settings.system.crashLogs.time': 'Time',
'settings.system.crashLogs.message': 'Message',
'settings.system.crashLogs.stack': 'Stack Trace',
'settings.system.crashLogs.hint': 'Crash logs are retained for 30 days and automatically rotated.',
'settings.system.crashLogs.collapse': 'Collapse',
'settings.system.crashLogs.expand': 'Show details',
// Settings > System > Software Update
'settings.update.title': 'Software Update',
'settings.update.currentVersion': 'Current version',
'settings.update.checkForUpdates': 'Check for Updates',
'settings.update.checking': 'Checking...',
'settings.update.upToDate': 'You are using the latest version.',
'settings.update.available': 'New version {version} is available.',
'settings.update.download': 'Download Update',
'settings.update.downloading': 'Downloading... {percent}%',
'settings.update.readyToInstall': 'Update downloaded and ready to install.',
'settings.update.restartNow': 'Restart to Update',
'settings.update.error': 'Failed to check for updates.',
'settings.update.downloadError': 'Download failed.',
'settings.update.manualDownload': 'Download from GitHub',
'settings.update.manualDownloadHint': 'Auto-update is not available on this platform. Download the latest version from GitHub.',
'settings.update.hint': 'Netcatty checks for updates from GitHub Releases.',
'settings.update.lastCheckedJustNow': 'just now',
'settings.update.lastCheckedMinutesAgo': '{n} min ago',
'settings.update.lastCheckedHoursAgo': '{n} hr ago',
'settings.update.lastCheckedPrefix': 'Last checked: ',
'settings.update.autoUpdateEnabled': 'Automatic Updates',
'settings.update.autoUpdateEnabledDesc': 'Automatically check and download updates when available.',
// Settings > Session Logs
'settings.sessionLogs.title': 'Session Logs',
'settings.sessionLogs.description': 'Configure session log export and auto-save settings.',
'settings.sessionLogs.autoSave': 'Auto-Save',
'settings.sessionLogs.enableAutoSave': 'Enable auto-save',
'settings.sessionLogs.enableAutoSaveDesc': 'Automatically save session logs when terminal sessions end.',
'settings.sessionLogs.directory': 'Save Directory',
'settings.sessionLogs.noDirectory': 'No directory selected',
'settings.sessionLogs.browse': 'Browse',
'settings.sessionLogs.openFolder': 'Open folder',
'settings.sessionLogs.directoryHint': 'Logs will be organized by host in subdirectories.',
'settings.sessionLogs.format': 'Log Format',
'settings.sessionLogs.formatDesc': 'Choose the format for saved log files.',
'settings.sessionLogs.formatTxt': 'Plain Text (.txt)',
'settings.sessionLogs.formatRaw': 'Raw with ANSI (.log)',
'settings.sessionLogs.formatHtml': 'HTML (.html)',
'settings.sessionLogs.hint': 'Session logs capture all terminal output for troubleshooting and auditing purposes.',
// Settings > SSH Debug Logs
'settings.sshDebugLogs.title': 'SSH Debug Logs',
'settings.sshDebugLogs.enable': 'Enable SSH debug logs',
'settings.sshDebugLogs.enableDesc': 'Record connection, auth, handshake, disconnect, and error reasons without saving terminal output.',
'settings.sshDebugLogs.location': 'Log Location',
'settings.sshDebugLogs.status': 'Status',
'settings.sshDebugLogs.statusOn': 'On',
'settings.sshDebugLogs.statusOff': 'Off',
'settings.sshDebugLogs.size': 'Size',
'settings.sshDebugLogs.hint': 'When enabled, newly started SSH connections write diagnostic events for bastion, auth, and unexpected disconnect troubleshooting.',
// Settings > Global Hotkey (Quake Mode)
'settings.globalHotkey.title': 'Global Hotkey',
'settings.globalHotkey.toggleWindow': 'Toggle Window',
'settings.globalHotkey.toggleWindowDesc': 'Press a key combination to set a global shortcut for showing/hiding the window.',
'settings.globalHotkey.notSet': 'Not set',
'settings.globalHotkey.reset': 'Reset to default',
'settings.globalHotkey.closeToTray': 'Close to System Tray',
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window will minimize to the system tray instead of quitting.',
'settings.globalHotkey.enabled': 'Enable Global Hotkey',
'settings.globalHotkey.enabledDesc': 'Register system-wide keyboard shortcuts. When disabled, all global hotkeys are unregistered.',
'settings.globalHotkey.hint': 'Global hotkey works system-wide to quickly show or hide the window (Quake-style terminal).',
// Tray Panel
'tray.openMainWindow': 'Open Main Window',
'tray.sessions': 'Sessions',
'tray.portForwarding': 'Port Forwarding',
'tray.status.connected': 'Connected',
'tray.status.connecting': 'Connecting',
'tray.status.disconnected': 'Disconnected',
'tray.status.active': 'Active',
'tray.status.inactive': 'Inactive',
'tray.status.error': 'Error',
'tray.recentHosts': 'Recent Hosts',
'tray.empty.title': 'Nothing here yet',
'tray.empty.subtitle': 'Go connect to a server, they miss you 🚀',
'tray.quit': 'Quit Netcatty',
// Vault Sidebar
'vault.sidebar.collapse': 'Collapse sidebar',
'vault.sidebar.expand': 'Expand sidebar',
'vault.sidebar.resize': 'Resize sidebar',
// Settings > Application
'settings.application.checkUpdates': 'Check for updates',
'settings.application.reportProblem': 'Report a problem',
'settings.application.reportProblem.subtitle': 'Generate a pre-filled GitHub issue',
'settings.application.community': 'Community',
'settings.application.community.subtitle': 'On GitHub Discussions',
'settings.application.github': 'GitHub',
'settings.application.github.subtitle': 'Source code',
'settings.application.whatsNew': "What's new",
'settings.application.whatsNew.subtitle': 'Show release notes',
'settings.application.openExternal.failedTitle': 'Cannot open link',
'settings.application.openExternal.failedBody': 'The link could not be opened in either the system browser or the built-in browser window.',
'settings.vault.title': 'Vault',
'settings.vault.showRecentHosts': 'Show recently connected hosts',
'settings.vault.showRecentHostsDesc': 'Display a section of recently connected hosts at the top of the vault',
'settings.vault.showOnlyUngroupedHostsInRoot': 'Only show ungrouped hosts at root',
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'When enabled, the root host list only shows hosts without a group. Open a group from the sidebar to see grouped hosts.',
'settings.vault.showSftpTab': 'Show SFTP tab',
'settings.vault.showSftpTabDesc': 'Display the standalone SFTP view in the top tab bar. When hidden, use the in-session SFTP side panel instead.',
// Update notifications
'update.available.title': 'Update Available',
'update.available.message': 'A new version {version} is available. Click to download.',
'update.checking': 'Checking for updates...',
'update.upToDate.title': 'Up to Date',
'update.upToDate.message': 'You are running the latest version ({version}).',
'update.error': 'Failed to check for updates',
'update.downloadNow': 'Download Now',
'update.viewInSettings': 'View in Settings',
'update.readyToInstall.title': 'Update Ready',
'update.readyToInstall.message': 'Version {version} downloaded and ready to install.',
'update.restartNow': 'Restart Now',
'update.downloadFailed.title': 'Update Failed',
'update.downloadFailed.message': 'Failed to download update. You can download it manually.',
'update.needsSave.title': 'Unsaved Changes',
'update.needsSave.message': 'Save your open editors first, then click Restart Now again to install the update.',
'update.openReleases': 'Open Releases',
'update.remindLater': 'Remind Later',
'update.skipVersion': 'Skip This Version',
// Settings > Appearance
'settings.appearance.uiTheme': 'UI Theme',
'settings.appearance.theme': 'Theme',
'settings.appearance.theme.desc': 'Choose light, dark, or follow system preference',
'settings.appearance.theme.light': 'Light',
'settings.appearance.theme.dark': 'Dark',
'settings.appearance.theme.system': 'System',
'settings.appearance.accentColor': 'Accent Color',
'settings.appearance.customColor': 'Custom color',
'settings.appearance.accentColor.mode': 'Use custom accent',
'settings.appearance.accentColor.mode.desc': 'Override the theme accent color',
'settings.appearance.accentColor.custom': 'Custom accent',
'settings.appearance.themeColor': 'Theme Color',
'settings.appearance.themeColor.desc': 'Pick a preset palette for each theme',
'settings.appearance.themeColor.light': 'Light palette',
'settings.appearance.themeColor.dark': 'Dark palette',
'settings.appearance.customCss': 'Custom CSS',
'settings.appearance.customCss.desc':
'Add custom CSS to personalize the app appearance. Changes apply immediately. Major UI regions expose a [data-section="..."] attribute you can target — e.g. snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar, top-tabs.',
'settings.appearance.customCss.placeholder':
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Make snippet sidebar text larger */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* Custom terminal background */\n.terminal { background: #1a1a2e !important; }\n\n/* Tweak global border radius */\n:root { --radius: 0.25rem; }',
'settings.appearance.language': 'Language',
'settings.appearance.language.desc': 'Choose the UI language',
'settings.appearance.uiFont': 'Interface Font',
'settings.appearance.uiFont.desc': 'Choose the font for the application interface',
// Settings > Terminal
'settings.terminal.section.theme': 'Terminal Theme',
'settings.terminal.themeModal.title': 'Select Theme',
'settings.terminal.themeModal.darkThemes': 'Dark Themes',
'settings.terminal.themeModal.lightThemes': 'Light Themes',
'settings.terminal.theme.selectButton': 'Select Theme',
'settings.terminal.theme.followApp': 'Follow Application Theme',
'settings.terminal.theme.followApp.desc': 'Automatically match the terminal background to the current app theme for a seamless look.',
'settings.terminal.theme.darkTheme': 'Dark mode terminal theme',
'settings.terminal.theme.lightTheme': 'Light mode terminal theme',
'settings.terminal.theme.auto': 'Auto (match app theme)',
'settings.terminal.theme.autoDesc': 'Follows the active UI theme preset',
'settings.terminal.section.font': 'Font',
'settings.terminal.section.cursor': 'Cursor',
'settings.terminal.section.keyboard': 'Keyboard',
'settings.terminal.section.accessibility': 'Accessibility',
'settings.terminal.section.behavior': 'Behavior',
'settings.terminal.section.scrollback': 'Scrollback',
'settings.terminal.section.keywordHighlight': 'Keyword highlighting',
'settings.terminal.font.family': 'Font',
'settings.terminal.font.family.desc': 'Terminal font family',
'settings.terminal.font.cjk': 'CJK font',
'settings.terminal.font.cjk.desc': 'Font used for Chinese / Japanese / Korean characters; "Auto" picks one based on the primary font',
'settings.terminal.font.cjk.option.auto': 'Auto · paired with the primary font',
'settings.terminal.font.cjk.option.sarasaSC': 'Sarasa Mono SC (Iosevka + Source Han SC)',
'settings.terminal.font.cjk.option.sarasaTC': 'Sarasa Mono TC (Iosevka + Source Han TC)',
'settings.terminal.font.cjk.option.mapleCN': 'Maple Mono CN',
'settings.terminal.font.cjk.option.sourceHan': 'Source Han Mono SC',
'settings.terminal.font.cjk.option.notoCJK': 'Noto Sans Mono CJK SC',
'settings.terminal.font.cjk.option.lxgwWenkai': 'LXGW WenKai Mono',
'settings.terminal.font.cjk.option.simSun': 'SimSun',
'settings.terminal.font.cjk.option.legacy': '{font} · not recommended (proportional font)',
'settings.terminal.font.size': 'Font size',
'settings.terminal.font.size.desc': 'Terminal text size',
'settings.terminal.font.weight': 'Font weight',
'settings.terminal.font.weight.desc': 'Weight for regular text (100-900)',
'settings.terminal.font.weightBold': 'Bold font weight',
'settings.terminal.font.weightBold.desc': 'Weight for bold text (100-900)',
'settings.terminal.font.linePadding': 'Line padding',
'settings.terminal.font.linePadding.desc': 'Additional space between lines (0-10)',
'settings.terminal.font.emulationType': 'Terminal emulation type',
'settings.terminal.cursor.style': 'Cursor style',
'settings.terminal.cursor.style.block': 'Block',
'settings.terminal.cursor.style.bar': 'Bar',
'settings.terminal.cursor.style.underline': 'Underline',
'settings.terminal.cursor.blink': 'Cursor blink',
'settings.terminal.keyboard.altAsMeta': 'Use Option as Meta key',
'settings.terminal.keyboard.altAsMeta.desc':
'Use Option (Alt) as the Meta key instead of for special characters',
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ jumps by word',
'settings.terminal.keyboard.optionArrowWordJump.desc':
'Send Meta-b / Meta-f on Option+Left/Right so the shell moves by word, instead of the default ^[[1;3D / ^[[1;3C',
'settings.terminal.accessibility.minimumContrastRatio': 'Minimum contrast ratio',
'settings.terminal.accessibility.minimumContrastRatio.desc':
'Adjust colors to meet contrast requirements (1 = disabled, 21 = max)',
'settings.terminal.behavior.rightClick': 'Right-click behavior',
'settings.terminal.behavior.rightClick.desc': 'Action when right-clicking in terminal',
'settings.terminal.behavior.rightClick.menu': 'Show menu',
'settings.terminal.behavior.rightClick.paste': 'Paste',
'settings.terminal.behavior.rightClick.selectWord': 'Select word',
'settings.terminal.behavior.copyOnSelect': 'Copy on select',
'settings.terminal.behavior.copyOnSelect.desc': 'Automatically copy selected text. In tmux/vim with mouse mode, hold Option on macOS or Shift on Windows/Linux to select',
'settings.terminal.behavior.middleClickPaste': 'Middle-click paste',
'settings.terminal.behavior.middleClickPaste.desc':
'Paste clipboard content on middle-click',
'settings.terminal.behavior.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.clearWipesScrollback': '`clear` wipes scrollback',
'settings.terminal.behavior.clearWipesScrollback.desc':
'Make `clear` also wipe the scrollback buffer (POSIX default). Disable to keep history visible after `clear`.',
'settings.terminal.behavior.preserveSelectionOnInput': 'Keep selection while typing',
'settings.terminal.behavior.preserveSelectionOnInput.desc':
'Don\'t clear mouse-selected text when typing — useful for selecting a path then pasting it after a command prefix like `sz `.',
'settings.terminal.behavior.forcePromptNewLine': 'Prompt on a new line',
'settings.terminal.behavior.forcePromptNewLine.desc':
'When the final line of command output is not terminated by a newline, move the recognized shell prompt to the next visual line.',
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 clipboard',
'settings.terminal.behavior.osc52Clipboard.desc':
'Allow remote programs (tmux, vim, etc.) to access the local clipboard via OSC-52 escape sequences.',
'settings.terminal.behavior.osc52Clipboard.off': 'Disabled',
'settings.terminal.behavior.osc52Clipboard.writeOnly': 'Write only',
'settings.terminal.behavior.osc52Clipboard.readWrite': 'Read & Write',
'settings.terminal.behavior.osc52Clipboard.prompt': 'Write + Prompt on Read',
'terminal.osc52.readPrompt.title': 'Clipboard Read Request',
'terminal.osc52.readPrompt.desc': 'A remote program is requesting to read your clipboard. Allow?',
'terminal.osc52.readPrompt.allow': 'Allow',
'terminal.osc52.readPrompt.deny': 'Deny',
'settings.terminal.behavior.scrollOnInput': 'Scroll on input',
'settings.terminal.behavior.scrollOnInput.desc': 'Scroll terminal to bottom when typing',
'settings.terminal.behavior.scrollOnOutput': 'Scroll on output',
'settings.terminal.behavior.scrollOnOutput.desc':
'Scroll terminal to bottom when new output arrives',
'settings.terminal.behavior.scrollOnKeyPress': 'Scroll on key press',
'settings.terminal.behavior.scrollOnKeyPress.desc':
'Scroll terminal to bottom when pressing a key (e.g., Enter)',
'settings.terminal.behavior.scrollOnPaste': 'Scroll on paste',
'settings.terminal.behavior.scrollOnPaste.desc':
'Scroll terminal to bottom when pasting text',
'settings.terminal.behavior.smoothScrolling': 'Smooth scrolling',
'settings.terminal.behavior.smoothScrolling.desc':
'Animate terminal viewport scrolling instead of jumping instantly',
'settings.terminal.behavior.linkModifier': 'Link modifier key',
'settings.terminal.behavior.linkModifier.desc': 'Hold this key to click on links in terminal',
'settings.terminal.behavior.linkModifier.none': 'None (click directly)',
'settings.terminal.behavior.linkModifier.ctrl': 'Ctrl',
'settings.terminal.behavior.linkModifier.alt': 'Alt / Option',
'settings.terminal.behavior.linkModifier.meta': 'Cmd / Win',
'settings.terminal.scrollback.desc': 'Limit number of terminal rows. Set to 0 for no limit.',
'settings.terminal.scrollback.rows': 'Number of rows *',
'settings.terminal.section.startupCommand': 'Startup command',
'settings.terminal.startupCommandDelay.label': 'Startup command delay (ms)',
'settings.terminal.startupCommandDelay.desc': 'How long to wait after connecting before sending the startup command. Also used between lines when the startup command has multiple lines. Increase for slow connections.',
'settings.terminal.keywordHighlight.title': 'Keyword highlighting',
'settings.terminal.keywordHighlight.resetColors': 'Reset to default colors',
'settings.terminal.keywordHighlight.resetDefaults': 'Reset built-ins to defaults',
'settings.terminal.keywordHighlight.resetBuiltIn': 'Restore default label and patterns',
'settings.terminal.keywordHighlight.addCustom': 'Add Custom Rule',
'settings.terminal.keywordHighlight.editCustom': 'Edit Rule',
'settings.terminal.keywordHighlight.editBuiltIn': 'Edit Built-in Rule',
'settings.terminal.keywordHighlight.labelField': 'Label & Color',
'settings.terminal.keywordHighlight.labelPlaceholder': 'Label (e.g., Down)',
'settings.terminal.keywordHighlight.patternField': 'Regex Patterns',
'settings.terminal.keywordHighlight.patternPlaceholder': 'One regex per line (e.g., \\bdown\\b)',
'settings.terminal.keywordHighlight.patternHint': 'One regex per line. Patterns are matched case-insensitively with the global flag.',
'settings.terminal.keywordHighlight.invalidPattern': 'Invalid regex pattern',
'settings.terminal.keywordHighlight.preview': 'Preview',
'settings.terminal.section.localShell': 'Local Shell',
'settings.terminal.localShell.shell': 'Shell executable',
'settings.terminal.localShell.shell.desc': 'Path to the shell executable (e.g., /bin/zsh, pwsh.exe). Leave empty for system default.',
'settings.terminal.localShell.shell.placeholder': 'System default',
'settings.terminal.localShell.shell.detected': 'Detected',
'settings.terminal.localShell.shell.notFound': 'Shell executable not found',
'settings.terminal.localShell.shell.isDirectory': 'Path is a directory, not an executable',
'settings.terminal.localShell.shell.default': 'System Default',
'settings.terminal.localShell.shell.custom': 'Custom...',
'settings.terminal.localShell.shell.customPath': 'Shell executable path',
'settings.terminal.localShell.shell.customArgs': 'Launch arguments',
'settings.terminal.localShell.shell.customArgs.placeholder': 'e.g. --login -i',
'settings.terminal.localShell.shell.customArgs.desc': 'Arguments passed to the shell. Some shells need them to work — e.g. msys2 bash requires --login -i to load the environment.',
'settings.terminal.localShell.shell.commonPaths': 'Common paths',
'settings.terminal.localShell.shell.pathValid': 'Path valid',
'settings.terminal.localShell.startDir': 'Starting directory',
'settings.terminal.localShell.startDir.desc': 'Directory to start in when opening a local terminal. Leave empty for home directory.',
'settings.terminal.localShell.startDir.placeholder': 'Home directory',
'settings.terminal.localShell.startDir.notFound': 'Directory not found',
'settings.terminal.localShell.startDir.isFile': 'Path is a file, not a directory',
'settings.terminal.section.connection': 'Connection',
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
'settings.terminal.connection.keepaliveInterval.desc': 'How often (in seconds) to send SSH-level keepalive packets. Set to 0 to disable globally — note that individual hosts can override this in their own settings.',
'settings.terminal.connection.keepaliveCountMax': 'Max unanswered keepalives',
'settings.terminal.connection.keepaliveCountMax.desc': 'Unanswered keepalives before the connection is declared dead. Higher values are more forgiving of brief network glitches and SSH servers that respond slowly.',
'settings.terminal.connection.x11Display': 'X11 display',
'settings.terminal.connection.x11Display.desc': 'Optional local display address for X11 forwarding. Leave empty to use the system default.',
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 or DISPLAY)',
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
'settings.terminal.serverStats.show': 'Show Server Stats',
'settings.terminal.serverStats.show.desc': 'Display CPU, memory, and disk usage in the terminal statusbar (Linux servers only).',
'settings.terminal.serverStats.refreshInterval': 'Refresh Interval',
'settings.terminal.serverStats.refreshInterval.desc': 'How often to refresh server stats.',
'settings.terminal.serverStats.seconds': 'seconds',
// Settings > Terminal > Rendering
'settings.terminal.section.rendering': 'Rendering',
'settings.terminal.rendering.renderer': 'Renderer',
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use DOM on low-memory devices. Changes take effect on new terminal sessions.',
'settings.terminal.rendering.auto': 'Auto',
// Settings > Terminal > Workspace Focus Indicator
'settings.terminal.section.workspaceFocus': 'Workspace Focus Indicator',
'settings.terminal.workspaceFocus.style': 'Focus indicator style',
'settings.terminal.workspaceFocus.style.desc': 'How to indicate which pane is focused in split view.',
'settings.terminal.workspaceFocus.dim': 'Dim unfocused panes',
'settings.terminal.workspaceFocus.border': 'Border on focused pane',
// Settings > Terminal > Autocomplete
'settings.terminal.section.autocomplete': 'Autocomplete',
'settings.terminal.autocomplete.enabled': 'Enable autocomplete',
'settings.terminal.autocomplete.enabled.desc': 'Show command suggestions based on history and command specs as you type.',
'settings.terminal.autocomplete.ghostText': 'Ghost text',
'settings.terminal.autocomplete.ghostText.desc': 'Show inline gray suggestion text after the cursor (like fish shell).',
'settings.terminal.autocomplete.popupMenu': 'Popup menu',
'settings.terminal.autocomplete.popupMenu.desc': 'Show a floating list of multiple suggestions.',
// Settings > Shortcuts
'settings.shortcuts.section.scheme': 'Hotkey Scheme',
'settings.shortcuts.scheme.label': 'Keyboard shortcuts',
'settings.shortcuts.scheme.desc': 'Choose which keyboard layout to use for shortcuts',
'settings.shortcuts.scheme.disabled': 'Disabled',
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
'settings.shortcuts.section.custom': 'Custom Shortcuts',
'settings.shortcuts.resetAll': 'Reset All',
'settings.shortcuts.recording': 'Press keys...',
'settings.shortcuts.none': 'None',
'settings.shortcuts.setDisabled': 'Set to disabled',
'settings.shortcuts.category.tabs': 'Tabs',
'settings.shortcuts.category.terminal': 'Terminal',
'settings.shortcuts.category.navigation': 'Navigation',
'settings.shortcuts.category.app': 'App',
'settings.shortcuts.category.sftp': 'SFTP',
// Context menus / common actions
'action.newHost': 'New Host',
'action.newSubfolder': 'New Subfolder',
'action.copyPublicKey': 'Copy Public Key',
'action.keyExport': 'Key Export',
'action.edit': 'Edit',
'action.delete': 'Delete',
'action.duplicate': 'Duplicate',
'action.open': 'Open',
'action.copy': 'Copy',
'action.run': 'Run',
'action.start': 'Start',
'action.stop': 'Stop',
'action.remove': 'Remove',
'action.convertToHost': 'Convert to Host',
// Sync
'sync.cloudSync': 'Cloud Sync',
'sync.settings': 'Sync Settings',
'sync.active': 'Cloud Sync Active',
'sync.syncing': 'Syncing...',
'sync.error': 'Sync Error',
'sync.notConfigured': 'Not Configured',
'sync.failed': 'Sync failed',
'sync.connected': 'Connected',
'sync.syncNow': 'Sync Now',
'sync.recentActivity': 'Recent activity',
'sync.history.uploaded': 'Uploaded',
'sync.history.downloaded': 'Downloaded',
'sync.history.resolved': 'Resolved',
'sync.toast.completedMessage': 'Sync completed successfully',
'sync.toast.errorTitle': 'Sync Error',
'sync.autoSync.failedTitle': 'Sync failed',
'sync.autoSync.inspectFailedTitle': 'Sync paused',
'sync.autoSync.inspectFailedMessage': 'Could not reach the cloud to check for changes. Auto-sync will retry when data changes or the app is restarted.',
'sync.autoSync.syncedTitle': 'Synced from cloud',
'sync.autoSync.syncedMessage': 'Your data has been updated from the cloud.',
'sync.autoSync.noProvider': 'No cloud provider connected. Open Settings → Sync & Cloud to connect one.',
'sync.autoSync.alreadySyncing': 'Sync is already in progress.',
'sync.autoSync.restoreInProgress': 'A vault restore is in progress in another window. Please wait for it to finish.',
'sync.autoSync.interruptedApplyTitle': 'Sync paused — previous restore interrupted',
'sync.autoSync.interruptedApplyMessage': 'A previous restore did not finish cleanly, so the local vault may be inconsistent. Open Settings → Sync & Cloud → Restore and apply a protective backup before auto-sync resumes.',
'sync.autoSync.vaultLocked': 'Vault is locked. Open Settings → Sync & Cloud to unlock.',
'sync.autoSync.conflictDetected': 'Sync conflict detected. Open Settings → Sync & Cloud to resolve.',
'sync.autoSync.syncFailed': 'Sync failed',
'sync.autoSync.restoredTitle': 'Vault restored',
'sync.autoSync.restoredMessage': 'Your vault has been restored from the cloud.',
'sync.autoSync.keptLocalTitle': 'Kept local vault',
'sync.autoSync.keptLocalMessage': 'Your empty local vault was kept. Cloud data was not applied.',
'sync.autoSync.emptyVaultConflict.title': 'Empty Vault Detected',
'sync.autoSync.emptyVaultConflict.description': 'Your local vault is empty, but the cloud has data. This usually happens after an update or storage reset. What would you like to do?',
'sync.autoSync.emptyVaultConflict.cloudLabel': 'Cloud',
'sync.autoSync.emptyVaultConflict.restore': 'Restore from Cloud',
'sync.autoSync.emptyVaultConflict.restoreDesc': 'Recommended — recover your hosts, keys, and snippets from the cloud backup',
'sync.autoSync.emptyVaultConflict.keepEmpty': 'Keep Empty',
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': 'Start fresh with an empty vault',
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} hosts, {keys} keys, {snippets} snippets, {proxyProfiles} proxies',
'sync.autoSync.emptyVaultManual': 'Cannot sync: the local vault is empty. Restore from a local backup or enable Force Push in the sync panel first.',
'sync.blocked.title': 'Sync paused',
'sync.blocked.reason.bulkShrink': 'Would delete {lost} of {baseCount} {entityType} from cloud ({percent}% reduction).',
'sync.blocked.reason.largeShrink': 'Would delete {lost} {entityType} from cloud.',
'sync.blocked.detail': 'This is usually caused by a degraded local state (keychain failure, partial data load). Restore from a local backup, or force-push if you truly meant to remove these entries.',
'sync.blocked.restoreButton': 'Restore from local backup',
'sync.blocked.forcePushButton': 'Force push anyway',
'sync.forcePush.title': 'Confirm force push',
'sync.forcePush.body': 'You are about to remove {lost} {entityType} from the cloud. This cannot be undone. Proceed?',
'sync.forcePush.confirm': 'Yes, push anyway',
'sync.forcePush.cancel': 'Cancel',
'sync.entityType.hosts': 'hosts',
'sync.entityType.keys': 'keys',
'sync.entityType.identities': 'identities',
'sync.entityType.proxyProfiles': 'proxy profiles',
'sync.entityType.snippets': 'snippets',
'sync.entityType.customGroups': 'groups',
'sync.entityType.snippetPackages': 'snippet packages',
'sync.entityType.knownHosts': 'known-host entries',
'sync.entityType.portForwardingRules': 'port-forwarding rules',
'sync.entityType.groupConfigs': 'group configs',
'sync.credentialsUnavailable': 'This device cannot decrypt some saved credentials. Re-enter credentials locally before syncing.',
'time.never': 'Never',
'time.justNow': 'Just now',
'time.minutesAgo': '{minutes}m ago',
// Vault navigation
'vault.nav.hosts': 'Hosts',
'vault.nav.keychain': 'Keychain',
'vault.nav.proxies': 'Proxies',
'vault.nav.portForwarding': 'Port Forwarding',
'vault.nav.snippets': 'Snippets',
'vault.nav.knownHosts': 'Known Hosts',
'vault.nav.logs': 'Logs',
'proxyProfiles.action.add': 'Add Proxy',
'proxyProfiles.search.placeholder': 'Search proxies…',
'proxyProfiles.section.proxies': 'Proxies',
'proxyProfiles.count.items': '{count} items',
'proxyProfiles.empty.title': 'No Proxies',
'proxyProfiles.empty.desc': 'Create reusable HTTP, SOCKS5, or command proxies and select them from host details.',
'proxyProfiles.usage': '{count} linked',
'proxyProfiles.copyName': '{name} Copy',
'proxyProfiles.panel.newTitle': 'New Proxy',
'proxyProfiles.field.name': 'Proxy name',
'proxyProfiles.error.required': 'Name and proxy details are required.',
'proxyProfiles.error.port': 'Port must be between 1 and 65535.',
'proxyProfiles.viewMode': 'Proxy view mode',
'proxyProfiles.delete.title': 'Delete proxy?',
'proxyProfiles.delete.desc': 'Deleting "{name}" will unlink it from {count} host or group settings.',
'vault.groups.title': 'Groups',
'vault.groups.total': '{count} total',
'vault.groups.hostsCount': '{count} Hosts',
'vault.groups.newSubgroup': 'New Subgroup',
'vault.groups.rename': 'Rename Group',
'vault.groups.delete': 'Delete Group',
'vault.groups.createSubfolder': 'Create Subfolder',
'vault.groups.createRoot': 'Create Root Group',
'vault.groups.createDialog.desc': 'Create a new group for organizing hosts.',
'vault.groups.renameDialogTitle': 'Rename Group',
'vault.groups.renameDialog.desc': 'Rename an existing group.',
'vault.groups.deleteDialogTitle': 'Delete Group',
'vault.groups.deleteDialog.desc': 'This will permanently delete the group and move all hosts to the root level.',
'vault.groups.deleteDialog.managedDesc': 'This is a managed SSH config group. Deleting it will also delete all hosts and unlink from the source file.',
'vault.groups.deleteDialog.deleteHosts': 'Also delete all hosts in this group',
'vault.groups.ungrouped': 'Ungrouped',
'vault.groups.field.name': 'Group Name',
'vault.groups.placeholder.example': 'e.g. Production',
'vault.groups.parentLabel': 'Parent',
'vault.groups.pathLabel': 'Path',
'vault.groups.settings': 'Group Settings',
'vault.groups.details': 'Group Details',
'vault.groups.details.general': 'General',
'vault.groups.details.ssh': 'SSH',
'vault.groups.details.telnet': 'Telnet',
'vault.groups.details.advanced': 'Advanced',
'vault.groups.details.appearance': 'Appearance',
'vault.groups.details.mosh': 'Mosh',
'vault.groups.details.parentGroup': 'Parent Group',
'vault.groups.details.none': 'None',
'vault.groups.details.inherited': 'Inherited from group',
'vault.groups.details.addProtocol': 'Add Protocol',
'vault.groups.details.removeProtocol': 'Remove Protocol',
'vault.groups.details.fontFamily': 'Font Family',
'vault.groups.details.fontSize': 'Font Size',
'vault.groups.errors.required': 'Group name is required.',
'vault.groups.errors.invalidChars': "Group name cannot include '/' or '\\\\'.",
'vault.groups.errors.duplicatePath': 'A group with this name already exists at this location.',
'vault.managedSource.unmanage': 'Unmanage',
'vault.managedSource.unmanageSuccess': 'Successfully unmanaged group',
'vault.hosts.header.entries': '{count} entries',
'vault.hosts.header.live': '{count} live',
// Vault hosts header/actions
'vault.hosts.search.placeholder': 'Find a host or ssh user@hostname / ssh -p 2222 user@hostname...',
'vault.hosts.connect': 'Connect',
'vault.view.grid': 'Grid',
'vault.view.list': 'List',
'vault.view.tree': 'Tree',
'vault.tree.expandAll': 'Expand All',
'vault.tree.collapseAll': 'Collapse All',
'vault.hosts.newHost': 'New Host',
'vault.hosts.newGroup': 'New Group',
'vault.hosts.import': 'Import',
'vault.hosts.export': 'Export',
'vault.hosts.export.toast.success': 'Exported {count} hosts to CSV',
'vault.hosts.export.toast.successWithSkipped': 'Exported {count} hosts to CSV ({skipped} unsupported hosts skipped)',
'vault.hosts.export.toast.noHosts': 'No hosts to export',
'vault.hosts.allHosts': 'All hosts',
'vault.hosts.pinned': 'Pinned',
'vault.hosts.recentlyConnected': 'Recently Connected',
'vault.hosts.pinToTop': 'Pin to Top',
'vault.hosts.unpin': 'Unpin',
'vault.hosts.copyCredentials': 'Copy Credentials',
'vault.hosts.copyCredentials.toast.success': 'Credentials copied to clipboard',
'vault.hosts.copyCredentials.toast.noPassword': 'No password saved for this host',
'vault.hosts.multiSelect': 'Multi-select',
'vault.hosts.selected': '{count} selected',
'vault.hosts.selectAll': 'Select All',
'vault.hosts.deselectAll': 'Deselect All',
'vault.hosts.deleteSelected': 'Delete ({count})',
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
'vault.hosts.connectSelected': 'Connect ({count})',
'vault.hosts.connectMultiple.success': 'Connecting {count} hosts',
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
'vault.hosts.empty.title': 'Set up your hosts',
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
};

View File

@@ -0,0 +1,658 @@
import type { Messages } from '../types';
export const enTerminalMessages: Messages = {
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': 'Open SFTP',
'terminal.toolbar.availableAfterConnect': 'Available after connect',
'terminal.toolbar.sftp': 'SFTP',
'terminal.toolbar.more': 'More actions',
'terminal.toolbar.scripts': 'Scripts',
'terminal.toolbar.library': 'Library',
'terminal.toolbar.noSnippets': 'No snippets available',
'terminal.toolbar.terminalSettings': 'Terminal settings',
'terminal.toolbar.searchTerminal': 'Search terminal',
'terminal.toolbar.search': 'Search',
'terminal.toolbar.broadcast': 'Broadcast',
'terminal.toolbar.broadcastEnable': 'Enable Broadcast Mode',
'terminal.toolbar.broadcastDisable': 'Disable Broadcast Mode',
'terminal.toolbar.composeBar': 'Compose Bar',
'terminal.composeBar.placeholder': 'Type command here, press Enter to send...',
'terminal.composeBar.send': 'Send',
'terminal.composeBar.close': 'Close compose bar',
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
'terminal.toolbar.focus': 'Focus',
'terminal.toolbar.focusMode': 'Focus Mode',
'terminal.toolbar.encoding': 'Terminal Encoding',
'terminal.toolbar.encoding.utf8': 'UTF-8',
'terminal.toolbar.encoding.gb18030': 'GB18030',
'terminal.toolbar.closeSession': 'Close session',
'terminal.toolbar.hostHighlight.title': 'Host Keyword Highlighting',
'terminal.toolbar.hostHighlight.noRules': 'No custom highlight rules defined for this host',
'terminal.toolbar.hostHighlight.addRule': 'Add New Rule',
'terminal.toolbar.hostHighlight.labelPlaceholder': 'Label (e.g., Error)',
'terminal.toolbar.hostHighlight.patternPlaceholder': 'Regex pattern (e.g., \\bfailed\\b)',
'terminal.toolbar.hostHighlight.invalidPattern': 'Invalid regex pattern',
'terminal.toolbar.hostHighlight.clearAll': 'Clear All',
'terminal.toolbar.hostHighlight.changeColor': 'Change highlight color for',
'terminal.toolbar.hostHighlight.selectColor': 'Select color for new rule',
'terminal.statusbar.copyHostname.label': 'Copy host address',
'terminal.statusbar.copyHostname.tooltip': 'Copy host address ({hostname})',
'terminal.statusbar.copyHostname.toast': 'Copied host address: {hostname}',
'terminal.statusbar.copyHostname.error': 'Failed to copy host address to clipboard',
'terminal.serverStats.cpu': 'CPU Usage',
'terminal.serverStats.cpuCores': 'CPU Core Usage',
'terminal.serverStats.memory': 'Memory Usage',
'terminal.serverStats.memoryDetails': 'Memory Details',
'terminal.serverStats.memUsed': 'Used',
'terminal.serverStats.memBuffers': 'Buffers',
'terminal.serverStats.memCached': 'Cache',
'terminal.serverStats.memFree': 'Free',
'terminal.serverStats.swap': 'Swap',
'terminal.serverStats.swapUsed': 'Swap Used',
'terminal.serverStats.swapFree': 'Swap Free',
'terminal.serverStats.swapTotal': 'Total',
'terminal.serverStats.topProcesses': 'Top Processes by Memory',
'terminal.serverStats.disk': 'Disk Usage (Root)',
'terminal.serverStats.diskDetails': 'Mounted Disks',
'terminal.serverStats.network': 'Network Speed',
'terminal.serverStats.networkDetails': 'Network Interfaces',
'terminal.serverStats.noData': 'No data available',
'terminal.dragDrop.localTitle': 'Drop to Insert Paths',
'terminal.dragDrop.localMessage': 'File paths will be inserted into the terminal',
'terminal.dragDrop.remoteTitle': 'Drop to Upload Files',
'terminal.dragDrop.remoteMessage': 'Files will be uploaded via SFTP',
'terminal.dragDrop.notConnected': 'Cannot drop files - terminal is not connected',
'terminal.dragDrop.errorTitle': 'Drop Error',
'terminal.dragDrop.errorMessage': 'Failed to process dropped files',
'terminal.search.placeholder': 'Search...',
'terminal.search.noResults': 'No results',
'terminal.search.prevMatch': 'Previous match (Shift+Enter)',
'terminal.search.nextMatch': 'Next match (Enter)',
'terminal.menu.copy': 'Copy',
'terminal.menu.paste': 'Paste',
'terminal.menu.pasteSelection': 'Paste Selection',
'terminal.menu.selectAll': 'Select All',
'terminal.menu.reconnect': 'Reconnect',
'terminal.menu.splitHorizontal': 'Split Horizontal',
'terminal.menu.splitVertical': 'Split Vertical',
'terminal.menu.clearBuffer': 'Clear Buffer',
'terminal.menu.closeTerminal': 'Close terminal',
'terminal.auth.password': 'Password',
'terminal.auth.sshKey': 'SSH Key',
'terminal.auth.username': 'Username',
'terminal.auth.username.placeholder': 'root',
'terminal.auth.passwordLabel': 'Password',
'terminal.auth.password.placeholder': 'Enter password',
'terminal.auth.passphrase': 'Passphrase',
'terminal.auth.passphrase.placeholder': 'Optional passphrase for the selected private key',
'terminal.auth.certificate': 'Certificate',
'terminal.auth.selectKey': 'Select Key',
'terminal.auth.noKeysHint': 'No keys available. Add keys in Keychain.',
'terminal.auth.continueSave': 'Continue & Save',
'terminal.auth.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Please re-enter and save them again.',
'terminal.auth.jumpCredentialsUnavailable': 'A jump host has saved credentials that cannot be decrypted on this device. Open host settings and re-enter them.',
'terminal.auth.proxyCredentialsUnavailable': 'Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.',
'terminal.auth.keyUnavailableFallbackPassword': 'Saved SSH key is unavailable on this device. Falling back to password authentication.',
'terminal.progress.timeoutIn': 'Timeout in {seconds}s',
'terminal.progress.disconnected': 'Disconnected',
'terminal.progress.cancelling': 'Cancelling...',
'terminal.progress.startOver': 'Start over',
'terminal.connection.dismissDisconnectedDialog': 'Dismiss disconnected notice',
'terminal.connection.chainOf': 'Chain {current} of {total}',
'terminal.connection.showLogs': 'Show logs',
'terminal.connection.hideLogs': 'Hide logs',
'terminal.connection.protocol.ssh': 'SSH',
'terminal.connection.protocol.telnet': 'Telnet',
'terminal.connection.protocol.mosh': 'Mosh',
'terminal.connection.protocol.et': 'EternalTerminal',
'terminal.et.proxyUnsupported': 'EternalTerminal does not currently support Netcatty proxy settings. Use SSH or remove the proxy for this host.',
'terminal.et.multiJumpUnsupported': 'EternalTerminal currently supports at most one jump host in Netcatty.',
'terminal.connection.protocol.serial': 'Serial',
'terminal.connection.protocol.local': 'Local Shell',
'terminal.hostKey.unknownTitle': 'Confirm this host key',
'terminal.hostKey.changedTitle': 'Host key changed',
'terminal.hostKey.unknownDescription': 'The authenticity of {host} cannot be established yet.',
'terminal.hostKey.changedDescription': 'The saved key for {host} no longer matches this server.',
'terminal.hostKey.fingerprintLabel': '{keyType} fingerprint is SHA256:',
'terminal.hostKey.savedFingerprintLabel': 'Saved fingerprint',
'terminal.hostKey.unknownHint': 'Remember it if this fingerprint belongs to the server you expected.',
'terminal.hostKey.changedHint': 'Only continue if you expected this host to change.',
'terminal.hostKey.addAndContinue': 'Add and continue',
'terminal.hostKey.updateAndContinue': 'Update and continue',
'terminal.themeModal.title': 'Terminal Appearance',
'terminal.themeModal.tab.theme': 'Theme',
'terminal.themeModal.tab.font': 'Font',
'terminal.themeModal.tab.custom': 'Custom',
'terminal.themeModal.globalTheme': 'Global Theme',
'terminal.themeModal.globalFont': 'Global Font',
'terminal.themeModal.fontSize': 'Font Size',
'terminal.themeModal.fontWeight': 'Font Weight',
'terminal.themeModal.livePreview': 'Live Preview',
'terminal.themeModal.themeType': '{type} theme',
'terminal.hiddenTheme.title': 'Current hidden theme',
'terminal.hiddenTheme.desc': 'This theme is hidden from manual picks and will be replaced when you choose another theme.',
'topTabs.toggleTheme.systemExitTitle': 'System theme is active',
'topTabs.toggleTheme.systemExitMessage': 'Open Settings to choose a fixed Light or Dark theme.',
'topTabs.toggleTheme.openSettings': 'Open Settings',
// Custom Themes
'terminal.customTheme.section': 'Custom Themes',
'terminal.customTheme.yourThemes': 'Your Themes',
'terminal.customTheme.new': 'New Theme',
'terminal.customTheme.newDesc': 'Clone current theme and customize',
'terminal.customTheme.newTitle': 'New Custom Theme',
'terminal.customTheme.editTitle': 'Edit Theme',
'terminal.customTheme.import': 'Import .itermcolors',
'terminal.customTheme.importDesc': 'Import from iTerm2 color scheme file',
'terminal.customTheme.importError': 'Failed to parse the selected file. Please ensure it is a valid .itermcolors XML file.',
'terminal.customTheme.delete': 'Delete Theme',
'terminal.customTheme.confirmDelete': 'Confirm Delete',
'terminal.customTheme.name': 'Name',
'terminal.customTheme.namePlaceholder': 'My Custom Theme',
'terminal.customTheme.type': 'Type',
'terminal.customTheme.group.general': 'General',
'terminal.customTheme.group.normal': 'Normal Colors',
'terminal.customTheme.group.bright': 'Bright Colors',
'terminal.customTheme.color.background': 'Background',
'terminal.customTheme.color.foreground': 'Foreground',
'terminal.customTheme.color.cursor': 'Cursor',
'terminal.customTheme.color.selection': 'Selection',
'terminal.customTheme.color.black': 'Black',
'terminal.customTheme.color.red': 'Red',
'terminal.customTheme.color.green': 'Green',
'terminal.customTheme.color.yellow': 'Yellow',
'terminal.customTheme.color.blue': 'Blue',
'terminal.customTheme.color.magenta': 'Magenta',
'terminal.customTheme.color.cyan': 'Cyan',
'terminal.customTheme.color.white': 'White',
'terminal.customTheme.color.brightBlack': 'Bright Black',
'terminal.customTheme.color.brightRed': 'Bright Red',
'terminal.customTheme.color.brightGreen': 'Bright Green',
'terminal.customTheme.color.brightYellow': 'Bright Yellow',
'terminal.customTheme.color.brightBlue': 'Bright Blue',
'terminal.customTheme.color.brightMagenta': 'Bright Magenta',
'terminal.customTheme.color.brightCyan': 'Bright Cyan',
'terminal.customTheme.color.brightWhite': 'Bright White',
// Cloud Sync Settings
'cloudSync.gate.title': 'End-to-End Encrypted Sync',
'cloudSync.gate.desc':
'Your data is encrypted locally before syncing. Cloud providers never see your plaintext data. Set a master key to enable secure sync.',
'cloudSync.gate.masterKey': 'Master Key',
'cloudSync.gate.confirmMasterKey': 'Confirm Master Key',
'cloudSync.gate.placeholder': 'Enter a strong password',
'cloudSync.gate.confirmPlaceholder': 'Confirm your password',
'cloudSync.gate.mismatch': 'Passwords do not match',
'cloudSync.gate.warning':
'I understand that if I forget my master key, my data cannot be recovered. There is no password reset.',
'cloudSync.gate.enableVault': 'Enable Encrypted Vault',
'cloudSync.gate.enabledToast': 'Encrypted vault enabled',
'cloudSync.gate.setupFailed': 'Failed to set up master key',
'cloudSync.passwordStrength.tooShort': 'Too short',
'cloudSync.passwordStrength.weak': 'Weak',
'cloudSync.passwordStrength.moderate': 'Moderate',
'cloudSync.passwordStrength.strong': 'Strong',
'cloudSync.passwordStrength.veryStrong': 'Very Strong',
'cloudSync.provider.notConnected': 'Not connected',
'cloudSync.provider.sync': 'Sync',
'cloudSync.provider.connect': 'Connect',
'cloudSync.provider.connecting': 'Connecting...',
'cloudSync.provider.webdav': 'WebDAV',
'cloudSync.provider.webdav.desc': 'Connect to a self-hosted WebDAV endpoint',
'cloudSync.provider.s3': 'S3 Compatible',
'cloudSync.provider.s3.desc': 'Connect to S3-compatible object storage',
'cloudSync.provider.comingSoon': 'Coming soon',
'cloudSync.webdav.title': 'WebDAV Settings',
'cloudSync.webdav.desc': 'Configure a WebDAV endpoint for encrypted sync.',
'cloudSync.webdav.endpoint': 'Endpoint URL',
'cloudSync.webdav.authType': 'Auth Type',
'cloudSync.webdav.auth.basic': 'Basic',
'cloudSync.webdav.auth.digest': 'Digest',
'cloudSync.webdav.auth.token': 'Token',
'cloudSync.webdav.username': 'Username',
'cloudSync.webdav.password': 'Password',
'cloudSync.webdav.token': 'Token',
'cloudSync.webdav.showSecret': 'Show secret',
'cloudSync.webdav.allowInsecure': 'Allow insecure connection (ignore certificate errors)',
'cloudSync.webdav.validation.endpoint': 'Enter a valid WebDAV endpoint.',
'cloudSync.webdav.validation.credentials': 'Username and password are required.',
'cloudSync.webdav.validation.token': 'Token is required.',
'cloudSync.s3.title': 'S3 Settings',
'cloudSync.s3.desc': 'Connect to S3-compatible object storage for encrypted sync.',
'cloudSync.s3.endpoint': 'Endpoint URL',
'cloudSync.s3.region': 'Region',
'cloudSync.s3.bucket': 'Bucket',
'cloudSync.s3.accessKeyId': 'Access Key ID',
'cloudSync.s3.secretAccessKey': 'Secret Access Key',
'cloudSync.s3.sessionToken': 'Session Token (optional)',
'cloudSync.s3.prefix': 'Key Prefix (optional)',
'cloudSync.s3.forcePathStyle': 'Force path-style URLs (for MinIO/R2, etc.)',
'cloudSync.s3.showSecret': 'Show secrets',
'cloudSync.s3.validation.required': 'Endpoint, region, bucket, access key, and secret are required.',
'cloudSync.smb.title': 'SMB Settings',
'cloudSync.smb.desc': 'Connect to an SMB/CIFS file share for encrypted sync.',
'cloudSync.smb.share': 'Share Path',
'cloudSync.smb.username': 'Username',
'cloudSync.smb.password': 'Password',
'cloudSync.smb.domain': 'Domain (optional)',
'cloudSync.smb.domainPlaceholder': 'e.g., WORKGROUP',
'cloudSync.smb.port': 'Port (optional)',
'cloudSync.smb.showSecret': 'Show password',
'cloudSync.smb.validation.share': 'Share path is required.',
'cloudSync.smb.validation.port': 'Port must be a number between 1 and 65535.',
'cloudSync.connect.smb.success': 'SMB connected successfully',
'cloudSync.connect.smb.failedTitle': 'SMB connection failed',
'cloudSync.provider.smb': 'SMB Share',
'cloudSync.connect.webdav.success': 'WebDAV connected successfully',
'cloudSync.connect.webdav.failedTitle': 'WebDAV connection failed',
'cloudSync.connect.s3.success': 'S3 connected successfully',
'cloudSync.connect.s3.failedTitle': 'S3 connection failed',
'cloudSync.lastSync.never': 'Never',
'cloudSync.lastSync.justNow': 'Just now',
'cloudSync.lastSync.minutesAgo': '{minutes} min ago',
'cloudSync.changeKey': 'Change Key',
'cloudSync.providers.title': 'Cloud Providers',
'cloudSync.syncAll': 'Sync All Connected Providers',
'cloudSync.autoSync.title': 'Auto-sync',
'cloudSync.autoSync.desc': 'Automatically sync when changes are made',
'cloudSync.strategy.title': 'Sync strategy',
'cloudSync.strategy.desc': 'Choose what happens when local and cloud data both changed.',
'cloudSync.strategy.smartMerge': 'Smart merge (recommended)',
'cloudSync.strategy.smartMergeDesc': 'Combine changes from both sides when possible; if Netcatty cannot decide safely, ask you to choose.',
'cloudSync.strategy.preferCloud': 'Cloud wins',
'cloudSync.strategy.preferCloudDesc': 'When both sides changed, download the cloud version and replace local changes.',
'cloudSync.strategy.preferLocal': 'Local wins',
'cloudSync.strategy.preferLocalDesc': 'When both sides changed, upload the local version and replace cloud changes.',
'cloudSync.status.title': 'Sync Status',
'cloudSync.status.localVersion': 'Local Version',
'cloudSync.status.remoteVersion': 'Remote Version',
'cloudSync.history.title': 'Sync History',
'cloudSync.history.upload': 'Upload',
'cloudSync.history.download': 'Download',
'cloudSync.history.resolved': 'Resolved',
'cloudSync.history.error': 'Error',
'cloudSync.localBackups.title': 'Local Backup History',
'cloudSync.localBackups.desc': 'Netcatty keeps local restore points before app version changes and before vault restores.',
'cloudSync.localBackups.retentionTitle': 'Backup Retention',
'cloudSync.localBackups.retentionDesc': 'Choose how many local backups Netcatty should keep.',
'cloudSync.localBackups.maxCount': 'Max backups',
'cloudSync.localBackups.maxSaved': 'Saved backup retention: {count}',
'cloudSync.localBackups.maxInvalid': 'Please enter a number between 1 and 100.',
'cloudSync.localBackups.empty': 'No local backups yet.',
'cloudSync.localBackups.reason.appVersionChange': 'Before app version change',
'cloudSync.localBackups.reason.beforeRestore': 'Before restore',
'cloudSync.localBackups.versionChange': '{from} -> {to}',
'cloudSync.localBackups.counts': '{hosts} hosts, {keys} keys, {snippets} snippets',
'cloudSync.localBackups.restore': 'Restore',
'cloudSync.localBackups.restoreSuccess': 'Local backup restored.',
'cloudSync.localBackups.restoreFailedTitle': 'Restore failed',
'cloudSync.localBackups.restoreMissing': 'Backup not found.',
'cloudSync.localBackups.protectiveBackupFailed': 'Safety backup could not be created, so the restore was aborted to protect your current data. Resolve the underlying issue (e.g. keychain access) and try again. Details: {message}',
'cloudSync.localBackups.restoreConfirmTitle': 'Restore this backup?',
'cloudSync.localBackups.restoreConfirmDesc': 'Your current hosts, keys, snippets and settings will be replaced with the contents of this backup. A protective snapshot of your current data is taken automatically first.',
'cloudSync.localBackups.restoreConfirmButton': 'Restore',
'cloudSync.localBackups.restoreConfirmCancel': 'Cancel',
'cloudSync.localBackups.unavailableTitle': 'Local backups unavailable',
'cloudSync.localBackups.unavailableDesc': 'This platform does not expose a secure keychain to Netcatty, so local backups cannot be written safely. Install Netcatty on a system with a supported keychain to enable the local backup history.',
'cloudSync.localBackups.lockedTitle': 'Master key required',
'cloudSync.localBackups.lockedDesc': 'Set up or unlock your master key before restoring a backup, so restored credentials remain encrypted.',
'cloudSync.revisionHistory.viewButton': 'History',
'cloudSync.revisionHistory.title': 'Vault Version History',
'cloudSync.revisionHistory.description': 'Browse and restore previous versions of your vault from the Gist revision history.',
'cloudSync.revisionHistory.empty': 'No revisions found.',
'cloudSync.revisionHistory.current': 'Current',
'cloudSync.revisionHistory.revision': 'Revision',
'cloudSync.revisionHistory.revisionPreview': 'Revision Contents',
'cloudSync.revisionHistory.device': 'Device',
'cloudSync.revisionHistory.hosts': 'Hosts',
'cloudSync.revisionHistory.keys': 'Keys',
'cloudSync.revisionHistory.snippets': 'Snippets',
'cloudSync.revisionHistory.identities': 'Identities',
'cloudSync.revisionHistory.restoreButton': 'Restore This Version',
'cloudSync.revisionHistory.restored': 'Vault restored from selected revision.',
'cloudSync.revisionHistory.revisionNotFound': 'Revision not found or does not contain vault data.',
'cloudSync.revisionHistory.decryptFailed': 'Cannot decrypt this revision. It may have been encrypted with a different master password.',
'cloudSync.changeKey.title': 'Change Master Key',
'cloudSync.changeKey.current': 'Current Master Key',
'cloudSync.changeKey.new': 'New Master Key',
'cloudSync.changeKey.confirmNew': 'Confirm New Master Key',
'cloudSync.changeKey.currentPlaceholder': 'Enter current master key',
'cloudSync.changeKey.newPlaceholder': 'Enter new master key',
'cloudSync.changeKey.confirmPlaceholder': 'Confirm new master key',
'cloudSync.changeKey.fillAll': 'Please fill in all fields',
'cloudSync.changeKey.minLength': 'New master key must be at least 8 characters',
'cloudSync.changeKey.notMatch': 'New master keys do not match',
'cloudSync.changeKey.incorrectCurrent': 'Incorrect current master key',
'cloudSync.changeKey.failed': 'Failed to change master key',
'cloudSync.changeKey.desc': 'This will re-encrypt your vault. Make sure you remember the new key.',
'cloudSync.changeKey.showKeys': 'Show keys',
'cloudSync.changeKey.updatedToast': 'Master key updated',
'cloudSync.changeKey.updateButton': 'Update Key',
'cloudSync.unlock.title': 'Enter Master Key',
'cloudSync.unlock.masterKey': 'Master Key',
'cloudSync.unlock.desc':
'Enter your master key once to enable encrypted sync. It will be stored securely using your OS keychain.',
'cloudSync.unlock.placeholder': 'Enter your master key',
'cloudSync.unlock.empty': 'Please enter your master key',
'cloudSync.unlock.incorrect': 'Incorrect master key',
'cloudSync.unlock.failed': 'Failed to unlock vault',
'cloudSync.unlock.showKey': 'Show key',
'cloudSync.unlock.notNow': 'Not now',
'cloudSync.unlock.readyToast': 'Vault ready',
'cloudSync.unlock.unlockButton': 'Unlock',
'cloudSync.header.vaultReady': 'Vault ready',
'cloudSync.header.preparingVault': 'Preparing vault...',
'cloudSync.header.providersConnected': '{count} provider(s) connected',
'cloudSync.githubFlow.title': 'Connect to GitHub',
'cloudSync.githubFlow.desc': 'Copy the code below and enter it on GitHub to authorize Netcatty.',
'cloudSync.githubFlow.copyCode': 'Copy code',
'cloudSync.githubFlow.copied': 'Copied!',
'cloudSync.githubFlow.openGitHub': 'Open GitHub',
'cloudSync.githubFlow.waiting': 'Waiting for authorization...',
'cloudSync.conflict.title': 'Version conflict detected',
'cloudSync.conflict.desc': 'Choose which version to keep',
'cloudSync.conflict.local': 'LOCAL',
'cloudSync.conflict.cloud': 'CLOUD',
'cloudSync.conflict.detailsTitle': 'Changed data',
'cloudSync.conflict.detailsCounts': 'Local {local} · Cloud {cloud} · Conflicts {conflicts}',
'cloudSync.conflict.entity.hosts': 'Hosts',
'cloudSync.conflict.entity.keys': 'Keys',
'cloudSync.conflict.entity.identities': 'Identities',
'cloudSync.conflict.entity.proxyProfiles': 'Proxy profiles',
'cloudSync.conflict.entity.snippets': 'Snippets',
'cloudSync.conflict.entity.customGroups': 'Groups',
'cloudSync.conflict.entity.snippetPackages': 'Snippet packages',
'cloudSync.conflict.entity.portForwardingRules': 'Port forwarding',
'cloudSync.conflict.entity.groupConfigs': 'Group settings',
'cloudSync.conflict.entity.settings': 'Settings',
'cloudSync.conflict.keepLocal': 'Overwrite cloud (keep local)',
'cloudSync.conflict.useCloud': 'Download cloud (overwrite local)',
'cloudSync.connect.browserContinue': 'Complete authorization in browser',
'cloudSync.connect.browserCancelled': 'Previous browser authorization was cancelled',
'cloudSync.connect.github.success': 'GitHub connected successfully',
'cloudSync.connect.github.failedTitle': 'GitHub connection failed',
'cloudSync.connect.github.timeout': 'GitHub connection timed out. Check your network or proxy settings.',
'cloudSync.connect.github.networkError': 'Unable to reach GitHub. Check your network or proxy settings.',
'cloudSync.connect.google.failedTitle': 'Google connection failed',
'cloudSync.connect.onedrive.failedTitle': 'OneDrive connection failed',
'cloudSync.sync.success': 'Synced to {provider}',
'cloudSync.sync.failed': 'Sync failed',
'cloudSync.sync.failedTitle': 'Sync failed',
'cloudSync.sync.errorTitle': 'Sync error',
'cloudSync.resolve.downloaded': 'Downloaded cloud data',
'cloudSync.resolve.uploaded': 'Uploaded local data',
'cloudSync.resolve.failedTitle': 'Conflict resolution failed',
'cloudSync.clearLocal.title': 'Clear Local Data',
'cloudSync.clearLocal.desc': 'Reset local version and sync history. Next sync will download from cloud.',
'cloudSync.clearLocal.button': 'Clear',
'cloudSync.clearLocal.dialog.title': 'Clear Local Vault Data?',
'cloudSync.clearLocal.dialog.desc': 'This will reset local version to 0 and clear sync history. Your next sync will download data from the cloud, replacing local data.',
'cloudSync.clearLocal.dialog.cancel': 'Cancel',
'cloudSync.clearLocal.dialog.confirm': 'Clear Local Data',
'cloudSync.clearLocal.toast.title': 'Local data cleared',
'cloudSync.clearLocal.toast.desc': 'Local version reset to 0. Sync to download from cloud.',
// Keychain
'keychain.filter.key': 'KEY',
'keychain.filter.certificate': 'CERTIFICATE',
'keychain.action.generateKey': 'Generate Key',
'keychain.action.importKey': 'Import Key',
'keychain.action.newIdentity': 'New Identity',
'keychain.action.importCertificate': 'Import Certificate',
'keychain.view.grid': 'Grid',
'keychain.view.list': 'List',
'keychain.section.keys': 'Keys',
'keychain.section.identities': 'Identities',
'keychain.count.items': '{count} items',
'keychain.empty.title': 'Set up your keys',
'keychain.empty.desc': 'Import or generate SSH keys for secure authentication.',
'keychain.panel.generateKey': 'Generate Key',
'keychain.panel.newKey': 'New Key',
'keychain.panel.keyDetails': 'Key Details',
'keychain.panel.editKey': 'Edit Key',
'keychain.panel.editIdentity': 'Edit Identity',
'keychain.panel.newIdentity': 'New Identity',
'keychain.panel.keyExport': 'Key Export',
'keychain.validation.labelRequired': 'Please enter a label for the key',
'keychain.validation.labelAndPrivateKeyRequired': 'Label and private key are required',
'keychain.validation.labelAndUsernameRequired': 'Label and username are required',
'keychain.error.generationUnavailable':
'Key generation not available - please ensure the app is running in Electron',
'keychain.error.generateKeyPairFailed': 'Failed to generate key pair',
'keychain.error.generateKeyFailed': 'Failed to generate key',
'keychain.error.keyGenerationTitle': 'Key Generation',
'keychain.export.exportTo': 'Export to *',
'keychain.export.selectHost': 'Select Host',
'keychain.export.location': 'Location ~ $1 *',
'keychain.export.filename': 'Filename ~ $2 *',
'keychain.export.note':
'Key export currently supports only {unix} systems. Use the {advanced} section to customize the export script.',
'keychain.export.script': 'Script *',
'keychain.export.scriptPlaceholder': 'Export script...',
'keychain.export.missingCredentials':
'Host has no saved password or key. Please add password credentials to the host first.',
'keychain.export.successTitle': 'Export Successful',
'keychain.export.successMessage': 'Public key exported and attached to {host}',
'keychain.export.failedTitle': 'Export Failed',
'keychain.export.failedMessage': 'Failed to export key: {error}',
'keychain.export.failedPrefix': 'Export failed: {error}',
'keychain.export.exitCode': 'Command exited with code {code}',
'keychain.export.exporting': 'Exporting...',
'keychain.export.exportAndAttach': 'Export and Attach',
'keychain.export.title': 'Key export',
'keychain.export.exportToRequired': 'Export to *',
'keychain.export.selectHostPlaceholder': 'Select a host...',
'keychain.export.locationLabel': 'Location ~ $1 *',
'keychain.export.filenameLabel': 'Filename ~ $2 *',
'keychain.export.advanced': 'Advanced',
'keychain.export.note.supportsOnly': 'Key export currently supports only',
'keychain.export.note.systems': 'systems.',
'keychain.export.note.use': 'Use',
'keychain.export.note.customize': 'section to customize the export script.',
'keychain.export.scriptRequired': 'Script *',
'keychain.export.exportToHost': 'Export to host',
'keychain.export.failedGeneric': 'Export failed: {message}',
'keychain.field.label': 'Label',
'keychain.field.labelRequired': 'Label *',
'keychain.field.labelPlaceholder': 'Key label',
'keychain.field.privateKeyRequired': 'Private key *',
'keychain.field.publicKey': 'Public key',
'keychain.field.certificatePlaceholder': 'Certificate content (optional)',
'keychain.generate.keyType': 'Key type',
'keychain.generate.keySize': 'Key size',
'keychain.generate.labelPlaceholder': 'Key label',
'keychain.generate.passphrasePlaceholder': 'Passphrase (optional)',
'keychain.generate.savePassphrase': 'Save passphrase',
'keychain.generate.generate': 'Generate',
'keychain.generate.generateSave': 'Generate & Save',
'keychain.import.dropHint': 'Drop a key file here',
'keychain.import.importFromFile': 'Import from file',
'keychain.import.saveKey': 'Save Key',
'keychain.import.importedKeyLabel': 'Imported Key',
'keychain.identity.usernameRequired': 'Username *',
'keychain.identity.method.passwordOnly': 'Password',
'keychain.identity.summary.password': 'Auth password',
'keychain.identity.summary.key': 'Auth key',
'keychain.identity.summary.certificate': 'Auth certificate',
'keychain.identity.summary.passwordAndKey': 'Auth password and key',
'keychain.identity.summary.passwordAndCertificate': 'Auth password and certificate',
'keychain.identity.summary.none': 'No credentials',
'keychain.identity.selectCredential': 'Select {kind}',
'keychain.identity.save': 'Save',
'keychain.identity.update': 'Update',
'keychain.keyDialog.newTitle': 'New Key',
'keychain.keyDialog.newDesc': 'Add a new SSH key',
'keychain.keyDialog.editTitle': 'Edit Key',
'keychain.keyDialog.editDesc': 'Update this SSH key',
'keychain.keyDialog.updateKey': 'Update Key',
// Tabs
'tabs.closeSessionAria': 'Close session',
'tabs.closeLogViewAria': 'Close log view',
'tabs.logPrefix': 'Log:',
'tabs.logLocal': 'Local',
'tabs.copyTab': 'Copy Tab',
'tabs.closeOthers': 'Close Others',
'tabs.closeToRight': 'Close Tabs to the Right',
'tabs.closeAll': 'Close All',
'keychain.edit.labelRequired': 'Label *',
'keychain.edit.keyLabelPlaceholder': 'Key label',
'keychain.edit.privateKeyRequired': 'Private key *',
'keychain.edit.publicKey': 'Public key',
'keychain.edit.certificate': 'Certificate',
'keychain.edit.certificatePlaceholder': 'Certificate content (optional)',
'keychain.edit.filePath': 'File path',
'keychain.edit.keyExport': 'Key export',
'keychain.edit.exportToHost': 'Export to host',
// Snippets
'snippets.searchPlaceholder': 'Search snippets...',
'snippets.action.newSnippet': 'New Snippet',
'snippets.action.newPackage': 'New Package',
'snippets.panel.newTitle': 'New Snippet',
'snippets.panel.editTitle': 'Edit Snippet',
'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.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.history.title': 'Shell History',
'snippets.history.subtitle': '{count} commands',
'snippets.history.emptyTitle': 'No shell history yet',
'snippets.history.emptyDesc': 'Commands you execute will appear here',
'snippets.history.loadMore': 'Load more',
'snippets.history.separator': '•',
'snippets.history.labelPlaceholder': 'Set a label for this snippet',
'snippets.history.saveAsSnippet': 'Save as Snippet',
'snippets.history.time.justNow': 'just now',
'snippets.history.time.minutesAgo': '{count}m ago',
'snippets.history.time.hoursAgo': '{count}h ago',
'snippets.history.time.daysAgo': '{count}d ago',
'snippets.breadcrumb.allPackages': 'All 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.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.commandFallback': 'Command',
'snippets.view.grid': 'Grid',
'snippets.view.list': 'List',
'snippets.packageDialog.title': 'New 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 Rename Dialog
'snippets.renameDialog.title': 'Rename 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.field.noAutoRun': 'Paste only (do not auto-execute)',
// Snippet Shortkey
'snippets.field.shortkey': 'Keyboard Shortcut',
'snippets.shortkey.placeholder': 'Click to set shortcut',
'snippets.shortkey.recording': 'Press a key combination...',
'snippets.shortkey.hint': 'Press this shortcut in terminal to quickly send the command.',
'snippets.shortkey.clear': 'Clear shortcut',
'snippets.shortkey.error.systemConflict': 'This shortcut conflicts with a system shortcut',
'snippets.shortkey.error.snippetConflict': 'This shortcut is already used by snippet: {name}',
'snippets.variables.dialogTitle': 'Snippet variables',
'snippets.variables.dialogDesc': 'Fill in values for "{label}" before running.',
'snippets.variables.hint': 'Values are inserted as-is into the script (not shell-escaped).',
'snippets.variables.preview': 'Preview',
'snippets.variables.placeholder': 'Enter a value',
'snippets.variables.placeholderDefault': 'Default: {value}',
'snippets.variables.required': 'This variable is required',
'snippets.variables.run': 'Run',
'snippets.field.variablesHelp': 'Use {{name}} or {{name:default}} for placeholders in the script.',
'snippets.field.variablesDetected': 'Variables',
'snippets.field.variableDefault': 'default {value}',
// Serial Port
'serial.button': 'Serial',
'serial.modal.title': 'Connect to Serial Port',
'serial.modal.desc': 'Configure serial port connection settings',
'serial.field.port': 'Serial Port',
'serial.field.selectPort': 'Select a port...',
'serial.field.baudRate': 'Baud Rate',
'serial.field.dataBits': 'Data Bits',
'serial.field.stopBits': 'Stop Bits',
'serial.field.stopBits15Warning': '1.5 stop bits may not be supported on all Windows devices',
'serial.field.parity': 'Parity',
'serial.field.flowControl': 'Flow Control',
'serial.noPorts': 'No serial ports detected. Connect a device and refresh.',
'serial.field.customPort': 'Custom Port Path',
'serial.field.customPortPlaceholder': 'e.g. /dev/ttys001 or COM1',
'serial.type.hardware': 'Hardware',
'serial.type.pseudo': 'Pseudo Terminal',
'serial.type.custom': 'Custom',
'serial.parity.none': 'None',
'serial.parity.even': 'Even',
'serial.parity.odd': 'Odd',
'serial.parity.mark': 'Mark',
'serial.parity.space': 'Space',
'serial.flowControl.none': 'None',
'serial.flowControl.xon/xoff': 'XON/XOFF (Software)',
'serial.flowControl.rts/cts': 'RTS/CTS (Hardware)',
'serial.field.localEcho': 'Force Local Echo',
'serial.field.localEchoDesc': 'Echo typed characters locally (for devices without remote echo)',
'serial.field.lineMode': 'Line Mode',
'serial.field.lineModeDesc': 'Buffer input and send on Enter (instead of character-by-character)',
'serial.field.charset': 'Charset',
'serial.connectionError': 'Failed to connect to serial port',
'serial.field.baudRatePlaceholder': 'Select or enter baud rate...',
'serial.field.baudRateEmpty': 'Enter a custom baud rate',
'serial.field.customBaudRate': 'Using custom baud rate',
'serial.field.saveConfig': 'Save Configuration',
'serial.field.saveConfigDesc': 'Save this serial configuration to hosts for quick access',
'serial.field.configLabel': 'Configuration Name',
'serial.field.configLabelPlaceholder': 'e.g. Arduino Uno',
'serial.connectAndSave': 'Connect & Save',
'serial.edit.title': 'Serial Port Settings',
// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': 'Authentication Required',
'keyboard.interactive.desc': 'The server requires additional authentication.',
'keyboard.interactive.descWithHost': 'The server {hostname} requires additional authentication.',
'keyboard.interactive.response': 'Response',
'keyboard.interactive.enterCode': 'Enter verification code',
'keyboard.interactive.enterResponse': 'Enter response',
'keyboard.interactive.submit': 'Submit',
'keyboard.interactive.verifying': 'Verifying...',
'keyboard.interactive.savePassword': 'Save password',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH Key Passphrase',
'passphrase.desc': 'Enter the passphrase for {keyName}',
'passphrase.descWithHost': 'Enter the passphrase for {keyName} to connect to {hostname}',
'passphrase.label': 'Passphrase',
'passphrase.keyPath': 'Key',
'passphrase.unlock': 'Unlock',
'passphrase.unlocking': 'Unlocking...',
'passphrase.skip': 'Skip',
'passphrase.remember': 'Remember this passphrase',
// Text Editor
'sftp.editor.wordWrap': 'Word Wrap',
'sftp.editor.maximize': 'Maximize',
'sftp.editor.unsavedTitle': 'Unsaved changes',
'sftp.editor.unsavedMessage': '{fileName} has unsaved changes. Save before closing?',
'sftp.editor.discardChanges': 'Discard',
'sftp.editor.saveAndClose': 'Save and close',
'sftp.editor.quitBlockedByDirty': 'Unsaved editors — please save or discard before quitting',
};

View File

@@ -0,0 +1,658 @@
import type { Messages } from '../types';
export const enVaultMessages: Messages = {
// Vault import
'vault.import.title': 'Add data to your vault',
'vault.import.desc':
'Transfer your connections from popular clients. Select a file format to start the migration.',
'vault.import.chooseFormat': 'Select a file format',
'vault.import.csv.tip': 'Bulk import: use the CSV template.',
'vault.import.csv.downloadTemplate': 'Download CSV template',
'vault.import.toast.start': 'Importing from {format}...',
'vault.import.toast.completedTitle': 'Import completed',
'vault.import.toast.failedTitle': 'Import failed',
'vault.import.toast.noEntries': 'No importable entries found in {format}.',
'vault.import.toast.noNewHosts': 'No new hosts imported from {format}.',
'vault.import.toast.summary':
'Imported {count} hosts (skipped {skipped}, duplicates {duplicates}).',
'vault.import.toast.firstIssue': 'First issue: {issue}',
'vault.import.sshConfig.chooseMode': 'Choose how to import your SSH config file.',
'vault.import.sshConfig.modeQuestion': 'How would you like to import?',
'vault.import.sshConfig.importOnly': 'Import Only',
'vault.import.sshConfig.importOnlyDesc': 'One-time import. Changes won\'t sync back to the file.',
'vault.import.sshConfig.managed': 'Managed Sync',
'vault.import.sshConfig.managedDesc': 'Keep in sync. Changes will be saved back to the file.',
'vault.import.sshConfig.managedGroup': 'ssh config',
'vault.import.sshConfig.managedSuccess': 'Imported {count} hosts. File is now managed.',
'vault.import.sshConfig.alreadyManaged': 'This file is already being managed.',
'vault.import.sshConfig.alreadyManagedDesc': 'This file is already managed under group "{group}". Remove the existing managed source first if you want to re-import.',
'vault.import.sshConfig.noFilePath': 'Cannot manage this file.',
'vault.import.sshConfig.noFilePathDesc': 'Unable to determine the file path. Managed sync requires access to the file system.',
// Known Hosts
'knownHosts.search.placeholder': 'Search known hosts...',
'knownHosts.action.scanSystem': 'Scan System',
'knownHosts.action.importFile': 'Import File',
'knownHosts.action.browseFile': 'Browse File',
'knownHosts.empty.title': 'No Known Hosts',
'knownHosts.empty.desc':
"Known hosts are SSH servers you've connected to before. Import from your system's known_hosts file to get started.",
'knownHosts.results.showingLimited':
'Showing {shown} of {total} hosts. Use search to find specific hosts.',
'knownHosts.toast.scanUnavailable': 'System scan is unavailable on this platform.',
'knownHosts.toast.scanNoFile': 'No system known_hosts file found.',
'knownHosts.toast.scanNoEntries': 'No usable entries found in known_hosts.',
'knownHosts.toast.scanImported': 'Imported {count} new hosts.',
'knownHosts.toast.scanNoNew': 'No new hosts found.',
'knownHosts.toast.scanFailed': 'Failed to scan system known_hosts.',
// Port Forwarding
'pf.empty.title': 'Set up port forwarding',
'pf.empty.desc': 'Save port forwarding to access databases, web apps, and other services.',
'pf.title': 'Port Forwarding',
'pf.rulesCount': '{count} rules',
'pf.wizard.editTitle': 'Edit Port Forwarding',
'pf.wizard.newTitle': 'New Port Forwarding',
'pf.wizard.saveChanges': 'Save Changes',
'pf.wizard.done': 'Done',
'pf.wizard.continue': 'Continue',
'pf.wizard.cancel': 'Cancel',
'pf.wizard.skipWizard': 'Skip wizard',
'pf.error.hostNotFound': 'Host not found',
'pf.toast.titleWithLabel': 'Port Forwarding: {label}',
'pf.type.local': 'Local',
'pf.type.remote': 'Remote',
'pf.type.dynamic': 'Dynamic',
'pf.type.menu.local': 'Local Forwarding',
'pf.type.menu.remote': 'Remote Forwarding',
'pf.type.menu.dynamic': 'Dynamic Forwarding',
'pf.type.local.desc': "Local forwarding lets you access a remote server's listening port as though it were local.",
'pf.type.remote.desc': 'Remote forwarding opens a port on the remote machine and forwards connections to the local (current) host.',
'pf.type.dynamic.desc': 'Dynamic port forwarding turns Netcatty into a SOCKS proxy server.',
'pf.wizard.type.title': 'Select the port forwarding type:',
'pf.wizard.localConfig.title': 'Set the local port and binding address:',
'pf.wizard.localConfig.desc': 'This port will be open on the local (current) device, and it will receive the traffic.',
'pf.wizard.localConfig.localPort': 'Local port number *',
'pf.wizard.bindAddress': 'Bind address',
'pf.wizard.remoteHost.title': 'Select the remote host:',
'pf.wizard.remoteHost.desc': 'Select a host where the port will be open. Traffic from this port will be forwarded to the destination host.',
'pf.wizard.remoteConfig.title': 'Set the port and binding address:',
'pf.wizard.remoteConfig.desc': 'Traffic will be forwarded from the specified port and interface address of the selected host.',
'pf.wizard.remoteConfig.remotePort': 'Remote port number *',
'pf.wizard.destination.title': 'Select the destination host:',
'pf.wizard.destination.desc.local': 'Enter the remote destination that you want to access through the tunnel.',
'pf.wizard.destination.desc.remote': 'The destination address and port where the traffic will be forwarded.',
'pf.wizard.destination.address': 'Destination address *',
'pf.wizard.destination.addressPlaceholder': 'e.g. 127.0.0.1 or 192.168.1.100',
'pf.wizard.destination.port': 'Destination port number *',
'pf.wizard.sshServer.title': 'Select the SSH server:',
'pf.wizard.sshServer.desc.dynamic': 'Select the SSH server that will act as your SOCKS proxy.',
'pf.wizard.sshServer.desc.default': 'Select the SSH server that will tunnel your traffic to the destination.',
'pf.wizard.label.title': 'Select the label:',
'pf.wizard.label.placeholder.dynamic': 'e.g. SOCKS Proxy',
'pf.wizard.label.placeholder.default': 'e.g. MySQL Production',
'pf.wizard.label.placeholder.remoteRule': 'e.g. Remote Rule',
'pf.wizard.placeholders.portExample': 'e.g. {port}',
'pf.action.newForwarding': 'New Forwarding',
'pf.form.labelPlaceholder': 'Rule label',
'pf.form.intermediateHost': 'Intermediate host *',
'pf.form.createRule': 'Create Rule',
'pf.form.openWizard': 'Open Wizard',
'pf.form.openWizardTitle': 'Open Port Forwarding Wizard',
'pf.view.grid': 'Grid',
'pf.view.list': 'List',
'pf.rule.summary.dynamic': 'SOCKS on {bindAddress}:{localPort}',
'pf.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
'pf.tooltip.relayHost': 'Relay Host',
'pf.tooltip.hostLabel': 'Host',
'pf.tooltip.hostAddress': 'Address',
'pf.tooltip.noHost': 'No relay host configured',
'pf.tooltip.localDesc': 'Local port forwarding: Access remote services through SSH tunnel',
'pf.tooltip.remoteDesc': 'Remote port forwarding: Expose local services to remote host',
'pf.tooltip.dynamicDesc': 'Dynamic SOCKS proxy: Route traffic through SSH tunnel',
'pf.deleteActive.title': 'Delete Active Port Forwarding?',
'pf.deleteActive.desc': 'This port forwarding rule "{label}" is currently active. Deleting it will stop the tunnel first.',
'pf.deleteActive.confirm': 'Stop and Delete',
'pf.form.autoStart': 'Auto Start',
'pf.form.autoStartDesc': 'Automatically start this rule when the app launches',
// SFTP
'sftp.newFolder': 'New Folder',
'sftp.newFile': 'New File',
'sftp.filter': 'Filter',
'sftp.filter.placeholder': 'Filter by filename...',
'sftp.bookmark.add': 'Bookmark this path',
'sftp.bookmark.remove': 'Remove bookmark',
'sftp.bookmark.addGlobal': '+Global',
'sftp.bookmark.addGlobalTooltip': 'Save as global bookmark (shared across all hosts)',
'sftp.bookmark.empty': 'No bookmarks yet',
'sftp.columns.name': 'Name',
'sftp.columns.modified': 'Modified',
'sftp.columns.size': 'Size',
'sftp.columns.kind': 'Kind',
'sftp.columns.actions': 'Actions',
'sftp.emptyDirectory': 'Empty directory',
'sftp.nav.up': 'Go up',
'sftp.nav.home': 'Go to home',
'sftp.nav.refresh': 'Refresh',
'sftp.upload': 'Upload',
'sftp.uploadFiles': 'Upload files',
'sftp.uploadFolder': 'Upload folder',
'sftp.dragDropToUpload': 'Drag and drop files here to upload',
'sftp.retry': 'Retry',
'sftp.context.open': 'Open',
'sftp.context.navigateTo': 'Navigate to',
'sftp.context.moveTo': 'Move to...',
'sftp.context.moveToParent': 'Move to parent directory',
'sftp.moveTo.title': 'Move to directory',
'sftp.moveTo.placeholder': 'Enter target directory path',
'sftp.moveTo.confirm': 'Move',
'sftp.moveTo.pathNotFound': 'Directory not found or inaccessible',
'sftp.context.download': 'Download',
'sftp.context.copyToOtherPane': 'Copy to other pane',
'sftp.viewMode.label': 'View mode',
'sftp.viewMode.list': 'List view',
'sftp.viewMode.tree': 'Tree view',
'sftp.tree.loadError': 'Failed to load directory',
'sftp.tree.loading': 'Loading...',
'sftp.kind.folder': 'Folder',
'sftp.context.rename': 'Rename',
'sftp.context.permissions': 'Permissions',
'sftp.context.delete': 'Delete',
'sftp.context.refresh': 'Refresh',
'sftp.context.uploadFiles': 'Upload File(s)...',
'sftp.context.uploadFilesHere': 'Upload File(s) Here...',
'sftp.context.uploadFolder': 'Upload Folder...',
'sftp.context.uploadFolderHere': 'Upload Folder Here...',
'sftp.context.downloadSelected': 'Download selected ({count})',
'sftp.context.deleteSelected': 'Delete selected ({count})',
'sftp.dropFilesHere': 'Drop files here',
'sftp.itemsCount': '{count} items',
'sftp.selectedCount': '{count} selected',
'sftp.path.doubleClickToEdit': 'Double-click to edit path',
'sftp.showHiddenPaths': 'Hidden paths',
'sftp.task.waiting': 'Waiting...',
'sftp.transfer.preparing': 'preparing...',
'sftp.status.loading': 'Loading...',
'sftp.status.uploading': 'Uploading...',
'sftp.status.ready': 'Ready',
'sftp.transfers': 'Transfers',
'sftp.transfers.active': '{count} active',
'sftp.transfers.clearCompleted': 'Clear completed',
'sftp.transfers.calculatingTotal': 'Calculating total size...',
'sftp.transfers.filesCount': '{count} files',
'sftp.transfers.filesProgress': '{current}/{total} files',
'sftp.transfers.expandChildren': 'Show files',
'sftp.transfers.collapseChildren': 'Hide files',
'sftp.transfers.expandChildList': 'Show detail',
'sftp.transfers.collapseChildList': 'Hide',
'sftp.transfers.retryAction': 'Retry',
'sftp.transfers.dismissAction': 'Dismiss',
'sftp.transfers.openTargetFolder': 'Open target folder',
'sftp.transfers.openTargetFolderError': 'Could not open target folder',
'sftp.transfers.copyTargetPath': 'Copy target path',
'sftp.transfers.copyTargetPathSuccess': 'Target path copied',
'sftp.transfers.copyTargetPathError': 'Could not copy target path',
'sftp.transfers.resizeNameColumn': 'Resize file name column',
'sftp.transfers.dragToResize': 'Drag to resize',
'sftp.goUp': 'Go up',
'sftp.goToTerminalCwd': 'Go to terminal directory',
'sftp.encoding.label': 'Filename Encoding',
'sftp.encoding.auto': 'Auto',
'sftp.encoding.utf8': 'UTF-8',
'sftp.encoding.gb18030': 'GB18030',
'sftp.goHome': 'Go to home',
'sftp.folderName': 'Folder name',
'sftp.folderName.placeholder': 'Enter folder name',
'sftp.fileName': 'File name',
'sftp.fileName.placeholder': 'Enter file name',
'sftp.prompt.newFolderName': 'New folder name?',
'sftp.rename.title': 'Rename',
'sftp.rename.newName': 'New name',
'sftp.rename.placeholder': 'Enter new name',
'sftp.confirm.deleteOne': 'Delete "{name}"?',
'sftp.deleteConfirm.single': 'Delete "{name}"?',
'sftp.deleteConfirm.title': 'Delete {count} item(s)?',
'sftp.deleteConfirm.desc': 'This action cannot be undone. The following will be deleted:',
'sftp.deleteConfirm.descSingle': 'This action cannot be undone.',
'sftp.deleteConfirm.host': 'Host',
'sftp.deleteConfirm.path': 'Path',
'sftp.error.loadFailed': 'Failed to load directory',
'sftp.error.downloadFailed': 'Download failed',
'sftp.error.uploadFailed': 'Upload failed',
'sftp.error.deleteFailed': 'Delete failed',
'sftp.error.createFolderFailed': 'Failed to create folder',
'sftp.error.createFileFailed': 'Failed to create file',
'sftp.error.invalidFileName': 'Filename contains invalid characters: {chars}',
'sftp.error.reservedName': 'This filename is reserved by the system',
'sftp.overwrite.title': 'File Already Exists',
'sftp.overwrite.desc': 'A file named "{name}" already exists. Do you want to replace it?',
'sftp.overwrite.confirm': 'Replace',
'sftp.error.renameFailed': 'Failed to rename',
'sftp.picker.title': 'Select Host',
'sftp.picker.desc': 'Pick a host for the {side} pane',
'sftp.picker.searchPlaceholder': 'Search hosts...',
'sftp.picker.local.title': 'Local filesystem',
'sftp.picker.local.desc': 'Browse local files',
'sftp.picker.local.badge': 'Local',
'sftp.picker.noMatch': 'No matching hosts',
'sftp.permissions.title': 'Edit Permissions',
'sftp.permissions.owner': 'Owner',
'sftp.permissions.group': 'Group',
'sftp.permissions.others': 'Others',
'sftp.permissions.octal': 'Octal',
'sftp.permissions.symbolic': 'Symbolic',
'sftp.permissions.success': 'Permissions updated successfully',
'sftp.permissions.failed': 'Failed to update permissions',
'sftp.pane.local': 'Local',
'sftp.pane.remote': 'Remote',
'sftp.pane.selectHost': 'Select host',
'sftp.pane.selectHostToStart': 'Select a host to start',
'sftp.pane.chooseFilesystem': 'Choose a local or remote filesystem to browse',
'sftp.tabs.addTab': 'Add new tab',
'sftp.tabs.closeTab': 'Close tab',
'sftp.tabs.newTab': 'New Tab',
'sftp.conflict.title': 'File Conflict',
'sftp.conflict.desc': 'A file with the same name already exists at the destination',
'sftp.conflict.alreadyExistsSuffix': 'already exists',
'sftp.conflict.existingFile': 'Existing file',
'sftp.conflict.newFile': 'New file',
'sftp.conflict.size': 'Size:',
'sftp.conflict.modified': 'Modified:',
'sftp.conflict.applyToAll': 'Apply this action to all {count} remaining conflicts',
'sftp.conflict.action.stop': 'Stop',
'sftp.conflict.action.skip': 'Skip',
'sftp.conflict.action.keepBoth': 'Keep Both',
'sftp.conflict.action.duplicate': 'Duplicate',
'sftp.conflict.action.merge': 'Merge',
'sftp.conflict.action.replace': 'Replace',
// SFTP Upload Phases
'sftp.upload.phase.compressing': 'Compressing',
'sftp.upload.phase.uploading': 'Uploading',
'sftp.upload.phase.extracting': 'Extracting',
'sftp.upload.phase.compressed': 'Compressed',
// SFTP File Opener
'sftp.context.copyPath': 'Copy file path',
'sftp.context.openWithDefault': 'Open with system default',
'sftp.context.openWith': 'Open with...',
'sftp.context.edit': 'Edit',
'sftp.context.preview': 'Preview',
'sftp.opener.title': 'Open with',
'sftp.opener.desc': 'Choose an application to open this file',
'sftp.opener.builtInEditor': 'Built-in Editor',
'sftp.opener.editDescription': 'Edit text files',
'sftp.opener.builtInImageViewer': 'Built-in Image Viewer',
'sftp.opener.previewDescription': 'Preview images',
'sftp.opener.systemApp': 'Choose Application...',
'sftp.opener.systemAppDescription': 'Select an application from your computer',
'sftp.opener.onlySystemApp': 'This file can only be opened with an external application',
'sftp.opener.noAppsAvailable': 'No applications available',
'sftp.opener.noExtension': 'files without extension',
'sftp.opener.setDefault': 'Always use this for {ext} files',
'sftp.opener.confirmTitle': 'Set as Default?',
'sftp.opener.confirmDescription': 'Do you want to always use {app} for {ext} files?',
'sftp.opener.yesRemember': 'Yes, remember this choice',
'sftp.opener.justOnce': 'Just this once',
'sftp.opener.confirm.title': 'Set Default Application',
'sftp.opener.confirm.desc': 'Do you want to always open .{ext} files with this application?',
'sftp.editor.title': 'Text Editor',
'sftp.editor.save': 'Save to Remote',
'sftp.editor.saving': 'Saving...',
'sftp.editor.saved': 'Saved successfully',
'sftp.editor.saveFailed': 'Failed to save file',
'sftp.editor.unsavedChanges': 'You have unsaved changes. Close anyway?',
'sftp.editor.syntaxHighlight': 'Syntax Highlighting',
'sftp.preview.title': 'Image Preview',
'sftp.preview.zoomIn': 'Zoom In',
'sftp.preview.zoomOut': 'Zoom Out',
'sftp.preview.resetZoom': 'Reset Zoom',
'sftp.preview.fitToWindow': 'Fit to Window',
// Settings > SFTP File Associations
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftp.transferConcurrency': 'Transfer Concurrency',
'settings.sftp.transferConcurrency.desc': 'Number of files to transfer in parallel when uploading or downloading folders. Higher values may improve speed but can overwhelm some servers.',
'settings.sftp.defaultOpener': 'Default File Opener',
'settings.sftp.defaultOpener.desc': 'Choose the default application for opening files without a specific file association',
'settings.sftp.defaultOpener.ask': 'Always ask',
'settings.sftp.defaultOpener.askDesc': 'Show a dialog to choose an application each time',
'settings.sftp.defaultOpener.builtInDesc': 'Open text files in the built-in editor by default',
'settings.sftp.defaultOpener.systemApp': 'Choose Application...',
'settings.sftp.defaultOpener.systemAppDesc': 'Open files with a specific application by default',
'settings.sftpFileAssociations.title': 'SFTP File Associations',
'settings.sftpFileAssociations.desc': 'Configure default applications for opening files by extension',
'settings.sftpFileAssociations.extension': 'Extension',
'settings.sftpFileAssociations.application': 'Application',
'settings.sftpFileAssociations.noAssociations': 'No file associations configured',
'settings.sftpFileAssociations.remove': 'Remove',
'settings.sftpFileAssociations.removeConfirm': 'Remove association for .{ext}?',
// Settings > SFTP Behavior
'settings.sftp.doubleClickBehavior': 'Double-click behavior',
'settings.sftp.doubleClickBehavior.desc': 'Choose the action when double-clicking a file in SFTP View',
'settings.sftp.doubleClickBehavior.open': 'Open file',
'settings.sftp.doubleClickBehavior.transfer': 'Transfer to other pane',
'settings.sftp.doubleClickBehavior.openDesc': 'Open the file in the default application',
'settings.sftp.doubleClickBehavior.transferDesc': 'Transfer the file to the other pane\'s active host',
// Settings > SFTP Auto Sync
'settings.sftp.autoSync': 'Auto-sync to remote',
'settings.sftp.autoSync.desc': 'Automatically sync file changes back to the remote server when opening files with external applications',
'settings.sftp.autoSync.enable': 'Enable auto-sync',
'settings.sftp.autoSync.enableDesc': 'When you save a file in an external application, changes will be automatically uploaded to the remote server',
// Settings > SFTP Auto Open Sidebar
'settings.sftp.autoOpenSidebar': 'Auto-open sidebar on connect',
'settings.sftp.autoOpenSidebar.desc': 'Automatically open the SFTP file browser sidebar when connecting to a host',
'settings.sftp.autoOpenSidebar.enable': 'Enable auto-open sidebar',
'settings.sftp.autoOpenSidebar.enableDesc': 'The SFTP sidebar will open automatically when a terminal session connects to a remote host',
'settings.sftp.defaultViewMode': 'Default View Mode',
'settings.sftp.defaultViewMode.desc': 'Choose the default view mode when opening a new SFTP tab. Per-host preferences override this setting.',
'settings.sftp.defaultViewMode.list': 'List View',
'settings.sftp.defaultViewMode.listDesc': 'Display files in a flat list for the current directory',
'settings.sftp.defaultViewMode.tree': 'Tree View',
'settings.sftp.defaultViewMode.treeDesc': 'Display files in a hierarchical tree structure',
'sftp.autoSync.success': 'File synced to remote: {fileName}',
'sftp.autoSync.error': 'Failed to sync file: {error}',
// SFTP Folder Upload Progress
'sftp.upload.progress': 'Uploading {current} of {total} files...',
'sftp.upload.uploading': 'Uploading...',
'sftp.upload.compressing': 'Compressing...',
'sftp.upload.extracting': 'Extracting...',
'sftp.upload.scanning': 'Scanning files...',
'sftp.upload.completed': 'Completed',
'sftp.upload.compressed': 'Compressed Transfer',
'sftp.upload.currentFile': 'Current: {fileName}',
'sftp.upload.cancelled': 'Upload cancelled',
'sftp.upload.cancel': 'Cancel',
'sftp.upload.completedToPath': 'Uploaded to {path}',
// SFTP Download
'sftp.download.completed': 'Downloaded',
'sftp.download.cancelled': 'Download cancelled',
// SFTP Reconnecting
'sftp.reconnecting.title': 'Reconnecting...',
'sftp.reconnecting.desc': 'Connection lost, attempting to reconnect',
'sftp.reconnected': 'Connection restored',
'sftp.error.reconnectFailed': 'Failed to reconnect. Please try again.',
'sftp.error.connectionLostManual': 'Connection lost. Please reconnect manually.',
'sftp.error.connectionLostReconnecting': 'Connection lost. Reconnecting...',
'sftp.error.sessionLost': 'SFTP session lost. Please reconnect.',
// Settings > SFTP Show Hidden Files
'settings.sftp.showHiddenFiles': 'Show hidden files',
'settings.sftp.showHiddenFiles.desc': 'Display hidden files (dotfiles on Unix/macOS and files with the hidden attribute on Windows) in the SFTP file browser.',
'settings.sftp.showHiddenFiles.enable': 'Show hidden files',
'settings.sftp.showHiddenFiles.enableDesc': 'Display hidden files when browsing both local and remote filesystems',
// Settings > SFTP Compressed Upload
'settings.sftp.compressedUpload': 'Folder Compression Transfer',
'settings.sftp.compressedUpload.desc': 'Compress folders before uploading to significantly reduce transfer time.',
'settings.sftp.compressedUpload.enable': 'Enable folder compression',
'settings.sftp.compressedUpload.enableDesc': 'Automatically compress folders using tar before transfer. Requires tar support on the server. Falls back to regular transfer if not available.',
// Quick Switcher
'qs.search.placeholder': 'Search hosts or tabs',
'qs.jumpTo': 'Jump To',
'qs.localTerminal': 'Local Terminal',
'qs.localShells': 'Local Shells',
'qs.default': 'Default',
// Select Host panel
'selectHost.title': 'Select Host',
'selectHost.noHostsFound': 'No hosts found',
'selectHost.newHost': 'New Host',
'selectHost.continue': 'Continue',
'selectHost.continueWithCount': 'Continue ({count} selected)',
// Quick Connect
'quickConnect.knownHost.title': 'Are you sure you want to connect?',
'quickConnect.knownHost.authenticity': 'The authenticity of {hostname} can not be established.',
'quickConnect.knownHost.fingerprintLabel': '{keyType} fingerprint is SHA256:',
'quickConnect.knownHost.addQuestion': 'Do you want to add it to the list of known hosts?',
'quickConnect.knownHost.addAndContinue': 'Add and continue',
'quickConnect.addKey': 'Add key',
'quickConnect.warning.unparsedOptions': 'Some SSH arguments were ignored: {options}',
// Terminal
'terminal.connectionErrorTitle': 'Connection Error',
// Protocol select dialog
'protocolSelect.chooseProtocol': 'Choose protocol',
'protocolSelect.port': 'port:',
// Host Details
'hostDetails.title.details': 'Host Details',
'hostDetails.title.new': 'New Host',
'hostDetails.saveAria': 'Save',
'hostDetails.section.address': 'Address',
'hostDetails.hostname.placeholder': 'IP or Hostname',
'hostDetails.section.general': 'General',
'hostDetails.section.sftp': 'SFTP Settings',
'hostDetails.sftp.sudo': 'Sudo Mode',
'hostDetails.sftp.sudo.desc': 'Automatically acquire Root privileges using stored password',
'hostDetails.sftp.sudo.passwordWarning': 'Sudo mode requires a password. Configure one above, or ensure the server allows passwordless sudo.',
'hostDetails.sftp.encoding': 'Filename Encoding',
'hostDetails.sftp.encoding.desc': 'Select the encoding used to decode and send SFTP filenames.',
'hostDetails.label.placeholder': 'Label (e.g., Production Server)',
'hostDetails.notes.label': 'Notes',
'hostDetails.notes.placeholder': 'Hardware, project, customer, region, role...',
'hostDetails.notes.help': 'Supports Markdown. Do not store passwords or private keys here.',
'hostDetails.notes.tab.edit': 'Edit',
'hostDetails.notes.tab.preview': 'Preview',
'hostDetails.notes.preview.empty': 'Nothing to preview yet.',
'hostDetails.group.placeholder': 'Parent Group',
'hostDetails.section.credentials': 'Credentials',
'hostDetails.section.portCredentials': 'Port & Credentials',
'hostDetails.section.appearance': 'Appearance',
'hostDetails.distro.title': 'Linux Distribution',
'hostDetails.distro.desc': 'Auto-detect on connect, or override the distro icon manually.',
'hostDetails.distro.mode': 'Source',
'hostDetails.distro.mode.auto': 'Auto-detect',
'hostDetails.distro.mode.manual': 'Manual override',
'hostDetails.distro.detectedLabel': 'Current',
'hostDetails.distro.manualLabel': 'Override',
'hostDetails.distro.pending': 'Detect after first connection',
'hostDetails.distro.unknown': 'Unknown',
'hostDetails.distro.option.linux': 'Generic Linux',
'hostDetails.distro.option.ubuntu': 'Ubuntu',
'hostDetails.distro.option.debian': 'Debian',
'hostDetails.distro.option.centos': 'CentOS',
'hostDetails.distro.option.rocky': 'Rocky Linux',
'hostDetails.distro.option.fedora': 'Fedora',
'hostDetails.distro.option.arch': 'Arch Linux',
'hostDetails.distro.option.alpine': 'Alpine',
'hostDetails.distro.option.amazon': 'Amazon Linux',
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
'hostDetails.distro.option.almalinux': 'AlmaLinux',
'hostDetails.distro.option.alinux': 'Alibaba Cloud Linux',
'hostDetails.distro.option.oracle': 'Oracle Linux',
'hostDetails.distro.option.kali': 'Kali Linux',
'hostDetails.distro.option.cisco': 'Cisco',
'hostDetails.distro.option.juniper': 'Juniper Networks',
'hostDetails.distro.option.huawei': 'Huawei',
'hostDetails.distro.option.hpe': 'HPE / H3C',
'hostDetails.distro.option.mikrotik': 'MikroTik',
'hostDetails.distro.option.fortinet': 'Fortinet',
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
'hostDetails.distro.option.zyxel': 'ZyXEL',
'hostDetails.distro.option.ruijie': 'Ruijie',
'hostDetails.section.mosh': 'Mosh',
'hostDetails.section.et': 'EternalTerminal',
'hostDetails.et.port': 'ET server port',
'hostDetails.et.port.desc': 'Port etserver listens on (default 2022)',
'hostDetails.username.placeholder': 'Username',
'hostDetails.password.placeholder': 'Password',
'hostDetails.password.show': 'Show password',
'hostDetails.password.hide': 'Hide password',
'hostDetails.password.save': 'Save password',
'hostDetails.identity.suggestions': 'Identities',
'hostDetails.identity.missing': 'Identity not found',
'hostDetails.credential.keyCertificate': 'Key, Certificate, Local Key File',
'hostDetails.credential.key': 'Key',
'hostDetails.credential.certificate': 'Certificate',
'hostDetails.credential.localKeyFile': 'Local Key File',
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
'hostDetails.credential.browseKeyFile': 'Browse...',
'hostDetails.credential.missing': 'Credential not found',
'hostDetails.keys.search': 'Search keys...',
'hostDetails.keys.empty': 'No keys available',
'hostDetails.certs.search': 'Search certificates...',
'hostDetails.certs.empty': 'No certificates available',
'hostDetails.agentForwarding': 'Forward SSH Agent',
'hostDetails.agentForwarding.desc': 'Allow remote server to use your local SSH keys (e.g., for git operations)',
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not available',
'hostDetails.agentForwarding.agentNotRunningHint': 'No SSH agent detected. Enable OpenSSH Authentication Agent in Windows Services, or use a compatible agent such as Bitwarden, 1Password, or gpg-agent.',
'hostDetails.section.agentForwarding': 'SSH Agent',
'hostDetails.x11Forwarding': 'Forward X11 apps',
'hostDetails.x11Forwarding.desc': 'Show remote graphical apps on your local desktop when a local X server is running.',
'hostDetails.section.x11Forwarding': 'X11 Forwarding',
'hostDetails.section.deviceType': 'Device Type',
'hostDetails.deviceType': 'Network Device Mode',
'hostDetails.deviceType.desc': 'Enable for network equipment (switches, routers, firewalls) connected via SSH. Commands are sent as-is without shell wrapping, compatible with vendor CLIs like Huawei VRP and Cisco IOS.',
'hostDetails.deviceType.warning': 'AI agent commands will be sent directly without exit code tracking. Only enable for devices that do not run a standard shell.',
'hostDetails.section.sshAlgorithms': 'SSH Algorithms',
'hostDetails.section.terminalBehavior': 'Terminal Behavior',
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',
'hostDetails.skipEcdsaHostKey': 'Skip ECDSA host key',
'hostDetails.skipEcdsaHostKey.desc': 'Some old Huawei / Cisco switches produce non-standard ECDSA host-key signatures that cause "signature verification failed". Turning this on drops every ecdsa-sha2-* from the client offer so negotiation falls back to RSA / Ed25519.',
'hostDetails.algorithms.advanced': 'Advanced algorithm overrides',
'hostDetails.algorithms.advanced.desc': 'Replace the offered algorithm list for any category on a per-host basis. Leaving a category untouched uses the default; selecting a subset fully replaces the default list. Incorrect values can make the host unreachable.',
'hostDetails.algorithms.inheritedNotice': 'The current group has algorithm overrides set for: {categories}. The "Reset" button here falls back to the group\'s lists, not NetCatty\'s defaults. To ignore the group restriction, clear the override in the group\'s algorithm settings.',
'hostDetails.algorithms.customized': 'customized',
'hostDetails.algorithms.reset': 'Reset',
'hostDetails.algorithms.category.kex': 'Key Exchange (KEX)',
'hostDetails.algorithms.category.cipher': 'Cipher',
'hostDetails.algorithms.category.hmac': 'MAC (HMAC)',
'hostDetails.algorithms.category.serverHostKey': 'Host Key',
'hostDetails.algorithms.category.compress': 'Compression',
'hostDetails.section.keepalive': 'Keepalive',
'hostDetails.keepalive.override': 'Override global keepalive',
'hostDetails.keepalive.desc': 'Use a custom keepalive policy for this host instead of the global setting. Useful for older routers or switches whose SSH server does not reply to keepalive@openssh.com requests — set interval to 0 to disable keepalive entirely on this host.',
'hostDetails.keepalive.interval': 'Interval (seconds)',
'hostDetails.keepalive.countMax': 'Max unanswered keepalives',
'hostDetails.keepalive.disabledHint': 'Interval = 0 disables keepalive for this host. The session will rely on TCP-level timeouts to detect a dead connection.',
'hostDetails.backspaceBehavior': 'Backspace Behavior',
'hostDetails.backspaceBehavior.default': 'Default',
'hostDetails.jumpHosts': 'Proxy via Hosts',
'hostDetails.jumpHosts.hops': '{count} hop(s)',
'hostDetails.jumpHosts.direct': 'Direct',
'hostDetails.jumpHosts.configure': 'Configure Proxy Hosts',
'hostDetails.proxy': 'Proxy via HTTP/SOCKS5/Command',
'hostDetails.proxy.none': 'None',
'hostDetails.proxy.edit': 'Edit Proxy',
'hostDetails.proxy.configure': 'Configure Proxy',
'hostDetails.proxyPanel.title': 'Proxy via HTTP/SOCKS5/Command',
'hostDetails.proxyPanel.hostPlaceholder': 'Proxy host',
'hostDetails.proxyPanel.command': 'ProxyCommand',
'hostDetails.proxyPanel.commandPlaceholder': 'cloudflared access ssh --hostname %h',
'hostDetails.proxyPanel.commandHelp': 'Use %h for the target host, %p for the target port, and %% for a literal percent.',
'hostDetails.proxyPanel.credentials': 'Credentials',
'hostDetails.proxyPanel.usernamePlaceholder': 'Username',
'hostDetails.proxyPanel.passwordPlaceholder': 'Password',
'hostDetails.proxyPanel.identities': 'Identities',
'hostDetails.proxyPanel.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.error.required': 'Proxy host and port, or a ProxyCommand, are required.',
'hostDetails.envVars': 'Environment Variables',
'hostDetails.envVars.add': 'Add Environment Variable',
'hostDetails.envVars.title': 'Environment Variables',
'hostDetails.envVars.desc': 'Set an environment variable for {host}.',
'hostDetails.envVars.note':
'Some SSH servers by default only allow variables with prefix LC_ and LANG_.',
'hostDetails.envVars.variable': 'Variable',
'hostDetails.envVars.value': 'Value',
'hostDetails.envVars.newVariable': 'New Variable',
'hostDetails.envVars.variableName': 'Variable name',
'hostDetails.chain.title': 'Edit Chain',
'hostDetails.chain.desc': 'Adding another host will create a connection to {host}.',
'hostDetails.chain.addHost': 'Add a Host',
'hostDetails.chain.target': 'Target',
'hostDetails.chain.availableHosts': 'Available Hosts',
'hostDetails.chain.clear': 'Clear',
'hostDetails.group.title': 'New Group',
'hostDetails.group.general': 'General',
'hostDetails.group.namePlaceholder': 'Group name',
'hostDetails.group.parentPlaceholder': 'Parent Group',
'hostDetails.group.cloudSync': 'Cloud Sync',
'hostDetails.group.addProtocol': 'Add protocol',
'hostDetails.startupCommand': 'Startup Command',
'hostDetails.startupCommand.placeholder': 'Command to run on connect (e.g., cd /app && ls)',
'hostDetails.startupCommand.help':
'This command will be executed automatically after SSH connection is established.',
'hostDetails.otherProtocols': 'Other Protocols',
'hostDetails.telnetOn': 'Telnet on',
'hostDetails.port': 'port',
'hostDetails.telnet.credentials': 'Credentials',
'hostDetails.telnet.username': 'Telnet Username',
'hostDetails.telnet.password': 'Telnet Password',
'hostDetails.charset.placeholder': 'Charset (e.g. UTF-8)',
'hostDetails.telnet.add': 'Add Telnet Protocol',
'hostDetails.telnet.setDefault': 'Connect with Telnet by default',
'hostDetails.tags': 'Tags',
'hostDetails.group': 'Group',
'hostDetails.selectGroup': 'Select Group',
'hostDetails.addTag': 'Add a tag...',
'hostDetails.createTag': 'Create tag',
'hostDetails.createGroup': 'Create group',
// Host form (legacy modal)
'hostForm.title.edit': 'Edit Host',
'hostForm.title.new': 'New Host',
'hostForm.desc.edit': 'Update connection details for this host',
'hostForm.desc.new': 'Create a new SSH host entry',
'hostForm.field.label': 'Label',
'hostForm.placeholder.label': 'My Production Server',
'hostForm.field.hostname': 'Hostname / IP',
'hostForm.placeholder.hostname': '192.168.1.1',
'hostForm.field.port': 'Port',
'hostForm.field.username': 'Username',
'hostForm.field.osType': 'OS Type',
'hostForm.placeholder.selectOs': 'Select OS',
'hostForm.field.group': 'Group',
'hostForm.placeholder.group': 'e.g. AWS, DigitalOcean',
'hostForm.field.tags': 'Tags',
'hostForm.placeholder.addTag': 'Add a tag...',
'hostForm.auth.method': 'Authentication Method',
'hostForm.auth.password': 'Password',
'hostForm.auth.sshKey': 'SSH Key',
'hostForm.auth.selectKey': 'Select an SSH Key',
'hostForm.auth.noKeys': 'No keys available',
'hostForm.auth.noKeysHint': 'No SSH keys found in Keychain. Please create one first.',
'hostForm.saveHost': 'Save Host',
// Connection logs
'logs.table.date': 'Date',
'logs.table.user': 'User',
'logs.table.host': 'Host',
'logs.table.saved': 'Saved',
'logs.empty.title': 'No Connection Logs',
'logs.empty.desc':
'Your connection history will appear here when you connect to hosts or open local terminals.',
'logs.loadMore': 'Load {count} more logs',
'logs.ongoing': 'ongoing',
'logs.localTerminal': 'Local Terminal',
'logs.action.save': 'Save',
'logs.action.unsave': 'Unsave',
'logs.action.delete': 'Delete',
// Log view
'logView.customizeAppearance': 'Customize appearance',
'logView.appearance': 'Appearance',
'logView.readOnly': 'Read-only',
'logView.export': 'Export',
};

View File

@@ -0,0 +1,16 @@
import type { Messages } from './types';
import { ruCoreMessages } from './ru/core';
import { ruVaultMessages } from './ru/vault';
import { ruTerminalMessages } from './ru/terminal';
import { ruAiMessages } from './ru/ai';
export type { Messages } from './types';
const ru: Messages = {
...ruCoreMessages,
...ruVaultMessages,
...ruTerminalMessages,
...ruAiMessages,
};
export default ru;

View File

@@ -0,0 +1,254 @@
import type { Messages } from '../types';
export const ruAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Настройки агента',
'ai.title': 'AI',
'ai.description': 'Настройка AI-провайдеров, агентов и параметров безопасности',
'ai.providers': 'Провайдеры',
'ai.providers.empty': 'Провайдеры не настроены. Добавьте провайдера, чтобы начать.',
'ai.providers.add': 'Добавить провайдера',
'ai.providers.active': 'Активен',
'ai.providers.apiKeyConfigured': 'API-ключ настроен',
'ai.providers.noApiKey': 'Нет API-ключа',
'ai.providers.configure': 'Настроить',
'ai.providers.remove': 'Удалить',
'ai.providers.name': 'Отображаемое имя',
'ai.providers.name.placeholder': 'например, Мой провайдер',
'ai.providers.style': 'Стиль протокола',
'ai.providers.style.anthropic': 'Совместимый с Anthropic',
'ai.providers.style.openai': 'Совместимый с OpenAI',
'ai.providers.style.google': 'Совместимый с Google',
'ai.providers.style.inherited': 'авто',
'ai.providers.style.help': 'Определяет, какой формат API используется для запросов. Переопределите, если стороннее API использует другой диалект.',
'ai.providers.icon.change': 'Изменить иконку',
'ai.providers.icon.upload': 'Загрузить изображение',
'ai.providers.icon.reset': 'Сбросить',
'ai.providers.icon.close': 'Свернуть',
'ai.providers.icon.uploadedNote': 'Своя иконка (64×64 WebP)',
'ai.providers.icon.errorType': 'Пожалуйста, выберите файл изображения.',
'ai.providers.apiKey': 'API-ключ',
'ai.providers.apiKey.placeholder': 'Введите API-ключ',
'ai.providers.apiKey.decrypting': 'Расшифровка...',
'ai.providers.baseUrl': 'Базовый URL',
'ai.providers.skipTLSVerify': 'Пропустить проверку TLS-сертификата (для самоподписанных сертификатов)',
'ai.providers.defaultModel': 'Модель по умолчанию',
'ai.providers.defaultModel.placeholder': 'например, gpt-4o, claude-sonnet-4-20250514',
'ai.providers.contextWindow': 'Контекстное окно',
'ai.providers.contextWindow.placeholder': 'например, 128000',
'ai.providers.contextWindow.help': 'Оставьте пустым, чтобы использовать значение из списка моделей, если оно доступно; иначе Netcatty применит безопасное значение по умолчанию.',
'ai.providers.contextWindow.error': 'Введите положительное целое число или оставьте поле пустым.',
'ai.providers.refreshModels': 'Обновить модели',
'ai.providers.searchModel': 'Искать или ввести ID модели...',
'ai.providers.filterModels': 'Фильтровать модели...',
'ai.providers.loadingModels': 'Загрузка моделей...',
'ai.providers.noMatchingModels': 'Нет подходящих моделей',
'ai.providers.clickToLoadModels': 'Нажмите, чтобы загрузить модели',
'ai.providers.showingModels': 'Показаны первые 100 из {count} моделей. Введите текст для фильтрации.',
'ai.providers.advancedParams': 'Дополнительные параметры',
'ai.providers.advancedParams.hint': 'Оставьте пустым, чтобы использовать настройки провайдера по умолчанию.',
'ai.providers.advancedParams.maxTokens.placeholder': 'например, 4096',
'ai.providers.advancedParams.default': 'По умолчанию у провайдера',
// AI Codex
'ai.codex': 'Codex',
'ai.codex.title': 'Codex CLI',
'ai.codex.description': 'Подключение OpenAI Codex. Здесь можно войти через ChatGPT или включить API-ключ OpenAI-совместимого провайдера и пользовательский endpoint в настройках.',
'ai.codex.detecting': 'Обнаружение...',
'ai.codex.notFound': 'Не найден',
'ai.codex.awaitingLogin': 'Ожидание входа',
'ai.codex.connectedChatGPT': 'Подключено через ChatGPT',
'ai.codex.connectedApiKey': 'Подключено через API-ключ',
'ai.codex.connectedCustomConfig': 'Подключено через ~/.codex/config.toml',
'ai.codex.customConfigIncomplete': 'Обнаружен пользовательский конфиг (отсутствует переменная окружения)',
'ai.codex.customConfigHint': 'Используется пользовательский провайдер "{provider}", настроенный в ~/.codex/config.toml — вход через ChatGPT не требуется.',
'ai.codex.customConfigMissingEnvKey': 'Предупреждение: {envKey} не задана в переменных окружения вашей оболочки. Экспортируйте её (или запустите netcatty из оболочки, где она задана), чтобы Codex мог пройти аутентификацию.',
'ai.codex.notConnected': 'Не подключено',
'ai.codex.statusUnknown': 'Статус неизвестен',
'ai.codex.path': 'Путь:',
'ai.codex.notFoundHint': 'Не удалось найти codex в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
'ai.codex.customPathPlaceholder': 'например, /usr/local/bin/codex',
'ai.codex.check': 'Проверить',
'ai.codex.openLogin': 'Открыть вход',
'ai.codex.logout': 'Выйти',
'ai.codex.connectChatGPT': 'Подключить ChatGPT',
'ai.codex.refreshStatus': 'Обновить статус',
// AI Claude Code
'ai.claude.title': 'Claude Code',
'ai.claude.description': 'Агентный помощник для программирования от Anthropic. Требует установленный в системе Claude Code CLI.',
'ai.claude.detecting': 'Обнаружение...',
'ai.claude.detected': 'Обнаружен',
'ai.claude.notFound': 'Не найден',
'ai.claude.path': 'Путь:',
'ai.claude.notFoundHint': 'Не удалось найти claude в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
'ai.claude.customPathPlaceholder': 'например, /usr/local/bin/claude',
'ai.claude.configSection': 'Аутентификация и конфигурация (опционально)',
'ai.claude.configDir': 'Каталог конфигурации',
'ai.claude.configDir.placeholder': '~/.claude (пусто — по умолчанию)',
'ai.claude.configDir.hint': 'Задаёт CLAUDE_CONFIG_DIR — укажите папку, где выполнен вход `claude` (содержит settings.json и учётные данные).',
'ai.claude.settings': 'Файл настроек',
'ai.claude.settings.placeholder': '~/team-settings.json (путь или встроенный {"model":"..."})',
'ai.claude.settings.hint': 'Опционально. Путь к settings.json или встроенный JSON, передаётся в SDK как `settings`. Дополняет «Каталог конфигурации» выше и независим от него (накладывается сверху, не заменяет).',
'ai.claude.envVars': 'Переменные окружения',
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
'ai.claude.envVars.hint': 'По одному KEY=VALUE в строке, передаётся агенту Claude. Хранится локально в открытом виде — для API-ключей и учётных данных используйте «Каталог конфигурации» выше (вход `claude`).',
'ai.claude.check': 'Проверить',
// AI GitHub Copilot CLI
'ai.copilot.title': 'GitHub Copilot CLI',
'ai.copilot.description': 'Использует GitHub Copilot CLI. После обнаружения может быть выбран как внешний агент для программирования.',
'ai.copilot.detecting': 'Обнаружение...',
'ai.copilot.detected': 'Обнаружен',
'ai.copilot.notFound': 'Не найден',
'ai.copilot.path': 'Путь:',
'ai.copilot.notFoundHint': 'Не удалось найти copilot в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
'ai.copilot.customPathPlaceholder': 'например, /usr/local/bin/copilot',
'ai.copilot.check': 'Проверить',
// AI Default Agent
'ai.defaultAgent': 'Агент по умолчанию',
'ai.defaultAgent.description': 'Агент, который будет использоваться при запуске новой AI-сессии',
'ai.defaultAgent.catty': 'Catty (встроенный)',
'ai.toolAccess.title': 'Доступ к инструментам',
'ai.toolAccess.mode': 'Режим доступа Netcatty',
'ai.toolAccess.description': 'Выберите, как внешние агенты получают доступ к сессиям Netcatty. MCP предоставляет встроенный сервер, а Skills + CLI указывает агентам на локальный skill Netcatty и команды CLI.',
'ai.toolAccess.mode.mcp': 'MCP',
'ai.toolAccess.mode.skills': 'Skills + CLI',
'ai.userSkills.title': 'Пользовательские skills',
'ai.userSkills.description': 'Откройте папку skills Netcatty, чтобы добавить свои каталоги skills. Netcatty автоматически сканирует их и добавляет только лёгкие индексы, если skill явно не соответствует текущему запросу.',
'ai.userSkills.openFolder': 'Открыть папку skills',
'ai.userSkills.reload': 'Перезагрузить skills',
'ai.userSkills.location': 'Расположение',
'ai.userSkills.loading': 'Сканирование пользовательских skills...',
'ai.userSkills.summary': '{ready} готово, {warnings} предупреждений',
'ai.userSkills.empty': 'Пользовательские skills пока не найдены. Откройте папку, чтобы добавить каталоги skills с файлом SKILL.md.',
'ai.userSkills.unavailable': 'Пользовательские skills недоступны в этой среде.',
'ai.userSkills.status.ready': 'Готово',
'ai.userSkills.status.warning': 'Предупреждение',
// AI Chat
'ai.chat.noProvider': 'AI-провайдер не настроен. Перейдите в **Настройки → AI → Провайдеры**, чтобы добавить и включить провайдера.',
'ai.chat.toolDenied': 'Действие было отклонено пользователем.',
'ai.chat.toolApproved': 'Одобрено',
'ai.chat.toolApprovalHint': 'Нажмите Enter для одобрения, Escape для отклонения',
'ai.chat.approve': 'Одобрить',
'ai.chat.reject': 'Отклонить',
'ai.chat.toolLabel': 'Инструмент',
'ai.chat.targetLabel': 'Цель',
'ai.chat.permissionRequired': 'Требуется разрешение',
'ai.chat.permissionDescription': 'AI-агент хочет выполнить вызов инструмента, для которого требуется ваше одобрение.',
'ai.chat.commandBlocked': 'Эта команда заблокирована вашей политикой безопасности и не может быть выполнена.',
'ai.chat.recommendAllow': 'Разрешить',
'ai.chat.recommendConfirm': 'Подтвердить',
'ai.chat.recommendDeny': 'Запретить',
'ai.chat.exportConversation': 'Экспортировать разговор',
'ai.chat.exportAs': 'Экспортировать как',
'ai.chat.exportMarkdown': 'Markdown',
'ai.chat.exportJSON': 'JSON',
'ai.chat.exportPlainText': 'Обычный текст',
'ai.chat.thinking': 'Размышляет',
'ai.chat.thoughtFor': 'Размышлял {duration}',
'ai.chat.thought': 'Мысль',
'ai.chat.agents': 'Агенты',
'ai.chat.detectedOnMachine': 'Обнаружено на этом устройстве',
'ai.chat.rescan': 'Пересканировать',
'ai.chat.permObserver': 'Наблюдатель',
'ai.chat.permConfirm': 'Подтверждение',
'ai.chat.permAuto': 'Авто',
'ai.chat.permObserverDesc': 'Только чтение',
'ai.chat.permConfirmDesc': 'Спрашивать перед действиями',
'ai.chat.permAutoDesc': 'Выполнять свободно',
'ai.chat.emptyHint': 'Спрашивайте о ваших серверах, запускайте команды или получайте помощь с конфигурациями.',
'ai.chat.placeholder': 'Сообщение {agent} — @ для добавления контекста, / для команд',
'ai.chat.placeholderDefault': 'Сообщение агенту Catty...',
'ai.chat.noModel': 'Нет модели',
'ai.chat.noProviderModel': 'Модель по умолчанию не задана — настройте её в Настройки → AI → Провайдеры.',
'ai.chat.selectProvider': 'Выберите провайдера',
'ai.chat.recent': 'Недавние',
'ai.chat.viewAll': 'Показать всё',
'ai.chat.untitled': 'Без названия',
'ai.chat.justNow': 'Только что',
'ai.chat.minutesAgo': '{n}м назад',
'ai.chat.hoursAgo': '{n}ч назад',
'ai.chat.daysAgo': '{n}д назад',
'ai.chat.newChat': 'Новый чат',
'ai.chat.allSessions': 'Все сессии',
'ai.chat.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 Error
'ai.codex.bridgeError': 'Обработчики главного процесса Codex ещё не загружены. Полностью перезапустите Netcatty или dev-процесс Electron и попробуйте снова.',
// AI Web Search
'ai.webSearch.title': 'Веб-поиск',
'ai.webSearch.enable': 'Включить веб-поиск',
'ai.webSearch.enable.description': 'Разрешить AI-агенту искать в интернете актуальную информацию.',
'ai.webSearch.provider': 'Провайдер поиска',
'ai.webSearch.provider.description': 'Выберите провайдера API веб-поиска.',
'ai.webSearch.apiKey': 'API-ключ',
'ai.webSearch.apiKey.description': 'API-ключ для выбранного провайдера поиска.',
'ai.webSearch.apiKey.placeholder': 'Введите API-ключ...',
'ai.webSearch.apiHost': 'API Host',
'ai.webSearch.apiHost.description': 'Пользовательский API endpoint. Оставьте по умолчанию, если не используете прокси.',
'ai.webSearch.apiHost.searxngDescription': 'URL вашего экземпляра SearXNG (обязательно).',
'ai.webSearch.maxResults': 'Макс. число результатов',
'ai.webSearch.maxResults.description': 'Максимальное количество результатов поиска для возврата (1-20).',
// AI Safety Settings
'ai.safety.title': 'Безопасность',
'ai.safety.permissionMode': 'Режим разрешений',
'ai.safety.permissionMode.description': 'Управляет тем, как AI взаимодействует с вашими терминалами. Режим наблюдателя блокирует все операции записи через Netcatty и применяется как к встроенным, так и к внешним агентам. Режим подтверждения носит рекомендательный характер для внешних агентов (они управляют собственным потоком одобрения инструментов).',
'ai.safety.permissionMode.observer': 'Наблюдатель — только чтение, без действий',
'ai.safety.permissionMode.confirm': 'Подтверждение — спрашивать перед действиями',
'ai.safety.permissionMode.autonomous': 'Автономный — выполнять свободно',
'ai.safety.commandTimeout': 'Тайм-аут команды',
'ai.safety.commandTimeout.description': 'Максимальное число секунд, которое команда может выполняться до принудительного завершения. Применяется как к встроенным, так и к внешним агентам.',
'ai.safety.commandTimeout.unit': 'с',
'ai.safety.maxIterations': 'Макс. число итераций',
'ai.safety.maxIterations.description': 'Максимальное число циклов использования инструментов AI, чтобы предотвратить бесконтрольное выполнение. У внешних агентов могут быть собственные внутренние лимиты итераций, имеющие приоритет.',
'ai.safety.blocklist': 'Чёрный список команд',
'ai.safety.blocklist.description': 'Regex-шаблоны для блокировки опасных команд. Применяется как к встроенным, так и к внешним агентам через механизм выполнения Netcatty.',
'ai.safety.blocklist.placeholder': 'Regex-шаблон...',
'ai.safety.blocklist.reset': 'Сбросить по умолчанию',
'ai.safety.blocklist.add': 'Добавить шаблон',
'ai.safety.note': 'Эти настройки безопасности применяются к действиям, выполняемым через Netcatty. Внешние CLI-агенты могут иметь собственные локальные инструменты и собственные правила управления ими.',
// Unified tooltips for terminal workspace and top tabs (issue #954)
'terminal.layer.addTerminal': 'Добавить терминал',
'terminal.layer.switchToSplitView': 'Переключить в режим разделения',
'terminal.layer.sftp': 'SFTP',
'terminal.layer.scripts': 'Скрипты',
'terminal.layer.theme': 'Тема',
'terminal.layer.aiChat': 'AI-чат',
'terminal.layer.movePanelLeft': 'Переместить панель влево',
'terminal.layer.movePanelRight': 'Переместить панель вправо',
'terminal.layer.closePanel': 'Закрыть панель',
'topTabs.openQuickSwitcher': 'Открыть быстрый переключатель',
'topTabs.moreTabs': 'Больше вкладок',
'topTabs.aiAssistant': 'AI-помощник',
'topTabs.toggleTheme': 'Переключить тему',
'topTabs.openSettings': 'Открыть настройки',
'ai.chat.sessionHistory': 'История сессий',
'ai.chat.attach': 'Прикрепить',
'ai.chat.collapse': 'Свернуть',
'ai.chat.expand': 'Развернуть',
'ai.chat.enableAgent': 'Включить {name}',
'zmodem.waitingForRemote': 'Ожидание удалённой стороны...',
'zmodem.uploading': 'Загрузка',
'zmodem.downloading': 'Скачивание',
'zmodem.cancelTransfer': 'Отменить передачу (Ctrl+C)',
'zmodem.overwrite.title': 'Remote file already exists',
'zmodem.overwrite.applyToRest': 'Apply to remaining conflicts',
'zmodem.overwrite.overwrite': 'Overwrite',
'zmodem.overwrite.skip': 'Skip',
'zmodem.overwrite.cancel': 'Cancel',
'settings.shortcuts.resetToDefault': 'Сбросить по умолчанию',
};

View File

@@ -0,0 +1,669 @@
import type { Messages } from '../types';
export const ruCoreMessages: Messages = {
// Common
'common.save': 'Сохранить',
'common.cancel': 'Отмена',
'common.close': 'Закрыть',
'common.reset': 'Сбросить',
'common.zoomIn': 'Увеличить',
'common.zoomOut': 'Уменьшить',
'common.settings': 'Настройки',
'common.search': 'Поиск',
'common.searchPlaceholder': 'Поиск...',
'common.connect': 'Подключиться',
'common.terminal': 'Терминал',
'common.create': 'Создать',
'common.import': 'Импорт',
'common.generate': 'Сгенерировать',
'common.delete': 'Удалить',
'common.edit': 'Редактировать',
'common.clear': 'Очистить',
'common.optional': 'Необязательно',
'common.selectPlaceholder': 'Выбрать...',
'common.add': 'Добавить',
'common.rename': 'Переименовать',
'common.refresh': 'Обновить',
'common.continue': 'Продолжить',
'common.enabled': 'Включено',
'common.disabled': 'Отключено',
'common.error': 'Ошибка',
'common.validation': 'Проверка',
'common.unknownError': 'Неизвестная ошибка',
'common.noResultsFound': 'Ничего не найдено',
'common.back': 'Назад',
'common.apply': 'Применить',
'common.use': 'Использовать',
'common.useGlobal': 'Использовать глобальное',
'common.saveChanges': 'Сохранить изменения',
'common.advanced': 'Дополнительно',
'common.left': 'Слева',
'common.right': 'Справа',
'common.more': 'Ещё',
'common.selectAHost': 'Выберите хост',
'common.selectAHostPlaceholder': 'Выберите хост...',
'sort.az': 'А-Я',
'sort.za': 'Я-А',
'sort.newest': 'Сначала новые',
'sort.oldest': 'Сначала старые',
'sort.group': 'По группе',
'field.label': 'Метка',
'field.type': 'Тип',
'auth.keyType': 'Тип {type}',
'auth.showAllKeys': 'Показать все ключи',
// Dialogs / prompts
'confirm.deleteHost': 'Удалить хост "{name}"?',
'confirm.deleteIdentity': 'Удалить идентификатор "{name}"?',
'confirm.removeProvider': 'Удалить провайдера "{name}"?',
'confirm.closeBusyTerminal.title': 'Подтвердите закрытие',
'confirm.closeBusyTerminal.message': 'Процесс "{command}" всё ещё выполняется и будет завершён.',
'confirm.closeBusyTerminal.messageWithMore': 'Процесс "{command}" и ещё {count} выполняющихся процесс(ов) будут завершены.',
'confirm.closeBusyTerminal.cancel': 'Отмена',
'confirm.closeBusyTerminal.close': 'Закрыть',
'dialog.createWorkspace.title': 'Создать рабочее пространство',
'dialog.renameWorkspace.title': 'Переименовать рабочее пространство',
'dialog.renameSession.title': 'Переименовать сессию',
'field.name': 'Имя',
'field.selectHosts': 'Выбрать хосты',
'placeholder.workspaceName': 'Имя рабочего пространства',
'placeholder.sessionName': 'Имя сессии',
'placeholder.searchHosts': 'Поиск хостов...',
'toast.settingsUnavailable': 'Окно настроек недоступно на этой платформе.',
'credentials.protectionUnavailable.title': 'Защита учётных данных недоступна',
'credentials.protectionUnavailable.message': 'Сохранённые пароли и ключи не могут быть автоматически расшифрованы на этом устройстве. Перед подключением введите учётные данные заново.',
'credentials.protectionUnavailable.action': 'Открыть настройки',
// Settings shell
'settings.title': 'Настройки',
'settings.tab.application': 'Приложение',
'settings.tab.appearance': 'Внешний вид',
'settings.tab.terminal': 'Терминал',
'settings.tab.shortcuts': 'Горячие клавиши',
'settings.tab.syncCloud': 'Синхронизация и облако',
'settings.tab.system': 'Система',
// Settings > System
'settings.system.title': 'Система',
'settings.system.description': 'Системная информация и управление временными файлами.',
'settings.system.tempDirectory': 'Временные файлы',
'settings.system.location': 'Расположение',
'settings.system.fileCount': 'Файлы',
'settings.system.totalSize': 'Размер',
'settings.system.openFolder': 'Открыть папку',
'settings.system.refresh': 'Обновить',
'settings.system.clearTempFiles': 'Очистить временные файлы',
'settings.system.clearing': 'Очистка...',
'settings.system.clearResult': 'Удалено файлов: {deleted}, ошибок: {failed}.',
'settings.system.tempDirectoryHint': 'Временные файлы создаются при открытии удалённых файлов во внешних приложениях. Они автоматически очищаются при закрытии SFTP-сессий.',
'settings.system.credentials.title': 'Защита учётных данных',
'settings.system.credentials.status': 'Статус',
'settings.system.credentials.checking': 'Проверка...',
'settings.system.credentials.available': 'Доступно (системное хранилище ключей готово)',
'settings.system.credentials.unavailable': 'Недоступно (невозможно расшифровать сохранённые учётные данные)',
'settings.system.credentials.unknown': 'Неизвестно (не поддерживается в этой среде)',
'settings.system.credentials.unavailableHint': 'Учётные данные, зашифрованные в другом профиле пользователя или на другой машине, здесь расшифровать нельзя. Повторно введите и сохраните их на этом устройстве.',
'settings.system.credentials.portabilityHint': 'Облачная синхронизация переносима, потому что использует шифрование вашим мастер-ключом. Локальное шифрование safeStorage привязано к устройству и пользователю.',
// Settings > System > Crash Logs
'settings.system.crashLogs.title': 'Журналы сбоев',
'settings.system.crashLogs.description': 'Просмотр журналов ошибок основного процесса для диагностики неожиданного поведения.',
'settings.system.crashLogs.noLogs': 'Журналы сбоев не найдены.',
'settings.system.crashLogs.entries': 'Записей: {count}',
'settings.system.crashLogs.clear': 'Очистить все журналы',
'settings.system.crashLogs.cleared': 'Очищено файлов журналов: {count}.',
'settings.system.crashLogs.source': 'Источник',
'settings.system.crashLogs.time': 'Время',
'settings.system.crashLogs.message': 'Сообщение',
'settings.system.crashLogs.stack': 'Трассировка стека',
'settings.system.crashLogs.hint': 'Журналы сбоев хранятся 30 дней и автоматически ротируются.',
'settings.system.crashLogs.collapse': 'Свернуть',
'settings.system.crashLogs.expand': 'Показать детали',
// Settings > System > Software Update
'settings.update.title': 'Обновление программы',
'settings.update.currentVersion': 'Текущая версия',
'settings.update.checkForUpdates': 'Проверить обновления',
'settings.update.checking': 'Проверка...',
'settings.update.upToDate': 'Вы используете последнюю версию.',
'settings.update.available': 'Доступна новая версия {version}.',
'settings.update.download': 'Скачать обновление',
'settings.update.downloading': 'Загрузка... {percent}%',
'settings.update.readyToInstall': 'Обновление загружено и готово к установке.',
'settings.update.restartNow': 'Перезапустить для обновления',
'settings.update.error': 'Не удалось проверить наличие обновлений.',
'settings.update.downloadError': 'Не удалось загрузить обновление.',
'settings.update.manualDownload': 'Скачать с GitHub',
'settings.update.manualDownloadHint': 'Автообновление недоступно на этой платформе. Скачайте последнюю версию с GitHub.',
'settings.update.hint': 'Netcatty проверяет обновления через GitHub Releases.',
'settings.update.lastCheckedJustNow': 'только что',
'settings.update.lastCheckedMinutesAgo': '{n} мин назад',
'settings.update.lastCheckedHoursAgo': '{n} ч назад',
'settings.update.lastCheckedPrefix': 'Последняя проверка: ',
'settings.update.autoUpdateEnabled': 'Автоматические обновления',
'settings.update.autoUpdateEnabledDesc': 'Автоматически проверять и загружать обновления, когда они доступны.',
// Settings > Session Logs
'settings.sessionLogs.title': 'Журналы сессий',
'settings.sessionLogs.description': 'Настройка экспорта журналов сессий и параметров автосохранения.',
'settings.sessionLogs.autoSave': 'Автосохранение',
'settings.sessionLogs.enableAutoSave': 'Включить автосохранение',
'settings.sessionLogs.enableAutoSaveDesc': 'Автоматически сохранять журналы сессий после завершения терминальных сессий.',
'settings.sessionLogs.directory': 'Папка сохранения',
'settings.sessionLogs.noDirectory': 'Папка не выбрана',
'settings.sessionLogs.browse': 'Обзор',
'settings.sessionLogs.openFolder': 'Открыть папку',
'settings.sessionLogs.directoryHint': 'Журналы будут организованы по хостам во вложенных папках.',
'settings.sessionLogs.format': 'Формат журнала',
'settings.sessionLogs.formatDesc': 'Выберите формат сохраняемых файлов журналов.',
'settings.sessionLogs.formatTxt': 'Обычный текст (.txt)',
'settings.sessionLogs.formatRaw': 'Сырые данные с ANSI (.log)',
'settings.sessionLogs.formatHtml': 'HTML (.html)',
'settings.sessionLogs.hint': 'Журналы сессий сохраняют весь вывод терминала для диагностики и аудита.',
// Settings > SSH Debug Logs
'settings.sshDebugLogs.title': 'Отладочные журналы SSH',
'settings.sshDebugLogs.enable': 'Включить отладочные журналы SSH',
'settings.sshDebugLogs.enableDesc': 'Записывать подключение, аутентификацию, рукопожатие, отключение и причины ошибок без вывода терминала.',
'settings.sshDebugLogs.location': 'Расположение журнала',
'settings.sshDebugLogs.status': 'Статус',
'settings.sshDebugLogs.statusOn': 'Включено',
'settings.sshDebugLogs.statusOff': 'Отключено',
'settings.sshDebugLogs.size': 'Размер',
'settings.sshDebugLogs.hint': 'Когда включено, новые SSH-подключения записывают диагностические события для разбора бастионов, аутентификации и неожиданных отключений.',
// Settings > Global Hotkey (Quake Mode)
'settings.globalHotkey.title': 'Глобальная горячая клавиша',
'settings.globalHotkey.toggleWindow': 'Переключение окна',
'settings.globalHotkey.toggleWindowDesc': 'Нажмите сочетание клавиш, чтобы задать глобальную горячую клавишу для показа или скрытия окна.',
'settings.globalHotkey.notSet': 'Не задано',
'settings.globalHotkey.reset': 'Сбросить по умолчанию',
'settings.globalHotkey.closeToTray': 'Сворачивать в системный трей',
'settings.globalHotkey.closeToTrayDesc': 'Если включено, при закрытии окно будет сворачиваться в системный трей вместо выхода из приложения.',
'settings.globalHotkey.enabled': 'Включить глобальную горячую клавишу',
'settings.globalHotkey.enabledDesc': 'Регистрировать системные сочетания клавиш. Когда отключено, все глобальные горячие клавиши снимаются с регистрации.',
'settings.globalHotkey.hint': 'Глобальная горячая клавиша работает на уровне всей системы и позволяет быстро показывать или скрывать окно (терминал в стиле Quake).',
// Tray Panel
'tray.openMainWindow': 'Открыть главное окно',
'tray.sessions': 'Сессии',
'tray.portForwarding': 'Проброс портов',
'tray.status.connected': 'Подключено',
'tray.status.connecting': 'Подключение',
'tray.status.disconnected': 'Отключено',
'tray.status.active': 'Активно',
'tray.status.inactive': 'Неактивно',
'tray.status.error': 'Ошибка',
'tray.recentHosts': 'Недавние хосты',
'tray.empty.title': 'Пока здесь ничего нет',
'tray.empty.subtitle': 'Подключитесь к серверу, они по вам скучают 🚀',
'tray.quit': 'Выйти из Netcatty',
// Vault Sidebar
'vault.sidebar.collapse': 'Свернуть боковую панель',
'vault.sidebar.expand': 'Развернуть боковую панель',
'vault.sidebar.resize': 'Изменить ширину боковой панели',
// Settings > Application
'settings.application.checkUpdates': 'Проверить обновления',
'settings.application.reportProblem': 'Сообщить о проблеме',
'settings.application.reportProblem.subtitle': 'Создать заранее заполненную задачу на GitHub',
'settings.application.community': 'Сообщество',
'settings.application.community.subtitle': 'На GitHub Discussions',
'settings.application.github': 'GitHub',
'settings.application.github.subtitle': 'Исходный код',
'settings.application.whatsNew': 'Что нового',
'settings.application.whatsNew.subtitle': 'Показать примечания к релизу',
'settings.application.openExternal.failedTitle': 'Не удалось открыть ссылку',
'settings.application.openExternal.failedBody': 'Не удалось открыть ссылку ни в системном браузере, ни во встроенном окне браузера.',
'settings.vault.title': 'Хранилище',
'settings.vault.showRecentHosts': 'Показывать недавно подключённые хосты',
'settings.vault.showRecentHostsDesc': 'Показывать раздел недавно подключённых хостов в верхней части хранилища',
'settings.vault.showOnlyUngroupedHostsInRoot': 'Показывать в корне только хосты без группы',
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'Если включено, в корневом списке хостов будут показаны только хосты без группы. Откройте группу на боковой панели, чтобы увидеть сгруппированные хосты.',
'settings.vault.showSftpTab': 'Показывать вкладку SFTP',
'settings.vault.showSftpTabDesc': 'Показывать отдельный SFTP-вид в верхней панели вкладок. Если скрыто, используйте боковую панель SFTP внутри сессии.',
// Update notifications
'update.available.title': 'Доступно обновление',
'update.available.message': 'Доступна новая версия {version}. Нажмите, чтобы скачать.',
'update.checking': 'Проверка обновлений...',
'update.upToDate.title': 'Актуальная версия',
'update.upToDate.message': 'У вас установлена последняя версия ({version}).',
'update.error': 'Не удалось проверить наличие обновлений',
'update.downloadNow': 'Скачать сейчас',
'update.viewInSettings': 'Открыть в настройках',
'update.readyToInstall.title': 'Обновление готово',
'update.readyToInstall.message': 'Версия {version} загружена и готова к установке.',
'update.restartNow': 'Перезапустить сейчас',
'update.downloadFailed.title': 'Ошибка обновления',
'update.downloadFailed.message': 'Не удалось скачать обновление. Вы можете скачать его вручную.',
'update.needsSave.title': 'Несохранённые изменения',
'update.needsSave.message': 'Сначала сохраните открытые редакторы, затем снова нажмите «Перезапустить сейчас», чтобы установить обновление.',
'update.openReleases': 'Открыть релизы',
'update.remindLater': 'Напомнить позже',
'update.skipVersion': 'Пропустить эту версию',
// Settings > Appearance
'settings.appearance.uiTheme': 'Тема интерфейса',
'settings.appearance.theme': 'Тема',
'settings.appearance.theme.desc': 'Выберите светлую, тёмную тему или следование системным настройкам',
'settings.appearance.theme.light': 'Светлая',
'settings.appearance.theme.dark': 'Тёмная',
'settings.appearance.theme.system': 'Системная',
'settings.appearance.accentColor': 'Акцентный цвет',
'settings.appearance.customColor': 'Пользовательский цвет',
'settings.appearance.accentColor.mode': 'Использовать свой акцент',
'settings.appearance.accentColor.mode.desc': 'Переопределить акцентный цвет темы',
'settings.appearance.accentColor.custom': 'Пользовательский акцент',
'settings.appearance.themeColor': 'Цвет темы',
'settings.appearance.themeColor.desc': 'Выберите готовую палитру для каждой темы',
'settings.appearance.themeColor.light': 'Палитра светлой темы',
'settings.appearance.themeColor.dark': 'Палитра тёмной темы',
'settings.appearance.customCss': 'Пользовательский CSS',
'settings.appearance.customCss.desc':
'Добавьте пользовательский CSS, чтобы настроить внешний вид приложения. Изменения применяются сразу. Основные области интерфейса имеют атрибут [data-section="..."], который можно использовать для выбора элементов, например: snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar, top-tabs.',
'settings.appearance.customCss.placeholder':
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Сделать текст в боковой панели сниппетов крупнее */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* Пользовательский фон терминала */\n.terminal { background: #1a1a2e !important; }\n\n/* Настройка глобального радиуса скругления */\n:root { --radius: 0.25rem; }',
'settings.appearance.language': 'Язык',
'settings.appearance.language.desc': 'Выберите язык интерфейса',
'settings.appearance.uiFont': 'Шрифт интерфейса',
'settings.appearance.uiFont.desc': 'Выберите шрифт для интерфейса приложения',
// Settings > Terminal
'settings.terminal.section.theme': 'Тема терминала',
'settings.terminal.themeModal.title': 'Выберите тему',
'settings.terminal.themeModal.darkThemes': 'Тёмные темы',
'settings.terminal.themeModal.lightThemes': 'Светлые темы',
'settings.terminal.theme.selectButton': 'Выбрать тему',
'settings.terminal.theme.followApp': 'Следовать теме приложения',
'settings.terminal.theme.followApp.desc': 'Автоматически подбирать фон терминала под текущую тему приложения для более цельного вида.',
'settings.terminal.theme.darkTheme': 'Тема терминала для тёмного режима',
'settings.terminal.theme.lightTheme': 'Тема терминала для светлого режима',
'settings.terminal.theme.auto': 'Авто (как тема приложения)',
'settings.terminal.theme.autoDesc': 'Следует активному пресету темы интерфейса',
'settings.terminal.section.font': 'Шрифт',
'settings.terminal.section.cursor': 'Курсор',
'settings.terminal.section.keyboard': 'Клавиатура',
'settings.terminal.section.accessibility': 'Доступность',
'settings.terminal.section.behavior': 'Поведение',
'settings.terminal.section.scrollback': 'Буфер прокрутки',
'settings.terminal.section.keywordHighlight': 'Подсветка ключевых слов',
'settings.terminal.font.family': 'Шрифт',
'settings.terminal.font.family.desc': 'Семейство шрифта терминала',
'settings.terminal.font.cjk': 'Шрифт CJK',
'settings.terminal.font.cjk.desc': 'Шрифт для китайских, японских и корейских символов; вариант "Авто" выбирает подходящий шрифт на основе основного',
'settings.terminal.font.cjk.option.auto': 'Авто · в паре с основным шрифтом',
'settings.terminal.font.cjk.option.sarasaSC': 'Sarasa Mono SC (Iosevka + Source Han SC)',
'settings.terminal.font.cjk.option.sarasaTC': 'Sarasa Mono TC (Iosevka + Source Han TC)',
'settings.terminal.font.cjk.option.mapleCN': 'Maple Mono CN',
'settings.terminal.font.cjk.option.sourceHan': 'Source Han Mono SC',
'settings.terminal.font.cjk.option.notoCJK': 'Noto Sans Mono CJK SC',
'settings.terminal.font.cjk.option.lxgwWenkai': 'LXGW WenKai Mono',
'settings.terminal.font.cjk.option.simSun': 'SimSun',
'settings.terminal.font.cjk.option.legacy': '{font} · не рекомендуется (пропорциональный шрифт)',
'settings.terminal.font.size': 'Размер шрифта',
'settings.terminal.font.size.desc': 'Размер текста терминала',
'settings.terminal.font.weight': 'Толщина шрифта',
'settings.terminal.font.weight.desc': 'Толщина обычного текста (100-900)',
'settings.terminal.font.weightBold': 'Толщина жирного шрифта',
'settings.terminal.font.weightBold.desc': 'Толщина жирного текста (100-900)',
'settings.terminal.font.linePadding': 'Межстрочный отступ',
'settings.terminal.font.linePadding.desc': 'Дополнительное пространство между строками (0-10)',
'settings.terminal.font.emulationType': 'Тип эмуляции терминала',
'settings.terminal.cursor.style': 'Стиль курсора',
'settings.terminal.cursor.style.block': 'Блок',
'settings.terminal.cursor.style.bar': 'Полоса',
'settings.terminal.cursor.style.underline': 'Подчёркивание',
'settings.terminal.cursor.blink': 'Мигание курсора',
'settings.terminal.keyboard.altAsMeta': 'Использовать Option как клавишу Meta',
'settings.terminal.keyboard.altAsMeta.desc':
'Использовать Option (Alt) как клавишу Meta вместо ввода специальных символов',
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ переход по словам',
'settings.terminal.keyboard.optionArrowWordJump.desc':
'Отправлять Meta-b / Meta-f при Option+Влево/Вправо, чтобы оболочка перемещалась по словам, вместо стандартного ^[[1;3D / ^[[1;3C',
'settings.terminal.accessibility.minimumContrastRatio': 'Минимальный коэффициент контрастности',
'settings.terminal.accessibility.minimumContrastRatio.desc':
'Подстраивать цвета под требования контрастности (1 = отключено, 21 = максимум)',
'settings.terminal.behavior.rightClick': 'Поведение правой кнопки мыши',
'settings.terminal.behavior.rightClick.desc': 'Действие при щелчке правой кнопкой в терминале',
'settings.terminal.behavior.rightClick.menu': 'Показать меню',
'settings.terminal.behavior.rightClick.paste': 'Вставить',
'settings.terminal.behavior.rightClick.selectWord': 'Выбрать слово',
'settings.terminal.behavior.copyOnSelect': 'Копировать при выделении',
'settings.terminal.behavior.copyOnSelect.desc': 'Автоматически копировать выделенный текст. В tmux/vim с режимом мыши удерживайте Option на macOS или Shift на Windows/Linux для выделения',
'settings.terminal.behavior.middleClickPaste': 'Вставка средней кнопкой мыши',
'settings.terminal.behavior.middleClickPaste.desc':
'Вставлять содержимое буфера обмена по щелчку средней кнопкой',
'settings.terminal.behavior.bracketedPaste': 'Режим bracketed paste',
'settings.terminal.behavior.bracketedPaste.desc':
'Оборачивать вставляемый текст escape-последовательностями, чтобы оболочка отличала вставку от обычного ввода. Отключите, если видите артефакты вида ^[[200~.',
'settings.terminal.behavior.clearWipesScrollback': '`clear` очищает буфер прокрутки',
'settings.terminal.behavior.clearWipesScrollback.desc':
'Команда `clear` также будет очищать буфер прокрутки (поведение POSIX по умолчанию). Отключите, чтобы история оставалась видимой после `clear`.',
'settings.terminal.behavior.preserveSelectionOnInput': 'Сохранять выделение при вводе',
'settings.terminal.behavior.preserveSelectionOnInput.desc':
'Не сбрасывать выделенный мышью текст при вводе. Это удобно, например, чтобы выделить путь и вставить его после префикса команды вроде `sz `.',
'settings.terminal.behavior.forcePromptNewLine': 'Переносить приглашение на новую строку',
'settings.terminal.behavior.forcePromptNewLine.desc':
'Если последняя строка вывода команды не завершена переводом строки, переносить распознанное приглашение оболочки на следующую визуальную строку.',
'settings.terminal.behavior.osc52Clipboard': 'Буфер обмена OSC-52',
'settings.terminal.behavior.osc52Clipboard.desc':
'Разрешить удалённым программам (tmux, vim и т. д.) доступ к локальному буферу обмена через escape-последовательности OSC-52.',
'settings.terminal.behavior.osc52Clipboard.off': 'Отключено',
'settings.terminal.behavior.osc52Clipboard.writeOnly': 'Только запись',
'settings.terminal.behavior.osc52Clipboard.readWrite': 'Чтение и запись',
'settings.terminal.behavior.osc52Clipboard.prompt': 'Запись + запрос при чтении',
'terminal.osc52.readPrompt.title': 'Запрос чтения буфера обмена',
'terminal.osc52.readPrompt.desc': 'Удалённая программа запрашивает чтение вашего буфера обмена. Разрешить?',
'terminal.osc52.readPrompt.allow': 'Разрешить',
'terminal.osc52.readPrompt.deny': 'Запретить',
'settings.terminal.behavior.scrollOnInput': 'Прокручивать при вводе',
'settings.terminal.behavior.scrollOnInput.desc': 'Прокручивать терминал вниз при наборе текста',
'settings.terminal.behavior.scrollOnOutput': 'Прокручивать при выводе',
'settings.terminal.behavior.scrollOnOutput.desc':
'Прокручивать терминал вниз при появлении нового вывода',
'settings.terminal.behavior.scrollOnKeyPress': 'Прокручивать при нажатии клавиш',
'settings.terminal.behavior.scrollOnKeyPress.desc':
'Прокручивать терминал вниз при нажатии клавиши (например, Enter)',
'settings.terminal.behavior.scrollOnPaste': 'Прокручивать при вставке',
'settings.terminal.behavior.scrollOnPaste.desc':
'Прокручивать терминал вниз при вставке текста',
'settings.terminal.behavior.smoothScrolling': 'Плавная прокрутка',
'settings.terminal.behavior.smoothScrolling.desc':
'Анимировать прокрутку области терминала вместо мгновенного перехода',
'settings.terminal.behavior.linkModifier': 'Клавиша-модификатор для ссылок',
'settings.terminal.behavior.linkModifier.desc': 'Удерживайте эту клавишу, чтобы нажимать на ссылки в терминале',
'settings.terminal.behavior.linkModifier.none': 'Нет (нажимать напрямую)',
'settings.terminal.behavior.linkModifier.ctrl': 'Ctrl',
'settings.terminal.behavior.linkModifier.alt': 'Alt / Option',
'settings.terminal.behavior.linkModifier.meta': 'Cmd / Win',
'settings.terminal.scrollback.desc': 'Ограничение количества строк терминала. Установите 0, чтобы снять ограничение.',
'settings.terminal.scrollback.rows': 'Количество строк *',
'settings.terminal.section.startupCommand': 'Команда запуска',
'settings.terminal.startupCommandDelay.label': 'Задержка команды запуска (мс)',
'settings.terminal.startupCommandDelay.desc': 'Сколько ждать после подключения перед отправкой команды запуска. Также используется между строками, если команда запуска многострочная. Увеличьте для медленных соединений.',
'settings.terminal.keywordHighlight.title': 'Подсветка ключевых слов',
'settings.terminal.keywordHighlight.resetColors': 'Сбросить цвета по умолчанию',
'settings.terminal.keywordHighlight.resetDefaults': 'Сбросить встроенные правила по умолчанию',
'settings.terminal.keywordHighlight.resetBuiltIn': 'Восстановить стандартную метку и шаблоны',
'settings.terminal.keywordHighlight.addCustom': 'Добавить своё правило',
'settings.terminal.keywordHighlight.editCustom': 'Редактировать правило',
'settings.terminal.keywordHighlight.editBuiltIn': 'Редактировать встроенное правило',
'settings.terminal.keywordHighlight.labelField': 'Метка и цвет',
'settings.terminal.keywordHighlight.labelPlaceholder': 'Метка (например, Down)',
'settings.terminal.keywordHighlight.patternField': 'Шаблоны Regex',
'settings.terminal.keywordHighlight.patternPlaceholder': 'Один regex на строку (например, \\bdown\\b)',
'settings.terminal.keywordHighlight.patternHint': 'Один regex на строку. Шаблоны сопоставляются без учёта регистра с глобальным флагом.',
'settings.terminal.keywordHighlight.invalidPattern': 'Некорректный regex-шаблон',
'settings.terminal.keywordHighlight.preview': 'Предпросмотр',
'settings.terminal.section.localShell': 'Локальная оболочка',
'settings.terminal.localShell.shell': 'Исполняемый файл оболочки',
'settings.terminal.localShell.shell.desc': 'Путь к исполняемому файлу оболочки (например, /bin/zsh, pwsh.exe). Оставьте пустым, чтобы использовать системную оболочку по умолчанию.',
'settings.terminal.localShell.shell.placeholder': 'Системная по умолчанию',
'settings.terminal.localShell.shell.detected': 'Обнаружено',
'settings.terminal.localShell.shell.notFound': 'Исполняемый файл оболочки не найден',
'settings.terminal.localShell.shell.isDirectory': 'Путь указывает на каталог, а не на исполняемый файл',
'settings.terminal.localShell.shell.default': 'Системная по умолчанию',
'settings.terminal.localShell.shell.custom': 'Пользовательская...',
'settings.terminal.localShell.shell.customPath': 'Путь к исполняемому файлу оболочки',
'settings.terminal.localShell.shell.customArgs': 'Аргументы запуска',
'settings.terminal.localShell.shell.customArgs.placeholder': 'напр. --login -i',
'settings.terminal.localShell.shell.customArgs.desc': 'Аргументы, передаваемые оболочке. Некоторым оболочкам они необходимы — например, msys2 bash требует --login -i для загрузки окружения.',
'settings.terminal.localShell.shell.commonPaths': 'Частые пути',
'settings.terminal.localShell.shell.pathValid': 'Путь корректен',
'settings.terminal.localShell.startDir': 'Начальный каталог',
'settings.terminal.localShell.startDir.desc': 'Каталог, в котором будет открываться локальный терминал. Оставьте пустым, чтобы использовать домашний каталог.',
'settings.terminal.localShell.startDir.placeholder': 'Домашний каталог',
'settings.terminal.localShell.startDir.notFound': 'Каталог не найден',
'settings.terminal.localShell.startDir.isFile': 'Путь указывает на файл, а не на каталог',
'settings.terminal.section.connection': 'Подключение',
'settings.terminal.connection.keepaliveInterval': 'Интервал keepalive',
'settings.terminal.connection.keepaliveInterval.desc': 'Как часто (в секундах) отправлять keepalive-пакеты на уровне SSH. Установите 0, чтобы отключить глобально. Учтите, что отдельные хосты могут переопределять это значение в своих настройках.',
'settings.terminal.connection.keepaliveCountMax': 'Макс. число пропущенных keepalive',
'settings.terminal.connection.keepaliveCountMax.desc': 'Количество пропущенных keepalive, после которого соединение считается мёртвым. Более высокие значения лучше переносят краткие сетевые сбои и медленные ответы SSH-серверов.',
'settings.terminal.connection.x11Display': 'Дисплей X11',
'settings.terminal.connection.x11Display.desc': 'Необязательный адрес локального дисплея для перенаправления X11. Оставьте пустым, чтобы использовать системное значение по умолчанию.',
'settings.terminal.connection.x11Display.placeholder': 'Авто (:0 или DISPLAY)',
'settings.terminal.section.serverStats': 'Статистика сервера (Linux)',
'settings.terminal.serverStats.show': 'Показывать статистику сервера',
'settings.terminal.serverStats.show.desc': 'Показывать загрузку CPU, памяти и диска в строке состояния терминала (только для Linux-серверов).',
'settings.terminal.serverStats.refreshInterval': 'Интервал обновления',
'settings.terminal.serverStats.refreshInterval.desc': 'Как часто обновлять статистику сервера.',
'settings.terminal.serverStats.seconds': 'секунд',
// Settings > Terminal > Rendering
'settings.terminal.section.rendering': 'Рендеринг',
'settings.terminal.rendering.renderer': 'Рендерер',
'settings.terminal.rendering.renderer.desc': 'Выберите технологию рендеринга терминала. В режиме "Авто" на устройствах с малым объёмом памяти будет использоваться DOM. Изменения применяются к новым терминальным сессиям.',
'settings.terminal.rendering.auto': 'Авто',
// Settings > Terminal > Workspace Focus Indicator
'settings.terminal.section.workspaceFocus': 'Индикатор фокуса рабочей области',
'settings.terminal.workspaceFocus.style': 'Стиль индикатора фокуса',
'settings.terminal.workspaceFocus.style.desc': 'Как показывать, какая панель активна в режиме разделённого вида.',
'settings.terminal.workspaceFocus.dim': 'Затемнять неактивные панели',
'settings.terminal.workspaceFocus.border': 'Рамка вокруг активной панели',
// Settings > Terminal > Autocomplete
'settings.terminal.section.autocomplete': 'Автодополнение',
'settings.terminal.autocomplete.enabled': 'Включить автодополнение',
'settings.terminal.autocomplete.enabled.desc': 'Показывать подсказки команд на основе истории и описаний команд во время ввода.',
'settings.terminal.autocomplete.ghostText': 'Призрачный текст',
'settings.terminal.autocomplete.ghostText.desc': 'Показывать серую встроенную подсказку после курсора (как в fish shell).',
'settings.terminal.autocomplete.popupMenu': 'Всплывающее меню',
'settings.terminal.autocomplete.popupMenu.desc': 'Показывать плавающий список из нескольких подсказок.',
// Settings > Shortcuts
'settings.shortcuts.section.scheme': 'Схема горячих клавиш',
'settings.shortcuts.scheme.label': 'Сочетания клавиш',
'settings.shortcuts.scheme.desc': 'Выберите раскладку клавиш для использования в сочетаниях',
'settings.shortcuts.scheme.disabled': 'Отключено',
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
'settings.shortcuts.section.custom': 'Пользовательские сочетания',
'settings.shortcuts.resetAll': 'Сбросить все',
'settings.shortcuts.recording': 'Нажмите клавиши...',
'settings.shortcuts.none': 'Нет',
'settings.shortcuts.setDisabled': 'Отключить',
'settings.shortcuts.category.tabs': 'Вкладки',
'settings.shortcuts.category.terminal': 'Терминал',
'settings.shortcuts.category.navigation': 'Навигация',
'settings.shortcuts.category.app': 'Приложение',
'settings.shortcuts.category.sftp': 'SFTP',
// Settings > Shortcuts -> key bings
'settings.shortcuts.binding.switch-tab-1-9': 'Переключиться на вкладку [1...9]',
'settings.shortcuts.binding.next-tab': 'Следующая вкладка',
'settings.shortcuts.binding.prev-tab': 'Предыдущая вкладка',
'settings.shortcuts.binding.close-tab': 'Закрыть вкладку',
'settings.shortcuts.binding.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.move-focus': 'Переместить фокус между разделёнными окнами',
'settings.shortcuts.binding.split-horizontal': 'Горизонтальное разделение',
'settings.shortcuts.binding.split-vertical': 'Вертикальное разделение',
'settings.shortcuts.binding.open-hosts': 'Открыть список хостов',
'settings.shortcuts.binding.open-local': 'Открыть локальный терминал',
'settings.shortcuts.binding.open-sftp': 'Открыть SFTP',
'settings.shortcuts.binding.open-settings': 'Открыть настройки',
'settings.shortcuts.binding.port-forwarding': 'Открыть перенаправление портов',
'settings.shortcuts.binding.command-palette': 'Открыть палитру команд',
'settings.shortcuts.binding.quick-switch': 'Быстрое переключение',
'settings.shortcuts.binding.new-workspace': 'Новая рабочая область',
'settings.shortcuts.binding.snippets': 'Открыть сниппеты',
'settings.shortcuts.binding.broadcast': 'Переключить режим трансляции',
'settings.shortcuts.binding.toggle-side-panel': 'Переключить боковую панель',
'settings.shortcuts.binding.sftp-copy': 'Копировать файл',
'settings.shortcuts.binding.sftp-cut': 'Вырезать файл',
'settings.shortcuts.binding.sftp-paste': 'Вставить файл',
'settings.shortcuts.binding.sftp-select-all': 'Выделить все файлы',
'settings.shortcuts.binding.sftp-rename': 'Переименовать файл',
'settings.shortcuts.binding.sftp-delete': 'Удалить файл',
'settings.shortcuts.binding.sftp-refresh': 'Обновить',
'settings.shortcuts.binding.sftp-new-folder': 'Создать новую папку',
'settings.shortcuts.binding.sftp-open': 'Открыть файл / Войти в директорию',
'settings.shortcuts.binding.sftp-go-parent': 'Перейти в родительскую директорию',
'settings.shortcuts.binding.sftp-navigate-to': 'Перейти в выбранную директорию',
// Context menus / common actions
'action.newHost': 'Новый хост',
'action.newSubfolder': 'Новая подпапка',
'action.copyPublicKey': 'Копировать публичный ключ',
'action.keyExport': 'Экспорт ключа',
'action.edit': 'Редактировать',
'action.delete': 'Удалить',
'action.duplicate': 'Дублировать',
'action.open': 'Открыть',
'action.copy': 'Копировать',
'action.run': 'Запустить',
'action.start': 'Старт',
'action.stop': 'Остановить',
'action.remove': 'Убрать',
'action.convertToHost': 'Преобразовать в хост',
// Sync
'sync.cloudSync': 'Облачная синхронизация',
'sync.settings': 'Настройки синхронизации',
'sync.active': 'Облачная синхронизация активна',
'sync.syncing': 'Синхронизация...',
'sync.error': 'Ошибка синхронизации',
'sync.notConfigured': 'Не настроено',
'sync.failed': 'Синхронизация не удалась',
'sync.connected': 'Подключено',
'sync.syncNow': 'Синхронизировать сейчас',
'sync.recentActivity': 'Недавняя активность',
'sync.history.uploaded': 'Загружено',
'sync.history.downloaded': 'Скачано',
'sync.history.resolved': 'Разрешено',
'sync.toast.completedMessage': 'Синхронизация успешно завершена',
'sync.toast.errorTitle': 'Ошибка синхронизации',
'sync.autoSync.failedTitle': 'Синхронизация не удалась',
'sync.autoSync.inspectFailedTitle': 'Синхронизация приостановлена',
'sync.autoSync.inspectFailedMessage': 'Не удалось подключиться к облаку для проверки изменений. Автосинхронизация повторит попытку при изменении данных или после перезапуска приложения.',
'sync.autoSync.syncedTitle': 'Синхронизировано из облака',
'sync.autoSync.syncedMessage': 'Ваши данные были обновлены из облака.',
'sync.autoSync.noProvider': 'Облачный провайдер не подключён. Откройте Настройки → Синхронизация и облако, чтобы подключить его.',
'sync.autoSync.alreadySyncing': 'Синхронизация уже выполняется.',
'sync.autoSync.restoreInProgress': 'В другом окне уже выполняется восстановление хранилища. Подождите, пока оно завершится.',
'sync.autoSync.interruptedApplyTitle': 'Синхронизация приостановлена — предыдущее восстановление прервано',
'sync.autoSync.interruptedApplyMessage': 'Предыдущее восстановление завершилось некорректно, поэтому локальное хранилище может быть в несогласованном состоянии. Откройте Настройки → Синхронизация и облако → Восстановление и примените защитную резервную копию перед возобновлением автосинхронизации.',
'sync.autoSync.vaultLocked': 'Хранилище заблокировано. Откройте Настройки → Синхронизация и облако, чтобы разблокировать его.',
'sync.autoSync.conflictDetected': 'Обнаружен конфликт синхронизации. Откройте Настройки → Синхронизация и облако, чтобы разрешить его.',
'sync.autoSync.syncFailed': 'Синхронизация не удалась',
'sync.autoSync.restoredTitle': 'Хранилище восстановлено',
'sync.autoSync.restoredMessage': 'Ваше хранилище было восстановлено из облака.',
'sync.autoSync.keptLocalTitle': 'Локальное хранилище сохранено',
'sync.autoSync.keptLocalMessage': 'Ваше пустое локальное хранилище было сохранено. Облачные данные не применялись.',
'sync.autoSync.emptyVaultConflict.title': 'Обнаружено пустое хранилище',
'sync.autoSync.emptyVaultConflict.description': 'Ваше локальное хранилище пусто, но в облаке есть данные. Обычно это происходит после обновления или сброса хранилища. Что вы хотите сделать?',
'sync.autoSync.emptyVaultConflict.cloudLabel': 'Облако',
'sync.autoSync.emptyVaultConflict.restore': 'Восстановить из облака',
'sync.autoSync.emptyVaultConflict.restoreDesc': 'Рекомендуется — восстановить ваши хосты, ключи и сниппеты из облачной резервной копии',
'sync.autoSync.emptyVaultConflict.keepEmpty': 'Оставить пустым',
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': 'Начать заново с пустым хранилищем',
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} хостов, {keys} ключей, {snippets} сниппетов, {proxyProfiles} прокси',
'sync.autoSync.emptyVaultManual': 'Синхронизация невозможна: локальное хранилище пусто. Сначала восстановите его из локальной резервной копии или включите принудительную отправку в панели синхронизации.',
'sync.blocked.title': 'Синхронизация приостановлена',
'sync.blocked.reason.bulkShrink': 'Будет удалено {lost} из {baseCount} сущностей типа {entityType} из облака (сокращение на {percent}%).',
'sync.blocked.reason.largeShrink': 'Будет удалено {lost} сущностей типа {entityType} из облака.',
'sync.blocked.detail': 'Обычно это вызвано повреждённым локальным состоянием (сбой keychain, частичная загрузка данных). Восстановите данные из локальной резервной копии или выполните принудительную отправку, если вы действительно хотели удалить эти записи.',
'sync.blocked.restoreButton': 'Восстановить из локальной резервной копии',
'sync.blocked.forcePushButton': 'Всё равно отправить принудительно',
'sync.forcePush.title': 'Подтвердите принудительную отправку',
'sync.forcePush.body': 'Вы собираетесь удалить {lost} сущностей типа {entityType} из облака. Это действие нельзя отменить. Продолжить?',
'sync.forcePush.confirm': 'Да, всё равно отправить',
'sync.forcePush.cancel': 'Отмена',
'sync.entityType.hosts': 'хостов',
'sync.entityType.keys': 'ключей',
'sync.entityType.identities': 'идентификаторов',
'sync.entityType.proxyProfiles': 'профилей прокси',
'sync.entityType.snippets': 'сниппетов',
'sync.entityType.customGroups': 'групп',
'sync.entityType.snippetPackages': 'пакетов сниппетов',
'sync.entityType.knownHosts': 'записей known_hosts',
'sync.entityType.portForwardingRules': 'правил проброса портов',
'sync.entityType.groupConfigs': 'конфигураций групп',
'sync.credentialsUnavailable': 'Это устройство не может расшифровать некоторые сохранённые учётные данные. Перед синхронизацией повторно введите их локально.',
'time.never': 'Никогда',
'time.justNow': 'Только что',
'time.minutesAgo': '{minutes} мин назад',
// Vault navigation
'vault.nav.hosts': 'Хосты',
'vault.nav.keychain': 'Связка ключей',
'vault.nav.proxies': 'Прокси',
'vault.nav.portForwarding': 'Проброс портов',
'vault.nav.snippets': 'Сниппеты',
'vault.nav.knownHosts': 'Известные хосты',
'vault.nav.logs': 'Журналы',
'proxyProfiles.action.add': 'Добавить прокси',
'proxyProfiles.search.placeholder': 'Поиск прокси…',
'proxyProfiles.section.proxies': 'Прокси',
'proxyProfiles.count.items': 'Элементов: {count}',
'proxyProfiles.empty.title': 'Нет прокси',
'proxyProfiles.empty.desc': 'Создавайте переиспользуемые HTTP-, SOCKS5- или командные прокси и выбирайте их в настройках хоста.',
'proxyProfiles.usage': 'Связано: {count}',
'proxyProfiles.copyName': '{name} Копия',
'proxyProfiles.panel.newTitle': 'Новый прокси',
'proxyProfiles.field.name': 'Имя прокси',
'proxyProfiles.error.required': 'Имя и параметры прокси обязательны.',
'proxyProfiles.error.port': 'Порт должен быть в диапазоне от 1 до 65535.',
'proxyProfiles.viewMode': 'Режим просмотра прокси',
'proxyProfiles.delete.title': 'Удалить прокси?',
'proxyProfiles.delete.desc': 'Удаление "{name}" отвяжет его от {count} настроек хостов или групп.',
'vault.groups.title': 'Группы',
'vault.groups.total': 'Всего: {count}',
'vault.groups.hostsCount': 'Хостов: {count}',
'vault.groups.newSubgroup': 'Новая подгруппа',
'vault.groups.rename': 'Переименовать группу',
'vault.groups.delete': 'Удалить группу',
'vault.groups.createSubfolder': 'Создать подпапку',
'vault.groups.createRoot': 'Создать корневую группу',
'vault.groups.createDialog.desc': 'Создайте новую группу для организации хостов.',
'vault.groups.renameDialogTitle': 'Переименовать группу',
'vault.groups.renameDialog.desc': 'Переименуйте существующую группу.',
'vault.groups.deleteDialogTitle': 'Удалить группу',
'vault.groups.deleteDialog.desc': 'Группа будет безвозвратно удалена, а все хосты будут перемещены в корень.',
'vault.groups.deleteDialog.managedDesc': 'Это управляемая группа SSH-конфига. При её удалении также будут удалены все хосты и снята связь с исходным файлом.',
'vault.groups.deleteDialog.deleteHosts': 'Также удалить все хосты в этой группе',
'vault.groups.ungrouped': 'Без группы',
'vault.groups.field.name': 'Имя группы',
'vault.groups.placeholder.example': 'например, Production',
'vault.groups.parentLabel': 'Родитель',
'vault.groups.pathLabel': 'Путь',
'vault.groups.settings': 'Настройки группы',
'vault.groups.details': 'Сведения о группе',
'vault.groups.details.general': 'Общие',
'vault.groups.details.ssh': 'SSH',
'vault.groups.details.telnet': 'Telnet',
'vault.groups.details.advanced': 'Дополнительно',
'vault.groups.details.appearance': 'Внешний вид',
'vault.groups.details.mosh': 'Mosh',
'vault.groups.details.parentGroup': 'Родительская группа',
'vault.groups.details.none': 'Нет',
'vault.groups.details.inherited': 'Унаследовано от группы',
'vault.groups.details.addProtocol': 'Добавить протокол',
'vault.groups.details.removeProtocol': 'Удалить протокол',
'vault.groups.details.fontFamily': 'Семейство шрифта',
'vault.groups.details.fontSize': 'Размер шрифта',
'vault.groups.errors.required': 'Имя группы обязательно.',
'vault.groups.errors.invalidChars': "Имя группы не может содержать '/' или '\\\\'.",
'vault.groups.errors.duplicatePath': 'Группа с таким именем уже существует в этом расположении.',
'vault.managedSource.unmanage': 'Снять управление',
'vault.managedSource.unmanageSuccess': 'Управление группой успешно снято',
'vault.hosts.header.entries': 'Записей: {count}',
'vault.hosts.header.live': 'Активных: {count}',
};

View File

@@ -0,0 +1,672 @@
import type { Messages } from '../types';
export const ruTerminalMessages: Messages = {
// 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.sftp': 'SFTP',
'terminal.toolbar.more': 'Другие действия',
'terminal.toolbar.scripts': 'Скрипты',
'terminal.toolbar.library': 'Библиотека',
'terminal.toolbar.noSnippets': 'Нет доступных сниппетов',
'terminal.toolbar.terminalSettings': 'Настройки терминала',
'terminal.toolbar.searchTerminal': 'Поиск по терминалу',
'terminal.toolbar.search': 'Поиск',
'terminal.toolbar.broadcast': 'Трансляция',
'terminal.toolbar.broadcastEnable': 'Включить режим трансляции',
'terminal.toolbar.broadcastDisable': 'Отключить режим трансляции',
'terminal.toolbar.composeBar': 'Строка ввода',
'terminal.composeBar.placeholder': 'Введите команду здесь и нажмите Enter для отправки...',
'terminal.composeBar.send': 'Отправить',
'terminal.composeBar.close': 'Закрыть строку ввода',
'terminal.composeBar.broadcasting': 'Трансляция во все сессии',
'terminal.toolbar.focus': 'Фокус',
'terminal.toolbar.focusMode': 'Режим фокуса',
'terminal.toolbar.encoding': 'Кодировка терминала',
'terminal.toolbar.encoding.utf8': 'UTF-8',
'terminal.toolbar.encoding.gb18030': 'GB18030',
'terminal.toolbar.closeSession': 'Закрыть сессию',
'terminal.toolbar.hostHighlight.title': 'Подсветка ключевых слов хоста',
'terminal.toolbar.hostHighlight.noRules': 'Для этого хоста не задано пользовательских правил подсветки',
'terminal.toolbar.hostHighlight.addRule': 'Добавить новое правило',
'terminal.toolbar.hostHighlight.labelPlaceholder': 'Метка (например, Error)',
'terminal.toolbar.hostHighlight.patternPlaceholder': 'Regex-шаблон (например, \\bfailed\\b)',
'terminal.toolbar.hostHighlight.invalidPattern': 'Некорректный regex-шаблон',
'terminal.toolbar.hostHighlight.clearAll': 'Очистить все',
'terminal.toolbar.hostHighlight.changeColor': 'Изменить цвет подсветки для',
'terminal.toolbar.hostHighlight.selectColor': 'Выбрать цвет для нового правила',
'terminal.statusbar.copyHostname.label': 'Копировать адрес хоста',
'terminal.statusbar.copyHostname.tooltip': 'Копировать адрес хоста ({hostname})',
'terminal.statusbar.copyHostname.toast': 'Адрес хоста скопирован: {hostname}',
'terminal.statusbar.copyHostname.error': 'Не удалось скопировать адрес хоста в буфер обмена',
'terminal.serverStats.cpu': 'Использование CPU',
'terminal.serverStats.cpuCores': 'Использование ядер CPU',
'terminal.serverStats.memory': 'Использование памяти',
'terminal.serverStats.memoryDetails': 'Сведения о памяти',
'terminal.serverStats.memUsed': 'Использовано',
'terminal.serverStats.memBuffers': 'Буферы',
'terminal.serverStats.memCached': 'Кэш',
'terminal.serverStats.memFree': 'Свободно',
'terminal.serverStats.swap': 'Swap',
'terminal.serverStats.swapUsed': 'Использовано swap',
'terminal.serverStats.swapFree': 'Свободный swap',
'terminal.serverStats.swapTotal': 'Всего',
'terminal.serverStats.topProcesses': 'Топ процессов по памяти',
'terminal.serverStats.disk': 'Использование диска (корень)',
'terminal.serverStats.diskDetails': 'Смонтированные диски',
'terminal.serverStats.network': 'Скорость сети',
'terminal.serverStats.networkDetails': 'Сетевые интерфейсы',
'terminal.serverStats.noData': 'Данные недоступны',
'terminal.dragDrop.localTitle': 'Перетащите для вставки путей',
'terminal.dragDrop.localMessage': 'Пути к файлам будут вставлены в терминал',
'terminal.dragDrop.remoteTitle': 'Перетащите для загрузки файлов',
'terminal.dragDrop.remoteMessage': 'Файлы будут загружены через SFTP',
'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.pasteSelection': 'Вставить выделенное',
'terminal.menu.selectAll': 'Выбрать всё',
'terminal.menu.reconnect': 'Переподключиться',
'terminal.menu.splitHorizontal': 'Разделить по горизонтали',
'terminal.menu.splitVertical': 'Разделить по вертикали',
'terminal.menu.clearBuffer': 'Очистить буфер',
'terminal.menu.closeTerminal': 'Закрыть терминал',
'terminal.auth.password': 'Пароль',
'terminal.auth.sshKey': 'SSH-ключ',
'terminal.auth.username': 'Имя пользователя',
'terminal.auth.username.placeholder': 'root',
'terminal.auth.passwordLabel': 'Пароль',
'terminal.auth.password.placeholder': 'Введите пароль',
'terminal.auth.passphrase': 'Парольная фраза',
'terminal.auth.passphrase.placeholder': 'Необязательная парольная фраза для выбранного приватного ключа',
'terminal.auth.certificate': 'Сертификат',
'terminal.auth.selectKey': 'Выбрать ключ',
'terminal.auth.noKeysHint': 'Нет доступных ключей. Добавьте ключи в связке ключей.',
'terminal.auth.continueSave': 'Продолжить и сохранить',
'terminal.auth.credentialsUnavailable': 'Сохранённые учётные данные не могут быть расшифрованы на этом устройстве. Пожалуйста, введите и сохраните их заново.',
'terminal.auth.jumpCredentialsUnavailable': 'У jump-хоста сохранены учётные данные, которые нельзя расшифровать на этом устройстве. Откройте настройки хоста и введите их заново.',
'terminal.auth.proxyCredentialsUnavailable': 'Учётные данные прокси не могут быть расшифрованы на этом устройстве. Откройте настройки хоста и заново введите пароль прокси.',
'terminal.auth.keyUnavailableFallbackPassword': 'Сохранённый SSH-ключ недоступен на этом устройстве. Выполняется переход на аутентификацию по паролю.',
'terminal.progress.timeoutIn': 'Тайм-аут через {seconds}с',
'terminal.progress.disconnected': 'Отключено',
'terminal.progress.cancelling': 'Отмена...',
'terminal.progress.startOver': 'Начать заново',
'terminal.connection.dismissDisconnectedDialog': 'Закрыть уведомление об отключении',
'terminal.connection.chainOf': 'Цепочка {current} из {total}',
'terminal.connection.showLogs': 'Показать журналы',
'terminal.connection.hideLogs': 'Скрыть журналы',
'terminal.connection.protocol.ssh': 'SSH',
'terminal.connection.protocol.telnet': 'Telnet',
'terminal.connection.protocol.mosh': 'Mosh',
'terminal.connection.protocol.serial': 'Serial',
'terminal.connection.protocol.local': 'Локальная оболочка',
'terminal.hostKey.unknownTitle': 'Подтвердите этот ключ хоста',
'terminal.hostKey.changedTitle': 'Ключ хоста изменился',
'terminal.hostKey.unknownDescription': 'Подлинность {host} пока не может быть установлена.',
'terminal.hostKey.changedDescription': 'Сохранённый ключ для {host} больше не совпадает с этим сервером.',
'terminal.hostKey.fingerprintLabel': 'Отпечаток {keyType} — SHA256:',
'terminal.hostKey.savedFingerprintLabel': 'Сохранённый отпечаток',
'terminal.hostKey.unknownHint': 'Запомните его, если этот отпечаток принадлежит серверу, к которому вы ожидали подключиться.',
'terminal.hostKey.changedHint': 'Продолжайте только если вы ожидали, что этот хост изменится.',
'terminal.hostKey.addAndContinue': 'Добавить и продолжить',
'terminal.hostKey.updateAndContinue': 'Обновить и продолжить',
'terminal.themeModal.title': 'Внешний вид терминала',
'terminal.themeModal.tab.theme': 'Тема',
'terminal.themeModal.tab.font': 'Шрифт',
'terminal.themeModal.tab.custom': 'Пользовательское',
'terminal.themeModal.globalTheme': 'Глобальная тема',
'terminal.themeModal.globalFont': 'Глобальный шрифт',
'terminal.themeModal.fontSize': 'Размер шрифта',
'terminal.themeModal.fontWeight': 'Толщина шрифта',
'terminal.themeModal.livePreview': 'Предпросмотр в реальном времени',
'terminal.themeModal.themeType': 'Тема {type}',
'terminal.hiddenTheme.title': 'Текущая скрытая тема',
'terminal.hiddenTheme.desc': 'Эта тема скрыта из ручного выбора и будет заменена, когда вы выберете другую тему.',
'topTabs.toggleTheme.systemExitTitle': 'Активна системная тема',
'topTabs.toggleTheme.systemExitMessage': 'Откройте настройки, чтобы выбрать фиксированную светлую или тёмную тему.',
'topTabs.toggleTheme.openSettings': 'Открыть настройки',
// Custom Themes
'terminal.customTheme.section': 'Пользовательские темы',
'terminal.customTheme.yourThemes': 'Ваши темы',
'terminal.customTheme.new': 'Новая тема',
'terminal.customTheme.newDesc': 'Клонировать текущую тему и настроить её',
'terminal.customTheme.newTitle': 'Новая пользовательская тема',
'terminal.customTheme.editTitle': 'Редактировать тему',
'terminal.customTheme.import': 'Импорт .itermcolors',
'terminal.customTheme.importDesc': 'Импорт из файла цветовой схемы iTerm2',
'terminal.customTheme.importError': 'Не удалось разобрать выбранный файл. Убедитесь, что это корректный XML-файл .itermcolors.',
'terminal.customTheme.delete': 'Удалить тему',
'terminal.customTheme.confirmDelete': 'Подтвердить удаление',
'terminal.customTheme.name': 'Название',
'terminal.customTheme.namePlaceholder': 'Моя пользовательская тема',
'terminal.customTheme.type': 'Тип',
'terminal.customTheme.group.general': 'Общие',
'terminal.customTheme.group.normal': 'Обычные цвета',
'terminal.customTheme.group.bright': 'Яркие цвета',
'terminal.customTheme.color.background': 'Фон',
'terminal.customTheme.color.foreground': 'Текст',
'terminal.customTheme.color.cursor': 'Курсор',
'terminal.customTheme.color.selection': 'Выделение',
'terminal.customTheme.color.black': 'Чёрный',
'terminal.customTheme.color.red': 'Красный',
'terminal.customTheme.color.green': 'Зелёный',
'terminal.customTheme.color.yellow': 'Жёлтый',
'terminal.customTheme.color.blue': 'Синий',
'terminal.customTheme.color.magenta': 'Пурпурный',
'terminal.customTheme.color.cyan': 'Голубой',
'terminal.customTheme.color.white': 'Белый',
'terminal.customTheme.color.brightBlack': 'Яркий чёрный',
'terminal.customTheme.color.brightRed': 'Яркий красный',
'terminal.customTheme.color.brightGreen': 'Яркий зелёный',
'terminal.customTheme.color.brightYellow': 'Яркий жёлтый',
'terminal.customTheme.color.brightBlue': 'Яркий синий',
'terminal.customTheme.color.brightMagenta': 'Яркий пурпурный',
'terminal.customTheme.color.brightCyan': 'Яркий голубой',
'terminal.customTheme.color.brightWhite': 'Яркий белый',
// Cloud Sync Settings
'cloudSync.gate.title': 'Синхронизация с end-to-end шифрованием',
'cloudSync.gate.desc':
'Ваши данные шифруются локально перед синхронизацией. Облачные провайдеры никогда не видят ваши данные в открытом виде. Задайте мастер-ключ, чтобы включить безопасную синхронизацию.',
'cloudSync.gate.masterKey': 'Мастер-ключ',
'cloudSync.gate.confirmMasterKey': 'Подтвердите мастер-ключ',
'cloudSync.gate.placeholder': 'Введите надёжный пароль',
'cloudSync.gate.confirmPlaceholder': 'Подтвердите пароль',
'cloudSync.gate.mismatch': 'Пароли не совпадают',
'cloudSync.gate.warning':
'Я понимаю, что если забуду мастер-ключ, мои данные нельзя будет восстановить. Сброс пароля невозможен.',
'cloudSync.gate.enableVault': 'Включить зашифрованное хранилище',
'cloudSync.gate.enabledToast': 'Зашифрованное хранилище включено',
'cloudSync.gate.setupFailed': 'Не удалось настроить мастер-ключ',
'cloudSync.passwordStrength.tooShort': 'Слишком короткий',
'cloudSync.passwordStrength.weak': 'Слабый',
'cloudSync.passwordStrength.moderate': 'Средний',
'cloudSync.passwordStrength.strong': 'Сильный',
'cloudSync.passwordStrength.veryStrong': 'Очень сильный',
'cloudSync.provider.notConnected': 'Не подключено',
'cloudSync.provider.sync': 'Синхронизация',
'cloudSync.provider.connect': 'Подключить',
'cloudSync.provider.connecting': 'Подключение...',
'cloudSync.provider.webdav': 'WebDAV',
'cloudSync.provider.webdav.desc': 'Подключение к самостоятельно размещённому WebDAV endpoint',
'cloudSync.provider.s3': 'Совместимое с S3',
'cloudSync.provider.s3.desc': 'Подключение к объектному хранилищу, совместимому с S3',
'cloudSync.provider.comingSoon': 'Скоро',
'cloudSync.webdav.title': 'Настройки WebDAV',
'cloudSync.webdav.desc': 'Настройка WebDAV endpoint для зашифрованной синхронизации.',
'cloudSync.webdav.endpoint': 'URL endpoint',
'cloudSync.webdav.authType': 'Тип аутентификации',
'cloudSync.webdav.auth.basic': 'Basic',
'cloudSync.webdav.auth.digest': 'Digest',
'cloudSync.webdav.auth.token': 'Токен',
'cloudSync.webdav.username': 'Имя пользователя',
'cloudSync.webdav.password': 'Пароль',
'cloudSync.webdav.token': 'Токен',
'cloudSync.webdav.showSecret': 'Показать секрет',
'cloudSync.webdav.allowInsecure': 'Разрешить небезопасное соединение (игнорировать ошибки сертификата)',
'cloudSync.webdav.validation.endpoint': 'Введите корректный WebDAV endpoint.',
'cloudSync.webdav.validation.credentials': 'Имя пользователя и пароль обязательны.',
'cloudSync.webdav.validation.token': 'Токен обязателен.',
'cloudSync.s3.title': 'Настройки S3',
'cloudSync.s3.desc': 'Подключение к объектному хранилищу, совместимому с S3, для зашифрованной синхронизации.',
'cloudSync.s3.endpoint': 'URL endpoint',
'cloudSync.s3.region': 'Регион',
'cloudSync.s3.bucket': 'Бакет',
'cloudSync.s3.accessKeyId': 'ID ключа доступа',
'cloudSync.s3.secretAccessKey': 'Секретный ключ доступа',
'cloudSync.s3.sessionToken': 'Токен сессии (необязательно)',
'cloudSync.s3.prefix': 'Префикс ключа (необязательно)',
'cloudSync.s3.forcePathStyle': 'Принудительно использовать path-style URL (для MinIO/R2 и т. д.)',
'cloudSync.s3.showSecret': 'Показать секреты',
'cloudSync.s3.validation.required': 'Endpoint, регион, бакет, access key и secret обязательны.',
'cloudSync.smb.title': 'Настройки SMB',
'cloudSync.smb.desc': 'Подключение к файловой SMB/CIFS-шаре для зашифрованной синхронизации.',
'cloudSync.smb.share': 'Путь к шаре',
'cloudSync.smb.username': 'Имя пользователя',
'cloudSync.smb.password': 'Пароль',
'cloudSync.smb.domain': 'Домен (необязательно)',
'cloudSync.smb.domainPlaceholder': 'например, WORKGROUP',
'cloudSync.smb.port': 'Порт (необязательно)',
'cloudSync.smb.showSecret': 'Показать пароль',
'cloudSync.smb.validation.share': 'Путь к шаре обязателен.',
'cloudSync.smb.validation.port': 'Порт должен быть числом от 1 до 65535.',
'cloudSync.connect.smb.success': 'SMB успешно подключён',
'cloudSync.connect.smb.failedTitle': 'Ошибка подключения SMB',
'cloudSync.provider.smb': 'SMB-шара',
'cloudSync.connect.webdav.success': 'WebDAV успешно подключён',
'cloudSync.connect.webdav.failedTitle': 'Ошибка подключения WebDAV',
'cloudSync.connect.s3.success': 'S3 успешно подключён',
'cloudSync.connect.s3.failedTitle': 'Ошибка подключения S3',
'cloudSync.lastSync.never': 'Никогда',
'cloudSync.lastSync.justNow': 'Только что',
'cloudSync.lastSync.minutesAgo': '{minutes} мин назад',
'cloudSync.changeKey': 'Изменить ключ',
'cloudSync.providers.title': 'Облачные провайдеры',
'cloudSync.syncAll': 'Синхронизировать всех подключённых провайдеров',
'cloudSync.autoSync.title': 'Автосинхронизация',
'cloudSync.autoSync.desc': 'Автоматически синхронизировать при внесении изменений',
'cloudSync.strategy.title': 'Стратегия синхронизации',
'cloudSync.strategy.desc': 'Выберите, что делать, когда изменились и локальные, и облачные данные.',
'cloudSync.strategy.smartMerge': 'Умное объединение (рекомендуется)',
'cloudSync.strategy.smartMergeDesc': 'По возможности объединять изменения с обеих сторон; если Netcatty не сможет безопасно выбрать, он попросит вас решить вручную.',
'cloudSync.strategy.preferCloud': 'Приоритет облака',
'cloudSync.strategy.preferCloudDesc': 'Когда изменились обе стороны, скачать облачную версию и заменить локальные изменения.',
'cloudSync.strategy.preferLocal': 'Приоритет локальных данных',
'cloudSync.strategy.preferLocalDesc': 'Когда изменились обе стороны, загрузить локальную версию и заменить облачные изменения.',
'cloudSync.status.title': 'Статус синхронизации',
'cloudSync.status.localVersion': 'Локальная версия',
'cloudSync.status.remoteVersion': 'Удалённая версия',
'cloudSync.history.title': 'История синхронизации',
'cloudSync.history.upload': 'Загрузка',
'cloudSync.history.download': 'Скачивание',
'cloudSync.history.resolved': 'Разрешено',
'cloudSync.history.error': 'Ошибка',
'cloudSync.localBackups.title': 'История локальных резервных копий',
'cloudSync.localBackups.desc': 'Netcatty сохраняет локальные точки восстановления перед сменой версии приложения и перед восстановлением хранилища.',
'cloudSync.localBackups.retentionTitle': 'Хранение резервных копий',
'cloudSync.localBackups.retentionDesc': 'Выберите, сколько локальных резервных копий должен хранить Netcatty.',
'cloudSync.localBackups.maxCount': 'Макс. число копий',
'cloudSync.localBackups.maxSaved': 'Хранение резервных копий: {count}',
'cloudSync.localBackups.maxInvalid': 'Введите число от 1 до 100.',
'cloudSync.localBackups.empty': 'Локальных резервных копий пока нет.',
'cloudSync.localBackups.reason.appVersionChange': 'Перед сменой версии приложения',
'cloudSync.localBackups.reason.beforeRestore': 'Перед восстановлением',
'cloudSync.localBackups.versionChange': '{from} -> {to}',
'cloudSync.localBackups.counts': '{hosts} хостов, {keys} ключей, {snippets} сниппетов',
'cloudSync.localBackups.restore': 'Восстановить',
'cloudSync.localBackups.restoreSuccess': 'Локальная резервная копия восстановлена.',
'cloudSync.localBackups.restoreFailedTitle': 'Ошибка восстановления',
'cloudSync.localBackups.restoreMissing': 'Резервная копия не найдена.',
'cloudSync.localBackups.protectiveBackupFailed': 'Не удалось создать защитную резервную копию, поэтому восстановление было прервано для защиты ваших текущих данных. Устраните основную проблему (например, доступ к keychain) и попробуйте снова. Подробности: {message}',
'cloudSync.localBackups.restoreConfirmTitle': 'Восстановить эту резервную копию?',
'cloudSync.localBackups.restoreConfirmDesc': 'Ваши текущие хосты, ключи, сниппеты и настройки будут заменены содержимым этой резервной копии. Перед этим автоматически создаётся защитный снимок текущих данных.',
'cloudSync.localBackups.restoreConfirmButton': 'Восстановить',
'cloudSync.localBackups.restoreConfirmCancel': 'Отмена',
'cloudSync.localBackups.unavailableTitle': 'Локальные резервные копии недоступны',
'cloudSync.localBackups.unavailableDesc': 'Эта платформа не предоставляет Netcatty безопасное хранилище ключей, поэтому локальные резервные копии нельзя записывать безопасно. Установите Netcatty в систему с поддерживаемым keychain, чтобы включить историю локальных резервных копий.',
'cloudSync.localBackups.lockedTitle': 'Требуется мастер-ключ',
'cloudSync.localBackups.lockedDesc': 'Настройте или разблокируйте мастер-ключ перед восстановлением резервной копии, чтобы восстановленные учётные данные оставались зашифрованными.',
'cloudSync.revisionHistory.viewButton': 'История',
'cloudSync.revisionHistory.title': 'История версий хранилища',
'cloudSync.revisionHistory.description': 'Просматривайте и восстанавливайте предыдущие версии вашего хранилища из истории ревизий Gist.',
'cloudSync.revisionHistory.empty': 'Ревизии не найдены.',
'cloudSync.revisionHistory.current': 'Текущая',
'cloudSync.revisionHistory.revision': 'Ревизия',
'cloudSync.revisionHistory.revisionPreview': 'Содержимое ревизии',
'cloudSync.revisionHistory.device': 'Устройство',
'cloudSync.revisionHistory.hosts': 'Хосты',
'cloudSync.revisionHistory.keys': 'Ключи',
'cloudSync.revisionHistory.snippets': 'Сниппеты',
'cloudSync.revisionHistory.identities': 'Идентификаторы',
'cloudSync.revisionHistory.restoreButton': 'Восстановить эту версию',
'cloudSync.revisionHistory.restored': 'Хранилище восстановлено из выбранной ревизии.',
'cloudSync.revisionHistory.revisionNotFound': 'Ревизия не найдена или не содержит данных хранилища.',
'cloudSync.revisionHistory.decryptFailed': 'Не удалось расшифровать эту ревизию. Возможно, она была зашифрована другим мастер-паролем.',
'cloudSync.changeKey.title': 'Изменить мастер-ключ',
'cloudSync.changeKey.current': 'Текущий мастер-ключ',
'cloudSync.changeKey.new': 'Новый мастер-ключ',
'cloudSync.changeKey.confirmNew': 'Подтвердите новый мастер-ключ',
'cloudSync.changeKey.currentPlaceholder': 'Введите текущий мастер-ключ',
'cloudSync.changeKey.newPlaceholder': 'Введите новый мастер-ключ',
'cloudSync.changeKey.confirmPlaceholder': 'Подтвердите новый мастер-ключ',
'cloudSync.changeKey.fillAll': 'Пожалуйста, заполните все поля',
'cloudSync.changeKey.minLength': 'Новый мастер-ключ должен содержать не менее 8 символов',
'cloudSync.changeKey.notMatch': 'Новые мастер-ключи не совпадают',
'cloudSync.changeKey.incorrectCurrent': 'Неверный текущий мастер-ключ',
'cloudSync.changeKey.failed': 'Не удалось изменить мастер-ключ',
'cloudSync.changeKey.desc': 'Это заново зашифрует ваше хранилище. Убедитесь, что вы помните новый ключ.',
'cloudSync.changeKey.showKeys': 'Показать ключи',
'cloudSync.changeKey.updatedToast': 'Мастер-ключ обновлён',
'cloudSync.changeKey.updateButton': 'Обновить ключ',
'cloudSync.unlock.title': 'Введите мастер-ключ',
'cloudSync.unlock.masterKey': 'Мастер-ключ',
'cloudSync.unlock.desc':
'Введите мастер-ключ один раз, чтобы включить зашифрованную синхронизацию. Он будет безопасно сохранён в системном keychain.',
'cloudSync.unlock.placeholder': 'Введите мастер-ключ',
'cloudSync.unlock.empty': 'Пожалуйста, введите мастер-ключ',
'cloudSync.unlock.incorrect': 'Неверный мастер-ключ',
'cloudSync.unlock.failed': 'Не удалось разблокировать хранилище',
'cloudSync.unlock.showKey': 'Показать ключ',
'cloudSync.unlock.notNow': 'Не сейчас',
'cloudSync.unlock.readyToast': 'Хранилище готово',
'cloudSync.unlock.unlockButton': 'Разблокировать',
'cloudSync.header.vaultReady': 'Хранилище готово',
'cloudSync.header.preparingVault': 'Подготовка хранилища...',
'cloudSync.header.providersConnected': 'Подключено провайдеров: {count}',
'cloudSync.githubFlow.title': 'Подключить GitHub',
'cloudSync.githubFlow.desc': 'Скопируйте код ниже и введите его на GitHub, чтобы авторизовать Netcatty.',
'cloudSync.githubFlow.copyCode': 'Скопировать код',
'cloudSync.githubFlow.copied': 'Скопировано!',
'cloudSync.githubFlow.openGitHub': 'Открыть GitHub',
'cloudSync.githubFlow.waiting': 'Ожидание авторизации...',
'cloudSync.conflict.title': 'Обнаружен конфликт версий',
'cloudSync.conflict.desc': 'Выберите, какую версию сохранить',
'cloudSync.conflict.local': 'ЛОКАЛЬНАЯ',
'cloudSync.conflict.cloud': 'ОБЛАЧНАЯ',
'cloudSync.conflict.detailsTitle': 'Изменённые данные',
'cloudSync.conflict.detailsCounts': 'Локально {local} · Облако {cloud} · Конфликты {conflicts}',
'cloudSync.conflict.entity.hosts': 'Хосты',
'cloudSync.conflict.entity.keys': 'Ключи',
'cloudSync.conflict.entity.identities': 'Идентификаторы',
'cloudSync.conflict.entity.proxyProfiles': 'Профили прокси',
'cloudSync.conflict.entity.snippets': 'Сниппеты',
'cloudSync.conflict.entity.customGroups': 'Группы',
'cloudSync.conflict.entity.snippetPackages': 'Пакеты сниппетов',
'cloudSync.conflict.entity.portForwardingRules': 'Проброс портов',
'cloudSync.conflict.entity.groupConfigs': 'Настройки групп',
'cloudSync.conflict.entity.settings': 'Настройки',
'cloudSync.conflict.keepLocal': 'Перезаписать облако (сохранить локальную)',
'cloudSync.conflict.useCloud': 'Скачать из облака (перезаписать локальную)',
'cloudSync.connect.browserContinue': 'Завершите авторизацию в браузере',
'cloudSync.connect.browserCancelled': 'Предыдущая авторизация в браузере была отменена',
'cloudSync.connect.github.success': 'GitHub успешно подключён',
'cloudSync.connect.github.failedTitle': 'Ошибка подключения GitHub',
'cloudSync.connect.github.timeout': 'Время подключения к GitHub истекло. Проверьте сеть или настройки прокси.',
'cloudSync.connect.github.networkError': 'Не удалось связаться с GitHub. Проверьте сеть или настройки прокси.',
'cloudSync.connect.google.failedTitle': 'Ошибка подключения Google',
'cloudSync.connect.onedrive.failedTitle': 'Ошибка подключения OneDrive',
'cloudSync.sync.success': 'Синхронизировано с {provider}',
'cloudSync.sync.failed': 'Синхронизация не удалась',
'cloudSync.sync.failedTitle': 'Синхронизация не удалась',
'cloudSync.sync.errorTitle': 'Ошибка синхронизации',
'cloudSync.resolve.downloaded': 'Скачаны данные из облака',
'cloudSync.resolve.uploaded': 'Загружены локальные данные',
'cloudSync.resolve.failedTitle': 'Не удалось разрешить конфликт',
'cloudSync.clearLocal.title': 'Очистить локальные данные',
'cloudSync.clearLocal.desc': 'Сбросить локальную версию и историю синхронизации. При следующей синхронизации данные будут скачаны из облака.',
'cloudSync.clearLocal.button': 'Очистить',
'cloudSync.clearLocal.dialog.title': 'Очистить локальные данные хранилища?',
'cloudSync.clearLocal.dialog.desc': 'Локальная версия будет сброшена до 0, а история синхронизации очищена. При следующей синхронизации данные будут скачаны из облака и заменят локальные.',
'cloudSync.clearLocal.dialog.cancel': 'Отмена',
'cloudSync.clearLocal.dialog.confirm': 'Очистить локальные данные',
'cloudSync.clearLocal.toast.title': 'Локальные данные очищены',
'cloudSync.clearLocal.toast.desc': 'Локальная версия сброшена до 0. Выполните синхронизацию для загрузки из облака.',
// Keychain
'keychain.filter.key': 'Ключ',
'keychain.filter.certificate': 'Сертификат',
'keychain.action.generateKey': 'Создать ключ',
'keychain.action.importKey': 'Импорт. ключ',
'keychain.action.newIdentity': 'Новый ид-катор',
'keychain.action.importCertificate': 'Импорт. сертификат',
'keychain.view.grid': 'Сетка',
'keychain.view.list': 'Список',
'keychain.section.keys': 'Ключи',
'keychain.section.identities': 'Идентификаторы',
'keychain.count.items': '{count} запис(ей)',
'keychain.empty.title': 'Настройте свои ключи',
'keychain.empty.desc': 'Импортируйте или создайте SSH-ключи для безопасной аутентификации.',
'keychain.panel.generateKey': 'Сгенерировать ключ',
'keychain.panel.newKey': 'Новый ключ',
'keychain.panel.keyDetails': 'Сведения о ключе',
'keychain.panel.editKey': 'Редактировать ключ',
'keychain.panel.editIdentity': 'Редактировать идентификатор',
'keychain.panel.newIdentity': 'Новый идентификатор',
'keychain.panel.keyExport': 'Экспорт ключа',
'keychain.validation.labelRequired': 'Пожалуйста, введите метку для ключа',
'keychain.validation.labelAndPrivateKeyRequired': 'Метка и приватный ключ обязательны',
'keychain.validation.labelAndUsernameRequired': 'Метка и имя пользователя обязательны',
'keychain.error.generationUnavailable': 'Генератор ключей не работает - пожалуйста, убедитесь, что приложение работает в Electron',
'keychain.error.generateKeyPairFailed': 'Не удалось сгенерировать пару ключей',
'keychain.error.generateKeyFailed': 'Не удалось сгенерировать ключ',
'keychain.error.keyGenerationTitle': 'Генерация ключа',
'keychain.export.exportTo': 'Экспортировать в *',
'keychain.export.selectHost': 'Выберите хост',
'keychain.export.location': 'Расположение ~ $1 *',
'keychain.export.filename': 'Имя файла ~ $2 *',
'keychain.export.note': 'Экспорт ключей сейчас поддерживается только в системах {unix}. Используйте раздел {advanced} для настройки скрипта экспорта.',
'keychain.export.script': 'Скрипт *',
'keychain.export.scriptPlaceholder': 'Скрипт экспорта...',
'keychain.export.missingCredentials': 'У хоста нет сохранённого пароля или ключа. Сначала добавьте в хост учётные данные с паролем.',
'keychain.export.successTitle': 'Экспорт выполнен успешно',
'keychain.export.successMessage': 'Публичный ключ экспортирован и привязан к {host}',
'keychain.export.failedTitle': 'Ошибка экспорта',
'keychain.export.failedMessage': 'Не удалось экспортировать ключ: {error}',
'keychain.export.failedPrefix': 'Ошибка экспорта: {error}',
'keychain.export.exitCode': 'Команда завершилась с кодом {code}',
'keychain.export.exporting': 'Экспорт...',
'keychain.export.exportAndAttach': 'Экспортировать и привязать',
'keychain.export.title': 'Экспорт ключа',
'keychain.export.exportToRequired': 'Экспортировать в *',
'keychain.export.selectHostPlaceholder': 'Выберите хост...',
'keychain.export.locationLabel': 'Расположение ~ $1 *',
'keychain.export.filenameLabel': 'Имя файла ~ $2 *',
'keychain.export.advanced': 'Дополнительно',
'keychain.export.note.supportsOnly': 'Экспорт ключей сейчас поддерживается только в',
'keychain.export.note.systems': 'системах.',
'keychain.export.note.use': 'Используйте',
'keychain.export.note.customize': 'раздел для настройки скрипта экспорта.',
'keychain.export.scriptRequired': 'Скрипт *',
'keychain.export.exportToHost': 'Экспортировать на хост',
'keychain.export.failedGeneric': 'Ошибка экспорта: {message}',
'keychain.field.label': 'Метка',
'keychain.field.labelRequired': 'Метка *',
'keychain.field.labelPlaceholder': 'Метка ключа',
'keychain.field.privateKeyRequired': 'Приватный ключ *',
'keychain.field.publicKey': 'Публичный ключ',
'keychain.field.certificatePlaceholder': 'Содержимое сертификата (необязательно)',
'keychain.generate.keyType': 'Тип ключа',
'keychain.generate.keySize': 'Размер ключа',
'keychain.generate.labelPlaceholder': 'Метка ключа',
'keychain.generate.passphrasePlaceholder': 'Парольная фраза (необязательно)',
'keychain.generate.savePassphrase': 'Сохранить парольную фразу',
'keychain.generate.generate': 'Сгенерировать',
'keychain.generate.generateSave': 'Сгенерировать и сохранить',
'keychain.import.dropHint': 'Перетащите сюда файл ключа',
'keychain.import.importFromFile': 'Импортировать из файла',
'keychain.import.saveKey': 'Сохранить ключ',
'keychain.import.importedKeyLabel': 'Импортированный ключ',
'keychain.identity.usernameRequired': 'Имя пользователя *',
'keychain.identity.method.passwordOnly': 'Пароль',
'keychain.identity.summary.password': 'Пароль аутентификации',
'keychain.identity.summary.key': 'Ключ аутентификации',
'keychain.identity.summary.certificate': 'Сертификат аутентификации',
'keychain.identity.summary.passwordAndKey': 'Пароль и ключ аутентификации',
'keychain.identity.summary.passwordAndCertificate': 'Пароль и сертификат аутентификации',
'keychain.identity.summary.none': 'Нет учётных данных',
'keychain.identity.selectCredential': 'Выберите {kind}',
'keychain.identity.save': 'Сохранить',
'keychain.identity.update': 'Обновить',
'keychain.keyDialog.newTitle': 'Новый ключ',
'keychain.keyDialog.newDesc': 'Добавить новый SSH-ключ',
'keychain.keyDialog.editTitle': 'Редактировать ключ',
'keychain.keyDialog.editDesc': 'Обновить этот SSH-ключ',
'keychain.keyDialog.updateKey': 'Обновить ключ',
// Tabs
'tabs.closeSessionAria': 'Закрыть сессию',
'tabs.closeLogViewAria': 'Закрыть просмотр журнала',
'tabs.logPrefix': 'Журнал:',
'tabs.logLocal': 'Локальный',
'tabs.copyTab': 'Копировать вкладку',
'tabs.closeOthers': 'Закрыть остальные',
'tabs.closeToRight': 'Закрыть вкладки справа',
'tabs.closeAll': 'Закрыть все',
'keychain.edit.labelRequired': 'Метка *',
'keychain.edit.keyLabelPlaceholder': 'Метка ключа',
'keychain.edit.privateKeyRequired': 'Приватный ключ *',
'keychain.edit.publicKey': 'Публичный ключ',
'keychain.edit.certificate': 'Сертификат',
'keychain.edit.certificatePlaceholder': 'Содержимое сертификата (необязательно)',
'keychain.edit.filePath': 'Путь к файлу',
'keychain.edit.keyExport': 'Экспорт ключа',
'keychain.edit.exportToHost': 'Экспортировать на хост',
// Snippets
'snippets.searchPlaceholder': 'Поиск сниппетов...',
'snippets.action.newSnippet': 'Новый сниппет',
'snippets.action.newPackage': 'Новый пакет',
'snippets.panel.newTitle': 'Новый сниппет',
'snippets.panel.editTitle': 'Редактировать сниппет',
'snippets.field.description': 'Описание действия',
'snippets.field.descriptionPlaceholder': 'Например: проверить сетевую нагрузку',
'snippets.field.package': 'Добавить пакет',
'snippets.field.packagePlaceholder': 'Выберите или создайте пакет',
'snippets.field.createPackage': 'Создать пакет',
'snippets.field.scriptRequired': 'Скрипт *',
'snippets.scriptEditor.expand': 'Открыть в окне',
'snippets.scriptEditor.resize': 'Изменить высоту редактора',
'snippets.scriptEditor.modalTitle': 'Редактировать скрипт',
'snippets.variables.dialogTitle': 'Переменные сниппета',
'snippets.variables.dialogDesc': 'Заполните значения для "{label}" перед запуском.',
'snippets.variables.hint': 'Значения вставляются в скрипт как есть (без shell-экранирования).',
'snippets.variables.preview': 'Предпросмотр',
'snippets.variables.placeholder': 'Введите значение',
'snippets.variables.placeholderDefault': 'По умолчанию: {value}',
'snippets.variables.required': 'Эта переменная обязательна',
'snippets.variables.run': 'Запустить',
'snippets.field.variablesHelp': 'Используйте {{name}} или {{name:default}} для плейсхолдеров в скрипте.',
'snippets.field.variablesDetected': 'Переменные',
'snippets.field.variableDefault': 'по умолчанию {value}',
'snippets.targets.title': 'Цели',
'snippets.targets.add': 'Добавить цели',
'snippets.history.title': 'История оболочки',
'snippets.history.subtitle': '{count} команд',
'snippets.history.emptyTitle': 'История оболочки пока пуста',
'snippets.history.emptyDesc': 'Здесь будут появляться выполненные вами команды',
'snippets.history.loadMore': 'Загрузить ещё',
'snippets.history.separator': '•',
'snippets.history.labelPlaceholder': 'Задайте метку для этого сниппета',
'snippets.history.saveAsSnippet': 'Сохранить как сниппет',
'snippets.history.time.justNow': 'только что',
'snippets.history.time.minutesAgo': '{count}м назад',
'snippets.history.time.hoursAgo': '{count}ч назад',
'snippets.history.time.daysAgo': '{count}д назад',
'snippets.breadcrumb.allPackages': 'Все пакеты',
'snippets.breadcrumb.separator': '',
'snippets.empty.title': 'Создать сниппет',
'snippets.empty.desc': 'Сохраняйте самые используемые команды как сниппеты, чтобы повторно использовать их в один клик.',
'snippets.search.noResults.title': 'Нет совпадений',
'snippets.search.noResults.desc': 'Ни один сниппет или пакет не соответствует запросу "{query}". Попробуйте другой поисковый запрос или очистите поиск для просмотра.',
'snippets.section.packages': 'Пакеты',
'snippets.section.snippets': 'Сниппеты',
'snippets.package.count': '{count} сниппет(ов)',
'snippets.commandFallback': 'Команда',
'snippets.view.grid': 'Сетка',
'snippets.view.list': 'Список',
'snippets.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': 'Только вставить (не выполнять автоматически)',
// Snippet Shortkey
'snippets.field.shortkey': 'Сочетание клавиш',
'snippets.shortkey.placeholder': 'Нажмите, чтобы задать сочетание',
'snippets.shortkey.recording': 'Нажмите сочетание клавиш...',
'snippets.shortkey.hint': 'Нажмите это сочетание в терминале, чтобы быстро отправить команду.',
'snippets.shortkey.clear': 'Очистить сочетание',
'snippets.shortkey.error.systemConflict': 'Это сочетание конфликтует с системным сочетанием',
'snippets.shortkey.error.snippetConflict': 'Это сочетание уже используется сниппетом: {name}',
// Serial Port
'serial.button': 'Серийный',
'serial.modal.title': 'Подключение к последовательному порту',
'serial.modal.desc': 'Настройте параметры подключения к последовательному порту',
'serial.field.port': 'Последовательный порт',
'serial.field.selectPort': 'Выберите порт...',
'serial.field.baudRate': 'Скорость передачи',
'serial.field.dataBits': 'Биты данных',
'serial.field.stopBits': 'Стоп-биты',
'serial.field.stopBits15Warning': '1.5 stop bits may not be supported on all Windows devices',
'serial.field.parity': 'Чётность',
'serial.field.flowControl': 'Управление потоком',
'serial.noPorts': 'Последовательные порты не обнаружены. Подключите устройство и обновите список.',
'serial.field.customPort': 'Путь к пользовательскому порту',
'serial.field.customPortPlaceholder': 'например, /dev/ttys001 или COM1',
'serial.type.hardware': 'Аппаратный',
'serial.type.pseudo': 'Псевдотерминал',
'serial.type.custom': 'Пользовательский',
'serial.parity.none': 'Нет',
'serial.parity.even': 'Чётная',
'serial.parity.odd': 'Нечётная',
'serial.parity.mark': 'Mark',
'serial.parity.space': 'Space',
'serial.flowControl.none': 'Нет',
'serial.flowControl.xon/xoff': 'XON/XOFF (программный)',
'serial.flowControl.rts/cts': 'RTS/CTS (аппаратный)',
'serial.field.localEcho': 'Принудительное локальное эхо',
'serial.field.localEchoDesc': 'Локально отображать вводимые символы (для устройств без удалённого эха)',
'serial.field.lineMode': 'Построчный режим',
'serial.field.lineModeDesc': 'Буферизовать ввод и отправлять по Enter (вместо посимвольной отправки)',
'serial.field.charset': 'Кодировка',
'serial.connectionError': 'Не удалось подключиться к последовательному порту',
'serial.field.baudRatePlaceholder': 'Выберите или введите скорость...',
'serial.field.baudRateEmpty': 'Введите пользовательскую скорость передачи',
'serial.field.customBaudRate': 'Используется пользовательская скорость передачи',
'serial.field.saveConfig': 'Сохранить конфигурацию',
'serial.field.saveConfigDesc': 'Сохраните эту последовательную конфигурацию в хостах для быстрого доступа',
'serial.field.configLabel': 'Имя конфигурации',
'serial.field.configLabelPlaceholder': 'например, Arduino Uno',
'serial.connectAndSave': 'Подключить и сохранить',
'serial.edit.title': 'Настройки последовательного порта',
// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': 'Требуется аутентификация',
'keyboard.interactive.desc': 'Сервер требует дополнительную аутентификацию.',
'keyboard.interactive.descWithHost': 'Сервер {hostname} требует дополнительную аутентификацию.',
'keyboard.interactive.response': 'Ответ',
'keyboard.interactive.enterCode': 'Введите код подтверждения',
'keyboard.interactive.enterResponse': 'Введите ответ',
'keyboard.interactive.submit': 'Отправить',
'keyboard.interactive.verifying': 'Проверка...',
'keyboard.interactive.savePassword': 'Сохранить пароль',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'Парольная фраза SSH-ключа',
'passphrase.desc': 'Введите парольную фразу для {keyName}',
'passphrase.descWithHost': 'Введите парольную фразу для {keyName}, чтобы подключиться к {hostname}',
'passphrase.label': 'Парольная фраза',
'passphrase.keyPath': 'Ключ',
'passphrase.unlock': 'Разблокировать',
'passphrase.unlocking': 'Разблокировка...',
'passphrase.skip': 'Пропустить',
'passphrase.remember': 'Запомнить эту парольную фразу',
// Text Editor
'sftp.editor.wordWrap': 'Перенос строк',
'sftp.editor.maximize': 'Развернуть',
'sftp.editor.unsavedTitle': 'Несохранённые изменения',
'sftp.editor.unsavedMessage': 'В файле {fileName} есть несохранённые изменения. Сохранить перед закрытием?',
'sftp.editor.discardChanges': 'Отбросить',
'sftp.editor.saveAndClose': 'Сохранить и закрыть',
'sftp.editor.quitBlockedByDirty': 'Есть несохранённые редакторы — перед выходом сохраните изменения или отбросьте их',
};

View File

@@ -0,0 +1,668 @@
import type { Messages } from '../types';
export const ruVaultMessages: Messages = {
// Vault hosts header/actions
'vault.hosts.search.placeholder': 'Найти хост или ssh user@hostname / ssh -p 2222 user@hostname...',
'vault.hosts.connect': 'Подключиться',
'vault.view.grid': 'Сетка',
'vault.view.list': 'Список',
'vault.view.tree': 'Дерево',
'vault.tree.expandAll': 'Развернуть все',
'vault.tree.collapseAll': 'Свернуть все',
'vault.hosts.newHost': 'Новый хост',
'vault.hosts.newGroup': 'Новая группа',
'vault.hosts.import': 'Импорт',
'vault.hosts.export': 'Экспорт',
'vault.hosts.export.toast.success': 'Экспортировано {count} хостов в CSV',
'vault.hosts.export.toast.successWithSkipped': 'Экспортировано {count} хостов в CSV ({skipped} неподдерживаемых хостов пропущено)',
'vault.hosts.export.toast.noHosts': 'Нет хостов для экспорта',
'vault.hosts.allHosts': 'Все хосты',
'vault.hosts.pinned': 'Закреплённые',
'vault.hosts.recentlyConnected': 'Недавно подключённые',
'vault.hosts.pinToTop': 'Закрепить сверху',
'vault.hosts.unpin': 'Открепить',
'vault.hosts.copyCredentials': 'Копировать учётные данные',
'vault.hosts.copyCredentials.toast.success': 'Учётные данные скопированы в буфер обмена',
'vault.hosts.copyCredentials.toast.noPassword': 'Для этого хоста нет сохранённого пароля',
'vault.hosts.multiSelect': 'Множественный выбор',
'vault.hosts.selected': 'Выбрано: {count}',
'vault.hosts.selectAll': 'Выбрать все',
'vault.hosts.deselectAll': 'Снять выделение',
'vault.hosts.deleteSelected': 'Удалить ({count})',
'vault.hosts.deleteMultiple.success': 'Удалено хостов: {count}',
'vault.hosts.connectSelected': 'Подключить ({count})',
'vault.hosts.connectMultiple.success': 'Подключение хостов: {count}',
'vault.hosts.moveToGroup.success': 'Хост {host} перемещён в {group}',
'vault.hosts.empty.title': 'Настройте свои хосты',
'vault.hosts.empty.desc': 'Сохраняйте хосты, чтобы быстро подключаться к серверам, виртуальным машинам и контейнерам.',
// Vault import
'vault.import.title': 'Добавить данные в хранилище',
'vault.import.desc':
'Перенесите свои подключения из популярных клиентов. Выберите формат файла, чтобы начать миграцию.',
'vault.import.chooseFormat': 'Выберите формат файла',
'vault.import.csv.tip': 'Массовый импорт: используйте шаблон CSV.',
'vault.import.csv.downloadTemplate': 'Скачать шаблон CSV',
'vault.import.toast.start': 'Импорт из {format}...',
'vault.import.toast.completedTitle': 'Импорт завершён',
'vault.import.toast.failedTitle': 'Ошибка импорта',
'vault.import.toast.noEntries': 'В {format} не найдено импортируемых записей.',
'vault.import.toast.noNewHosts': 'Из {format} не импортировано новых хостов.',
'vault.import.toast.summary':
'Импортировано {count} хостов (пропущено {skipped}, дубликатов {duplicates}).',
'vault.import.toast.firstIssue': 'Первая проблема: {issue}',
'vault.import.sshConfig.chooseMode': 'Выберите, как импортировать ваш файл SSH-конфига.',
'vault.import.sshConfig.modeQuestion': 'Как вы хотите выполнить импорт?',
'vault.import.sshConfig.importOnly': 'Только импорт',
'vault.import.sshConfig.importOnlyDesc': 'Одноразовый импорт. Изменения не будут синхронизироваться обратно в файл.',
'vault.import.sshConfig.managed': 'Управляемая синхронизация',
'vault.import.sshConfig.managedDesc': 'Поддерживать синхронизацию. Изменения будут сохраняться обратно в файл.',
'vault.import.sshConfig.managedGroup': 'ssh config',
'vault.import.sshConfig.managedSuccess': 'Импортировано {count} хостов. Файл теперь находится под управлением.',
'vault.import.sshConfig.alreadyManaged': 'Этот файл уже находится под управлением.',
'vault.import.sshConfig.alreadyManagedDesc': 'Этот файл уже управляется в группе "{group}". Если хотите импортировать его заново, сначала удалите существующий управляемый источник.',
'vault.import.sshConfig.noFilePath': 'Невозможно управлять этим файлом.',
'vault.import.sshConfig.noFilePathDesc': 'Не удалось определить путь к файлу. Для управляемой синхронизации нужен доступ к файловой системе.',
// Known Hosts
'knownHosts.search.placeholder': 'Поиск известных хостов...',
'knownHosts.action.scanSystem': 'Сканировать систему',
'knownHosts.action.importFile': 'Импортировать файл',
'knownHosts.action.browseFile': 'Выбрать файл',
'knownHosts.empty.title': 'Нет известных хостов',
'knownHosts.empty.desc':
'Известные хосты — это SSH-серверы, к которым вы подключались раньше. Импортируйте системный файл known_hosts, чтобы начать.',
'knownHosts.results.showingLimited':
'Показано {shown} из {total} хостов. Используйте поиск, чтобы найти нужные хосты.',
'knownHosts.toast.scanUnavailable': 'Сканирование системы недоступно на этой платформе.',
'knownHosts.toast.scanNoFile': 'Системный файл known_hosts не найден.',
'knownHosts.toast.scanNoEntries': 'В known_hosts не найдено пригодных записей.',
'knownHosts.toast.scanImported': 'Импортировано новых хостов: {count}.',
'knownHosts.toast.scanNoNew': 'Новых хостов не найдено.',
'knownHosts.toast.scanFailed': 'Не удалось просканировать системный known_hosts.',
// Port Forwarding
'pf.empty.title': 'Настройте проброс портов',
'pf.empty.desc': 'Сохраняйте правила проброса портов для доступа к базам данных, веб-приложениям и другим сервисам.',
'pf.title': 'Проброс портов',
'pf.rulesCount': 'Правил: {count}',
'pf.wizard.editTitle': 'Редактировать проброс портов',
'pf.wizard.newTitle': 'Новый проброс портов',
'pf.wizard.saveChanges': 'Сохранить изменения',
'pf.wizard.done': 'Готово',
'pf.wizard.continue': 'Продолжить',
'pf.wizard.cancel': 'Отмена',
'pf.wizard.skipWizard': 'Пропустить мастер',
'pf.error.hostNotFound': 'Хост не найден',
'pf.toast.titleWithLabel': 'Проброс портов: {label}',
'pf.type.local': 'Локальный',
'pf.type.remote': 'Удалённый',
'pf.type.dynamic': 'Динамический',
'pf.type.menu.local': 'Локальный проброс',
'pf.type.menu.remote': 'Удалённый проброс',
'pf.type.menu.dynamic': 'Динамический проброс',
'pf.type.local.desc': 'Локальный проброс позволяет обращаться к прослушиваемому порту удалённого сервера так, как будто он локальный.',
'pf.type.remote.desc': 'Удалённый проброс открывает порт на удалённой машине и перенаправляет подключения на локальный (текущий) хост.',
'pf.type.dynamic.desc': 'Динамический проброс портов превращает Netcatty в SOCKS-прокси-сервер.',
'pf.wizard.type.title': 'Выберите тип проброса портов:',
'pf.wizard.localConfig.title': 'Укажите локальный порт и адрес привязки:',
'pf.wizard.localConfig.desc': 'Этот порт будет открыт на локальном (текущем) устройстве и будет принимать трафик.',
'pf.wizard.localConfig.localPort': 'Номер локального порта *',
'pf.wizard.bindAddress': 'Адрес привязки',
'pf.wizard.remoteHost.title': 'Выберите удалённый хост:',
'pf.wizard.remoteHost.desc': 'Выберите хост, на котором будет открыт порт. Трафик с этого порта будет перенаправляться на конечный хост.',
'pf.wizard.remoteConfig.title': 'Укажите порт и адрес привязки:',
'pf.wizard.remoteConfig.desc': 'Трафик будет перенаправляться с указанного порта и адреса интерфейса выбранного хоста.',
'pf.wizard.remoteConfig.remotePort': 'Номер удалённого порта *',
'pf.wizard.destination.title': 'Выберите конечный хост:',
'pf.wizard.destination.desc.local': 'Введите удалённый адрес назначения, к которому вы хотите получить доступ через туннель.',
'pf.wizard.destination.desc.remote': 'Адрес назначения и порт, на которые будет перенаправляться трафик.',
'pf.wizard.destination.address': 'Адрес назначения *',
'pf.wizard.destination.addressPlaceholder': 'например, 127.0.0.1 или 192.168.1.100',
'pf.wizard.destination.port': 'Номер порта назначения *',
'pf.wizard.sshServer.title': 'Выберите SSH-сервер:',
'pf.wizard.sshServer.desc.dynamic': 'Выберите SSH-сервер, который будет работать как SOCKS-прокси.',
'pf.wizard.sshServer.desc.default': 'Выберите SSH-сервер, который будет туннелировать ваш трафик к адресу назначения.',
'pf.wizard.label.title': 'Выберите метку:',
'pf.wizard.label.placeholder.dynamic': 'например, SOCKS Proxy',
'pf.wizard.label.placeholder.default': 'например, MySQL Production',
'pf.wizard.label.placeholder.remoteRule': 'например, Remote Rule',
'pf.wizard.placeholders.portExample': 'например, {port}',
'pf.action.newForwarding': 'Новое правило',
'pf.form.labelPlaceholder': 'Метка правила',
'pf.form.intermediateHost': 'Промежуточный хост *',
'pf.form.createRule': 'Создать правило',
'pf.form.openWizard': 'Открыть мастер',
'pf.form.openWizardTitle': 'Открыть мастер проброса портов',
'pf.view.grid': 'Сетка',
'pf.view.list': 'Список',
'pf.rule.summary.dynamic': 'SOCKS на {bindAddress}:{localPort}',
'pf.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
'pf.tooltip.relayHost': 'Промежуточный хост',
'pf.tooltip.hostLabel': 'Хост',
'pf.tooltip.hostAddress': 'Адрес',
'pf.tooltip.noHost': 'Промежуточный хост не настроен',
'pf.tooltip.localDesc': 'Локальный проброс портов: доступ к удалённым сервисам через SSH-туннель',
'pf.tooltip.remoteDesc': 'Удалённый проброс портов: публикация локальных сервисов на удалённом хосте',
'pf.tooltip.dynamicDesc': 'Динамический SOCKS-прокси: маршрутизация трафика через SSH-туннель',
'pf.deleteActive.title': 'Удалить активное правило проброса портов?',
'pf.deleteActive.desc': 'Правило проброса портов "{label}" сейчас активно. При удалении туннель будет сначала остановлен.',
'pf.deleteActive.confirm': 'Остановить и удалить',
'pf.form.autoStart': 'Автозапуск',
'pf.form.autoStartDesc': 'Автоматически запускать это правило при запуске приложения',
// SFTP
'sftp.newFolder': 'Новая папка',
'sftp.newFile': 'Новый файл',
'sftp.filter': 'Фильтр',
'sftp.filter.placeholder': 'Фильтр по имени файла...',
'sftp.bookmark.add': 'Добавить путь в закладки',
'sftp.bookmark.remove': 'Удалить закладку',
'sftp.bookmark.addGlobal': '+Глобальная',
'sftp.bookmark.addGlobalTooltip': 'Сохранить как глобальную закладку (общую для всех хостов)',
'sftp.bookmark.empty': 'Пока нет закладок',
'sftp.columns.name': 'Имя',
'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.viewMode.label': 'Режим просмотра',
'sftp.viewMode.list': 'Список',
'sftp.viewMode.tree': 'Дерево',
'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.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.noMatch': 'Подходящие хосты не найдены',
'sftp.permissions.title': 'Изменить права доступа',
'sftp.permissions.owner': 'Владелец',
'sftp.permissions.group': 'Группа',
'sftp.permissions.others': 'Остальные',
'sftp.permissions.octal': 'Восьмеричный',
'sftp.permissions.symbolic': 'Символьный',
'sftp.permissions.success': 'Права доступа успешно обновлены',
'sftp.permissions.failed': 'Не удалось обновить права доступа',
'sftp.pane.local': 'Локально',
'sftp.pane.remote': 'Удалённо',
'sftp.pane.selectHost': 'Выберите хост',
'sftp.pane.selectHostToStart': 'Выберите хост для начала',
'sftp.pane.chooseFilesystem': 'Выберите локальную или удалённую файловую систему для просмотра',
'sftp.tabs.addTab': 'Добавить новую вкладку',
'sftp.tabs.closeTab': 'Закрыть вкладку',
'sftp.tabs.newTab': 'Новая вкладка',
'sftp.conflict.title': 'Конфликт файлов',
'sftp.conflict.desc': 'В месте назначения уже существует файл с таким именем',
'sftp.conflict.alreadyExistsSuffix': 'уже существует',
'sftp.conflict.existingFile': 'Существующий файл',
'sftp.conflict.newFile': 'Новый файл',
'sftp.conflict.size': 'Размер:',
'sftp.conflict.modified': 'Изменён:',
'sftp.conflict.applyToAll': 'Применить это действие ко всем оставшимся конфликтам ({count})',
'sftp.conflict.action.stop': 'Остановить',
'sftp.conflict.action.skip': 'Пропустить',
'sftp.conflict.action.keepBoth': 'Сохранить оба',
'sftp.conflict.action.duplicate': 'Дублировать',
'sftp.conflict.action.merge': 'Объединить',
'sftp.conflict.action.replace': 'Заменить',
// SFTP Upload Phases
'sftp.upload.phase.compressing': 'Сжатие',
'sftp.upload.phase.uploading': 'Загрузка',
'sftp.upload.phase.extracting': 'Распаковка',
'sftp.upload.phase.compressed': 'Сжато',
// SFTP File Opener
'sftp.context.copyPath': 'Копировать путь к файлу',
'sftp.context.openWithDefault': 'Открыть в системном приложении',
'sftp.context.openWith': 'Открыть с помощью...',
'sftp.context.edit': 'Редактировать',
'sftp.context.preview': 'Предпросмотр',
'sftp.opener.title': 'Открыть с помощью',
'sftp.opener.desc': 'Выберите приложение для открытия этого файла',
'sftp.opener.builtInEditor': 'Встроенный редактор',
'sftp.opener.editDescription': 'Редактировать текстовые файлы',
'sftp.opener.builtInImageViewer': 'Встроенный просмотрщик изображений',
'sftp.opener.previewDescription': 'Просмотр изображений',
'sftp.opener.systemApp': 'Выбрать приложение...',
'sftp.opener.systemAppDescription': 'Выберите приложение на вашем компьютере',
'sftp.opener.onlySystemApp': 'Этот файл можно открыть только во внешнем приложении',
'sftp.opener.noAppsAvailable': 'Нет доступных приложений',
'sftp.opener.noExtension': 'файлы без расширения',
'sftp.opener.setDefault': 'Всегда использовать это для файлов {ext}',
'sftp.opener.confirmTitle': 'Установить по умолчанию?',
'sftp.opener.confirmDescription': 'Хотите всегда использовать {app} для файлов {ext}?',
'sftp.opener.yesRemember': 'Да, запомнить выбор',
'sftp.opener.justOnce': 'Только один раз',
'sftp.opener.confirm.title': 'Установить приложение по умолчанию',
'sftp.opener.confirm.desc': 'Хотите всегда открывать файлы .{ext} этим приложением?',
'sftp.editor.title': 'Текстовый редактор',
'sftp.editor.save': 'Сохранить на удалённый сервер',
'sftp.editor.saving': 'Сохранение...',
'sftp.editor.saved': 'Успешно сохранено',
'sftp.editor.saveFailed': 'Не удалось сохранить файл',
'sftp.editor.unsavedChanges': 'У вас есть несохранённые изменения. Всё равно закрыть?',
'sftp.editor.syntaxHighlight': 'Подсветка синтаксиса',
'sftp.preview.title': 'Просмотр изображения',
'sftp.preview.zoomIn': 'Увеличить',
'sftp.preview.zoomOut': 'Уменьшить',
'sftp.preview.resetZoom': 'Сбросить масштаб',
'sftp.preview.fitToWindow': 'Подогнать по окну',
// Settings > SFTP File Associations
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftp.transferConcurrency': 'Параллелизм передачи',
'settings.sftp.transferConcurrency.desc': 'Количество файлов, передаваемых параллельно при загрузке или скачивании папок. Более высокие значения могут ускорить работу, но способны перегрузить некоторые серверы.',
'settings.sftp.defaultOpener': 'Приложение для открытия по умолчанию',
'settings.sftp.defaultOpener.desc': 'Выберите приложение по умолчанию для открытия файлов без конкретной ассоциации',
'settings.sftp.defaultOpener.ask': 'Всегда спрашивать',
'settings.sftp.defaultOpener.askDesc': 'Каждый раз показывать диалог выбора приложения',
'settings.sftp.defaultOpener.builtInDesc': 'По умолчанию открывать текстовые файлы во встроенном редакторе',
'settings.sftp.defaultOpener.systemApp': 'Выбрать приложение...',
'settings.sftp.defaultOpener.systemAppDesc': 'По умолчанию открывать файлы в конкретном приложении',
'settings.sftpFileAssociations.title': 'Ассоциации файлов SFTP',
'settings.sftpFileAssociations.desc': 'Настройка приложений по умолчанию для открытия файлов по расширению',
'settings.sftpFileAssociations.extension': 'Расширение',
'settings.sftpFileAssociations.application': 'Приложение',
'settings.sftpFileAssociations.noAssociations': 'Ассоциации файлов не настроены',
'settings.sftpFileAssociations.remove': 'Удалить',
'settings.sftpFileAssociations.removeConfirm': 'Удалить ассоциацию для .{ext}?',
// Settings > SFTP Behavior
'settings.sftp.doubleClickBehavior': 'Поведение двойного щелчка',
'settings.sftp.doubleClickBehavior.desc': 'Выберите действие при двойном щелчке по файлу в SFTP-режиме',
'settings.sftp.doubleClickBehavior.open': 'Открыть файл',
'settings.sftp.doubleClickBehavior.transfer': 'Передать в другую панель',
'settings.sftp.doubleClickBehavior.openDesc': 'Открыть файл в приложении по умолчанию',
'settings.sftp.doubleClickBehavior.transferDesc': 'Передать файл на активный хост другой панели',
// Settings > SFTP Auto Sync
'settings.sftp.autoSync': 'Автосинхронизация с удалённым сервером',
'settings.sftp.autoSync.desc': 'Автоматически синхронизировать изменения файлов обратно на удалённый сервер при открытии файлов во внешних приложениях',
'settings.sftp.autoSync.enable': 'Включить автосинхронизацию',
'settings.sftp.autoSync.enableDesc': 'Когда вы сохраняете файл во внешнем приложении, изменения автоматически загружаются на удалённый сервер',
// Settings > SFTP Auto Open Sidebar
'settings.sftp.autoOpenSidebar': 'Автооткрытие боковой панели при подключении',
'settings.sftp.autoOpenSidebar.desc': 'Автоматически открывать боковую панель файлового браузера SFTP при подключении к хосту',
'settings.sftp.autoOpenSidebar.enable': 'Включить автооткрытие боковой панели',
'settings.sftp.autoOpenSidebar.enableDesc': 'Боковая панель SFTP будет автоматически открываться при подключении терминальной сессии к удалённому хосту',
'settings.sftp.defaultViewMode': 'Режим просмотра по умолчанию',
'settings.sftp.defaultViewMode.desc': 'Выберите режим просмотра по умолчанию при открытии новой вкладки SFTP. Настройки конкретного хоста имеют приоритет.',
'settings.sftp.defaultViewMode.list': 'Список',
'settings.sftp.defaultViewMode.listDesc': 'Показывать файлы в виде плоского списка для текущего каталога',
'settings.sftp.defaultViewMode.tree': 'Дерево',
'settings.sftp.defaultViewMode.treeDesc': 'Показывать файлы в иерархической древовидной структуре',
'sftp.autoSync.success': 'Файл синхронизирован с удалённым сервером: {fileName}',
'sftp.autoSync.error': 'Не удалось синхронизировать файл: {error}',
// SFTP Folder Upload Progress
'sftp.upload.progress': 'Загрузка файлов {current} из {total}...',
'sftp.upload.uploading': 'Загрузка...',
'sftp.upload.compressing': 'Сжатие...',
'sftp.upload.extracting': 'Распаковка...',
'sftp.upload.scanning': 'Сканирование файлов...',
'sftp.upload.completed': 'Завершено',
'sftp.upload.compressed': 'Сжатая передача',
'sftp.upload.currentFile': 'Текущий: {fileName}',
'sftp.upload.cancelled': 'Загрузка отменена',
'sftp.upload.cancel': 'Отмена',
'sftp.upload.completedToPath': 'Загружено в {path}',
// SFTP Download
'sftp.download.completed': 'Скачано',
'sftp.download.cancelled': 'Скачивание отменено',
// SFTP Reconnecting
'sftp.reconnecting.title': 'Переподключение...',
'sftp.reconnecting.desc': 'Соединение потеряно, выполняется попытка переподключения',
'sftp.reconnected': 'Соединение восстановлено',
'sftp.error.reconnectFailed': 'Не удалось переподключиться. Попробуйте ещё раз.',
'sftp.error.connectionLostManual': 'Соединение потеряно. Пожалуйста, переподключитесь вручную.',
'sftp.error.connectionLostReconnecting': 'Соединение потеряно. Переподключение...',
'sftp.error.sessionLost': 'SFTP-сессия потеряна. Пожалуйста, переподключитесь.',
// Settings > SFTP Show Hidden Files
'settings.sftp.showHiddenFiles': 'Показывать скрытые файлы',
'settings.sftp.showHiddenFiles.desc': 'Показывать скрытые файлы (dotfiles в Unix/macOS и файлы с атрибутом hidden в Windows) в файловом браузере SFTP.',
'settings.sftp.showHiddenFiles.enable': 'Показывать скрытые файлы',
'settings.sftp.showHiddenFiles.enableDesc': 'Показывать скрытые файлы при просмотре как локальной, так и удалённой файловой системы',
// Settings > SFTP Compressed Upload
'settings.sftp.compressedUpload': 'Передача со сжатием папок',
'settings.sftp.compressedUpload.desc': 'Сжимать папки перед загрузкой, чтобы значительно сократить время передачи.',
'settings.sftp.compressedUpload.enable': 'Включить сжатие папок',
'settings.sftp.compressedUpload.enableDesc': 'Автоматически сжимать папки с помощью tar перед передачей. Требует поддержки tar на сервере. Если она недоступна, будет использована обычная передача.',
// Quick Switcher
'qs.search.placeholder': 'Поиск хостов или вкладок',
'qs.jumpTo': 'Перейти к',
'qs.localTerminal': 'Локальный терминал',
'qs.localShells': 'Локальные оболочки',
'qs.default': 'По умолчанию',
// Select Host panel
'selectHost.title': 'Выберите хост',
'selectHost.noHostsFound': 'Хосты не найдены',
'selectHost.newHost': 'Новый хост',
'selectHost.continue': 'Продолжить',
'selectHost.continueWithCount': 'Продолжить (выбрано: {count})',
// Quick Connect
'quickConnect.knownHost.title': 'Вы уверены, что хотите подключиться?',
'quickConnect.knownHost.authenticity': 'Подлинность {hostname} не может быть установлена.',
'quickConnect.knownHost.fingerprintLabel': '{keyType} fingerprint is SHA256:',
'quickConnect.knownHost.addQuestion': 'Хотите добавить его в список известных хостов?',
'quickConnect.knownHost.addAndContinue': 'Добавить и продолжить',
'quickConnect.addKey': 'Добавить ключ',
'quickConnect.warning.unparsedOptions': 'Некоторые аргументы SSH были проигнорированы: {options}',
// Terminal
'terminal.connectionErrorTitle': 'Ошибка подключения',
// 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.group.placeholder': 'Родительская группа',
'hostDetails.section.credentials': 'Учётные данные',
'hostDetails.section.portCredentials': 'Порт и учётные данные',
'hostDetails.section.appearance': 'Внешний вид',
'hostDetails.distro.title': 'Дистрибутив Linux',
'hostDetails.distro.desc': 'Автоопределение при подключении или ручное переопределение значка дистрибутива.',
'hostDetails.distro.mode': 'Источник',
'hostDetails.distro.mode.auto': 'Автоопределение',
'hostDetails.distro.mode.manual': 'Ручное переопределение',
'hostDetails.distro.detectedLabel': 'Текущий',
'hostDetails.distro.manualLabel': 'Переопределить',
'hostDetails.distro.pending': 'Определится после первого подключения',
'hostDetails.distro.unknown': 'Неизвестно',
'hostDetails.distro.option.linux': 'Обычный Linux',
'hostDetails.distro.option.ubuntu': 'Ubuntu',
'hostDetails.distro.option.debian': 'Debian',
'hostDetails.distro.option.centos': 'CentOS',
'hostDetails.distro.option.rocky': 'Rocky Linux',
'hostDetails.distro.option.fedora': 'Fedora',
'hostDetails.distro.option.arch': 'Arch Linux',
'hostDetails.distro.option.alpine': 'Alpine',
'hostDetails.distro.option.amazon': 'Amazon Linux',
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
'hostDetails.distro.option.almalinux': 'AlmaLinux',
'hostDetails.distro.option.alinux': 'Alibaba Cloud Linux',
'hostDetails.distro.option.oracle': 'Oracle Linux',
'hostDetails.distro.option.kali': 'Kali Linux',
'hostDetails.distro.option.cisco': 'Cisco',
'hostDetails.distro.option.juniper': 'Juniper Networks',
'hostDetails.distro.option.huawei': 'Huawei',
'hostDetails.distro.option.hpe': 'HPE / H3C',
'hostDetails.distro.option.mikrotik': 'MikroTik',
'hostDetails.distro.option.fortinet': 'Fortinet',
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
'hostDetails.distro.option.zyxel': 'ZyXEL',
'hostDetails.distro.option.ruijie': 'Ruijie',
'hostDetails.section.mosh': 'Mosh',
'hostDetails.username.placeholder': 'Имя пользователя',
'hostDetails.password.placeholder': 'Пароль',
'hostDetails.password.show': 'Показать пароль',
'hostDetails.password.hide': 'Скрыть пароль',
'hostDetails.password.save': 'Сохранить пароль',
'hostDetails.identity.suggestions': 'Идентификаторы',
'hostDetails.identity.missing': 'Идентификатор не найден',
'hostDetails.credential.keyCertificate': 'Ключ, сертификат, локальный файл ключа',
'hostDetails.credential.key': 'Ключ',
'hostDetails.credential.certificate': 'Сертификат',
'hostDetails.credential.localKeyFile': 'Локальный файл ключа',
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
'hostDetails.credential.browseKeyFile': 'Обзор...',
'hostDetails.credential.missing': 'Учётные данные не найдены',
'hostDetails.keys.search': 'Поиск ключей...',
'hostDetails.keys.empty': 'Нет доступных ключей',
'hostDetails.certs.search': 'Поиск сертификатов...',
'hostDetails.certs.empty': 'Нет доступных сертификатов',
'hostDetails.agentForwarding': 'Проброс SSH Agent',
'hostDetails.agentForwarding.desc': 'Разрешить удалённому серверу использовать ваши локальные SSH-ключи (например, для операций git)',
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent недоступен',
'hostDetails.agentForwarding.agentNotRunningHint': 'SSH Agent не обнаружен. Включите OpenSSH Authentication Agent в службах Windows или используйте совместимый агент, например Bitwarden, 1Password или gpg-agent.',
'hostDetails.section.agentForwarding': 'SSH Agent',
'hostDetails.x11Forwarding': 'Проброс X11-приложений',
'hostDetails.x11Forwarding.desc': 'Показывать удалённые графические приложения на вашем локальном рабочем столе, если запущен локальный X-сервер.',
'hostDetails.section.x11Forwarding': 'Проброс X11',
'hostDetails.section.deviceType': 'Тип устройства',
'hostDetails.deviceType': 'Режим сетевого устройства',
'hostDetails.deviceType.desc': 'Включайте для сетевого оборудования (коммутаторов, маршрутизаторов, межсетевых экранов), подключённого по SSH. Команды отправляются как есть, без обёртки оболочки, что совместимо с CLI вендоров вроде Huawei VRP и Cisco IOS.',
'hostDetails.deviceType.warning': 'Команды AI-агента будут отправляться напрямую без отслеживания кода выхода. Включайте только для устройств, на которых нет стандартной оболочки.',
'hostDetails.section.sshAlgorithms': 'SSH-алгоритмы',
'hostDetails.section.terminalBehavior': 'Поведение терминала',
'hostDetails.legacyAlgorithms': 'Разрешить устаревшие алгоритмы',
'hostDetails.legacyAlgorithms.desc': 'Включить устаревшие SSH-алгоритмы (diffie-hellman-group1, ssh-dss, 3des-cbc и т. д.) для подключения к старому сетевому оборудованию.',
'hostDetails.legacyAlgorithms.warning': 'У этих алгоритмов есть известные слабые места безопасности. Включайте только для устаревших устройств, которые не поддерживают современную криптографию.',
'hostDetails.skipEcdsaHostKey': 'Пропустить ECDSA host key',
'hostDetails.skipEcdsaHostKey.desc': 'Некоторые старые коммутаторы Huawei / Cisco выдают нестандартные подписи ECDSA host-key, из-за чего соединение падает с "signature verification failed". Включение этой опции убирает все ecdsa-sha2-* из предложения клиента, и согласование переходит к RSA / Ed25519.',
'hostDetails.algorithms.advanced': 'Дополнительные настройки алгоритмов',
'hostDetails.algorithms.advanced.desc': 'Заменить предлагаемый список алгоритмов для любой категории для конкретного хоста. Не трогать категорию = использовать значение по умолчанию; выбранное подмножество полностью заменяет список по умолчанию. Неверные значения могут сделать хост недоступным.',
'hostDetails.algorithms.inheritedNotice': 'В текущей группе заданы переопределения алгоритмов для: {categories}. Кнопка «Сбросить» здесь возвращает к спискам группы, а не к значениям NetCatty по умолчанию. Чтобы игнорировать ограничение группы, очистите переопределение в настройках алгоритмов группы.',
'hostDetails.algorithms.customized': 'настроено',
'hostDetails.algorithms.reset': 'Сбросить',
'hostDetails.algorithms.category.kex': 'Обмен ключами (KEX)',
'hostDetails.algorithms.category.cipher': 'Шифр',
'hostDetails.algorithms.category.hmac': 'MAC (HMAC)',
'hostDetails.algorithms.category.serverHostKey': 'Host Key',
'hostDetails.algorithms.category.compress': 'Сжатие',
'hostDetails.section.keepalive': 'Keepalive',
'hostDetails.keepalive.override': 'Переопределить глобальный keepalive',
'hostDetails.keepalive.desc': 'Использовать для этого хоста собственную политику keepalive вместо глобальной настройки. Полезно для старых маршрутизаторов и коммутаторов, чей SSH-сервер не отвечает на запросы keepalive@openssh.com. Установите интервал 0, чтобы полностью отключить keepalive для этого хоста.',
'hostDetails.keepalive.interval': 'Интервал (секунды)',
'hostDetails.keepalive.countMax': 'Макс. число пропущенных keepalive',
'hostDetails.keepalive.disabledHint': 'Интервал = 0 отключает keepalive для этого хоста. Для определения разорванного соединения сессия будет полагаться на TCP-таймауты.',
'hostDetails.backspaceBehavior': 'Поведение Backspace',
'hostDetails.backspaceBehavior.default': 'По умолчанию',
'hostDetails.jumpHosts': 'Прокси через хосты',
'hostDetails.jumpHosts.hops': '{count} hop(s)',
'hostDetails.jumpHosts.direct': 'Напрямую',
'hostDetails.jumpHosts.configure': 'Настроить прокси-хосты',
'hostDetails.proxy': 'Прокси через HTTP/SOCKS5/Command',
'hostDetails.proxy.none': 'Нет',
'hostDetails.proxy.edit': 'Редактировать прокси',
'hostDetails.proxy.configure': 'Настроить прокси',
'hostDetails.proxyPanel.title': 'Прокси через HTTP/SOCKS5/Command',
'hostDetails.proxyPanel.hostPlaceholder': 'Прокси-хост',
'hostDetails.proxyPanel.command': 'ProxyCommand',
'hostDetails.proxyPanel.commandPlaceholder': 'cloudflared access ssh --hostname %h',
'hostDetails.proxyPanel.commandHelp': 'Используйте %h для целевого хоста, %p для целевого порта и %% для символа процента.',
'hostDetails.proxyPanel.credentials': 'Учётные данные',
'hostDetails.proxyPanel.usernamePlaceholder': 'Имя пользователя',
'hostDetails.proxyPanel.passwordPlaceholder': 'Пароль',
'hostDetails.proxyPanel.identities': 'Идентификаторы',
'hostDetails.proxyPanel.remove': 'Удалить прокси',
'hostDetails.proxyPanel.savedProxy': 'Сохранённый прокси',
'hostDetails.proxyPanel.selectSaved': 'Выбрать сохранённый прокси',
'hostDetails.proxyPanel.customProxy': 'Пользовательский прокси',
'hostDetails.proxyPanel.missing': 'Отсутствует',
'hostDetails.proxyPanel.missingSaved': 'Сохранённый прокси отсутствует',
'hostDetails.proxyPanel.error.required': 'Требуются хост и порт прокси или ProxyCommand.',
'hostDetails.envVars': 'Переменные окружения',
'hostDetails.envVars.add': 'Добавить переменную окружения',
'hostDetails.envVars.title': 'Переменные окружения',
'hostDetails.envVars.desc': 'Задайте переменную окружения для {host}.',
'hostDetails.envVars.note': 'Некоторые SSH-серверы по умолчанию разрешают только переменные с префиксом LC_ и LANG_.',
'hostDetails.envVars.variable': 'Переменная',
'hostDetails.envVars.value': 'Значение',
'hostDetails.envVars.newVariable': 'Новая переменная',
'hostDetails.envVars.variableName': 'Имя переменной',
'hostDetails.chain.title': 'Редактировать цепочку',
'hostDetails.chain.desc': 'Добавление ещё одного хоста создаст подключение к {host}.',
'hostDetails.chain.addHost': 'Добавить хост',
'hostDetails.chain.target': 'Цель',
'hostDetails.chain.availableHosts': 'Доступные хосты',
'hostDetails.chain.clear': 'Очистить',
'hostDetails.group.title': 'Новая группа',
'hostDetails.group.general': 'Общие',
'hostDetails.group.namePlaceholder': 'Имя группы',
'hostDetails.group.parentPlaceholder': 'Родительская группа',
'hostDetails.group.cloudSync': 'Облачная синхронизация',
'hostDetails.group.addProtocol': 'Добавить протокол',
'hostDetails.startupCommand': 'Команда запуска',
'hostDetails.startupCommand.placeholder': 'Команда для запуска при подключении (например, cd /app && ls)',
'hostDetails.startupCommand.help':
'This command will be executed automatically after SSH connection is established.',
'hostDetails.otherProtocols': 'Другие протоколы',
'hostDetails.telnetOn': 'Telnet на',
'hostDetails.port': 'порт',
'hostDetails.telnet.credentials': 'Учётные данные',
'hostDetails.telnet.username': 'Имя пользователя Telnet',
'hostDetails.telnet.password': 'Пароль Telnet',
'hostDetails.charset.placeholder': 'Кодировка (например, UTF-8)',
'hostDetails.telnet.add': 'Добавить протокол Telnet',
'hostDetails.telnet.setDefault': 'Подключаться по Telnet по умолчанию',
'hostDetails.tags': 'Теги',
'hostDetails.group': 'Группа',
'hostDetails.selectGroup': 'Выберите группу',
'hostDetails.addTag': 'Добавить тег...',
'hostDetails.createTag': 'Создать тег',
'hostDetails.createGroup': 'Создать группу',
// Host form (legacy modal)
'hostForm.title.edit': 'Редактировать хост',
'hostForm.title.new': 'Новый хост',
'hostForm.desc.edit': 'Обновите параметры подключения для этого хоста',
'hostForm.desc.new': 'Создайте новую запись SSH-хоста',
'hostForm.field.label': 'Метка',
'hostForm.placeholder.label': 'Мой production-сервер',
'hostForm.field.hostname': 'Имя хоста / IP',
'hostForm.placeholder.hostname': '192.168.1.1',
'hostForm.field.port': 'Порт',
'hostForm.field.username': 'Имя пользователя',
'hostForm.field.osType': 'Тип ОС',
'hostForm.placeholder.selectOs': 'Выберите ОС',
'hostForm.field.group': 'Группа',
'hostForm.placeholder.group': 'например, AWS, DigitalOcean',
'hostForm.field.tags': 'Теги',
'hostForm.placeholder.addTag': 'Добавить тег...',
'hostForm.auth.method': 'Метод аутентификации',
'hostForm.auth.password': 'Пароль',
'hostForm.auth.sshKey': 'SSH-ключ',
'hostForm.auth.selectKey': 'Выберите SSH-ключ',
'hostForm.auth.noKeys': 'Нет доступных ключей',
'hostForm.auth.noKeysHint': 'В связке ключей не найдено SSH-ключей. Сначала создайте один.',
'hostForm.saveHost': 'Сохранить хост',
};

View File

@@ -0,0 +1 @@
export type Messages = Record<string, string>;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,254 @@
import type { Messages } from '../types';
export const zhCNAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Agent 设置',
'ai.title': 'AI',
'ai.description': '配置 AI 提供商、Agent 和安全设置',
'ai.providers': '提供商',
'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.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 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 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.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 Chat
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
'ai.chat.toolDenied': '操作已被用户拒绝。',
'ai.chat.toolApproved': '已批准',
'ai.chat.toolApprovalHint': '按回车批准,按 Esc 拒绝',
'ai.chat.approve': '批准',
'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.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 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.autonomous': '自主 - 自由执行',
'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.note': '这些安全设置会约束经由 Netcatty 执行的操作。外部 Agent CLI 也可能提供本机工具,那部分由 Agent 自己的控制规则约束。',
// 统一终端工作区和顶部标签的 tooltip 文案 (issue #954)
'terminal.layer.addTerminal': '添加终端',
'terminal.layer.switchToSplitView': '切换到分屏视图',
'terminal.layer.sftp': '文件传输',
'terminal.layer.scripts': '脚本',
'terminal.layer.theme': '主题',
'terminal.layer.aiChat': 'AI 助手',
'terminal.layer.movePanelLeft': '面板移至左侧',
'terminal.layer.movePanelRight': '面板移至右侧',
'terminal.layer.closePanel': '关闭面板',
'topTabs.openQuickSwitcher': '打开快速切换',
'topTabs.moreTabs': '更多标签页',
'topTabs.aiAssistant': 'AI 助手',
'topTabs.toggleTheme': '切换主题',
'topTabs.openSettings': '打开设置',
'ai.chat.sessionHistory': '会话历史',
'ai.chat.attach': '附件',
'ai.chat.collapse': '收起',
'ai.chat.expand': '展开',
'ai.chat.enableAgent': '启用 {name}',
'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': '重置为默认',
};

View File

@@ -0,0 +1,669 @@
import type { Messages } from '../types';
export const zhCNCoreMessages: Messages = {
// Common
'common.save': '保存',
'common.cancel': '取消',
'common.close': '关闭',
'common.reset': '重置',
'common.zoomIn': '放大',
'common.zoomOut': '缩小',
'common.settings': '设置',
'common.search': '搜索',
'common.connect': '连接',
'common.terminal': '终端',
'common.create': '创建',
'common.add': '添加',
'common.rename': '重命名',
'common.refresh': '刷新',
'common.continue': '继续',
'common.enabled': '已启用',
'common.disabled': '已禁用',
'common.unknownError': '未知错误',
'common.noResultsFound': '没有匹配结果',
'common.back': '返回',
'common.apply': '应用',
'common.use': '使用',
'common.useGlobal': '跟随全局',
'common.left': '左侧',
'common.right': '右侧',
'common.more': '更多',
'common.selectAHost': '选择主机',
'sort.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 > 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 > Crash Logs
'settings.system.crashLogs.title': '崩溃日志',
'settings.system.crashLogs.description': '查看主进程错误日志,帮助诊断异常行为。',
'settings.system.crashLogs.noLogs': '未找到崩溃日志。',
'settings.system.crashLogs.entries': '{count} 条记录',
'settings.system.crashLogs.clear': '清除所有日志',
'settings.system.crashLogs.cleared': '已清除 {count} 个日志文件。',
'settings.system.crashLogs.source': '来源',
'settings.system.crashLogs.time': '时间',
'settings.system.crashLogs.message': '消息',
'settings.system.crashLogs.stack': '堆栈跟踪',
'settings.system.crashLogs.hint': '崩溃日志保留 30 天,超期自动清理。',
'settings.system.crashLogs.collapse': '收起',
'settings.system.crashLogs.expand': '查看详情',
// Settings > System > Software Update
'settings.update.title': '软件更新',
'settings.update.currentVersion': '当前版本',
'settings.update.checkForUpdates': '检查更新',
'settings.update.checking': '检查中...',
'settings.update.upToDate': '当前已是最新版本。',
'settings.update.available': '新版本 {version} 已发布。',
'settings.update.download': '下载更新',
'settings.update.downloading': '正在下载... {percent}%',
'settings.update.readyToInstall': '更新已下载,准备安装。',
'settings.update.restartNow': '重启并更新',
'settings.update.error': '检查更新失败。',
'settings.update.downloadError': '下载失败。',
'settings.update.manualDownload': '前往 GitHub 下载',
'settings.update.manualDownloadHint': '当前平台不支持自动更新,请前往 GitHub 下载最新版本。',
'settings.update.hint': 'Netcatty 从 GitHub Releases 检查更新。',
'settings.update.lastCheckedJustNow': '刚刚',
'settings.update.lastCheckedMinutesAgo': '{n} 分钟前',
'settings.update.lastCheckedHoursAgo': '{n} 小时前',
'settings.update.lastCheckedPrefix': '上次检查:',
'settings.update.autoUpdateEnabled': '自动更新',
'settings.update.autoUpdateEnabledDesc': '有新版本时自动检查并下载更新。',
// Settings > Session Logs
'settings.sessionLogs.title': '会话日志',
'settings.sessionLogs.description': '配置会话日志导出和自动保存设置。',
'settings.sessionLogs.autoSave': '自动保存',
'settings.sessionLogs.enableAutoSave': '启用自动保存',
'settings.sessionLogs.enableAutoSaveDesc': '在终端会话结束时自动保存会话日志。',
'settings.sessionLogs.directory': '保存目录',
'settings.sessionLogs.noDirectory': '未选择目录',
'settings.sessionLogs.browse': '浏览',
'settings.sessionLogs.openFolder': '打开文件夹',
'settings.sessionLogs.directoryHint': '日志将按主机名组织在子目录中。',
'settings.sessionLogs.format': '日志格式',
'settings.sessionLogs.formatDesc': '选择保存日志文件的格式。',
'settings.sessionLogs.formatTxt': '纯文本 (.txt)',
'settings.sessionLogs.formatRaw': '原始格式 (.log)',
'settings.sessionLogs.formatHtml': 'HTML (.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 侧栏。',
// 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、top-tabs。',
'settings.appearance.customCss.placeholder':
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 放大代码片段侧边栏字号 */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* 自定义终端背景色 */\n.terminal { background: #1a1a2e !important; }\n\n/* 调整全局圆角 */\n:root { --radius: 0.25rem; }',
'settings.appearance.language': '语言',
'settings.appearance.language.desc': '选择界面语言',
'settings.appearance.uiFont': '界面字体',
'settings.appearance.uiFont.desc': '选择软件界面使用的字体',
// 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.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} 个代码片段,{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.knownHosts': '已知主机',
'vault.nav.logs': '日志',
'proxyProfiles.action.add': '添加代理',
'proxyProfiles.search.placeholder': '搜索代理…',
'proxyProfiles.section.proxies': '代理',
'proxyProfiles.count.items': '{count} 项',
'proxyProfiles.empty.title': '暂无代理',
'proxyProfiles.empty.desc': '创建可复用的 HTTP、SOCKS5 或命令代理,然后在主机详情里选择。',
'proxyProfiles.usage': '已关联 {count} 处',
'proxyProfiles.copyName': '{name} 副本',
'proxyProfiles.panel.newTitle': '新建代理',
'proxyProfiles.field.name': '代理名称',
'proxyProfiles.error.required': '名称和代理详情不能为空。',
'proxyProfiles.error.port': '端口必须在 1 到 65535 之间。',
'proxyProfiles.viewMode': '代理显示方式',
'proxyProfiles.delete.title': '删除代理?',
'proxyProfiles.delete.desc': '删除 "{name}" 会同时从 {count} 个主机或分组设置中解除关联。',
'vault.groups.title': '分组',
'vault.groups.total': '共 {count} 个',
'vault.groups.hostsCount': '{count} 台主机',
'vault.groups.newSubgroup': '新建子分组',
'vault.groups.rename': '重命名分组',
'vault.groups.delete': '删除分组',
'vault.groups.createSubfolder': '创建子分组',
'vault.groups.createRoot': '创建根分组',
'vault.groups.createDialog.desc': '创建新的分组用于组织主机。',
'vault.groups.renameDialogTitle': '重命名分组',
'vault.groups.renameDialog.desc': '重命名已有分组。',
'vault.groups.deleteDialogTitle': '删除分组',
'vault.groups.deleteDialog.desc': '这将永久删除该分组并将所有主机移动到根级别。',
'vault.groups.deleteDialog.managedDesc': '这是一个托管的 SSH 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.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.empty.title': '设置你的主机',
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
// 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.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.addGlobal': '+全局',
'sftp.bookmark.addGlobalTooltip': '保存为全局收藏(所有主机共享)',
'sftp.bookmark.empty': '暂无收藏路径',
'sftp.columns.name': '名称',
'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.viewMode.label': '视图模式',
'sftp.viewMode.list': '列表视图',
'sftp.viewMode.tree': '树形视图',
'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.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.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': '默认',
};

View File

@@ -0,0 +1,645 @@
import type { Messages } from '../types';
export const zhCNTerminalMessages: Messages = {
'terminal.connection.protocol.et': 'EternalTerminal',
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支持 Netcatty 的代理设置。请改用 SSH或移除该主机的代理。',
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支持一个跳板机。',
// 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 > 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.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.weightBold': '粗体字重',
'settings.terminal.font.weightBold.desc': '粗体文本字重 (100-900)',
'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 按住 OptionWindows/Linux 按住 Shift 拖选即可选中文本',
'settings.terminal.behavior.middleClickPaste': '中键粘贴',
'settings.terminal.behavior.middleClickPaste.desc': '中键点击时粘贴剪贴板内容',
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
'settings.terminal.behavior.bracketedPaste.desc':
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
'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.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.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.serverStats.show': '显示服务器状态',
'settings.terminal.serverStats.show.desc': '在终端状态栏显示 CPU、内存和磁盘使用情况仅限 Linux 服务器)。',
'settings.terminal.serverStats.refreshInterval': '刷新间隔',
'settings.terminal.serverStats.refreshInterval.desc': '服务器状态刷新的频率。',
'settings.terminal.serverStats.seconds': '秒',
// Settings > Terminal > Rendering
'settings.terminal.section.rendering': '渲染',
'settings.terminal.rendering.renderer': '渲染器',
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 DOM 渲染。更改将在新终端会话中生效。',
'settings.terminal.rendering.auto': '自动',
// Settings > Terminal > 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.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.new-tab': '新建本地标签页',
'settings.shortcuts.binding.copy': '从终端复制',
'settings.shortcuts.binding.paste': '粘贴到终端',
'settings.shortcuts.binding.select-all': '全选终端内容',
'settings.shortcuts.binding.clear-buffer': '清空终端缓冲区',
'settings.shortcuts.binding.search-terminal': '打开终端搜索',
'settings.shortcuts.binding.move-focus': '在分屏间移动焦点',
'settings.shortcuts.binding.split-horizontal': '水平分屏',
'settings.shortcuts.binding.split-vertical': '垂直分屏',
'settings.shortcuts.binding.open-hosts': '打开主机列表',
'settings.shortcuts.binding.open-local': '打开本地终端',
'settings.shortcuts.binding.open-sftp': '打开 SFTP',
'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': '新建文件夹',
// 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.remove': '移除代理',
'hostDetails.proxyPanel.savedProxy': '已保存代理',
'hostDetails.proxyPanel.selectSaved': '选择已保存代理',
'hostDetails.proxyPanel.customProxy': '自定义代理',
'hostDetails.proxyPanel.missing': '缺失',
'hostDetails.proxyPanel.missingSaved': '保存的代理不存在',
'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.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.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.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.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': '仅粘贴(不自动执行)',
// 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': '存在未保存的编辑器,请先处理后再退出',
};

View File

@@ -0,0 +1,678 @@
import type { Messages } from '../types';
export const zhCNVaultMessages: Messages = {
// 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}',
// 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.group.placeholder': '父级 Group',
'hostDetails.section.credentials': '凭据',
'hostDetails.section.portCredentials': '端口与凭据',
'hostDetails.section.appearance': '外观',
'hostDetails.distro.title': 'Linux 发行版',
'hostDetails.distro.desc': '可在连接后自动探测,也可以手动覆盖图标所用的发行版。',
'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.oracle': 'Oracle Linux',
'hostDetails.distro.option.kali': 'Kali Linux',
'hostDetails.distro.option.cisco': '思科',
'hostDetails.distro.option.juniper': '瞻博网络',
'hostDetails.distro.option.huawei': '华为',
'hostDetails.distro.option.hpe': '慧与 / H3C',
'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.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.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.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.otherProtocols': '其他协议',
'hostDetails.telnetOn': 'Telnet on',
'hostDetails.port': '端口',
'hostDetails.telnet.credentials': '凭据',
'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.sftp': 'SFTP',
'terminal.toolbar.more': '更多操作',
'terminal.toolbar.scripts': '脚本',
'terminal.toolbar.library': '库',
'terminal.toolbar.noSnippets': '暂无代码片段',
'terminal.toolbar.terminalSettings': '终端设置',
'terminal.toolbar.searchTerminal': '搜索终端',
'terminal.toolbar.search': '搜索',
'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.toolbar.focus': '聚焦',
'terminal.toolbar.focusMode': '聚焦模式',
'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.networkDetails': '网络接口',
'terminal.serverStats.noData': '暂无数据',
'terminal.dragDrop.localTitle': '拖放以插入路径',
'terminal.dragDrop.localMessage': '文件路径将被插入到终端',
'terminal.dragDrop.remoteTitle': '拖放以上传文件',
'terminal.dragDrop.remoteMessage': '文件将通过 SFTP 上传',
'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.pasteSelection': '粘贴选中文本',
'terminal.menu.selectAll': '全选',
'terminal.menu.reconnect': '重新连接',
'terminal.menu.splitHorizontal': '水平分屏',
'terminal.menu.splitVertical': '垂直分屏',
'terminal.menu.clearBuffer': '清空缓冲区',
'terminal.menu.closeTerminal': '关闭终端',
'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.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.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.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} 个代码片段',
'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.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.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.dynamic': 'SOCKS 监听于 {bindAddress}:{localPort}',
'pf.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
'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.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': '压缩传输',
};

View File

@@ -1,11 +1,13 @@
import en, { type Messages } from './locales/en';
import zhCN from './locales/zh-CN';
import ru from './locales/ru';
// Keep keys stable; add new locales by adding another import and map entry.
export { type Messages };
export const MESSAGES_BY_LOCALE: Record<string, Messages> = {
en,
ru,
'zh-CN': zhCN,
};

View File

@@ -1,4 +1,4 @@
import { useCallback,useSyncExternalStore } from 'react';
import { useCallback, useSyncExternalStore } from 'react';
// Simple store for active tab that allows fine-grained subscriptions
type Listener = () => void;
@@ -92,7 +92,11 @@ export const useIsEditorTabActive = (tabId: string): boolean => {
// Check if terminal layer should be visible
// Editor tabs are NOT terminal tabs, so exclude them from the visibility condition.
export const useIsTerminalLayerVisible = (draggingSessionId: string | null) => {
const activeTabId = useActiveTabId();
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !isEditorTabId(activeTabId);
return isTerminalTab || !!draggingSessionId;
const getSnapshot = useCallback(() => {
const activeTabId = activeTabStore.getActiveTabId();
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !isEditorTabId(activeTabId);
return isTerminalTab || !!draggingSessionId;
}, [draggingSessionId]);
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot);
};

View File

@@ -0,0 +1,39 @@
export function removeProviderReferences(
removedProviderId: string,
agentProviderMap: Record<string, string>,
agentModelMap: Record<string, string>,
): {
agentProviderMap: Record<string, string>;
agentModelMap: Record<string, string>;
providerMapChanged: boolean;
modelMapChanged: boolean;
} {
let providerMapChanged = false;
let modelMapChanged = false;
const orphanedAgents = new Set<string>();
const nextAgentProviderMap: Record<string, string> = {};
for (const [agentId, providerId] of Object.entries(agentProviderMap)) {
if (providerId === removedProviderId) {
providerMapChanged = true;
orphanedAgents.add(agentId);
} else {
nextAgentProviderMap[agentId] = providerId;
}
}
const nextAgentModelMap: Record<string, string> = { ...agentModelMap };
for (const agentId of orphanedAgents) {
if (agentId in nextAgentModelMap) {
delete nextAgentModelMap[agentId];
modelMapChanged = true;
}
}
return {
agentProviderMap: providerMapChanged ? nextAgentProviderMap : agentProviderMap,
agentModelMap: modelMapChanged ? nextAgentModelMap : agentModelMap,
providerMapChanged,
modelMapChanged,
};
}

View File

@@ -65,7 +65,7 @@ test("pruneInactiveScopedTransientState removes closed workspace and terminal sc
});
});
test("pruneInactiveScopedSessions preserves restorable terminal ACP ids across reconnects", () => {
test("pruneInactiveScopedSessions preserves restorable terminal external session ids across reconnects", () => {
const sessions = [
createSession("terminal-restorable", {
type: "terminal",
@@ -131,7 +131,7 @@ test("pruneInactiveScopedSessions treats sessions displayed elsewhere as in-use,
// terminal-restorable's original scope (terminal-closed-A) is gone, but
// the user resumed it into terminal-open-B from history. The session's
// externalSessionId must be preserved and it must not appear in the
// orphaned list, otherwise the active chat loses ACP continuity.
// orphaned list, otherwise the active chat loses external agent continuity.
const resumedElsewhere = createSession("terminal-restorable", {
type: "terminal",
targetId: "terminal-closed-A",

View File

@@ -0,0 +1,20 @@
/**
* Same-window AI-state-changed event plumbing.
*
* `localStorage` writes only emit `storage` events in *other* windows; the
* window doing the write never gets notified. That's a problem for code
* that mutates AI storage outside of `useAIState`'s setters (e.g. sync
* apply): without a manual nudge, mounted components keep showing stale
* AI state until reload.
*
* Both the dispatcher and `useAIState`'s listener live here so non-React
* call sites (sync, IPC handlers, etc.) can fire the event without
* pulling in the hook.
*/
export const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
export function emitAIStateChanged(key: string): void {
if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent<{ key: string }>(AI_STATE_CHANGED_EVENT, { detail: { key } }));
}

View File

@@ -0,0 +1,226 @@
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
STORAGE_KEY_AI_SESSIONS,
} from '../../infrastructure/config/storageKeys';
import type {
AIDraft,
AIPanelView,
AISession,
AIPermissionMode,
AIToolIntegrationMode,
} from '../../infrastructure/ai/types';
import {
bumpDraftMutationVersionState,
bumpDraftUploadGenerationState,
getDraftUploadGenerationState,
} from './aiDraftState';
import {
pruneInactiveScopedSessions,
pruneInactiveScopedTransientState,
} from './aiScopeCleanup';
import { emitAIStateChanged } from './aiStateEvents';
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
export interface AIBridge {
aiSdkAgentCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
aiMcpSetToolIntegrationMode?: (mode: AIToolIntegrationMode) => Promise<unknown> | unknown;
aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown;
aiMcpSetCommandTimeout?: (timeout: number) => Promise<unknown> | unknown;
aiMcpSetMaxIterations?: (maxIterations: number) => Promise<unknown> | unknown;
}
export function getAIBridge() {
return (window as unknown as { netcatty?: AIBridge }).netcatty;
}
export const AI_STATE_CHANGED_DRAFTS_BY_SCOPE = 'netcatty:ai-drafts-by-scope';
export const AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE = 'netcatty:ai-panel-view-by-scope';
export type DraftsByScope = Partial<Record<string, AIDraft>>;
export type PanelViewByScope = Partial<Record<string, AIPanelView>>;
export function cleanupSdkAgentSessions(sessionIds: string[]) {
const bridge = getAIBridge();
if (!bridge?.aiSdkAgentCleanup || sessionIds.length === 0) return;
for (const sessionId of sessionIds) {
void bridge.aiSdkAgentCleanup(sessionId).catch(() => {});
}
}
function isScopeKeyActive(scopeKey: string, activeTargetIds: Set<string>) {
const separatorIndex = scopeKey.indexOf(':');
if (separatorIndex === -1) return true;
const targetId = scopeKey.slice(separatorIndex + 1);
if (!targetId) return true;
return activeTargetIds.has(targetId);
}
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
const currentSessions = latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
// Sessions shown by a still-live scope must be protected from cleanup
// even when their own `scope.targetId` points at a closed terminal —
// history can be resumed into a different terminal and we must not
// delete it outright while it's actively being used.
const preCleanupActiveSessionMap = latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {};
const activeSessionIds = new Set<string>();
for (const [scopeKey, sessionId] of Object.entries(preCleanupActiveSessionMap)) {
if (!sessionId) continue;
if (!isScopeKeyActive(scopeKey, activeTargetIds)) continue;
activeSessionIds.add(sessionId);
}
const nextSessionCleanup = pruneInactiveScopedSessions(
currentSessions,
activeTargetIds,
activeSessionIds,
);
if (nextSessionCleanup.orphanedSessionIds.length > 0) {
cleanupSdkAgentSessions(nextSessionCleanup.orphanedSessionIds);
}
if (nextSessionCleanup.sessions !== currentSessions) {
setLatestAISessionsSnapshot(nextSessionCleanup.sessions);
localStorageAdapter.write(
STORAGE_KEY_AI_SESSIONS,
pruneSessionsForStorage(nextSessionCleanup.sessions),
);
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
}
const activeSessionIdMap = preCleanupActiveSessionMap;
let activeSessionMapChanged = false;
const nextActiveSessionIdMap = { ...activeSessionIdMap };
for (const scopeKey of Object.keys(activeSessionIdMap)) {
if (isScopeKeyActive(scopeKey, activeTargetIds)) continue;
delete nextActiveSessionIdMap[scopeKey];
activeSessionMapChanged = true;
}
if (activeSessionMapChanged) {
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
const currentActiveSessionIdMap = activeSessionMapChanged
? nextActiveSessionIdMap
: activeSessionIdMap;
const currentDraftsByScope = latestAIDraftsByScopeSnapshot ?? {};
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
const prunedScopedTransientState = pruneInactiveScopedTransientState(
currentActiveSessionIdMap,
currentDraftsByScope,
currentPanelViewByScope,
activeTargetIds,
);
if (prunedScopedTransientState.activeSessionIdMap !== currentActiveSessionIdMap) {
setLatestAIActiveSessionMapSnapshot(prunedScopedTransientState.activeSessionIdMap);
localStorageAdapter.write(
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
prunedScopedTransientState.activeSessionIdMap,
);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
if (prunedScopedTransientState.draftsByScope !== currentDraftsByScope) {
for (const scopeKey of Object.keys(currentDraftsByScope)) {
if (scopeKey in prunedScopedTransientState.draftsByScope) continue;
bumpDraftMutationVersion(scopeKey);
bumpDraftUploadGeneration(scopeKey);
}
setLatestAIDraftsByScopeSnapshot(prunedScopedTransientState.draftsByScope);
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
}
if (prunedScopedTransientState.panelViewByScope !== currentPanelViewByScope) {
for (const scopeKey of Object.keys(currentPanelViewByScope)) {
if (scopeKey in prunedScopedTransientState.panelViewByScope) continue;
bumpDraftMutationVersion(scopeKey);
}
setLatestAIPanelViewByScopeSnapshot(prunedScopedTransientState.panelViewByScope);
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
}
}
/** Maximum number of sessions to keep in localStorage. */
const MAX_STORED_SESSIONS = 50;
/** Maximum number of messages per session when persisting to localStorage. */
const MAX_SESSION_MESSAGES = 200;
/**
* Prune sessions before writing to localStorage to prevent hitting the
* ~5-10 MB storage quota. Only affects what is persisted — the in-memory
* state retains all messages until the session is reloaded.
*
* - Keeps only the MAX_STORED_SESSIONS most-recently-updated sessions.
* - Trims each session's messages to the last MAX_SESSION_MESSAGES.
*/
export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
// Sort by updatedAt descending so we keep the newest
const sorted = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt);
const limited = sorted.slice(0, MAX_STORED_SESSIONS);
return limited.map(s => {
if (s.messages.length > MAX_SESSION_MESSAGES) {
return { ...s, messages: s.messages.slice(-MAX_SESSION_MESSAGES) };
}
return s;
});
}
export let latestAISessionsSnapshot: AISession[] | null = null;
export let latestAIActiveSessionMapSnapshot: Record<string, string | null> | null = null;
export let latestAIDraftsByScopeSnapshot: DraftsByScope | null = null;
export let latestAIPanelViewByScopeSnapshot: PanelViewByScope | null = null;
let latestAIDraftMutationVersionByScopeSnapshot: Record<string, number> = {};
let latestAIDraftUploadGenerationByScopeSnapshot: Record<string, number> = {};
export function setLatestAISessionsSnapshot(sessions: AISession[]) {
latestAISessionsSnapshot = sessions;
}
export function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<string, string | null>) {
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
}
export function setLatestAIDraftsByScopeSnapshot(draftsByScope: DraftsByScope) {
latestAIDraftsByScopeSnapshot = draftsByScope;
}
export function setLatestAIPanelViewByScopeSnapshot(panelViewByScope: PanelViewByScope) {
latestAIPanelViewByScopeSnapshot = panelViewByScope;
}
export function bumpDraftMutationVersion(scopeKey: string) {
latestAIDraftMutationVersionByScopeSnapshot = bumpDraftMutationVersionState(
latestAIDraftMutationVersionByScopeSnapshot,
scopeKey,
);
}
export function getDraftUploadGeneration(scopeKey: string) {
return getDraftUploadGenerationState(
latestAIDraftUploadGenerationByScopeSnapshot,
scopeKey,
);
}
export function bumpDraftUploadGeneration(scopeKey: string) {
latestAIDraftUploadGenerationByScopeSnapshot = bumpDraftUploadGenerationState(
latestAIDraftUploadGenerationByScopeSnapshot,
scopeKey,
);
}

View File

@@ -0,0 +1,111 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
getRuntimeRemoteCheckIntervalMs,
shouldRunRuntimeRemoteCheck,
} from './autoSyncRemoteSchedule';
test("runtime remote checks wait for the startup check to finish", () => {
assert.equal(
shouldRunRuntimeRemoteCheck({
hasAnyConnectedProvider: true,
autoSyncEnabled: true,
isUnlocked: true,
startupRemoteCheckDone: false,
isSyncing: false,
isSyncRunning: false,
remoteCheckInFlight: false,
now: 10_000,
lastRemoteCheckAt: null,
minIntervalMs: 30_000,
}),
false,
);
});
test("runtime remote checks run immediately after startup gate opens", () => {
assert.equal(
shouldRunRuntimeRemoteCheck({
hasAnyConnectedProvider: true,
autoSyncEnabled: true,
isUnlocked: true,
startupRemoteCheckDone: true,
isSyncing: false,
isSyncRunning: false,
remoteCheckInFlight: false,
now: 10_000,
lastRemoteCheckAt: null,
minIntervalMs: 30_000,
}),
true,
);
});
test("runtime remote checks respect the minimum interval", () => {
const common = {
hasAnyConnectedProvider: true,
autoSyncEnabled: true,
isUnlocked: true,
startupRemoteCheckDone: true,
isSyncing: false,
isSyncRunning: false,
remoteCheckInFlight: false,
minIntervalMs: 30_000,
};
assert.equal(
shouldRunRuntimeRemoteCheck({
...common,
now: 35_000,
lastRemoteCheckAt: 10_000,
}),
false,
);
assert.equal(
shouldRunRuntimeRemoteCheck({
...common,
now: 40_000,
lastRemoteCheckAt: 10_000,
}),
true,
);
});
test("forced runtime remote checks bypass only the interval gate", () => {
const common = {
hasAnyConnectedProvider: true,
autoSyncEnabled: true,
isUnlocked: true,
startupRemoteCheckDone: true,
isSyncing: false,
isSyncRunning: false,
remoteCheckInFlight: false,
minIntervalMs: 30_000,
force: true,
};
assert.equal(
shouldRunRuntimeRemoteCheck({
...common,
now: 35_000,
lastRemoteCheckAt: 10_000,
}),
true,
);
assert.equal(
shouldRunRuntimeRemoteCheck({
...common,
isSyncing: true,
now: 35_000,
lastRemoteCheckAt: 10_000,
}),
false,
);
});
test("configured auto-sync intervals map to bounded remote recheck intervals", () => {
assert.equal(getRuntimeRemoteCheckIntervalMs(1), 30_000);
assert.equal(getRuntimeRemoteCheckIntervalMs(10), 300_000);
assert.equal(getRuntimeRemoteCheckIntervalMs(120), 300_000);
});

View File

@@ -0,0 +1,35 @@
const MIN_RUNTIME_REMOTE_CHECK_MS = 30_000;
const MAX_RUNTIME_REMOTE_CHECK_MS = 5 * 60_000;
export function getRuntimeRemoteCheckIntervalMs(autoSyncIntervalMinutes: number): number {
const configuredMs = Math.max(1, Number(autoSyncIntervalMinutes) || 1) * 60_000;
return Math.max(
MIN_RUNTIME_REMOTE_CHECK_MS,
Math.min(MAX_RUNTIME_REMOTE_CHECK_MS, Math.floor(configuredMs / 2)),
);
}
export interface RuntimeRemoteCheckInput {
hasAnyConnectedProvider: boolean;
autoSyncEnabled: boolean;
isUnlocked: boolean;
startupRemoteCheckDone: boolean;
isSyncing: boolean;
isSyncRunning: boolean;
remoteCheckInFlight: boolean;
force?: boolean;
now: number;
lastRemoteCheckAt: number | null;
minIntervalMs: number;
}
export function shouldRunRuntimeRemoteCheck(input: RuntimeRemoteCheckInput): boolean {
if (!input.hasAnyConnectedProvider) return false;
if (!input.autoSyncEnabled) return false;
if (!input.isUnlocked) return false;
if (!input.startupRemoteCheckDone) return false;
if (input.isSyncing || input.isSyncRunning || input.remoteCheckInFlight) return false;
if (input.force === true) return true;
if (input.lastRemoteCheckAt == null) return true;
return input.now - input.lastRemoteCheckAt >= input.minIntervalMs;
}

View File

@@ -0,0 +1,194 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
clearKeyPassphrasesByIds,
clearReferenceKeyPassphrases,
loadDefaultKeyPassphrase,
rememberKeyPassphrase,
shouldUpdateReferenceKeyPassphrase,
} from "../defaultKeyPassphrases";
import { STORAGE_KEY_DEFAULT_KEY_PASSPHRASES } from "../../infrastructure/config/storageKeys";
import type { SSHKey } from "../../domain/models";
function installLocalStorage(t: test.TestContext): void {
const store = new Map<string, string>();
const storage: Storage = {
get length() {
return store.size;
},
clear() {
store.clear();
},
getItem(key: string) {
return store.get(key) ?? null;
},
key(index: number) {
return Array.from(store.keys())[index] ?? null;
},
removeItem(key: string) {
store.delete(key);
},
setItem(key: string, value: string) {
store.set(key, value);
},
};
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: storage,
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { netcatty: undefined },
});
t.after(() => {
Reflect.deleteProperty(globalThis, "localStorage");
Reflect.deleteProperty(globalThis, "window");
});
}
const referenceKey = (): SSHKey => ({
id: "reference-key",
label: "id_ed25519",
type: "ED25519",
category: "key",
source: "reference",
filePath: "/Users/alice/.ssh/id_ed25519",
privateKey: "",
created: 1,
});
test("loadDefaultKeyPassphrase removes undecryptable credential placeholders", async (t) => {
installLocalStorage(t);
const keyPath = "/Users/alice/.ssh/id_ed25519";
globalThis.localStorage.setItem(
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({
[keyPath]: "enc:v1:djEwYWJj",
"/Users/alice/.ssh/id_rsa": "still-valid",
}),
);
const result = await loadDefaultKeyPassphrase(keyPath);
assert.equal(result, null);
assert.deepEqual(
JSON.parse(globalThis.localStorage.getItem(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? "{}"),
{ "/Users/alice/.ssh/id_rsa": "still-valid" },
);
});
test("loadDefaultKeyPassphrase returns plain stored passphrases", async (t) => {
installLocalStorage(t);
const keyPath = "/Users/alice/.ssh/id_ed25519";
globalThis.localStorage.setItem(
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({ [keyPath]: "correct horse battery staple" }),
);
assert.equal(await loadDefaultKeyPassphrase(keyPath), "correct horse battery staple");
});
test("clearReferenceKeyPassphrases clears matching reference key paths only", () => {
const keys: SSHKey[] = [
{
...referenceKey(),
passphrase: "bad",
savePassphrase: true,
},
{
...referenceKey(),
id: "other-key",
label: "other",
filePath: "/Users/alice/.ssh/other",
passphrase: "keep",
savePassphrase: true,
},
];
const updated = clearReferenceKeyPassphrases(keys, ["/Users/alice/.ssh/id_ed25519"]);
assert.equal(updated[0].passphrase, undefined);
assert.equal(updated[0].savePassphrase, false);
assert.equal(updated[1].passphrase, "keep");
});
test("clearKeyPassphrasesByIds clears matching saved key passphrases", () => {
const keys: SSHKey[] = [
{
...referenceKey(),
id: "inline-key",
source: "imported",
filePath: undefined,
privateKey: "PRIVATE KEY",
passphrase: "bad",
savePassphrase: true,
},
{
...referenceKey(),
id: "other-key",
label: "other",
passphrase: "keep",
savePassphrase: true,
},
];
const updated = clearKeyPassphrasesByIds(keys, ["inline-key"]);
assert.equal(updated[0].passphrase, undefined);
assert.equal(updated[0].savePassphrase, false);
assert.equal(updated[1].passphrase, "keep");
});
test("shouldUpdateReferenceKeyPassphrase replaces missing or undecryptable passphrases", () => {
assert.equal(shouldUpdateReferenceKeyPassphrase(null), false);
assert.equal(shouldUpdateReferenceKeyPassphrase(referenceKey()), true);
assert.equal(
shouldUpdateReferenceKeyPassphrase({
...referenceKey(),
passphrase: "enc:v1:djEwAAAA",
}),
true,
);
assert.equal(
shouldUpdateReferenceKeyPassphrase({
...referenceKey(),
passphrase: "saved",
}),
false,
);
});
test("rememberKeyPassphrase updates reference key state before completing", async (t) => {
installLocalStorage(t);
const keys = [referenceKey()];
let currentKeys = keys;
let releaseUpdate: (() => void) | undefined;
let rememberPromise: Promise<void> | undefined;
const updateStarted = new Promise<void>((resolve) => {
const updateKeys = async (updated: SSHKey[]) => {
assert.equal(currentKeys[0].passphrase, "saved");
assert.equal(updated[0].passphrase, "saved");
resolve();
await new Promise<void>((release) => {
releaseUpdate = release;
});
};
rememberPromise = rememberKeyPassphrase({
keyPath: "/Users/alice/.ssh/id_ed25519",
passphrase: "saved",
keys,
updateKeys,
setCurrentKeys: (updated) => {
currentKeys = updated;
},
});
});
await updateStarted;
assert.equal(currentKeys[0].passphrase, "saved");
releaseUpdate?.();
await rememberPromise;
});

View File

@@ -0,0 +1,88 @@
import test from "node:test";
import assert from "node:assert/strict";
import { EditorTabStore, type EditorTab } from "./editorTabStore.ts";
import { createEditorTabSaveService } from "./editorTabSave.ts";
const deferred = <T = void>() => {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const makeTab = (overrides: Partial<EditorTab> = {}): EditorTab => ({
id: "edt_1",
kind: "editor",
sessionId: "conn_1",
hostId: "host_1",
remotePath: "/tmp/file.txt",
fileName: "file.txt",
languageId: "plaintext",
content: "v1",
baselineContent: "old",
wordWrap: false,
viewState: null,
savingState: "idle",
saveError: null,
...overrides,
});
test("editor tab save service joins duplicate saves for the same content", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab());
const pending = deferred();
const writes: string[] = [];
const service = createEditorTabSaveService({
store,
write: async (_sessionId, _hostId, _remotePath, content) => {
writes.push(content);
await pending.promise;
},
});
const first = service.saveTab("edt_1");
const second = service.saveTab("edt_1", "v1");
assert.deepEqual(writes, ["v1"]);
pending.resolve();
assert.equal(await first, true);
assert.equal(await second, true);
assert.deepEqual(writes, ["v1"]);
assert.equal(store.getTab("edt_1")?.baselineContent, "v1");
assert.equal(store.getTab("edt_1")?.savingState, "idle");
});
test("editor tab save service queues newer tab content after an in-flight save", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab());
const firstSave = deferred();
const secondSave = deferred();
const writes: string[] = [];
const service = createEditorTabSaveService({
store,
write: async (_sessionId, _hostId, _remotePath, content) => {
writes.push(content);
await (content === "v1" ? firstSave.promise : secondSave.promise);
},
});
const first = service.saveTab("edt_1");
store.updateContent("edt_1", "v2", null);
const second = service.saveTab("edt_1");
assert.deepEqual(writes, ["v1"]);
firstSave.resolve();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
assert.deepEqual(writes, ["v1", "v2"]);
secondSave.resolve();
assert.equal(await first, true);
assert.equal(await second, true);
assert.equal(store.getTab("edt_1")?.baselineContent, "v2");
assert.equal(store.getTab("edt_1")?.content, "v2");
});

View File

@@ -0,0 +1,72 @@
import { editorSftpWrite, type EditorSftpWrite } from "./editorSftpBridge";
import { editorTabStore, type EditorTabId, type EditorTabStore } from "./editorTabStore";
import {
createTextEditorSaveCoordinator,
type TextEditorSaveCoordinator,
} from "./textEditorSaveCoordinator";
interface EditorTabSaveServiceDeps {
store: EditorTabStore;
write: EditorSftpWrite;
}
export interface EditorTabSaveService {
saveTab(id: EditorTabId, contentOverride?: string): Promise<boolean>;
releaseTab(id: EditorTabId): void;
}
const formatSaveError = (error: unknown): string =>
error instanceof Error ? error.message : "Save failed";
export const createEditorTabSaveService = ({
store,
write,
}: EditorTabSaveServiceDeps): EditorTabSaveService => {
const coordinators = new Map<EditorTabId, TextEditorSaveCoordinator>();
const getCoordinator = (id: EditorTabId): TextEditorSaveCoordinator => {
const existing = coordinators.get(id);
if (existing) return existing;
const coordinator = createTextEditorSaveCoordinator({
onSave: async (content) => {
const tab = store.getTab(id);
if (!tab) throw new Error("Editor tab closed before save completed");
await write(tab.sessionId, tab.hostId, tab.remotePath, content);
},
onSaveStart: () => {
store.setSavingState(id, "saving");
},
onSaveSuccess: (content) => {
store.markSaved(id, content);
},
onSaveError: (error) => {
store.setSavingState(id, "error", formatSaveError(error));
},
});
coordinators.set(id, coordinator);
return coordinator;
};
return {
saveTab: async (id, contentOverride) => {
const tab = store.getTab(id);
if (!tab) return false;
return getCoordinator(id).save(contentOverride ?? tab.content);
},
releaseTab: (id) => {
const coordinator = coordinators.get(id);
coordinator?.reset();
coordinators.delete(id);
},
};
};
const editorTabSaveService = createEditorTabSaveService({
store: editorTabStore,
write: editorSftpWrite,
});
export const saveEditorTab = editorTabSaveService.saveTab;
export const releaseEditorTabSaveCoordinator = editorTabSaveService.releaseTab;

View File

@@ -196,3 +196,24 @@ test("confirmCloseBySession invokes save callback for 'save' choice and only clo
assert.equal(ok, true);
assert.equal(store.getTab("edt_1"), undefined);
});
test("confirmCloseBySession reports every closed editor tab to cleanup callback", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ id: "edt_clean" }));
store._debugInsert(makeTab({ id: "edt_dirty", remotePath: "/b.txt", fileName: "b.txt", content: "new", baselineContent: "old" }));
const closed: string[] = [];
const ok = await store.confirmCloseBySession(
"conn_1",
async () => "save",
async (id) => {
const tab = store.getTab(id)!;
store.markSaved(id, tab.content);
},
(id) => closed.push(id),
);
assert.equal(ok, true);
assert.deepEqual(closed, ["edt_clean", "edt_dirty"]);
assert.equal(store.getTabs().length, 0);
});

View File

@@ -167,17 +167,23 @@ export class EditorTabStore {
sessionId: string,
promptChoice: (tab: EditorTab) => Promise<"save" | "discard" | "cancel">,
saveTab?: (tabId: EditorTabId) => Promise<void>,
onCloseTab?: (tabId: EditorTabId) => void,
): Promise<boolean> => {
const matching = this.tabs.filter((t) => t.sessionId === sessionId);
for (const tab of matching) {
const dirty = tab.content !== tab.baselineContent;
if (!dirty) {
onCloseTab?.(tab.id);
this.close(tab.id);
continue;
}
const choice = await promptChoice(tab);
if (choice === "cancel") return false;
if (choice === "discard") { this.close(tab.id); continue; }
if (choice === "discard") {
onCloseTab?.(tab.id);
this.close(tab.id);
continue;
}
if (choice === "save") {
if (!saveTab) throw new Error("saveTab callback required when 'save' choice is possible");
try {
@@ -186,6 +192,7 @@ export class EditorTabStore {
// Save failed — treat like cancel (keep tab open, abort batch so the user sees the error)
return false;
}
onCloseTab?.(tab.id);
this.close(tab.id);
}
}
@@ -237,16 +244,3 @@ export const useEditorTab = (id: EditorTabId): EditorTab | undefined => {
const getSnapshot = useCallback(() => editorTabStore.getTab(id), [id]);
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
};
export const useEditorDirty = (id: EditorTabId): boolean => {
const getSnapshot = useCallback(() => editorTabStore.isDirty(id), [id]);
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
};
export const useAnyEditorDirty = (): boolean => {
const getSnapshot = useCallback(
() => editorTabStore.getTabs().some((t) => t.content !== t.baselineContent),
[],
);
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
};

View File

@@ -1,6 +1,7 @@
import { useSyncExternalStore } from 'react';
import { TERMINAL_FONTS, type TerminalFont } from '../../infrastructure/config/fonts';
import { getMonospaceFonts } from '../../lib/localFonts';
import { getAllSystemFontFamilies, getMonospaceFonts } from '../../lib/localFonts';
import { setSystemFamilies } from '../../lib/fontAvailability';
/**
* Global font store - singleton pattern using useSyncExternalStore
@@ -60,7 +61,14 @@ class FontStore {
this.setState({ isLoading: true, error: null });
try {
const localFonts = await getMonospaceFonts();
// Populate the authoritative installed-family set used by
// fontAvailability.isFontInstalled. Runs in parallel with the
// monospace-only query (both share an underlying cache).
const [localFonts, systemFamilies] = await Promise.all([
getMonospaceFonts(),
getAllSystemFontFamilies(),
]);
setSystemFamilies(systemFamilies);
// Combine default fonts with local fonts, deduplicate by id
const fontMap = new Map<string, TerminalFont>();

View File

@@ -0,0 +1,24 @@
import type { ConnectionLog } from "../../domain/models";
export interface LogView {
id: string;
connectionLogId: string;
log: ConnectionLog;
}
export const getLogViewTabId = (log: Pick<ConnectionLog, "id">): string => `log-${log.id}`;
export const addLogView = (views: LogView[], log: ConnectionLog): LogView[] => {
if (views.some((view) => view.connectionLogId === log.id)) return views;
return [
...views,
{
id: getLogViewTabId(log),
connectionLogId: log.id,
log,
},
];
};
export const removeLogView = (views: LogView[], logViewId: string): LogView[] =>
views.filter((view) => view.id !== logViewId);

View File

@@ -0,0 +1,20 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveAiSidePanelToggleIntent } from "./resolveAiSidePanelToggleIntent.ts";
test("close: AI panel already open → close the side panel", () => {
const r = resolveAiSidePanelToggleIntent("ai");
assert.deepEqual(r, { kind: "closeTerminalSidePanel" });
});
test("open: no panel open → open AI", () => {
const r = resolveAiSidePanelToggleIntent(null);
assert.deepEqual(r, { kind: "openAi" });
});
test("open: a different sub-panel is open → switch to AI", () => {
assert.deepEqual(resolveAiSidePanelToggleIntent("sftp"), { kind: "openAi" });
assert.deepEqual(resolveAiSidePanelToggleIntent("scripts"), { kind: "openAi" });
assert.deepEqual(resolveAiSidePanelToggleIntent("theme"), { kind: "openAi" });
});

View File

@@ -0,0 +1,19 @@
export type AiSidePanelToggleIntent =
| { kind: 'closeTerminalSidePanel' }
| { kind: 'openAi' };
/**
* Decide what the top-bar AI button should do given the side panel that is
* currently open for the active tab.
* - If the AI panel is already the open sub-panel → close the whole side panel.
* - Otherwise (closed, or showing a different sub-panel) → switch to AI.
*/
export function resolveAiSidePanelToggleIntent(
activePanel: string | null,
): AiSidePanelToggleIntent {
if (activePanel === 'ai') {
return { kind: 'closeTerminalSidePanel' };
}
return { kind: 'openAi' };
}

View File

@@ -3,33 +3,27 @@ import assert from "node:assert/strict";
import { resolveCloseIntent } from "./resolveCloseIntent.ts";
const baseWorkspace = {
id: "w1",
focusedSessionId: "s1",
};
const baseWorkspace = { id: "w1", focusedSessionId: "s1" };
const baseSession = { id: "s1" };
test("non-workspace tab → closeSingleTab with session id", () => {
const result = resolveCloseIntent({
const r = resolveCloseIntent({
activeTabId: "s1",
workspace: null,
sessionForTab: baseSession,
activeSidePanelTab: null,
focusIsInsideTerminal: true,
});
assert.deepEqual(result, { kind: "closeSingleTab", sessionId: "s1" });
assert.deepEqual(r, { kind: "closeSingleTab", sessionId: "s1" });
});
test("non-workspace session tab + sidebar open → closeSidePanel (sidebar beats session close)", () => {
test("non-workspace session tab → closeSingleTab even when focus is outside the terminal", () => {
const r = resolveCloseIntent({
activeTabId: "s1",
workspace: null,
sessionForTab: { id: "s1" },
activeSidePanelTab: "ai",
focusIsInsideTerminal: true, // focus IS in terminal, but sidebar wins
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
assert.deepEqual(r, { kind: "closeSingleTab", sessionId: "s1" });
});
test("vault/sftp tab → noop", () => {
@@ -37,74 +31,37 @@ test("vault/sftp tab → noop", () => {
activeTabId: "vault",
workspace: null,
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "noop" });
});
test("workspace + focus in terminal + sidebar open → closeSidePanel wins (sidebar beats focus)", () => {
test("workspace + focus in terminal → closeTerminal (side panel no longer intercepts)", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: "ai",
focusIsInsideTerminal: true,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});
test("workspace + focus NOT in terminal + sidebar open → closeSidePanel", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: "sftp",
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});
test("workspace + sidebar closed + focus in terminal → closeTerminal", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: true,
});
assert.deepEqual(r, { kind: "closeTerminal", sessionId: "s1" });
});
test("workspace + sidebar closed + focus NOT in terminal → closeWorkspace", () => {
test("workspace + focus NOT in terminal → closeWorkspace", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
});
test("workspace with no focused session + sidebar closed → closeWorkspace", () => {
test("workspace with no focused session → closeWorkspace", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: { id: "w1", focusedSessionId: undefined },
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: true, // even if flag true, no focused id → cannot closeTerminal
focusIsInsideTerminal: true,
});
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
});
test("workspace with no focused session + sidebar open → closeSidePanel", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: { id: "w1", focusedSessionId: undefined },
sessionForTab: null,
activeSidePanelTab: "ai",
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});

View File

@@ -1,6 +1,5 @@
export type CloseIntent =
| { kind: 'closeTerminal'; sessionId: string }
| { kind: 'closeSidePanel' }
| { kind: 'closeWorkspace'; workspaceId: string }
| { kind: 'closeSingleTab'; sessionId: string }
| { kind: 'noop' };
@@ -9,22 +8,14 @@ export interface ResolveCloseInput {
activeTabId: string | null;
workspace: { id: string; focusedSessionId?: string } | null;
sessionForTab: { id: string } | null;
activeSidePanelTab: string | null;
focusIsInsideTerminal: boolean;
}
export function resolveCloseIntent(input: ResolveCloseInput): CloseIntent {
const { activeTabId, workspace, sessionForTab, activeSidePanelTab, focusIsInsideTerminal } = input;
const { activeTabId, workspace, sessionForTab, focusIsInsideTerminal } = input;
if (!activeTabId) return { kind: 'noop' };
// Sidebar always wins — applies to any tab type (workspace, single-session, etc.).
// Modals take priority over this but are intercepted upstream in App.tsx before the
// hotkey reaches resolveCloseIntent.
if (activeSidePanelTab !== null) {
return { kind: 'closeSidePanel' };
}
if (sessionForTab && !workspace) {
return { kind: 'closeSingleTab', sessionId: sessionForTab.id };
}

View File

@@ -0,0 +1,19 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveSidePanelToggleIntent } from "./resolveSidePanelToggleIntent.ts";
test("open: closed with a remembered tab → open that tab", () => {
const r = resolveSidePanelToggleIntent({ isOpen: false, lastTab: "sftp", fallbackTab: "scripts" });
assert.deepEqual(r, { kind: "open", tab: "sftp" });
});
test("open: closed with no memory → open the fallback tab", () => {
const r = resolveSidePanelToggleIntent({ isOpen: false, lastTab: null, fallbackTab: "scripts" });
assert.deepEqual(r, { kind: "open", tab: "scripts" });
});
test("close: already open → close", () => {
const r = resolveSidePanelToggleIntent({ isOpen: true, lastTab: "theme", fallbackTab: "sftp" });
assert.deepEqual(r, { kind: "close" });
});

View File

@@ -0,0 +1,18 @@
export type SidePanelToggleIntent<T extends string> =
| { kind: 'close' }
| { kind: 'open'; tab: T };
/**
* Decide what the "toggle side panel" shortcut should do.
* - If a panel is open → close it.
* - If closed → reopen the last-shown sub-panel for the tab, falling back to
* `fallbackTab` when the tab has no remembered panel.
*/
export function resolveSidePanelToggleIntent<T extends string>(input: {
isOpen: boolean;
lastTab: T | null;
fallbackTab: T;
}): SidePanelToggleIntent<T> {
if (input.isOpen) return { kind: 'close' };
return { kind: 'open', tab: input.lastTab ?? input.fallbackTab };
}

View File

@@ -0,0 +1,32 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveTerminalSessionExitIntent } from "./resolveTerminalSessionExitIntent.ts";
test("normal backend exited events close the session tab", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }),
{ kind: "closeSession" },
);
});
test("backend timeout events keep the tab and mark it disconnected", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "timeout", error: "idle timeout" }),
{ kind: "markDisconnected" },
);
});
test("backend error events keep the tab and mark it disconnected", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "error", error: "connection reset" }),
{ kind: "markDisconnected" },
);
});
test("backend closed events keep the tab and mark it disconnected", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "closed", exitCode: 0 }),
{ kind: "markDisconnected" },
);
});

View File

@@ -0,0 +1,22 @@
export type TerminalSessionExitEvent = {
exitCode?: number;
signal?: number;
error?: string;
reason?: "exited" | "error" | "timeout" | "closed";
};
export type TerminalSessionExitIntent =
| { kind: "closeSession" }
| { kind: "markDisconnected" };
export function resolveTerminalSessionExitIntent(
evt: TerminalSessionExitEvent,
): TerminalSessionExitIntent {
if (evt.reason === "exited") {
return { kind: "closeSession" };
}
// Timeouts, transport errors, and channel closes should keep the tab visible
// so the user can inspect output and reconnect.
return { kind: "markDisconnected" };
}

View File

@@ -0,0 +1,90 @@
import type { Host, SerialConfig, TerminalSession } from "../../domain/models";
export interface LocalTerminalOptions {
shellType?: TerminalSession["shellType"];
shell?: string;
shellArgs?: string[];
shellName?: string;
shellIcon?: string;
}
export const createLocalTerminalSession = (
sessionId: string,
options?: LocalTerminalOptions,
): TerminalSession => ({
id: sessionId,
hostId: `local-${sessionId}`,
hostLabel: options?.shellName || "Local Terminal",
hostname: "localhost",
username: "local",
status: "connecting",
protocol: "local",
shellType: options?.shellType,
localShell: options?.shell,
localShellArgs: options?.shellArgs,
localShellName: options?.shellName,
localShellIcon: options?.shellIcon,
});
export const createSerialTerminalSession = (
sessionId: string,
config: SerialConfig,
options?: { charset?: string },
): TerminalSession => {
const portName = config.path.split("/").pop() || config.path;
return {
id: sessionId,
hostId: `serial-${sessionId}`,
hostLabel: `Serial: ${portName}`,
hostname: config.path,
username: "",
status: "connecting",
protocol: "serial",
serialConfig: config,
charset: options?.charset,
};
};
export const createHostTerminalSession = (
sessionId: string,
host: Host,
): TerminalSession => {
if (host.protocol === "serial") {
const serialConfig: SerialConfig = host.serialConfig || {
path: host.hostname,
baudRate: host.port || 115200,
dataBits: 8,
stopBits: 1,
parity: "none",
flowControl: "none",
localEcho: false,
lineMode: false,
};
const portName = serialConfig.path.split("/").pop() || serialConfig.path;
return {
id: sessionId,
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: serialConfig.path,
username: "",
status: "connecting",
protocol: "serial",
serialConfig,
charset: host.charset,
};
}
return {
id: sessionId,
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: host.username,
status: "connecting",
protocol: host.protocol,
port: host.port,
moshEnabled: host.moshEnabled,
etEnabled: host.etEnabled,
charset: host.charset,
};
};

View File

@@ -0,0 +1,242 @@
import { useEffect, type Dispatch, type SetStateAction } from 'react';
import type { CustomKeyBindings, HotkeyScheme, SessionLogFormat, TerminalSettings, UILanguage } from '../../domain/models';
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,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_HOTKEY_RECORDING,
STORAGE_KEY_HOTKEY_SCHEME,
STORAGE_KEY_SESSION_LOGS_DIR,
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_FORMAT,
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
STORAGE_KEY_TERM_SETTINGS,
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,
} from '../../infrastructure/config/storageKeys';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { isValidUiFontId, migrateIncomingTerminalFontId } from './settingsStateDefaults';
interface UseSettingsIpcSyncParams {
syncAppearanceFromStorage: () => void;
syncCustomCssFromStorage: () => void;
setUiLanguage: Dispatch<SetStateAction<UILanguage>>;
setUiFontFamilyId: Dispatch<SetStateAction<string>>;
setTerminalThemeId: Dispatch<SetStateAction<string>>;
setTerminalThemeDarkId: Dispatch<SetStateAction<string>>;
setTerminalThemeLightId: Dispatch<SetStateAction<string>>;
setFollowAppTerminalThemeState: Dispatch<SetStateAction<boolean>>;
setTerminalFontFamilyId: Dispatch<SetStateAction<string>>;
setTerminalFontSize: Dispatch<SetStateAction<number>>;
mergeIncomingTerminalSettings: (incoming: Partial<TerminalSettings>) => void;
setEditorWordWrapState: Dispatch<SetStateAction<boolean>>;
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
setSessionLogsDir: Dispatch<SetStateAction<string>>;
setSessionLogsFormat: Dispatch<SetStateAction<SessionLogFormat>>;
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
setHotkeyScheme: Dispatch<SetStateAction<HotkeyScheme>>;
applyIncomingCustomKeyBindings: (incoming: { bindings: CustomKeyBindings; version: number; origin: string }) => void;
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
}
export function useSettingsIpcSync({
syncAppearanceFromStorage,
syncCustomCssFromStorage,
setUiLanguage,
setUiFontFamilyId,
setTerminalThemeId,
setTerminalThemeDarkId,
setTerminalThemeLightId,
setFollowAppTerminalThemeState,
setTerminalFontFamilyId,
setTerminalFontSize,
mergeIncomingTerminalSettings,
setEditorWordWrapState,
setSessionLogsEnabled,
setSessionLogsDir,
setSessionLogsFormat,
setSshDebugLogsEnabled,
setHotkeyScheme,
applyIncomingCustomKeyBindings,
setIsHotkeyRecordingState,
setGlobalHotkeyEnabled,
setAutoUpdateEnabled,
setSftpAutoOpenSidebar,
setSftpDefaultViewMode,
setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState,
}: UseSettingsIpcSyncParams) {
// Listen for settings changes from other windows via IPC
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onSettingsChanged) return;
const unsubscribe = bridge.onSettingsChanged((payload) => {
const { key, value } = payload;
if (
key === STORAGE_KEY_THEME ||
key === STORAGE_KEY_UI_THEME_LIGHT ||
key === STORAGE_KEY_UI_THEME_DARK ||
key === STORAGE_KEY_ACCENT_MODE ||
key === STORAGE_KEY_COLOR
) {
syncAppearanceFromStorage();
return;
}
if (key === STORAGE_KEY_UI_LANGUAGE && typeof value === 'string') {
const next = resolveSupportedLocale(value);
setUiLanguage((prev) => (prev === next ? prev : next));
document.documentElement.lang = next;
}
if (key === STORAGE_KEY_CUSTOM_CSS && typeof value === 'string') {
syncCustomCssFromStorage();
}
if (key === STORAGE_KEY_UI_FONT_FAMILY && typeof value === 'string') {
if (isValidUiFontId(value)) {
setUiFontFamilyId(value);
}
}
if (key === STORAGE_KEY_TERM_THEME && typeof value === 'string') {
setTerminalThemeId(value);
}
if (key === STORAGE_KEY_TERM_THEME_DARK && typeof value === 'string') {
setTerminalThemeDarkId(value);
}
if (key === STORAGE_KEY_TERM_THEME_LIGHT && typeof value === 'string') {
setTerminalThemeLightId(value);
}
if (key === STORAGE_KEY_TERM_FOLLOW_APP_THEME) {
const next = value === true || value === 'true';
setFollowAppTerminalThemeState((prev) => (prev === next ? prev : next));
}
if (key === STORAGE_KEY_TERM_FONT_FAMILY && typeof value === 'string') {
const migrated = migrateIncomingTerminalFontId(value);
if (migrated) setTerminalFontFamilyId(migrated);
}
if (key === STORAGE_KEY_TERM_FONT_SIZE && typeof value === 'number') {
setTerminalFontSize(value);
}
if (key === STORAGE_KEY_TERM_SETTINGS) {
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value) as Partial<TerminalSettings>;
mergeIncomingTerminalSettings(parsed);
} catch {
// ignore parse errors
}
} else if (value && typeof value === 'object') {
mergeIncomingTerminalSettings(value as Partial<TerminalSettings>);
}
}
if (key === STORAGE_KEY_EDITOR_WORD_WRAP && typeof value === 'boolean') {
setEditorWordWrapState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SESSION_LOGS_ENABLED && typeof value === 'boolean') {
setSessionLogsEnabled((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SESSION_LOGS_DIR && typeof value === 'string') {
setSessionLogsDir((prev) => (prev === value ? prev : value));
}
if (
key === STORAGE_KEY_SESSION_LOGS_FORMAT &&
(value === 'txt' || value === 'raw' || value === 'html')
) {
setSessionLogsFormat((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED && typeof value === 'boolean') {
setSshDebugLogsEnabled((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
setHotkeyScheme(value);
}
if (key === STORAGE_KEY_CUSTOM_KEY_BINDINGS) {
const parsed = parseCustomKeyBindingsStorageRecord(value);
if (parsed) {
applyIncomingCustomKeyBindings(parsed);
}
}
if (key === STORAGE_KEY_HOTKEY_RECORDING && typeof value === 'boolean') {
setIsHotkeyRecordingState(value);
}
if (key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && typeof value === 'boolean') {
setGlobalHotkeyEnabled((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_AUTO_UPDATE_ENABLED && typeof value === 'boolean') {
setAutoUpdateEnabled((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE && typeof value === 'string') {
if (value === 'list' || value === 'tree') {
setSftpDefaultViewMode((prev) => (prev === value ? prev : value));
}
}
if (key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && (value === 'dim' || value === 'border')) {
setWorkspaceFocusStyleState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && typeof value === 'number') {
setSftpTransferConcurrencyState((prev) => (prev === value ? prev : value));
}
});
return () => {
try {
unsubscribe?.();
} catch {
// ignore
}
};
}, [
applyIncomingCustomKeyBindings,
mergeIncomingTerminalSettings,
setAutoUpdateEnabled,
setEditorWordWrapState,
setFollowAppTerminalThemeState,
setGlobalHotkeyEnabled,
setHotkeyScheme,
setIsHotkeyRecordingState,
setSessionLogsDir,
setSessionLogsEnabled,
setSessionLogsFormat,
setSshDebugLogsEnabled,
setSftpAutoOpenSidebar,
setSftpDefaultViewMode,
setSftpTransferConcurrencyState,
setTerminalFontFamilyId,
setTerminalFontSize,
setTerminalThemeDarkId,
setTerminalThemeId,
setTerminalThemeLightId,
setUiFontFamilyId,
setUiLanguage,
setWorkspaceFocusStyleState,
syncAppearanceFromStorage,
syncCustomCssFromStorage,
]);
}

View File

@@ -0,0 +1,158 @@
import type { HotkeyScheme, SessionLogFormat, TerminalSettings } from '../../domain/models';
import { STORAGE_KEY_TERM_FONT_FAMILY } from '../../infrastructure/config/storageKeys';
import { isDeprecatedPrimaryFontId } from '../../infrastructure/config/fonts';
import { DARK_UI_THEMES, LIGHT_UI_THEMES, type UiThemeTokens } from '../../infrastructure/config/uiThemes';
import { UI_FONTS } from '../../infrastructure/config/uiFonts';
import { uiFontStore } from './uiFontStore';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export const DEFAULT_THEME: 'light' | 'dark' | 'system' = 'dark';
/** Resolve the current OS color scheme preference. */
export const getSystemPreference = (): 'light' | 'dark' =>
typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
export const DEFAULT_LIGHT_UI_THEME = 'snow';
export const DEFAULT_DARK_UI_THEME = 'midnight';
export const DEFAULT_ACCENT_MODE: 'theme' | 'custom' = 'theme';
export const DEFAULT_CUSTOM_ACCENT = '221.2 83.2% 53.3%';
export const DEFAULT_TERMINAL_THEME = 'netcatty-dark';
export const DEFAULT_FONT_FAMILY = 'menlo';
/**
* Migrate any terminal font id arriving from storage / IPC / sync to a
* safe value. If `raw` is a deprecated proportional id (pingfang-sc,
* microsoft-yahei, comic-sans-ms), persist the rewrite back to
* localStorage so subsequent ingest paths and cloud-sync uploads stop
* carrying it. Used by every place that reads STORAGE_KEY_TERM_FONT_FAMILY
* — initial useState init, rehydrateAllFromStorage, IPC notifySettings
* change listener, and cross-window storage event listener — so a
* single point of truth keeps deprecated ids from re-entering state.
*
* Returns null when there's nothing to apply (raw is empty); callers
* fall back to DEFAULT_FONT_FAMILY in that case.
*/
export function migrateIncomingTerminalFontId(raw: string | null | undefined): string | null {
if (!raw) return null;
if (isDeprecatedPrimaryFontId(raw)) {
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, DEFAULT_FONT_FAMILY);
return DEFAULT_FONT_FAMILY;
}
return raw;
}
// Auto-detect default hotkey scheme based on platform
export const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
? 'mac'
: 'pc';
export const DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR: 'open' | 'transfer' = 'open';
export const DEFAULT_SFTP_AUTO_SYNC = false;
export const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
export const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
export const DEFAULT_SFTP_AUTO_OPEN_SIDEBAR = false;
export const DEFAULT_SFTP_DEFAULT_VIEW_MODE: 'list' | 'tree' = 'list';
export const DEFAULT_SHOW_RECENT_HOSTS = true;
export const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
export const DEFAULT_SHOW_SFTP_TAB = true;
// Editor defaults
export const DEFAULT_EDITOR_WORD_WRAP = false;
// Session Logs defaults
export const DEFAULT_SESSION_LOGS_ENABLED = false;
export const DEFAULT_SESSION_LOGS_FORMAT: SessionLogFormat = 'txt';
export const DEFAULT_SSH_DEBUG_LOGS_ENABLED = false;
export const readStoredString = (key: string): string | null => {
const raw = localStorageAdapter.readString(key);
if (!raw) return null;
const trimmed = raw.trim();
if (!trimmed) return null;
try {
const parsed = JSON.parse(trimmed);
return typeof parsed === 'string' ? parsed : trimmed;
} catch {
return trimmed;
}
};
export const isValidTheme = (value: unknown): value is 'light' | 'dark' | 'system' => value === 'light' || value === 'dark' || value === 'system';
export const isValidHslToken = (value: string): boolean => {
// Expect: "<h> <s>% <l>%", e.g. "221.2 83.2% 53.3%"
return /^\s*\d+(\.\d+)?\s+\d+(\.\d+)?%\s+\d+(\.\d+)?%\s*$/.test(value);
};
export const isValidUiThemeId = (theme: 'light' | 'dark', value: string): boolean => {
const list = theme === 'dark' ? DARK_UI_THEMES : LIGHT_UI_THEMES;
return list.some((preset) => preset.id === value);
};
export const isValidUiFontId = (value: string): boolean => {
// Local fonts are always considered valid
if (value.startsWith('local-')) return true;
// Check bundled fonts first, then check dynamically loaded fonts
return UI_FONTS.some((font) => font.id === value) ||
uiFontStore.getAvailableFonts().some((font) => font.id === value);
};
export const serializeTerminalSettings = (settings: TerminalSettings): string =>
JSON.stringify(settings);
export const areTerminalSettingsEqual = (a: TerminalSettings, b: TerminalSettings): boolean =>
serializeTerminalSettings(a) === serializeTerminalSettings(b);
export const createCustomKeyBindingsSyncOrigin = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
};
export const applyThemeTokens = (
themeSource: 'light' | 'dark' | 'system',
resolvedTheme: 'light' | 'dark',
tokens: UiThemeTokens,
accentMode: 'theme' | 'custom',
accentOverride: string,
) => {
const root = window.document.documentElement;
// If immersive override is active (style tag present), it owns the dark/light class — don't override
if (!document.getElementById('netcatty-immersive-override')) {
root.classList.remove('light', 'dark');
root.classList.add(resolvedTheme);
}
root.style.setProperty('--background', tokens.background);
root.style.setProperty('--foreground', tokens.foreground);
root.style.setProperty('--card', tokens.card);
root.style.setProperty('--card-foreground', tokens.cardForeground);
root.style.setProperty('--popover', tokens.popover);
root.style.setProperty('--popover-foreground', tokens.popoverForeground);
const accentToken = accentMode === 'custom' ? accentOverride : tokens.accent;
const accentLightness = parseFloat(accentToken.split(/\s+/)[2]?.replace('%', '') || '');
const computedAccentForeground = resolvedTheme === 'dark'
? '220 40% 96%'
: (!Number.isNaN(accentLightness) && accentLightness < 55 ? '0 0% 98%' : '222 47% 12%');
root.style.setProperty('--primary', accentToken);
root.style.setProperty('--primary-foreground', accentMode === 'custom' ? computedAccentForeground : tokens.primaryForeground);
root.style.setProperty('--secondary', tokens.secondary);
root.style.setProperty('--secondary-foreground', tokens.secondaryForeground);
root.style.setProperty('--muted', tokens.muted);
root.style.setProperty('--muted-foreground', tokens.mutedForeground);
root.style.setProperty('--accent', accentToken);
root.style.setProperty('--accent-foreground', accentMode === 'custom' ? computedAccentForeground : tokens.accentForeground);
root.style.setProperty('--destructive', tokens.destructive);
root.style.setProperty('--destructive-foreground', tokens.destructiveForeground);
root.style.setProperty('--border', tokens.border);
root.style.setProperty('--input', tokens.input);
root.style.setProperty('--ring', accentToken);
// Sync with native window title bar (Electron)
netcattyBridge.get()?.setTheme?.(themeSource);
netcattyBridge.get()?.setBackgroundColor?.(tokens.background);
};

View File

@@ -0,0 +1,422 @@
import { useEffect, useRef, type Dispatch, type SetStateAction } from 'react';
import type { CustomKeyBindings, HotkeyScheme, SessionLogFormat, TerminalSettings, UILanguage } from '../../domain/models';
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,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_HOTKEY_SCHEME,
STORAGE_KEY_SESSION_LOGS_DIR,
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_FORMAT,
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_AUTO_SYNC,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
STORAGE_KEY_TERM_SETTINGS,
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,
} from '../../infrastructure/config/storageKeys';
import {
isValidHslToken,
isValidTheme,
isValidUiFontId,
isValidUiThemeId,
migrateIncomingTerminalFontId,
} from './settingsStateDefaults';
interface UseSettingsStorageSyncParams {
theme: 'dark' | 'light' | 'system';
lightUiThemeId: string;
darkUiThemeId: string;
accentMode: 'theme' | 'custom';
customAccent: string;
customCSS: string;
uiFontFamilyId: string;
hotkeyScheme: HotkeyScheme;
uiLanguage: UILanguage;
terminalThemeId: string;
followAppTerminalTheme: boolean;
terminalFontFamilyId: string;
terminalFontSize: number;
sftpDoubleClickBehavior: 'open' | 'transfer';
sftpAutoSync: boolean;
sftpShowHiddenFiles: boolean;
sftpUseCompressedUpload: boolean;
sftpAutoOpenSidebar: boolean;
sftpDefaultViewMode: 'list' | 'tree';
showRecentHosts: boolean;
showOnlyUngroupedHostsInRoot: boolean;
showSftpTab: boolean;
editorWordWrap: boolean;
sessionLogsEnabled: boolean;
sessionLogsDir: string;
sessionLogsFormat: SessionLogFormat;
sshDebugLogsEnabled: boolean;
globalHotkeyEnabled: boolean;
autoUpdateEnabled: boolean;
setTheme: Dispatch<SetStateAction<'dark' | 'light' | 'system'>>;
setLightUiThemeId: Dispatch<SetStateAction<string>>;
setDarkUiThemeId: Dispatch<SetStateAction<string>>;
setAccentMode: Dispatch<SetStateAction<'theme' | 'custom'>>;
setCustomAccent: Dispatch<SetStateAction<string>>;
setCustomCSS: Dispatch<SetStateAction<string>>;
setUiFontFamilyId: Dispatch<SetStateAction<string>>;
setHotkeyScheme: Dispatch<SetStateAction<HotkeyScheme>>;
setUiLanguage: Dispatch<SetStateAction<UILanguage>>;
setTerminalThemeId: Dispatch<SetStateAction<string>>;
setTerminalThemeDarkId: Dispatch<SetStateAction<string>>;
setTerminalThemeLightId: Dispatch<SetStateAction<string>>;
setFollowAppTerminalThemeState: Dispatch<SetStateAction<boolean>>;
setTerminalFontFamilyId: Dispatch<SetStateAction<string>>;
setTerminalFontSize: Dispatch<SetStateAction<number>>;
setSftpDoubleClickBehavior: Dispatch<SetStateAction<'open' | 'transfer'>>;
setSftpAutoSync: Dispatch<SetStateAction<boolean>>;
setSftpShowHiddenFiles: Dispatch<SetStateAction<boolean>>;
setSftpUseCompressedUpload: Dispatch<SetStateAction<boolean>>;
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
setShowRecentHostsState: Dispatch<SetStateAction<boolean>>;
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
setEditorWordWrapState: Dispatch<SetStateAction<boolean>>;
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
setSessionLogsDir: Dispatch<SetStateAction<string>>;
setSessionLogsFormat: Dispatch<SetStateAction<SessionLogFormat>>;
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
applyIncomingCustomKeyBindings: (incoming: { bindings: CustomKeyBindings; version: number; origin: string }) => void;
mergeIncomingTerminalSettings: (incoming: Partial<TerminalSettings>) => void;
}
export function useSettingsStorageSync({
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSshDebugLogsEnabled,
setGlobalHotkeyEnabled, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
}: UseSettingsStorageSyncParams) {
// Fix 4: Keep a ref snapshot of current settings so the storage event handler
// can compare without capturing 25+ state variables in its closure / dep array.
// This avoids constant listener detach/reattach on every state change.
const settingsSnapshotRef = useRef({
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled,
});
settingsSnapshotRef.current = {
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled,
};
// Listen for storage changes from other windows (cross-window sync)
useEffect(() => {
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());
}
}
if (e.key === STORAGE_KEY_CUSTOM_CSS && e.newValue !== null) {
if (e.newValue !== s.customCSS) {
setCustomCSS(e.newValue);
}
}
if (e.key === STORAGE_KEY_UI_FONT_FAMILY && e.newValue) {
if (isValidUiFontId(e.newValue) && e.newValue !== s.uiFontFamilyId) {
setUiFontFamilyId(e.newValue);
}
}
if (e.key === STORAGE_KEY_HOTKEY_SCHEME && e.newValue) {
const newScheme = e.newValue as HotkeyScheme;
if (newScheme !== s.hotkeyScheme) {
setHotkeyScheme(newScheme);
}
}
if (e.key === STORAGE_KEY_UI_LANGUAGE && e.newValue) {
const next = resolveSupportedLocale(e.newValue);
if (next !== s.uiLanguage) {
setUiLanguage(next as UILanguage);
}
}
if (e.key === STORAGE_KEY_CUSTOM_KEY_BINDINGS && e.newValue) {
const parsed = parseCustomKeyBindingsStorageRecord(e.newValue);
if (parsed) {
applyIncomingCustomKeyBindings(parsed);
}
}
// Sync terminal settings from other windows
if (e.key === STORAGE_KEY_TERM_SETTINGS && e.newValue) {
try {
const newSettings = JSON.parse(e.newValue) as TerminalSettings;
mergeIncomingTerminalSettings(newSettings);
} catch {
// ignore parse errors
}
}
// Sync terminal theme from other windows
if (e.key === STORAGE_KEY_TERM_THEME && e.newValue) {
if (e.newValue !== s.terminalThemeId) {
setTerminalThemeId(e.newValue);
}
}
// Sync per-mode follow terminal themes from other windows
if (e.key === STORAGE_KEY_TERM_THEME_DARK && e.newValue) {
const next = e.newValue;
setTerminalThemeDarkId((prev) => (prev === next ? prev : next));
}
if (e.key === STORAGE_KEY_TERM_THEME_LIGHT && e.newValue) {
const next = e.newValue;
setTerminalThemeLightId((prev) => (prev === next ? prev : next));
}
// Sync follow-app-theme toggle from other windows
if (e.key === STORAGE_KEY_TERM_FOLLOW_APP_THEME && e.newValue) {
const next = e.newValue === 'true';
if (next !== s.followAppTerminalTheme) {
setFollowAppTerminalThemeState(next);
}
}
// Sync terminal font family from other windows
if (e.key === STORAGE_KEY_TERM_FONT_FAMILY && e.newValue) {
const migrated = migrateIncomingTerminalFontId(e.newValue);
if (migrated && migrated !== s.terminalFontFamilyId) {
setTerminalFontFamilyId(migrated);
}
}
// Sync terminal font size from other windows
if (e.key === STORAGE_KEY_TERM_FONT_SIZE && e.newValue) {
const newSize = parseInt(e.newValue, 10);
if (!isNaN(newSize) && newSize !== s.terminalFontSize) {
setTerminalFontSize(newSize);
}
}
// Sync SFTP double-click behavior from other windows
if (e.key === STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR && e.newValue) {
if ((e.newValue === 'open' || e.newValue === 'transfer') && e.newValue !== s.sftpDoubleClickBehavior) {
setSftpDoubleClickBehavior(e.newValue);
}
}
// Sync SFTP auto-sync setting from other windows
if (e.key === STORAGE_KEY_SFTP_AUTO_SYNC && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.sftpAutoSync) {
setSftpAutoSync(newValue);
}
}
// Sync SFTP show hidden files setting from other windows
if (e.key === STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.sftpShowHiddenFiles) {
setSftpShowHiddenFiles(newValue);
}
}
if (e.key === STORAGE_KEY_EDITOR_WORD_WRAP && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.editorWordWrap) {
setEditorWordWrapState(newValue);
}
}
if (e.key === STORAGE_KEY_SESSION_LOGS_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.sessionLogsEnabled) {
setSessionLogsEnabled(newValue);
}
}
if (e.key === STORAGE_KEY_SESSION_LOGS_DIR && e.newValue !== null) {
if (e.newValue !== s.sessionLogsDir) {
setSessionLogsDir(e.newValue);
}
}
if (e.key === STORAGE_KEY_SESSION_LOGS_FORMAT && e.newValue) {
if (
(e.newValue === 'txt' || e.newValue === 'raw' || e.newValue === 'html') &&
e.newValue !== s.sessionLogsFormat
) {
setSessionLogsFormat(e.newValue);
}
}
if (e.key === STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.sshDebugLogsEnabled) {
setSshDebugLogsEnabled(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';
if (newValue !== s.sftpUseCompressedUpload) {
setSftpUseCompressedUpload(newValue);
}
}
// Sync SFTP auto-open sidebar setting from other windows
if (e.key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.sftpAutoOpenSidebar) {
setSftpAutoOpenSidebar(newValue);
}
}
// Sync SFTP default view mode from other windows
if (e.key === STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE && e.newValue) {
if ((e.newValue === 'list' || e.newValue === 'tree') && e.newValue !== s.sftpDefaultViewMode) {
setSftpDefaultViewMode(e.newValue);
}
}
if (e.key === STORAGE_KEY_SHOW_RECENT_HOSTS && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.showRecentHosts) {
setShowRecentHostsState(newValue);
}
}
if (e.key === STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.showOnlyUngroupedHostsInRoot) {
setShowOnlyUngroupedHostsInRootState(newValue);
}
}
if (e.key === STORAGE_KEY_SHOW_SFTP_TAB && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.showSftpTab) {
setShowSftpTabState(newValue);
}
}
// Sync global hotkey enabled setting from other windows
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.globalHotkeyEnabled) {
setGlobalHotkeyEnabled(newValue);
}
}
// Sync auto-update enabled setting from other windows
if (e.key === STORAGE_KEY_AUTO_UPDATE_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.autoUpdateEnabled) {
setAutoUpdateEnabled(newValue);
}
}
// Sync workspace focus style from other windows
if (e.key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && e.newValue !== null) {
if (e.newValue === 'dim' || e.newValue === 'border') {
setWorkspaceFocusStyleState(e.newValue);
}
}
// Sync transfer concurrency from other windows
if (e.key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && e.newValue !== null) {
const num = Number(e.newValue);
if (num >= 1 && num <= 16) {
setSftpTransferConcurrencyState(num);
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [
applyIncomingCustomKeyBindings,
mergeIncomingTerminalSettings,
setAccentMode,
setAutoUpdateEnabled,
setCustomAccent,
setCustomCSS,
setDarkUiThemeId,
setEditorWordWrapState,
setFollowAppTerminalThemeState,
setGlobalHotkeyEnabled,
setHotkeyScheme,
setLightUiThemeId,
setSessionLogsDir,
setSessionLogsEnabled,
setSessionLogsFormat,
setSshDebugLogsEnabled,
setSftpAutoOpenSidebar,
setSftpAutoSync,
setSftpDefaultViewMode,
setSftpDoubleClickBehavior,
setSftpShowHiddenFiles,
setSftpTransferConcurrencyState,
setSftpUseCompressedUpload,
setShowOnlyUngroupedHostsInRootState,
setShowRecentHostsState,
setShowSftpTabState,
setTerminalFontFamilyId,
setTerminalFontSize,
setTerminalThemeDarkId,
setTerminalThemeId,
setTerminalThemeLightId,
setTheme,
setUiFontFamilyId,
setUiLanguage,
setWorkspaceFocusStyleState,
]);
}

View File

@@ -0,0 +1,49 @@
import type { TerminalTheme } from '../../domain/models';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
import { applyCustomAccentToTerminalTheme, resolveFollowedTerminalThemeId } 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,
terminalThemeDarkId,
terminalThemeLightId,
lightUiThemeId,
darkUiThemeId,
fallbackThemeId: terminalThemeId,
});
const followed = TERMINAL_THEMES.find(t => t.id === followedId)
|| customThemes.find(t => t.id === followedId);
if (followed) {
return applyCustomAccentToTerminalTheme(followed, accentMode, customAccent);
}
}
const baseTheme = TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|| customThemes.find(t => t.id === terminalThemeId)
|| TERMINAL_THEMES[0];
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}

View File

@@ -0,0 +1,23 @@
import type { SftpBookmark } from "../../../domain/models";
const ROOT_PATH_RE = /^[A-Za-z]:[\\/]?$/;
export function getSftpBookmarkLabel(path: string): string {
const trimmed = path.trim();
if (trimmed === "/" || ROOT_PATH_RE.test(trimmed)) return trimmed;
return trimmed.split(/[\\/]/).filter(Boolean).pop() || trimmed;
}
export function createSftpBookmark(
path: string,
options: { global?: boolean; idPrefix?: string } = {},
): SftpBookmark {
const global = options.global === true;
const idPrefix = options.idPrefix ?? (global ? "gbm" : "bm");
return {
id: `${idPrefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
path,
label: getSftpBookmarkLabel(path),
...(global ? { global: true } : {}),
};
}

View File

@@ -0,0 +1,45 @@
import type { SftpBookmark } from "../../../domain/models";
import { STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
type Listener = () => void;
const listeners = new Set<Listener>();
let snapshot: SftpBookmark[] =
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) ?? [];
export function subscribeGlobalSftpBookmarks(listener: Listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function getGlobalSftpBookmarksSnapshot() {
return snapshot;
}
export function rehydrateGlobalSftpBookmarks() {
snapshot = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) ?? [];
for (const listener of listeners) listener();
}
export function setGlobalSftpBookmarks(
next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[]),
) {
snapshot = typeof next === "function" ? next(snapshot) : next;
localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, snapshot);
for (const listener of listeners) listener();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("sftp-bookmarks-changed"));
}
}
if (typeof window !== "undefined") {
window.addEventListener("storage", (event) => {
if (event.key === STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) {
rehydrateGlobalSftpBookmarks();
}
});
}

View File

@@ -0,0 +1,105 @@
import { useCallback } from "react";
import type { SftpFilenameEncoding, TransferTask } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { SftpPane } from "./types";
import { getParentPath, joinPath } from "./utils";
export function useSftpTransferConflictOps() {
const splitNameForDuplicate = useCallback((fileName: string, isDirectory: boolean) => {
if (isDirectory) return { baseName: fileName, ext: "" };
const lastDot = fileName.lastIndexOf(".");
if (lastDot <= 0) return { baseName: fileName, ext: "" };
return {
baseName: fileName.slice(0, lastDot),
ext: fileName.slice(lastDot),
};
}, []);
const statTargetPath = useCallback(
async (
targetPane: SftpPane,
targetSftpId: string | null,
targetPath: string,
targetEncoding: SftpFilenameEncoding,
): Promise<{ type?: "file" | "directory" | "symlink"; size: number; mtime: number } | null> => {
if (!targetPane.connection) return null;
if (targetPane.connection.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(targetPath);
if (!stat) return null;
return {
type: stat.type as "file" | "directory" | "symlink" | undefined,
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
}
if (!targetSftpId) return null;
const stat = await netcattyBridge.get()?.statSftp?.(
targetSftpId,
targetPath,
targetEncoding,
);
if (!stat) return null;
return {
type: stat.type as "file" | "directory" | "symlink" | undefined,
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
},
[],
);
const getDuplicateTarget = useCallback(
async (
task: TransferTask,
targetPane: SftpPane,
targetSftpId: string | null,
targetEncoding: SftpFilenameEncoding,
) => {
const parentPath = getParentPath(task.targetPath);
const { baseName, ext } = splitNameForDuplicate(task.fileName, task.isDirectory);
for (let index = 1; index < 1000; index++) {
const suffix = index === 1 ? " (copy)" : ` (copy ${index})`;
const fileName = `${baseName}${suffix}${ext}`;
const targetPath = joinPath(parentPath, fileName);
try {
const existing = await statTargetPath(targetPane, targetSftpId, targetPath, targetEncoding);
if (!existing) return { fileName, targetPath };
} catch {
return { fileName, targetPath };
}
}
const fallbackName = `${baseName} (copy ${Date.now()})${ext}`;
return { fileName: fallbackName, targetPath: joinPath(parentPath, fallbackName) };
},
[splitNameForDuplicate, statTargetPath],
);
const deleteTargetPath = useCallback(
async (
task: TransferTask,
targetPane: SftpPane,
targetSftpId: string | null,
targetEncoding: SftpFilenameEncoding,
) => {
if (!targetPane.connection) return;
if (targetPane.connection.isLocal) {
const deleteLocalFile = netcattyBridge.get()?.deleteLocalFile;
if (!deleteLocalFile) throw new Error("Local delete unavailable");
await deleteLocalFile(task.targetPath);
return;
}
if (!targetSftpId) throw new Error("Target SFTP session not found");
const deleteSftp = netcattyBridge.get()?.deleteSftp;
if (!deleteSftp) throw new Error("SFTP delete unavailable");
await deleteSftp(targetSftpId, task.targetPath, targetEncoding);
},
[],
);
return { statTargetPath, getDuplicateTarget, deleteTargetPath };
}

View File

@@ -0,0 +1,455 @@
import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import type { SftpFileEntry, SftpFilenameEncoding, TransferStatus, TransferTask } from "../../../domain/models";
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from "../../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { joinPath } from "./utils";
interface UseSftpDirectoryTransferOpsParams {
cancelledTasksRef: MutableRefObject<Set<string>>;
activeChildIdsRef: MutableRefObject<Map<string, Set<string>>>;
setTransfers: Dispatch<SetStateAction<TransferTask[]>>;
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
}
export function useSftpDirectoryTransferOps({
cancelledTasksRef,
activeChildIdsRef,
setTransfers,
listLocalFiles,
listRemoteFiles,
}: UseSftpDirectoryTransferOpsParams) {
const getEntrySize = useCallback((entry: SftpFileEntry): number => {
if (typeof entry.size === "string") {
const parsed = parseInt(entry.size, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
return typeof entry.size === "number" && entry.size > 0 ? entry.size : 0;
}, []);
const MAX_SYMLINK_DEPTH = 32;
const estimateDirectoryBytes = useCallback(
async (
sourcePath: string,
sourceSftpId: string | null,
sourceIsLocal: boolean,
sourceEncoding: SftpFilenameEncoding,
rootTaskId: string,
symlinkDepth = 0,
followSymlinks = false,
): Promise<number> => {
const estT0 = performance.now();
if (cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
}
const files = sourceIsLocal
? await listLocalFiles(sourcePath)
: sourceSftpId
? await listRemoteFiles(sourceSftpId, sourcePath, sourceEncoding)
: null;
if (!files) {
throw new Error("No source connection");
}
let totalBytes = 0;
const subdirs: { entry: SftpFileEntry; nextDepth: number }[] = [];
for (const file of files) {
if (file.name === ".." || file.name === ".") continue;
if (file.type === "directory") {
subdirs.push({ entry: file, nextDepth: symlinkDepth });
} else if (followSymlinks && file.type === "symlink" && file.linkTarget === "directory") {
if (symlinkDepth < MAX_SYMLINK_DEPTH) {
subdirs.push({ entry: file, nextDepth: symlinkDepth + 1 });
}
// Skip at max depth — consistent with transferDirectory
} else {
totalBytes += getEntrySize(file);
}
}
if (subdirs.length > 0) {
if (cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
}
const subResults = await Promise.all(
subdirs.map(({ entry: subdir, nextDepth }) =>
estimateDirectoryBytes(
joinPath(sourcePath, subdir.name),
sourceSftpId,
sourceIsLocal,
sourceEncoding,
rootTaskId,
nextDepth,
followSymlinks,
),
),
);
totalBytes += subResults.reduce((sum, size) => sum + size, 0);
}
logger.debug(`[SFTP:perf] estimateDirectoryBytes ${sourcePath} = ${totalBytes}${(performance.now() - estT0).toFixed(0)}ms`);
return totalBytes;
},
[cancelledTasksRef, getEntrySize, listLocalFiles, listRemoteFiles],
);
const transferFile = async (
task: TransferTask,
sourceSftpId: string | null,
targetSftpId: string | null,
sourceIsLocal: boolean,
targetIsLocal: boolean,
sourceEncoding: SftpFilenameEncoding,
targetEncoding: SftpFilenameEncoding,
rootTaskId: string, // The original top-level task ID for cancellation checking
sameHost?: boolean,
onStreamProgress?: (transferred: number, total: number, speed: number) => void,
): Promise<void> => {
// Check if task or root task was cancelled before starting
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
}
return new Promise((resolve, reject) => {
const options = {
transferId: task.id,
sourcePath: task.sourcePath,
targetPath: task.targetPath,
sourceType: sourceIsLocal ? ("local" as const) : ("sftp" as const),
targetType: targetIsLocal ? ("local" as const) : ("sftp" as const),
sourceSftpId: sourceSftpId || undefined,
targetSftpId: targetSftpId || undefined,
totalBytes: task.totalBytes || undefined,
sourceEncoding: sourceIsLocal ? undefined : sourceEncoding,
targetEncoding: targetIsLocal ? undefined : targetEncoding,
sameHost: sameHost || undefined,
};
let lastProgressUpdate = 0;
const onProgress = (
transferred: number,
total: number,
speed: number,
) => {
// Bubble up streaming progress to parent (for directory transfers)
onStreamProgress?.(transferred, total, speed);
// Throttle state updates to at most once per 100ms
const now = Date.now();
if (now - lastProgressUpdate < 100 && transferred < total) return;
lastProgressUpdate = now;
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== task.id) return t;
if (t.status === "cancelled") return t;
const normalizedTotal = total > 0 ? total : t.totalBytes;
// Clamp to [previous, total] — the backend normalizes progress
// but we guard against any non-monotonic edge cases.
const normalizedTransferred = Math.max(
t.transferredBytes,
Math.min(transferred, normalizedTotal > 0 ? normalizedTotal : transferred),
);
return {
...t,
transferredBytes: normalizedTransferred,
totalBytes: normalizedTotal,
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
};
}),
);
};
const onComplete = () => {
resolve();
};
const onError = (error: string) => {
reject(new Error(error));
};
netcattyBridge.require().startStreamTransfer!(
options,
onProgress,
onComplete,
onError,
).catch(reject);
});
};
const getTransferConcurrency = () => {
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
};
/** Recursively count all files under a directory (for progress display). */
const countDirectoryFiles = async (
sourcePath: string,
sourceSftpId: string | null,
sourceIsLocal: boolean,
sourceEncoding: SftpFilenameEncoding,
rootTaskId: string,
symlinkDepth = 0,
followSymlinks = false,
): Promise<number> => {
if (cancelledTasksRef.current.has(rootTaskId)) return 0;
const files = sourceIsLocal
? await listLocalFiles(sourcePath)
: sourceSftpId
? await listRemoteFiles(sourceSftpId, sourcePath, sourceEncoding)
: null;
if (!files) return 0;
let count = 0;
const subdirPromises: Promise<number>[] = [];
for (const file of files) {
if (file.name === ".." || file.name === ".") continue;
if (file.type === "directory") {
subdirPromises.push(
countDirectoryFiles(joinPath(sourcePath, file.name), sourceSftpId, sourceIsLocal, sourceEncoding, rootTaskId, symlinkDepth, followSymlinks),
);
} else if (followSymlinks && file.type === "symlink" && file.linkTarget === "directory") {
// Only recurse if within depth limit; skip entirely at max depth
// (consistent with transferDirectory which also skips these)
if (symlinkDepth < MAX_SYMLINK_DEPTH) {
subdirPromises.push(
countDirectoryFiles(joinPath(sourcePath, file.name), sourceSftpId, sourceIsLocal, sourceEncoding, rootTaskId, symlinkDepth + 1, followSymlinks),
);
}
} else {
count++;
}
}
if (subdirPromises.length > 0) {
const subCounts = await Promise.all(subdirPromises);
count += subCounts.reduce((a, b) => a + b, 0);
}
return count;
};
/** Returns number of failed child file transfers */
const transferDirectory = async (
task: TransferTask,
sourceSftpId: string | null,
targetSftpId: string | null,
sourceIsLocal: boolean,
targetIsLocal: boolean,
sourceEncoding: SftpFilenameEncoding,
targetEncoding: SftpFilenameEncoding,
rootTaskId: string, // The original top-level task ID for cancellation checking
sameHost?: boolean,
symlinkDepth = 0,
followSymlinks = false, // Only true for downloadToLocal — uploads/copies treat symlinks as files
) => {
// Check if task or root task was cancelled before starting
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
}
let totalErrors = 0;
if (targetIsLocal) {
try {
await netcattyBridge.get()?.mkdirLocal?.(task.targetPath);
} catch (mkdirErr: unknown) {
const isEEXIST = mkdirErr instanceof Error && mkdirErr.message.includes("EEXIST");
if (!isEEXIST) throw mkdirErr;
// EEXIST: verify the existing path is actually a directory, not a file
const stat = await netcattyBridge.get()?.statLocal?.(task.targetPath);
if (stat && stat.type !== 'directory') {
throw new Error(`Target path exists as a file: ${task.targetPath}`);
}
}
} else if (targetSftpId) {
await netcattyBridge.get()?.mkdirSftp(targetSftpId, task.targetPath, targetEncoding);
}
let files: SftpFileEntry[];
if (sourceIsLocal) {
files = await listLocalFiles(task.sourcePath);
} else if (sourceSftpId) {
files = await listRemoteFiles(sourceSftpId, task.sourcePath, sourceEncoding);
} else {
throw new Error("No source connection");
}
// Filter both "." and ".." — some SFTP servers include "." in readdir
const filtered = files.filter((f) => f.name !== ".." && f.name !== ".");
// Separate directories from files.
// Symlink directories are only followed when followSymlinks is true
// (downloadToLocal). Uploads/copies treat symlinks as regular entries
// to preserve existing behavior and avoid expanding symlinked trees.
const dirs: SftpFileEntry[] = [];
const regularFiles: SftpFileEntry[] = [];
for (const f of filtered) {
if (f.type === "directory") {
dirs.push(f);
} else if (followSymlinks && f.type === "symlink" && f.linkTarget === "directory") {
if (symlinkDepth < MAX_SYMLINK_DEPTH) {
dirs.push(f);
} else {
// Count as an error so the parent task is marked failed
totalErrors++;
logger.warn(`[SFTP] Skipping symlink directory at max depth: ${joinPath(task.sourcePath, f.name)}`);
}
} else {
regularFiles.push(f);
}
}
// Process subdirectories sequentially to avoid unbounded concurrent SFTP
// requests from nested Promise.all + worker pools across the tree.
// File-level concurrency within each directory is still governed by
// getTransferConcurrency().
for (const dir of dirs) {
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
}
const childTask: TransferTask = {
...task,
id: crypto.randomUUID(),
fileName: dir.name,
originalFileName: dir.name,
sourcePath: joinPath(task.sourcePath, dir.name),
targetPath: joinPath(task.targetPath, dir.name),
isDirectory: true,
progressMode: "files",
parentTaskId: task.id,
};
const isSymlink = dir.type === "symlink";
const subdirErrors = await transferDirectory(
childTask,
sourceSftpId,
targetSftpId,
sourceIsLocal,
targetIsLocal,
sourceEncoding,
targetEncoding,
rootTaskId,
sameHost,
isSymlink ? symlinkDepth + 1 : symlinkDepth,
followSymlinks,
);
totalErrors += subdirErrors;
}
// Transfer files in parallel with concurrency limit
if (regularFiles.length > 0) {
let fileIndex = 0;
const errors: Error[] = [];
const worker = async () => {
while (fileIndex < regularFiles.length) {
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
}
const idx = fileIndex++;
const file = regularFiles[idx];
const fileId = crypto.randomUUID();
const fileSize = getEntrySize(file);
// Track child ID outside React state for immediate cancellation visibility
if (!activeChildIdsRef.current.has(rootTaskId)) {
activeChildIdsRef.current.set(rootTaskId, new Set());
}
activeChildIdsRef.current.get(rootTaskId)!.add(fileId);
const childTask: TransferTask = {
...task,
id: fileId,
fileName: file.name,
originalFileName: file.name,
sourcePath: joinPath(task.sourcePath, file.name),
targetPath: joinPath(task.targetPath, file.name),
isDirectory: false,
progressMode: "bytes",
parentTaskId: rootTaskId,
totalBytes: fileSize,
// Inherit retryable from parent — downloadToLocal sets retryable: false
// because "local" targetConnectionId can't be resolved by retryTransfer
retryable: task.retryable,
};
// Register child in transfers array so UI can render it
setTransfers((prev) => [...prev, {
...childTask,
status: "transferring" as TransferStatus,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
}]);
try {
await transferFile(
childTask,
sourceSftpId,
targetSftpId,
sourceIsLocal,
targetIsLocal,
sourceEncoding,
targetEncoding,
rootTaskId,
sameHost,
);
activeChildIdsRef.current.get(rootTaskId)?.delete(fileId);
// Mark child as completed & update parent file count
setTransfers((prev) => {
const updated = prev.map((t) => {
if (t.id === fileId) {
return { ...t, status: "completed" as TransferStatus, endTime: Date.now(), transferredBytes: t.totalBytes };
}
if (t.id === rootTaskId) {
return { ...t, transferredBytes: t.transferredBytes + 1 };
}
return t;
});
return updated;
});
} catch (err) {
activeChildIdsRef.current.get(rootTaskId)?.delete(fileId);
// Mark child as failed
setTransfers((prev) =>
prev.map((t) =>
t.id === fileId
? { ...t, status: "failed" as TransferStatus, error: err instanceof Error ? err.message : String(err) }
: t,
),
);
if (err instanceof Error && err.message === "Transfer cancelled") throw err;
errors.push(err instanceof Error ? err : new Error(String(err)));
}
}
};
const concurrency = getTransferConcurrency();
const workers = Array.from(
{ length: Math.min(concurrency, regularFiles.length) },
() => worker(),
);
await Promise.all(workers);
totalErrors += errors.length;
if (errors.length > 0) {
logger.debug?.("[SFTP] Some files in directory transfer failed", errors);
}
}
return totalErrors;
};
return { estimateDirectoryBytes, transferFile, countDirectoryFiles, transferDirectory };
}

View File

@@ -0,0 +1,115 @@
import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import type { FileConflict, TransferStatus, TransferTask } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import type { TransferResult } from "./useSftpTransfers.types";
interface UseSftpTransferTaskOpsParams {
cancelledTasksRef: MutableRefObject<Set<string>>;
activeChildIdsRef: MutableRefObject<Map<string, Set<string>>>;
transfersRef: MutableRefObject<TransferTask[]>;
completionHandlersRef: MutableRefObject<Map<string, (result: TransferResult) => void | Promise<void>>>;
setConflicts: Dispatch<SetStateAction<FileConflict[]>>;
setTransfers: Dispatch<SetStateAction<TransferTask[]>>;
}
export function useSftpTransferTaskOps({
cancelledTasksRef,
activeChildIdsRef,
transfersRef,
completionHandlersRef,
setConflicts,
setTransfers,
}: UseSftpTransferTaskOpsParams) {
const completeCancelledTask = useCallback(
async (task: TransferTask) => {
const completionHandler = completionHandlersRef.current.get(task.id);
if (completionHandler) {
try {
await completionHandler({
id: task.id,
fileName: task.fileName,
originalFileName: task.originalFileName ?? task.fileName,
status: "cancelled",
});
} finally {
completionHandlersRef.current.delete(task.id);
}
}
},
[completionHandlersRef],
);
const cancelBackendTransfers = useCallback(async (transferIds: string[]) => {
const idsToCancel = new Set<string>();
const currentTransfers = transfersRef.current;
for (const transferId of transferIds) {
idsToCancel.add(transferId);
const trackedChildren = activeChildIdsRef.current.get(transferId);
if (trackedChildren) {
for (const childId of trackedChildren) {
idsToCancel.add(childId);
cancelledTasksRef.current.add(childId);
}
}
for (const transfer of currentTransfers) {
if (
transfer.parentTaskId === transferId &&
(transfer.status === "transferring" || transfer.status === "pending")
) {
idsToCancel.add(transfer.id);
cancelledTasksRef.current.add(transfer.id);
}
}
}
const cancelTransferAtBackend = netcattyBridge.get()?.cancelTransfer;
if (!cancelTransferAtBackend) return;
await Promise.all(
Array.from(idsToCancel).map((id) =>
cancelTransferAtBackend(id).catch((err) => {
logger.warn("Failed to cancel transfer at backend:", err);
}),
),
);
}, [activeChildIdsRef, cancelledTasksRef, transfersRef]);
const markBatchStopped = useCallback(
async (task: TransferTask) => {
const batchId = task.batchId;
const affected = transfersRef.current.filter((candidate) =>
candidate.id === task.id ||
(!!batchId && candidate.batchId === batchId && (candidate.status === "pending" || candidate.status === "transferring")),
);
affected.forEach((candidate) => cancelledTasksRef.current.add(candidate.id));
const affectedIds = new Set(affected.map((candidate) => candidate.id));
setConflicts((prev) => prev.filter((conflict) => conflict.transferId !== task.id && (!batchId || conflict.batchId !== batchId)));
setTransfers((prev) => {
for (const candidate of prev) {
if (candidate.parentTaskId && affectedIds.has(candidate.parentTaskId)) {
cancelledTasksRef.current.add(candidate.id);
}
}
return prev
.filter((candidate) => !(candidate.parentTaskId && affectedIds.has(candidate.parentTaskId)))
.map((candidate) =>
affectedIds.has(candidate.id)
? { ...candidate, status: "cancelled" as TransferStatus, endTime: Date.now() }
: candidate,
);
});
await cancelBackendTransfers(affected.map((candidate) => candidate.id));
for (const candidate of affected) {
await completeCancelledTask(candidate);
}
},
[cancelBackendTransfers, cancelledTasksRef, completeCancelledTask, setConflicts, setTransfers, transfersRef],
);
return { completeCancelledTask, cancelBackendTransfers, markBatchStopped };
}

View File

@@ -64,4 +64,10 @@ export interface SftpStateOptions {
useCompressedUpload?: boolean;
defaultShowHiddenFiles?: boolean;
autoConnectLocalOnMount?: boolean;
/**
* Global SSH keepalive settings, forwarded through to per-SFTP-connection
* keepalive resolution so a host that has opted into its own override
* is honored for SFTP browsing too (not just the terminal session).
*/
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}

View File

@@ -0,0 +1,99 @@
import type { TransferTask, TransferStatus } from "../../../domain/models";
import type { UploadCallbacks, UploadTaskInfo } from "../../../lib/uploadService";
import { joinPath } from "./utils";
interface UploadTaskCallbacksParams {
connectionId: string;
targetPath: string;
targetHostId?: string;
targetConnectionKey?: string;
addExternalUpload?: (task: TransferTask) => void;
updateExternalUpload?: (taskId: string, updates: Partial<TransferTask>) => void;
dismissExternalUpload?: (taskId: string) => void;
}
export const createUploadTaskCallbacks = ({
connectionId,
targetPath,
targetHostId,
targetConnectionKey,
addExternalUpload,
updateExternalUpload,
dismissExternalUpload,
}: UploadTaskCallbacksParams): UploadCallbacks => ({
onScanningStart: (taskId: string) => {
if (!addExternalUpload) return;
addExternalUpload({
id: taskId,
fileName: "Scanning files...",
sourcePath: "local",
targetPath,
sourceConnectionId: "external",
targetConnectionId: connectionId,
targetHostId,
targetConnectionKey,
direction: "upload",
status: "pending" as TransferStatus,
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: true,
progressMode: "bytes",
});
},
onScanningEnd: (taskId: string) => {
dismissExternalUpload?.(taskId);
},
onTaskCreated: (task: UploadTaskInfo) => {
if (!addExternalUpload) return;
addExternalUpload({
id: task.id,
fileName: task.displayName,
sourcePath: "local",
targetPath: joinPath(targetPath, task.fileName),
sourceConnectionId: "external",
targetConnectionId: connectionId,
targetHostId,
targetConnectionKey,
direction: "upload",
status: "transferring" as TransferStatus,
totalBytes: task.totalBytes,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: task.isDirectory,
progressMode: task.progressMode ?? "bytes",
parentTaskId: task.parentTaskId,
});
},
onTaskProgress: (taskId: string, progress) => {
updateExternalUpload?.(taskId, {
transferredBytes: progress.transferred,
speed: progress.speed,
});
},
onTaskCompleted: (taskId: string, totalBytes: number) => {
updateExternalUpload?.(taskId, {
status: "completed" as TransferStatus,
endTime: Date.now(),
transferredBytes: totalBytes,
speed: 0,
});
},
onTaskFailed: (taskId: string, error: string) => {
updateExternalUpload?.(taskId, {
status: "failed" as TransferStatus,
endTime: Date.now(),
error,
speed: 0,
});
},
onTaskCancelled: (taskId: string) => {
updateExternalUpload?.(taskId, {
status: "cancelled" as TransferStatus,
endTime: Date.now(),
speed: 0,
});
},
});

View File

@@ -11,6 +11,7 @@ interface UseSftpConnectionsParams {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
leftTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
rightTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
leftTabs: { tabs: SftpPane[] };
@@ -44,6 +45,7 @@ export const useSftpConnections = ({
hosts,
keys,
identities,
terminalSettings,
leftTabsRef,
rightTabsRef,
leftTabs,
@@ -65,11 +67,11 @@ export const useSftpConnections = ({
createEmptyPane,
autoConnectLocalOnMount = true,
}: UseSftpConnectionsParams): UseSftpConnectionsResult => {
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities });
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities, terminalSettings });
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
const connect = useCallback(
async (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean; onTabCreated?: (tabId: string) => void }) => {
async (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean; onTabCreated?: (tabId: string) => void; sourceSessionId?: string }) => {
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
let activeTabId: string | null = null;
@@ -205,6 +207,11 @@ export const useSftpConnections = ({
isLocal: false,
status: "connecting",
currentPath: cachedStartPath,
// Suppress loading animation when connection reuse is requested.
// If the backend falls back to a fresh connection, the pane stays
// non-interactive (loading=true) with stale cached files visible —
// no worse than the previous UX of always showing a spinner.
reusedConnection: !!options?.sourceSessionId,
};
updateTab(side, activeTabId, (prev) => ({
@@ -281,7 +288,7 @@ export const useSftpConnections = ({
);
};
const hasKey = !!credentials.privateKey;
const hasKey = !!credentials.privateKey || !!credentials.identityFilePaths?.length;
const hasPassword = !!credentials.password;
let sftpId: string | undefined;
@@ -290,6 +297,7 @@ export const useSftpConnections = ({
const keyFirstCredentials = {
sessionId: `sftp-${connectionId}`,
...credentials,
sourceSessionId: options?.sourceSessionId,
};
if (!credentials.sudo) {
keyFirstCredentials.password = undefined;
@@ -300,11 +308,13 @@ export const useSftpConnections = ({
sftpId = await openSftp({
sessionId: `sftp-${connectionId}`,
...credentials,
sourceSessionId: options?.sourceSessionId,
privateKey: undefined,
certificate: undefined,
publicKey: undefined,
keyId: undefined,
keySource: undefined,
identityFilePaths: undefined,
});
} else {
throw err;
@@ -314,6 +324,7 @@ export const useSftpConnections = ({
sftpId = await openSftp({
sessionId: `sftp-${connectionId}`,
...credentials,
sourceSessionId: options?.sourceSessionId,
});
}
@@ -449,6 +460,7 @@ export const useSftpConnections = ({
status: "connected",
currentPath: startPath,
homeDir,
reusedConnection: undefined,
}
: null,
files,

View File

@@ -1,69 +1,26 @@
import React, { useCallback, useRef, useMemo } from "react";
import { TransferTask, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
import { useCallback, useRef, useMemo, useState } from "react";
import { FileConflict, FileConflictAction, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { SftpPane } from "./types";
import { notify } from "../../notification";
import { joinPath } from "./utils";
import { createUploadTaskCallbacks } from "./uploadTaskCallbacks";
import {
UploadController,
uploadFromDataTransfer,
uploadFromFileList,
uploadEntriesDirect,
UploadBridge,
UploadCallbacks,
UploadResult,
UploadTaskInfo,
startUploadScanningTask,
} from "../../../lib/uploadService";
import type { DropEntry } from "../../../lib/sftpFileUtils";
// Re-export UploadResult for external usage
export type { UploadResult };
interface UseSftpExternalOperationsParams {
getActivePane: (side: "left" | "right") => SftpPane | null;
getPaneByConnectionId: (connectionId: string) => SftpPane | null;
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
clearDirCacheEntry?: (connectionId: string, path: string) => void;
useCompressedUpload?: boolean;
addExternalUpload?: (task: TransferTask) => void;
updateExternalUpload?: (taskId: string, updates: Partial<TransferTask>) => void;
isTransferCancelled?: (taskId: string) => boolean;
dismissExternalUpload?: (taskId: string) => void;
}
interface SftpExternalOperationsResult {
readTextFile: (side: "left" | "right", filePath: string) => Promise<string>;
readBinaryFile: (side: "left" | "right", filePath: string) => Promise<ArrayBuffer>;
writeTextFile: (side: "left" | "right", filePath: string, content: string) => Promise<void>;
writeTextFileByConnection: (
connectionId: string,
expectedHostId: string,
filePath: string,
content: string,
filenameEncoding?: SftpFilenameEncoding,
) => Promise<void>;
downloadToTempAndOpen: (
side: "left" | "right",
remotePath: string,
fileName: string,
appPath: string,
options?: { enableWatch?: boolean }
) => Promise<{ localTempPath: string; watchId?: string }>;
activeFileWatchCountRef: React.MutableRefObject<number>;
uploadExternalFiles: (
side: "left" | "right",
dataTransfer: DataTransfer,
targetPath?: string
) => Promise<UploadResult[]>;
uploadExternalEntries: (
side: "left" | "right",
entries: DropEntry[],
options?: { targetPath?: string }
) => Promise<UploadResult[]>;
cancelExternalUpload: () => Promise<void>;
selectApplication: () => Promise<{ path: string; name: string } | null>;
}
import type { UseSftpExternalOperationsParams, SftpExternalOperationsResult } from "./useSftpExternalOperations.types";
export const useSftpExternalOperations = (
params: UseSftpExternalOperationsParams
@@ -88,6 +45,11 @@ export const useSftpExternalOperations = (
// Track active file watches so the side panel can block host-switching.
// Reset to 0 when the SFTP session disconnects (handled in SftpSidePanel).
const activeFileWatchCountRef = useRef(0);
const [uploadConflicts, setUploadConflicts] = useState<FileConflict[]>([]);
const uploadConflictResolversRef = useRef(new Map<string, {
resolve: (action: FileConflictAction) => void;
setDefault: (action: FileConflictAction) => void;
}>());
const readTextFile = useCallback(
async (side: "left" | "right", filePath: string): Promise<string> => {
@@ -217,27 +179,24 @@ export const useSftpExternalOperations = (
[getPaneByConnectionId, sftpSessionsRef],
);
const downloadToTempAndOpen = useCallback(
const downloadToTemp = useCallback(
async (
side: "left" | "right",
remotePath: string,
fileName: string,
appPath: string,
options?: { enableWatch?: boolean }
): Promise<{ localTempPath: string; watchId?: string }> => {
): Promise<{ localTempPath: string; sftpId: string; externalTransferId?: string }> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No connection available");
}
const bridge = netcattyBridge.get();
if (!bridge?.downloadSftpToTemp || !bridge?.openWithApplication) {
throw new Error("System app opening not supported");
if (!bridge?.downloadSftpToTemp) {
throw new Error("SFTP temp download not supported");
}
if (pane.connection.isLocal) {
await bridge.openWithApplication(remotePath, appPath);
return { localTempPath: remotePath };
throw new Error("Temp download is only available for remote files");
}
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
@@ -326,12 +285,12 @@ export const useSftpExternalOperations = (
if (localTempPath && bridge.deleteTempFile) {
bridge.deleteTempFile(localTempPath).catch(() => {});
}
return { localTempPath: "" };
return { localTempPath: "", sftpId, externalTransferId };
}
if (isLocalTempDownloadCancelled()) {
await cleanupTempDownload(localTempPath);
return { localTempPath: "" };
return { localTempPath: "", sftpId, externalTransferId };
}
updateExternalUpload(externalTransferId, {
@@ -350,7 +309,7 @@ export const useSftpExternalOperations = (
if (isLocalTempDownloadCancelled()) {
await cleanupTempDownload(localTempPath);
return { localTempPath: "" };
return { localTempPath: "", sftpId, externalTransferId };
}
if (bridge.registerTempFile) {
@@ -361,11 +320,44 @@ export const useSftpExternalOperations = (
}
}
return { localTempPath, sftpId, externalTransferId };
},
[getActivePane, sftpSessionsRef, addExternalUpload, updateExternalUpload, isTransferCancelled],
);
const downloadToTempAndOpen = useCallback(
async (
side: "left" | "right",
remotePath: string,
fileName: string,
appPath: string,
options?: { enableWatch?: boolean }
): Promise<{ localTempPath: string; watchId?: string }> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No connection available");
}
const bridge = netcattyBridge.get();
if (!bridge?.openWithApplication) {
throw new Error("System app opening not supported");
}
if (pane.connection.isLocal) {
await bridge.openWithApplication(remotePath, appPath);
return { localTempPath: remotePath };
}
const { localTempPath, sftpId, externalTransferId } = await downloadToTemp(side, remotePath, fileName);
if (!localTempPath) {
return { localTempPath: "" };
}
try {
await bridge.openWithApplication(localTempPath, appPath);
} catch (err) {
if (externalTransferId) {
updateExternalUpload(externalTransferId, {
updateExternalUpload?.(externalTransferId, {
status: "failed" as TransferStatus,
endTime: Date.now(),
error: err instanceof Error ? err.message : String(err),
@@ -393,7 +385,67 @@ export const useSftpExternalOperations = (
return { localTempPath, watchId };
},
[getActivePane, sftpSessionsRef, addExternalUpload, updateExternalUpload, isTransferCancelled],
[downloadToTemp, getActivePane, updateExternalUpload],
);
const openWithSystemDefault = useCallback(
async (
side: "left" | "right",
remotePath: string,
fileName: string,
options?: { enableWatch?: boolean }
): Promise<void> => {
try {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No connection available");
}
const bridge = netcattyBridge.get();
if (!bridge?.openWithSystemDefault) {
throw new Error("System default opening not supported");
}
const bridgeMethods = bridge;
const { localTempPath, sftpId, externalTransferId } = pane.connection.isLocal
? { localTempPath: remotePath, sftpId: "", externalTransferId: undefined }
: await downloadToTemp(side, remotePath, fileName);
if (!localTempPath) return;
const result = await bridgeMethods.openWithSystemDefault(localTempPath);
if (!result.success) {
if (externalTransferId) {
updateExternalUpload?.(externalTransferId, {
status: "failed" as TransferStatus,
endTime: Date.now(),
error: result.error || "Failed to open file",
speed: 0,
});
}
throw new Error(result.error || "Failed to open file");
}
// Start file watch for remote SFTP auto-sync (mirrors downloadToTempAndOpen behavior)
if (options?.enableWatch && !pane.connection.isLocal && bridgeMethods.startFileWatch) {
try {
await bridgeMethods.startFileWatch(
localTempPath,
remotePath,
sftpId,
pane.filenameEncoding,
);
activeFileWatchCountRef.current += 1;
} catch (err) {
console.warn("[SFTP] Failed to start file watch for default app open:", err);
}
}
} catch (err) {
notify.error(err instanceof Error ? err.message : String(err), "SFTP");
}
},
[downloadToTemp, getActivePane, updateExternalUpload],
);
// Create upload callbacks that translate to TransferTask updates
@@ -402,99 +454,83 @@ export const useSftpExternalOperations = (
targetPath: string,
targetHostId?: string,
targetConnectionKey?: string,
): UploadCallbacks => {
return {
onScanningStart: (taskId: string) => {
if (addExternalUpload) {
const scanningTask: TransferTask = {
id: taskId,
fileName: "Scanning files...",
sourcePath: "local",
targetPath,
sourceConnectionId: "external",
targetConnectionId: connectionId,
targetHostId,
targetConnectionKey,
direction: "upload",
status: "pending" as TransferStatus,
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: true,
progressMode: "bytes",
};
addExternalUpload(scanningTask);
}
},
onScanningEnd: (taskId: string) => {
if (dismissExternalUpload) {
dismissExternalUpload(taskId);
}
},
onTaskCreated: (task: UploadTaskInfo) => {
if (addExternalUpload) {
const transferTask: TransferTask = {
id: task.id,
fileName: task.displayName,
sourcePath: "local",
targetPath: joinPath(targetPath, task.fileName),
sourceConnectionId: "external",
targetConnectionId: connectionId,
targetHostId,
targetConnectionKey,
direction: "upload",
status: "transferring" as TransferStatus,
totalBytes: task.totalBytes,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: task.isDirectory,
progressMode: task.progressMode ?? "bytes",
parentTaskId: task.parentTaskId,
};
addExternalUpload(transferTask);
}
},
onTaskProgress: (taskId: string, progress) => {
if (updateExternalUpload) {
updateExternalUpload(taskId, {
transferredBytes: progress.transferred,
speed: progress.speed,
});
}
},
onTaskCompleted: (taskId: string, totalBytes: number) => {
if (updateExternalUpload) {
updateExternalUpload(taskId, {
status: "completed" as TransferStatus,
endTime: Date.now(),
transferredBytes: totalBytes,
speed: 0,
});
}
},
onTaskFailed: (taskId: string, error: string) => {
if (updateExternalUpload) {
updateExternalUpload(taskId, {
status: "failed" as TransferStatus,
endTime: Date.now(),
error,
speed: 0,
});
}
},
onTaskCancelled: (taskId: string) => {
if (updateExternalUpload) {
updateExternalUpload(taskId, {
status: "cancelled" as TransferStatus,
endTime: Date.now(),
speed: 0,
});
}
},
): UploadCallbacks => createUploadTaskCallbacks({
connectionId,
targetPath,
targetHostId,
targetConnectionKey,
addExternalUpload,
updateExternalUpload,
dismissExternalUpload,
}), [addExternalUpload, updateExternalUpload, dismissExternalUpload]);
const resolveUploadConflict = useCallback((conflictId: string, action: FileConflictAction, applyToAll = false) => {
const conflict = uploadConflicts.find((item) => item.transferId === conflictId);
setUploadConflicts((prev) => prev.filter((item) => item.transferId !== conflictId));
const resolver = uploadConflictResolversRef.current.get(conflictId);
if (!resolver) return;
uploadConflictResolversRef.current.delete(conflictId);
if (conflict && applyToAll) {
resolver.setDefault(action);
}
resolver.resolve(action);
}, [uploadConflicts]);
const cancelPendingUploadConflicts = useCallback(() => {
const resolvers = Array.from(uploadConflictResolversRef.current.values());
if (resolvers.length === 0) return;
uploadConflictResolversRef.current.clear();
setUploadConflicts([]);
for (const resolver of resolvers) {
resolver.resolve("stop");
}
}, []);
const createUploadConflictResolver = useCallback(() => {
const conflictDefaults = new Map<string, FileConflictAction>();
return async (conflict: {
fileName: string;
targetPath: string;
isDirectory: boolean;
existingType?: 'file' | 'directory' | 'symlink';
existingSize: number;
newSize: number;
existingModified: number;
newModified: number;
applyToAllCount: number;
}): Promise<FileConflictAction> => {
const conflictType = conflict.isDirectory ? "directory" : "file";
const defaultAction = conflictDefaults.get(conflictType);
if (defaultAction) return defaultAction;
const conflictId = `upload-conflict-${crypto.randomUUID()}`;
const fileConflict: FileConflict = {
transferId: conflictId,
fileName: conflict.fileName,
sourcePath: "local",
targetPath: conflict.targetPath,
isDirectory: conflict.isDirectory,
existingType: conflict.existingType,
applyToAllCount: conflict.applyToAllCount,
existingSize: conflict.existingSize,
newSize: conflict.newSize,
existingModified: conflict.existingModified,
newModified: conflict.newModified,
};
setUploadConflicts((prev) => [...prev, fileConflict]);
return new Promise<FileConflictAction>((resolve) => {
uploadConflictResolversRef.current.set(conflictId, {
resolve,
setDefault: (action) => {
conflictDefaults.set(conflictType, action);
},
});
});
};
}, [addExternalUpload, updateExternalUpload, dismissExternalUpload]);
}, []);
// Create upload bridge that wraps netcattyBridge
const createUploadBridge = useMemo((): UploadBridge => {
@@ -502,12 +538,25 @@ export const useSftpExternalOperations = (
return {
writeLocalFile: bridge?.writeLocalFile,
mkdirLocal: bridge?.mkdirLocal,
statLocal: bridge?.statLocal,
deleteLocalFile: bridge?.deleteLocalFile,
mkdirSftp: async (sftpId: string, path: string) => {
const b = netcattyBridge.get();
if (b?.mkdirSftp) {
await b.mkdirSftp(sftpId, path);
}
},
statSftp: async (sftpId: string, path: string) => {
const b = netcattyBridge.get();
if (!b?.statSftp) return null;
return b.statSftp(sftpId, path);
},
deleteSftp: async (sftpId: string, path: string) => {
const b = netcattyBridge.get();
if (b?.deleteSftp) {
await b.deleteSftp(sftpId, path);
}
},
writeSftpBinary: bridge?.writeSftpBinary,
// Wrap writeSftpBinaryWithProgress to adapt UploadBridge interface to NetcattyBridge interface
// UploadBridge: (sftpId, path, data, taskId, onProgress, onComplete, onError)
@@ -596,6 +645,7 @@ export const useSftpExternalOperations = (
joinPath,
callbacks,
useCompressedUpload,
resolveConflict: createUploadConflictResolver(),
},
controller
);
@@ -624,6 +674,217 @@ export const useSftpExternalOperations = (
sftpSessionsRef,
createUploadCallbacks,
createUploadBridge,
createUploadConflictResolver,
useCompressedUpload,
],
);
// Upload from a FileList. This keeps the original File objects from the file
// picker so Electron can resolve local file paths for stream uploads.
const uploadExternalFileList = useCallback(
async (
side: "left" | "right",
fileList: FileList | File[],
targetPath?: string,
): Promise<UploadResult[]> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No active connection");
}
const bridge = netcattyBridge.get();
if (!bridge) {
throw new Error("Bridge not available");
}
const sftpId = pane.connection.isLocal
? null
: sftpSessionsRef.current.get(pane.connection.id) || null;
if (!pane.connection.isLocal && !sftpId) {
throw new Error("SFTP session not found");
}
const uploadPaneId = pane.id;
const uploadTargetPath = targetPath || pane.connection.currentPath;
const controller = new UploadController();
uploadControllerRef.current = controller;
const callbacks = createUploadCallbacks(
pane.connection.id,
uploadTargetPath,
pane.connection.isLocal ? undefined : pane.connection.hostId,
pane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(pane.connection.id),
);
try {
const results = await uploadFromFileList(
fileList,
{
targetPath: uploadTargetPath,
sftpId,
isLocal: pane.connection.isLocal,
bridge: createUploadBridge,
joinPath,
callbacks,
useCompressedUpload,
resolveConflict: createUploadConflictResolver(),
},
controller,
);
if (clearDirCacheEntry && targetPath) {
clearDirCacheEntry(pane.connection.id, uploadTargetPath);
}
if (uploadTargetPath === pane.connection.currentPath) {
await refresh(side, { tabId: uploadPaneId });
}
return results;
} catch (error) {
logger.error("[SFTP] File picker upload failed:", error);
throw error;
} finally {
uploadControllerRef.current = null;
}
},
[
clearDirCacheEntry,
connectionCacheKeyMapRef,
getActivePane,
refresh,
sftpSessionsRef,
createUploadCallbacks,
createUploadBridge,
createUploadConflictResolver,
useCompressedUpload,
],
);
const uploadExternalFolderPath = useCallback(
async (
side: "left" | "right",
folderPath: string,
targetPath?: string,
): Promise<UploadResult[]> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No active connection");
}
const bridge = netcattyBridge.get();
if (!bridge) {
throw new Error("Bridge not available");
}
if (!bridge.listLocalTree) {
throw new Error("Folder upload not supported");
}
const sftpId = pane.connection.isLocal
? null
: sftpSessionsRef.current.get(pane.connection.id) || null;
if (!pane.connection.isLocal && !sftpId) {
throw new Error("SFTP session not found");
}
const uploadPaneId = pane.id;
const uploadTargetPath = targetPath || pane.connection.currentPath;
const controller = new UploadController();
uploadControllerRef.current = controller;
const callbacks = createUploadCallbacks(
pane.connection.id,
uploadTargetPath,
pane.connection.isLocal ? undefined : pane.connection.hostId,
pane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(pane.connection.id),
);
const scanningTask = startUploadScanningTask(callbacks);
try {
const localEntries = await bridge.listLocalTree(folderPath);
if (controller.isCancelled()) {
scanningTask.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
scanningTask.complete();
const entries: DropEntry[] = localEntries.map((entry) => {
if (entry.type === "directory") {
return {
file: null,
relativePath: entry.relativePath,
isDirectory: true,
};
}
const file = {
name: entry.relativePath.split("/").pop() || entry.relativePath,
size: entry.size,
lastModified: entry.lastModified,
type: "",
path: entry.localPath,
arrayBuffer: async () => {
const currentBridge = netcattyBridge.get();
if (!currentBridge?.readLocalFile) {
throw new Error("Local file reading not supported");
}
return currentBridge.readLocalFile(entry.localPath);
},
} as File & { path?: string };
return {
file,
relativePath: entry.relativePath,
isDirectory: false,
};
});
const results = await uploadEntriesDirect(
entries,
{
targetPath: uploadTargetPath,
sftpId,
isLocal: pane.connection.isLocal,
bridge: createUploadBridge,
joinPath,
callbacks,
useCompressedUpload,
resolveConflict: createUploadConflictResolver(),
},
controller,
);
if (clearDirCacheEntry) {
clearDirCacheEntry(pane.connection.id, uploadTargetPath);
}
if (uploadTargetPath === pane.connection.currentPath) {
await refresh(side, { tabId: uploadPaneId });
}
return results;
} catch (error) {
if (controller.isCancelled()) {
scanningTask.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
if (scanningTask.isOpen()) {
scanningTask.fail(error);
}
logger.error("[SFTP] Folder picker upload failed:", error);
throw error;
} finally {
uploadControllerRef.current = null;
}
},
[
clearDirCacheEntry,
connectionCacheKeyMapRef,
createUploadCallbacks,
createUploadBridge,
createUploadConflictResolver,
getActivePane,
refresh,
sftpSessionsRef,
useCompressedUpload,
],
);
@@ -680,6 +941,7 @@ export const useSftpExternalOperations = (
joinPath,
callbacks,
useCompressedUpload,
resolveConflict: createUploadConflictResolver(),
},
controller,
);
@@ -707,6 +969,7 @@ export const useSftpExternalOperations = (
connectionCacheKeyMapRef,
createUploadCallbacks,
createUploadBridge,
createUploadConflictResolver,
getActivePane,
refresh,
sftpSessionsRef,
@@ -716,11 +979,14 @@ export const useSftpExternalOperations = (
const cancelExternalUpload = useCallback(async () => {
const controller = uploadControllerRef.current;
let cancelPromise: Promise<void> | undefined;
if (controller) {
logger.info("[SFTP] Cancelling external upload");
await controller.cancel();
cancelPromise = controller.cancel();
}
}, []);
cancelPendingUploadConflicts();
await cancelPromise;
}, [cancelPendingUploadConflicts]);
const selectApplication = useCallback(
async (): Promise<{ path: string; name: string } | null> => {
@@ -739,10 +1005,15 @@ export const useSftpExternalOperations = (
writeTextFile,
writeTextFileByConnection,
downloadToTempAndOpen,
openWithSystemDefault,
uploadExternalFiles,
uploadExternalFileList,
uploadExternalFolderPath,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
activeFileWatchCountRef,
uploadConflicts,
resolveUploadConflict,
};
};

View File

@@ -0,0 +1,65 @@
import type React from "react";
import type { FileConflict, FileConflictAction, TransferTask, SftpFilenameEncoding } from "../../../domain/models";
import type { UploadResult } from "../../../lib/uploadService";
import type { DropEntry } from "../../../lib/sftpFileUtils";
import type { SftpPane } from "./types";
export interface UseSftpExternalOperationsParams {
getActivePane: (side: "left" | "right") => SftpPane | null;
getPaneByConnectionId: (connectionId: string) => SftpPane | null;
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
clearDirCacheEntry?: (connectionId: string, path: string) => void;
useCompressedUpload?: boolean;
addExternalUpload?: (task: TransferTask) => void;
updateExternalUpload?: (taskId: string, updates: Partial<TransferTask>) => void;
isTransferCancelled?: (taskId: string) => boolean;
dismissExternalUpload?: (taskId: string) => void;
}
export interface SftpExternalOperationsResult {
readTextFile: (side: "left" | "right", filePath: string) => Promise<string>;
readBinaryFile: (side: "left" | "right", filePath: string) => Promise<ArrayBuffer>;
writeTextFile: (side: "left" | "right", filePath: string, content: string) => Promise<void>;
writeTextFileByConnection: (
connectionId: string,
expectedHostId: string,
filePath: string,
content: string,
filenameEncoding?: SftpFilenameEncoding,
) => Promise<void>;
downloadToTempAndOpen: (
side: "left" | "right",
remotePath: string,
fileName: string,
appPath: string,
options?: { enableWatch?: boolean }
) => Promise<{ localTempPath: string; watchId?: string }>;
openWithSystemDefault: (side: "left" | "right", remotePath: string, fileName: string, options?: { enableWatch?: boolean }) => Promise<void>;
activeFileWatchCountRef: React.MutableRefObject<number>;
uploadExternalFiles: (
side: "left" | "right",
dataTransfer: DataTransfer,
targetPath?: string
) => Promise<UploadResult[]>;
uploadExternalFileList: (
side: "left" | "right",
fileList: FileList | File[],
targetPath?: string
) => Promise<UploadResult[]>;
uploadExternalFolderPath: (
side: "left" | "right",
folderPath: string,
targetPath?: string
) => Promise<UploadResult[]>;
uploadExternalEntries: (
side: "left" | "right",
entries: DropEntry[],
options?: { targetPath?: string }
) => Promise<UploadResult[]>;
cancelExternalUpload: () => Promise<void>;
selectApplication: () => Promise<{ path: string; name: string } | null>;
uploadConflicts: FileConflict[];
resolveUploadConflict: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => void;
}

View File

@@ -0,0 +1,212 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildSftpHostCredentials } from "./useSftpHostCredentials.ts";
import type { Host, SSHKey } from "../../../domain/models.ts";
const host = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
...overrides,
});
test("buildSftpHostCredentials rejects missing jump hosts", () => {
assert.throws(
() => buildSftpHostCredentials({
host: host({ hostChain: { hostIds: ["missing-jump"] } }),
hosts: [],
keys: [],
identities: [],
}),
/Jump host "missing-jump" is missing/,
);
});
test("buildSftpHostCredentials rejects missing saved proxy profiles", () => {
assert.throws(
() => buildSftpHostCredentials({
host: host({ proxyProfileId: "missing-proxy" }),
hosts: [],
keys: [],
identities: [],
}),
/Saved proxy for host "Host" is missing/,
);
});
test("buildSftpHostCredentials rejects missing saved proxy profiles on jump hosts", () => {
const jumpHost = host({ id: "jump-1", label: "Jump", proxyProfileId: "missing-proxy" });
assert.throws(
() => buildSftpHostCredentials({
host: host({ hostChain: { hostIds: ["jump-1"] } }),
hosts: [jumpHost],
keys: [],
identities: [],
}),
/Saved proxy for jump host "Jump" is missing/,
);
});
test("buildSftpHostCredentials forwards custom ProxyCommand settings", () => {
const credentials = buildSftpHostCredentials({
host: host({
proxyConfig: {
type: "command",
host: "",
port: 0,
command: "cloudflared access ssh --hostname %h",
},
}),
hosts: [],
keys: [],
identities: [],
});
assert.deepEqual(credentials.proxy, {
type: "command",
host: "",
port: 0,
command: "cloudflared access ssh --hostname %h",
username: undefined,
password: undefined,
});
});
test("buildSftpHostCredentials passes reference keys as identity file paths", () => {
const key: SSHKey = {
id: "key-1",
label: "Reference key",
type: "ED25519",
privateKey: "",
source: "reference",
category: "key",
created: 1,
filePath: "/Users/alice/.ssh/id_ed25519",
passphrase: "saved-passphrase",
};
const credentials = buildSftpHostCredentials({
host: host({ authMethod: "key", identityFileId: "key-1" }),
hosts: [],
keys: [key],
identities: [],
});
assert.equal(credentials.privateKey, undefined);
assert.deepEqual(credentials.identityFilePaths, ["/Users/alice/.ssh/id_ed25519"]);
assert.equal(credentials.passphrase, "saved-passphrase");
});
test("buildSftpHostCredentials passes jump host reference keys as identity file paths", () => {
const key: SSHKey = {
id: "jump-key",
label: "Jump key",
type: "ED25519",
privateKey: "",
source: "reference",
category: "key",
created: 1,
filePath: "/Users/alice/.ssh/jump_ed25519",
};
const jumpHost = host({
id: "jump-1",
label: "Jump",
authMethod: "key",
identityFileId: "jump-key",
});
const credentials = buildSftpHostCredentials({
host: host({ hostChain: { hostIds: ["jump-1"] } }),
hosts: [jumpHost],
keys: [key],
identities: [],
});
assert.equal(credentials.jumpHosts?.[0]?.privateKey, undefined);
assert.deepEqual(credentials.jumpHosts?.[0]?.identityFilePaths, ["/Users/alice/.ssh/jump_ed25519"]);
});
test("buildSftpHostCredentials rejects undecryptable saved password credentials", () => {
assert.throws(
() => buildSftpHostCredentials({
host: host({
authMethod: "password",
password: "enc:v1:djEwAAAA",
}),
hosts: [],
keys: [],
identities: [],
}),
/Saved credentials cannot be decrypted/,
);
});
test("buildSftpHostCredentials omits local key file paths for password auth", () => {
const credentials = buildSftpHostCredentials({
host: host({
authMethod: "password",
password: "secret",
identityFilePaths: ["/Users/alice/.ssh/id_ed25519"],
}),
hosts: [],
keys: [],
identities: [],
});
assert.equal(credentials.password, "secret");
assert.equal(credentials.privateKey, undefined);
assert.equal(credentials.identityFilePaths, undefined);
});
test("buildSftpHostCredentials rejects undecryptable saved key material without fallback credentials", () => {
const key: SSHKey = {
id: "key-1",
label: "Imported key",
type: "ED25519",
privateKey: "enc:v1:djEwAAAA",
source: "imported",
category: "key",
created: 1,
};
assert.throws(
() => buildSftpHostCredentials({
host: host({ authMethod: "key", identityFileId: "key-1" }),
hosts: [],
keys: [key],
identities: [],
}),
/Saved credentials cannot be decrypted/,
);
});
test("buildSftpHostCredentials does not use stale local key paths when a selected key is unavailable", () => {
const key: SSHKey = {
id: "key-1",
label: "Imported key",
type: "ED25519",
privateKey: "enc:v1:djEwAAAA",
source: "imported",
category: "key",
created: 1,
};
assert.throws(
() => buildSftpHostCredentials({
host: host({
authMethod: "key",
identityFileId: "key-1",
identityFilePaths: ["/Users/alice/.ssh/stale_ed25519"],
}),
hosts: [],
keys: [key],
identities: [],
}),
/Saved credentials cannot be decrypted/,
);
});

View File

@@ -1,102 +1,187 @@
import { useCallback } from "react";
import type { Host, Identity, SSHKey } from "../../../domain/models";
import type { Host, Identity, SSHKey, TerminalSettings } from "../../../domain/models";
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
import { resolveHostAuth } from "../../../domain/sshAuth";
import { resolveBridgeKeyAuth, resolveHostAuth } from "../../../domain/sshAuth";
import { resolveHostKeepalive } from "../../../domain/host";
import { hasUsableProxyConfig } from "../../../domain/proxyProfiles";
// Fallback used when no global TerminalSettings are wired through (older
// call sites or tests). Matches DEFAULT_TERMINAL_SETTINGS so behavior is
// identical whether or not the caller passes settings.
const FALLBACK_KEEPALIVE = { keepaliveInterval: 30, keepaliveCountMax: 10 };
interface UseSftpHostCredentialsParams {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>;
}
export const buildSftpHostCredentials = ({
host,
hosts,
keys,
identities,
terminalSettings,
}: UseSftpHostCredentialsParams & { host: Host }): NetcattySSHOptions => {
const globalKeepalive = terminalSettings ?? FALLBACK_KEEPALIVE;
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.`);
}
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),
}
: undefined;
let jumpHosts: NetcattyJumpHost[] | undefined;
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
jumpHosts = host.hostChain.hostIds.map((hostId) => {
const jumpHost = hosts.find((candidate) => candidate.id === hostId);
if (!jumpHost) {
throw new Error(`Jump host "${hostId}" is missing. Open host settings and repair the jump host chain.`);
}
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.`);
}
return jumpHost;
}).map((jumpHost, index) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
keys,
identities,
});
const jumpKey = jumpAuth.key;
const jumpPassword = sanitizeCredentialValue(jumpAuth.password);
const jumpKeyAuth = resolveBridgeKeyAuth({
key: jumpKey,
fallbackIdentityFilePaths: jumpAuth.authMethod === "password" || jumpAuth.keyId
? undefined
: jumpHost.identityFilePaths,
passphrase: jumpAuth.passphrase,
});
const hasJumpKeyMaterial = Boolean(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)
) {
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.`);
}
const hasUnreadableJumpCredential =
isEncryptedCredentialPlaceholder(jumpAuth.password) ||
isEncryptedCredentialPlaceholder(jumpKey?.privateKey) ||
isEncryptedCredentialPlaceholder(jumpAuth.passphrase);
if (
(jumpAuth.authMethod === "password" && isEncryptedCredentialPlaceholder(jumpAuth.password) && !jumpPassword) ||
(jumpAuth.authMethod !== "password" && hasUnreadableJumpCredential && !jumpPassword && !hasJumpKeyMaterial)
) {
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, globalKeepalive);
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
username: jumpAuth.username || "root",
password: jumpPassword,
privateKey: jumpKeyAuth.privateKey,
certificate: jumpKey?.certificate,
passphrase: jumpKeyAuth.passphrase,
publicKey: jumpKey?.publicKey,
keyId: jumpAuth.keyId,
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),
}
: undefined,
identityFilePaths: jumpKeyAuth.identityFilePaths,
keepaliveInterval: hopKeepalive.interval,
keepaliveCountMax: hopKeepalive.countMax,
legacyAlgorithms: jumpHost.legacyAlgorithms,
skipEcdsaHostKey: jumpHost.skipEcdsaHostKey,
algorithmOverrides: jumpHost.algorithms,
};
});
}
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts?.[0]?.proxy;
if (usesTargetProxyForFirstHop && host.proxyConfig?.username && isEncryptedCredentialPlaceholder(host.proxyConfig.password) && !proxyConfig?.password) {
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
? undefined
: host.identityFilePaths,
passphrase: resolved.passphrase,
});
const password = sanitizeCredentialValue(resolved.password);
const hasKeyMaterial = Boolean(keyAuth.privateKey || keyAuth.identityFilePaths?.length);
const hasUnreadableCredential =
isEncryptedCredentialPlaceholder(resolved.password) ||
isEncryptedCredentialPlaceholder(key?.privateKey) ||
isEncryptedCredentialPlaceholder(resolved.passphrase);
if (
(resolved.authMethod === "password" && isEncryptedCredentialPlaceholder(resolved.password) && !password) ||
(resolved.authMethod !== "password" && hasUnreadableCredential && !password && !hasKeyMaterial)
) {
throw new Error("Saved credentials cannot be decrypted on this device. Open host settings and re-enter them.");
}
const targetKeepalive = resolveHostKeepalive(host, globalKeepalive);
return {
hostname: host.hostname,
username: resolved.username,
port: host.port || 22,
password,
privateKey: keyAuth.privateKey,
certificate: key?.certificate,
passphrase: keyAuth.passphrase,
publicKey: key?.publicKey,
keyId: resolved.keyId,
keySource: key?.source,
proxy: proxyConfig,
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
sudo: host.sftpSudo,
identityFilePaths: keyAuth.identityFilePaths,
keepaliveInterval: targetKeepalive.interval,
keepaliveCountMax: targetKeepalive.countMax,
// Algorithm settings — must reach the SFTP bridge or hosts that need
// legacy mode / the ECDSA skip / advanced overrides would still hit
// the original negotiation failure when opening their SFTP pane,
// even though the terminal session works.
legacyAlgorithms: host.legacyAlgorithms,
skipEcdsaHostKey: host.skipEcdsaHostKey,
algorithmOverrides: host.algorithms,
};
};
export const useSftpHostCredentials = ({
hosts,
keys,
identities,
terminalSettings,
}: UseSftpHostCredentialsParams) =>
useCallback(
(host: Host): NetcattySSHOptions => {
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,
username: host.proxyConfig.username,
password: sanitizeCredentialValue(host.proxyConfig.password),
}
: undefined;
let jumpHosts: NetcattyJumpHost[] | undefined;
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
jumpHosts = host.hostChain.hostIds
.map((hostId) => hosts.find((h) => h.id === hostId))
.filter((h): h is Host => !!h)
.map((jumpHost, index) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
keys,
identities,
});
const jumpKey = jumpAuth.key;
const hasConfiguredJumpProxyEndpoint =
index === 0 &&
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
if (
hasConfiguredJumpProxyEndpoint &&
jumpHost.proxyConfig?.username &&
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig.password) &&
!sanitizeCredentialValue(jumpHost.proxyConfig.password)
) {
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.`);
}
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
username: jumpAuth.username || "root",
password: jumpAuth.password,
privateKey: jumpKey?.privateKey,
certificate: jumpKey?.certificate,
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
publicKey: jumpKey?.publicKey,
keyId: jumpAuth.keyId,
keySource: jumpKey?.source,
label: jumpHost.label,
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
? {
type: jumpHost.proxyConfig.type,
host: jumpHost.proxyConfig.host,
port: jumpHost.proxyConfig.port,
username: jumpHost.proxyConfig.username,
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
}
: undefined,
identityFilePaths: jumpHost.identityFilePaths,
};
});
}
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts?.[0]?.proxy;
if (usesTargetProxyForFirstHop && host.proxyConfig?.username && isEncryptedCredentialPlaceholder(host.proxyConfig.password) && !proxyConfig?.password) {
throw new Error("Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.");
}
return {
hostname: host.hostname,
username: resolved.username,
port: host.port || 22,
password: resolved.password,
privateKey: key?.privateKey,
certificate: key?.certificate,
passphrase: resolved.passphrase || key?.passphrase,
publicKey: key?.publicKey,
keyId: resolved.keyId,
keySource: key?.source,
proxy: proxyConfig,
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
sudo: host.sftpSudo,
identityFilePaths: host.identityFilePaths,
};
},
[hosts, identities, keys],
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities, terminalSettings }),
[hosts, identities, keys, terminalSettings],
);

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useMemo, useRef, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import {
FileConflict,
SftpFileEntry,
FileConflictAction,
SftpFilenameEncoding,
TransferDirection,
TransferStatus,
@@ -10,66 +10,11 @@ import {
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { SftpPane } from "./types";
import { useSftpDirectoryTransferOps } from "./transferDirectoryOps";
import { useSftpTransferConflictOps } from "./transferConflictOps";
import { useSftpTransferTaskOps } from "./transferTaskOps";
import type { TransferResult, UseSftpTransfersParams, UseSftpTransfersResult } from "./useSftpTransfers.types";
import { getParentPath, joinPath } from "./utils";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from "../../../infrastructure/config/storageKeys";
interface UseSftpTransfersParams {
getActivePane: (side: "left" | "right") => SftpPane | null;
getPaneByConnectionId: (connectionId: string) => SftpPane | null;
getTabByConnectionId: (connectionId: string) => { side: "left" | "right"; tabId: string; pane: SftpPane } | null;
updateTab: (side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => void;
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
clearCacheForConnection: (connectionId: string) => void;
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
handleSessionError: (side: "left" | "right", error: Error) => void;
}
interface UseSftpTransfersResult {
transfers: TransferTask[];
conflicts: FileConflict[];
activeTransfersCount: number;
startTransfer: (
sourceFiles: { name: string; isDirectory: boolean }[],
sourceSide: "left" | "right",
targetSide: "left" | "right",
options?: {
sourcePane?: SftpPane;
sourcePath?: string;
sourceConnectionId?: string;
targetPath?: string;
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
},
) => Promise<TransferResult[]>;
downloadToLocal: (params: {
fileName: string;
sourcePath: string;
targetPath: string;
sftpId: string;
connectionId: string;
sourceEncoding?: SftpFilenameEncoding;
isDirectory: boolean;
totalBytes?: number;
}) => Promise<TransferStatus>;
addExternalUpload: (task: TransferTask) => void;
updateExternalUpload: (taskId: string, updates: Partial<TransferTask>) => void;
cancelTransfer: (transferId: string) => Promise<void>;
isTransferCancelled: (transferId: string) => boolean;
retryTransfer: (transferId: string) => Promise<void>;
clearCompletedTransfers: () => void;
dismissTransfer: (transferId: string) => void;
resolveConflict: (conflictId: string, action: "replace" | "skip" | "duplicate") => Promise<void>;
}
interface TransferResult {
id: string;
fileName: string;
originalFileName?: string;
status: TransferStatus;
}
export const useSftpTransfers = ({
getActivePane,
@@ -96,6 +41,7 @@ export const useSftpTransfers = ({
const conflictsRef = useRef(conflicts);
conflictsRef.current = conflicts;
const completionHandlersRef = useRef<Map<string, (result: TransferResult) => void | Promise<void>>>(new Map());
const conflictDefaultsRef = useRef<Map<string, FileConflictAction>>(new Map());
const clearCancelledTask = useCallback((taskId: string) => {
cancelledTasksRef.current.delete(taskId);
@@ -122,434 +68,30 @@ export const useSftpTransfers = ({
[],
);
const getEntrySize = useCallback((entry: SftpFileEntry): number => {
if (typeof entry.size === "string") {
const parsed = parseInt(entry.size, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
return typeof entry.size === "number" && entry.size > 0 ? entry.size : 0;
}, []);
const MAX_SYMLINK_DEPTH = 32;
const estimateDirectoryBytes = useCallback(
async (
sourcePath: string,
sourceSftpId: string | null,
sourceIsLocal: boolean,
sourceEncoding: SftpFilenameEncoding,
rootTaskId: string,
symlinkDepth = 0,
followSymlinks = false,
): Promise<number> => {
const estT0 = performance.now();
if (cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
}
const files = sourceIsLocal
? await listLocalFiles(sourcePath)
: sourceSftpId
? await listRemoteFiles(sourceSftpId, sourcePath, sourceEncoding)
: null;
if (!files) {
throw new Error("No source connection");
}
let totalBytes = 0;
const subdirs: { entry: SftpFileEntry; nextDepth: number }[] = [];
for (const file of files) {
if (file.name === ".." || file.name === ".") continue;
if (file.type === "directory") {
subdirs.push({ entry: file, nextDepth: symlinkDepth });
} else if (followSymlinks && file.type === "symlink" && file.linkTarget === "directory") {
if (symlinkDepth < MAX_SYMLINK_DEPTH) {
subdirs.push({ entry: file, nextDepth: symlinkDepth + 1 });
}
// Skip at max depth — consistent with transferDirectory
} else {
totalBytes += getEntrySize(file);
}
}
if (subdirs.length > 0) {
if (cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
}
const subResults = await Promise.all(
subdirs.map(({ entry: subdir, nextDepth }) =>
estimateDirectoryBytes(
joinPath(sourcePath, subdir.name),
sourceSftpId,
sourceIsLocal,
sourceEncoding,
rootTaskId,
nextDepth,
followSymlinks,
),
),
);
totalBytes += subResults.reduce((sum, size) => sum + size, 0);
}
logger.debug(`[SFTP:perf] estimateDirectoryBytes ${sourcePath} = ${totalBytes}${(performance.now() - estT0).toFixed(0)}ms`);
return totalBytes;
},
[getEntrySize, listLocalFiles, listRemoteFiles],
const conflictDefaultKey = useCallback(
(batchId: string | undefined, isDirectory: boolean) =>
`${batchId ?? "global"}:${isDirectory ? "directory" : "file"}`,
[],
);
const transferFile = async (
task: TransferTask,
sourceSftpId: string | null,
targetSftpId: string | null,
sourceIsLocal: boolean,
targetIsLocal: boolean,
sourceEncoding: SftpFilenameEncoding,
targetEncoding: SftpFilenameEncoding,
rootTaskId: string, // The original top-level task ID for cancellation checking
sameHost?: boolean,
onStreamProgress?: (transferred: number, total: number, speed: number) => void,
): Promise<void> => {
// Check if task or root task was cancelled before starting
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
}
const { completeCancelledTask, cancelBackendTransfers, markBatchStopped } = useSftpTransferTaskOps({
cancelledTasksRef,
activeChildIdsRef,
transfersRef,
completionHandlersRef,
setConflicts,
setTransfers,
});
return new Promise((resolve, reject) => {
const options = {
transferId: task.id,
sourcePath: task.sourcePath,
targetPath: task.targetPath,
sourceType: sourceIsLocal ? ("local" as const) : ("sftp" as const),
targetType: targetIsLocal ? ("local" as const) : ("sftp" as const),
sourceSftpId: sourceSftpId || undefined,
targetSftpId: targetSftpId || undefined,
totalBytes: task.totalBytes || undefined,
sourceEncoding: sourceIsLocal ? undefined : sourceEncoding,
targetEncoding: targetIsLocal ? undefined : targetEncoding,
sameHost: sameHost || undefined,
};
const { statTargetPath, getDuplicateTarget, deleteTargetPath } = useSftpTransferConflictOps();
let lastProgressUpdate = 0;
const onProgress = (
transferred: number,
total: number,
speed: number,
) => {
// Bubble up streaming progress to parent (for directory transfers)
onStreamProgress?.(transferred, total, speed);
// Throttle state updates to at most once per 100ms
const now = Date.now();
if (now - lastProgressUpdate < 100 && transferred < total) return;
lastProgressUpdate = now;
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== task.id) return t;
if (t.status === "cancelled") return t;
const normalizedTotal = total > 0 ? total : t.totalBytes;
// Clamp to [previous, total] — the backend normalizes progress
// but we guard against any non-monotonic edge cases.
const normalizedTransferred = Math.max(
t.transferredBytes,
Math.min(transferred, normalizedTotal > 0 ? normalizedTotal : transferred),
);
return {
...t,
transferredBytes: normalizedTransferred,
totalBytes: normalizedTotal,
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
};
}),
);
};
const onComplete = () => {
resolve();
};
const onError = (error: string) => {
reject(new Error(error));
};
netcattyBridge.require().startStreamTransfer!(
options,
onProgress,
onComplete,
onError,
).catch(reject);
});
};
const getTransferConcurrency = () => {
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
};
/** Recursively count all files under a directory (for progress display). */
const countDirectoryFiles = async (
sourcePath: string,
sourceSftpId: string | null,
sourceIsLocal: boolean,
sourceEncoding: SftpFilenameEncoding,
rootTaskId: string,
symlinkDepth = 0,
followSymlinks = false,
): Promise<number> => {
if (cancelledTasksRef.current.has(rootTaskId)) return 0;
const files = sourceIsLocal
? await listLocalFiles(sourcePath)
: sourceSftpId
? await listRemoteFiles(sourceSftpId, sourcePath, sourceEncoding)
: null;
if (!files) return 0;
let count = 0;
const subdirPromises: Promise<number>[] = [];
for (const file of files) {
if (file.name === ".." || file.name === ".") continue;
if (file.type === "directory") {
subdirPromises.push(
countDirectoryFiles(joinPath(sourcePath, file.name), sourceSftpId, sourceIsLocal, sourceEncoding, rootTaskId, symlinkDepth, followSymlinks),
);
} else if (followSymlinks && file.type === "symlink" && file.linkTarget === "directory") {
// Only recurse if within depth limit; skip entirely at max depth
// (consistent with transferDirectory which also skips these)
if (symlinkDepth < MAX_SYMLINK_DEPTH) {
subdirPromises.push(
countDirectoryFiles(joinPath(sourcePath, file.name), sourceSftpId, sourceIsLocal, sourceEncoding, rootTaskId, symlinkDepth + 1, followSymlinks),
);
}
} else {
count++;
}
}
if (subdirPromises.length > 0) {
const subCounts = await Promise.all(subdirPromises);
count += subCounts.reduce((a, b) => a + b, 0);
}
return count;
};
/** Returns number of failed child file transfers */
const transferDirectory = async (
task: TransferTask,
sourceSftpId: string | null,
targetSftpId: string | null,
sourceIsLocal: boolean,
targetIsLocal: boolean,
sourceEncoding: SftpFilenameEncoding,
targetEncoding: SftpFilenameEncoding,
rootTaskId: string, // The original top-level task ID for cancellation checking
sameHost?: boolean,
symlinkDepth = 0,
followSymlinks = false, // Only true for downloadToLocal — uploads/copies treat symlinks as files
) => {
// Check if task or root task was cancelled before starting
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
}
let totalErrors = 0;
if (targetIsLocal) {
try {
await netcattyBridge.get()?.mkdirLocal?.(task.targetPath);
} catch (mkdirErr: unknown) {
const isEEXIST = mkdirErr instanceof Error && mkdirErr.message.includes("EEXIST");
if (!isEEXIST) throw mkdirErr;
// EEXIST: verify the existing path is actually a directory, not a file
const stat = await netcattyBridge.get()?.statLocal?.(task.targetPath);
if (stat && stat.type !== 'directory') {
throw new Error(`Target path exists as a file: ${task.targetPath}`);
}
}
} else if (targetSftpId) {
await netcattyBridge.get()?.mkdirSftp(targetSftpId, task.targetPath, targetEncoding);
}
let files: SftpFileEntry[];
if (sourceIsLocal) {
files = await listLocalFiles(task.sourcePath);
} else if (sourceSftpId) {
files = await listRemoteFiles(sourceSftpId, task.sourcePath, sourceEncoding);
} else {
throw new Error("No source connection");
}
// Filter both "." and ".." — some SFTP servers include "." in readdir
const filtered = files.filter((f) => f.name !== ".." && f.name !== ".");
// Separate directories from files.
// Symlink directories are only followed when followSymlinks is true
// (downloadToLocal). Uploads/copies treat symlinks as regular entries
// to preserve existing behavior and avoid expanding symlinked trees.
const dirs: SftpFileEntry[] = [];
const regularFiles: SftpFileEntry[] = [];
for (const f of filtered) {
if (f.type === "directory") {
dirs.push(f);
} else if (followSymlinks && f.type === "symlink" && f.linkTarget === "directory") {
if (symlinkDepth < MAX_SYMLINK_DEPTH) {
dirs.push(f);
} else {
// Count as an error so the parent task is marked failed
totalErrors++;
logger.warn(`[SFTP] Skipping symlink directory at max depth: ${joinPath(task.sourcePath, f.name)}`);
}
} else {
regularFiles.push(f);
}
}
// Process subdirectories sequentially to avoid unbounded concurrent SFTP
// requests from nested Promise.all + worker pools across the tree.
// File-level concurrency within each directory is still governed by
// getTransferConcurrency().
for (const dir of dirs) {
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
}
const childTask: TransferTask = {
...task,
id: crypto.randomUUID(),
fileName: dir.name,
originalFileName: dir.name,
sourcePath: joinPath(task.sourcePath, dir.name),
targetPath: joinPath(task.targetPath, dir.name),
isDirectory: true,
progressMode: "files",
parentTaskId: task.id,
};
const isSymlink = dir.type === "symlink";
const subdirErrors = await transferDirectory(
childTask,
sourceSftpId,
targetSftpId,
sourceIsLocal,
targetIsLocal,
sourceEncoding,
targetEncoding,
rootTaskId,
sameHost,
isSymlink ? symlinkDepth + 1 : symlinkDepth,
followSymlinks,
);
totalErrors += subdirErrors;
}
// Transfer files in parallel with concurrency limit
if (regularFiles.length > 0) {
let fileIndex = 0;
const errors: Error[] = [];
const worker = async () => {
while (fileIndex < regularFiles.length) {
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
}
const idx = fileIndex++;
const file = regularFiles[idx];
const fileId = crypto.randomUUID();
const fileSize = getEntrySize(file);
// Track child ID outside React state for immediate cancellation visibility
if (!activeChildIdsRef.current.has(rootTaskId)) {
activeChildIdsRef.current.set(rootTaskId, new Set());
}
activeChildIdsRef.current.get(rootTaskId)!.add(fileId);
const childTask: TransferTask = {
...task,
id: fileId,
fileName: file.name,
originalFileName: file.name,
sourcePath: joinPath(task.sourcePath, file.name),
targetPath: joinPath(task.targetPath, file.name),
isDirectory: false,
progressMode: "bytes",
parentTaskId: rootTaskId,
totalBytes: fileSize,
// Inherit retryable from parent — downloadToLocal sets retryable: false
// because "local" targetConnectionId can't be resolved by retryTransfer
retryable: task.retryable,
};
// Register child in transfers array so UI can render it
setTransfers((prev) => [...prev, {
...childTask,
status: "transferring" as TransferStatus,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
}]);
try {
await transferFile(
childTask,
sourceSftpId,
targetSftpId,
sourceIsLocal,
targetIsLocal,
sourceEncoding,
targetEncoding,
rootTaskId,
sameHost,
);
activeChildIdsRef.current.get(rootTaskId)?.delete(fileId);
// Mark child as completed & update parent file count
setTransfers((prev) => {
const updated = prev.map((t) => {
if (t.id === fileId) {
return { ...t, status: "completed" as TransferStatus, endTime: Date.now(), transferredBytes: t.totalBytes };
}
if (t.id === rootTaskId) {
return { ...t, transferredBytes: t.transferredBytes + 1 };
}
return t;
});
return updated;
});
} catch (err) {
activeChildIdsRef.current.get(rootTaskId)?.delete(fileId);
// Mark child as failed
setTransfers((prev) =>
prev.map((t) =>
t.id === fileId
? { ...t, status: "failed" as TransferStatus, error: err instanceof Error ? err.message : String(err) }
: t,
),
);
if (err instanceof Error && err.message === "Transfer cancelled") throw err;
errors.push(err instanceof Error ? err : new Error(String(err)));
}
}
};
const concurrency = getTransferConcurrency();
const workers = Array.from(
{ length: Math.min(concurrency, regularFiles.length) },
() => worker(),
);
await Promise.all(workers);
totalErrors += errors.length;
if (errors.length > 0) {
logger.debug?.("[SFTP] Some files in directory transfer failed", errors);
}
}
return totalErrors;
};
const { estimateDirectoryBytes, transferFile, countDirectoryFiles, transferDirectory } = useSftpDirectoryTransferOps({
cancelledTasksRef,
activeChildIdsRef,
setTransfers,
listLocalFiles,
listRemoteFiles,
});
const processTransfer = async (
task: TransferTask,
@@ -557,6 +99,10 @@ export const useSftpTransfers = ({
targetPane: SftpPane,
targetSide: "left" | "right",
): Promise<TransferStatus> => {
if (cancelledTasksRef.current.has(task.id)) {
return "cancelled";
}
const updateTask = (updates: Partial<TransferTask>) => {
setTransfers((prev) =>
prev.map((t) => (t.id === task.id ? { ...t, ...updates } : t)),
@@ -676,7 +222,7 @@ export const useSftpTransfers = ({
// Run size discovery and conflict check in parallel
const conflictCheckPromise = (async (): Promise<FileConflict | null> => {
if (task.skipConflictCheck || task.isDirectory || !targetPane.connection) return null;
if (task.skipConflictCheck || !targetPane.connection) return null;
const sourceStat: { size: number; mtime: number } | null =
(task.totalBytes > 0 || task.sourceLastModified)
@@ -684,30 +230,26 @@ export const useSftpTransfers = ({
: null;
try {
let existingStat: { size: number; mtime: number } | null = null;
if (targetPane.connection.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(task.targetPath);
if (stat) {
existingStat = { size: stat.size, mtime: stat.lastModified || Date.now() };
}
} else if (targetSftpId) {
const stat = await netcattyBridge.get()?.statSftp?.(
targetSftpId,
task.targetPath,
targetEncoding,
);
if (stat) {
existingStat = { size: stat.size, mtime: stat.lastModified || Date.now() };
}
}
const existingStat = await statTargetPath(targetPane, targetSftpId, task.targetPath, targetEncoding);
if (existingStat) {
return {
transferId: task.id,
batchId: task.batchId,
fileName: task.fileName,
sourcePath: task.sourcePath,
targetPath: task.targetPath,
isDirectory: task.isDirectory,
existingType: existingStat.type,
applyToAllCount: task.batchId
? transfersRef.current.filter((candidate) =>
candidate.batchId === task.batchId &&
candidate.isDirectory === task.isDirectory &&
!candidate.parentTaskId &&
candidate.status !== "completed" &&
candidate.status !== "cancelled",
).length
: 1,
existingSize: existingStat.size,
newSize: sourceStat?.size || task.totalBytes || 0,
existingModified: existingStat.mtime,
@@ -729,6 +271,44 @@ export const useSftpTransfers = ({
const conflict = await conflictCheckPromise;
if (conflict) {
const defaultAction = conflictDefaultsRef.current.get(conflictDefaultKey(task.batchId, task.isDirectory));
if (defaultAction) {
if (defaultAction === "stop") {
await markBatchStopped(task);
return "cancelled";
}
if (defaultAction === "skip") {
cancelledTasksRef.current.add(task.id);
updateTask({ status: "cancelled", endTime: Date.now() });
await completeCancelledTask(task);
return "cancelled";
}
const duplicateTarget = defaultAction === "duplicate"
? await getDuplicateTarget(task, targetPane, targetSftpId, targetEncoding)
: null;
const updatedTask: TransferTask = {
...task,
...(duplicateTarget
? {
fileName: duplicateTarget.fileName,
targetPath: duplicateTarget.targetPath,
}
: null),
skipConflictCheck: true,
replaceExistingTarget: defaultAction === "replace",
};
setTransfers((prev) =>
prev.map((t) =>
t.id === task.id
? { ...updatedTask, status: "pending" as TransferStatus }
: t,
),
);
return processTransfer(updatedTask, sourcePane, targetPane, targetSide);
}
setConflicts((prev) => [...prev, conflict]);
updateTask({
status: "pending",
@@ -741,6 +321,10 @@ export const useSftpTransfers = ({
let dirPartialFailure = false;
if (task.replaceExistingTarget) {
await deleteTargetPath(task, targetPane, targetSftpId, targetEncoding);
}
// Same-host exec-based paths are only safe for UTF-8 compatible encodings.
// "auto" is allowed here — the backend resolves it to the actual encoding
// and skips exec if it resolved to non-UTF-8 (e.g. gb18030).
@@ -816,6 +400,10 @@ export const useSftpTransfers = ({
);
}
if (cancelledTasksRef.current.has(task.id)) {
throw new Error("Transfer cancelled");
}
const finalStatus: TransferStatus = dirPartialFailure ? "failed" : "completed";
setTransfers((prev) => {
return prev.map((t) => {
@@ -940,6 +528,7 @@ export const useSftpTransfers = ({
const sourcePath = options?.sourcePath ?? sourcePane.connection.currentPath;
const targetPath = options?.targetPath ?? targetPane.connection.currentPath;
const sourceConnectionId = options?.sourceConnectionId ?? sourcePane.connection.id;
const batchId = crypto.randomUUID();
const newTasks: TransferTask[] = [];
@@ -965,6 +554,7 @@ export const useSftpTransfers = ({
newTasks.push({
id: crypto.randomUUID(),
batchId,
fileName: file.name,
originalFileName: file.name,
sourcePath: joinPath(sourcePath, file.name),
@@ -1032,37 +622,10 @@ export const useSftpTransfers = ({
setConflicts((prev) => prev.filter((c) => c.transferId !== transferId));
if (netcattyBridge.get()?.cancelTransfer) {
// Cancel parent and all active child streams at the backend.
// Use activeChildIdsRef for immediate visibility (not subject to
// React state batching delays like transfersRef).
const idsToCancel = [transferId];
const trackedChildren = activeChildIdsRef.current.get(transferId);
if (trackedChildren) {
for (const childId of trackedChildren) {
idsToCancel.push(childId);
cancelledTasksRef.current.add(childId);
}
}
// Also check rendered state as fallback for transfers started
// via other paths (e.g. startTransfer/processTransfer)
const currentTransfers = transfersRef.current;
for (const t of currentTransfers) {
if (t.parentTaskId === transferId && (t.status === "transferring" || t.status === "pending") && !idsToCancel.includes(t.id)) {
idsToCancel.push(t.id);
}
}
await Promise.all(
idsToCancel.map((id) =>
netcattyBridge.get()!.cancelTransfer!(id).catch((err) => {
logger.warn("Failed to cancel transfer at backend:", err);
}),
),
);
}
await cancelBackendTransfers([transferId]);
},
[],
[cancelBackendTransfers],
);
const retryTransfer = useCallback(
@@ -1155,79 +718,123 @@ export const useSftpTransfers = ({
}, []);
const resolveConflict = useCallback(
async (conflictId: string, action: "replace" | "skip" | "duplicate") => {
async (conflictId: string, action: FileConflictAction, applyToAll = false) => {
const conflict = conflictsRef.current.find((c) => c.transferId === conflictId);
if (!conflict) return;
setConflicts((prev) => prev.filter((c) => c.transferId !== conflictId));
const task = transfersRef.current.find((t) => t.id === conflictId);
if (!task) return;
if (!task) {
setConflicts((prev) => prev.filter((c) => c.transferId !== conflictId));
return;
}
const selectedConflictKey = conflictDefaultKey(task.batchId, task.isDirectory);
const affectedConflicts = applyToAll
? conflictsRef.current.filter((candidate) =>
conflictDefaultKey(candidate.batchId, candidate.isDirectory) === selectedConflictKey,
)
: [conflict];
const affectedConflictIds = new Set(affectedConflicts.map((candidate) => candidate.transferId));
const affectedTasks = affectedConflicts
.map((candidate) => transfersRef.current.find((transfer) => transfer.id === candidate.transferId))
.filter((candidate): candidate is TransferTask => Boolean(candidate));
if (applyToAll) {
conflictDefaultsRef.current.set(selectedConflictKey, action);
}
setConflicts((prev) => prev.filter((c) => !affectedConflictIds.has(c.transferId)));
if (affectedTasks.length === 0) {
return;
}
if (action === "stop") {
await markBatchStopped(task);
return;
}
if (action === "skip") {
for (const affectedTask of affectedTasks) {
cancelledTasksRef.current.add(affectedTask.id);
}
setTransfers((prev) =>
prev.map((t) =>
t.id === conflictId
? { ...t, status: "cancelled" as TransferStatus }
prev.map((t) => affectedConflictIds.has(t.id)
? { ...t, status: "cancelled" as TransferStatus, endTime: Date.now() }
: t,
),
);
const completionHandler = completionHandlersRef.current.get(conflictId);
if (completionHandler) {
try {
await completionHandler({
id: task.id,
fileName: task.fileName,
originalFileName: task.originalFileName ?? task.fileName,
status: "cancelled",
});
} finally {
completionHandlersRef.current.delete(conflictId);
}
for (const affectedTask of affectedTasks) {
await completeCancelledTask(affectedTask);
}
return;
}
let updatedTask = { ...task };
const updatedTasks: TransferTask[] = [];
if (action === "duplicate") {
const ext = task.fileName.includes(".")
? "." + task.fileName.split(".").pop()
: "";
const baseName = task.fileName.includes(".")
? task.fileName.slice(0, task.fileName.lastIndexOf("."))
: task.fileName;
const newName = `${baseName} (copy)${ext}`;
const newTargetPath = joinPath(getParentPath(task.targetPath), newName);
updatedTask = {
...task,
fileName: newName,
targetPath: newTargetPath,
skipConflictCheck: true,
};
} else if (action === "replace") {
updatedTask = {
...task,
skipConflictCheck: true,
};
for (const affectedTask of affectedTasks) {
let updatedTask = { ...affectedTask };
if (action === "duplicate") {
const endpoints = resolveTaskEndpoints(affectedTask);
if (!endpoints) continue;
const targetSftpId = endpoints.targetPane.connection?.isLocal
? null
: sftpSessionsRef.current.get(endpoints.targetPane.connection!.id) ?? null;
const targetEncoding = endpoints.targetPane.connection?.isLocal
? "auto"
: endpoints.targetPane.filenameEncoding || "auto";
const duplicateTarget = await getDuplicateTarget(affectedTask, endpoints.targetPane, targetSftpId, targetEncoding);
updatedTask = {
...affectedTask,
fileName: duplicateTarget.fileName,
targetPath: duplicateTarget.targetPath,
skipConflictCheck: true,
};
} else if (action === "replace") {
updatedTask = {
...affectedTask,
skipConflictCheck: true,
replaceExistingTarget: true,
};
} else if (action === "merge") {
updatedTask = {
...affectedTask,
skipConflictCheck: true,
replaceExistingTarget: false,
};
}
updatedTasks.push(updatedTask);
}
const updatedTaskMap = new Map(updatedTasks.map((updatedTask) => [updatedTask.id, updatedTask]));
setTransfers((prev) =>
prev.map((t) =>
t.id === conflictId
prev.map((t) => {
const updatedTask = updatedTaskMap.get(t.id);
return updatedTask
? { ...updatedTask, status: "pending" as TransferStatus }
: t,
),
: t;
}),
);
setTimeout(async () => {
const endpoints = resolveTaskEndpoints(updatedTask);
if (!endpoints) return;
await processTransfer(updatedTask, endpoints.sourcePane, endpoints.targetPane, endpoints.targetSide);
}, 100);
for (const updatedTask of updatedTasks) {
setTimeout(async () => {
const endpoints = resolveTaskEndpoints(updatedTask);
if (!endpoints) return;
await processTransfer(updatedTask, endpoints.sourcePane, endpoints.targetPane, endpoints.targetSide);
}, 100);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline; transfers/conflicts accessed via refs
[resolveTaskEndpoints],
[
completeCancelledTask,
conflictDefaultKey,
getDuplicateTarget,
markBatchStopped,
resolveTaskEndpoints,
sftpSessionsRef,
],
);
const activeTransfersCount = useMemo(() => transfers.filter(

View File

@@ -0,0 +1,60 @@
import type { MutableRefObject } from "react";
import type { FileConflict, FileConflictAction, SftpFileEntry, SftpFilenameEncoding, TransferStatus, TransferTask } from "../../../domain/models";
import type { SftpPane } from "./types";
export interface UseSftpTransfersParams {
getActivePane: (side: "left" | "right") => SftpPane | null;
getPaneByConnectionId: (connectionId: string) => SftpPane | null;
getTabByConnectionId: (connectionId: string) => { side: "left" | "right"; tabId: string; pane: SftpPane } | null;
updateTab: (side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => void;
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
clearCacheForConnection: (connectionId: string) => void;
sftpSessionsRef: MutableRefObject<Map<string, string>>;
connectionCacheKeyMapRef: MutableRefObject<Map<string, string>>;
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
handleSessionError: (side: "left" | "right", error: Error) => void;
}
export interface UseSftpTransfersResult {
transfers: TransferTask[];
conflicts: FileConflict[];
activeTransfersCount: number;
startTransfer: (
sourceFiles: { name: string; isDirectory: boolean }[],
sourceSide: "left" | "right",
targetSide: "left" | "right",
options?: {
sourcePane?: SftpPane;
sourcePath?: string;
sourceConnectionId?: string;
targetPath?: string;
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
},
) => Promise<TransferResult[]>;
downloadToLocal: (params: {
fileName: string;
sourcePath: string;
targetPath: string;
sftpId: string;
connectionId: string;
sourceEncoding?: SftpFilenameEncoding;
isDirectory: boolean;
totalBytes?: number;
}) => Promise<TransferStatus>;
addExternalUpload: (task: TransferTask) => void;
updateExternalUpload: (taskId: string, updates: Partial<TransferTask>) => void;
cancelTransfer: (transferId: string) => Promise<void>;
isTransferCancelled: (transferId: string) => boolean;
retryTransfer: (transferId: string) => Promise<void>;
clearCompletedTransfers: () => void;
dismissTransfer: (transferId: string) => void;
resolveConflict: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => Promise<void>;
}
export interface TransferResult {
id: string;
fileName: string;
originalFileName?: string;
status: TransferStatus;
}

View File

@@ -0,0 +1,11 @@
import test from "node:test";
import assert from "node:assert/strict";
import { isConcreteTransferTargetPath } from "./utils";
test("concrete transfer target paths exclude temporary placeholders", () => {
assert.equal(isConcreteTransferTargetPath({ targetPath: "/Users/alice/Downloads/report.pdf" }), true);
assert.equal(isConcreteTransferTargetPath({ targetPath: "C:\\Users\\alice\\Downloads\\report.pdf" }), true);
assert.equal(isConcreteTransferTargetPath({ targetPath: "(temp)" }), false);
assert.equal(isConcreteTransferTargetPath({ targetPath: " " }), false);
});

View File

@@ -1,4 +1,4 @@
import { SftpFileEntry } from "../../../domain/models";
import { SftpFileEntry, TransferTask } from "../../../domain/models";
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "--";
@@ -76,6 +76,11 @@ export const getParentPath = (path: string): string => {
return result;
};
export const isConcreteTransferTargetPath = (task: Pick<TransferTask, "targetPath">): boolean => {
const targetPath = task.targetPath.trim();
return targetPath.length > 0 && targetPath !== "(temp)";
};
export const getFileName = (path: string): string => {
const parts = path.split(/[\\/]/).filter(Boolean);
return parts[parts.length - 1] || "";

View File

@@ -0,0 +1,4 @@
export {
readSnippetVariableValuesForSnippet,
saveSnippetVariableValues,
} from '../../infrastructure/persistence/snippetVariableValuesStorage';

View File

@@ -0,0 +1,123 @@
import { useEffect, type MutableRefObject } from 'react';
import {
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
} from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
interface UseSystemSettingsEffectsParams {
toggleWindowHotkey: string;
globalHotkeyEnabled: boolean;
closeToTray: boolean;
autoUpdateEnabled: boolean;
persistMountedRef: MutableRefObject<boolean>;
setHotkeyRegistrationError: (error: string | null) => void;
setAutoUpdateEnabled: (enabled: boolean | ((prev: boolean) => boolean)) => void;
notifySettingsChanged: (key: string, value: unknown) => void;
}
export function useSystemSettingsEffects({
toggleWindowHotkey,
globalHotkeyEnabled,
closeToTray,
autoUpdateEnabled,
persistMountedRef,
setHotkeyRegistrationError,
setAutoUpdateEnabled,
notifySettingsChanged,
}: UseSystemSettingsEffectsParams) {
// Persist and sync toggle window hotkey setting
useEffect(() => {
// Register/unregister the global hotkey in main process (needed on mount)
const bridge = netcattyBridge.get();
if (bridge?.registerGlobalHotkey) {
if (toggleWindowHotkey && globalHotkeyEnabled) {
setHotkeyRegistrationError(null);
bridge
.registerGlobalHotkey(toggleWindowHotkey)
.then((result) => {
if (result?.success === false) {
console.warn('[GlobalHotkey] Hotkey registration failed:', result.error);
setHotkeyRegistrationError(result.error || 'Failed to register hotkey');
}
})
.catch((err) => {
console.warn('[GlobalHotkey] Failed to register hotkey:', err);
setHotkeyRegistrationError(err?.message || 'Failed to register hotkey');
});
} else {
setHotkeyRegistrationError(null);
bridge.unregisterGlobalHotkey?.().catch((err) => {
console.warn('[GlobalHotkey] Failed to unregister hotkey:', err);
});
}
}
localStorageAdapter.writeString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
// Skip IPC on initial mount
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
}, [
toggleWindowHotkey,
globalHotkeyEnabled,
notifySettingsChanged,
persistMountedRef,
setHotkeyRegistrationError,
]);
// Persist global hotkey enabled setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled);
}, [globalHotkeyEnabled, notifySettingsChanged, persistMountedRef]);
// Persist and sync close to tray setting
useEffect(() => {
// Update main process tray behavior (needed on mount)
const bridge = netcattyBridge.get();
if (bridge?.setCloseToTray) {
bridge.setCloseToTray(closeToTray).catch((err) => {
console.warn('[SystemTray] Failed to set close-to-tray:', err);
});
}
localStorageAdapter.writeString(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray ? 'true' : 'false');
// Skip IPC on initial mount
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
}, [closeToTray, notifySettingsChanged, persistMountedRef]);
// Hydrate auto-update state from the main-process preference file on mount.
// This reconciles localStorage (renderer) with auto-update-pref.json (main)
// in case localStorage was cleared or is stale.
useEffect(() => {
const bridge = netcattyBridge.get();
void bridge?.getAutoUpdate?.().then((result) => {
if (result && typeof result.enabled === 'boolean') {
setAutoUpdateEnabled((prev) => {
if (prev === result.enabled) return prev;
// Sync localStorage with the main-process truth
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, result.enabled ? 'true' : 'false');
return result.enabled;
});
}
}).catch(() => { /* bridge unavailable */ });
}, [setAutoUpdateEnabled]);
// Persist auto-update enabled setting.
// Initial mount still writes localStorage, but skips cross-window/main-process IPC.
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled);
// Notify main process on user-initiated changes
const bridge = netcattyBridge.get();
bridge?.setAutoUpdate?.(autoUpdateEnabled).catch((err: unknown) => {
console.warn('[AutoUpdate] Failed to set auto-update:', err);
});
}, [autoUpdateEnabled, notifySettingsChanged, persistMountedRef]);
}

View File

@@ -0,0 +1,62 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { TerminalSession } from "../../domain/models";
import {
canReuseTerminalConnection,
createCopiedTerminalSessionClone,
createSplitTerminalSessionClone,
} from "./terminalConnectionReuse";
const session = (overrides: Partial<TerminalSession> = {}): TerminalSession => ({
id: "session-1",
hostId: "host-1",
hostLabel: "Host",
hostname: "example.com",
username: "alice",
status: "connected",
protocol: "ssh",
...overrides,
});
test("connected SSH sessions can reuse their authenticated connection", () => {
assert.equal(canReuseTerminalConnection(session()), true);
assert.equal(canReuseTerminalConnection(session({ protocol: undefined })), true);
});
test("non-SSH or unavailable sessions do not reuse a connection", () => {
assert.equal(canReuseTerminalConnection(session({ status: "connecting" })), false);
assert.equal(canReuseTerminalConnection(session({ status: "disconnected" })), false);
assert.equal(canReuseTerminalConnection(session({ protocol: "local" })), false);
assert.equal(canReuseTerminalConnection(session({ protocol: "serial" })), false);
assert.equal(canReuseTerminalConnection(session({ protocol: "telnet" })), false);
assert.equal(canReuseTerminalConnection(session({ moshEnabled: true })), false);
assert.equal(canReuseTerminalConnection(session({ etEnabled: true })), false);
});
test("split session clones reuse only connected SSH sources", () => {
assert.equal(
createSplitTerminalSessionClone(session(), { id: "split-1", workspaceId: "workspace-1" }).reuseConnectionFromSessionId,
"session-1",
);
assert.equal(
createSplitTerminalSessionClone(session({ etEnabled: true }), { id: "split-2" }).reuseConnectionFromSessionId,
undefined,
);
assert.equal(
createSplitTerminalSessionClone(session({ moshEnabled: true }), { id: "split-3" }).reuseConnectionFromSessionId,
undefined,
);
});
test("copy session clones reuse SSH sources and preserve serial config", () => {
const copied = createCopiedTerminalSessionClone(
session({
serialConfig: { path: "/dev/tty.usbserial", baudRate: 115200 },
}),
{ id: "copy-1" },
);
assert.equal(copied.reuseConnectionFromSessionId, "session-1");
assert.deepEqual(copied.serialConfig, { path: "/dev/tty.usbserial", baudRate: 115200 });
});

View File

@@ -0,0 +1,71 @@
import type { TerminalSession } from "../../domain/models";
export function canReuseTerminalConnection(session: TerminalSession): boolean {
return (
(session.protocol === "ssh" || session.protocol === undefined) &&
!session.moshEnabled &&
!session.etEnabled &&
session.status === "connected"
);
}
type CloneSessionOptions = {
id: string;
localShellType?: TerminalSession["shellType"];
workspaceId?: string;
};
function getClonedShellType(
session: TerminalSession,
localShellType?: TerminalSession["shellType"],
): TerminalSession["shellType"] {
return session.protocol === "local" ? localShellType : session.shellType;
}
function createTerminalSessionClone(
session: TerminalSession,
options: CloneSessionOptions,
): TerminalSession {
const clonedSession: TerminalSession = {
id: options.id,
hostId: session.hostId,
hostLabel: session.hostLabel,
hostname: session.hostname,
username: session.username,
status: "connecting",
protocol: session.protocol,
port: session.port,
moshEnabled: session.moshEnabled,
etEnabled: session.etEnabled,
shellType: getClonedShellType(session, options.localShellType),
charset: session.charset,
localShell: session.localShell,
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
reuseConnectionFromSessionId: canReuseTerminalConnection(session) ? session.id : undefined,
};
if (options.workspaceId) {
clonedSession.workspaceId = options.workspaceId;
}
return clonedSession;
}
export function createSplitTerminalSessionClone(
session: TerminalSession,
options: CloneSessionOptions,
): TerminalSession {
return createTerminalSessionClone(session, options);
}
export function createCopiedTerminalSessionClone(
session: TerminalSession,
options: CloneSessionOptions,
): TerminalSession {
return {
...createTerminalSessionClone(session, options),
serialConfig: session.serialConfig,
};
}

View File

@@ -0,0 +1,130 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createTextEditorSaveCoordinator } from "./textEditorSaveCoordinator.ts";
const deferred = <T = void>() => {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
test("text editor save coordinator joins duplicate saves already in flight", async () => {
const pending = deferred();
const saved: string[] = [];
const savingStates: boolean[] = [];
const coordinator = createTextEditorSaveCoordinator({
onSave: async (content) => {
saved.push(content);
await pending.promise;
},
onSavingChange: (saving) => savingStates.push(saving),
});
const first = coordinator.save("remote text");
const second = coordinator.save("remote text");
assert.deepEqual(saved, ["remote text"]);
pending.resolve();
assert.equal(await first, true);
assert.equal(await second, true);
assert.deepEqual(saved, ["remote text"]);
assert.deepEqual(savingStates, [true, false]);
});
test("text editor save coordinator saves newer content after an in-flight save finishes", async () => {
const firstSave = deferred();
const secondSave = deferred();
const saved: string[] = [];
const coordinator = createTextEditorSaveCoordinator({
onSave: async (content) => {
saved.push(content);
await (content === "v1" ? firstSave.promise : secondSave.promise);
},
});
const first = coordinator.save("v1");
const second = coordinator.save("v2");
assert.deepEqual(saved, ["v1"]);
firstSave.resolve();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
assert.deepEqual(saved, ["v1", "v2"]);
secondSave.resolve();
assert.equal(await first, true);
assert.equal(await second, true);
});
test("text editor save coordinator returns false to duplicate callers when the in-flight save fails", async () => {
const pending = deferred();
const errors: string[] = [];
const coordinator = createTextEditorSaveCoordinator({
onSave: async () => {
await pending.promise;
throw new Error("denied");
},
onSaveError: (error) => {
errors.push(error instanceof Error ? error.message : String(error));
},
});
const first = coordinator.save("content");
const second = coordinator.save("content");
pending.resolve();
assert.equal(await first, false);
assert.equal(await second, false);
assert.deepEqual(errors, ["denied"]);
});
test("text editor save coordinator reset prevents an old in-flight save from updating the next file", async () => {
const pending = deferred();
const successes: string[] = [];
const errors: string[] = [];
const savingStates: boolean[] = [];
const coordinator = createTextEditorSaveCoordinator({
onSave: async () => {
await pending.promise;
},
onSaveSuccess: (content) => successes.push(content),
onSaveError: (error) => errors.push(error instanceof Error ? error.message : String(error)),
onSavingChange: (saving) => savingStates.push(saving),
});
const save = coordinator.save("old file");
coordinator.reset();
pending.resolve();
assert.equal(await save, false);
assert.deepEqual(successes, []);
assert.deepEqual(errors, []);
assert.deepEqual(savingStates, [true, false]);
});
test("text editor save coordinator reset cancels queued stale saves", async () => {
const firstSave = deferred();
const saved: string[] = [];
const coordinator = createTextEditorSaveCoordinator({
onSave: async (content) => {
saved.push(content);
await firstSave.promise;
},
});
const first = coordinator.save("old v1");
const queued = coordinator.save("old v2");
coordinator.reset();
firstSave.resolve();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
assert.equal(await first, false);
assert.equal(await queued, false);
assert.deepEqual(saved, ["old v1"]);
});

View File

@@ -0,0 +1,90 @@
export interface TextEditorSaveCoordinator {
save(content: string): Promise<boolean>;
isSaving(): boolean;
reset(): void;
}
export interface TextEditorSaveCoordinatorOptions {
onSave: (content: string) => Promise<void>;
onSaveStart?: (content: string) => void;
onSaveSuccess?: (content: string) => void;
onSaveError?: (error: unknown) => void;
onSavingChange?: (saving: boolean) => void;
}
interface InFlightSave {
content: string;
promise: Promise<boolean>;
}
export const createTextEditorSaveCoordinator = (
options: TextEditorSaveCoordinatorOptions,
): TextEditorSaveCoordinator => {
let inFlight: InFlightSave | null = null;
let generation = 0;
const notifySavingChange = () => {
options.onSavingChange?.(inFlight !== null);
};
const startSave = (content: string): Promise<boolean> => {
const saveGeneration = generation;
options.onSaveStart?.(content);
const promise = (async () => {
try {
await options.onSave(content);
if (saveGeneration !== generation) {
return false;
}
if (saveGeneration === generation) {
options.onSaveSuccess?.(content);
}
return true;
} catch (error) {
if (saveGeneration !== generation) {
return false;
}
if (saveGeneration === generation) {
options.onSaveError?.(error);
}
return false;
}
})();
const entry = { content, promise };
inFlight = entry;
notifySavingChange();
void promise.finally(() => {
if (inFlight === entry) {
inFlight = null;
notifySavingChange();
}
});
return promise;
};
const save = async (content: string): Promise<boolean> => {
const current = inFlight;
if (current) {
const waitGeneration = generation;
const ok = await current.promise;
if (waitGeneration !== generation) return false;
if (!ok || current.content === content) return ok;
return save(content);
}
return startSave(content);
};
return {
save,
isSaving: () => inFlight !== null,
reset: () => {
generation += 1;
if (inFlight) {
inFlight = null;
notifySavingChange();
}
},
};
};

View File

@@ -0,0 +1,292 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
UploadController,
startUploadScanningTask,
uploadEntriesDirect,
uploadFromDataTransfer,
uploadFromFileList,
} from "../../lib/uploadService.ts";
function createDataTransfer(files: File[]): DataTransfer {
return {
items: { length: 0 },
files,
} as unknown as DataTransfer;
}
function createDataTransferWithNullEntries(files: File[]): DataTransfer {
const items = files.map((file) => ({
kind: "file",
getAsFile: () => file,
webkitGetAsEntry: () => null,
}));
return {
items,
files,
} as unknown as DataTransfer;
}
test("upload scanning task can be shown and cancelled before transfers start", () => {
const events: string[] = [];
const scanningTask = startUploadScanningTask(
{
onScanningStart: (taskId) => events.push(`start:${taskId}`),
onScanningEnd: (taskId) => events.push(`end:${taskId}`),
onTaskCancelled: (taskId) => events.push(`cancel:${taskId}`),
},
"scan-folder-1",
);
assert.equal(scanningTask.isOpen(), true);
scanningTask.cancel();
scanningTask.complete();
assert.equal(scanningTask.isOpen(), false);
assert.deepEqual(events, ["start:scan-folder-1", "cancel:scan-folder-1"]);
});
test("clears the scanning placeholder when every dropped file is skipped by conflict resolution", async () => {
const events: string[] = [];
const file = new File(["local"], "conflict.txt", { lastModified: 1234 });
const results = await uploadFromDataTransfer(
createDataTransfer([file]),
{
targetPath: "/target",
sftpId: null,
isLocal: true,
bridge: {
mkdirSftp: async () => {},
statLocal: async () => ({ type: "file", size: 10, lastModified: 1000 }),
writeLocalFile: async () => {
throw new Error("skipped conflicts should not upload");
},
},
joinPath: (base, name) => `${base}/${name}`,
callbacks: {
onScanningStart: () => events.push("scan:start"),
onScanningEnd: () => events.push("scan:end"),
onTaskCreated: () => events.push("task:create"),
},
resolveConflict: async () => "skip",
},
);
assert.deepEqual(results, [
{ fileName: "conflict.txt", success: false, cancelled: true },
]);
assert.deepEqual(events, ["scan:start", "scan:end"]);
});
test("uploads DataTransfer files when entry extraction returns no entries", async () => {
const file = new File(["picked"], "picked.txt", { lastModified: 1234 });
const uploadedPaths: string[] = [];
const results = await uploadFromDataTransfer(
createDataTransferWithNullEntries([file]),
{
targetPath: "/target",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async () => {},
writeSftpBinary: async (_sftpId, path) => {
uploadedPaths.push(path);
},
},
joinPath: (base, name) => `${base}/${name}`,
},
);
assert.deepEqual(uploadedPaths, ["/target/picked.txt"]);
assert.deepEqual(results, [
{ fileName: "picked.txt", success: true },
]);
});
test("uploads picked folder files with their relative directory structure", async () => {
const file = new File(["nested"], "file.txt", { lastModified: 1234 });
Object.defineProperty(file, "webkitRelativePath", {
value: "folder/sub/file.txt",
});
const madeDirs: string[] = [];
const uploadedPaths: string[] = [];
const results = await uploadFromFileList(
[file],
{
targetPath: "/target",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async (_sftpId, path) => {
madeDirs.push(path);
},
writeSftpBinary: async (_sftpId, path) => {
uploadedPaths.push(path);
},
},
joinPath: (base, name) => `${base}/${name}`,
},
);
assert.deepEqual(madeDirs, ["/target/folder", "/target/folder/sub"]);
assert.deepEqual(uploadedPaths, ["/target/folder/sub/file.txt"]);
assert.deepEqual(results, [
{ fileName: "folder/sub/file.txt", success: true },
]);
});
test("uploads path-backed clipboard files through stream transfer", async () => {
const transfers: Array<{ sourcePath: string; targetPath: string; totalBytes?: number }> = [];
const taskTotals: number[] = [];
const results = await uploadEntriesDirect(
[
{
file: null,
localPath: "/Users/me/Desktop/report.txt",
relativePath: "report.txt",
isDirectory: false,
size: 42,
},
],
{
targetPath: "/target",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async () => {},
startStreamTransfer: async (payload) => {
transfers.push({
sourcePath: payload.sourcePath,
targetPath: payload.targetPath,
totalBytes: payload.totalBytes,
});
return { transferId: payload.transferId };
},
},
joinPath: (base, name) => `${base}/${name}`,
callbacks: {
onTaskCreated: (task) => taskTotals.push(task.totalBytes),
},
},
);
assert.deepEqual(taskTotals, [42]);
assert.deepEqual(transfers, [
{
sourcePath: "/Users/me/Desktop/report.txt",
targetPath: "/target/report.txt",
totalBytes: 42,
},
]);
assert.deepEqual(results, [
{ fileName: "report.txt", success: true },
]);
});
test("copies path-backed clipboard files into local panes through stream transfer", async () => {
const transfers: Array<{ sourcePath: string; targetPath: string; targetType: string; totalBytes?: number }> = [];
const results = await uploadEntriesDirect(
[
{
file: null,
localPath: "/Users/me/Desktop/report.txt",
relativePath: "report.txt",
isDirectory: false,
size: 42,
},
],
{
targetPath: "/target",
sftpId: null,
isLocal: true,
bridge: {
mkdirLocal: async () => {},
startStreamTransfer: async (payload) => {
transfers.push({
sourcePath: payload.sourcePath,
targetPath: payload.targetPath,
targetType: payload.targetType,
totalBytes: payload.totalBytes,
});
return { transferId: payload.transferId };
},
},
joinPath: (base, name) => `${base}/${name}`,
},
);
assert.deepEqual(transfers, [
{
sourcePath: "/Users/me/Desktop/report.txt",
targetPath: "/target/report.txt",
targetType: "local",
totalBytes: 42,
},
]);
assert.deepEqual(results, [
{ fileName: "report.txt", success: true },
]);
});
test("reports empty directory creation failures", async () => {
const madeDirs: string[] = [];
const results = await uploadEntriesDirect(
[
{ file: null, relativePath: "folder", isDirectory: true },
{ file: null, relativePath: "folder/empty", isDirectory: true },
],
{
targetPath: "/target",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async (_sftpId, path) => {
madeDirs.push(path);
if (path.endsWith("/empty")) {
throw new Error("permission denied");
}
},
},
joinPath: (base, name) => `${base}/${name}`,
},
);
assert.deepEqual(madeDirs, ["/target/folder", "/target/folder/empty"]);
assert.deepEqual(results, [
{ fileName: "folder/empty", success: false, error: "permission denied" },
]);
});
test("does not restart a direct upload that was already cancelled", async () => {
const controller = new UploadController();
await controller.cancel();
let mkdirCalled = false;
const results = await uploadEntriesDirect(
[{ file: null, relativePath: "folder", isDirectory: true }],
{
targetPath: "/target",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async () => {
mkdirCalled = true;
},
},
joinPath: (base, name) => `${base}/${name}`,
},
controller,
);
assert.equal(mkdirCalled, false);
assert.deepEqual(results, [
{ fileName: "", success: false, cancelled: true },
]);
});

View File

@@ -15,11 +15,11 @@ import {
STORAGE_KEY_AI_SESSIONS,
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
} from '../../infrastructure/config/storageKeys';
import type {
AIDraft,
AIPanelView,
AISession,
AIPermissionMode,
AIToolIntegrationMode,
@@ -33,228 +33,36 @@ import type {
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
import {
activateDraftView,
bumpDraftMutationVersionState,
bumpDraftUploadGenerationState,
clearScopeDraftState,
ensureDraftForScopeState,
getDraftUploadGenerationState,
setSessionView,
updateDraftForScope,
} from './aiDraftState';
import {
pruneInactiveScopedSessions,
pruneInactiveScopedTransientState,
} from './aiScopeCleanup';
import { convertFilesToUploads } from './useFileUpload';
import { removeProviderReferences } from './aiProviderCleanup';
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
interface AIBridge {
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
aiMcpSetToolIntegrationMode?: (mode: AIToolIntegrationMode) => Promise<unknown> | unknown;
aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown;
aiMcpSetCommandTimeout?: (timeout: number) => Promise<unknown> | unknown;
aiMcpSetMaxIterations?: (maxIterations: number) => Promise<unknown> | unknown;
}
function getAIBridge() {
return (window as unknown as { netcatty?: AIBridge }).netcatty;
}
const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
const AI_STATE_CHANGED_DRAFTS_BY_SCOPE = 'netcatty:ai-drafts-by-scope';
const AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE = 'netcatty:ai-panel-view-by-scope';
type DraftsByScope = Partial<Record<string, AIDraft>>;
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
function emitAIStateChanged(key: string) {
window.dispatchEvent(new CustomEvent<{ key: string }>(AI_STATE_CHANGED_EVENT, { detail: { key } }));
}
function cleanupAcpSessions(sessionIds: string[]) {
const bridge = getAIBridge();
if (!bridge?.aiAcpCleanup || sessionIds.length === 0) return;
for (const sessionId of sessionIds) {
void bridge.aiAcpCleanup(sessionId).catch(() => {});
}
}
function isScopeKeyActive(scopeKey: string, activeTargetIds: Set<string>) {
const separatorIndex = scopeKey.indexOf(':');
if (separatorIndex === -1) return true;
const targetId = scopeKey.slice(separatorIndex + 1);
if (!targetId) return true;
return activeTargetIds.has(targetId);
}
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
const currentSessions = latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
// Sessions shown by a still-live scope must be protected from cleanup
// even when their own `scope.targetId` points at a closed terminal —
// history can be resumed into a different terminal and we must not
// delete it outright while it's actively being used.
const preCleanupActiveSessionMap = latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {};
const activeSessionIds = new Set<string>();
for (const [scopeKey, sessionId] of Object.entries(preCleanupActiveSessionMap)) {
if (!sessionId) continue;
if (!isScopeKeyActive(scopeKey, activeTargetIds)) continue;
activeSessionIds.add(sessionId);
}
const nextSessionCleanup = pruneInactiveScopedSessions(
currentSessions,
activeTargetIds,
activeSessionIds,
);
if (nextSessionCleanup.orphanedSessionIds.length > 0) {
cleanupAcpSessions(nextSessionCleanup.orphanedSessionIds);
}
if (nextSessionCleanup.sessions !== currentSessions) {
setLatestAISessionsSnapshot(nextSessionCleanup.sessions);
localStorageAdapter.write(
STORAGE_KEY_AI_SESSIONS,
pruneSessionsForStorage(nextSessionCleanup.sessions),
);
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
}
const activeSessionIdMap = preCleanupActiveSessionMap;
let activeSessionMapChanged = false;
const nextActiveSessionIdMap = { ...activeSessionIdMap };
for (const scopeKey of Object.keys(activeSessionIdMap)) {
if (isScopeKeyActive(scopeKey, activeTargetIds)) continue;
delete nextActiveSessionIdMap[scopeKey];
activeSessionMapChanged = true;
}
if (activeSessionMapChanged) {
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
const currentActiveSessionIdMap = activeSessionMapChanged
? nextActiveSessionIdMap
: activeSessionIdMap;
const currentDraftsByScope = latestAIDraftsByScopeSnapshot ?? {};
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
const prunedScopedTransientState = pruneInactiveScopedTransientState(
currentActiveSessionIdMap,
currentDraftsByScope,
currentPanelViewByScope,
activeTargetIds,
);
if (prunedScopedTransientState.activeSessionIdMap !== currentActiveSessionIdMap) {
setLatestAIActiveSessionMapSnapshot(prunedScopedTransientState.activeSessionIdMap);
localStorageAdapter.write(
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
prunedScopedTransientState.activeSessionIdMap,
);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
if (prunedScopedTransientState.draftsByScope !== currentDraftsByScope) {
for (const scopeKey of Object.keys(currentDraftsByScope)) {
if (scopeKey in prunedScopedTransientState.draftsByScope) continue;
bumpDraftMutationVersion(scopeKey);
bumpDraftUploadGeneration(scopeKey);
}
setLatestAIDraftsByScopeSnapshot(prunedScopedTransientState.draftsByScope);
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
}
if (prunedScopedTransientState.panelViewByScope !== currentPanelViewByScope) {
for (const scopeKey of Object.keys(currentPanelViewByScope)) {
if (scopeKey in prunedScopedTransientState.panelViewByScope) continue;
bumpDraftMutationVersion(scopeKey);
}
setLatestAIPanelViewByScopeSnapshot(prunedScopedTransientState.panelViewByScope);
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
}
}
/** Maximum number of sessions to keep in localStorage. */
const MAX_STORED_SESSIONS = 50;
/** Maximum number of messages per session when persisting to localStorage. */
const MAX_SESSION_MESSAGES = 200;
/**
* Prune sessions before writing to localStorage to prevent hitting the
* ~5-10 MB storage quota. Only affects what is persisted — the in-memory
* state retains all messages until the session is reloaded.
*
* - Keeps only the MAX_STORED_SESSIONS most-recently-updated sessions.
* - Trims each session's messages to the last MAX_SESSION_MESSAGES.
*/
function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
// Sort by updatedAt descending so we keep the newest
const sorted = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt);
const limited = sorted.slice(0, MAX_STORED_SESSIONS);
return limited.map(s => {
if (s.messages.length > MAX_SESSION_MESSAGES) {
return { ...s, messages: s.messages.slice(-MAX_SESSION_MESSAGES) };
}
return s;
});
}
let latestAISessionsSnapshot: AISession[] | null = null;
let latestAIActiveSessionMapSnapshot: Record<string, string | null> | null = null;
let latestAIDraftsByScopeSnapshot: DraftsByScope | null = null;
let latestAIPanelViewByScopeSnapshot: PanelViewByScope | null = null;
let latestAIDraftMutationVersionByScopeSnapshot: Record<string, number> = {};
let latestAIDraftUploadGenerationByScopeSnapshot: Record<string, number> = {};
function setLatestAISessionsSnapshot(sessions: AISession[]) {
latestAISessionsSnapshot = sessions;
}
function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<string, string | null>) {
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
}
function setLatestAIDraftsByScopeSnapshot(draftsByScope: DraftsByScope) {
latestAIDraftsByScopeSnapshot = draftsByScope;
}
function setLatestAIPanelViewByScopeSnapshot(panelViewByScope: PanelViewByScope) {
latestAIPanelViewByScopeSnapshot = panelViewByScope;
}
function bumpDraftMutationVersion(scopeKey: string) {
latestAIDraftMutationVersionByScopeSnapshot = bumpDraftMutationVersionState(
latestAIDraftMutationVersionByScopeSnapshot,
scopeKey,
);
}
function getDraftUploadGeneration(scopeKey: string) {
return getDraftUploadGenerationState(
latestAIDraftUploadGenerationByScopeSnapshot,
scopeKey,
);
}
function bumpDraftUploadGeneration(scopeKey: string) {
latestAIDraftUploadGenerationByScopeSnapshot = bumpDraftUploadGenerationState(
latestAIDraftUploadGenerationByScopeSnapshot,
scopeKey,
);
}
import {
AI_STATE_CHANGED_DRAFTS_BY_SCOPE,
AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE,
bumpDraftMutationVersion,
bumpDraftUploadGeneration,
cleanupSdkAgentSessions,
cleanupOrphanedAISessions,
getAIBridge,
getDraftUploadGeneration,
latestAIActiveSessionMapSnapshot,
latestAIDraftsByScopeSnapshot,
latestAIPanelViewByScopeSnapshot,
latestAISessionsSnapshot,
pruneSessionsForStorage,
setLatestAIActiveSessionMapSnapshot,
setLatestAIDraftsByScopeSnapshot,
setLatestAIPanelViewByScopeSnapshot,
setLatestAISessionsSnapshot,
type DraftsByScope,
type PanelViewByScope,
} from './aiStateSnapshots';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
export function useAIState() {
// ── Provider Config ──
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
@@ -326,6 +134,24 @@ export function useAIState() {
const [agentModelMap, setAgentModelMapRaw] = useState<Record<string, string>>(() =>
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {}
);
const agentModelMapRef = useRef(agentModelMap);
useEffect(() => {
agentModelMapRef.current = agentModelMap;
}, [agentModelMap]);
// Per-agent provider override: remembers which provider config each agent
// should bind to. Falls back to the global `activeProviderId` when an agent
// has no entry. Used so that e.g. Catty Agent can stay on DeepSeek while
// a Claude/Codex run continues on its existing provider.
const [agentProviderMap, setAgentProviderMapRaw] = useState<Record<string, string>>(() =>
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP) ?? {}
);
// Mirror for non-functional reads inside removeProvider — needed to know
// which agents were bound to the deleted provider so we can also drop
// their saved model ids (those ids belonged to the now-missing provider).
const agentProviderMapRef = useRef(agentProviderMap);
useEffect(() => {
agentProviderMapRef.current = agentProviderMap;
}, [agentProviderMap]);
// ── Web Search Config ──
const [webSearchConfig, setWebSearchConfigRaw] = useState<WebSearchConfig | null>(() =>
@@ -413,6 +239,21 @@ export function useAIState() {
});
}, []);
const setAgentProvider = useCallback((agentId: string, providerId: string) => {
setAgentProviderMapRaw(prev => {
// Empty string clears the per-agent override and lets the agent fall
// back to the global `activeProviderId`.
const next = { ...prev };
if (providerId) {
next[agentId] = providerId;
} else {
delete next[agentId];
}
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, next);
return next;
});
}, []);
const setWebSearchConfig = useCallback((config: WebSearchConfig | null) => {
setWebSearchConfigRaw(config);
if (config) {
@@ -480,7 +321,7 @@ export function useAIState() {
const setCommandBlocklist = useCallback((value: string[]) => {
setCommandBlocklistRaw(value);
localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, value);
// Sync to MCP Server bridge so ACP agents also respect the blocklist
// Sync to MCP Server bridge so SDK agents also respect the blocklist
const bridge = getAIBridge();
bridge?.aiMcpSetCommandBlocklist?.(value);
}, []);
@@ -496,7 +337,7 @@ export function useAIState() {
const setMaxIterations = useCallback((value: number) => {
setMaxIterationsRaw(value);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, value);
// Sync to MCP Server bridge (used by ACP agent path)
// Sync to MCP Server bridge (used by SDK agent path)
const bridge = getAIBridge();
bridge?.aiMcpSetMaxIterations?.(value);
}, []);
@@ -600,6 +441,9 @@ export function useAIState() {
case STORAGE_KEY_AI_AGENT_MODEL_MAP:
setAgentModelMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {});
break;
case STORAGE_KEY_AI_AGENT_PROVIDER_MAP:
setAgentProviderMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP) ?? {});
break;
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP: {
const nextActiveSessionIdMap =
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
@@ -727,7 +571,7 @@ export function useAIState() {
}, [defaultAgentId, persistSessions, setActiveSessionId]);
const deleteSession = useCallback((sessionId: string, scopeKey?: string) => {
cleanupAcpSessions([sessionId]);
cleanupSdkAgentSessions([sessionId]);
if (persistTimerRef.current) {
clearTimeout(persistTimerRef.current);
persistTimerRef.current = null;
@@ -756,7 +600,7 @@ export function useAIState() {
const removedSessionIds = sessionsRef.current
.filter(s => s.scope.type === scopeType && s.scope.targetId === targetId)
.map(s => s.id);
cleanupAcpSessions(removedSessionIds);
cleanupSdkAgentSessions(removedSessionIds);
if (persistTimerRef.current) {
clearTimeout(persistTimerRef.current);
persistTimerRef.current = null;
@@ -1071,7 +915,6 @@ export function useAIState() {
const removeProvider = useCallback((id: string) => {
setProviders(prev => prev.filter(p => p.id !== id));
// Use the raw setter to avoid stale closure over setActiveProviderId
setActiveProviderIdRaw(prevId => {
if (prevId === id) {
const next = '';
@@ -1080,13 +923,25 @@ export function useAIState() {
}
return prevId;
});
const cleanup = removeProviderReferences(
id,
agentProviderMapRef.current,
agentModelMapRef.current,
);
if (cleanup.providerMapChanged) {
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, cleanup.agentProviderMap);
setAgentProviderMapRaw(cleanup.agentProviderMap);
}
if (cleanup.modelMapChanged) {
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, cleanup.agentModelMap);
setAgentModelMapRaw(cleanup.agentModelMap);
}
}, [setProviders]);
// ── Computed ──
const activeProvider = providers.find(p => p.id === activeProviderId) ?? null;
return {
// Provider config
providers,
setProviders,
addProvider,
@@ -1097,38 +952,28 @@ export function useAIState() {
activeModelId,
setActiveModelId,
activeProvider,
// Permission model
globalPermissionMode,
setGlobalPermissionMode,
toolIntegrationMode,
setToolIntegrationMode,
hostPermissions,
setHostPermissions,
// External agents
externalAgents,
setExternalAgents,
defaultAgentId,
setDefaultAgentId,
// Safety
commandBlocklist,
setCommandBlocklist,
commandTimeout,
setCommandTimeout,
maxIterations,
setMaxIterations,
// Per-agent model memory
agentModelMap,
setAgentModel,
// Web search
agentProviderMap,
setAgentProvider,
webSearchConfig,
setWebSearchConfig,
// Sessions (per-scope active session)
sessions,
activeSessionIdMap,
draftsByScope,

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import type { DiscoveredAgent, ExternalAgentConfig } from '../../infrastructure/ai/types';
import { getExternalAgentSdkBackend } from '../../infrastructure/ai/managedAgents';
interface NetcattyBridge {
aiDiscoverAgents(): Promise<DiscoveredAgent[]>;
@@ -52,14 +53,23 @@ export function useAgentDiscovery(
);
if (!match) return ea;
// Check if args or ACP config differ
// Check if args, SDK backend, or Claude's resolved system path differ
const currentArgs = JSON.stringify(ea.args || []);
const newArgs = JSON.stringify(match.args);
const acpChanged = ea.acpCommand !== match.acpCommand
|| JSON.stringify(ea.acpArgs || []) !== JSON.stringify(match.acpArgs || []);
if (currentArgs !== newArgs || acpChanged) {
const backend = match.sdkBackend ?? match.command;
const backendChanged = getExternalAgentSdkBackend(ea) !== backend
|| Boolean(ea.acpCommand)
|| JSON.stringify(ea.acpArgs || []) !== JSON.stringify([]);
const matchPath = match.binPath || match.path;
const env = match.command === 'claude'
? { ...(ea.env ?? {}), CLAUDE_CODE_EXECUTABLE: matchPath }
: ea.env;
const envChanged = match.command === 'claude'
&& ea.env?.CLAUDE_CODE_EXECUTABLE !== matchPath;
if (currentArgs !== newArgs || backendChanged || envChanged) {
changed = true;
return { ...ea, args: match.args, acpCommand: match.acpCommand, acpArgs: match.acpArgs };
const { acpCommand: _legacyCommand, acpArgs: _legacyArgs, ...rest } = ea;
return { ...rest, args: match.args, sdkBackend: backend, ...(env ? { env } : {}) };
}
return ea;
});
@@ -77,15 +87,18 @@ export function useAgentDiscovery(
// Build ExternalAgentConfig from a discovered agent
const enableAgent = useCallback(
(agent: DiscoveredAgent): ExternalAgentConfig => {
const backend = agent.sdkBackend ?? agent.command;
return {
id: `discovered_${agent.command}`,
name: agent.name,
command: agent.path || agent.command,
command: agent.binPath || agent.path || agent.command,
args: agent.args,
icon: agent.icon,
enabled: true,
acpCommand: agent.acpCommand,
acpArgs: agent.acpArgs,
sdkBackend: backend,
...(agent.command === 'claude'
? { env: { CLAUDE_CODE_EXECUTABLE: agent.binPath || agent.path || '' } }
: {}),
};
},
[],

View File

@@ -16,26 +16,39 @@ import {
findSyncPayloadEncryptedCredentialPaths,
} from '../../domain/credentials';
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
import { collectSyncableSettings, hasMeaningfulSyncData } from '../syncPayload';
import { mergeSyncPayloads } from '../../domain/syncMerge';
import { resolveCloudSyncConflictAction } from '../../domain/syncStrategy';
import {
SYNCABLE_SETTING_STORAGE_KEYS,
collectSyncableSettings,
getEffectivePortForwardingRulesForSync,
hasMeaningfulCloudSyncData,
shouldPromptCloudVaultRecovery,
} from '../syncPayload';
import { readInterruptedVaultApply } from '../localVaultBackups';
import {
STORAGE_KEY_PORT_FORWARDING,
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
} from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
import {
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
localStorageAdapter,
} from '../../infrastructure/persistence/localStorageAdapter';
import { notify } from '../notification';
import {
getRuntimeRemoteCheckIntervalMs,
shouldRunRuntimeRemoteCheck,
} from './autoSyncRemoteSchedule';
interface AutoSyncConfig {
// Data to sync
hosts: SyncPayload['hosts'];
keys: SyncPayload['keys'];
identities?: SyncPayload['identities'];
proxyProfiles?: SyncPayload['proxyProfiles'];
snippets: SyncPayload['snippets'];
customGroups: SyncPayload['customGroups'];
snippetPackages?: SyncPayload['snippetPackages'];
portForwardingRules?: SyncPayload['portForwardingRules'];
knownHosts?: SyncPayload['knownHosts'];
groupConfigs?: SyncPayload['groupConfigs'];
/** Opaque token that changes whenever a synced setting changes. */
settingsVersion?: number;
@@ -48,6 +61,7 @@ interface AutoSyncConfig {
// Get manager singleton for direct state access
const manager = getCloudSyncManager();
const AUTO_SYNC_PROVIDER_ORDER: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
const SYNCABLE_SETTING_STORAGE_KEY_SET = new Set<string>(SYNCABLE_SETTING_STORAGE_KEYS);
// Cross-window restore barrier: stored as an epoch-ms deadline. Any value
// in the future means a restore is applying in some window and auto-sync
@@ -88,6 +102,11 @@ interface SyncNowOptions {
trigger?: SyncTrigger;
}
interface RemoteVersionCheckOptions {
force?: boolean;
notifyOnFailure?: boolean;
}
export const useAutoSync = (config: AutoSyncConfig) => {
const { t } = useI18n();
const sync = useCloudSync();
@@ -112,6 +131,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
remotePayload: SyncPayload;
hostCount: number;
keyCount: number;
proxyProfileCount: number;
snippetCount: number;
} | null>(null);
const emptyVaultResolveRef = useRef<((action: 'restore' | 'keep-empty') => void) | null>(null);
@@ -124,44 +144,50 @@ export const useAutoSync = (config: AutoSyncConfig) => {
return () => window.removeEventListener('sftp-bookmarks-changed', handler);
}, []);
const [syncableSettingsStorageVersion, setSyncableSettingsStorageVersion] = useState(0);
useEffect(() => {
const bumpIfSyncableSetting = (key: string | null | undefined) => {
if (!key || !SYNCABLE_SETTING_STORAGE_KEY_SET.has(key)) return;
setSyncableSettingsStorageVersion((v) => v + 1);
};
const handleStorage = (event: StorageEvent) => {
bumpIfSyncableSetting(event.key);
};
const handleLocalStorageAdapterChanged = (event: Event) => {
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
bumpIfSyncableSetting(key);
};
window.addEventListener('storage', handleStorage);
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
return () => {
window.removeEventListener('storage', handleStorage);
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
};
}, []);
const getSyncSnapshot = useCallback(() => {
let effectivePFRules = config.portForwardingRules;
if (!effectivePFRules || effectivePFRules.length === 0) {
const stored = localStorageAdapter.read<SyncPayload['portForwardingRules']>(
STORAGE_KEY_PORT_FORWARDING,
);
if (stored && Array.isArray(stored) && stored.length > 0) {
effectivePFRules = stored.map((rule) => ({
...rule,
status: 'inactive' as const,
error: undefined,
lastUsedAt: undefined,
}));
}
}
const effectiveKnownHosts = getEffectiveKnownHosts(config.knownHosts);
return {
hosts: config.hosts,
keys: config.keys,
identities: config.identities,
proxyProfiles: config.proxyProfiles,
snippets: config.snippets,
customGroups: config.customGroups,
snippetPackages: config.snippetPackages,
portForwardingRules: effectivePFRules,
knownHosts: effectiveKnownHosts,
portForwardingRules: getEffectivePortForwardingRulesForSync(config.portForwardingRules),
groupConfigs: config.groupConfigs,
};
}, [
config.hosts,
config.keys,
config.identities,
config.proxyProfiles,
config.snippets,
config.customGroups,
config.snippetPackages,
config.portForwardingRules,
config.knownHosts,
config.groupConfigs,
]);
@@ -283,7 +309,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// checkRemoteVersion below: if inspect transiently errors we still
// let auto-sync run, trusting this guard to refuse if local is
// truly empty rather than letting an empty state clobber remote.
if (!hasMeaningfulSyncData(payload)) {
if (!hasMeaningfulCloudSyncData(payload)) {
if (trigger === 'auto') {
console.warn('[AutoSync] Blocked: refusing to auto-sync an empty vault to cloud');
return;
@@ -295,15 +321,27 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// Apply merged payloads first (before checking for failures) so local
// state gets updated even when some providers failed
for (const result of results.values()) {
const resultList = Array.from(results.values());
const allProvidersSynced = resultList.length > 0
&& resultList.every((result) => result.success);
for (const result of resultList) {
if (result.mergedPayload) {
await Promise.resolve(onApplyPayload(result.mergedPayload));
skipNextSyncRef.current = true;
if (result.remoteFile) {
await sync.commitRemoteInspection(result.provider, result.remoteFile, result.mergedPayload, {
recordDownload: true,
});
}
skipNextSyncRef.current = allProvidersSynced;
if (!allProvidersSynced) {
console.warn('[AutoSync] Remote payload applied locally, but not every provider synced; leaving next auto-sync enabled for retry.');
}
break; // All providers share the same merged payload
}
}
for (const result of results.values()) {
for (const result of resultList) {
if (!result.success) {
if (result.conflictDetected) {
throw new Error(t('sync.autoSync.conflictDetected'));
@@ -379,6 +417,10 @@ export const useAutoSync = (config: AutoSyncConfig) => {
useEffect(() => {
buildPayloadRef.current = buildPayload;
}, [buildPayload]);
const getDataHashRef = useRef(getDataHash);
useEffect(() => {
getDataHashRef.current = getDataHash;
}, [getDataHash]);
// Serialize `checkRemoteVersion` invocations. Overlapping runs would
// race on `commitRemoteInspection` + `onApplyPayload`: two merges
@@ -388,17 +430,20 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// windows but does NOT serialize same-window re-entry, so this
// in-flight guard closes that gap at the top of the call.
const checkRemoteInFlightRef = useRef(false);
const lastRuntimeRemoteCheckAtRef = useRef<number | null>(null);
// Check remote version and pull if newer (on startup)
const checkRemoteVersion = useCallback(async () => {
const checkRemoteVersion = useCallback(async (options?: RemoteVersionCheckOptions) => {
if (checkRemoteInFlightRef.current) {
return;
}
const force = options?.force === true;
const notifyOnFailure = options?.notifyOnFailure !== false;
const state = manager.getState();
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
const unlocked = state.securityState === 'UNLOCKED';
if (!hasProvider || !unlocked || hasCheckedRemoteRef.current || startupReadyRef.current === false) {
if (!hasProvider || !unlocked || (!force && hasCheckedRemoteRef.current) || startupReadyRef.current === false) {
return;
}
@@ -422,6 +467,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// are consistent with the local vault. Only then should we latch
// hasCheckedRemoteRef so that transient failures are retryable.
let startupConsistent = false;
let markCurrentDataSynced = true;
try {
// Load base BEFORE observing the remote payload (commitRemoteInspection overwrites the base).
const base = await manager.loadSyncBase(connectedProvider);
@@ -437,19 +483,18 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const remoteFile = inspection.remoteFile;
const remotePayload = inspection.payload;
const localPayload = buildPayloadRef.current();
const localIsEmpty = !hasMeaningfulSyncData(localPayload);
const remoteHasData = hasMeaningfulSyncData(remotePayload);
// If local vault is empty but cloud has data, this almost certainly
// means the user's data was lost (update, storage corruption, etc.).
// Pause and ask the user what to do instead of silently merging.
if (localIsEmpty && remoteHasData) {
if (shouldPromptCloudVaultRecovery(localPayload, remotePayload)) {
const userAction = await new Promise<'restore' | 'keep-empty'>((resolve) => {
emptyVaultResolveRef.current = resolve;
setEmptyVaultConflict({
remotePayload,
hostCount: remotePayload.hosts?.length ?? 0,
keyCount: remotePayload.keys?.length ?? 0,
proxyProfileCount: remotePayload.proxyProfiles?.length ?? 0,
snippetCount: remotePayload.snippets?.length ?? 0,
});
});
@@ -463,7 +508,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// remote while local is still empty — the exact overwrite window
// we're trying to close.
await Promise.resolve(onApplyPayloadRef.current(remotePayload));
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload);
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload, {
recordDownload: true,
});
skipNextSyncRef.current = true;
startupConsistent = true;
notify.success(t('sync.autoSync.restoredMessage'), t('sync.autoSync.restoredTitle'));
@@ -479,7 +526,58 @@ export const useAutoSync = (config: AutoSyncConfig) => {
return;
}
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
const conflictAction = resolveCloudSyncConflictAction(state.syncStrategy, {
hasConflict: inspection.remoteChanged,
hasRemoteFile: Boolean(inspection.remoteFile),
});
if (conflictAction === 'download-remote') {
// Apply remote FIRST; only commit anchor/base after the UI-side
// state has accepted the remote payload, matching the empty-vault
// restore ordering above.
await Promise.resolve(onApplyPayloadRef.current(remotePayload));
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload, {
recordDownload: true,
});
startupConsistent = true;
markCurrentDataSynced = false;
const roundTripResults = await manager.syncAllProviders(remotePayload, {
conflictActionOverride: 'upload-local',
});
const roundTripResultList = Array.from(roundTripResults.values());
const wasShrinkBlocked = roundTripResultList.some((result) => result.shrinkBlocked === true);
const roundTripFullySynced = roundTripResultList.length > 0
&& roundTripResultList.every((result) => result.success);
skipNextSyncRef.current = roundTripFullySynced || wasShrinkBlocked;
markCurrentDataSynced = roundTripFullySynced || wasShrinkBlocked;
if (wasShrinkBlocked) {
console.warn('[AutoSync] Cloud-wins round-trip was shrink-blocked; cloud data applied locally, leaving sync blocked for user review.');
} else if (!roundTripFullySynced) {
console.warn('[AutoSync] Cloud-wins round-trip did not update every provider; leaving next auto-sync enabled for retry.');
}
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
return;
}
if (conflictAction === 'upload-local') {
const pushResults = await manager.syncAllProviders(localPayload);
const results = Array.from(pushResults.values());
const allProvidersSynced = results.length > 0
&& results.every((result) => result.success);
const wasShrinkBlocked = results.some((result) => result.shrinkBlocked === true);
if (allProvidersSynced) {
startupConsistent = true;
return;
}
if (wasShrinkBlocked) {
return;
}
throw new Error('Startup local-wins sync failed for one or more providers');
}
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
// Apply merged payload to local state BEFORE committing. If the apply
@@ -491,6 +589,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// local-only state.
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload);
startupConsistent = true;
markCurrentDataSynced = false;
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
// If the three-way merge introduced any local-only additions that the
@@ -509,9 +608,10 @@ export const useAutoSync = (config: AutoSyncConfig) => {
if (mergeResult.payload) {
try {
const roundTripResults = await manager.syncAllProviders(mergeResult.payload);
const wasShrinkBlocked = Array.from(roundTripResults.values()).some(
(r) => r.shrinkBlocked === true,
);
const roundTripResultList = Array.from(roundTripResults.values());
const wasShrinkBlocked = roundTripResultList.some((r) => r.shrinkBlocked === true);
const roundTripFullySynced = roundTripResultList.length > 0
&& roundTripResultList.every((result) => result.success);
if (wasShrinkBlocked) {
// The merged payload is already applied locally and is the source of truth
// for THIS device. The blocking only prevents pushing it to cloud, which
@@ -521,11 +621,15 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// in BLOCKED with no banner visible.
console.warn('[AutoSync] Post-merge round-trip was shrink-blocked; merged data applied locally, reset syncState to IDLE for next attempt.');
manager.clearShrinkBlockedState();
} else if (!roundTripFullySynced) {
console.warn('[AutoSync] Post-merge round-trip did not update every provider; leaving next auto-sync enabled for retry.');
}
// Suppress the debounced follow-up tick that otherwise fires
// once React commits the applied state, since we've just
// already pushed that exact payload upstream.
skipNextSyncRef.current = true;
// already pushed that exact payload upstream. If some provider
// failed, allow the follow-up tick to retry the applied payload.
skipNextSyncRef.current = roundTripFullySynced || wasShrinkBlocked;
markCurrentDataSynced = roundTripFullySynced || wasShrinkBlocked;
} catch (error) {
// Non-fatal: the next user edit will drive another sync cycle.
console.warn('[AutoSync] Post-merge round-trip push failed:', error);
@@ -533,18 +637,28 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
} catch (error) {
console.error('[AutoSync] Failed to check remote version:', error);
// Surface a degraded-sync hint to the user rather than silently
// opening the auto-sync gate. Auto-sync will still retry on next
// data change (see finally block), but without this toast the user
// has no visible signal that startup reconciliation failed.
notify.error(
t('sync.autoSync.inspectFailedMessage'),
t('sync.autoSync.inspectFailedTitle'),
);
if (notifyOnFailure) {
// Surface a degraded-sync hint to the user rather than silently
// opening the auto-sync gate. Auto-sync will still retry on next
// data change (see finally block), but without this toast the user
// has no visible signal that startup reconciliation failed.
notify.error(
t('sync.autoSync.inspectFailedMessage'),
t('sync.autoSync.inspectFailedTitle'),
);
}
// Leave hasCheckedRemoteRef=false so the next startup (or the next
// provider/unlock transition) can retry.
} finally {
if (startupConsistent) {
if (!isInitializedRef.current) {
isInitializedRef.current = true;
}
if (markCurrentDataSynced) {
lastSyncedDataRef.current = getDataHashRef.current();
} else {
lastSyncedDataRef.current = '';
}
hasCheckedRemoteRef.current = true;
// Only open the auto-sync gate when the inspect actually
// validated the remote state. Leaving the gate closed on
@@ -640,7 +754,17 @@ export const useAutoSync = (config: AutoSyncConfig) => {
clearTimeout(syncTimeoutRef.current);
}
};
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow, config.settingsVersion, bookmarksVersion]);
}, [
sync.hasAnyConnectedProvider,
sync.autoSyncEnabled,
sync.isUnlocked,
sync.isSyncing,
getDataHash,
syncNow,
config.settingsVersion,
bookmarksVersion,
syncableSettingsStorageVersion,
]);
// Check remote version on startup/unlock, then retry with backoff
// while the inspect keeps failing. Without the timer-based retry,
@@ -701,12 +825,86 @@ export const useAutoSync = (config: AutoSyncConfig) => {
if (timerId) clearTimeout(timerId);
};
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady, checkRemoteVersion]);
const runRuntimeRemoteCheck = useCallback(async (options?: { force?: boolean }) => {
const now = Date.now();
const minIntervalMs = getRuntimeRemoteCheckIntervalMs(sync.autoSyncInterval);
if (!shouldRunRuntimeRemoteCheck({
hasAnyConnectedProvider: sync.hasAnyConnectedProvider,
autoSyncEnabled: sync.autoSyncEnabled,
isUnlocked: sync.isUnlocked,
startupRemoteCheckDone: remoteCheckDoneRef.current,
isSyncing: sync.isSyncing,
isSyncRunning: isSyncRunningRef.current,
remoteCheckInFlight: checkRemoteInFlightRef.current,
force: options?.force === true,
now,
lastRemoteCheckAt: lastRuntimeRemoteCheckAtRef.current,
minIntervalMs,
})) {
return;
}
lastRuntimeRemoteCheckAtRef.current = now;
await checkRemoteVersion({ force: true, notifyOnFailure: false });
}, [
checkRemoteVersion,
sync.autoSyncEnabled,
sync.autoSyncInterval,
sync.hasAnyConnectedProvider,
sync.isSyncing,
sync.isUnlocked,
]);
// Keep checking the cloud while the app is open. This closes the gap where
// another device uploads changes after our startup inspection but before
// this device edits anything locally.
useEffect(() => {
if (!sync.hasAnyConnectedProvider || !sync.autoSyncEnabled || !sync.isUnlocked) {
return;
}
const intervalMs = getRuntimeRemoteCheckIntervalMs(sync.autoSyncInterval);
const timerId = window.setInterval(() => {
void runRuntimeRemoteCheck();
}, intervalMs);
return () => window.clearInterval(timerId);
}, [
runRuntimeRemoteCheck,
sync.autoSyncEnabled,
sync.autoSyncInterval,
sync.hasAnyConnectedProvider,
sync.isUnlocked,
]);
// Also re-check when the user returns to the app or the network comes back.
useEffect(() => {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void runRuntimeRemoteCheck({ force: true });
}
};
const handleOnline = () => {
void runRuntimeRemoteCheck({ force: true });
};
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('online', handleOnline);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('online', handleOnline);
};
}, [runRuntimeRemoteCheck]);
// Reset check flags when provider disconnects
useEffect(() => {
if (!sync.hasAnyConnectedProvider) {
hasCheckedRemoteRef.current = false;
remoteCheckDoneRef.current = false;
lastRuntimeRemoteCheckAtRef.current = null;
}
}, [sync.hasAnyConnectedProvider]);

View File

@@ -0,0 +1,57 @@
import test from "node:test";
import assert from "node:assert/strict";
import { SYNC_STORAGE_KEYS } from "../../domain/sync.ts";
import { EncryptionService } from "../../infrastructure/services/EncryptionService.ts";
import { handleStorageEventImpl } from "../../infrastructure/services/cloudSync/stateAndSecurityMethods.ts";
test("master key replacement from another window locks the current window and clears the old password", async () => {
const oldConfig = await EncryptionService.createMasterKeyConfig("old-master-password");
const newConfig = await EncryptionService.createMasterKeyConfig("new-master-password");
const fakeStorage = {};
const originalWindow = globalThis.window;
let notifyCount = 0;
let stopAutoSyncCount = 0;
let syncSecurityGenerationCount = 0;
(globalThis as typeof globalThis & { window?: unknown }).window = {
localStorage: fakeStorage,
};
const manager = {
state: {
masterKeyConfig: oldConfig,
securityState: "UNLOCKED",
unlockedKey: await EncryptionService.unlockMasterKey("old-master-password", oldConfig),
},
masterPassword: "old-master-password",
safeJsonParse: (value: string | null) => (value ? JSON.parse(value) : null),
stopAutoSync: () => {
stopAutoSyncCount += 1;
},
bumpSyncSecurityGeneration: () => {
syncSecurityGenerationCount += 1;
},
notifyStateChange: () => {
notifyCount += 1;
},
};
try {
handleStorageEventImpl.call(manager, {
storageArea: fakeStorage,
key: SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG,
newValue: JSON.stringify(newConfig),
} as StorageEvent);
} finally {
(globalThis as typeof globalThis & { window?: unknown }).window = originalWindow;
}
assert.equal(manager.state.masterKeyConfig.verificationHash, newConfig.verificationHash);
assert.equal(manager.state.securityState, "LOCKED");
assert.equal(manager.state.unlockedKey, null);
assert.equal(manager.masterPassword, null);
assert.equal(stopAutoSyncCount, 1);
assert.equal(syncSecurityGenerationCount, 1);
assert.equal(notifyCount, 1);
});

View File

@@ -14,6 +14,8 @@ import {
type ProviderConnection,
type ConflictInfo,
type ConflictResolution,
type RemoteSyncPayload,
type SyncedFile,
type SyncPayload,
type SyncResult,
type SyncHistoryEntry,
@@ -23,6 +25,8 @@ import {
getSyncDotColor,
isProviderReadyForSync,
} from '../../domain/sync';
import type { CloudSyncStrategy } from '../../domain/syncStrategy';
import type { CloudSyncConflictAction } from '../../domain/syncStrategy';
import {
getCloudSyncManager,
type SyncManagerState,
@@ -48,6 +52,7 @@ export interface CloudSyncHook {
deviceName: string;
autoSyncEnabled: boolean;
autoSyncInterval: number;
syncStrategy: CloudSyncStrategy;
localVersion: number;
localUpdatedAt: number;
remoteVersion: number;
@@ -91,10 +96,11 @@ export interface CloudSyncHook {
resetProviderStatus: (provider: CloudProvider) => void;
// Sync Actions
syncNow: (payload: SyncPayload, opts?: { overrideShrink?: boolean }) => Promise<Map<CloudProvider, SyncResult>>;
syncNow: (payload: SyncPayload, opts?: { overrideShrink?: boolean; conflictActionOverride?: CloudSyncConflictAction }) => Promise<Map<CloudProvider, SyncResult>>;
syncToProvider: (provider: CloudProvider, payload: SyncPayload, opts?: { overrideShrink?: boolean }) => Promise<SyncResult>;
downloadFromProvider: (provider: CloudProvider) => Promise<SyncPayload | null>;
resolveConflict: (resolution: ConflictResolution) => Promise<SyncPayload | null>;
downloadFromProvider: (provider: CloudProvider) => Promise<RemoteSyncPayload | null>;
commitRemoteInspection: (provider: CloudProvider, remoteFile: SyncedFile, payload: SyncPayload, opts?: { recordDownload?: boolean }) => Promise<void>;
resolveConflict: (resolution: ConflictResolution) => Promise<RemoteSyncPayload | null>;
// Gist Revision History
getGistRevisionHistory: () => Promise<Array<{ version: string; date: Date }>>;
@@ -113,6 +119,7 @@ export interface CloudSyncHook {
// Settings
setAutoSync: (enabled: boolean, intervalMinutes?: number) => void;
setDeviceName: (name: string) => void;
setSyncStrategy: (strategy: CloudSyncStrategy) => void;
// Local Data Reset
resetLocalVersion: () => void;
@@ -631,6 +638,10 @@ export const useCloudSync = (): CloudSyncHook => {
const setDeviceName = useCallback((name: string) => {
manager.setDeviceName(name);
}, []);
const setSyncStrategy = useCallback((strategy: CloudSyncStrategy) => {
manager.setSyncStrategy(strategy);
}, []);
// ========== Utilities ==========
@@ -661,7 +672,7 @@ export const useCloudSync = (): CloudSyncHook => {
throw new Error('Vault is locked');
}, []);
const syncNowWithUnlock = useCallback(async (payload: SyncPayload, opts?: { overrideShrink?: boolean }) => {
const syncNowWithUnlock = useCallback(async (payload: SyncPayload, opts?: { overrideShrink?: boolean; conflictActionOverride?: CloudSyncConflictAction }) => {
await ensureUnlocked();
return await manager.syncAllProviders(payload, opts);
}, [ensureUnlocked]);
@@ -676,6 +687,16 @@ export const useCloudSync = (): CloudSyncHook => {
return await manager.downloadFromProvider(provider);
}, [ensureUnlocked]);
const commitRemoteInspectionWithUnlock = useCallback(async (
provider: CloudProvider,
remoteFile: SyncedFile,
payload: SyncPayload,
opts: { recordDownload?: boolean } = {},
) => {
await ensureUnlocked();
await manager.commitRemoteInspection(provider, remoteFile, payload, opts);
}, [ensureUnlocked]);
const subscribeToEvents = useCallback(
(callback: SyncEventCallback) => manager.subscribe(callback),
[],
@@ -703,6 +724,7 @@ export const useCloudSync = (): CloudSyncHook => {
deviceName: state.deviceName,
autoSyncEnabled: state.autoSyncEnabled,
autoSyncInterval: state.autoSyncInterval,
syncStrategy: state.syncStrategy,
localVersion: state.localVersion,
localUpdatedAt: state.localUpdatedAt,
remoteVersion: state.remoteVersion,
@@ -738,6 +760,7 @@ export const useCloudSync = (): CloudSyncHook => {
syncNow: syncNowWithUnlock,
syncToProvider: syncToProviderWithUnlock,
downloadFromProvider: downloadFromProviderWithUnlock,
commitRemoteInspection: commitRemoteInspectionWithUnlock,
resolveConflict: resolveConflictWithUnlock,
// Gist Revision History (#679)
@@ -747,6 +770,7 @@ export const useCloudSync = (): CloudSyncHook => {
// Settings
setAutoSync,
setDeviceName,
setSyncStrategy,
// Local Data Reset
resetLocalVersion: () => manager.resetLocalVersion(),

View File

@@ -1,40 +1,5 @@
import { useCallback, useEffect, useRef } from 'react';
import { KeyBinding, matchesKeyBinding } from '../../domain/models';
interface HotkeyActions {
// Tab management
switchToTab: (tabIndex: number) => void;
nextTab: () => void;
prevTab: () => void;
closeTab: () => void;
newTab: () => void;
// Navigation
openHosts: () => void;
openSftp: () => void;
quickSwitch: () => void;
newWorkspace: () => void;
commandPalette: () => void;
portForwarding: () => void;
snippets: () => void;
// Terminal actions (handled per-terminal)
copy: () => void;
paste: () => void;
selectAll: () => void;
clearBuffer: () => void;
searchTerminal: () => void;
// Workspace/split actions
splitHorizontal: () => void;
splitVertical: () => void;
moveFocus: (direction: 'up' | 'down' | 'left' | 'right') => void;
// App features
broadcast: () => void;
openLocal: () => void;
}
// Check if keyboard event matches our app-level shortcuts
// Returns the matched binding action or null
export const checkAppShortcut = (
@@ -71,6 +36,7 @@ export const getAppLevelActions = (): Set<string> => {
'moveFocus',
'broadcast',
'openLocal',
'openSettings',
]);
};
@@ -83,162 +49,8 @@ export const getTerminalPassthroughActions = (): Set<string> => {
'selectAll',
'clearBuffer',
'searchTerminal',
'increaseTerminalFontSize',
'decreaseTerminalFontSize',
'resetTerminalFontSize',
]);
};
interface UseGlobalHotkeysOptions {
hotkeyScheme: 'disabled' | 'mac' | 'pc';
keyBindings: KeyBinding[];
actions: Partial<HotkeyActions>;
orderedTabs: string[];
sessions: { id: string }[];
workspaces: { id: string }[];
isSettingsOpen?: boolean;
}
export const useGlobalHotkeys = ({
hotkeyScheme,
keyBindings,
actions,
orderedTabs,
sessions,
workspaces,
isSettingsOpen = false,
}: UseGlobalHotkeysOptions) => {
const actionsRef = useRef(actions);
actionsRef.current = actions;
const orderedTabsRef = useRef(orderedTabs);
orderedTabsRef.current = orderedTabs;
const sessionsRef = useRef(sessions);
sessionsRef.current = sessions;
const workspacesRef = useRef(workspaces);
workspacesRef.current = workspaces;
const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => {
if (hotkeyScheme === 'disabled') return;
if (isSettingsOpen) return; // Don't handle hotkeys when settings is open
const isMac = hotkeyScheme === 'mac';
const appLevelActions = getAppLevelActions();
// Check if this is an app-level shortcut
const matched = checkAppShortcut(e, keyBindings, isMac);
if (!matched) return;
const { action, binding: _binding } = matched;
// Only handle app-level actions here
// Terminal-level actions are handled by the terminal itself
if (!appLevelActions.has(action)) return;
e.preventDefault();
e.stopPropagation();
const currentActions = actionsRef.current;
switch (action) {
case 'switchToTab': {
const num = parseInt(e.key, 10);
if (num >= 1 && num <= 9) {
currentActions.switchToTab?.(num);
}
break;
}
case 'nextTab':
currentActions.nextTab?.();
break;
case 'prevTab':
currentActions.prevTab?.();
break;
case 'closeTab':
currentActions.closeTab?.();
break;
case 'newTab':
currentActions.newTab?.();
break;
case 'openHosts':
currentActions.openHosts?.();
break;
case 'openSftp':
currentActions.openSftp?.();
break;
case 'openLocal':
currentActions.openLocal?.();
break;
case 'quickSwitch':
currentActions.quickSwitch?.();
break;
case 'newWorkspace':
currentActions.newWorkspace?.();
break;
case 'commandPalette':
currentActions.commandPalette?.();
break;
case 'portForwarding':
currentActions.portForwarding?.();
break;
case 'snippets':
currentActions.snippets?.();
break;
case 'splitHorizontal':
currentActions.splitHorizontal?.();
break;
case 'splitVertical':
currentActions.splitVertical?.();
break;
case 'moveFocus': {
// Determine direction from arrow key
const key = e.key;
if (key === 'ArrowUp') currentActions.moveFocus?.('up');
else if (key === 'ArrowDown') currentActions.moveFocus?.('down');
else if (key === 'ArrowLeft') currentActions.moveFocus?.('left');
else if (key === 'ArrowRight') currentActions.moveFocus?.('right');
break;
}
case 'broadcast':
currentActions.broadcast?.();
break;
}
}, [hotkeyScheme, keyBindings, isSettingsOpen]);
useEffect(() => {
// Use capture phase to intercept before xterm
window.addEventListener('keydown', handleGlobalKeyDown, true);
return () => window.removeEventListener('keydown', handleGlobalKeyDown, true);
}, [handleGlobalKeyDown]);
};
// Helper to create key event handler for xterm's attachCustomKeyEventHandler
// Returns false to let xterm handle the key, true to prevent xterm from handling
export const createXtermKeyHandler = (
keyBindings: KeyBinding[],
isMac: boolean,
onTerminalAction?: (action: string, e: KeyboardEvent) => void
) => {
const appLevelActions = getAppLevelActions();
const terminalActions = getTerminalPassthroughActions();
return (e: KeyboardEvent): boolean => {
const matched = checkAppShortcut(e, keyBindings, isMac);
if (!matched) return true; // Let xterm handle it
const { action } = matched;
// App-level actions: prevent xterm from handling, let global handler take over
if (appLevelActions.has(action)) {
return false; // Don't let xterm handle, will bubble to global handler
}
// Terminal-level actions: handle here and prevent default
if (terminalActions.has(action)) {
e.preventDefault();
e.stopPropagation();
onTerminalAction?.(action, e);
return false;
}
return true; // Let xterm handle other keys
};
};

View File

@@ -17,6 +17,13 @@ export const useKeychainBackend = () => {
timeout?: number;
enableKeyboardInteractive?: boolean;
sessionId?: string;
// Algorithm settings — let the keychain "export public key" flow honor
// the same per-host SSH algorithm config the terminal uses, so a host
// that needs the ECDSA skip / legacy mode / advanced overrides works
// here too.
legacyAlgorithms?: boolean;
skipEcdsaHostKey?: boolean;
algorithmOverrides?: import("../../domain/models").HostAlgorithmOverrides;
}) => {
const bridge = netcattyBridge.get();
if (!bridge?.execCommand) throw new Error("execCommand unavailable");

View File

@@ -0,0 +1,117 @@
import test from "node:test";
import assert from "node:assert/strict";
import { getAutoStartRuleBlockReason, isAutoStartProxyReady } from "./usePortForwardingAutoStart.ts";
import type { GroupConfig, Host, PortForwardingRule, ProxyProfile } from "../../domain/models.ts";
const host = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
...overrides,
});
const proxyProfile = (id: string): ProxyProfile => ({
id,
label: "Proxy",
config: { type: "http", host: "proxy.example.com", port: 3128 },
createdAt: 1,
});
const rule = (overrides: Partial<PortForwardingRule> = {}): PortForwardingRule => ({
id: "rule-1",
label: "Rule",
type: "local",
localPort: 8080,
bindAddress: "127.0.0.1",
remoteHost: "127.0.0.1",
remotePort: 80,
hostId: "host-1",
autoStart: true,
status: "inactive",
createdAt: 1,
...overrides,
});
test("isAutoStartProxyReady waits when a host saved proxy is unresolved", () => {
assert.equal(
isAutoStartProxyReady(
host({ proxyProfileId: "missing-proxy" }),
[],
[],
[],
),
false,
);
});
test("isAutoStartProxyReady waits when a missing host proxy has a group fallback", () => {
const groupConfigs: GroupConfig[] = [{ path: "prod", proxyProfileId: "group-proxy" }];
const currentHost = host({ group: "prod", proxyProfileId: "missing-proxy" });
assert.equal(
isAutoStartProxyReady(
currentHost,
[currentHost],
[proxyProfile("group-proxy")],
groupConfigs,
),
false,
);
});
test("isAutoStartProxyReady waits when a group saved proxy is unresolved", () => {
const groupConfigs: GroupConfig[] = [{ path: "prod", proxyProfileId: "missing-proxy" }];
const currentHost = host({ group: "prod" });
assert.equal(
isAutoStartProxyReady(
currentHost,
[currentHost],
[],
groupConfigs,
),
false,
);
});
test("isAutoStartProxyReady checks group-inherited jump hosts", () => {
const currentHost = host({ group: "prod" });
const jumpHost = host({ id: "jump-1", proxyProfileId: "missing-proxy" });
assert.equal(
isAutoStartProxyReady(
currentHost,
[currentHost, jumpHost],
[],
[{ path: "prod", hostChain: { hostIds: ["jump-1"] } }],
),
false,
);
});
test("getAutoStartRuleBlockReason only blocks the affected rule", () => {
const goodHost = host();
const badHost = host({ id: "host-2", proxyProfileId: "missing-proxy" });
const hosts = [goodHost, badHost];
const isHostAuthReady = () => true;
assert.equal(
getAutoStartRuleBlockReason(rule({ id: "good", hostId: "host-1" }), hosts, [], [], isHostAuthReady),
undefined,
);
assert.equal(
getAutoStartRuleBlockReason(rule({ id: "bad", hostId: "host-2" }), hosts, [], [], isHostAuthReady),
"Proxy or jump host configuration is not ready",
);
});
test("getAutoStartRuleBlockReason marks rules without a host", () => {
assert.equal(
getAutoStartRuleBlockReason(rule({ hostId: undefined }), [], [], [], () => true),
"Rule host is not configured",
);
});

View File

@@ -4,8 +4,9 @@
* when the application starts, not when the user navigates to the port forwarding page.
*/
import { useCallback, useEffect, useRef } from "react";
import { GroupConfig, Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
import { GroupConfig, Host, Identity, PortForwardingRule, ProxyProfile, SSHKey } from "../../domain/models";
import { resolveGroupDefaults, applyGroupDefaults } from "../../domain/groupConfig";
import { materializeHostProxyProfile } from "../../domain/proxyProfiles";
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import {
@@ -17,27 +18,102 @@ import {
import { logger } from "../../lib/logger";
export interface UsePortForwardingAutoStartOptions {
isVaultInitialized: boolean;
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
proxyProfiles: ProxyProfile[];
groupConfigs: GroupConfig[];
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}
const AUTO_START_PROXY_NOT_READY_ERROR = "Proxy or jump host configuration is not ready";
const AUTO_START_AUTH_NOT_READY_ERROR = "Host authentication configuration is not ready";
export const isAutoStartProxyReady = (
host: Host,
allHosts: Host[],
proxyProfiles: ProxyProfile[],
groupConfigs: GroupConfig[],
seen = new Set<string>(),
): boolean => {
if (!host || seen.has(host.id)) return true;
seen.add(host.id);
const validProxyProfileIds: ReadonlySet<string> = new Set(proxyProfiles.map((profile) => profile.id));
const rawGroupDefaults = host.group
? resolveGroupDefaults(host.group, groupConfigs)
: {};
const groupDefaults = host.group
? resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds })
: {};
const missingHostProxyProfile = Boolean(
host.proxyProfileId && !validProxyProfileIds.has(host.proxyProfileId),
);
const missingGroupProxyProfile = Boolean(
!host.proxyConfig &&
!host.proxyProfileId &&
rawGroupDefaults.proxyProfileId &&
!validProxyProfileIds.has(rawGroupDefaults.proxyProfileId),
);
const effectiveHost = applyGroupDefaults(host, groupDefaults, { validProxyProfileIds });
const hasProxyReplacement = Boolean(
effectiveHost.proxyConfig ||
(effectiveHost.proxyProfileId && validProxyProfileIds.has(effectiveHost.proxyProfileId)),
);
if ((missingHostProxyProfile || missingGroupProxyProfile) && !hasProxyReplacement) {
return false;
}
const chainIds = effectiveHost.hostChain?.hostIds || [];
for (const chainId of chainIds) {
const chainHost = allHosts.find((candidate) => candidate.id === chainId);
if (!chainHost) return false;
if (!isAutoStartProxyReady(chainHost, allHosts, proxyProfiles, groupConfigs, seen)) return false;
}
return true;
};
export const getAutoStartRuleBlockReason = (
rule: PortForwardingRule,
hosts: Host[],
proxyProfiles: ProxyProfile[],
groupConfigs: GroupConfig[],
isHostAuthReady: (host: Host) => boolean,
): string | undefined => {
if (!rule.hostId) return "Rule host is not configured";
const host = hosts.find((candidate) => candidate.id === rule.hostId);
if (!host) return "Host not found";
if (!isHostAuthReady(host)) return AUTO_START_AUTH_NOT_READY_ERROR;
if (!isAutoStartProxyReady(host, hosts, proxyProfiles, groupConfigs)) {
return AUTO_START_PROXY_NOT_READY_ERROR;
}
return undefined;
};
/**
* Auto-starts port forwarding rules that have autoStart enabled.
* This hook should be called at the App level to run on app launch.
*/
export const usePortForwardingAutoStart = ({
isVaultInitialized,
hosts,
keys,
identities,
proxyProfiles,
groupConfigs,
terminalSettings,
}: UsePortForwardingAutoStartOptions): void => {
const autoStartExecutedRef = useRef(false);
const hostsRef = useRef<Host[]>(hosts);
const keysRef = useRef<SSHKey[]>(keys);
const identitiesRef = useRef<Identity[]>(identities);
const proxyProfilesRef = useRef<ProxyProfile[]>(proxyProfiles);
const groupConfigsRef = useRef<GroupConfig[]>(groupConfigs);
const terminalSettingsRef = useRef(terminalSettings);
terminalSettingsRef.current = terminalSettings;
const isHostAuthReady = useCallback((host: Host, seen = new Set<string>()): boolean => {
if (!host || seen.has(host.id)) return true;
@@ -77,16 +153,53 @@ export const usePortForwardingAutoStart = ({
identitiesRef.current = identities;
}, [identities]);
useEffect(() => {
proxyProfilesRef.current = proxyProfiles;
}, [proxyProfiles]);
useEffect(() => {
groupConfigsRef.current = groupConfigs;
}, [groupConfigs]);
const resolveEffectiveHost = useCallback((host: Host): Host => {
if (!host.group) return host;
const defaults = resolveGroupDefaults(host.group, groupConfigsRef.current);
return applyGroupDefaults(host, defaults);
const validProxyProfileIds: ReadonlySet<string> = new Set(proxyProfilesRef.current.map((profile) => profile.id));
const withGroupDefaults = host.group
? applyGroupDefaults(
host,
resolveGroupDefaults(host.group, groupConfigsRef.current, { validProxyProfileIds }),
{ validProxyProfileIds },
)
: applyGroupDefaults(host, {}, { validProxyProfileIds });
return materializeHostProxyProfile(withGroupDefaults, proxyProfilesRef.current);
}, []);
const resolveEffectiveHosts = useCallback(
(items: Host[]): Host[] => items.map((host) => resolveEffectiveHost(host)),
[resolveEffectiveHost],
);
const updateStoredRuleStatus = useCallback(
(ruleId: string, status: PortForwardingRule["status"], error?: string) => {
const currentRules = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
) ?? [];
const updatedRules = currentRules.map((rule) =>
rule.id === ruleId
? {
...rule,
status,
error,
lastUsedAt: status === "active" ? Date.now() : rule.lastUsedAt,
}
: rule,
);
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, updatedRules);
},
[],
);
// Set up the reconnect callback
useEffect(() => {
const handleReconnect = async (
@@ -99,40 +212,49 @@ export const usePortForwardingAutoStart = ({
) ?? [];
const rule = rules.find((r) => r.id === ruleId);
if (!rule || !rule.hostId) {
return { success: false, error: "Rule or host not found" };
if (!rule) {
const error = "Rule not found";
onStatusChange("error", error);
return { success: false, error };
}
if (!rule.hostId) {
const error = "Rule host is not configured";
onStatusChange("error", error);
return { success: false, error };
}
const rawHost = hostsRef.current.find((h) => h.id === rule.hostId);
if (!rawHost) {
return { success: false, error: "Host not found" };
const error = "Host not found";
onStatusChange("error", error);
return { success: false, error };
}
const blockReason = getAutoStartRuleBlockReason(
rule,
hostsRef.current,
proxyProfilesRef.current,
groupConfigsRef.current,
(host) => isHostAuthReady(host),
);
if (blockReason) {
onStatusChange("error", blockReason);
return { success: false, error: blockReason };
}
const host = resolveEffectiveHost(rawHost);
return startPortForward(rule, host, hostsRef.current, keysRef.current, identitiesRef.current, onStatusChange, true);
return startPortForward(rule, host, resolveEffectiveHosts(hostsRef.current), keysRef.current, identitiesRef.current, onStatusChange, true, terminalSettingsRef.current);
};
setReconnectCallback(handleReconnect);
return () => {
setReconnectCallback(null);
};
}, [resolveEffectiveHost]);
}, [isHostAuthReady, resolveEffectiveHost, resolveEffectiveHosts]);
// Auto-start rules on app launch
useEffect(() => {
if (autoStartExecutedRef.current) return;
if (hosts.length === 0) return;
const storedRules = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
) ?? [];
const pendingAutoStartRules = storedRules.filter((rule) => rule.autoStart && rule.hostId);
if (pendingAutoStartRules.some((rule) => {
const host = hosts.find((candidate) => candidate.id === rule.hostId);
return !host || !isHostAuthReady(host);
})) {
return;
}
if (!isVaultInitialized) return;
// Mark as executed immediately to prevent duplicate runs
// (React StrictMode or dependency changes could cause re-runs)
@@ -149,7 +271,7 @@ export const usePortForwardingAutoStart = ({
// Only start rules that are not already active
const autoStartRules = rules.filter((r) => {
if (!r.autoStart || !r.hostId) return false;
if (!r.autoStart) return false;
// Check if there's an active connection for this rule
const conn = getActiveConnection(r.id);
// Only start if not already connecting or active
@@ -162,39 +284,49 @@ export const usePortForwardingAutoStart = ({
// Start each auto-start rule
for (const rule of autoStartRules) {
const rawHost = hosts.find((h) => h.id === rule.hostId);
if (rawHost) {
const host = resolveEffectiveHost(rawHost);
void startPortForward(
rule,
host,
hosts,
keys,
identities,
(status, error) => {
// Update the rule status in storage
const currentRules = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
) ?? [];
const updatedRules = currentRules.map((r) =>
r.id === rule.id
? {
...r,
status,
error,
lastUsedAt: status === "active" ? Date.now() : r.lastUsedAt,
}
: r,
);
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, updatedRules);
},
true, // Enable reconnect for auto-start rules
);
const blockReason = getAutoStartRuleBlockReason(
rule,
hosts,
proxyProfiles,
groupConfigs,
(host) => isHostAuthReady(host),
);
if (blockReason) {
updateStoredRuleStatus(rule.id, "error", blockReason);
continue;
}
if (!rawHost) continue;
const host = resolveEffectiveHost(rawHost);
void startPortForward(
rule,
host,
resolveEffectiveHosts(hosts),
keys,
identities,
(status, error) => {
updateStoredRuleStatus(rule.id, status, error);
},
true, // Enable reconnect for auto-start rules
// Read via ref so adjusting global keepalive after launch doesn't
// re-trigger the auto-start effect (its dep array is intentionally
// stable to fire once on vault init).
terminalSettingsRef.current,
);
}
};
void runAutoStart();
}, [hosts, identities, isHostAuthReady, keys, resolveEffectiveHost]);
}, [
groupConfigs,
hosts,
identities,
isHostAuthReady,
isVaultInitialized,
keys,
proxyProfiles,
resolveEffectiveHost,
resolveEffectiveHosts,
updateStoredRuleStatus,
]);
};

View File

@@ -68,6 +68,7 @@ export interface UsePortForwardingStateResult {
identities: Identity[],
onStatusChange?: (status: PortForwardingRule["status"], error?: string) => void,
enableReconnect?: boolean,
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number },
) => Promise<{ success: boolean; error?: string }>;
stopTunnel: (
ruleId: string,
@@ -387,11 +388,12 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
error?: string,
) => void,
enableReconnect = false,
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number },
) => {
return startPortForward(rule, host, hosts, keys, identities, (status, error) => {
setRuleStatus(rule.id, status, error);
onStatusChange?.(status, error ?? undefined);
}, enableReconnect);
}, enableReconnect, terminalSettings);
},
[setRuleStatus],
);

View File

@@ -1,5 +1,7 @@
import { MouseEvent,useCallback,useMemo,useRef,useState } from 'react';
import { ConnectionLog,Host,SerialConfig,Snippet,TerminalSession,Workspace,WorkspaceViewMode } from '../../domain/models';
import { addLogView, getLogViewTabId, removeLogView, type LogView } from './logViewState';
import { createHostTerminalSession, createLocalTerminalSession, createSerialTerminalSession, type LocalTerminalOptions } from './sessionFactories';
import {
appendPaneToWorkspaceRoot,
collectSessionIds,
@@ -9,18 +11,17 @@ FocusDirection,
getNextFocusSessionId,
insertPaneIntoWorkspace,
pruneWorkspaceNode,
reorderWorkspaceFocusSessionOrder,
SplitDirection,
SplitHint,
updateWorkspaceSplitSizes,
} from '../../domain/workspace';
import { activeTabStore } from './activeTabStore';
import {
createCopiedTerminalSessionClone,
createSplitTerminalSessionClone,
} from './terminalConnectionReuse';
// LogView represents an open log replay tab
export interface LogView {
id: string; // Tab ID (log-${connectionLogId})
connectionLogId: string;
log: ConnectionLog;
}
export const useSessionState = () => {
const [sessions, setSessions] = useState<TerminalSession[]>([]);
@@ -45,100 +46,22 @@ export const useSessionState = () => {
// Log views: stores open log replay tabs
const [logViews, setLogViews] = useState<LogView[]>([]);
const createLocalTerminal = useCallback((options?: {
shellType?: TerminalSession['shellType'];
shell?: string;
shellArgs?: string[];
shellName?: string;
shellIcon?: string;
}) => {
const createLocalTerminal = useCallback((options?: LocalTerminalOptions) => {
const sessionId = crypto.randomUUID();
const localHostId = `local-${sessionId}`;
const newSession: TerminalSession = {
id: sessionId,
hostId: localHostId,
hostLabel: options?.shellName || 'Local Terminal',
hostname: 'localhost',
username: 'local',
status: 'connecting',
protocol: 'local',
shellType: options?.shellType,
localShell: options?.shell,
localShellArgs: options?.shellArgs,
localShellName: options?.shellName,
localShellIcon: options?.shellIcon,
};
setSessions(prev => [...prev, newSession]);
setSessions(prev => [...prev, createLocalTerminalSession(sessionId, options)]);
setActiveTabId(sessionId);
return sessionId;
}, [setActiveTabId]);
const createSerialSession = useCallback((config: SerialConfig, options?: { charset?: string }) => {
const sessionId = crypto.randomUUID();
const serialHostId = `serial-${sessionId}`;
const portName = config.path.split('/').pop() || config.path;
const newSession: TerminalSession = {
id: sessionId,
hostId: serialHostId,
hostLabel: `Serial: ${portName}`,
hostname: config.path,
username: '',
status: 'connecting',
protocol: 'serial',
serialConfig: config,
charset: options?.charset,
};
setSessions(prev => [...prev, newSession]);
setSessions(prev => [...prev, createSerialTerminalSession(sessionId, config, options)]);
setActiveTabId(sessionId);
return sessionId;
}, [setActiveTabId]);
const connectToHost = useCallback((host: Host) => {
// Handle serial hosts specially - use createSerialSession for them
if (host.protocol === 'serial') {
// Use stored serialConfig or construct from host data
const serialConfig: SerialConfig = host.serialConfig || {
path: host.hostname,
baudRate: host.port || 115200,
dataBits: 8,
stopBits: 1,
parity: 'none',
flowControl: 'none',
localEcho: false,
lineMode: false,
};
const sessionId = crypto.randomUUID();
const portName = serialConfig.path.split('/').pop() || serialConfig.path;
const newSession: TerminalSession = {
id: sessionId,
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: serialConfig.path,
username: '',
status: 'connecting',
protocol: 'serial',
serialConfig: serialConfig,
charset: host.charset,
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
return sessionId;
}
const newSession: TerminalSession = {
id: crypto.randomUUID(),
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: host.username,
status: 'connecting',
// Store connection-time protocol settings from the host object
protocol: host.protocol,
port: host.port,
moshEnabled: host.moshEnabled,
charset: host.charset,
};
const newSession = createHostTerminalSession(crypto.randomUUID(), host);
setSessions(prev => [...prev, newSession]);
setActiveTabId(newSession.id);
return newSession.id;
@@ -367,6 +290,7 @@ export const useSessionState = () => {
protocol: host.protocol,
port: host.port,
moshEnabled: host.moshEnabled,
etEnabled: host.etEnabled,
charset: host.charset,
};
});
@@ -453,6 +377,7 @@ export const useSessionState = () => {
protocol: host.protocol,
port: host.port,
moshEnabled: host.moshEnabled,
etEnabled: host.etEnabled,
charset: host.charset,
};
});
@@ -556,6 +481,7 @@ export const useSessionState = () => {
protocol: host.protocol,
port: host.port,
moshEnabled: host.moshEnabled,
etEnabled: host.etEnabled,
charset: host.charset,
workspaceId,
};
@@ -655,31 +581,15 @@ export const useSessionState = () => {
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
if (!session) return prevSessions;
const nextShellType = session.protocol === 'local'
? options?.localShellType
: session.shellType;
// If session is already in a workspace, split within that workspace
if (session.workspaceId) {
// Create a new session with the same host
const newSession: TerminalSession = {
const newSession = createSplitTerminalSessionClone(session, {
id: crypto.randomUUID(),
hostId: session.hostId,
hostLabel: session.hostLabel,
hostname: session.hostname,
username: session.username,
status: 'connecting',
localShellType: options?.localShellType,
workspaceId: session.workspaceId,
protocol: session.protocol,
port: session.port,
moshEnabled: session.moshEnabled,
shellType: nextShellType,
charset: session.charset,
localShell: session.localShell,
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
};
});
// Add pane to existing workspace
const hint: SplitHint = {
@@ -699,23 +609,10 @@ export const useSessionState = () => {
}
// Session is standalone - create a new workspace
const newSession: TerminalSession = {
const newSession = createSplitTerminalSessionClone(session, {
id: crypto.randomUUID(),
hostId: session.hostId,
hostLabel: session.hostLabel,
hostname: session.hostname,
username: session.username,
status: 'connecting',
protocol: session.protocol,
port: session.port,
moshEnabled: session.moshEnabled,
shellType: nextShellType,
charset: session.charset,
localShell: session.localShell,
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
};
localShellType: options?.localShellType,
});
const hint: SplitHint = {
direction,
@@ -759,6 +656,27 @@ export const useSessionState = () => {
}));
}, []);
const reorderWorkspaceSessions = useCallback((
workspaceId: string,
draggedSessionId: string,
targetSessionId: string,
position: 'before' | 'after' = 'before',
) => {
setWorkspaces(prev => prev.map(ws => {
if (ws.id !== workspaceId) return ws;
return {
...ws,
focusSessionOrder: reorderWorkspaceFocusSessionOrder(
ws.root,
ws.focusSessionOrder,
draggedSessionId,
targetSessionId,
position,
),
};
}));
}, []);
// Move focus between panes in a workspace
const moveFocusInWorkspace = useCallback((workspaceId: string, direction: FocusDirection): boolean => {
const workspace = workspaces.find(w => w.id === workspaceId);
@@ -791,8 +709,9 @@ export const useSessionState = () => {
}, [workspaces]);
// Run a snippet on multiple target hosts - creates a focus mode workspace
const runSnippet = useCallback((snippet: Snippet, targetHosts: Host[]) => {
const runSnippet = useCallback((snippet: Snippet, targetHosts: Host[], commandOverride?: string) => {
if (targetHosts.length === 0) return;
const resolvedCommand = commandOverride ?? snippet.command;
// Create sessions for each target host
const newSessions: TerminalSession[] = targetHosts.map(host => ({
@@ -820,7 +739,7 @@ export const useSessionState = () => {
...s,
workspaceId: workspace.id,
// Store the command to run after connection
startupCommand: snippet.command,
startupCommand: resolvedCommand,
noAutoRun: snippet.noAutoRun,
}));
@@ -831,36 +750,17 @@ export const useSessionState = () => {
const orphanSessions = useMemo(() => sessions.filter(s => !s.workspaceId), [sessions]);
// Open a log view tab
const openLogView = useCallback((log: ConnectionLog) => {
const tabId = `log-${log.id}`;
// Check if already open
setLogViews(prev => {
if (prev.some(lv => lv.connectionLogId === log.id)) {
// Already open, just switch to it
setActiveTabId(tabId);
return prev;
}
// Open new log view
const newLogView: LogView = {
id: tabId,
connectionLogId: log.id,
log,
};
setActiveTabId(tabId);
return [...prev, newLogView];
});
const tabId = getLogViewTabId(log);
setLogViews(prev => addLogView(prev, log));
setActiveTabId(tabId);
}, [setActiveTabId]);
// Close a log view tab
const closeLogView = useCallback((logViewId: string) => {
setLogViews(prev => {
const updated = prev.filter(lv => lv.id !== logViewId);
// If this was the active tab, switch to vault
const currentActiveTabId = activeTabStore.getActiveTabId();
if (currentActiveTabId === logViewId) {
const fallback = updated.length > 0 ? updated[updated.length - 1].id : 'vault';
setActiveTabId(fallback);
const updated = removeLogView(prev, logViewId);
if (activeTabStore.getActiveTabId() === logViewId) {
setActiveTabId(updated.length > 0 ? updated[updated.length - 1].id : 'vault');
}
return updated;
});
@@ -880,28 +780,10 @@ export const useSessionState = () => {
// update running; in that case skip entirely — do NOT switch the
// active tab or insert into tabOrder, which would leave dangling ids.
if (!session) return prevSessions;
const nextShellType = session.protocol === 'local'
? options?.localShellType
: session.shellType;
const newSession: TerminalSession = {
const newSession = createCopiedTerminalSessionClone(session, {
id: newSessionId,
hostId: session.hostId,
hostLabel: session.hostLabel,
hostname: session.hostname,
username: session.username,
status: 'connecting',
protocol: session.protocol,
port: session.port,
moshEnabled: session.moshEnabled,
shellType: nextShellType,
charset: session.charset,
serialConfig: session.serialConfig,
localShell: session.localShell,
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
};
localShellType: options?.localShellType,
});
// Schedule the activeTab + tabOrder updates only when creation
// actually happens. These nested setStates are idempotent, so
@@ -1049,6 +931,7 @@ export const useSessionState = () => {
splitSession,
toggleWorkspaceViewMode,
setWorkspaceFocusedSession,
reorderWorkspaceSessions,
moveFocusInWorkspace,
runSnippet,
orphanSessions,

View File

@@ -5,6 +5,8 @@ import {
STORAGE_KEY_SYNC,
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_TERM_THEME_DARK,
STORAGE_KEY_TERM_THEME_LIGHT,
STORAGE_KEY_THEME,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
@@ -29,6 +31,7 @@ import {
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_DIR,
STORAGE_KEY_SESSION_LOGS_FORMAT,
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
@@ -39,7 +42,6 @@ import {
STORAGE_KEY_SHOW_SFTP_TAB,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
import {
areCustomKeyBindingsEqual,
nextCustomKeyBindingsSyncVersion,
@@ -49,141 +51,52 @@ import {
shouldApplyIncomingCustomKeyBindingsRecord,
updateCustomKeyBinding as updateCustomKeyBindingRecord,
} from '../../domain/customKeyBindings';
import { getTerminalThemeForUiTheme } from '../../domain/terminalAppearance';
import { TERMINAL_THEME_AUTO } from '../../domain/terminalAppearance';
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
import { UI_FONTS, DEFAULT_UI_FONT_ID } from '../../infrastructure/config/uiFonts';
import { getUiThemeById } from '../../infrastructure/config/uiThemes';
import { DEFAULT_UI_FONT_ID } from '../../infrastructure/config/uiFonts';
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
const DEFAULT_THEME: 'light' | 'dark' | 'system' = 'dark';
/** Resolve the current OS color scheme preference. */
const getSystemPreference = (): 'light' | 'dark' =>
typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
const DEFAULT_LIGHT_UI_THEME = 'snow';
const DEFAULT_DARK_UI_THEME = 'midnight';
const DEFAULT_ACCENT_MODE: 'theme' | 'custom' = 'theme';
const DEFAULT_CUSTOM_ACCENT = '221.2 83.2% 53.3%';
const DEFAULT_TERMINAL_THEME = 'netcatty-dark';
const DEFAULT_FONT_FAMILY = 'menlo';
// Auto-detect default hotkey scheme based on platform
const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
? 'mac'
: 'pc';
const DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR: 'open' | 'transfer' = 'open';
const DEFAULT_SFTP_AUTO_SYNC = false;
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
const DEFAULT_SFTP_AUTO_OPEN_SIDEBAR = false;
const DEFAULT_SFTP_DEFAULT_VIEW_MODE: 'list' | 'tree' = 'list';
const DEFAULT_SHOW_RECENT_HOSTS = true;
const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
const DEFAULT_SHOW_SFTP_TAB = true;
// Editor defaults
const DEFAULT_EDITOR_WORD_WRAP = false;
// Session Logs defaults
const DEFAULT_SESSION_LOGS_ENABLED = false;
const DEFAULT_SESSION_LOGS_FORMAT: SessionLogFormat = 'txt';
const readStoredString = (key: string): string | null => {
const raw = localStorageAdapter.readString(key);
if (!raw) return null;
const trimmed = raw.trim();
if (!trimmed) return null;
try {
const parsed = JSON.parse(trimmed);
return typeof parsed === 'string' ? parsed : trimmed;
} catch {
return trimmed;
}
};
const isValidTheme = (value: unknown): value is 'light' | 'dark' | 'system' => value === 'light' || value === 'dark' || value === 'system';
const isValidHslToken = (value: string): boolean => {
// Expect: "<h> <s>% <l>%", e.g. "221.2 83.2% 53.3%"
return /^\s*\d+(\.\d+)?\s+\d+(\.\d+)?%\s+\d+(\.\d+)?%\s*$/.test(value);
};
const isValidUiThemeId = (theme: 'light' | 'dark', value: string): boolean => {
const list = theme === 'dark' ? DARK_UI_THEMES : LIGHT_UI_THEMES;
return list.some((preset) => preset.id === value);
};
const isValidUiFontId = (value: string): boolean => {
// Local fonts are always considered valid
if (value.startsWith('local-')) return true;
// Check bundled fonts first, then check dynamically loaded fonts
return UI_FONTS.some((font) => font.id === value) ||
uiFontStore.getAvailableFonts().some((font) => font.id === value);
};
const serializeTerminalSettings = (settings: TerminalSettings): string =>
JSON.stringify(settings);
const areTerminalSettingsEqual = (a: TerminalSettings, b: TerminalSettings): boolean =>
serializeTerminalSettings(a) === serializeTerminalSettings(b);
const createCustomKeyBindingsSyncOrigin = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
};
const applyThemeTokens = (
themeSource: 'light' | 'dark' | 'system',
resolvedTheme: 'light' | 'dark',
tokens: UiThemeTokens,
accentMode: 'theme' | 'custom',
accentOverride: string,
) => {
const root = window.document.documentElement;
// If immersive override is active (style tag present), it owns the dark/light class — don't override
if (!document.getElementById('netcatty-immersive-override')) {
root.classList.remove('light', 'dark');
root.classList.add(resolvedTheme);
}
root.style.setProperty('--background', tokens.background);
root.style.setProperty('--foreground', tokens.foreground);
root.style.setProperty('--card', tokens.card);
root.style.setProperty('--card-foreground', tokens.cardForeground);
root.style.setProperty('--popover', tokens.popover);
root.style.setProperty('--popover-foreground', tokens.popoverForeground);
const accentToken = accentMode === 'custom' ? accentOverride : tokens.accent;
const accentLightness = parseFloat(accentToken.split(/\s+/)[2]?.replace('%', '') || '');
const computedAccentForeground = resolvedTheme === 'dark'
? '220 40% 96%'
: (!Number.isNaN(accentLightness) && accentLightness < 55 ? '0 0% 98%' : '222 47% 12%');
root.style.setProperty('--primary', accentToken);
root.style.setProperty('--primary-foreground', accentMode === 'custom' ? computedAccentForeground : tokens.primaryForeground);
root.style.setProperty('--secondary', tokens.secondary);
root.style.setProperty('--secondary-foreground', tokens.secondaryForeground);
root.style.setProperty('--muted', tokens.muted);
root.style.setProperty('--muted-foreground', tokens.mutedForeground);
root.style.setProperty('--accent', accentToken);
root.style.setProperty('--accent-foreground', accentMode === 'custom' ? computedAccentForeground : tokens.accentForeground);
root.style.setProperty('--destructive', tokens.destructive);
root.style.setProperty('--destructive-foreground', tokens.destructiveForeground);
root.style.setProperty('--border', tokens.border);
root.style.setProperty('--input', tokens.input);
root.style.setProperty('--ring', accentToken);
// Sync with native window title bar (Electron)
netcattyBridge.get()?.setTheme?.(themeSource);
netcattyBridge.get()?.setBackgroundColor?.(tokens.background);
};
import {
DEFAULT_ACCENT_MODE,
DEFAULT_CUSTOM_ACCENT,
DEFAULT_DARK_UI_THEME,
DEFAULT_EDITOR_WORD_WRAP,
DEFAULT_FONT_FAMILY,
DEFAULT_HOTKEY_SCHEME,
DEFAULT_LIGHT_UI_THEME,
DEFAULT_SESSION_LOGS_ENABLED,
DEFAULT_SESSION_LOGS_FORMAT,
DEFAULT_SFTP_AUTO_OPEN_SIDEBAR,
DEFAULT_SFTP_AUTO_SYNC,
DEFAULT_SFTP_DEFAULT_VIEW_MODE,
DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR,
DEFAULT_SFTP_SHOW_HIDDEN_FILES,
DEFAULT_SFTP_USE_COMPRESSED_UPLOAD,
DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
DEFAULT_SHOW_RECENT_HOSTS,
DEFAULT_SHOW_SFTP_TAB,
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
DEFAULT_TERMINAL_THEME,
DEFAULT_THEME,
applyThemeTokens,
areTerminalSettingsEqual,
createCustomKeyBindingsSyncOrigin,
getSystemPreference,
isValidHslToken,
isValidTheme,
isValidUiFontId,
isValidUiThemeId,
migrateIncomingTerminalFontId,
readStoredString,
serializeTerminalSettings,
} from './settingsStateDefaults';
import { useSettingsStorageSync } from './settingsStorageSync';
import { useSettingsIpcSync } from './settingsIpcSync';
import { resolveCurrentTerminalTheme } from './settingsTerminalTheme';
import { useSystemSettingsEffects } from './systemSettingsEffects';
export const useSettingsState = () => {
const initialCustomKeyBindingsRecord =
@@ -232,7 +145,16 @@ export const useSettingsState = () => {
const isUpgrade = !!localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
return !isUpgrade;
});
const [terminalFontFamilyId, setTerminalFontFamilyId] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY) || DEFAULT_FONT_FAMILY);
const [terminalThemeDarkId, setTerminalThemeDarkId] = useState<string>(
() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_DARK) || TERMINAL_THEME_AUTO,
);
const [terminalThemeLightId, setTerminalThemeLightId] = useState<string>(
() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_LIGHT) || TERMINAL_THEME_AUTO,
);
const [terminalFontFamilyId, setTerminalFontFamilyId] = useState<string>(() => {
const stored = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
return migrateIncomingTerminalFontId(stored) ?? DEFAULT_FONT_FAMILY;
});
const [terminalFontSize, setTerminalFontSize] = useState<number>(() => localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE) || DEFAULT_FONT_SIZE);
const [uiLanguage, setUiLanguage] = useState<UILanguage>(() => {
const stored = readStoredString(STORAGE_KEY_UI_LANGUAGE);
@@ -320,6 +242,10 @@ export const useSettingsState = () => {
if (stored === 'txt' || stored === 'raw' || stored === 'html') return stored;
return DEFAULT_SESSION_LOGS_FORMAT;
});
const [sshDebugLogsEnabled, setSshDebugLogsEnabled] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED);
return stored === 'true' ? true : DEFAULT_SSH_DEBUG_LOGS_ENABLED;
});
// Global Toggle Window Settings (Quake Mode)
const [toggleWindowHotkey, setToggleWindowHotkey] = useState<string>(() => {
@@ -511,8 +437,13 @@ export const useSettingsState = () => {
// Terminal
const storedTermTheme = readStoredString(STORAGE_KEY_TERM_THEME);
if (storedTermTheme) setTerminalThemeId(storedTermTheme);
const storedTermThemeDark = readStoredString(STORAGE_KEY_TERM_THEME_DARK);
if (storedTermThemeDark) setTerminalThemeDarkId(storedTermThemeDark);
const storedTermThemeLight = readStoredString(STORAGE_KEY_TERM_THEME_LIGHT);
if (storedTermThemeLight) setTerminalThemeLightId(storedTermThemeLight);
const storedTermFont = readStoredString(STORAGE_KEY_TERM_FONT_FAMILY);
if (storedTermFont) setTerminalFontFamilyId(storedTermFont);
const migratedTermFont = migrateIncomingTerminalFontId(storedTermFont);
if (migratedTermFont) setTerminalFontFamilyId(migratedTermFont);
const storedTermSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
if (storedTermSize != null) setTerminalFontSize(storedTermSize);
const storedTermSettings = readStoredString(STORAGE_KEY_TERM_SETTINGS);
@@ -535,6 +466,12 @@ export const useSettingsState = () => {
const storedWrap = readStoredString(STORAGE_KEY_EDITOR_WORD_WRAP);
if (storedWrap === 'true' || storedWrap === 'false') setEditorWordWrapState(storedWrap === 'true');
// SSH diagnostics
const storedSshDebugLogsEnabled = readStoredString(STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED);
if (storedSshDebugLogsEnabled === 'true' || storedSshDebugLogsEnabled === 'false') {
setSshDebugLogsEnabled(storedSshDebugLogsEnabled === 'true');
}
// SFTP
const storedDblClick = readStoredString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
if (storedDblClick === 'open' || storedDblClick === 'transfer') setSftpDoubleClickBehavior(storedDblClick);
@@ -611,116 +548,33 @@ export const useSettingsState = () => {
}
}, [uiFontFamilyId, uiFontsLoaded, notifySettingsChanged]);
// Listen for settings changes from other windows via IPC
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onSettingsChanged) return;
const unsubscribe = bridge.onSettingsChanged((payload) => {
const { key, value } = payload;
if (
key === STORAGE_KEY_THEME ||
key === STORAGE_KEY_UI_THEME_LIGHT ||
key === STORAGE_KEY_UI_THEME_DARK ||
key === STORAGE_KEY_ACCENT_MODE ||
key === STORAGE_KEY_COLOR
) {
syncAppearanceFromStorage();
return;
}
if (key === STORAGE_KEY_UI_LANGUAGE && typeof value === 'string') {
const next = resolveSupportedLocale(value);
setUiLanguage((prev) => (prev === next ? prev : next));
document.documentElement.lang = next;
}
if (key === STORAGE_KEY_CUSTOM_CSS && typeof value === 'string') {
syncCustomCssFromStorage();
}
if (key === STORAGE_KEY_UI_FONT_FAMILY && typeof value === 'string') {
if (isValidUiFontId(value)) {
setUiFontFamilyId(value);
}
}
if (key === STORAGE_KEY_TERM_THEME && typeof value === 'string') {
setTerminalThemeId(value);
}
if (key === STORAGE_KEY_TERM_FOLLOW_APP_THEME) {
const next = value === true || value === 'true';
setFollowAppTerminalThemeState((prev) => (prev === next ? prev : next));
}
if (key === STORAGE_KEY_TERM_FONT_FAMILY && typeof value === 'string') {
setTerminalFontFamilyId(value);
}
if (key === STORAGE_KEY_TERM_FONT_SIZE && typeof value === 'number') {
setTerminalFontSize(value);
}
if (key === STORAGE_KEY_TERM_SETTINGS) {
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value) as Partial<TerminalSettings>;
mergeIncomingTerminalSettings(parsed);
} catch {
// ignore parse errors
}
} else if (value && typeof value === 'object') {
mergeIncomingTerminalSettings(value as Partial<TerminalSettings>);
}
}
if (key === STORAGE_KEY_EDITOR_WORD_WRAP && typeof value === 'boolean') {
setEditorWordWrapState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SESSION_LOGS_ENABLED && typeof value === 'boolean') {
setSessionLogsEnabled((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SESSION_LOGS_DIR && typeof value === 'string') {
setSessionLogsDir((prev) => (prev === value ? prev : value));
}
if (
key === STORAGE_KEY_SESSION_LOGS_FORMAT &&
(value === 'txt' || value === 'raw' || value === 'html')
) {
setSessionLogsFormat((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
setHotkeyScheme(value);
}
if (key === STORAGE_KEY_CUSTOM_KEY_BINDINGS) {
const parsed = parseCustomKeyBindingsStorageRecord(value);
if (parsed) {
applyIncomingCustomKeyBindings(parsed);
}
}
if (key === STORAGE_KEY_HOTKEY_RECORDING && typeof value === 'boolean') {
setIsHotkeyRecordingState(value);
}
if (key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && typeof value === 'boolean') {
setGlobalHotkeyEnabled((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_AUTO_UPDATE_ENABLED && typeof value === 'boolean') {
setAutoUpdateEnabled((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE && typeof value === 'string') {
if (value === 'list' || value === 'tree') {
setSftpDefaultViewMode((prev) => (prev === value ? prev : value));
}
}
if (key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && (value === 'dim' || value === 'border')) {
setWorkspaceFocusStyleState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && typeof value === 'number') {
setSftpTransferConcurrencyState((prev) => (prev === value ? prev : value));
}
});
return () => {
try {
unsubscribe?.();
} catch {
// ignore
}
};
}, [applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings, syncAppearanceFromStorage, syncCustomCssFromStorage]);
useSettingsIpcSync({
syncAppearanceFromStorage,
syncCustomCssFromStorage,
setUiLanguage,
setUiFontFamilyId,
setTerminalThemeId,
setTerminalThemeDarkId,
setTerminalThemeLightId,
setFollowAppTerminalThemeState,
setTerminalFontFamilyId,
setTerminalFontSize,
mergeIncomingTerminalSettings,
setEditorWordWrapState,
setSessionLogsEnabled,
setSessionLogsDir,
setSessionLogsFormat,
setSshDebugLogsEnabled,
setHotkeyScheme,
applyIncomingCustomKeyBindings,
setIsHotkeyRecordingState,
setGlobalHotkeyEnabled,
setAutoUpdateEnabled,
setSftpAutoOpenSidebar,
setSftpDefaultViewMode,
setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState,
});
useEffect(() => {
const bridge = netcattyBridge.get();
@@ -739,237 +593,26 @@ export const useSettingsState = () => {
};
}, []);
// Fix 4: Keep a ref snapshot of current settings so the storage event handler
// can compare without capturing 25+ state variables in its closure / dep array.
// This avoids constant listener detach/reattach on every state change.
const settingsSnapshotRef = useRef({
useSettingsStorageSync({
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSshDebugLogsEnabled,
setGlobalHotkeyEnabled, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
});
settingsSnapshotRef.current = {
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
globalHotkeyEnabled, autoUpdateEnabled,
};
// Listen for storage changes from other windows (cross-window sync)
useEffect(() => {
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());
}
}
if (e.key === STORAGE_KEY_CUSTOM_CSS && e.newValue !== null) {
if (e.newValue !== s.customCSS) {
setCustomCSS(e.newValue);
}
}
if (e.key === STORAGE_KEY_UI_FONT_FAMILY && e.newValue) {
if (isValidUiFontId(e.newValue) && e.newValue !== s.uiFontFamilyId) {
setUiFontFamilyId(e.newValue);
}
}
if (e.key === STORAGE_KEY_HOTKEY_SCHEME && e.newValue) {
const newScheme = e.newValue as HotkeyScheme;
if (newScheme !== s.hotkeyScheme) {
setHotkeyScheme(newScheme);
}
}
if (e.key === STORAGE_KEY_UI_LANGUAGE && e.newValue) {
const next = resolveSupportedLocale(e.newValue);
if (next !== s.uiLanguage) {
setUiLanguage(next as UILanguage);
}
}
if (e.key === STORAGE_KEY_CUSTOM_KEY_BINDINGS && e.newValue) {
const parsed = parseCustomKeyBindingsStorageRecord(e.newValue);
if (parsed) {
applyIncomingCustomKeyBindings(parsed);
}
}
// Sync terminal settings from other windows
if (e.key === STORAGE_KEY_TERM_SETTINGS && e.newValue) {
try {
const newSettings = JSON.parse(e.newValue) as TerminalSettings;
mergeIncomingTerminalSettings(newSettings);
} catch {
// ignore parse errors
}
}
// Sync terminal theme from other windows
if (e.key === STORAGE_KEY_TERM_THEME && e.newValue) {
if (e.newValue !== s.terminalThemeId) {
setTerminalThemeId(e.newValue);
}
}
// Sync follow-app-theme toggle from other windows
if (e.key === STORAGE_KEY_TERM_FOLLOW_APP_THEME && e.newValue) {
const next = e.newValue === 'true';
if (next !== s.followAppTerminalTheme) {
setFollowAppTerminalThemeState(next);
}
}
// Sync terminal font family from other windows
if (e.key === STORAGE_KEY_TERM_FONT_FAMILY && e.newValue) {
if (e.newValue !== s.terminalFontFamilyId) {
setTerminalFontFamilyId(e.newValue);
}
}
// Sync terminal font size from other windows
if (e.key === STORAGE_KEY_TERM_FONT_SIZE && e.newValue) {
const newSize = parseInt(e.newValue, 10);
if (!isNaN(newSize) && newSize !== s.terminalFontSize) {
setTerminalFontSize(newSize);
}
}
// Sync SFTP double-click behavior from other windows
if (e.key === STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR && e.newValue) {
if ((e.newValue === 'open' || e.newValue === 'transfer') && e.newValue !== s.sftpDoubleClickBehavior) {
setSftpDoubleClickBehavior(e.newValue);
}
}
// Sync SFTP auto-sync setting from other windows
if (e.key === STORAGE_KEY_SFTP_AUTO_SYNC && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.sftpAutoSync) {
setSftpAutoSync(newValue);
}
}
// Sync SFTP show hidden files setting from other windows
if (e.key === STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.sftpShowHiddenFiles) {
setSftpShowHiddenFiles(newValue);
}
}
if (e.key === STORAGE_KEY_EDITOR_WORD_WRAP && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.editorWordWrap) {
setEditorWordWrapState(newValue);
}
}
if (e.key === STORAGE_KEY_SESSION_LOGS_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.sessionLogsEnabled) {
setSessionLogsEnabled(newValue);
}
}
if (e.key === STORAGE_KEY_SESSION_LOGS_DIR && e.newValue !== null) {
if (e.newValue !== s.sessionLogsDir) {
setSessionLogsDir(e.newValue);
}
}
if (e.key === STORAGE_KEY_SESSION_LOGS_FORMAT && e.newValue) {
if (
(e.newValue === 'txt' || e.newValue === 'raw' || e.newValue === 'html') &&
e.newValue !== s.sessionLogsFormat
) {
setSessionLogsFormat(e.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';
if (newValue !== s.sftpUseCompressedUpload) {
setSftpUseCompressedUpload(newValue);
}
}
// Sync SFTP auto-open sidebar setting from other windows
if (e.key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.sftpAutoOpenSidebar) {
setSftpAutoOpenSidebar(newValue);
}
}
// Sync SFTP default view mode from other windows
if (e.key === STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE && e.newValue) {
if ((e.newValue === 'list' || e.newValue === 'tree') && e.newValue !== s.sftpDefaultViewMode) {
setSftpDefaultViewMode(e.newValue);
}
}
if (e.key === STORAGE_KEY_SHOW_RECENT_HOSTS && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.showRecentHosts) {
setShowRecentHostsState(newValue);
}
}
if (e.key === STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.showOnlyUngroupedHostsInRoot) {
setShowOnlyUngroupedHostsInRootState(newValue);
}
}
if (e.key === STORAGE_KEY_SHOW_SFTP_TAB && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.showSftpTab) {
setShowSftpTabState(newValue);
}
}
// Sync global hotkey enabled setting from other windows
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.globalHotkeyEnabled) {
setGlobalHotkeyEnabled(newValue);
}
}
// Sync auto-update enabled setting from other windows
if (e.key === STORAGE_KEY_AUTO_UPDATE_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.autoUpdateEnabled) {
setAutoUpdateEnabled(newValue);
}
}
// Sync workspace focus style from other windows
if (e.key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && e.newValue !== null) {
if (e.newValue === 'dim' || e.newValue === 'border') {
setWorkspaceFocusStyleState(e.newValue);
}
}
// Sync transfer concurrency from other windows
if (e.key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && e.newValue !== null) {
const num = Number(e.newValue);
if (num >= 1 && num <= 16) {
setSftpTransferConcurrencyState(num);
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings]); // Fix 4: stable deps only — state comparisons use settingsSnapshotRef
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
@@ -983,6 +626,18 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(followAppTerminalTheme));
}, [followAppTerminalTheme, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_DARK, terminalThemeDarkId);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_THEME_DARK, terminalThemeDarkId);
}, [terminalThemeDarkId, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_LIGHT, terminalThemeLightId);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_THEME_LIGHT, terminalThemeLightId);
}, [terminalThemeLightId, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
if (!persistMountedRef.current) return;
@@ -1137,89 +792,22 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
}, [sessionLogsFormat, notifySettingsChanged]);
// Persist and sync toggle window hotkey setting
useEffect(() => {
// Register/unregister the global hotkey in main process (needed on mount)
const bridge = netcattyBridge.get();
if (bridge?.registerGlobalHotkey) {
if (toggleWindowHotkey && globalHotkeyEnabled) {
setHotkeyRegistrationError(null);
bridge
.registerGlobalHotkey(toggleWindowHotkey)
.then((result) => {
if (result?.success === false) {
console.warn('[GlobalHotkey] Hotkey registration failed:', result.error);
setHotkeyRegistrationError(result.error || 'Failed to register hotkey');
}
})
.catch((err) => {
console.warn('[GlobalHotkey] Failed to register hotkey:', err);
setHotkeyRegistrationError(err?.message || 'Failed to register hotkey');
});
} else {
setHotkeyRegistrationError(null);
bridge.unregisterGlobalHotkey?.().catch((err) => {
console.warn('[GlobalHotkey] Failed to unregister hotkey:', err);
});
}
}
localStorageAdapter.writeString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
// Skip IPC on initial mount
localStorageAdapter.writeString(STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED, sshDebugLogsEnabled ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
}, [toggleWindowHotkey, globalHotkeyEnabled, notifySettingsChanged]);
notifySettingsChanged(STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED, sshDebugLogsEnabled);
}, [sshDebugLogsEnabled, notifySettingsChanged]);
// Persist global hotkey enabled setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled);
}, [globalHotkeyEnabled, notifySettingsChanged]);
// Persist and sync close to tray setting
useEffect(() => {
// Update main process tray behavior (needed on mount)
const bridge = netcattyBridge.get();
if (bridge?.setCloseToTray) {
bridge.setCloseToTray(closeToTray).catch((err) => {
console.warn('[SystemTray] Failed to set close-to-tray:', err);
});
}
localStorageAdapter.writeString(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray ? 'true' : 'false');
// Skip IPC on initial mount
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
}, [closeToTray, notifySettingsChanged]);
// Hydrate auto-update state from the main-process preference file on mount.
// This reconciles localStorage (renderer) with auto-update-pref.json (main)
// in case localStorage was cleared or is stale.
useEffect(() => {
const bridge = netcattyBridge.get();
void bridge?.getAutoUpdate?.().then((result) => {
if (result && typeof result.enabled === 'boolean') {
setAutoUpdateEnabled((prev) => {
if (prev === result.enabled) return prev;
// Sync localStorage with the main-process truth
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, result.enabled ? 'true' : 'false');
return result.enabled;
});
}
}).catch(() => { /* bridge unavailable */ });
}, []);
// Persist auto-update enabled setting.
// Initial mount still writes localStorage, but skips cross-window/main-process IPC.
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled);
// Notify main process on user-initiated changes
const bridge = netcattyBridge.get();
bridge?.setAutoUpdate?.(autoUpdateEnabled).catch((err: unknown) => {
console.warn('[AutoUpdate] Failed to set auto-update:', err);
});
}, [autoUpdateEnabled, notifySettingsChanged]);
useSystemSettingsEffects({
toggleWindowHotkey,
globalHotkeyEnabled,
closeToTray,
autoUpdateEnabled,
persistMountedRef,
setHotkeyRegistrationError,
setAutoUpdateEnabled,
notifySettingsChanged,
});
// Fix 1: Mark all persist effects as mounted.
// This MUST be declared AFTER all persist useEffects so that React runs it last
@@ -1264,21 +852,20 @@ export const useSettingsState = () => {
// Subscribe to custom theme changes so editing in-place triggers re-render
const customThemes = useCustomThemes();
const currentTerminalTheme = useMemo(() => {
// When "Follow Application Theme" is enabled, pick the terminal theme
// whose background matches the active UI theme preset.
if (followAppTerminalTheme) {
const activeUiThemeId = resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId;
const mapped = getTerminalThemeForUiTheme(activeUiThemeId);
if (mapped) {
const found = TERMINAL_THEMES.find(t => t.id === mapped);
if (found) return found;
}
}
return TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|| customThemes.find(t => t.id === terminalThemeId)
|| TERMINAL_THEMES[0];
}, [terminalThemeId, customThemes, followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId]);
const currentTerminalTheme = useMemo(() => resolveCurrentTerminalTheme({
terminalThemeId,
terminalThemeDarkId,
terminalThemeLightId,
customThemes,
followAppTerminalTheme,
resolvedTheme,
lightUiThemeId,
darkUiThemeId,
accentMode,
customAccent,
}), [terminalThemeId, terminalThemeDarkId, terminalThemeLightId, customThemes,
followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId,
accentMode, customAccent]);
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
key: K,
@@ -1315,6 +902,10 @@ export const useSettingsState = () => {
setTerminalThemeId,
followAppTerminalTheme,
setFollowAppTerminalTheme: setFollowAppTerminalThemeState,
terminalThemeDarkId,
setTerminalThemeDarkId,
terminalThemeLightId,
setTerminalThemeLightId,
currentTerminalTheme,
terminalFontFamilyId,
setTerminalFontFamilyId,
@@ -1368,6 +959,8 @@ export const useSettingsState = () => {
setSessionLogsDir,
sessionLogsFormat,
setSessionLogsFormat,
sshDebugLogsEnabled,
setSshDebugLogsEnabled,
// Global Toggle Window (Quake Mode)
toggleWindowHotkey,
setToggleWindowHotkey,
@@ -1391,7 +984,7 @@ export const useSettingsState = () => {
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
customThemes, workspaceFocusStyle,
customThemes, workspaceFocusStyle, sshDebugLogsEnabled,
]),
};
};

View File

@@ -150,6 +150,16 @@ export const useSftpBackend = () => {
return bridge.getHomeDir();
}, []);
const listDrives = useCallback(async () => {
return await netcattyBridge.get()?.listDrives?.() ?? [];
}, []);
const openPath = useCallback(async (path: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.openPath) throw new Error("openPath unavailable");
return bridge.openPath(path);
}, []);
const startStreamTransfer = useCallback(
async (
options: Parameters<NonNullable<NetcattyBridge["startStreamTransfer"]>>[0],
@@ -268,6 +278,8 @@ export const useSftpBackend = () => {
mkdirLocal,
statLocal,
getHomeDir,
listDrives,
openPath,
startStreamTransfer,
cancelTransfer,

View File

@@ -174,6 +174,7 @@ export const useSftpState = (
hosts,
keys,
identities,
terminalSettings: options?.terminalSettings,
leftTabsRef,
rightTabsRef,
leftTabs,
@@ -271,7 +272,7 @@ export const useSftpState = (
const {
transfers,
conflicts,
conflicts: transferConflicts,
activeTransfersCount,
startTransfer,
downloadToLocal,
@@ -282,7 +283,7 @@ export const useSftpState = (
retryTransfer,
clearCompletedTransfers,
dismissTransfer,
resolveConflict,
resolveConflict: resolveTransferConflict,
} = useSftpTransfers({
getActivePane,
getPaneByConnectionId,
@@ -303,11 +304,16 @@ export const useSftpState = (
writeTextFile,
writeTextFileByConnection,
downloadToTempAndOpen,
openWithSystemDefault,
uploadExternalFiles,
uploadExternalFileList,
uploadExternalFolderPath,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
activeFileWatchCountRef,
uploadConflicts,
resolveUploadConflict,
} = useSftpExternalOperations({
getActivePane,
getPaneByConnectionId,
@@ -322,6 +328,21 @@ export const useSftpState = (
dismissExternalUpload: dismissTransfer,
});
const conflicts = useMemo(
() => [...transferConflicts, ...uploadConflicts],
[transferConflicts, uploadConflicts],
);
const resolveAnyConflict = useCallback(
(...args: Parameters<typeof resolveTransferConflict>) => {
const [conflictId] = args;
if (uploadConflicts.some((conflict) => conflict.transferId === conflictId)) {
return resolveUploadConflict(...args);
}
return resolveTransferConflict(...args);
},
[resolveTransferConflict, resolveUploadConflict, uploadConflicts],
);
// Store methods in a ref to create stable wrapper functions
// This prevents callback reference changes from causing re-renders in consumers
const methodsRef = useRef({
@@ -363,7 +384,10 @@ export const useSftpState = (
writeTextFile,
writeTextFileByConnection,
downloadToTempAndOpen,
openWithSystemDefault,
uploadExternalFiles,
uploadExternalFileList,
uploadExternalFolderPath,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
@@ -375,7 +399,7 @@ export const useSftpState = (
retryTransfer,
clearCompletedTransfers,
dismissTransfer,
resolveConflict,
resolveConflict: resolveAnyConflict,
getSftpIdForConnection,
reportSessionError: handleSessionError,
});
@@ -418,7 +442,10 @@ export const useSftpState = (
writeTextFile,
writeTextFileByConnection,
downloadToTempAndOpen,
openWithSystemDefault,
uploadExternalFiles,
uploadExternalFileList,
uploadExternalFolderPath,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
@@ -430,7 +457,7 @@ export const useSftpState = (
retryTransfer,
clearCompletedTransfers,
dismissTransfer,
resolveConflict,
resolveConflict: resolveAnyConflict,
getSftpIdForConnection,
reportSessionError: handleSessionError,
};
@@ -483,7 +510,13 @@ export const useSftpState = (
writeTextFileByConnection: (...args: Parameters<typeof writeTextFileByConnection>) =>
methodsRef.current.writeTextFileByConnection(...args),
downloadToTempAndOpen: (...args: Parameters<typeof downloadToTempAndOpen>) => methodsRef.current.downloadToTempAndOpen(...args),
openWithSystemDefault: (...args: Parameters<typeof openWithSystemDefault>) =>
methodsRef.current.openWithSystemDefault(...args),
uploadExternalFiles: (...args: Parameters<typeof uploadExternalFiles>) => methodsRef.current.uploadExternalFiles(...args),
uploadExternalFileList: (...args: Parameters<typeof uploadExternalFileList>) =>
methodsRef.current.uploadExternalFileList(...args),
uploadExternalFolderPath: (...args: Parameters<typeof uploadExternalFolderPath>) =>
methodsRef.current.uploadExternalFolderPath(...args),
uploadExternalEntries: (...args: Parameters<typeof uploadExternalEntries>) =>
methodsRef.current.uploadExternalEntries(...args),
cancelExternalUpload: () => methodsRef.current.cancelExternalUpload(),
@@ -496,7 +529,7 @@ export const useSftpState = (
retryTransfer: (...args: Parameters<typeof retryTransfer>) => methodsRef.current.retryTransfer(...args),
clearCompletedTransfers: () => methodsRef.current.clearCompletedTransfers(),
dismissTransfer: (...args: Parameters<typeof dismissTransfer>) => methodsRef.current.dismissTransfer(...args),
resolveConflict: (...args: Parameters<typeof resolveConflict>) => methodsRef.current.resolveConflict(...args),
resolveConflict: (...args: Parameters<typeof resolveAnyConflict>) => methodsRef.current.resolveConflict(...args),
getSftpIdForConnection: (...args: Parameters<typeof getSftpIdForConnection>) => methodsRef.current.getSftpIdForConnection(...args),
reportSessionError: (...args: Parameters<typeof handleSessionError>) => methodsRef.current.reportSessionError(...args),
activeFileWatchCountRef,

View File

@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
export const useTerminalBackend = () => {
@@ -12,6 +12,11 @@ export const useTerminalBackend = () => {
return !!bridge?.startMoshSession;
}, []);
const etAvailable = useCallback(() => {
const bridge = netcattyBridge.get();
return !!bridge?.startEtSession;
}, []);
const localAvailable = useCallback(() => {
const bridge = netcattyBridge.get();
return !!bridge?.startLocalSession;
@@ -45,6 +50,12 @@ export const useTerminalBackend = () => {
return bridge.startMoshSession(options);
}, []);
const startEtSession = useCallback(async (options: Parameters<NonNullable<NetcattyBridge["startEtSession"]>>[0]) => {
const bridge = netcattyBridge.get();
if (!bridge?.startEtSession) throw new Error("startEtSession unavailable");
return bridge.startEtSession(options);
}, []);
const startLocalSession = useCallback(async (options: Parameters<NonNullable<NetcattyBridge["startLocalSession"]>>[0]) => {
const bridge = netcattyBridge.get();
if (!bridge?.startLocalSession) throw new Error("startLocalSession unavailable");
@@ -63,9 +74,9 @@ export const useTerminalBackend = () => {
return bridge.execCommand(options);
}, []);
const writeToSession = useCallback((sessionId: string, data: string) => {
const writeToSession = useCallback((sessionId: string, data: string, options?: { automated?: boolean }) => {
const bridge = netcattyBridge.get();
bridge?.writeToSession?.(sessionId, data);
bridge?.writeToSession?.(sessionId, data, options);
}, []);
const resizeSession = useCallback((sessionId: string, cols: number, rows: number) => {
@@ -73,6 +84,11 @@ export const useTerminalBackend = () => {
bridge?.resizeSession?.(sessionId, cols, rows);
}, []);
const setSessionFlowPaused = useCallback((sessionId: string, paused: boolean) => {
const bridge = netcattyBridge.get();
bridge?.setSessionFlowPaused?.(sessionId, paused);
}, []);
const closeSession = useCallback((sessionId: string) => {
const bridge = netcattyBridge.get();
bridge?.closeSession?.(sessionId);
@@ -96,11 +112,43 @@ export const useTerminalBackend = () => {
return bridge.onSessionExit(sessionId, cb);
}, []);
const onTelnetAutoLoginComplete = useCallback((sessionId: string, cb: (evt: { sessionId: string }) => void) => {
const bridge = netcattyBridge.get();
return bridge?.onTelnetAutoLoginComplete?.(sessionId, cb);
}, []);
const onTelnetAutoLoginCancelled = useCallback((sessionId: string, cb: (evt: { sessionId: string }) => void) => {
const bridge = netcattyBridge.get();
return bridge?.onTelnetAutoLoginCancelled?.(sessionId, cb);
}, []);
const onChainProgress = useCallback((cb: (sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void) => {
const bridge = netcattyBridge.get();
return bridge?.onChainProgress?.(cb);
}, []);
const onConnectionReuseFallback = useCallback((cb: (sessionId: string, sourceSessionId?: string) => void) => {
const bridge = netcattyBridge.get();
return bridge?.onConnectionReuseFallback?.(cb);
}, []);
const onHostKeyVerification = useCallback((cb: Parameters<NonNullable<NetcattyBridge["onHostKeyVerification"]>>[0]) => {
const bridge = netcattyBridge.get();
return bridge?.onHostKeyVerification?.(cb);
}, []);
const respondHostKeyVerification = useCallback(async (
requestId: string,
accept: boolean,
addToKnownHosts?: boolean,
) => {
const bridge = netcattyBridge.get();
if (!bridge?.respondHostKeyVerification) {
return { success: false, error: "respondHostKeyVerification unavailable" };
}
return bridge.respondHostKeyVerification(requestId, accept, addToKnownHosts);
}, []);
const openExternal = useCallback(async (url: string) => {
const bridge = netcattyBridge.get();
await bridge?.openExternal?.(url);
@@ -150,32 +198,87 @@ export const useTerminalBackend = () => {
return bridge.getServerStats(sessionId);
}, []);
return {
backendAvailable,
telnetAvailable,
moshAvailable,
localAvailable,
serialAvailable,
execAvailable,
openExternalAvailable,
startSSHSession,
startTelnetSession,
startMoshSession,
startLocalSession,
startSerialSession,
listSerialPorts,
execCommand,
getSessionPwd,
getSessionRemoteInfo,
getSessionDistroInfo,
getServerStats,
writeToSession,
resizeSession,
closeSession,
setSessionEncoding,
onSessionData,
onSessionExit,
onChainProgress,
openExternal,
};
// Memoize the returned object so its identity is stable across the
// hook's lifetime. Each method above is already useCallback([])-stable,
// so listing them as deps means useMemo recomputes once and then
// caches forever. Without this, every render produced a fresh object
// literal — making `terminalBackend` an unstable reference that
// forced consumers' useEffects (`}, [..., terminalBackend])`) to
// rerun on every parent render and forced lint to flag any deeper
// property dep (`}, [terminalBackend.onHostKeyVerification])`) it
// couldn't statically prove safe.
return useMemo(
() => ({
backendAvailable,
telnetAvailable,
moshAvailable,
etAvailable,
localAvailable,
serialAvailable,
execAvailable,
openExternalAvailable,
startSSHSession,
startTelnetSession,
startMoshSession,
startEtSession,
startLocalSession,
startSerialSession,
listSerialPorts,
execCommand,
getSessionPwd,
getSessionRemoteInfo,
getSessionDistroInfo,
getServerStats,
writeToSession,
resizeSession,
setSessionFlowPaused,
closeSession,
setSessionEncoding,
onSessionData,
onSessionExit,
onTelnetAutoLoginComplete,
onTelnetAutoLoginCancelled,
onChainProgress,
onConnectionReuseFallback,
onHostKeyVerification,
respondHostKeyVerification,
openExternal,
}),
[
backendAvailable,
telnetAvailable,
moshAvailable,
etAvailable,
localAvailable,
serialAvailable,
execAvailable,
openExternalAvailable,
startSSHSession,
startTelnetSession,
startMoshSession,
startEtSession,
startLocalSession,
startSerialSession,
listSerialPorts,
execCommand,
getSessionPwd,
getSessionRemoteInfo,
getSessionDistroInfo,
getServerStats,
writeToSession,
resizeSession,
setSessionFlowPaused,
closeSession,
setSessionEncoding,
onSessionData,
onSessionExit,
onTelnetAutoLoginComplete,
onTelnetAutoLoginCancelled,
onChainProgress,
onConnectionReuseFallback,
onHostKeyVerification,
respondHostKeyVerification,
openExternal,
],
);
};

Some files were not shown because too many files have changed in this diff Show More