Compare commits

..
211 Commits
Author SHA1 Message Date
陈大猫 b88f7a0bfd Merge pull request #1826 from binaricat/codex/issue-937-identity-proxy-telnet
[codex] Support identities for Telnet and proxy credentials
2026-06-29 23:49:22 +08:00
bincxz c1635a0f55 Give Telnet its own identity selection 2026-06-29 23:48:41 +08:00
陈大猫 986551f146 Merge pull request #1828 from binaricat/codex/issue-1201-terminal-reconnect-history
[codex] Preserve terminal output on reconnect
2026-06-29 23:27:03 +08:00
bincxz 2796975526 Address identity proxy review gaps 2026-06-29 23:26:53 +08:00
bincxz 0cbb377385 keep terminal exit cleanup after close 2026-06-29 23:18:47 +08:00
bincxz ee3dce4cc5 ignore duplicate terminal exit events 2026-06-29 23:04:32 +08:00
陈大猫 854ae0d6f2 Merge pull request #1825 from binaricat/codex/terminal-side-panel-auto-open
Add terminal side panel auto-open setting
2026-06-29 23:03:59 +08:00
陈大猫 a20bb21af8 Merge pull request #1829 from binaricat/codex/issue-1824-zmodem-progress
Fix ZMODEM transfer progress events
2026-06-29 22:56:39 +08:00
bincxz 2fbc78fdc0 Fix ZMODEM transfer progress events 2026-06-29 22:55:53 +08:00
bincxz 32d9824eb9 preserve reconnect exit listener 2026-06-29 22:55:05 +08:00
陈大猫 98ead6af1f Merge pull request #1822 from binaricat/codex/fix-issue-1820-windows-force-focus 2026-06-29 22:41:52 +08:00
bincxz 595f42c84d Stabilize proxy timeout test 2026-06-29 22:39:52 +08:00
陈大猫 025d0cec08 Merge pull request #1823 from binaricat/codex/fix-issue-1821-script-stale-output 2026-06-29 22:30:39 +08:00
bincxz 6ba594800f Tighten side panel auto-open test coverage 2026-06-29 22:29:43 +08:00
陈大猫 16caa48a2a Merge pull request #1827 from binaricat/codex/issue-1824-zmodem-progress-toast
Fix ZMODEM transfer progress notifications
2026-06-29 22:24:51 +08:00
bincxz cdcacaef28 fix terminal reconnect output retention 2026-06-29 22:24:23 +08:00
bincxz 90b6e514ef Address terminal side panel auto-open review feedback 2026-06-29 22:24:07 +08:00
bincxz c10debea35 preserve script output during startup sync 2026-06-29 22:21:21 +08:00
bincxz d6f98948e0 Fix ZMODEM transfer progress notifications 2026-06-29 22:20:10 +08:00
bincxz 7770e86743 handle delayed show focus recovery 2026-06-29 22:18:26 +08:00
bincxz 1f7d2f91c9 Support identities for telnet and proxy credentials 2026-06-29 22:16:00 +08:00
bincxz 05231d3fba Add terminal side panel auto-open setting 2026-06-29 22:08:07 +08:00
bincxz d534b80532 fix script waits ignoring stale output 2026-06-29 21:55:26 +08:00
bincxz acd39df54b fix windows focus recovery trigger 2026-06-29 21:51:30 +08:00
陈大猫 657aa5a637 Merge pull request #1818 from binaricat/codex/issue-1817-zmodem-download-picker
[codex] Fix ZMODEM download picker in terminal worker
2026-06-29 17:32:14 +08:00
bincxz 1386357d23 fix zmodem download picker in worker 2026-06-29 17:23:45 +08:00
陈大猫 0749847fa3 Merge pull request #1810 from binaricat/codex/terminal-rendering-smoothness
[codex] Improve terminal output flood responsiveness
2026-06-29 16:32:46 +08:00
bincxz 10c9318e96 Address remaining PR review feedback 2026-06-29 16:30:50 +08:00
bincxz 136be6cdc6 Address terminal rendering review feedback 2026-06-29 16:17:50 +08:00
陈大猫 28b111dae8 Merge pull request #1813 from binaricat/codex/windows-zip-release
[codex] Add Windows zip release artifact
2026-06-29 16:04:59 +08:00
bincxz 98a2cdf22b Limit Windows zip to requested arch 2026-06-29 15:58:28 +08:00
bincxz 50a8c57023 Add Windows zip release artifact 2026-06-29 15:41:01 +08:00
bincxz 569b3d1426 Merge remote-tracking branch 'origin/main' into codex/terminal-rendering-smoothness
# Conflicts:
#	components/terminal/runtime/terminalOutputPipeline.test.ts
#	components/terminal/runtime/terminalWriteQueue.test.ts
#	components/terminal/runtime/terminalWriteQueue.ts
2026-06-29 15:25:46 +08:00
bincxz bd9dbf30f8 Preserve terminal output under backpressure 2026-06-29 15:15:53 +08:00
陈大猫 281948ee17 Merge pull request #1809 from pyroch/main
fix: Windows UI font fallback order
2026-06-29 15:04:34 +08:00
bincxz c47a116403 Improve terminal output flood responsiveness 2026-06-29 15:03:48 +08:00
陈大猫 e70b501990 Merge pull request #1808 from binaricat/codex/fix-terminal-input-echo-drift
[codex] Fix terminal input echo drift
2026-06-29 14:35:27 +08:00
pyroch 8c54a3b036 fix: index.html font stack 2026-06-29 09:33:26 +03:00
pyroch 3f0ff88107 fix: Windows UI font fallback order 2026-06-29 09:26:45 +03:00
bincxz 071ff09d8e Fix terminal input echo drift 2026-06-29 14:25:37 +08:00
陈大猫 45c9b9e86d Collapse host notes editor
Move host notes below connection fields and hide empty notes behind a switch.
2026-06-29 13:56:16 +08:00
陈大猫 ae7798f997 Merge pull request #1781 from StartHex/codex/remember-terminal-toolbar-state
Remember terminal encoding per host
2026-06-29 13:29:44 +08:00
陈大猫 94224d41a8 Merge pull request #1799 from binaricat/codex/issue-1425-ssh-timeout-display
Fix SSH timeout countdown mismatch
2026-06-29 13:27:49 +08:00
bincxz 324b7b47fd Fix terminal flow ack threshold test 2026-06-29 13:22:55 +08:00
bincxz b1db158b09 Merge remote-tracking branch 'origin/main' into codex/pr-1781-review-fix 2026-06-29 13:18:59 +08:00
bincxz 331edcbcc5 Fix SSH timeout countdown mismatch 2026-06-29 13:16:27 +08:00
bincxz c1563e3862 Fix remembered serial terminal encoding 2026-06-29 13:12:39 +08:00
陈大猫 922c372cb8 Merge pull request #1796 from binaricat/codex/issue-1502-keyword-highlight-enable-rule
[codex] Fix host keyword highlight enablement
2026-06-29 13:07:00 +08:00
陈大猫 4fdf892b6a Merge pull request #1797 from binaricat/codex/issue-1779-script-editor-layout
Fix script output trigger modal layout
2026-06-29 13:04:42 +08:00
陈大猫 c2361461f7 Merge pull request #1798 from binaricat/codex/issue-1060-topsec-ghost-delete 2026-06-29 12:09:50 +08:00
陈大猫 1e9e4a10e7 Merge pull request #1795 from binaricat/codex/issue-1753-sftp-reopen-last-path 2026-06-29 12:09:18 +08:00
陈大猫 45d51d204c Merge pull request #1802 from binaricat/codex/issue-1790-terminal-flow-ack-idle-flush
[codex] Fix terminal flow ack idle flush
2026-06-29 12:05:23 +08:00
陈大猫 9f3603d4ae Merge pull request #1800 from binaricat/codex/issue-1250-mosh-utf8-decoder
Fix Windows mosh reconnect output garbling
2026-06-29 12:02:24 +08:00
bincxz 4ab29424b7 fix(sftp): restore reopened panel path 2026-06-29 12:00:35 +08:00
bincxz 6c04cecbba Fix ghost autocomplete after Topsec backspace echo 2026-06-29 11:57:08 +08:00
陈大猫 1337eb355b Merge pull request #1792 from binaricat/codex/issue-1784-zmodem-worker-dialog
Fix ZMODEM upload picker in terminal worker
2026-06-29 11:55:09 +08:00
陈大猫 584abffa74 Merge pull request #1801 from binaricat/codex/issue-1786-terminal-tui-flow
Fix terminal TUI output throttling regression
2026-06-29 11:51:18 +08:00
bincxz 6fd8d711e9 Publish pinned Windows mosh runtime bundle 2026-06-29 11:42:39 +08:00
陈大猫 506bc849ee Merge pull request #1803 from binaricat/codex/issue-1285-codex-login-status
[codex] Fix Codex login status validation
2026-06-29 11:42:26 +08:00
陈大猫 ff639063b0 Merge pull request #1804 from binaricat/codex/issue-1768-terminal-input-priority
Fix terminal history edit echo desync
2026-06-29 11:37:28 +08:00
bincxz 4c00ec42f9 fix terminal input echo desync 2026-06-29 11:30:43 +08:00
bincxz 4e5ad952f8 Fix ZMODEM upload picker in terminal worker 2026-06-29 11:29:59 +08:00
bincxz 9f86ae2c54 fix codex login status validation 2026-06-29 11:29:58 +08:00
bincxz 4683cbc299 fix terminal flow ack idle flush 2026-06-29 11:24:15 +08:00
bincxz 4eebd091bf Fix terminal TUI output throttling regression 2026-06-29 11:23:40 +08:00
陈大猫 70e06201d3 Merge pull request #1794 from binaricat/codex/issue-1497-webgl-render-guard
test: harden terminal WebGL recovery guard
2026-06-29 11:22:58 +08:00
bincxz 2c953cd5cb Fix Windows mosh reconnect output garbling 2026-06-29 11:22:13 +08:00
bincxz 8f1167ff34 Fix script output trigger modal layout 2026-06-29 11:17:54 +08:00
bincxz f5f3b1bb20 Fix host keyword highlight enablement 2026-06-29 11:17:13 +08:00
bincxz 98438de1c4 test: harden terminal webgl recovery guard 2026-06-29 11:16:17 +08:00
shixu 61f89e7f7d Update CI expectations for terminal theme and MCP tools 2026-06-29 10:58:25 +08:00
陈大猫 30d78ebc5a Merge pull request #1791 from binaricat/fix/terminal-search-refocus-1789
fix(terminal): refocus search box when Cmd/Ctrl+F re-fires while open
2026-06-29 10:57:08 +08:00
bincxzandClaude f23c0fa0e0 chore: drop dead setIsSearchOpen from Terminal ctx plumbing
useTerminalEffects no longer consumes setIsSearchOpen (the runtime now uses
requestSearchFocus), so stop destructuring and passing it through. Caught by
codex review as a harmless leftover.

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

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

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

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

Closes #1789

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(scripts): harden output trigger wait helpers

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore(scripts): remove onOutput trigger debug logging

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

* Sync package lock for CI

* Persist group device type setting

* Fix group device type inheritance controls

* Fix device type inheritance with shell transports

* Use resolved hosts for AI executor context

* Reduce device type inheritance diff scope

---------

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

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

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

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

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

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

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

* Fix Codex review: gate reinjection and match sftp_read_file.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Use compaction summary budget for pre-summarize pruning.

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

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

* Prune oldest messages first when fitting compaction summarize input.

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

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

* Detect JSON tool errors before superseding stale reads.

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

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

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

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

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

---------

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 11:15:46 +08:00
Zheng Liu f4ca635c1a Fix restored terminal cwd tracking 2026-06-26 10:51:59 +08:00
533 changed files with 54780 additions and 4014 deletions

No files matched your search

+7 -6
View File
@@ -129,17 +129,18 @@ jobs:
path: out/
# ------------------------------------------------------------------
# Windows x64 pinned standalone client.
# Do not compile this in CI: the upstream Cygwin build can clear the
# terminal and never render output on Windows. Ship the SHA256-pinned
# FluentTerminal standalone binary verified by fetch-windows.sh.
# Windows x64 pinned runtime bundle.
# Do not compile this in CI: rebuilding the upstream Cygwin client here
# previously regressed Windows startup. Ship the SHA256-pinned Netcatty
# bundle with DLLs + terminfo, plus the FluentTerminal standalone fallback,
# verified by fetch-windows.sh.
# ------------------------------------------------------------------
fetch-windows-x64:
name: fetch-windows-x64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Fetch pinned mosh-client.exe (win32-x64)
- name: Fetch pinned mosh-client bundle (win32-x64)
run: |
set -euo pipefail
export OUT_DIR="${GITHUB_WORKSPACE}/out"
@@ -212,7 +213,7 @@ jobs:
{
printf '%s\n' 'Pre-built `mosh-client` binaries consumed by `scripts/fetch-mosh-binaries.cjs` during `npm run pack`.'
printf 'Linux/macOS artifacts are built from `mobile-shell/mosh` upstream ref `%s`.\n' "${MOSH_REF}"
printf '%s\n\n' 'Windows x64 is the SHA256-pinned FluentTerminal standalone `mosh-client.exe` fallback.'
printf '%s\n\n' 'Windows x64 is the SHA256-pinned Netcatty runtime bundle, with the FluentTerminal standalone `mosh-client.exe` kept as a fallback asset.'
printf 'Source workflow: %s/%s/actions/runs/%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_RUN_ID}"
printf 'Source commit: `%s`\n\n' "${GITHUB_SHA}"
printf '%s\n' 'All artifacts are GPL-3.0; see `resources/mosh/README.md` for source provenance.'
+16 -2
View File
@@ -30,12 +30,26 @@ Turn orchestration is centralized in **AgentRuntime**; the React hook `useAIChat
|-------|--------|------|
| Runtime | `agentRuntime.ts`, `globalAgentRuntime.ts` | Turn lifecycle, trace fan-out, per-turn ToolOutputStore / ToolResultDedup |
| Drivers | `turnDrivers/cattyTurnDriver.ts`, `turnDrivers/externalSdkTurnDriver.ts` | Catty `streamText` + External SDK IPC; emit unified `AgentEvent`s |
| Context | `contextManager.ts`, `tokenEstimator.ts`, `cattyRuntime.ts` | Pre-turn / 413 compaction, `prepareStepContext`, provider-aware token estimates |
| Context | `contextManager.ts`, `contextBudget.ts`, `tokenEstimator.ts`, `sessionState.ts`, `staleContextPruner.ts`, `compactionPruner.ts`, `cattyRuntime.ts` | Pre-turn / step / 413 compaction, dynamic thresholds, SessionState reinjection, stale tool pruning |
| Tools | `capabilityTools.ts`, `toolOutputStore.ts`, `toolResultDedup.ts` | Catalog tools, truncated output handles (`tool_output_read`), duplicate-read notices |
| Trace | `traceStore.ts`, `agentEventAdapter.ts` | Session event log incl. `usage` and `CompactionTrace` |
| Trace | `traceStore.ts`, `agentEventAdapter.ts` | Session event log incl. `usage`, `performance`, and `CompactionTrace` |
**Stop** always goes through `stopAgentTurn()` (UI, `/stop`, MCP). Do not add parallel abort paths in hooks.
### AI SDK v7 (Catty path)
Catty sidebar turns use **Vercel AI SDK 7** via `streamText` in `turnDrivers/cattyStreamProcessor.ts`. Key conventions:
| Concern | Module | Notes |
|---------|--------|-------|
| `runtimeContext` | `cattyRuntimeContext.ts` | Per-turn orchestration state (`chatSessionId`, `turnId`, `agentKind`, `permissionMode`, scope, `lastCompaction`). Passed to `prepareStep` and lifecycle callbacks. **Does not replace** pre-turn compaction or 413 handling. |
| `toolsContext` | `capabilityTools.ts` | Per-tool keyed context (`bridge`, `getExecutorContext`, `toolOutputStore`, …). Tools read deps from `{ context }` in `execute`, not closure capture. |
| `toolApproval` | `cattyToolApproval.ts` | Write-tool gating for Catty `streamText` only. Calls `requestApproval()` in confirm mode; observer auto-denies writes. **External MCP agents** still approve via main-process `mcpServerBridge` → `setupMcpApprovalBridge()` — unchanged. |
| `timeout` | `streamTimeouts.ts` | `totalMs` / `stepMs` / `chunkMs` / `toolMs` on `streamText`; compaction `generateText` uses a shorter 90s timeout. |
| Lifecycle | `cattyStreamProcessor.ts` | `onStart` → `model_call_start`; `onStepEnd` → per-step `step_end` usage; `onEnd` → turn-total `usage`; `finalStep.performance` → `performance` event. Distinct from `AgentRuntime` turn_start/turn_end. |
Compaction remains **`prepareTurnContext` / `compactCattyMessages`** (pre-turn + 413-retry). Step-level pruning is **`prepareStepContext`** only (typed compression + handle notices, no LLM summarize).
### Capability exposure (Round 2 + gap fill)
Single source of truth: `electron/capabilities/catalog/` + `electron/capabilities/codegen/toolSurfaces.cjs`.
+74 -17
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useEffectEvent, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { activeTabStore, toEditorTabId, fromEditorTabId, isEditorTabId } from './application/state/activeTabStore';
import { useAutoSync } from './application/state/useAutoSync';
import { useManagedSourceSync } from './application/state/useManagedSourceSync';
@@ -29,8 +29,8 @@ import { buildSshDeepLinkConnectionHost, buildSshDeepLinkHostDraft, findSshDeepL
import { resolveHostAuth } from './domain/sshAuth';
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
import {
getFollowAppTerminalThemeSelectionUpdate,
mergeTerminalHostUpdate,
TERMINAL_THEME_AUTO,
type TerminalHostUpdate,
} from './domain/terminalAppearance';
import { selectConnectionLogForTerminalDataCapture } from './domain/connectionLog';
@@ -39,7 +39,8 @@ import { resolveCloseIntent } from './application/state/resolveCloseIntent';
import { resolveSnippetsShortcutIntent } from './application/state/resolveSnippetsShortcutIntent';
import { resolveWindowCommandCloseIntent } from './application/state/windowCommandClose';
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
import { customThemeStore, useCustomThemes } from './application/state/customThemeStore';
import { useThemeRuntime, useTerminalAppearanceInjection } from './application/state/useThemeRuntime';
import { useCustomThemes } from './application/state/customThemeStore';
import type { SyncPayload } from './domain/sync';
import { applySyncPayload, buildLocalVaultPayload, hasMeaningfulSyncData } from './application/syncPayload';
import {
@@ -65,8 +66,10 @@ import { getHostSearchMatch } from './lib/searchMatcher';
import { useDiscoveredShells, resolveShellSetting } from './lib/useDiscoveredShells';
import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession } from './types';
import { resolveSnippetCommand } from './components/SnippetExecutionProvider';
import { AppView } from './application/app/AppView';
import { isScriptSnippet } from './domain/snippetScript.ts';
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
import { AppActiveTabChrome } from './application/app/AppActiveTabChrome';
import { AppView } from './application/app/AppView';
import { useAppStartupEffects } from './application/app/useAppStartupEffects';
import { LogViewWrapper, SftpViewMount, TerminalLayerMount, VaultViewContainer } from './application/app/AppMounts';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleToggleThemeImpl, handleRootContextMenuImpl } from './application/app/AppHandlers';
@@ -116,6 +119,10 @@ function App({ settings }: { settings: SettingsState }) {
const {
theme,
setTheme,
setLightUiThemeId,
setDarkUiThemeId,
lightUiThemeId,
darkUiThemeId,
resolvedTheme,
accentMode,
customAccent,
@@ -123,8 +130,9 @@ function App({ settings }: { settings: SettingsState }) {
setTerminalThemeId,
setTerminalThemeDarkId,
setTerminalThemeLightId,
terminalThemeDarkId,
terminalThemeLightId,
followAppTerminalTheme,
currentTerminalTheme,
terminalFontFamilyId,
setTerminalFontFamilyId,
terminalFontSize,
@@ -278,6 +286,11 @@ function App({ settings }: { settings: SettingsState }) {
const handleRunSnippet = useCallback(
async (snippet: Snippet, targetHosts: Host[]) => {
if (targetHosts.length === 0) return;
if (isScriptSnippet(snippet)) {
runSnippet(snippet, targetHosts);
return;
}
const command = await resolveSnippetCommand(snippet);
if (command === null) return;
runSnippet(snippet, targetHosts, command);
@@ -292,6 +305,32 @@ function App({ settings }: { settings: SettingsState }) {
// Active tab lookup maps
// ---------------------------------------------------------------------------
const customThemes = useCustomThemes();
const themeRuntime = useThemeRuntime({
terminalThemeId,
terminalThemeDarkId,
terminalThemeLightId,
followAppTerminalTheme,
resolvedTheme,
lightUiThemeId,
darkUiThemeId,
accentMode,
customAccent,
customThemes,
setTheme,
setLightUiThemeId,
setDarkUiThemeId,
});
useTerminalAppearanceInjection(themeRuntime.globalAppearance, {
includeChromeSurfaces: followAppTerminalTheme,
});
const prevFollowAppTerminalThemeRef = useRef(followAppTerminalTheme);
const clearThemeIntent = themeRuntime.clearIntent;
useLayoutEffect(() => {
if (prevFollowAppTerminalThemeRef.current === followAppTerminalTheme) return;
prevFollowAppTerminalThemeRef.current = followAppTerminalTheme;
clearThemeIntent();
}, [followAppTerminalTheme, clearThemeIntent]);
const currentTerminalTheme = themeRuntime.currentTerminalTheme;
const editorTabs = useEditorTabs();
const hostById = useMemo(
@@ -708,6 +747,10 @@ function App({ settings }: { settings: SettingsState }) {
type: 'note';
noteId: string;
requestId: number;
} | {
type: 'snippet';
snippetId: string;
requestId: number;
} | null>(null);
// Populated below so the hotkey dispatcher can open the Settings window
// even though `handleOpenSettings` is declared further down in the file.
@@ -913,6 +956,7 @@ function App({ settings }: { settings: SettingsState }) {
terminalSettings,
resolveEffectiveHost,
updateHosts,
updateSnippets,
customGroups,
updateCustomGroups,
notes,
@@ -996,11 +1040,22 @@ function App({ settings }: { settings: SettingsState }) {
setActiveTabId('vault');
}, [hosts, setActiveTabId]);
const handleOpenVaultSectionFromChat = useCallback((section: 'notes' | 'hosts') => {
const handleOpenVaultSectionFromChat = useCallback((section: 'notes' | 'hosts' | 'snippets') => {
setNavigateToSection(section);
setActiveTabId('vault');
}, [setActiveTabId]);
const handleOpenVaultSnippetFromChat = useCallback((snippetId: string) => {
vaultFocusRequestIdRef.current += 1;
setVaultFocusRequest({
type: 'snippet',
snippetId,
requestId: vaultFocusRequestIdRef.current,
});
setNavigateToSection('snippets');
setActiveTabId('vault');
}, [setActiveTabId]);
// Wrap updateSessionStatus to track lastConnectedAt on successful connection
const handleSessionStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
updateSessionStatus(sessionId, status);
@@ -1052,17 +1107,17 @@ function App({ settings }: { settings: SettingsState }) {
const handleToggleTheme = useCallback(() => { return handleToggleThemeImpl(() => ({ openSettingsWindow, resolvedTheme, setTheme, t, theme, toast })); }, [openSettingsWindow, resolvedTheme, setTheme, t, theme]);
const handleFollowAppTerminalThemeChange = useCallback((themeId: string) => {
const selectedTheme = customThemeStore.getThemeById(themeId);
if (!selectedTheme) return;
const update = getFollowAppTerminalThemeSelectionUpdate(selectedTheme);
if (update.terminalThemeDarkId) {
setTerminalThemeDarkId(update.terminalThemeDarkId);
themeRuntime.pickTheme(themeId);
}, [themeRuntime]);
const handleDefaultTerminalThemeChange = useCallback((themeId: string) => {
setTerminalThemeId(themeId);
if (resolvedTheme === 'dark') {
setTerminalThemeDarkId(TERMINAL_THEME_AUTO);
} else {
setTerminalThemeLightId(TERMINAL_THEME_AUTO);
}
if (update.terminalThemeLightId) {
setTerminalThemeLightId(update.terminalThemeLightId);
}
setTheme(update.appTheme);
}, [setTerminalThemeDarkId, setTerminalThemeLightId, setTheme]);
}, [resolvedTheme, setTerminalThemeDarkId, setTerminalThemeId, setTerminalThemeLightId]);
const handleOpenQuickSwitcher = useCallback(() => {
setIsQuickSwitcherOpen(true);
@@ -1137,9 +1192,10 @@ function App({ settings }: { settings: SettingsState }) {
customAccent={customAccent}
editorTabs={editorTabs}
logViews={logViews}
resolveSessionAppearance={themeRuntime.resolveFocusedAppearance}
t={t}
/>
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleOpenHostFromVaultNote, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setVaultFocusRequest, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, clearThemeIntent: themeRuntime.clearIntent, settleManualThemeIntent: themeRuntime.settleManualIntent, pickTerminalTheme: themeRuntime.pickTheme, resolveSessionAppearance: themeRuntime.resolveFocusedAppearance, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost, handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleOpenHostFromVaultNote, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
</>
);
}
@@ -1171,6 +1227,7 @@ function AppWithProviders() {
<I18nProvider locale={settings.uiLanguage}>
<ToastProvider>
<TooltipProvider delayDuration={300}>
<ScriptAutomationRoot />
<App settings={settings} />
</TooltipProvider>
</ToastProvider>
+5
View File
@@ -9,6 +9,7 @@ import { updateActiveChromeThemeDeps } from '../state/activeChromeThemeSync';
import { useActiveChromeTheme } from '../state/useActiveChromeTheme';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { resolveActiveChromeTheme } from './activeChromeTheme';
import type { TerminalAppearanceHostScope, ResolvedAppearance } from '../../domain/terminalAppearanceRuntime';
import type {
Host,
TerminalSession,
@@ -32,6 +33,7 @@ interface AppActiveTabChromeProps {
customAccent: string;
editorTabs: readonly EditorTab[];
logViews: readonly LogView[];
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
t: (key: string) => string;
}
@@ -57,6 +59,7 @@ export function AppActiveTabChrome({
customAccent,
editorTabs,
logViews,
resolveSessionAppearance,
t,
}: AppActiveTabChromeProps) {
const activeTabId = useActiveTabId();
@@ -76,6 +79,7 @@ export function AppActiveTabChrome({
followAppTerminalTheme,
hostById,
logViews,
resolveSessionAppearance,
sessionById,
themeById,
workspaceById,
@@ -88,6 +92,7 @@ export function AppActiveTabChrome({
followAppTerminalTheme,
hostById,
logViews,
resolveSessionAppearance,
sessionById,
themeById,
workspaceById,
+29 -4
View File
@@ -2,9 +2,34 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("../../App.tsx", import.meta.url), "utf8");
const appSource = readFileSync(new URL("../../App.tsx", import.meta.url), "utf8");
const appViewSource = readFileSync(new URL("./AppView.tsx", import.meta.url), "utf8");
const runtimeSource = readFileSync(new URL("../state/useThemeRuntime.ts", import.meta.url), "utf8");
const settingsSource = readFileSync(new URL("../state/useSettingsState.ts", import.meta.url), "utf8");
test("follow-app terminal theme selection reads the latest custom theme store", () => {
assert.match(source, /customThemeStore\.getThemeById\(themeId\)/);
assert.doesNotMatch(source, /const selectedTheme = themeById\.get\(themeId\)/);
test("follow-app terminal theme selection updates the matching UI theme via ThemeRuntime", () => {
assert.match(runtimeSource, /getFollowAppTerminalThemeSelectionUpdate\(themeId\)/);
assert.match(runtimeSource, /setDarkUiThemeId\(update\.uiThemeId\)/);
assert.match(runtimeSource, /setLightUiThemeId\(update\.uiThemeId\)/);
assert.match(runtimeSource, /setTheme\(update\.appTheme\)/);
assert.doesNotMatch(runtimeSource, /isFollowAppIntentSettled\(userIntent\.themeId/);
assert.match(appSource, /useThemeRuntime\(/);
assert.match(appSource, /themeRuntime\.pickTheme\(themeId\)/);
assert.match(appSource, /useTerminalAppearanceInjection/);
assert.match(appSource, /includeChromeSurfaces: followAppTerminalTheme/);
assert.match(appSource, /clearThemeIntent\(\)/);
assert.match(runtimeSource, /injectTerminalAppearanceVars\(appearance\.theme, \{ includeChromeSurfaces \}\)/);
assert.doesNotMatch(settingsSource, /pendingFollowAppTerminalThemeId/);
assert.doesNotMatch(settingsSource, /applyFollowAppTerminalThemePick/);
assert.match(settingsSource, /appearanceTransitionModeRef\.current = 'instant'/);
assert.match(appViewSource, /data-terminal-appearance-root/);
assert.match(appViewSource, /pickTerminalTheme=\{ctx\.pickTerminalTheme\}/);
});
test("default terminal theme selection clears the current mode override", () => {
assert.match(appSource, /const handleDefaultTerminalThemeChange = useCallback\(\(themeId: string\) => \{/);
assert.match(appSource, /setTerminalThemeId\(themeId\)/);
assert.match(appSource, /resolvedTheme === 'dark'[\s\S]*setTerminalThemeDarkId\(TERMINAL_THEME_AUTO\)/);
assert.match(appSource, /setTerminalThemeLightId\(TERMINAL_THEME_AUTO\)/);
assert.match(appViewSource, /onUpdateTerminalThemeId=\{handleDefaultTerminalThemeChange\}/);
});
+4 -3
View File
@@ -577,9 +577,10 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
break;
}
case 'newTab':
case 'openLocal':
// Add connection log for local terminal
case 'openLocal': {
const sessionId = createLocalTerminalWithCurrentShell();
addConnectionLogRef.current({
sessionId,
hostId: '',
hostLabel: 'Local Terminal',
hostname: 'localhost',
@@ -590,8 +591,8 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
localHostname: systemInfoRef.current.hostname,
saved: false,
});
createLocalTerminalWithCurrentShell();
break;
}
case 'openHosts':
setActiveTabId('vault');
break;
+6
View File
@@ -42,3 +42,9 @@ test('host tree layer hides immediately when leaving work tab surfaces', () => {
assert.match(hostTreeLayerSource, /getAppHostTreeLayerStyle\(surfaceVisible\)/);
assert.doesNotMatch(hostTreeLayerSource, /layerVisible/);
});
test('shared host tree theme follows active chrome resolution and manual chrome injection', () => {
assert.match(hostTreeLayerSource, /resolveActiveChromeTheme/);
assert.match(hostTreeLayerSource, /useManualTerminalChromeSurfaceInjection/);
assert.match(hostTreeLayerSource, /resolveSessionAppearance/);
});
+43 -11
View File
@@ -3,12 +3,17 @@ import React, { useMemo } from 'react';
import { useActiveTabId } from '../state/activeTabStore';
import type { EditorTab } from '../state/editorTabStore';
import type { LogView } from '../state/logViewState';
import { useManualTerminalChromeSurfaceInjection } from '../state/useManualTerminalChromeSurfaceInjection';
import { TerminalHostTreeSidebar } from '../../components/terminalLayer/TerminalHostTreeSidebar';
import type {
ResolvedAppearance,
TerminalAppearanceHostScope,
} from '../../domain/terminalAppearanceRuntime';
import type { GroupConfig, Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
import { resolveActiveChromeTheme } from './activeChromeTheme';
import {
isHostTreeWorkTabSurface,
resolveWorkTabActiveHostId,
resolveWorkTabHostTreeTheme,
} from './workTabSurface';
interface AppHostTreeLayerProps {
@@ -27,6 +32,7 @@ interface AppHostTreeLayerProps {
followAppTerminalTheme: boolean;
hostById: ReadonlyMap<string, Host>;
themeById: ReadonlyMap<string, TerminalTheme>;
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
onConnect: (host: Host) => void;
onCreateLocalTerminal?: () => void;
}
@@ -55,6 +61,7 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
followAppTerminalTheme,
hostById,
themeById,
resolveSessionAppearance,
onConnect,
onCreateLocalTerminal,
}) => {
@@ -62,6 +69,14 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
const sessionIds = useMemo(() => new Set(sessions.map((session) => session.id)), [sessions]);
const workspaceIds = useMemo(() => new Set(workspaces.map((workspace) => workspace.id)), [workspaces]);
const logViewIds = useMemo(() => new Set(logViews.map((logView) => logView.id)), [logViews]);
const sessionById = useMemo(
() => new Map(sessions.map((session) => [session.id, session])),
[sessions],
);
const workspaceById = useMemo(
() => new Map(workspaces.map((workspace) => [workspace.id, workspace])),
[workspaces],
);
const surfaceVisible = isHostTreeWorkTabSurface({
enabled,
activeTabId,
@@ -78,24 +93,41 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
workspaces,
}), [activeTabId, editorTabs, sessions, workspaces]);
const hostTreeTheme = useMemo(() => resolveWorkTabHostTreeTheme({
activeHostId,
accentMode,
currentTerminalTheme,
customAccent,
followAppTerminalTheme,
hostById,
themeById,
}), [
activeHostId,
const hostTreeTheme = useMemo(() => (
resolveActiveChromeTheme({
accentMode,
activeTabId,
currentTerminalTheme,
customAccent,
editorTabs,
followAppTerminalTheme,
hostById,
logViews,
resolveSessionAppearance,
sessionById,
themeById,
workspaceById,
}) ?? currentTerminalTheme
), [
accentMode,
activeTabId,
currentTerminalTheme,
customAccent,
editorTabs,
followAppTerminalTheme,
hostById,
logViews,
resolveSessionAppearance,
sessionById,
themeById,
workspaceById,
]);
useManualTerminalChromeSurfaceInjection(
hostTreeTheme,
!followAppTerminalTheme && surfaceVisible,
);
return (
<div
className="absolute left-0 top-0 bottom-0 flex min-h-0"
+65 -9
View File
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { Suspense, lazy, useMemo } from 'react';
import React, { Suspense, lazy, useCallback, useMemo } from 'react';
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
import { activeTabStore, toEditorTabId, useIsEditorTabActive } from '../state/activeTabStore';
import { editorTabStore } from '../state/editorTabStore';
@@ -8,6 +8,7 @@ import { useTerminalHostTreeLayoutWidth } from '../state/terminalHostTreeStore';
import { TopTabs } from '../../components/TopTabs';
import { VaultView } from '../../components/VaultView';
import { QuickAddSnippetDialog } from '../../components/QuickAddSnippetDialog';
import { QuickScriptEditorDialog } from '../../components/scripts/QuickScriptEditorDialog';
import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog';
import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal';
import { PassphraseModal } from '../../components/PassphraseModal';
@@ -22,6 +23,7 @@ import { toast } from '../../components/ui/toast';
import { AppHostTreeLayer } from './AppHostTreeLayer';
import { getUiThemeById } from '../../infrastructure/config/uiThemes';
import { buildAppThemeCssVars } from '../state/settingsStateDefaults';
import { useMainWindowInputFocusRecovery } from '../state/useMainWindowInputFocusRecovery';
const LazyProtocolSelectDialog = lazy(() => import('../../components/ProtocolSelectDialog'));
const LazyQuickSwitcher = lazy(() =>
@@ -53,21 +55,52 @@ const TextEditorTabFallback = ({ tabId }: { tabId: string }) => {
type AppViewContext = Record<string, any>;
export function AppView({ ctx }: { ctx: AppViewContext }) {
const {
resetSessionRename,
resetWorkspaceRename,
setAddToWorkspaceDialog,
setIsCreateWorkspaceOpen,
setIsQuickSwitcherOpen,
setProtocolSelectHost,
setQuickSearch,
} = ctx;
const dismissTransientOverlays = useCallback(() => {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
setIsCreateWorkspaceOpen(false);
setProtocolSelectHost(null);
setAddToWorkspaceDialog(null);
resetSessionRename();
resetWorkspaceRename();
}, [
resetSessionRename,
resetWorkspaceRename,
setAddToWorkspaceDialog,
setIsCreateWorkspaceOpen,
setIsQuickSwitcherOpen,
setProtocolSelectHost,
setQuickSearch,
]);
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
const {
accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, closeWorkspace, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell,
connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent,
customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost,
followAppTerminalTheme,
groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost,
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal,
hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename,
resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen,
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
resolveEmptyVaultConflict, resolvedTheme, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
sessionRenameValue, sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
setNavigateToSection, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
@@ -139,7 +172,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
return (
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" data-terminal-appearance-root onContextMenu={handleRootContextMenu}>
<TopTabs
theme={resolvedTheme}
hosts={hosts}
@@ -193,6 +226,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
followAppTerminalTheme={followAppTerminalTheme}
hostById={hostById}
themeById={themeById}
resolveSessionAppearance={resolveSessionAppearance}
onConnect={handleConnectToHost}
onCreateLocalTerminal={handleCreateLocalTerminal}
/>
@@ -299,6 +333,10 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
terminalTheme={currentTerminalTheme}
terminalThemeId={terminalThemeId}
followAppTerminalTheme={followAppTerminalTheme}
pickTerminalTheme={ctx.pickTerminalTheme}
clearThemeIntent={ctx.clearThemeIntent}
settleManualThemeIntent={ctx.settleManualThemeIntent}
resolveSessionAppearance={ctx.resolveSessionAppearance}
accentMode={accentMode}
customAccent={customAccent}
terminalSettings={terminalSettings}
@@ -309,7 +347,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
restoreTerminalCwd={settings.restoreTerminalCwd}
keyBindings={keyBindings}
onHotkeyAction={handleHotkeyAction}
onUpdateTerminalThemeId={setTerminalThemeId}
onUpdateTerminalThemeId={handleDefaultTerminalThemeChange}
onUpdateFollowAppTerminalThemeId={handleFollowAppTerminalThemeChange}
onUpdateTerminalFontFamilyId={setTerminalFontFamilyId}
onUpdateTerminalFontSize={setTerminalFontSize}
@@ -348,6 +386,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
onOpenVaultNoteFromChat={handleOpenVaultNoteFromChat}
onOpenVaultHostFromChat={handleOpenVaultHostFromChat}
onOpenVaultSectionFromChat={handleOpenVaultSectionFromChat}
onOpenVaultSnippetFromChat={handleOpenVaultSnippetFromChat}
onCreateLocalTerminal={handleCreateLocalTerminal}
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}
@@ -362,6 +401,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
sftpShowHiddenFiles={sftpShowHiddenFiles}
sftpUseCompressedUpload={sftpUseCompressedUpload}
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
terminalSidePanelAutoOpen={settings.terminalSidePanelAutoOpen}
terminalSidePanelAutoOpenTab={settings.terminalSidePanelAutoOpenTab}
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
setSftpFollowTerminalCwd={setSftpFollowTerminalCwd}
editorWordWrap={editorWordWrap}
@@ -426,6 +467,21 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
updateSnippetPackages(Array.from(new Set([...snippetPackages, pkg])))
}
/>
<QuickScriptEditorDialog
snippets={snippets}
packages={snippetPackages}
hosts={hosts}
customGroups={customGroups}
onCreateSnippet={(snippet) => updateSnippets([...snippets, snippet])}
onUpdateSnippet={(snippet) =>
updateSnippets(snippets.map((s) => (s.id === snippet.id ? snippet : s)))
}
onCreatePackage={(pkg) =>
updateSnippetPackages(Array.from(new Set([...snippetPackages, pkg])))
}
onUpdateHosts={updateHosts}
onRunSnippet={runSnippet}
/>
{/* Root-mounted AddToWorkspaceDialog — triggered by the focus-mode
"+" button (mode='append') or QuickSwitcher's "New Workspace"
+140
View File
@@ -101,6 +101,146 @@ test("root pages use the normal application theme", () => {
assert.equal(resolved, null);
});
test("follow-app workspace split view always uses the global terminal theme", () => {
const workspace: Workspace = {
id: "ws-1",
name: "Workspace",
viewMode: "split",
focusedSessionId: "session-1",
root: {
type: "split",
direction: "horizontal",
sizes: [50, 50],
children: [
{ type: "session", sessionId: "session-1" },
{ type: "session", sessionId: "session-2" },
],
},
} as unknown as Workspace;
const hostA = { id: "host-a", theme: hostTheme.id, themeOverride: true } as unknown as Host;
const hostB = { id: "host-b", theme: logTheme.id, themeOverride: true } as unknown as Host;
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: "ws-1",
followAppTerminalTheme: true,
hostById: new Map([
["host-a", hostA],
["host-b", hostB],
]),
sessionById: new Map([
["session-1", { id: "session-1", hostId: "host-a" } as TerminalSession],
["session-2", { id: "session-2", hostId: "host-b" } as TerminalSession],
]),
workspaceById: new Map([["ws-1", workspace]]),
});
assert.equal(resolved?.id, currentTheme.id);
});
test("manual workspace split view uses the focused session theme when panes differ", () => {
const workspace: Workspace = {
id: "ws-1",
name: "Workspace",
viewMode: "split",
focusedSessionId: "session-2",
root: {
type: "split",
direction: "horizontal",
sizes: [50, 50],
children: [
{ type: "pane", sessionId: "session-1" },
{ type: "pane", sessionId: "session-2" },
],
},
} as unknown as Workspace;
const hostA = { id: "host-a", theme: hostTheme.id, themeOverride: true } as unknown as Host;
const hostB = { id: "host-b", theme: logTheme.id, themeOverride: true } as unknown as Host;
const focusedTheme = theme("focused-intent");
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: "ws-1",
hostById: new Map([
["host-a", hostA],
["host-b", hostB],
]),
sessionById: new Map([
["session-1", { id: "session-1", hostId: "host-a" } as TerminalSession],
["session-2", { id: "session-2", hostId: "host-b" } as TerminalSession],
]),
workspaceById: new Map([["ws-1", workspace]]),
resolveSessionAppearance: ({ host }) => (
host?.id === "host-b"
? { themeId: focusedTheme.id, theme: focusedTheme, source: "intent", appThemeUpdate: null }
: { themeId: hostTheme.id, theme: hostTheme, source: "host-override", appThemeUpdate: null }
),
});
assert.equal(resolved?.id, focusedTheme.id);
});
test("manual split workspace falls back to the first tree session theme when focus is stale", () => {
const workspace: Workspace = {
id: "ws-1",
name: "Workspace",
viewMode: "split",
focusedSessionId: "missing-session",
root: {
type: "split",
direction: "horizontal",
sizes: [50, 50],
children: [
{ type: "pane", sessionId: "session-1" },
{ type: "pane", sessionId: "session-2" },
],
},
} as unknown as Workspace;
const hostA = { id: "host-a", theme: hostTheme.id, themeOverride: true } as unknown as Host;
const hostB = { id: "host-b", theme: logTheme.id, themeOverride: true } as unknown as Host;
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: "ws-1",
hostById: new Map([
["host-a", hostA],
["host-b", hostB],
]),
sessionById: new Map([
["session-1", { id: "session-1", hostId: "host-a" } as TerminalSession],
["session-2", { id: "session-2", hostId: "host-b" } as TerminalSession],
]),
workspaceById: new Map([["ws-1", workspace]]),
});
assert.equal(resolved?.id, hostTheme.id);
});
test("manual mode prefers runtime session appearance over stale host theme ids", () => {
const intentTheme = theme("intent-theme");
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: "session-1",
hostById: new Map([
["host-1", { id: "host-1", theme: hostTheme.id, themeOverride: true } as unknown as Host],
]),
sessionById: new Map([
["session-1", { id: "session-1", hostId: "host-1" } as TerminalSession],
]),
resolveSessionAppearance: () => ({
themeId: intentTheme.id,
theme: intentTheme,
source: "intent",
appThemeUpdate: null,
}),
});
assert.equal(resolved?.id, intentTheme.id);
});
test("chrome theme sync waits until a newly opened session is present in deps", () => {
assert.equal(
isActiveChromeThemeResolvable({
+28 -11
View File
@@ -1,5 +1,16 @@
import { fromEditorTabId, isEditorTabId } from "../state/activeTabStore";
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from "../../domain/terminalAppearance";
import type {
ResolvedAppearance,
TerminalAppearanceHostScope,
} from "../../domain/terminalAppearanceRuntime";
import { collectSessionIds } from "../../domain/workspace";
import type { EditorTab } from "../state/editorTabStore";
import type { LogView } from "../state/logViewState";
import type { Host, TerminalSession, TerminalTheme, Workspace } from "../../types";
import { resolveWorkspaceTargetSessionFromMap } from "./workTabSurface";
export type ResolveActiveChromeThemeInput = {
accentMode: "theme" | "custom";
activeTabId: string;
@@ -9,6 +20,7 @@ export type ResolveActiveChromeThemeInput = {
followAppTerminalTheme: boolean;
hostById: Map<string, Host>;
logViews: readonly LogView[];
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
sessionById: Map<string, TerminalSession>;
themeById: Map<string, TerminalTheme>;
workspaceById: Map<string, Workspace>;
@@ -33,11 +45,6 @@ export function isActiveChromeThemeResolvable({
if (sessionById.has(activeTabId)) return true;
return false;
}
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from "../../domain/terminalAppearance";
import { collectSessionIds } from "../../domain/workspace";
import type { EditorTab } from "../state/editorTabStore";
import type { LogView } from "../state/logViewState";
import type { Host, TerminalSession, TerminalTheme, Workspace } from "../../types";
export function resolveActiveChromeTheme({
accentMode,
@@ -48,14 +55,23 @@ export function resolveActiveChromeTheme({
followAppTerminalTheme,
hostById,
logViews,
resolveSessionAppearance,
sessionById,
themeById,
workspaceById,
}: ResolveActiveChromeThemeInput): TerminalTheme | null {
if (activeTabId === "vault" || activeTabId === "sftp") return null;
const resolveHostScope = (hostId: string): TerminalAppearanceHostScope => {
const host = hostById.get(hostId) ?? null;
return { host, isEphemeral: !host || !hostById.has(host.id) };
};
const resolveHostTheme = (hostId: string): TerminalTheme => {
if (followAppTerminalTheme) return currentTerminalTheme;
if (resolveSessionAppearance) {
return resolveSessionAppearance(resolveHostScope(hostId)).theme;
}
const host = hostById.get(hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
@@ -79,12 +95,10 @@ export function resolveActiveChromeTheme({
const workspace = workspaceById.get(activeTabId);
if (workspace) {
if (followAppTerminalTheme) return currentTerminalTheme;
if (workspace.viewMode === "focus") {
const workspaceSessionIds = collectSessionIds(workspace.root);
const focusedSession = (workspace.focusedSessionId
? sessionById.get(workspace.focusedSessionId)
: null)
?? workspaceSessionIds.map((id) => sessionById.get(id)).find(Boolean);
const focusedSession = resolveWorkspaceTargetSessionFromMap(workspace, sessionById);
return focusedSession ? resolveSessionTheme(focusedSession) : null;
}
@@ -95,7 +109,10 @@ export function resolveActiveChromeTheme({
const firstTheme = resolveSessionTheme(workspaceSessions[0]);
const allSame = workspaceSessions.every((session) => resolveSessionTheme(session).id === firstTheme.id);
return allSame ? firstTheme : null;
if (allSame) return firstTheme;
const focusedSession = resolveWorkspaceTargetSessionFromMap(workspace, sessionById);
return focusedSession ? resolveSessionTheme(focusedSession) : null;
}
const session = sessionById.get(activeTabId);
+5
View File
@@ -70,13 +70,17 @@ const TOP_TABS_THEME_PROPERTIES = [
'--muted-foreground',
] as const;
let topTabsChromeThemeVarsApplied = false;
export function clearTopTabsChromeThemeVars(): void {
if (typeof document === 'undefined') return;
if (!topTabsChromeThemeVarsApplied) return;
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
if (!tabsRoot) return;
for (const property of TOP_TABS_THEME_PROPERTIES) {
removeStylePropertyIfSet(tabsRoot, property);
}
topTabsChromeThemeVarsApplied = false;
}
export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
@@ -107,6 +111,7 @@ export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
setStylePropertyIfChanged(tabsRoot, '--top-tabs-muted', 'hsl(var(--muted-foreground))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-active-bg', 'hsl(var(--background))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
topTabsChromeThemeVarsApplied = true;
}
export function hasActiveChromeThemeDataset(): boolean {
+62 -4
View File
@@ -119,11 +119,13 @@ test('terminal content surface is limited to sessions and workspaces', () => {
test('shared host tree resolves active host ids across work tab types', () => {
const sessions = [
{ id: 'session-1', hostId: 'host-1' },
{ id: 'session-2', hostId: 'host-2' },
{ id: 'session-2', hostId: 'host-2', workspaceId: 'workspace-1' },
] as TerminalSession[];
const workspaces = [
{ id: 'workspace-1', focusedSessionId: 'session-2' },
] as Workspace[];
const workspaces = [{
id: 'workspace-1',
focusedSessionId: 'session-2',
root: { id: 'pane-2', type: 'pane', sessionId: 'session-2' },
}] as Workspace[];
const editorTabs = [
{ id: 'file-1', hostId: 'host-3' },
] as EditorTab[];
@@ -134,6 +136,62 @@ test('shared host tree resolves active host ids across work tab types', () => {
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'log-1', sessions, workspaces, editorTabs }), null);
});
test('shared host tree falls back to the first workspace session when focused session is missing', () => {
const sessions = [
{ id: 'session-1', hostId: 'host-1', workspaceId: 'workspace-1' },
{ id: 'session-2', hostId: 'host-2', workspaceId: 'workspace-1' },
] as TerminalSession[];
const workspaces = [{
id: 'workspace-1',
focusedSessionId: 'missing-session',
root: {
id: 'split-1',
type: 'split',
direction: 'horizontal',
children: [
{ id: 'pane-1', type: 'pane', sessionId: 'session-1' },
{ id: 'pane-2', type: 'pane', sessionId: 'session-2' },
],
sizes: [0.5, 0.5],
},
}] as Workspace[];
assert.equal(resolveWorkTabActiveHostId({
activeTabId: 'workspace-1',
sessions,
workspaces,
editorTabs: [],
}), 'host-1');
});
test('shared host tree fallback prefers workspace tree order over sessions array order', () => {
const sessions = [
{ id: 'session-2', hostId: 'host-2', workspaceId: 'workspace-1' },
{ id: 'session-1', hostId: 'host-1', workspaceId: 'workspace-1' },
] as TerminalSession[];
const workspaces = [{
id: 'workspace-1',
focusedSessionId: 'missing-session',
root: {
id: 'split-1',
type: 'split',
direction: 'horizontal',
children: [
{ id: 'pane-1', type: 'pane', sessionId: 'session-1' },
{ id: 'pane-2', type: 'pane', sessionId: 'session-2' },
],
sizes: [0.5, 0.5],
},
}] as Workspace[];
assert.equal(resolveWorkTabActiveHostId({
activeTabId: 'workspace-1',
sessions,
workspaces,
editorTabs: [],
}), 'host-1');
});
test('shared host tree uses the active host theme when follow-app terminal theme is off', () => {
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
const hostTheme = makeTheme('host-light', 'light', '#fafafa');
+31 -6
View File
@@ -3,6 +3,7 @@ import {
isEditorTabId,
} from '../state/activeTabStore';
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from '../../domain/terminalAppearance';
import { collectSessionIds } from '../../domain/workspace';
import type { EditorTab } from '../state/editorTabStore';
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
@@ -97,6 +98,34 @@ export function isTerminalContentTabSurface({
return sessionIds.has(activeTabId) || workspaceIds.has(activeTabId);
}
export function resolveWorkspaceTargetSession(
workspace: Workspace,
sessions: readonly TerminalSession[],
): TerminalSession | undefined {
const sessionById = new Map(sessions.map((session) => [session.id, session]));
return resolveWorkspaceTargetSessionFromMap(workspace, sessionById);
}
export function resolveWorkspaceTargetSessionFromMap(
workspace: Workspace,
sessionById: ReadonlyMap<string, TerminalSession>,
): TerminalSession | undefined {
const orderedSessionIds = collectSessionIds(workspace.root);
const workspaceSessionIdSet = new Set(orderedSessionIds);
const focusedSession = workspace.focusedSessionId
? sessionById.get(workspace.focusedSessionId)
: undefined;
const validFocusedSession = focusedSession && workspaceSessionIdSet.has(focusedSession.id)
? focusedSession
: undefined;
if (validFocusedSession) return validFocusedSession;
for (const sessionId of orderedSessionIds) {
const session = sessionById.get(sessionId);
if (session) return session;
}
return undefined;
}
export function resolveWorkTabActiveHostId({
activeTabId,
editorTabs,
@@ -119,12 +148,8 @@ export function resolveWorkTabActiveHostId({
const activeWorkspace = workspaces.find((workspace) => workspace.id === activeTabId);
if (!activeWorkspace) return null;
const focusedSessionId = activeWorkspace.focusedSessionId;
if (focusedSessionId) {
return sessions.find((session) => session.id === focusedSessionId)?.hostId ?? null;
}
return null;
const targetSession = resolveWorkspaceTargetSession(activeWorkspace, sessions);
return targetSession?.hostId ?? null;
}
export function resolveWorkTabHostTreeTheme({
+2
View File
@@ -4,6 +4,7 @@ import { enVaultMessages } from './en/vault';
import { enTerminalMessages } from './en/terminal';
import { enAiMessages } from './en/ai';
import { enSystemManagerMessages } from './en/systemManager';
import { enScriptsMessages } from './en/scripts';
export type { Messages } from './types';
@@ -13,6 +14,7 @@ const en: Messages = {
...enTerminalMessages,
...enAiMessages,
...enSystemManagerMessages,
...enScriptsMessages,
};
export default en;
+24 -3
View File
@@ -4,6 +4,10 @@ export const enAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Agent Settings',
'ai.chat.preparing': 'Preparing…',
'ai.chat.compactingContext': 'Compacting earlier context…',
'ai.chat.compactingStep': 'Trimming context for next step…',
'ai.chat.compactionRetry': 'Request was too large. Compacting context and retrying…',
'ai.chat.compactionBanner': 'Context compacted: {before}K → {after}K tokens',
'ai.title': 'AI',
'ai.description': 'Configure AI providers, agents, and safety settings',
'ai.providers': 'Providers',
@@ -220,10 +224,10 @@ export const enAiMessages: Messages = {
'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 once, Escape to reject',
'ai.chat.toolApprovalHint': 'Enter once · Esc reject',
'ai.chat.approve': 'Approve',
'ai.chat.approveOnce': 'Allow once',
'ai.chat.alwaysAllow': 'Always allow',
'ai.chat.approveOnce': 'Once',
'ai.chat.alwaysAllow': 'Always',
'ai.chat.slashStopDesc': 'Stop the current AI turn and cancel in-flight tools',
'ai.chat.reject': 'Reject',
'ai.chat.toolLabel': 'Tool',
@@ -393,6 +397,23 @@ export const enAiMessages: Messages = {
'ai.chat.artifact.unavailableTitle': 'Unavailable',
'ai.chat.artifact.noteMissing': 'This note is no longer in your Vault.',
'ai.chat.artifact.hostMissing': 'This host is no longer in your Vault.',
'ai.chat.artifact.snippetMissing': 'This snippet or script is no longer in your Vault.',
'ai.chat.artifact.openSnippets': 'Open Snippets',
'ai.chat.artifact.snippetsSummary': '{count} snippets in Vault',
'ai.chat.artifact.scriptsSummary': '{count} scripts in Vault',
'ai.chat.artifact.snippetFallback': 'Vault snippet',
'ai.chat.artifact.scriptFallback': 'Automation script',
'ai.chat.artifact.scriptLanguage': '{language} script',
'ai.chat.artifact.snippetDeleted': 'Snippet deleted',
'ai.chat.artifact.scriptDeleted': 'Script deleted',
'ai.chat.artifact.snippetRan': 'Snippet executed',
'ai.chat.artifact.scriptStarted': 'Script run started',
'ai.chat.artifact.scriptRunStatus': 'Script run {status}',
'ai.chat.artifact.scriptRunsSummary': '{count} script runs',
'ai.chat.artifact.scriptRunStopped': 'Script run stopped',
'ai.chat.artifact.scriptRunPaused': 'Script run paused',
'ai.chat.artifact.scriptRunResumed': 'Script run resumed',
'ai.chat.artifact.scriptReference': 'nct API reference',
'zmodem.waitingForRemote': 'Waiting for remote...',
'zmodem.uploading': 'Uploading',
'zmodem.downloading': 'Downloading',
+31 -1
View File
@@ -287,6 +287,23 @@ export const enCoreMessages: Messages = {
'settings.appearance.uiFont.desc': 'Choose the font for the application interface',
'settings.appearance.windowOpacity': 'Window Opacity',
'settings.appearance.windowOpacity.desc': 'Adjust the transparency of the entire application window. Lower values also fade terminal text. Some Linux desktop environments may not support this.',
'settings.appearance.appIcon': 'App Icon',
'settings.appearance.appIcon.desc': 'Choose a dock/taskbar icon style to make Netcatty easier to spot when switching apps. Applies at runtime; the installed app bundle icon is unchanged.',
'settings.appearance.appIcon.original': 'Original',
'settings.appearance.appIcon.bright': 'Bright',
'settings.appearance.appIcon.dark': 'Dark',
'settings.appearance.appIcon.colorful': 'Colorful',
'settings.appearance.appIcon.highContrast': 'High contrast',
'settings.appearance.appIcon.group.classic': 'Colored backgrounds',
'settings.appearance.appIcon.group.white': 'White background',
'settings.appearance.appIcon.group.special': 'Hidden',
'settings.appearance.appIcon.whiteNavy': 'White · Navy cat',
'settings.appearance.appIcon.whiteSky': 'White · Sky cat',
'settings.appearance.appIcon.whiteRose': 'White · Rose cat',
'settings.appearance.appIcon.whiteEmerald': 'White · Emerald cat',
'settings.appearance.appIcon.whiteAmber': 'White · Amber cat',
'settings.appearance.appIcon.whiteViolet': 'White · Violet cat',
'settings.appearance.appIcon.rainbow': 'Rainbow cat',
// Settings > Terminal
'settings.terminal.section.theme': 'Terminal Theme',
'settings.terminal.themeModal.title': 'Select Theme',
@@ -304,6 +321,7 @@ export const enCoreMessages: Messages = {
'settings.terminal.section.keyboard': 'Keyboard',
'settings.terminal.section.accessibility': 'Accessibility',
'settings.terminal.section.behavior': 'Behavior',
'settings.terminal.section.sidePanel': 'Side Panel',
'settings.terminal.section.scrollback': 'Scrollback',
'settings.terminal.section.keywordHighlight': 'Keyword highlighting',
'settings.terminal.font.family': 'Font',
@@ -351,6 +369,17 @@ export const enCoreMessages: Messages = {
'settings.terminal.keyboard.optionArrowWordJump.desc':
'Send Meta-b / Meta-f on Option+Left/Right so the shell moves by word, instead of the default ^[[1;3D / ^[[1;3C',
'settings.terminal.accessibility.minimumContrastRatio': 'Minimum contrast ratio',
'settings.terminal.sidePanel.autoOpen': 'Open side panel with terminal',
'settings.terminal.sidePanel.autoOpen.desc': 'Automatically open a side panel when a terminal connects.',
'settings.terminal.sidePanel.autoOpenPane': 'Default side panel',
'settings.terminal.sidePanel.autoOpenPane.desc': 'Choose which panel opens first.',
'settings.terminal.sidePanel.pane.sftp': 'SFTP',
'settings.terminal.sidePanel.pane.scripts': 'Scripts',
'settings.terminal.sidePanel.pane.history': 'History',
'settings.terminal.sidePanel.pane.theme': 'Theme',
'settings.terminal.sidePanel.pane.system': 'System',
'settings.terminal.sidePanel.pane.notes': 'Notes',
'settings.terminal.sidePanel.pane.ai': 'AI',
'settings.terminal.accessibility.minimumContrastRatio.desc':
'Adjust colors to meet contrast requirements (1 = disabled, 21 = max)',
'settings.terminal.behavior.rightClick': 'Right-click behavior',
@@ -558,6 +587,7 @@ export const enCoreMessages: Messages = {
'sync.cloudSync': 'Cloud Sync',
'sync.settings': 'Sync Settings',
'sync.active': 'Cloud Sync Active',
'sync.pending': 'Changes pending sync',
'sync.syncing': 'Syncing...',
'sync.error': 'Sync Error',
'sync.notConfigured': 'Not Configured',
@@ -630,7 +660,7 @@ export const enCoreMessages: Messages = {
'vault.nav.keychain': 'Keychain',
'vault.nav.proxies': 'Proxies',
'vault.nav.portForwarding': 'Port Forwarding',
'vault.nav.snippets': 'Snippets',
'vault.nav.snippets': 'Scripts',
'vault.nav.notes': 'Notes',
'vault.nav.knownHosts': 'Known Hosts',
'vault.nav.logs': 'Logs',
+123
View File
@@ -0,0 +1,123 @@
export const enScriptsMessages = {
'scripts.meta.name': 'Name',
'scripts.meta.language': 'Language',
'scripts.meta.description': 'Description',
'scripts.meta.descriptionPlaceholder': 'Optional notes about this script',
'scripts.meta.trigger': 'Trigger',
'scripts.meta.triggerPattern': 'Output pattern (regex)',
'scripts.meta.code': 'Script',
'scripts.trigger.manual': 'Manual run',
'scripts.trigger.onConnect': 'Run on connect',
'scripts.trigger.onOutput': 'Run on output match',
'scripts.trigger.onOutputHint': 'Fires when server output matches the pattern (user keystroke echo is ignored). With no target hosts configured, listens on the current connected session; with targets, only those hosts. Disabled in alternate-screen apps such as vim or htop. Suppressed while another script is running on this session; rechecked after it finishes. For in-flow waits, use nct.screen.waitFor in script code.',
'scripts.actions.save': 'Save',
'scripts.actions.runNow': 'Run now',
'scripts.actions.openEditor': 'Open editor',
'scripts.actions.openEditorHint': 'Edit script in a larger window',
'scripts.editor.modalTitle': 'Script editor',
'scripts.editor.modalSubtitle': 'Edit metadata and script code in a larger workspace.',
'scripts.editor.lineCount': '{count} lines',
'scripts.editor.resize': 'Resize editor',
'scripts.targets.hint': 'Automatic triggers apply to selected hosts, or to all hosts when that option is enabled.',
'scripts.targets.connectOrderHint': 'Run order for connect scripts is configured per host under Host details → Automation.',
'scripts.actions.runNowHint': 'Run on selected targets, or on all connectable hosts when that option is enabled.',
'scripts.actions.runParallel': 'Run on all tabs (parallel)',
'scripts.actions.runSequential': 'Run on all tabs (sequential)',
'scripts.actions.skippedConnectingSessions': '{count} tab(s) still connecting and were skipped',
'scripts.actions.noRunnableHosts': 'No connectable hosts match this script\'s targets',
'scripts.sidePanel.library': 'Library',
'scripts.sidePanel.running': 'Running',
'scripts.sidePanel.newScript': 'New script',
'scripts.running.empty': 'No scripts are running in this session.',
'scripts.running.unnamed': 'Untitled script',
'scripts.running.status.running': 'Running',
'scripts.running.status.paused': 'Paused',
'scripts.running.status.completed': 'Completed',
'scripts.running.status.failed': 'Failed',
'scripts.running.waitingFor': 'Waiting for {pattern}',
'scripts.running.waitingForLabel': 'Waiting for',
'scripts.running.waitingForShellPrompt': 'shell prompt (# or $)',
'scripts.running.lastSent': 'Sent: {command}',
'scripts.recording.start': 'Start recording',
'scripts.recording.active': 'Stop recording',
'scripts.recording.startHint': 'Record actions in the focused terminal and generate nct script code',
'scripts.recording.unavailableHint': 'Recording is not available here — use the scripts sidebar on the right',
'scripts.recording.activeHint': 'Recording this terminal. Type commands as usual; click Stop or use the REC control in the toolbar to finish and save.',
'scripts.recording.started': 'Recording started — operate in the terminal',
'scripts.recording.noSession': 'Connect a terminal first (single tab or workspace)',
'scripts.recording.alreadyActive': 'Another terminal is already recording',
'scripts.recording.stop': 'Stop recording',
'scripts.recording.pause': 'Pause recording',
'scripts.recording.resume': 'Resume recording',
'scripts.recording.saveTitle': 'Save recorded script',
'scripts.recording.namePlaceholder': 'Script name',
'scripts.recording.packagePlaceholder': 'Save to folder',
'scripts.recording.rootPackage': 'Root',
'scripts.recording.save': 'Save',
'scripts.recording.saveAndEdit': 'Save and edit',
'scripts.recording.helpTitle': 'How to record a script',
'scripts.recording.helpIntro': 'Recording turns what you do in the terminal into a reusable automation script — useful for deploys, health checks, and other repetitive tasks.',
'scripts.recording.helpStep1': 'Connect to a host. A standalone terminal tab or a terminal inside a workspace both work — open the scripts sidebar on the right.',
'scripts.recording.helpStep2': 'Click Start recording. A red REC badge in the terminal toolbar means recording is active.',
'scripts.recording.helpStep3': 'Type commands in the terminal as you normally would. Each Enter press is captured as one step.',
'scripts.recording.helpStep4': 'When you are done, click Stop recording, or use the stop control next to REC in the terminal toolbar.',
'scripts.recording.helpStep5': 'Name the script and save. Choose Save and edit if you want to fine-tune the generated code in the script editor.',
'scripts.recording.helpTipsTitle': 'Tips',
'scripts.recording.helpTip1': 'Pauses longer than 1 second between actions are recorded as wait time so playback is not too fast.',
'scripts.recording.helpTip2': 'After each command, recording waits for a shell prompt (such as $ or #) before the next step.',
'scripts.recording.helpTip3': 'Password input is marked sensitive and is not stored in plain text in the script.',
'scripts.recording.helpTip4': 'For vim, menus, or other interactive flows, record the main commands first, then refine the script manually.',
'scripts.dialog.title': 'Script',
'scripts.dialog.ok': 'OK',
'scripts.dialog.waitForTimeoutTitle': 'Wait timed out',
'scripts.dialog.retry': 'Retry',
'scripts.dialog.skip': 'Skip',
'scripts.dialog.abort': 'Abort',
'scripts.running.stepProgress': 'Step {current} / {total}',
'scripts.running.determinateProgress': '{label} {current}/{total}',
'scripts.running.progressFallback': 'Progress',
'scripts.running.operationsCount': '{count} operations',
'scripts.running.opsPrefix': '',
'scripts.running.opsSuffix': ' operations',
'scripts.running.elapsedLabel': 'Elapsed',
'scripts.running.lastSentLabel': 'Sent:',
'scripts.running.elapsed': '{elapsed}',
'scripts.running.completedSummary': 'Completed · {count} operations · {elapsed}',
'scripts.running.dismissHint': 'Tap close to dismiss',
'scripts.running.dismiss': 'Close',
'scripts.running.viewLogs': 'View logs',
'scripts.running.logTitle': '{name} · Run log',
'scripts.running.logEmpty': 'No log output yet',
'scripts.running.pause': 'Pause',
'scripts.running.resume': 'Resume',
'scripts.running.stop': 'Stop',
'scripts.recording.saved': 'Script saved',
'scripts.recording.savedNamed': 'Saved "{name}"',
'scripts.observer.blocked': 'Observer mode blocks scripts that write to the terminal.',
'vault.section.scripts': 'Scripts',
'vault.nav.scripts': 'Scripts',
'snippets.action.newScript': 'New automation script',
'hostDetails.section.automation': 'Automation',
'hostDetails.automation.loginScript': 'Login script',
'hostDetails.automation.loginScriptPlaceholder': 'Select a script',
'hostDetails.automation.none': 'None',
'hostDetails.automation.outputTriggers': 'Output triggers',
'hostDetails.automation.addTrigger': 'Add trigger',
'hostDetails.automation.triggerPatternPlaceholder': 'Regex pattern',
'hostDetails.automation.linkedScripts': 'Linked scripts',
'hostDetails.automation.linkedScriptsEmpty': 'No scripts linked to this host yet.',
'hostDetails.automation.linkScriptPlaceholder': 'Link existing script…',
'hostDetails.automation.unlink': 'Unlink',
'hostDetails.automation.equivalenceHint': 'Links add this host to the script\'s target list in Scripts. Both views stay in sync.',
'hostDetails.automation.queueHint': 'Scripts run in order when this host connects. Global scripts run first, then this queue.',
'hostDetails.automation.globalScripts': 'Global connect scripts',
'hostDetails.automation.globalScriptsHint': 'Runs first on every connect. Reorder in Scripts library sort order.',
'hostDetails.automation.connectQueue': 'This host\'s run queue',
'hostDetails.automation.connectQueueEmpty': 'No connect scripts queued for this host yet.',
'hostDetails.automation.addToQueuePlaceholder': 'Add script to queue…',
'hostDetails.automation.moveUp': 'Move up',
'hostDetails.automation.moveDown': 'Move down',
'hostDetails.automation.removeFromQueue': 'Remove from queue',
'hostDetails.automation.dragHandle': 'Drag to reorder',
'hostDetails.automation.queueDragHint': 'Drag items to reorder the connect queue.',
};
+32 -20
View File
@@ -127,6 +127,9 @@ export const enTerminalMessages: Messages = {
'terminal.osc7Setup.targets': 'Possible files to update',
'terminal.osc7Setup.command': 'Command to run',
'terminal.osc7Setup.run': 'Run setup',
'terminal.osc7Setup.running': 'Configuring...',
'terminal.osc7Setup.configured': 'Directory tracking configured',
'terminal.osc7Setup.failed': 'Directory tracking setup failed',
'terminal.osc7Setup.sent': 'Directory tracking setup sent to terminal',
'terminal.ymodem.selectFile': 'Select file to send',
'terminal.ymodem.allFiles': 'All files',
@@ -159,6 +162,7 @@ export const enTerminalMessages: Messages = {
'terminal.auth.proxyCredentialsUnavailable': 'Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.',
'terminal.auth.keyUnavailableFallbackPassword': 'Saved SSH key is unavailable on this device. Falling back to password authentication.',
'terminal.progress.timeoutIn': 'Timeout in {seconds}s',
'terminal.progress.waitingForUserInput': 'Waiting for user input',
'terminal.progress.disconnected': 'Disconnected',
'terminal.progress.cancelling': 'Cancelling...',
'terminal.progress.startOver': 'Start over',
@@ -576,24 +580,30 @@ export const enTerminalMessages: Messages = {
'keychain.edit.exportToHost': 'Export to host',
// Snippets
'snippets.searchPlaceholder': 'Search snippets...',
'snippets.searchPlaceholder': 'Search scripts...',
'snippets.action.newSnippet': 'New Snippet',
'snippets.action.newPackage': 'New Package',
'snippets.action.newPackage': 'New Script Package',
'snippets.action.import': 'Import',
'snippets.action.selectSnippets': 'Select snippets',
'snippets.panel.newTitle': 'New Snippet',
'snippets.panel.editTitle': 'Edit Snippet',
'snippets.panel.newAutomationTitle': 'New Automation Script',
'snippets.panel.editAutomationTitle': 'Edit Automation Script',
'snippets.panel.resizeWidth': 'Resize panel width',
'snippets.field.description': 'Action description',
'snippets.field.descriptionPlaceholder': 'Example: check network load',
'snippets.field.package': 'Add a Package',
'snippets.field.packagePlaceholder': 'Select or create package',
'snippets.field.createPackage': 'Create Package',
'snippets.field.package': 'Add a script package',
'snippets.field.packagePlaceholder': 'Select or create script package',
'snippets.field.createPackage': 'Create Script Package',
'snippets.field.scriptRequired': 'Script *',
'snippets.scriptEditor.expand': 'Open in dialog',
'snippets.scriptEditor.resize': 'Resize editor height',
'snippets.scriptEditor.modalTitle': 'Edit script',
'snippets.targets.title': 'Targets',
'snippets.targets.add': 'Add targets',
'snippets.targets.allHosts': 'Apply to all hosts',
'snippets.targets.allHostsShort': 'All hosts',
'snippets.targets.allHostsActive': 'Applies to every connectable host.',
'snippets.history.title': 'Shell History',
'snippets.history.subtitle': '{count} commands',
'snippets.history.emptyTitle': 'No shell history yet',
@@ -606,15 +616,17 @@ export const enTerminalMessages: Messages = {
'snippets.history.time.minutesAgo': '{count}m ago',
'snippets.history.time.hoursAgo': '{count}h ago',
'snippets.history.time.daysAgo': '{count}d ago',
'snippets.breadcrumb.allPackages': 'All packages',
'snippets.breadcrumb.allPackages': 'All script packages',
'snippets.breadcrumb.separator': '›',
'snippets.empty.title': 'Create snippet',
'snippets.empty.desc': 'Save your most used commands as snippets to reuse them in one click.',
'snippets.empty.title': 'Create scripts',
'snippets.empty.desc': 'Save common commands as code snippets, or write automation scripts for repeatable operations.',
'snippets.search.noResults.title': 'No matches',
'snippets.search.noResults.desc': 'No snippets or packages match "{query}". Try a different search term or clear the search to browse.',
'snippets.section.packages': 'Packages',
'snippets.section.snippets': 'Snippets',
'snippets.package.count': '{count} snippet(s)',
'snippets.search.noResults.desc': 'No scripts or script packages match "{query}". Try a different search term or clear the search to browse.',
'snippets.section.packages': 'Script Packages',
'snippets.section.snippets': 'Scripts',
'snippets.kind.codeSnippet': 'Code snippet',
'snippets.kind.automationScript': 'Automation script',
'snippets.package.count': '{count} script(s)',
'snippets.commandFallback': 'Command',
'snippets.view.grid': 'Grid',
'snippets.view.list': 'List',
@@ -623,7 +635,7 @@ export const enTerminalMessages: Messages = {
'snippets.selection.deselectAll': 'Deselect all',
'snippets.selection.exportSelected': 'Export selected ({count})',
'snippets.export.snippet': 'Export snippet',
'snippets.export.package': 'Export package',
'snippets.export.package': 'Export script package',
'snippets.export.toast.empty': 'No snippets to export.',
'snippets.export.toast.successTitle': 'Export ready',
'snippets.export.toast.success': 'Exported {count} snippet(s).',
@@ -639,26 +651,26 @@ export const enTerminalMessages: Messages = {
'snippets.import.modal.chooseFile': 'Choose file',
'snippets.import.modal.downloadExamples': 'Download samples',
'snippets.import.modal.multipleFiles': '{count} files selected',
'snippets.import.modal.parsedSummary': '{files} file(s), {total} snippet(s), {packages} package(s), {conflicts} duplicate command(s). Host bindings are ignored.',
'snippets.import.modal.parsedSummary': '{files} file(s), {total} script(s), {packages} script package(s), {conflicts} duplicate command(s). Host bindings are ignored.',
'snippets.import.modal.confirm': 'Confirm import',
'snippets.import.conflict.title': 'Import snippets?',
'snippets.import.conflict.desc': '{file} contains {total} snippet(s). {conflicts} duplicate command(s) already exist.',
'snippets.import.conflict.hostBindingsNote': 'Host bindings are not imported or exported with snippets.',
'snippets.import.conflict.skip': 'Skip duplicates',
'snippets.import.conflict.overwrite': 'Overwrite duplicates',
'snippets.packageDialog.title': 'New Package',
'snippets.packageDialog.title': 'New Script Package',
'snippets.packageDialog.parent': 'Parent: {parent}',
'snippets.packageDialog.root': 'Root',
'snippets.packageDialog.placeholder': 'e.g. ops/maintenance',
'snippets.packageDialog.hint': 'Use "/" to create nested packages.',
'snippets.packageDialog.hint': 'Use "/" to create nested script packages.',
// Snippets Rename Dialog
'snippets.renameDialog.title': 'Rename Package',
'snippets.renameDialog.title': 'Rename Script Package',
'snippets.renameDialog.currentPath': 'Current path: {path}',
'snippets.renameDialog.placeholder': 'Enter new name',
'snippets.renameDialog.error.empty': 'Package name cannot be empty',
'snippets.renameDialog.error.duplicate': 'A package with this name already exists',
'snippets.renameDialog.error.invalidChars': 'Package name can only contain letters, numbers, hyphens, and underscores',
'snippets.renameDialog.error.empty': 'Script package name cannot be empty',
'snippets.renameDialog.error.duplicate': 'A script package with this name already exists',
'snippets.renameDialog.error.invalidChars': 'Script package name can only contain letters, numbers, hyphens, and underscores',
'snippets.field.noAutoRun': 'Paste only (do not auto-execute)',
// Snippet Shortkey
+16
View File
@@ -6,11 +6,17 @@ export const enVaultMessages: Messages = {
'notes.action.newNote': 'New Note',
'notes.action.newGroup': 'New Folder',
'notes.action.importMarkdown': 'Import Markdown',
'notes.action.exportNote': 'Export Note',
'notes.action.exportGroup': 'Export Folder',
'notes.action.exportAll': 'Export Notes',
'notes.import.toast.success': 'Imported {count} note(s)',
'notes.import.toast.noNotes': 'No Markdown files were imported.',
'notes.import.toast.failed': 'Failed to import Markdown files.',
'notes.import.toast.inProgress': 'A Markdown import is already in progress.',
'notes.import.toast.skipped': 'Skipped {count} unsupported file(s).',
'notes.export.toast.success': 'Exported {count} note(s)',
'notes.export.toast.empty': 'No notes to export.',
'notes.export.toast.failed': 'Failed to export notes.',
'notes.codeBlock.copied': 'Copied',
'notes.codeBlock.copyFailed': 'Failed to copy code block.',
'notes.group.prompt': 'Folder path',
@@ -477,6 +483,7 @@ export const enVaultMessages: Messages = {
// Host Details
'hostDetails.title.details': 'Host Details',
'hostDetails.title.new': 'New Host',
'vault.panel.resizeWidth': 'Resize panel width',
'hostDetails.saveAria': 'Save',
'hostDetails.section.address': 'Address',
'hostDetails.hostname.placeholder': 'IP or Hostname',
@@ -494,6 +501,8 @@ export const enVaultMessages: Messages = {
'hostDetails.notes.tab.edit': 'Edit',
'hostDetails.notes.tab.preview': 'Preview',
'hostDetails.notes.preview.empty': 'Nothing to preview yet.',
'hostDetails.notes.toggle.show': 'Show notes editor',
'hostDetails.notes.toggle.hide': 'Hide notes editor',
'hostDetails.group.placeholder': 'Parent Group',
'hostDetails.section.credentials': 'Credentials',
'hostDetails.section.portCredentials': 'Port & Credentials',
@@ -516,6 +525,7 @@ export const enVaultMessages: Messages = {
'hostDetails.icon.colorManual': 'Manual',
'hostDetails.icon.colorChoice': 'Color',
'hostDetails.icon.autoColorLabel': 'Current color',
'hostDetails.icon.preview': 'Icon preview',
'hostDetails.icon.showLibrary': 'Show icon library',
'hostDetails.icon.hideLibrary': 'Hide icon library',
'hostDetails.icon.autoUsesDistro': 'Use Linux Distribution icon and selected color for this host.',
@@ -668,12 +678,17 @@ export const enVaultMessages: Messages = {
'hostDetails.proxyPanel.usernamePlaceholder': 'Username',
'hostDetails.proxyPanel.passwordPlaceholder': 'Password',
'hostDetails.proxyPanel.identities': 'Identities',
'hostDetails.proxyPanel.keychainIdentity': 'Keychain identity',
'hostDetails.proxyPanel.manualCredentials': 'Manual credentials',
'hostDetails.proxyPanel.remove': 'Remove Proxy',
'hostDetails.proxyPanel.savedProxy': 'Saved proxy',
'hostDetails.proxyPanel.selectSaved': 'Select saved proxy',
'hostDetails.proxyPanel.customProxy': 'Custom proxy',
'hostDetails.proxyPanel.missing': 'Missing',
'hostDetails.proxyPanel.missingSaved': 'Missing saved proxy',
'hostDetails.proxyPanel.missingIdentity': 'Missing keychain identity',
'hostDetails.proxyPanel.incompleteIdentity': 'Proxy identity needs a username and password',
'hostDetails.proxyPanel.unreadableIdentity': 'Proxy identity password cannot be read on this device',
'hostDetails.proxyPanel.error.required': 'Proxy host and port, or a ProxyCommand, are required.',
'hostDetails.envVars': 'Environment Variables',
'hostDetails.envVars.add': 'Add Environment Variable',
@@ -705,6 +720,7 @@ export const enVaultMessages: Messages = {
'hostDetails.telnetOn': 'Telnet on',
'hostDetails.port': 'port',
'hostDetails.telnet.credentials': 'Credentials',
'hostDetails.telnet.identity.placeholder': 'Select Telnet Identity',
'hostDetails.telnet.username': 'Telnet Username',
'hostDetails.telnet.password': 'Telnet Password',
'hostDetails.charset.placeholder': 'Charset (e.g. UTF-8)',
+17
View File
@@ -374,6 +374,23 @@ export const ruAiMessages: Messages = {
'ai.chat.artifact.unavailableTitle': 'Недоступно',
'ai.chat.artifact.noteMissing': 'Эта заметка больше не в Vault.',
'ai.chat.artifact.hostMissing': 'Этот хост больше не в Vault.',
'ai.chat.artifact.snippetMissing': 'Этот snippet или скрипт больше не в Vault.',
'ai.chat.artifact.openSnippets': 'Открыть Snippets',
'ai.chat.artifact.snippetsSummary': '{count} snippets в Vault',
'ai.chat.artifact.scriptsSummary': '{count} scripts в Vault',
'ai.chat.artifact.snippetFallback': 'Snippet Vault',
'ai.chat.artifact.scriptFallback': 'Automation script',
'ai.chat.artifact.scriptLanguage': 'Script {language}',
'ai.chat.artifact.snippetDeleted': 'Snippet удален',
'ai.chat.artifact.scriptDeleted': 'Script удален',
'ai.chat.artifact.snippetRan': 'Snippet выполнен',
'ai.chat.artifact.scriptStarted': 'Запуск script начат',
'ai.chat.artifact.scriptRunStatus': 'Script run {status}',
'ai.chat.artifact.scriptRunsSummary': '{count} script runs',
'ai.chat.artifact.scriptRunStopped': 'Script run остановлен',
'ai.chat.artifact.scriptRunPaused': 'Script run приостановлен',
'ai.chat.artifact.scriptRunResumed': 'Script run возобновлен',
'ai.chat.artifact.scriptReference': 'Справка nct API',
'zmodem.waitingForRemote': 'Ожидание удалённой стороны...',
'zmodem.uploading': 'Загрузка',
'zmodem.downloading': 'Скачивание',
+18
View File
@@ -276,6 +276,23 @@ export const ruCoreMessages: Messages = {
'settings.appearance.uiFont.desc': 'Выберите шрифт для интерфейса приложения',
'settings.appearance.windowOpacity': 'Прозрачность окна',
'settings.appearance.windowOpacity.desc': 'Настройте прозрачность всего окна приложения. При низких значениях текст терминала тоже бледнеет. В некоторых средах Linux это может не поддерживаться.',
'settings.appearance.appIcon': 'Значок приложения',
'settings.appearance.appIcon.desc': 'Выберите стиль значка для Dock/панели задач, чтобы Netcatty было проще найти при переключении приложений. Применяется во время работы; значок установленного пакета не меняется.',
'settings.appearance.appIcon.original': 'Оригинал',
'settings.appearance.appIcon.bright': 'Яркий',
'settings.appearance.appIcon.dark': 'Тёмный',
'settings.appearance.appIcon.colorful': 'Цветной',
'settings.appearance.appIcon.highContrast': 'Высокий контраст',
'settings.appearance.appIcon.group.classic': 'Цветной фон',
'settings.appearance.appIcon.group.white': 'Белый фон',
'settings.appearance.appIcon.group.special': 'Скрытые',
'settings.appearance.appIcon.whiteNavy': 'Белый · синий кот',
'settings.appearance.appIcon.whiteSky': 'Белый · голубой кот',
'settings.appearance.appIcon.whiteRose': 'Белый · розовый кот',
'settings.appearance.appIcon.whiteEmerald': 'Белый · изумрудный кот',
'settings.appearance.appIcon.whiteAmber': 'Белый · янтарный кот',
'settings.appearance.appIcon.whiteViolet': 'Белый · фиолетовый кот',
'settings.appearance.appIcon.rainbow': 'Радужный кот',
// Settings > Terminal
'settings.terminal.section.theme': 'Тема терминала',
'settings.terminal.themeModal.title': 'Выберите тему',
@@ -575,6 +592,7 @@ export const ruCoreMessages: Messages = {
'sync.cloudSync': 'Облачная синхронизация',
'sync.settings': 'Настройки синхронизации',
'sync.active': 'Облачная синхронизация активна',
'sync.pending': 'Есть несинхронизированные изменения',
'sync.syncing': 'Синхронизация...',
'sync.error': 'Ошибка синхронизации',
'sync.notConfigured': 'Не настроено',
+5
View File
@@ -145,6 +145,9 @@ export const ruTerminalMessages: Messages = {
'terminal.osc7Setup.targets': 'Возможные файлы для обновления',
'terminal.osc7Setup.command': 'Команда для запуска',
'terminal.osc7Setup.run': 'Запустить настройку',
'terminal.osc7Setup.running': 'Настройка...',
'terminal.osc7Setup.configured': 'Отслеживание каталога настроено',
'terminal.osc7Setup.failed': 'Не удалось настроить отслеживание каталога',
'terminal.osc7Setup.sent': 'Настройка отслеживания каталога отправлена в терминал',
'terminal.ymodem.selectFile': 'Выберите файл для отправки',
'terminal.ymodem.allFiles': 'Все файлы',
@@ -179,6 +182,7 @@ export const ruTerminalMessages: Messages = {
'terminal.auth.proxyCredentialsUnavailable': 'Учётные данные прокси не могут быть расшифрованы на этом устройстве. Откройте настройки хоста и заново введите пароль прокси.',
'terminal.auth.keyUnavailableFallbackPassword': 'Сохранённый SSH-ключ недоступен на этом устройстве. Выполняется переход на аутентификацию по паролю.',
'terminal.progress.timeoutIn': 'Тайм-аут через {seconds}с',
'terminal.progress.waitingForUserInput': 'Ожидание ввода пользователя',
'terminal.progress.disconnected': 'Отключено',
'terminal.progress.cancelling': 'Отмена...',
'terminal.progress.startOver': 'Начать заново',
@@ -597,6 +601,7 @@ export const ruTerminalMessages: Messages = {
'snippets.action.selectSnippets': 'Выбрать сниппеты',
'snippets.panel.newTitle': 'Новый сниппет',
'snippets.panel.editTitle': 'Редактировать сниппет',
'snippets.panel.resizeWidth': 'Изменить ширину панели',
'snippets.field.description': 'Описание действия',
'snippets.field.descriptionPlaceholder': 'Например: проверить сетевую нагрузку',
'snippets.field.package': 'Добавить пакет',
+16
View File
@@ -6,11 +6,17 @@ export const ruVaultMessages: Messages = {
'notes.action.newNote': 'Новая заметка',
'notes.action.newGroup': 'Новая папка',
'notes.action.importMarkdown': 'Импорт Markdown',
'notes.action.exportNote': 'Экспорт заметки',
'notes.action.exportGroup': 'Экспорт папки',
'notes.action.exportAll': 'Экспорт заметок',
'notes.import.toast.success': 'Импортировано заметок: {count}',
'notes.import.toast.noNotes': 'Markdown-файлы не были импортированы.',
'notes.import.toast.failed': 'Не удалось импортировать Markdown-файлы.',
'notes.import.toast.inProgress': 'Импорт Markdown уже выполняется.',
'notes.import.toast.skipped': 'Пропущено неподдерживаемых файлов: {count}.',
'notes.export.toast.success': 'Экспортировано заметок: {count}',
'notes.export.toast.empty': 'Нет заметок для экспорта.',
'notes.export.toast.failed': 'Не удалось экспортировать заметки.',
'notes.codeBlock.copied': 'Скопировано',
'notes.codeBlock.copyFailed': 'Не удалось скопировать блок кода.',
'notes.group.prompt': 'Путь папки',
@@ -512,6 +518,7 @@ export const ruVaultMessages: Messages = {
// Host Details
'hostDetails.title.details': 'Сведения о хосте',
'hostDetails.title.new': 'Новый хост',
'vault.panel.resizeWidth': 'Изменить ширину панели',
'hostDetails.saveAria': 'Сохранить',
'hostDetails.section.address': 'Адрес',
'hostDetails.hostname.placeholder': 'IP или имя хоста',
@@ -529,6 +536,8 @@ export const ruVaultMessages: Messages = {
'hostDetails.notes.tab.edit': 'Редактировать',
'hostDetails.notes.tab.preview': 'Просмотр',
'hostDetails.notes.preview.empty': 'Пока нечего просматривать.',
'hostDetails.notes.toggle.show': 'Показать редактор заметок',
'hostDetails.notes.toggle.hide': 'Скрыть редактор заметок',
'hostDetails.group.placeholder': 'Родительская группа',
'hostDetails.section.credentials': 'Учётные данные',
'hostDetails.section.portCredentials': 'Порт и учётные данные',
@@ -551,6 +560,7 @@ export const ruVaultMessages: Messages = {
'hostDetails.icon.colorManual': 'Вручную',
'hostDetails.icon.colorChoice': 'Цвет',
'hostDetails.icon.autoColorLabel': 'Текущий цвет',
'hostDetails.icon.preview': 'Предпросмотр значка',
'hostDetails.icon.showLibrary': 'Показать библиотеку значков',
'hostDetails.icon.hideLibrary': 'Скрыть библиотеку значков',
'hostDetails.icon.autoUsesDistro': 'Использует значок дистрибутива Linux и выбранный цвет для этого хоста.',
@@ -700,12 +710,17 @@ export const ruVaultMessages: Messages = {
'hostDetails.proxyPanel.usernamePlaceholder': 'Имя пользователя',
'hostDetails.proxyPanel.passwordPlaceholder': 'Пароль',
'hostDetails.proxyPanel.identities': 'Идентификаторы',
'hostDetails.proxyPanel.keychainIdentity': 'Идентификатор из связки ключей',
'hostDetails.proxyPanel.manualCredentials': 'Ввести вручную',
'hostDetails.proxyPanel.remove': 'Удалить прокси',
'hostDetails.proxyPanel.savedProxy': 'Сохранённый прокси',
'hostDetails.proxyPanel.selectSaved': 'Выбрать сохранённый прокси',
'hostDetails.proxyPanel.customProxy': 'Пользовательский прокси',
'hostDetails.proxyPanel.missing': 'Отсутствует',
'hostDetails.proxyPanel.missingSaved': 'Сохранённый прокси отсутствует',
'hostDetails.proxyPanel.missingIdentity': 'Идентификатор из связки ключей отсутствует',
'hostDetails.proxyPanel.incompleteIdentity': 'Для идентификатора прокси нужны имя пользователя и пароль',
'hostDetails.proxyPanel.unreadableIdentity': 'Пароль идентификатора прокси нельзя прочитать на этом устройстве',
'hostDetails.proxyPanel.error.required': 'Требуются хост и порт прокси или ProxyCommand.',
'hostDetails.envVars': 'Переменные окружения',
'hostDetails.envVars.add': 'Добавить переменную окружения',
@@ -736,6 +751,7 @@ export const ruVaultMessages: Messages = {
'hostDetails.telnetOn': 'Telnet на',
'hostDetails.port': 'порт',
'hostDetails.telnet.credentials': 'Учётные данные',
'hostDetails.telnet.identity.placeholder': 'Выберите идентификатор Telnet',
'hostDetails.telnet.username': 'Имя пользователя Telnet',
'hostDetails.telnet.password': 'Пароль Telnet',
'hostDetails.charset.placeholder': 'Кодировка (например, UTF-8)',
@@ -12,6 +12,9 @@ const osc7Keys = [
"terminal.osc7Setup.targets",
"terminal.osc7Setup.command",
"terminal.osc7Setup.run",
"terminal.osc7Setup.running",
"terminal.osc7Setup.configured",
"terminal.osc7Setup.failed",
"terminal.osc7Setup.sent",
] as const;
+2
View File
@@ -4,6 +4,7 @@ import { zhCNVaultMessages } from './zh-CN/vault';
import { zhCNTerminalMessages } from './zh-CN/terminal';
import { zhCNAiMessages } from './zh-CN/ai';
import { zhCnSystemManagerMessages } from './zh-CN/systemManager';
import { zhCNScriptsMessages } from './zh-CN/scripts';
export type { Messages } from './types';
@@ -13,6 +14,7 @@ const zhCN: Messages = {
...zhCNTerminalMessages,
...zhCNAiMessages,
...zhCnSystemManagerMessages,
...zhCNScriptsMessages,
};
export default zhCN;
+24 -3
View File
@@ -4,6 +4,10 @@ export const zhCNAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Agent 设置',
'ai.chat.preparing': '准备中…',
'ai.chat.compactingContext': '正在压缩较早的上下文…',
'ai.chat.compactingStep': '正在为下一步整理上下文…',
'ai.chat.compactionRetry': '请求过大,正在压缩上下文并重试…',
'ai.chat.compactionBanner': '上下文已压缩:{before}K → {after}K tokens',
'ai.title': 'AI',
'ai.description': '配置 AI 提供商、Agent 和安全设置',
'ai.providers': '提供商',
@@ -220,10 +224,10 @@ export const zhCNAiMessages: Messages = {
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
'ai.chat.toolDenied': '操作已被用户拒绝。',
'ai.chat.toolApproved': '已批准',
'ai.chat.toolApprovalHint': 'Enter 仅当次许可,Escape 拒绝',
'ai.chat.toolApprovalHint': 'Enter 允许 · Esc 拒绝',
'ai.chat.approve': '批准',
'ai.chat.approveOnce': '仅当次许可',
'ai.chat.alwaysAllow': '一律许可',
'ai.chat.approveOnce': '允许',
'ai.chat.alwaysAllow': '始终',
'ai.chat.slashStopDesc': '停止当前 AI 回合并取消进行中的工具',
'ai.chat.reject': '拒绝',
'ai.chat.toolLabel': '工具',
@@ -393,6 +397,23 @@ export const zhCNAiMessages: Messages = {
'ai.chat.artifact.unavailableTitle': '不可用',
'ai.chat.artifact.noteMissing': '该笔记已不在 Vault 中。',
'ai.chat.artifact.hostMissing': '该主机已不在 Vault 中。',
'ai.chat.artifact.snippetMissing': '该 Snippet 或脚本已不在 Vault 中。',
'ai.chat.artifact.openSnippets': '打开 Snippets',
'ai.chat.artifact.snippetsSummary': 'Vault 中有 {count} 个 Snippet',
'ai.chat.artifact.scriptsSummary': 'Vault 中有 {count} 个脚本',
'ai.chat.artifact.snippetFallback': 'Vault Snippet',
'ai.chat.artifact.scriptFallback': '自动化脚本',
'ai.chat.artifact.scriptLanguage': '{language} 脚本',
'ai.chat.artifact.snippetDeleted': 'Snippet 已删除',
'ai.chat.artifact.scriptDeleted': '脚本已删除',
'ai.chat.artifact.snippetRan': 'Snippet 已执行',
'ai.chat.artifact.scriptStarted': '脚本运行已启动',
'ai.chat.artifact.scriptRunStatus': '脚本运行 {status}',
'ai.chat.artifact.scriptRunsSummary': '{count} 个脚本运行',
'ai.chat.artifact.scriptRunStopped': '脚本运行已停止',
'ai.chat.artifact.scriptRunPaused': '脚本运行已暂停',
'ai.chat.artifact.scriptRunResumed': '脚本运行已恢复',
'ai.chat.artifact.scriptReference': 'nct API 参考',
'zmodem.waitingForRemote': '等待远端...',
'zmodem.uploading': '上传中',
'zmodem.downloading': '下载中',
+31 -1
View File
@@ -69,6 +69,18 @@ export const zhCNCoreMessages: Messages = {
'settings.tab.system': '系统',
'settings.terminal.connection.verifyHostKeys': '校验 SSH 主机密钥',
'settings.terminal.connection.verifyHostKeys.desc': '连接到新的或已变化的 SSH 主机密钥前先询问。只建议在可信内网中关闭。',
'settings.terminal.section.sidePanel': '侧边栏',
'settings.terminal.sidePanel.autoOpen': '打开终端时展开侧边栏',
'settings.terminal.sidePanel.autoOpen.desc': '终端连接成功后自动打开一个侧边栏。',
'settings.terminal.sidePanel.autoOpenPane': '默认侧边栏',
'settings.terminal.sidePanel.autoOpenPane.desc': '选择优先打开哪个面板。',
'settings.terminal.sidePanel.pane.sftp': 'SFTP',
'settings.terminal.sidePanel.pane.scripts': '脚本',
'settings.terminal.sidePanel.pane.history': '历史',
'settings.terminal.sidePanel.pane.theme': '主题',
'settings.terminal.sidePanel.pane.system': '系统',
'settings.terminal.sidePanel.pane.notes': '笔记',
'settings.terminal.sidePanel.pane.ai': 'AI',
// Settings > System
'settings.system.title': '系统',
@@ -273,6 +285,23 @@ export const zhCNCoreMessages: Messages = {
'settings.appearance.uiFont.desc': '选择软件界面使用的字体',
'settings.appearance.windowOpacity': '窗口透明度',
'settings.appearance.windowOpacity.desc': '调节整个应用窗口的透明度,方便叠在其他内容上方。较低时终端文字也会变淡;部分 Linux 桌面环境可能不支持。',
'settings.appearance.appIcon': '应用图标',
'settings.appearance.appIcon.desc': '选择 Dock/任务栏图标风格,切换应用时更容易找到 Netcatty。运行时生效,安装包内的图标不会改变。',
'settings.appearance.appIcon.original': '原始',
'settings.appearance.appIcon.bright': '明亮',
'settings.appearance.appIcon.dark': '深色',
'settings.appearance.appIcon.colorful': '彩色',
'settings.appearance.appIcon.highContrast': '高对比',
'settings.appearance.appIcon.group.classic': '彩色底',
'settings.appearance.appIcon.group.white': '白底',
'settings.appearance.appIcon.group.special': '隐藏款',
'settings.appearance.appIcon.whiteNavy': '白底 · 海军蓝猫',
'settings.appearance.appIcon.whiteSky': '白底 · 天蓝猫',
'settings.appearance.appIcon.whiteRose': '白底 · 玫红猫',
'settings.appearance.appIcon.whiteEmerald': '白底 · 翡翠猫',
'settings.appearance.appIcon.whiteAmber': '白底 · 琥珀猫',
'settings.appearance.appIcon.whiteViolet': '白底 · 紫罗兰猫',
'settings.appearance.appIcon.rainbow': '彩虹猫',
// Context menus / common actions
'action.newHost': '新建主机',
'action.newSubfolder': '新建文件夹',
@@ -287,6 +316,7 @@ export const zhCNCoreMessages: Messages = {
'sync.cloudSync': '云同步',
'sync.settings': '同步设置',
'sync.active': '云同步已启用',
'sync.pending': '有待同步的更改',
'sync.syncing': '正在同步…',
'sync.error': '同步错误',
'sync.notConfigured': '未配置',
@@ -359,7 +389,7 @@ export const zhCNCoreMessages: Messages = {
'vault.nav.keychain': '钥匙串',
'vault.nav.proxies': '代理',
'vault.nav.portForwarding': '端口转发',
'vault.nav.snippets': '代码片段',
'vault.nav.snippets': '脚本',
'vault.nav.notes': '笔记',
'vault.nav.knownHosts': '已知主机',
'vault.nav.logs': '日志',
+123
View File
@@ -0,0 +1,123 @@
export const zhCNScriptsMessages = {
'scripts.meta.name': '名称',
'scripts.meta.language': '语言',
'scripts.meta.description': '描述',
'scripts.meta.descriptionPlaceholder': '可选,脚本的简要说明',
'scripts.meta.trigger': '触发方式',
'scripts.meta.triggerPattern': '输出匹配(正则)',
'scripts.meta.code': '脚本代码',
'scripts.trigger.manual': '手动运行',
'scripts.trigger.onConnect': '连接时运行',
'scripts.trigger.onOutput': '输出匹配时运行',
'scripts.trigger.onOutputHint': '仅在服务端输出匹配正则时触发(会忽略本地按键回显)。未配置目标主机时,对当前已连接会话生效;配置目标后仅监听对应主机。进入备用屏幕的应用(如 vim、htop)期间不会触发。本 session 有其他脚本运行期间会抑制;脚本结束后再检查 buffer。流程内等待请用 nct.screen.waitFor。',
'scripts.actions.save': '保存',
'scripts.actions.runNow': '立即运行',
'scripts.actions.openEditor': '弹窗编辑',
'scripts.actions.openEditorHint': '在更大的窗口中编辑脚本',
'scripts.editor.modalTitle': '脚本编辑器',
'scripts.editor.modalSubtitle': '在更大的工作区中编辑脚本元数据和代码。',
'scripts.editor.lineCount': '{count} 行',
'scripts.editor.resize': '调整编辑器高度',
'scripts.targets.hint': '自动触发作用于所选主机;勾选「对所有主机生效」则作用于全部可连接主机。',
'scripts.targets.connectOrderHint': '连接脚本的执行顺序请在主机详情 → 自动化 中调整。',
'scripts.actions.runNowHint': '在所选目标主机上运行;勾选「对所有主机生效」时在所有可连接主机上运行。',
'scripts.actions.runParallel': '在所有标签页并行运行',
'scripts.actions.runSequential': '在所有标签页顺序运行',
'scripts.actions.skippedConnectingSessions': '有 {count} 个标签仍在连接中,已跳过',
'scripts.actions.noRunnableHosts': '没有与脚本目标匹配的可连接主机',
'scripts.sidePanel.library': '脚本库',
'scripts.sidePanel.running': '运行中',
'scripts.sidePanel.newScript': '新建脚本',
'scripts.running.empty': '当前会话没有正在运行的脚本。',
'scripts.running.unnamed': '未命名脚本',
'scripts.running.status.running': '运行中',
'scripts.running.status.paused': '已暂停',
'scripts.running.status.completed': '已完成',
'scripts.running.status.failed': '失败',
'scripts.running.waitingFor': '等待 {pattern}',
'scripts.running.waitingForLabel': '等待',
'scripts.running.waitingForShellPrompt': 'Shell 提示符(# 或 $)',
'scripts.running.lastSent': '已发送:{command}',
'scripts.recording.start': '开始录制',
'scripts.recording.active': '停止录制',
'scripts.recording.startHint': '在当前终端会话中录制操作,自动生成 nct 脚本代码',
'scripts.recording.unavailableHint': '此入口不支持录制,请用终端右侧脚本侧栏',
'scripts.recording.activeHint': '正在录制当前终端。在终端中正常输入命令;点「停止录制」或终端工具栏的 REC 结束并保存。',
'scripts.recording.started': '已开始录制,请在终端中操作',
'scripts.recording.noSession': '请先连接终端(单机标签或工作区均可)',
'scripts.recording.alreadyActive': '已有其他终端正在录制',
'scripts.recording.stop': '停止录制',
'scripts.recording.pause': '暂停录制',
'scripts.recording.resume': '继续录制',
'scripts.recording.saveTitle': '保存录制的脚本',
'scripts.recording.namePlaceholder': '脚本名称',
'scripts.recording.packagePlaceholder': '保存到文件夹',
'scripts.recording.rootPackage': '根目录',
'scripts.recording.save': '保存',
'scripts.recording.saveAndEdit': '保存并编辑',
'scripts.recording.helpTitle': '如何录制脚本',
'scripts.recording.helpIntro': '录制会把你在终端里的操作自动转成可重复运行的自动化脚本,适合部署、巡检等重复任务。',
'scripts.recording.helpStep1': '连接主机。单机终端标签或工作区里的终端都可以,打开右侧脚本侧栏即可。',
'scripts.recording.helpStep2': '点击「开始录制」。终端工具栏出现红色 REC 表示已开始。',
'scripts.recording.helpStep3': '在终端里像平时一样输入命令,按 Enter 执行。每次回车都会记为一步。',
'scripts.recording.helpStep4': '全部操作完成后,点击「停止录制」,或点终端工具栏 REC 旁的停止按钮。',
'scripts.recording.helpStep5': '填写脚本名称并保存。需要微调时可选「保存并编辑」,在脚本编辑器里修改生成的代码。',
'scripts.recording.helpTipsTitle': '小提示',
'scripts.recording.helpTip1': '两次操作之间停顿超过 1 秒,会自动加入等待时间,避免回放太快。',
'scripts.recording.helpTip2': '每条命令后会等待 shell 提示符(如 $ 或 #)出现,再记录下一步,减少乱序。',
'scripts.recording.helpTip3': '密码输入会被标记为敏感内容,不会明文写入脚本。',
'scripts.recording.helpTip4': 'vim、交互式菜单等复杂操作,建议先录主干命令,再在编辑器里手动补充。',
'scripts.dialog.title': '脚本',
'scripts.dialog.ok': '确定',
'scripts.dialog.waitForTimeoutTitle': '等待超时',
'scripts.dialog.retry': '重试',
'scripts.dialog.skip': '跳过',
'scripts.dialog.abort': '终止',
'scripts.running.stepProgress': '步骤 {current} / {total}',
'scripts.running.determinateProgress': '{label} {current}/{total}',
'scripts.running.progressFallback': '进度',
'scripts.running.operationsCount': '已执行 {count} 个操作',
'scripts.running.opsPrefix': '已执行 ',
'scripts.running.opsSuffix': ' 个操作',
'scripts.running.elapsedLabel': '耗时',
'scripts.running.lastSentLabel': '已发送:',
'scripts.running.elapsed': '耗时 {elapsed}',
'scripts.running.completedSummary': '已完成 · 共 {count} 个操作 · {elapsed}',
'scripts.running.dismissHint': '点击关闭',
'scripts.running.dismiss': '关闭',
'scripts.running.viewLogs': '查看日志',
'scripts.running.logTitle': '{name} · 运行日志',
'scripts.running.logEmpty': '暂无日志输出',
'scripts.running.pause': '暂停',
'scripts.running.resume': '继续',
'scripts.running.stop': '停止',
'scripts.recording.saved': '脚本已保存',
'scripts.recording.savedNamed': '已保存「{name}」',
'scripts.observer.blocked': 'Observer 模式禁止执行会写入终端的脚本。',
'vault.section.scripts': '脚本',
'vault.nav.scripts': '脚本',
'snippets.action.newScript': '新建自动化脚本',
'hostDetails.section.automation': '自动化',
'hostDetails.automation.loginScript': '登录脚本',
'hostDetails.automation.loginScriptPlaceholder': '选择脚本',
'hostDetails.automation.none': '无',
'hostDetails.automation.outputTriggers': '输出触发器',
'hostDetails.automation.addTrigger': '添加触发器',
'hostDetails.automation.triggerPatternPlaceholder': '正则表达式',
'hostDetails.automation.linkedScripts': '已关联脚本',
'hostDetails.automation.linkedScriptsEmpty': '尚未关联任何脚本。',
'hostDetails.automation.linkScriptPlaceholder': '关联已有脚本…',
'hostDetails.automation.unlink': '取消关联',
'hostDetails.automation.equivalenceHint': '此处关联会将本主机加入 Scripts 中该脚本的目标列表,两侧视图同步。',
'hostDetails.automation.queueHint': '连接本主机时按顺序运行脚本。全局脚本最先执行,再执行下方队列。',
'hostDetails.automation.globalScripts': '全局连接脚本',
'hostDetails.automation.globalScriptsHint': '每次连接时最先运行。顺序在脚本库排序中调整。',
'hostDetails.automation.connectQueue': '本主机运行队列',
'hostDetails.automation.connectQueueEmpty': '尚未为本主机配置连接脚本队列。',
'hostDetails.automation.addToQueuePlaceholder': '添加脚本到队列…',
'hostDetails.automation.moveUp': '上移',
'hostDetails.automation.moveDown': '下移',
'hostDetails.automation.removeFromQueue': '从队列移除',
'hostDetails.automation.dragHandle': '拖拽排序',
'hostDetails.automation.queueDragHint': '拖动条目即可调整连接时的运行顺序。',
};
+36 -20
View File
@@ -14,6 +14,9 @@ export const zhCNTerminalMessages: Messages = {
'terminal.osc7Setup.targets': '可能写入的文件',
'terminal.osc7Setup.command': '将执行的命令',
'terminal.osc7Setup.run': '执行配置',
'terminal.osc7Setup.running': '正在配置...',
'terminal.osc7Setup.configured': '目录追踪已配置',
'terminal.osc7Setup.failed': '目录追踪配置失败',
'terminal.osc7Setup.sent': '目录追踪配置已发送到终端',
'terminal.toolbar.timestampsEnable': '显示时间戳',
'terminal.toolbar.timestampsDisable': '隐藏时间戳',
@@ -459,12 +462,17 @@ export const zhCNTerminalMessages: Messages = {
'hostDetails.proxyPanel.usernamePlaceholder': '用户名',
'hostDetails.proxyPanel.passwordPlaceholder': '密码',
'hostDetails.proxyPanel.identities': '身份',
'hostDetails.proxyPanel.keychainIdentity': '钥匙串身份',
'hostDetails.proxyPanel.manualCredentials': '手动凭据',
'hostDetails.proxyPanel.remove': '移除代理',
'hostDetails.proxyPanel.savedProxy': '已保存代理',
'hostDetails.proxyPanel.selectSaved': '选择已保存代理',
'hostDetails.proxyPanel.customProxy': '自定义代理',
'hostDetails.proxyPanel.missing': '缺失',
'hostDetails.proxyPanel.missingSaved': '保存的代理不存在',
'hostDetails.proxyPanel.missingIdentity': '钥匙串身份不存在',
'hostDetails.proxyPanel.incompleteIdentity': '代理身份需要用户名和密码',
'hostDetails.proxyPanel.unreadableIdentity': '此设备无法读取代理身份密码',
'hostDetails.proxyPanel.error.required': '代理主机和端口,或 ProxyCommand 不能为空。',
'hostDetails.envVars.title': '环境变量',
'hostDetails.envVars.desc': '为 {host} 设置环境变量。',
@@ -599,24 +607,30 @@ export const zhCNTerminalMessages: Messages = {
'keychain.edit.exportToHost': '导出到主机',
// Snippets
'snippets.searchPlaceholder': '搜索代码片段...',
'snippets.searchPlaceholder': '搜索脚本...',
'snippets.action.newSnippet': '新建代码片段',
'snippets.action.newPackage': '新建代码包',
'snippets.action.newPackage': '新建脚本包',
'snippets.action.import': '导入',
'snippets.action.selectSnippets': '选择代码片段',
'snippets.panel.newTitle': '新建代码片段',
'snippets.panel.editTitle': '编辑代码片段',
'snippets.panel.newAutomationTitle': '新建自动化脚本',
'snippets.panel.editAutomationTitle': '编辑自动化脚本',
'snippets.panel.resizeWidth': '调整面板宽度',
'snippets.field.description': '描述',
'snippets.field.descriptionPlaceholder': '例如:check network load',
'snippets.field.package': '添加代码包',
'snippets.field.packagePlaceholder': '选择或创建代码包',
'snippets.field.createPackage': '创建代码包',
'snippets.field.package': '添加脚本包',
'snippets.field.packagePlaceholder': '选择或创建脚本包',
'snippets.field.createPackage': '创建脚本包',
'snippets.field.scriptRequired': '脚本 *',
'snippets.scriptEditor.expand': '弹窗编辑',
'snippets.scriptEditor.resize': '调整编辑器高度',
'snippets.scriptEditor.modalTitle': '编辑脚本',
'snippets.targets.title': '目标主机',
'snippets.targets.add': '添加目标主机',
'snippets.targets.allHosts': '对所有主机生效',
'snippets.targets.allHostsShort': '所有主机',
'snippets.targets.allHostsActive': '将作用于所有可连接的主机。',
'snippets.history.title': 'Shell 历史',
'snippets.history.subtitle': '{count} 条命令',
'snippets.history.emptyTitle': '暂无 Shell 历史',
@@ -629,15 +643,17 @@ export const zhCNTerminalMessages: Messages = {
'snippets.history.time.minutesAgo': '{count} 分钟前',
'snippets.history.time.hoursAgo': '{count} 小时前',
'snippets.history.time.daysAgo': '{count} 天前',
'snippets.breadcrumb.allPackages': '全部代码包',
'snippets.breadcrumb.allPackages': '全部脚本包',
'snippets.breadcrumb.separator': '›',
'snippets.empty.title': '创建代码片段',
'snippets.empty.desc': '将常用命令保存为代码片段,一键复用。',
'snippets.empty.title': '创建脚本',
'snippets.empty.desc': '将常用命令保存为代码片段,或编写自动化脚本来处理重复运维任务。',
'snippets.search.noResults.title': '无匹配结果',
'snippets.search.noResults.desc': '没有代码片段或代码包与"{query}"匹配。换一个关键字,或清除搜索进行浏览。',
'snippets.section.packages': '代码包',
'snippets.section.snippets': '代码片段',
'snippets.package.count': '{count} 个代码片段',
'snippets.search.noResults.desc': '没有脚本或脚本包与"{query}"匹配。换一个关键字,或清除搜索进行浏览。',
'snippets.section.packages': '脚本包',
'snippets.section.snippets': '脚本',
'snippets.kind.codeSnippet': '代码片段',
'snippets.kind.automationScript': '自动化脚本',
'snippets.package.count': '{count} 个脚本',
'snippets.commandFallback': '命令',
'snippets.view.grid': '网格',
'snippets.view.list': '列表',
@@ -646,7 +662,7 @@ export const zhCNTerminalMessages: Messages = {
'snippets.selection.deselectAll': '取消选择',
'snippets.selection.exportSelected': '导出选中({count})',
'snippets.export.snippet': '导出代码片段',
'snippets.export.package': '导出代码包',
'snippets.export.package': '导出脚本包',
'snippets.export.toast.empty': '没有可导出的代码片段。',
'snippets.export.toast.successTitle': '导出已准备好',
'snippets.export.toast.success': '已导出 {count} 个代码片段。',
@@ -662,26 +678,26 @@ export const zhCNTerminalMessages: Messages = {
'snippets.import.modal.chooseFile': '选择文件',
'snippets.import.modal.downloadExamples': '下载示例文件',
'snippets.import.modal.multipleFiles': '已选择 {count} 个文件',
'snippets.import.modal.parsedSummary': '{files} 个文件,{total} 个代码片段,{packages} 个代码包,{conflicts} 个重复命令。主机绑定会被忽略。',
'snippets.import.modal.parsedSummary': '{files} 个文件,{total} 个脚本,{packages} 个脚本包,{conflicts} 个重复命令。主机绑定会被忽略。',
'snippets.import.modal.confirm': '确认导入',
'snippets.import.conflict.title': '导入代码片段?',
'snippets.import.conflict.desc': '“{file}”包含 {total} 个代码片段,其中 {conflicts} 个命令已存在。',
'snippets.import.conflict.hostBindingsNote': '代码片段的主机绑定不会被导入或导出。',
'snippets.import.conflict.skip': '跳过重复项',
'snippets.import.conflict.overwrite': '覆盖重复项',
'snippets.packageDialog.title': '新建代码包',
'snippets.packageDialog.title': '新建脚本包',
'snippets.packageDialog.parent': '父级:{parent}',
'snippets.packageDialog.root': '根目录',
'snippets.packageDialog.placeholder': '例如:ops/maintenance',
'snippets.packageDialog.hint': '使用 "/" 创建嵌套代码包。',
'snippets.packageDialog.hint': '使用 "/" 创建嵌套脚本包。',
// Snippets Rename Dialog
'snippets.renameDialog.title': '重命名代码包',
'snippets.renameDialog.title': '重命名脚本包',
'snippets.renameDialog.currentPath': '当前路径:{path}',
'snippets.renameDialog.placeholder': '输入新名称',
'snippets.renameDialog.error.empty': '代码包名称不能为空',
'snippets.renameDialog.error.duplicate': '已存在同名的代码包',
'snippets.renameDialog.error.invalidChars': '代码包名称只能包含字母、数字、连字符和下划线',
'snippets.renameDialog.error.empty': '脚本包名称不能为空',
'snippets.renameDialog.error.duplicate': '已存在同名的脚本包',
'snippets.renameDialog.error.invalidChars': '脚本包名称只能包含字母、数字、连字符和下划线',
'snippets.field.noAutoRun': '仅粘贴(不自动执行)',
// Snippet Shortkey
+12
View File
@@ -6,11 +6,17 @@ export const zhCNVaultMessages: Messages = {
'notes.action.newNote': '新建笔记',
'notes.action.newGroup': '新建文件夹',
'notes.action.importMarkdown': '导入 Markdown',
'notes.action.exportNote': '导出笔记',
'notes.action.exportGroup': '导出文件夹',
'notes.action.exportAll': '导出全部笔记',
'notes.import.toast.success': '已导入 {count} 条笔记',
'notes.import.toast.noNotes': '没有导入任何 Markdown 文件。',
'notes.import.toast.failed': '导入 Markdown 文件失败。',
'notes.import.toast.inProgress': '正在导入 Markdown 文件,请稍候。',
'notes.import.toast.skipped': '已跳过 {count} 个不支持的文件。',
'notes.export.toast.success': '已导出 {count} 条笔记',
'notes.export.toast.empty': '没有可导出的笔记。',
'notes.export.toast.failed': '导出笔记失败。',
'notes.codeBlock.copied': '已复制',
'notes.codeBlock.copyFailed': '复制代码块失败。',
'notes.group.prompt': '文件夹路径',
@@ -51,6 +57,7 @@ export const zhCNVaultMessages: Messages = {
// Host Details
'hostDetails.title.details': '主机详情',
'hostDetails.title.new': '新建主机',
'vault.panel.resizeWidth': '调整面板宽度',
'hostDetails.saveAria': '保存',
'hostDetails.section.address': '地址',
'hostDetails.hostname.placeholder': 'IP 或 主机名',
@@ -68,6 +75,8 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.notes.tab.edit': '编辑',
'hostDetails.notes.tab.preview': '预览',
'hostDetails.notes.preview.empty': '暂无内容可预览。',
'hostDetails.notes.toggle.show': '显示备注编辑框',
'hostDetails.notes.toggle.hide': '隐藏备注编辑框',
'hostDetails.group.placeholder': '父级 Group',
'hostDetails.section.credentials': '凭据',
'hostDetails.section.portCredentials': '端口与凭据',
@@ -90,6 +99,7 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.icon.colorManual': '手动',
'hostDetails.icon.colorChoice': '颜色',
'hostDetails.icon.autoColorLabel': '当前颜色',
'hostDetails.icon.preview': '图标预览',
'hostDetails.icon.showLibrary': '展开图标库',
'hostDetails.icon.hideLibrary': '收起图标库',
'hostDetails.icon.autoUsesDistro': '使用 Linux 发行版图标和所选颜色显示此主机。',
@@ -242,6 +252,7 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.telnetOn': 'Telnet on',
'hostDetails.port': '端口',
'hostDetails.telnet.credentials': '凭据',
'hostDetails.telnet.identity.placeholder': '选择 Telnet 身份',
'hostDetails.telnet.username': 'Telnet 用户名',
'hostDetails.telnet.password': 'Telnet 密码',
'hostDetails.charset.placeholder': '字符集(例如 UTF-8)',
@@ -430,6 +441,7 @@ export const zhCNVaultMessages: Messages = {
'terminal.auth.keyUnavailableFallbackPassword': '已保存 SSH 密钥在当前设备不可用,改用密码认证。',
'terminal.connectionErrorTitle': '连接错误',
'terminal.progress.timeoutIn': '将在 {seconds}s 后超时',
'terminal.progress.waitingForUserInput': '等待用户输入',
'terminal.progress.disconnected': '已断开',
'terminal.progress.cancelling': '正在取消...',
'terminal.progress.startOver': '重新开始',
+20
View File
@@ -0,0 +1,20 @@
import type { Messages } from './types';
import { zhTWCoreMessages } from './zh-TW/core';
import { zhTWVaultMessages } from './zh-TW/vault';
import { zhTWTerminalMessages } from './zh-TW/terminal';
import { zhTWAiMessages } from './zh-TW/ai';
import { zhTwSystemManagerMessages } from './zh-TW/systemManager';
import { zhTWScriptsMessages } from './zh-TW/scripts';
export type { Messages } from './types';
const zhTW: Messages = {
...zhTWCoreMessages,
...zhTWVaultMessages,
...zhTWTerminalMessages,
...zhTWAiMessages,
...zhTwSystemManagerMessages,
...zhTWScriptsMessages,
};
export default zhTW;
+427
View File
@@ -0,0 +1,427 @@
import type { Messages } from '../types';
export const zhTWAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Agent 設定',
'ai.chat.preparing': '準備中…',
'ai.chat.compactingContext': '正在壓縮較早的上下文…',
'ai.chat.compactingStep': '正在為下一步整理上下文…',
'ai.chat.compactionRetry': '請求過大,正在壓縮上下文並重試…',
'ai.chat.compactionBanner': '上下文已壓縮:{before}K → {after}K tokens',
'ai.title': 'AI',
'ai.description': '設定 AI 供應商、Agent 和安全設定',
'ai.providers': '供應商',
'ai.agents': 'Agent',
'ai.providers.empty': '尚未設定供應商。新增一個供應商以開始使用。',
'ai.providers.add': '新增供應商',
'ai.providers.active': '使用中',
'ai.providers.apiKeyConfigured': 'API Key 已設定',
'ai.providers.noApiKey': '未設定 API Key',
'ai.providers.configure': '設定',
'ai.providers.remove': '移除',
'ai.providers.name': '顯示名稱',
'ai.providers.name.placeholder': '例如 我的供應商',
'ai.providers.style': '協定風格',
'ai.providers.style.anthropic': 'Anthropic 相容',
'ai.providers.style.openai': 'OpenAI 相容',
'ai.providers.style.google': 'Google 相容',
'ai.providers.style.inherited': '預設',
'ai.providers.style.help': '決定請求使用哪種 API 格式。當第三方端點的協定與其供應商型別不一致時,可手動覆蓋。',
'ai.providers.icon.change': '修改圖示',
'ai.providers.icon.upload': '上傳圖片',
'ai.providers.icon.reset': '恢復預設',
'ai.providers.icon.close': '收起',
'ai.providers.icon.uploadedNote': '自訂圖示(64×64 WebP)',
'ai.providers.icon.errorType': '請選擇圖片檔案。',
'ai.providers.apiKey': 'API Key',
'ai.providers.apiKey.placeholder': '輸入 API Key',
'ai.providers.apiKey.decrypting': '解密中...',
'ai.providers.baseUrl': 'Base URL',
'ai.providers.skipTLSVerify': '跳過 TLS 憑證驗證(用於自我簽署憑證)',
'ai.providers.defaultModel': '預設模型',
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
'ai.providers.contextWindow': '上下文視窗',
'ai.providers.contextWindow.placeholder': '例如 128000',
'ai.providers.contextWindow.help': '留空時優先使用模型列表傳回的值;如果沒有,Netcatty 會使用安全預設值。',
'ai.providers.contextWindow.error': '請輸入正整數,或留空。',
'ai.providers.refreshModels': '重新整理模型列表',
'ai.providers.searchModel': '搜尋或輸入模型 ID...',
'ai.providers.filterModels': '篩選模型...',
'ai.providers.loadingModels': '載入模型中...',
'ai.providers.noMatchingModels': '沒有相符的模型',
'ai.providers.clickToLoadModels': '點選載入模型',
'ai.providers.showingModels': '顯示前 100 個,共 {count} 個模型。輸入以篩選。',
'ai.providers.advancedParams': '高階引數',
'ai.providers.advancedParams.hint': '留空則使用供應商預設值。',
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
'ai.providers.advancedParams.default': '供應商預設',
// AI Codex
'ai.codex': 'Codex',
'ai.codex.title': 'Codex CLI',
'ai.codex.description': '介接 OpenAI Codex。可以在這裡登入 ChatGPT,也可以在設定裡啟用相容 OpenAI 的 API Key 和自訂介面地址。',
'ai.codex.detecting': '偵測中...',
'ai.codex.notFound': '未找到',
'ai.codex.awaitingLogin': '等待登入',
'ai.codex.connectedChatGPT': '已透過 ChatGPT 連線',
'ai.codex.connectedApiKey': '已透過 API Key 連線',
'ai.codex.connectedCustomConfig': '使用 ~/.codex/config.toml 自訂 provider',
'ai.codex.customConfigIncomplete': '偵測到自訂設定(缺少環境變數)',
'ai.codex.customConfigHint': '使用 ~/.codex/config.toml 中設定的自訂 provider "{provider}",無需 ChatGPT 登入。',
'ai.codex.customConfigMissingEnvKey': '警告:環境變數 {envKey} 未在目前 shell 中設定。請 export 它(或從包含該變數的 shell 啟動 netcatty),否則 Codex 無法完成認證。',
'ai.codex.notConnected': '未連線',
'ai.codex.statusUnknown': '狀態未知',
'ai.codex.path': '路徑:',
'ai.codex.notFoundHint': '在 PATH 中未找到 codex。請安裝或在下方指定執行檔路徑。',
'ai.codex.customPathPlaceholder': '例如 /usr/local/bin/codex',
'ai.codex.check': '檢查',
'ai.codex.resetPath': '重置',
'ai.codex.openLogin': '開啟登入',
'ai.codex.logout': '登出',
'ai.codex.connectChatGPT': '連線 ChatGPT',
'ai.codex.refreshStatus': '重新整理狀態',
// AI Claude Code
'ai.claude.title': 'Claude Code',
'ai.claude.description': 'Anthropic 的智慧程式設計助手。需要系統中已安裝 Claude Code CLI。',
'ai.claude.detecting': '偵測中...',
'ai.claude.detected': '已偵測到',
'ai.claude.notFound': '未找到',
'ai.claude.path': '路徑:',
'ai.claude.notFoundHint': '在 PATH 中未找到 claude。請安裝或在下方指定執行檔路徑。',
'ai.claude.customPathPlaceholder': '例如 /usr/local/bin/claude',
'ai.claude.configSection': '認證與設定(可選)',
'ai.claude.configDir': '設定目錄',
'ai.claude.configDir.placeholder': '~/.claude(留空用預設)',
'ai.claude.configDir.hint': '設定 CLAUDE_CONFIG_DIR —— 指向你已執行 `claude` 登入的目錄(含 settings.json 和憑證)。',
'ai.claude.settings': 'Settings 檔案',
'ai.claude.settings.placeholder': '~/team-settings.json(路徑,或內聯 {"model":"..."})',
'ai.claude.settings.hint': '可選。settings.json 路徑或內聯 JSON,作為 SDK 的 `settings` 傳入。與上面的「設定目錄」互補且獨立(疊加合併,不是替換)。',
'ai.claude.envVars': '環境變數',
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
'ai.claude.envVars.hint': '每行一個 KEY=VALUE,傳給 Claude agent。明文存在本機——API key/憑證建議用上面的「設定目錄」(claude 登入),不要放這裡。',
'ai.claude.check': '檢查',
'ai.claude.resetPath': '重置',
// AI GitHub Copilot CLI
'ai.copilot.title': 'GitHub Copilot CLI',
'ai.copilot.description': '介接 GitHub Copilot CLI。偵測到後即可作為外部程式設計 Agent 使用。',
'ai.copilot.detecting': '偵測中...',
'ai.copilot.detected': '已偵測到',
'ai.copilot.notFound': '未找到',
'ai.copilot.path': '路徑:',
'ai.copilot.notFoundHint': '在 PATH 中未找到 copilot。請安裝或在下方指定執行檔路徑。',
'ai.copilot.customPathPlaceholder': '例如 /usr/local/bin/copilot',
'ai.copilot.check': '檢查',
'ai.copilot.resetPath': '重置',
// AI Cursor SDK
'ai.cursor.title': 'Cursor',
'ai.cursor.description': '使用 Cursor SDK。',
'ai.cursor.detecting': '偵測中...',
'ai.cursor.detected': '可用',
'ai.cursor.notFound': '不可用',
'ai.cursor.path': '執行環境:',
'ai.cursor.notFoundHint': '填寫 API Key 後即可使用。',
'ai.cursor.notInstalledHint': '未偵測到 Cursor SDK。',
'ai.cursor.installStatus': 'Cursor SDK',
'ai.cursor.installed': '已偵測到',
'ai.cursor.notInstalled': '未偵測到',
'ai.cursor.apiKeyStatus': 'API Key',
'ai.cursor.apiKeyConfigured': '已填寫',
'ai.cursor.apiKeyMissing': '未填寫',
'ai.cursor.apiKeyFromEnv': '來自環境變數',
'ai.cursor.apiKey': 'API Key',
'ai.cursor.apiKeyPlaceholder': '輸入 Cursor API Key',
'ai.cursor.apiKeyPlaceholder.env': '已使用 CURSOR_API_KEY;填寫後會覆蓋',
'ai.cursor.apiKeyEnvHint': '已偵測到本機 CURSOR_API_KEY。留空即可繼續使用,填寫儲存後會覆蓋它。',
'ai.cursor.apiKeyOverrideHint': '目前優先使用這裡儲存的 Key;清空儲存後會回到 CURSOR_API_KEY。',
'ai.cursor.saveApiKey': '儲存',
'ai.cursor.saved': '已儲存',
'ai.cursor.showApiKey': '顯示 API Key',
'ai.cursor.hideApiKey': '隱藏 API Key',
'ai.cursor.customPathPlaceholder': '例如 /usr/local/bin/cursor',
'ai.cursor.check': '檢查',
// AI CodeBuddy Code
'ai.codebuddy.title': 'CodeBuddy Code',
'ai.codebuddy.description': '透過官方 Agent SDK(`@tencent-ai/agent-sdk`)介接 CodeBuddy Code。偵測到後即可作為外部程式設計 Agent 使用。',
'ai.codebuddy.detecting': '偵測中...',
'ai.codebuddy.detected': '已偵測到',
'ai.codebuddy.notFound': '未找到',
'ai.codebuddy.path': '路徑:',
'ai.codebuddy.notFoundHint': '在 PATH 中未找到 codebuddy。請安裝或在下方指定執行檔路徑。',
'ai.codebuddy.customPathPlaceholder': '例如 /usr/local/bin/codebuddy',
'ai.codebuddy.check': '檢查',
'ai.codebuddy.resetPath': '重置',
'ai.codebuddy.configSection': '認證與設定(可選)',
'ai.codebuddy.internetEnv': '網路環境',
'ai.codebuddy.internetEnv.default': '預設(海外)',
'ai.codebuddy.internetEnv.internal': 'Internal',
'ai.codebuddy.internetEnv.ioa': 'IOA',
'ai.codebuddy.internetEnv.hint': '設定 CODEBUDDY_INTERNET_ENVIRONMENT —— 受限網路環境請選擇 Internal 或 IOA。',
'ai.codebuddy.envVars': '環境變數',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': '每行一個 KEY=VALUE,傳給 CodeBuddy agent。可在此設定 CODEBUDDY_API_KEY 或 CODEBUDDY_AUTH_TOKEN 完成認證。明文存在本機。',
// AI OpenCode
'ai.opencode.title': 'OpenCode',
'ai.opencode.description': '透過官方 SDK 介接 OpenCode。先在 OpenCode 裡設定 provider 和金鑰,偵測到後即可作為外部程式設計 Agent 使用。',
'ai.opencode.detecting': '偵測中...',
'ai.opencode.detected': '已偵測到',
'ai.opencode.notFound': '未找到',
'ai.opencode.path': '路徑:',
'ai.opencode.notFoundHint': '在 PATH 中未找到 opencode。請安裝或在下方指定執行檔路徑。',
'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode',
'ai.opencode.check': '檢查',
'ai.opencode.resetPath': '重置',
// AI Default Agent
'ai.defaultAgent': '預設 Agent',
'ai.defaultAgent.description': '建立新 AI 工作階段時使用的 Agent',
'ai.defaultAgent.catty': 'Catty(內建)',
'ai.toolAccess.title': '工具介接',
'ai.toolAccess.mode': 'Netcatty 介接模式',
'ai.toolAccess.description': '選擇外部 Agent 存取 Netcatty 工作階段的方式。MCP 會公開內建伺服器,Skills + CLI 會引導 Agent 讀取本機 Skill 並呼叫 Netcatty CLI。',
'ai.toolAccess.mode.mcp': 'MCP',
'ai.toolAccess.mode.skills': 'Skills + CLI',
'ai.userSkills.title': '使用者 Skills',
'ai.userSkills.description': '開啟 Netcatty 的 Skills 資料夾以新增你自己的技能目錄。Netcatty 會自動掃描這些 skills,預設只注入輕量索引,只有在請求明顯命中某個 skill 時才展開正文。',
'ai.userSkills.openFolder': '開啟 Skills 資料夾',
'ai.userSkills.reload': '重新載入 Skills',
'ai.userSkills.location': '位置',
'ai.userSkills.loading': '正在掃描使用者 skills...',
'ai.userSkills.summary': '已就緒 {ready} 個,警告 {warnings} 個',
'ai.userSkills.empty': '暫未發現使用者 skills。開啟資料夾後可新增包含 SKILL.md 的技能目錄。',
'ai.userSkills.unavailable': '目前環境不支援使用者 skills。',
'ai.userSkills.status.ready': '正常',
'ai.userSkills.status.warning': '警告',
// AI Quick Messages
'ai.quickMessages.title': '快捷訊息',
'ai.quickMessages.description': '建立常用提示詞,在 AI 聊天框輸入 / 或點選快捷按鈕即可插入到輸入框。與使用者 Skills 不同,快捷訊息會直接填入訊息內容。',
'ai.quickMessages.add': '新增快捷訊息',
'ai.quickMessages.createTitle': '新建快捷訊息',
'ai.quickMessages.editTitle': '編輯快捷訊息',
'ai.quickMessages.name': '名稱',
'ai.quickMessages.name.placeholder': '例如:檢查磁碟空間',
'ai.quickMessages.slug': '指令',
'ai.quickMessages.slug.placeholder': 'disk-check',
'ai.quickMessages.descriptionField': '說明(可選)',
'ai.quickMessages.descriptionField.placeholder': '簡短描述這條快捷訊息的用途',
'ai.quickMessages.content': '訊息內容',
'ai.quickMessages.content.placeholder': '輸入選擇後要插入的完整提示詞...',
'ai.quickMessages.empty': '還沒有快捷訊息。新增幾條常用提示,聊天時就能一鍵插入。',
'ai.quickMessages.confirmDelete': '確定刪除快捷訊息「{name}」嗎?',
'ai.quickMessages.error.nameRequired': '請填寫名稱。',
'ai.quickMessages.error.invalidSlug': '指令只能包含小寫字母、數字和連字元。',
'ai.quickMessages.error.contentRequired': '請填寫訊息內容。',
'ai.quickMessages.error.slugTaken': '該指令已被其他快捷訊息使用。',
'ai.quickMessages.error.slugConflictsWithSkill': '該指令與使用者 Skill「/{slug}」衝突,請換一個指令。',
'ai.quickMessages.error.maxItems': '最多只能儲存 {max} 條快捷訊息。',
// AI Chat
'ai.chat.noProvider': '尚未設定 AI 供應商。請前往 **設定 → AI → 供應商** 新增並啟用一個供應商。',
'ai.chat.toolDenied': '操作已被使用者拒絕。',
'ai.chat.toolApproved': '已批准',
'ai.chat.toolApprovalHint': 'Enter 允許 · Esc 拒絕',
'ai.chat.approve': '批准',
'ai.chat.approveOnce': '允許',
'ai.chat.alwaysAllow': '始終',
'ai.chat.slashStopDesc': '停止目前 AI 回合並取消進行中的工具',
'ai.chat.reject': '拒絕',
'ai.chat.toolLabel': '工具',
'ai.chat.targetLabel': '目標',
'ai.chat.permissionRequired': '需要權限',
'ai.chat.permissionDescription': 'AI Agent 希望執行一個需要你批准的工具呼叫。',
'ai.chat.commandBlocked': '此指令已被安全策略攔截,無法執行。',
'ai.chat.recommendAllow': '允許',
'ai.chat.recommendConfirm': '確認',
'ai.chat.recommendDeny': '拒絕',
'ai.chat.exportConversation': '匯出對話',
'ai.chat.exportAs': '匯出為',
'ai.chat.exportMarkdown': 'Markdown',
'ai.chat.exportJSON': 'JSON',
'ai.chat.exportPlainText': '純文字',
'ai.chat.thinking': '思考中',
'ai.chat.thoughtFor': '思考了 {duration}',
'ai.chat.thought': '思考',
'ai.chat.agents': 'Agents',
'ai.chat.detectedOnMachine': '在本機偵測到',
'ai.chat.rescan': '重新掃描',
'ai.chat.permObserver': '觀察',
'ai.chat.permConfirm': '確認',
'ai.chat.permAuto': '自動',
'ai.chat.permObserverDesc': '只讀模式',
'ai.chat.permConfirmDesc': '操作前詢問',
'ai.chat.permAutoDesc': '自由執行',
'ai.chat.emptyHint': '詢問伺服器相關問題、執行指令或取得設定協助。',
'ai.chat.placeholder': '向 {agent} 傳送訊息 — @ 引用上下文,/ 使用指令',
'ai.chat.placeholderDefault': '向 Catty Agent 傳送訊息...',
'ai.chat.noModel': '未選擇模型',
'ai.chat.noProviderModel': '未設定預設模型——前往 設定 → AI → 供應商 設定。',
'ai.chat.selectProvider': '選擇供應商',
'ai.chat.recent': '最近',
'ai.chat.viewAll': '檢視全部',
'ai.chat.untitled': '無標題',
'ai.chat.justNow': '剛剛',
'ai.chat.minutesAgo': '{n}分鐘前',
'ai.chat.hoursAgo': '{n}小時前',
'ai.chat.daysAgo': '{n}天前',
'ai.chat.newChat': '新對話',
'ai.chat.allSessions': '所有工作階段',
'ai.chat.loadEarlierMessages': '載入更早的訊息(還有 {n} 條)',
'ai.chat.usedTools': '已使用 {n} 個工具',
'ai.chat.loadMoreSessions': '載入更多工作階段(還有 {n} 條)',
'ai.chat.noSessions': '沒有歷史工作階段',
'ai.chat.retryHint': '你可以重新發送訊息來重試。',
'ai.chat.approvalTimeout': '工具審批已超時(5 分鐘)。你可以重新發送訊息來重試。',
'ai.chat.menuHosts': '主機',
'ai.chat.menuContext': '上下文',
'ai.chat.menuFiles': '檔案',
'ai.chat.menuImage': '圖片',
'ai.chat.menuMentionHost': '提及主機',
'ai.chat.menuUserSkills': '使用者 Skills',
'ai.chat.menuSlashCommands': '快捷指令',
'ai.chat.slashCommands': '快捷指令',
'ai.chat.slashQuickMessages': '快捷訊息',
'ai.chat.slashUserSkills': '使用者 Skills',
'ai.chat.quickMessages': '快捷指令',
'ai.chat.slashNoResults': '沒有相符的指令',
'ai.chat.slashEmptyHint': '可在 設定 → AI → 快捷訊息 中新增常用提示詞。',
// AI 聊天快捷入口
'ai.chatShortcuts.title': '聊天快捷入口',
'ai.chatShortcuts.selectionAction': '選取終端內容時顯示「新增到對話」',
'ai.chatShortcuts.selectionAction.description': '在終端裡選取文字後顯示 AI 快捷按鈕。',
// AI Error
'ai.codex.bridgeError': 'Codex 主程序處理器尚未載入。請完全重啟 Netcatty 或重啟 Electron 開發程序,然後重試。',
// AI Web Search
'ai.webSearch.title': '網路搜尋',
'ai.webSearch.enable': '啟用網路搜尋',
'ai.webSearch.enable.description': '允許 AI 代理搜尋網際網路取得最新資訊。',
'ai.webSearch.provider': '搜尋供應商',
'ai.webSearch.provider.description': '選擇一個網路搜尋 API 供應商。',
'ai.webSearch.apiKey': 'API 金鑰',
'ai.webSearch.apiKey.description': '所選搜尋供應商的 API 金鑰。',
'ai.webSearch.apiKey.placeholder': '輸入 API 金鑰...',
'ai.webSearch.apiHost': 'API 地址',
'ai.webSearch.apiHost.description': '自訂 API 端點。除非使用代理,否則保持預設值。',
'ai.webSearch.apiHost.searxngDescription': 'SearXNG 例項的 URL(必填)。',
'ai.webSearch.maxResults': '最大結果數',
'ai.webSearch.maxResults.description': '搜尋傳回的最大結果數(1-20)。',
// AI Safety Settings
'ai.safety.title': '安全',
'ai.safety.permissionMode': '權限模式',
'ai.safety.permissionMode.description': '控制 AI 透過 Netcatty 存取終端工作階段的方式。觀察者模式會阻止經由 Netcatty 的寫操作;外部 Agent CLI 可能仍有自己的本機工具和審批流程。',
'ai.safety.permissionMode.observer': '觀察者 - 只讀,禁止操作',
'ai.safety.permissionMode.confirm': '確認 - 操作前詢問',
'ai.safety.permissionMode.auto': '自動 - 自由執行',
'ai.safety.commandTimeout': '指令超時',
'ai.safety.commandTimeout.description': '透過 Netcatty 執行指令時允許執行的最長秒數,超時將被終止。',
'ai.safety.commandTimeout.unit': '秒',
'ai.safety.maxIterations': '最大迭代次數',
'ai.safety.maxIterations.description': '防止 AI 失控執行的最大工具呼叫迴圈次數。外部 Agent 可能有自己的內部迭代限制,以其為準。',
'ai.safety.blocklist': '指令黑名單',
'ai.safety.blocklist.description': '用於攔截透過 Netcatty 執行的危險指令的正規表示式。',
'ai.safety.blocklist.placeholder': '正規表示式...',
'ai.safety.blocklist.reset': '恢復預設',
'ai.safety.blocklist.add': '新增規則',
'ai.safety.grants.title': '許可記憶表',
'ai.safety.grants.heading': 'Confirm 模式放行規則',
'ai.safety.grants.description': 'Confirm 模式會先詢問再執行;儲存為規則後,相符的同類操作會自動放行。規則對所有終端節點/工作階段生效,也可手動編輯。',
'ai.safety.grants.empty': '尚無規則。可在審批時選擇「一律許可」,或手動新增。',
'ai.safety.grants.capability': '能力',
'ai.safety.grants.sessionPattern': '節點/工作階段模式',
'ai.safety.grants.commandPattern': '指令模式(可選)',
'ai.safety.grants.note': '備註(可選)',
'ai.safety.grants.add': '新增規則',
'ai.safety.grants.remove': '刪除',
'ai.safety.grants.export': '匯出 JSON',
'ai.safety.grants.import': '匯入 JSON',
'ai.safety.note': '這些安全設定會約束經由 Netcatty 執行的操作。外部 Agent CLI 也可能提供本機工具,那部分由 Agent 自己的控制規則約束。',
// 統一終端工作區和頂部標籤的 tooltip 文案 (issue #954)
'terminal.layer.addTerminal': '新增終端',
'terminal.layer.switchToSplitView': '切換到分割檢視',
'terminal.layer.sftp': '檔案傳輸',
'terminal.layer.scripts': '指令碼',
'terminal.layer.history': '指令歷史',
'terminal.layer.theme': '主題',
'terminal.layer.notes': '筆記',
'terminal.layer.aiChat': 'AI 助手',
'terminal.layer.movePanelLeft': '面板移至左側',
'terminal.layer.movePanelRight': '面板移至右側',
'terminal.layer.closePanel': '關閉面板',
'terminal.layer.hostTree.search': '搜尋主機...',
'terminal.layer.hostTree.searchButton': '搜尋',
'terminal.layer.hostTree.tagsButton': '按標籤篩選',
'terminal.layer.hostTree.newGroup': '新建分組',
'terminal.layer.hostTree.localShell': '本機 Shell',
'terminal.layer.hostTree.tagsEmpty': '暫無標籤',
'terminal.layer.hostTree.clearTags': '清除篩選',
'terminal.layer.hostTree.collapse': '收起主機列表',
'terminal.layer.hostTree.expand': '展開主機列表',
'terminal.layer.hostTree.empty': '沒有相符的主機',
'terminal.layer.hostTree.details.host': '主機',
'terminal.layer.hostTree.details.user': '使用者',
'terminal.layer.hostTree.details.port': '埠',
'terminal.layer.hostTree.details.protocol': '協定',
'terminal.layer.hostTree.details.group': '分組',
'terminal.layer.hostTree.details.tags': '標籤',
'terminal.layer.hostTree.details.lastConnected': '最近連線',
'topTabs.openQuickSwitcher': '開啟快速切換',
'topTabs.moreTabs': '更多標籤頁',
'topTabs.aiAssistant': 'AI 助手',
'topTabs.windowOpacity': '視窗透明度',
'topTabs.toggleTheme': '切換主題',
'topTabs.openSettings': '開啟設定',
'ai.chat.sessionHistory': '工作階段歷史',
'ai.chat.attach': '附件',
'ai.chat.terminalSelectionAttachment': '終端選區',
'ai.chat.terminalSelectionLines': '{count} 行',
'ai.chat.collapse': '收起',
'ai.chat.expand': '展開',
'ai.chat.enableAgent': '啟用 {name}',
'ai.chat.artifact.noteFallback': 'Vault 筆記',
'ai.chat.artifact.openNotes': '開啟筆記',
'ai.chat.artifact.openHosts': '開啟主機',
'ai.chat.artifact.notesSummary': 'Vault 中有 {count} 條筆記',
'ai.chat.artifact.hostsSummary': 'Vault 中有 {count} 台主機',
'ai.chat.artifact.hostsAdded': '已新增 {count} 台主機',
'ai.chat.artifact.hostsPreview': '預覽 {count} 台主機',
'ai.chat.artifact.failed': 'Vault 操作失敗',
'ai.chat.artifact.unavailableTitle': '不可用',
'ai.chat.artifact.noteMissing': '該筆記已不在 Vault 中。',
'ai.chat.artifact.hostMissing': '該主機已不在 Vault 中。',
'ai.chat.artifact.snippetMissing': '該 Snippet 或指令碼已不在 Vault 中。',
'ai.chat.artifact.openSnippets': '開啟 Snippets',
'ai.chat.artifact.snippetsSummary': 'Vault 中有 {count} 個 Snippet',
'ai.chat.artifact.scriptsSummary': 'Vault 中有 {count} 個指令碼',
'ai.chat.artifact.snippetFallback': 'Vault Snippet',
'ai.chat.artifact.scriptFallback': '自動化指令碼',
'ai.chat.artifact.scriptLanguage': '{language} 指令碼',
'ai.chat.artifact.snippetDeleted': 'Snippet 已刪除',
'ai.chat.artifact.scriptDeleted': '指令碼已刪除',
'ai.chat.artifact.snippetRan': 'Snippet 已執行',
'ai.chat.artifact.scriptStarted': '指令碼執行已啟動',
'ai.chat.artifact.scriptRunStatus': '指令碼執行 {status}',
'ai.chat.artifact.scriptRunsSummary': '{count} 個指令碼執行',
'ai.chat.artifact.scriptRunStopped': '指令碼執行已停止',
'ai.chat.artifact.scriptRunPaused': '指令碼執行已暫停',
'ai.chat.artifact.scriptRunResumed': '指令碼執行已恢復',
'ai.chat.artifact.scriptReference': 'nct API 參考',
'zmodem.waitingForRemote': '等待遠端...',
'zmodem.uploading': '上傳中',
'zmodem.downloading': '下載中',
'zmodem.cancelTransfer': '取消傳輸 (Ctrl+C)',
'zmodem.overwrite.title': '遠端已存在同名檔案',
'zmodem.overwrite.applyToRest': '套用到其餘衝突檔案',
'zmodem.overwrite.overwrite': '覆蓋',
'zmodem.overwrite.skip': '跳過',
'zmodem.overwrite.cancel': '取消',
'settings.shortcuts.resetToDefault': '重置為預設',
};
+731
View File
@@ -0,0 +1,731 @@
import type { Messages } from '../types';
export const zhTWCoreMessages: Messages = {
// Common
'common.save': '儲存',
'common.cancel': '取消',
'common.close': '關閉',
'common.reset': '重置',
'common.zoomIn': '放大',
'common.zoomOut': '縮小',
'common.settings': '設定',
'common.search': '搜尋',
'common.connect': '連線',
'common.terminal': '終端',
'common.create': '建立',
'common.add': '新增',
'common.rename': '重新命名',
'common.refresh': '重新整理',
'common.continue': '繼續',
'common.enabled': '已啟用',
'common.disabled': '已停用',
'common.unknownError': '未知錯誤',
'common.noResultsFound': '沒有相符結果',
'common.back': '返回',
'common.apply': '套用',
'common.use': '使用',
'common.useGlobal': '跟隨全域',
'common.left': '左側',
'common.right': '右側',
'common.more': '更多',
'common.selectAHost': '選擇主機',
'sort.manual': '手動順序',
'sort.az': 'A-z',
'sort.za': 'Z-a',
'sort.newest': '從新到舊',
'sort.oldest': '從舊到新',
'sort.group': '按分組',
'field.label': 'Label',
'field.type': '型別',
'auth.keyType': '型別 {type}',
'auth.showAllKeys': '顯示全部 keys',
// Dialogs / prompts
'confirm.deleteHost': '刪除主機 "{name}"?',
'confirm.deleteIdentity': '刪除身份 "{name}"?',
'confirm.removeProvider': '移除供應商 "{name}"?',
'confirm.closeBusyTerminal.title': '確認關閉',
'confirm.closeBusyTerminal.message': '程序 "{command}" 仍在執行,關閉後會被終止。',
'confirm.closeBusyTerminal.messageWithMore': '程序 "{command}" 及其他 {count} 個正在執行的程序將被終止。',
'confirm.closeBusyTerminal.cancel': '取消',
'confirm.closeBusyTerminal.close': '關閉',
'dialog.renameWorkspace.title': '重新命名工作區',
'dialog.renameSession.title': '重新命名工作階段',
'field.name': '名稱',
'placeholder.workspaceName': '工作區名稱',
'placeholder.sessionName': '工作階段名稱',
'toast.settingsUnavailable': '目前平台無法開啟設定視窗。',
'credentials.protectionUnavailable.title': '憑證保護不可用',
'credentials.protectionUnavailable.message': '目前裝置無法自動解密已儲存的密碼和金鑰。連線前請重新輸入憑證。',
'credentials.protectionUnavailable.action': '開啟設定',
// Settings shell
'settings.title': '設定',
'settings.tab.application': '應用程式',
'settings.tab.appearance': '外觀',
'settings.tab.terminal': '終端',
'settings.tab.shortcuts': '快捷鍵',
'settings.tab.syncCloud': '同步與雲',
'settings.tab.system': '系統',
'settings.terminal.connection.verifyHostKeys': '驗證 SSH 主機金鑰',
'settings.terminal.connection.verifyHostKeys.desc': '連線到新的或已變化的 SSH 主機金鑰前先詢問。只建議在可信內網中關閉。',
'settings.terminal.section.sidePanel': '側邊欄',
'settings.terminal.sidePanel.autoOpen': '開啟終端時展開側邊欄',
'settings.terminal.sidePanel.autoOpen.desc': '終端連線成功後自動開啟一個側邊欄。',
'settings.terminal.sidePanel.autoOpenPane': '預設側邊欄',
'settings.terminal.sidePanel.autoOpenPane.desc': '選擇優先開啟哪個面板。',
'settings.terminal.sidePanel.pane.sftp': 'SFTP',
'settings.terminal.sidePanel.pane.scripts': '指令碼',
'settings.terminal.sidePanel.pane.history': '歷史',
'settings.terminal.sidePanel.pane.theme': '主題',
'settings.terminal.sidePanel.pane.system': '系統',
'settings.terminal.sidePanel.pane.notes': '筆記',
'settings.terminal.sidePanel.pane.ai': 'AI',
// Settings > System
'settings.system.title': '系統',
'settings.system.description': '系統資訊與臨時檔案管理。',
'settings.system.tempDirectory': '臨時檔案',
'settings.system.location': '位置',
'settings.system.fileCount': '檔案數量',
'settings.system.totalSize': '佔用空間',
'settings.system.openFolder': '開啟資料夾',
'settings.system.refresh': '重新整理',
'settings.system.clearTempFiles': '清理臨時檔案',
'settings.system.clearing': '清理中...',
'settings.system.clearResult': '已刪除 {deleted} 個檔案,{failed} 個失敗。',
'settings.system.tempDirectoryHint': '臨時檔案在使用外部應用程式開啟遠端檔案時建立。SFTP 工作階段關閉時會自動清理。',
'settings.system.credentials.title': '憑證保護',
'settings.system.credentials.status': '狀態',
'settings.system.credentials.checking': '檢查中...',
'settings.system.credentials.available': '可用(系統鑰匙串正常)',
'settings.system.credentials.unavailable': '不可用(無法解密已儲存憑證)',
'settings.system.credentials.unknown': '未知(目前環境不支援)',
'settings.system.credentials.unavailableHint': '在其他使用者或機器上加密的憑證無法在此處解密。請在目前裝置重新輸入並儲存憑證。',
'settings.system.credentials.portabilityHint': '雲同步可跨裝置,因為使用主金鑰加密;本機 safeStorage 加密僅綁定目前系統使用者/裝置。',
// Settings > System > Crash Logs
'settings.system.crashLogs.title': '崩潰日誌',
'settings.system.crashLogs.description': '檢視主程序錯誤日誌,協助診斷異常行為。',
'settings.system.crashLogs.noLogs': '未找到崩潰日誌。',
'settings.system.crashLogs.entries': '{count} 條記錄',
'settings.system.crashLogs.clear': '清除所有日誌',
'settings.system.crashLogs.cleared': '已清除 {count} 個日誌檔案。',
'settings.system.crashLogs.source': '來源',
'settings.system.crashLogs.time': '時間',
'settings.system.crashLogs.message': '訊息',
'settings.system.crashLogs.stack': '堆疊追蹤',
'settings.system.crashLogs.hint': '崩潰日誌保留 30 天,超期自動清理。',
'settings.system.crashLogs.collapse': '收起',
'settings.system.crashLogs.expand': '檢視詳情',
// Settings > System > Software Update
'settings.update.title': '軟體更新',
'settings.update.currentVersion': '目前版本',
'settings.update.checkForUpdates': '檢查更新',
'settings.update.checking': '檢查中...',
'settings.update.upToDate': '目前已是最新版本。',
'settings.update.available': '新版本 {version} 已釋出。',
'settings.update.download': '下載更新',
'settings.update.downloading': '正在下載... {percent}%',
'settings.update.readyToInstall': '更新已下載,準備安裝。',
'settings.update.restartNow': '重啟並更新',
'settings.update.error': '檢查更新失敗。',
'settings.update.downloadError': '下載失敗。',
'settings.update.manualDownload': '前往 GitHub 下載',
'settings.update.manualDownloadHint': '目前平台不支援自動更新,請前往 GitHub 下載最新版本。',
'settings.update.hint': 'Netcatty 從 GitHub Releases 檢查更新。',
'settings.update.lastCheckedJustNow': '剛剛',
'settings.update.lastCheckedMinutesAgo': '{n} 分鐘前',
'settings.update.lastCheckedHoursAgo': '{n} 小時前',
'settings.update.lastCheckedPrefix': '上次檢查:',
'settings.update.autoUpdateEnabled': '自動更新',
'settings.update.autoUpdateEnabledDesc': '有新版本時自動檢查並下載更新。',
// Settings > Session Restore
'settings.sessionRestore.title': '工作階段恢復',
'settings.sessionRestore.restorePreviousSession': '恢復上次的標籤頁和工作區',
'settings.sessionRestore.restorePreviousSessionDesc': '啟動時重新開啟上次的終端標籤頁和工作區佈局。',
'settings.sessionRestore.restoreTerminalCwd': '恢復終端工作目錄',
'settings.sessionRestore.restoreTerminalCwdDesc': '重新連線恢復的終端時,嘗試回到上次記錄的目錄;失敗時不會阻塞連線。',
'terminal.restore.placeholder.title': '已恢復的工作階段',
'terminal.restore.placeholder.desc': '此終端目前已斷開。準備好後可以重新連線,為這個標籤頁啟動新的 shell。',
'terminal.restore.placeholder.reconnect': '重新連線',
'terminal.restore.cwdLog': '正在恢復工作目錄:{cwd}',
// Settings > Session Logs
'settings.sessionLogs.title': '工作階段日誌',
'settings.sessionLogs.description': '設定工作階段日誌匯出和自動儲存設定。',
'settings.sessionLogs.autoSave': '自動儲存',
'settings.sessionLogs.enableAutoSave': '啟用自動儲存',
'settings.sessionLogs.enableAutoSaveDesc': '在終端工作階段結束時自動儲存工作階段日誌。',
'settings.sessionLogs.directory': '儲存目錄',
'settings.sessionLogs.noDirectory': '未選擇目錄',
'settings.sessionLogs.browse': '瀏覽',
'settings.sessionLogs.openFolder': '開啟資料夾',
'settings.sessionLogs.directoryHint': '日誌將按主機名組織在子目錄中。',
'settings.sessionLogs.format': '日誌格式',
'settings.sessionLogs.formatDesc': '選擇儲存日誌檔案的格式。',
'settings.sessionLogs.formatTxt': '純文字 (.txt)',
'settings.sessionLogs.formatRaw': '原始格式 (.log)',
'settings.sessionLogs.formatHtml': 'HTML (.html)',
'settings.sessionLogs.timestamps': '新增時間戳',
'settings.sessionLogs.timestampsDesc': '為純文字和 HTML 日誌的每一行新增本機時間。',
'settings.sessionLogs.hint': '工作階段日誌用於記錄終端輸出,便於故障排查和審計。',
// Settings > SSH Debug Logs
'settings.sshDebugLogs.title': 'SSH 除錯日誌',
'settings.sshDebugLogs.enable': '啟用 SSH 除錯日誌',
'settings.sshDebugLogs.enableDesc': '記錄連線、認證、握手、斷開和錯誤原因,不記錄終端輸出。',
'settings.sshDebugLogs.location': '日誌位置',
'settings.sshDebugLogs.status': '狀態',
'settings.sshDebugLogs.statusOn': '已開啟',
'settings.sshDebugLogs.statusOff': '未開啟',
'settings.sshDebugLogs.size': '大小',
'settings.sshDebugLogs.hint': '開啟後,新發起的 SSH 連線會寫入診斷資訊,方便排查堡壘機、認證和異常斷開問題。',
// Settings > Global Hotkey (Quake Mode)
'settings.globalHotkey.title': '全域快捷鍵',
'settings.globalHotkey.toggleWindow': '切換視窗',
'settings.globalHotkey.toggleWindowDesc': '按下組合鍵以設定顯示/隱藏視窗的全域快捷鍵。',
'settings.globalHotkey.notSet': '未設定',
'settings.globalHotkey.reset': '恢復預設',
'settings.globalHotkey.closeToTray': '關閉時最小化到系統匣',
'settings.globalHotkey.closeToTrayDesc': '啟用後,關閉視窗將最小化到系統匣而不是結束程式。',
'settings.globalHotkey.enabled': '啟用全域快捷鍵',
'settings.globalHotkey.enabledDesc': '註冊系統級鍵盤快捷鍵。停用後將取消所有全域快捷鍵註冊。',
'settings.globalHotkey.hint': '全域快捷鍵在系統範圍內工作,可快速顯示或隱藏視窗(下拉式終端風格)。',
// Tray Panel
'tray.openMainWindow': '開啟主視窗',
'tray.sessions': '工作階段',
'tray.portForwarding': '埠轉發',
'tray.status.connected': '已連線',
'tray.status.connecting': '連線中',
'tray.status.disconnected': '已斷開',
'tray.status.active': '已啟用',
'tray.status.inactive': '未啟用',
'tray.status.error': '錯誤',
'tray.recentHosts': '最近連線的主機',
'tray.empty.title': '一切都很安靜',
'tray.empty.subtitle': '去連線個伺服器吧,它們想念你了 🚀',
'tray.quit': '結束 Netcatty',
// Vault Sidebar
'vault.sidebar.collapse': '收起側邊欄',
'vault.sidebar.expand': '展開側邊欄',
'vault.sidebar.resize': '調整側邊欄寬度',
// Settings > Application
'settings.application.checkUpdates': '檢查更新',
'settings.application.reportProblem': '反饋問題',
'settings.application.reportProblem.subtitle': '生成預填的 GitHub issue',
'settings.application.community': '社群',
'settings.application.community.subtitle': 'GitHub Discussions',
'settings.application.github': 'GitHub',
'settings.application.github.subtitle': '原始碼',
'settings.application.whatsNew': '更新內容',
'settings.application.whatsNew.subtitle': '檢視釋出說明',
'settings.application.openExternal.failedTitle': '無法開啟連結',
'settings.application.openExternal.failedBody': '系統瀏覽器和內建瀏覽器視窗都無法開啟該連結。',
'settings.vault.title': '主機庫',
'settings.vault.showRecentHosts': '顯示最近連線的主機',
'settings.vault.showRecentHostsDesc': '在主機列表頂部顯示最近連線過的主機',
'settings.vault.showOnlyUngroupedHostsInRoot': '根目錄只顯示未分組主機',
'settings.vault.showOnlyUngroupedHostsInRootDesc': '開啟後,主機庫根目錄的主機列表只顯示沒有分組的主機,已分組主機請從左側分組進入檢視。',
'settings.vault.showSftpTab': '顯示 SFTP 標籤頁',
'settings.vault.showSftpTabDesc': '在頂部標籤欄顯示獨立的 SFTP 檢視。關閉後可改用工作階段內左側的 SFTP 側欄。',
'settings.vault.showHostTreeSidebar': '顯示主機列表側欄',
'settings.vault.showHostTreeSidebarDesc': '在終端和編輯器標籤頁顯示主機列表側欄及頂部開關。',
// Update notifications
'update.available.title': '發現新版本',
'update.available.message': '新版本 {version} 已釋出,點選前往下載。',
'update.checking': '正在檢查更新...',
'update.upToDate.title': '已是最新版本',
'update.upToDate.message': '目前版本 ({version}) 已是最新。',
'update.error': '檢查更新失敗',
'update.downloadNow': '立即下載',
'update.viewInSettings': '在設定中檢視',
'update.readyToInstall.title': '更新已就緒',
'update.readyToInstall.message': '版本 {version} 已下載完成,準備安裝。',
'update.restartNow': '立即重啟',
'update.downloadFailed.title': '更新失敗',
'update.downloadFailed.message': '下載更新失敗,可前往 GitHub 手動下載。',
'update.needsSave.title': '有未儲存內容',
'update.needsSave.message': '請先儲存已開啟的編輯器,然後再次點選「立即重啟」以安裝更新。',
'update.openReleases': '開啟 Releases',
'update.remindLater': '稍後提醒',
'update.skipVersion': '跳過此版本',
// Settings > Appearance
'settings.appearance.uiTheme': '介面主題',
'settings.appearance.theme': '主題',
'settings.appearance.theme.desc': '選擇淺色、深色或跟隨系統設定',
'settings.appearance.theme.light': '淺色',
'settings.appearance.theme.dark': '深色',
'settings.appearance.theme.system': '系統',
'settings.appearance.accentColor': '強調色',
'settings.appearance.customColor': '自訂顏色',
'settings.appearance.accentColor.mode': '使用自訂強調色',
'settings.appearance.accentColor.mode.desc': '覆蓋主題自帶的強調色',
'settings.appearance.accentColor.custom': '自訂強調色',
'settings.appearance.themeColor': '主題色',
'settings.appearance.themeColor.desc': '為淺色與深色主題選擇預設配色',
'settings.appearance.themeColor.light': '淺色主題',
'settings.appearance.themeColor.dark': '深色主題',
'settings.appearance.customCss': '自訂 CSS',
'settings.appearance.customCss.desc':
'使用自訂 CSS 個人化介面,修改會立即生效。主要 UI 區塊都公開了 [data-section="..."] 屬性供你定位,比如:snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebar(Focus 模式終端列表)、terminal-host-tree-sidebar、terminal-host-tree-sidebar-content、terminal-host-tree-sidebar-row、terminal-side-panel(SFTP/指令碼/主題/AI 側欄,開啟時生效)、terminal-side-panel-tabs、terminal-side-panel-content、terminal-sftp-panel、terminal-sftp-host-header、terminal-sftp-pane、terminal-sftp-toolbar、terminal-sftp-path、terminal-sftp-filter-bar、terminal-sftp-list、terminal-sftp-list-header、terminal-sftp-list-row、terminal-sftp-tree、terminal-sftp-tree-row、terminal-sftp-transfer-queue、terminal-sftp-transfer-row、terminal-split-pane、terminal-split-resizer、top-tabs、top-tabs-host-tree-toggle、top-tabs-quick-switcher-toggle。',
'settings.appearance.customCss.placeholder':
'/* 範例 — 由於 Tailwind 優先順序較高,需要使用 !important */\n\n/* 隱藏頂部標籤欄裡的主機列表開關 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隱藏開啟快速切換器的加號按鈕 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作側欄邊框(關閉側欄後不會殘留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整個操作側欄背景,而不只是頂部標籤 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改選取的 SFTP 檔案行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分割分割線 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 標示目前聚焦的分割 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 設定 → 終端 → 工作區聚焦指示 → 聚焦窗格顯示邊框 */',
'settings.appearance.language': '語言',
'settings.appearance.language.desc': '選擇介面語言',
'settings.appearance.uiFont': '介面字型',
'settings.appearance.uiFont.desc': '選擇軟體介面使用的字型',
'settings.appearance.windowOpacity': '視窗透明度',
'settings.appearance.windowOpacity.desc': '調節整個應用程式視窗的透明度,方便疊在其他內容上方。較低時終端文字也會變淡;部分 Linux 桌面環境可能不支援。',
'settings.appearance.appIcon': '應用程式圖示',
'settings.appearance.appIcon.desc': '選擇 Dock/工作列圖示風格,切換應用程式時更容易找到 Netcatty。執行時生效,安裝包內的圖示不會改變。',
'settings.appearance.appIcon.original': '原始',
'settings.appearance.appIcon.bright': '明亮',
'settings.appearance.appIcon.dark': '深色',
'settings.appearance.appIcon.colorful': '彩色',
'settings.appearance.appIcon.highContrast': '高對比',
'settings.appearance.appIcon.group.classic': '彩色底',
'settings.appearance.appIcon.group.white': '白底',
'settings.appearance.appIcon.group.special': '隱藏款',
'settings.appearance.appIcon.whiteNavy': '白底 · 海軍藍貓',
'settings.appearance.appIcon.whiteSky': '白底 · 天藍貓',
'settings.appearance.appIcon.whiteRose': '白底 · 玫紅貓',
'settings.appearance.appIcon.whiteEmerald': '白底 · 翡翠貓',
'settings.appearance.appIcon.whiteAmber': '白底 · 琥珀貓',
'settings.appearance.appIcon.whiteViolet': '白底 · 紫羅蘭貓',
'settings.appearance.appIcon.rainbow': '彩虹貓',
// Context menus / common actions
'action.newHost': '新建主機',
'action.newSubfolder': '新建資料夾',
'action.copyPublicKey': '複製公鑰',
'action.keyExport': '匯出金鑰',
'action.edit': '編輯',
'action.delete': '刪除',
'action.remove': '移除',
'action.convertToHost': '轉換為主機',
// Sync
'sync.cloudSync': '雲同步',
'sync.settings': '同步設定',
'sync.active': '雲同步已啟用',
'sync.syncing': '正在同步…',
'sync.error': '同步錯誤',
'sync.notConfigured': '未設定',
'sync.failed': '同步失敗',
'sync.connected': '已連線',
'sync.syncNow': '立即同步',
'sync.recentActivity': '最近活動',
'sync.pending': '有待同步的變更',
'sync.history.uploaded': '已 Upload',
'sync.history.downloaded': '已 Download',
'sync.history.resolved': '已處理',
'sync.toast.completedMessage': '同步完成',
'sync.toast.errorTitle': '同步錯誤',
'sync.autoSync.failedTitle': '同步失敗',
'sync.autoSync.inspectFailedTitle': '同步已暫停',
'sync.autoSync.inspectFailedMessage': '無法存取雲端以檢查變更。資料改動或下次啟動時會自動重試。',
'sync.autoSync.syncedTitle': '已從雲端同步',
'sync.autoSync.syncedMessage': '你的資料已從雲端更新。',
'sync.autoSync.noProvider': '未連線雲同步 provider。請開啟 設定 → Sync & Cloud 進行連線。',
'sync.autoSync.alreadySyncing': '同步正在進行中。',
'sync.autoSync.restoreInProgress': '另一個視窗中的本機備份恢復正在進行中,請等待其完成。',
'sync.autoSync.interruptedApplyTitle': '同步已暫停 — 上次恢復未完成',
'sync.autoSync.interruptedApplyMessage': '上次本機恢復過程未正常結束,本機資料可能處於半套用狀態。請開啟「設定 → Sync & Cloud → 恢復」,從保護性備份中恢復後再讓自動同步繼續。',
'sync.autoSync.vaultLocked': 'Vault 處於鎖定狀態。請開啟 設定 → Sync & Cloud 解鎖。',
'sync.autoSync.conflictDetected': '偵測到同步衝突。請開啟 設定 → Sync & Cloud 處理。',
'sync.autoSync.syncFailed': '同步失敗',
'sync.autoSync.restoredTitle': '已恢復',
'sync.autoSync.restoredMessage': '已從雲端恢復主機庫資料。',
'sync.autoSync.keptLocalTitle': '已保留本機資料',
'sync.autoSync.keptLocalMessage': '保留了空的本機主機庫,未套用雲端資料。',
'sync.autoSync.emptyVaultConflict.title': '偵測到空主機庫',
'sync.autoSync.emptyVaultConflict.description': '本機主機庫為空,但云端有資料。這通常發生在應用程式更新或儲存重置之後。請選擇如何處理:',
'sync.autoSync.emptyVaultConflict.cloudLabel': '雲端',
'sync.autoSync.emptyVaultConflict.restore': '從雲端恢復',
'sync.autoSync.emptyVaultConflict.restoreDesc': '推薦 — 從雲端備份恢復主機、金鑰和程式碼片段',
'sync.autoSync.emptyVaultConflict.keepEmpty': '保持為空',
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': '從頭開始,使用空的主機庫',
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} 台主機,{keys} 個金鑰,{snippets} 個程式碼片段,{notes} 條筆記,{proxyProfiles} 個代理',
'sync.autoSync.emptyVaultManual': '無法同步:本機 vault 為空。請先從本機備份恢復,或在同步面板裡使用"強制推送"。',
'sync.blocked.title': '同步已暫停',
'sync.blocked.reason.bulkShrink': '即將從雲端刪除 {baseCount} 條 {entityType} 中的 {lost} 條(縮減 {percent}%)。',
'sync.blocked.reason.largeShrink': '即將從雲端刪除 {lost} 條 {entityType}。',
'sync.blocked.detail': '通常是本機狀態異常(鑰匙串故障、資料載入不全)導致。請從本機備份恢復,如果確實要刪這些條目請使用強制推送。',
'sync.blocked.restoreButton': '從本機備份恢復',
'sync.blocked.forcePushButton': '強制推送',
'sync.forcePush.title': '確認強制推送',
'sync.forcePush.body': '你將從雲端移除 {lost} 條 {entityType},此操作不可復原。繼續?',
'sync.forcePush.confirm': '確認推送',
'sync.forcePush.cancel': '取消',
'sync.entityType.hosts': '主機',
'sync.entityType.keys': '金鑰',
'sync.entityType.identities': '身份',
'sync.entityType.proxyProfiles': '代理設定',
'sync.entityType.snippets': '程式碼片段',
'sync.entityType.customGroups': '分組',
'sync.entityType.snippetPackages': '片段包',
'sync.entityType.knownHosts': '主機金鑰記錄',
'sync.entityType.portForwardingRules': '埠轉發規則',
'sync.entityType.groupConfigs': '分組設定',
'sync.credentialsUnavailable': '目前裝置無法解密部分已儲存憑證。請先在本機重新輸入憑證後再同步。',
'time.never': '從未',
'time.justNow': '剛剛',
'time.minutesAgo': '{minutes} 分鐘前',
// Vault navigation
'vault.nav.hosts': '主機',
'vault.nav.keychain': '鑰匙串',
'vault.nav.proxies': '代理',
'vault.nav.portForwarding': '埠轉發',
'vault.nav.snippets': '程式碼片段',
'vault.nav.notes': '筆記',
'vault.nav.knownHosts': '已知主機',
'vault.nav.logs': '日誌',
'proxyProfiles.action.add': '新增代理',
'proxyProfiles.search.placeholder': '搜尋代理…',
'proxyProfiles.section.proxies': '代理',
'proxyProfiles.count.items': '{count} 項',
'proxyProfiles.empty.title': '暫無代理',
'proxyProfiles.empty.desc': '建立可重複使用的 HTTP、SOCKS5 或指令代理,然後在主機詳情裡選擇。',
'proxyProfiles.usage': '已關聯 {count} 處',
'proxyProfiles.copyName': '{name} 副本',
'proxyProfiles.panel.newTitle': '新建代理',
'proxyProfiles.field.name': '代理名稱',
'proxyProfiles.error.required': '名稱和代理詳情不能為空。',
'proxyProfiles.error.port': '埠必須在 1 到 65535 之間。',
'proxyProfiles.viewMode': '代理顯示方式',
'proxyProfiles.delete.title': '刪除代理?',
'proxyProfiles.delete.desc': '刪除 "{name}" 會同時從 {count} 個主機或分組設定中解除關聯。',
'vault.groups.title': '分組',
'vault.groups.total': '共 {count} 個',
'vault.groups.hostsCount': '{count} 台主機',
'vault.groups.newSubgroup': '新建子分組',
'vault.groups.rename': '重新命名分組',
'vault.groups.unnamed': '未命名分組',
'vault.groups.delete': '刪除分組',
'vault.groups.createSubfolder': '建立子分組',
'vault.groups.createRoot': '建立根分組',
'vault.groups.createDialog.desc': '建立新的分組用於組織主機。',
'vault.groups.renameDialogTitle': '重新命名分組',
'vault.groups.renameDialog.desc': '重新命名已有分組。',
'vault.groups.deleteDialogTitle': '刪除分組',
'vault.groups.deleteDialog.desc': '這將永久刪除該分組並將所有主機移動到根級別。',
'vault.groups.deleteDialog.managedDesc': '這是一個託管的 SSH config 分組。刪除後將同時刪除所有主機並斷開與原始檔的連線。',
'vault.groups.deleteDialog.deleteHosts': '同時刪除該分組下的所有主機',
'vault.groups.ungrouped': '未分組',
'vault.groups.field.name': '分組名稱',
'vault.groups.placeholder.example': '例如:Production',
'vault.groups.parentLabel': '父級',
'vault.groups.pathLabel': '路徑',
'vault.groups.settings': '分組設定',
'vault.groups.details': '分組詳情',
'vault.groups.details.general': '常規',
'vault.groups.details.ssh': 'SSH',
'vault.groups.details.telnet': 'Telnet',
'vault.groups.details.advanced': '高階',
'vault.groups.details.appearance': '外觀',
'vault.groups.details.mosh': 'Mosh',
'vault.groups.details.parentGroup': '父分組',
'vault.groups.details.none': '無',
'vault.groups.details.inherited': '繼承自分組',
'vault.groups.details.addProtocol': '新增協定',
'vault.groups.details.removeProtocol': '移除協定',
'vault.groups.details.fontFamily': '字型',
'vault.groups.details.fontSize': '字號',
'vault.groups.errors.required': '分組名稱不能為空。',
'vault.groups.errors.invalidChars': "分組名稱不能包含 '/' 或 '\\\\'.",
'vault.groups.errors.duplicatePath': '該位置已存在同名分組。',
'vault.managedSource.unmanage': '取消託管',
'vault.managedSource.unmanageSuccess': '已取消託管分組',
'vault.hosts.header.entries': '{count} 條',
'vault.hosts.header.live': '{count} 個線上',
// Vault hosts header/actions
'vault.hosts.search.placeholder': '查詢主機或 ssh user@hostname / ssh -p 2222 user@hostname…',
'vault.hosts.connect': '連線',
'vault.view.grid': '網格',
'vault.view.list': '列表',
'vault.view.tree': '樹形',
'vault.tree.expandAll': '展開全部',
'vault.tree.collapseAll': '摺疊全部',
'vault.hosts.newHost': '新建主機',
'vault.hosts.newGroup': '新建分組',
'vault.hosts.import': '匯入',
'vault.hosts.export': '匯出',
'vault.hosts.export.toast.success': '已匯出 {count} 個主機到 CSV',
'vault.hosts.export.toast.successWithSkipped': '已匯出 {count} 個主機到 CSV(跳過 {skipped} 個不支援的主機)',
'vault.hosts.export.toast.noHosts': '沒有主機可匯出',
'vault.hosts.allHosts': '全部主機',
'vault.hosts.pinned': '已置頂',
'vault.hosts.recentlyConnected': '最近連線',
'vault.hosts.pinToTop': '置頂',
'vault.hosts.unpin': '取消置頂',
'vault.hosts.copyCredentials': '複製賬密資訊',
'vault.hosts.copyCredentials.toast.success': '賬密資訊已複製到剪貼簿',
'vault.hosts.copyCredentials.toast.noPassword': '該主機未儲存密碼',
'vault.hosts.multiSelect': '多選',
'vault.hosts.selected': '已選擇 {count} 項',
'vault.hosts.selectAll': '全選',
'vault.hosts.deselectAll': '取消全選',
'vault.hosts.deleteSelected': '刪除 ({count})',
'vault.hosts.deleteMultiple.success': '已刪除 {count} 個主機',
'vault.hosts.connectSelected': '連線 ({count})',
'vault.hosts.connectMultiple.success': '正在連線 {count} 個主機',
'vault.hosts.moveToGroup.success': '已將 {host} 移動到 {group}',
'vault.hosts.errors.nameRequired': '主機名稱不能為空。',
'vault.hosts.empty.title': '設定你的主機',
'vault.hosts.empty.desc': '儲存主機以快速連線到你的伺服器、虛擬機器和容器。',
'deepLink.ssh.invalid': '無效的 ssh:// 連結',
// 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.copyCurrentPath': '複製目前路徑',
'sftp.copyCurrentPath.success': '已複製目前路徑',
'sftp.copyCurrentPath.error': '無法複製目前路徑',
'sftp.viewMode.label': '檢視模式',
'sftp.viewMode.list': '列表檢視',
'sftp.viewMode.tree': '樹形檢視',
'sftp.viewMode.switchToList': '切換到列表檢視',
'sftp.viewMode.switchToTree': '切換到樹形檢視',
'sftp.tree.loadError': '載入目錄失敗',
'sftp.tree.loading': '載入中...',
'sftp.kind.folder': '資料夾',
'sftp.context.rename': '重新命名',
'sftp.context.permissions': '權限',
'sftp.context.delete': '刪除',
'sftp.context.refresh': '重新整理',
'sftp.context.uploadFiles': '上傳檔案...',
'sftp.context.uploadFilesHere': '上傳檔案到這裡...',
'sftp.context.uploadFolder': '上傳資料夾...',
'sftp.context.uploadFolderHere': '上傳資料夾到這裡...',
'sftp.context.downloadSelected': '下載選取項({count})',
'sftp.context.deleteSelected': '刪除選取項({count})',
'sftp.dropFilesHere': '拖拽檔案到這裡',
'sftp.itemsCount': '{count} 個專案',
'sftp.selectedCount': '已選 {count} 個',
'sftp.path.doubleClickToEdit': '雙擊編輯路徑',
'sftp.showHiddenPaths': '隱藏的路徑',
'sftp.task.waiting': '等待中...',
'sftp.transfer.preparing': '準備中...',
'sftp.status.loading': '載入中...',
'sftp.status.uploading': '上傳中...',
'sftp.status.ready': '就緒',
'sftp.transfers': '傳輸',
'sftp.transfers.active': '{count} 個進行中',
'sftp.transfers.clearCompleted': '清除已完成',
'sftp.transfers.calculatingTotal': '正在統計總大小...',
'sftp.transfers.filesCount': '{count} 個檔案',
'sftp.transfers.filesProgress': '{current}/{total} 個檔案',
'sftp.transfers.expandChildren': '展開檔案',
'sftp.transfers.collapseChildren': '收起檔案',
'sftp.transfers.expandChildList': '展開詳情',
'sftp.transfers.collapseChildList': '收起',
'sftp.transfers.retryAction': '重試',
'sftp.transfers.dismissAction': '移除',
'sftp.transfers.openTargetFolder': '開啟目標目錄',
'sftp.transfers.openTargetFolderError': '無法開啟目標目錄',
'sftp.transfers.copyTargetPath': '複製目標路徑',
'sftp.transfers.copyTargetPathSuccess': '已複製目標路徑',
'sftp.transfers.copyTargetPathError': '無法複製目標路徑',
'sftp.transfers.resizeNameColumn': '調整檔名列寬',
'sftp.transfers.dragToResize': '拖拽調整高度',
'sftp.goUp': '上一級',
'sftp.goToTerminalCwd': '定位到終端目前目錄',
'sftp.followTerminalCwd': '追隨終端目錄',
'sftp.followTerminalCwd.enable': '開啟追隨終端目錄',
'sftp.followTerminalCwd.disable': '關閉追隨終端目錄',
'sftp.encoding.label': '檔名編碼',
'sftp.encoding.auto': '自動',
'sftp.encoding.utf8': 'UTF-8',
'sftp.encoding.gb18030': 'GB18030',
'sftp.goHome': '返回主目錄',
'sftp.folderName': '資料夾名稱',
'sftp.folderName.placeholder': '輸入資料夾名稱',
'sftp.fileName': '檔名稱',
'sftp.fileName.placeholder': '輸入檔名稱',
'sftp.prompt.newFolderName': '新建資料夾名稱?',
'sftp.rename.title': '重新命名',
'sftp.rename.newName': '新名稱',
'sftp.rename.placeholder': '輸入新名稱',
'sftp.confirm.deleteOne': '刪除 "{name}"?',
'sftp.deleteConfirm.single': '刪除 "{name}"?',
'sftp.deleteConfirm.title': '刪除 {count} 個專案?',
'sftp.deleteConfirm.desc': '此操作不可復原,將刪除以下內容:',
'sftp.deleteConfirm.descSingle': '此操作不可復原。',
'sftp.deleteConfirm.host': '主機',
'sftp.deleteConfirm.path': '路徑',
'sftp.error.loadFailed': '載入目錄失敗',
'sftp.error.downloadFailed': '下載失敗',
'sftp.error.uploadFailed': '上傳失敗',
'sftp.error.deleteFailed': '刪除失敗',
'sftp.error.createFolderFailed': '建立資料夾失敗',
'sftp.error.createFileFailed': '建立檔案失敗',
'sftp.error.invalidFileName': '檔名包含非法字元:{chars}',
'sftp.error.reservedName': '此檔名是系統保留名稱',
'sftp.overwrite.title': '檔案已存在',
'sftp.overwrite.desc': '名為"{name}"的檔案已存在。是否要替換它?',
'sftp.overwrite.confirm': '替換',
'sftp.error.renameFailed': '重新命名失敗',
'sftp.picker.title': '選擇主機',
'sftp.picker.desc': '為{side}窗格選擇主機',
'sftp.picker.searchPlaceholder': '搜尋主機...',
'sftp.picker.local.title': '本機檔案系統',
'sftp.picker.local.desc': '瀏覽本機檔案',
'sftp.picker.local.badge': '本機',
'sftp.picker.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': '預設',
};
+123
View File
@@ -0,0 +1,123 @@
export const zhTWScriptsMessages = {
'scripts.meta.name': '名稱',
'scripts.meta.language': '語言',
'scripts.meta.description': '描述',
'scripts.meta.descriptionPlaceholder': '可選,指令碼的簡要說明',
'scripts.meta.trigger': '觸發方式',
'scripts.meta.triggerPattern': '輸出比對(正規表示式)',
'scripts.meta.code': '指令碼程式碼',
'scripts.trigger.manual': '手動執行',
'scripts.trigger.onConnect': '連線時執行',
'scripts.trigger.onOutput': '輸出比對時執行',
'scripts.trigger.onOutputHint': '僅在伺服器端輸出比對到正規表示式時觸發(會忽略本機按鍵回顯)。未設定目標主機時,對目前已連線的工作階段生效;設定目標後僅監聽對應主機。進入備用畫面的應用程式(如 vim、htop)期間不會觸發。本 session 有其他指令碼執行期間會抑制;指令碼結束後再檢查 buffer。流程內等待請用 nct.screen.waitFor。',
'scripts.actions.save': '儲存',
'scripts.actions.runNow': '立即執行',
'scripts.actions.openEditor': '彈出視窗編輯',
'scripts.actions.openEditorHint': '在更大的視窗中編輯指令碼',
'scripts.editor.modalTitle': '指令碼編輯器',
'scripts.editor.modalSubtitle': '在更大的工作區中編輯指令碼的中繼資料與程式碼。',
'scripts.editor.lineCount': '{count} 行',
'scripts.editor.resize': '調整編輯器高度',
'scripts.targets.hint': '自動觸發會作用於所選主機;勾選「對所有主機生效」則作用於全部可連線的主機。',
'scripts.targets.connectOrderHint': '連線指令碼的執行順序請在主機詳情 → 自動化 中調整。',
'scripts.actions.runNowHint': '在所選目標主機上執行;勾選「對所有主機生效」時在所有可連線的主機上執行。',
'scripts.actions.runParallel': '在所有標籤頁並行執行',
'scripts.actions.runSequential': '在所有標籤頁依序執行',
'scripts.actions.skippedConnectingSessions': '有 {count} 個標籤仍在連線中,已跳過',
'scripts.actions.noRunnableHosts': '沒有與指令碼目標相符的可連線主機',
'scripts.sidePanel.library': '指令碼庫',
'scripts.sidePanel.running': '執行中',
'scripts.sidePanel.newScript': '新建指令碼',
'scripts.running.empty': '目前工作階段沒有正在執行的指令碼。',
'scripts.running.unnamed': '未命名指令碼',
'scripts.running.status.running': '執行中',
'scripts.running.status.paused': '已暫停',
'scripts.running.status.completed': '已完成',
'scripts.running.status.failed': '失敗',
'scripts.running.waitingFor': '等待 {pattern}',
'scripts.running.waitingForLabel': '等待',
'scripts.running.waitingForShellPrompt': 'Shell 提示字元(# 或 $)',
'scripts.running.lastSent': '已傳送:{command}',
'scripts.recording.start': '開始錄製',
'scripts.recording.active': '停止錄製',
'scripts.recording.startHint': '在目前終端工作階段中錄製操作,自動產生 nct 指令碼程式碼',
'scripts.recording.unavailableHint': '此入口不支援錄製,請用終端右側的指令碼側欄',
'scripts.recording.activeHint': '正在錄製目前終端。在終端中正常輸入指令;點「停止錄製」或終端工具列的 REC 結束並儲存。',
'scripts.recording.started': '已開始錄製,請在終端中操作',
'scripts.recording.noSession': '請先連線終端(單一標籤或工作區皆可)',
'scripts.recording.alreadyActive': '已有其他終端正在錄製',
'scripts.recording.stop': '停止錄製',
'scripts.recording.pause': '暫停錄製',
'scripts.recording.resume': '繼續錄製',
'scripts.recording.saveTitle': '儲存錄製的指令碼',
'scripts.recording.namePlaceholder': '指令碼名稱',
'scripts.recording.packagePlaceholder': '儲存到資料夾',
'scripts.recording.rootPackage': '根目錄',
'scripts.recording.save': '儲存',
'scripts.recording.saveAndEdit': '儲存並編輯',
'scripts.recording.helpTitle': '如何錄製指令碼',
'scripts.recording.helpIntro': '錄製會把你在終端裡的操作自動轉成可重複執行的自動化指令碼,適合部署、巡檢等重複性任務。',
'scripts.recording.helpStep1': '連線主機。單一終端標籤或工作區裡的終端都可以,開啟右側的指令碼側欄即可。',
'scripts.recording.helpStep2': '點選「開始錄製」。終端工具列出現紅色 REC 表示已開始。',
'scripts.recording.helpStep3': '在終端裡像平常一樣輸入指令,按 Enter 執行。每次按 Enter 都會記為一步。',
'scripts.recording.helpStep4': '全部操作完成後,點選「停止錄製」,或點終端工具列 REC 旁的停止按鈕。',
'scripts.recording.helpStep5': '填寫指令碼名稱並儲存。需要微調時可選「儲存並編輯」,在指令碼編輯器裡修改產生的程式碼。',
'scripts.recording.helpTipsTitle': '小提示',
'scripts.recording.helpTip1': '兩次操作之間停頓超過 1 秒,會自動加入等待時間,避免回放太快。',
'scripts.recording.helpTip2': '每條指令後會等待 shell 提示字元(如 $ 或 #)出現,再記錄下一步,減少亂序。',
'scripts.recording.helpTip3': '密碼輸入會被標記為敏感內容,不會以明文寫入指令碼。',
'scripts.recording.helpTip4': 'vim、互動式選單等複雜操作,建議先錄主幹指令,再在編輯器裡手動補充。',
'scripts.dialog.title': '指令碼',
'scripts.dialog.ok': '確定',
'scripts.dialog.waitForTimeoutTitle': '等待逾時',
'scripts.dialog.retry': '重試',
'scripts.dialog.skip': '跳過',
'scripts.dialog.abort': '終止',
'scripts.running.stepProgress': '步驟 {current} / {total}',
'scripts.running.determinateProgress': '{label} {current}/{total}',
'scripts.running.progressFallback': '進度',
'scripts.running.operationsCount': '已執行 {count} 個操作',
'scripts.running.opsPrefix': '已執行 ',
'scripts.running.opsSuffix': ' 個操作',
'scripts.running.elapsedLabel': '耗時',
'scripts.running.lastSentLabel': '已傳送:',
'scripts.running.elapsed': '耗時 {elapsed}',
'scripts.running.completedSummary': '已完成 · 共 {count} 個操作 · {elapsed}',
'scripts.running.dismissHint': '點選關閉',
'scripts.running.dismiss': '關閉',
'scripts.running.viewLogs': '檢視日誌',
'scripts.running.logTitle': '{name} · 執行日誌',
'scripts.running.logEmpty': '暫無日誌輸出',
'scripts.running.pause': '暫停',
'scripts.running.resume': '繼續',
'scripts.running.stop': '停止',
'scripts.recording.saved': '指令碼已儲存',
'scripts.recording.savedNamed': '已儲存「{name}」',
'scripts.observer.blocked': 'Observer 模式禁止執行會寫入終端的指令碼。',
'vault.section.scripts': '指令碼',
'vault.nav.scripts': '指令碼',
'snippets.action.newScript': '新建自動化指令碼',
'hostDetails.section.automation': '自動化',
'hostDetails.automation.loginScript': '登入指令碼',
'hostDetails.automation.loginScriptPlaceholder': '選擇指令碼',
'hostDetails.automation.none': '無',
'hostDetails.automation.outputTriggers': '輸出觸發器',
'hostDetails.automation.addTrigger': '新增觸發器',
'hostDetails.automation.triggerPatternPlaceholder': '正規表示式',
'hostDetails.automation.linkedScripts': '已關聯指令碼',
'hostDetails.automation.linkedScriptsEmpty': '尚未關聯任何指令碼。',
'hostDetails.automation.linkScriptPlaceholder': '關聯既有指令碼…',
'hostDetails.automation.unlink': '取消關聯',
'hostDetails.automation.equivalenceHint': '此處關聯會將本主機加入 Scripts 中該指令碼的目標列表,兩側檢視同步。',
'hostDetails.automation.queueHint': '連線本主機時依序執行指令碼。全域指令碼最先執行,再執行下方佇列。',
'hostDetails.automation.globalScripts': '全域連線指令碼',
'hostDetails.automation.globalScriptsHint': '每次連線時最先執行。順序在指令碼庫排序中調整。',
'hostDetails.automation.connectQueue': '本主機執行佇列',
'hostDetails.automation.connectQueueEmpty': '尚未為本主機設定連線指令碼佇列。',
'hostDetails.automation.addToQueuePlaceholder': '新增指令碼到佇列…',
'hostDetails.automation.moveUp': '上移',
'hostDetails.automation.moveDown': '下移',
'hostDetails.automation.removeFromQueue': '從佇列移除',
'hostDetails.automation.dragHandle': '拖曳排序',
'hostDetails.automation.queueDragHint': '拖動項目即可調整連線時的執行順序。',
};
@@ -0,0 +1,181 @@
import type { Messages } from '../types';
export const zhTwSystemManagerMessages: 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',
};
+797
View File
@@ -0,0 +1,797 @@
import type { Messages } from '../types';
export const zhTWTerminalMessages: Messages = {
'terminal.sudoHint.pressEnter': '按 Enter 貼上 sudo 密碼',
'terminal.menu.rename': '重新命名',
'terminal.menu.uploadClipboardImage': '上傳剪貼簿圖片',
'terminal.clipboardImageUpload.noImage': '剪貼簿中沒有圖片',
'terminal.clipboardImageUpload.failed': '無法上傳剪貼簿圖片',
'terminal.toolbar.configureOsc7': '設定目錄追蹤',
'terminal.toolbar.detach': '移出到獨立標籤',
'terminal.menu.detach': '從工作區移出',
'terminal.osc7Setup.title': '設定目錄追蹤',
'terminal.osc7Setup.desc': 'Netcatty 會為目前遠端使用者新增 OSC 7 提示字元設定。這樣在 sudo 或 su 之後,SFTP 也能繼續跟隨終端目錄。',
'terminal.osc7Setup.targets': '可能寫入的檔案',
'terminal.osc7Setup.command': '將執行的指令',
'terminal.osc7Setup.run': '執行設定',
'terminal.osc7Setup.running': '正在設定...',
'terminal.osc7Setup.configured': '目錄追蹤已設定',
'terminal.osc7Setup.failed': '目錄追蹤設定失敗',
'terminal.osc7Setup.sent': '目錄追蹤設定已傳送到終端',
'terminal.toolbar.timestampsEnable': '顯示時間戳',
'terminal.toolbar.timestampsDisable': '隱藏時間戳',
'terminal.connection.protocol.et': 'EternalTerminal',
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支援 Netcatty 的代理設定。請改用 SSH,或移除該主機的代理。',
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支援一個跳板機。',
// Command history side panel
'history.scope.label': '歷史範圍',
'history.tab.host': '主機',
'history.tab.global': '全域',
'history.searchPlaceholder': '搜尋歷史指令...',
'history.loading': '正在讀取遠端歷史...',
'history.meta.count': '{count} 條',
'history.empty.noSession': '請先開啟一個遠端工作階段以檢視其指令歷史。',
'history.empty.unsupportedProtocol': '僅 SSH/Mosh/ET 工作階段支援指令歷史。',
'history.empty.noHistory': '該主機上未找到指令歷史。',
'history.empty.noGlobalHistory': '暫無全域指令歷史。你執行的指令會記錄在這裡。',
'history.action.refresh': '重新整理',
'history.action.retry': '重試',
'history.action.paste': '貼上到終端',
'history.action.run': '在終端執行',
'history.action.saveAsSnippet': '儲存為程式碼片段',
// SFTP File Opener
'sftp.context.copyPath': '複製檔案路徑',
'sftp.context.openWith': '開啟方式...',
'sftp.context.edit': '編輯',
'sftp.context.preview': '預覽',
'sftp.opener.title': '開啟方式',
'sftp.opener.desc': '選擇一個應用程式來開啟此檔案',
'sftp.opener.builtInEditor': '內建編輯器',
'sftp.opener.editDescription': '編輯文字檔案',
'sftp.opener.builtInImageViewer': '內建圖片預覽',
'sftp.opener.previewDescription': '預覽圖片',
'sftp.opener.systemApp': '選擇應用程式...',
'sftp.opener.systemAppDescription': '從本機選擇一個應用程式',
'sftp.opener.onlySystemApp': '此檔案只能用外部應用程式開啟',
'sftp.opener.noAppsAvailable': '無可用應用程式',
'sftp.opener.noExtension': '無副檔名檔案',
'sftp.opener.setDefault': '始終使用此方式開啟 {ext} 檔案',
'sftp.opener.confirmTitle': '設為預設?',
'sftp.opener.confirmDescription': '是否始終使用 {app} 開啟 {ext} 檔案?',
'sftp.opener.yesRemember': '是,記住此選擇',
'sftp.opener.justOnce': '僅此一次',
'sftp.opener.confirm.title': '設定預設應用程式',
'sftp.opener.confirm.desc': '是否始終使用此應用程式開啟 .{ext} 檔案?',
'sftp.editor.title': '文字編輯器',
'sftp.editor.save': '儲存到遠端',
'sftp.editor.saving': '儲存中...',
'sftp.editor.saved': '儲存成功',
'sftp.editor.saveFailed': '儲存檔案失敗',
'sftp.editor.unsavedChanges': '你有未儲存的更改。確定要關閉嗎?',
'sftp.editor.syntaxHighlight': '語法標示',
'sftp.preview.title': '圖片預覽',
'sftp.preview.zoomIn': '放大',
'sftp.preview.zoomOut': '縮小',
'sftp.preview.resetZoom': '重置縮放',
'sftp.preview.fitToWindow': '適應視窗',
// Settings > SFTP File Associations
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftp.transferConcurrency': '傳輸併發數',
'settings.sftp.transferConcurrency.desc': '上傳或下載資料夾時並行傳輸的檔案數量。較高的值可能提高速度,但可能導致某些伺服器過載。',
'settings.sftp.defaultOpener': '預設檔案開啟方式',
'settings.sftp.defaultOpener.desc': '選擇沒有特定檔案關聯時的預設開啟方式',
'settings.sftp.defaultOpener.ask': '每次詢問',
'settings.sftp.defaultOpener.askDesc': '每次開啟檔案時彈出選擇對話方塊',
'settings.sftp.defaultOpener.builtInDesc': '預設使用內建編輯器開啟文字檔案',
'settings.sftp.defaultOpener.systemApp': '選擇應用程式...',
'settings.sftp.defaultOpener.systemAppDesc': '預設使用指定的外部應用程式開啟檔案',
'settings.sftpFileAssociations.title': 'SFTP 檔案關聯',
'settings.sftpFileAssociations.desc': '設定按副檔名開啟檔案的預設應用程式',
'settings.sftpFileAssociations.extension': '副檔名',
'settings.sftpFileAssociations.application': '應用程式',
'settings.sftpFileAssociations.noAssociations': '未設定檔案關聯',
'settings.sftpFileAssociations.remove': '移除',
'settings.sftpFileAssociations.removeConfirm': '確定移除 .{ext} 的關聯嗎?',
// Settings > SFTP Behavior
'settings.sftp.doubleClickBehavior': '雙擊行為',
'settings.sftp.doubleClickBehavior.desc': '選擇在 SFTP 檢視中雙擊檔案時的操作',
'settings.sftp.doubleClickBehavior.open': '開啟檔案',
'settings.sftp.doubleClickBehavior.transfer': '傳輸到另一側',
'settings.sftp.doubleClickBehavior.openDesc': '使用預設應用程式開啟檔案',
'settings.sftp.doubleClickBehavior.transferDesc': '將檔案傳輸到另一窗格的使用中主機',
'settings.sshDeepLink.title': 'SSH 連結',
'settings.sshDeepLink.enable': '用 Netcatty 開啟 ssh:// 連結',
'settings.sshDeepLink.enableDesc': '允許 Netcatty 接管來自瀏覽器和其他應用程式的 ssh:// 連結。',
// Settings > SFTP Auto Sync
'settings.sftp.autoSync': '自動同步到遠端',
'settings.sftp.autoSync.desc': '使用外部應用程式開啟檔案時,自動將檔案更改同步回遠端伺服器',
'settings.sftp.autoSync.enable': '啟用自動同步',
'settings.sftp.autoSync.enableDesc': '在外部應用程式中儲存檔案時,更改將自動上傳到遠端伺服器',
// Settings > SFTP 自動開啟側欄
'settings.sftp.autoOpenSidebar': '連線時自動開啟側欄',
'settings.sftp.autoOpenSidebar.desc': '連線到主機時自動開啟 SFTP 檔案瀏覽器側欄',
'settings.sftp.autoOpenSidebar.enable': '啟用自動開啟側欄',
'settings.sftp.autoOpenSidebar.enableDesc': '當終端工作階段連線到遠端主機時,SFTP 側欄將自動開啟',
'settings.sftp.followTerminalCwd': '追隨終端目錄',
'settings.sftp.followTerminalCwd.desc': '在側欄 SFTP 中自動跟隨終端目前工作目錄變化(可在工具欄切換)',
'settings.sftp.followTerminalCwd.enable': '預設開啟追隨終端目錄',
'settings.sftp.followTerminalCwd.enableDesc': '開啟側欄 SFTP 時預設啟用追隨模式,終端執行 cd 後檔案瀏覽器會自動跳轉',
'settings.sftp.defaultViewMode': '預設檢視模式',
'settings.sftp.defaultViewMode.desc': '選擇開啟新 SFTP 標籤頁時的預設檢視模式。每個主機的偏好設定會覆蓋此全域設定。',
'settings.sftp.defaultViewMode.list': '列表檢視',
'settings.sftp.defaultViewMode.listDesc': '以平面列表顯示目前目錄的檔案',
'settings.sftp.defaultViewMode.tree': '樹形檢視',
'settings.sftp.defaultViewMode.treeDesc': '以層級樹形結構顯示檔案',
'sftp.autoSync.success': '檔案已同步到遠端:{fileName}',
'sftp.autoSync.error': '同步檔案失敗:{error}',
// SFTP Folder Upload Progress
'sftp.upload.progress': '正在上傳 {current}/{total} 個檔案...',
'sftp.upload.uploading': '正在上傳...',
'sftp.upload.compressing': '正在壓縮...',
'sftp.upload.extracting': '正在解壓...',
'sftp.upload.scanning': '正在掃描檔案...',
'sftp.upload.completed': '已完成',
'sftp.upload.compressed': '壓縮傳輸',
'sftp.upload.currentFile': '目前: {fileName}',
'sftp.upload.cancelled': '上傳已取消',
'sftp.upload.cancel': '取消',
'sftp.upload.completedToPath': '已上傳至 {path}',
// SFTP Download
'sftp.download.completed': '已下載',
'sftp.download.cancelled': '下載已取消',
// SFTP Reconnecting
'sftp.reconnecting.title': '正在重連...',
'sftp.reconnecting.desc': '連線已斷開,正在嘗試重新連線',
'sftp.reconnected': '連線已恢復',
'sftp.error.reconnectFailed': '重連失敗,請重試。',
'sftp.error.connectionLostManual': '連線已斷開,請手動重新連線。',
'sftp.error.connectionLostReconnecting': '連線已斷開,正在重連...',
'sftp.error.sessionLost': 'SFTP 工作階段已斷開,請重新連線。',
// Settings > SFTP Show Hidden Files
'settings.sftp.showHiddenFiles': '顯示隱藏檔案',
'settings.sftp.showHiddenFiles.desc': '在 SFTP 檔案瀏覽器中顯示隱藏檔案(Unix/macOS 點檔案和 Windows 隱藏屬性檔案)。',
'settings.sftp.showHiddenFiles.enable': '顯示隱藏檔案',
'settings.sftp.showHiddenFiles.enableDesc': '瀏覽本機和遠端檔案系統時顯示隱藏檔案',
// Settings > SFTP Compressed Upload
'settings.sftp.compressedUpload': '資料夾壓縮傳輸',
'settings.sftp.compressedUpload.desc': '上傳前壓縮資料夾,可大幅減少傳輸時間。',
'settings.sftp.compressedUpload.enable': '啟用資料夾壓縮',
'settings.sftp.compressedUpload.enableDesc': '自動使用 tar 壓縮資料夾後再傳輸。需要伺服器支援 tar 指令,不支援時自動回退到普通傳輸。',
// Settings > Terminal
'settings.terminal.section.theme': '終端主題',
'settings.terminal.themeModal.title': '選擇主題',
'settings.terminal.themeModal.darkThemes': '深色主題',
'settings.terminal.themeModal.lightThemes': '淺色主題',
'settings.terminal.theme.selectButton': '選擇主題',
'settings.terminal.theme.followApp': '跟隨應用程式主題',
'settings.terminal.theme.followApp.desc': '終端背景色自動配合目前應用程式主題,保持視覺一致性。',
'settings.terminal.theme.darkTheme': '深色模式終端主題',
'settings.terminal.theme.lightTheme': '淺色模式終端主題',
'settings.terminal.theme.auto': '自動(跟隨介面主題)',
'settings.terminal.theme.autoDesc': '跟隨目前介面主題預設',
'settings.terminal.section.font': '字型',
'settings.terminal.section.cursor': '游標',
'settings.terminal.section.keyboard': '鍵盤',
'settings.terminal.section.accessibility': '無障礙',
'settings.terminal.section.behavior': '行為',
'settings.terminal.section.scrollback': '捲動緩衝區',
'settings.terminal.section.keywordHighlight': '關鍵字標示',
'settings.terminal.font.family': '字型',
'settings.terminal.font.family.desc': '終端字型',
'settings.terminal.font.cjk': '中文 / CJK 字型',
'settings.terminal.font.cjk.desc': '用於渲染中 / 日 / 韓字元的字型;"Auto" 會按主字型智慧搭配',
'settings.terminal.font.cjk.option.auto': 'Auto · 按主字型智慧搭配',
'settings.terminal.font.cjk.option.sarasaSC': 'Sarasa Mono SC (更紗黑體 簡)',
'settings.terminal.font.cjk.option.sarasaTC': 'Sarasa Mono TC (更紗黑體 繁)',
'settings.terminal.font.cjk.option.mapleCN': 'Maple Mono CN',
'settings.terminal.font.cjk.option.sourceHan': 'Source Han Mono SC (思源等寬)',
'settings.terminal.font.cjk.option.notoCJK': 'Noto Sans Mono CJK SC',
'settings.terminal.font.cjk.option.lxgwWenkai': 'LXGW WenKai Mono (霞鶩文楷等寬)',
'settings.terminal.font.cjk.option.simSun': 'SimSun (宋體)',
'settings.terminal.font.cjk.option.legacy': '{font} · 不推薦(非等寬字型)',
'settings.terminal.font.size': '字型大小',
'settings.terminal.font.size.desc': '終端文字大小',
'settings.terminal.font.weight': '字重',
'settings.terminal.font.weight.desc': '常規文字字重 (100-900)',
'settings.terminal.font.weight.thin': '極細',
'settings.terminal.font.weight.extraLight': '特細',
'settings.terminal.font.weight.light': '細',
'settings.terminal.font.weight.normal': '常規',
'settings.terminal.font.weight.medium': '中等',
'settings.terminal.font.weight.semiBold': '半粗',
'settings.terminal.font.weight.bold': '粗',
'settings.terminal.font.weight.extraBold': '特粗',
'settings.terminal.font.weight.black': '黑體',
'settings.terminal.font.weightBold': '粗體字重',
'settings.terminal.font.weightBold.desc': '粗體文字字重 (100-900)',
'settings.terminal.font.smoothing': '字型平滑',
'settings.terminal.font.smoothing.desc': '使用 macOS 原生字型抗鋸齒',
'settings.terminal.font.linePadding': '行間距',
'settings.terminal.font.linePadding.desc': '行之間的額外間距 (0-10)',
'settings.terminal.font.emulationType': '終端模擬型別',
'settings.terminal.cursor.style': '游標樣式',
'settings.terminal.cursor.style.block': '塊',
'settings.terminal.cursor.style.bar': '豎線',
'settings.terminal.cursor.style.underline': '下劃線',
'settings.terminal.cursor.blink': '游標閃爍',
'settings.terminal.keyboard.altAsMeta': '將 Option 作為 Meta 鍵',
'settings.terminal.keyboard.altAsMeta.desc': '使用 Option (Alt) 作為 Meta 鍵,而不是用於輸入特殊字元',
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ 按單字跳轉',
'settings.terminal.keyboard.optionArrowWordJump.desc': '按 Option+左/右 時傳送 Meta-b / Meta-f,讓 Shell 按單字移動游標(而非預設的 ^[[1;3D / ^[[1;3C)',
'settings.terminal.accessibility.minimumContrastRatio': '最小對比度',
'settings.terminal.accessibility.minimumContrastRatio.desc': '調整顏色以滿足對比度要求 (1 = 停用, 21 = 最大)',
'settings.terminal.behavior.rightClick': '右鍵行為',
'settings.terminal.behavior.rightClick.desc': '在終端中右鍵時執行的操作',
'settings.terminal.behavior.rightClick.menu': '顯示選單',
'settings.terminal.behavior.rightClick.paste': '貼上',
'settings.terminal.behavior.rightClick.selectWord': '選擇單字',
'settings.terminal.behavior.copyOnSelect': '選擇即複製',
'settings.terminal.behavior.copyOnSelect.desc': '自動複製選取的文字。在 tmux/vim 滑鼠模式下,macOS 按住 Option,Windows/Linux 按住 Shift 拖選即可選取文字',
'settings.terminal.behavior.middleClickPaste': '中鍵貼上',
'settings.terminal.behavior.middleClickPaste.desc': '中鍵點選時貼上剪貼簿內容',
'settings.terminal.behavior.middleClick': '中鍵行為',
'settings.terminal.behavior.middleClick.desc': '在終端中點選滑鼠中鍵時執行的操作',
'settings.terminal.behavior.middleClick.menu': '顯示選單',
'settings.terminal.behavior.middleClick.paste': '貼上',
'settings.terminal.behavior.middleClick.disabled': '無動作',
'settings.terminal.behavior.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.dynamicTabTitle': '動態標籤頁標題',
'settings.terminal.behavior.dynamicTabTitle.desc':
'選擇標籤頁什麼時候跟隨 Shell 報告的視窗標題。',
'settings.terminal.behavior.dynamicTabTitle.off': '關閉',
'settings.terminal.behavior.dynamicTabTitle.agent': '僅 Agent',
'settings.terminal.behavior.dynamicTabTitle.all': '全部工作階段',
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪貼簿',
'settings.terminal.behavior.osc52Clipboard.desc':
'允許遠端程式(tmux、vim 等)透過 OSC-52 轉義序列存取本機剪貼簿。',
'settings.terminal.behavior.osc52Clipboard.off': '關閉',
'settings.terminal.behavior.osc52Clipboard.writeOnly': '僅寫入',
'settings.terminal.behavior.osc52Clipboard.readWrite': '讀寫',
'settings.terminal.behavior.osc52Clipboard.prompt': '寫入 + 讀取時詢問',
'terminal.osc52.readPrompt.title': '剪貼簿讀取請求',
'terminal.osc52.readPrompt.desc': '遠端程式正在請求讀取你的剪貼簿,是否允許?',
'terminal.osc52.readPrompt.allow': '允許',
'terminal.osc52.readPrompt.deny': '拒絕',
'settings.terminal.behavior.scrollOnInput': '輸入時自動捲動',
'settings.terminal.behavior.scrollOnInput.desc': '輸入時將終端捲動到底部',
'settings.terminal.behavior.scrollOnOutput': '輸出時自動捲動',
'settings.terminal.behavior.scrollOnOutput.desc': '有新輸出時將終端捲動到底部',
'settings.terminal.behavior.scrollOnKeyPress': '按鍵時自動捲動',
'settings.terminal.behavior.scrollOnKeyPress.desc': '按鍵(例如 Enter)時將終端捲動到底部',
'settings.terminal.behavior.scrollOnPaste': '貼上時自動捲動',
'settings.terminal.behavior.scrollOnPaste.desc': '貼上文字時將終端捲動到底部',
'settings.terminal.behavior.smoothScrolling': '平滑捲動',
'settings.terminal.behavior.smoothScrolling.desc': '捲動終端可視區域時使用平滑動畫',
'settings.terminal.behavior.linkModifier': '連結修飾鍵',
'settings.terminal.behavior.linkModifier.desc': '按住此鍵再點選終端中的連結',
'settings.terminal.behavior.linkModifier.none': '無(直接點選)',
'settings.terminal.behavior.linkModifier.ctrl': 'Ctrl',
'settings.terminal.behavior.linkModifier.alt': 'Alt / Option',
'settings.terminal.behavior.linkModifier.meta': 'Cmd / Win',
'settings.terminal.scrollback.desc': '限制終端行數。設為 0 表示不限制。',
'settings.terminal.scrollback.rows': '行數 *',
'settings.terminal.section.startupCommand': '啟動指令',
'settings.terminal.startupCommandDelay.label': '啟動指令延遲(毫秒)',
'settings.terminal.startupCommandDelay.desc': '連線建立後等待多久再發送啟動指令;啟動指令為多行時,行與行之間也使用該間隔。慢連線可調大。',
'settings.terminal.keywordHighlight.title': '關鍵字標示',
'settings.terminal.keywordHighlight.resetColors': '重置為預設顏色',
'settings.terminal.keywordHighlight.resetDefaults': '把內建規則恢復為預設',
'settings.terminal.keywordHighlight.resetBuiltIn': '恢復內建標籤與正規表示式',
'settings.terminal.keywordHighlight.addCustom': '新增自訂規則',
'settings.terminal.keywordHighlight.editCustom': '編輯規則',
'settings.terminal.keywordHighlight.editBuiltIn': '編輯內建規則',
'settings.terminal.keywordHighlight.labelField': '標籤與顏色',
'settings.terminal.keywordHighlight.labelPlaceholder': '標籤(如 Down)',
'settings.terminal.keywordHighlight.patternField': '正規表示式',
'settings.terminal.keywordHighlight.patternPlaceholder': '每行一個正規表示式(如 \\bdown\\b)',
'settings.terminal.keywordHighlight.patternHint': '每行一個正規表示式。比對時忽略大小寫,全域比對。',
'settings.terminal.keywordHighlight.invalidPattern': '無效的正規表示式',
'settings.terminal.keywordHighlight.preview': '預覽',
'settings.terminal.section.localShell': '本機 Shell',
'settings.terminal.localShell.shell': 'Shell 執行檔',
'settings.terminal.localShell.shell.desc': 'Shell 執行檔的路徑(例如 /bin/zsh、pwsh.exe)。留空使用系統預設。',
'settings.terminal.localShell.shell.placeholder': '系統預設',
'settings.terminal.localShell.shell.detected': '偵測到',
'settings.terminal.localShell.shell.notFound': '未找到 Shell 執行檔',
'settings.terminal.localShell.shell.isDirectory': '路徑是目錄,不是執行檔',
'settings.terminal.localShell.shell.default': '系統預設',
'settings.terminal.localShell.shell.custom': '自訂...',
'settings.terminal.localShell.shell.customPath': 'Shell 執行檔路徑',
'settings.terminal.localShell.shell.customArgs': '啟動引數',
'settings.terminal.localShell.shell.customArgs.placeholder': '例如 --login -i',
'settings.terminal.localShell.shell.customArgs.desc': '傳給 Shell 的啟動引數。部分 Shell 必須指定才能正常工作,例如 msys2 bash 需要 --login -i 才能載入環境變數。',
'settings.terminal.localShell.shell.commonPaths': '常用路徑',
'settings.terminal.localShell.shell.pathValid': '路徑有效',
'settings.terminal.localShell.startDir': '起始目錄',
'settings.terminal.localShell.startDir.desc': '開啟本機終端時的起始目錄。留空使用使用者主目錄。',
'settings.terminal.localShell.startDir.placeholder': '使用者主目錄',
'settings.terminal.localShell.startDir.notFound': '目錄不存在',
'settings.terminal.localShell.startDir.isFile': '路徑是檔案,不是目錄',
'settings.terminal.section.connection': '連線',
'settings.terminal.connection.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.rendering.hibernateHiddenTabs': '隱藏標籤頁休眠',
'settings.terminal.rendering.hibernateHiddenTabs.desc': '標籤頁離開視野後釋放終端渲染器以節省記憶體,SSH 連線保持不斷。檔案傳輸期間不會休眠。',
'settings.terminal.rendering.hibernateHiddenTabsDelay': '休眠延遲',
'settings.terminal.rendering.hibernateHiddenTabsDelay.desc': '標籤頁離開視野多久後釋放渲染器(5–600 秒)。',
'settings.terminal.rendering.hibernateSkipAltScreen': '全屏 TUI 跳過休眠',
'settings.terminal.rendering.hibernateSkipAltScreen.desc': 'vim、htop 或 Agent TUI 佔用 alternate screen 時保持終端渲染器,避免慢恢復。',
'settings.terminal.rendering.hibernateKeepRendererCount': '保留隱藏渲染器數量',
'settings.terminal.rendering.hibernateKeepRendererCount.desc': '多少個不可見標籤頁僅暫停 WebGL、不銷燬 xterm,超出後才完整休眠。',
'settings.terminal.rendering.hibernateReplayChunkBytes': '喚醒分片大小',
'settings.terminal.rendering.hibernateReplayChunkBytes.desc': '恢復休眠終端時每幀回放的 ANSI 位元組數(4–64 KB)。',
'settings.terminal.rendering.hibernatePreferWasmSerialize': '優先 WASM 序列化',
'settings.terminal.rendering.hibernatePreferWasmSerialize.desc': '在可用時使用 WASM 終端序列化,否則回退到 JS SerializeAddon。',
// Settings > Terminal > Workspace Focus Indicator
'settings.terminal.section.workspaceFocus': '工作區焦點提示',
'settings.terminal.workspaceFocus.style': '焦點提示樣式',
'settings.terminal.workspaceFocus.style.desc': '在分割檢視中如何標識目前聚焦的窗格。',
'settings.terminal.workspaceFocus.dim': '淡化未聚焦窗格',
'settings.terminal.workspaceFocus.border': '為聚焦窗格顯示邊框',
// Settings > Terminal > Autocomplete
'settings.terminal.section.autocomplete': '自動補全',
'settings.terminal.autocomplete.enabled': '啟用自動補全',
'settings.terminal.autocomplete.enabled.desc': '輸入時根據歷史指令和指令規範顯示補全建議。',
'settings.terminal.autocomplete.ghostText': '行內建議',
'settings.terminal.autocomplete.ghostText.desc': '在游標後顯示灰色的建議文字(類似 fish shell)。',
'settings.terminal.autocomplete.popupMenu': '彈出選單',
'settings.terminal.autocomplete.popupMenu.desc': '顯示包含多個建議的浮動列表。',
// Settings > Shortcuts
'settings.shortcuts.section.scheme': '快捷鍵方案',
'settings.shortcuts.scheme.label': '鍵盤快捷鍵',
'settings.shortcuts.scheme.desc': '選擇快捷鍵使用的鍵盤佈局',
'settings.shortcuts.scheme.disabled': '停用',
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
'settings.shortcuts.disableTerminalFontZoom.label': '停用終端縮放',
'settings.shortcuts.disableTerminalFontZoom.desc': '關閉終端文字縮放快捷操作,包括 Cmd/Ctrl 加滾輪。',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '標籤快捷鍵跳過固定標籤',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '開啟後,Cmd/Ctrl+[1...9] 和 Ctrl+Tab 僅在終端、工作區、編輯器等可關閉標籤頁之間切換,不包括固定的 Vault 和 SFTP 標籤頁。',
'settings.shortcuts.section.custom': '自訂快捷鍵',
'settings.shortcuts.resetAll': '全部重置',
'settings.shortcuts.recording': '請按鍵...',
'settings.shortcuts.none': '無',
'settings.shortcuts.setDisabled': '設為停用',
'settings.shortcuts.category.tabs': '標籤頁',
'settings.shortcuts.category.terminal': '終端',
'settings.shortcuts.category.navigation': '導航',
'settings.shortcuts.category.app': '應用程式',
'settings.shortcuts.category.sftp': 'SFTP',
'settings.shortcuts.binding.switch-tab-1-9': '切換到標籤頁 [1...9]',
'settings.shortcuts.binding.next-tab': '下一個標籤頁',
'settings.shortcuts.binding.prev-tab': '上一個標籤頁',
'settings.shortcuts.binding.close-tab': '關閉標籤頁',
'settings.shortcuts.binding.close-session': '關閉工作階段窗格',
'settings.shortcuts.binding.new-tab': '新建本機標籤頁',
'settings.shortcuts.binding.copy': '從終端複製',
'settings.shortcuts.binding.paste': '貼上到終端',
'settings.shortcuts.binding.paste-selection': '將選區貼上到終端',
'settings.shortcuts.binding.select-all': '全選終端內容',
'settings.shortcuts.binding.clear-buffer': '清空終端緩衝區',
'settings.shortcuts.binding.search-terminal': '開啟終端搜尋',
'settings.shortcuts.binding.increase-terminal-font-size': '增大終端字號',
'settings.shortcuts.binding.decrease-terminal-font-size': '減小終端字號',
'settings.shortcuts.binding.reset-terminal-font-size': '重置終端字號',
'settings.shortcuts.binding.move-focus': '在分割間移動焦點',
'settings.shortcuts.binding.split-horizontal': '水平分割',
'settings.shortcuts.binding.split-vertical': '垂直分割',
'settings.shortcuts.binding.toggle-pane-zoom': '切換窗格縮放',
'settings.shortcuts.binding.open-hosts': '開啟主機列表',
'settings.shortcuts.binding.open-local': '開啟本機終端',
'settings.shortcuts.binding.open-sftp': '開啟 SFTP',
'settings.shortcuts.binding.open-settings': '開啟設定',
'settings.shortcuts.binding.port-forwarding': '開啟埠轉發',
'settings.shortcuts.binding.command-palette': '開啟指令面板',
'settings.shortcuts.binding.quick-switch': '快速切換',
'settings.shortcuts.binding.new-workspace': '新建工作區',
'settings.shortcuts.binding.snippets': '開啟程式碼片段',
'settings.shortcuts.binding.broadcast': '切換廣播模式',
'settings.shortcuts.binding.toggle-side-panel': '切換側邊欄',
'settings.shortcuts.binding.sftp-copy': '複製檔案',
'settings.shortcuts.binding.sftp-cut': '剪下檔案',
'settings.shortcuts.binding.sftp-paste': '貼上檔案',
'settings.shortcuts.binding.sftp-select-all': '全選檔案',
'settings.shortcuts.binding.sftp-rename': '重新命名檔案',
'settings.shortcuts.binding.sftp-delete': '刪除檔案',
'settings.shortcuts.binding.sftp-refresh': '重新整理',
'settings.shortcuts.binding.sftp-new-folder': '新建資料夾',
'settings.shortcuts.binding.sftp-open': '開啟檔案 / 進入目錄',
'settings.shortcuts.binding.sftp-go-parent': '轉到上級目錄',
'settings.shortcuts.binding.sftp-navigate-to': '轉到選取的目錄',
// Host Details (sub-panels)
'hostDetails.proxyPanel.title': '透過 HTTP/SOCKS5/指令代理',
'hostDetails.proxyPanel.hostPlaceholder': '代理主機',
'hostDetails.proxyPanel.command': 'ProxyCommand',
'hostDetails.proxyPanel.commandPlaceholder': 'cloudflared access ssh --hostname %h',
'hostDetails.proxyPanel.commandHelp': '使用 %h 表示目標主機,%p 表示目標埠,%% 表示字面百分號。',
'hostDetails.proxyPanel.credentials': '憑證',
'hostDetails.proxyPanel.usernamePlaceholder': '使用者名稱',
'hostDetails.proxyPanel.passwordPlaceholder': '密碼',
'hostDetails.proxyPanel.identities': '身份',
'hostDetails.proxyPanel.keychainIdentity': '鑰匙串身份',
'hostDetails.proxyPanel.manualCredentials': '手動憑證',
'hostDetails.proxyPanel.remove': '移除代理',
'hostDetails.proxyPanel.savedProxy': '已儲存代理',
'hostDetails.proxyPanel.selectSaved': '選擇已儲存代理',
'hostDetails.proxyPanel.customProxy': '自訂代理',
'hostDetails.proxyPanel.missing': '缺失',
'hostDetails.proxyPanel.missingSaved': '儲存的代理不存在',
'hostDetails.proxyPanel.missingIdentity': '鑰匙串身份不存在',
'hostDetails.proxyPanel.incompleteIdentity': '代理身份需要使用者名稱和密碼',
'hostDetails.proxyPanel.unreadableIdentity': '此裝置無法讀取代理身份密碼',
'hostDetails.proxyPanel.error.required': '代理主機和埠,或 ProxyCommand 不能為空。',
'hostDetails.envVars.title': '環境變數',
'hostDetails.envVars.desc': '為 {host} 設定環境變數。',
'hostDetails.envVars.note': '部分 SSH 伺服器預設只允許以 LC_ 和 LANG_ 為字首的變數。',
'hostDetails.envVars.variable': '變數',
'hostDetails.envVars.value': '值',
'hostDetails.envVars.newVariable': '新變數',
'hostDetails.envVars.variableName': '變數名',
'hostDetails.chain.title': '編輯鏈路',
'hostDetails.chain.desc': '新增另一台主機將建立到 {host} 的連線。',
'hostDetails.chain.addHost': '新增主機',
'hostDetails.chain.target': '目標',
'hostDetails.chain.availableHosts': '可用主機',
'hostDetails.chain.clear': '清空',
'hostDetails.group.title': '新建分組',
'hostDetails.group.general': '常規',
'hostDetails.group.namePlaceholder': '分組名稱',
'hostDetails.group.parentPlaceholder': '父分組',
'hostDetails.group.cloudSync': '雲同步',
'hostDetails.group.addProtocol': '新增協定',
// Keychain
'keychain.filter.key': '金鑰',
'keychain.filter.certificate': '憑證',
'keychain.action.generateKey': '生成金鑰',
'keychain.action.importKey': '匯入金鑰',
'keychain.action.newIdentity': '新建身份',
'keychain.action.importCertificate': '匯入憑證',
'keychain.view.grid': '網格',
'keychain.view.list': '列表',
'keychain.section.keys': '金鑰',
'keychain.section.identities': '身份',
'keychain.count.items': '{count} 項',
'keychain.empty.title': '設定金鑰',
'keychain.empty.desc': '匯入或生成 SSH 金鑰用於安全認證。',
'keychain.panel.generateKey': '生成金鑰',
'keychain.panel.newKey': '新建金鑰',
'keychain.panel.keyDetails': '金鑰詳情',
'keychain.panel.editKey': '編輯金鑰',
'keychain.panel.editIdentity': '編輯身份',
'keychain.panel.newIdentity': '新建身份',
'keychain.panel.keyExport': '金鑰匯出',
'keychain.validation.labelRequired': '請填寫金鑰的 Label',
'keychain.validation.labelAndPrivateKeyRequired': 'Label 和私鑰為必填項',
'keychain.validation.labelAndUsernameRequired': 'Label 和使用者名稱為必填項',
'keychain.error.generationUnavailable': '無法生成金鑰:請確保應用程式執行在 Electron 環境',
'keychain.error.generateKeyPairFailed': '生成金鑰對失敗',
'keychain.error.generateKeyFailed': '生成金鑰失敗',
'keychain.error.keyGenerationTitle': '金鑰生成',
'keychain.export.exportTo': '匯出到 *',
'keychain.export.selectHost': '選擇主機',
'keychain.export.location': '位置 ~ $1 *',
'keychain.export.filename': '檔名 ~ $2 *',
'keychain.export.note': '金鑰匯出目前僅支援 {unix} 系統。請在 {advanced} 部分自訂匯出指令碼。',
'keychain.export.script': '指令碼 *',
'keychain.export.scriptPlaceholder': '匯出指令碼...',
'keychain.export.missingCredentials': '主機未儲存密碼或金鑰。請先為該主機新增密碼憑證。',
'keychain.export.successTitle': '匯出成功',
'keychain.export.successMessage': '已匯出公鑰並繫結到 {host}',
'keychain.export.failedTitle': '匯出失敗',
'keychain.export.failedMessage': '匯出金鑰失敗:{error}',
'keychain.export.failedPrefix': '匯出失敗:{error}',
'keychain.export.exitCode': '指令結束代碼 {code}',
'keychain.export.exporting': '匯出中...',
'keychain.export.exportAndAttach': '匯出並繫結',
'keychain.export.title': '金鑰匯出',
'keychain.export.exportToRequired': '匯出到 *',
'keychain.export.selectHostPlaceholder': '選擇主機...',
'keychain.export.locationLabel': '位置 ~ $1 *',
'keychain.export.filenameLabel': '檔名 ~ $2 *',
'keychain.export.advanced': '高階',
'keychain.export.note.supportsOnly': '金鑰匯出目前僅支援',
'keychain.export.note.systems': '系統。',
'keychain.export.note.use': '請使用',
'keychain.export.note.customize': '部分自訂匯出指令碼。',
'keychain.export.scriptRequired': '指令碼 *',
'keychain.export.exportToHost': '匯出到主機',
'keychain.export.failedGeneric': '匯出失敗:{message}',
'keychain.field.label': 'Label',
'keychain.field.labelRequired': 'Label *',
'keychain.field.labelPlaceholder': '金鑰 Label',
'keychain.field.privateKeyRequired': '私鑰 *',
'keychain.field.publicKey': '公鑰',
'keychain.field.certificatePlaceholder': '憑證內容(可選)',
'keychain.generate.keyType': '金鑰型別',
'keychain.generate.keySize': '金鑰長度',
'keychain.generate.labelPlaceholder': '金鑰 Label',
'keychain.generate.passphrasePlaceholder': 'Passphrase(可選)',
'keychain.generate.savePassphrase': '儲存 Passphrase',
'keychain.generate.generate': '生成',
'keychain.generate.generateSave': '生成並儲存',
'keychain.import.dropHint': '將金鑰檔案拖到這裡',
'keychain.import.importFromFile': '從檔案匯入',
'keychain.import.saveKey': '儲存金鑰',
'keychain.import.importedKeyLabel': '已匯入金鑰',
'keychain.identity.usernameRequired': '使用者名稱 *',
'keychain.identity.method.passwordOnly': '密碼',
'keychain.identity.summary.password': '認證密碼',
'keychain.identity.summary.key': '認證金鑰',
'keychain.identity.summary.certificate': '認證憑證',
'keychain.identity.summary.passwordAndKey': '認證密碼與金鑰',
'keychain.identity.summary.passwordAndCertificate': '認證密碼與憑證',
'keychain.identity.summary.none': '無憑證',
'keychain.identity.selectCredential': '選擇{kind}',
'keychain.identity.save': '儲存',
'keychain.identity.update': '更新',
'keychain.keyDialog.newTitle': '新建金鑰',
'keychain.keyDialog.newDesc': '新增新的 SSH 金鑰',
'keychain.keyDialog.editTitle': '編輯金鑰',
'keychain.keyDialog.editDesc': '更新此 SSH 金鑰',
'keychain.keyDialog.updateKey': '更新金鑰',
// Tabs
'tabs.closeSessionAria': '關閉工作階段',
'tabs.closeLogViewAria': '關閉日誌檢視',
'tabs.logPrefix': '日誌:',
'tabs.logLocal': '本機',
'tabs.copyTab': '複製標籤頁',
'tabs.copyTabToNewWindow': '複製標籤頁到新視窗',
'tabs.copyTabToNewWindowFailed': '無法在新視窗開啟標籤頁',
'tabs.closeOthers': '關閉其他標籤',
'tabs.closeToRight': '關閉右側標籤',
'tabs.closeAll': '關閉所有標籤',
'keychain.edit.labelRequired': 'Label *',
'keychain.edit.keyLabelPlaceholder': '金鑰 Label',
'keychain.edit.privateKeyRequired': '私鑰 *',
'keychain.edit.publicKey': '公鑰',
'keychain.edit.certificate': '憑證',
'keychain.edit.certificatePlaceholder': '憑證內容(可選)',
'keychain.edit.filePath': '檔案路徑',
'keychain.edit.keyExport': '金鑰匯出',
'keychain.edit.exportToHost': '匯出到主機',
// Snippets
'snippets.searchPlaceholder': '搜尋程式碼片段...',
'snippets.action.newSnippet': '新建程式碼片段',
'snippets.action.newPackage': '新建程式碼包',
'snippets.action.import': '匯入',
'snippets.action.selectSnippets': '選擇程式碼片段',
'snippets.panel.newTitle': '新建程式碼片段',
'snippets.panel.editTitle': '編輯程式碼片段',
'snippets.field.description': '描述',
'snippets.field.descriptionPlaceholder': '例如:check network load',
'snippets.field.package': '新增程式碼包',
'snippets.field.packagePlaceholder': '選擇或建立程式碼包',
'snippets.field.createPackage': '建立程式碼包',
'snippets.field.scriptRequired': '指令碼 *',
'snippets.scriptEditor.expand': '彈出視窗編輯',
'snippets.scriptEditor.resize': '調整編輯器高度',
'snippets.scriptEditor.modalTitle': '編輯指令碼',
'snippets.targets.title': '目標主機',
'snippets.targets.add': '新增目標主機',
'snippets.history.title': 'Shell 歷史',
'snippets.history.subtitle': '{count} 條指令',
'snippets.history.emptyTitle': '暫無 Shell 歷史',
'snippets.history.emptyDesc': '你執行過的指令會顯示在這裡',
'snippets.history.loadMore': '載入更多',
'snippets.history.separator': '•',
'snippets.history.labelPlaceholder': '為此程式碼片段設定一個 Label',
'snippets.history.saveAsSnippet': '儲存為程式碼片段',
'snippets.history.time.justNow': '剛剛',
'snippets.history.time.minutesAgo': '{count} 分鐘前',
'snippets.history.time.hoursAgo': '{count} 小時前',
'snippets.history.time.daysAgo': '{count} 天前',
'snippets.breadcrumb.allPackages': '全部程式碼包',
'snippets.breadcrumb.separator': '›',
'snippets.empty.title': '建立程式碼片段',
'snippets.empty.desc': '將常用指令儲存為程式碼片段,一鍵重複使用。',
'snippets.panel.newAutomationTitle': '新建自動化指令碼',
'snippets.panel.editAutomationTitle': '編輯自動化指令碼',
'snippets.panel.resizeWidth': '調整面板寬度',
'snippets.targets.allHosts': '對所有主機生效',
'snippets.targets.allHostsShort': '所有主機',
'snippets.targets.allHostsActive': '將作用於所有可連線的主機。',
'snippets.kind.codeSnippet': '程式碼片段',
'snippets.kind.automationScript': '自動化指令碼',
'snippets.search.noResults.title': '無相符結果',
'snippets.search.noResults.desc': '沒有程式碼片段或程式碼包與"{query}"相符。換一個關鍵字,或清除搜尋進行瀏覽。',
'snippets.section.packages': '程式碼包',
'snippets.section.snippets': '程式碼片段',
'snippets.package.count': '{count} 個程式碼片段',
'snippets.commandFallback': '指令',
'snippets.view.grid': '網格',
'snippets.view.list': '列表',
'snippets.selection.selected': '已選擇 {count} 個',
'snippets.selection.selectVisible': '選擇目前顯示',
'snippets.selection.deselectAll': '取消選擇',
'snippets.selection.exportSelected': '匯出選取({count})',
'snippets.export.snippet': '匯出程式碼片段',
'snippets.export.package': '匯出程式碼包',
'snippets.export.toast.empty': '沒有可匯出的程式碼片段。',
'snippets.export.toast.successTitle': '匯出已準備好',
'snippets.export.toast.success': '已匯出 {count} 個程式碼片段。',
'snippets.import.toast.empty': '沒有找到可匯入的程式碼片段。',
'snippets.import.toast.failedTitle': '匯入失敗',
'snippets.import.toast.invalidDesc': '這不是有效的 Netcatty 程式碼片段檔案。',
'snippets.import.toast.successTitle': '匯入完成',
'snippets.import.toast.summary': '已匯入 {imported} 個,覆蓋 {overwritten} 個,跳過 {skipped} 個。',
'snippets.import.modal.title': '匯入程式碼片段',
'snippets.import.modal.desc': '選擇一個或多個 Netcatty 程式碼片段 JSON 檔案。匯入前會先解析並展示結果,不會直接寫入。',
'snippets.import.modal.exampleTitle': '範例 JSON',
'snippets.import.modal.noFile': '還沒有選擇檔案。可以參考下面的格式,也可以直接使用程式碼片段 JSON 陣列,然後選擇一個或多個檔案。',
'snippets.import.modal.chooseFile': '選擇檔案',
'snippets.import.modal.downloadExamples': '下載範例檔案',
'snippets.import.modal.multipleFiles': '已選擇 {count} 個檔案',
'snippets.import.modal.parsedSummary': '{files} 個檔案,{total} 個程式碼片段,{packages} 個程式碼包,{conflicts} 個重複指令。主機繫結會被忽略。',
'snippets.import.modal.confirm': '確認匯入',
'snippets.import.conflict.title': '匯入程式碼片段?',
'snippets.import.conflict.desc': '“{file}”包含 {total} 個程式碼片段,其中 {conflicts} 個指令已存在。',
'snippets.import.conflict.hostBindingsNote': '程式碼片段的主機繫結不會被匯入或匯出。',
'snippets.import.conflict.skip': '跳過重複項',
'snippets.import.conflict.overwrite': '覆蓋重複項',
'snippets.packageDialog.title': '新建程式碼包',
'snippets.packageDialog.parent': '父級:{parent}',
'snippets.packageDialog.root': '根目錄',
'snippets.packageDialog.placeholder': '例如:ops/maintenance',
'snippets.packageDialog.hint': '使用 "/" 建立巢狀程式碼包。',
// Snippets Rename Dialog
'snippets.renameDialog.title': '重新命名程式碼包',
'snippets.renameDialog.currentPath': '目前路徑:{path}',
'snippets.renameDialog.placeholder': '輸入新名稱',
'snippets.renameDialog.error.empty': '程式碼包名稱不能為空',
'snippets.renameDialog.error.duplicate': '已存在同名的程式碼包',
'snippets.renameDialog.error.invalidChars': '程式碼包名稱只能包含字母、數字、連字元和下劃線',
'snippets.field.noAutoRun': '僅貼上(不自動執行)',
// 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': '存在未儲存的編輯器,請先處理後再離開',
};
+822
View File
@@ -0,0 +1,822 @@
import type { Messages } from '../types';
export const zhTWVaultMessages: Messages = {
// Notes
'notes.search.placeholder': '搜尋筆記、主機、標籤...',
'notes.action.newNote': '新建筆記',
'notes.action.newGroup': '新建資料夾',
'notes.action.importMarkdown': '匯入 Markdown',
'notes.action.exportNote': '匯出筆記',
'notes.action.exportGroup': '匯出資料夾',
'notes.action.exportAll': '匯出全部筆記',
'notes.import.toast.success': '已匯入 {count} 條筆記',
'notes.import.toast.noNotes': '沒有匯入任何 Markdown 檔案。',
'notes.import.toast.failed': '匯入 Markdown 檔案失敗。',
'notes.import.toast.inProgress': '正在匯入 Markdown 檔案,請稍候。',
'notes.import.toast.skipped': '已跳過 {count} 個不支援的檔案。',
'notes.export.toast.success': '已匯出 {count} 條筆記',
'notes.export.toast.empty': '沒有可匯出的筆記。',
'notes.export.toast.failed': '匯出筆記失敗。',
'notes.codeBlock.copied': '已複製',
'notes.codeBlock.copyFailed': '複製程式碼塊失敗。',
'notes.group.prompt': '資料夾路徑',
'notes.group.all': '筆記本',
'notes.group.ungrouped': '未分組',
'notes.section.notebooks': '筆記本',
'notes.section.notes': '筆記',
'notes.mode.edit': '編輯',
'notes.mode.split': '分割',
'notes.mode.preview': '預覽',
'notes.title.placeholder': '筆記標題',
'notes.editor.placeholder': '在這裡寫 Markdown 筆記...',
'notes.preview.empty': '暫無內容可預覽。',
'notes.empty.title': '還沒有筆記',
'notes.empty.desc': '可以記錄運維手冊、臨時備忘、伺服器交接資訊。',
'notes.empty.group': '這個筆記本里還沒有筆記。',
'notes.search.noResults': '沒有找到筆記。',
// Select Host panel
'selectHost.title': '選擇主機',
'selectHost.noHostsFound': '未找到主機',
'selectHost.newHost': '新建主機',
'selectHost.continue': '繼續',
'selectHost.continueWithCount': '繼續(已選 {count} 個)',
// Quick Connect
'quickConnect.knownHost.title': '確認要連線嗎?',
'quickConnect.knownHost.authenticity': '無法驗證 {hostname} 的真實性。',
'quickConnect.knownHost.fingerprintLabel': '{keyType} fingerprint (SHA256):',
'quickConnect.knownHost.addQuestion': '是否將它加入 Known Hosts?',
'quickConnect.knownHost.addAndContinue': '加入並繼續',
'quickConnect.addKey': '新增 key',
'quickConnect.warning.unparsedOptions': '部分 SSH 引數已被忽略: {options}',
// Protocol select dialog
'protocolSelect.chooseProtocol': '選擇協定',
'protocolSelect.port': '埠:',
// Host Details
'hostDetails.title.details': '主機詳情',
'hostDetails.title.new': '新建主機',
'hostDetails.saveAria': '儲存',
'hostDetails.section.address': '地址',
'hostDetails.hostname.placeholder': 'IP 或 主機名',
'hostDetails.section.general': '通用',
'hostDetails.section.sftp': 'SFTP 設定',
'hostDetails.sftp.sudo': 'Sudo 提權模式',
'hostDetails.sftp.sudo.desc': '使用儲存的密碼自動取得 Root 權限',
'hostDetails.sftp.sudo.passwordWarning': 'Sudo 模式需要密碼。請在上方設定密碼,或確保伺服器允許免密 sudo。',
'hostDetails.sftp.encoding': '檔名編碼',
'hostDetails.sftp.encoding.desc': '選擇用於解碼和傳送 SFTP 檔名的編碼。',
'hostDetails.label.placeholder': '名稱(例如:Production Server)',
'hostDetails.notes.label': '備註',
'hostDetails.notes.placeholder': '硬體設定、專案、客戶、地域、角色...',
'hostDetails.notes.help': '支援 Markdown。請勿在此存放密碼或私鑰。',
'hostDetails.notes.tab.edit': '編輯',
'hostDetails.notes.tab.preview': '預覽',
'hostDetails.notes.preview.empty': '暫無內容可預覽。',
'hostDetails.notes.toggle.show': '顯示備註編輯框',
'hostDetails.notes.toggle.hide': '隱藏備註編輯框',
'hostDetails.group.placeholder': '父級 Group',
'hostDetails.section.credentials': '憑證',
'hostDetails.section.portCredentials': '埠與憑證',
'hostDetails.section.appearance': '外觀',
'hostDetails.distro.title': 'Linux 發行版',
'hostDetails.distro.desc': '可在連線後自動探測,也可以手動覆蓋圖示所用的發行版。',
'hostDetails.icon.title': '主機圖示',
'hostDetails.icon.preview': '圖示預覽',
'vault.panel.resizeWidth': '調整面板寬度',
'hostDetails.icon.sectionTitle': '顏色與圖示',
'hostDetails.icon.desc': '選擇此主機使用的圖示來源和顏色。',
'hostDetails.icon.source': '來源',
'hostDetails.icon.manualLabel': '手動指定',
'hostDetails.icon.tab.brand': '品牌',
'hostDetails.icon.tab.type': '型別',
'hostDetails.icon.mode.auto': '自動',
'hostDetails.icon.mode.custom': '自訂',
'hostDetails.icon.reset': '重置主機圖示',
'hostDetails.icon.colorLabel': '圖示顏色',
'hostDetails.icon.colorDefault': '預設',
'hostDetails.icon.colorAuto': '自動',
'hostDetails.icon.colorManual': '手動',
'hostDetails.icon.colorChoice': '顏色',
'hostDetails.icon.autoColorLabel': '目前顏色',
'hostDetails.icon.showLibrary': '展開圖示庫',
'hostDetails.icon.hideLibrary': '收起圖示庫',
'hostDetails.icon.autoUsesDistro': '使用 Linux 發行版圖示和所選顏色顯示此主機。',
'hostDetails.icon.customOverridesDistro': '內建圖示會替換此主機的 Linux 發行版圖示。',
'hostDetails.icon.option.server': '伺服器',
'hostDetails.icon.option.terminal': '終端',
'hostDetails.icon.option.database': '資料庫',
'hostDetails.icon.option.cloud': '雲主機',
'hostDetails.icon.option.router': '路由器',
'hostDetails.icon.option.shield': '安全',
'hostDetails.icon.option.code': '程式碼',
'hostDetails.icon.option.box': '節點',
'hostDetails.icon.option.globe': '公網',
'hostDetails.icon.option.cpu': '計算',
'hostDetails.icon.option.hard-drive': '儲存',
'hostDetails.icon.option.network': '網路',
'hostDetails.icon.option.wifi': '無線',
'hostDetails.icon.option.lock': '鎖定',
'hostDetails.icon.option.key': '金鑰',
'hostDetails.icon.option.monitor': '顯示器',
'hostDetails.icon.option.container': '容器',
'hostDetails.icon.option.activity': '活動',
'hostDetails.icon.option.zap': '高速',
'hostDetails.icon.option.server-cog': '伺服器設定',
'hostDetails.icon.color.blue': '藍色',
'hostDetails.icon.color.green': '綠色',
'hostDetails.icon.color.red': '紅色',
'hostDetails.icon.color.amber': '琥珀色',
'hostDetails.icon.color.purple': '紫色',
'hostDetails.icon.color.cyan': '青色',
'hostDetails.icon.color.orange': '橙色',
'hostDetails.icon.color.slate': '石板灰',
'hostDetails.icon.color.violet': '紫羅蘭',
'hostDetails.icon.color.pink': '粉色',
'hostDetails.icon.color.rose': '玫瑰紅',
'hostDetails.icon.color.lime': '青檸',
'hostDetails.icon.color.teal': '藍綠色',
'hostDetails.icon.color.sky': '天藍',
'hostDetails.icon.color.indigo': '靛藍',
'hostDetails.icon.color.zinc': '鋅灰',
'hostDetails.icon.color.custom': '自訂顏色',
'hostDetails.distro.mode': '來源',
'hostDetails.distro.mode.auto': '自動探測',
'hostDetails.distro.mode.manual': '手動覆蓋',
'hostDetails.distro.detectedLabel': '目前值',
'hostDetails.distro.manualLabel': '手動指定',
'hostDetails.distro.pending': '首次連線後自動探測',
'hostDetails.distro.unknown': '未知',
'hostDetails.distro.option.linux': '通用 Linux',
'hostDetails.distro.option.ubuntu': 'Ubuntu',
'hostDetails.distro.option.debian': 'Debian',
'hostDetails.distro.option.centos': 'CentOS',
'hostDetails.distro.option.rocky': 'Rocky Linux',
'hostDetails.distro.option.fedora': 'Fedora',
'hostDetails.distro.option.arch': 'Arch Linux',
'hostDetails.distro.option.alpine': 'Alpine',
'hostDetails.distro.option.amazon': 'Amazon Linux',
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
'hostDetails.distro.option.almalinux': 'AlmaLinux',
'hostDetails.distro.option.alinux': '阿里雲 Linux',
'hostDetails.distro.option.openeuler': 'openEuler',
'hostDetails.distro.option.oracle': 'Oracle Linux',
'hostDetails.distro.option.kali': 'Kali Linux',
'hostDetails.distro.option.cisco': '思科',
'hostDetails.distro.option.juniper': '瞻博網路',
'hostDetails.distro.option.huawei': '華為',
'hostDetails.distro.option.h3c': '新華三 / H3C',
'hostDetails.distro.option.hpe': '慧與 / HPE',
'hostDetails.distro.option.mikrotik': 'MikroTik',
'hostDetails.distro.option.fortinet': '飛塔',
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
'hostDetails.distro.option.zyxel': '合勤',
'hostDetails.distro.option.ruijie': '銳捷',
'hostDetails.section.mosh': 'Mosh',
'hostDetails.section.et': 'EternalTerminal',
'hostDetails.et.port': 'ET 服務埠',
'hostDetails.et.port.desc': 'etserver 監聽埠(預設 2022)',
'hostDetails.username.placeholder': '使用者名稱',
'hostDetails.password.placeholder': '密碼',
'hostDetails.password.show': '顯示密碼',
'hostDetails.password.hide': '隱藏密碼',
'hostDetails.password.save': '儲存密碼',
'hostDetails.identity.suggestions': '身份',
'hostDetails.identity.missing': '身份不存在',
'hostDetails.credential.keyCertificate': '金鑰 / 憑證 / 本機金鑰',
'hostDetails.credential.key': '金鑰',
'hostDetails.credential.certificate': '憑證',
'hostDetails.credential.localKeyFile': '本機金鑰檔案',
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
'hostDetails.credential.browseKeyFile': '瀏覽…',
'hostDetails.credential.missing': '憑證不存在',
'hostDetails.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.identity.placeholder': '選擇 Telnet 身份',
'hostDetails.telnet.username': 'Telnet 使用者名稱',
'hostDetails.telnet.password': 'Telnet 密碼',
'hostDetails.charset.placeholder': '字元集(例如 UTF-8)',
'hostDetails.telnet.add': '新增 Telnet 協定',
'hostDetails.telnet.setDefault': '預設用 Telnet 連線',
'hostDetails.tags': '標籤',
'hostDetails.group': '分組',
'hostDetails.selectGroup': '選擇分組',
'hostDetails.addTag': '新增標籤...',
'hostDetails.createTag': '建立標籤',
'hostDetails.createGroup': '建立分組',
// Host form (legacy modal)
'hostForm.title.edit': '編輯主機',
'hostForm.title.new': '新建主機',
'hostForm.desc.edit': '更新該主機的連線資訊',
'hostForm.desc.new': '建立一個新的 SSH 主機條目',
'hostForm.field.label': '名稱',
'hostForm.placeholder.label': 'My Production Server',
'hostForm.field.hostname': 'Hostname / IP',
'hostForm.placeholder.hostname': '192.168.1.1',
'hostForm.field.port': '埠',
'hostForm.field.username': '使用者名稱',
'hostForm.field.osType': '作業系統型別',
'hostForm.placeholder.selectOs': '選擇作業系統',
'hostForm.field.group': '分組',
'hostForm.placeholder.group': '例如:AWS、DigitalOcean',
'hostForm.field.tags': '標籤',
'hostForm.placeholder.addTag': '新增標籤…',
'hostForm.auth.method': '認證方式',
'hostForm.auth.password': '密碼',
'hostForm.auth.sshKey': 'SSH金鑰',
'hostForm.auth.selectKey': '選擇 SSH金鑰',
'hostForm.auth.noKeys': '暫無金鑰',
'hostForm.auth.noKeysHint': '鑰匙串中未找到 SSH金鑰,請先建立一個。',
'hostForm.saveHost': '儲存主機',
// Connection logs
'logs.table.date': '日期',
'logs.table.user': '使用者',
'logs.table.host': '主機',
'logs.table.saved': '收藏',
'logs.empty.title': '暫無連線日誌',
'logs.empty.desc': '當你連線主機或開啟本機終端後,這裡會顯示連線歷史。',
'logs.loadMore': '載入更多 ({count} 條)',
'logs.ongoing': '進行中',
'logs.localTerminal': '本機終端',
'logs.action.save': '收藏',
'logs.action.unsave': '取消收藏',
'logs.action.delete': '刪除',
// Log view
'logView.customizeAppearance': '自訂外觀',
'logView.appearance': '外觀',
'logView.readOnly': '只讀',
'logView.export': '匯出',
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': '開啟 SFTP',
'terminal.toolbar.availableAfterConnect': '連線後可用',
'terminal.toolbar.sendYmodem': 'YMODEM 傳送',
'terminal.toolbar.receiveYmodem': 'YMODEM 接收',
'terminal.toolbar.sftp': 'SFTP',
'terminal.toolbar.more': '更多操作',
'terminal.toolbar.scripts': '指令碼',
'terminal.toolbar.history': '指令歷史',
'terminal.toolbar.library': '庫',
'terminal.toolbar.noSnippets': '暫無程式碼片段',
'terminal.toolbar.terminalSettings': '終端設定',
'terminal.toolbar.searchTerminal': '搜尋終端',
'terminal.toolbar.search': '搜尋',
'terminal.toolbar.startSessionLog': '開始工作階段日誌',
'terminal.toolbar.stopSessionLog': '停止工作階段日誌',
'terminal.toolbar.broadcast': '廣播',
'terminal.toolbar.broadcastEnable': '啟用廣播模式',
'terminal.toolbar.broadcastDisable': '關閉廣播模式',
'terminal.toolbar.composeBar': '撰寫欄',
'terminal.composeBar.placeholder': '在此輸入指令,按回車傳送...',
'terminal.composeBar.send': '傳送',
'terminal.composeBar.close': '關閉撰寫欄',
'terminal.composeBar.broadcasting': '正在廣播到所有工作階段',
'terminal.composeBar.resize': '拖拽調整撰寫欄高度',
'terminal.composeBar.manageSnippets': '管理快捷程式碼片段',
'terminal.composeBar.searchSnippets': '搜尋程式碼片段...',
'terminal.composeBar.noPinnedSnippets': '點選 + 固定常用程式碼片段',
'terminal.composeBar.noMatchingSnippets': '沒有相符的程式碼片段',
'terminal.composeBar.pinnedCount': '已固定 {count} 個',
'terminal.composeBar.unpinSnippet': '從快捷欄移除 {label}',
'terminal.composeBar.snippetClickHint': '單擊插入 · Shift+單擊直接傳送',
'terminal.toolbar.focus': '聚焦',
'terminal.toolbar.focusMode': '聚焦模式',
'terminal.toolbar.detach': '移出到獨立標籤',
'terminal.toolbar.encoding': '終端編碼',
'terminal.toolbar.encoding.utf8': 'UTF-8',
'terminal.toolbar.encoding.gb18030': 'GB18030',
'terminal.toolbar.closeSession': '關閉工作階段',
'terminal.toolbar.hostHighlight.title': '主機關鍵字標示',
'terminal.toolbar.hostHighlight.noRules': '此主機未定義自訂標示規則',
'terminal.toolbar.hostHighlight.addRule': '新增新規則',
'terminal.toolbar.hostHighlight.labelPlaceholder': '標籤(例如:錯誤)',
'terminal.toolbar.hostHighlight.patternPlaceholder': '正規表示式(例如:\\bfailed\\b)',
'terminal.toolbar.hostHighlight.invalidPattern': '無效的正規表示式',
'terminal.toolbar.hostHighlight.clearAll': '清除全部',
'terminal.toolbar.hostHighlight.changeColor': '更改標示顏色',
'terminal.toolbar.hostHighlight.selectColor': '選擇新規則的顏色',
'terminal.statusbar.copyHostname.label': '複製主機地址',
'terminal.statusbar.copyHostname.tooltip': '複製主機地址({hostname})',
'terminal.statusbar.copyHostname.toast': '已複製主機地址:{hostname}',
'terminal.statusbar.copyHostname.error': '複製主機地址失敗',
'terminal.serverStats.cpu': 'CPU 使用率',
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
'terminal.serverStats.memory': '記憶體使用',
'terminal.serverStats.memoryDetails': '記憶體詳情',
'terminal.serverStats.memUsed': '已用',
'terminal.serverStats.memBuffers': '緩衝區',
'terminal.serverStats.memCached': '快取',
'terminal.serverStats.memFree': '空閒',
'terminal.serverStats.swap': '交換空間',
'terminal.serverStats.swapUsed': '已用交換',
'terminal.serverStats.swapFree': '空閒交換',
'terminal.serverStats.swapTotal': '總計',
'terminal.serverStats.topProcesses': '記憶體佔用前十程序',
'terminal.serverStats.disk': '磁碟使用(根分割槽)',
'terminal.serverStats.diskDetails': '已掛載磁碟',
'terminal.serverStats.network': '網路速度',
'terminal.serverStats.latency': '延遲',
'terminal.serverStats.networkDetails': '網路介面',
'terminal.serverStats.noData': '暫無資料',
'terminal.dragDrop.localTitle': '拖放以插入路徑',
'terminal.dragDrop.localMessage': '檔案路徑將被插入到終端',
'terminal.dragDrop.remoteTitle': '拖放以上傳檔案',
'terminal.dragDrop.remoteZmodemMessage': '檔案將透過 ZMODEM(PTY)上傳',
'terminal.dragDrop.remoteSftpMessage': '檔案將透過 SFTP 上傳',
'terminal.dragDrop.noFiles': '沒有可上傳的檔案',
'terminal.dragDrop.notConnected': '無法拖放檔案 - 終端未連線',
'terminal.dragDrop.errorTitle': '拖放錯誤',
'terminal.dragDrop.errorMessage': '處理拖放檔案失敗',
'terminal.search.placeholder': '搜尋…',
'terminal.search.noResults': '無結果',
'terminal.search.prevMatch': '上一個相符 (Shift+Enter)',
'terminal.search.nextMatch': '下一個相符 (Enter)',
'terminal.menu.copy': '複製',
'terminal.menu.paste': '貼上',
'terminal.menu.addSelectionToAI': '新增到對話',
'terminal.menu.pasteSelection': '貼上選取文字',
'terminal.menu.selectAll': '全選',
'terminal.menu.reconnect': '重新連線',
'terminal.menu.sendYmodem': 'YMODEM 傳送',
'terminal.menu.receiveYmodem': 'YMODEM 接收',
'terminal.menu.splitHorizontal': '水平分割',
'terminal.menu.splitVertical': '垂直分割',
'terminal.menu.clearBuffer': '清空緩衝區',
'terminal.menu.closeTerminal': '關閉終端',
'terminal.menu.rename': '重新命名',
'terminal.menu.detach': '從工作區移出',
'terminal.menu.detachSession': '移出 {name}',
'terminal.ymodem.selectFile': '選擇要傳送的檔案',
'terminal.ymodem.allFiles': '所有檔案',
'terminal.ymodem.started': '正在透過 YMODEM 傳送 {fileName}',
'terminal.ymodem.complete': 'YMODEM 已傳送 {fileName}',
'terminal.ymodem.failed': 'YMODEM 傳送失敗',
'terminal.ymodem.selectReceiveDirectory': '選擇接收檔案儲存位置',
'terminal.ymodem.receiveStarted': '正在透過 YMODEM 接收...',
'terminal.ymodem.receiveComplete': 'YMODEM 已接收 {fileName}',
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM 已接收 {count} 個檔案',
'terminal.ymodem.receiveEmpty': '沒有接收到 YMODEM 檔案',
'terminal.ymodem.receiveFailed': 'YMODEM 接收失敗',
'terminal.ymodem.unavailable': 'YMODEM 目前不可用',
'terminal.selection.addToAI': '新增到對話',
'terminal.selection.addToAIDesc': '將選取的終端輸出作為附件加入 AI 草稿',
'terminal.auth.password': '密碼',
'terminal.auth.sshKey': 'SSH Key',
'terminal.auth.username': '使用者名稱',
'terminal.auth.username.placeholder': 'root',
'terminal.auth.passwordLabel': '密碼',
'terminal.auth.password.placeholder': '輸入密碼',
'terminal.auth.passphrase': '密碼短語',
'terminal.auth.passphrase.placeholder': '可選:所選私鑰的密碼短語',
'terminal.auth.certificate': '憑證',
'terminal.auth.selectKey': '選擇金鑰',
'terminal.auth.noKeysHint': '暫無金鑰,請先在鑰匙串中新增。',
'terminal.auth.continueSave': '繼續並儲存',
'terminal.auth.credentialsUnavailable': '目前裝置無法解密已儲存憑證,請重新輸入並再次儲存。',
'terminal.auth.jumpCredentialsUnavailable': '某個跳板機的已儲存憑證無法在目前裝置解密,請到主機設定中重新填寫。',
'terminal.auth.proxyCredentialsUnavailable': '代理憑證無法在目前裝置解密,請到主機設定中重新填寫代理密碼。',
'terminal.auth.keyUnavailableFallbackPassword': '已儲存 SSH 金鑰在目前裝置不可用,改用密碼認證。',
'terminal.connectionErrorTitle': '連線錯誤',
'terminal.progress.timeoutIn': '將在 {seconds}s 後超時',
'terminal.progress.waitingForUserInput': '等待使用者輸入',
'terminal.progress.disconnected': '已斷開',
'terminal.progress.cancelling': '正在取消...',
'terminal.progress.startOver': '重新開始',
'terminal.connection.dismissDisconnectedDialog': '關閉斷連提示',
'terminal.connection.chainOf': 'Chain {current} / {total}',
'terminal.connection.showLogs': '顯示日誌',
'terminal.connection.hideLogs': '隱藏日誌',
'terminal.connection.protocol.ssh': 'SSH',
'terminal.connection.protocol.telnet': 'Telnet',
'terminal.connection.protocol.mosh': 'Mosh',
'terminal.connection.protocol.serial': '序列埠',
'terminal.connection.protocol.local': '本機終端',
'terminal.hostKey.unknownTitle': '確認主機指紋',
'terminal.hostKey.changedTitle': '主機指紋已變化',
'terminal.hostKey.unknownDescription': '尚未確認 {host} 的真實性。',
'terminal.hostKey.changedDescription': '{host} 的已儲存指紋與目前伺服器不一致。',
'terminal.hostKey.fingerprintLabel': '{keyType} 指紋為 SHA256:',
'terminal.hostKey.savedFingerprintLabel': '已儲存的指紋',
'terminal.hostKey.unknownHint': '如果這個指紋屬於你預期連線的伺服器,可以記住它。',
'terminal.hostKey.changedHint': '只有在你確認這台主機確實變更過時才繼續。',
'terminal.hostKey.addAndContinue': '記住並繼續',
'terminal.hostKey.updateAndContinue': '更新並繼續',
'terminal.themeModal.title': 'Terminal 外觀',
'terminal.themeModal.tab.theme': '主題',
'terminal.themeModal.tab.font': '字型',
'terminal.themeModal.tab.custom': '自訂',
'terminal.themeModal.globalTheme': '全域主題',
'terminal.themeModal.globalFont': '全域字型',
'terminal.themeModal.fontSize': '字型大小',
'terminal.themeModal.fontWeight': '字型粗細',
'terminal.themeModal.livePreview': '即時預覽',
'terminal.themeModal.themeType': '{type} 主題',
'terminal.hiddenTheme.title': '目前隱藏主題',
'terminal.hiddenTheme.desc': '這個主題已從手動選擇列表中隱藏;當你選擇其他可見主題後,它會被替換。',
'topTabs.toggleTheme.systemExitTitle': '目前正在跟隨系統主題',
'topTabs.toggleTheme.systemExitMessage': '請到設定裡選擇固定的淺色或深色主題。',
'topTabs.toggleTheme.openSettings': '開啟設定',
// Custom Themes
'terminal.customTheme.section': '自訂主題',
'terminal.customTheme.yourThemes': '我的主題',
'terminal.customTheme.new': '新建主題',
'terminal.customTheme.newDesc': '克隆目前主題並自訂',
'terminal.customTheme.newTitle': '新建自訂主題',
'terminal.customTheme.editTitle': '編輯主題',
'terminal.customTheme.import': '匯入 .itermcolors',
'terminal.customTheme.importDesc': '從 iTerm2 配色方案檔案匯入',
'terminal.customTheme.importError': '無法解析所選檔案,請確保它是有效的 .itermcolors XML 檔案。',
'terminal.customTheme.delete': '刪除主題',
'terminal.customTheme.confirmDelete': '確認刪除',
'terminal.customTheme.name': '名稱',
'terminal.customTheme.namePlaceholder': '我的自訂主題',
'terminal.customTheme.type': '型別',
'terminal.customTheme.group.general': '通用',
'terminal.customTheme.group.normal': '標準色',
'terminal.customTheme.group.bright': '標示色',
'terminal.customTheme.color.background': '背景',
'terminal.customTheme.color.foreground': '前景',
'terminal.customTheme.color.cursor': '游標',
'terminal.customTheme.color.selection': '選區',
'terminal.customTheme.color.black': '黑色',
'terminal.customTheme.color.red': '紅色',
'terminal.customTheme.color.green': '綠色',
'terminal.customTheme.color.yellow': '黃色',
'terminal.customTheme.color.blue': '藍色',
'terminal.customTheme.color.magenta': '品紅',
'terminal.customTheme.color.cyan': '青色',
'terminal.customTheme.color.white': '白色',
'terminal.customTheme.color.brightBlack': '亮黑',
'terminal.customTheme.color.brightRed': '亮紅',
'terminal.customTheme.color.brightGreen': '亮綠',
'terminal.customTheme.color.brightYellow': '亮黃',
'terminal.customTheme.color.brightBlue': '亮藍',
'terminal.customTheme.color.brightMagenta': '亮品紅',
'terminal.customTheme.color.brightCyan': '亮青色',
'terminal.customTheme.color.brightWhite': '亮白',
'cloudSync.gate.title': '端到端加密同步',
'cloudSync.gate.desc':
'資料會在本機加密後再同步,雲端不會看到明文。設定主金鑰以啟用安全同步。',
'cloudSync.gate.masterKey': '主金鑰',
'cloudSync.gate.confirmMasterKey': '確認主金鑰',
'cloudSync.gate.placeholder': '輸入一個強密碼',
'cloudSync.gate.confirmPlaceholder': '再次輸入密碼',
'cloudSync.gate.mismatch': '兩次輸入的密碼不一致',
'cloudSync.gate.warning':
'我已瞭解:如果忘記主金鑰,資料無法恢復,且沒有密碼重置功能。',
'cloudSync.gate.enableVault': '啟用加密 Vault',
'cloudSync.gate.enabledToast': '已啟用加密 Vault',
'cloudSync.gate.setupFailed': '設定主金鑰失敗',
'cloudSync.passwordStrength.tooShort': '太短',
'cloudSync.passwordStrength.weak': '弱',
'cloudSync.passwordStrength.moderate': '一般',
'cloudSync.passwordStrength.strong': '強',
'cloudSync.passwordStrength.veryStrong': '非常強',
'cloudSync.provider.notConnected': '未連線',
'cloudSync.provider.sync': '同步',
'cloudSync.provider.connect': '連線',
'cloudSync.provider.connecting': '連線中...',
'cloudSync.provider.webdav': 'WebDAV',
'cloudSync.provider.webdav.desc': '連線到自建 WebDAV 端點',
'cloudSync.provider.s3': 'S3 相容儲存',
'cloudSync.provider.s3.desc': '連線到 S3 相容物件儲存',
'cloudSync.provider.comingSoon': '即將支援',
'cloudSync.webdav.title': 'WebDAV 設定',
'cloudSync.webdav.desc': '設定 WebDAV 端點用於加密同步。',
'cloudSync.webdav.endpoint': '端點地址',
'cloudSync.webdav.authType': '認證方式',
'cloudSync.webdav.auth.basic': 'Basic',
'cloudSync.webdav.auth.digest': 'Digest',
'cloudSync.webdav.auth.token': 'Token',
'cloudSync.webdav.username': '使用者名稱',
'cloudSync.webdav.password': '密碼',
'cloudSync.webdav.token': 'Token',
'cloudSync.webdav.showSecret': '顯示金鑰',
'cloudSync.webdav.allowInsecure': '允許不安全的連線(忽略憑證錯誤)',
'cloudSync.webdav.validation.endpoint': '請輸入有效的 WebDAV 端點。',
'cloudSync.webdav.validation.credentials': '請輸入使用者名稱和密碼。',
'cloudSync.webdav.validation.token': '請輸入 Token。',
'cloudSync.s3.title': 'S3 設定',
'cloudSync.s3.desc': '連線到 S3 相容物件儲存以進行加密同步。',
'cloudSync.s3.endpoint': '端點地址',
'cloudSync.s3.region': 'Region',
'cloudSync.s3.bucket': 'Bucket',
'cloudSync.s3.accessKeyId': 'Access Key ID',
'cloudSync.s3.secretAccessKey': 'Secret Access Key',
'cloudSync.s3.sessionToken': 'Session Token(可選)',
'cloudSync.s3.prefix': 'Key 字首(可選)',
'cloudSync.s3.forcePathStyle': '強制使用 path-style URL(適用於 MinIO/R2 等)',
'cloudSync.s3.showSecret': '顯示金鑰',
'cloudSync.s3.validation.required': '端點、Region、Bucket、Access Key 與 Secret 必填。',
'cloudSync.smb.title': 'SMB 設定',
'cloudSync.smb.desc': '連線到 SMB/CIFS 檔案共享以進行加密同步。',
'cloudSync.smb.share': '共享路徑',
'cloudSync.smb.username': '使用者名稱',
'cloudSync.smb.password': '密碼',
'cloudSync.smb.domain': '域(可選)',
'cloudSync.smb.domainPlaceholder': '例如:WORKGROUP',
'cloudSync.smb.port': '埠(可選)',
'cloudSync.smb.showSecret': '顯示密碼',
'cloudSync.smb.validation.share': '共享路徑必填。',
'cloudSync.smb.validation.port': '埠必須是 1 到 65535 之間的數字。',
'cloudSync.connect.smb.success': 'SMB 已連線',
'cloudSync.connect.smb.failedTitle': 'SMB 連線失敗',
'cloudSync.provider.smb': 'SMB 共享',
'cloudSync.connect.webdav.success': 'WebDAV 已連線',
'cloudSync.connect.webdav.failedTitle': 'WebDAV 連線失敗',
'cloudSync.connect.s3.success': 'S3 已連線',
'cloudSync.connect.s3.failedTitle': 'S3 連線失敗',
'cloudSync.lastSync.never': '從未',
'cloudSync.lastSync.justNow': '剛剛',
'cloudSync.lastSync.minutesAgo': '{minutes} 分鐘前',
'cloudSync.changeKey': '更改 Key',
'cloudSync.providers.title': '雲服務',
'cloudSync.syncAll': '同步所有已連線的服務',
'cloudSync.autoSync.title': '自動同步',
'cloudSync.autoSync.desc': '發生變更時自動同步',
'cloudSync.strategy.title': '同步策略',
'cloudSync.strategy.desc': '當本機和雲端都發生變化時,選擇如何處理。',
'cloudSync.strategy.smartMerge': '智慧合併(推薦)',
'cloudSync.strategy.smartMergeDesc': '儘量保留兩邊的變化;如果無法安全判斷,會再讓你手動選擇。',
'cloudSync.strategy.preferCloud': '雲端優先',
'cloudSync.strategy.preferCloudDesc': '兩邊都有變化時,下載雲端版本,並替換本機變化。',
'cloudSync.strategy.preferLocal': '本機優先',
'cloudSync.strategy.preferLocalDesc': '兩邊都有變化時,上傳本機版本,並替換雲端變化。',
'cloudSync.status.title': '同步狀態',
'cloudSync.status.localVersion': '本機版本',
'cloudSync.status.remoteVersion': '遠端版本',
'cloudSync.history.title': '同步歷史',
'cloudSync.history.upload': '上傳',
'cloudSync.history.download': '下載',
'cloudSync.history.resolved': '已解決',
'cloudSync.history.error': '錯誤',
'cloudSync.localBackups.title': '本機備份歷史',
'cloudSync.localBackups.desc': 'Netcatty 會在版本變化前,以及恢復主機庫前,自動留下一份本機恢復點。',
'cloudSync.localBackups.retentionTitle': '備份保留數量',
'cloudSync.localBackups.retentionDesc': '設定 Netcatty 最多保留多少份本機備份。',
'cloudSync.localBackups.maxCount': '最多保留',
'cloudSync.localBackups.maxSaved': '已儲存保留數量:{count}',
'cloudSync.localBackups.maxInvalid': '請輸入 1 到 100 之間的數字。',
'cloudSync.localBackups.empty': '還沒有本機備份。',
'cloudSync.localBackups.reason.appVersionChange': '版本變化前',
'cloudSync.localBackups.reason.beforeRestore': '恢復前',
'cloudSync.localBackups.versionChange': '{from} -> {to}',
'cloudSync.localBackups.counts': '{hosts} 台主機,{keys} 個金鑰,{snippets} 個程式碼片段,{notes} 條筆記',
'cloudSync.localBackups.restore': '恢復',
'cloudSync.localBackups.restoreSuccess': '已恢復本機備份。',
'cloudSync.localBackups.restoreFailedTitle': '恢復失敗',
'cloudSync.localBackups.restoreMissing': '找不到這份備份。',
'cloudSync.localBackups.protectiveBackupFailed': '無法建立保護性備份,已中止恢復以避免覆蓋目前資料。請先解決底層問題(例如鑰匙串存取)後重試。詳情:{message}',
'cloudSync.localBackups.restoreConfirmTitle': '確認恢復此備份?',
'cloudSync.localBackups.restoreConfirmDesc': '目前的主機、金鑰、程式碼片段與設定將被替換為此備份中的內容。系統會先自動建立一個保護性快照,便於復原。',
'cloudSync.localBackups.restoreConfirmButton': '恢復',
'cloudSync.localBackups.restoreConfirmCancel': '取消',
'cloudSync.localBackups.unavailableTitle': '無法使用本機備份',
'cloudSync.localBackups.unavailableDesc': '目前平台未提供受支援的安全金鑰庫,Netcatty 無法安全地寫入本機備份。請在支援系統鑰匙串的環境中執行,或改用雲同步保留恢復點。',
'cloudSync.localBackups.lockedTitle': '需要主金鑰',
'cloudSync.localBackups.lockedDesc': '請先設定或解鎖主金鑰再恢復備份,以確保恢復後的憑證仍保持加密。',
'cloudSync.revisionHistory.viewButton': '歷史版本',
'cloudSync.revisionHistory.title': '主機庫版本歷史',
'cloudSync.revisionHistory.description': '瀏覽並恢復 Gist 修訂歷史中的舊版主機庫資料。',
'cloudSync.revisionHistory.empty': '未找到修訂記錄。',
'cloudSync.revisionHistory.current': '目前版本',
'cloudSync.revisionHistory.revision': '修訂',
'cloudSync.revisionHistory.revisionPreview': '修訂內容',
'cloudSync.revisionHistory.device': '裝置',
'cloudSync.revisionHistory.hosts': '主機',
'cloudSync.revisionHistory.keys': '金鑰',
'cloudSync.revisionHistory.snippets': '程式碼片段',
'cloudSync.revisionHistory.notes': '筆記',
'cloudSync.revisionHistory.identities': '身份',
'cloudSync.revisionHistory.restoreButton': '恢復此版本',
'cloudSync.revisionHistory.restored': '已從選取的修訂恢復主機庫資料。',
'cloudSync.revisionHistory.revisionNotFound': '修訂未找到或不包含主機庫資料。',
'cloudSync.revisionHistory.decryptFailed': '無法解密此修訂。可能是使用了不同的主金鑰加密的。',
'cloudSync.changeKey.title': '更改主金鑰',
'cloudSync.changeKey.current': '目前主金鑰',
'cloudSync.changeKey.new': '新的主金鑰',
'cloudSync.changeKey.confirmNew': '確認新的主金鑰',
'cloudSync.changeKey.currentPlaceholder': '輸入目前主金鑰',
'cloudSync.changeKey.newPlaceholder': '輸入新的主金鑰',
'cloudSync.changeKey.confirmPlaceholder': '再次輸入新的主金鑰',
'cloudSync.changeKey.fillAll': '請填寫所有欄位',
'cloudSync.changeKey.minLength': '新的主金鑰至少 8 個字元',
'cloudSync.changeKey.notMatch': '兩次輸入的主金鑰不一致',
'cloudSync.changeKey.incorrectCurrent': '目前主金鑰不正確',
'cloudSync.changeKey.failed': '更改主金鑰失敗',
'cloudSync.changeKey.desc': '這將重新加密 Vault,請務必記住新的主金鑰。',
'cloudSync.changeKey.showKeys': '顯示主金鑰',
'cloudSync.changeKey.updatedToast': '主金鑰已更新',
'cloudSync.changeKey.updateButton': '更新主金鑰',
'cloudSync.unlock.title': '輸入主金鑰',
'cloudSync.unlock.masterKey': '主金鑰',
'cloudSync.unlock.desc': '僅需輸入一次主金鑰以啟用加密同步,之後會透過系統 Keychain 安全儲存。',
'cloudSync.unlock.placeholder': '輸入你的主金鑰',
'cloudSync.unlock.empty': '請輸入主金鑰',
'cloudSync.unlock.incorrect': '主金鑰不正確',
'cloudSync.unlock.failed': '解鎖 Vault 失敗',
'cloudSync.unlock.showKey': '顯示主金鑰',
'cloudSync.unlock.notNow': '暫不',
'cloudSync.unlock.readyToast': 'Vault 已就緒',
'cloudSync.unlock.unlockButton': '解鎖',
'cloudSync.header.vaultReady': 'Vault 已就緒',
'cloudSync.header.preparingVault': '正在準備 Vault...',
'cloudSync.header.providersConnected': '已連線 {count} 個 provider',
'cloudSync.githubFlow.title': '連線到 GitHub',
'cloudSync.githubFlow.desc': '複製下面的 code,並在 GitHub 頁面輸入以授權 Netcatty。',
'cloudSync.githubFlow.copyCode': '複製 code',
'cloudSync.githubFlow.copied': '已複製',
'cloudSync.githubFlow.openGitHub': '開啟 GitHub',
'cloudSync.githubFlow.waiting': '等待授權...',
'cloudSync.conflict.title': '偵測到版本衝突',
'cloudSync.conflict.desc': '選擇保留哪個版本',
'cloudSync.conflict.local': '本機',
'cloudSync.conflict.cloud': '雲端',
'cloudSync.conflict.detailsTitle': '發生變化的資料',
'cloudSync.conflict.detailsCounts': '本機 {local} · 雲端 {cloud} · 衝突 {conflicts}',
'cloudSync.conflict.entity.hosts': '主機',
'cloudSync.conflict.entity.keys': '金鑰',
'cloudSync.conflict.entity.identities': '身份',
'cloudSync.conflict.entity.proxyProfiles': '代理設定',
'cloudSync.conflict.entity.snippets': '片段',
'cloudSync.conflict.entity.notes': '筆記',
'cloudSync.conflict.entity.noteGroups': '筆記分組',
'cloudSync.conflict.entity.customGroups': '分組',
'cloudSync.conflict.entity.snippetPackages': '片段包',
'cloudSync.conflict.entity.portForwardingRules': '埠轉發',
'cloudSync.conflict.entity.groupConfigs': '分組設定',
'cloudSync.conflict.entity.settings': '設定',
'cloudSync.conflict.keepLocal': '覆蓋雲端(保留本機)',
'cloudSync.conflict.useCloud': '下載雲端(覆蓋本機)',
'cloudSync.connect.browserContinue': '請在瀏覽器中完成授權',
'cloudSync.connect.browserCancelled': '已取消上一個瀏覽器授權流程',
'cloudSync.connect.github.success': 'GitHub 已連線',
'cloudSync.connect.github.failedTitle': 'GitHub 連線失敗',
'cloudSync.connect.github.timeout': '連線 GitHub 超時,請檢查網路或代理設定。',
'cloudSync.connect.github.networkError': '無法存取 GitHub,請檢查網路或代理設定。',
'cloudSync.connect.google.failedTitle': 'Google 連線失敗',
'cloudSync.connect.onedrive.failedTitle': 'OneDrive 連線失敗',
'cloudSync.sync.success': '已同步到 {provider}',
'cloudSync.sync.failed': '同步失敗',
'cloudSync.sync.failedTitle': '同步失敗',
'cloudSync.sync.errorTitle': '同步錯誤',
'cloudSync.resolve.downloaded': '已下載雲端資料',
'cloudSync.resolve.uploaded': '已上傳本機資料',
'cloudSync.resolve.failedTitle': '衝突處理失敗',
'cloudSync.clearLocal.title': '清空本機資料',
'cloudSync.clearLocal.desc': '重置本機版本和同步歷史。下次同步將從雲端下載。',
'cloudSync.clearLocal.button': '清空',
'cloudSync.clearLocal.dialog.title': '清空本機 Vault 資料?',
'cloudSync.clearLocal.dialog.desc': '這將重置本機版本為 0 並清除同步歷史。下次同步時會從雲端下載資料,替換本機資料。',
'cloudSync.clearLocal.dialog.cancel': '取消',
'cloudSync.clearLocal.dialog.confirm': '確認清空',
'cloudSync.clearLocal.toast.title': '本機資料已清空',
'cloudSync.clearLocal.toast.desc': '本機版本已重置為 0。同步以從雲端下載資料。',
// Common (additional)
'common.searchPlaceholder': '搜尋...',
'common.import': '匯入',
'common.generate': '生成',
'common.delete': '刪除',
'common.edit': '編輯',
'sftp.context.openWithDefault': '系統預設程式開啟',
'common.clear': '清除',
'common.optional': '可選',
'common.selectPlaceholder': '請選擇...',
'common.error': '錯誤',
'common.validation': '驗證',
'common.saveChanges': '儲存修改',
'common.advanced': '高階',
'common.selectAHostPlaceholder': '選擇主機...',
// Actions
'action.duplicate': '複製',
'action.open': '開啟',
'action.copy': '複製',
'action.run': '執行',
'action.start': '啟動',
'action.stop': '停止',
// Port Forwarding (form)
'pf.form.labelPlaceholder': '規則標籤',
'pf.form.intermediateHost': '中轉主機 *',
'pf.form.createRule': '建立規則',
'pf.form.openWizard': '打開向導',
'pf.form.openWizardTitle': '開啟埠轉發精靈',
'pf.action.newForwarding': '新建轉發',
'pf.view.grid': '網格',
'pf.view.list': '列表',
'pf.rule.summary.local': '本機 {bindAddress}:{localPort} -> SSH 隧道 -> 遠端 {remoteHost}:{remotePort}',
'pf.rule.summary.remote': '遠端 {bindAddress}:{localPort} -> SSH 隧道 -> 本機 {remoteHost}:{remotePort}',
'pf.rule.summary.dynamic': 'SOCKS 監聽於 {bindAddress}:{localPort}',
'pf.tooltip.relayHost': '中轉主機',
'pf.tooltip.hostLabel': '主機',
'pf.tooltip.hostAddress': '地址',
'pf.tooltip.noHost': '未設定中轉主機',
'pf.tooltip.localDesc': '本機埠轉發:透過 SSH 隧道存取遠端服務',
'pf.tooltip.remoteDesc': '遠端埠轉發:將本機服務公開給遠端主機',
'pf.tooltip.dynamicDesc': '動態 SOCKS 代理:透過 SSH 隧道轉發流量',
'pf.deleteActive.title': '刪除正在執行的埠轉發?',
'pf.deleteActive.desc': '埠轉發規則 "{label}" 目前正在執行。刪除前將先關閉轉發連線。',
'pf.deleteActive.confirm': '關閉並刪除',
'pf.form.autoStart': '自動啟動',
'pf.form.autoStartDesc': '應用程式啟動時自動開啟此規則',
// SFTP (pane + conflict)
'sftp.pane.local': '本機',
'sftp.pane.remote': '遠端',
'sftp.pane.selectHost': '選擇主機',
'sftp.pane.selectHostToStart': '先選擇一個主機',
'sftp.pane.chooseFilesystem': '選擇要瀏覽的本機或遠端檔案系統',
'sftp.tabs.addTab': '新建標籤頁',
'sftp.tabs.closeTab': '關閉標籤頁',
'sftp.tabs.newTab': '新標籤頁',
'sftp.tabs.copyDefaultPath': '複製標籤頁(預設路徑)',
'sftp.tabs.copyCurrentPath': '複製並跳轉到目前路徑',
'sftp.conflict.title': '檔案衝突',
'sftp.conflict.desc': '目標位置已存在同名檔案',
'sftp.conflict.alreadyExistsSuffix': '已存在',
'sftp.conflict.existingFile': '已有檔案',
'sftp.conflict.newFile': '新檔案',
'sftp.conflict.size': '大小:',
'sftp.conflict.modified': '修改時間:',
'sftp.conflict.applyToAll': '將此操作套用到剩餘的 {count} 個衝突',
'sftp.conflict.action.stop': '停止',
'sftp.conflict.action.skip': '跳過',
'sftp.conflict.action.keepBoth': '保留兩者',
'sftp.conflict.action.duplicate': '建立副本',
'sftp.conflict.action.merge': '合併',
'sftp.conflict.action.replace': '替換',
// SFTP Upload Phases
'sftp.upload.phase.compressing': '正在壓縮',
'sftp.upload.phase.uploading': '正在上傳',
'sftp.upload.phase.extracting': '正在解壓',
'sftp.upload.phase.compressed': '壓縮傳輸',
};
+2
View File
@@ -1,5 +1,6 @@
import en, { type Messages } from './locales/en';
import zhCN from './locales/zh-CN';
import zhTW from './locales/zh-TW';
import ru from './locales/ru';
// Keep keys stable; add new locales by adding another import and map entry.
@@ -9,5 +10,6 @@ export const MESSAGES_BY_LOCALE: Record<string, Messages> = {
en,
ru,
'zh-CN': zhCN,
'zh-TW': zhTW,
};
@@ -1,5 +1,6 @@
import { isActiveChromeThemeResolvable, resolveActiveChromeTheme } from '../app/activeChromeTheme';
import { clearTopTabsChromeThemeVars } from '../app/topTabsChromeTheme';
import type { TerminalAppearanceHostScope, ResolvedAppearance } from '../../domain/terminalAppearanceRuntime';
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
import { activeTabStore } from './activeTabStore';
import type { EditorTab } from './editorTabStore';
@@ -15,6 +16,7 @@ export type ActiveChromeThemeDeps = {
followAppTerminalTheme: boolean;
hostById: Map<string, Host>;
logViews: readonly LogView[];
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
sessionById: Map<string, TerminalSession>;
themeById: Map<string, TerminalTheme>;
workspaceById: Map<string, Workspace>;
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
setScriptRuns,
waitForScriptRun,
} from './scriptAutomationCoordinator.ts';
test('waitForScriptRun resolves when run is already completed on subscribe', async () => {
const runId = 'run-already-done';
setScriptRuns([{
runId,
scriptId: 's1',
sessionId: 'sess1',
status: 'completed',
startedAt: Date.now() - 1000,
endedAt: Date.now(),
logs: [],
}]);
const run = await waitForScriptRun(runId, { timeoutMs: 5000 });
assert.equal(run.runId, runId);
assert.equal(run.status, 'completed');
});
test('waitForScriptRun rejects when run already failed on subscribe', async () => {
const runId = 'run-already-failed';
setScriptRuns([{
runId,
sessionId: 'sess1',
status: 'failed',
startedAt: Date.now() - 1000,
endedAt: Date.now(),
error: 'boom',
logs: [],
}]);
await assert.rejects(
() => waitForScriptRun(runId, { timeoutMs: 5000 }),
/boom/,
);
});
@@ -0,0 +1,186 @@
import type { Snippet } from '@/domain/models';
import { isScriptSnippet, scriptContainsWriteOperations } from '@/domain/snippetScript.ts';
import { localStorageAdapter } from '@/infrastructure/persistence/localStorageAdapter.ts';
import { STORAGE_KEY_AI_PERMISSION_MODE } from '@/infrastructure/config/storageKeys.ts';
import type { AIPermissionMode } from '@/infrastructure/ai/types.ts';
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
type RunsListener = (runs: ScriptRun[]) => void;
let runs: ScriptRun[] = [];
const runsListeners = new Set<RunsListener>();
function readPermissionMode(): AIPermissionMode {
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
if (stored === 'observer' || stored === 'confirm' || stored === 'auto') return stored;
return 'confirm';
}
export function subscribeScriptRuns(listener: RunsListener): () => void {
runsListeners.add(listener);
queueMicrotask(() => {
if (runsListeners.has(listener)) {
listener(runs);
}
});
return () => runsListeners.delete(listener);
}
export function setScriptRuns(nextRuns: ScriptRun[]) {
runs = nextRuns;
runsListeners.forEach((listener) => listener(runs));
}
export function getActiveScriptRunForSession(sessionId: string): ScriptRun | undefined {
return runs.find((run) =>
run.sessionId === sessionId && (run.status === 'running' || run.status === 'paused'),
);
}
export async function runAutomationScript(params: {
snippet: Snippet;
sessionId: string;
sessionIds?: string[];
mode?: 'sequential' | 'parallel';
sessionMeta?: {
connected?: boolean;
hostname?: string;
username?: string;
};
}): Promise<{ runId: string; runIds: string[] }> {
const permissionMode = readPermissionMode();
if (permissionMode === 'observer' && scriptContainsWriteOperations(params.snippet.command)) {
throw new Error('Observer mode blocks scripts that write to the terminal.');
}
const bridge = netcattyBridge.get();
if (!bridge?.scriptRun) {
throw new Error('Script bridge unavailable');
}
return bridge.scriptRun({
scriptId: params.snippet.id,
scriptLabel: params.snippet.label,
content: params.snippet.command,
sessionId: params.sessionId,
sessionIds: params.sessionIds,
mode: params.mode,
permissionMode,
sessionMeta: params.sessionMeta,
});
}
const TERMINAL_SCRIPT_STATUSES = new Set<ScriptRun['status']>(['completed', 'failed']);
export function waitForScriptRun(
runId: string,
options: { signal?: AbortSignal; timeoutMs?: number } = {},
): Promise<ScriptRun> {
const existing = runs.find((entry) => entry.runId === runId);
if (existing && TERMINAL_SCRIPT_STATUSES.has(existing.status)) {
if (existing.status === 'completed') {
return Promise.resolve(existing);
}
return Promise.reject(new Error(existing.error || 'Script failed'));
}
const timeoutMs = options.timeoutMs ?? 3_600_000;
return new Promise((resolve, reject) => {
let settled = false;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
let unsubscribe: () => void = () => {};
const finish = (handler: () => void) => {
if (settled) return;
settled = true;
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
unsubscribe();
options.signal?.removeEventListener('abort', onAbort);
handler();
};
const onAbort = () => {
finish(() => reject(new Error('Aborted')));
};
const settleRun = (run: ScriptRun | undefined) => {
if (!run || !TERMINAL_SCRIPT_STATUSES.has(run.status)) return;
if (run.status === 'completed') {
finish(() => resolve(run));
return;
}
finish(() => reject(new Error(run.error || 'Script failed')));
};
unsubscribe = subscribeScriptRuns((currentRuns) => {
settleRun(currentRuns.find((entry) => entry.runId === runId));
});
timeoutId = setTimeout(() => {
finish(() => reject(new Error('Script run timed out')));
}, timeoutMs);
options.signal?.addEventListener('abort', onAbort, { once: true });
});
}
export async function runConnectScriptsSequential(params: {
scripts: Snippet[];
sessionId: string;
signal?: AbortSignal;
onScriptStart?: (snippet: Snippet) => void;
onScriptComplete?: (snippet: Snippet) => void;
sessionMeta?: {
connected?: boolean;
hostname?: string;
username?: string;
};
}): Promise<void> {
for (const snippet of params.scripts) {
if (params.signal?.aborted) {
throw new Error('Aborted');
}
params.onScriptStart?.(snippet);
const { runId } = await runAutomationScript({
snippet,
sessionId: params.sessionId,
sessionMeta: params.sessionMeta,
});
await waitForScriptRun(runId, { signal: params.signal });
params.onScriptComplete?.(snippet);
}
}
export async function runSnippetOrScript(params: {
snippet: Snippet;
sessionId: string;
runSnippetText: (command: string, noAutoRun?: boolean) => void;
command: string;
}) {
if (isScriptSnippet(params.snippet)) {
await runAutomationScript({
snippet: params.snippet,
sessionId: params.sessionId,
});
return;
}
params.runSnippetText(params.command, params.snippet.noAutoRun);
}
export async function stopScriptRun(runId: string): Promise<{ ok: boolean }> {
const result = await netcattyBridge.get()?.scriptStop?.(runId);
return { ok: result?.ok !== false };
}
export async function pauseScriptRun(runId: string): Promise<{ ok: boolean }> {
const result = await netcattyBridge.get()?.scriptPause?.(runId);
return { ok: result?.ok !== false };
}
export async function resumeScriptRun(runId: string): Promise<{ ok: boolean }> {
const result = await netcattyBridge.get()?.scriptResume?.(runId);
return { ok: result?.ok !== false };
}
+30
View File
@@ -0,0 +1,30 @@
type ScriptRecordingSnapshot = {
sessionId: string | null;
isPaused: boolean;
};
type Listener = () => void;
let snapshot: ScriptRecordingSnapshot = { sessionId: null, isPaused: false };
const listeners = new Set<Listener>();
function emit() {
for (const listener of listeners) {
listener();
}
}
export function getScriptRecordingSnapshot(): ScriptRecordingSnapshot {
return snapshot;
}
export function subscribeScriptRecording(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function setScriptRecordingState(sessionId: string | null, isPaused = false): void {
if (snapshot.sessionId === sessionId && snapshot.isPaused === isPaused) return;
snapshot = { sessionId, isPaused };
emit();
}
+24
View File
@@ -39,14 +39,19 @@ import {
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_APP_ICON_VARIANT,
} from '../../infrastructure/config/storageKeys';
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
clampWindowOpacity,
isValidUiFontId,
migrateIncomingTerminalFontId,
} from './settingsStateDefaults';
import { isTerminalSidePanelAutoOpenTab, type TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
interface UseSettingsIpcSyncParams {
enabled?: boolean;
@@ -73,12 +78,15 @@ interface UseSettingsIpcSyncParams {
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setWindowOpacity: Dispatch<SetStateAction<number>>;
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
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>>;
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
setRestoreTerminalCwdState: Dispatch<SetStateAction<boolean>>;
@@ -110,12 +118,15 @@ export function useSettingsIpcSync({
setIsHotkeyRecordingState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setAppIconVariant,
setAutoUpdateEnabled,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setWorkspaceFocusStyleState,
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
setTerminalSidePanelAutoOpenTabState,
setDisableTerminalFontZoomState,
setRestorePreviousSessionState,
setRestoreTerminalCwdState,
@@ -226,6 +237,10 @@ export function useSettingsIpcSync({
const nextOpacity = clampWindowOpacity(value);
setWindowOpacity((prev) => (prev === nextOpacity ? prev : nextOpacity));
}
if (key === STORAGE_KEY_APP_ICON_VARIANT) {
const nextVariant = resolveAppIconVariant(value);
setAppIconVariant((prev) => (prev === nextVariant ? prev : nextVariant));
}
if (key === STORAGE_KEY_AUTO_UPDATE_ENABLED && typeof value === 'boolean') {
setAutoUpdateEnabled((prev) => (prev === value ? prev : value));
}
@@ -246,6 +261,12 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && typeof value === 'boolean') {
setShowHostTreeSidebarState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN && typeof value === 'boolean') {
setTerminalSidePanelAutoOpenState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB && isTerminalSidePanelAutoOpenTab(value)) {
setTerminalSidePanelAutoOpenTabState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM && typeof value === 'boolean') {
setDisableTerminalFontZoomState((prev) => (prev === value ? prev : value));
}
@@ -275,6 +296,7 @@ export function useSettingsIpcSync({
setFollowAppTerminalThemeState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setAppIconVariant,
setHotkeyScheme,
setIsHotkeyRecordingState,
setSessionLogsDir,
@@ -287,6 +309,8 @@ export function useSettingsIpcSync({
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
setTerminalSidePanelAutoOpenTabState,
setDisableTerminalFontZoomState,
setRestorePreviousSessionState,
setRestoreTerminalCwdState,
+39 -8
View File
@@ -31,6 +31,8 @@ import {
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
@@ -47,7 +49,9 @@ import {
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_APP_ICON_VARIANT,
} from '../../infrastructure/config/storageKeys';
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
import {
clampWindowOpacity,
isValidHslToken,
@@ -56,6 +60,7 @@ import {
isValidUiThemeId,
migrateIncomingTerminalFontId,
} from './settingsStateDefaults';
import { isTerminalSidePanelAutoOpenTab, type TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
interface UseSettingsStorageSyncParams {
enabled?: boolean;
@@ -83,6 +88,8 @@ interface UseSettingsStorageSyncParams {
showOnlyUngroupedHostsInRoot: boolean;
showSftpTab: boolean;
showHostTreeSidebar: boolean;
terminalSidePanelAutoOpen: boolean;
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
shellOnlyTabNumberShortcuts: boolean;
disableTerminalFontZoom: boolean;
restorePreviousSession: boolean;
@@ -97,6 +104,7 @@ interface UseSettingsStorageSyncParams {
globalHotkeyEnabled: boolean;
autoUpdateEnabled: boolean;
windowOpacity: number;
appIconVariant: AppIconVariant;
setTheme: Dispatch<SetStateAction<'dark' | 'light' | 'system'>>;
setLightUiThemeId: Dispatch<SetStateAction<string>>;
setDarkUiThemeId: Dispatch<SetStateAction<string>>;
@@ -123,6 +131,8 @@ interface UseSettingsStorageSyncParams {
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
setShellOnlyTabNumberShortcutsState: Dispatch<SetStateAction<boolean>>;
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
@@ -136,6 +146,7 @@ interface UseSettingsStorageSyncParams {
setSshDeepLinkEnabledState: (enabled: boolean) => void;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setWindowOpacity: Dispatch<SetStateAction<number>>;
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
@@ -150,18 +161,18 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
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, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState,
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
}: UseSettingsStorageSyncParams) {
// Fix 4: Keep a ref snapshot of current settings so the storage event handler
@@ -173,9 +184,9 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
});
settingsSnapshotRef.current = {
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
@@ -183,9 +194,9 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
};
// Listen for storage changes from other windows (cross-window sync)
@@ -404,6 +415,17 @@ export function useSettingsStorageSync({
setShowHostTreeSidebarState(newValue);
}
}
if (e.key === STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.terminalSidePanelAutoOpen) {
setTerminalSidePanelAutoOpenState(newValue);
}
}
if (e.key === STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB && e.newValue !== null) {
if (isTerminalSidePanelAutoOpenTab(e.newValue) && e.newValue !== s.terminalSidePanelAutoOpenTab) {
setTerminalSidePanelAutoOpenTabState(e.newValue);
}
}
if (e.key === STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.shellOnlyTabNumberShortcuts) {
@@ -448,6 +470,12 @@ export function useSettingsStorageSync({
setWindowOpacity(newValue);
}
}
if (e.key === STORAGE_KEY_APP_ICON_VARIANT && e.newValue !== null) {
const newValue = resolveAppIconVariant(e.newValue);
if (newValue !== s.appIconVariant) {
setAppIconVariant(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') {
@@ -478,6 +506,7 @@ export function useSettingsStorageSync({
setFollowAppTerminalThemeState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setAppIconVariant,
setHotkeyScheme,
setLightUiThemeId,
setSessionLogsDir,
@@ -496,6 +525,8 @@ export function useSettingsStorageSync({
setSftpUseCompressedUpload,
setShowOnlyUngroupedHostsInRootState,
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
setTerminalSidePanelAutoOpenTabState,
setShowRecentHostsState,
setShowSftpTabState,
setShellOnlyTabNumberShortcutsState,
@@ -0,0 +1,63 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveCurrentTerminalTheme } from "./settingsTerminalTheme";
const commonArgs = {
terminalThemeId: "netcatty-dark",
customThemes: [],
resolvedTheme: "dark" as const,
lightUiThemeId: "snow",
darkUiThemeId: "github",
accentMode: "theme" as const,
customAccent: "",
};
test("follow-app terminal theme uses pending pick before resolvedTheme catches up", () => {
const theme = resolveCurrentTerminalTheme({
...commonArgs,
followAppTerminalTheme: true,
pendingFollowAppTerminalThemeId: "system-flexoki-light",
resolvedTheme: "dark",
lightUiThemeId: "flexoki",
darkUiThemeId: "github",
terminalThemeDarkId: "dracula",
terminalThemeLightId: "solarized-light",
});
assert.equal(theme.id, "system-flexoki-light");
});
test("follow-app terminal theme ignores manual per-mode terminal picks", () => {
const theme = resolveCurrentTerminalTheme({
...commonArgs,
followAppTerminalTheme: true,
terminalThemeDarkId: "dracula",
terminalThemeLightId: "solarized-light",
});
assert.equal(theme.id, "system-github-dark");
});
test("manual terminal theme uses the active per-mode pick", () => {
const theme = resolveCurrentTerminalTheme({
...commonArgs,
followAppTerminalTheme: false,
terminalThemeDarkId: "dracula",
terminalThemeLightId: "solarized-light",
});
assert.equal(theme.id, "dracula");
});
test("manual terminal theme preserves the saved global theme until a per-mode pick exists", () => {
const theme = resolveCurrentTerminalTheme({
...commonArgs,
terminalThemeId: "dracula",
followAppTerminalTheme: false,
terminalThemeDarkId: "auto",
terminalThemeLightId: "auto",
});
assert.equal(theme.id, "dracula");
});
+21 -7
View File
@@ -1,6 +1,10 @@
import type { TerminalTheme } from '../../domain/models';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
import { applyCustomAccentToTerminalTheme, resolveFollowedTerminalThemeId } from '../../domain/terminalAppearance';
import { TERMINAL_THEMES, getBuiltinTerminalThemeById } from '../../infrastructure/config/terminalThemes';
import {
applyCustomAccentToTerminalTheme,
resolveFollowAppTerminalThemeId,
resolveManualTerminalThemeId,
} from '../../domain/terminalAppearance';
interface ResolveCurrentTerminalThemeParams {
terminalThemeId: string;
@@ -8,6 +12,7 @@ interface ResolveCurrentTerminalThemeParams {
terminalThemeLightId: string;
customThemes: TerminalTheme[];
followAppTerminalTheme: boolean;
pendingFollowAppTerminalThemeId?: string | null;
resolvedTheme: 'light' | 'dark';
lightUiThemeId: string;
darkUiThemeId: string;
@@ -21,6 +26,7 @@ export function resolveCurrentTerminalTheme({
terminalThemeLightId,
customThemes,
followAppTerminalTheme,
pendingFollowAppTerminalThemeId = null,
resolvedTheme,
lightUiThemeId,
darkUiThemeId,
@@ -28,21 +34,29 @@ export function resolveCurrentTerminalTheme({
customAccent,
}: ResolveCurrentTerminalThemeParams): TerminalTheme {
if (followAppTerminalTheme) {
const followedId = resolveFollowedTerminalThemeId({
const followedId = resolveFollowAppTerminalThemeId(pendingFollowAppTerminalThemeId, {
resolvedTheme,
terminalThemeDarkId,
terminalThemeLightId,
lightUiThemeId,
darkUiThemeId,
fallbackThemeId: terminalThemeId,
});
const followed = TERMINAL_THEMES.find(t => t.id === followedId)
const followed = getBuiltinTerminalThemeById(followedId)
|| customThemes.find(t => t.id === followedId);
if (followed) {
return applyCustomAccentToTerminalTheme(followed, accentMode, customAccent);
}
}
const baseTheme = TERMINAL_THEMES.find(t => t.id === terminalThemeId)
const manualThemeId = resolveManualTerminalThemeId({
resolvedTheme,
terminalThemeDarkId,
terminalThemeLightId,
lightUiThemeId,
darkUiThemeId,
fallbackThemeId: terminalThemeId,
});
const baseTheme = getBuiltinTerminalThemeById(manualThemeId)
|| customThemes.find(t => t.id === manualThemeId)
|| getBuiltinTerminalThemeById(terminalThemeId)
|| customThemes.find(t => t.id === terminalThemeId)
|| TERMINAL_THEMES[0];
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
@@ -0,0 +1,122 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
getSftpCurrentPathMemoryKey,
getSftpReopenMemoryKey,
resolveSftpOpenLocation,
} from "./sftpReopenLocation.ts";
test("first open of a terminal lands on the terminal cwd", () => {
const location = resolveSftpOpenLocation({
hostId: "host-1",
connectionKey: "host-1:server-a:22:ssh::deploy",
terminalCwd: "/home/deploy",
remembered: null,
});
assert.equal(location, "/home/deploy");
});
test("reopening the same terminal restores the last browsed path", () => {
const location = resolveSftpOpenLocation({
hostId: "host-1",
connectionKey: "host-1:server-a:22:ssh::deploy",
terminalCwd: "/home/deploy",
remembered: {
hostId: "host-1",
connectionKey: "host-1:server-a:22:ssh::deploy",
path: "/home/deploy/projects/app",
},
});
assert.equal(location, "/home/deploy/projects/app");
});
test("remembered path for a different host falls back to terminal cwd", () => {
const location = resolveSftpOpenLocation({
hostId: "host-2",
connectionKey: "host-2:server-b:22:ssh::root",
terminalCwd: "/srv",
remembered: {
hostId: "host-1",
connectionKey: "host-1:server-a:22:ssh::deploy",
path: "/home/deploy/projects/app",
},
});
assert.equal(location, "/srv");
});
test("explicit upload target wins over remembered path", () => {
const location = resolveSftpOpenLocation({
hostId: "host-1",
connectionKey: "host-1:server-a:22:ssh::deploy",
terminalCwd: "/home/deploy",
explicitTargetPath: "/tmp/upload-here",
hasPendingUpload: true,
remembered: {
hostId: "host-1",
connectionKey: "host-1:server-a:22:ssh::deploy",
path: "/home/deploy/projects/app",
},
});
assert.equal(location, "/tmp/upload-here");
});
test("untargeted upload does not inherit a remembered reopen path", () => {
const location = resolveSftpOpenLocation({
hostId: "host-1",
connectionKey: "host-1:server-a:22:ssh::deploy",
terminalCwd: undefined,
hasPendingUpload: true,
remembered: {
hostId: "host-1",
connectionKey: "host-1:server-a:22:ssh::deploy",
path: "/home/deploy/projects/app",
},
});
assert.equal(location, undefined);
});
test("remembered path for the same saved host but different endpoint falls back to terminal cwd", () => {
const location = resolveSftpOpenLocation({
hostId: "host-1",
connectionKey: "host-1:server-a:2200:ssh::root",
terminalCwd: "/srv/root",
remembered: {
hostId: "host-1",
connectionKey: "host-1:server-a:22:ssh::deploy",
path: "/home/deploy/projects/app",
},
});
assert.equal(location, "/srv/root");
});
test("workspace sftp memory is keyed by source session rather than workspace tab", () => {
const workspaceTabId = "workspace-1";
assert.equal(
getSftpReopenMemoryKey({ tabId: workspaceTabId, sourceSessionId: "session-a" }),
"session-a",
);
assert.equal(
getSftpReopenMemoryKey({ tabId: workspaceTabId, sourceSessionId: "session-b" }),
"session-b",
);
assert.equal(getSftpReopenMemoryKey({ tabId: "terminal-tab-1" }), "terminal-tab-1");
});
test("path changes for non-reusable sessions still use the focused terminal session", () => {
assert.equal(
getSftpCurrentPathMemoryKey({
tabId: "workspace-1",
activeTerminalSessionIdForSftp: null,
focusedSessionId: "mosh-session-1",
}),
"mosh-session-1",
);
});
@@ -0,0 +1,50 @@
export interface SftpRememberedLocation {
hostId: string;
connectionKey: string;
path: string;
}
export function getSftpReopenMemoryKey(params: {
tabId: string;
sourceSessionId?: string | null;
}): string {
return params.sourceSessionId || params.tabId;
}
export function getSftpCurrentPathMemoryKey(params: {
tabId: string;
activeTerminalSessionIdForSftp?: string | null;
focusedSessionId?: string | null;
}): string {
return params.activeTerminalSessionIdForSftp || params.focusedSessionId || params.tabId;
}
export function resolveSftpOpenLocation(params: {
hostId: string;
connectionKey: string;
terminalCwd?: string;
explicitTargetPath?: string;
hasPendingUpload?: boolean;
remembered?: SftpRememberedLocation | null;
}): string | undefined {
const { hostId, connectionKey, terminalCwd, explicitTargetPath, hasPendingUpload, remembered } = params;
if (explicitTargetPath) {
return explicitTargetPath;
}
if (hasPendingUpload) {
return terminalCwd && terminalCwd.length > 0 ? terminalCwd : undefined;
}
if (
remembered &&
remembered.hostId === hostId &&
remembered.connectionKey === connectionKey &&
remembered.path
) {
return remembered.path;
}
return terminalCwd && terminalCwd.length > 0 ? terminalCwd : undefined;
}
@@ -2,7 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import { buildSftpHostCredentials } from "./useSftpHostCredentials.ts";
import type { Host, KnownHost, SSHKey } from "../../../domain/models.ts";
import type { Host, Identity, KnownHost, SSHKey } from "../../../domain/models.ts";
const host = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
@@ -52,6 +52,129 @@ test("buildSftpHostCredentials rejects missing saved proxy profiles on jump host
);
});
test("buildSftpHostCredentials rejects missing proxy identities", () => {
assert.throws(
() => buildSftpHostCredentials({
host: host({
proxyConfig: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "missing-identity",
},
}),
hosts: [],
keys: [],
identities: [],
}),
/Proxy identity for "Host" is missing/,
);
});
test("buildSftpHostCredentials rejects incomplete proxy identities", () => {
assert.throws(
() => buildSftpHostCredentials({
host: host({
proxyConfig: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "identity-1",
},
}),
hosts: [],
keys: [],
identities: [{
id: "identity-1",
label: "Proxy login",
username: "proxy-user",
authMethod: "password",
created: 1,
}],
}),
/Proxy identity for "Host" is incomplete/,
);
});
test("buildSftpHostCredentials rejects proxy identities with blank usernames even when passwords are encrypted", () => {
assert.throws(
() => buildSftpHostCredentials({
host: host({
proxyConfig: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "identity-1",
},
}),
hosts: [],
keys: [],
identities: [{
id: "identity-1",
label: "Proxy login",
username: " ",
authMethod: "password",
password: "enc:v1:djEwAAAA",
created: 1,
}],
}),
/Proxy identity for "Host" is incomplete/,
);
});
test("buildSftpHostCredentials rejects missing proxy identities on jump hosts", () => {
const jumpHost = host({
id: "jump-1",
label: "Jump",
proxyConfig: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "missing-identity",
},
});
assert.throws(
() => buildSftpHostCredentials({
host: host({ hostChain: { hostIds: ["jump-1"] } }),
hosts: [jumpHost],
keys: [],
identities: [],
}),
/Proxy identity for "Jump" is missing/,
);
});
test("buildSftpHostCredentials rejects incomplete proxy identities on jump hosts", () => {
const jumpHost = host({
id: "jump-1",
label: "Jump",
proxyConfig: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "identity-1",
},
});
assert.throws(
() => buildSftpHostCredentials({
host: host({ hostChain: { hostIds: ["jump-1"] } }),
hosts: [jumpHost],
keys: [],
identities: [{
id: "identity-1",
label: "Proxy login",
username: "",
authMethod: "password",
password: "enc:v1:djEwAAAA",
created: 1,
}],
}),
/Proxy identity for "Jump" is incomplete/,
);
});
test("buildSftpHostCredentials forwards custom ProxyCommand settings", () => {
const credentials = buildSftpHostCredentials({
host: host({
@@ -77,6 +200,130 @@ test("buildSftpHostCredentials forwards custom ProxyCommand settings", () => {
});
});
test("buildSftpHostCredentials resolves proxy credentials from a selected identity", () => {
const identity: Identity = {
id: "identity-1",
label: "Proxy login",
username: "proxy-user",
authMethod: "password",
password: "proxy-secret",
created: 1,
};
const credentials = buildSftpHostCredentials({
host: host({
proxyConfig: {
type: "socks5",
host: "proxy.example.com",
port: 1080,
identityId: identity.id,
},
}),
hosts: [],
keys: [],
identities: [identity],
});
assert.deepEqual(credentials.proxy, {
type: "socks5",
host: "proxy.example.com",
port: 1080,
username: "proxy-user",
password: "proxy-secret",
});
});
test("buildSftpHostCredentials rejects undecryptable proxy identity passwords", () => {
assert.throws(
() => buildSftpHostCredentials({
host: host({
proxyConfig: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "identity-1",
},
}),
hosts: [],
keys: [],
identities: [{
id: "identity-1",
label: "Proxy login",
username: "proxy-user",
authMethod: "password",
password: "enc:v1:djEwAAAA",
created: 1,
}],
}),
/Proxy credentials cannot be decrypted/,
);
});
test("buildSftpHostCredentials resolves jump host proxy credentials from a selected identity", () => {
const identity: Identity = {
id: "identity-1",
label: "Proxy login",
username: "proxy-user",
authMethod: "password",
password: "proxy-secret",
created: 1,
};
const jumpHost = host({
id: "jump-1",
label: "Jump",
proxyConfig: {
type: "socks5",
host: "jump-proxy.example.com",
port: 1080,
identityId: identity.id,
},
});
const credentials = buildSftpHostCredentials({
host: host({ hostChain: { hostIds: ["jump-1"] } }),
hosts: [jumpHost],
keys: [],
identities: [identity],
});
assert.deepEqual(credentials.jumpHosts?.[0]?.proxy, {
type: "socks5",
host: "jump-proxy.example.com",
port: 1080,
username: "proxy-user",
password: "proxy-secret",
});
});
test("buildSftpHostCredentials rejects undecryptable jump host proxy identity passwords", () => {
const jumpHost = host({
id: "jump-1",
label: "Jump",
proxyConfig: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "identity-1",
},
});
assert.throws(
() => buildSftpHostCredentials({
host: host({ hostChain: { hostIds: ["jump-1"] } }),
hosts: [jumpHost],
keys: [],
identities: [{
id: "identity-1",
label: "Proxy login",
username: "proxy-user",
authMethod: "password",
password: "enc:v1:djEwAAAA",
created: 1,
}],
}),
/Proxy credentials for jump host "Jump" cannot be decrypted/,
);
});
test("buildSftpHostCredentials passes reference keys as identity file paths", () => {
const key: SSHKey = {
id: "key-1",
@@ -3,7 +3,15 @@ import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from "../../
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
import { resolveBridgeKeyAuth, resolveHostAuth } from "../../../domain/sshAuth";
import { resolveHostKeepalive } from "../../../domain/host";
import { hasUsableProxyConfig } from "../../../domain/proxyProfiles";
import {
findIncompleteProxyIdentityId,
findMissingProxyIdentityId,
formatIncompleteProxyIdentityMessage,
formatMissingProxyIdentityMessage,
hasUnreadableProxyCredential,
hasUsableProxyConfig,
resolveProxyConfigAuth,
} from "../../../domain/proxyProfiles";
// Fallback used when no global TerminalSettings are wired through (older
// call sites or tests). Matches DEFAULT_TERMINAL_SETTINGS so behavior is
@@ -34,19 +42,18 @@ export const buildSftpHostCredentials = ({
if (host.proxyProfileId && !host.proxyConfig) {
throw new Error(`Saved proxy for host "${host.label || host.hostname}" is missing. Open host settings and select a valid proxy.`);
}
if (findMissingProxyIdentityId(host.proxyConfig, identities)) {
throw new Error(formatMissingProxyIdentityMessage(host.label || host.hostname));
}
if (findIncompleteProxyIdentityId(host.proxyConfig, identities)) {
throw new Error(formatIncompleteProxyIdentityMessage(host.label || host.hostname));
}
const resolved = resolveHostAuth({ host, keys, identities });
const key = resolved.key || null;
const proxyConfig = host.proxyConfig
? {
type: host.proxyConfig.type,
host: host.proxyConfig.host,
port: host.proxyConfig.port,
command: host.proxyConfig.command,
username: host.proxyConfig.username,
password: sanitizeCredentialValue(host.proxyConfig.password),
}
? resolveProxyConfigAuth(host.proxyConfig, identities)
: undefined;
let jumpHosts: NetcattyJumpHost[] | undefined;
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
@@ -58,6 +65,12 @@ export const buildSftpHostCredentials = ({
if (jumpHost.proxyProfileId && !jumpHost.proxyConfig) {
throw new Error(`Saved proxy for jump host "${jumpHost.label || jumpHost.hostname}" is missing. Open host settings and select a valid proxy.`);
}
if (findMissingProxyIdentityId(jumpHost.proxyConfig, identities)) {
throw new Error(formatMissingProxyIdentityMessage(jumpHost.label || jumpHost.hostname));
}
if (findIncompleteProxyIdentityId(jumpHost.proxyConfig, identities)) {
throw new Error(formatIncompleteProxyIdentityMessage(jumpHost.label || jumpHost.hostname));
}
return jumpHost;
}).map((jumpHost, index) => {
const jumpAuth = resolveHostAuth({
@@ -80,9 +93,7 @@ export const buildSftpHostCredentials = ({
hasUsableProxyConfig(jumpHost.proxyConfig);
if (
hasConfiguredJumpProxyEndpoint &&
jumpHost.proxyConfig?.username &&
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig.password) &&
!sanitizeCredentialValue(jumpHost.proxyConfig.password)
hasUnreadableProxyCredential(jumpHost.proxyConfig, identities)
) {
throw new Error(`Proxy credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter the proxy password.`);
}
@@ -110,14 +121,7 @@ export const buildSftpHostCredentials = ({
keySource: jumpKey?.source,
label: jumpHost.label,
proxy: hasUsableProxyConfig(jumpHost.proxyConfig)
? {
type: jumpHost.proxyConfig.type,
host: jumpHost.proxyConfig.host,
port: jumpHost.proxyConfig.port,
command: jumpHost.proxyConfig.command,
username: jumpHost.proxyConfig.username,
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
}
? resolveProxyConfigAuth(jumpHost.proxyConfig, identities)
: undefined,
identityFilePaths: jumpKeyAuth.identityFilePaths,
keepaliveInterval: hopKeepalive.interval,
@@ -130,7 +134,7 @@ export const buildSftpHostCredentials = ({
});
}
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts?.[0]?.proxy;
if (usesTargetProxyForFirstHop && host.proxyConfig?.username && isEncryptedCredentialPlaceholder(host.proxyConfig.password) && !proxyConfig?.password) {
if (usesTargetProxyForFirstHop && hasUnreadableProxyCredential(host.proxyConfig, identities)) {
throw new Error("Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.");
}
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getSidePanelLiveSnapshot,
sidePanelLiveStore,
SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
} from './sidePanelLiveStore.ts';
test('sidePanelLiveStore skips notify when snapshot is unchanged', () => {
let notifications = 0;
const unsubscribe = sidePanelLiveStore.subscribe(() => {
notifications += 1;
});
const snapshot = {
...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
activeTerminalCwd: '/tmp',
};
sidePanelLiveStore.update(snapshot);
sidePanelLiveStore.update({ ...snapshot });
unsubscribe();
assert.equal(notifications, 1);
});
test('getSidePanelLiveSnapshot returns inactive snapshot when disabled', () => {
sidePanelLiveStore.update({
...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
activeTerminalCwd: '/var/log',
});
assert.equal(getSidePanelLiveSnapshot(false), SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT);
assert.equal(getSidePanelLiveSnapshot(true).activeTerminalCwd, '/var/log');
});
+101
View File
@@ -0,0 +1,101 @@
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
export type SidePanelLiveSnapshot = {
sftpActiveHost: Host | null;
activeTerminalSessionIdForSftp: string | null;
activeTerminalCwd: string | null;
activeWorkspace: Workspace | undefined;
activeTerminalSessionForSystem: TerminalSession | null;
activeSystemSessionHost: Host | null;
focusedHost: Host | null;
focusedSessionId: string | null;
historySessionId: string | null;
resolvedPreviewTheme: TerminalTheme | null;
previewedOrVisibleThemeId: string | undefined;
focusedFontFamilyId: string | undefined;
focusedFontFamilyOverridden: boolean;
focusedFontSize: number | undefined;
focusedFontSizeOverridden: boolean;
focusedFontWeight: number | undefined;
focusedFontWeightOverridden: boolean;
focusedThemeOverridden: boolean;
};
const EMPTY_SNAPSHOT: SidePanelLiveSnapshot = {
sftpActiveHost: null,
activeTerminalSessionIdForSftp: null,
activeTerminalCwd: null,
activeWorkspace: undefined,
activeTerminalSessionForSystem: null,
activeSystemSessionHost: null,
focusedHost: null,
focusedSessionId: null,
historySessionId: null,
resolvedPreviewTheme: null,
previewedOrVisibleThemeId: undefined,
focusedFontFamilyId: undefined,
focusedFontFamilyOverridden: false,
focusedFontSize: undefined,
focusedFontSizeOverridden: false,
focusedFontWeight: undefined,
focusedFontWeightOverridden: false,
focusedThemeOverridden: false,
};
export const SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT = EMPTY_SNAPSHOT;
type Listener = () => void;
function liveSnapshotEqual(a: SidePanelLiveSnapshot, b: SidePanelLiveSnapshot): boolean {
return a.sftpActiveHost === b.sftpActiveHost
&& a.activeTerminalSessionIdForSftp === b.activeTerminalSessionIdForSftp
&& a.activeTerminalCwd === b.activeTerminalCwd
&& a.activeWorkspace === b.activeWorkspace
&& a.activeTerminalSessionForSystem === b.activeTerminalSessionForSystem
&& a.activeSystemSessionHost === b.activeSystemSessionHost
&& a.focusedHost === b.focusedHost
&& a.focusedSessionId === b.focusedSessionId
&& a.historySessionId === b.historySessionId
&& a.resolvedPreviewTheme === b.resolvedPreviewTheme
&& a.previewedOrVisibleThemeId === b.previewedOrVisibleThemeId
&& a.focusedFontFamilyId === b.focusedFontFamilyId
&& a.focusedFontFamilyOverridden === b.focusedFontFamilyOverridden
&& a.focusedFontSize === b.focusedFontSize
&& a.focusedFontSizeOverridden === b.focusedFontSizeOverridden
&& a.focusedFontWeight === b.focusedFontWeight
&& a.focusedFontWeightOverridden === b.focusedFontWeightOverridden
&& a.focusedThemeOverridden === b.focusedThemeOverridden;
}
class SidePanelLiveStore {
private snapshot: SidePanelLiveSnapshot = EMPTY_SNAPSHOT;
private listeners = new Set<Listener>();
getSnapshot = (): SidePanelLiveSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
update(next: SidePanelLiveSnapshot): void {
if (liveSnapshotEqual(this.snapshot, next)) return;
this.snapshot = next;
for (const listener of this.listeners) {
listener();
}
}
}
export const sidePanelLiveStore = new SidePanelLiveStore();
const noopSubscribe = () => () => {};
export function subscribeSidePanelLiveSnapshot(enabled: boolean, listener: Listener): () => void {
if (!enabled) return noopSubscribe();
return sidePanelLiveStore.subscribe(listener);
}
export function getSidePanelLiveSnapshot(enabled: boolean): SidePanelLiveSnapshot {
return enabled ? sidePanelLiveStore.getSnapshot() : SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT;
}
+60 -1
View File
@@ -1,11 +1,13 @@
import { useEffect, type MutableRefObject } from 'react';
import { useEffect, useRef, 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,
STORAGE_KEY_APP_ICON_VARIANT,
} from '../../infrastructure/config/storageKeys';
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
@@ -15,10 +17,12 @@ interface UseSystemSettingsEffectsParams {
globalHotkeyEnabled: boolean;
closeToTray: boolean;
windowOpacity: number;
appIconVariant: AppIconVariant;
autoUpdateEnabled: boolean;
persistMountedRef: MutableRefObject<boolean>;
setHotkeyRegistrationError: (error: string | null) => void;
setAutoUpdateEnabled: (enabled: boolean | ((prev: boolean) => boolean)) => void;
setAppIconVariant: (variant: AppIconVariant | ((prev: AppIconVariant) => AppIconVariant)) => void;
notifySettingsChanged: (key: string, value: unknown) => void;
}
@@ -28,12 +32,16 @@ export function useSystemSettingsEffects({
globalHotkeyEnabled,
closeToTray,
windowOpacity,
appIconVariant,
autoUpdateEnabled,
persistMountedRef,
setHotkeyRegistrationError,
setAutoUpdateEnabled,
setAppIconVariant,
notifySettingsChanged,
}: UseSystemSettingsEffectsParams) {
const appIconApplyRequestIdRef = useRef(0);
// Persist and sync toggle window hotkey setting
useEffect(() => {
if (!enabled) return;
@@ -110,6 +118,57 @@ export function useSystemSettingsEffects({
notifySettingsChanged(STORAGE_KEY_WINDOW_OPACITY, windowOpacity);
}, [enabled, windowOpacity, notifySettingsChanged, persistMountedRef]);
// Persist and sync app icon variant
useEffect(() => {
if (!enabled) return;
const storedBefore = resolveAppIconVariant(
localStorageAdapter.readString(STORAGE_KEY_APP_ICON_VARIANT) ?? '',
);
localStorageAdapter.writeString(STORAGE_KEY_APP_ICON_VARIANT, appIconVariant);
if (!persistMountedRef.current) {
// Still apply on initial mount before cross-window notify is enabled.
} else {
notifySettingsChanged(STORAGE_KEY_APP_ICON_VARIANT, appIconVariant);
}
const bridge = netcattyBridge.get();
if (!bridge?.setAppIconVariant) return;
const requestId = ++appIconApplyRequestIdRef.current;
let cancelled = false;
const revertVariant = () => {
localStorageAdapter.writeString(STORAGE_KEY_APP_ICON_VARIANT, storedBefore);
if (appIconVariant !== storedBefore) {
setAppIconVariant(storedBefore);
}
if (persistMountedRef.current) {
notifySettingsChanged(STORAGE_KEY_APP_ICON_VARIANT, storedBefore);
}
};
void bridge.setAppIconVariant(appIconVariant)
.then((applied) => {
if (cancelled || requestId !== appIconApplyRequestIdRef.current) return;
if (applied === false && storedBefore !== appIconVariant) {
console.warn('[AppIcon] Failed to apply app icon variant:', appIconVariant);
revertVariant();
}
})
.catch((err) => {
if (cancelled || requestId !== appIconApplyRequestIdRef.current) return;
if (storedBefore !== appIconVariant) {
console.warn('[AppIcon] Failed to apply app icon variant:', err);
revertVariant();
}
});
return () => {
cancelled = true;
};
}, [enabled, appIconVariant, notifySettingsChanged, persistMountedRef, setAppIconVariant]);
// 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.
+16 -8
View File
@@ -26,7 +26,11 @@ import type {
ProviderConfig,
WebSearchConfig,
} from '../../infrastructure/ai/types';
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
import {
DEFAULT_COMMAND_BLOCKLIST,
DEFAULT_COMMAND_TIMEOUT_SECONDS,
normalizeCommandTimeoutSeconds,
} from '../../infrastructure/ai/types';
import { removeProviderReferences } from './aiProviderCleanup';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
import { getAIBridge } from './aiStateSnapshots';
@@ -66,7 +70,9 @@ export function useAISettingsState() {
localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST]
);
const [commandTimeout, setCommandTimeoutRaw] = useState<number>(() =>
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60
normalizeCommandTimeoutSeconds(
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
)
);
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
@@ -163,9 +169,10 @@ export function useAISettingsState() {
}, []);
const setCommandTimeout = useCallback((value: number) => {
setCommandTimeoutRaw(value);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, value);
getAIBridge()?.aiMcpSetCommandTimeout?.(value);
const normalizedValue = normalizeCommandTimeoutSeconds(value);
setCommandTimeoutRaw(normalizedValue);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, normalizedValue);
getAIBridge()?.aiMcpSetCommandTimeout?.(normalizedValue);
}, []);
const setMaxIterations = useCallback((value: number) => {
@@ -235,10 +242,11 @@ export function useAISettingsState() {
break;
}
case STORAGE_KEY_AI_COMMAND_TIMEOUT: {
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60;
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS;
if (!Number.isFinite(timeout)) break;
setCommandTimeoutRaw(timeout);
getAIBridge()?.aiMcpSetCommandTimeout?.(timeout);
const normalizedTimeout = normalizeCommandTimeoutSeconds(timeout);
setCommandTimeoutRaw(normalizedTimeout);
getAIBridge()?.aiMcpSetCommandTimeout?.(normalizedTimeout);
break;
}
case STORAGE_KEY_AI_MAX_ITERATIONS: {
+19 -9
View File
@@ -33,7 +33,11 @@ import type {
AISessionScope,
WebSearchConfig,
} from '../../infrastructure/ai/types';
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
import {
DEFAULT_COMMAND_BLOCKLIST,
DEFAULT_COMMAND_TIMEOUT_SECONDS,
normalizeCommandTimeoutSeconds,
} from '../../infrastructure/ai/types';
import {
activateDraftView,
clearScopeDraftState,
@@ -107,7 +111,9 @@ export function useAIState() {
localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST]
);
const [commandTimeout, setCommandTimeoutRaw] = useState<number>(() =>
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60
normalizeCommandTimeoutSeconds(
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
)
);
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
@@ -361,11 +367,12 @@ export function useAIState() {
}, []);
const setCommandTimeout = useCallback((value: number) => {
setCommandTimeoutRaw(value);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, value);
const normalizedValue = normalizeCommandTimeoutSeconds(value);
setCommandTimeoutRaw(normalizedValue);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, normalizedValue);
// Sync to MCP Server bridge
const bridge = getAIBridge();
bridge?.aiMcpSetCommandTimeout?.(value);
bridge?.aiMcpSetCommandTimeout?.(normalizedValue);
}, []);
const setMaxIterations = useCallback((value: number) => {
@@ -438,13 +445,14 @@ export function useAIState() {
break;
}
case STORAGE_KEY_AI_COMMAND_TIMEOUT: {
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60;
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS;
if (!Number.isFinite(timeout)) {
console.warn('[useAIState] Cross-window sync: AI_COMMAND_TIMEOUT is not a finite number, skipping');
break;
}
setCommandTimeoutRaw(timeout);
getAIBridge()?.aiMcpSetCommandTimeout?.(timeout);
const normalizedTimeout = normalizeCommandTimeoutSeconds(timeout);
setCommandTimeoutRaw(normalizedTimeout);
getAIBridge()?.aiMcpSetCommandTimeout?.(normalizedTimeout);
break;
}
case STORAGE_KEY_AI_MAX_ITERATIONS: {
@@ -539,7 +547,9 @@ export function useAIState() {
const bridge = getAIBridge();
const initialBlocklist = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST];
bridge?.aiMcpSetCommandBlocklist?.(initialBlocklist);
const initialTimeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60;
const initialTimeout = normalizeCommandTimeoutSeconds(
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
);
bridge?.aiMcpSetCommandTimeout?.(initialTimeout);
const initialMaxIter = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
bridge?.aiMcpSetMaxIterations?.(initialMaxIter);
@@ -61,7 +61,7 @@ test("chrome layout animations wait until theme settle frames complete", () => {
cancel();
});
test("syncActiveChromeTheme refreshes top tabs when the active theme fingerprint is unchanged", () => {
test("syncActiveChromeTheme skips surface refresh when the active theme fingerprint is unchanged", () => {
const globalWithDocument = globalThis as typeof globalThis & { document?: Document };
const originalDocument = globalWithDocument.document;
const theme = TERMINAL_THEMES[0];
@@ -83,9 +83,9 @@ test("syncActiveChromeTheme refreshes top tabs when the active theme fingerprint
throw new Error("app theme should not be restored for an unchanged active chrome theme");
});
assert.notEqual(topTabsRoot.style.getPropertyValue("--top-tabs-bg"), "");
assert.notEqual(topTabsRoot.style.getPropertyValue("--top-tabs-active-bg"), "");
assert.notEqual(topTabsRoot.style.getPropertyValue("--top-tabs-accent"), "");
assert.equal(topTabsRoot.style.getPropertyValue("--top-tabs-bg"), "");
assert.equal(topTabsRoot.style.getPropertyValue("--top-tabs-active-bg"), "");
assert.equal(topTabsRoot.style.getPropertyValue("--top-tabs-accent"), "");
} finally {
if (originalDocument) {
globalWithDocument.document = originalDocument;
@@ -228,11 +228,6 @@ export function syncActiveChromeTheme(
const nextFingerprint = activeTheme ? themeFingerprint(activeTheme) : null;
const appliedFingerprint = getAppliedChromeFingerprint();
if (nextFingerprint === appliedFingerprint) {
if (activeTheme) {
refreshActiveChromeThemeSurfaces(activeTheme);
} else {
clearTopTabsChromeThemeVars();
}
return;
}
+9 -4
View File
@@ -148,7 +148,7 @@ export function useAgentDiscovery(
);
if (!match) return ea;
// Check if args, SDK backend, or Claude's resolved system path differ
// Check if args, SDK backend, or managed SDK path env differ.
const currentArgs = JSON.stringify(ea.args || []);
const newArgs = JSON.stringify(match.args);
const backend = match.sdkBackend ?? match.command;
@@ -158,9 +158,12 @@ export function useAgentDiscovery(
const matchPath = match.binPath || match.path;
const env = match.command === 'claude'
? { ...(ea.env ?? {}), CLAUDE_CODE_EXECUTABLE: matchPath }
: ea.env;
const envChanged = match.command === 'claude'
&& ea.env?.CLAUDE_CODE_EXECUTABLE !== matchPath;
: match.command === 'opencode'
? { ...(ea.env ?? {}), OPENCODE_BIN: matchPath }
: ea.env;
const envChanged =
(match.command === 'claude' && ea.env?.CLAUDE_CODE_EXECUTABLE !== matchPath)
|| (match.command === 'opencode' && ea.env?.OPENCODE_BIN !== matchPath);
if (currentArgs !== newArgs || backendChanged || envChanged) {
changed = true;
const { acpCommand: _legacyCommand, acpArgs: _legacyArgs, ...rest } = ea;
@@ -193,6 +196,8 @@ export function useAgentDiscovery(
sdkBackend: backend,
...(agent.command === 'claude'
? { env: { CLAUDE_CODE_EXECUTABLE: agent.binPath || agent.path || '' } }
: agent.command === 'opencode'
? { env: { OPENCODE_BIN: agent.binPath || agent.path || '' } }
: {}),
};
},
+39
View File
@@ -385,6 +385,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
lastSyncedDataRef.current = dataHash;
manager.setPendingLocalSync(false);
// Successful sync implies a successful per-provider
// `checkProviderConflict` (which inspects remote) — equivalent
@@ -459,6 +460,28 @@ export const useAutoSync = (config: AutoSyncConfig) => {
getDataHashRef.current = getDataHash;
}, [getDataHash]);
const refreshPendingLocalSync = useCallback(async () => {
if (!enabled || !sync.hasAnyConnectedProvider || !sync.isUnlocked) {
manager.setPendingLocalSync(false);
return;
}
if (!remoteCheckDoneRef.current) {
return;
}
const currentHash = await getDataHashRef.current();
const hashDecision = resolveAutoSyncHashDecision({
currentHash,
lastSyncedHash: lastSyncedDataRef.current,
appliedSkipHash: skipNextSyncHashRef.current,
});
manager.setPendingLocalSync(hashDecision === 'sync');
}, [enabled, sync.hasAnyConnectedProvider, sync.isUnlocked]);
const refreshPendingLocalSyncRef = useRef(refreshPendingLocalSync);
useEffect(() => {
refreshPendingLocalSyncRef.current = refreshPendingLocalSync;
}, [refreshPendingLocalSync]);
// Serialize `checkRemoteVersion` invocations. Overlapping runs would
// race on `commitRemoteInspection` + `onApplyPayload`: two merges
// could both write-then-clear the apply-in-progress sentinel around
@@ -713,6 +736,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// unlock/startupReady transition, and a manual sync from
// Settings remains available as an escape hatch.
remoteCheckDoneRef.current = true;
await refreshPendingLocalSyncRef.current();
}
checkRemoteInFlightRef.current = false;
}
@@ -843,6 +867,21 @@ export const useAutoSync = (config: AutoSyncConfig) => {
bookmarksVersion,
syncableSettingsStorageVersion,
]);
// Reflect unsynced local edits in the top-bar cloud indicator.
useEffect(() => {
void refreshPendingLocalSync();
}, [
refreshPendingLocalSync,
getDataHash,
sync.hasAnyConnectedProvider,
sync.isUnlocked,
sync.isSyncing,
config.settingsVersion,
enabled,
bookmarksVersion,
syncableSettingsStorageVersion,
]);
// Check remote version on startup/unlock, then retry with backoff
// while the inspect keeps failing. Without the timer-based retry,
+2
View File
@@ -58,6 +58,7 @@ export interface CloudSyncHook {
remoteVersion: number;
remoteUpdatedAt: number;
syncHistory: SyncHistoryEntry[];
pendingLocalSync: boolean;
pendingBrowserAuthProvider: 'google' | 'onedrive' | null;
// Computed
@@ -731,6 +732,7 @@ export const useCloudSync = (): CloudSyncHook => {
remoteVersion: state.remoteVersion,
remoteUpdatedAt: state.remoteUpdatedAt,
syncHistory: state.syncHistory,
pendingLocalSync: state.pendingLocalSync,
pendingBrowserAuthProvider: pendingBrowserAuth?.provider ?? null,
// Computed
@@ -0,0 +1,202 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { startMainWindowInputFocusRecovery } from "./useMainWindowInputFocusRecovery.ts";
const root = fileURLToPath(new URL("../..", import.meta.url));
function readProjectFile(path: string): string {
return readFileSync(join(root, path), "utf8");
}
type Listener = () => void;
function createEventTargetStub() {
const listeners = new Map<string, Set<Listener>>();
return {
addEventListener(eventName: string, listener: Listener) {
const eventListeners = listeners.get(eventName) ?? new Set<Listener>();
eventListeners.add(listener);
listeners.set(eventName, eventListeners);
},
removeEventListener(eventName: string, listener: Listener) {
listeners.get(eventName)?.delete(listener);
},
dispatch(eventName: string) {
for (const listener of listeners.get(eventName) ?? []) {
listener();
}
},
listenerCount(eventName: string) {
return listeners.get(eventName)?.size ?? 0;
},
};
}
test("main-window input focus recovery ignores generic focus and visible visibility changes", () => {
let visibilityState: DocumentVisibilityState = "visible";
let scheduleFocusCalls = 0;
let cancelFocusCalls = 0;
let hiddenCalls = 0;
let shownHandler: Listener | null = null;
let willHideHandler: Listener | null = null;
const documentTarget = createEventTargetStub();
const windowTarget = createEventTargetStub();
const cleanup = startMainWindowInputFocusRecovery(
{ onPageHidden: () => { hiddenCalls += 1; } },
{
documentRef: {
...documentTarget,
get visibilityState() {
return visibilityState;
},
},
windowRef: windowTarget,
bridge: {
onWindowShown(cb: Listener) {
shownHandler = cb;
return () => { shownHandler = null; };
},
onWindowWillHide(cb: Listener) {
willHideHandler = cb;
return () => { willHideHandler = null; };
},
},
scheduleFocus: () => {
scheduleFocusCalls += 1;
return { cancel: () => { cancelFocusCalls += 1; } };
},
},
);
assert.equal(windowTarget.listenerCount("focus"), 0);
windowTarget.dispatch("focus");
documentTarget.dispatch("visibilitychange");
assert.equal(scheduleFocusCalls, 0);
assert.equal(hiddenCalls, 0);
shownHandler?.();
assert.equal(scheduleFocusCalls, 1);
willHideHandler?.();
assert.equal(hiddenCalls, 1);
assert.equal(cancelFocusCalls, 1);
shownHandler?.();
assert.equal(scheduleFocusCalls, 2);
visibilityState = "hidden";
documentTarget.dispatch("visibilitychange");
assert.equal(hiddenCalls, 2);
assert.equal(cancelFocusCalls, 2);
cleanup();
});
test("main-window input focus recovery retries an explicit show when visibility catches up", () => {
let visibilityState: DocumentVisibilityState = "hidden";
let scheduleFocusCalls = 0;
let hiddenCalls = 0;
let shownHandler: Listener | null = null;
const documentTarget = createEventTargetStub();
const cleanup = startMainWindowInputFocusRecovery(
{ onPageHidden: () => { hiddenCalls += 1; } },
{
documentRef: {
...documentTarget,
get visibilityState() {
return visibilityState;
},
},
bridge: {
onWindowShown(cb: Listener) {
shownHandler = cb;
return () => { shownHandler = null; };
},
},
scheduleFocus: () => {
scheduleFocusCalls += 1;
return { cancel: () => undefined };
},
},
);
shownHandler?.();
assert.equal(scheduleFocusCalls, 0);
visibilityState = "visible";
documentTarget.dispatch("visibilitychange");
assert.equal(scheduleFocusCalls, 1);
assert.equal(hiddenCalls, 0);
documentTarget.dispatch("visibilitychange");
assert.equal(scheduleFocusCalls, 1);
cleanup();
});
test("useMainWindowInputFocusRecovery restores input focus only after explicit window-shown IPC", () => {
const source = readProjectFile("application/state/useMainWindowInputFocusRecovery.ts");
assert.match(source, /visibilityState !== "visible"/);
assert.match(source, /scheduleFocus\(\)/);
assert.match(source, /onWindowShown\?\.\(\(\) => \{\s*pendingExplicitShowRecovery = true;\s*recoverFocus\(\);\s*\}\)/);
assert.doesNotMatch(source, /window\.addEventListener\("focus"/);
assert.doesNotMatch(source, /window\.removeEventListener\("focus"/);
});
test("useMainWindowInputFocusRecovery uses visibility changes only to dismiss transient UI", () => {
const source = readProjectFile("application/state/useMainWindowInputFocusRecovery.ts");
const handlerStart = source.indexOf("const onVisibilityChange");
const handlerEnd = source.indexOf('addEventListener("visibilitychange"', handlerStart);
const visibilityHandler = source.slice(handlerStart, handlerEnd);
assert.match(source, /visibilityState === "hidden"/);
assert.match(source, /addEventListener\("visibilitychange", onVisibilityChange\)/);
assert.match(source, /onPageHidden\?\.\(\)/);
assert.match(source, /onWindowWillHide/);
assert.match(source, /cancelPendingFocusRecovery/);
assert.match(visibilityHandler, /dismissTransientUi\(\)/);
assert.doesNotMatch(visibilityHandler, /else\s*\{\s*recoverFocus\(\);\s*\}/);
assert.doesNotMatch(visibilityHandler, /visibilityState !== "hidden"[\s\S]*recoverFocus\(\)/);
});
test("scheduleWindowInputFocus skips deferred focus when the page is hidden", () => {
const source = readProjectFile("application/state/windowInputFocus.ts");
assert.match(source, /document\.visibilityState !== "visible"/);
assert.match(source, /cancelAnimationFrame/);
assert.match(source, /clearTimeout/);
});
test("AppView mounts main-window input focus recovery with overlay dismiss", () => {
const source = readProjectFile("application/app/AppView.tsx");
assert.match(source, /useMainWindowInputFocusRecovery\(\{ onPageHidden: dismissTransientOverlays \}\)/);
assert.match(source, /setIsQuickSwitcherOpen\(false\)/);
assert.match(source, /setProtocolSelectHost\(null\)/);
});
test("dropdown closes when the document becomes hidden", () => {
const source = readProjectFile("components/ui/dropdown.tsx");
assert.match(source, /document\.visibilityState === "hidden"/);
assert.match(source, /setOpen\(false\)/);
});
@@ -0,0 +1,118 @@
import { useEffect } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
import {
scheduleWindowInputFocus,
type ScheduledWindowInputFocus,
} from "./windowInputFocus";
export type MainWindowInputFocusRecoveryOptions = {
/** Close transient overlays before the window hides (#1722). */
onPageHidden?: () => void;
};
type Listener = () => void;
type MainWindowInputFocusRecoveryDocument = {
visibilityState: DocumentVisibilityState;
addEventListener: (eventName: "visibilitychange", listener: Listener) => void;
removeEventListener: (eventName: "visibilitychange", listener: Listener) => void;
};
type MainWindowInputFocusRecoveryWindow = {
addEventListener: (eventName: "focus", listener: Listener) => void;
removeEventListener: (eventName: "focus", listener: Listener) => void;
};
type MainWindowInputFocusRecoveryBridge = {
onWindowShown?: (callback: Listener) => Listener;
onWindowWillHide?: (callback: Listener) => Listener;
};
export type MainWindowInputFocusRecoveryDependencies = {
documentRef: MainWindowInputFocusRecoveryDocument;
windowRef?: MainWindowInputFocusRecoveryWindow;
bridge?: MainWindowInputFocusRecoveryBridge;
scheduleFocus?: () => ScheduledWindowInputFocus;
};
export function startMainWindowInputFocusRecovery(
options: MainWindowInputFocusRecoveryOptions = {},
dependencies: MainWindowInputFocusRecoveryDependencies = {
documentRef: document,
windowRef: window,
bridge: netcattyBridge.get(),
scheduleFocus: scheduleWindowInputFocus,
},
): () => void {
const { onPageHidden } = options;
const {
documentRef,
bridge,
scheduleFocus = scheduleWindowInputFocus,
} = dependencies;
let pendingFocusRecovery: ScheduledWindowInputFocus | null = null;
let pendingExplicitShowRecovery = false;
const cancelPendingFocusRecovery = () => {
pendingFocusRecovery?.cancel();
pendingFocusRecovery = null;
};
const recoverFocus = (): boolean => {
if (documentRef.visibilityState !== "visible") return false;
pendingExplicitShowRecovery = false;
cancelPendingFocusRecovery();
pendingFocusRecovery = scheduleFocus();
return true;
};
const dismissTransientUi = () => {
pendingExplicitShowRecovery = false;
cancelPendingFocusRecovery();
onPageHidden?.();
};
const onVisibilityChange = () => {
if (documentRef.visibilityState === "hidden") {
dismissTransientUi();
return;
}
if (pendingExplicitShowRecovery) {
recoverFocus();
}
};
documentRef.addEventListener("visibilitychange", onVisibilityChange);
const unsubscribeShown = bridge?.onWindowShown?.(() => {
pendingExplicitShowRecovery = true;
recoverFocus();
});
const unsubscribeWillHide = bridge?.onWindowWillHide?.(() => {
dismissTransientUi();
});
return () => {
pendingExplicitShowRecovery = false;
cancelPendingFocusRecovery();
documentRef.removeEventListener("visibilitychange", onVisibilityChange);
unsubscribeShown?.();
unsubscribeWillHide?.();
};
}
/**
* Recover OS/renderer input focus when the main process explicitly shows the
* main window (#760, #1714, #1722).
*/
export function useMainWindowInputFocusRecovery(
options: MainWindowInputFocusRecoveryOptions = {},
): void {
const { onPageHidden } = options;
useEffect(() => {
return startMainWindowInputFocusRecovery({ onPageHidden });
}, [onPageHidden]);
}
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const appSource = readFileSync(new URL('../../App.tsx', import.meta.url), 'utf8');
const hookSource = readFileSync(new URL('./useManualTerminalChromeSurfaceInjection.ts', import.meta.url), 'utf8');
const bridgeSource = readFileSync(new URL('../../components/terminalLayer/TerminalLayerTabBridge.tsx', import.meta.url), 'utf8');
const varsSource = readFileSync(new URL('../../infrastructure/theme/terminalAppearanceVars.ts', import.meta.url), 'utf8');
test('manual mode injects chrome surfaces from focused session theme', () => {
assert.match(appSource, /includeChromeSurfaces: followAppTerminalTheme/);
assert.match(bridgeSource, /useManualTerminalChromeSurfaceInjection/);
assert.match(bridgeSource, /!s\.followAppTerminalTheme && isTerminalLayerVisible/);
assert.match(readFileSync(new URL('../app/AppHostTreeLayer.tsx', import.meta.url), 'utf8'), /useManualTerminalChromeSurfaceInjection/);
assert.match(appSource, /resolveSessionAppearance=\{themeRuntime\.resolveFocusedAppearance\}/);
assert.match(hookSource, /applyTopTabsChromeThemeVars\(theme\)/);
assert.match(hookSource, /injectTerminalLayerChromeSurfaceVars\(theme\)/);
assert.match(hookSource, /if \(wasEnabled\) \{[\s\S]*clearTerminalLayerChromeSurfaceVars\(\)/);
assert.doesNotMatch(hookSource, /clearTopTabsChromeThemeVars\(\)/);
assert.match(varsSource, /injectTerminalLayerChromeSurfaceVars/);
assert.match(varsSource, /clearTerminalLayerChromeSurfaceVars/);
});
@@ -0,0 +1,28 @@
import { useLayoutEffect, useRef } from 'react';
import type { TerminalTheme } from '../../domain/models';
import { applyTopTabsChromeThemeVars } from '../app/topTabsChromeTheme';
import {
clearTerminalLayerChromeSurfaceVars,
injectTerminalLayerChromeSurfaceVars,
} from '../../infrastructure/theme/terminalAppearanceVars';
/** Manual mode: side panel + host tree follow the focused session theme, not the global default. */
export function useManualTerminalChromeSurfaceInjection(
theme: TerminalTheme,
enabled: boolean,
): void {
const prevEnabledRef = useRef(enabled);
useLayoutEffect(() => {
const wasEnabled = prevEnabledRef.current;
prevEnabledRef.current = enabled;
if (enabled) {
injectTerminalLayerChromeSurfaceVars(theme);
applyTopTabsChromeThemeVars(theme);
return;
}
if (wasEnabled) {
clearTerminalLayerChromeSurfaceVars();
}
}, [enabled, theme.id, theme]);
}
@@ -0,0 +1,29 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { findMatchEndingAfter } from './useOutputTriggers.ts';
test('findMatchEndingAfter skips stale overlap matches and finds current output', () => {
const text = 'NETCATTY_TRIGGER_PROBE old\r\nprompt# NETCATTY_TRIGGER_PROBE: command not found\r\n';
const minEndOffset = 'NETCATTY_TRIGGER_PROBE old\r\n'.length;
const match = findMatchEndingAfter(text, 'NETCATTY_TRIGGER_PROBE', minEndOffset);
assert.deepEqual(match, {
value: 'NETCATTY_TRIGGER_PROBE',
endOffset: 'NETCATTY_TRIGGER_PROBE old\r\nprompt# NETCATTY_TRIGGER_PROBE'.length,
});
});
test('findMatchEndingAfter supports regex patterns', () => {
const text = 'old done\r\nservice ready: 200\r\n';
const minEndOffset = 'old done\r\n'.length;
const match = findMatchEndingAfter(text, 'ready:\\s+\\d+', minEndOffset);
assert.deepEqual(match, {
value: 'ready: 200',
endOffset: 'old done\r\nservice ready: 200'.length,
});
});
test('findMatchEndingAfter returns null when matches are only stale', () => {
const text = 'NETCATTY_TRIGGER_PROBE old\r\nnew output\r\n';
const minEndOffset = 'NETCATTY_TRIGGER_PROBE old\r\n'.length;
assert.equal(findMatchEndingAfter(text, 'NETCATTY_TRIGGER_PROBE', minEndOffset), null);
});
+132
View File
@@ -0,0 +1,132 @@
import { useCallback, useEffect, useRef } from 'react';
import type { Snippet } from '@/domain/models';
import { snippetAppliesToOutputTrigger } from '@/domain/snippetTargets.ts';
import { isScriptSnippet } from '@/domain/snippetScript.ts';
import { createTerminalOutputTriggerFilter } from '@/domain/terminalOutputTriggerFilter.ts';
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
import { getActiveScriptRunForSession } from '@/application/state/scriptAutomationCoordinator.ts';
type OutputTriggerContext = {
sessionId: string;
hostId?: string;
snippets: Snippet[];
onRunScript: (snippet: Snippet, sessionId: string) => void | Promise<void>;
};
function isSessionScriptRunActive(sessionId: string): boolean {
return Boolean(getActiveScriptRunForSession(sessionId));
}
export function findMatchEndingAfter(text: string, pattern: string, minEndOffset: number): { value: string; endOffset: number } | null {
const source = new RegExp(pattern);
for (let startOffset = 0; startOffset <= text.length;) {
const match = source.exec(text.slice(startOffset));
if (!match || match.index === undefined) return null;
const absoluteStart = startOffset + match.index;
const absoluteEnd = absoluteStart + match[0].length;
if (absoluteEnd > minEndOffset) {
return { value: match[0], endOffset: absoluteEnd };
}
startOffset = Math.max(absoluteStart + 1, absoluteEnd);
}
return null;
}
export function useOutputTriggers({
sessionId,
hostId,
snippets,
onRunScript,
}: OutputTriggerContext) {
const bufferRef = useRef('');
const launchingRef = useRef(false);
const lastTriggerMatchEndRef = useRef(new Map<string, number>());
const serverOutputFilterRef = useRef(createTerminalOutputTriggerFilter());
const scanBuffer = useCallback((recentChunk: string) => {
if (!recentChunk || isSessionScriptRunActive(sessionId) || launchingRef.current) {
return;
}
const text = bufferRef.current;
const overlap = 64;
const chunkWithOverlap = text.slice(Math.max(0, text.length - recentChunk.length - overlap));
const chunkStartInSlice = Math.max(0, chunkWithOverlap.length - recentChunk.length);
const chunkBaseOffset = text.length - chunkWithOverlap.length;
for (const snippet of snippets) {
if (isSessionScriptRunActive(sessionId) || launchingRef.current) {
return;
}
if (!isScriptSnippet(snippet) || snippet.trigger !== 'onOutput' || !snippet.triggerPattern || !snippet.id) {
continue;
}
if (!snippetAppliesToOutputTrigger(snippet, hostId)) continue;
try {
const matched = findMatchEndingAfter(chunkWithOverlap, snippet.triggerPattern, chunkStartInSlice);
if (!matched) {
continue;
}
const matchEnd = chunkBaseOffset + matched.endOffset;
const lastMatchEnd = lastTriggerMatchEndRef.current.get(snippet.id) ?? -1;
if (matchEnd <= lastMatchEnd) {
continue;
}
const matchedSnippetId = snippet.id;
launchingRef.current = true;
lastTriggerMatchEndRef.current.set(matchedSnippetId, matchEnd);
void Promise.resolve(onRunScript(snippet, sessionId))
.catch(() => {
// Failed starts can retry on the next matching output chunk.
})
.finally(() => {
launchingRef.current = false;
});
return;
} catch {
// ignore invalid regex
}
}
}, [hostId, onRunScript, sessionId, snippets]);
const appendOutput = useCallback((chunk: string) => {
if (!chunk) return;
const { scannableText, alternateScreenActive } = serverOutputFilterRef.current.processServerChunk(chunk);
if (!scannableText || alternateScreenActive) {
return;
}
bufferRef.current = (bufferRef.current + scannableText).slice(-8192);
scanBuffer(scannableText);
}, [scanBuffer]);
const noteUserInput = useCallback((data: string) => {
if (!data) return;
serverOutputFilterRef.current.noteUserInput(data);
}, []);
useEffect(() => {
bufferRef.current = '';
launchingRef.current = false;
lastTriggerMatchEndRef.current = new Map();
serverOutputFilterRef.current.reset();
}, [sessionId, hostId]);
return { appendOutput, noteUserInput };
}
export function setupScriptBridgeListeners(
getSnapshot: (sessionId: string) => ReturnType<typeof import('@/infrastructure/scripts/screenSnapshotRegistry.ts').captureScreenSnapshot>,
) {
const disposers: Array<() => void> = [];
disposers.push(
netcattyBridge.get()?.onScriptScreenSnapshotRequest?.(({ requestId, sessionId }) => {
const snapshot = getSnapshot(sessionId);
void netcattyBridge.get()?.scriptScreenSnapshotResponse?.(requestId, snapshot);
}) ?? (() => {}),
);
return () => {
disposers.forEach((dispose) => dispose());
};
}
+52
View File
@@ -0,0 +1,52 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { ScriptRun, ScriptRunParams } from '@/types/global/netcatty-bridge-script.d.ts';
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
export function useScriptExecution() {
const [runs, setRuns] = useState<ScriptRun[]>([]);
const runsRef = useRef(runs);
runsRef.current = runs;
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.scriptGetRuns) return undefined;
bridge.scriptGetRuns().then(setRuns).catch(() => {});
const dispose = bridge.onScriptRunsUpdated?.(({ runs: nextRuns }) => {
setRuns(nextRuns);
});
return dispose;
}, []);
const runScript = useCallback(async (params: ScriptRunParams) => {
const bridge = netcattyBridge.get();
if (!bridge?.scriptRun) {
throw new Error('Script bridge unavailable');
}
return bridge.scriptRun(params);
}, []);
const stopRun = useCallback(async (runId: string) => {
await netcattyBridge.get()?.scriptStop?.(runId);
}, []);
const pauseRun = useCallback(async (runId: string) => {
await netcattyBridge.get()?.scriptPause?.(runId);
}, []);
const resumeRun = useCallback(async (runId: string) => {
await netcattyBridge.get()?.scriptResume?.(runId);
}, []);
const getRunsForSession = useCallback((sessionId: string) => {
return runsRef.current.filter((run) => run.sessionId === sessionId);
}, []);
return {
runs,
runScript,
stopRun,
pauseRun,
resumeRun,
getRunsForSession,
};
}
+125
View File
@@ -0,0 +1,125 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
import { DEFAULT_RECORDING_PROMPT_TIMEOUT_MS } from '@/domain/snippetScript.ts';
import type { ScriptRecordingStep } from '@/types/global/netcatty-bridge-script.d.ts';
export function useScriptRecorder(sessionId: string | undefined) {
const [isRecording, setIsRecording] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [elapsedMs, setElapsedMs] = useState(0);
const startedAtRef = useRef<number | null>(null);
const inputBufferRef = useRef('');
const lastStepAtRef = useRef<number>(Date.now());
const isRecordingRef = useRef(false);
const isPausedRef = useRef(false);
const sessionIdRef = useRef(sessionId);
sessionIdRef.current = sessionId;
isRecordingRef.current = isRecording;
isPausedRef.current = isPaused;
useEffect(() => {
if (!isRecording || isPaused) return undefined;
const timer = window.setInterval(() => {
if (startedAtRef.current) {
setElapsedMs(Date.now() - startedAtRef.current);
}
}, 250);
return () => window.clearInterval(timer);
}, [isRecording, isPaused]);
const startRecording = useCallback(async () => {
const sid = sessionIdRef.current;
const bridge = netcattyBridge.get();
if (!sid || !bridge?.scriptRecordingStart) return;
await bridge.scriptRecordingStart(sid);
startedAtRef.current = Date.now();
lastStepAtRef.current = Date.now();
inputBufferRef.current = '';
setElapsedMs(0);
setIsPaused(false);
isPausedRef.current = false;
isRecordingRef.current = true;
setIsRecording(true);
}, []);
const stopRecording = useCallback(async () => {
const sid = sessionIdRef.current;
const bridge = netcattyBridge.get();
if (!sid || !bridge?.scriptRecordingStop) {
return { steps: [] as ScriptRecordingStep[], code: '' };
}
const result = await bridge.scriptRecordingStop(sid);
isRecordingRef.current = false;
isPausedRef.current = false;
setIsRecording(false);
setIsPaused(false);
startedAtRef.current = null;
return result;
}, []);
const pauseRecording = useCallback(() => {
isPausedRef.current = true;
setIsPaused(true);
}, []);
const resumeRecording = useCallback(() => {
isPausedRef.current = false;
setIsPaused(false);
}, []);
const appendStep = useCallback(async (step: ScriptRecordingStep) => {
const sid = sessionIdRef.current;
if (!sid || !isRecordingRef.current || isPausedRef.current) return;
await netcattyBridge.get()?.scriptRecordingAppendStep?.(sid, step);
lastStepAtRef.current = Date.now();
}, []);
const recordInput = useCallback((data: string) => {
if (!isRecordingRef.current || isPausedRef.current) return;
inputBufferRef.current += data;
}, []);
const recordBackspace = useCallback(() => {
if (!isRecordingRef.current || isPausedRef.current) return;
inputBufferRef.current = inputBufferRef.current.slice(0, -1);
}, []);
const recordClearLine = useCallback(() => {
if (!isRecordingRef.current || isPausedRef.current) return;
inputBufferRef.current = '';
}, []);
const recordEnter = useCallback(async (options?: { sensitive?: boolean }) => {
const sid = sessionIdRef.current;
if (!isRecordingRef.current || isPausedRef.current || !sid) return;
const line = inputBufferRef.current;
inputBufferRef.current = '';
const now = Date.now();
const gap = now - lastStepAtRef.current;
if (gap > 1000) {
await appendStep({ type: 'sleep', value: gap });
}
await appendStep({
type: 'send',
value: line,
sensitive: options?.sensitive,
});
await appendStep({ type: 'waitForPrompt', timeoutMs: DEFAULT_RECORDING_PROMPT_TIMEOUT_MS });
lastStepAtRef.current = now;
}, [appendStep]);
return {
isRecording,
isPaused,
elapsedMs,
startRecording,
stopRecording,
pauseRecording,
resumeRecording,
recordInput,
recordBackspace,
recordClearLine,
recordEnter,
};
}
+7 -3
View File
@@ -2,6 +2,7 @@ import { MouseEvent,useCallback,useEffect,useMemo,useRef,useState } from 'react'
import { ConnectionLog,Host,SerialConfig,Snippet,TerminalSession,Workspace,WorkspaceViewMode } from '../../domain/models';
import { addLogView, getLogViewTabId, removeLogView, type LogView } from './logViewState';
import { createHostTerminalSession, createLocalTerminalSession, createSerialTerminalSession, type LocalTerminalOptions } from './sessionFactories';
import { isScriptSnippet } from '../../domain/snippetScript.ts';
import {
appendPaneToWorkspaceRoot,
collectSessionIds,
@@ -967,9 +968,12 @@ export const useSessionState = ({
const sessionsWithWorkspace = newSessions.map(s => ({
...s,
workspaceId: workspace.id,
// Store the command to run after connection
startupCommand: resolvedCommand,
noAutoRun: snippet.noAutoRun,
...(isScriptSnippet(snippet)
? { pendingScriptId: snippet.id, pendingScript: snippet }
: {
startupCommand: resolvedCommand,
noAutoRun: snippet.noAutoRun,
}),
}));
setSessions(prev => [...prev, ...sessionsWithWorkspace]);
+95 -21
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { runThemeTransition } from './themeTransition';
import { runThemeTransition, type ThemeTransitionMode } from './themeTransition';
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
import {
STORAGE_KEY_COLOR,
@@ -43,12 +43,15 @@ import {
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_APP_ICON_VARIANT,
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
} from '../../infrastructure/config/storageKeys';
@@ -62,8 +65,7 @@ import {
shouldApplyIncomingCustomKeyBindingsRecord,
updateCustomKeyBinding as updateCustomKeyBindingRecord,
} from '../../domain/customKeyBindings';
import { TERMINAL_THEME_AUTO } from '../../domain/terminalAppearance';
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
import { resolveGlobalTerminalAppearance, idleThemeUserIntent } from '../../domain/terminalAppearanceRuntime';
import { DEFAULT_FONT_SIZE, TERMINAL_FONT_AUTO } from '../../infrastructure/config/fonts';
import { getUiThemeById } from '../../infrastructure/config/uiThemes';
import { DEFAULT_UI_FONT_ID, withWindowsEmojiFallback } from '../../infrastructure/config/uiFonts';
@@ -116,9 +118,18 @@ import { resolveRestorePreviousSessionSetting, resolveRestoreTerminalCwdSetting
import { sessionRestoreStorage } from './sessionRestoreStorage';
import { useSettingsStorageSync } from './settingsStorageSync';
import { useSettingsIpcSync } from './settingsIpcSync';
import { resolveCurrentTerminalTheme } from './settingsTerminalTheme';
import { TERMINAL_THEME_AUTO } from '../../domain/terminalAppearance';
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
import { useSystemSettingsEffects } from './systemSettingsEffects';
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
import { DEFAULT_APP_ICON_VARIANT } from '../../infrastructure/config/appIconVariants';
import { applyCustomCssToDocument } from '../../lib/customCss';
import {
DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_ENABLED,
DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
isTerminalSidePanelAutoOpenTab,
type TerminalSidePanelAutoOpenTab,
} from '../../domain/terminalSidePanelAutoOpen';
export const useSettingsState = (options: { enableSettingsSync?: boolean; enableSystemEffects?: boolean } = {}) => {
const enableSettingsSync = options.enableSettingsSync !== false;
@@ -250,6 +261,14 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
return stored ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR;
});
const [terminalSidePanelAutoOpen, setTerminalSidePanelAutoOpenState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN);
return stored ?? DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_ENABLED;
});
const [terminalSidePanelAutoOpenTab, setTerminalSidePanelAutoOpenTabState] = useState<TerminalSidePanelAutoOpenTab>(() => {
const stored = readStoredString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB);
return isTerminalSidePanelAutoOpenTab(stored) ? stored : DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB;
});
const [shellOnlyTabNumberShortcuts, setShellOnlyTabNumberShortcutsState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
return stored ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS;
@@ -342,6 +361,18 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
return clampWindowOpacity(candidate);
});
}, []);
const [appIconVariant, setAppIconVariantState] = useState<AppIconVariant>(() => {
const stored = readStoredString(STORAGE_KEY_APP_ICON_VARIANT);
return resolveAppIconVariant(stored ?? DEFAULT_APP_ICON_VARIANT);
});
const setAppIconVariant = useCallback((nextValue: SetStateAction<AppIconVariant>) => {
setAppIconVariantState((prev) => {
const candidate = typeof nextValue === 'function'
? (nextValue as (prevState: AppIconVariant) => AppIconVariant)(prev)
: nextValue;
return resolveAppIconVariant(candidate);
});
}, []);
const incomingTerminalSettingsSignatureRef = useRef<string | null>(null);
const localTerminalSettingsVersionRef = useRef(0);
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
@@ -356,6 +387,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
// Fix 1: Mount guard — skip redundant IPC broadcasts & localStorage writes on initial mount.
// Set to true by the LAST useEffect declaration; all persist effects see false on first render.
const persistMountedRef = useRef(false);
const appearanceTransitionModeRef = useRef<ThemeTransitionMode>('view');
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
setTerminalSettingsState((prev) => {
@@ -511,6 +543,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const syncCustomCssFromStorage = useCallback(() => {
const storedCss = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_CSS) || '';
setCustomCSS((prev) => (prev === storedCss ? prev : storedCss));
applyCustomCssToDocument(storedCss);
}, []);
const rehydrateAllFromStorage = useCallback(() => {
@@ -591,6 +624,14 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setShowSftpTabState(storedShowSftpTab ?? DEFAULT_SHOW_SFTP_TAB);
const storedShowHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
setShowHostTreeSidebarState(storedShowHostTreeSidebar ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR);
const storedTerminalSidePanelAutoOpen = localStorageAdapter.readBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN);
setTerminalSidePanelAutoOpenState(storedTerminalSidePanelAutoOpen ?? DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_ENABLED);
const storedTerminalSidePanelAutoOpenTab = readStoredString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB);
setTerminalSidePanelAutoOpenTabState(
isTerminalSidePanelAutoOpenTab(storedTerminalSidePanelAutoOpenTab)
? storedTerminalSidePanelAutoOpenTab
: DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
);
const storedShellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
setShellOnlyTabNumberShortcutsState(storedShellOnlyTabNumberShortcuts ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
const storedDisableTerminalFontZoom = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
@@ -611,8 +652,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
useLayoutEffect(() => {
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
const apply = () => applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
const transitionMode = appearanceTransitionModeRef.current;
appearanceTransitionModeRef.current = 'instant';
if (persistMountedRef.current) {
runThemeTransition(apply);
runThemeTransition(apply, { mode: transitionMode });
} else {
apply();
}
@@ -686,12 +729,15 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setIsHotkeyRecordingState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setAppIconVariant,
setAutoUpdateEnabled,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setWorkspaceFocusStyleState,
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
setTerminalSidePanelAutoOpenTabState,
setDisableTerminalFontZoomState,
setRestorePreviousSessionState,
setRestoreTerminalCwdState,
@@ -723,18 +769,18 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
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, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled,
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
});
@@ -846,6 +892,21 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
notifySettingsChanged(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, enabled);
}, [notifySettingsChanged]);
const setTerminalSidePanelAutoOpen = useCallback((enabled: boolean) => {
setTerminalSidePanelAutoOpenState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, enabled);
}, [notifySettingsChanged]);
const setTerminalSidePanelAutoOpenTab = useCallback((tab: TerminalSidePanelAutoOpenTab) => {
const next = isTerminalSidePanelAutoOpenTab(tab) ? tab : DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB;
setTerminalSidePanelAutoOpenTabState(next);
localStorageAdapter.writeString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, next);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, next);
}, [notifySettingsChanged]);
const setShellOnlyTabNumberShortcuts = useCallback((enabled: boolean) => {
setShellOnlyTabNumberShortcutsState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, enabled);
@@ -1032,10 +1093,12 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
globalHotkeyEnabled,
closeToTray,
windowOpacity,
appIconVariant,
autoUpdateEnabled,
persistMountedRef,
setHotkeyRegistrationError,
setAutoUpdateEnabled,
setAppIconVariant,
notifySettingsChanged,
});
@@ -1082,21 +1145,26 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
// Subscribe to custom theme changes so editing in-place triggers re-render
const customThemes = useCustomThemes();
const currentTerminalTheme = useMemo(() => resolveCurrentTerminalTheme({
terminalThemeId,
terminalThemeDarkId,
terminalThemeLightId,
const settledTerminalTheme = useMemo(() => resolveGlobalTerminalAppearance({
userIntent: idleThemeUserIntent(),
settings: {
terminalThemeId,
terminalThemeDarkId,
terminalThemeLightId,
followAppTerminalTheme,
resolvedTheme,
lightUiThemeId,
darkUiThemeId,
accentMode,
customAccent,
},
customThemes,
followAppTerminalTheme,
resolvedTheme,
lightUiThemeId,
darkUiThemeId,
accentMode,
customAccent,
}), [terminalThemeId, terminalThemeDarkId, terminalThemeLightId, customThemes,
}).theme, [terminalThemeId, terminalThemeDarkId, terminalThemeLightId, customThemes,
followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId,
accentMode, customAccent]);
const currentTerminalTheme = settledTerminalTheme;
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
key: K,
value: TerminalSettings[K]
@@ -1176,6 +1244,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setShowSftpTab,
showHostTreeSidebar,
setShowHostTreeSidebar,
terminalSidePanelAutoOpen,
setTerminalSidePanelAutoOpen,
terminalSidePanelAutoOpenTab,
setTerminalSidePanelAutoOpenTab,
shellOnlyTabNumberShortcuts,
setShellOnlyTabNumberShortcuts,
disableTerminalFontZoom,
@@ -1218,6 +1290,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setGlobalHotkeyEnabled,
windowOpacity,
setWindowOpacity,
appIconVariant,
setAppIconVariant,
rehydrateAllFromStorage,
applyAppTheme,
workspaceFocusStyle,
@@ -1230,7 +1304,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
]),
};
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import {
createStoredStringSyncHandlers,
readOptionalStoredStringValue,
readStoredStringValue,
resolveStoredStringUpdate,
} from "./useStoredString.ts";
@@ -85,12 +86,15 @@ test("stored string helpers read fallback and resolve updater-style toggles", (t
const storageKey = "netcatty:test-mode";
assert.equal(readStoredStringValue(storageKey, "edit", isTestMode), "edit");
assert.equal(readOptionalStoredStringValue(storageKey, isTestMode), null);
env.storage.setItem(storageKey, "preview");
assert.equal(readStoredStringValue(storageKey, "edit", isTestMode), "preview");
assert.equal(readOptionalStoredStringValue(storageKey, isTestMode), "preview");
env.storage.setItem(storageKey, "invalid");
assert.equal(readStoredStringValue(storageKey, "edit", isTestMode), "edit");
assert.equal(readOptionalStoredStringValue(storageKey, isTestMode), null);
assert.equal(resolveStoredStringUpdate<TestMode>("edit", "preview"), "preview");
assert.equal(
+9
View File
@@ -18,6 +18,15 @@ export const readStoredStringValue = <T extends string>(
return isAllowedValue(stored) ? stored : fallback;
};
export const readOptionalStoredStringValue = <T extends string>(
storageKey: string,
isAllowedValue: (value: string | null) => value is T,
): T | null => {
if (!canUseLocalStorage()) return null;
const stored = localStorageAdapter.readString(storageKey);
return isAllowedValue(stored) ? stored : null;
};
export const resolveStoredStringUpdate = <T extends string>(
currentValue: T,
nextValue: T | ((currentValue: T) => T),
+34 -2
View File
@@ -74,11 +74,28 @@ export const useTerminalBackend = () => {
return bridge.execCommand(options);
}, []);
const setupOsc7Tracking = useCallback(async (sessionId: string, command: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.setupOsc7Tracking) {
return { success: false, error: "setupOsc7Tracking unavailable" };
}
return bridge.setupOsc7Tracking(sessionId, command);
}, []);
const writeToSession = useCallback((sessionId: string, data: string, options?: Parameters<NonNullable<NetcattyBridge["writeToSession"]>>[2]) => {
const bridge = netcattyBridge.get();
bridge?.writeToSession?.(sessionId, data, options);
}, []);
const interruptSession = useCallback((sessionId: string, trace?: NetcattyTerminalInterruptTrace) => {
const bridge = netcattyBridge.get();
if (bridge?.interruptSession) {
bridge.interruptSession(sessionId, trace);
return;
}
bridge?.writeToSession?.(sessionId, "\x03");
}, []);
const resizeSession = useCallback((sessionId: string, cols: number, rows: number) => {
const bridge = netcattyBridge.get();
bridge?.resizeSession?.(sessionId, cols, rows);
@@ -89,6 +106,11 @@ export const useTerminalBackend = () => {
bridge?.setSessionFlowPaused?.(sessionId, paused);
}, []);
const ackSessionFlow = useCallback((sessionId: string, bytes: number) => {
const bridge = netcattyBridge.get();
bridge?.ackSessionFlow?.(sessionId, bytes);
}, []);
const closeSession = useCallback((sessionId: string) => {
const bridge = netcattyBridge.get();
bridge?.closeSession?.(sessionId);
@@ -100,10 +122,14 @@ export const useTerminalBackend = () => {
return bridge.setSessionEncoding(sessionId, encoding);
}, []);
const onSessionData = useCallback((sessionId: string, cb: (data: string) => void) => {
const onSessionData = useCallback((
sessionId: string,
cb: (data: string) => void,
options?: Parameters<NetcattyBridge["onSessionData"]>[2],
) => {
const bridge = netcattyBridge.get();
if (!bridge?.onSessionData) throw new Error("onSessionData unavailable");
return bridge.onSessionData(sessionId, cb);
return bridge.onSessionData(sessionId, cb, options);
}, []);
const onSessionExit = useCallback((sessionId: string, cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void) => {
@@ -324,13 +350,16 @@ export const useTerminalBackend = () => {
cancelZmodem,
onZmodemEvent,
execCommand,
setupOsc7Tracking,
getSessionPwd,
getSessionRemoteInfo,
getSessionDistroInfo,
getServerStats,
writeToSession,
interruptSession,
resizeSession,
setSessionFlowPaused,
ackSessionFlow,
closeSession,
setSessionEncoding,
onSessionData,
@@ -373,13 +402,16 @@ export const useTerminalBackend = () => {
cancelZmodem,
onZmodemEvent,
execCommand,
setupOsc7Tracking,
getSessionPwd,
getSessionRemoteInfo,
getSessionDistroInfo,
getServerStats,
writeToSession,
interruptSession,
resizeSession,
setSessionFlowPaused,
ackSessionFlow,
closeSession,
setSessionEncoding,
onSessionData,
+119
View File
@@ -0,0 +1,119 @@
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import type { Host, TerminalTheme } from '../../domain/models';
import {
idleThemeUserIntent,
pickingThemeUserIntent,
resolveGlobalTerminalAppearance,
resolveTerminalAppearance,
type ResolvedAppearance,
type TerminalAppearanceHostScope,
type TerminalAppearanceSettings,
type ThemeUserIntent,
} from '../../domain/terminalAppearanceRuntime';
import { getFollowAppTerminalThemeSelectionUpdate } from '../../domain/terminalAppearance';
import {
injectTerminalAppearanceVars,
} from '../../infrastructure/theme/terminalAppearanceVars';
export type ThemeRuntimeSettings = TerminalAppearanceSettings & {
customThemes: TerminalTheme[];
setTheme: (theme: 'light' | 'dark' | 'system') => void;
setLightUiThemeId: (id: string) => void;
setDarkUiThemeId: (id: string) => void;
};
export function useThemeRuntime(settings: ThemeRuntimeSettings) {
const [userIntent, setUserIntent] = useState<ThemeUserIntent>(idleThemeUserIntent());
const appearanceSettings = useMemo((): TerminalAppearanceSettings => ({
terminalThemeId: settings.terminalThemeId,
terminalThemeDarkId: settings.terminalThemeDarkId,
terminalThemeLightId: settings.terminalThemeLightId,
followAppTerminalTheme: settings.followAppTerminalTheme,
resolvedTheme: settings.resolvedTheme,
lightUiThemeId: settings.lightUiThemeId,
darkUiThemeId: settings.darkUiThemeId,
accentMode: settings.accentMode,
customAccent: settings.customAccent,
}), [
settings.terminalThemeId,
settings.terminalThemeDarkId,
settings.terminalThemeLightId,
settings.followAppTerminalTheme,
settings.resolvedTheme,
settings.lightUiThemeId,
settings.darkUiThemeId,
settings.accentMode,
settings.customAccent,
]);
const globalAppearance = useMemo(() => resolveGlobalTerminalAppearance({
userIntent,
settings: appearanceSettings,
customThemes: settings.customThemes,
}), [userIntent, appearanceSettings, settings.customThemes]);
const resolveFocusedAppearance = useCallback((hostScope: TerminalAppearanceHostScope): ResolvedAppearance => (
resolveTerminalAppearance({
userIntent,
settings: appearanceSettings,
hostScope,
customThemes: settings.customThemes,
})
), [userIntent, appearanceSettings, settings.customThemes]);
const applyFollowAppSettingsForPick = useCallback((themeId: string) => {
const update = getFollowAppTerminalThemeSelectionUpdate(themeId);
if (!update) return false;
if (update.appTheme === 'dark') {
settings.setDarkUiThemeId(update.uiThemeId);
} else {
settings.setLightUiThemeId(update.uiThemeId);
}
settings.setTheme(update.appTheme);
return true;
}, [settings]);
const pickTheme = useCallback((themeId: string, options?: { followApp?: boolean; scopeHostId?: string | null }) => {
const followApp = options?.followApp ?? settings.followAppTerminalTheme;
setUserIntent(pickingThemeUserIntent(themeId, {
scopeHostId: followApp ? undefined : options?.scopeHostId,
}));
if (followApp) {
applyFollowAppSettingsForPick(themeId);
}
}, [applyFollowAppSettingsForPick, settings.followAppTerminalTheme]);
const clearIntent = useCallback(() => {
setUserIntent(idleThemeUserIntent());
}, []);
const settleManualIntent = useCallback(() => {
setUserIntent(idleThemeUserIntent());
}, []);
return {
userIntent,
globalAppearance,
resolveFocusedAppearance,
pickTheme,
clearIntent,
settleManualIntent,
currentTerminalTheme: globalAppearance.theme,
};
}
export function useTerminalAppearanceInjection(
appearance: ResolvedAppearance,
options?: { includeChromeSurfaces?: boolean },
): void {
const includeChromeSurfaces = options?.includeChromeSurfaces ?? true;
useLayoutEffect(() => {
injectTerminalAppearanceVars(appearance.theme, { includeChromeSurfaces });
}, [appearance.theme.id, appearance.theme, includeChromeSurfaces]);
}
export function buildHostScope(host: Host | null, isEphemeral: boolean): TerminalAppearanceHostScope {
return { host, isEphemeral };
}
+12 -1
View File
@@ -16,6 +16,7 @@ export interface UseVaultAgentBridgeInput {
terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>;
resolveEffectiveHost: (host: Host) => Host;
updateHosts: (hosts: Host[]) => void;
updateSnippets: (snippets: Snippet[]) => void;
customGroups: string[];
updateCustomGroups: (groups: string[]) => void;
notes: VaultNote[];
@@ -27,6 +28,7 @@ export interface UseVaultAgentBridgeInput {
type VaultAgentSnapshot = {
hosts: Host[];
notes: VaultNote[];
snippets: Snippet[];
customGroups: string[];
};
@@ -37,27 +39,32 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
const vaultSnapshotRef = useRef<VaultAgentSnapshot>({
hosts: input.hosts,
notes: input.notes,
snippets: input.snippets,
customGroups: input.customGroups,
});
const lastSyncedVaultInputRef = useRef({
hosts: input.hosts,
notes: input.notes,
snippets: input.snippets,
customGroups: input.customGroups,
});
if (
input.hosts !== lastSyncedVaultInputRef.current.hosts
|| input.notes !== lastSyncedVaultInputRef.current.notes
|| input.snippets !== lastSyncedVaultInputRef.current.snippets
|| input.customGroups !== lastSyncedVaultInputRef.current.customGroups
) {
vaultSnapshotRef.current = {
hosts: input.hosts,
notes: input.notes,
snippets: input.snippets,
customGroups: input.customGroups,
};
lastSyncedVaultInputRef.current = {
hosts: input.hosts,
notes: input.notes,
snippets: input.snippets,
customGroups: input.customGroups,
};
}
@@ -69,7 +76,7 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
getHosts: () => vaultSnapshotRef.current.hosts,
getNotes: () => vaultSnapshotRef.current.notes,
getCustomGroups: () => vaultSnapshotRef.current.customGroups,
snippets: current.snippets,
snippets: vaultSnapshotRef.current.snippets,
portForwardingRules: current.portForwardingRules,
keys: current.keys,
identities: current.identities,
@@ -94,6 +101,10 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
vaultSnapshotRef.current.notes = notes;
current.updateNotes(notes);
},
updateSnippets: (nextSnippets) => {
vaultSnapshotRef.current.snippets = nextSnippets;
current.updateSnippets(nextSnippets);
},
startTunnel: current.startTunnel,
stopTunnel: current.stopTunnel,
});
+179 -15
View File
@@ -23,6 +23,7 @@ import {
} from "../../infrastructure/config/defaultData";
import {
STORAGE_KEY_CONNECTION_LOGS,
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA,
STORAGE_KEY_GROUP_CONFIGS,
STORAGE_KEY_GROUPS,
STORAGE_KEY_HOSTS,
@@ -39,10 +40,18 @@ import {
STORAGE_KEY_SNIPPETS,
STORAGE_KEY_TERM_SETTINGS,
} from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import { localStorageAdapter, LOCAL_STORAGE_ADAPTER_CHANGED_EVENT } from "../../infrastructure/persistence/localStorageAdapter";
import { mergeGlobalHistoryOnAppend, sanitizeGlobalHistoryEntries } from "../../domain/globalHistory";
import {
buildTerminalDataMapFromLogs,
mergeConnectionLogsFromStorage,
mergeTerminalDataIntoLogs,
mergeTerminalDataMapsForStorage,
type ConnectionLogTerminalDataMap,
} from "../../domain/connectionLogTerminalData";
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
import { loadSanitizedShellHistory } from "./shellHistoryPersistence";
import { setVaultInitialized } from "./vaultInitStore";
import {
decryptGroupConfigs,
decryptHosts,
@@ -148,6 +157,43 @@ const readLegacyLineTimestampsEnabled = (): boolean => {
return stored?.showLineTimestamps === true;
};
const readConnectionLogTerminalDataMap = (): ConnectionLogTerminalDataMap =>
localStorageAdapter.read<ConnectionLogTerminalDataMap>(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) ?? {};
const readPersistedConnectionLogIds = (): Set<string> => {
const stored = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS) ?? [];
return new Set(stored.map((log) => log.id));
};
const buildPersistedLogIds = (
logs: ConnectionLog[],
usePersistedLogIdsFromDisk: boolean,
): Set<string> => {
const ids = new Set(logs.map((log) => log.id));
if (usePersistedLogIdsFromDisk) {
for (const id of readPersistedConnectionLogIds()) {
ids.add(id);
}
}
return ids;
};
const writeConnectionLogTerminalDataMap = (
logs: ConnectionLog[],
localMaps: ConnectionLogTerminalDataMap[],
usePersistedLogIdsFromDisk: boolean,
): boolean => {
const existing = readConnectionLogTerminalDataMap();
const pruned = mergeTerminalDataMapsForStorage(
logs,
existing,
localMaps,
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
);
if (JSON.stringify(existing) === JSON.stringify(pruned)) return true;
return localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA, pruned);
};
export const useVaultState = () => {
const [isInitialized, setIsInitialized] = useState(false);
const [hosts, setHosts] = useState<Host[]>([]);
@@ -183,6 +229,87 @@ export const useVaultState = () => {
const identitiesReadSeq = useRef(0);
const proxyProfilesReadSeq = useRef(0);
const groupConfigsReadSeq = useRef(0);
const connectionLogTerminalDataRef = useRef<ConnectionLogTerminalDataMap>({});
const syncConnectionLogTerminalDataMap = useCallback((
logs: ConnectionLog[],
options?: { usePersistedLogIdsFromDisk?: boolean },
) => {
const usePersistedLogIdsFromDisk = options?.usePersistedLogIdsFromDisk ?? false;
const localMaps = [
connectionLogTerminalDataRef.current,
buildTerminalDataMapFromLogs(logs),
];
const existing = readConnectionLogTerminalDataMap();
const merged = mergeTerminalDataMapsForStorage(
logs,
existing,
localMaps,
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
);
const persisted = writeConnectionLogTerminalDataMap(
logs,
localMaps,
usePersistedLogIdsFromDisk,
);
if (persisted) {
connectionLogTerminalDataRef.current = merged;
} else {
connectionLogTerminalDataRef.current = {
...connectionLogTerminalDataRef.current,
...buildTerminalDataMapFromLogs(logs),
};
}
return { map: connectionLogTerminalDataRef.current, persisted };
}, []);
const persistConnectionLogState = useCallback((
logs: ConnectionLog[],
options?: { pruneMainBlob?: boolean },
) => {
const unsavedTerminalDataPending = logs.some(
(log) => !log.saved && log.terminalData !== undefined,
);
let mainPersisted = true;
if (options?.pruneMainBlob) {
const prunedMain = pruneConnectionLogsForStorage(logs);
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, prunedMain);
if (!mainPersisted && unsavedTerminalDataPending) {
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, logs);
}
}
let sidePersisted = true;
if (mainPersisted || !options?.pruneMainBlob) {
({ persisted: sidePersisted } = syncConnectionLogTerminalDataMap(logs, {
usePersistedLogIdsFromDisk: Boolean(options?.pruneMainBlob && mainPersisted),
}));
}
if (!options?.pruneMainBlob) {
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, logs);
}
if (!mainPersisted && unsavedTerminalDataPending) {
console.warn(
"[useVaultState] Failed to persist connection log terminal replay data to localStorage.",
);
} else if (!sidePersisted && unsavedTerminalDataPending && options?.pruneMainBlob && mainPersisted) {
console.warn(
"[useVaultState] Failed to persist connection log terminal replay data side store.",
);
}
}, [syncConnectionLogTerminalDataMap]);
const applyConnectionLogsFromStorage = useCallback((
prev: ConnectionLog[],
storedLogs: ConnectionLog[],
terminalDataMap: ConnectionLogTerminalDataMap,
) => {
connectionLogTerminalDataRef.current = terminalDataMap;
return mergeConnectionLogsFromStorage(prev, storedLogs, terminalDataMap);
}, []);
const updateHosts = useCallback((data: Host[]) => {
const cleaned = normalizeVaultOrder(data.map(sanitizeHost));
@@ -403,12 +530,12 @@ export const useVaultState = () => {
const final = [...updated, ...savedLogs].sort(
(a, b) => b.startTime - a.startTime
);
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, pruneConnectionLogsForStorage(final));
persistConnectionLogState(final, { pruneMainBlob: true });
return final;
});
return newLog.id;
},
[]
[persistConnectionLogState],
);
const updateConnectionLog = useCallback(
@@ -417,11 +544,11 @@ export const useVaultState = () => {
const updated = prev.map((log) =>
log.id === id ? { ...log, ...updates } : log
);
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, pruneConnectionLogsForStorage(updated));
persistConnectionLogState(updated, { pruneMainBlob: true });
return updated;
});
},
[]
[persistConnectionLogState],
);
const toggleConnectionLogSaved = useCallback((id: string) => {
@@ -429,26 +556,29 @@ export const useVaultState = () => {
const updated = prev.map((log) =>
log.id === id ? { ...log, saved: !log.saved } : log
);
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, updated);
persistConnectionLogState(updated, { pruneMainBlob: false });
return updated;
});
}, []);
}, [persistConnectionLogState]);
const deleteConnectionLog = useCallback((id: string) => {
setConnectionLogs((prev) => {
const updated = prev.filter((log) => log.id !== id);
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, updated);
const map = { ...connectionLogTerminalDataRef.current };
delete map[id];
connectionLogTerminalDataRef.current = map;
persistConnectionLogState(updated, { pruneMainBlob: true });
return updated;
});
}, []);
}, [persistConnectionLogState]);
const clearUnsavedConnectionLogs = useCallback(() => {
setConnectionLogs((prev) => {
const saved = prev.filter((log) => log.saved);
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, pruneConnectionLogsForStorage(saved));
persistConnectionLogState(saved, { pruneMainBlob: true });
return saved;
});
}, []);
}, [persistConnectionLogState]);
// Convert a known host to a managed host
const convertKnownHostToHost = useCallback((knownHost: KnownHost): Host => {
@@ -644,7 +774,11 @@ export const useVaultState = () => {
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
STORAGE_KEY_CONNECTION_LOGS,
);
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
const terminalDataMap = readConnectionLogTerminalDataMap();
connectionLogTerminalDataRef.current = terminalDataMap;
if (savedConnectionLogs) {
setConnectionLogs(mergeTerminalDataIntoLogs(savedConnectionLogs, terminalDataMap));
}
// Load managed sources
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
@@ -668,6 +802,7 @@ export const useVaultState = () => {
}
} finally {
setIsInitialized(true);
setVaultInitialized(true);
}
};
@@ -787,7 +922,16 @@ export const useVaultState = () => {
if (key === STORAGE_KEY_CONNECTION_LOGS) {
const next = safeParse<ConnectionLog[]>(event.newValue) ?? [];
setConnectionLogs(next);
setConnectionLogs((prev) =>
applyConnectionLogsFromStorage(prev, next, connectionLogTerminalDataRef.current),
);
return;
}
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) {
const next = safeParse<ConnectionLogTerminalDataMap>(event.newValue) ?? {};
connectionLogTerminalDataRef.current = next;
setConnectionLogs((prev) => mergeConnectionLogsFromStorage(prev, prev, next));
return;
}
@@ -810,9 +954,29 @@ export const useVaultState = () => {
}
};
const handleLocalStorageAdapterChanged = (event: Event) => {
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
if (key === STORAGE_KEY_CONNECTION_LOGS) {
const next = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS) ?? [];
setConnectionLogs((prev) =>
applyConnectionLogsFromStorage(prev, next, connectionLogTerminalDataRef.current),
);
return;
}
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) {
const next = readConnectionLogTerminalDataMap();
connectionLogTerminalDataRef.current = next;
setConnectionLogs((prev) => mergeConnectionLogsFromStorage(prev, prev, next));
}
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []);
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
return () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
};
}, [applyConnectionLogsFromStorage]);
const updateHostLastConnected = useCallback((hostId: string) => {
setHosts((prev) => {
+9
View File
@@ -0,0 +1,9 @@
let vaultInitialized = false;
export function isVaultInitialized(): boolean {
return vaultInitialized;
}
export function setVaultInitialized(value: boolean): void {
vaultInitialized = value;
}
+39 -7
View File
@@ -9,17 +9,49 @@ export const requestWindowInputFocus = (): void => {
}
};
export const scheduleWindowInputFocus = (): void => {
const scheduleFrame: (callback: () => void) => unknown =
export type ScheduledWindowInputFocus = {
cancel: () => void;
};
export const scheduleWindowInputFocus = (): ScheduledWindowInputFocus => {
let cancelled = false;
let frameId: number | undefined;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const cancel = () => {
cancelled = true;
if (frameId !== undefined && typeof cancelAnimationFrame === "function") {
cancelAnimationFrame(frameId);
frameId = undefined;
}
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
timeoutId = undefined;
}
};
const runIfVisible = () => {
if (cancelled || document.visibilityState !== "visible") return;
requestWindowInputFocus();
};
const scheduleFrame: (callback: FrameRequestCallback) => number =
typeof requestAnimationFrame === "function"
? requestAnimationFrame
: (callback) => {
callback();
return undefined;
callback(0);
return 0;
};
scheduleFrame(() => {
requestWindowInputFocus();
setTimeout(requestWindowInputFocus, 50);
frameId = scheduleFrame(() => {
frameId = undefined;
runIfVisible();
if (cancelled) return;
timeoutId = setTimeout(() => {
timeoutId = undefined;
runIfVisible();
}, 50);
});
return { cancel };
};
+21
View File
@@ -181,12 +181,29 @@ test("terminal selection AI preference is syncable for auto-sync detection", ()
);
});
test("terminal side panel auto-open settings are syncable for auto-sync detection", () => {
assert.ok(
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
),
);
assert.ok(
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
),
);
});
test("buildSyncPayload includes host tree sidebar visibility setting", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, "false");
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, "true");
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, "scripts");
const payload = buildSyncPayload(vault([]));
assert.equal(payload.settings?.showHostTreeSidebar, false);
assert.equal(payload.settings?.terminalSidePanelAutoOpen, true);
assert.equal(payload.settings?.terminalSidePanelAutoOpenTab, "scripts");
});
test("buildSyncPayload excludes externalAgents (device-local OS-bound config)", () => {
@@ -378,6 +395,8 @@ test("applySyncPayload restores host tree sidebar visibility setting", async ()
customGroups: [],
settings: {
showHostTreeSidebar: false,
terminalSidePanelAutoOpen: true,
terminalSidePanelAutoOpenTab: "scripts",
},
syncedAt: 1,
} as SyncPayload;
@@ -385,6 +404,8 @@ test("applySyncPayload restores host tree sidebar visibility setting", async ()
await applySyncPayload(payload, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR), "false");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN), "true");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB), "scripts");
});
test("applySyncPayload dispatches a same-window AI-state-changed event so the open chat panel rehydrates", async () => {
+17
View File
@@ -53,6 +53,8 @@ import {
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
STORAGE_KEY_EDITOR_WORD_WRAP,
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
@@ -88,6 +90,7 @@ import {
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
STORAGE_KEY_PORT_FORWARDING,
} from '../infrastructure/config/storageKeys';
import { isTerminalSidePanelAutoOpenTab } from '../domain/terminalSidePanelAutoOpen';
// ---------------------------------------------------------------------------
// Input types
@@ -227,6 +230,8 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_CUSTOM_THEMES,
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
STORAGE_KEY_EDITOR_WORD_WRAP,
@@ -386,6 +391,12 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (termFont) settings.terminalFontFamily = termFont;
const termSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
if (termSize != null) settings.terminalFontSize = termSize;
const terminalSidePanelAutoOpen = localStorageAdapter.readBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN);
if (terminalSidePanelAutoOpen != null) settings.terminalSidePanelAutoOpen = terminalSidePanelAutoOpen;
const terminalSidePanelAutoOpenTab = localStorageAdapter.readString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB);
if (isTerminalSidePanelAutoOpenTab(terminalSidePanelAutoOpenTab)) {
settings.terminalSidePanelAutoOpenTab = terminalSidePanelAutoOpenTab;
}
// Terminal settings (syncable subset only)
const termSettingsRaw = localStorageAdapter.readString(STORAGE_KEY_TERM_SETTINGS);
@@ -571,6 +582,12 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
if (settings.terminalThemeLight != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_LIGHT, settings.terminalThemeLight);
if (settings.terminalFontFamily != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, settings.terminalFontFamily);
if (settings.terminalFontSize != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_SIZE, String(settings.terminalFontSize));
if (settings.terminalSidePanelAutoOpen != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, settings.terminalSidePanelAutoOpen);
}
if (isTerminalSidePanelAutoOpenTab(settings.terminalSidePanelAutoOpenTab)) {
localStorageAdapter.writeString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, settings.terminalSidePanelAutoOpenTab);
}
// Terminal settings — merge with existing to preserve platform-specific keys
if (settings.terminalSettings) {
+3
View File
@@ -47,6 +47,7 @@ interface AIChatPanelContentProps {
handleDeleteSession: (event: React.MouseEvent, sessionId: string) => void;
messages: ChatMessage[];
isStreaming: boolean;
activeCompaction?: import('./ai/hooks/useAgentCompactionUi').ActiveCompactionUi | null;
inputValue: string;
setInputValue: (value: string) => void;
handleSend: () => void;
@@ -100,6 +101,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
handleDeleteSession,
messages,
isStreaming,
activeCompaction = null,
inputValue,
setInputValue,
handleSend,
@@ -210,6 +212,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
messages={messages}
isStreaming={isStreaming}
activeSessionId={activeSessionId}
activeCompaction={activeCompaction}
notes={notes}
hosts={hosts}
onOpenVaultNote={onOpenVaultNote}
@@ -182,3 +182,15 @@ test('AI side panel re-renders when retained content becomes visible again', ()
{ ...hiddenProps, isVisible: true },
), false);
});
test('AI side panel re-renders when command timeout changes', () => {
const props = baseProps({
isVisible: true,
commandTimeout: 60,
});
assert.equal(aiChatSidePanelPropsAreEqual(
props,
{ ...props, commandTimeout: 86_400 },
), false);
});
+186 -39
View File
@@ -10,6 +10,7 @@ import type {
AgentModelPreset,
AISessionScope,
DiscoveredAgent,
ExternalAgentConfig,
} from '../infrastructure/ai/types';
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
import { getAgentModelPresets } from '../infrastructure/ai/types';
@@ -54,11 +55,14 @@ import { useAIPermissionGrantsState } from '../application/state/useAIPermission
import { useConversationExport } from './ai/hooks/useConversationExport';
import type { AIChatSidePanelProps } from './AIChatSidePanel.types';
import {
buildSdkRuntimeModelCacheKey,
sdkRuntimeModelCache,
generateId,
normalizeSdkRuntimeModelPresets,
shouldAdoptSdkCurrentModel,
shouldLoadSdkRuntimeModels,
shouldUseStoredAgentModel,
type SdkRuntimeModelCatalog,
} from './AIChatSidePanelHelpers';
import { AIChatPanelContent } from './AIChatPanelContent';
import {
@@ -74,6 +78,13 @@ type UserSkillsStatusResult = { ok: boolean; skills?: Array<{
status: 'ready' | 'warning';
}> } | null;
type UserSkillsStatusLoadResult = UserSkillsStatusResult | undefined;
type SdkRuntimeModelTarget = {
agentId: string;
cacheKey: string;
sdkBackend: string;
agentEnv?: Record<string, string>;
agentCommand?: string;
};
const USER_SKILLS_STATUS_CACHE_TTL_MS = 60_000;
let userSkillsStatusCache: {
@@ -252,6 +263,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
globalPermissionMode,
setGlobalPermissionMode,
commandBlocklist,
commandTimeout,
maxIterations = 20,
webSearchConfig,
quickMessages = [],
@@ -264,8 +276,10 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
isVisible = true,
notes = [],
hosts = [],
snippets = [],
onOpenVaultNote,
onOpenVaultHost,
onOpenVaultSnippet,
onOpenVaultSection,
}) => {
const { t } = useI18n();
@@ -288,6 +302,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
sendToCattyAgent,
sendToExternalAgent,
reportStreamError,
activeCompaction,
} = useAIChatStreaming({
maxIterations,
addMessageToSession,
@@ -653,55 +668,178 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const agentModelMapRef = useRef(agentModelMap);
agentModelMapRef.current = agentModelMap;
useEffect(() => {
if (!isVisible) return;
const sdkBackend = getExternalAgentSdkBackend(currentAgentConfig);
if (!sdkBackend) return;
if (!shouldLoadSdkRuntimeModels(currentAgentConfig) && !isCodexManagedAgent) return;
const bridge = getNetcattyBridge();
if (!bridge?.aiSdkAgentListModels) return;
let cancelled = false;
void bridge.aiSdkAgentListModels(
const buildExternalAgentRuntimeModelTarget = useCallback((agent: ExternalAgentConfig | undefined): SdkRuntimeModelTarget | null => {
if (!agent) return null;
const sdkBackend = getExternalAgentSdkBackend(agent);
if (!sdkBackend) return null;
return {
agentId: agent.id,
cacheKey: buildSdkRuntimeModelCacheKey(agent),
sdkBackend,
undefined,
undefined,
`models_${currentAgentId}`,
currentAgentConfig.env,
getManualAgentCommand(currentAgentConfig),
).then((result) => {
if (cancelled || !result?.ok || !Array.isArray(result.models)) return;
const runtimePresets = normalizeSdkRuntimeModelPresets(result.models, result.currentModelId);
const storedModelId = agentModelMapRef.current[currentAgentId];
if (runtimePresets.length === 0) {
setRuntimeAgentModelPresets((prev) => {
if (!(currentAgentId in prev)) return prev;
const { [currentAgentId]: _removed, ...rest } = prev;
return rest;
});
if (shouldAdoptSdkCurrentModel(result.currentModelId, storedModelId, runtimePresets)) {
setAgentModel(currentAgentId, result.currentModelId!);
}
return;
}
agentEnv: agent.env,
agentCommand: getManualAgentCommand(agent),
};
}, []);
const buildDiscoveredAgentRuntimeModelTarget = useCallback((agent: DiscoveredAgent): SdkRuntimeModelTarget | null => {
const sdkBackend = agent.sdkBackend ?? agent.command;
if (sdkBackend !== 'opencode') return null;
const command = agent.binPath || agent.path || agent.command;
const agentId = `discovered_${agent.command}`;
return {
agentId,
cacheKey: buildSdkRuntimeModelCacheKey({
id: agentId,
command,
sdkBackend,
env: command ? { OPENCODE_BIN: command } : undefined,
}),
sdkBackend,
agentCommand: command,
};
}, []);
const applySdkRuntimeModelCatalog = useCallback((
agentId: string,
catalog: SdkRuntimeModelCatalog,
options: { adoptCurrentModel?: boolean } = {},
) => {
const runtimePresets = normalizeSdkRuntimeModelPresets(catalog.models, catalog.currentModelId);
const storedModelId = agentModelMapRef.current[agentId];
if (runtimePresets.length === 0) {
setRuntimeAgentModelPresets((prev) => {
if (!(agentId in prev)) return prev;
const { [agentId]: _removed, ...rest } = prev;
return rest;
});
} else {
setRuntimeAgentModelPresets((prev) => ({
...prev,
[currentAgentId]: runtimePresets,
[agentId]: runtimePresets,
}));
if (shouldAdoptSdkCurrentModel(result.currentModelId, storedModelId, runtimePresets)) {
setAgentModel(currentAgentId, result.currentModelId);
}
}).catch((err) => {
if (!cancelled) {
}
if (
options.adoptCurrentModel
&& catalog.currentModelId
&& shouldAdoptSdkCurrentModel(catalog.currentModelId, storedModelId, runtimePresets)
) {
setAgentModel(agentId, catalog.currentModelId);
}
}, [setAgentModel]);
const loadSdkRuntimeModelCatalog = useCallback((
target: SdkRuntimeModelTarget,
options: { force?: boolean; logErrors?: boolean } = {},
): Promise<SdkRuntimeModelCatalog | null> => {
const bridge = getNetcattyBridge();
if (!bridge?.aiSdkAgentListModels) return Promise.resolve(null);
return sdkRuntimeModelCache.refresh(
target.cacheKey,
async () => {
const result = await bridge.aiSdkAgentListModels!(
target.sdkBackend,
undefined,
undefined,
`models_${target.agentId}`,
target.agentEnv,
target.agentCommand,
);
if (!result?.ok || !Array.isArray(result.models)) {
throw new Error(result?.error || 'Failed to load SDK agent models');
}
return {
currentModelId: result.currentModelId ?? null,
models: result.models,
};
},
{ force: options.force },
).catch((err) => {
if (options.logErrors !== false) {
console.warn('[AIChatSidePanel] Failed to load SDK agent models:', err);
}
return null;
});
}, []);
useEffect(() => {
if (!isVisible) return;
if (!currentAgentConfig) return;
if (!shouldLoadSdkRuntimeModels(currentAgentConfig) && !isCodexManagedAgent) return;
const target = buildExternalAgentRuntimeModelTarget(currentAgentConfig);
if (!target) return;
const cached = sdkRuntimeModelCache.read(target.cacheKey);
if (cached) {
applySdkRuntimeModelCatalog(target.agentId, cached);
}
let cancelled = false;
void loadSdkRuntimeModelCatalog(target, {
force: target.sdkBackend === 'opencode',
}).then((catalog) => {
if (cancelled || !catalog) return;
applySdkRuntimeModelCatalog(target.agentId, catalog, { adoptCurrentModel: true });
});
return () => {
cancelled = true;
};
}, [isVisible, currentAgentConfig, currentAgentId, isCodexManagedAgent, setAgentModel]);
}, [
isVisible,
currentAgentConfig,
isCodexManagedAgent,
buildExternalAgentRuntimeModelTarget,
loadSdkRuntimeModelCatalog,
applySdkRuntimeModelCatalog,
]);
useEffect(() => {
if (!isVisible) return;
const targets = new Map<string, SdkRuntimeModelTarget>();
const configuredTargetCacheKeys = new Set<string>();
for (const agent of externalAgents) {
if (!agent.enabled || getExternalAgentSdkBackend(agent) !== 'opencode') continue;
const target = buildExternalAgentRuntimeModelTarget(agent);
if (target) {
targets.set(target.cacheKey, target);
configuredTargetCacheKeys.add(target.cacheKey);
}
}
for (const agent of discoveredAgents) {
const target = buildDiscoveredAgentRuntimeModelTarget(agent);
if (target) targets.set(target.cacheKey, target);
}
if (targets.size === 0) return;
let cancelled = false;
for (const target of targets.values()) {
void loadSdkRuntimeModelCatalog(target, { logErrors: false })
.then((catalog) => {
if (cancelled || !catalog) return;
if (configuredTargetCacheKeys.has(target.cacheKey)) {
applySdkRuntimeModelCatalog(target.agentId, catalog);
}
});
}
return () => {
cancelled = true;
};
}, [
isVisible,
externalAgents,
discoveredAgents,
buildExternalAgentRuntimeModelTarget,
buildDiscoveredAgentRuntimeModelTarget,
loadSdkRuntimeModelCatalog,
applySdkRuntimeModelCatalog,
]);
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent;
@@ -966,6 +1104,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
scopeLabel,
globalPermissionMode,
commandBlocklist,
commandTimeout,
terminalSessions,
webSearchConfig,
getExecutorContext: () => buildExecutorContextForScope(toolScope),
@@ -986,7 +1125,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
setStreamingForScope,
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
abortControllersRef, terminalSessions, defaultTargetSession, providers, selectedAgentModel, updateSessionExternalSessionId,
scopeType, scopeTargetId, scopeHostIds, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope,
scopeType, scopeTargetId, scopeHostIds, scopeLabel, globalPermissionMode, commandBlocklist, commandTimeout, webSearchConfig, buildExecutorContextForScope,
toolIntegrationMode,
clearScopeDraft, showScopeSessionView, setActiveSessionId,
]);
@@ -1113,6 +1252,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
handleDeleteSession={handleDeleteSession}
messages={messages}
isStreaming={isStreaming}
activeCompaction={
activeCompaction?.sessionId === activeSessionId ? activeCompaction : null
}
inputValue={inputValue}
setInputValue={setInputValue}
handleSend={handleSend}
@@ -1140,8 +1282,10 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
setGlobalPermissionMode={setGlobalPermissionMode}
notes={notes}
hosts={hosts}
snippets={snippets}
onOpenVaultNote={onOpenVaultNote}
onOpenVaultHost={onOpenVaultHost}
onOpenVaultSnippet={onOpenVaultSnippet}
onOpenVaultSection={onOpenVaultSection}
/>
</React.Profiler>
@@ -1183,6 +1327,7 @@ const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
'globalPermissionMode',
'setGlobalPermissionMode',
'commandBlocklist',
'commandTimeout',
'maxIterations',
'webSearchConfig',
'quickMessages',
@@ -1210,8 +1355,10 @@ export function aiChatSidePanelPropsAreEqual(
if (prev.resolveExecutorContext !== next.resolveExecutorContext) return false;
if (prev.notes !== next.notes) return false;
if (prev.hosts !== next.hosts) return false;
if (prev.snippets !== next.snippets) return false;
if (prev.onOpenVaultNote !== next.onOpenVaultNote) return false;
if (prev.onOpenVaultHost !== next.onOpenVaultHost) return false;
if (prev.onOpenVaultSnippet !== next.onOpenVaultSnippet) return false;
if (prev.onOpenVaultSection !== next.onOpenVaultSection) return false;
for (const key of AI_CHAT_SIDE_PANEL_AI_STATE_KEYS) {
+5 -2
View File
@@ -12,7 +12,7 @@ import type {
} from '../infrastructure/ai/types';
import type { AIQuickMessage } from '../infrastructure/ai/quickMessages';
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
import type { Host, VaultNote } from '../types';
import type { Host, Snippet, VaultNote } from '../types';
// -------------------------------------------------------------------
// Props
@@ -69,6 +69,7 @@ export interface AIChatSidePanelProps {
globalPermissionMode: AIPermissionMode;
setGlobalPermissionMode?: (mode: AIPermissionMode) => void;
commandBlocklist?: string[];
commandTimeout?: number;
maxIterations?: number;
// Web search
@@ -108,9 +109,11 @@ export interface AIChatSidePanelProps {
// Vault artifact navigation (from AI chat tool results)
notes?: VaultNote[];
hosts?: Host[];
snippets?: Snippet[];
onOpenVaultNote?: (noteId: string) => void;
onOpenVaultHost?: (hostId: string) => void;
onOpenVaultSection?: (section: 'notes' | 'hosts') => void;
onOpenVaultSnippet?: (snippetId: string) => void;
onOpenVaultSection?: (section: 'notes' | 'hosts' | 'snippets') => void;
}
// -------------------------------------------------------------------
+114
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
createSdkRuntimeModelCache,
modelPresetsContainId,
normalizeSdkRuntimeModelPresets,
shouldAdoptSdkCurrentModel,
@@ -85,3 +86,116 @@ test('shouldUseStoredAgentModel trusts SDK defaults when no runtime list is retu
false,
);
});
test('SDK runtime model cache coalesces concurrent refreshes', async () => {
const cache = createSdkRuntimeModelCache();
let calls = 0;
let resolveLoad!: (value: { currentModelId: string | null; models: AgentModelPreset[] }) => void;
const load = () => {
calls += 1;
return new Promise<{ currentModelId: string | null; models: AgentModelPreset[] }>((resolve) => {
resolveLoad = resolve;
});
};
const first = cache.refresh('opencode:/opt/bin/opencode', load);
const second = cache.refresh('opencode:/opt/bin/opencode', load);
assert.equal(first, second);
assert.equal(calls, 1);
resolveLoad({
currentModelId: 'openai/gpt-5.1',
models: [{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }],
});
assert.deepEqual(await first, {
currentModelId: 'openai/gpt-5.1',
models: [{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }],
});
});
test('SDK runtime model cache serves cached models while background refresh updates them', async () => {
let now = 1_000;
const cache = createSdkRuntimeModelCache({ now: () => now });
await cache.refresh('opencode:/opt/bin/opencode', async () => ({
currentModelId: 'openai/gpt-5.1',
models: [{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }],
}));
assert.deepEqual(cache.read('opencode:/opt/bin/opencode')?.models, [
{ id: 'openai/gpt-5.1', name: 'GPT-5.1' },
]);
now = 2_000;
const refresh = cache.refresh(
'opencode:/opt/bin/opencode',
async () => ({
currentModelId: 'anthropic/claude-sonnet-4-6',
models: [{ id: 'anthropic/claude-sonnet-4-6', name: 'Claude Sonnet 4.6' }],
}),
{ force: true },
);
assert.deepEqual(cache.read('opencode:/opt/bin/opencode')?.models, [
{ id: 'openai/gpt-5.1', name: 'GPT-5.1' },
]);
await refresh;
assert.deepEqual(cache.read('opencode:/opt/bin/opencode')?.models, [
{ id: 'anthropic/claude-sonnet-4-6', name: 'Claude Sonnet 4.6' },
]);
});
test('SDK runtime model cache keeps the previous catalog when a refresh fails', async () => {
const cache = createSdkRuntimeModelCache();
await cache.refresh('opencode:/opt/bin/opencode', async () => ({
currentModelId: 'openai/gpt-5.1',
models: [{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }],
}));
await assert.rejects(
cache.refresh(
'opencode:/opt/bin/opencode',
async () => {
throw new Error('OpenCode unavailable');
},
{ force: true },
),
/OpenCode unavailable/,
);
assert.deepEqual(cache.read('opencode:/opt/bin/opencode')?.models, [
{ id: 'openai/gpt-5.1', name: 'GPT-5.1' },
]);
});
test('SDK runtime model cache ignores degraded empty catalogs', async () => {
const cache = createSdkRuntimeModelCache();
await cache.refresh('opencode:/opt/bin/opencode', async () => ({
currentModelId: 'openai/gpt-5.1',
models: [{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }],
}));
const refreshed = await cache.refresh(
'opencode:/opt/bin/opencode',
async () => ({ currentModelId: null, models: [] }),
{ force: true },
);
assert.deepEqual(refreshed.models, [
{ id: 'openai/gpt-5.1', name: 'GPT-5.1' },
]);
assert.deepEqual(cache.read('opencode:/opt/bin/opencode')?.models, [
{ id: 'openai/gpt-5.1', name: 'GPT-5.1' },
]);
const emptyCache = createSdkRuntimeModelCache();
await emptyCache.refresh('opencode:/usr/bin/opencode', async () => ({
currentModelId: null,
models: [],
}));
assert.equal(emptyCache.read('opencode:/usr/bin/opencode'), null);
});
+96
View File
@@ -1,6 +1,102 @@
import type { AgentModelPreset, ExternalAgentConfig } from '../infrastructure/ai/types';
import { getExternalAgentSdkBackend } from '../infrastructure/ai/managedAgents';
export type SdkRuntimeModelCatalog = {
currentModelId: string | null;
models: AgentModelPreset[];
};
export type SdkRuntimeModelCacheEntry = SdkRuntimeModelCatalog & {
updatedAt: number;
};
type SdkRuntimeModelCacheOptions = {
ttlMs?: number;
now?: () => number;
};
type SdkRuntimeModelRefreshOptions = {
force?: boolean;
};
const SDK_RUNTIME_MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
const MODEL_CACHE_ENV_HINTS = ['CLAUDE_CODE_EXECUTABLE', 'CODEBUDDY_CODE_PATH', 'OPENCODE_BIN'] as const;
function cloneCatalog(catalog: SdkRuntimeModelCatalog): SdkRuntimeModelCatalog {
return {
currentModelId: catalog.currentModelId ?? null,
models: [...catalog.models],
};
}
function normalizeSdkRuntimeModelCatalog(catalog: SdkRuntimeModelCatalog): SdkRuntimeModelCatalog {
return {
currentModelId: catalog.currentModelId ?? null,
models: Array.isArray(catalog.models)
? catalog.models.filter((model): model is AgentModelPreset => Boolean(model?.id))
: [],
};
}
export function buildSdkRuntimeModelCacheKey(agent: {
id: string;
command?: string;
sdkBackend?: string;
acpCommand?: string;
env?: Record<string, string>;
}): string {
const sdkBackend = agent.sdkBackend || agent.acpCommand || '';
const envHints = MODEL_CACHE_ENV_HINTS.map((key) => `${key}=${agent.env?.[key] ?? ''}`);
return [agent.id, sdkBackend, agent.command ?? '', ...envHints].join('\u0000');
}
export function createSdkRuntimeModelCache(options: SdkRuntimeModelCacheOptions = {}) {
const ttlMs = options.ttlMs ?? SDK_RUNTIME_MODEL_CACHE_TTL_MS;
const now = options.now ?? (() => Date.now());
const entries = new Map<string, SdkRuntimeModelCacheEntry>();
const inFlight = new Map<string, Promise<SdkRuntimeModelCatalog>>();
return {
read(key: string): SdkRuntimeModelCacheEntry | null {
const entry = entries.get(key);
return entry ? { ...cloneCatalog(entry), updatedAt: entry.updatedAt } : null;
},
refresh(
key: string,
load: () => Promise<SdkRuntimeModelCatalog>,
refreshOptions: SdkRuntimeModelRefreshOptions = {},
): Promise<SdkRuntimeModelCatalog> {
const cached = entries.get(key);
if (!refreshOptions.force && cached && now() - cached.updatedAt < ttlMs) {
return Promise.resolve(cloneCatalog(cached));
}
const existing = inFlight.get(key);
if (existing) return existing;
const promise = Promise.resolve(load())
.then((catalog) => {
const normalized = normalizeSdkRuntimeModelCatalog(catalog);
if (normalized.models.length === 0 && !normalized.currentModelId) {
return cached ? cloneCatalog(cached) : cloneCatalog(normalized);
}
entries.set(key, { ...cloneCatalog(normalized), updatedAt: now() });
return cloneCatalog(normalized);
})
.finally(() => {
if (inFlight.get(key) === promise) {
inFlight.delete(key);
}
});
inFlight.set(key, promise);
return promise;
},
};
}
export const sdkRuntimeModelCache = createSdkRuntimeModelCache();
export function modelPresetMatchesId(preset: AgentModelPreset, modelId: string): boolean {
if (preset.thinkingLevels?.length) {
return preset.thinkingLevels.some((level) => `${preset.id}/${level}` === modelId);
+14 -2
View File
@@ -35,6 +35,19 @@ test("DistroAvatar keeps serial hosts on the USB icon", () => {
assert.doesNotMatch(markup, /background-color:#2563EB/i);
});
test("DistroAvatar xs size uses compact corners without appearing circular", () => {
const markup = renderToStaticMarkup(
<DistroAvatar
host={{ ...baseHost, distro: "ubuntu" }}
fallback="U"
size="xs"
/>,
);
assert.match(markup, /h-4 w-4 rounded-\[4px\]/);
assert.doesNotMatch(markup, /rounded-full/);
});
test("DistroAvatar tree size uses compact host icon corners", () => {
const markup = renderToStaticMarkup(
<DistroAvatar
@@ -44,8 +57,7 @@ test("DistroAvatar tree size uses compact host icon corners", () => {
/>,
);
assert.match(markup, /h-6 w-6 rounded/);
assert.doesNotMatch(markup, /rounded-md/);
assert.match(markup, /h-6 w-6 rounded-\[6px\]/);
});
test("DistroAvatar keeps distro icon and applies custom palette color when icon mode is automatic", () => {
+22 -12
View File
@@ -93,14 +93,24 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
const [errored, setErrored] = React.useState(false);
const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default;
// Size variants — rounded rects (same corner style as SessionTabIcon in TopTabItems)
// Corner radii ~27% of edge (matches lg rounded-xl on 44px) — avoids circles on small sizes
const radiusClasses = {
xs: "rounded-[4px]",
sm: "rounded-[5px]",
md: "rounded-lg",
tree: "rounded-[6px]",
log: "rounded-[10px]",
lg: "rounded-xl",
} as const;
// Size variants — rounded rects aligned with vault host list (lg = rounded-xl)
const sizeClasses = {
xs: "h-4 w-4 rounded",
sm: "h-5 w-5 rounded",
md: "h-8 w-8 rounded",
tree: "h-6 w-6 rounded",
log: "h-9 w-9 rounded-xl",
lg: "h-11 w-11 rounded-xl",
xs: "h-4 w-4",
sm: "h-5 w-5",
md: "h-8 w-8",
tree: "h-6 w-6",
log: "h-9 w-9",
lg: "h-11 w-11",
};
const iconSizes = {
xs: "h-2.5 w-2.5",
@@ -111,7 +121,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
lg: "h-5 w-5",
};
const containerClass = sizeClasses[size];
const containerClass = cn(sizeClasses[size], radiusClasses[size]);
const iconSize = iconSizes[size];
// Show USB icon for serial hosts
@@ -119,7 +129,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
return (
<div
className={cn(
"shrink-0 rounded flex items-center justify-center bg-amber-600 text-white dark:bg-amber-400 dark:text-slate-950",
"shrink-0 flex items-center justify-center bg-amber-600 text-white dark:bg-amber-400 dark:text-slate-950",
containerClass,
className,
)}
@@ -135,7 +145,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
return (
<div
className={cn(
"shrink-0 rounded flex items-center justify-center text-white",
"shrink-0 flex items-center justify-center text-white",
containerClass,
className,
)}
@@ -150,7 +160,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
return (
<div
className={cn(
"shrink-0 rounded flex items-center justify-center overflow-hidden",
"shrink-0 flex items-center justify-center overflow-hidden",
containerClass,
!customColor && bg,
className,
@@ -170,7 +180,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
return (
<div
className={cn(
"shrink-0 rounded flex items-center justify-center bg-primary text-primary-foreground",
"shrink-0 flex items-center justify-center bg-primary text-primary-foreground",
containerClass,
className,
)}
+10
View File
@@ -0,0 +1,10 @@
import test from "node:test";
import assert from "node:assert/strict";
import { hasGroupTelnetFields } from "./GroupDetailsPanel.tsx";
test("GroupDetailsPanel treats cleared telnet credentials as explicit settings", () => {
assert.equal(hasGroupTelnetFields({ telnetUsername: "" }), true);
assert.equal(hasGroupTelnetFields({ telnetPassword: "" }), true);
assert.equal(hasGroupTelnetFields({}), false);
});
+160 -94
View File
@@ -1,4 +1,5 @@
import {
AlertTriangle,
Check,
Eye,
EyeOff,
@@ -6,6 +7,7 @@ import {
MoreHorizontal,
Palette,
Plus,
Router,
Settings2,
Trash2,
} from "lucide-react";
@@ -16,8 +18,7 @@ import { resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/gro
import {
formatProxyConfigEndpoint,
formatProxyConfigType,
isCompleteProxyConfig,
normalizeManualProxyConfig,
updateProxyConfigField,
} from "../domain/proxyProfiles";
import {
EnvVar,
@@ -34,24 +35,36 @@ import {
EnvVarsPanel,
HostDetailsSection,
HostDetailsSettingRow,
HostDetailsOverrideReset,
ProxyPanel,
} from "./host-details";
import {
AsidePanel,
AsidePanelContent,
type AsidePanelLayout,
type AsidePanelResizeProps,
} from "./ui/aside-panel";
import { Button } from "./ui/button";
import { Combobox } from "./ui/combobox";
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
import { Input } from "./ui/input";
import { Switch } from "./ui/switch";
import { TerminalFontSelect } from "./settings/TerminalFontSelect";
import { useAvailableFonts } from "../application/state/fontStore";
import { toast } from "./ui/toast";
import { GroupSshSettingsSection } from "./GroupSshSettingsSection";
import { prepareProxyConfigForSave } from "./HostDetailsPanel.helpers";
type SubPanel = "none" | "proxy" | "chain" | "env-vars" | "theme-select";
const ToggleRow: React.FC<{ label: string; hint?: React.ReactNode; enabled: boolean; onToggle: () => void }> = ({ label, hint, enabled, onToggle }) => {
return (
<HostDetailsSettingRow label={label} hint={hint}>
<Switch checked={enabled} onCheckedChange={() => onToggle()} />
</HostDetailsSettingRow>
);
};
interface GroupDetailsPanelProps {
groupPath: string;
config: GroupConfig | undefined;
@@ -68,11 +81,19 @@ interface GroupDetailsPanelProps {
layout?: AsidePanelLayout;
}
const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
type GroupDetailsPanelPropsWithResize = GroupDetailsPanelProps & AsidePanelResizeProps;
export const hasGroupTelnetFields = (c: Partial<GroupConfig>): boolean =>
c.telnetPort !== undefined ||
c.telnetUsername !== undefined ||
c.telnetPassword !== undefined ||
c.telnetEnabled === true;
const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
groupPath,
config,
availableKeys,
identities: _identities,
identities,
proxyProfiles = [],
allHosts,
groups,
@@ -82,8 +103,16 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
onSave,
onCancel,
layout = "overlay",
resizable,
persistWidthStorageKey,
resizeAriaLabel,
}) => {
const { t } = useI18n();
const asideResizeProps = {
resizable,
persistWidthStorageKey,
resizeAriaLabel,
};
const availableFonts = useAvailableFonts();
const originalName = groupPath.includes("/")
@@ -104,17 +133,16 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
const hasSshFields = (c: Partial<GroupConfig>) =>
c.protocol === 'ssh' ||
c.port !== undefined || !!c.username || !!c.password || !!c.identityFileId ||
c.deviceType !== undefined ||
c.agentForwarding !== undefined || c.authMethod !== undefined || !!c.identityId ||
!!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.legacyAlgorithms !== undefined || c.skipEcdsaHostKey !== undefined || c.algorithms !== undefined || c.backspaceBehavior !== undefined ||
(c.environmentVariables && c.environmentVariables.length > 0) ||
c.moshEnabled !== undefined || !!c.moshServerPath ||
c.etEnabled !== undefined || c.etPort !== undefined ||
(c.identityFilePaths && c.identityFilePaths.length > 0);
const hasTelnetFields = (c: Partial<GroupConfig>) =>
c.telnetPort !== undefined || !!c.telnetUsername || !!c.telnetPassword || c.telnetEnabled === true;
const [sshEnabled, setSshEnabled] = useState(() => hasSshFields(config || {}));
const [telnetEnabled, setTelnetEnabled] = useState(() => hasTelnetFields(config || {}));
const [telnetEnabled, setTelnetEnabled] = useState(() => hasGroupTelnetFields(config || {}));
// Sub-panel state
const [activeSubPanel, setActiveSubPanel] = useState<SubPanel>("none");
@@ -164,6 +192,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
delete next.identityId;
delete next.identityFileId;
delete next.identityFilePaths;
delete next.deviceType;
delete next.agentForwarding;
delete next.startupCommand;
delete next.legacyAlgorithms;
@@ -198,18 +227,12 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
// Proxy helpers
const updateProxyConfig = useCallback(
(field: keyof ProxyConfig, value: string | number) => {
(field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => {
setForm((prev) => {
const { proxyProfileId: _proxyProfileId, ...rest } = prev;
return {
...rest,
proxyConfig: {
type: prev.proxyConfig?.type || "http",
host: prev.proxyConfig?.host || "",
port: prev.proxyConfig?.port || 8080,
...prev.proxyConfig,
[field]: value,
},
proxyConfig: updateProxyConfigField(prev.proxyConfig, field, value),
};
});
},
@@ -342,6 +365,11 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
if (!parentGroup || groupConfigs.length === 0) return false;
return !!resolveGroupDefaults(parentGroup, groupConfigs).skipEcdsaHostKey;
}, [groupConfigs, parentGroup]);
const inheritedDeviceType = useMemo(() => {
if (!parentGroup || groupConfigs.length === 0) return undefined;
return resolveGroupDefaults(parentGroup, groupConfigs).deviceType;
}, [groupConfigs, parentGroup]);
const effectiveDeviceType = form.deviceType ?? inheritedDeviceType;
const effectiveThemeId = form.themeOverride === false
? inheritedThemeId
: (form.theme || inheritedThemeId);
@@ -355,19 +383,31 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
setNameError(t("vault.groups.errors.invalidChars"));
return;
}
const normalizedProxyConfig = normalizeManualProxyConfig(form.proxyConfig);
if (normalizedProxyConfig && !isCompleteProxyConfig(normalizedProxyConfig)) {
toast.error(
normalizedProxyConfig.host ? t("proxyProfiles.error.port") : t("hostDetails.proxyPanel.error.required"),
);
setActiveSubPanel("proxy");
return;
}
if (sshEnabled && hasMissingProxyProfile) {
toast.error(t("hostDetails.proxyPanel.missingSaved"));
const proxySave = sshEnabled
? prepareProxyConfigForSave({
proxyConfig: form.proxyConfig,
proxyProfileId: form.proxyProfileId,
proxyProfiles,
identities,
})
: null;
if (proxySave?.error) {
const messageKey = proxySave.error === "port"
? "proxyProfiles.error.port"
: proxySave.error === "required"
? "hostDetails.proxyPanel.error.required"
: proxySave.error === "missingSaved"
? "hostDetails.proxyPanel.missingSaved"
: proxySave.error === "missingIdentity"
? "hostDetails.proxyPanel.missingIdentity"
: proxySave.error === "incompleteIdentity"
? "hostDetails.proxyPanel.incompleteIdentity"
: "hostDetails.proxyPanel.unreadableIdentity";
toast.error(t(messageKey));
setActiveSubPanel("proxy");
return;
}
const normalizedProxyConfig = proxySave?.normalizedProxyConfig;
setNameError(null);
const newPath = parentGroup
@@ -387,6 +427,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
...(form.identityId !== undefined && { identityId: form.identityId }),
...(form.identityFileId !== undefined && { identityFileId: form.identityFileId }),
...(form.identityFilePaths !== undefined && { identityFilePaths: form.identityFilePaths }),
...(form.deviceType !== undefined && { deviceType: form.deviceType }),
...(form.agentForwarding !== undefined && { agentForwarding: form.agentForwarding }),
...(form.startupCommand !== undefined && { startupCommand: form.startupCommand }),
...(form.legacyAlgorithms !== undefined && { legacyAlgorithms: form.legacyAlgorithms }),
@@ -437,6 +478,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
<ProxyPanel
proxyConfig={form.proxyConfig}
proxyProfiles={proxyProfiles}
identities={identities}
selectedProxyProfileId={form.proxyProfileId}
onUpdateProxy={updateProxyConfig}
onSelectProxyProfile={selectProxyProfile}
@@ -444,6 +486,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
layout={layout}
{...asideResizeProps}
/>
);
}
@@ -462,6 +505,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
layout={layout}
{...asideResizeProps}
/>
);
}
@@ -490,6 +534,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
layout={layout}
{...asideResizeProps}
/>
);
}
@@ -511,6 +556,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
onBack={() => setActiveSubPanel("none")}
showBackButton={true}
layout={layout}
{...asideResizeProps}
/>
);
}
@@ -529,6 +575,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
dataSection="group-details-panel"
title={t("vault.groups.details")}
layout={layout}
{...asideResizeProps}
actions={
<Button
variant="ghost"
@@ -594,6 +641,28 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
chainedHosts={chainedHosts}
/>
{sshEnabled && (!form.protocol || form.protocol === "ssh") && !form.moshEnabled && !form.etEnabled && (
<HostDetailsSection
icon={<Router size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.deviceType")}
>
<ToggleRow
label={t("hostDetails.deviceType")}
hint={t("hostDetails.deviceType.desc")}
enabled={effectiveDeviceType === "network"}
onToggle={() => update("deviceType", effectiveDeviceType === "network" ? "general" : "network")}
/>
{effectiveDeviceType === "network" && (
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
{t("hostDetails.deviceType.warning")}
</p>
</div>
)}
</HostDetailsSection>
)}
{/* Telnet Section (if enabled) */}
{telnetEnabled && (
<HostDetailsSection
@@ -642,7 +711,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
<Input
placeholder={t("hostDetails.username.placeholder")}
value={form.telnetUsername || ""}
onChange={(e) => update("telnetUsername", e.target.value || undefined)}
onChange={(e) => update("telnetUsername", e.target.value)}
className="h-10"
/>
<div className="relative">
@@ -650,7 +719,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
placeholder={t("hostDetails.password.placeholder")}
type={showTelnetPassword ? "text" : "password"}
value={form.telnetPassword || ""}
onChange={(e) => update("telnetPassword", e.target.value || undefined)}
onChange={(e) => update("telnetPassword", e.target.value)}
className="h-10 pr-10"
/>
<button
@@ -684,79 +753,76 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
title={t("vault.groups.details.appearance")}
>
<button
type="button"
className="w-full flex items-center gap-3 p-2 rounded-lg bg-secondary/50 hover:bg-secondary transition-colors text-left"
onClick={() => setActiveSubPanel("theme-select")}
>
<div
className="w-12 h-8 rounded-md border border-border/60 flex items-center justify-center text-[6px] font-mono overflow-hidden"
style={{
backgroundColor:
customThemeStore.getThemeById(effectiveThemeId)?.colors.background || "#100F0F",
color:
customThemeStore.getThemeById(effectiveThemeId)?.colors.foreground || "#CECDC3",
}}
<div className="flex w-full items-center gap-1 rounded-lg bg-secondary/50 p-2 transition-colors hover:bg-secondary">
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3 text-left"
onClick={() => setActiveSubPanel("theme-select")}
>
<div className="p-0.5">
<div
style={{
color: customThemeStore.getThemeById(effectiveThemeId)?.colors.green,
}}
>
$
<div
className="w-12 h-8 rounded-md border border-border/60 flex items-center justify-center text-[6px] font-mono overflow-hidden shrink-0"
style={{
backgroundColor:
customThemeStore.getThemeById(effectiveThemeId)?.colors.background || "#100F0F",
color:
customThemeStore.getThemeById(effectiveThemeId)?.colors.foreground || "#CECDC3",
}}
>
<div className="p-0.5">
<div
style={{
color: customThemeStore.getThemeById(effectiveThemeId)?.colors.green,
}}
>
$
</div>
</div>
</div>
</div>
<span className="text-sm flex-1">
{customThemeStore.getThemeById(effectiveThemeId)?.name || "Flexoki Dark"}
</span>
</button>
{hasActiveThemeOverride && (
<Button
variant="ghost"
size="sm"
className="w-full justify-start text-primary"
onClick={() =>
setForm((prev) => ({
...prev,
theme: undefined,
themeOverride: false,
}))
}
>
{t("common.useGlobal")}
</Button>
)}
<span className="text-sm flex-1 truncate">
{customThemeStore.getThemeById(effectiveThemeId)?.name || "Flexoki Dark"}
</span>
</button>
{hasActiveThemeOverride && (
<HostDetailsOverrideReset
label={t("common.useGlobal")}
onClick={(event) => {
event.stopPropagation();
setForm((prev) => ({
...prev,
theme: undefined,
themeOverride: false,
}));
}}
/>
)}
</div>
<TerminalFontSelect
value={form.fontFamily || availableFonts[0]?.id || ""}
fonts={availableFonts}
onChange={(id) => {
setForm((prev) => ({
...prev,
fontFamily: id,
fontFamilyOverride: true,
}));
}}
className="w-full"
/>
{form.fontFamilyOverride && (
<Button
variant="ghost"
size="sm"
className="w-full justify-start text-primary"
onClick={() =>
<div className="flex items-center gap-2">
<TerminalFontSelect
value={form.fontFamily || availableFonts[0]?.id || ""}
fonts={availableFonts}
onChange={(id) => {
setForm((prev) => ({
...prev,
fontFamily: undefined,
fontFamilyOverride: false,
}))
}
>
{t("common.useGlobal")}
</Button>
)}
fontFamily: id,
fontFamilyOverride: true,
}));
}}
className="min-w-0 flex-1"
/>
{form.fontFamilyOverride && (
<HostDetailsOverrideReset
label={t("common.useGlobal")}
onClick={() =>
setForm((prev) => ({
...prev,
fontFamily: undefined,
fontFamilyOverride: false,
}))
}
/>
)}
</div>
{/* Font Size */}
<HostDetailsSettingRow label="Font Size">
+103 -100
View File
@@ -6,7 +6,7 @@ import { MAX_FONT_SIZE, MIN_FONT_SIZE } from "../infrastructure/config/fonts";
import { AlgorithmOverridesPanel } from "./host-details/AlgorithmOverridesPanel";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { HostDetailsSection, HostDetailsSettingRow } from "./host-details";
import { HostDetailsSection, HostDetailsSettingRow, HostDetailsOverrideReset } from "./host-details";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
@@ -46,7 +46,11 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
proxySummaryTooltip,
clearProxyConfig,
groupDefaults,
}) => (
}) => {
const inheritedDeviceType = effectiveGroupDefaults?.deviceType;
const effectiveDeviceType = form.deviceType ?? inheritedDeviceType;
return (
<>
<HostDetailsSection
icon={<Palette size={14} className="text-muted-foreground" />}
@@ -54,110 +58,110 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
>
{/* SSH Theme Selection */}
<button
type="button"
className="w-full flex items-center gap-3 p-2 rounded-lg bg-secondary/50 hover:bg-secondary transition-colors text-left"
onClick={() => setActiveSubPanel("theme-select")}
>
<div
className="w-12 h-8 rounded-md border border-border/60 flex items-center justify-center text-[6px] font-mono overflow-hidden"
style={{
backgroundColor:
customThemeStore.getThemeById(effectiveThemeId)?.colors.background || "#100F0F",
color:
customThemeStore.getThemeById(effectiveThemeId)?.colors.foreground || "#CECDC3",
}}
<div className="flex w-full items-center gap-1 rounded-lg bg-secondary/50 p-2 transition-colors hover:bg-secondary">
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3 text-left"
onClick={() => setActiveSubPanel("theme-select")}
>
<div className="p-0.5">
<div
style={{
color: customThemeStore.getThemeById(effectiveThemeId)?.colors.green,
}}
>
$
<div
className="w-12 h-8 rounded-md border border-border/60 flex items-center justify-center text-[6px] font-mono overflow-hidden shrink-0"
style={{
backgroundColor:
customThemeStore.getThemeById(effectiveThemeId)?.colors.background || "#100F0F",
color:
customThemeStore.getThemeById(effectiveThemeId)?.colors.foreground || "#CECDC3",
}}
>
<div className="p-0.5">
<div
style={{
color: customThemeStore.getThemeById(effectiveThemeId)?.colors.green,
}}
>
$
</div>
</div>
</div>
</div>
<span className="text-sm flex-1">
{customThemeStore.getThemeById(effectiveThemeId)?.name || "Flexoki Dark"}
</span>
</button>
{hasEffectiveThemeOverride && (
<Button
variant="ghost"
size="sm"
className="w-full justify-start text-primary"
onClick={() => setForm((prev) => clearHostThemeOverride(prev))}
>
{t("common.useGlobal")}
</Button>
)}
<span className="text-sm flex-1 truncate">
{customThemeStore.getThemeById(effectiveThemeId)?.name || "Flexoki Dark"}
</span>
</button>
{hasEffectiveThemeOverride && (
<HostDetailsOverrideReset
label={t("common.useGlobal")}
onClick={(event) => {
event.stopPropagation();
setForm((prev) => clearHostThemeOverride(prev));
}}
/>
)}
</div>
{/* Font Size */}
<HostDetailsSettingRow label="Font Size">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
if (effectiveFontSize > MIN_FONT_SIZE) {
setForm((prev) => ({
...prev,
fontSize: effectiveFontSize - 1,
fontSizeOverride: true,
}));
}
}}
disabled={effectiveFontSize <= MIN_FONT_SIZE}
className="h-8 w-8 px-0"
>
-
</Button>
<Input
type="number"
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
value={effectiveFontSize}
onChange={(e) => {
const val = parseInt(e.target.value);
if (val >= MIN_FONT_SIZE && val <= MAX_FONT_SIZE) {
setForm((prev) => ({
...prev,
fontSize: val,
fontSizeOverride: true,
}));
}
}}
className="h-8 w-16 text-center"
/>
<span className="text-sm text-muted-foreground">pt</span>
{hasEffectiveFontSizeOverride && (
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
variant="outline"
size="sm"
className="h-8 text-primary"
onClick={() => setForm((prev) => clearHostFontSizeOverride(prev))}
onClick={() => {
if (effectiveFontSize > MIN_FONT_SIZE) {
setForm((prev) => ({
...prev,
fontSize: effectiveFontSize - 1,
fontSizeOverride: true,
}));
}
}}
disabled={effectiveFontSize <= MIN_FONT_SIZE}
className="h-8 w-8 px-0"
>
{t("common.useGlobal")}
-
</Button>
<Input
type="number"
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
value={effectiveFontSize}
onChange={(e) => {
const val = parseInt(e.target.value);
if (val >= MIN_FONT_SIZE && val <= MAX_FONT_SIZE) {
setForm((prev) => ({
...prev,
fontSize: val,
fontSizeOverride: true,
}));
}
}}
className="h-8 w-16 text-center"
/>
<span className="text-sm text-muted-foreground">pt</span>
<Button
variant="outline"
size="sm"
onClick={() => {
if (effectiveFontSize < MAX_FONT_SIZE) {
setForm((prev) => ({
...prev,
fontSize: effectiveFontSize + 1,
fontSizeOverride: true,
}));
}
}}
disabled={effectiveFontSize >= MAX_FONT_SIZE}
className="h-8 w-8 px-0"
>
+
</Button>
</div>
{hasEffectiveFontSizeOverride && (
<HostDetailsOverrideReset
size="sm"
label={t("common.useGlobal")}
onClick={() => setForm((prev) => clearHostFontSizeOverride(prev))}
/>
)}
<Button
variant="outline"
size="sm"
onClick={() => {
if (effectiveFontSize < MAX_FONT_SIZE) {
setForm((prev) => ({
...prev,
fontSize: effectiveFontSize + 1,
fontSizeOverride: true,
}));
}
}}
disabled={effectiveFontSize >= MAX_FONT_SIZE}
className="h-8 w-8 px-0"
>
+
</Button>
</div>
</HostDetailsSettingRow>
</HostDetailsSection>
@@ -176,7 +180,6 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
...prev,
moshEnabled: true,
etEnabled: false,
deviceType: prev.deviceType === 'network' ? undefined : prev.deviceType,
x11Forwarding: undefined,
}));
} else {
@@ -200,7 +203,6 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
...prev,
etEnabled: true,
moshEnabled: false,
deviceType: prev.deviceType === 'network' ? undefined : prev.deviceType,
x11Forwarding: undefined,
}));
} else {
@@ -276,10 +278,10 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
<ToggleRow
label={t("hostDetails.deviceType")}
hint={t("hostDetails.deviceType.desc")}
enabled={form.deviceType === 'network'}
onToggle={() => update("deviceType", form.deviceType === 'network' ? undefined : 'network')}
enabled={effectiveDeviceType === 'network'}
onToggle={() => update("deviceType", effectiveDeviceType === 'network' ? 'general' : 'network')}
/>
{form.deviceType === 'network' && (
{effectiveDeviceType === 'network' && (
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
@@ -627,4 +629,5 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
/>
</HostDetailsSection>
</>
);
);
};
+9 -20
View File
@@ -1,7 +1,6 @@
import React from "react";
import { ChevronDown, Eye, EyeOff, FileKey, FolderLock, FolderOpen, Key, KeyRound, MapPin, Plus, Shield, Trash2, User, X } from "lucide-react";
import { ChevronDown, Eye, EyeOff, FileKey, FolderLock, FolderOpen, Key, KeyRound, MapPin, Plus, Shapes, Shield, Trash2, User, X } from "lucide-react";
import type { Host } from "../types";
import { DistroAvatar } from "./DistroAvatar";
import { HostIconPicker } from "./HostIconPicker";
import { Button } from "./ui/button";
import { Combobox } from "./ui/combobox";
@@ -51,23 +50,12 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
icon={<MapPin size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.address")}
>
<div className="flex items-center gap-2">
<DistroAvatar
host={form as Host}
fallback={
form.label?.slice(0, 2).toUpperCase() ||
form.hostname?.slice(0, 2).toUpperCase() ||
"H"
}
className="h-10 w-10"
/>
<Input
placeholder={t("hostDetails.hostname.placeholder")}
value={form.hostname}
onChange={(e) => update("hostname", e.target.value)}
className="h-10 flex-1"
/>
</div>
<Input
placeholder={t("hostDetails.hostname.placeholder")}
value={form.hostname}
onChange={(e) => update("hostname", e.target.value)}
className="h-10 w-full"
/>
</HostDetailsSection>
<HostDetailsSection
@@ -630,11 +618,12 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
</HostDetailsSection>
<HostDetailsSection
icon={<DistroAvatar host={form as Host} fallback="H" size="sm" />}
icon={<Shapes size={14} className="text-muted-foreground" />}
title={t("hostDetails.icon.sectionTitle")}
hint={t("hostDetails.icon.desc")}
>
<HostIconPicker
previewHost={form as Host}
distroMode={form.distroMode}
manualDistro={form.manualDistro}
effectiveDistro={effectiveFormDistro}
+81 -2
View File
@@ -1,6 +1,18 @@
import type { GroupConfig } from "../domain/models";
import type { Host } from "../types";
import { LINUX_DISTRO_OPTIONS, NETWORK_DEVICE_OPTIONS } from "../domain/host";
import {
findProxyProfile,
hasIncompleteProxyIdentity,
hasMissingProxyIdentity,
hasUnreadableProxyCredential,
isCompleteProxyConfig,
normalizeManualProxyConfig,
} from "../domain/proxyProfiles";
import type { Host, Identity, ProxyConfig, ProxyProfile } from "../types";
import {
LINUX_DISTRO_OPTIONS,
NETWORK_DEVICE_OPTIONS,
POSIX_PLATFORM_OPTIONS,
} from "../domain/host";
export const parseOptionalPortInput = (value: string): number | undefined =>
value ? Number(value) : undefined;
@@ -74,7 +86,74 @@ export const resolveDetailsTelnetPassword = (
? groupDefaults.telnetPassword
: host.password ?? groupDefaults?.password ?? "";
export const prepareTelnetCredentialsForSave = (host: Host): Host =>
host.telnetIdentityId
? {
...host,
telnetUsername: undefined,
telnetPassword: undefined,
}
: host;
export const LINUX_DISTRO_OPTION_IDS = [
...LINUX_DISTRO_OPTIONS,
...POSIX_PLATFORM_OPTIONS,
...NETWORK_DEVICE_OPTIONS,
];
export type ProxySaveValidationError =
| "required"
| "port"
| "missingSaved"
| "missingIdentity"
| "incompleteIdentity"
| "unreadableIdentity";
export const validateProxyConfigForSave = ({
proxyConfig,
proxyProfileId,
proxyProfiles,
identities,
}: {
proxyConfig?: ProxyConfig;
proxyProfileId?: string;
proxyProfiles?: ProxyProfile[];
identities?: Identity[];
}): ProxySaveValidationError | null => {
const selectedProxyProfile = findProxyProfile(proxyProfileId, proxyProfiles ?? []);
if (proxyProfileId && !selectedProxyProfile) return "missingSaved";
const normalizedProxyConfig = normalizeManualProxyConfig(proxyConfig);
if (normalizedProxyConfig && !isCompleteProxyConfig(normalizedProxyConfig)) {
return normalizedProxyConfig.host ? "port" : "required";
}
const effectiveProxyConfig = selectedProxyProfile?.config ?? proxyConfig;
if (hasMissingProxyIdentity(effectiveProxyConfig, identities ?? [])) {
return "missingIdentity";
}
if (hasIncompleteProxyIdentity(effectiveProxyConfig, identities ?? [])) {
return "incompleteIdentity";
}
if (hasUnreadableProxyCredential(effectiveProxyConfig, identities ?? [])) {
return "unreadableIdentity";
}
return null;
};
export const prepareProxyConfigForSave = (params: {
proxyConfig?: ProxyConfig;
proxyProfileId?: string;
proxyProfiles?: ProxyProfile[];
identities?: Identity[];
}): {
error?: ProxySaveValidationError;
normalizedProxyConfig?: ProxyConfig;
} => {
const error = validateProxyConfigForSave(params);
if (error) return { error };
return {
normalizedProxyConfig: normalizeManualProxyConfig(params.proxyConfig),
};
};
@@ -4,11 +4,14 @@ import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import type { Host } from "../types.ts";
import type { Host, Identity } from "../types.ts";
import HostDetailsPanel, { parseOptionalPortInput } from "./HostDetailsPanel.tsx";
import {
prepareProxyConfigForSave,
prepareTelnetCredentialsForSave,
resolvePrimaryProtocolSavePort,
resolvePrimaryProtocolSwitchPort,
validateProxyConfigForSave,
} from "./HostDetailsPanel.helpers.ts";
import { TooltipProvider } from "./ui/tooltip.tsx";
@@ -26,7 +29,10 @@ const hostWithMissingProxyProfile: Host = {
createdAt: 1,
};
const renderHostDetails = (initialData: Host = hostWithMissingProxyProfile) =>
const renderHostDetails = (
initialData: Host = hostWithMissingProxyProfile,
options: { identities?: Identity[] } = {},
) =>
renderToStaticMarkup(
React.createElement(
I18nProvider,
@@ -37,7 +43,7 @@ const renderHostDetails = (initialData: Host = hostWithMissingProxyProfile) =>
React.createElement(HostDetailsPanel, {
initialData,
availableKeys: [],
identities: [],
identities: options.identities ?? [],
proxyProfiles: [],
groups: [],
managedSources: [],
@@ -108,6 +114,60 @@ test("HostDetailsPanel keeps explicitly cleared telnet credentials empty", () =>
assert.doesNotMatch(markup, /placeholder="Telnet Password"[^>]*value="ssh-password"/);
});
test("HostDetailsPanel shows a selected telnet identity instead of manual telnet fields", () => {
const markup = renderHostDetails(
{
...hostWithMissingProxyProfile,
protocol: "telnet",
telnetEnabled: true,
identityId: "ssh-identity",
telnetIdentityId: "telnet-identity",
telnetUsername: undefined,
telnetPassword: undefined,
proxyProfileId: undefined,
},
{
identities: [{
id: "ssh-identity",
label: "SSH login",
username: "ssh-user",
authMethod: "password",
password: "ssh-password",
created: 1,
}, {
id: "telnet-identity",
label: "Telnet login",
username: "telnet-user",
authMethod: "password",
password: "telnet-password",
created: 2,
}],
},
);
assert.match(markup, /telnet-user - Telnet login/);
assert.doesNotMatch(markup, /placeholder="Telnet Username"/);
assert.doesNotMatch(markup, /placeholder="Telnet Password"/);
});
test("prepareTelnetCredentialsForSave preserves selected telnet identity and clears manual telnet fields", () => {
const saved = prepareTelnetCredentialsForSave({
...hostWithMissingProxyProfile,
protocol: "telnet",
telnetEnabled: true,
identityId: "ssh-identity",
telnetIdentityId: "telnet-identity",
telnetUsername: "stale-telnet-user",
telnetPassword: "stale-telnet-password",
proxyProfileId: undefined,
});
assert.equal(saved.identityId, "ssh-identity");
assert.equal(saved.telnetIdentityId, "telnet-identity");
assert.equal(saved.telnetUsername, undefined);
assert.equal(saved.telnetPassword, undefined);
});
test("HostDetailsPanel disables save when hostname is whitespace only", () => {
const markup = renderHostDetails({
...hostWithMissingProxyProfile,
@@ -275,6 +335,138 @@ test("parseOptionalPortInput clears empty port values", () => {
assert.equal(parseOptionalPortInput("2325"), 2325);
});
test("validateProxyConfigForSave rejects missing proxy identities", () => {
assert.equal(
validateProxyConfigForSave({
proxyConfig: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "missing-identity",
},
identities: [],
}),
"missingIdentity",
);
});
test("validateProxyConfigForSave rejects missing identities inside saved proxy profiles", () => {
assert.equal(
validateProxyConfigForSave({
proxyProfileId: "profile-1",
proxyProfiles: [{
id: "profile-1",
label: "Office Proxy",
config: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "missing-identity",
},
createdAt: 1,
}],
identities: [],
}),
"missingIdentity",
);
});
test("validateProxyConfigForSave rejects incomplete proxy identities", () => {
assert.equal(
validateProxyConfigForSave({
proxyConfig: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "identity-1",
},
identities: [{
id: "identity-1",
label: "Proxy login",
username: "proxy-user",
authMethod: "password",
created: 1,
}],
}),
"incompleteIdentity",
);
});
test("validateProxyConfigForSave rejects unreadable proxy identity passwords", () => {
assert.equal(
validateProxyConfigForSave({
proxyConfig: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "identity-1",
},
identities: [{
id: "identity-1",
label: "Proxy login",
username: "proxy-user",
authMethod: "password",
password: "enc:v1:djEwAAAA",
created: 1,
}],
}),
"unreadableIdentity",
);
});
test("prepareProxyConfigForSave returns a normalized proxy config for save handlers", () => {
assert.deepEqual(
prepareProxyConfigForSave({
proxyConfig: {
type: "http",
host: " proxy.example.com ",
port: "3128" as never,
command: "cloudflared access ssh --hostname %h --token secret",
username: " proxy-user ",
password: "proxy-secret",
},
identities: [],
}),
{
normalizedProxyConfig: {
type: "http",
host: "proxy.example.com",
port: 3128,
username: "proxy-user",
password: "proxy-secret",
},
},
);
});
test("prepareProxyConfigForSave returns identity errors before save handlers build output", () => {
assert.deepEqual(
prepareProxyConfigForSave({
proxyProfileId: "profile-1",
proxyProfiles: [{
id: "profile-1",
label: "Office Proxy",
config: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "identity-1",
},
createdAt: 1,
}],
identities: [{
id: "identity-1",
label: "Proxy login",
username: "",
authMethod: "password",
password: "enc:v1:djEwAAAA",
created: 1,
}],
}),
{ error: "incompleteIdentity" },
);
});
test("resolvePrimaryProtocolSwitchPort only migrates opposite protocol defaults", () => {
assert.equal(resolvePrimaryProtocolSwitchPort(22, "telnet", false, false), 23);
assert.equal(resolvePrimaryProtocolSwitchPort(23, "ssh", false, false), 22);
@@ -326,3 +518,34 @@ test("HostDetailsPanel shows color and icon controls in the connection settings"
assert.match(markup, /Blue/);
assert.match(markup, /IP or Hostname/);
});
test("HostDetailsPanel keeps empty notes collapsed below connection settings", () => {
const markup = renderHostDetails({
...hostWithMissingProxyProfile,
proxyProfileId: undefined,
notes: "",
});
assert.match(markup, /aria-label="Show notes editor"/);
assert.doesNotMatch(markup, /placeholder="Hardware, project, customer, region, role\.\.\."/);
assert.ok(
markup.indexOf("IP or Hostname") < markup.indexOf("Notes"),
"expected notes to render after connection settings",
);
});
test("HostDetailsPanel expands notes when the host already has notes", () => {
const markup = renderHostDetails({
...hostWithMissingProxyProfile,
proxyProfileId: undefined,
notes: "Runs nightly backups",
});
assert.match(markup, /aria-label="Hide notes editor"/);
assert.match(markup, /Runs nightly backups/);
assert.match(markup, /placeholder="Hardware, project, customer, region, role\.\.\."/);
assert.ok(
markup.indexOf("IP or Hostname") < markup.indexOf("Notes"),
"expected notes to render after connection settings",
);
});
+229 -69
View File
@@ -20,8 +20,7 @@ import {
import {
formatProxyConfigEndpoint,
formatProxyConfigType,
isCompleteProxyConfig,
normalizeManualProxyConfig,
updateProxyConfigField,
} from "../domain/proxyProfiles";
import { customThemeStore } from "../application/state/customThemeStore";
import {
@@ -30,7 +29,7 @@ import {
resolveHostTerminalFontSize,
resolveHostTerminalThemeId,
} from "../domain/terminalAppearance";
import { EnvVar, GroupConfig, Host, Identity, ManagedSource, ProxyConfig, ProxyProfile, SSHKey } from "../types";
import { EnvVar, GroupConfig, Host, Identity, ManagedSource, ProxyConfig, ProxyProfile, Snippet, SSHKey } from "../types";
import { DISTRO_COLORS, DISTRO_LOGOS } from "./DistroAvatar";
import ThemeSelectPanel from "./ThemeSelectPanel";
import {
@@ -38,6 +37,7 @@ import {
AsidePanelContent,
AsidePanelFooter,
type AsidePanelLayout,
type AsidePanelResizeProps,
} from "./ui/aside-panel";
import { HostDetailsAdvancedSections } from "./HostDetailsAdvancedSections";
import { HostDetailsConnectionSections } from "./HostDetailsConnectionSections";
@@ -49,6 +49,8 @@ import {
resolveDetailsTelnetUsername,
resolvePrimaryProtocolSavePort,
resolvePrimaryProtocolSwitchPort,
prepareTelnetCredentialsForSave,
prepareProxyConfigForSave,
} from "./HostDetailsPanel.helpers";
export { parseOptionalPortInput } from "./HostDetailsPanel.helpers";
import { Button } from "./ui/button";
@@ -65,6 +67,10 @@ import {
ProxyPanel,
} from "./host-details";
import { HostNotesEditor } from "./host/HostNotesEditor";
import { HostDetailsScriptsSection } from "./host/HostDetailsScriptsSection";
import { ensureHostConnectScriptIds, getHostConnectScriptIds, prepareSnippetForHostConnectQueue } from "@/domain/hostConnectScripts.ts";
import { isScriptSnippet } from "@/domain/snippetScript.ts";
import { unlinkHostFromScripts } from "@/domain/snippetTargets.ts";
type CredentialType = "sshid" | "key" | "certificate" | "localKeyFile" | null;
type SubPanel =
@@ -96,9 +102,13 @@ interface HostDetailsPanelProps {
groupConfigs?: GroupConfig[];
layout?: AsidePanelLayout;
onImportKey?: (draft: Partial<SSHKey>) => SSHKey;
snippets?: Snippet[];
onSnippetsChange?: (snippets: Snippet[]) => void;
}
const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
type HostDetailsPanelPropsWithResize = HostDetailsPanelProps & AsidePanelResizeProps;
const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
initialData,
availableKeys,
identities,
@@ -118,8 +128,18 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
groupConfigs = [],
layout = "overlay",
onImportKey,
snippets = [],
onSnippetsChange,
resizable,
persistWidthStorageKey,
resizeAriaLabel,
}) => {
const { t } = useI18n();
const asideResizeProps = {
resizable,
persistWidthStorageKey,
resizeAriaLabel,
};
const { checkSshAgent } = useApplicationBackend();
const [form, setForm] = useState<Host>(
() =>
@@ -152,6 +172,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
const [showPassword, setShowPassword] = useState(false);
const [showTelnetPassword, setShowTelnetPassword] = useState(false);
const [showAlgorithmOverrides, setShowAlgorithmOverrides] = useState(false);
const [showNotesEditor, setShowNotesEditor] = useState(() => Boolean(initialData?.notes?.trim()));
const [newKeyFilePath, setNewKeyFilePath] = useState("");
const [pendingReferenceKeyPath, setPendingReferenceKeyPath] = useState<string | null>(null);
@@ -175,15 +196,39 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
const [groupInputValue, setGroupInputValue] = useState(form.group || "");
const initialHostId = initialData?.id;
useEffect(() => {
if (initialData) {
setForm(normalizePrimaryTelnetState(initialData));
setGroupInputValue(initialData.group || "");
setPendingReferenceKeyPath(null);
setShowPassword(false);
setShowTelnetPassword(false);
}
}, [initialData]);
if (!initialData) return;
const normalized = normalizePrimaryTelnetState(initialData);
setForm(
snippets.length > 0
? ensureHostConnectScriptIds(normalized, snippets)
: normalized,
);
setGroupInputValue(initialData.group || "");
setPendingReferenceKeyPath(null);
setShowPassword(false);
setShowTelnetPassword(false);
setShowNotesEditor(Boolean(normalized.notes?.trim()));
// Reset only when opening a different host — not when snippets list updates.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialHostId]);
useEffect(() => {
if (!initialData || snippets.length === 0) return;
setForm((prev) => {
if (prev.id !== initialData.id) return prev;
const synced = ensureHostConnectScriptIds(prev, snippets);
if (
synced.connectScriptIds === prev.connectScriptIds
&& synced.loginScriptId === prev.loginScriptId
) {
return prev;
}
return synced;
});
}, [initialData, snippets]);
const update = <K extends keyof Host>(key: K, value: Host[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
@@ -283,18 +328,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
}, []);
const updateProxyConfig = useCallback(
(field: keyof ProxyConfig, value: string | number) => {
(field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => {
setForm((prev) => {
const { proxyProfileId: _proxyProfileId, ...rest } = prev;
return {
...rest,
proxyConfig: {
type: prev.proxyConfig?.type || "http",
host: prev.proxyConfig?.host || "",
port: prev.proxyConfig?.port || 8080,
...prev.proxyConfig,
[field]: value,
},
proxyConfig: updateProxyConfigField(prev.proxyConfig, field, value),
} as Host;
});
},
@@ -364,19 +403,29 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
const handleSubmit = () => {
const hostname = form.hostname.trim();
if (!hostname) return;
const normalizedProxyConfig = normalizeManualProxyConfig(form.proxyConfig);
if (normalizedProxyConfig && !isCompleteProxyConfig(normalizedProxyConfig)) {
toast.error(
normalizedProxyConfig.host ? t("proxyProfiles.error.port") : t("hostDetails.proxyPanel.error.required"),
);
setActiveSubPanel("proxy");
return;
}
if (hasMissingProxyProfile) {
toast.error(t("hostDetails.proxyPanel.missingSaved"));
const proxySave = prepareProxyConfigForSave({
proxyConfig: form.proxyConfig,
proxyProfileId: form.proxyProfileId,
proxyProfiles,
identities,
});
if (proxySave.error) {
const messageKey = proxySave.error === "port"
? "proxyProfiles.error.port"
: proxySave.error === "required"
? "hostDetails.proxyPanel.error.required"
: proxySave.error === "missingSaved"
? "hostDetails.proxyPanel.missingSaved"
: proxySave.error === "missingIdentity"
? "hostDetails.proxyPanel.missingIdentity"
: proxySave.error === "incompleteIdentity"
? "hostDetails.proxyPanel.incompleteIdentity"
: "hostDetails.proxyPanel.unreadableIdentity";
toast.error(t(messageKey));
setActiveSubPanel("proxy");
return;
}
const normalizedProxyConfig = proxySave.normalizedProxyConfig;
let finalLabel = form.label?.trim() || hostname;
const finalGroup = groupInputValue.trim() || form.group || "";
@@ -418,7 +467,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
password: form.savePassword === false ? undefined : form.password,
managedSourceId: finalManagedSourceId,
};
cleaned = normalizePrimaryTelnetState(cleaned);
cleaned = prepareTelnetCredentialsForSave(normalizePrimaryTelnetState(cleaned));
if (
onImportKey &&
pendingReferenceKeyPath &&
@@ -464,6 +513,38 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
if ((cleaned.protocol && cleaned.protocol !== "ssh") || cleaned.moshEnabled || cleaned.etEnabled) {
delete cleaned.x11Forwarding;
}
if (onSnippetsChange && initialData) {
const hostId = cleaned.id;
const savedQueueIds = initialData.connectScriptIds ?? getHostConnectScriptIds(initialData, snippets);
const finalQueueIds = cleaned.connectScriptIds ?? getHostConnectScriptIds(cleaned, snippets);
const savedSet = new Set(savedQueueIds);
const finalSet = new Set(finalQueueIds);
let nextSnippets = snippets;
let changed = false;
for (const scriptId of finalQueueIds) {
if (!savedSet.has(scriptId)) {
nextSnippets = nextSnippets.map((item) => (
item.id === scriptId && isScriptSnippet(item)
? prepareSnippetForHostConnectQueue(item, hostId)
: item
));
changed = true;
}
}
for (const scriptId of savedQueueIds) {
if (!finalSet.has(scriptId)) {
const unlinked = unlinkHostFromScripts(nextSnippets, hostId, scriptId);
if (unlinked !== nextSnippets) {
nextSnippets = unlinked;
changed = true;
}
}
}
if (changed) {
onSnippetsChange(nextSnippets);
}
}
onSave(cleaned);
};
@@ -518,6 +599,21 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
return identities.find((i) => i.id === form.identityId);
}, [form.identityId, identities]);
const selectedTelnetIdentity = useMemo(() => {
if (!form.telnetIdentityId) return undefined;
return identities.find((i) => i.id === form.telnetIdentityId);
}, [form.telnetIdentityId, identities]);
const telnetIdentityOptions: ComboboxOption[] = useMemo(
() =>
identities.map((identity) => ({
value: identity.id,
label: identity.label,
sublabel: identity.username,
})),
[identities],
);
const filteredIdentitySuggestions = useMemo(() => {
if (selectedIdentity) return [];
const q = (form.username || "").toLowerCase().trim();
@@ -563,6 +659,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
setIdentitySuggestionsOpen(false);
}, []);
const updateTelnetIdentity = useCallback((identityId: string) => {
setForm((prev) => ({
...prev,
telnetIdentityId: identityId || undefined,
...(identityId
? {
telnetUsername: undefined,
telnetPassword: undefined,
}
: {}),
}));
}, []);
if (activeSubPanel === "create-group") {
return (
<CreateGroupPanel
@@ -575,6 +684,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
layout={layout}
{...asideResizeProps}
/>
);
}
@@ -584,6 +694,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<ProxyPanel
proxyConfig={form.proxyConfig}
proxyProfiles={proxyProfiles}
identities={identities}
selectedProxyProfileId={form.proxyProfileId}
onUpdateProxy={updateProxyConfig}
onSelectProxyProfile={selectProxyProfile}
@@ -591,6 +702,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
layout={layout}
{...asideResizeProps}
/>
);
}
@@ -609,6 +721,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
layout={layout}
{...asideResizeProps}
/>
);
}
@@ -637,6 +750,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
layout={layout}
{...asideResizeProps}
/>
);
}
@@ -658,6 +772,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onBack={() => setActiveSubPanel("none")}
showBackButton={true}
layout={layout}
{...asideResizeProps}
/>
);
}
@@ -694,6 +809,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onBack={() => setActiveSubPanel("none")}
showBackButton={true}
layout={layout}
{...asideResizeProps}
/>
);
}
@@ -705,6 +821,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
width="w-[420px]"
layout={layout}
dataSection="host-details-panel"
{...asideResizeProps}
title={
initialData ? t("hostDetails.title.details") : t("hostDetails.title.new")
}
@@ -784,19 +901,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</div>
</HostDetailsSection>
<HostDetailsSection
icon={<FileText size={14} className="text-muted-foreground shrink-0" />}
title={t("hostDetails.notes.label")}
hint={t("hostDetails.notes.help")}
>
<HostNotesEditor
panelKey={form.id}
value={form.notes ?? ""}
onChange={(notes) => update("notes", notes)}
showHeader={false}
/>
</HostDetailsSection>
<HostDetailsConnectionSections
t={t}
form={form}
@@ -828,6 +932,42 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
getDistroOptionLabel={getDistroOptionLabel}
/>
{onSnippetsChange ? (
<HostDetailsScriptsSection
host={form}
onHostChange={setForm}
snippets={snippets}
t={t}
/>
) : null}
<HostDetailsSection
icon={<FileText size={14} className="text-muted-foreground shrink-0" />}
title={t("hostDetails.notes.label")}
hint={t("hostDetails.notes.help")}
action={
<Switch
checked={showNotesEditor}
onCheckedChange={setShowNotesEditor}
aria-label={
showNotesEditor
? t("hostDetails.notes.toggle.hide")
: t("hostDetails.notes.toggle.show")
}
/>
}
>
{showNotesEditor ? (
<HostNotesEditor
panelKey={form.id}
value={form.notes ?? ""}
onChange={(notes) => update("notes", notes)}
showHeader={false}
defaultTab="edit"
/>
) : null}
</HostDetailsSection>
<HostDetailsAdvancedSections
t={t}
form={form}
@@ -908,32 +1048,52 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</div>
<p className="text-xs font-semibold">{t("hostDetails.telnet.credentials")}</p>
<Input
placeholder={t("hostDetails.telnet.username")}
value={effectiveTelnetUsername}
onChange={(e) =>
update("telnetUsername" as keyof Host, e.target.value)
}
className="h-10"
/>
<div className="relative">
<Input
placeholder={t("hostDetails.telnet.password")}
type={showTelnetPassword ? "text" : "password"}
value={effectiveTelnetPassword}
onChange={(e) =>
update("telnetPassword" as keyof Host, e.target.value)
}
className="h-10 pr-10"
{identities.length > 0 && (
<Combobox
options={telnetIdentityOptions}
value={form.telnetIdentityId || ""}
onValueChange={updateTelnetIdentity}
placeholder={t("hostDetails.telnet.identity.placeholder")}
emptyText={t("common.noResultsFound")}
className="w-full"
/>
<button
type="button"
onClick={() => setShowTelnetPassword(!showTelnetPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
>
{showTelnetPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
)}
{form.telnetIdentityId ? (
<div className="text-xs text-muted-foreground">
{selectedTelnetIdentity
? `${selectedTelnetIdentity.username} - ${selectedTelnetIdentity.label}`
: t("hostDetails.identity.missing")}
</div>
) : (
<>
<Input
placeholder={t("hostDetails.telnet.username")}
value={effectiveTelnetUsername}
onChange={(e) =>
update("telnetUsername" as keyof Host, e.target.value)
}
className="h-10"
/>
<div className="relative">
<Input
placeholder={t("hostDetails.telnet.password")}
type={showTelnetPassword ? "text" : "password"}
value={effectiveTelnetPassword}
onChange={(e) =>
update("telnetPassword" as keyof Host, e.target.value)
}
className="h-10 pr-10"
/>
<button
type="button"
onClick={() => setShowTelnetPassword(!showTelnetPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
>
{showTelnetPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</>
)}
<Input
placeholder={groupDefaults?.charset || t("hostDetails.charset.placeholder")}
+21
View File
@@ -10,8 +10,10 @@ import {
isHostIconId,
resolveHostIconDefaultColorHex,
} from "../domain/hostIcon";
import type { Host } from "../domain/models";
import type { HostIconColorId, HostIconColorMode, HostIconId, HostIconMode } from "../domain/models";
import { cn } from "../lib/utils";
import { DistroAvatar } from "./DistroAvatar";
import { renderHostIconGlyph } from "./hostIconRenderer";
import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
@@ -24,6 +26,7 @@ type DistroOption = {
};
type HostIconPickerProps = {
previewHost?: Host;
distroMode?: "auto" | "manual";
manualDistro?: string;
effectiveDistro?: string;
@@ -94,6 +97,7 @@ const colorHexForId = (colorId?: HostIconColorId) =>
HOST_ICON_COLORS.find((color) => color.id === colorId)?.hex || HOST_ICON_COLORS[0].hex;
export const HostIconPicker: React.FC<HostIconPickerProps> = ({
previewHost,
distroMode,
manualDistro,
effectiveDistro,
@@ -205,6 +209,23 @@ export const HostIconPicker: React.FC<HostIconPickerProps> = ({
return (
<div className="space-y-4">
{previewHost ? (
<div
className="flex items-center justify-center rounded-xl border border-border/60 bg-secondary/20 py-5"
aria-label={t("hostDetails.icon.preview")}
>
<DistroAvatar
host={previewHost}
fallback={
previewHost.label?.slice(0, 2).toUpperCase()
|| previewHost.hostname?.slice(0, 2).toUpperCase()
|| "H"
}
size="lg"
/>
</div>
) : null}
<div className="grid gap-2 md:grid-cols-2">
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.icon.source")}</span>
+1 -3
View File
@@ -457,9 +457,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
<div className="mr-2 h-4 w-4 flex-shrink-0" />
)}
icon={(
<div className="mr-2 flex-shrink-0">
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="tree" />
</div>
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="tree" />
)}
content={(
<div className="min-w-0 flex-1 leading-tight">
+63 -53
View File
@@ -123,6 +123,57 @@ interface HostItemProps {
onConvertToHost: (knownHost: KnownHost) => void;
}
const knownHostActionButtonClass =
"h-8 w-8 shrink-0 opacity-0 transition-opacity group-hover:opacity-100";
const KnownHostItemActions: React.FC<{
knownHost: KnownHost;
converted: boolean;
onDelete: (id: string) => void;
onConvertToHost: (knownHost: KnownHost) => void;
}> = ({ knownHost, converted, onDelete, onConvertToHost }) => {
const { t } = useI18n();
return (
<div className="flex shrink-0 items-center gap-1">
{!converted && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={knownHostActionButtonClass}
onClick={(e) => {
e.stopPropagation();
onConvertToHost(knownHost);
}}
>
<ArrowRight size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("action.convertToHost")}</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(knownHostActionButtonClass, "text-destructive hover:text-destructive hover:bg-destructive/10")}
onClick={(e) => {
e.stopPropagation();
onDelete(knownHost.id);
}}
>
<Trash2 size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("action.remove")}</TooltipContent>
</Tooltip>
</div>
);
};
const HostItem = React.memo<HostItemProps>(
({ knownHost, converted, viewMode, reorderProps, onDelete, onConvertToHost }) => {
const { t } = useI18n();
@@ -141,39 +192,6 @@ const HostItem = React.memo<HostItemProps>(
reorderProps?.className,
)}
>
{/* Quick action buttons on hover */}
<div className="absolute top-1 right-1 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{!converted && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="p-1 rounded hover:bg-primary/20 text-primary"
onClick={(e) => {
e.stopPropagation();
onConvertToHost(knownHost);
}}
>
<ArrowRight size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{t("action.convertToHost")}</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
className="p-1 rounded hover:bg-destructive/20 text-destructive"
onClick={(e) => {
e.stopPropagation();
onDelete(knownHost.id);
}}
>
<Trash2 size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{t("action.remove")}</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-3 h-full">
<VaultEntityIcon
className={vaultPrimaryIconClass}
@@ -184,6 +202,12 @@ const HostItem = React.memo<HostItemProps>(
{knownHost.hostname}
</span>
</div>
<KnownHostItemActions
knownHost={knownHost}
converted={converted}
onDelete={onDelete}
onConvertToHost={onConvertToHost}
/>
</div>
</div>
</ContextMenuTrigger>
@@ -226,26 +250,12 @@ const HostItem = React.memo<HostItemProps>(
{knownHost.hostname}
</span>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{!converted && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
onConvertToHost(knownHost);
}}
>
<ArrowRight size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("action.convertToHost")}</TooltipContent>
</Tooltip>
)}
</div>
<KnownHostItemActions
knownHost={knownHost}
converted={converted}
onDelete={onDelete}
onConvertToHost={onConvertToHost}
/>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
+100 -1
View File
@@ -4,7 +4,7 @@ import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import type { ProxyProfile } from "../types.ts";
import type { Identity, ProxyProfile } from "../types.ts";
import { ProxyPanel } from "./host-details/ProxyPanel.tsx";
const proxyProfile: ProxyProfile = {
@@ -18,6 +18,15 @@ const proxyProfile: ProxyProfile = {
createdAt: 1,
};
const proxyIdentity: Identity = {
id: "identity-1",
label: "Proxy login",
username: "proxy-user",
authMethod: "password",
password: "proxy-secret",
created: 1,
};
const renderPanel = (props: Partial<React.ComponentProps<typeof ProxyPanel>> = {}) =>
renderToStaticMarkup(
React.createElement(
@@ -26,6 +35,7 @@ const renderPanel = (props: Partial<React.ComponentProps<typeof ProxyPanel>> = {
React.createElement(ProxyPanel, {
proxyConfig: undefined,
proxyProfiles: [],
identities: [],
selectedProxyProfileId: undefined,
onUpdateProxy: () => {},
onSelectProxyProfile: () => {},
@@ -102,6 +112,79 @@ test("ProxyPanel disables saving invalid manual proxy ports", () => {
assert.match(markup, /disabled=""/);
});
test("ProxyPanel shows a clear missing state for stale proxy identities", () => {
const markup = renderPanel({
proxyConfig: {
type: "http",
host: "manual-proxy.example.com",
port: 3128,
identityId: "missing-identity",
},
identities: [proxyIdentity],
});
assert.match(markup, /Missing keychain identity/);
assert.match(markup, /Username/);
assert.match(markup, /disabled=""/);
});
test("ProxyPanel warns when a saved proxy profile has a stale identity", () => {
const markup = renderPanel({
proxyProfiles: [{
...proxyProfile,
config: {
type: "http",
host: "office-proxy.example.com",
port: 8080,
identityId: "missing-identity",
},
}],
selectedProxyProfileId: proxyProfile.id,
identities: [proxyIdentity],
});
assert.match(markup, /Missing keychain identity/);
assert.match(markup, /disabled=""/);
});
test("ProxyPanel warns when a saved proxy profile has an incomplete identity", () => {
const markup = renderPanel({
proxyProfiles: [{
...proxyProfile,
config: {
type: "http",
host: "office-proxy.example.com",
port: 8080,
identityId: proxyIdentity.id,
},
}],
selectedProxyProfileId: proxyProfile.id,
identities: [{ ...proxyIdentity, password: undefined }],
});
assert.match(markup, /Proxy identity needs a username and password/);
assert.match(markup, /disabled=""/);
});
test("ProxyPanel warns when a saved proxy profile has an unreadable identity password", () => {
const markup = renderPanel({
proxyProfiles: [{
...proxyProfile,
config: {
type: "http",
host: "office-proxy.example.com",
port: 8080,
identityId: proxyIdentity.id,
},
}],
selectedProxyProfileId: proxyProfile.id,
identities: [{ ...proxyIdentity, password: "enc:v1:djEwAAAA" }],
});
assert.match(markup, /Proxy identity password cannot be read/);
assert.match(markup, /disabled=""/);
});
test("ProxyPanel supports custom ProxyCommand settings", () => {
const markup = renderPanel({
proxyConfig: {
@@ -127,3 +210,19 @@ test("ProxyPanel uses a dropdown for proxy type selection", () => {
assert.match(markup, /role="combobox"/);
assert.match(markup, /aria-label="Type"/);
});
test("ProxyPanel offers keychain identities for HTTP and SOCKS5 proxy credentials", () => {
const markup = renderPanel({
proxyConfig: {
type: "socks5",
host: "manual-proxy.example.com",
port: 1080,
identityId: proxyIdentity.id,
},
identities: [proxyIdentity],
});
assert.match(markup, /Keychain identity/);
assert.match(markup, /Proxy login/);
assert.match(markup, /proxy-user/);
});
Loaded 100 of 533 files, more files were not shown because too many files have changed in this diff. Show more