Compare commits

...

272 Commits

Author SHA1 Message Date
lengyuqu
ea24841939 Fix Windows AI model selection
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
Fix the Windows issue where AI models could not be selected.
2026-06-12 01:42:38 +08:00
陈大猫
bf9f557e42 Fix popup timestamps and sidebar return animation
Hide timestamp controls in popup shell windows and keep the terminal host sidebar width while root pages are shown so returning to terminal tabs does not replay the open animation.
2026-06-12 01:35:50 +08:00
陈大猫
106e748a9b Fix terminal tab theme sync (#1421) 2026-06-12 01:21:28 +08:00
陈大猫
94fff62f9b [codex] Virtualize process manager list
Render only visible process rows while preserving sorting, search, status pills, and row actions.
2026-06-12 00:57:05 +08:00
陈大猫
324253f23a [codex] Optimize terminal side panel performance
Optimize terminal side panel performance, keep process status labels visible, and improve heavy panel loading behavior.
2026-06-12 00:47:17 +08:00
陈大猫
e9a2e44a91 Improve terminal timestamp gutter (#1417) 2026-06-12 00:45:08 +08:00
陈大猫
7b4f046001 [codex] Optimize AI settings agent performance (#1416)
* Optimize AI settings agent performance

* Address AI settings review feedback

* Retry interrupted agent discovery

* Harden agent CLI probe timeout cleanup

* Avoid duplicate agent discovery retries

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

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

* fix(ai): harden Cursor SDK support

* fix(ai): address Cursor SDK review findings

* fix(ai): refresh Cursor environment handling

* fix(ai): refresh Cursor discovery scans

* fix(ai): enable Cursor recheck without path

* Use official Cursor agent icon

* Clarify Cursor SDK setup requirements

* Split Cursor SDK setup status

* Simplify Cursor settings copy

* Improve Cursor API key error

* Add safe Cursor auth diagnostics

* Disable Cursor local sandbox by default

* Show Cursor MCP tool names in tool cards

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(history): address review findings before merge

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

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

---------

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

Closes #1355

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

* fix(settings): flush debounced textarea on unmount

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

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

---------

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

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

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

* chore: drop unused primaryFontFamily from terminal effects context

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

---------

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

Fixes #1101

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

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

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

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

---------

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

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

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

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

Fixes #1323

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

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

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

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

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

* fix(ai): pair replayed tool results chronologically

---------

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

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

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

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

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

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

* perf(terminal): reduce tab switch jank

---------

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

Fixes #1298

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

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

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

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

---------

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

Fixes #1301

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

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

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

Fixes #1287

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

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

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

Two bugs in prompt detection for Chinese-locale users:

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

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

Fixes #1286

* fix: restore OSC stripping pattern broken in previous commit

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 22:52:35 +08:00
陈大猫
31cccdec03 Fix duplicate-tab clone payload dropped on renderer readiness timeout (#1278) 2026-06-06 22:44:39 +08:00
陈大猫
29a6172120 Add duplicate tab to new window
Adds a tab context menu action to duplicate a terminal into an independent peer window, with per-window active-tab titles and multi-window lifecycle safeguards.
2026-06-06 22:13:47 +08:00
陈大猫
06486e06dd Merge pull request #1274 from binaricat/codex/fix-sudo-autofill-prompt-verification
[codex] Fix sudo autofill prompt verification
2026-06-06 21:27:02 +08:00
bincxz
ada55ab461 Fix sudo autofill prompt verification 2026-06-06 21:26:01 +08:00
陈大猫
a9e4de65a9 Merge pull request #1273 from binaricat/codex/host-sudo-password-autofill
Add per-host sudo password autofill
2026-06-06 20:50:52 +08:00
bincxz
2867262e4d Add per-host sudo password autofill 2026-06-06 20:47:16 +08:00
陈大猫
779c09186c Merge pull request #1272 from binaricat/codex/terminal-selection-ai-attach
Add terminal selection AI attachments
2026-06-06 19:08:38 +08:00
bincxz
6a0408b942 Add terminal selection AI attachments 2026-06-06 18:59:48 +08:00
陈大猫
43e094c345 Merge pull request #1269 from binaricat/codex/terminal-log-timestamps
Add terminal and log timestamps
2026-06-06 17:36:03 +08:00
bincxz
7d30b19421 Harden timestamp edge cases 2026-06-06 17:31:41 +08:00
bincxz
e9e8c35178 Add terminal and log timestamps 2026-06-06 17:04:33 +08:00
陈大猫
646e7ce001 fix(tray): update Windows tray click and right-click behaviors to match conventions (#1152) (#1268)
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
lengyuqu
aa1781577b 修复检查无 cli 的问题 2026-06-04 20:57:23 +08:00
lengyuqu
409d293faa 修复缺陷 2026-06-04 20:43:18 +08:00
陈大猫
8181fe71cf fix(ai): make SDK deps optional, degrade gracefully when missing (#1242)
* fix(ai): make SDK deps optional, degrade gracefully when missing

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

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

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

* fixup: guard runTurn against missing SDK modules

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

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

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

Closes #1236

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

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

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

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

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

* fix: tighten context compaction review issues

* fix: preserve tool context during compaction

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

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

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

Treat BrowserWindow instances that do not participate in Netcatty's command-close bridge as regular closable windows. Keep the existing command-close path for the main and settings windows, and add tests that cover both the fallback close behavior and the renderer-capable send path.
2026-06-04 17:20:44 +08:00
陈大猫
3fc56df111 refactor(ai): migrate agent backends from ACP to official SDKs (claude/codex/copilot) (#1229) 2026-06-04 17:11:02 +08:00
lengyuqu
4860581525 Merge branch 'binaricat:main' into codebuddy 2026-06-04 16:17:35 +08:00
陈大猫
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
lengyuqu
d9156349e1 Merge branch 'binaricat:main' into codebuddy 2026-06-01 21:41:08 +08:00
lengyuqu
983b0b2f1d 修复 CodeBuddy 未检测时无法预配置 API Key 的问题
当 discovered_codebuddy 条目不存在时,updateCodebuddyEnv 现在会创建
一个 disabled 的托管条目,允许用户在安装 CLI 前预配置认证信息。
2026-06-01 21:28:44 +08:00
陈大猫
368c31e48d Remove default host seed data
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
lengyuqu
a552c14cbd Delete .qoder directory 2026-06-01 21:06:26 +08:00
lengyuqu
3f5787ceb1 Merge branch 'binaricat:main' into codebuddy 2026-06-01 21:05:51 +08:00
lengyuqu
e4ec2363d0 md 2026-06-01 21:03:53 +08:00
陈大猫
0fa926de26 Polish vault UI (#1177) 2026-06-01 21:03:43 +08:00
lengyuqu
84b71910ee 修复 CodeBuddy CLI 集成缺陷,对齐 Claude/Codex 标准
- 修复 ALLOWED_AGENT_COMMANDS 缺少 codebuddy(非 ACP 回退路径)
- 添加 CODEBUDDY_API_KEY 预检(list-models + stream handler)
- 添加 codebuddyAuthPresence 认证状态检测
- 错误时强制清理 provider 进程(避免僵尸进程)
- 认证失败时显示友好错误提示(引导用户配置 API Key)
2026-06-01 20:55:35 +08:00
lengyuqu
371217832b 移除 CodeBuddy auto 模型预设,新增 tool call 分组折叠
- 移除 CODEBUDDY_MODEL_PRESETS 常量及 getAgentModelPresets 中的 codebuddy 分支(auto 模型实际不可用)
- 新增 ToolCallGroup 组件,支持 Codex 风格可折叠 tool call 分组
- ChatMessageList 将连续 tool 消息、底部 pending calls、内嵌未解析 calls 分组展示
- 流式输出时自动展开,完成后自动折叠,支持手动切换
2026-06-01 20:22:08 +08:00
陈大猫
b9b7db2a4e fix kimi tool stream parsing (#1176) 2026-06-01 19:42:42 +08:00
陈大猫
e3f68e1a3f [codex] fix cloud sync master key refresh (#1175)
* fix cloud sync master key refresh

* Protect first sync from default settings

* Avoid recovery prompt for settings-only sync

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

* fix: preserve tombstones from checked remotes

* fix: keep conflict change counts typed
2026-06-01 18:40:19 +08:00
lengyuqu
afb514b472 md 2026-06-01 18:24:41 +08:00
lengyuqu
e14dc22bba 修正 bug 2026-06-01 18:14:48 +08:00
陈大猫
463dd4464f [codex] add cloud sync strategies (#1171)
* feat: add cloud sync strategies

* chore: clarify cloud sync strategy options

* chore: keep selected sync strategy concise

* fix: sync cloud-wins payload to remaining providers

* fix: apply cloud sync strategy during startup

* fix: retry partial startup cloud syncs
2026-06-01 17:07:07 +08:00
lengyuqu
6b7c12c23c Merge branch 'binaricat:main' into codebuddy 2026-06-01 16:40:44 +08:00
陈大猫
63b95bb68e [codex] add terminal font size shortcuts 2026-06-01 15:25:31 +08:00
陈大猫
ea41389842 Fix Ruijie network device detection (#1168) 2026-06-01 13:52:22 +08:00
Jerry
429cb8d6e9 fix: keep macOS Dock icon consistent with bundle icon (#1166) 2026-06-01 13:05:07 +08:00
陈大猫
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
lengyuqu
222b3869dd 修复 codebuddy 的 acp 路径问题 2026-05-29 15:35:19 +08:00
lengyuqu
56af2d3840 Merge branch 'binaricat:main' into codebuddy 2026-05-29 15:32:07 +08:00
陈大猫
053a976d37 fix(ssh): support PKCS#8 private keys by converting for ssh2 (#1146)
ssh2's key parser only accepts OpenSSH, legacy PKCS#1/SEC1 and PuTTY
keys, so PKCS#8 keys (-----BEGIN PRIVATE KEY----- / BEGIN ENCRYPTED
PRIVATE KEY) fail with "Cannot parse privateKey: Unsupported key
format" even though they are valid and work in other clients such as
Termius. (#1139)

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

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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 15:09:52 +08:00
lengyuqu
1695470089 修复 codebuddy cli 2026-05-28 23:50:51 +08:00
lengyuqu
d4b5f799cb 添加 codebuddy cli 2026-05-28 23:50:15 +08:00
陈大猫
40fb5b62a9 Fix storage change render warning (#1138)
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
898 changed files with 121608 additions and 53723 deletions

118
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,118 @@
name: Bug Report
description: Report a reproducible problem in Netcatty
title: "[Bug] "
labels: ["bug", "triage"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug. Incomplete reports may be closed automatically.
Please search [existing issues](https://github.com/binaricat/Netcatty/issues) first.
- type: dropdown
id: platform
attributes:
label: Operating system
options:
- macOS
- Windows
- Linux
validations:
required: true
- type: input
id: version
attributes:
label: Netcatty version
description: Find it in Settings > Application, or on the [latest release](https://github.com/binaricat/Netcatty/releases/latest) page.
placeholder: "e.g. 1.2.3"
validations:
required: true
- type: dropdown
id: install_source
attributes:
label: How did you install Netcatty?
options:
- GitHub Release (.dmg / .exe / .AppImage / .deb / .rpm / .pacman)
- Homebrew
- Built from source (npm run dev / pack)
- Other
validations:
required: true
- type: dropdown
id: area
attributes:
label: Affected area
multiple: true
options:
- SSH connection / terminal
- SFTP / file browser
- Host vault / keychain
- Port forwarding
- Snippets
- AI assistant
- Settings / sync
- UI / layout
- Crash / app won't start
- Other
validations:
required: true
- type: dropdown
id: reproducibility
attributes:
label: Can you reproduce it?
options:
- Always (100%)
- Often (>50%)
- Sometimes
- Once / not sure
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Numbered steps so we can follow exactly.
placeholder: |
1. Open Netcatty and connect to host X
2. Click SFTP tab
3. ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs / screenshots
description: |
Optional but helpful. Crash logs: Settings > System > Crash Logs > Open folder.
For SSH errors, include redacted connection details (no passwords / private keys).
placeholder: Paste relevant log lines or attach screenshots.
- type: checkboxes
id: checklist
attributes:
label: Before submitting
options:
- label: I searched existing issues and did not find a duplicate
required: true
- label: I removed passwords, private keys, and other secrets from this report
required: true

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Questions & general help
url: https://github.com/binaricat/Netcatty/discussions
about: Not sure if it is a bug? Ask in Discussions first.
- name: Latest release
url: https://github.com/binaricat/Netcatty/releases/latest
about: Check your Netcatty version before reporting.

View File

@@ -0,0 +1,72 @@
name: Feature Request
description: Suggest an improvement or new capability
title: "[Feature] "
labels: ["enhancement", "triage"]
body:
- type: markdown
attributes:
value: |
Describe the problem you are trying to solve and the change you want.
Vague requests like "make it better" may be closed.
- type: textarea
id: problem
attributes:
label: Problem / pain point
description: What is hard, missing, or frustrating today?
placeholder: When I manage 50+ hosts, I cannot ...
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed solution
description: What would you like Netcatty to do?
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Other tools, workarounds, or designs you thought about.
validations:
required: true
- type: dropdown
id: area
attributes:
label: Related area
multiple: true
options:
- SSH / terminal
- SFTP
- Host vault / keychain
- Port forwarding
- Snippets
- AI assistant
- Settings / sync
- UI / UX
- Other
validations:
required: true
- type: dropdown
id: priority
attributes:
label: How important is this to you?
options:
- Nice to have
- Would improve my daily workflow
- Blocking / critical for my use case
validations:
required: true
- type: checkboxes
id: checklist
attributes:
label: Before submitting
options:
- label: I searched existing issues and discussions for similar requests
required: true

View File

@@ -50,6 +50,7 @@ const baseUrl = `https://github.com/${repo}/releases/download/${tag}`;
// - AppImage: x64 -> x86_64, arm64 -> arm64
// - deb: x64 -> amd64, arm64 -> arm64
// - rpm: x64 -> x86_64, arm64 -> aarch64
// - pacman: x64 -> x64, arm64 -> aarch64
const files = {
mac: {
arm64: `Netcatty-${version}-mac-arm64.dmg`,
@@ -70,6 +71,10 @@ const files = {
rpm: {
x64: `Netcatty-${version}-linux-x86_64.rpm`,
arm64: `Netcatty-${version}-linux-aarch64.rpm`
},
pacman: {
x64: `Netcatty-${version}-linux-x64.pacman`,
arm64: `Netcatty-${version}-linux-aarch64.pacman`
}
}
};
@@ -88,7 +93,9 @@ const badges = {
deb_x64: `[![DebPackage x64](https://img.shields.io/badge/DebPackage-x64-A80030?style=flat-square&logo=debian)](${baseUrl}/${files.linux.deb.x64})`,
deb_arm64: `[![DebPackage arm64](https://img.shields.io/badge/DebPackage-arm64-A80030?style=flat-square&logo=debian)](${baseUrl}/${files.linux.deb.arm64})`,
rpm_x64: `[![RpmPackage x64](https://img.shields.io/badge/RpmPackage-x64-CC0000?style=flat-square&logo=redhat)](${baseUrl}/${files.linux.rpm.x64})`,
rpm_arm64: `[![RpmPackage arm64](https://img.shields.io/badge/RpmPackage-arm64-CC0000?style=flat-square&logo=redhat)](${baseUrl}/${files.linux.rpm.arm64})`
rpm_arm64: `[![RpmPackage arm64](https://img.shields.io/badge/RpmPackage-arm64-CC0000?style=flat-square&logo=redhat)](${baseUrl}/${files.linux.rpm.arm64})`,
pacman_x64: `[![ArchPackage x64](https://img.shields.io/badge/ArchPackage-x64-1793D1?style=flat-square&logo=archlinux)](${baseUrl}/${files.linux.pacman.x64})`,
pacman_arm64: `[![ArchPackage arm64](https://img.shields.io/badge/ArchPackage-arm64-1793D1?style=flat-square&logo=archlinux)](${baseUrl}/${files.linux.pacman.arm64})`
}
};
@@ -99,7 +106,7 @@ const content = `
| :--- | :--- |
| **Windows** | ${badges.win.setup_x64} |
| **macOS** | ${badges.mac.apple_silicon} ${badges.mac.intel} |
| **Linux** | ${badges.linux.appimage_x64} ${badges.linux.deb_x64} ${badges.linux.rpm_x64} <br> ${badges.linux.appimage_arm64} ${badges.linux.deb_arm64} ${badges.linux.rpm_arm64} |
| **Linux** | ${badges.linux.appimage_x64} ${badges.linux.deb_x64} ${badges.linux.rpm_x64} ${badges.linux.pacman_x64} <br> ${badges.linux.appimage_arm64} ${badges.linux.deb_arm64} ${badges.linux.rpm_arm64} ${badges.linux.pacman_arm64} |
`;
fs.writeFileSync('release_notes.md', content);

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

@@ -29,6 +29,10 @@ on:
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:
- "**"
@@ -54,6 +58,8 @@ permissions:
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:
@@ -191,9 +197,38 @@ jobs:
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: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && format('deduped build-{0}', matrix.name) || format('build-{0}', matrix.name) }}
needs: [dedupe, resolve-mosh]
needs: [dedupe, resolve-mosh, resolve-et]
if: |
always()
&& needs.dedupe.result == 'success'
@@ -214,6 +249,7 @@ jobs:
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 }}
@@ -230,6 +266,17 @@ jobs:
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
@@ -242,21 +289,6 @@ jobs:
- name: Install deps
run: npm ci
- name: Install cross-platform native binaries
shell: bash
run: |
# npm ci only installs optional deps for the host platform.
# macOS packages still cover both arm64 and x64, so we need
# codex-acp for both architectures there.
# 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
elif [[ "${{ matrix.name }}" == "windows" ]]; then
npm install "@zed-industries/codex-acp-win32-x64@${CODEX_VER}" --no-save --force
fi
- name: Fetch bundled mosh-client
if: env.BUNDLE_MOSH == 'true'
shell: bash
@@ -267,6 +299,16 @@ jobs:
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: |
@@ -306,6 +348,7 @@ jobs:
release/*.AppImage
release/*.deb
release/*.rpm
release/*.pacman
release/*.tar.gz
release/*.yml
release/*.blockmap
@@ -318,7 +361,7 @@ jobs:
# See #264.
build-linux-x64:
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }}
needs: [dedupe, resolve-mosh]
needs: [dedupe, resolve-mosh, resolve-et]
if: |
always()
&& needs.dedupe.result == 'success'
@@ -326,6 +369,7 @@ jobs:
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 }}
@@ -344,6 +388,17 @@ jobs:
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
@@ -356,6 +411,9 @@ jobs:
- name: Install deps
run: npm ci
- name: Install pacman packaging dependencies
run: sudo apt-get update && sudo apt-get install -y libarchive-tools
- name: Set version
shell: bash
run: |
@@ -379,6 +437,10 @@ jobs:
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
@@ -399,6 +461,7 @@ jobs:
release/*.AppImage
release/*.deb
release/*.rpm
release/*.pacman
release/*.yml
release/*.blockmap
if-no-files-found: ignore
@@ -408,7 +471,7 @@ jobs:
# Key: GLIBC < 2.34 avoids the libpthread-merge symbol requirement.
build-linux-arm64:
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }}
needs: [dedupe, resolve-mosh]
needs: [dedupe, resolve-mosh, resolve-et]
if: |
always()
&& needs.dedupe.result == 'success'
@@ -418,6 +481,7 @@ jobs:
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 }}
@@ -436,10 +500,22 @@ jobs:
exit 1
fi
- name: Validate bundled et-client release
if: env.BUNDLE_ET == 'true'
shell: bash
env:
RESOLVE_ET_RESULT: ${{ needs.resolve-et.result }}
run: |
if [[ "$RESOLVE_ET_RESULT" != "success" || -z "$ET_BIN_RELEASE" ]]; then
echo "::error::Bundled et-client release was not resolved for this package build."
exit 1
fi
- name: Install build dependencies
run: |
apt-get update
apt-get install -y curl build-essential python3 git libfuse2 file rpm \
libarchive-tools \
libglib2.0-0 libgtk-3-0 libnss3 libxss1 libxtst6 libasound2 \
libatk-bridge2.0-0 libdrm2 libgbm1 libx11-xcb1 libxcb-dri3-0
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
@@ -474,6 +550,10 @@ jobs:
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
@@ -494,6 +574,7 @@ jobs:
release/*.AppImage
release/*.deb
release/*.rpm
release/*.pacman
release/*.yml
release/*.blockmap
if-no-files-found: ignore
@@ -599,6 +680,7 @@ jobs:
artifacts/*.AppImage
artifacts/*.deb
artifacts/*.rpm
artifacts/*.pacman
artifacts/*.yml
artifacts/*.blockmap
generate_release_notes: true

139
.github/workflows/issue-format.yml vendored Normal file
View File

@@ -0,0 +1,139 @@
name: issue-format
on:
issues:
types: [opened, edited]
permissions:
issues: write
jobs:
validate:
runs-on: ubuntu-latest
# Skip issues opened by bots (e.g. dependabot) and maintainers fixing format
if: >-
github.event.issue.user.type != 'Bot' &&
!contains(github.event.issue.labels.*.name, 'format-exempt')
steps:
- name: Validate title and body
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
const title = issue.title.trim();
const body = (issue.body || '').trim();
const errors = [];
const modernTitle = /^\[(Bug|Feature)\] .{8,}/.test(title);
const legacyAppTitle = /^Bug:\s*.{5,}/i.test(title);
if (!modernTitle && !legacyAppTitle) {
errors.push(
'Title must start with `[Bug]` or `[Feature]` followed by a short summary (at least 8 characters after the prefix). Legacy app links using `Bug: ...` are also accepted. Example: `[Bug] SFTP upload fails on Windows`'
);
}
if (body.length < 120) {
errors.push(
'Body is too short. Please use the Bug Report or Feature Request template and fill in all required fields.'
);
}
const templateMarkers = [
'Steps to reproduce',
'Expected behavior',
'Actual behavior',
'Describe the problem',
'Problem / pain point',
'Proposed solution',
'Operating system',
];
const hasTemplateStructure = templateMarkers.some((marker) =>
body.includes(marker)
);
if (!hasTemplateStructure) {
errors.push(
'Body does not look like it came from an issue template. Choose **Bug Report** or **Feature Request** when opening an issue.'
);
}
const labels = new Set(
(issue.labels || []).map((label) =>
typeof label === 'string' ? label : label.name
)
);
if (errors.length === 0) {
if (
issue.state === 'closed' &&
labels.has('invalid-format')
) {
labels.delete('invalid-format');
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: 'open',
labels: [...labels],
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: '<!-- issue-format-bot --> Format looks good now. Reopening this issue.',
});
}
core.info('Issue format OK');
return;
}
const issueNumber = issue.number;
const marker = '<!-- issue-format-bot -->';
const bodyText = [
marker,
'## Issue format check failed',
'',
'This issue was closed automatically because it does not follow the required format.',
'',
...errors.map((e) => `- ${e}`),
'',
'### How to resubmit',
'',
'1. Go to [New Issue](https://github.com/binaricat/Netcatty/issues/new/choose)',
'2. Pick **Bug Report** or **Feature Request**',
'3. Fill in every required field',
'4. Keep the `[Bug]` or `[Feature]` prefix in the title and add a clear summary after it (older app versions may use `Bug: ...`)',
'',
'For questions and open-ended discussion, use [GitHub Discussions](https://github.com/binaricat/Netcatty/discussions) instead.',
'',
'If you believe this was a mistake, reply here after fixing the title/body and a maintainer can reopen.',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
per_page: 100,
});
const alreadyNotified = comments.some((c) =>
(c.body || '').includes(marker)
);
if (!alreadyNotified) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: bodyText,
});
}
labels.add('invalid-format');
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
state: 'closed',
state_reason: 'not_planned',
labels: [...labels],
});

20
.gitignore vendored
View File

@@ -8,6 +8,7 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
@@ -40,7 +41,15 @@ coverage
# Codex
/.codex/
/CLAUDE.md
# Qoder
.qoder
# Workbuddy
.workbuddy
# Codebuddy
.codebuddy
# AI / Superpowers generated docs (local only)
/docs/superpowers/
@@ -74,3 +83,12 @@ build_with_vs2022.bat
/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

1759
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,171 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { executeHotkeyActionImpl, handleGlobalHotkeyKeyDownImpl } from './app/AppHandlers.ts';
import { matchesKeyBinding } from '../domain/models.ts';
import { DEFAULT_KEY_BINDINGS } from '../domain/models/keyBindings.ts';
class FakeInputHTMLElement {
tagName = 'INPUT';
isContentEditable = false;
closest(): FakeInputHTMLElement | null {
return null;
}
}
class FakeHTMLElement {
tagName = 'TEXTAREA';
isContentEditable = false;
classList = {
contains: (className: string) => className === 'xterm-helper-textarea',
};
closest(selector: string): FakeHTMLElement | null {
return selector.includes('xterm') ? this : null;
}
hasAttribute(name: string): boolean {
return name === 'data-session-id';
}
}
const previousHTMLElement = globalThis.HTMLElement;
globalThis.HTMLElement = FakeHTMLElement as unknown as typeof HTMLElement;
test.after(() => {
globalThis.HTMLElement = previousHTMLElement;
});
test('global hotkey handler lets terminal font size shortcuts reach xterm', () => {
const target = new FakeHTMLElement();
const handledActions: string[] = [];
let prevented = false;
let stopped = false;
const event = {
key: '=',
code: 'Equal',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
target,
composedPath: () => [target],
preventDefault: () => {
prevented = true;
},
stopPropagation: () => {
stopped = true;
},
} as unknown as KeyboardEvent;
handleGlobalHotkeyKeyDownImpl(
() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: 'Ctrl + W',
executeHotkeyAction: (action: string) => {
handledActions.push(action);
},
hotkeyScheme: 'pc',
keyBindings: DEFAULT_KEY_BINDINGS,
matchesKeyBinding,
}),
event,
);
assert.deepEqual(handledActions, []);
assert.equal(prevented, false);
assert.equal(stopped, false);
});
test('global hotkey handler routes quick switch through focused search inputs', () => {
const target = new FakeInputHTMLElement();
const handledActions: string[] = [];
const event = {
key: 'j',
code: 'KeyJ',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
target,
composedPath: () => [target],
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as KeyboardEvent;
handleGlobalHotkeyKeyDownImpl(
() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: 'Ctrl + W',
executeHotkeyAction: (action: string) => {
handledActions.push(action);
},
hotkeyScheme: 'pc',
keyBindings: DEFAULT_KEY_BINDINGS,
matchesKeyBinding,
}),
event,
);
assert.deepEqual(handledActions, ['quickSwitch']);
});
test('quick switch hotkey toggles the quick switcher open state', () => {
let isQuickSwitcherOpen = false;
const setIsQuickSwitcherOpen = (next: boolean) => {
isQuickSwitcherOpen = next;
};
const noop = () => {};
const baseCtx = {
IS_DEV: false,
MOVE_FOCUS_DEBOUNCE_MS: 0,
activeTabStore: { getActiveTabId: () => 'vault' },
addConnectionLogRef: { current: noop },
closeSession: noop,
closeTabInFlightRef: { current: false },
closeWorkspace: noop,
collectSessionIds: () => [],
confirmIfBusyLocalTerminal: async () => true,
createLocalTerminalWithCurrentShell: noop,
editorTabs: [],
fromEditorTabId: () => null,
handleOpenSettingsRef: { current: noop },
handleRequestCloseEditorTabRef: { current: noop },
isEditorTabId: () => false,
isQuickSwitcherOpen,
lastMoveFocusTimeRef: { current: 0 },
moveFocusInWorkspace: noop,
orderedTabs: [],
resolveCloseIntent: () => ({ kind: 'noop' }),
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
sessions: [],
setActiveTabId: noop,
setAddToWorkspaceDialog: noop,
setIsQuickSwitcherOpen,
setNavigateToSection: noop,
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: false },
splitSessionWithCurrentShell: noop,
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
toEditorTabId: (id: string) => `editor:${id}`,
toggleBroadcast: noop,
toggleScriptsSidePanelRef: { current: noop },
toggleSidePanelRef: { current: noop },
workspaces: [],
};
const event = {
key: 'j',
code: 'KeyJ',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
} as KeyboardEvent;
executeHotkeyActionImpl(() => baseCtx, 'quickSwitch', event);
assert.equal(isQuickSwitcherOpen, true);
executeHotkeyActionImpl(() => ({ ...baseCtx, isQuickSwitcherOpen: true }), 'quickSwitch', event);
assert.equal(isQuickSwitcherOpen, false);
});

View File

@@ -0,0 +1,153 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { TerminalSession } from "../domain/models";
import { copySessionToNewWindowWithCurrentShellImpl } from "./app/AppHandlers";
const sourceSession = (overrides: Partial<TerminalSession> = {}): TerminalSession => ({
id: "session-1",
hostId: "host-1",
hostLabel: "Prod SSH",
hostname: "prod.example.com",
username: "deploy",
status: "connected",
protocol: "ssh",
port: 22,
...overrides,
});
test("copySessionToNewWindowWithCurrentShellImpl asks Electron to open a peer window for the selected session", async () => {
const openedPayloads: unknown[] = [];
await copySessionToNewWindowWithCurrentShellImpl(
() => ({
classifyLocalShellType: () => "zsh",
discoveredShells: [],
netcattyBridge: {
get: () => ({
openSessionInNewWindow: async (payload: unknown) => {
openedPayloads.push(payload);
return { success: true };
},
}),
},
resolveShellSetting: () => ({ command: "/bin/zsh" }),
sessions: [sourceSession()],
terminalSettings: { localShell: "system-default" },
}),
"session-1",
);
assert.equal(openedPayloads.length, 1);
assert.deepEqual(openedPayloads[0], {
title: "Prod SSH",
sourceSession: sourceSession(),
localShellType: "zsh",
});
});
test("copySessionToNewWindowWithCurrentShellImpl does nothing when the source session is gone", async () => {
let called = false;
await copySessionToNewWindowWithCurrentShellImpl(
() => ({
classifyLocalShellType: () => "zsh",
discoveredShells: [],
netcattyBridge: {
get: () => ({
openSessionInNewWindow: async () => {
called = true;
return { success: true };
},
}),
},
resolveShellSetting: () => ({ command: "/bin/zsh" }),
sessions: [],
terminalSettings: { localShell: "system-default" },
}),
"missing-session",
);
assert.equal(called, false);
});
test("copySessionToNewWindowWithCurrentShellImpl shows an error when Electron cannot open the window", async () => {
const errors: string[] = [];
const result = await copySessionToNewWindowWithCurrentShellImpl(
() => ({
classifyLocalShellType: () => "zsh",
discoveredShells: [],
netcattyBridge: {
get: () => ({
openSessionInNewWindow: async () => ({ success: false }),
}),
},
resolveShellSetting: () => ({ command: "/bin/zsh" }),
sessions: [sourceSession()],
terminalSettings: { localShell: "system-default" },
t: (key: string) => key === "tabs.copyTabToNewWindowFailed" ? "Could not open" : key,
toast: {
error: (message: string) => errors.push(message),
},
}),
"session-1",
);
assert.equal(result, false);
assert.deepEqual(errors, ["Could not open"]);
});
test("copySessionToNewWindowWithCurrentShellImpl shows an error when the bridge is unavailable", async () => {
const errors: string[] = [];
const result = await copySessionToNewWindowWithCurrentShellImpl(
() => ({
classifyLocalShellType: () => "zsh",
discoveredShells: [],
netcattyBridge: {
get: () => ({}),
},
resolveShellSetting: () => ({ command: "/bin/zsh" }),
sessions: [sourceSession()],
terminalSettings: { localShell: "system-default" },
t: (key: string) => key === "tabs.copyTabToNewWindowFailed" ? "Could not open" : key,
toast: {
error: (message: string) => errors.push(message),
},
}),
"session-1",
);
assert.equal(result, false);
assert.deepEqual(errors, ["Could not open"]);
});
test("copySessionToNewWindowWithCurrentShellImpl shows an error when the bridge throws", async () => {
const errors: string[] = [];
const result = await copySessionToNewWindowWithCurrentShellImpl(
() => ({
classifyLocalShellType: () => "zsh",
discoveredShells: [],
netcattyBridge: {
get: () => ({
openSessionInNewWindow: async () => {
throw new Error("boom");
},
}),
},
resolveShellSetting: () => ({ command: "/bin/zsh" }),
sessions: [sourceSession()],
terminalSettings: { localShell: "system-default" },
t: (key: string) => key === "tabs.copyTabToNewWindowFailed" ? "Could not open" : key,
toast: {
error: (message: string) => errors.push(message),
},
}),
"session-1",
);
assert.equal(result, false);
assert.deepEqual(errors, ["Could not open"]);
});

View File

@@ -0,0 +1,142 @@
import { useEffect, useMemo } from 'react';
import {
fromEditorTabId,
isEditorTabId,
useActiveTabId,
} from '../state/activeTabStore';
import { updateActiveChromeThemeDeps } from '../state/activeChromeThemeSync';
import { useActiveChromeTheme } from '../state/useActiveChromeTheme';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { resolveActiveChromeTheme } from './activeChromeTheme';
import type {
Host,
TerminalSession,
TerminalTheme,
Workspace,
} from '../../types';
import type { LogView } from '../state/logViewState';
import type { EditorTab } from '../state/editorTabStore';
interface AppActiveTabChromeProps {
showSftpTab: boolean;
setActiveTabId: (id: string) => void;
applyAppTheme: () => void;
hostById: Map<string, Host>;
sessionById: Map<string, TerminalSession>;
themeById: Map<string, TerminalTheme>;
workspaceById: Map<string, Workspace>;
currentTerminalTheme: TerminalTheme;
followAppTerminalTheme: boolean;
accentMode: 'theme' | 'custom';
customAccent: string;
editorTabs: readonly EditorTab[];
logViews: readonly LogView[];
t: (key: string) => string;
}
/**
* Owns the `activeTabId` subscription and the purely side-effectful "chrome"
* work derived from it: window title and the SFTP-tab guard.
* Extracted out of <App> so that switching top tabs only
* re-renders this null-rendering component (and the self-subscribing leaves)
* instead of forcing the entire App tree (which holds all vault/session/
* settings state and rebuilds the giant AppView ctx) to re-render.
*/
export function AppActiveTabChrome({
showSftpTab,
setActiveTabId,
applyAppTheme,
hostById,
sessionById,
themeById,
workspaceById,
currentTerminalTheme,
followAppTerminalTheme,
accentMode,
customAccent,
editorTabs,
logViews,
t,
}: AppActiveTabChromeProps) {
const activeTabId = useActiveTabId();
useEffect(() => {
if (!showSftpTab && activeTabId === 'sftp') {
setActiveTabId('vault');
}
}, [showSftpTab, activeTabId, setActiveTabId]);
const chromeThemeDeps = useMemo(() => ({
accentMode,
applyAppTheme,
currentTerminalTheme,
customAccent,
editorTabs,
followAppTerminalTheme,
hostById,
logViews,
sessionById,
themeById,
workspaceById,
}), [
accentMode,
applyAppTheme,
currentTerminalTheme,
customAccent,
editorTabs,
followAppTerminalTheme,
hostById,
logViews,
sessionById,
themeById,
workspaceById,
]);
updateActiveChromeThemeDeps(chromeThemeDeps);
const activeChromeTheme = useMemo(() => resolveActiveChromeTheme({
...chromeThemeDeps,
activeTabId,
}), [chromeThemeDeps, activeTabId]);
useActiveChromeTheme({
activeTheme: activeChromeTheme,
applyAppTheme,
});
const editorTabFileNameCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const tab of editorTabs) counts.set(tab.fileName, (counts.get(tab.fileName) ?? 0) + 1);
return counts;
}, [editorTabs]);
const activeWindowTitle = useMemo(() => {
if (activeTabId === 'vault') return 'Vaults';
if (activeTabId === 'sftp') return 'SFTP';
if (isEditorTabId(activeTabId)) {
const editorTab = editorTabs.find((tab) => tab.id === fromEditorTabId(activeTabId));
if (!editorTab) return 'Editor';
const suffix = (editorTabFileNameCounts.get(editorTab.fileName) ?? 0) > 1
? ` · ${editorTab.remotePath.split('/').slice(-2, -1)[0] || '/'}`
: '';
return `${editorTab.fileName}${suffix}`;
}
const workspace = workspaceById.get(activeTabId);
if (workspace) return workspace.title;
const session = sessionById.get(activeTabId);
if (session) return session.hostLabel;
const logView = logViews.find((item) => item.id === activeTabId);
if (logView) {
const isLocal = logView.log.protocol === 'local' || logView.log.hostname === 'localhost';
return `${t('tabs.logPrefix')} ${isLocal ? t('tabs.logLocal') : logView.log.hostname}`;
}
return 'Netcatty';
}, [activeTabId, editorTabFileNameCounts, editorTabs, logViews, sessionById, t, workspaceById]);
useEffect(() => {
void netcattyBridge.get()?.setWindowTitle?.(activeWindowTitle);
}, [activeWindowTitle]);
return null;
}

View File

@@ -0,0 +1,885 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type React from 'react';
import type { Host, HostProtocol } from '../../types';
import type { PassphraseRequest } from '../../components/PassphraseModal';
import { getEffectiveHostDistro } from '../../domain/host';
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
type AppContextGetter = () => Record<string, any>;
const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions();
const getLogHostVisualSnapshot = (host: Host) => ({
hostOs: host.os,
hostDistro: getEffectiveHostDistro(host) || undefined,
});
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',
...getLogHostVisualSnapshot(effectiveHost),
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',
...getLogHostVisualSnapshot(effectiveHost),
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");
const quickSwitchBinding = keyBindings.find((binding) => binding.action === 'quickSwitch');
const quickSwitchKeyStr = quickSwitchBinding ? (isMac ? quickSwitchBinding.mac : quickSwitchBinding.pc) : null;
const isQuickSwitchHotkey = quickSwitchKeyStr ? matchesKeyBinding(e, quickSwitchKeyStr, isMac) : false;
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape' && !isQuickSwitchHotkey) {
return;
}
const isTerminalElement =
target instanceof HTMLElement &&
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
const isTerminalInPath = Boolean(
e.composedPath?.().some(
(node) =>
node instanceof HTMLElement &&
(node.classList.contains("xterm") ||
node.classList.contains("xterm-helper-textarea") ||
node.classList.contains("xterm-screen") ||
node.classList.contains("xterm-viewport") ||
node.hasAttribute("data-session-id")),
),
);
for (const binding of keyBindings) {
const keyStr = isMac ? binding.mac : binding.pc;
if (!matchesKeyBinding(e, keyStr, isMac)) continue;
if (HOTKEY_DEBUG) console.log('[Hotkeys] Matched binding:', binding.action, keyStr);
if (binding.category === 'sftp') {
continue;
}
if (TERMINAL_PASSTHROUGH_ACTIONS.has(binding.action)) {
if (isTerminalElement) {
return;
}
continue;
}
e.preventDefault();
e.stopPropagation();
if (HOTKEY_DEBUG) {
console.log('[Hotkeys] Global handle', {
action: binding.action,
key: e.key,
meta: e.metaKey,
ctrl: e.ctrlKey,
alt: e.altKey,
shift: e.shiftKey,
targetTag: target?.tagName,
isTerminalElement,
isTerminalInPath,
});
}
executeHotkeyAction(binding.action, e);
return;
}
}
}
export function handleEscapeKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEvent) {
const { isQuickSwitcherOpen, setIsQuickSwitcherOpen } = getCtx();
{
if (e.key === 'Escape' && isQuickSwitcherOpen) {
setIsQuickSwitcherOpen(false);
}
}
}
export function handleKeyboardInteractiveSubmitImpl(getCtx: AppContextGetter, requestId: string, responses: string[], savePassword?: string) {
const { hosts, keyboardInteractiveQueue, netcattyBridge, sessions, setKeyboardInteractiveQueue, updateHosts } = getCtx();
{
const bridge = netcattyBridge.get();
if (bridge?.respondKeyboardInteractive) {
void bridge.respondKeyboardInteractive(requestId, responses, false);
}
// Save password to host if requested
if (savePassword) {
const request = keyboardInteractiveQueue.find(r => r.requestId === requestId);
if (request?.sessionId) {
const session = sessions.find(s => s.id === request.sessionId);
// Only save when the prompting hostname matches the session's host,
// to avoid overwriting the destination host's password with a jump host's password
if (session?.hostId && (!request.hostname || request.hostname === session.hostname)) {
const host = hosts.find(h => h.id === session.hostId);
if (host) {
updateHosts(hosts.map(h => h.id === host.id ? { ...h, password: savePassword, savePassword: true } : h));
}
}
}
}
// Remove from queue by requestId
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
}
}
export function handleKeyboardInteractiveCancelImpl(getCtx: AppContextGetter, requestId: string) {
const { netcattyBridge, setKeyboardInteractiveQueue } = getCtx();
{
const bridge = netcattyBridge.get();
if (bridge?.respondKeyboardInteractive) {
void bridge.respondKeyboardInteractive(requestId, [], true);
}
// Remove from queue by requestId
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
}
}
export async function handlePassphraseSubmitImpl(getCtx: AppContextGetter, requestId: string, passphrase: string, remember: boolean) {
const { keysRef, netcattyBridge, passphraseQueue, rememberKeyPassphrase, setPassphraseQueue, updateKeys } = getCtx();
{
const bridge = netcattyBridge.get();
const request = passphraseQueue.find((r: PassphraseRequest) => r.requestId === requestId);
// Save passphrase if requested
if (remember && request?.keyPath) {
console.log('[App] Saving passphrase for:', request.keyPath);
try {
await rememberKeyPassphrase({
keyPath: request.keyPath,
passphrase,
keys: keysRef.current,
updateKeys,
setCurrentKeys: (updated) => {
keysRef.current = updated;
},
});
} catch (err) {
console.warn('[App] Failed to save passphrase:', err);
}
}
if (bridge?.respondPassphrase) {
void bridge.respondPassphrase(requestId, passphrase, false);
}
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
}
}
export function handlePassphraseCancelImpl(getCtx: AppContextGetter, requestId: string) {
const { netcattyBridge, setPassphraseQueue } = getCtx();
{
const bridge = netcattyBridge.get();
if (bridge?.respondPassphrase) {
// Cancel = stop the entire passphrase flow
void bridge.respondPassphrase(requestId, '', true);
}
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
}
}
export function handlePassphraseSkipImpl(getCtx: AppContextGetter, requestId: string) {
const { netcattyBridge, setPassphraseQueue } = getCtx();
{
const bridge = netcattyBridge.get();
if (bridge?.respondPassphraseSkip) {
// Skip = skip this key but continue asking for others
void bridge.respondPassphraseSkip(requestId);
} else if (bridge?.respondPassphrase) {
// Fallback for older API
void bridge.respondPassphrase(requestId, '', false);
}
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
}
}
export function createLocalTerminalWithCurrentShellImpl(getCtx: AppContextGetter) {
const { classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, terminalSettings } = getCtx();
{
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells, terminalSettings.localShellArgs);
const matchedShell = discoveredShells.find(s => s.id === terminalSettings.localShell);
return createLocalTerminal({
shellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
shell: resolved?.command,
shellArgs: resolved?.args,
shellName: matchedShell?.name,
shellIcon: matchedShell?.icon,
});
}
}
export function splitSessionWithCurrentShellImpl(getCtx: AppContextGetter, sessionId: string, direction: 'horizontal' | 'vertical') {
const { classifyLocalShellType, discoveredShells, resolveShellSetting, splitSession, terminalSettings } = getCtx();
{
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
return splitSession(sessionId, direction, {
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
});
}
}
export function copySessionWithCurrentShellImpl(getCtx: AppContextGetter, sessionId: string) {
const { classifyLocalShellType, copySession, discoveredShells, resolveShellSetting, terminalSettings } = getCtx();
{
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
return copySession(sessionId, {
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
});
}
}
export async function copySessionToNewWindowWithCurrentShellImpl(getCtx: AppContextGetter, sessionId: string) {
const { classifyLocalShellType, discoveredShells, netcattyBridge, resolveShellSetting, sessions, terminalSettings, t, toast } = getCtx();
{
const sourceSession = sessions.find((session: { id: string }) => session.id === sessionId);
if (!sourceSession) return false;
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
const bridge = netcattyBridge.get();
if (!bridge?.openSessionInNewWindow) {
toast?.error?.(t?.('tabs.copyTabToNewWindowFailed') ?? 'Failed to open tab in a new window');
return false;
}
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : '';
try {
const result = await bridge.openSessionInNewWindow({
title: sourceSession.hostLabel,
sourceSession,
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, userAgent),
});
const success = result?.success === true;
if (!success) toast?.error?.(t?.('tabs.copyTabToNewWindowFailed') ?? 'Failed to open tab in a new window');
return success;
} catch {
toast?.error?.(t?.('tabs.copyTabToNewWindowFailed') ?? 'Failed to open tab in a new window');
return false;
}
}
}
export async function confirmIfBusyLocalTerminalImpl(getCtx: AppContextGetter, sessionIds: string[]) {
const { netcattyBridge, sessions, t } = getCtx();
{
const bridge = netcattyBridge.get();
const localIds = sessionIds.filter((id) => {
const s = sessions.find((x) => x.id === id);
return s?.protocol === 'local';
});
const busyCommands: string[] = [];
for (const id of localIds) {
const children = (await bridge?.ptyGetChildProcesses?.(id)) ?? [];
if (children.length > 0) {
busyCommands.push(children[0].command);
}
}
if (busyCommands.length === 0) return true;
const primary = busyCommands[0];
const extraCount = busyCommands.length - 1;
const message =
extraCount > 0
? t('confirm.closeBusyTerminal.messageWithMore', {
command: primary,
count: extraCount,
})
: t('confirm.closeBusyTerminal.message', { command: primary });
const ok = await bridge?.confirmCloseBusy?.({
command: primary,
title: t('confirm.closeBusyTerminal.title'),
message,
cancelLabel: t('confirm.closeBusyTerminal.cancel'),
closeLabel: t('confirm.closeBusyTerminal.close'),
});
return ok === true;
}
}
export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: string[]) {
const { closeLogView, closeSession, closeTabsInFlightRef, closeWorkspace, confirmIfBusyLocalTerminal, logViews, sessions, workspaces } = getCtx();
{
if (targetIds.length === 0) return;
if (closeTabsInFlightRef.current) return;
// Expand workspace ids into their constituent session ids so the busy
// probe sees every local shell that's about to be killed.
const sessionIdsToProbe: string[] = [];
for (const tabId of targetIds) {
const ws = workspaces.find((w) => w.id === tabId);
if (ws) {
for (const s of sessions) {
if (s.workspaceId === tabId) sessionIdsToProbe.push(s.id);
}
} else if (sessions.find((s) => s.id === tabId)) {
sessionIdsToProbe.push(tabId);
}
}
closeTabsInFlightRef.current = true;
try {
const ok = await confirmIfBusyLocalTerminal(sessionIdsToProbe);
if (!ok) return;
for (const tabId of targetIds) {
if (workspaces.find((w) => w.id === tabId)) {
closeWorkspace(tabId);
} else if (sessions.find((s) => s.id === tabId)) {
closeSession(tabId);
} else if (logViews.find((lv) => lv.id === tabId)) {
closeLogView(tabId);
}
}
} finally {
closeTabsInFlightRef.current = false;
}
}
}
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, 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))];
const numberShortcutTabs = buildNumberShortcutTabTargets({
showSftpTab: settings.showSftpTab ?? true,
shellOnlyTabNumberShortcuts: settings.shellOnlyTabNumberShortcuts ?? false,
orderedTabs,
editorTabIds: editorTabs.map((t) => toEditorTabId(t.id)),
});
switch (action) {
case 'switchToTab': {
// Get the number key pressed (1-9)
const num = parseInt(e.key, 10);
if (num >= 1 && num <= 9) {
if (num <= numberShortcutTabs.length) {
setActiveTabId(numberShortcutTabs[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':
setIsQuickSwitcherOpen(!isQuickSwitcherOpen);
break;
case 'commandPalette':
setIsQuickSwitcherOpen(true);
break;
case 'newWorkspace':
// Dedicated shortcut to launch the AddToWorkspaceDialog in
// create mode — same entry as QuickSwitcher's "New Workspace"
// button, but without having to open QS first.
setAddToWorkspaceDialog({ mode: 'create' });
break;
case 'portForwarding':
// Navigate to vault and open port forwarding section
setActiveTabId('vault');
setNavigateToSection('port');
break;
case 'snippets':
{
const currentId = activeTabStore.getActiveTabId();
const intent = resolveSnippetsShortcutIntent({
activeTabId: currentId,
sessionForTab: sessions.find((s) => s.id === currentId) ?? null,
workspaceForTab: workspaces.find((w) => w.id === currentId) ?? null,
terminalScriptsToggleAvailable: !!toggleScriptsSidePanelRef.current,
});
if (intent.kind === 'toggleTerminalScripts') {
toggleScriptsSidePanelRef.current();
break;
}
setActiveTabId('vault');
setNavigateToSection('snippets');
}
break;
case 'toggleSidePanel':
toggleSidePanelRef.current?.();
break;
case 'broadcast': {
// Toggle broadcast mode for the active workspace
const currentId = activeTabStore.getActiveTabId();
const activeWs = workspaces.find(w => w.id === currentId);
if (activeWs) {
toggleBroadcast(activeWs.id);
}
break;
}
case 'openSettings':
handleOpenSettingsRef.current();
break;
case 'splitHorizontal': {
const currentId = activeTabStore.getActiveTabId();
const activeSession = sessions.find(s => s.id === currentId);
const activeWs = workspaces.find(w => w.id === currentId);
if (activeSession && !activeSession.workspaceId) {
splitSessionWithCurrentShell(activeSession.id, 'horizontal');
} else if (activeWs) {
const liveIds = collectSessionIds(activeWs.root);
const targetId = (activeWs.focusedSessionId && liveIds.includes(activeWs.focusedSessionId))
? activeWs.focusedSessionId
: liveIds[0];
if (targetId) splitSessionWithCurrentShell(targetId, 'horizontal');
}
break;
}
case 'splitVertical': {
const currentId = activeTabStore.getActiveTabId();
const activeSession = sessions.find(s => s.id === currentId);
const activeWs = workspaces.find(w => w.id === currentId);
if (activeSession && !activeSession.workspaceId) {
splitSessionWithCurrentShell(activeSession.id, 'vertical');
} else if (activeWs) {
const liveIds = collectSessionIds(activeWs.root);
const targetId = (activeWs.focusedSessionId && liveIds.includes(activeWs.focusedSessionId))
? activeWs.focusedSessionId
: liveIds[0];
if (targetId) splitSessionWithCurrentShell(targetId, 'vertical');
}
break;
}
case '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',
...getLogHostVisualSnapshot(effectiveHost),
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',
...getLogHostVisualSnapshot(effectiveHost),
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,44 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const storage = new Map<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
},
});
const {
getAppHostTreeLayerStyle,
} = await import('./AppHostTreeLayer');
const hostTreeLayerSource = readFileSync(new URL('./AppHostTreeLayer.tsx', import.meta.url), 'utf8');
test('shared host tree layer is visible above work tabs', () => {
assert.deepEqual(getAppHostTreeLayerStyle(true), {
visibility: 'visible',
pointerEvents: 'auto',
zIndex: 30,
});
});
test('shared host tree layer is hidden behind root pages', () => {
assert.deepEqual(getAppHostTreeLayerStyle(false), {
visibility: 'hidden',
pointerEvents: 'none',
zIndex: 0,
});
});
test('shared host tree does not force open when entering a work tab surface', () => {
assert.doesNotMatch(hostTreeLayerSource, /setIsOpen\(true\)/);
assert.doesNotMatch(hostTreeLayerSource, /shouldAutoOpenHostTreeOnSurfaceChange/);
});
test('host tree layer hides immediately when leaving work tab surfaces', () => {
assert.match(hostTreeLayerSource, /getAppHostTreeLayerStyle\(surfaceVisible\)/);
assert.doesNotMatch(hostTreeLayerSource, /layerVisible/);
});

View File

@@ -0,0 +1,89 @@
import React, { useMemo } from 'react';
import { useActiveTabId } from '../state/activeTabStore';
import type { EditorTab } from '../state/editorTabStore';
import type { LogView } from '../state/logViewState';
import { TerminalHostTreeSidebar } from '../../components/terminalLayer/TerminalHostTreeSidebar';
import type { GroupConfig, Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
import {
isHostTreeWorkTabSurface,
resolveWorkTabActiveHostId,
} from './workTabSurface';
interface AppHostTreeLayerProps {
enabled: boolean;
hosts: Host[];
customGroups: string[];
groupConfigs: GroupConfig[];
sessions: TerminalSession[];
workspaces: Workspace[];
editorTabs: readonly EditorTab[];
logViews: readonly LogView[];
orderedTabs: readonly string[];
resolvedPreviewTheme: TerminalTheme;
onConnect: (host: Host) => void;
onCreateLocalTerminal?: () => void;
}
export function getAppHostTreeLayerStyle(surfaceVisible: boolean): React.CSSProperties {
return {
visibility: surfaceVisible ? 'visible' : 'hidden',
pointerEvents: surfaceVisible ? 'auto' : 'none',
zIndex: surfaceVisible ? 30 : 0,
};
}
export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
enabled,
hosts,
customGroups,
groupConfigs,
sessions,
workspaces,
editorTabs,
logViews,
orderedTabs,
resolvedPreviewTheme,
onConnect,
onCreateLocalTerminal,
}) => {
const activeTabId = useActiveTabId();
const sessionIds = useMemo(() => new Set(sessions.map((session) => session.id)), [sessions]);
const workspaceIds = useMemo(() => new Set(workspaces.map((workspace) => workspace.id)), [workspaces]);
const logViewIds = useMemo(() => new Set(logViews.map((logView) => logView.id)), [logViews]);
const surfaceVisible = isHostTreeWorkTabSurface({
enabled,
activeTabId,
logViewIds,
orderedTabs,
sessionIds,
workspaceIds,
});
const activeHostId = useMemo(() => resolveWorkTabActiveHostId({
activeTabId,
editorTabs,
sessions,
workspaces,
}), [activeTabId, editorTabs, sessions, workspaces]);
return (
<div
className="absolute left-0 top-0 bottom-0 flex min-h-0"
data-section="app-host-tree-layer"
style={getAppHostTreeLayerStyle(surfaceVisible)}
>
<TerminalHostTreeSidebar
enabled={enabled}
surfaceVisible={surfaceVisible}
hosts={hosts}
customGroups={customGroups}
groupConfigs={groupConfigs}
resolvedPreviewTheme={resolvedPreviewTheme}
activeHostId={activeHostId}
onConnect={onConnect}
onCreateLocalTerminal={onCreateLocalTerminal}
/>
</div>
);
};

View File

@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const storage = new Map<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
},
});
const { getLogViewWrapperStyle, shouldRenderTerminalLayerMount } = await import('./AppMounts.tsx');
const activeTabChromeSource = readFileSync(new URL('./AppActiveTabChrome.tsx', import.meta.url), 'utf8');
test('visible log view leaves room for the terminal host sidebar', () => {
assert.deepEqual(getLogViewWrapperStyle(true, 220), {
left: 220,
});
});
test('hidden log view remains hidden while preserving host sidebar offset', () => {
assert.deepEqual(getLogViewWrapperStyle(false, 220), {
visibility: 'hidden',
pointerEvents: 'none',
position: 'absolute',
zIndex: -1,
left: 220,
});
});
test('terminal layer renders only after terminal content is visible or mounted', () => {
assert.equal(shouldRenderTerminalLayerMount(true, false), true);
assert.equal(shouldRenderTerminalLayerMount(false, true), true);
assert.equal(shouldRenderTerminalLayerMount(false, false), false);
});
test('active tab chrome keeps removed theme side effects unmounted', () => {
const removedThemeHook = ['use', 'Im', 'mersive', 'Mode'].join('');
const removedThemeStoreSetter = ['set', 'Im', 'mersive', 'Active'].join('');
assert.equal(activeTabChromeSource.includes(removedThemeHook), false);
assert.equal(activeTabChromeSource.includes(removedThemeStoreSetter), false);
});

View File

@@ -0,0 +1,145 @@
import React, { Suspense, lazy, useEffect, useMemo, useState } from 'react';
import { useActiveTabId, useIsSftpActive, useIsVaultActive } from '../state/activeTabStore';
import { useTerminalHostTreeLayoutWidth } from '../state/terminalHostTreeStore';
import { isTerminalContentTabSurface } from './workTabSurface';
import { cn } from '../../lib/utils';
import { ConnectionLog, TerminalTheme } from '../../types';
import 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 function getLogViewWrapperStyle(
isVisible: boolean,
hostTreeLayoutWidth: number,
): React.CSSProperties {
const baseStyle = {
left: hostTreeLayoutWidth,
};
return isVisible
? baseStyle
: { visibility: 'hidden', pointerEvents: 'none', position: 'absolute', zIndex: -1, ...baseStyle };
}
export const LogViewWrapper: React.FC<LogViewWrapperProps> = ({ logView, defaultTerminalTheme, defaultFontSize, onClose, onUpdateLog }) => {
const activeTabId = useActiveTabId();
const isVisible = activeTabId === logView.id;
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
const containerStyle = getLogViewWrapperStyle(isVisible, hostTreeLayoutWidth);
return (
<div className={cn("absolute inset-0", isVisible ? "z-20" : "")} 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 function shouldRenderTerminalLayerMount(
isVisible: boolean,
shouldMount: boolean,
): boolean {
return isVisible || shouldMount;
}
export const SftpViewMount: React.FC<SftpViewProps> = (props) => {
const isActive = useIsSftpActive();
const [shouldMount, setShouldMount] = useState(isActive);
useEffect(() => {
if (isActive) setShouldMount(true);
}, [isActive]);
if (!shouldMount) return null;
return (
<Suspense fallback={null}>
<LazySftpView {...props} />
</Suspense>
);
};
export const TerminalLayerMount: React.FC<TerminalLayerProps> = (props) => {
const activeTabId = useActiveTabId();
const sessionIds = useMemo(() => new Set(props.sessions.map((session) => session.id)), [props.sessions]);
const workspaceIds = useMemo(() => new Set(props.workspaces.map((workspace) => workspace.id)), [props.workspaces]);
const isVisible = isTerminalContentTabSurface({
activeTabId,
sessionIds,
workspaceIds,
}) || !!props.draggingSessionId;
const [shouldMount, setShouldMount] = useState(isVisible);
useEffect(() => {
if (isVisible) setShouldMount(true);
}, [isVisible]);
useEffect(() => {
if (shouldMount) return;
type IdleWindow = Window & {
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
cancelIdleCallback?: (id: number) => void;
};
const idleWindow = window as IdleWindow;
if (typeof idleWindow.requestIdleCallback === "function") {
const id = idleWindow.requestIdleCallback(() => setShouldMount(true), { timeout: 5000 });
return () => idleWindow.cancelIdleCallback?.(id);
}
const id = window.setTimeout(() => setShouldMount(true), 5000);
return () => window.clearTimeout(id);
}, [shouldMount]);
const shouldRender = shouldRenderTerminalLayerMount(isVisible, shouldMount);
if (!shouldRender) return null;
return (
<Suspense fallback={null}>
<LazyTerminalLayer {...props} />
</Suspense>
);
};

588
application/app/AppView.tsx Normal file
View File

@@ -0,0 +1,588 @@
/* 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 { AppHostTreeLayer } from './AppHostTreeLayer';
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, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, closeWorkspace, copySessionToNewWindowWithCurrentShell, 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, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename,
resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen,
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, updateSessionFontSize, clearSessionFontSizeOverride,
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId,
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="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
<TopTabs
theme={resolvedTheme}
hosts={hosts}
sessions={sessions}
orphanSessions={orphanSessions}
workspaces={workspaces}
logViews={logViews}
orderedTabs={orderedTabsWithEditors}
draggingSessionId={draggingSessionId}
isMacClient={isMacClient}
onCloseSession={closeSession}
onRenameSession={startSessionRename}
onCopySession={copySessionWithCurrentShell}
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
onRenameWorkspace={startWorkspaceRename}
onCloseWorkspace={closeWorkspace}
onCloseLogView={closeLogView}
onCloseTabsBatch={closeTabsBatch}
onOpenQuickSwitcher={handleOpenQuickSwitcher}
onToggleTheme={handleToggleTheme}
onOpenSettings={handleOpenSettings}
windowOpacity={settings.windowOpacity}
setWindowOpacity={settings.setWindowOpacity}
onSyncNow={handleSyncNowManual}
onStartSessionDrag={setDraggingSessionId}
onEndSessionDrag={handleEndSessionDrag}
onReorderTabs={reorderWorkTabs}
showSftpTab={settings.showSftpTab}
showHostTreeSidebar={settings.showHostTreeSidebar}
editorTabs={editorTabs}
onRequestCloseEditorTab={handleRequestCloseEditorTab}
hostById={hostById}
/>
<div className="flex-1 relative min-h-0">
<AppHostTreeLayer
enabled={settings.showHostTreeSidebar}
hosts={hosts}
customGroups={customGroups}
groupConfigs={groupConfigs}
sessions={sessions}
workspaces={workspaces}
editorTabs={editorTabs}
logViews={logViews}
orderedTabs={orderedTabsWithEditors}
resolvedPreviewTheme={currentTerminalTheme}
onConnect={handleConnectToHost}
onCreateLocalTerminal={handleCreateLocalTerminal}
/>
<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}
customGroups={customGroups}
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}
onUpdateSessionFontSize={updateSessionFontSize}
onClearSessionFontSizeOverride={clearSessionFontSizeOverride}
onUpdateTerminalFontWeight={(w) => updateTerminalSetting('fontWeight', w)}
onCloseSession={closeSession}
onUpdateSessionStatus={handleSessionStatusChange}
onUpdateHostDistro={updateHostDistro}
onUpdateHost={handleUpdateHostFromTerminal}
onAddKnownHost={handleAddKnownHost}
onCommandExecuted={(command, hostId, hostLabel, sessionId) => {
addShellHistoryEntry({ command, hostId, hostLabel, sessionId });
}}
shellHistory={shellHistory}
onTerminalDataCapture={handleTerminalDataCapture}
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
onAddSessionToWorkspace={addSessionToWorkspace}
onRequestAddToWorkspace={(workspaceId) =>
setAddToWorkspaceDialog({ mode: 'append', workspaceId })
}
onUpdateSplitSizes={updateSplitSizes}
onSetDraggingSessionId={setDraggingSessionId}
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
onReorderWorkspaceSessions={reorderWorkspaceSessions}
onSplitSession={splitSessionWithCurrentShell}
onConnectToHost={handleConnectToHost}
onCreateLocalTerminal={handleCreateLocalTerminal}
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}
updateHosts={updateHosts}
updateSnippets={updateSnippets}
updateSnippetPackages={updateSnippetPackages}
sftpDefaultViewMode={sftpDefaultViewMode}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={sftpAutoSync}
sftpShowHiddenFiles={sftpShowHiddenFiles}
sftpUseCompressedUpload={sftpUseCompressedUpload}
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
setSftpFollowTerminalCwd={setSftpFollowTerminalCwd}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
sessionLogsEnabled={sessionLogsEnabled}
sessionLogsDir={sessionLogsDir}
sessionLogsFormat={sessionLogsFormat}
sessionLogsTimestampsEnabled={sessionLogsTimestampsEnabled}
sshDebugLogsEnabled={sshDebugLogsEnabled}
showHostTreeSidebar={settings.showHostTreeSidebar}
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
toggleSidePanelRef={toggleSidePanelRef}
/>
{/* 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}
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,106 @@
import assert from "node:assert/strict";
import test from "node:test";
import { toEditorTabId } from "../state/activeTabStore.ts";
import type { EditorTab } from "../state/editorTabStore.ts";
import type { LogView } from "../state/logViewState.ts";
import { isActiveChromeThemeResolvable, resolveActiveChromeTheme } from "./activeChromeTheme.ts";
import type { Host, TerminalSession, TerminalTheme, Workspace } from "../../types";
const theme = (id: string, type: "dark" | "light" = "dark"): TerminalTheme => ({
id,
name: id,
type,
colors: {
background: type === "dark" ? "#111111" : "#eeeeee",
foreground: type === "dark" ? "#eeeeee" : "#111111",
cursor: "#22aaff",
},
});
const currentTheme = theme("current");
const hostTheme = theme("host-theme");
const logTheme = theme("log-theme", "light");
const baseInput = {
accentMode: "theme" as const,
currentTerminalTheme: currentTheme,
customAccent: "221.2 83.2% 53.3%",
editorTabs: [],
followAppTerminalTheme: false,
hostById: new Map<string, Host>(),
logViews: [],
sessionById: new Map<string, TerminalSession>(),
themeById: new Map([
[currentTheme.id, currentTheme],
[hostTheme.id, hostTheme],
[logTheme.id, logTheme],
]),
workspaceById: new Map<string, Workspace>(),
};
test("editor tabs use the theme from their owning host", () => {
const editorTab = {
id: "editor-1",
hostId: "host-1",
sessionId: "sftp-1",
};
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: toEditorTabId(editorTab.id),
editorTabs: [editorTab as unknown as EditorTab],
hostById: new Map([
["host-1", { id: "host-1", theme: hostTheme.id } as unknown as Host],
]),
});
assert.equal(resolved?.id, hostTheme.id);
});
test("log tabs use the saved log theme when available", () => {
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: "log-1",
logViews: [{
id: "log-1",
connectionLogId: "1",
log: { id: "1", themeId: logTheme.id },
} as unknown as LogView],
});
assert.equal(resolved?.id, logTheme.id);
});
test("root pages use the normal application theme", () => {
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: "vault",
});
assert.equal(resolved, null);
});
test("chrome theme sync waits until a newly opened session is present in deps", () => {
assert.equal(
isActiveChromeThemeResolvable({
activeTabId: "session-new",
editorTabs: [],
logViews: [],
sessionById: new Map(),
workspaceById: new Map(),
}),
false,
);
assert.equal(
isActiveChromeThemeResolvable({
activeTabId: "session-new",
editorTabs: [],
logViews: [],
sessionById: new Map([["session-new", { id: "session-new" } as TerminalSession]]),
workspaceById: new Map(),
}),
true,
);
});

View File

@@ -0,0 +1,104 @@
import { fromEditorTabId, isEditorTabId } from "../state/activeTabStore";
export type ResolveActiveChromeThemeInput = {
accentMode: "theme" | "custom";
activeTabId: string;
currentTerminalTheme: TerminalTheme;
customAccent: string;
editorTabs: readonly EditorTab[];
followAppTerminalTheme: boolean;
hostById: Map<string, Host>;
logViews: readonly LogView[];
sessionById: Map<string, TerminalSession>;
themeById: Map<string, TerminalTheme>;
workspaceById: Map<string, Workspace>;
};
export function isActiveChromeThemeResolvable({
activeTabId,
editorTabs,
logViews,
sessionById,
workspaceById,
}: Pick<
ResolveActiveChromeThemeInput,
"activeTabId" | "editorTabs" | "logViews" | "sessionById" | "workspaceById"
>): boolean {
if (activeTabId === "vault" || activeTabId === "sftp") return true;
if (isEditorTabId(activeTabId)) {
return editorTabs.some((tab) => tab.id === fromEditorTabId(activeTabId));
}
if (logViews.some((item) => item.id === activeTabId)) return true;
if (workspaceById.has(activeTabId)) return true;
if (sessionById.has(activeTabId)) return true;
return false;
}
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from "../../domain/terminalAppearance";
import { collectSessionIds } from "../../domain/workspace";
import type { EditorTab } from "../state/editorTabStore";
import type { LogView } from "../state/logViewState";
import type { Host, TerminalSession, TerminalTheme, Workspace } from "../../types";
export function resolveActiveChromeTheme({
accentMode,
activeTabId,
currentTerminalTheme,
customAccent,
editorTabs,
followAppTerminalTheme,
hostById,
logViews,
sessionById,
themeById,
workspaceById,
}: ResolveActiveChromeThemeInput): TerminalTheme | null {
if (activeTabId === "vault" || activeTabId === "sftp") return null;
const resolveSessionTheme = (session: TerminalSession): TerminalTheme => {
if (followAppTerminalTheme) return currentTerminalTheme;
const host = hostById.get(session.hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
};
if (isEditorTabId(activeTabId)) {
const editorTabId = fromEditorTabId(activeTabId);
const editorTab = editorTabs.find((tab) => tab.id === editorTabId);
if (!editorTab) return null;
const host = hostById.get(editorTab.hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}
const logView = logViews.find((item) => item.id === activeTabId);
if (logView) {
const explicitThemeId = logView.log.themeId;
return explicitThemeId ? themeById.get(explicitThemeId) ?? currentTerminalTheme : currentTerminalTheme;
}
const workspace = workspaceById.get(activeTabId);
if (workspace) {
if (workspace.viewMode === "focus") {
const workspaceSessionIds = collectSessionIds(workspace.root);
const focusedSession = (workspace.focusedSessionId
? sessionById.get(workspace.focusedSessionId)
: null)
?? workspaceSessionIds.map((id) => sessionById.get(id)).find(Boolean);
return focusedSession ? resolveSessionTheme(focusedSession) : null;
}
const workspaceSessions = collectSessionIds(workspace.root)
.map((id) => sessionById.get(id))
.filter(Boolean) as TerminalSession[];
if (workspaceSessions.length === 0) return null;
const firstTheme = resolveSessionTheme(workspaceSessions[0]);
const allSame = workspaceSessions.every((session) => resolveSessionTheme(session).id === firstTheme.id);
return allSame ? firstTheme : null;
}
const session = sessionById.get(activeTabId);
return session ? resolveSessionTheme(session) : null;
}

View File

@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildNumberShortcutTabTargets } from './tabShortcutTargets.ts';
test('number shortcut tabs include vault and sftp by default', () => {
assert.deepEqual(
buildNumberShortcutTabTargets({
showSftpTab: true,
shellOnlyTabNumberShortcuts: false,
orderedTabs: ['session-1', 'workspace-1'],
editorTabIds: ['editor:file-1'],
}),
['vault', 'sftp', 'session-1', 'workspace-1', 'editor:file-1'],
);
});
test('number shortcut tabs skip vault and sftp when shell-only mode is enabled', () => {
assert.deepEqual(
buildNumberShortcutTabTargets({
showSftpTab: true,
shellOnlyTabNumberShortcuts: true,
orderedTabs: ['session-1', 'workspace-1'],
editorTabIds: ['editor:file-1'],
}),
['session-1', 'workspace-1', 'editor:file-1'],
);
});
test('hidden sftp tab is omitted from default number shortcut targets', () => {
assert.deepEqual(
buildNumberShortcutTabTargets({
showSftpTab: false,
shellOnlyTabNumberShortcuts: false,
orderedTabs: ['session-1'],
editorTabIds: [],
}),
['vault', 'session-1'],
);
});

View File

@@ -0,0 +1,14 @@
/** Tab ids targeted by Cmd/Ctrl+[1...9] number shortcuts. */
export function buildNumberShortcutTabTargets(params: {
showSftpTab: boolean;
shellOnlyTabNumberShortcuts: boolean;
orderedTabs: readonly string[];
editorTabIds: readonly string[];
}): string[] {
const workTabs = [...params.orderedTabs, ...params.editorTabIds];
if (params.shellOnlyTabNumberShortcuts) {
return workTabs;
}
const pinnedTabs = params.showSftpTab ? ['vault', 'sftp'] : ['vault'];
return [...pinnedTabs, ...workTabs];
}

View File

@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
test("active chrome theme applies top tab vars and clears them before vault restore transition", () => {
const chromeThemeSource = readFileSync(new URL("../state/useActiveChromeTheme.ts", import.meta.url), "utf8");
const syncSource = readFileSync(new URL("../state/activeChromeThemeSync.ts", import.meta.url), "utf8");
const effectsSource = readFileSync(new URL("../../components/terminalLayer/useTerminalLayerEffects.ts", import.meta.url), "utf8");
assert.match(chromeThemeSource, /applyTopTabsChromeThemeVars\(theme\)/);
const restoreBlock = chromeThemeSource.match(
/clearTopTabsChromeThemeVars\(\);\s*runThemeTransition\(\(\) => \{\s*removeActiveChromeTheme\(\);/,
)?.[0] ?? "";
assert.notEqual(restoreBlock, "", "top tab vars must clear before the vault restore transition starts");
assert.match(syncSource, /activeTabId === 'vault' \|\| activeTabId === 'sftp'\)[\s\S]*clearTopTabsChromeThemeVars\(\)/);
assert.match(effectsSource, /if \(!isTerminalLayerVisible\) \{[\s\S]*clearTopTabsPreviewVars\(\)/);
});

View File

@@ -0,0 +1,109 @@
import type { TerminalTheme } from '../../types';
function hexToHslToken(hex: string): string {
const normalized = hex.startsWith('#') ? hex : `#${hex}`;
const r = parseInt(normalized.slice(1, 3), 16) / 255;
const g = parseInt(normalized.slice(3, 5), 16) / 255;
const b = parseInt(normalized.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
break;
case g:
h = ((b - r) / d + 2) / 6;
break;
default:
h = ((r - g) / d + 4) / 6;
break;
}
}
return `${Math.round(h * 3600) / 10} ${Math.round(s * 1000) / 10}% ${Math.round(l * 1000) / 10}%`;
}
function adjustLightnessToken(hsl: string, delta: number): string {
const parts = hsl.split(/\s+/);
const newL = Math.max(0, Math.min(100, parseFloat(parts[2]) + delta));
return `${parts[0]} ${parts[1]} ${Math.round(newL * 10) / 10}%`;
}
function adjustSaturationToken(hsl: string, factor: number): string {
const parts = hsl.split(/\s+/);
const newS = Math.max(0, Math.min(100, parseFloat(parts[1]) * factor));
return `${parts[0]} ${Math.round(newS * 10) / 10}% ${parts[2]}`;
}
const setStylePropertyIfChanged = (element: HTMLElement, property: string, value: string) => {
if (element.style.getPropertyValue(property) === value) return;
element.style.setProperty(property, value);
};
const removeStylePropertyIfSet = (element: HTMLElement, property: string) => {
if (!element.style.getPropertyValue(property)) return;
element.style.removeProperty(property);
};
const TOP_TABS_THEME_PROPERTIES = [
'--top-tabs-bg',
'--top-tabs-fg',
'--top-tabs-muted',
'--top-tabs-active-bg',
'--top-tabs-accent',
'--background',
'--foreground',
'--accent',
'--primary',
'--secondary',
'--border',
'--muted-foreground',
] as const;
export function clearTopTabsChromeThemeVars(): void {
if (typeof document === 'undefined') return;
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
if (!tabsRoot) return;
for (const property of TOP_TABS_THEME_PROPERTIES) {
removeStylePropertyIfSet(tabsRoot, property);
}
}
export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
if (typeof document === 'undefined') return;
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
if (!tabsRoot) return;
const bg = hexToHslToken(theme.colors.background);
const fg = hexToHslToken(theme.colors.foreground);
const accent = hexToHslToken(theme.colors.cursor);
const isDark = theme.type === 'dark';
const secondary = adjustLightnessToken(bg, isDark ? 6 : -5);
const border = adjustLightnessToken(bg, isDark ? 12 : -10);
const mutedFg = adjustSaturationToken(adjustLightnessToken(fg, isDark ? -20 : 20), 0.5);
setStylePropertyIfChanged(tabsRoot, '--background', bg);
setStylePropertyIfChanged(tabsRoot, '--foreground', fg);
setStylePropertyIfChanged(tabsRoot, '--accent', accent);
setStylePropertyIfChanged(tabsRoot, '--primary', accent);
setStylePropertyIfChanged(tabsRoot, '--secondary', secondary);
setStylePropertyIfChanged(tabsRoot, '--border', border);
setStylePropertyIfChanged(tabsRoot, '--muted-foreground', mutedFg);
setStylePropertyIfChanged(tabsRoot, '--top-tabs-bg', 'hsl(var(--secondary))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-fg', 'hsl(var(--foreground))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-muted', 'hsl(var(--muted-foreground))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-active-bg', 'hsl(var(--background))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
}
export function hasActiveChromeThemeDataset(): boolean {
if (typeof document === 'undefined') return false;
return Boolean(document.documentElement.dataset.activeChromeTheme);
}

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,82 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildOrderedWorkTabIds,
isHostTreeWorkTabSurface,
isRootPageTabId,
isTerminalContentTabSurface,
resolveWorkTabActiveHostId,
} from './workTabSurface';
import type { EditorTab } from '../state/editorTabStore';
import type { TerminalSession, Workspace } from '../../types';
test('work tab order keeps custom positions and appends new tabs', () => {
assert.deepEqual(
buildOrderedWorkTabIds(['log-1', 'session-1'], ['session-1', 'workspace-1', 'log-1', 'editor:file-1']),
['log-1', 'session-1', 'workspace-1', 'editor:file-1'],
);
});
test('root pages are not work tab surfaces', () => {
assert.equal(isRootPageTabId('vault'), true);
assert.equal(isRootPageTabId('sftp'), true);
assert.equal(isRootPageTabId('session-1'), false);
});
test('shared host tree is visible for editor, log, session, and workspace tabs', () => {
const sessionIds = new Set(['session-1']);
const workspaceIds = new Set(['workspace-1']);
const logViewIds = new Set(['log-1']);
const orderedTabs = ['session-1', 'workspace-1', 'editor:file-1', 'log-1'];
for (const activeTabId of orderedTabs) {
assert.equal(isHostTreeWorkTabSurface({
enabled: true,
activeTabId,
logViewIds,
orderedTabs,
sessionIds,
workspaceIds,
}), true);
}
});
test('shared host tree recognizes active log view before tab ordering catches up', () => {
assert.equal(isHostTreeWorkTabSurface({
enabled: true,
activeTabId: 'log-1',
logViewIds: new Set(['log-1']),
orderedTabs: [],
sessionIds: new Set(),
workspaceIds: new Set(),
}), true);
});
test('terminal content surface is limited to sessions and workspaces', () => {
const sessionIds = new Set(['session-1']);
const workspaceIds = new Set(['workspace-1']);
assert.equal(isTerminalContentTabSurface({ activeTabId: 'session-1', sessionIds, workspaceIds }), true);
assert.equal(isTerminalContentTabSurface({ activeTabId: 'workspace-1', sessionIds, workspaceIds }), true);
assert.equal(isTerminalContentTabSurface({ activeTabId: 'editor:file-1', sessionIds, workspaceIds }), false);
assert.equal(isTerminalContentTabSurface({ activeTabId: 'log-1', sessionIds, workspaceIds }), false);
});
test('shared host tree resolves active host ids across work tab types', () => {
const sessions = [
{ id: 'session-1', hostId: 'host-1' },
{ id: 'session-2', hostId: 'host-2' },
] as TerminalSession[];
const workspaces = [
{ id: 'workspace-1', focusedSessionId: 'session-2' },
] as Workspace[];
const editorTabs = [
{ id: 'file-1', hostId: 'host-3' },
] as EditorTab[];
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'session-1', sessions, workspaces, editorTabs }), 'host-1');
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'workspace-1', sessions, workspaces, editorTabs }), 'host-2');
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'editor:file-1', sessions, workspaces, editorTabs }), 'host-3');
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'log-1', sessions, workspaces, editorTabs }), null);
});

View File

@@ -0,0 +1,87 @@
import {
fromEditorTabId,
isEditorTabId,
} from '../state/activeTabStore';
import type { EditorTab } from '../state/editorTabStore';
import type { TerminalSession, Workspace } from '../../types';
export function isRootPageTabId(activeTabId: string): boolean {
return activeTabId === 'vault' || activeTabId === 'sftp';
}
export function buildOrderedWorkTabIds(
tabOrder: readonly string[],
allTabIds: readonly string[],
): string[] {
const allTabIdSet = new Set(allTabIds);
const orderedIds = tabOrder.filter((id) => allTabIdSet.has(id));
const orderedIdSet = new Set(orderedIds);
const newIds = allTabIds.filter((id) => !orderedIdSet.has(id));
return [...orderedIds, ...newIds];
}
export function isHostTreeWorkTabSurface({
enabled,
activeTabId,
logViewIds = new Set(),
orderedTabs,
sessionIds,
workspaceIds,
}: {
enabled: boolean;
activeTabId: string;
logViewIds?: ReadonlySet<string>;
orderedTabs: readonly string[];
sessionIds: ReadonlySet<string>;
workspaceIds: ReadonlySet<string>;
}): boolean {
if (!enabled) return false;
if (isRootPageTabId(activeTabId)) return false;
return orderedTabs.includes(activeTabId)
|| isEditorTabId(activeTabId)
|| logViewIds.has(activeTabId)
|| sessionIds.has(activeTabId)
|| workspaceIds.has(activeTabId);
}
export function isTerminalContentTabSurface({
activeTabId,
sessionIds,
workspaceIds,
}: {
activeTabId: string;
sessionIds: ReadonlySet<string>;
workspaceIds: ReadonlySet<string>;
}): boolean {
return sessionIds.has(activeTabId) || workspaceIds.has(activeTabId);
}
export function resolveWorkTabActiveHostId({
activeTabId,
editorTabs,
sessions,
workspaces,
}: {
activeTabId: string;
editorTabs: readonly EditorTab[];
sessions: readonly TerminalSession[];
workspaces: readonly Workspace[];
}): string | null {
if (isEditorTabId(activeTabId)) {
const editorId = fromEditorTabId(activeTabId);
return editorTabs.find((tab) => tab.id === editorId)?.hostId ?? null;
}
const activeSession = sessions.find((session) => session.id === activeTabId);
if (activeSession) return activeSession.hostId ?? null;
const activeWorkspace = workspaces.find((workspace) => workspace.id === activeTabId);
if (!activeWorkspace) return null;
const focusedSessionId = activeWorkspace.focusedSessionId;
if (focusedSessionId) {
return sessions.find((session) => session.id === focusedSessionId)?.hostId ?? null;
}
return null;
}

View File

@@ -14,11 +14,19 @@ const I18nContext = createContext<I18nContextValue | null>(null);
const interpolate = (template: string, values?: InterpolationValues): string => {
if (!values) return template;
return template.replace(/\{(\w+)\}/g, (_match, key: string) => {
const replaceDoubleBraceToken = (match: string, key: string) => {
const v = values[key];
if (v === null || v === undefined) return match;
return String(v);
};
const replaceSingleBraceToken = (_match: string, key: string) => {
const v = values[key];
if (v === null || v === undefined) return '';
return String(v);
});
};
return template
.replace(/\{\{(\w+)\}\}/g, replaceDoubleBraceToken)
.replace(/\{(\w+)\}/g, replaceSingleBraceToken);
};
const resolveMessage = (resolvedLocale: string, key: string): string | undefined => {

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,358 @@
import type { Messages } from '../types';
export const enAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Agent Settings',
'ai.chat.preparing': 'Preparing…',
'ai.title': 'AI',
'ai.description': 'Configure AI providers, agents, and safety settings',
'ai.providers': 'Providers',
'ai.agents': 'Agents',
'ai.providers.empty': 'No providers configured. Add a provider to get started.',
'ai.providers.add': 'Add Provider',
'ai.providers.active': 'Active',
'ai.providers.apiKeyConfigured': 'API key configured',
'ai.providers.noApiKey': 'No API key',
'ai.providers.configure': 'Configure',
'ai.providers.remove': 'Remove',
'ai.providers.name': 'Display Name',
'ai.providers.name.placeholder': 'e.g. My Provider',
'ai.providers.style': 'Protocol style',
'ai.providers.style.anthropic': 'Anthropic-compatible',
'ai.providers.style.openai': 'OpenAI-compatible',
'ai.providers.style.google': 'Google-compatible',
'ai.providers.style.inherited': 'auto',
'ai.providers.style.help': 'Selects which API format requests use. Override when a third-party endpoint speaks a different dialect than its provider type suggests.',
'ai.providers.icon.change': 'Change icon',
'ai.providers.icon.upload': 'Upload image',
'ai.providers.icon.reset': 'Reset',
'ai.providers.icon.close': 'Close',
'ai.providers.icon.uploadedNote': 'Custom icon (64×64 WebP)',
'ai.providers.icon.errorType': 'Please choose an image file.',
'ai.providers.apiKey': 'API Key',
'ai.providers.apiKey.placeholder': 'Enter API key',
'ai.providers.apiKey.decrypting': 'Decrypting...',
'ai.providers.baseUrl': 'Base URL',
'ai.providers.skipTLSVerify': 'Skip TLS certificate verification (for self-signed certs)',
'ai.providers.defaultModel': 'Default Model',
'ai.providers.defaultModel.placeholder': 'e.g. gpt-4o, claude-sonnet-4-20250514',
'ai.providers.contextWindow': 'Context window',
'ai.providers.contextWindow.placeholder': 'e.g. 128000',
'ai.providers.contextWindow.help': 'Leave blank to use the model list value when available, otherwise Netcatty uses a safe default.',
'ai.providers.contextWindow.error': 'Enter a positive whole number, or leave it blank.',
'ai.providers.refreshModels': 'Refresh models',
'ai.providers.searchModel': 'Search or type model ID...',
'ai.providers.filterModels': 'Filter models...',
'ai.providers.loadingModels': 'Loading models...',
'ai.providers.noMatchingModels': 'No matching models',
'ai.providers.clickToLoadModels': 'Click to load models',
'ai.providers.showingModels': 'Showing first 100 of {count} models. Type to filter.',
'ai.providers.advancedParams': 'Advanced Parameters',
'ai.providers.advancedParams.hint': 'Leave blank to use provider defaults.',
'ai.providers.advancedParams.maxTokens.placeholder': 'e.g. 4096',
'ai.providers.advancedParams.default': 'Provider default',
// AI Codex
'ai.codex': 'Codex',
'ai.codex.title': 'Codex CLI',
'ai.codex.description': 'Connect OpenAI Codex. Sign in with ChatGPT here, or enable an OpenAI-compatible provider API key and custom endpoint in Settings.',
'ai.codex.detecting': 'Detecting...',
'ai.codex.notFound': 'Not found',
'ai.codex.awaitingLogin': 'Awaiting login',
'ai.codex.connectedChatGPT': 'Connected via ChatGPT',
'ai.codex.connectedApiKey': 'Connected via API key',
'ai.codex.connectedCustomConfig': 'Connected via ~/.codex/config.toml',
'ai.codex.customConfigIncomplete': 'Custom config detected (env var missing)',
'ai.codex.customConfigHint': 'Using custom provider "{provider}" configured in ~/.codex/config.toml — no ChatGPT login needed.',
'ai.codex.customConfigMissingEnvKey': 'Warning: {envKey} is not set in your shell environment. Export it (or launch netcatty from a shell that has it) so Codex can authenticate.',
'ai.codex.notConnected': 'Not connected',
'ai.codex.statusUnknown': 'Status unknown',
'ai.codex.path': 'Path:',
'ai.codex.notFoundHint': 'Could not find codex in PATH. Install it or specify the executable path below.',
'ai.codex.customPathPlaceholder': 'e.g. /usr/local/bin/codex',
'ai.codex.check': 'Check',
'ai.codex.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 Cursor SDK
'ai.cursor.title': 'Cursor',
'ai.cursor.description': 'Uses the Cursor SDK.',
'ai.cursor.detecting': 'Detecting...',
'ai.cursor.detected': 'Available',
'ai.cursor.notFound': 'Unavailable',
'ai.cursor.path': 'Runtime:',
'ai.cursor.notFoundHint': 'Enter an API key to enable Cursor.',
'ai.cursor.notInstalledHint': 'Cursor SDK was not detected.',
'ai.cursor.installStatus': 'Cursor SDK',
'ai.cursor.installed': 'Detected',
'ai.cursor.notInstalled': 'Not detected',
'ai.cursor.apiKeyStatus': 'API Key',
'ai.cursor.apiKeyConfigured': 'Configured',
'ai.cursor.apiKeyMissing': 'Missing',
'ai.cursor.apiKeyFromEnv': 'From environment',
'ai.cursor.apiKey': 'API Key',
'ai.cursor.apiKeyPlaceholder': 'Enter Cursor API key',
'ai.cursor.apiKeyPlaceholder.env': 'Using CURSOR_API_KEY; enter a key to override',
'ai.cursor.apiKeyEnvHint': 'Cursor can use CURSOR_API_KEY from your shell. Save a key here only if you want Netcatty to override it.',
'ai.cursor.apiKeyOverrideHint': 'Netcatty will use the saved key here before CURSOR_API_KEY.',
'ai.cursor.saveApiKey': 'Save',
'ai.cursor.saved': 'Saved',
'ai.cursor.showApiKey': 'Show API key',
'ai.cursor.hideApiKey': 'Hide API key',
'ai.cursor.customPathPlaceholder': 'e.g. /usr/local/bin/cursor',
'ai.cursor.check': 'Check',
// AI CodeBuddy Code
'ai.codebuddy.title': 'CodeBuddy Code',
'ai.codebuddy.description': 'Uses CodeBuddy Code via the official Agent SDK (`@tencent-ai/agent-sdk`). Once detected, it can be selected as an external coding agent.',
'ai.codebuddy.detecting': 'Detecting...',
'ai.codebuddy.detected': 'Detected',
'ai.codebuddy.notFound': 'Not found',
'ai.codebuddy.path': 'Path:',
'ai.codebuddy.notFoundHint': 'Could not find codebuddy in PATH. Install it or specify the executable path below.',
'ai.codebuddy.customPathPlaceholder': 'e.g. /usr/local/bin/codebuddy',
'ai.codebuddy.check': 'Check',
'ai.codebuddy.configSection': 'Authentication & config (optional)',
'ai.codebuddy.internetEnv': 'Internet Environment',
'ai.codebuddy.internetEnv.default': 'Default (overseas)',
'ai.codebuddy.internetEnv.internal': 'Internal',
'ai.codebuddy.internetEnv.ioa': 'IOA',
'ai.codebuddy.internetEnv.hint': 'Sets CODEBUDDY_INTERNET_ENVIRONMENT — choose Internal or IOA for restricted network environments.',
'ai.codebuddy.envVars': 'Environment variables',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': 'One KEY=VALUE per line, passed to the CodeBuddy agent. Set CODEBUDDY_API_KEY or CODEBUDDY_AUTH_TOKEN here for authentication. Stored locally in plaintext.',
// AI 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 Quick Messages
'ai.quickMessages.title': 'Quick Messages',
'ai.quickMessages.description': 'Create reusable prompts you can insert from the AI chat with / or the quick-message button. Unlike user skills, quick messages fill the composer with text.',
'ai.quickMessages.add': 'Add Quick Message',
'ai.quickMessages.createTitle': 'New Quick Message',
'ai.quickMessages.editTitle': 'Edit Quick Message',
'ai.quickMessages.name': 'Name',
'ai.quickMessages.name.placeholder': 'e.g. Check disk space',
'ai.quickMessages.slug': 'Command',
'ai.quickMessages.slug.placeholder': 'disk-check',
'ai.quickMessages.descriptionField': 'Description (optional)',
'ai.quickMessages.descriptionField.placeholder': 'Short hint about what this prompt does',
'ai.quickMessages.content': 'Message content',
'ai.quickMessages.content.placeholder': 'Full prompt text to insert when selected...',
'ai.quickMessages.empty': 'No quick messages yet. Add a few prompts you use often.',
'ai.quickMessages.confirmDelete': 'Delete quick message "{name}"?',
'ai.quickMessages.error.nameRequired': 'Name is required.',
'ai.quickMessages.error.invalidSlug': 'Command may only contain lowercase letters, numbers, and hyphens.',
'ai.quickMessages.error.contentRequired': 'Message content is required.',
'ai.quickMessages.error.slugTaken': 'This command is already used by another quick message.',
'ai.quickMessages.error.slugConflictsWithSkill': 'This command conflicts with user skill "/{slug}". Choose another.',
'ai.quickMessages.error.maxItems': 'You can save at most {max} quick messages.',
// AI Chat
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
'ai.chat.toolDenied': 'Action was rejected by the user.',
'ai.chat.toolApproved': 'Approved',
'ai.chat.toolApprovalHint': '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.loadEarlierMessages': 'Load earlier messages ({n} more)',
'ai.chat.usedTools': 'Tools used: {n}',
'ai.chat.loadMoreSessions': 'Load more sessions ({n} more)',
'ai.chat.noSessions': 'No previous sessions',
'ai.chat.retryHint': 'You can retry by sending your message again.',
'ai.chat.approvalTimeout': 'Tool approval timed out after 5 minutes. You can retry by sending your message again.',
'ai.chat.menuHosts': 'Hosts',
'ai.chat.menuContext': 'Context',
'ai.chat.menuFiles': 'Files',
'ai.chat.menuImage': 'Image',
'ai.chat.menuMentionHost': 'Mention Host',
'ai.chat.menuUserSkills': 'User Skills',
'ai.chat.menuSlashCommands': 'Slash Commands',
'ai.chat.slashCommands': 'Slash commands',
'ai.chat.slashQuickMessages': 'Quick messages',
'ai.chat.slashUserSkills': 'User skills',
'ai.chat.quickMessages': 'Slash commands',
'ai.chat.slashNoResults': 'No matching commands',
'ai.chat.slashEmptyHint': 'Add prompts in Settings → AI → Quick Messages.',
// AI 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.history': 'History',
'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',
'terminal.layer.hostTree.search': 'Search hosts...',
'terminal.layer.hostTree.searchButton': 'Search',
'terminal.layer.hostTree.tagsButton': 'Filter by tags',
'terminal.layer.hostTree.newGroup': 'New group',
'terminal.layer.hostTree.localShell': 'Local shell',
'terminal.layer.hostTree.tagsEmpty': 'No tags available',
'terminal.layer.hostTree.clearTags': 'Clear selection',
'terminal.layer.hostTree.collapse': 'Collapse host list',
'terminal.layer.hostTree.expand': 'Expand host list',
'terminal.layer.hostTree.empty': 'No hosts found',
'terminal.layer.hostTree.details.host': 'Host',
'terminal.layer.hostTree.details.user': 'User',
'terminal.layer.hostTree.details.port': 'Port',
'terminal.layer.hostTree.details.protocol': 'Protocol',
'terminal.layer.hostTree.details.group': 'Group',
'terminal.layer.hostTree.details.tags': 'Tags',
'terminal.layer.hostTree.details.lastConnected': 'Last connected',
'topTabs.openQuickSwitcher': 'Open quick switcher',
'topTabs.moreTabs': 'More tabs',
'topTabs.aiAssistant': 'AI Assistant',
'topTabs.windowOpacity': 'Window opacity',
'topTabs.toggleTheme': 'Toggle theme',
'topTabs.openSettings': 'Open Settings',
'ai.chat.sessionHistory': 'Session history',
'ai.chat.attach': 'Attach',
'ai.chat.terminalSelectionAttachment': 'Terminal selection',
'ai.chat.terminalSelectionLines': 'lines: {count}',
'ai.chat.collapse': 'Collapse',
'ai.chat.expand': 'Expand',
'ai.chat.enableAgent': 'Enable {name}',
'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,686 @@
import type { Messages } from '../types';
export const enCoreMessages: Messages = {
// Common
'common.save': 'Save',
'common.cancel': 'Cancel',
'common.close': 'Close',
'common.reset': 'Reset',
'common.zoomIn': 'Zoom in',
'common.zoomOut': 'Zoom out',
'common.settings': 'Settings',
'common.search': 'Search',
'common.searchPlaceholder': 'Search...',
'common.connect': 'Connect',
'common.terminal': 'Terminal',
'common.create': 'Create',
'common.import': 'Import',
'common.generate': 'Generate',
'common.delete': 'Delete',
'common.edit': 'Edit',
'common.clear': 'Clear',
'common.optional': 'Optional',
'common.selectPlaceholder': 'Select...',
'common.add': 'Add',
'common.rename': 'Rename',
'common.refresh': 'Refresh',
'common.continue': 'Continue',
'common.enabled': 'Enabled',
'common.disabled': 'Disabled',
'common.error': 'Error',
'common.validation': 'Validation',
'common.unknownError': 'Unknown error',
'common.noResultsFound': 'No results found',
'common.back': 'Back',
'common.apply': 'Apply',
'common.use': 'Use',
'common.useGlobal': 'Use global',
'common.saveChanges': 'Save Changes',
'common.advanced': 'Advanced',
'common.left': 'Left',
'common.right': 'Right',
'common.more': 'More',
'common.selectAHost': 'Select a host',
'common.selectAHostPlaceholder': 'Select a host...',
'sort.manual': 'Manual order',
'sort.az': 'A-z',
'sort.za': 'Z-a',
'sort.newest': 'Newest to oldest',
'sort.oldest': 'Oldest to newest',
'sort.group': 'By group',
'field.label': 'Label',
'field.type': 'Type',
'auth.keyType': 'Type {type}',
'auth.showAllKeys': 'Show all keys',
// Dialogs / prompts
'confirm.deleteHost': 'Delete Host "{name}"?',
'confirm.deleteIdentity': 'Delete Identity "{name}"?',
'confirm.removeProvider': 'Remove provider "{name}"?',
'confirm.closeBusyTerminal.title': 'Confirm close',
'confirm.closeBusyTerminal.message': 'Process "{command}" is still running and will be terminated.',
'confirm.closeBusyTerminal.messageWithMore': 'Process "{command}" and {count} other running process(es) will be terminated.',
'confirm.closeBusyTerminal.cancel': 'Cancel',
'confirm.closeBusyTerminal.close': 'Close',
'dialog.createWorkspace.title': 'Create Workspace',
'dialog.renameWorkspace.title': 'Rename workspace',
'dialog.renameSession.title': 'Rename session',
'field.name': 'Name',
'field.selectHosts': 'Select Hosts',
'placeholder.workspaceName': 'Workspace name',
'placeholder.sessionName': 'Session name',
'placeholder.searchHosts': 'Search hosts...',
'toast.settingsUnavailable': 'Settings window is unavailable on this platform.',
'credentials.protectionUnavailable.title': 'Credential Protection Unavailable',
'credentials.protectionUnavailable.message': 'Saved passwords and keys cannot be auto-decrypted on this device. Re-enter credentials before connecting.',
'credentials.protectionUnavailable.action': 'Open Settings',
// Settings shell
'settings.title': 'Settings',
'settings.tab.application': 'Application',
'settings.tab.appearance': 'Appearance',
'settings.tab.terminal': 'Terminal',
'settings.tab.shortcuts': 'Shortcuts',
'settings.tab.syncCloud': 'Sync & Cloud',
'settings.tab.system': 'System',
// Settings > System
'settings.system.title': 'System',
'settings.system.description': 'System information and temporary file management.',
'settings.system.tempDirectory': 'Temporary Files',
'settings.system.location': 'Location',
'settings.system.fileCount': 'Files',
'settings.system.totalSize': 'Size',
'settings.system.openFolder': 'Open folder',
'settings.system.refresh': 'Refresh',
'settings.system.clearTempFiles': 'Clear temp files',
'settings.system.clearing': 'Clearing...',
'settings.system.clearResult': 'Deleted {deleted} file(s), {failed} failed.',
'settings.system.tempDirectoryHint': 'Temporary files are created when opening remote files with external applications. They are automatically cleaned up when SFTP sessions close.',
'settings.system.credentials.title': 'Credential Protection',
'settings.system.credentials.status': 'Status',
'settings.system.credentials.checking': 'Checking...',
'settings.system.credentials.available': 'Available (OS keychain ready)',
'settings.system.credentials.unavailable': 'Unavailable (cannot decrypt saved credentials)',
'settings.system.credentials.unknown': 'Unknown (not supported in this environment)',
'settings.system.credentials.unavailableHint': 'Credentials encrypted on another user profile or machine cannot be decrypted here. Re-enter and save credentials on this device.',
'settings.system.credentials.portabilityHint': 'Cloud Sync is portable because it uses your master key encryption. Local safeStorage encryption is device/user scoped.',
// Settings > System > 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.timestamps': 'Add timestamps',
'settings.sessionLogs.timestampsDesc': 'Prefix each line in plain text and HTML logs with the local time.',
'settings.sessionLogs.hint': 'Session logs capture all terminal output for troubleshooting and auditing purposes.',
// Settings > SSH Debug Logs
'settings.sshDebugLogs.title': 'SSH Debug Logs',
'settings.sshDebugLogs.enable': 'Enable SSH debug logs',
'settings.sshDebugLogs.enableDesc': 'Record connection, auth, handshake, disconnect, and error reasons without saving terminal output.',
'settings.sshDebugLogs.location': 'Log Location',
'settings.sshDebugLogs.status': 'Status',
'settings.sshDebugLogs.statusOn': 'On',
'settings.sshDebugLogs.statusOff': 'Off',
'settings.sshDebugLogs.size': 'Size',
'settings.sshDebugLogs.hint': 'When enabled, newly started SSH connections write diagnostic events for bastion, auth, and unexpected disconnect troubleshooting.',
// Settings > Global Hotkey (Quake Mode)
'settings.globalHotkey.title': 'Global Hotkey',
'settings.globalHotkey.toggleWindow': 'Toggle Window',
'settings.globalHotkey.toggleWindowDesc': 'Press a key combination to set a global shortcut for showing/hiding the window.',
'settings.globalHotkey.notSet': 'Not set',
'settings.globalHotkey.reset': 'Reset to default',
'settings.globalHotkey.closeToTray': 'Close to System Tray',
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window will minimize to the system tray instead of quitting.',
'settings.globalHotkey.enabled': 'Enable Global Hotkey',
'settings.globalHotkey.enabledDesc': 'Register system-wide keyboard shortcuts. When disabled, all global hotkeys are unregistered.',
'settings.globalHotkey.hint': 'Global hotkey works system-wide to quickly show or hide the window (Quake-style terminal).',
// Tray Panel
'tray.openMainWindow': 'Open Main Window',
'tray.sessions': 'Sessions',
'tray.portForwarding': 'Port Forwarding',
'tray.status.connected': 'Connected',
'tray.status.connecting': 'Connecting',
'tray.status.disconnected': 'Disconnected',
'tray.status.active': 'Active',
'tray.status.inactive': 'Inactive',
'tray.status.error': 'Error',
'tray.recentHosts': 'Recent Hosts',
'tray.empty.title': 'Nothing here yet',
'tray.empty.subtitle': 'Go connect to a server, they miss you 🚀',
'tray.quit': 'Quit Netcatty',
// Vault Sidebar
'vault.sidebar.collapse': 'Collapse sidebar',
'vault.sidebar.expand': 'Expand sidebar',
'vault.sidebar.resize': 'Resize sidebar',
// Settings > Application
'settings.application.checkUpdates': 'Check for updates',
'settings.application.reportProblem': 'Report a problem',
'settings.application.reportProblem.subtitle': 'Generate a pre-filled GitHub issue',
'settings.application.community': 'Community',
'settings.application.community.subtitle': 'On GitHub Discussions',
'settings.application.github': 'GitHub',
'settings.application.github.subtitle': 'Source code',
'settings.application.whatsNew': "What's new",
'settings.application.whatsNew.subtitle': 'Show release notes',
'settings.application.openExternal.failedTitle': 'Cannot open link',
'settings.application.openExternal.failedBody': 'The link could not be opened in either the system browser or the built-in browser window.',
'settings.vault.title': 'Vault',
'settings.vault.showRecentHosts': 'Show recently connected hosts',
'settings.vault.showRecentHostsDesc': 'Display a section of recently connected hosts at the top of the vault',
'settings.vault.showOnlyUngroupedHostsInRoot': 'Only show ungrouped hosts at root',
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'When enabled, the root host list only shows hosts without a group. Open a group from the sidebar to see grouped hosts.',
'settings.vault.showSftpTab': 'Show SFTP tab',
'settings.vault.showSftpTabDesc': 'Display the standalone SFTP view in the top tab bar. When hidden, use the in-session SFTP side panel instead.',
'settings.vault.showHostTreeSidebar': 'Show host list sidebar',
'settings.vault.showHostTreeSidebarDesc': 'Display the host list sidebar and its top-bar toggle on terminal and editor tabs.',
// Update notifications
'update.available.title': 'Update Available',
'update.available.message': 'A new version {version} is available. Click to download.',
'update.checking': 'Checking for updates...',
'update.upToDate.title': 'Up to Date',
'update.upToDate.message': 'You are running the latest version ({version}).',
'update.error': 'Failed to check for updates',
'update.downloadNow': 'Download Now',
'update.viewInSettings': 'View in Settings',
'update.readyToInstall.title': 'Update Ready',
'update.readyToInstall.message': 'Version {version} downloaded and ready to install.',
'update.restartNow': 'Restart Now',
'update.downloadFailed.title': 'Update Failed',
'update.downloadFailed.message': 'Failed to download update. You can download it manually.',
'update.needsSave.title': 'Unsaved Changes',
'update.needsSave.message': 'Save your open editors first, then click Restart Now again to install the update.',
'update.openReleases': 'Open Releases',
'update.remindLater': 'Remind Later',
'update.skipVersion': 'Skip This Version',
// Settings > Appearance
'settings.appearance.uiTheme': 'UI Theme',
'settings.appearance.theme': 'Theme',
'settings.appearance.theme.desc': 'Choose light, dark, or follow system preference',
'settings.appearance.theme.light': 'Light',
'settings.appearance.theme.dark': 'Dark',
'settings.appearance.theme.system': 'System',
'settings.appearance.accentColor': 'Accent Color',
'settings.appearance.customColor': 'Custom color',
'settings.appearance.accentColor.mode': 'Use custom accent',
'settings.appearance.accentColor.mode.desc': 'Override the theme accent color',
'settings.appearance.accentColor.custom': 'Custom accent',
'settings.appearance.themeColor': 'Theme Color',
'settings.appearance.themeColor.desc': 'Pick a preset palette for each theme',
'settings.appearance.themeColor.light': 'Light palette',
'settings.appearance.themeColor.dark': 'Dark palette',
'settings.appearance.customCss': 'Custom CSS',
'settings.appearance.customCss.desc':
'Add custom CSS to personalize the app appearance. Changes apply immediately. Major UI regions expose a [data-section="..."] attribute you can target — e.g. snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (focus-mode terminal list), terminal-host-tree-sidebar, terminal-host-tree-sidebar-content, terminal-host-tree-sidebar-row, terminal-side-panel (SFTP/Scripts/Theme/AI panel, available while open), terminal-side-panel-tabs, terminal-side-panel-content, terminal-sftp-panel, terminal-sftp-host-header, terminal-sftp-pane, terminal-sftp-toolbar, terminal-sftp-path, terminal-sftp-filter-bar, terminal-sftp-list, terminal-sftp-list-header, terminal-sftp-list-row, terminal-sftp-tree, terminal-sftp-tree-row, terminal-sftp-transfer-queue, terminal-sftp-transfer-row, terminal-split-pane, terminal-split-resizer, top-tabs, top-tabs-host-tree-toggle, top-tabs-quick-switcher-toggle.',
'settings.appearance.customCss.placeholder':
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Hide the host-list toggle in the top tab bar */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Hide the plus button that opens the quick switcher */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Border around the SFTP / side panel (does not linger after closing) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Change the whole side panel background, not only the top tabs */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Style selected SFTP file rows */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */',
'settings.appearance.language': 'Language',
'settings.appearance.language.desc': 'Choose the UI language',
'settings.appearance.uiFont': 'Interface Font',
'settings.appearance.uiFont.desc': 'Choose the font for the application interface',
'settings.appearance.windowOpacity': 'Window Opacity',
'settings.appearance.windowOpacity.desc': 'Adjust the transparency of the entire application window. Lower values also fade terminal text. Some Linux desktop environments may not support this.',
// Settings > 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.section.systemManager': 'System Manager',
'settings.terminal.systemManager.processRefreshInterval': 'Process list refresh',
'settings.terminal.systemManager.processRefreshInterval.desc': 'How often to refresh the process list in the system manager side panel.',
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux session refresh',
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'How often to refresh the tmux session list.',
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker container list refresh',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh the Docker container list.',
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker stats refresh',
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'How often to refresh Docker container CPU/memory/network stats.',
'settings.terminal.serverStats.show': 'Show Server Stats',
'settings.terminal.serverStats.show.desc': 'Display CPU, memory, and disk usage in the terminal statusbar (Linux servers only).',
'settings.terminal.serverStats.refreshInterval': 'Refresh Interval',
'settings.terminal.serverStats.refreshInterval.desc': 'How often to refresh server stats.',
'settings.terminal.serverStats.seconds': 'seconds',
// Settings > Terminal > Rendering
'settings.terminal.section.rendering': 'Rendering',
'settings.terminal.rendering.renderer': 'Renderer',
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use DOM on low-memory devices. Changes take effect on new terminal sessions.',
'settings.terminal.rendering.auto': 'Auto',
// Settings > Terminal > 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.shellOnlyTabNumberShortcuts.label': 'Number keys skip pinned tabs',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'When enabled, Cmd/Ctrl+[1...9] switches only work tabs (terminals, workspaces, editors), not the pinned Vault or SFTP tabs.',
'settings.shortcuts.section.custom': 'Custom Shortcuts',
'settings.shortcuts.resetAll': 'Reset All',
'settings.shortcuts.recording': 'Press keys...',
'settings.shortcuts.none': 'None',
'settings.shortcuts.setDisabled': 'Set to disabled',
'settings.shortcuts.category.tabs': 'Tabs',
'settings.shortcuts.category.terminal': 'Terminal',
'settings.shortcuts.category.navigation': 'Navigation',
'settings.shortcuts.category.app': 'App',
'settings.shortcuts.category.sftp': 'SFTP',
// Context menus / common actions
'action.newHost': 'New Host',
'action.newSubfolder': 'New Subfolder',
'action.copyPublicKey': 'Copy Public Key',
'action.keyExport': 'Key Export',
'action.edit': 'Edit',
'action.delete': 'Delete',
'action.duplicate': 'Duplicate',
'action.open': 'Open',
'action.copy': 'Copy',
'action.run': 'Run',
'action.start': 'Start',
'action.stop': 'Stop',
'action.remove': 'Remove',
'action.convertToHost': 'Convert to Host',
// Sync
'sync.cloudSync': 'Cloud Sync',
'sync.settings': 'Sync Settings',
'sync.active': 'Cloud Sync Active',
'sync.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.unnamed': 'Unnamed Group',
'vault.groups.delete': 'Delete Group',
'vault.groups.createSubfolder': 'Create Subfolder',
'vault.groups.createRoot': 'Create Root Group',
'vault.groups.createDialog.desc': 'Create a new group for organizing hosts.',
'vault.groups.renameDialogTitle': 'Rename Group',
'vault.groups.renameDialog.desc': 'Rename an existing group.',
'vault.groups.deleteDialogTitle': 'Delete Group',
'vault.groups.deleteDialog.desc': 'This will permanently delete the group and move all hosts to the root level.',
'vault.groups.deleteDialog.managedDesc': 'This is a managed SSH config group. Deleting it will also delete all hosts and unlink from the source file.',
'vault.groups.deleteDialog.deleteHosts': 'Also delete all hosts in this group',
'vault.groups.ungrouped': 'Ungrouped',
'vault.groups.field.name': 'Group Name',
'vault.groups.placeholder.example': 'e.g. Production',
'vault.groups.parentLabel': 'Parent',
'vault.groups.pathLabel': 'Path',
'vault.groups.settings': 'Group Settings',
'vault.groups.details': 'Group Details',
'vault.groups.details.general': 'General',
'vault.groups.details.ssh': 'SSH',
'vault.groups.details.telnet': 'Telnet',
'vault.groups.details.advanced': 'Advanced',
'vault.groups.details.appearance': 'Appearance',
'vault.groups.details.mosh': 'Mosh',
'vault.groups.details.parentGroup': 'Parent Group',
'vault.groups.details.none': 'None',
'vault.groups.details.inherited': 'Inherited from group',
'vault.groups.details.addProtocol': 'Add Protocol',
'vault.groups.details.removeProtocol': 'Remove Protocol',
'vault.groups.details.fontFamily': 'Font Family',
'vault.groups.details.fontSize': 'Font Size',
'vault.groups.errors.required': 'Group name is required.',
'vault.groups.errors.invalidChars': "Group name cannot include '/' or '\\\\'.",
'vault.groups.errors.duplicatePath': 'A group with this name already exists at this location.',
'vault.managedSource.unmanage': 'Unmanage',
'vault.managedSource.unmanageSuccess': 'Successfully unmanaged group',
'vault.hosts.header.entries': '{count} entries',
'vault.hosts.header.live': '{count} live',
// Vault hosts header/actions
'vault.hosts.search.placeholder': 'Find a host or ssh user@hostname / ssh -p 2222 user@hostname...',
'vault.hosts.connect': 'Connect',
'vault.view.grid': 'Grid',
'vault.view.list': 'List',
'vault.view.tree': 'Tree',
'vault.tree.expandAll': 'Expand All',
'vault.tree.collapseAll': 'Collapse All',
'vault.hosts.newHost': 'New Host',
'vault.hosts.newGroup': 'New Group',
'vault.hosts.import': 'Import',
'vault.hosts.export': 'Export',
'vault.hosts.export.toast.success': 'Exported {count} hosts to CSV',
'vault.hosts.export.toast.successWithSkipped': 'Exported {count} hosts to CSV ({skipped} unsupported hosts skipped)',
'vault.hosts.export.toast.noHosts': 'No hosts to export',
'vault.hosts.allHosts': 'All hosts',
'vault.hosts.pinned': 'Pinned',
'vault.hosts.recentlyConnected': 'Recently Connected',
'vault.hosts.pinToTop': 'Pin to Top',
'vault.hosts.unpin': 'Unpin',
'vault.hosts.copyCredentials': 'Copy Credentials',
'vault.hosts.copyCredentials.toast.success': 'Credentials copied to clipboard',
'vault.hosts.copyCredentials.toast.noPassword': 'No password saved for this host',
'vault.hosts.multiSelect': 'Multi-select',
'vault.hosts.selected': '{count} selected',
'vault.hosts.selectAll': 'Select All',
'vault.hosts.deselectAll': 'Deselect All',
'vault.hosts.deleteSelected': 'Delete ({count})',
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
'vault.hosts.connectSelected': 'Connect ({count})',
'vault.hosts.connectMultiple.success': 'Connecting {count} hosts',
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
'vault.hosts.errors.nameRequired': 'Host name is required.',
'vault.hosts.empty.title': 'Set up your hosts',
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
};

View File

@@ -0,0 +1,181 @@
import type { Messages } from '../types';
export const enSystemManagerMessages: Messages = {
'terminal.layer.system': 'System',
'systemManager.noSession': 'No active terminal session.',
'systemManager.notConnected': 'Connect to a host to manage processes and services.',
'systemManager.empty': 'No data available.',
'systemManager.tabs.processes': 'Processes',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.popup.loading': 'Opening terminal…',
'systemManager.popup.startupFailed': 'The startup command did not complete successfully. Check that the target is still available and try again.',
'systemManager.errors.loadProcesses': 'Failed to load processes',
'systemManager.errors.loadTmux': 'Failed to load tmux sessions',
'systemManager.errors.loadTmuxWindows': 'Failed to load tmux windows',
'systemManager.errors.loadTmuxPanes': 'Failed to load tmux panes',
'systemManager.errors.loadTmuxClients': 'Failed to load tmux clients',
'systemManager.errors.actionFailed': 'Action failed',
'systemManager.errors.loadDocker': 'Failed to load containers',
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
'systemManager.errors.loadDockerImages': 'Failed to load images',
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
'systemManager.processes.search': 'Search processes…',
'systemManager.processes.command': 'Command',
'systemManager.processes.user': 'User',
'systemManager.processes.term': 'Terminate',
'systemManager.processes.kill': 'Kill',
'systemManager.processes.stop': 'Stop (SIGSTOP)',
'systemManager.processes.cont': 'Continue (SIGCONT)',
'systemManager.processes.hup': 'Hang up (SIGHUP)',
'systemManager.processes.renice': 'Renice',
'systemManager.processes.renicePrompt': 'Nice value (-20 to 19)',
'systemManager.processes.reniceInvalid': 'Nice value must be between -20 and 19',
'systemManager.processes.confirmKill': 'Send SIGKILL to process {{pid}}?',
'systemManager.processes.confirmSignal': 'Send SIG{{signal}} to process {{pid}}?',
'systemManager.processes.filter.all': 'All',
'systemManager.processes.filter.running': 'Running',
'systemManager.processes.ppid': 'Parent PID',
'systemManager.processes.rss': 'RSS',
'systemManager.processes.vsz': 'Virtual size',
'systemManager.processes.elapsed': 'Elapsed',
'systemManager.processes.stat': 'State',
'systemManager.processes.meta': '{{count}} process(es)',
'systemManager.processes.loading': 'Loading processes…',
'systemManager.processes.loadingMore': 'Loading more processes…',
'systemManager.processes.state.running': 'Running',
'systemManager.processes.state.sleeping': 'Sleeping',
'systemManager.processes.state.stopped': 'Stopped',
'systemManager.processes.state.zombie': 'Zombie',
'systemManager.processes.sort.cpu': 'CPU',
'systemManager.processes.sort.mem': 'MEM',
'systemManager.processes.sort.pid': 'PID',
'systemManager.processes.sort.command': 'Command',
'systemManager.processes.sort.user': 'User',
'systemManager.common.dismiss': 'Dismiss',
'systemManager.common.checkingAvailability': 'Checking availability…',
'systemManager.common.loading': 'Loading…',
'systemManager.common.loadingDetails': 'Loading details…',
'systemManager.common.loadingStats': 'Loading stats…',
'systemManager.tmux.new': 'New',
'systemManager.tmux.search': 'Search sessions…',
'systemManager.tmux.newSessionTitle': 'New tmux session',
'systemManager.tmux.newSessionDesc': 'Name the session and optionally run a script on start.',
'systemManager.tmux.newSessionTabCustom': 'Custom command',
'systemManager.tmux.newSessionTabSnippet': 'From snippet',
'systemManager.tmux.pickSnippet': 'From snippets',
'systemManager.tmux.pickSnippetEmpty': 'No snippets yet — add some in the Scripts panel or Vault.',
'systemManager.tmux.selectedSnippet': 'Using snippet: {{label}}',
'systemManager.tmux.newSessionName': 'Session name',
'systemManager.tmux.newSessionCommand': 'Start command',
'systemManager.tmux.newSessionCommandPlaceholder': 'e.g. htop or npm run dev (optional)',
'systemManager.tmux.newSessionCommandHint': 'Leave empty for a default shell session.',
'systemManager.tmux.creating': 'Creating…',
'systemManager.tmux.newSessionPlaceholder': 'my-session',
'systemManager.tmux.newSessionRequired': 'Enter a session name first',
'systemManager.tmux.empty': 'No tmux sessions',
'systemManager.tmux.attach': 'Attach',
'systemManager.tmux.attached': 'Attached',
'systemManager.tmux.detached': 'Detached',
'systemManager.tmux.windows': '{{count}} window(s)',
'systemManager.tmux.created': 'Created',
'systemManager.tmux.activity': 'Activity',
'systemManager.tmux.rename': 'Rename',
'systemManager.tmux.detach': 'Detach all',
'systemManager.tmux.killSession': 'Kill session',
'systemManager.tmux.killServer': 'Kill server',
'systemManager.tmux.loadingDetails': 'Loading details…',
'systemManager.tmux.clients': 'Attached clients',
'systemManager.tmux.windowList': 'Windows',
'systemManager.tmux.newWindow': 'New window',
'systemManager.tmux.newWindowPlaceholder': 'Window name (optional)',
'systemManager.tmux.noWindows': 'No windows',
'systemManager.tmux.unavailable': 'tmux is not available on this host',
'systemManager.docker.unavailable': 'Docker is not available on this host',
'systemManager.tmux.windowsMismatch': 'Session reports {{count}} window(s) but list-windows returned none',
'systemManager.tmux.lastCommand': 'last command: {{command}}',
'systemManager.tmux.noPanes': 'No panes',
'systemManager.tmux.panes': '{{count}} pane(s)',
'systemManager.tmux.active': 'active',
'systemManager.tmux.unnamedWindow': 'Unnamed window',
'systemManager.tmux.unnamedPane': 'Unnamed pane',
'systemManager.tmux.attachWindow': 'Attach to window',
'systemManager.tmux.selectWindow': 'Select window',
'systemManager.tmux.killWindow': 'Kill window',
'systemManager.tmux.killPane': 'Kill pane',
'systemManager.tmux.splitHorizontal': 'Split horizontal',
'systemManager.tmux.splitVertical': 'Split vertical',
'systemManager.tmux.sendKeys': 'Send keys',
'systemManager.tmux.sendKeysTo': 'Send keys to window {{window}} pane {{pane}}',
'systemManager.tmux.sendKeysPlaceholder': 'Command or text…',
'systemManager.tmux.renameSessionPrompt': 'Rename session',
'systemManager.tmux.renameWindowPrompt': 'Rename window',
'systemManager.tmux.windowName': 'Window name',
'systemManager.tmux.confirmKillSession': 'Kill tmux session "{{name}}"?',
'systemManager.tmux.confirmDetachSession': 'Detach all clients from "{{name}}"?',
'systemManager.tmux.confirmKillWindow': 'Kill window "{{name}}"?',
'systemManager.tmux.confirmKillPane': 'Kill pane #{{index}}?',
'systemManager.tmux.confirmKillServer': 'Kill tmux server? All sessions will be terminated.',
'systemManager.tmux.meta': '{{count}} session(s)',
'systemManager.docker.title': 'Containers',
'systemManager.docker.subTabs.containers': 'Containers',
'systemManager.docker.subTabs.images': 'Images',
'systemManager.docker.empty': 'No containers found',
'systemManager.docker.imagesEmpty': 'No images found',
'systemManager.docker.search': 'Search containers…',
'systemManager.docker.searchImages': 'Search images…',
'systemManager.docker.filter.all': 'All',
'systemManager.docker.filter.running': 'Running',
'systemManager.docker.filter.stopped': 'Stopped',
'systemManager.docker.filter.paused': 'Paused',
'systemManager.docker.shell': 'Shell',
'systemManager.docker.logs': 'Logs',
'systemManager.docker.details': 'Details',
'systemManager.docker.inspect': 'Inspect',
'systemManager.docker.imageInspect': 'Image inspect',
'systemManager.docker.confirmRemove': 'Remove this container?',
'systemManager.docker.confirmKill': 'Force kill this container?',
'systemManager.docker.confirmRemoveImage': 'Remove image "{{name}}"?',
'systemManager.docker.confirmPrune': 'Remove dangling images?',
'systemManager.docker.confirmPruneAll': 'Remove all unused images?',
'systemManager.docker.pause': 'Pause',
'systemManager.docker.unpause': 'Unpause',
'systemManager.docker.restart': 'Restart',
'systemManager.docker.kill': 'Kill',
'systemManager.docker.renamePrompt': 'Container name',
'systemManager.docker.prune': 'Prune',
'systemManager.docker.pruneAll': 'Prune all',
'systemManager.docker.tag': 'Tag',
'systemManager.docker.tagRepoPrompt': 'Repository name',
'systemManager.docker.tagNamePrompt': 'Tag name',
'systemManager.docker.meta': '{{count}} container(s)',
'systemManager.docker.imagesMeta': '{{count}} image(s)',
'systemManager.docker.start': 'Start',
'systemManager.docker.stop': 'Stop',
'systemManager.inspect.status': 'Status',
'systemManager.inspect.image': 'Image',
'systemManager.inspect.created': 'Created',
'systemManager.inspect.started': 'Started',
'systemManager.inspect.restartPolicy': 'Restart policy',
'systemManager.inspect.command': 'Command',
'systemManager.inspect.ports': 'Ports',
'systemManager.inspect.networks': 'Networks',
'systemManager.inspect.mounts': 'Mounts',
'systemManager.inspect.env': 'Environment',
'systemManager.inspect.labels': 'Labels',
'systemManager.inspect.tags': 'Tags',
'systemManager.inspect.digests': 'Digests',
'systemManager.inspect.size': 'Size',
'systemManager.inspect.platform': 'Platform',
'systemManager.inspect.workdir': 'Working dir',
'systemManager.inspect.exposedPorts': 'Exposed ports',
'systemManager.inspect.showRaw': 'JSON',
'systemManager.inspect.hideRaw': 'Hide JSON',
};

View File

@@ -0,0 +1,698 @@
import type { Messages } from '../types';
export const enTerminalMessages: Messages = {
'terminal.sudoHint.pressEnter': 'Press Enter to paste sudo password',
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': 'Open SFTP',
'terminal.toolbar.availableAfterConnect': 'Available after connect',
'terminal.toolbar.sendYmodem': 'Send with YMODEM',
'terminal.toolbar.sftp': 'SFTP',
'terminal.toolbar.more': 'More actions',
'terminal.toolbar.scripts': 'Scripts',
'terminal.toolbar.history': 'Command history',
'history.scope.label': 'History scope',
'history.tab.host': 'Host',
'history.tab.global': 'Global',
'history.searchPlaceholder': 'Search history...',
'history.loading': 'Loading remote history...',
'history.meta.count': '{count} commands',
'history.empty.noSession': 'Open a remote session to view its command history.',
'history.empty.unsupportedProtocol': 'Command history is only available for SSH/Mosh/ET sessions.',
'history.empty.noHistory': 'No command history found on this host.',
'history.empty.noGlobalHistory': 'No global command history yet. Commands you run will appear here.',
'history.action.refresh': 'Refresh',
'history.action.retry': 'Retry',
'history.action.paste': 'Paste to terminal',
'history.action.run': 'Run in terminal',
'history.action.saveAsSnippet': 'Save as snippet',
'terminal.toolbar.library': 'Library',
'terminal.toolbar.noSnippets': 'No snippets available',
'terminal.toolbar.terminalSettings': 'Terminal settings',
'terminal.toolbar.searchTerminal': 'Search terminal',
'terminal.toolbar.search': 'Search',
'terminal.toolbar.timestampsEnable': 'Show timestamps',
'terminal.toolbar.timestampsDisable': 'Hide timestamps',
'terminal.toolbar.broadcast': 'Broadcast',
'terminal.toolbar.broadcastEnable': 'Enable Broadcast Mode',
'terminal.toolbar.broadcastDisable': 'Disable Broadcast Mode',
'terminal.toolbar.composeBar': 'Compose Bar',
'terminal.composeBar.placeholder': 'Type command here, press Enter to send...',
'terminal.composeBar.send': 'Send',
'terminal.composeBar.close': 'Close compose bar',
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
'terminal.composeBar.resize': 'Resize compose bar height',
'terminal.composeBar.manageSnippets': 'Manage quick snippets',
'terminal.composeBar.searchSnippets': 'Search snippets...',
'terminal.composeBar.noPinnedSnippets': 'Pin snippets with + for quick access',
'terminal.composeBar.noMatchingSnippets': 'No matching snippets',
'terminal.composeBar.pinnedCount': '{count} pinned',
'terminal.composeBar.unpinSnippet': 'Remove {label} from quick bar',
'terminal.composeBar.snippetClickHint': 'Click to insert · Shift+Click to send',
'terminal.toolbar.focus': 'Focus',
'terminal.toolbar.focusMode': 'Focus Mode',
'terminal.toolbar.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.addSelectionToAI': 'Add to Conversation',
'terminal.menu.pasteSelection': 'Paste Selection',
'terminal.menu.selectAll': 'Select All',
'terminal.menu.reconnect': 'Reconnect',
'terminal.menu.sendYmodem': 'Send with YMODEM',
'terminal.menu.splitHorizontal': 'Split Horizontal',
'terminal.menu.splitVertical': 'Split Vertical',
'terminal.menu.clearBuffer': 'Clear Buffer',
'terminal.menu.closeTerminal': 'Close terminal',
'terminal.ymodem.selectFile': 'Select file to send',
'terminal.ymodem.allFiles': 'All files',
'terminal.ymodem.started': 'YMODEM sending {fileName}',
'terminal.ymodem.complete': 'YMODEM sent {fileName}',
'terminal.ymodem.failed': 'YMODEM send failed',
'terminal.ymodem.unavailable': 'YMODEM is unavailable',
'terminal.selection.addToAI': 'Add to Conversation',
'terminal.selection.addToAIDesc': 'Attach selected terminal output to the AI draft',
'terminal.auth.password': 'Password',
'terminal.auth.sshKey': 'SSH Key',
'terminal.auth.username': 'Username',
'terminal.auth.username.placeholder': 'root',
'terminal.auth.passwordLabel': 'Password',
'terminal.auth.password.placeholder': 'Enter password',
'terminal.auth.passphrase': 'Passphrase',
'terminal.auth.passphrase.placeholder': 'Optional passphrase for the selected private key',
'terminal.auth.certificate': 'Certificate',
'terminal.auth.selectKey': 'Select Key',
'terminal.auth.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.copyTabToNewWindow': 'Copy Tab to New Window',
'tabs.copyTabToNewWindowFailed': 'Failed to open tab in a new window',
'tabs.closeOthers': 'Close Others',
'tabs.closeToRight': 'Close Tabs to the Right',
'tabs.closeAll': 'Close All',
'keychain.edit.labelRequired': 'Label *',
'keychain.edit.keyLabelPlaceholder': 'Key label',
'keychain.edit.privateKeyRequired': 'Private key *',
'keychain.edit.publicKey': 'Public key',
'keychain.edit.certificate': 'Certificate',
'keychain.edit.certificatePlaceholder': 'Certificate content (optional)',
'keychain.edit.filePath': 'File path',
'keychain.edit.keyExport': 'Key export',
'keychain.edit.exportToHost': 'Export to host',
// Snippets
'snippets.searchPlaceholder': 'Search 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,670 @@
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.list': 'Bookmarked paths',
'sftp.bookmark.addGlobal': '+Global',
'sftp.bookmark.addGlobalTooltip': 'Save as global bookmark (shared across all hosts)',
'sftp.bookmark.empty': 'No bookmarks yet',
'sftp.columns.name': 'Name',
'sftp.columns.modified': 'Modified',
'sftp.columns.size': 'Size',
'sftp.columns.kind': 'Kind',
'sftp.columns.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.viewMode.switchToList': 'Switch to list view',
'sftp.viewMode.switchToTree': 'Switch to tree view',
'sftp.tree.loadError': 'Failed to load directory',
'sftp.tree.loading': 'Loading...',
'sftp.kind.folder': 'Folder',
'sftp.context.rename': 'Rename',
'sftp.context.permissions': 'Permissions',
'sftp.context.delete': 'Delete',
'sftp.context.refresh': 'Refresh',
'sftp.context.uploadFiles': 'Upload File(s)...',
'sftp.context.uploadFilesHere': 'Upload File(s) Here...',
'sftp.context.uploadFolder': 'Upload Folder...',
'sftp.context.uploadFolderHere': 'Upload Folder Here...',
'sftp.context.downloadSelected': 'Download selected ({count})',
'sftp.context.deleteSelected': 'Delete selected ({count})',
'sftp.dropFilesHere': 'Drop files here',
'sftp.itemsCount': '{count} items',
'sftp.selectedCount': '{count} selected',
'sftp.path.doubleClickToEdit': 'Double-click to edit path',
'sftp.showHiddenPaths': 'Hidden paths',
'sftp.task.waiting': 'Waiting...',
'sftp.transfer.preparing': 'preparing...',
'sftp.status.loading': 'Loading...',
'sftp.status.uploading': 'Uploading...',
'sftp.status.ready': 'Ready',
'sftp.transfers': 'Transfers',
'sftp.transfers.active': '{count} active',
'sftp.transfers.clearCompleted': 'Clear completed',
'sftp.transfers.calculatingTotal': 'Calculating total size...',
'sftp.transfers.filesCount': '{count} files',
'sftp.transfers.filesProgress': '{current}/{total} files',
'sftp.transfers.expandChildren': 'Show files',
'sftp.transfers.collapseChildren': 'Hide files',
'sftp.transfers.expandChildList': 'Show detail',
'sftp.transfers.collapseChildList': 'Hide',
'sftp.transfers.retryAction': 'Retry',
'sftp.transfers.dismissAction': 'Dismiss',
'sftp.transfers.openTargetFolder': 'Open target folder',
'sftp.transfers.openTargetFolderError': 'Could not open target folder',
'sftp.transfers.copyTargetPath': 'Copy target path',
'sftp.transfers.copyTargetPathSuccess': 'Target path copied',
'sftp.transfers.copyTargetPathError': 'Could not copy target path',
'sftp.transfers.resizeNameColumn': 'Resize file name column',
'sftp.transfers.dragToResize': 'Drag to resize',
'sftp.goUp': 'Go up',
'sftp.goToTerminalCwd': 'Go to terminal directory',
'sftp.followTerminalCwd': 'Follow terminal directory',
'sftp.followTerminalCwd.enable': 'Enable follow terminal directory',
'sftp.followTerminalCwd.disable': 'Disable follow terminal directory',
'sftp.encoding.label': 'Filename Encoding',
'sftp.encoding.auto': 'Auto',
'sftp.encoding.utf8': 'UTF-8',
'sftp.encoding.gb18030': 'GB18030',
'sftp.goHome': 'Go to home',
'sftp.folderName': 'Folder name',
'sftp.folderName.placeholder': 'Enter folder name',
'sftp.fileName': 'File name',
'sftp.fileName.placeholder': 'Enter file name',
'sftp.prompt.newFolderName': 'New folder name?',
'sftp.rename.title': 'Rename',
'sftp.rename.newName': 'New name',
'sftp.rename.placeholder': 'Enter new name',
'sftp.confirm.deleteOne': 'Delete "{name}"?',
'sftp.deleteConfirm.single': 'Delete "{name}"?',
'sftp.deleteConfirm.title': 'Delete {count} item(s)?',
'sftp.deleteConfirm.desc': 'This action cannot be undone. The following will be deleted:',
'sftp.deleteConfirm.descSingle': 'This action cannot be undone.',
'sftp.deleteConfirm.host': 'Host',
'sftp.deleteConfirm.path': 'Path',
'sftp.error.loadFailed': 'Failed to load directory',
'sftp.error.downloadFailed': 'Download failed',
'sftp.error.uploadFailed': 'Upload failed',
'sftp.error.deleteFailed': 'Delete failed',
'sftp.error.createFolderFailed': 'Failed to create folder',
'sftp.error.createFileFailed': 'Failed to create file',
'sftp.error.invalidFileName': 'Filename contains invalid characters: {chars}',
'sftp.error.reservedName': 'This filename is reserved by the system',
'sftp.overwrite.title': 'File Already Exists',
'sftp.overwrite.desc': 'A file named "{name}" already exists. Do you want to replace it?',
'sftp.overwrite.confirm': 'Replace',
'sftp.error.renameFailed': 'Failed to rename',
'sftp.picker.title': 'Select Host',
'sftp.picker.desc': 'Pick a host for the {side} pane',
'sftp.picker.searchPlaceholder': 'Search hosts...',
'sftp.picker.local.title': 'Local filesystem',
'sftp.picker.local.desc': 'Browse local files',
'sftp.picker.local.badge': 'Local',
'sftp.picker.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.followTerminalCwd': 'Follow terminal directory',
'settings.sftp.followTerminalCwd.desc': 'Automatically sync the sidebar SFTP browser with the terminal working directory (toggle in toolbar)',
'settings.sftp.followTerminalCwd.enable': 'Enable follow terminal directory by default',
'settings.sftp.followTerminalCwd.enableDesc': 'When the SFTP sidebar is open, follow mode stays on by default and updates after terminal cd commands',
'settings.sftp.defaultViewMode': 'Default View Mode',
'settings.sftp.defaultViewMode.desc': 'Choose the default view mode when opening a new SFTP tab. Per-host preferences override this setting.',
'settings.sftp.defaultViewMode.list': 'List View',
'settings.sftp.defaultViewMode.listDesc': 'Display files in a flat list for the current directory',
'settings.sftp.defaultViewMode.tree': 'Tree View',
'settings.sftp.defaultViewMode.treeDesc': 'Display files in a hierarchical tree structure',
'sftp.autoSync.success': 'File synced to remote: {fileName}',
'sftp.autoSync.error': 'Failed to sync file: {error}',
// SFTP Folder Upload Progress
'sftp.upload.progress': 'Uploading {current} of {total} files...',
'sftp.upload.uploading': 'Uploading...',
'sftp.upload.compressing': 'Compressing...',
'sftp.upload.extracting': 'Extracting...',
'sftp.upload.scanning': 'Scanning files...',
'sftp.upload.completed': 'Completed',
'sftp.upload.compressed': 'Compressed Transfer',
'sftp.upload.currentFile': 'Current: {fileName}',
'sftp.upload.cancelled': 'Upload cancelled',
'sftp.upload.cancel': 'Cancel',
'sftp.upload.completedToPath': 'Uploaded to {path}',
// SFTP Download
'sftp.download.completed': 'Downloaded',
'sftp.download.cancelled': 'Download cancelled',
// SFTP Reconnecting
'sftp.reconnecting.title': 'Reconnecting...',
'sftp.reconnecting.desc': 'Connection lost, attempting to reconnect',
'sftp.reconnected': 'Connection restored',
'sftp.error.reconnectFailed': 'Failed to reconnect. Please try again.',
'sftp.error.connectionLostManual': 'Connection lost. Please reconnect manually.',
'sftp.error.connectionLostReconnecting': 'Connection lost. Reconnecting...',
'sftp.error.sessionLost': 'SFTP session lost. Please reconnect.',
// Settings > SFTP Show Hidden Files
'settings.sftp.showHiddenFiles': 'Show hidden files',
'settings.sftp.showHiddenFiles.desc': 'Display hidden files (dotfiles on Unix/macOS and files with the hidden attribute on Windows) in the SFTP file browser.',
'settings.sftp.showHiddenFiles.enable': 'Show hidden files',
'settings.sftp.showHiddenFiles.enableDesc': 'Display hidden files when browsing both local and remote filesystems',
// Settings > SFTP Compressed Upload
'settings.sftp.compressedUpload': 'Folder Compression Transfer',
'settings.sftp.compressedUpload.desc': 'Compress folders before uploading to significantly reduce transfer time.',
'settings.sftp.compressedUpload.enable': 'Enable folder compression',
'settings.sftp.compressedUpload.enableDesc': 'Automatically compress folders using tar before transfer. Requires tar support on the server. Falls back to regular transfer if not available.',
// Quick Switcher
'qs.search.placeholder': 'Search hosts or tabs',
'qs.jumpTo': 'Jump To',
'qs.localTerminal': 'Local Terminal',
'qs.localShells': 'Local Shells',
'qs.default': 'Default',
// Select Host panel
'selectHost.title': 'Select Host',
'selectHost.noHostsFound': 'No hosts found',
'selectHost.newHost': 'New Host',
'selectHost.continue': 'Continue',
'selectHost.continueWithCount': 'Continue ({count} selected)',
// Quick Connect
'quickConnect.knownHost.title': 'Are you sure you want to connect?',
'quickConnect.knownHost.authenticity': 'The authenticity of {hostname} can not be established.',
'quickConnect.knownHost.fingerprintLabel': '{keyType} fingerprint is SHA256:',
'quickConnect.knownHost.addQuestion': 'Do you want to add it to the list of known hosts?',
'quickConnect.knownHost.addAndContinue': 'Add and continue',
'quickConnect.addKey': 'Add key',
'quickConnect.warning.unparsedOptions': 'Some SSH arguments were ignored: {options}',
// 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.lineTimestamps': 'Show output timestamps',
'hostDetails.lineTimestamps.desc': 'Show local time beside visible output lines for this host without changing terminal text.',
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',
'hostDetails.skipEcdsaHostKey': 'Skip ECDSA host key',
'hostDetails.skipEcdsaHostKey.desc': 'Some old Huawei / Cisco switches produce non-standard ECDSA host-key signatures that cause "signature verification failed". Turning this on drops every ecdsa-sha2-* from the client offer so negotiation falls back to RSA / Ed25519.',
'hostDetails.algorithms.advanced': 'Advanced algorithm overrides',
'hostDetails.algorithms.advanced.desc': 'Replace the offered algorithm list for any category on a per-host basis. Leaving a category untouched uses the default; selecting a subset fully replaces the default list. Incorrect values can make the host unreachable.',
'hostDetails.algorithms.inheritedNotice': 'The current group has algorithm overrides set for: {categories}. The "Reset" button here falls back to the group\'s lists, not NetCatty\'s defaults. To ignore the group restriction, clear the override in the group\'s algorithm settings.',
'hostDetails.algorithms.customized': 'customized',
'hostDetails.algorithms.reset': 'Reset',
'hostDetails.algorithms.category.kex': 'Key Exchange (KEX)',
'hostDetails.algorithms.category.cipher': 'Cipher',
'hostDetails.algorithms.category.hmac': 'MAC (HMAC)',
'hostDetails.algorithms.category.serverHostKey': 'Host Key',
'hostDetails.algorithms.category.compress': 'Compression',
'hostDetails.section.keepalive': 'Keepalive',
'hostDetails.keepalive.override': 'Override global keepalive',
'hostDetails.keepalive.desc': 'Use a custom keepalive policy for this host instead of the global setting. Useful for older routers or switches whose SSH server does not reply to keepalive@openssh.com requests — set interval to 0 to disable keepalive entirely on this host.',
'hostDetails.keepalive.interval': 'Interval (seconds)',
'hostDetails.keepalive.countMax': 'Max unanswered keepalives',
'hostDetails.keepalive.disabledHint': 'Interval = 0 disables keepalive for this host. The session will rely on TCP-level timeouts to detect a dead connection.',
'hostDetails.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',
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,351 @@
import type { Messages } from '../types';
export const ruAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Настройки агента',
'ai.chat.preparing': 'Подготовка…',
'ai.title': 'AI',
'ai.description': 'Настройка AI-провайдеров, агентов и параметров безопасности',
'ai.providers': 'Провайдеры',
'ai.agents': 'Агенты',
'ai.providers.empty': 'Провайдеры не настроены. Добавьте провайдера, чтобы начать.',
'ai.providers.add': 'Добавить провайдера',
'ai.providers.active': 'Активен',
'ai.providers.apiKeyConfigured': 'API-ключ настроен',
'ai.providers.noApiKey': 'Нет API-ключа',
'ai.providers.configure': 'Настроить',
'ai.providers.remove': 'Удалить',
'ai.providers.name': 'Отображаемое имя',
'ai.providers.name.placeholder': 'например, Мой провайдер',
'ai.providers.style': 'Стиль протокола',
'ai.providers.style.anthropic': 'Совместимый с Anthropic',
'ai.providers.style.openai': 'Совместимый с OpenAI',
'ai.providers.style.google': 'Совместимый с Google',
'ai.providers.style.inherited': 'авто',
'ai.providers.style.help': 'Определяет, какой формат API используется для запросов. Переопределите, если стороннее API использует другой диалект.',
'ai.providers.icon.change': 'Изменить иконку',
'ai.providers.icon.upload': 'Загрузить изображение',
'ai.providers.icon.reset': 'Сбросить',
'ai.providers.icon.close': 'Свернуть',
'ai.providers.icon.uploadedNote': 'Своя иконка (64×64 WebP)',
'ai.providers.icon.errorType': 'Пожалуйста, выберите файл изображения.',
'ai.providers.apiKey': 'API-ключ',
'ai.providers.apiKey.placeholder': 'Введите API-ключ',
'ai.providers.apiKey.decrypting': 'Расшифровка...',
'ai.providers.baseUrl': 'Базовый URL',
'ai.providers.skipTLSVerify': 'Пропустить проверку TLS-сертификата (для самоподписанных сертификатов)',
'ai.providers.defaultModel': 'Модель по умолчанию',
'ai.providers.defaultModel.placeholder': 'например, gpt-4o, claude-sonnet-4-20250514',
'ai.providers.contextWindow': 'Контекстное окно',
'ai.providers.contextWindow.placeholder': 'например, 128000',
'ai.providers.contextWindow.help': 'Оставьте пустым, чтобы использовать значение из списка моделей, если оно доступно; иначе Netcatty применит безопасное значение по умолчанию.',
'ai.providers.contextWindow.error': 'Введите положительное целое число или оставьте поле пустым.',
'ai.providers.refreshModels': 'Обновить модели',
'ai.providers.searchModel': 'Искать или ввести ID модели...',
'ai.providers.filterModels': 'Фильтровать модели...',
'ai.providers.loadingModels': 'Загрузка моделей...',
'ai.providers.noMatchingModels': 'Нет подходящих моделей',
'ai.providers.clickToLoadModels': 'Нажмите, чтобы загрузить модели',
'ai.providers.showingModels': 'Показаны первые 100 из {count} моделей. Введите текст для фильтрации.',
'ai.providers.advancedParams': 'Дополнительные параметры',
'ai.providers.advancedParams.hint': 'Оставьте пустым, чтобы использовать настройки провайдера по умолчанию.',
'ai.providers.advancedParams.maxTokens.placeholder': 'например, 4096',
'ai.providers.advancedParams.default': 'По умолчанию у провайдера',
// AI Codex
'ai.codex': 'Codex',
'ai.codex.title': 'Codex CLI',
'ai.codex.description': 'Подключение OpenAI Codex. Здесь можно войти через ChatGPT или включить API-ключ OpenAI-совместимого провайдера и пользовательский endpoint в настройках.',
'ai.codex.detecting': 'Обнаружение...',
'ai.codex.notFound': 'Не найден',
'ai.codex.awaitingLogin': 'Ожидание входа',
'ai.codex.connectedChatGPT': 'Подключено через ChatGPT',
'ai.codex.connectedApiKey': 'Подключено через API-ключ',
'ai.codex.connectedCustomConfig': 'Подключено через ~/.codex/config.toml',
'ai.codex.customConfigIncomplete': 'Обнаружен пользовательский конфиг (отсутствует переменная окружения)',
'ai.codex.customConfigHint': 'Используется пользовательский провайдер "{provider}", настроенный в ~/.codex/config.toml — вход через ChatGPT не требуется.',
'ai.codex.customConfigMissingEnvKey': 'Предупреждение: {envKey} не задана в переменных окружения вашей оболочки. Экспортируйте её (или запустите netcatty из оболочки, где она задана), чтобы Codex мог пройти аутентификацию.',
'ai.codex.notConnected': 'Не подключено',
'ai.codex.statusUnknown': 'Статус неизвестен',
'ai.codex.path': 'Путь:',
'ai.codex.notFoundHint': 'Не удалось найти codex в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
'ai.codex.customPathPlaceholder': 'например, /usr/local/bin/codex',
'ai.codex.check': 'Проверить',
'ai.codex.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 Cursor SDK
'ai.cursor.title': 'Cursor',
'ai.cursor.description': 'Использует Cursor SDK.',
'ai.cursor.detecting': 'Обнаружение...',
'ai.cursor.detected': 'Доступен',
'ai.cursor.notFound': 'Недоступен',
'ai.cursor.path': 'Среда:',
'ai.cursor.notFoundHint': 'Укажите API-ключ, чтобы включить Cursor.',
'ai.cursor.notInstalledHint': 'Cursor SDK не обнаружен.',
'ai.cursor.installStatus': 'Cursor SDK',
'ai.cursor.installed': 'Обнаружено',
'ai.cursor.notInstalled': 'Не обнаружено',
'ai.cursor.apiKeyStatus': 'API-ключ',
'ai.cursor.apiKeyConfigured': 'Настроен',
'ai.cursor.apiKeyMissing': 'Не указан',
'ai.cursor.apiKeyFromEnv': 'Из окружения',
'ai.cursor.apiKey': 'API-ключ',
'ai.cursor.apiKeyPlaceholder': 'Введите API-ключ Cursor',
'ai.cursor.apiKeyPlaceholder.env': 'Используется CURSOR_API_KEY; введите ключ для замены',
'ai.cursor.apiKeyEnvHint': 'Cursor может использовать CURSOR_API_KEY из shell. Сохраняйте ключ здесь только если хотите переопределить его в Netcatty.',
'ai.cursor.apiKeyOverrideHint': 'Netcatty сначала использует сохранённый здесь ключ, затем CURSOR_API_KEY.',
'ai.cursor.saveApiKey': 'Сохранить',
'ai.cursor.saved': 'Сохранено',
'ai.cursor.showApiKey': 'Показать API-ключ',
'ai.cursor.hideApiKey': 'Скрыть API-ключ',
'ai.cursor.customPathPlaceholder': 'например, /usr/local/bin/cursor',
'ai.cursor.check': 'Проверить',
// AI CodeBuddy Code
'ai.codebuddy.title': 'CodeBuddy Code',
'ai.codebuddy.description': 'Использует CodeBuddy Code через официальный Agent SDK (`@tencent-ai/agent-sdk`). После обнаружения может быть выбран как внешний агент для программирования.',
'ai.codebuddy.detecting': 'Обнаружение...',
'ai.codebuddy.detected': 'Обнаружен',
'ai.codebuddy.notFound': 'Не найден',
'ai.codebuddy.path': 'Путь:',
'ai.codebuddy.notFoundHint': 'Не удалось найти codebuddy в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
'ai.codebuddy.customPathPlaceholder': 'например, /usr/local/bin/codebuddy',
'ai.codebuddy.check': 'Проверить',
'ai.codebuddy.configSection': 'Аутентификация и конфигурация (необязательно)',
'ai.codebuddy.internetEnv': 'Сетевая среда',
'ai.codebuddy.internetEnv.default': 'По умолчанию (зарубежная)',
'ai.codebuddy.internetEnv.internal': 'Internal',
'ai.codebuddy.internetEnv.ioa': 'IOA',
'ai.codebuddy.internetEnv.hint': 'Устанавливает CODEBUDDY_INTERNET_ENVIRONMENT — выберите Internal или IOA для ограниченных сетевых сред.',
'ai.codebuddy.envVars': 'Переменные окружения',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': 'По одной записи KEY=VALUE на строку, передаются агенту CodeBuddy. Укажите CODEBUDDY_API_KEY или CODEBUDDY_AUTH_TOKEN для аутентификации. Хранятся локально в открытом виде.',
// AI Default Agent
'ai.defaultAgent': 'Агент по умолчанию',
'ai.defaultAgent.description': 'Агент, который будет использоваться при запуске новой AI-сессии',
'ai.defaultAgent.catty': 'Catty (встроенный)',
'ai.toolAccess.title': 'Доступ к инструментам',
'ai.toolAccess.mode': 'Режим доступа Netcatty',
'ai.toolAccess.description': 'Выберите, как внешние агенты получают доступ к сессиям Netcatty. MCP предоставляет встроенный сервер, а Skills + CLI указывает агентам на локальный skill Netcatty и команды CLI.',
'ai.toolAccess.mode.mcp': 'MCP',
'ai.toolAccess.mode.skills': 'Skills + CLI',
'ai.userSkills.title': 'Пользовательские skills',
'ai.userSkills.description': 'Откройте папку skills Netcatty, чтобы добавить свои каталоги skills. Netcatty автоматически сканирует их и добавляет только лёгкие индексы, если skill явно не соответствует текущему запросу.',
'ai.userSkills.openFolder': 'Открыть папку skills',
'ai.userSkills.reload': 'Перезагрузить skills',
'ai.userSkills.location': 'Расположение',
'ai.userSkills.loading': 'Сканирование пользовательских skills...',
'ai.userSkills.summary': '{ready} готово, {warnings} предупреждений',
'ai.userSkills.empty': 'Пользовательские skills пока не найдены. Откройте папку, чтобы добавить каталоги skills с файлом SKILL.md.',
'ai.userSkills.unavailable': 'Пользовательские skills недоступны в этой среде.',
'ai.userSkills.status.ready': 'Готово',
'ai.userSkills.status.warning': 'Предупреждение',
// AI Quick Messages
'ai.quickMessages.title': 'Быстрые сообщения',
'ai.quickMessages.description': 'Создавайте часто используемые подсказки и вставляйте их в AI-чат через / или кнопку быстрых сообщений. В отличие от user skills, быстрые сообщения заполняют поле ввода текстом.',
'ai.quickMessages.add': 'Добавить быстрое сообщение',
'ai.quickMessages.createTitle': 'Новое быстрое сообщение',
'ai.quickMessages.editTitle': 'Редактировать быстрое сообщение',
'ai.quickMessages.name': 'Название',
'ai.quickMessages.name.placeholder': 'например: Проверить диск',
'ai.quickMessages.slug': 'Команда',
'ai.quickMessages.slug.placeholder': 'disk-check',
'ai.quickMessages.descriptionField': 'Описание (необязательно)',
'ai.quickMessages.descriptionField.placeholder': 'Краткая подсказка о назначении',
'ai.quickMessages.content': 'Текст сообщения',
'ai.quickMessages.content.placeholder': 'Полный текст подсказки для вставки...',
'ai.quickMessages.empty': 'Быстрых сообщений пока нет. Добавьте несколько часто используемых подсказок.',
'ai.quickMessages.confirmDelete': 'Удалить быстрое сообщение «{name}»?',
'ai.quickMessages.error.nameRequired': 'Укажите название.',
'ai.quickMessages.error.invalidSlug': 'Команда может содержать только строчные буквы, цифры и дефисы.',
'ai.quickMessages.error.contentRequired': 'Укажите текст сообщения.',
'ai.quickMessages.error.slugTaken': 'Эта команда уже используется другим быстрым сообщением.',
'ai.quickMessages.error.slugConflictsWithSkill': 'Команда конфликтует с user skill «/{slug}». Выберите другую.',
'ai.quickMessages.error.maxItems': 'Можно сохранить не более {max} быстрых сообщений.',
// AI Chat
'ai.chat.noProvider': 'AI-провайдер не настроен. Перейдите в **Настройки → AI → Провайдеры**, чтобы добавить и включить провайдера.',
'ai.chat.toolDenied': 'Действие было отклонено пользователем.',
'ai.chat.toolApproved': 'Одобрено',
'ai.chat.toolApprovalHint': 'Нажмите Enter для одобрения, Escape для отклонения',
'ai.chat.approve': 'Одобрить',
'ai.chat.reject': 'Отклонить',
'ai.chat.toolLabel': 'Инструмент',
'ai.chat.targetLabel': 'Цель',
'ai.chat.permissionRequired': 'Требуется разрешение',
'ai.chat.permissionDescription': 'AI-агент хочет выполнить вызов инструмента, для которого требуется ваше одобрение.',
'ai.chat.commandBlocked': 'Эта команда заблокирована вашей политикой безопасности и не может быть выполнена.',
'ai.chat.recommendAllow': 'Разрешить',
'ai.chat.recommendConfirm': 'Подтвердить',
'ai.chat.recommendDeny': 'Запретить',
'ai.chat.exportConversation': 'Экспортировать разговор',
'ai.chat.exportAs': 'Экспортировать как',
'ai.chat.exportMarkdown': 'Markdown',
'ai.chat.exportJSON': 'JSON',
'ai.chat.exportPlainText': 'Обычный текст',
'ai.chat.thinking': 'Размышляет',
'ai.chat.thoughtFor': 'Размышлял {duration}',
'ai.chat.thought': 'Мысль',
'ai.chat.agents': 'Агенты',
'ai.chat.detectedOnMachine': 'Обнаружено на этом устройстве',
'ai.chat.rescan': 'Пересканировать',
'ai.chat.permObserver': 'Наблюдатель',
'ai.chat.permConfirm': 'Подтверждение',
'ai.chat.permAuto': 'Авто',
'ai.chat.permObserverDesc': 'Только чтение',
'ai.chat.permConfirmDesc': 'Спрашивать перед действиями',
'ai.chat.permAutoDesc': 'Выполнять свободно',
'ai.chat.emptyHint': 'Спрашивайте о ваших серверах, запускайте команды или получайте помощь с конфигурациями.',
'ai.chat.placeholder': 'Сообщение {agent} — @ для добавления контекста, / для команд',
'ai.chat.placeholderDefault': 'Сообщение агенту Catty...',
'ai.chat.noModel': 'Нет модели',
'ai.chat.noProviderModel': 'Модель по умолчанию не задана — настройте её в Настройки → AI → Провайдеры.',
'ai.chat.selectProvider': 'Выберите провайдера',
'ai.chat.recent': 'Недавние',
'ai.chat.viewAll': 'Показать всё',
'ai.chat.untitled': 'Без названия',
'ai.chat.justNow': 'Только что',
'ai.chat.minutesAgo': '{n}м назад',
'ai.chat.hoursAgo': '{n}ч назад',
'ai.chat.daysAgo': '{n}д назад',
'ai.chat.newChat': 'Новый чат',
'ai.chat.allSessions': 'Все сессии',
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
'ai.chat.usedTools': 'Использовано инструментов: {n}',
'ai.chat.loadMoreSessions': 'Загрузить больше сессий (ещё {n})',
'ai.chat.noSessions': 'Предыдущих сессий нет',
'ai.chat.retryHint': 'Вы можете повторить попытку, отправив сообщение ещё раз.',
'ai.chat.approvalTimeout': 'Время ожидания одобрения инструмента истекло через 5 минут. Вы можете повторить попытку, отправив сообщение ещё раз.',
'ai.chat.menuHosts': 'Хосты',
'ai.chat.menuContext': 'Контекст',
'ai.chat.menuFiles': 'Файлы',
'ai.chat.menuImage': 'Изображение',
'ai.chat.menuMentionHost': 'Упомянуть хост',
'ai.chat.menuUserSkills': 'Пользовательские skills',
'ai.chat.menuSlashCommands': 'Команды /',
'ai.chat.slashCommands': 'Команды /',
'ai.chat.slashQuickMessages': 'Быстрые сообщения',
'ai.chat.slashUserSkills': 'User skills',
'ai.chat.quickMessages': 'Команды /',
'ai.chat.slashNoResults': 'Нет подходящих команд',
'ai.chat.slashEmptyHint': 'Добавьте подсказки в Настройки → AI → Быстрые сообщения.',
// AI 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.history': 'История',
'terminal.layer.theme': 'Тема',
'terminal.layer.aiChat': 'AI-чат',
'terminal.layer.movePanelLeft': 'Переместить панель влево',
'terminal.layer.movePanelRight': 'Переместить панель вправо',
'terminal.layer.closePanel': 'Закрыть панель',
'terminal.layer.hostTree.search': 'Поиск хостов...',
'terminal.layer.hostTree.searchButton': 'Поиск',
'terminal.layer.hostTree.tagsButton': 'Фильтр по тегам',
'terminal.layer.hostTree.newGroup': 'Новая группа',
'terminal.layer.hostTree.localShell': 'Локальная оболочка',
'terminal.layer.hostTree.tagsEmpty': 'Нет доступных тегов',
'terminal.layer.hostTree.clearTags': 'Сбросить выбор',
'terminal.layer.hostTree.collapse': 'Свернуть список хостов',
'terminal.layer.hostTree.expand': 'Развернуть список хостов',
'terminal.layer.hostTree.empty': 'Хосты не найдены',
'topTabs.openQuickSwitcher': 'Открыть быстрый переключатель',
'topTabs.moreTabs': 'Больше вкладок',
'topTabs.aiAssistant': 'AI-помощник',
'topTabs.windowOpacity': 'Прозрачность окна',
'topTabs.toggleTheme': 'Переключить тему',
'topTabs.openSettings': 'Открыть настройки',
'ai.chat.sessionHistory': 'История сессий',
'ai.chat.attach': 'Прикрепить',
'ai.chat.terminalSelectionAttachment': 'Выделение терминала',
'ai.chat.terminalSelectionLines': 'строк: {count}',
'ai.chat.collapse': 'Свернуть',
'ai.chat.expand': 'Развернуть',
'ai.chat.enableAgent': 'Включить {name}',
'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,686 @@
import type { Messages } from '../types';
export const ruCoreMessages: Messages = {
// Common
'common.save': 'Сохранить',
'common.cancel': 'Отмена',
'common.close': 'Закрыть',
'common.reset': 'Сбросить',
'common.zoomIn': 'Увеличить',
'common.zoomOut': 'Уменьшить',
'common.settings': 'Настройки',
'common.search': 'Поиск',
'common.searchPlaceholder': 'Поиск...',
'common.connect': 'Подключиться',
'common.terminal': 'Терминал',
'common.create': 'Создать',
'common.import': 'Импорт',
'common.generate': 'Сгенерировать',
'common.delete': 'Удалить',
'common.edit': 'Редактировать',
'common.clear': 'Очистить',
'common.optional': 'Необязательно',
'common.selectPlaceholder': 'Выбрать...',
'common.add': 'Добавить',
'common.rename': 'Переименовать',
'common.refresh': 'Обновить',
'common.continue': 'Продолжить',
'common.enabled': 'Включено',
'common.disabled': 'Отключено',
'common.error': 'Ошибка',
'common.validation': 'Проверка',
'common.unknownError': 'Неизвестная ошибка',
'common.noResultsFound': 'Ничего не найдено',
'common.back': 'Назад',
'common.apply': 'Применить',
'common.use': 'Использовать',
'common.useGlobal': 'Использовать глобальное',
'common.saveChanges': 'Сохранить изменения',
'common.advanced': 'Дополнительно',
'common.left': 'Слева',
'common.right': 'Справа',
'common.more': 'Ещё',
'common.selectAHost': 'Выберите хост',
'common.selectAHostPlaceholder': 'Выберите хост...',
'sort.manual': 'Ручной порядок',
'sort.az': 'А-Я',
'sort.za': 'Я-А',
'sort.newest': 'Сначала новые',
'sort.oldest': 'Сначала старые',
'sort.group': 'По группе',
'field.label': 'Метка',
'field.type': 'Тип',
'auth.keyType': 'Тип {type}',
'auth.showAllKeys': 'Показать все ключи',
// Dialogs / prompts
'confirm.deleteHost': 'Удалить хост "{name}"?',
'confirm.deleteIdentity': 'Удалить идентификатор "{name}"?',
'confirm.removeProvider': 'Удалить провайдера "{name}"?',
'confirm.closeBusyTerminal.title': 'Подтвердите закрытие',
'confirm.closeBusyTerminal.message': 'Процесс "{command}" всё ещё выполняется и будет завершён.',
'confirm.closeBusyTerminal.messageWithMore': 'Процесс "{command}" и ещё {count} выполняющихся процесс(ов) будут завершены.',
'confirm.closeBusyTerminal.cancel': 'Отмена',
'confirm.closeBusyTerminal.close': 'Закрыть',
'dialog.createWorkspace.title': 'Создать рабочее пространство',
'dialog.renameWorkspace.title': 'Переименовать рабочее пространство',
'dialog.renameSession.title': 'Переименовать сессию',
'field.name': 'Имя',
'field.selectHosts': 'Выбрать хосты',
'placeholder.workspaceName': 'Имя рабочего пространства',
'placeholder.sessionName': 'Имя сессии',
'placeholder.searchHosts': 'Поиск хостов...',
'toast.settingsUnavailable': 'Окно настроек недоступно на этой платформе.',
'credentials.protectionUnavailable.title': 'Защита учётных данных недоступна',
'credentials.protectionUnavailable.message': 'Сохранённые пароли и ключи не могут быть автоматически расшифрованы на этом устройстве. Перед подключением введите учётные данные заново.',
'credentials.protectionUnavailable.action': 'Открыть настройки',
// Settings shell
'settings.title': 'Настройки',
'settings.tab.application': 'Приложение',
'settings.tab.appearance': 'Внешний вид',
'settings.tab.terminal': 'Терминал',
'settings.tab.shortcuts': 'Горячие клавиши',
'settings.tab.syncCloud': 'Синхронизация и облако',
'settings.tab.system': 'Система',
// Settings > System
'settings.system.title': 'Система',
'settings.system.description': 'Системная информация и управление временными файлами.',
'settings.system.tempDirectory': 'Временные файлы',
'settings.system.location': 'Расположение',
'settings.system.fileCount': 'Файлы',
'settings.system.totalSize': 'Размер',
'settings.system.openFolder': 'Открыть папку',
'settings.system.refresh': 'Обновить',
'settings.system.clearTempFiles': 'Очистить временные файлы',
'settings.system.clearing': 'Очистка...',
'settings.system.clearResult': 'Удалено файлов: {deleted}, ошибок: {failed}.',
'settings.system.tempDirectoryHint': 'Временные файлы создаются при открытии удалённых файлов во внешних приложениях. Они автоматически очищаются при закрытии SFTP-сессий.',
'settings.system.credentials.title': 'Защита учётных данных',
'settings.system.credentials.status': 'Статус',
'settings.system.credentials.checking': 'Проверка...',
'settings.system.credentials.available': 'Доступно (системное хранилище ключей готово)',
'settings.system.credentials.unavailable': 'Недоступно (невозможно расшифровать сохранённые учётные данные)',
'settings.system.credentials.unknown': 'Неизвестно (не поддерживается в этой среде)',
'settings.system.credentials.unavailableHint': 'Учётные данные, зашифрованные в другом профиле пользователя или на другой машине, здесь расшифровать нельзя. Повторно введите и сохраните их на этом устройстве.',
'settings.system.credentials.portabilityHint': 'Облачная синхронизация переносима, потому что использует шифрование вашим мастер-ключом. Локальное шифрование safeStorage привязано к устройству и пользователю.',
// Settings > System > Crash Logs
'settings.system.crashLogs.title': 'Журналы сбоев',
'settings.system.crashLogs.description': 'Просмотр журналов ошибок основного процесса для диагностики неожиданного поведения.',
'settings.system.crashLogs.noLogs': 'Журналы сбоев не найдены.',
'settings.system.crashLogs.entries': 'Записей: {count}',
'settings.system.crashLogs.clear': 'Очистить все журналы',
'settings.system.crashLogs.cleared': 'Очищено файлов журналов: {count}.',
'settings.system.crashLogs.source': 'Источник',
'settings.system.crashLogs.time': 'Время',
'settings.system.crashLogs.message': 'Сообщение',
'settings.system.crashLogs.stack': 'Трассировка стека',
'settings.system.crashLogs.hint': 'Журналы сбоев хранятся 30 дней и автоматически ротируются.',
'settings.system.crashLogs.collapse': 'Свернуть',
'settings.system.crashLogs.expand': 'Показать детали',
// Settings > System > Software Update
'settings.update.title': 'Обновление программы',
'settings.update.currentVersion': 'Текущая версия',
'settings.update.checkForUpdates': 'Проверить обновления',
'settings.update.checking': 'Проверка...',
'settings.update.upToDate': 'Вы используете последнюю версию.',
'settings.update.available': 'Доступна новая версия {version}.',
'settings.update.download': 'Скачать обновление',
'settings.update.downloading': 'Загрузка... {percent}%',
'settings.update.readyToInstall': 'Обновление загружено и готово к установке.',
'settings.update.restartNow': 'Перезапустить для обновления',
'settings.update.error': 'Не удалось проверить наличие обновлений.',
'settings.update.downloadError': 'Не удалось загрузить обновление.',
'settings.update.manualDownload': 'Скачать с GitHub',
'settings.update.manualDownloadHint': 'Автообновление недоступно на этой платформе. Скачайте последнюю версию с GitHub.',
'settings.update.hint': 'Netcatty проверяет обновления через GitHub Releases.',
'settings.update.lastCheckedJustNow': 'только что',
'settings.update.lastCheckedMinutesAgo': '{n} мин назад',
'settings.update.lastCheckedHoursAgo': '{n} ч назад',
'settings.update.lastCheckedPrefix': 'Последняя проверка: ',
'settings.update.autoUpdateEnabled': 'Автоматические обновления',
'settings.update.autoUpdateEnabledDesc': 'Автоматически проверять и загружать обновления, когда они доступны.',
// Settings > Session Logs
'settings.sessionLogs.title': 'Журналы сессий',
'settings.sessionLogs.description': 'Настройка экспорта журналов сессий и параметров автосохранения.',
'settings.sessionLogs.autoSave': 'Автосохранение',
'settings.sessionLogs.enableAutoSave': 'Включить автосохранение',
'settings.sessionLogs.enableAutoSaveDesc': 'Автоматически сохранять журналы сессий после завершения терминальных сессий.',
'settings.sessionLogs.directory': 'Папка сохранения',
'settings.sessionLogs.noDirectory': 'Папка не выбрана',
'settings.sessionLogs.browse': 'Обзор',
'settings.sessionLogs.openFolder': 'Открыть папку',
'settings.sessionLogs.directoryHint': 'Журналы будут организованы по хостам во вложенных папках.',
'settings.sessionLogs.format': 'Формат журнала',
'settings.sessionLogs.formatDesc': 'Выберите формат сохраняемых файлов журналов.',
'settings.sessionLogs.formatTxt': 'Обычный текст (.txt)',
'settings.sessionLogs.formatRaw': 'Сырые данные с ANSI (.log)',
'settings.sessionLogs.formatHtml': 'HTML (.html)',
'settings.sessionLogs.timestamps': 'Добавлять метки времени',
'settings.sessionLogs.timestampsDesc': 'Добавлять локальное время в начало каждой строки в текстовых и HTML-журналах.',
'settings.sessionLogs.hint': 'Журналы сессий сохраняют весь вывод терминала для диагностики и аудита.',
// Settings > SSH Debug Logs
'settings.sshDebugLogs.title': 'Отладочные журналы SSH',
'settings.sshDebugLogs.enable': 'Включить отладочные журналы SSH',
'settings.sshDebugLogs.enableDesc': 'Записывать подключение, аутентификацию, рукопожатие, отключение и причины ошибок без вывода терминала.',
'settings.sshDebugLogs.location': 'Расположение журнала',
'settings.sshDebugLogs.status': 'Статус',
'settings.sshDebugLogs.statusOn': 'Включено',
'settings.sshDebugLogs.statusOff': 'Отключено',
'settings.sshDebugLogs.size': 'Размер',
'settings.sshDebugLogs.hint': 'Когда включено, новые SSH-подключения записывают диагностические события для разбора бастионов, аутентификации и неожиданных отключений.',
// Settings > Global Hotkey (Quake Mode)
'settings.globalHotkey.title': 'Глобальная горячая клавиша',
'settings.globalHotkey.toggleWindow': 'Переключение окна',
'settings.globalHotkey.toggleWindowDesc': 'Нажмите сочетание клавиш, чтобы задать глобальную горячую клавишу для показа или скрытия окна.',
'settings.globalHotkey.notSet': 'Не задано',
'settings.globalHotkey.reset': 'Сбросить по умолчанию',
'settings.globalHotkey.closeToTray': 'Сворачивать в системный трей',
'settings.globalHotkey.closeToTrayDesc': 'Если включено, при закрытии окно будет сворачиваться в системный трей вместо выхода из приложения.',
'settings.globalHotkey.enabled': 'Включить глобальную горячую клавишу',
'settings.globalHotkey.enabledDesc': 'Регистрировать системные сочетания клавиш. Когда отключено, все глобальные горячие клавиши снимаются с регистрации.',
'settings.globalHotkey.hint': 'Глобальная горячая клавиша работает на уровне всей системы и позволяет быстро показывать или скрывать окно (терминал в стиле Quake).',
// Tray Panel
'tray.openMainWindow': 'Открыть главное окно',
'tray.sessions': 'Сессии',
'tray.portForwarding': 'Проброс портов',
'tray.status.connected': 'Подключено',
'tray.status.connecting': 'Подключение',
'tray.status.disconnected': 'Отключено',
'tray.status.active': 'Активно',
'tray.status.inactive': 'Неактивно',
'tray.status.error': 'Ошибка',
'tray.recentHosts': 'Недавние хосты',
'tray.empty.title': 'Пока здесь ничего нет',
'tray.empty.subtitle': 'Подключитесь к серверу, они по вам скучают 🚀',
'tray.quit': 'Выйти из Netcatty',
// Vault Sidebar
'vault.sidebar.collapse': 'Свернуть боковую панель',
'vault.sidebar.expand': 'Развернуть боковую панель',
'vault.sidebar.resize': 'Изменить ширину боковой панели',
// Settings > Application
'settings.application.checkUpdates': 'Проверить обновления',
'settings.application.reportProblem': 'Сообщить о проблеме',
'settings.application.reportProblem.subtitle': 'Создать заранее заполненную задачу на GitHub',
'settings.application.community': 'Сообщество',
'settings.application.community.subtitle': 'На GitHub Discussions',
'settings.application.github': 'GitHub',
'settings.application.github.subtitle': 'Исходный код',
'settings.application.whatsNew': 'Что нового',
'settings.application.whatsNew.subtitle': 'Показать примечания к релизу',
'settings.application.openExternal.failedTitle': 'Не удалось открыть ссылку',
'settings.application.openExternal.failedBody': 'Не удалось открыть ссылку ни в системном браузере, ни во встроенном окне браузера.',
'settings.vault.title': 'Хранилище',
'settings.vault.showRecentHosts': 'Показывать недавно подключённые хосты',
'settings.vault.showRecentHostsDesc': 'Показывать раздел недавно подключённых хостов в верхней части хранилища',
'settings.vault.showOnlyUngroupedHostsInRoot': 'Показывать в корне только хосты без группы',
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'Если включено, в корневом списке хостов будут показаны только хосты без группы. Откройте группу на боковой панели, чтобы увидеть сгруппированные хосты.',
'settings.vault.showSftpTab': 'Показывать вкладку SFTP',
'settings.vault.showSftpTabDesc': 'Показывать отдельный SFTP-вид в верхней панели вкладок. Если скрыто, используйте боковую панель SFTP внутри сессии.',
'settings.vault.showHostTreeSidebar': 'Показывать боковую панель хостов',
'settings.vault.showHostTreeSidebarDesc': 'Показывать список хостов и кнопку в верхней панели для вкладок терминала и редактора.',
// Update notifications
'update.available.title': 'Доступно обновление',
'update.available.message': 'Доступна новая версия {version}. Нажмите, чтобы скачать.',
'update.checking': 'Проверка обновлений...',
'update.upToDate.title': 'Актуальная версия',
'update.upToDate.message': 'У вас установлена последняя версия ({version}).',
'update.error': 'Не удалось проверить наличие обновлений',
'update.downloadNow': 'Скачать сейчас',
'update.viewInSettings': 'Открыть в настройках',
'update.readyToInstall.title': 'Обновление готово',
'update.readyToInstall.message': 'Версия {version} загружена и готова к установке.',
'update.restartNow': 'Перезапустить сейчас',
'update.downloadFailed.title': 'Ошибка обновления',
'update.downloadFailed.message': 'Не удалось скачать обновление. Вы можете скачать его вручную.',
'update.needsSave.title': 'Несохранённые изменения',
'update.needsSave.message': 'Сначала сохраните открытые редакторы, затем снова нажмите «Перезапустить сейчас», чтобы установить обновление.',
'update.openReleases': 'Открыть релизы',
'update.remindLater': 'Напомнить позже',
'update.skipVersion': 'Пропустить эту версию',
// Settings > Appearance
'settings.appearance.uiTheme': 'Тема интерфейса',
'settings.appearance.theme': 'Тема',
'settings.appearance.theme.desc': 'Выберите светлую, тёмную тему или следование системным настройкам',
'settings.appearance.theme.light': 'Светлая',
'settings.appearance.theme.dark': 'Тёмная',
'settings.appearance.theme.system': 'Системная',
'settings.appearance.accentColor': 'Акцентный цвет',
'settings.appearance.customColor': 'Пользовательский цвет',
'settings.appearance.accentColor.mode': 'Использовать свой акцент',
'settings.appearance.accentColor.mode.desc': 'Переопределить акцентный цвет темы',
'settings.appearance.accentColor.custom': 'Пользовательский акцент',
'settings.appearance.themeColor': 'Цвет темы',
'settings.appearance.themeColor.desc': 'Выберите готовую палитру для каждой темы',
'settings.appearance.themeColor.light': 'Палитра светлой темы',
'settings.appearance.themeColor.dark': 'Палитра тёмной темы',
'settings.appearance.customCss': 'Пользовательский CSS',
'settings.appearance.customCss.desc':
'Добавьте пользовательский CSS, чтобы настроить внешний вид приложения. Изменения применяются сразу. Основные области интерфейса имеют атрибут [data-section="..."], который можно использовать для выбора элементов, например: snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (список терминалов в режиме Focus), terminal-host-tree-sidebar, terminal-host-tree-sidebar-content, terminal-host-tree-sidebar-row, terminal-side-panel (панель SFTP/скриптов/темы/AI, доступна пока открыта), terminal-side-panel-tabs, terminal-side-panel-content, terminal-sftp-panel, terminal-sftp-host-header, terminal-sftp-pane, terminal-sftp-toolbar, terminal-sftp-path, terminal-sftp-filter-bar, terminal-sftp-list, terminal-sftp-list-header, terminal-sftp-list-row, terminal-sftp-tree, terminal-sftp-tree-row, terminal-sftp-transfer-queue, terminal-sftp-transfer-row, terminal-split-pane, terminal-split-resizer, top-tabs, top-tabs-host-tree-toggle, top-tabs-quick-switcher-toggle.',
'settings.appearance.customCss.placeholder':
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Скрыть переключатель списка хостов в верхней панели вкладок */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Скрыть кнопку плюса, открывающую быстрый переключатель */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Рамка вокруг боковой панели SFTP (не остаётся после закрытия) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Изменить фон всей боковой панели, а не только верхних вкладок */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Настроить выбранные строки SFTP */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Более заметные разделители сплита */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Подсветка активной панели сплита */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
'settings.appearance.language': 'Язык',
'settings.appearance.language.desc': 'Выберите язык интерфейса',
'settings.appearance.uiFont': 'Шрифт интерфейса',
'settings.appearance.uiFont.desc': 'Выберите шрифт для интерфейса приложения',
'settings.appearance.windowOpacity': 'Прозрачность окна',
'settings.appearance.windowOpacity.desc': 'Настройте прозрачность всего окна приложения. При низких значениях текст терминала тоже бледнеет. В некоторых средах Linux это может не поддерживаться.',
// Settings > 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.section.systemManager': 'Системный менеджер',
'settings.terminal.systemManager.processRefreshInterval': 'Обновление списка процессов',
'settings.terminal.systemManager.processRefreshInterval.desc': 'Как часто обновлять список процессов в боковой панели системного менеджера.',
'settings.terminal.systemManager.tmuxRefreshInterval': 'Обновление сессий tmux',
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'Как часто обновлять список сессий tmux.',
'settings.terminal.systemManager.dockerListRefreshInterval': 'Обновление списка контейнеров Docker',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Как часто обновлять список контейнеров Docker.',
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Обновление статистики Docker',
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Как часто обновлять CPU/память/сеть контейнеров Docker.',
'settings.terminal.serverStats.show': 'Показывать статистику сервера',
'settings.terminal.serverStats.show.desc': 'Показывать загрузку CPU, памяти и диска в строке состояния терминала (только для Linux-серверов).',
'settings.terminal.serverStats.refreshInterval': 'Интервал обновления',
'settings.terminal.serverStats.refreshInterval.desc': 'Как часто обновлять статистику сервера.',
'settings.terminal.serverStats.seconds': 'секунд',
// Settings > Terminal > Rendering
'settings.terminal.section.rendering': 'Рендеринг',
'settings.terminal.rendering.renderer': 'Рендерер',
'settings.terminal.rendering.renderer.desc': 'Выберите технологию рендеринга терминала. В режиме "Авто" на устройствах с малым объёмом памяти будет использоваться DOM. Изменения применяются к новым терминальным сессиям.',
'settings.terminal.rendering.auto': 'Авто',
// Settings > Terminal > Workspace Focus Indicator
'settings.terminal.section.workspaceFocus': 'Индикатор фокуса рабочей области',
'settings.terminal.workspaceFocus.style': 'Стиль индикатора фокуса',
'settings.terminal.workspaceFocus.style.desc': 'Как показывать, какая панель активна в режиме разделённого вида.',
'settings.terminal.workspaceFocus.dim': 'Затемнять неактивные панели',
'settings.terminal.workspaceFocus.border': 'Рамка вокруг активной панели',
// Settings > Terminal > Autocomplete
'settings.terminal.section.autocomplete': 'Автодополнение',
'settings.terminal.autocomplete.enabled': 'Включить автодополнение',
'settings.terminal.autocomplete.enabled.desc': 'Показывать подсказки команд на основе истории и описаний команд во время ввода.',
'settings.terminal.autocomplete.ghostText': 'Призрачный текст',
'settings.terminal.autocomplete.ghostText.desc': 'Показывать серую встроенную подсказку после курсора (как в fish shell).',
'settings.terminal.autocomplete.popupMenu': 'Всплывающее меню',
'settings.terminal.autocomplete.popupMenu.desc': 'Показывать плавающий список из нескольких подсказок.',
// Settings > Shortcuts
'settings.shortcuts.section.scheme': 'Схема горячих клавиш',
'settings.shortcuts.scheme.label': 'Сочетания клавиш',
'settings.shortcuts.scheme.desc': 'Выберите раскладку клавиш для использования в сочетаниях',
'settings.shortcuts.scheme.disabled': 'Отключено',
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Цифры без закреплённых вкладок',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'Если включено, Cmd/Ctrl+[1...9] переключает только рабочие вкладки (терминалы, рабочие области, редакторы), а не закреплённые Vault и SFTP.',
'settings.shortcuts.section.custom': 'Пользовательские сочетания',
'settings.shortcuts.resetAll': 'Сбросить все',
'settings.shortcuts.recording': 'Нажмите клавиши...',
'settings.shortcuts.none': 'Нет',
'settings.shortcuts.setDisabled': 'Отключить',
'settings.shortcuts.category.tabs': 'Вкладки',
'settings.shortcuts.category.terminal': 'Терминал',
'settings.shortcuts.category.navigation': 'Навигация',
'settings.shortcuts.category.app': 'Приложение',
'settings.shortcuts.category.sftp': 'SFTP',
// Settings > Shortcuts -> key bings
'settings.shortcuts.binding.switch-tab-1-9': 'Переключиться на вкладку [1...9]',
'settings.shortcuts.binding.next-tab': 'Следующая вкладка',
'settings.shortcuts.binding.prev-tab': 'Предыдущая вкладка',
'settings.shortcuts.binding.close-tab': 'Закрыть вкладку',
'settings.shortcuts.binding.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,181 @@
import type { Messages } from '../types';
export const ruSystemManagerMessages: Messages = {
'terminal.layer.system': 'Система',
'systemManager.noSession': 'Нет активного терминального сеанса.',
'systemManager.notConnected': 'Подключитесь к хосту для управления процессами и сервисами.',
'systemManager.empty': 'Нет данных.',
'systemManager.tabs.processes': 'Процессы',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.popup.loading': 'Открытие терминала…',
'systemManager.popup.startupFailed': 'Команда запуска не была выполнена успешно. Проверьте, что цель доступна, и повторите попытку.',
'systemManager.errors.loadProcesses': 'Не удалось загрузить процессы',
'systemManager.errors.loadTmux': 'Не удалось загрузить сессии tmux',
'systemManager.errors.loadTmuxWindows': 'Не удалось загрузить окна tmux',
'systemManager.errors.loadTmuxPanes': 'Не удалось загрузить панели tmux',
'systemManager.errors.loadTmuxClients': 'Не удалось загрузить клиентов tmux',
'systemManager.errors.actionFailed': 'Не удалось выполнить действие',
'systemManager.errors.loadDocker': 'Не удалось загрузить контейнеры',
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
'systemManager.processes.search': 'Поиск процессов…',
'systemManager.processes.command': 'Команда',
'systemManager.processes.user': 'Пользователь',
'systemManager.processes.term': 'Завершить',
'systemManager.processes.kill': 'Убить',
'systemManager.processes.stop': 'Остановить (SIGSTOP)',
'systemManager.processes.cont': 'Продолжить (SIGCONT)',
'systemManager.processes.hup': 'Сигнал SIGHUP',
'systemManager.processes.renice': 'Renice',
'systemManager.processes.renicePrompt': 'Значение nice (-20 до 19)',
'systemManager.processes.reniceInvalid': 'Nice должно быть от -20 до 19',
'systemManager.processes.confirmKill': 'Отправить SIGKILL процессу {{pid}}?',
'systemManager.processes.confirmSignal': 'Отправить SIG{{signal}} процессу {{pid}}?',
'systemManager.processes.filter.all': 'Все',
'systemManager.processes.filter.running': 'Активные',
'systemManager.processes.ppid': 'Родительский PID',
'systemManager.processes.rss': 'RSS',
'systemManager.processes.vsz': 'Виртуальный размер',
'systemManager.processes.elapsed': 'Время работы',
'systemManager.processes.stat': 'Состояние',
'systemManager.processes.meta': '{{count}} проц.',
'systemManager.processes.loading': 'Загрузка процессов…',
'systemManager.processes.loadingMore': 'Загрузка следующих процессов…',
'systemManager.processes.state.running': 'Активен',
'systemManager.processes.state.sleeping': 'Сон',
'systemManager.processes.state.stopped': 'Остановлен',
'systemManager.processes.state.zombie': 'Зомби',
'systemManager.processes.sort.cpu': 'CPU',
'systemManager.processes.sort.mem': 'Память',
'systemManager.processes.sort.pid': 'PID',
'systemManager.processes.sort.command': 'Команда',
'systemManager.processes.sort.user': 'Пользователь',
'systemManager.common.dismiss': 'Закрыть',
'systemManager.common.checkingAvailability': 'Проверка доступности…',
'systemManager.common.loading': 'Загрузка…',
'systemManager.common.loadingDetails': 'Загрузка деталей…',
'systemManager.common.loadingStats': 'Загрузка статистики…',
'systemManager.tmux.new': 'Создать',
'systemManager.tmux.search': 'Поиск сессий…',
'systemManager.tmux.newSessionTitle': 'Новая сессия tmux',
'systemManager.tmux.newSessionDesc': 'Задайте имя сессии и при необходимости команду запуска.',
'systemManager.tmux.newSessionTabCustom': 'Своя команда',
'systemManager.tmux.newSessionTabSnippet': 'Из сниппета',
'systemManager.tmux.pickSnippet': 'Из сниппетов',
'systemManager.tmux.pickSnippetEmpty': 'Сниппетов пока нет — добавьте их на панели скриптов или в хранилище.',
'systemManager.tmux.selectedSnippet': 'Выбран сниппет: {{label}}',
'systemManager.tmux.newSessionName': 'Имя сессии',
'systemManager.tmux.newSessionCommand': 'Команда запуска',
'systemManager.tmux.newSessionCommandPlaceholder': 'например htop или npm run dev (необяз.)',
'systemManager.tmux.newSessionCommandHint': 'Оставьте пустым для сессии с shell по умолчанию.',
'systemManager.tmux.creating': 'Создание…',
'systemManager.tmux.newSessionPlaceholder': 'my-session',
'systemManager.tmux.newSessionRequired': 'Сначала введите имя сессии',
'systemManager.tmux.empty': 'Нет сессий tmux',
'systemManager.tmux.attach': 'Подключить',
'systemManager.tmux.attached': 'Подключена',
'systemManager.tmux.detached': 'Отключена',
'systemManager.tmux.windows': '{{count}} окон',
'systemManager.tmux.created': 'Создана',
'systemManager.tmux.activity': 'Активность',
'systemManager.tmux.rename': 'Переименовать',
'systemManager.tmux.detach': 'Отключить всех',
'systemManager.tmux.killSession': 'Завершить сессию',
'systemManager.tmux.killServer': 'Остановить сервер',
'systemManager.tmux.loadingDetails': 'Загрузка деталей…',
'systemManager.tmux.clients': 'Подключённые клиенты',
'systemManager.tmux.windowList': 'Окна',
'systemManager.tmux.newWindow': 'Новое окно',
'systemManager.tmux.newWindowPlaceholder': 'Имя окна (необязательно)',
'systemManager.tmux.noWindows': 'Нет окон',
'systemManager.tmux.unavailable': 'tmux недоступен на этом хосте',
'systemManager.docker.unavailable': 'Docker недоступен на этом хосте',
'systemManager.tmux.windowsMismatch': 'В сессии указано {{count}} окон, но list-windows ничего не вернул',
'systemManager.tmux.lastCommand': 'последняя команда: {{command}}',
'systemManager.tmux.noPanes': 'Нет панелей',
'systemManager.tmux.panes': '{{count}} пан.',
'systemManager.tmux.active': 'активно',
'systemManager.tmux.unnamedWindow': 'Безымянное окно',
'systemManager.tmux.unnamedPane': 'Безымянная панель',
'systemManager.tmux.attachWindow': 'Подключить к окну',
'systemManager.tmux.selectWindow': 'Выбрать окно',
'systemManager.tmux.killWindow': 'Закрыть окно',
'systemManager.tmux.killPane': 'Закрыть панель',
'systemManager.tmux.splitHorizontal': 'Разделить горизонтально',
'systemManager.tmux.splitVertical': 'Разделить вертикально',
'systemManager.tmux.sendKeys': 'Отправить клавиши',
'systemManager.tmux.sendKeysTo': 'Отправить клавиши в окно {{window}} панель {{pane}}',
'systemManager.tmux.sendKeysPlaceholder': 'Команда или текст…',
'systemManager.tmux.renameSessionPrompt': 'Переименовать сессию',
'systemManager.tmux.renameWindowPrompt': 'Переименовать окно',
'systemManager.tmux.windowName': 'Имя окна',
'systemManager.tmux.confirmKillSession': 'Завершить сессию tmux «{{name}}»?',
'systemManager.tmux.confirmDetachSession': 'Отключить всех клиентов от «{{name}}»?',
'systemManager.tmux.confirmKillWindow': 'Закрыть окно «{{name}}»?',
'systemManager.tmux.confirmKillPane': 'Закрыть панель #{{index}}?',
'systemManager.tmux.confirmKillServer': 'Остановить сервер tmux? Все сессии будут завершены.',
'systemManager.tmux.meta': '{{count}} сессий',
'systemManager.docker.title': 'Контейнеры',
'systemManager.docker.subTabs.containers': 'Контейнеры',
'systemManager.docker.subTabs.images': 'Образы',
'systemManager.docker.empty': 'Контейнеры не найдены',
'systemManager.docker.imagesEmpty': 'Образы не найдены',
'systemManager.docker.search': 'Поиск контейнеров…',
'systemManager.docker.searchImages': 'Поиск образов…',
'systemManager.docker.filter.all': 'Все',
'systemManager.docker.filter.running': 'Запущены',
'systemManager.docker.filter.stopped': 'Остановлены',
'systemManager.docker.filter.paused': 'На паузе',
'systemManager.docker.shell': 'Shell',
'systemManager.docker.logs': 'Логи',
'systemManager.docker.details': 'Детали',
'systemManager.docker.inspect': 'Inspect',
'systemManager.docker.imageInspect': 'Inspect образа',
'systemManager.docker.confirmRemove': 'Удалить этот контейнер?',
'systemManager.docker.confirmKill': 'Принудительно завершить контейнер?',
'systemManager.docker.confirmRemoveImage': 'Удалить образ «{{name}}»?',
'systemManager.docker.confirmPrune': 'Удалить dangling-образы?',
'systemManager.docker.confirmPruneAll': 'Удалить все неиспользуемые образы?',
'systemManager.docker.pause': 'Пауза',
'systemManager.docker.unpause': 'Возобновить',
'systemManager.docker.restart': 'Перезапустить',
'systemManager.docker.kill': 'Kill',
'systemManager.docker.renamePrompt': 'Имя контейнера',
'systemManager.docker.prune': 'Prune',
'systemManager.docker.pruneAll': 'Prune all',
'systemManager.docker.tag': 'Tag',
'systemManager.docker.tagRepoPrompt': 'Имя репозитория',
'systemManager.docker.tagNamePrompt': 'Имя тега',
'systemManager.docker.meta': '{{count}} конт.',
'systemManager.docker.imagesMeta': '{{count}} образов',
'systemManager.docker.start': 'Запустить',
'systemManager.docker.stop': 'Остановить',
'systemManager.inspect.status': 'Статус',
'systemManager.inspect.image': 'Образ',
'systemManager.inspect.created': 'Создан',
'systemManager.inspect.started': 'Запущен',
'systemManager.inspect.restartPolicy': 'Перезапуск',
'systemManager.inspect.command': 'Команда',
'systemManager.inspect.ports': 'Порты',
'systemManager.inspect.networks': 'Сети',
'systemManager.inspect.mounts': 'Тома',
'systemManager.inspect.env': 'Окружение',
'systemManager.inspect.labels': 'Метки',
'systemManager.inspect.tags': 'Теги',
'systemManager.inspect.digests': 'Дайджесты',
'systemManager.inspect.size': 'Размер',
'systemManager.inspect.platform': 'Платформа',
'systemManager.inspect.workdir': 'Рабочий каталог',
'systemManager.inspect.exposedPorts': 'Открытые порты',
'systemManager.inspect.showRaw': 'JSON',
'systemManager.inspect.hideRaw': 'Скрыть JSON',
};

View File

@@ -0,0 +1,712 @@
import type { Messages } from '../types';
export const ruTerminalMessages: Messages = {
'terminal.sudoHint.pressEnter': 'Нажмите Enter, чтобы вставить пароль sudo',
// Connection logs
'logs.table.date': 'Дата',
'logs.table.user': 'Пользователь',
'logs.table.host': 'Хост',
'logs.table.saved': 'Сохранено',
'logs.empty.title': 'Нет журналов подключений',
'logs.empty.desc':
'История ваших подключений будет отображаться здесь, когда вы подключаетесь к хостам или открываете локальные терминалы.',
'logs.loadMore': 'Загрузить ещё {count} журналов',
'logs.ongoing': 'в процессе',
'logs.localTerminal': 'Локальный терминал',
'logs.action.save': 'Сохранить',
'logs.action.unsave': 'Убрать из сохранённых',
'logs.action.delete': 'Удалить',
// Log view
'logView.customizeAppearance': 'Настроить внешний вид',
'logView.appearance': 'Внешний вид',
'logView.readOnly': 'Только чтение',
'logView.export': 'Экспорт',
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': 'Открыть SFTP',
'terminal.toolbar.availableAfterConnect': 'Доступно после подключения',
'terminal.toolbar.sendYmodem': 'Отправить через YMODEM',
'terminal.toolbar.sftp': 'SFTP',
'terminal.toolbar.more': 'Другие действия',
'terminal.toolbar.scripts': 'Скрипты',
'terminal.toolbar.history': 'История команд',
'history.scope.label': 'Область истории',
'history.tab.host': 'Хост',
'history.tab.global': 'Глобальная',
'history.searchPlaceholder': 'Поиск по истории...',
'history.loading': 'Загрузка удалённой истории...',
'history.meta.count': '{count} команд',
'history.empty.noSession': 'Откройте удалённую сессию, чтобы просмотреть историю команд.',
'history.empty.unsupportedProtocol': 'История команд доступна только для сессий SSH/Mosh/ET.',
'history.empty.noHistory': 'История команд на этом хосте не найдена.',
'history.empty.noGlobalHistory': 'Глобальной истории команд пока нет. Выполненные команды появятся здесь.',
'history.action.refresh': 'Обновить',
'history.action.retry': 'Повторить',
'history.action.paste': 'Вставить в терминал',
'history.action.run': 'Выполнить в терминале',
'history.action.saveAsSnippet': 'Сохранить как сниппет',
'terminal.toolbar.library': 'Библиотека',
'terminal.toolbar.noSnippets': 'Нет доступных сниппетов',
'terminal.toolbar.terminalSettings': 'Настройки терминала',
'terminal.toolbar.searchTerminal': 'Поиск по терминалу',
'terminal.toolbar.search': 'Поиск',
'terminal.toolbar.timestampsEnable': 'Показать время',
'terminal.toolbar.timestampsDisable': 'Скрыть время',
'terminal.toolbar.broadcast': 'Трансляция',
'terminal.toolbar.broadcastEnable': 'Включить режим трансляции',
'terminal.toolbar.broadcastDisable': 'Отключить режим трансляции',
'terminal.toolbar.composeBar': 'Строка ввода',
'terminal.composeBar.placeholder': 'Введите команду здесь и нажмите Enter для отправки...',
'terminal.composeBar.send': 'Отправить',
'terminal.composeBar.close': 'Закрыть строку ввода',
'terminal.composeBar.broadcasting': 'Трансляция во все сессии',
'terminal.composeBar.resize': 'Изменить высоту строки ввода',
'terminal.composeBar.manageSnippets': 'Управление быстрыми сниппетами',
'terminal.composeBar.searchSnippets': 'Поиск сниппетов...',
'terminal.composeBar.noPinnedSnippets': 'Закрепите сниппеты через + для быстрого доступа',
'terminal.composeBar.noMatchingSnippets': 'Сниппеты не найдены',
'terminal.composeBar.pinnedCount': 'Закреплено: {count}',
'terminal.composeBar.unpinSnippet': 'Убрать {label} из панели',
'terminal.composeBar.snippetClickHint': 'Клик — вставить · Shift+клик — отправить',
'terminal.toolbar.focus': 'Фокус',
'terminal.toolbar.focusMode': 'Режим фокуса',
'terminal.toolbar.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.addSelectionToAI': 'Добавить в чат',
'terminal.menu.pasteSelection': 'Вставить выделенное',
'terminal.menu.selectAll': 'Выбрать всё',
'terminal.menu.reconnect': 'Переподключиться',
'terminal.menu.sendYmodem': 'Отправить через YMODEM',
'terminal.menu.splitHorizontal': 'Разделить по горизонтали',
'terminal.menu.splitVertical': 'Разделить по вертикали',
'terminal.menu.clearBuffer': 'Очистить буфер',
'terminal.menu.closeTerminal': 'Закрыть терминал',
'terminal.ymodem.selectFile': 'Выберите файл для отправки',
'terminal.ymodem.allFiles': 'Все файлы',
'terminal.ymodem.started': 'YMODEM отправляет {fileName}',
'terminal.ymodem.complete': 'YMODEM отправил {fileName}',
'terminal.ymodem.failed': 'Не удалось отправить через YMODEM',
'terminal.ymodem.unavailable': 'YMODEM недоступен',
'terminal.selection.addToAI': 'Добавить в чат',
'terminal.selection.addToAIDesc': 'Прикрепить выбранный вывод терминала к черновику AI',
'terminal.auth.password': 'Пароль',
'terminal.auth.sshKey': 'SSH-ключ',
'terminal.auth.username': 'Имя пользователя',
'terminal.auth.username.placeholder': 'root',
'terminal.auth.passwordLabel': 'Пароль',
'terminal.auth.password.placeholder': 'Введите пароль',
'terminal.auth.passphrase': 'Парольная фраза',
'terminal.auth.passphrase.placeholder': 'Необязательная парольная фраза для выбранного приватного ключа',
'terminal.auth.certificate': 'Сертификат',
'terminal.auth.selectKey': 'Выбрать ключ',
'terminal.auth.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.copyTabToNewWindow': 'Копировать вкладку в новое окно',
'tabs.copyTabToNewWindowFailed': 'Не удалось открыть вкладку в новом окне',
'tabs.closeOthers': 'Закрыть остальные',
'tabs.closeToRight': 'Закрыть вкладки справа',
'tabs.closeAll': 'Закрыть все',
'keychain.edit.labelRequired': 'Метка *',
'keychain.edit.keyLabelPlaceholder': 'Метка ключа',
'keychain.edit.privateKeyRequired': 'Приватный ключ *',
'keychain.edit.publicKey': 'Публичный ключ',
'keychain.edit.certificate': 'Сертификат',
'keychain.edit.certificatePlaceholder': 'Содержимое сертификата (необязательно)',
'keychain.edit.filePath': 'Путь к файлу',
'keychain.edit.keyExport': 'Экспорт ключа',
'keychain.edit.exportToHost': 'Экспортировать на хост',
// Snippets
'snippets.searchPlaceholder': 'Поиск сниппетов...',
'snippets.action.newSnippet': 'Новый сниппет',
'snippets.action.newPackage': 'Новый пакет',
'snippets.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,680 @@
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.list': 'Закладки путей',
'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.viewMode.switchToList': 'Переключиться на список',
'sftp.viewMode.switchToTree': 'Переключиться на дерево',
'sftp.tree.loadError': 'Не удалось загрузить каталог',
'sftp.tree.loading': 'Загрузка...',
'sftp.kind.folder': 'Папка',
'sftp.context.rename': 'Переименовать',
'sftp.context.permissions': 'Права доступа',
'sftp.context.delete': 'Удалить',
'sftp.context.refresh': 'Обновить',
'sftp.context.uploadFiles': 'Загрузить файл(ы)...',
'sftp.context.uploadFilesHere': 'Загрузить файлы сюда...',
'sftp.context.uploadFolder': 'Загрузить папку...',
'sftp.context.uploadFolderHere': 'Загрузить папку сюда...',
'sftp.context.downloadSelected': 'Скачать выбранное ({count})',
'sftp.context.deleteSelected': 'Удалить выбранное ({count})',
'sftp.dropFilesHere': 'Перетащите сюда файлы',
'sftp.itemsCount': '{count} записей',
'sftp.selectedCount': '{count} выбрано',
'sftp.path.doubleClickToEdit': 'Дважды щёлкните, чтобы изменить путь',
'sftp.showHiddenPaths': 'Скрытые пути',
'sftp.task.waiting': 'Ожидание...',
'sftp.transfer.preparing': 'подготовка...',
'sftp.status.loading': 'Загрузка...',
'sftp.status.uploading': 'Загрузка...',
'sftp.status.ready': 'Готово',
'sftp.transfers': 'Передачи',
'sftp.transfers.active': '{count} активн(ый/ых)',
'sftp.transfers.clearCompleted': 'Очистить завершённые',
'sftp.transfers.calculatingTotal': 'Вычисление общего размера...',
'sftp.transfers.filesCount': '{count} файл(ов)',
'sftp.transfers.filesProgress': '{current}/{total} файл(ов)',
'sftp.transfers.expandChildren': 'Показать файлы',
'sftp.transfers.collapseChildren': 'Скрыть файлы',
'sftp.transfers.expandChildList': 'Показать детали',
'sftp.transfers.collapseChildList': 'Скрыть',
'sftp.transfers.retryAction': 'Повторить',
'sftp.transfers.dismissAction': 'Скрыть',
'sftp.transfers.openTargetFolder': 'Открыть целевую папку',
'sftp.transfers.openTargetFolderError': 'Не удалось открыть целевую папку',
'sftp.transfers.copyTargetPath': 'Копировать целевой путь',
'sftp.transfers.copyTargetPathSuccess': 'Целевой путь скопирован',
'sftp.transfers.copyTargetPathError': 'Не удалось скопировать целевой путь',
'sftp.transfers.resizeNameColumn': 'Изменить ширину столбца имени файла',
'sftp.transfers.dragToResize': 'Перетащите для изменения размера',
'sftp.goUp': 'Наверх',
'sftp.goToTerminalCwd': 'Перейти в каталог терминала',
'sftp.followTerminalCwd': 'Следовать за каталогом терминала',
'sftp.followTerminalCwd.enable': 'Включить следование за каталогом терминала',
'sftp.followTerminalCwd.disable': 'Отключить следование за каталогом терминала',
'sftp.encoding.label': 'Кодировка имён файлов',
'sftp.encoding.auto': 'Авто',
'sftp.encoding.utf8': 'UTF-8',
'sftp.encoding.gb18030': 'GB18030',
'sftp.goHome': 'Перейти в домашний каталог',
'sftp.folderName': 'Имя папки',
'sftp.folderName.placeholder': 'Введите имя папки',
'sftp.fileName': 'Имя файла',
'sftp.fileName.placeholder': 'Введите имя файла',
'sftp.prompt.newFolderName': 'Имя новой папки?',
'sftp.rename.title': 'Переименовать',
'sftp.rename.newName': 'Новое имя',
'sftp.rename.placeholder': 'Введите новое имя',
'sftp.confirm.deleteOne': 'Удалить "{name}"?',
'sftp.deleteConfirm.single': 'Удалить "{name}"?',
'sftp.deleteConfirm.title': 'Удалить {count} элемент(ов)?',
'sftp.deleteConfirm.desc': 'Это действие нельзя отменить. Будет удалено следующее:',
'sftp.deleteConfirm.descSingle': 'Это действие нельзя отменить.',
'sftp.deleteConfirm.host': 'Хост',
'sftp.deleteConfirm.path': 'Путь',
'sftp.error.loadFailed': 'Не удалось загрузить каталог',
'sftp.error.downloadFailed': 'Ошибка скачивания',
'sftp.error.uploadFailed': 'Ошибка загрузки',
'sftp.error.deleteFailed': 'Ошибка удаления',
'sftp.error.createFolderFailed': 'Не удалось создать папку',
'sftp.error.createFileFailed': 'Не удалось создать файл',
'sftp.error.invalidFileName': 'Имя файла содержит недопустимые символы: {chars}',
'sftp.error.reservedName': 'Это имя файла зарезервировано системой',
'sftp.overwrite.title': 'Файл уже существует',
'sftp.overwrite.desc': 'Файл с именем "{name}" уже существует. Хотите заменить его?',
'sftp.overwrite.confirm': 'Заменить',
'sftp.error.renameFailed': 'Не удалось переименовать',
'sftp.picker.title': 'Выберите хост',
'sftp.picker.desc': 'Выберите хост для панели {side}',
'sftp.picker.searchPlaceholder': 'Поиск хостов...',
'sftp.picker.local.title': 'Локальная файловая система',
'sftp.picker.local.desc': 'Просмотр локальных файлов',
'sftp.picker.local.badge': 'Локально',
'sftp.picker.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.followTerminalCwd': 'Следовать за каталогом терминала',
'settings.sftp.followTerminalCwd.desc': 'Автоматически синхронизировать боковую панель SFTP с рабочим каталогом терминала (переключатель на панели инструментов)',
'settings.sftp.followTerminalCwd.enable': 'Включать следование по умолчанию',
'settings.sftp.followTerminalCwd.enableDesc': 'При открытой боковой панели SFTP режим следования включён по умолчанию и обновляется после команд cd в терминале',
'settings.sftp.defaultViewMode': 'Режим просмотра по умолчанию',
'settings.sftp.defaultViewMode.desc': 'Выберите режим просмотра по умолчанию при открытии новой вкладки SFTP. Настройки конкретного хоста имеют приоритет.',
'settings.sftp.defaultViewMode.list': 'Список',
'settings.sftp.defaultViewMode.listDesc': 'Показывать файлы в виде плоского списка для текущего каталога',
'settings.sftp.defaultViewMode.tree': 'Дерево',
'settings.sftp.defaultViewMode.treeDesc': 'Показывать файлы в иерархической древовидной структуре',
'sftp.autoSync.success': 'Файл синхронизирован с удалённым сервером: {fileName}',
'sftp.autoSync.error': 'Не удалось синхронизировать файл: {error}',
// SFTP Folder Upload Progress
'sftp.upload.progress': 'Загрузка файлов {current} из {total}...',
'sftp.upload.uploading': 'Загрузка...',
'sftp.upload.compressing': 'Сжатие...',
'sftp.upload.extracting': 'Распаковка...',
'sftp.upload.scanning': 'Сканирование файлов...',
'sftp.upload.completed': 'Завершено',
'sftp.upload.compressed': 'Сжатая передача',
'sftp.upload.currentFile': 'Текущий: {fileName}',
'sftp.upload.cancelled': 'Загрузка отменена',
'sftp.upload.cancel': 'Отмена',
'sftp.upload.completedToPath': 'Загружено в {path}',
// SFTP Download
'sftp.download.completed': 'Скачано',
'sftp.download.cancelled': 'Скачивание отменено',
// SFTP Reconnecting
'sftp.reconnecting.title': 'Переподключение...',
'sftp.reconnecting.desc': 'Соединение потеряно, выполняется попытка переподключения',
'sftp.reconnected': 'Соединение восстановлено',
'sftp.error.reconnectFailed': 'Не удалось переподключиться. Попробуйте ещё раз.',
'sftp.error.connectionLostManual': 'Соединение потеряно. Пожалуйста, переподключитесь вручную.',
'sftp.error.connectionLostReconnecting': 'Соединение потеряно. Переподключение...',
'sftp.error.sessionLost': 'SFTP-сессия потеряна. Пожалуйста, переподключитесь.',
// Settings > SFTP Show Hidden Files
'settings.sftp.showHiddenFiles': 'Показывать скрытые файлы',
'settings.sftp.showHiddenFiles.desc': 'Показывать скрытые файлы (dotfiles в Unix/macOS и файлы с атрибутом hidden в Windows) в файловом браузере SFTP.',
'settings.sftp.showHiddenFiles.enable': 'Показывать скрытые файлы',
'settings.sftp.showHiddenFiles.enableDesc': 'Показывать скрытые файлы при просмотре как локальной, так и удалённой файловой системы',
// Settings > SFTP Compressed Upload
'settings.sftp.compressedUpload': 'Передача со сжатием папок',
'settings.sftp.compressedUpload.desc': 'Сжимать папки перед загрузкой, чтобы значительно сократить время передачи.',
'settings.sftp.compressedUpload.enable': 'Включить сжатие папок',
'settings.sftp.compressedUpload.enableDesc': 'Автоматически сжимать папки с помощью tar перед передачей. Требует поддержки tar на сервере. Если она недоступна, будет использована обычная передача.',
// Quick Switcher
'qs.search.placeholder': 'Поиск хостов или вкладок',
'qs.jumpTo': 'Перейти к',
'qs.localTerminal': 'Локальный терминал',
'qs.localShells': 'Локальные оболочки',
'qs.default': 'По умолчанию',
// Select Host panel
'selectHost.title': 'Выберите хост',
'selectHost.noHostsFound': 'Хосты не найдены',
'selectHost.newHost': 'Новый хост',
'selectHost.continue': 'Продолжить',
'selectHost.continueWithCount': 'Продолжить (выбрано: {count})',
// Quick Connect
'quickConnect.knownHost.title': 'Вы уверены, что хотите подключиться?',
'quickConnect.knownHost.authenticity': 'Подлинность {hostname} не может быть установлена.',
'quickConnect.knownHost.fingerprintLabel': '{keyType} fingerprint is SHA256:',
'quickConnect.knownHost.addQuestion': 'Хотите добавить его в список известных хостов?',
'quickConnect.knownHost.addAndContinue': 'Добавить и продолжить',
'quickConnect.addKey': 'Добавить ключ',
'quickConnect.warning.unparsedOptions': 'Некоторые аргументы SSH были проигнорированы: {options}',
// 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.lineTimestamps': 'Показывать время вывода',
'hostDetails.lineTimestamps.desc': 'Показывать локальное время рядом с видимыми строками вывода для этого хоста, не изменяя текст терминала.',
'hostDetails.legacyAlgorithms': 'Разрешить устаревшие алгоритмы',
'hostDetails.legacyAlgorithms.desc': 'Включить устаревшие SSH-алгоритмы (diffie-hellman-group1, ssh-dss, 3des-cbc и т. д.) для подключения к старому сетевому оборудованию.',
'hostDetails.legacyAlgorithms.warning': 'У этих алгоритмов есть известные слабые места безопасности. Включайте только для устаревших устройств, которые не поддерживают современную криптографию.',
'hostDetails.skipEcdsaHostKey': 'Пропустить ECDSA host key',
'hostDetails.skipEcdsaHostKey.desc': 'Некоторые старые коммутаторы Huawei / Cisco выдают нестандартные подписи ECDSA host-key, из-за чего соединение падает с "signature verification failed". Включение этой опции убирает все ecdsa-sha2-* из предложения клиента, и согласование переходит к RSA / Ed25519.',
'hostDetails.algorithms.advanced': 'Дополнительные настройки алгоритмов',
'hostDetails.algorithms.advanced.desc': 'Заменить предлагаемый список алгоритмов для любой категории для конкретного хоста. Не трогать категорию = использовать значение по умолчанию; выбранное подмножество полностью заменяет список по умолчанию. Неверные значения могут сделать хост недоступным.',
'hostDetails.algorithms.inheritedNotice': 'В текущей группе заданы переопределения алгоритмов для: {categories}. Кнопка «Сбросить» здесь возвращает к спискам группы, а не к значениям NetCatty по умолчанию. Чтобы игнорировать ограничение группы, очистите переопределение в настройках алгоритмов группы.',
'hostDetails.algorithms.customized': 'настроено',
'hostDetails.algorithms.reset': 'Сбросить',
'hostDetails.algorithms.category.kex': 'Обмен ключами (KEX)',
'hostDetails.algorithms.category.cipher': 'Шифр',
'hostDetails.algorithms.category.hmac': 'MAC (HMAC)',
'hostDetails.algorithms.category.serverHostKey': 'Host Key',
'hostDetails.algorithms.category.compress': 'Сжатие',
'hostDetails.section.keepalive': 'Keepalive',
'hostDetails.keepalive.override': 'Переопределить глобальный keepalive',
'hostDetails.keepalive.desc': 'Использовать для этого хоста собственную политику keepalive вместо глобальной настройки. Полезно для старых маршрутизаторов и коммутаторов, чей SSH-сервер не отвечает на запросы keepalive@openssh.com. Установите интервал 0, чтобы полностью отключить keepalive для этого хоста.',
'hostDetails.keepalive.interval': 'Интервал (секунды)',
'hostDetails.keepalive.countMax': 'Макс. число пропущенных keepalive',
'hostDetails.keepalive.disabledHint': 'Интервал = 0 отключает keepalive для этого хоста. Для определения разорванного соединения сессия будет полагаться на TCP-таймауты.',
'hostDetails.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,358 @@
import type { Messages } from '../types';
export const zhCNAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Agent 设置',
'ai.chat.preparing': '准备中…',
'ai.title': 'AI',
'ai.description': '配置 AI 提供商、Agent 和安全设置',
'ai.providers': '提供商',
'ai.agents': 'Agent',
'ai.providers.empty': '尚未配置提供商。添加一个提供商以开始使用。',
'ai.providers.add': '添加提供商',
'ai.providers.active': '活跃',
'ai.providers.apiKeyConfigured': 'API Key 已配置',
'ai.providers.noApiKey': '未设置 API Key',
'ai.providers.configure': '配置',
'ai.providers.remove': '移除',
'ai.providers.name': '显示名称',
'ai.providers.name.placeholder': '例如 我的提供商',
'ai.providers.style': '协议风格',
'ai.providers.style.anthropic': 'Anthropic 兼容',
'ai.providers.style.openai': 'OpenAI 兼容',
'ai.providers.style.google': 'Google 兼容',
'ai.providers.style.inherited': '默认',
'ai.providers.style.help': '决定请求使用哪种 API 格式。当第三方端点的协议与其提供商类型不一致时,可手动覆盖。',
'ai.providers.icon.change': '修改图标',
'ai.providers.icon.upload': '上传图片',
'ai.providers.icon.reset': '恢复默认',
'ai.providers.icon.close': '收起',
'ai.providers.icon.uploadedNote': '自定义图标64×64 WebP',
'ai.providers.icon.errorType': '请选择图片文件。',
'ai.providers.apiKey': 'API Key',
'ai.providers.apiKey.placeholder': '输入 API Key',
'ai.providers.apiKey.decrypting': '解密中...',
'ai.providers.baseUrl': 'Base URL',
'ai.providers.skipTLSVerify': '跳过 TLS 证书验证(用于自签名证书)',
'ai.providers.defaultModel': '默认模型',
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
'ai.providers.contextWindow': '上下文窗口',
'ai.providers.contextWindow.placeholder': '例如 128000',
'ai.providers.contextWindow.help': '留空时优先使用模型列表返回的值如果没有Netcatty 会使用安全默认值。',
'ai.providers.contextWindow.error': '请输入正整数,或留空。',
'ai.providers.refreshModels': '刷新模型列表',
'ai.providers.searchModel': '搜索或输入模型 ID...',
'ai.providers.filterModels': '筛选模型...',
'ai.providers.loadingModels': '加载模型中...',
'ai.providers.noMatchingModels': '没有匹配的模型',
'ai.providers.clickToLoadModels': '点击加载模型',
'ai.providers.showingModels': '显示前 100 个,共 {count} 个模型。输入以筛选。',
'ai.providers.advancedParams': '高级参数',
'ai.providers.advancedParams.hint': '留空则使用提供商默认值。',
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
'ai.providers.advancedParams.default': '提供商默认',
// AI Codex
'ai.codex': 'Codex',
'ai.codex.title': 'Codex CLI',
'ai.codex.description': '接入 OpenAI Codex。可以在这里登录 ChatGPT也可以在设置里启用兼容 OpenAI 的 API Key 和自定义接口地址。',
'ai.codex.detecting': '检测中...',
'ai.codex.notFound': '未找到',
'ai.codex.awaitingLogin': '等待登录',
'ai.codex.connectedChatGPT': '已通过 ChatGPT 连接',
'ai.codex.connectedApiKey': '已通过 API Key 连接',
'ai.codex.connectedCustomConfig': '使用 ~/.codex/config.toml 自定义 provider',
'ai.codex.customConfigIncomplete': '检测到自定义配置(缺少环境变量)',
'ai.codex.customConfigHint': '使用 ~/.codex/config.toml 中配置的自定义 provider "{provider}",无需 ChatGPT 登录。',
'ai.codex.customConfigMissingEnvKey': '警告:环境变量 {envKey} 未在当前 shell 中设置。请 export 它(或从包含该变量的 shell 启动 netcatty否则 Codex 无法鉴权。',
'ai.codex.notConnected': '未连接',
'ai.codex.statusUnknown': '状态未知',
'ai.codex.path': '路径:',
'ai.codex.notFoundHint': '在 PATH 中未找到 codex。请安装或在下方指定可执行文件路径。',
'ai.codex.customPathPlaceholder': '例如 /usr/local/bin/codex',
'ai.codex.check': '检查',
'ai.codex.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 Cursor SDK
'ai.cursor.title': 'Cursor',
'ai.cursor.description': '使用 Cursor SDK。',
'ai.cursor.detecting': '检测中...',
'ai.cursor.detected': '可用',
'ai.cursor.notFound': '不可用',
'ai.cursor.path': '运行环境:',
'ai.cursor.notFoundHint': '填写 API Key 后即可使用。',
'ai.cursor.notInstalledHint': '未检测到 Cursor SDK。',
'ai.cursor.installStatus': 'Cursor SDK',
'ai.cursor.installed': '已检测到',
'ai.cursor.notInstalled': '未检测到',
'ai.cursor.apiKeyStatus': 'API Key',
'ai.cursor.apiKeyConfigured': '已填写',
'ai.cursor.apiKeyMissing': '未填写',
'ai.cursor.apiKeyFromEnv': '来自环境变量',
'ai.cursor.apiKey': 'API Key',
'ai.cursor.apiKeyPlaceholder': '输入 Cursor API Key',
'ai.cursor.apiKeyPlaceholder.env': '已使用 CURSOR_API_KEY填写后会覆盖',
'ai.cursor.apiKeyEnvHint': '已检测到本机 CURSOR_API_KEY。留空即可继续使用填写保存后会覆盖它。',
'ai.cursor.apiKeyOverrideHint': '当前优先使用这里保存的 Key清空保存后会回到 CURSOR_API_KEY。',
'ai.cursor.saveApiKey': '保存',
'ai.cursor.saved': '已保存',
'ai.cursor.showApiKey': '显示 API Key',
'ai.cursor.hideApiKey': '隐藏 API Key',
'ai.cursor.customPathPlaceholder': '例如 /usr/local/bin/cursor',
'ai.cursor.check': '检查',
// AI CodeBuddy Code
'ai.codebuddy.title': 'CodeBuddy Code',
'ai.codebuddy.description': '通过官方 Agent SDK`@tencent-ai/agent-sdk`)接入 CodeBuddy Code。检测到后即可作为外部编程 Agent 使用。',
'ai.codebuddy.detecting': '检测中...',
'ai.codebuddy.detected': '已检测到',
'ai.codebuddy.notFound': '未找到',
'ai.codebuddy.path': '路径:',
'ai.codebuddy.notFoundHint': '在 PATH 中未找到 codebuddy。请安装或在下方指定可执行文件路径。',
'ai.codebuddy.customPathPlaceholder': '例如 /usr/local/bin/codebuddy',
'ai.codebuddy.check': '检查',
'ai.codebuddy.configSection': '认证与配置(可选)',
'ai.codebuddy.internetEnv': '网络环境',
'ai.codebuddy.internetEnv.default': '默认(海外)',
'ai.codebuddy.internetEnv.internal': 'Internal',
'ai.codebuddy.internetEnv.ioa': 'IOA',
'ai.codebuddy.internetEnv.hint': '设置 CODEBUDDY_INTERNET_ENVIRONMENT —— 受限网络环境请选择 Internal 或 IOA。',
'ai.codebuddy.envVars': '环境变量',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': '每行一个 KEY=VALUE传给 CodeBuddy agent。可在此设置 CODEBUDDY_API_KEY 或 CODEBUDDY_AUTH_TOKEN 完成认证。明文存在本地。',
// AI 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 Quick Messages
'ai.quickMessages.title': '快捷消息',
'ai.quickMessages.description': '创建常用提示词,在 AI 聊天框输入 / 或点击快捷按钮即可插入到输入框。与用户 Skills 不同,快捷消息会直接填入消息内容。',
'ai.quickMessages.add': '添加快捷消息',
'ai.quickMessages.createTitle': '新建快捷消息',
'ai.quickMessages.editTitle': '编辑快捷消息',
'ai.quickMessages.name': '名称',
'ai.quickMessages.name.placeholder': '例如:检查磁盘空间',
'ai.quickMessages.slug': '命令',
'ai.quickMessages.slug.placeholder': 'disk-check',
'ai.quickMessages.descriptionField': '说明(可选)',
'ai.quickMessages.descriptionField.placeholder': '简短描述这条快捷消息的用途',
'ai.quickMessages.content': '消息内容',
'ai.quickMessages.content.placeholder': '输入选择后要插入的完整提示词...',
'ai.quickMessages.empty': '还没有快捷消息。添加几条常用提示,聊天时就能一键插入。',
'ai.quickMessages.confirmDelete': '确定删除快捷消息「{name}」吗?',
'ai.quickMessages.error.nameRequired': '请填写名称。',
'ai.quickMessages.error.invalidSlug': '命令只能包含小写字母、数字和连字符。',
'ai.quickMessages.error.contentRequired': '请填写消息内容。',
'ai.quickMessages.error.slugTaken': '该命令已被其他快捷消息使用。',
'ai.quickMessages.error.slugConflictsWithSkill': '该命令与用户 Skill「/{slug}」冲突,请换一个命令。',
'ai.quickMessages.error.maxItems': '最多只能保存 {max} 条快捷消息。',
// AI Chat
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
'ai.chat.toolDenied': '操作已被用户拒绝。',
'ai.chat.toolApproved': '已批准',
'ai.chat.toolApprovalHint': '按回车批准,按 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.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
'ai.chat.usedTools': '已使用 {n} 个工具',
'ai.chat.loadMoreSessions': '加载更多会话(还有 {n} 条)',
'ai.chat.noSessions': '没有历史会话',
'ai.chat.retryHint': '你可以重新发送消息来重试。',
'ai.chat.approvalTimeout': '工具审批已超时5 分钟)。你可以重新发送消息来重试。',
'ai.chat.menuHosts': '主机',
'ai.chat.menuContext': '上下文',
'ai.chat.menuFiles': '文件',
'ai.chat.menuImage': '图片',
'ai.chat.menuMentionHost': '提及主机',
'ai.chat.menuUserSkills': '用户 Skills',
'ai.chat.menuSlashCommands': '快捷命令',
'ai.chat.slashCommands': '快捷命令',
'ai.chat.slashQuickMessages': '快捷消息',
'ai.chat.slashUserSkills': '用户 Skills',
'ai.chat.quickMessages': '快捷命令',
'ai.chat.slashNoResults': '没有匹配的命令',
'ai.chat.slashEmptyHint': '可在 设置 → AI → 快捷消息 中添加常用提示词。',
// AI 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.history': '命令历史',
'terminal.layer.theme': '主题',
'terminal.layer.aiChat': 'AI 助手',
'terminal.layer.movePanelLeft': '面板移至左侧',
'terminal.layer.movePanelRight': '面板移至右侧',
'terminal.layer.closePanel': '关闭面板',
'terminal.layer.hostTree.search': '搜索主机...',
'terminal.layer.hostTree.searchButton': '搜索',
'terminal.layer.hostTree.tagsButton': '按标签筛选',
'terminal.layer.hostTree.newGroup': '新建分组',
'terminal.layer.hostTree.localShell': '本地 Shell',
'terminal.layer.hostTree.tagsEmpty': '暂无标签',
'terminal.layer.hostTree.clearTags': '清除筛选',
'terminal.layer.hostTree.collapse': '收起主机列表',
'terminal.layer.hostTree.expand': '展开主机列表',
'terminal.layer.hostTree.empty': '没有匹配的主机',
'terminal.layer.hostTree.details.host': '主机',
'terminal.layer.hostTree.details.user': '用户',
'terminal.layer.hostTree.details.port': '端口',
'terminal.layer.hostTree.details.protocol': '协议',
'terminal.layer.hostTree.details.group': '分组',
'terminal.layer.hostTree.details.tags': '标签',
'terminal.layer.hostTree.details.lastConnected': '最近连接',
'topTabs.openQuickSwitcher': '打开快速切换',
'topTabs.moreTabs': '更多标签页',
'topTabs.aiAssistant': 'AI 助手',
'topTabs.windowOpacity': '窗口透明度',
'topTabs.toggleTheme': '切换主题',
'topTabs.openSettings': '打开设置',
'ai.chat.sessionHistory': '会话历史',
'ai.chat.attach': '附件',
'ai.chat.terminalSelectionAttachment': '终端选区',
'ai.chat.terminalSelectionLines': '{count} 行',
'ai.chat.collapse': '收起',
'ai.chat.expand': '展开',
'ai.chat.enableAgent': '启用 {name}',
'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,683 @@
import type { Messages } from '../types';
export const zhCNCoreMessages: Messages = {
// Common
'common.save': '保存',
'common.cancel': '取消',
'common.close': '关闭',
'common.reset': '重置',
'common.zoomIn': '放大',
'common.zoomOut': '缩小',
'common.settings': '设置',
'common.search': '搜索',
'common.connect': '连接',
'common.terminal': '终端',
'common.create': '创建',
'common.add': '添加',
'common.rename': '重命名',
'common.refresh': '刷新',
'common.continue': '继续',
'common.enabled': '已启用',
'common.disabled': '已禁用',
'common.unknownError': '未知错误',
'common.noResultsFound': '没有匹配结果',
'common.back': '返回',
'common.apply': '应用',
'common.use': '使用',
'common.useGlobal': '跟随全局',
'common.left': '左侧',
'common.right': '右侧',
'common.more': '更多',
'common.selectAHost': '选择主机',
'sort.manual': '手动顺序',
'sort.az': 'A-z',
'sort.za': 'Z-a',
'sort.newest': '从新到旧',
'sort.oldest': '从旧到新',
'sort.group': '按分组',
'field.label': 'Label',
'field.type': '类型',
'auth.keyType': '类型 {type}',
'auth.showAllKeys': '显示全部 keys',
// Dialogs / prompts
'confirm.deleteHost': '删除主机 "{name}"',
'confirm.deleteIdentity': '删除身份 "{name}"',
'confirm.removeProvider': '移除提供商 "{name}"',
'confirm.closeBusyTerminal.title': '确认关闭',
'confirm.closeBusyTerminal.message': '进程 "{command}" 仍在运行,关闭后会被终止。',
'confirm.closeBusyTerminal.messageWithMore': '进程 "{command}" 及其他 {count} 个正在运行的进程将被终止。',
'confirm.closeBusyTerminal.cancel': '取消',
'confirm.closeBusyTerminal.close': '关闭',
'dialog.renameWorkspace.title': '重命名工作区',
'dialog.renameSession.title': '重命名会话',
'field.name': '名称',
'placeholder.workspaceName': '工作区名称',
'placeholder.sessionName': '会话名称',
'toast.settingsUnavailable': '当前平台无法打开设置窗口。',
'credentials.protectionUnavailable.title': '凭据保护不可用',
'credentials.protectionUnavailable.message': '当前设备无法自动解密已保存的密码和密钥。连接前请重新输入凭据。',
'credentials.protectionUnavailable.action': '打开设置',
// Settings shell
'settings.title': '设置',
'settings.tab.application': '应用',
'settings.tab.appearance': '外观',
'settings.tab.terminal': '终端',
'settings.tab.shortcuts': '快捷键',
'settings.tab.syncCloud': '同步与云',
'settings.tab.system': '系统',
// Settings > 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.timestamps': '添加时间戳',
'settings.sessionLogs.timestampsDesc': '为纯文本和 HTML 日志的每一行添加本地时间。',
'settings.sessionLogs.hint': '会话日志用于记录终端输出,便于故障排查和审计。',
// Settings > SSH Debug Logs
'settings.sshDebugLogs.title': 'SSH 调试日志',
'settings.sshDebugLogs.enable': '启用 SSH 调试日志',
'settings.sshDebugLogs.enableDesc': '记录连接、认证、握手、断开和错误原因,不记录终端输出。',
'settings.sshDebugLogs.location': '日志位置',
'settings.sshDebugLogs.status': '状态',
'settings.sshDebugLogs.statusOn': '已开启',
'settings.sshDebugLogs.statusOff': '未开启',
'settings.sshDebugLogs.size': '大小',
'settings.sshDebugLogs.hint': '开启后,新发起的 SSH 连接会写入诊断信息,方便排查堡垒机、认证和异常断开问题。',
// Settings > Global Hotkey (Quake Mode)
'settings.globalHotkey.title': '全局快捷键',
'settings.globalHotkey.toggleWindow': '切换窗口',
'settings.globalHotkey.toggleWindowDesc': '按下组合键以设置显示/隐藏窗口的全局快捷键。',
'settings.globalHotkey.notSet': '未设置',
'settings.globalHotkey.reset': '恢复默认',
'settings.globalHotkey.closeToTray': '关闭时最小化到托盘',
'settings.globalHotkey.closeToTrayDesc': '启用后,关闭窗口将最小化到系统托盘而不是退出程序。',
'settings.globalHotkey.enabled': '启用全局快捷键',
'settings.globalHotkey.enabledDesc': '注册系统级键盘快捷键。禁用后将取消所有全局快捷键注册。',
'settings.globalHotkey.hint': '全局快捷键在系统范围内工作,可快速显示或隐藏窗口(下拉式终端风格)。',
// Tray Panel
'tray.openMainWindow': '打开主窗口',
'tray.sessions': '会话',
'tray.portForwarding': '端口转发',
'tray.status.connected': '已连接',
'tray.status.connecting': '连接中',
'tray.status.disconnected': '已断开',
'tray.status.active': '已启用',
'tray.status.inactive': '未启用',
'tray.status.error': '错误',
'tray.recentHosts': '最近连接的主机',
'tray.empty.title': '一切都很安静',
'tray.empty.subtitle': '去连接个服务器吧,它们想念你了 🚀',
'tray.quit': '退出 Netcatty',
// Vault Sidebar
'vault.sidebar.collapse': '收起侧边栏',
'vault.sidebar.expand': '展开侧边栏',
'vault.sidebar.resize': '调整侧边栏宽度',
// Settings > Application
'settings.application.checkUpdates': '检查更新',
'settings.application.reportProblem': '反馈问题',
'settings.application.reportProblem.subtitle': '生成预填的 GitHub issue',
'settings.application.community': '社区',
'settings.application.community.subtitle': 'GitHub Discussions',
'settings.application.github': 'GitHub',
'settings.application.github.subtitle': '源代码',
'settings.application.whatsNew': '更新内容',
'settings.application.whatsNew.subtitle': '查看发布说明',
'settings.application.openExternal.failedTitle': '无法打开链接',
'settings.application.openExternal.failedBody': '系统浏览器和内置浏览器窗口都无法打开该链接。',
'settings.vault.title': '主机库',
'settings.vault.showRecentHosts': '显示最近连接的主机',
'settings.vault.showRecentHostsDesc': '在主机列表顶部显示最近连接过的主机',
'settings.vault.showOnlyUngroupedHostsInRoot': '根目录只显示未分组主机',
'settings.vault.showOnlyUngroupedHostsInRootDesc': '开启后,主机库根目录的主机列表只显示没有分组的主机,已分组主机请从左侧分组进入查看。',
'settings.vault.showSftpTab': '显示 SFTP 标签页',
'settings.vault.showSftpTabDesc': '在顶部标签栏显示独立的 SFTP 视图。关闭后可改用会话内左侧的 SFTP 侧栏。',
'settings.vault.showHostTreeSidebar': '显示主机列表侧栏',
'settings.vault.showHostTreeSidebarDesc': '在终端和编辑器标签页显示主机列表侧栏及顶部开关。',
// Update notifications
'update.available.title': '发现新版本',
'update.available.message': '新版本 {version} 已发布,点击前往下载。',
'update.checking': '正在检查更新...',
'update.upToDate.title': '已是最新版本',
'update.upToDate.message': '当前版本 ({version}) 已是最新。',
'update.error': '检查更新失败',
'update.downloadNow': '立即下载',
'update.viewInSettings': '在设置中查看',
'update.readyToInstall.title': '更新已就绪',
'update.readyToInstall.message': '版本 {version} 已下载完成,准备安装。',
'update.restartNow': '立即重启',
'update.downloadFailed.title': '更新失败',
'update.downloadFailed.message': '下载更新失败,可前往 GitHub 手动下载。',
'update.needsSave.title': '有未保存内容',
'update.needsSave.message': '请先保存已打开的编辑器,然后再次点击「立即重启」以安装更新。',
'update.openReleases': '打开 Releases',
'update.remindLater': '稍后提醒',
'update.skipVersion': '跳过此版本',
// Settings > Appearance
'settings.appearance.uiTheme': '界面主题',
'settings.appearance.theme': '主题',
'settings.appearance.theme.desc': '选择浅色、深色或跟随系统设置',
'settings.appearance.theme.light': '浅色',
'settings.appearance.theme.dark': '深色',
'settings.appearance.theme.system': '系统',
'settings.appearance.accentColor': '强调色',
'settings.appearance.customColor': '自定义颜色',
'settings.appearance.accentColor.mode': '使用自定义强调色',
'settings.appearance.accentColor.mode.desc': '覆盖主题自带的强调色',
'settings.appearance.accentColor.custom': '自定义强调色',
'settings.appearance.themeColor': '主题色',
'settings.appearance.themeColor.desc': '为浅色与深色主题选择预设配色',
'settings.appearance.themeColor.light': '浅色主题',
'settings.appearance.themeColor.dark': '深色主题',
'settings.appearance.customCss': '自定义 CSS',
'settings.appearance.customCss.desc':
'使用自定义 CSS 个性化界面,修改会立即生效。主要 UI 区块都暴露了 [data-section="..."] 属性供你定位比如snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebarFocus 模式终端列表、terminal-host-tree-sidebar、terminal-host-tree-sidebar-content、terminal-host-tree-sidebar-row、terminal-side-panelSFTP/脚本/主题/AI 侧栏打开时生效、terminal-side-panel-tabs、terminal-side-panel-content、terminal-sftp-panel、terminal-sftp-host-header、terminal-sftp-pane、terminal-sftp-toolbar、terminal-sftp-path、terminal-sftp-filter-bar、terminal-sftp-list、terminal-sftp-list-header、terminal-sftp-list-row、terminal-sftp-tree、terminal-sftp-tree-row、terminal-sftp-transfer-queue、terminal-sftp-transfer-row、terminal-split-pane、terminal-split-resizer、top-tabs、top-tabs-host-tree-toggle、top-tabs-quick-switcher-toggle。',
'settings.appearance.customCss.placeholder':
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 隐藏顶部标签栏里的主机列表开关 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隐藏打开快速切换器的加号按钮 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作侧栏边框(关闭侧栏后不会残留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整个操作侧栏背景,而不只是顶部标签 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改选中的 SFTP 文件行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
'settings.appearance.language': '语言',
'settings.appearance.language.desc': '选择界面语言',
'settings.appearance.uiFont': '界面字体',
'settings.appearance.uiFont.desc': '选择软件界面使用的字体',
'settings.appearance.windowOpacity': '窗口透明度',
'settings.appearance.windowOpacity.desc': '调节整个应用窗口的透明度,方便叠在其他内容上方。较低时终端文字也会变淡;部分 Linux 桌面环境可能不支持。',
// 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.unnamed': '未命名分组',
'vault.groups.delete': '删除分组',
'vault.groups.createSubfolder': '创建子分组',
'vault.groups.createRoot': '创建根分组',
'vault.groups.createDialog.desc': '创建新的分组用于组织主机。',
'vault.groups.renameDialogTitle': '重命名分组',
'vault.groups.renameDialog.desc': '重命名已有分组。',
'vault.groups.deleteDialogTitle': '删除分组',
'vault.groups.deleteDialog.desc': '这将永久删除该分组并将所有主机移动到根级别。',
'vault.groups.deleteDialog.managedDesc': '这是一个托管的 SSH config 分组。删除后将同时删除所有主机并断开与源文件的连接。',
'vault.groups.deleteDialog.deleteHosts': '同时删除该分组下的所有主机',
'vault.groups.ungrouped': '未分组',
'vault.groups.field.name': '分组名称',
'vault.groups.placeholder.example': '例如Production',
'vault.groups.parentLabel': '父级',
'vault.groups.pathLabel': '路径',
'vault.groups.settings': '分组设置',
'vault.groups.details': '分组详情',
'vault.groups.details.general': '常规',
'vault.groups.details.ssh': 'SSH',
'vault.groups.details.telnet': 'Telnet',
'vault.groups.details.advanced': '高级',
'vault.groups.details.appearance': '外观',
'vault.groups.details.mosh': 'Mosh',
'vault.groups.details.parentGroup': '父分组',
'vault.groups.details.none': '无',
'vault.groups.details.inherited': '继承自分组',
'vault.groups.details.addProtocol': '添加协议',
'vault.groups.details.removeProtocol': '移除协议',
'vault.groups.details.fontFamily': '字体',
'vault.groups.details.fontSize': '字号',
'vault.groups.errors.required': '分组名称不能为空。',
'vault.groups.errors.invalidChars': "分组名称不能包含 '/' 或 '\\\\'.",
'vault.groups.errors.duplicatePath': '该位置已存在同名分组。',
'vault.managedSource.unmanage': '取消托管',
'vault.managedSource.unmanageSuccess': '已取消托管分组',
'vault.hosts.header.entries': '{count} 条',
'vault.hosts.header.live': '{count} 个在线',
// Vault hosts header/actions
'vault.hosts.search.placeholder': '查找主机或 ssh user@hostname / ssh -p 2222 user@hostname…',
'vault.hosts.connect': '连接',
'vault.view.grid': '网格',
'vault.view.list': '列表',
'vault.view.tree': '树形',
'vault.tree.expandAll': '展开全部',
'vault.tree.collapseAll': '折叠全部',
'vault.hosts.newHost': '新建主机',
'vault.hosts.newGroup': '新建分组',
'vault.hosts.import': '导入',
'vault.hosts.export': '导出',
'vault.hosts.export.toast.success': '已导出 {count} 个主机到 CSV',
'vault.hosts.export.toast.successWithSkipped': '已导出 {count} 个主机到 CSV跳过 {skipped} 个不支持的主机)',
'vault.hosts.export.toast.noHosts': '没有主机可导出',
'vault.hosts.allHosts': '全部主机',
'vault.hosts.pinned': '已置顶',
'vault.hosts.recentlyConnected': '最近连接',
'vault.hosts.pinToTop': '置顶',
'vault.hosts.unpin': '取消置顶',
'vault.hosts.copyCredentials': '复制账密信息',
'vault.hosts.copyCredentials.toast.success': '账密信息已复制到剪贴板',
'vault.hosts.copyCredentials.toast.noPassword': '该主机未保存密码',
'vault.hosts.multiSelect': '多选',
'vault.hosts.selected': '已选择 {count} 项',
'vault.hosts.selectAll': '全选',
'vault.hosts.deselectAll': '取消全选',
'vault.hosts.deleteSelected': '删除 ({count})',
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
'vault.hosts.connectSelected': '连接 ({count})',
'vault.hosts.connectMultiple.success': '正在连接 {count} 个主机',
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
'vault.hosts.errors.nameRequired': '主机名称不能为空。',
'vault.hosts.empty.title': '设置你的主机',
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
// 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.list': '收藏路径',
'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.viewMode.switchToList': '切换到列表视图',
'sftp.viewMode.switchToTree': '切换到树形视图',
'sftp.tree.loadError': '加载目录失败',
'sftp.tree.loading': '加载中...',
'sftp.kind.folder': '文件夹',
'sftp.context.rename': '重命名',
'sftp.context.permissions': '权限',
'sftp.context.delete': '删除',
'sftp.context.refresh': '刷新',
'sftp.context.uploadFiles': '上传文件...',
'sftp.context.uploadFilesHere': '上传文件到这里...',
'sftp.context.uploadFolder': '上传文件夹...',
'sftp.context.uploadFolderHere': '上传文件夹到这里...',
'sftp.context.downloadSelected': '下载选中项({count}',
'sftp.context.deleteSelected': '删除选中项({count}',
'sftp.dropFilesHere': '拖拽文件到这里',
'sftp.itemsCount': '{count} 个项目',
'sftp.selectedCount': '已选 {count} 个',
'sftp.path.doubleClickToEdit': '双击编辑路径',
'sftp.showHiddenPaths': '隐藏的路径',
'sftp.task.waiting': '等待中...',
'sftp.transfer.preparing': '准备中...',
'sftp.status.loading': '加载中...',
'sftp.status.uploading': '上传中...',
'sftp.status.ready': '就绪',
'sftp.transfers': '传输',
'sftp.transfers.active': '{count} 个进行中',
'sftp.transfers.clearCompleted': '清除已完成',
'sftp.transfers.calculatingTotal': '正在统计总大小...',
'sftp.transfers.filesCount': '{count} 个文件',
'sftp.transfers.filesProgress': '{current}/{total} 个文件',
'sftp.transfers.expandChildren': '展开文件',
'sftp.transfers.collapseChildren': '收起文件',
'sftp.transfers.expandChildList': '展开详情',
'sftp.transfers.collapseChildList': '收起',
'sftp.transfers.retryAction': '重试',
'sftp.transfers.dismissAction': '移除',
'sftp.transfers.openTargetFolder': '打开目标目录',
'sftp.transfers.openTargetFolderError': '无法打开目标目录',
'sftp.transfers.copyTargetPath': '复制目标路径',
'sftp.transfers.copyTargetPathSuccess': '已复制目标路径',
'sftp.transfers.copyTargetPathError': '无法复制目标路径',
'sftp.transfers.resizeNameColumn': '调整文件名列宽',
'sftp.transfers.dragToResize': '拖拽调整高度',
'sftp.goUp': '上一级',
'sftp.goToTerminalCwd': '定位到终端当前目录',
'sftp.followTerminalCwd': '追随终端目录',
'sftp.followTerminalCwd.enable': '开启追随终端目录',
'sftp.followTerminalCwd.disable': '关闭追随终端目录',
'sftp.encoding.label': '文件名编码',
'sftp.encoding.auto': '自动',
'sftp.encoding.utf8': 'UTF-8',
'sftp.encoding.gb18030': 'GB18030',
'sftp.goHome': '返回主目录',
'sftp.folderName': '文件夹名称',
'sftp.folderName.placeholder': '输入文件夹名称',
'sftp.fileName': '文件名称',
'sftp.fileName.placeholder': '输入文件名称',
'sftp.prompt.newFolderName': '新建文件夹名称?',
'sftp.rename.title': '重命名',
'sftp.rename.newName': '新名称',
'sftp.rename.placeholder': '输入新名称',
'sftp.confirm.deleteOne': '删除 "{name}"',
'sftp.deleteConfirm.single': '删除 "{name}"',
'sftp.deleteConfirm.title': '删除 {count} 个项目?',
'sftp.deleteConfirm.desc': '此操作不可撤销,将删除以下内容:',
'sftp.deleteConfirm.descSingle': '此操作不可撤销。',
'sftp.deleteConfirm.host': '主机',
'sftp.deleteConfirm.path': '路径',
'sftp.error.loadFailed': '加载目录失败',
'sftp.error.downloadFailed': '下载失败',
'sftp.error.uploadFailed': '上传失败',
'sftp.error.deleteFailed': '删除失败',
'sftp.error.createFolderFailed': '创建文件夹失败',
'sftp.error.createFileFailed': '创建文件失败',
'sftp.error.invalidFileName': '文件名包含非法字符:{chars}',
'sftp.error.reservedName': '此文件名是系统保留名称',
'sftp.overwrite.title': '文件已存在',
'sftp.overwrite.desc': '名为"{name}"的文件已存在。是否要替换它?',
'sftp.overwrite.confirm': '替换',
'sftp.error.renameFailed': '重命名失败',
'sftp.picker.title': '选择主机',
'sftp.picker.desc': '为{side}窗格选择主机',
'sftp.picker.searchPlaceholder': '搜索主机...',
'sftp.picker.local.title': '本地文件系统',
'sftp.picker.local.desc': '浏览本地文件',
'sftp.picker.local.badge': '本地',
'sftp.picker.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,181 @@
import type { Messages } from '../types';
export const zhCnSystemManagerMessages: Messages = {
'terminal.layer.system': '系统',
'systemManager.noSession': '没有活动的终端会话。',
'systemManager.notConnected': '请先连接到主机以管理进程与服务。',
'systemManager.empty': '暂无数据。',
'systemManager.tabs.processes': '进程',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.popup.loading': '正在打开终端…',
'systemManager.popup.startupFailed': '启动命令未成功。请确认目标仍然可用后重试。',
'systemManager.errors.loadProcesses': '加载进程列表失败',
'systemManager.errors.loadTmux': '加载 tmux 会话失败',
'systemManager.errors.loadTmuxWindows': '加载 tmux 窗口失败',
'systemManager.errors.loadTmuxPanes': '加载 tmux 面板失败',
'systemManager.errors.loadTmuxClients': '加载 tmux 客户端失败',
'systemManager.errors.actionFailed': '操作失败',
'systemManager.errors.loadDocker': '加载容器列表失败',
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
'systemManager.processes.search': '搜索进程…',
'systemManager.processes.command': '命令',
'systemManager.processes.user': '用户',
'systemManager.processes.term': '终止',
'systemManager.processes.kill': '强杀',
'systemManager.processes.stop': '暂停 (SIGSTOP)',
'systemManager.processes.cont': '继续 (SIGCONT)',
'systemManager.processes.hup': '挂断 (SIGHUP)',
'systemManager.processes.renice': '调整优先级',
'systemManager.processes.renicePrompt': 'Nice 值 (-20 到 19)',
'systemManager.processes.reniceInvalid': 'Nice 值必须在 -20 到 19 之间',
'systemManager.processes.confirmKill': '向进程 {{pid}} 发送 SIGKILL',
'systemManager.processes.confirmSignal': '向进程 {{pid}} 发送 SIG{{signal}}',
'systemManager.processes.filter.all': '全部',
'systemManager.processes.filter.running': '运行中',
'systemManager.processes.ppid': '父进程 PID',
'systemManager.processes.rss': '物理内存',
'systemManager.processes.vsz': '虚拟内存',
'systemManager.processes.elapsed': '运行时长',
'systemManager.processes.stat': '状态',
'systemManager.processes.meta': '{{count}} 个进程',
'systemManager.processes.loading': '正在加载进程…',
'systemManager.processes.loadingMore': '正在显示更多进程…',
'systemManager.processes.state.running': '运行中',
'systemManager.processes.state.sleeping': '睡眠',
'systemManager.processes.state.stopped': '已暂停',
'systemManager.processes.state.zombie': '僵尸',
'systemManager.processes.sort.cpu': 'CPU',
'systemManager.processes.sort.mem': '内存',
'systemManager.processes.sort.pid': 'PID',
'systemManager.processes.sort.command': '命令',
'systemManager.processes.sort.user': '用户',
'systemManager.common.dismiss': '关闭',
'systemManager.common.checkingAvailability': '正在检查可用状态…',
'systemManager.common.loading': '正在加载…',
'systemManager.common.loadingDetails': '正在加载详情…',
'systemManager.common.loadingStats': '正在加载性能数据…',
'systemManager.tmux.new': '新建',
'systemManager.tmux.search': '搜索会话…',
'systemManager.tmux.newSessionTitle': '新建 tmux 会话',
'systemManager.tmux.newSessionDesc': '为会话命名,并可选在启动时执行脚本。',
'systemManager.tmux.newSessionTabCustom': '自定义命令',
'systemManager.tmux.newSessionTabSnippet': '从代码片段',
'systemManager.tmux.pickSnippet': '从代码片段选择',
'systemManager.tmux.pickSnippetEmpty': '暂无代码片段,可在脚本侧栏或仓库中添加。',
'systemManager.tmux.selectedSnippet': '已选片段:{{label}}',
'systemManager.tmux.newSessionName': '会话名称',
'systemManager.tmux.newSessionCommand': '启动命令',
'systemManager.tmux.newSessionCommandPlaceholder': '例如 htop 或 npm run dev可选',
'systemManager.tmux.newSessionCommandHint': '留空则创建默认 shell 会话。',
'systemManager.tmux.creating': '创建中…',
'systemManager.tmux.newSessionPlaceholder': 'my-session',
'systemManager.tmux.newSessionRequired': '请先输入会话名称',
'systemManager.tmux.empty': '没有 tmux 会话',
'systemManager.tmux.attach': '附加',
'systemManager.tmux.attached': '已附加',
'systemManager.tmux.detached': '未附加',
'systemManager.tmux.windows': '{{count}} 个窗口',
'systemManager.tmux.created': '创建时间',
'systemManager.tmux.activity': '活动时间',
'systemManager.tmux.rename': '重命名',
'systemManager.tmux.detach': '全部分离',
'systemManager.tmux.killSession': '结束会话',
'systemManager.tmux.killServer': '结束 tmux 服务',
'systemManager.tmux.loadingDetails': '正在加载详情…',
'systemManager.tmux.clients': '已附加客户端',
'systemManager.tmux.windowList': '窗口',
'systemManager.tmux.newWindow': '新建窗口',
'systemManager.tmux.newWindowPlaceholder': '窗口名称(可选)',
'systemManager.tmux.noWindows': '没有窗口',
'systemManager.tmux.unavailable': '此主机未检测到 tmux',
'systemManager.docker.unavailable': '此主机未检测到 Docker',
'systemManager.tmux.windowsMismatch': '会话显示有 {{count}} 个窗口,但 list-windows 未返回任何窗口',
'systemManager.tmux.lastCommand': '最后执行的命令:{{command}}',
'systemManager.tmux.noPanes': '没有面板',
'systemManager.tmux.panes': '{{count}} 个面板',
'systemManager.tmux.active': '当前',
'systemManager.tmux.unnamedWindow': '未命名窗口',
'systemManager.tmux.unnamedPane': '未命名面板',
'systemManager.tmux.attachWindow': '附加到窗口',
'systemManager.tmux.selectWindow': '选中窗口',
'systemManager.tmux.killWindow': '关闭窗口',
'systemManager.tmux.killPane': '关闭面板',
'systemManager.tmux.splitHorizontal': '水平分屏',
'systemManager.tmux.splitVertical': '垂直分屏',
'systemManager.tmux.sendKeys': '发送按键',
'systemManager.tmux.sendKeysTo': '向窗口 {{window}} 面板 {{pane}} 发送按键',
'systemManager.tmux.sendKeysPlaceholder': '命令或文本…',
'systemManager.tmux.renameSessionPrompt': '重命名会话',
'systemManager.tmux.renameWindowPrompt': '重命名窗口',
'systemManager.tmux.windowName': '窗口名称',
'systemManager.tmux.confirmKillSession': '确定结束 tmux 会话「{{name}}」?',
'systemManager.tmux.confirmDetachSession': '确定将所有客户端从「{{name}}」分离?',
'systemManager.tmux.confirmKillWindow': '确定关闭窗口「{{name}}」?',
'systemManager.tmux.confirmKillPane': '确定关闭面板 #{{index}}',
'systemManager.tmux.confirmKillServer': '确定结束 tmux 服务?所有会话将被终止。',
'systemManager.tmux.meta': '{{count}} 个会话',
'systemManager.docker.title': '容器',
'systemManager.docker.subTabs.containers': '容器',
'systemManager.docker.subTabs.images': '镜像',
'systemManager.docker.empty': '未找到容器',
'systemManager.docker.imagesEmpty': '未找到镜像',
'systemManager.docker.search': '搜索容器…',
'systemManager.docker.searchImages': '搜索镜像…',
'systemManager.docker.filter.all': '全部',
'systemManager.docker.filter.running': '运行中',
'systemManager.docker.filter.stopped': '已停止',
'systemManager.docker.filter.paused': '已暂停',
'systemManager.docker.shell': 'Shell',
'systemManager.docker.logs': '日志',
'systemManager.docker.details': '详情',
'systemManager.docker.inspect': 'Inspect',
'systemManager.docker.imageInspect': '镜像 Inspect',
'systemManager.docker.confirmRemove': '确定删除此容器?',
'systemManager.docker.confirmKill': '确定强制终止此容器?',
'systemManager.docker.confirmRemoveImage': '确定删除镜像「{{name}}」?',
'systemManager.docker.confirmPrune': '确定清理悬空镜像?',
'systemManager.docker.confirmPruneAll': '确定清理所有未使用镜像?',
'systemManager.docker.pause': '暂停',
'systemManager.docker.unpause': '恢复',
'systemManager.docker.restart': '重启',
'systemManager.docker.kill': '强杀',
'systemManager.docker.renamePrompt': '容器名称',
'systemManager.docker.prune': '清理悬空',
'systemManager.docker.pruneAll': '清理全部',
'systemManager.docker.tag': '打标签',
'systemManager.docker.tagRepoPrompt': '仓库名',
'systemManager.docker.tagNamePrompt': '标签名',
'systemManager.docker.meta': '{{count}} 个容器',
'systemManager.docker.imagesMeta': '{{count}} 个镜像',
'systemManager.docker.start': '启动',
'systemManager.docker.stop': '停止',
'systemManager.inspect.status': '状态',
'systemManager.inspect.image': '镜像',
'systemManager.inspect.created': '创建时间',
'systemManager.inspect.started': '启动时间',
'systemManager.inspect.restartPolicy': '重启策略',
'systemManager.inspect.command': '启动命令',
'systemManager.inspect.ports': '端口映射',
'systemManager.inspect.networks': '网络',
'systemManager.inspect.mounts': '挂载',
'systemManager.inspect.env': '环境变量',
'systemManager.inspect.labels': '标签',
'systemManager.inspect.tags': '镜像标签',
'systemManager.inspect.digests': '摘要',
'systemManager.inspect.size': '大小',
'systemManager.inspect.platform': '平台',
'systemManager.inspect.workdir': '工作目录',
'systemManager.inspect.exposedPorts': '暴露端口',
'systemManager.inspect.showRaw': 'JSON',
'systemManager.inspect.hideRaw': '收起 JSON',
};

View File

@@ -0,0 +1,682 @@
import type { Messages } from '../types';
export const zhCNTerminalMessages: Messages = {
'terminal.sudoHint.pressEnter': '按 Enter 粘贴 sudo 密码',
'terminal.toolbar.timestampsEnable': '显示时间戳',
'terminal.toolbar.timestampsDisable': '隐藏时间戳',
'terminal.connection.protocol.et': 'EternalTerminal',
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支持 Netcatty 的代理设置。请改用 SSH或移除该主机的代理。',
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支持一个跳板机。',
// Command history side panel
'history.scope.label': '历史范围',
'history.tab.host': '主机',
'history.tab.global': '全局',
'history.searchPlaceholder': '搜索历史命令...',
'history.loading': '正在读取远程历史...',
'history.meta.count': '{count} 条',
'history.empty.noSession': '请先打开一个远程会话以查看其命令历史。',
'history.empty.unsupportedProtocol': '仅 SSH/Mosh/ET 会话支持命令历史。',
'history.empty.noHistory': '该主机上未找到命令历史。',
'history.empty.noGlobalHistory': '暂无全局命令历史。你执行的命令会记录在这里。',
'history.action.refresh': '刷新',
'history.action.retry': '重试',
'history.action.paste': '粘贴到终端',
'history.action.run': '在终端执行',
'history.action.saveAsSnippet': '保存为代码片段',
// SFTP File Opener
'sftp.context.copyPath': '复制文件路径',
'sftp.context.openWith': '打开方式...',
'sftp.context.edit': '编辑',
'sftp.context.preview': '预览',
'sftp.opener.title': '打开方式',
'sftp.opener.desc': '选择一个应用程序来打开此文件',
'sftp.opener.builtInEditor': '内置编辑器',
'sftp.opener.editDescription': '编辑文本文件',
'sftp.opener.builtInImageViewer': '内置图片预览',
'sftp.opener.previewDescription': '预览图片',
'sftp.opener.systemApp': '选择应用程序...',
'sftp.opener.systemAppDescription': '从本地选择一个应用程序',
'sftp.opener.onlySystemApp': '此文件只能用外部应用程序打开',
'sftp.opener.noAppsAvailable': '无可用应用程序',
'sftp.opener.noExtension': '无扩展名文件',
'sftp.opener.setDefault': '始终使用此方式打开 {ext} 文件',
'sftp.opener.confirmTitle': '设为默认?',
'sftp.opener.confirmDescription': '是否始终使用 {app} 打开 {ext} 文件?',
'sftp.opener.yesRemember': '是,记住此选择',
'sftp.opener.justOnce': '仅此一次',
'sftp.opener.confirm.title': '设置默认应用程序',
'sftp.opener.confirm.desc': '是否始终使用此应用程序打开 .{ext} 文件?',
'sftp.editor.title': '文本编辑器',
'sftp.editor.save': '保存到远程',
'sftp.editor.saving': '保存中...',
'sftp.editor.saved': '保存成功',
'sftp.editor.saveFailed': '保存文件失败',
'sftp.editor.unsavedChanges': '您有未保存的更改。确定要关闭吗?',
'sftp.editor.syntaxHighlight': '语法高亮',
'sftp.preview.title': '图片预览',
'sftp.preview.zoomIn': '放大',
'sftp.preview.zoomOut': '缩小',
'sftp.preview.resetZoom': '重置缩放',
'sftp.preview.fitToWindow': '适应窗口',
// Settings > SFTP File Associations
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftp.transferConcurrency': '传输并发数',
'settings.sftp.transferConcurrency.desc': '上传或下载文件夹时并行传输的文件数量。较高的值可能提高速度,但可能导致某些服务器过载。',
'settings.sftp.defaultOpener': '默认文件打开方式',
'settings.sftp.defaultOpener.desc': '选择没有特定文件关联时的默认打开方式',
'settings.sftp.defaultOpener.ask': '每次询问',
'settings.sftp.defaultOpener.askDesc': '每次打开文件时弹出选择对话框',
'settings.sftp.defaultOpener.builtInDesc': '默认使用内置编辑器打开文本文件',
'settings.sftp.defaultOpener.systemApp': '选择应用程序...',
'settings.sftp.defaultOpener.systemAppDesc': '默认使用指定的外部应用程序打开文件',
'settings.sftpFileAssociations.title': 'SFTP 文件关联',
'settings.sftpFileAssociations.desc': '配置按扩展名打开文件的默认应用程序',
'settings.sftpFileAssociations.extension': '扩展名',
'settings.sftpFileAssociations.application': '应用程序',
'settings.sftpFileAssociations.noAssociations': '未配置文件关联',
'settings.sftpFileAssociations.remove': '移除',
'settings.sftpFileAssociations.removeConfirm': '确定移除 .{ext} 的关联吗?',
// Settings > SFTP Behavior
'settings.sftp.doubleClickBehavior': '双击行为',
'settings.sftp.doubleClickBehavior.desc': '选择在 SFTP 视图中双击文件时的操作',
'settings.sftp.doubleClickBehavior.open': '打开文件',
'settings.sftp.doubleClickBehavior.transfer': '传输到另一侧',
'settings.sftp.doubleClickBehavior.openDesc': '使用默认应用程序打开文件',
'settings.sftp.doubleClickBehavior.transferDesc': '将文件传输到另一窗格的活动主机',
// Settings > SFTP Auto Sync
'settings.sftp.autoSync': '自动同步到远程',
'settings.sftp.autoSync.desc': '使用外部应用程序打开文件时,自动将文件更改同步回远程服务器',
'settings.sftp.autoSync.enable': '启用自动同步',
'settings.sftp.autoSync.enableDesc': '在外部应用程序中保存文件时,更改将自动上传到远程服务器',
// Settings > SFTP 自动打开侧栏
'settings.sftp.autoOpenSidebar': '连接时自动打开侧栏',
'settings.sftp.autoOpenSidebar.desc': '连接到主机时自动打开 SFTP 文件浏览器侧栏',
'settings.sftp.autoOpenSidebar.enable': '启用自动打开侧栏',
'settings.sftp.autoOpenSidebar.enableDesc': '当终端会话连接到远程主机时SFTP 侧栏将自动打开',
'settings.sftp.followTerminalCwd': '追随终端目录',
'settings.sftp.followTerminalCwd.desc': '在侧栏 SFTP 中自动跟随终端当前工作目录变化(可在工具栏切换)',
'settings.sftp.followTerminalCwd.enable': '默认开启追随终端目录',
'settings.sftp.followTerminalCwd.enableDesc': '打开侧栏 SFTP 时默认启用追随模式,终端执行 cd 后文件浏览器会自动跳转',
'settings.sftp.defaultViewMode': '默认视图模式',
'settings.sftp.defaultViewMode.desc': '选择打开新 SFTP 标签页时的默认视图模式。每个主机的偏好设置会覆盖此全局设置。',
'settings.sftp.defaultViewMode.list': '列表视图',
'settings.sftp.defaultViewMode.listDesc': '以平面列表显示当前目录的文件',
'settings.sftp.defaultViewMode.tree': '树形视图',
'settings.sftp.defaultViewMode.treeDesc': '以层级树形结构显示文件',
'sftp.autoSync.success': '文件已同步到远程:{fileName}',
'sftp.autoSync.error': '同步文件失败:{error}',
// SFTP Folder Upload Progress
'sftp.upload.progress': '正在上传 {current}/{total} 个文件...',
'sftp.upload.uploading': '正在上传...',
'sftp.upload.compressing': '正在压缩...',
'sftp.upload.extracting': '正在解压...',
'sftp.upload.scanning': '正在扫描文件...',
'sftp.upload.completed': '已完成',
'sftp.upload.compressed': '压缩传输',
'sftp.upload.currentFile': '当前: {fileName}',
'sftp.upload.cancelled': '上传已取消',
'sftp.upload.cancel': '取消',
'sftp.upload.completedToPath': '已上传至 {path}',
// SFTP Download
'sftp.download.completed': '已下载',
'sftp.download.cancelled': '下载已取消',
// SFTP Reconnecting
'sftp.reconnecting.title': '正在重连...',
'sftp.reconnecting.desc': '连接已断开,正在尝试重新连接',
'sftp.reconnected': '连接已恢复',
'sftp.error.reconnectFailed': '重连失败,请重试。',
'sftp.error.connectionLostManual': '连接已断开,请手动重新连接。',
'sftp.error.connectionLostReconnecting': '连接已断开,正在重连...',
'sftp.error.sessionLost': 'SFTP 会话已断开,请重新连接。',
// Settings > SFTP Show Hidden Files
'settings.sftp.showHiddenFiles': '显示隐藏文件',
'settings.sftp.showHiddenFiles.desc': '在 SFTP 文件浏览器中显示隐藏文件Unix/macOS 点文件和 Windows 隐藏属性文件)。',
'settings.sftp.showHiddenFiles.enable': '显示隐藏文件',
'settings.sftp.showHiddenFiles.enableDesc': '浏览本地和远程文件系统时显示隐藏文件',
// Settings > SFTP Compressed Upload
'settings.sftp.compressedUpload': '文件夹压缩传输',
'settings.sftp.compressedUpload.desc': '上传前压缩文件夹,可大幅减少传输时间。',
'settings.sftp.compressedUpload.enable': '启用文件夹压缩',
'settings.sftp.compressedUpload.enableDesc': '自动使用 tar 压缩文件夹后再传输。需要服务器支持 tar 命令,不支持时自动回退到普通传输。',
// Settings > Terminal
'settings.terminal.section.theme': '终端主题',
'settings.terminal.themeModal.title': '选择主题',
'settings.terminal.themeModal.darkThemes': '深色主题',
'settings.terminal.themeModal.lightThemes': '浅色主题',
'settings.terminal.theme.selectButton': '选择主题',
'settings.terminal.theme.followApp': '跟随应用主题',
'settings.terminal.theme.followApp.desc': '终端背景色自动匹配当前应用主题,保持视觉一致性。',
'settings.terminal.theme.darkTheme': '深色模式终端主题',
'settings.terminal.theme.lightTheme': '浅色模式终端主题',
'settings.terminal.theme.auto': '自动(跟随界面主题)',
'settings.terminal.theme.autoDesc': '跟随当前界面主题预设',
'settings.terminal.section.font': '字体',
'settings.terminal.section.cursor': '光标',
'settings.terminal.section.keyboard': '键盘',
'settings.terminal.section.accessibility': '无障碍',
'settings.terminal.section.behavior': '行为',
'settings.terminal.section.scrollback': '回滚',
'settings.terminal.section.keywordHighlight': '关键字高亮',
'settings.terminal.font.family': '字体',
'settings.terminal.font.family.desc': '终端字体',
'settings.terminal.font.cjk': '中文 / CJK 字体',
'settings.terminal.font.cjk.desc': '用于渲染中 / 日 / 韩字符的字体;"Auto" 会按主字体智能搭配',
'settings.terminal.font.cjk.option.auto': 'Auto · 按主字体智能搭配',
'settings.terminal.font.cjk.option.sarasaSC': 'Sarasa Mono SC (更纱黑体 简)',
'settings.terminal.font.cjk.option.sarasaTC': 'Sarasa Mono TC (更纱黑体 繁)',
'settings.terminal.font.cjk.option.mapleCN': 'Maple Mono CN',
'settings.terminal.font.cjk.option.sourceHan': 'Source Han Mono SC (思源等宽)',
'settings.terminal.font.cjk.option.notoCJK': 'Noto Sans Mono CJK SC',
'settings.terminal.font.cjk.option.lxgwWenkai': 'LXGW WenKai Mono (霞鹜文楷等宽)',
'settings.terminal.font.cjk.option.simSun': 'SimSun (宋体)',
'settings.terminal.font.cjk.option.legacy': '{font} · 不推荐(非等宽字体)',
'settings.terminal.font.size': '字体大小',
'settings.terminal.font.size.desc': '终端文字大小',
'settings.terminal.font.weight': '字重',
'settings.terminal.font.weight.desc': '常规文本字重 (100-900)',
'settings.terminal.font.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.section.systemManager': '系统管理',
'settings.terminal.systemManager.processRefreshInterval': '进程列表刷新间隔',
'settings.terminal.systemManager.processRefreshInterval.desc': '系统管理侧栏中进程列表的刷新频率。',
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 会话刷新间隔',
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 会话列表的刷新频率。',
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表刷新间隔',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的刷新频率。',
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 性能数据刷新间隔',
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/内存/网络指标的刷新频率。',
// Settings > Terminal > Rendering
'settings.terminal.section.rendering': '渲染',
'settings.terminal.rendering.renderer': '渲染器',
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 DOM 渲染。更改将在新终端会话中生效。',
'settings.terminal.rendering.auto': '自动',
// Settings > Terminal > 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.shellOnlyTabNumberShortcuts.label': '数字键跳过固定标签',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '开启后Cmd/Ctrl+[1...9] 仅在终端、工作区、编辑器等可关闭标签页之间切换,不包括固定的 Vault 和 SFTP 标签页。',
'settings.shortcuts.section.custom': '自定义快捷键',
'settings.shortcuts.resetAll': '全部重置',
'settings.shortcuts.recording': '请按键...',
'settings.shortcuts.none': '无',
'settings.shortcuts.setDisabled': '设为禁用',
'settings.shortcuts.category.tabs': '标签页',
'settings.shortcuts.category.terminal': '终端',
'settings.shortcuts.category.navigation': '导航',
'settings.shortcuts.category.app': '应用',
'settings.shortcuts.category.sftp': 'SFTP',
'settings.shortcuts.binding.switch-tab-1-9': '切换到标签页 [1...9]',
'settings.shortcuts.binding.next-tab': '下一个标签页',
'settings.shortcuts.binding.prev-tab': '上一个标签页',
'settings.shortcuts.binding.close-tab': '关闭标签页',
'settings.shortcuts.binding.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.copyTabToNewWindow': '复制标签页到新窗口',
'tabs.copyTabToNewWindowFailed': '无法在新窗口打开标签页',
'tabs.closeOthers': '关闭其他标签',
'tabs.closeToRight': '关闭右侧标签',
'tabs.closeAll': '关闭所有标签',
'keychain.edit.labelRequired': 'Label *',
'keychain.edit.keyLabelPlaceholder': '密钥 Label',
'keychain.edit.privateKeyRequired': '私钥 *',
'keychain.edit.publicKey': '公钥',
'keychain.edit.certificate': '证书',
'keychain.edit.certificatePlaceholder': '证书内容(可选)',
'keychain.edit.filePath': '文件路径',
'keychain.edit.keyExport': '密钥导出',
'keychain.edit.exportToHost': '导出到主机',
// Snippets
'snippets.searchPlaceholder': '搜索代码片段...',
'snippets.action.newSnippet': '新建代码片段',
'snippets.action.newPackage': '新建代码包',
'snippets.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,700 @@
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.lineTimestamps': '显示输出时间',
'hostDetails.lineTimestamps.desc': '在终端输出行旁边显示本地时间,不改变终端文本内容。',
'hostDetails.legacyAlgorithms': '允许旧版算法',
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
'hostDetails.skipEcdsaHostKey': '跳过 ECDSA 主机密钥',
'hostDetails.skipEcdsaHostKey.desc': '某些老款华为 / 思科交换机的 ECDSA 主机密钥签名不规范,会导致连接报 "signature verification failed"。开启后客户端不再 advertise ecdsa-sha2-*,强制使用 RSA / Ed25519。',
'hostDetails.algorithms.advanced': '高级算法配置',
'hostDetails.algorithms.advanced.desc': '针对单个 host 自定义各分类的算法清单。不勾选 = 使用默认;勾选子集后将完全替换默认列表。配置错误可能导致无法连接。',
'hostDetails.algorithms.inheritedNotice': '当前组已设置以下分类的算法 override{categories}。本面板的"恢复默认"只会回到组的设置,而不是 NetCatty 默认列表。若要忽略组的限制,请到组的算法设置里取消。',
'hostDetails.algorithms.customized': '已自定义',
'hostDetails.algorithms.reset': '恢复默认',
'hostDetails.algorithms.category.kex': '密钥交换 (KEX)',
'hostDetails.algorithms.category.cipher': '加密算法 (Cipher)',
'hostDetails.algorithms.category.hmac': '完整性算法 (HMAC)',
'hostDetails.algorithms.category.serverHostKey': '主机密钥 (Host Key)',
'hostDetails.algorithms.category.compress': '压缩 (Compression)',
'hostDetails.section.keepalive': '会话保活',
'hostDetails.keepalive.override': '为此主机单独配置',
'hostDetails.keepalive.desc': '为该主机使用专属的保活策略,而不是跟随全局设置。适用于不响应 keepalive@openssh.com 请求的老旧路由器 / 交换机——将间隔设为 0 可对该主机彻底关闭保活。',
'hostDetails.keepalive.interval': '间隔(秒)',
'hostDetails.keepalive.countMax': '最大无响应保活次数',
'hostDetails.keepalive.disabledHint': '间隔为 0 时该主机不发送保活包,仅依赖 TCP 层超时检测断连。',
'hostDetails.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.sendYmodem': 'YMODEM 发送',
'terminal.toolbar.sftp': 'SFTP',
'terminal.toolbar.more': '更多操作',
'terminal.toolbar.scripts': '脚本',
'terminal.toolbar.history': '命令历史',
'terminal.toolbar.library': '库',
'terminal.toolbar.noSnippets': '暂无代码片段',
'terminal.toolbar.terminalSettings': '终端设置',
'terminal.toolbar.searchTerminal': '搜索终端',
'terminal.toolbar.search': '搜索',
'terminal.toolbar.broadcast': '广播',
'terminal.toolbar.broadcastEnable': '启用广播模式',
'terminal.toolbar.broadcastDisable': '关闭广播模式',
'terminal.toolbar.composeBar': '撰写栏',
'terminal.composeBar.placeholder': '在此输入命令,按回车发送...',
'terminal.composeBar.send': '发送',
'terminal.composeBar.close': '关闭撰写栏',
'terminal.composeBar.broadcasting': '正在广播到所有会话',
'terminal.composeBar.resize': '拖拽调整撰写栏高度',
'terminal.composeBar.manageSnippets': '管理快捷代码片段',
'terminal.composeBar.searchSnippets': '搜索代码片段...',
'terminal.composeBar.noPinnedSnippets': '点击 + 固定常用代码片段',
'terminal.composeBar.noMatchingSnippets': '没有匹配的代码片段',
'terminal.composeBar.pinnedCount': '已固定 {count} 个',
'terminal.composeBar.unpinSnippet': '从快捷栏移除 {label}',
'terminal.composeBar.snippetClickHint': '单击插入 · Shift+单击直接发送',
'terminal.toolbar.focus': '聚焦',
'terminal.toolbar.focusMode': '聚焦模式',
'terminal.toolbar.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.addSelectionToAI': '添加到对话',
'terminal.menu.pasteSelection': '粘贴选中文本',
'terminal.menu.selectAll': '全选',
'terminal.menu.reconnect': '重新连接',
'terminal.menu.sendYmodem': 'YMODEM 发送',
'terminal.menu.splitHorizontal': '水平分屏',
'terminal.menu.splitVertical': '垂直分屏',
'terminal.menu.clearBuffer': '清空缓冲区',
'terminal.menu.closeTerminal': '关闭终端',
'terminal.ymodem.selectFile': '选择要发送的文件',
'terminal.ymodem.allFiles': '所有文件',
'terminal.ymodem.started': '正在通过 YMODEM 发送 {fileName}',
'terminal.ymodem.complete': 'YMODEM 已发送 {fileName}',
'terminal.ymodem.failed': 'YMODEM 发送失败',
'terminal.ymodem.unavailable': 'YMODEM 当前不可用',
'terminal.selection.addToAI': '添加到对话',
'terminal.selection.addToAIDesc': '将选中的终端输出作为附件加入 AI 草稿',
'terminal.auth.password': '密码',
'terminal.auth.sshKey': 'SSH Key',
'terminal.auth.username': '用户名',
'terminal.auth.username.placeholder': 'root',
'terminal.auth.passwordLabel': '密码',
'terminal.auth.password.placeholder': '输入密码',
'terminal.auth.passphrase': '密码短语',
'terminal.auth.passphrase.placeholder': '可选:所选私钥的密码短语',
'terminal.auth.certificate': '证书',
'terminal.auth.selectKey': '选择密钥',
'terminal.auth.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

@@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
test("active tab changes notify chrome theme before react subscribers", () => {
const storeSource = readFileSync(new URL("./activeTabStore.ts", import.meta.url), "utf8");
const syncSource = readFileSync(new URL("./activeChromeThemeSync.ts", import.meta.url), "utf8");
const setActiveTabIdBody = storeSource.match(/setActiveTabId = \(id: string\) => \{[\s\S]*?\n {2}\};/)?.[0] ?? "";
assert.match(setActiveTabIdBody, /this\.syncListeners\.forEach\(\(listener\) => listener\(id\)\)/);
assert.match(setActiveTabIdBody, /this\.scheduleNotify\(\)/);
assert.ok(
setActiveTabIdBody.indexOf("syncListeners.forEach") < setActiveTabIdBody.indexOf("scheduleNotify"),
"sync chrome theme listeners must run before deferred react notify",
);
assert.match(syncSource, /activeTabStore\.subscribeSync\(notifyActiveChromeThemeForTab\)/);
assert.match(syncSource, /isActiveChromeThemeResolvable/);
assert.match(syncSource, /clearTopTabsChromeThemeVars/);
});

View File

@@ -0,0 +1,39 @@
import { isActiveChromeThemeResolvable, resolveActiveChromeTheme } from '../app/activeChromeTheme';
import { clearTopTabsChromeThemeVars } from '../app/topTabsChromeTheme';
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
import { activeTabStore } from './activeTabStore';
import type { EditorTab } from './editorTabStore';
import type { LogView } from './logViewState';
import { syncActiveChromeTheme } from './useActiveChromeTheme';
export type ActiveChromeThemeDeps = {
accentMode: 'theme' | 'custom';
applyAppTheme: () => void;
currentTerminalTheme: TerminalTheme;
customAccent: string;
editorTabs: readonly EditorTab[];
followAppTerminalTheme: boolean;
hostById: Map<string, Host>;
logViews: readonly LogView[];
sessionById: Map<string, TerminalSession>;
themeById: Map<string, TerminalTheme>;
workspaceById: Map<string, Workspace>;
};
let depsRef: ActiveChromeThemeDeps | null = null;
export function updateActiveChromeThemeDeps(deps: ActiveChromeThemeDeps): void {
depsRef = deps;
}
export function notifyActiveChromeThemeForTab(activeTabId: string): void {
if (!depsRef || typeof document === 'undefined') return;
if (activeTabId === 'vault' || activeTabId === 'sftp') {
clearTopTabsChromeThemeVars();
}
if (!isActiveChromeThemeResolvable({ ...depsRef, activeTabId })) return;
const activeTheme = resolveActiveChromeTheme({ ...depsRef, activeTabId });
syncActiveChromeTheme(activeTheme, depsRef.applyAppTheme);
}
activeTabStore.subscribeSync(notifyActiveChromeThemeForTab);

View File

@@ -0,0 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { fromEditorTabId, isEditorTabId, toEditorTabId } from './activeTabStore';
test('editor tab helpers round trip ids', () => {
assert.equal(toEditorTabId('file-1'), 'editor:file-1');
assert.equal(fromEditorTabId('editor:file-1'), 'file-1');
});
test('editor tab helper detects editor top-tab ids', () => {
assert.equal(isEditorTabId('editor:file-1'), true);
assert.equal(isEditorTabId('session-1'), false);
});

View File

@@ -1,7 +1,10 @@
import { useCallback, useSyncExternalStore } from 'react';
import { terminalLayoutSuppressStore } from './terminalLayoutSuppressStore';
// Simple store for active tab that allows fine-grained subscriptions
type Listener = () => void;
type SyncListener = (activeTabId: string) => void;
// ----- Editor tab id helpers -----
export const EDITOR_PREFIX = 'editor:';
@@ -18,19 +21,37 @@ export const fromEditorTabId = (tabId: string): string => tabId.slice(EDITOR_PRE
class ActiveTabStore {
private activeTabId: string = 'vault';
private listeners = new Set<Listener>();
private pendingNotify = false;
private syncListeners = new Set<SyncListener>();
private notifyRafId: number | null = null;
getActiveTabId = () => this.activeTabId;
private scheduleNotify = () => {
if (this.notifyRafId !== null) return;
const schedule = typeof requestAnimationFrame === 'function'
? requestAnimationFrame
: (cb: () => void) => window.setTimeout(cb, 0) as unknown as number;
this.notifyRafId = schedule(() => {
this.notifyRafId = null;
this.listeners.forEach((listener) => listener());
});
};
setActiveTabId = (id: string) => {
if (this.activeTabId !== id) {
terminalLayoutSuppressStore.begin();
this.activeTabId = id;
// Defer listener notification to avoid "setState during render" if called from a render phase
if (this.pendingNotify) return;
this.pendingNotify = true;
Promise.resolve().then(() => {
this.pendingNotify = false;
this.listeners.forEach(listener => listener());
this.syncListeners.forEach((listener) => listener(id));
// Coalesce rapid tab switches into one notification per frame and avoid
// "setState during render" if called from a render phase.
this.scheduleNotify();
const schedule = typeof requestAnimationFrame === 'function'
? requestAnimationFrame
: (cb: () => void) => window.setTimeout(cb, 0) as unknown as number;
schedule(() => {
schedule(() => {
terminalLayoutSuppressStore.end();
});
});
}
};
@@ -39,6 +60,11 @@ class ActiveTabStore {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
subscribeSync = (listener: SyncListener) => {
this.syncListeners.add(listener);
return () => this.syncListeners.delete(listener);
};
}
export const activeTabStore = new ActiveTabStore();
@@ -47,7 +73,8 @@ export const activeTabStore = new ActiveTabStore();
export const useActiveTabId = () => {
return useSyncExternalStore(
activeTabStore.subscribe,
activeTabStore.getActiveTabId
activeTabStore.getActiveTabId,
activeTabStore.getActiveTabId,
);
};
@@ -59,7 +86,7 @@ export const useSetActiveTabId = () => {
// Check if a specific tab is active - only re-renders when this specific tab's active state changes
export const useIsTabActive = (tabId: string) => {
const getSnapshot = useCallback(() => activeTabStore.getActiveTabId() === tabId, [tabId]);
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot);
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot, getSnapshot);
};
// Stable snapshot functions - defined once outside components
@@ -70,7 +97,8 @@ const getIsSftpActive = () => activeTabStore.getActiveTabId() === 'sftp';
export const useIsVaultActive = () => {
return useSyncExternalStore(
activeTabStore.subscribe,
getIsVaultActive
getIsVaultActive,
getIsVaultActive,
);
};
@@ -78,7 +106,8 @@ export const useIsVaultActive = () => {
export const useIsSftpActive = () => {
return useSyncExternalStore(
activeTabStore.subscribe,
getIsSftpActive
getIsSftpActive,
getIsSftpActive,
);
};
@@ -86,17 +115,5 @@ export const useIsSftpActive = () => {
export const useIsEditorTabActive = (tabId: string): boolean => {
const editorTopId = toEditorTabId(tabId);
const getSnapshot = useCallback(() => activeTabStore.getActiveTabId() === editorTopId, [editorTopId]);
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot);
};
// 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 getSnapshot = useCallback(() => {
const activeTabId = activeTabStore.getActiveTabId();
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !isEditorTabId(activeTabId);
return isTerminalTab || !!draggingSessionId;
}, [draggingSessionId]);
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot);
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot, getSnapshot);
};

View File

@@ -10,6 +10,7 @@ import {
ensureDraftForScopeState,
getDraftMutationVersionState,
getDraftUploadGenerationState,
pruneStaleSessionPanelViews,
pruneTerminalScopeState,
pruneTerminalTransientState,
resolvePanelView,
@@ -89,6 +90,39 @@ test("setSessionView records target session id", () => {
});
});
test("pruneStaleSessionPanelViews resets session views that no longer exist", () => {
const panelViewByScope = {
"terminal:1": { mode: "session", sessionId: "deleted-session" },
"workspace:2": { mode: "session", sessionId: "session-keep" },
"terminal:3": { mode: "draft" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneStaleSessionPanelViews(
panelViewByScope,
new Set(["session-keep"]),
);
assert.deepEqual(next, {
"terminal:1": { mode: "draft" },
"workspace:2": { mode: "session", sessionId: "session-keep" },
"terminal:3": { mode: "draft" },
});
});
test("pruneStaleSessionPanelViews returns the original ref when nothing is stale", () => {
const panelViewByScope = {
"terminal:1": { mode: "session", sessionId: "session-keep" },
"terminal:2": { mode: "draft" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneStaleSessionPanelViews(
panelViewByScope,
new Set(["session-keep"]),
);
assert.equal(next, panelViewByScope);
});
test("clearScopeDraftState removes both the draft and current panel view", () => {
const draftsByScope = {
"terminal:1": createEmptyDraft("agent-alpha"),

View File

@@ -115,6 +115,25 @@ export function setSessionView(
};
}
export function pruneStaleSessionPanelViews(
panelViewByScope: PanelViewByScope,
validSessionIds: Set<string>,
): PanelViewByScope {
let next = panelViewByScope;
for (const [scopeKey, panelView] of Object.entries(panelViewByScope)) {
if (panelView?.mode !== 'session' || validSessionIds.has(panelView.sessionId)) {
continue;
}
const updated = setDraftView(next, scopeKey);
if (updated !== next) {
next = updated;
}
}
return next;
}
export function updateDraftForScope(
draftsByScope: DraftsByScope,
scopeKey: string,

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,241 @@
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
STORAGE_KEY_AI_SESSIONS,
} from '../../infrastructure/config/storageKeys';
import type {
AIDraft,
AIPanelView,
AISession,
AIPermissionMode,
AIToolIntegrationMode,
} from '../../infrastructure/ai/types';
import {
bumpDraftMutationVersionState,
bumpDraftUploadGenerationState,
getDraftUploadGenerationState,
} from './aiDraftState';
import {
pruneInactiveScopedSessions,
pruneInactiveScopedTransientState,
} from './aiScopeCleanup';
import { emitAIStateChanged } from './aiStateEvents';
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
export interface AIBridge {
aiSdkAgentCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
aiMcpSetToolIntegrationMode?: (mode: AIToolIntegrationMode) => Promise<unknown> | unknown;
aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown;
aiMcpSetCommandTimeout?: (timeout: number) => Promise<unknown> | unknown;
aiMcpSetMaxIterations?: (maxIterations: number) => Promise<unknown> | unknown;
}
export function getAIBridge() {
return (window as unknown as { netcatty?: AIBridge }).netcatty;
}
export const AI_STATE_CHANGED_DRAFTS_BY_SCOPE = 'netcatty:ai-drafts-by-scope';
export const AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE = 'netcatty:ai-panel-view-by-scope';
export type DraftsByScope = Partial<Record<string, AIDraft>>;
export type PanelViewByScope = Partial<Record<string, AIPanelView>>;
export function cleanupSdkAgentSessions(sessionIds: string[]) {
const bridge = getAIBridge();
if (!bridge?.aiSdkAgentCleanup || sessionIds.length === 0) return;
for (const sessionId of sessionIds) {
void bridge.aiSdkAgentCleanup(sessionId).catch(() => {});
}
}
function isScopeKeyActive(scopeKey: string, activeTargetIds: Set<string>) {
const separatorIndex = scopeKey.indexOf(':');
if (separatorIndex === -1) return true;
const targetId = scopeKey.slice(separatorIndex + 1);
if (!targetId) return true;
return activeTargetIds.has(targetId);
}
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
const currentSessions = latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
// Sessions shown by a still-live scope must be protected from cleanup
// even when their own `scope.targetId` points at a closed terminal —
// history can be resumed into a different terminal and we must not
// delete it outright while it's actively being used.
const preCleanupActiveSessionMap = latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {};
const activeSessionIds = new Set<string>();
for (const [scopeKey, sessionId] of Object.entries(preCleanupActiveSessionMap)) {
if (!sessionId) continue;
if (!isScopeKeyActive(scopeKey, activeTargetIds)) continue;
activeSessionIds.add(sessionId);
}
const nextSessionCleanup = pruneInactiveScopedSessions(
currentSessions,
activeTargetIds,
activeSessionIds,
);
if (nextSessionCleanup.orphanedSessionIds.length > 0) {
cleanupSdkAgentSessions(nextSessionCleanup.orphanedSessionIds);
}
if (nextSessionCleanup.sessions !== currentSessions) {
setLatestAISessionsSnapshot(nextSessionCleanup.sessions);
localStorageAdapter.write(
STORAGE_KEY_AI_SESSIONS,
pruneSessionsForStorage(nextSessionCleanup.sessions),
);
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
}
const activeSessionIdMap = preCleanupActiveSessionMap;
let activeSessionMapChanged = false;
const nextActiveSessionIdMap = { ...activeSessionIdMap };
for (const scopeKey of Object.keys(activeSessionIdMap)) {
if (isScopeKeyActive(scopeKey, activeTargetIds)) continue;
delete nextActiveSessionIdMap[scopeKey];
activeSessionMapChanged = true;
}
if (activeSessionMapChanged) {
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
const currentActiveSessionIdMap = activeSessionMapChanged
? nextActiveSessionIdMap
: activeSessionIdMap;
const currentDraftsByScope = latestAIDraftsByScopeSnapshot ?? {};
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
const prunedScopedTransientState = pruneInactiveScopedTransientState(
currentActiveSessionIdMap,
currentDraftsByScope,
currentPanelViewByScope,
activeTargetIds,
);
if (prunedScopedTransientState.activeSessionIdMap !== currentActiveSessionIdMap) {
setLatestAIActiveSessionMapSnapshot(prunedScopedTransientState.activeSessionIdMap);
localStorageAdapter.write(
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
prunedScopedTransientState.activeSessionIdMap,
);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
if (prunedScopedTransientState.draftsByScope !== currentDraftsByScope) {
for (const scopeKey of Object.keys(currentDraftsByScope)) {
if (scopeKey in prunedScopedTransientState.draftsByScope) continue;
bumpDraftMutationVersion(scopeKey);
bumpDraftUploadGeneration(scopeKey);
}
setLatestAIDraftsByScopeSnapshot(prunedScopedTransientState.draftsByScope);
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
}
if (prunedScopedTransientState.panelViewByScope !== currentPanelViewByScope) {
for (const scopeKey of Object.keys(currentPanelViewByScope)) {
if (scopeKey in prunedScopedTransientState.panelViewByScope) continue;
bumpDraftMutationVersion(scopeKey);
}
setLatestAIPanelViewByScopeSnapshot(prunedScopedTransientState.panelViewByScope);
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
}
}
/** Maximum number of sessions to keep in localStorage. */
const MAX_STORED_SESSIONS = 50;
/** Maximum number of messages per session when persisting to localStorage. */
const MAX_SESSION_MESSAGES = 200;
/**
* Prune sessions before writing to localStorage to prevent hitting the
* ~5-10 MB storage quota. Only affects what is persisted — the in-memory
* state retains all messages until the session is reloaded.
*
* - Keeps only the MAX_STORED_SESSIONS most-recently-updated sessions.
* - Trims each session's messages to the last MAX_SESSION_MESSAGES.
*/
export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
// Sort by updatedAt descending so we keep the newest
const sorted = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt);
const limited = sorted.slice(0, MAX_STORED_SESSIONS);
return limited.map(s => {
if (s.messages.length > MAX_SESSION_MESSAGES) {
return { ...s, messages: s.messages.slice(-MAX_SESSION_MESSAGES) };
}
return s;
});
}
export let latestAISessionsSnapshot: AISession[] | null = null;
export let latestAIActiveSessionMapSnapshot: Record<string, string | null> | null = null;
export let latestAIDraftsByScopeSnapshot: DraftsByScope | null = null;
export let latestAIPanelViewByScopeSnapshot: PanelViewByScope | null = null;
let latestAIDraftMutationVersionByScopeSnapshot: Record<string, number> = {};
let latestAIDraftUploadGenerationByScopeSnapshot: Record<string, number> = {};
export function setLatestAISessionsSnapshot(sessions: AISession[]) {
latestAISessionsSnapshot = sessions;
}
export function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<string, string | null>) {
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
}
export function prewarmAIStateStorageSnapshots() {
try {
if (latestAISessionsSnapshot === null) {
latestAISessionsSnapshot =
localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? [];
}
if (latestAIActiveSessionMapSnapshot === null) {
latestAIActiveSessionMapSnapshot =
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
}
} catch (error) {
console.warn('[AIState] Failed to prewarm AI state storage snapshots:', error);
}
}
export function setLatestAIDraftsByScopeSnapshot(draftsByScope: DraftsByScope) {
latestAIDraftsByScopeSnapshot = draftsByScope;
}
export function setLatestAIPanelViewByScopeSnapshot(panelViewByScope: PanelViewByScope) {
latestAIPanelViewByScopeSnapshot = panelViewByScope;
}
export function bumpDraftMutationVersion(scopeKey: string) {
latestAIDraftMutationVersionByScopeSnapshot = bumpDraftMutationVersionState(
latestAIDraftMutationVersionByScopeSnapshot,
scopeKey,
);
}
export function getDraftUploadGeneration(scopeKey: string) {
return getDraftUploadGenerationState(
latestAIDraftUploadGenerationByScopeSnapshot,
scopeKey,
);
}
export function bumpDraftUploadGeneration(scopeKey: string) {
latestAIDraftUploadGenerationByScopeSnapshot = bumpDraftUploadGenerationState(
latestAIDraftUploadGenerationByScopeSnapshot,
scopeKey,
);
}

View File

@@ -238,9 +238,9 @@ export const editorTabStore = new EditorTabStore();
const getTabsSnapshot = () => editorTabStore.getTabs();
export const useEditorTabs = (): readonly EditorTab[] =>
useSyncExternalStore(editorTabStore.subscribe, getTabsSnapshot);
useSyncExternalStore(editorTabStore.subscribe, getTabsSnapshot, getTabsSnapshot);
export const useEditorTab = (id: EditorTabId): EditorTab | undefined => {
const getSnapshot = useCallback(() => editorTabStore.getTab(id), [id]);
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot, getSnapshot);
};

View File

@@ -0,0 +1,36 @@
import { useSyncExternalStore } from 'react';
type Listener = () => void;
class HostTreeInlineGroupDeleteStore {
private targetPath: string | null = null;
private listeners = new Set<Listener>();
getTargetPath = () => this.targetPath;
open = (groupPath: string) => {
this.targetPath = groupPath;
this.listeners.forEach((listener) => listener());
};
close = () => {
if (!this.targetPath) return;
this.targetPath = null;
this.listeners.forEach((listener) => listener());
};
subscribe = (listener: Listener) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
}
export const hostTreeInlineGroupDeleteStore = new HostTreeInlineGroupDeleteStore();
export const useHostTreeInlineGroupDeleteTarget = () => {
return useSyncExternalStore(
hostTreeInlineGroupDeleteStore.subscribe,
hostTreeInlineGroupDeleteStore.getTargetPath,
hostTreeInlineGroupDeleteStore.getTargetPath,
);
};

View File

@@ -0,0 +1,52 @@
import { useSyncExternalStore } from 'react';
export type HostTreeInlineGroupEdit = {
groupPath: string;
initialName: string;
isNew: boolean;
shouldScrollIntoView?: boolean;
};
type Listener = () => void;
class HostTreeInlineGroupEditStore {
private edit: HostTreeInlineGroupEdit | null = null;
private listeners = new Set<Listener>();
getEdit = () => this.edit;
startEdit = (edit: HostTreeInlineGroupEdit) => {
this.edit = {
...edit,
shouldScrollIntoView: edit.isNew ? true : edit.shouldScrollIntoView,
};
this.listeners.forEach((listener) => listener());
};
markScrollHandled = () => {
if (!this.edit?.shouldScrollIntoView) return;
this.edit = { ...this.edit, shouldScrollIntoView: false };
this.listeners.forEach((listener) => listener());
};
clear = () => {
if (!this.edit) return;
this.edit = null;
this.listeners.forEach((listener) => listener());
};
subscribe = (listener: Listener) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
}
export const hostTreeInlineGroupEditStore = new HostTreeInlineGroupEditStore();
export const useHostTreeInlineGroupEdit = () => {
return useSyncExternalStore(
hostTreeInlineGroupEditStore.subscribe,
hostTreeInlineGroupEditStore.getEdit,
hostTreeInlineGroupEditStore.getEdit,
);
};

View File

@@ -0,0 +1,41 @@
import { useSyncExternalStore } from 'react';
export type HostTreeInlineHostEdit = {
hostId: string;
initialName: string;
};
type Listener = () => void;
class HostTreeInlineHostEditStore {
private edit: HostTreeInlineHostEdit | null = null;
private listeners = new Set<Listener>();
getEdit = () => this.edit;
startEdit = (edit: HostTreeInlineHostEdit) => {
this.edit = edit;
this.listeners.forEach((listener) => listener());
};
clear = () => {
if (!this.edit) return;
this.edit = null;
this.listeners.forEach((listener) => listener());
};
subscribe = (listener: Listener) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
}
export const hostTreeInlineHostEditStore = new HostTreeInlineHostEditStore();
export const useHostTreeInlineHostEdit = () => {
return useSyncExternalStore(
hostTreeInlineHostEditStore.subscribe,
hostTreeInlineHostEditStore.getEdit,
hostTreeInlineHostEditStore.getEdit,
);
};

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

@@ -1,7 +1,10 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveTerminalSessionExitIntent } from "./resolveTerminalSessionExitIntent.ts";
import {
resolveTerminalSessionExitIntent,
shouldCloseTerminalPopupOnExit,
} from "./resolveTerminalSessionExitIntent.ts";
test("normal backend exited events close the session tab", () => {
assert.deepEqual(
@@ -30,3 +33,10 @@ test("backend closed events keep the tab and mark it disconnected", () => {
{ kind: "markDisconnected" },
);
});
test("terminal popup only auto-closes after clean command exit", () => {
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 0 }), true);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 1 }), false);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "error", error: "connection reset" }), false);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "closed", exitCode: 0 }), false);
});

View File

@@ -20,3 +20,7 @@ export function resolveTerminalSessionExitIntent(
// so the user can inspect output and reconnect.
return { kind: "markDisconnected" };
}
export function shouldCloseTerminalPopupOnExit(evt: TerminalSessionExitEvent): boolean {
return evt.reason === "exited" && evt.exitCode === 0;
}

View File

@@ -74,5 +74,6 @@ export const useSessionActivityMap = () => {
return useSyncExternalStore(
sessionActivityStore.subscribe,
sessionActivityStore.getSnapshot,
sessionActivityStore.getSnapshot,
);
};

View File

@@ -0,0 +1,62 @@
import type { SessionCapabilities } from '../../domain/systemManager/types';
type Listener = () => void;
const capabilitiesBySessionId = new Map<string, SessionCapabilities>();
const listenersBySessionId = new Map<string, Set<Listener>>();
function notifySession(sessionId: string) {
listenersBySessionId.get(sessionId)?.forEach((listener) => listener());
}
export const sessionCapabilitiesStore = {
get(sessionId: string): SessionCapabilities | undefined {
return capabilitiesBySessionId.get(sessionId);
},
set(sessionId: string, capabilities: SessionCapabilities) {
const prev = capabilitiesBySessionId.get(sessionId);
if (
prev
&& prev.targetOs === capabilities.targetOs
&& prev.hasTmux === capabilities.hasTmux
&& prev.hasDocker === capabilities.hasDocker
&& prev.probedAt === capabilities.probedAt
) {
return;
}
capabilitiesBySessionId.set(sessionId, capabilities);
notifySession(sessionId);
},
delete(sessionId: string) {
if (!capabilitiesBySessionId.delete(sessionId)) return;
notifySession(sessionId);
listenersBySessionId.delete(sessionId);
},
/** Drop cached capabilities for sessions that no longer exist. */
prune(liveSessionIds: ReadonlySet<string>) {
for (const sessionId of capabilitiesBySessionId.keys()) {
if (!liveSessionIds.has(sessionId)) {
capabilitiesBySessionId.delete(sessionId);
listenersBySessionId.delete(sessionId);
}
}
},
subscribe(sessionId: string, listener: Listener): () => void {
let set = listenersBySessionId.get(sessionId);
if (!set) {
set = new Set();
listenersBySessionId.set(sessionId, set);
}
set.add(listener);
return () => {
set?.delete(listener);
if (set && set.size === 0) {
listenersBySessionId.delete(sessionId);
}
};
},
};

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,275 @@
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_SESSION_LOGS_TIMESTAMPS_ENABLED,
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
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,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_WINDOW_OPACITY,
} from '../../infrastructure/config/storageKeys';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
clampWindowOpacity,
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>>;
setSessionLogsTimestampsEnabled: Dispatch<SetStateAction<boolean>>;
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>>;
setWindowOpacity: Dispatch<SetStateAction<number>>;
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
}
export function useSettingsIpcSync({
syncAppearanceFromStorage,
syncCustomCssFromStorage,
setUiLanguage,
setUiFontFamilyId,
setTerminalThemeId,
setTerminalThemeDarkId,
setTerminalThemeLightId,
setFollowAppTerminalThemeState,
setTerminalFontFamilyId,
setTerminalFontSize,
mergeIncomingTerminalSettings,
setEditorWordWrapState,
setSessionLogsEnabled,
setSessionLogsDir,
setSessionLogsFormat,
setSessionLogsTimestampsEnabled,
setSshDebugLogsEnabled,
setHotkeyScheme,
applyIncomingCustomKeyBindings,
setIsHotkeyRecordingState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setAutoUpdateEnabled,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setWorkspaceFocusStyleState,
setShowHostTreeSidebarState,
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_SESSION_LOGS_TIMESTAMPS_ENABLED && typeof value === 'boolean') {
setSessionLogsTimestampsEnabled((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_WINDOW_OPACITY && (typeof value === 'number' || typeof value === 'string')) {
const nextOpacity = clampWindowOpacity(value);
setWindowOpacity((prev) => (prev === nextOpacity ? prev : nextOpacity));
}
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_FOLLOW_TERMINAL_CWD && typeof value === 'boolean') {
setSftpFollowTerminalCwd((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_SHOW_HOST_TREE_SIDEBAR && typeof value === 'boolean') {
setShowHostTreeSidebarState((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,
setWindowOpacity,
setHotkeyScheme,
setIsHotkeyRecordingState,
setSessionLogsDir,
setSessionLogsEnabled,
setSessionLogsFormat,
setSessionLogsTimestampsEnabled,
setSshDebugLogsEnabled,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setShowHostTreeSidebarState,
setSftpTransferConcurrencyState,
setTerminalFontFamilyId,
setTerminalFontSize,
setTerminalThemeDarkId,
setTerminalThemeId,
setTerminalThemeLightId,
setUiFontFamilyId,
setUiLanguage,
setWorkspaceFocusStyleState,
syncAppearanceFromStorage,
syncCustomCssFromStorage,
]);
}

View File

@@ -0,0 +1,165 @@
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';
export const DEFAULT_WINDOW_OPACITY = 1;
export function clampWindowOpacity(opacity: unknown): number {
const value = Number(opacity);
if (!Number.isFinite(value)) return DEFAULT_WINDOW_OPACITY;
return Math.min(1, Math.max(0.5, value));
}
/** 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_FOLLOW_TERMINAL_CWD = 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;
export const DEFAULT_SHOW_HOST_TREE_SIDEBAR = true;
export const DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS = false;
// 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_SESSION_LOGS_TIMESTAMPS_ENABLED = false;
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;
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,473 @@
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_SESSION_LOGS_TIMESTAMPS_ENABLED,
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
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_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
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,
STORAGE_KEY_WINDOW_OPACITY,
} from '../../infrastructure/config/storageKeys';
import {
clampWindowOpacity,
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;
sftpFollowTerminalCwd: boolean;
sftpDefaultViewMode: 'list' | 'tree';
showRecentHosts: boolean;
showOnlyUngroupedHostsInRoot: boolean;
showSftpTab: boolean;
showHostTreeSidebar: boolean;
shellOnlyTabNumberShortcuts: boolean;
editorWordWrap: boolean;
sessionLogsEnabled: boolean;
sessionLogsDir: string;
sessionLogsFormat: SessionLogFormat;
sessionLogsTimestampsEnabled: boolean;
sshDebugLogsEnabled: boolean;
globalHotkeyEnabled: boolean;
autoUpdateEnabled: boolean;
windowOpacity: number;
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>>;
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
setShowRecentHostsState: Dispatch<SetStateAction<boolean>>;
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
setShellOnlyTabNumberShortcutsState: Dispatch<SetStateAction<boolean>>;
setEditorWordWrapState: Dispatch<SetStateAction<boolean>>;
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
setSessionLogsDir: Dispatch<SetStateAction<string>>;
setSessionLogsFormat: Dispatch<SetStateAction<SessionLogFormat>>;
setSessionLogsTimestampsEnabled: Dispatch<SetStateAction<boolean>>;
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setWindowOpacity: Dispatch<SetStateAction<number>>;
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, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
setGlobalHotkeyEnabled, setWindowOpacity, 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, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
});
settingsSnapshotRef.current = {
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
};
// 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_SESSION_LOGS_TIMESTAMPS_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.sessionLogsTimestampsEnabled) {
setSessionLogsTimestampsEnabled(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);
}
}
if (e.key === STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.sftpFollowTerminalCwd) {
setSftpFollowTerminalCwd(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);
}
}
if (e.key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.showHostTreeSidebar) {
setShowHostTreeSidebarState(newValue);
}
}
if (e.key === STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.shellOnlyTabNumberShortcuts) {
setShellOnlyTabNumberShortcutsState(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);
}
}
if (e.key === STORAGE_KEY_WINDOW_OPACITY && e.newValue !== null) {
const newValue = clampWindowOpacity(e.newValue);
if (newValue !== s.windowOpacity) {
setWindowOpacity(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,
setWindowOpacity,
setHotkeyScheme,
setLightUiThemeId,
setSessionLogsDir,
setSessionLogsEnabled,
setSessionLogsFormat,
setSessionLogsTimestampsEnabled,
setSshDebugLogsEnabled,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpAutoSync,
setSftpDefaultViewMode,
setSftpDoubleClickBehavior,
setSftpShowHiddenFiles,
setSftpTransferConcurrencyState,
setSftpUseCompressedUpload,
setShowOnlyUngroupedHostsInRootState,
setShowHostTreeSidebarState,
setShowRecentHostsState,
setShowSftpTabState,
setShellOnlyTabNumberShortcutsState,
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,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

@@ -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

@@ -71,7 +71,7 @@ export const useSftpConnections = ({
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;
@@ -207,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) => ({
@@ -292,6 +297,7 @@ export const useSftpConnections = ({
const keyFirstCredentials = {
sessionId: `sftp-${connectionId}`,
...credentials,
sourceSessionId: options?.sourceSessionId,
};
if (!credentials.sudo) {
keyFirstCredentials.password = undefined;
@@ -302,6 +308,7 @@ export const useSftpConnections = ({
sftpId = await openSftp({
sessionId: `sftp-${connectionId}`,
...credentials,
sourceSessionId: options?.sourceSessionId,
privateKey: undefined,
certificate: undefined,
publicKey: undefined,
@@ -317,6 +324,7 @@ export const useSftpConnections = ({
sftpId = await openSftp({
sessionId: `sftp-${connectionId}`,
...credentials,
sourceSessionId: options?.sourceSessionId,
});
}
@@ -452,6 +460,7 @@ export const useSftpConnections = ({
status: "connected",
currentPath: startPath,
homeDir,
reusedConnection: undefined,
}
: null,
files,

View File

@@ -1,9 +1,10 @@
import React, { useCallback, useRef, useMemo, useState } from "react";
import { FileConflict, FileConflictAction, 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,
@@ -12,7 +13,6 @@ import {
UploadBridge,
UploadCallbacks,
UploadResult,
UploadTaskInfo,
startUploadScanningTask,
} from "../../../lib/uploadService";
import type { DropEntry } from "../../../lib/sftpFileUtils";
@@ -20,64 +20,7 @@ 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[]>;
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;
}
import type { UseSftpExternalOperationsParams, SftpExternalOperationsResult } from "./useSftpExternalOperations.types";
export const useSftpExternalOperations = (
params: UseSftpExternalOperationsParams
@@ -236,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);
@@ -345,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, {
@@ -369,7 +309,7 @@ export const useSftpExternalOperations = (
if (isLocalTempDownloadCancelled()) {
await cleanupTempDownload(localTempPath);
return { localTempPath: "" };
return { localTempPath: "", sftpId, externalTransferId };
}
if (bridge.registerTempFile) {
@@ -380,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),
@@ -412,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
@@ -421,99 +454,15 @@ 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,
});
}
},
};
}, [addExternalUpload, updateExternalUpload, dismissExternalUpload]);
): 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);
@@ -1056,6 +1005,7 @@ export const useSftpExternalOperations = (
writeTextFile,
writeTextFileByConnection,
downloadToTempAndOpen,
openWithSystemDefault,
uploadExternalFiles,
uploadExternalFileList,
uploadExternalFolderPath,

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

@@ -1,18 +1,21 @@
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { FileWatchErrorEvent, FileWatchSyncedEvent, SftpStateOptions } from "./types";
export const useSftpFileWatch = (options?: SftpStateOptions) => {
const optionsRef = useRef(options);
optionsRef.current = options;
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onFileWatchSynced || !bridge?.onFileWatchError) return;
const unsubscribeSynced = bridge.onFileWatchSynced((payload: FileWatchSyncedEvent) => {
options?.onFileWatchSynced?.(payload);
optionsRef.current?.onFileWatchSynced?.(payload);
});
const unsubscribeError = bridge.onFileWatchError((payload: FileWatchErrorEvent) => {
options?.onFileWatchError?.(payload);
optionsRef.current?.onFileWatchError?.(payload);
});
return () => {
@@ -23,5 +26,5 @@ export const useSftpFileWatch = (options?: SftpStateOptions) => {
// ignore cleanup errors
}
};
}, [options]);
}, []);
};

View File

@@ -52,6 +52,31 @@ test("buildSftpHostCredentials rejects missing saved proxy profiles on jump host
);
});
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",

View File

@@ -3,6 +3,7 @@ import type { Host, Identity, SSHKey, TerminalSettings } from "../../../domain/m
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
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
@@ -36,6 +37,7 @@ export const buildSftpHostCredentials = ({
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),
}
@@ -69,7 +71,7 @@ export const buildSftpHostCredentials = ({
const hasJumpKeyMaterial = Boolean(jumpKeyAuth.privateKey || jumpKeyAuth.identityFilePaths?.length);
const hasConfiguredJumpProxyEndpoint =
index === 0 &&
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
hasUsableProxyConfig(jumpHost.proxyConfig);
if (
hasConfiguredJumpProxyEndpoint &&
jumpHost.proxyConfig?.username &&
@@ -101,11 +103,12 @@ export const buildSftpHostCredentials = ({
keyId: jumpAuth.keyId,
keySource: jumpKey?.source,
label: jumpHost.label,
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
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),
}

View File

@@ -1,8 +1,7 @@
import React, { useCallback, useMemo, useRef, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import {
FileConflict,
FileConflictAction,
SftpFileEntry,
SftpFilenameEncoding,
TransferDirection,
TransferStatus,
@@ -11,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: FileConflictAction, applyToAll?: boolean) => Promise<void>;
}
interface TransferResult {
id: string;
fileName: string;
originalFileName?: string;
status: TransferStatus;
}
export const useSftpTransfers = ({
getActivePane,
@@ -130,618 +74,24 @@ export const useSftpTransfers = ({
[],
);
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 { completeCancelledTask, cancelBackendTransfers, markBatchStopped } = useSftpTransferTaskOps({
cancelledTasksRef,
activeChildIdsRef,
transfersRef,
completionHandlersRef,
setConflicts,
setTransfers,
});
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;
const { statTargetPath, getDuplicateTarget, deleteTargetPath } = useSftpTransferConflictOps();
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 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);
}
}
},
[],
);
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);
}),
),
);
}, []);
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, completeCancelledTask],
);
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);
},
[],
);
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 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;
};
const { estimateDirectoryBytes, transferFile, countDirectoryFiles, transferDirectory } = useSftpDirectoryTransferOps({
cancelledTasksRef,
activeChildIdsRef,
setTransfers,
listLocalFiles,
listRemoteFiles,
});
const processTransfer = async (
task: TransferTask,

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,4 @@
export {
readSnippetVariableValuesForSnippet,
saveSnippetVariableValues,
} from '../../infrastructure/persistence/snippetVariableValuesStorage';

View File

@@ -0,0 +1,16 @@
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export async function writeSystemManagerDiagnostic(
message: string,
extra?: Record<string, unknown>,
) {
try {
await netcattyBridge.get()?.logDiagnostic?.({
source: 'system-manager',
message,
extra,
});
} catch {
// Diagnostics must never block the user action being diagnosed.
}
}

View File

@@ -0,0 +1,137 @@
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,
STORAGE_KEY_WINDOW_OPACITY,
} from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
interface UseSystemSettingsEffectsParams {
toggleWindowHotkey: string;
globalHotkeyEnabled: boolean;
closeToTray: boolean;
windowOpacity: number;
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,
windowOpacity,
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]);
// Persist and sync window opacity
useEffect(() => {
const bridge = netcattyBridge.get();
bridge?.setWindowOpacity?.(windowOpacity).catch((err) => {
console.warn('[WindowOpacity] Failed to apply window opacity:', err);
});
localStorageAdapter.writeString(STORAGE_KEY_WINDOW_OPACITY, String(windowOpacity));
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_WINDOW_OPACITY, windowOpacity);
}, [windowOpacity, 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,73 @@
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,
fontSize: session.fontSize,
fontSizeOverride: session.fontSizeOverride,
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,5 @@
export const TERMINAL_HOST_TREE_ANIMATION_MS = 220;
export const TERMINAL_HOST_TREE_ANIMATION_EASING = 'cubic-bezier(0.4, 0, 0.2, 1)';
export const TERMINAL_HOST_TREE_ANIMATION = `${TERMINAL_HOST_TREE_ANIMATION_MS}ms ${TERMINAL_HOST_TREE_ANIMATION_EASING}`;
export const TERMINAL_HOST_TREE_LEFT_TRANSITION = `left ${TERMINAL_HOST_TREE_ANIMATION}`;
export const TERMINAL_HOST_TREE_WIDTH_TRANSITION = `width ${TERMINAL_HOST_TREE_ANIMATION}`;

View File

@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import test from 'node:test';
const storage = new Map<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
},
});
const {
TERMINAL_HOST_TREE_DEFAULT_WIDTH,
clampTerminalHostTreeWidth,
terminalHostTreeStore,
} = await import('./terminalHostTreeStore.ts');
test('closing host tree state does not mutate layout width by itself', () => {
terminalHostTreeStore.setIsOpen(true);
terminalHostTreeStore.setLayoutWidth(240);
terminalHostTreeStore.setIsOpen(false);
assert.equal(terminalHostTreeStore.getLayoutWidth(), 240);
terminalHostTreeStore.setLayoutWidth(0);
});
test('opening host tree state does not jump the layout width', () => {
storage.set('netcatty_terminal_host_tree_width_v1', '300');
terminalHostTreeStore.setLayoutWidth(0);
terminalHostTreeStore.setIsOpen(false);
terminalHostTreeStore.setIsOpen(true);
assert.equal(terminalHostTreeStore.getLayoutWidth(), 0);
terminalHostTreeStore.setLayoutWidth(0);
});
test('host tree restored layout width is clamped', () => {
assert.equal(clampTerminalHostTreeWidth(80), 160);
assert.equal(clampTerminalHostTreeWidth(999), 360);
assert.equal(clampTerminalHostTreeWidth(0), 160);
assert.equal(TERMINAL_HOST_TREE_DEFAULT_WIDTH, 220);
});

View File

@@ -0,0 +1,84 @@
import { useCallback, useSyncExternalStore } from 'react';
import { STORAGE_KEY_TERMINAL_HOST_TREE_COLLAPSED } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
type Listener = () => void;
export const TERMINAL_HOST_TREE_MIN_WIDTH = 160;
export const TERMINAL_HOST_TREE_DEFAULT_WIDTH = 220;
export const TERMINAL_HOST_TREE_MAX_WIDTH = 360;
export function clampTerminalHostTreeWidth(width: number): number {
return Math.max(
TERMINAL_HOST_TREE_MIN_WIDTH,
Math.min(TERMINAL_HOST_TREE_MAX_WIDTH, width),
);
}
function readIsOpen(): boolean {
const stored = localStorageAdapter.readString(STORAGE_KEY_TERMINAL_HOST_TREE_COLLAPSED);
// Legacy key stores "collapsed"; open is the inverse.
if (stored === 'true') return false;
if (stored === 'false') return true;
return false;
}
class TerminalHostTreeStore {
private isOpen = readIsOpen();
/** Live sidebar width (0 when collapsed) for top-tab alignment. */
private layoutWidth = 0;
private listeners = new Set<Listener>();
getIsOpen = () => this.isOpen;
getLayoutWidth = () => this.layoutWidth;
setIsOpen = (open: boolean) => {
if (this.isOpen === open) return;
this.isOpen = open;
localStorageAdapter.writeString(
STORAGE_KEY_TERMINAL_HOST_TREE_COLLAPSED,
open ? 'false' : 'true',
);
this.listeners.forEach((listener) => listener());
};
setLayoutWidth = (width: number) => {
const next = Math.max(0, Math.round(width));
if (this.layoutWidth === next) return;
this.layoutWidth = next;
this.listeners.forEach((listener) => listener());
};
toggle = () => {
this.setIsOpen(!this.isOpen);
};
subscribe = (listener: Listener) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
}
export const terminalHostTreeStore = new TerminalHostTreeStore();
export const useTerminalHostTreeOpen = () => {
return useSyncExternalStore(
terminalHostTreeStore.subscribe,
terminalHostTreeStore.getIsOpen,
terminalHostTreeStore.getIsOpen,
);
};
export const useToggleTerminalHostTree = () => {
return useCallback(() => terminalHostTreeStore.toggle(), []);
};
export const useTerminalHostTreeLayoutWidth = () => {
return useSyncExternalStore(
terminalHostTreeStore.subscribe,
terminalHostTreeStore.getLayoutWidth,
terminalHostTreeStore.getLayoutWidth,
);
};

View File

@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { terminalLayoutSuppressStore } from './terminalLayoutSuppressStore';
test('terminalLayoutSuppressStore tracks nested begin/end', () => {
assert.equal(terminalLayoutSuppressStore.getActive(), false);
terminalLayoutSuppressStore.begin();
assert.equal(terminalLayoutSuppressStore.getActive(), true);
terminalLayoutSuppressStore.begin();
assert.equal(terminalLayoutSuppressStore.getActive(), true);
terminalLayoutSuppressStore.end();
assert.equal(terminalLayoutSuppressStore.getActive(), true);
terminalLayoutSuppressStore.end();
assert.equal(terminalLayoutSuppressStore.getActive(), false);
});

View File

@@ -0,0 +1,40 @@
import { useSyncExternalStore } from 'react';
type Listener = () => void;
let suppressDepth = 0;
const listeners = new Set<Listener>();
function emit() {
listeners.forEach((listener) => listener());
}
export const terminalLayoutSuppressStore = {
getActive: () => suppressDepth > 0,
subscribe: (listener: Listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
begin: () => {
suppressDepth += 1;
emit();
},
end: () => {
const wasActive = suppressDepth > 0;
suppressDepth = Math.max(0, suppressDepth - 1);
if (wasActive) {
emit();
}
},
};
export function useTerminalLayoutSuppressActive(): boolean {
return useSyncExternalStore(
terminalLayoutSuppressStore.subscribe,
terminalLayoutSuppressStore.getActive,
terminalLayoutSuppressStore.getActive,
);
}

View File

@@ -0,0 +1,52 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE,
buildPromptWithTerminalSelectionAttachments,
createTerminalSelectionAttachment,
decodeTerminalSelectionAttachment,
} from "./terminalSelectionAttachment.ts";
test("createTerminalSelectionAttachment returns null for blank selections", () => {
assert.equal(createTerminalSelectionAttachment(" \n\t"), null);
});
test("createTerminalSelectionAttachment creates a compact terminal log attachment", () => {
const attachment = createTerminalSelectionAttachment("line one\nline two");
assert.ok(attachment);
assert.equal(attachment.mediaType, TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE);
assert.match(attachment.filename, /^terminal-selection-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}\.log$/);
assert.equal(attachment.terminalSelection, true);
assert.equal(attachment.previewText, "line one");
assert.equal(attachment.lineCount, 2);
assert.equal(decodeTerminalSelectionAttachment(attachment), "line one\nline two");
});
test("createTerminalSelectionAttachment preserves utf-8 terminal output", () => {
const attachment = createTerminalSelectionAttachment("错误: 权限不足\n路径: /tmp/测试");
assert.ok(attachment);
assert.equal(decodeTerminalSelectionAttachment(attachment), "错误: 权限不足\n路径: /tmp/测试");
});
test("buildPromptWithTerminalSelectionAttachments expands terminal selections into prompt text", () => {
const attachment = createTerminalSelectionAttachment("docker ps -a\npermission denied");
assert.ok(attachment);
assert.equal(
buildPromptWithTerminalSelectionAttachments("帮我看看", [attachment]),
`帮我看看\n\n[Terminal selection: ${attachment.filename}]\ndocker ps -a\npermission denied`,
);
});
test("buildPromptWithTerminalSelectionAttachments supports terminal-only prompts", () => {
const attachment = createTerminalSelectionAttachment("systemctl status nginx");
assert.ok(attachment);
assert.equal(
buildPromptWithTerminalSelectionAttachments("", [attachment]),
`[Terminal selection: ${attachment.filename}]\nsystemctl status nginx`,
);
});

View File

@@ -0,0 +1,101 @@
import type { ChatMessageAttachment, UploadedFile } from "../../infrastructure/ai/types";
export const TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE = "text/plain";
const MAX_PREVIEW_CHARS = 80;
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
}
function base64ToText(base64Data: string): string {
const binary = atob(base64Data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return new TextDecoder().decode(bytes);
}
function buildTimestamp(date: Date): string {
const pad = (value: number) => String(value).padStart(2, "0");
return [
date.getFullYear(),
pad(date.getMonth() + 1),
pad(date.getDate()),
pad(date.getHours()),
pad(date.getMinutes()),
pad(date.getSeconds()),
].join("-");
}
function getPreviewText(text: string): string {
const firstLine = text.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
return firstLine.length > MAX_PREVIEW_CHARS
? `${firstLine.slice(0, MAX_PREVIEW_CHARS - 1)}...`
: firstLine;
}
export function createTerminalSelectionAttachment(
selection: string,
now: Date = new Date(),
): UploadedFile | null {
const text = selection.trim();
if (!text) return null;
const base64Data = bytesToBase64(new TextEncoder().encode(text));
const filename = `terminal-selection-${buildTimestamp(now)}.log`;
return {
id: crypto.randomUUID(),
filename,
dataUrl: `data:${TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE};base64,${base64Data}`,
base64Data,
mediaType: TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE,
terminalSelection: true,
previewText: getPreviewText(text),
lineCount: text.split(/\r?\n/).length,
};
}
export function decodeTerminalSelectionAttachment(
attachment: Pick<UploadedFile | ChatMessageAttachment, "base64Data" | "terminalSelection">,
): string | null {
if (!attachment.terminalSelection) return null;
return base64ToText(attachment.base64Data);
}
export function isTerminalSelectionAttachment(
attachment: Pick<UploadedFile | ChatMessageAttachment, "terminalSelection">,
): boolean {
return attachment.terminalSelection === true;
}
export function buildPromptWithTerminalSelectionAttachments(
prompt: string,
attachments: Array<ChatMessageAttachment | UploadedFile>,
): string {
const terminalBlocks = attachments
.filter(isTerminalSelectionAttachment)
.map((attachment, index) => {
const text = decodeTerminalSelectionAttachment(attachment);
if (!text) return null;
const label = attachment.filename || `terminal-selection-${index + 1}.log`;
return `\n\n[Terminal selection: ${label}]\n${text}`;
})
.filter((block): block is string => block !== null);
if (terminalBlocks.length === 0) return prompt;
if (!prompt.trim()) return terminalBlocks.join("").trimStart();
return `${prompt}${terminalBlocks.join("")}`;
}

View File

@@ -0,0 +1,129 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
THEME_TRANSITION_ATTR,
THEME_TRANSITION_MS,
runThemeTransition,
} from "./themeTransition.ts";
function createRoot() {
const attributes = new Map<string, string>();
return {
attributes,
ownerDocument: { startViewTransition: undefined },
setAttribute: (name: string, value: string) => attributes.set(name, value),
removeAttribute: (name: string) => attributes.delete(name),
getAttribute: (name: string) => attributes.get(name) ?? null,
} as unknown as HTMLElement;
}
test("runThemeTransition applies tokens and clears fallback marker after duration", async () => {
const root = createRoot();
let applied = false;
runThemeTransition(() => {
applied = true;
}, root);
assert.equal(applied, true);
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), "true");
await new Promise((resolve) => setTimeout(resolve, THEME_TRANSITION_MS + 60));
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), null);
});
test("runThemeTransition cancels a pending fallback reset when invoked again", () => {
const root = createRoot();
let count = 0;
runThemeTransition(() => {
count += 1;
}, root);
runThemeTransition(() => {
count += 2;
}, root);
assert.equal(count, 3);
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), "true");
});
test("runThemeTransition uses view transition API when available", async () => {
const root = createRoot();
let applied = false;
let finished = false;
const doc = {
startViewTransition: (callback: () => void) => {
callback();
return {
finished: Promise.resolve().then(() => {
finished = true;
}),
skipTransition: () => {},
};
},
};
(root as { ownerDocument: typeof doc }).ownerDocument = doc;
runThemeTransition(() => {
applied = true;
}, root);
assert.equal(applied, true);
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), null);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(finished, true);
});
test("runThemeTransition handles skipped view transitions", async () => {
const root = createRoot();
let applied = false;
let rejectFinished!: (reason: unknown) => void;
const doc = {
startViewTransition: (callback: () => void) => {
callback();
return {
finished: new Promise<void>((_, reject) => {
rejectFinished = reject;
}),
skipTransition: () => {},
};
},
};
(root as { ownerDocument: typeof doc }).ownerDocument = doc;
runThemeTransition(() => {
applied = true;
}, root);
rejectFinished(new DOMException("Transition was skipped", "AbortError"));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(applied, true);
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), null);
});
test("runThemeTransition can apply without animation for heavy tab switches", () => {
const root = createRoot();
let applied = false;
let startViewTransitionCalled = false;
const doc = {
startViewTransition: (callback: () => void) => {
startViewTransitionCalled = true;
callback();
return {
finished: Promise.resolve(),
skipTransition: () => {},
};
},
};
(root as { ownerDocument: typeof doc }).ownerDocument = doc;
runThemeTransition(() => {
applied = true;
}, { root, mode: "instant" });
assert.equal(applied, true);
assert.equal(startViewTransitionCalled, false);
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), null);
});

Some files were not shown because too many files have changed in this diff Show More