Compare commits

...
195 Commits
Author SHA1 Message Date
陈大猫 e661169b60 Merge pull request #1645 from binaricat/codex/fix-port-forward-jump-auth-scope
fix: route port forward jump auth externally
2026-06-25 10:11:32 +08:00
bincxz 95f39f11b7 fix: route port forward jump auth externally 2026-06-25 10:08:29 +08:00
陈大猫 642905043c Merge pull request #1642 from binaricat/codex/fix-terminal-write-test-raf
fix: guard terminal write coalescing without raf
2026-06-25 09:52:42 +08:00
bincxz 3943b4869f fix: guard terminal write coalescing without raf 2026-06-25 09:52:22 +08:00
陈大猫 ee8687af15 Merge pull request #1641 from binaricat/codex/remove-terminal-background-mirror
fix: remove terminal background mirror
2026-06-25 09:45:09 +08:00
bincxz bf723e7348 fix: remove terminal background mirror 2026-06-25 09:42:19 +08:00
陈大猫andCursor 15baefaa57 fix(deps): resync package-lock.json for npm 10 npm ci (#1639)
Restore peer dependency lock entries removed in #1635 so CI npm ci on
Node 22 succeeds again.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #1612

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

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

* ignore release build artifacts in lint

* restore h3c mpssh banner detection

* detect network device vendor banners

* Map mpSSH banners to HPE iLO

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

Fixes #1614

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

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

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

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

---------

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

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

Fixes #1608

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

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

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

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

---------

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #1594

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

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

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

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

---------

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

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

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

* Skip terminal refocus while app dialogs are open

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

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

---------

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

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

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

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

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

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

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

* fix: address dynamic tab title review findings

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

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

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

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

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

* feat: show coding agent icons on session tabs

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

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

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

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

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

* fix: reset CLI output scanner when provider state clears

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

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

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

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

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

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

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

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

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

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

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

* fix: satisfy eslint for terminal escape stripping regexes

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

---------

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

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

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

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

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

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

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

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

* Improve notes tree drag interactions

* Harden notes tree move behavior

* Refine vault notes interactions

* Address vault notes review feedback

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

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

* Address OSC 7 setup review feedback

* Harden OSC 7 setup command

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

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

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

* test: align SSH key password MFA expectation

---------

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

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

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

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

* test tmux popup proxy coverage

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

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

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

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

两处均补充单测断言。

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

* Prefer sudo for Docker panel commands

* Use pending saved sudo password immediately

* Try plain Docker before sudo fallback

* Detect Docker before sudo fallback

* Add sudo fallback for Docker popup commands

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

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

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

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

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

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

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

* fix: preserve Ctrl-C passthrough for copy shortcut

---------

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

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

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

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

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

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

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

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

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

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

Closes #PR1456 Codex P2 review item.

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

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

This caused duplicate probes that waste SSH channel resources.

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: convert comments to ASCII-only English

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

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

* fix: sidebar inline rename with local state

* fix: add sessionDisplayName to terminalPropsAreEqual comparator

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

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

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

* fix: add toggleWorkspaceViewMode to executeHotkeyActionImpl destructuring

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

* fix: restore truncated ctx object in TerminalView render call

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

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

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

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

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

* fix: validate focusedSessionId before closing in closeSession hotkey

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

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

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

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

PR #1459

* fix: address remaining Codex review issues

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

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

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

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

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

* fix: refine workspace terminal detach interactions

* fix: preserve workspace detach tab ordering

---------

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

Root cause analysis for issue #1453:

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

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

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

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

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

Three remaining issues from PR #1455:

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #1453

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

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

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

This handles DOCKER_HOST, Docker contexts, and rootless Docker.

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

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

Now get() calls notifySession() on expiry, matching the notification
behavior already present in delete().
2026-06-13 08:58:04 +08:00
bincxz b08ddc73b4 Fix Windows PATH refresh precedence 2026-06-12 17:24:54 +08:00
lengyuqu dccd872122 Merge branch 'binaricat:main' into codebuddy 2026-06-12 17:07:20 +08:00
lengyuqu 1e568011f8 修复 windwos 下 codebuddy 自动扫描路径问题 2026-06-12 17:04:33 +08:00
lengyuqu 2318eca54e [fix] 修复 codebuddy 在 windwos 在无法有效使用的问题 2026-06-12 16:45:27 +08:00
lengyuqu ed8a04c84e Merge remote-tracking branch 'upstream/main' into codebuddy 2026-06-12 16:43:56 +08:00
lengyuqu 3d669fffed fix windwos 下 ai 无法选择获取模型的问题 2026-06-12 01:11:21 +08:00
489 changed files with 39950 additions and 3438 deletions

No files matched your search

+5
View File
@@ -27,6 +27,11 @@ jobs:
node-version: 22
cache: npm
- name: Install shell test dependencies
run: |
sudo apt-get update
sudo apt-get install -y fish
- name: Install deps
run: npm ci
+1
View File
@@ -53,6 +53,7 @@ coverage
# AI / Superpowers generated docs (local only)
/docs/superpowers/
/.superpowers/
# Dev-only electron-updater test config (not for production)
/dev-app-update.yml
+125 -20
View File
@@ -24,9 +24,11 @@ import { matchesKeyBinding } from './domain/models';
import { resolveGroupDefaults, applyGroupDefaults } from './domain/groupConfig';
import { upsertKnownHost } from './domain/knownHosts';
import { materializeHostProxyProfile } from './domain/proxyProfiles';
import { buildSshDeepLinkConnectionHost, buildSshDeepLinkHostDraft, findSshDeepLinkHost, parseSshDeepLink } from './domain/sshDeepLink';
import { resolveHostAuth } from './domain/sshAuth';
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
import {
getFollowAppTerminalThemeSelectionUpdate,
mergeTerminalHostUpdate,
type TerminalHostUpdate,
} from './domain/terminalAppearance';
@@ -36,7 +38,7 @@ import { resolveCloseIntent } from './application/state/resolveCloseIntent';
import { resolveSnippetsShortcutIntent } from './application/state/resolveSnippetsShortcutIntent';
import { resolveWindowCommandCloseIntent } from './application/state/windowCommandClose';
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
import { useCustomThemes } from './application/state/customThemeStore';
import { customThemeStore, useCustomThemes } from './application/state/customThemeStore';
import type { SyncPayload } from './domain/sync';
import { applySyncPayload, buildLocalVaultPayload, hasMeaningfulSyncData } from './application/syncPayload';
import {
@@ -57,6 +59,7 @@ import { VaultSection } from './components/VaultView';
import { KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
import { PassphraseRequest } from './components/PassphraseModal';
import { classifyLocalShellType } from './lib/localShell';
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';
@@ -100,11 +103,13 @@ function App({ settings }: { settings: SettingsState }) {
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(null);
// Navigation state for VaultView sections
const [navigateToSection, setNavigateToSection] = useState<VaultSection | null>(null);
const [deepLinkHostDraft, setDeepLinkHostDraft] = useState<Host | null>(null);
// Keyboard-interactive authentication queue (2FA/MFA) - queue-based to handle multiple concurrent sessions
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
// Passphrase request queue for encrypted SSH keys
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
const [pendingNewWindowSession, setPendingNewWindowSession] = useState<OpenSessionInNewWindowPayload | null>(null);
const isPeerSessionWindow = typeof window !== 'undefined' && window.location.hash.startsWith('#/session-window');
const {
theme,
@@ -114,6 +119,8 @@ function App({ settings }: { settings: SettingsState }) {
customAccent,
terminalThemeId,
setTerminalThemeId,
setTerminalThemeDarkId,
setTerminalThemeLightId,
followAppTerminalTheme,
currentTerminalTheme,
terminalFontFamilyId,
@@ -163,6 +170,8 @@ function App({ settings }: { settings: SettingsState }) {
snippets,
customGroups,
snippetPackages,
notes,
noteGroups,
knownHosts,
shellHistory,
connectionLogs,
@@ -174,6 +183,8 @@ function App({ settings }: { settings: SettingsState }) {
updateProxyProfiles,
updateSnippets,
updateSnippetPackages,
updateNotes,
updateNoteGroups,
updateCustomGroups,
updateKnownHosts,
updateManagedSources,
@@ -216,6 +227,7 @@ function App({ settings }: { settings: SettingsState }) {
sessionRenameValue,
setSessionRenameValue,
startSessionRename,
renameSessionInline,
submitSessionRename,
resetSessionRename,
workspaceRenameTarget,
@@ -235,6 +247,7 @@ function App({ settings }: { settings: SettingsState }) {
createWorkspaceWithHosts,
createWorkspaceFromSessions,
addSessionToWorkspace,
removeSessionFromWorkspace,
appendHostToWorkspace,
appendLocalTerminalToWorkspace,
createWorkspaceFromTargets,
@@ -256,7 +269,10 @@ function App({ settings }: { settings: SettingsState }) {
closeLogView,
copySession,
createSessionFromCloneSource,
} = useSessionState();
updateSessionRestoreCwd,
updateSessionDynamicTitle,
updateSessionCodingCliProvider,
} = useSessionState({ persistSessionRestore: !isPeerSessionWindow });
const handleRunSnippet = useCallback(
async (snippet: Snippet, targetHosts: Host[]) => {
@@ -304,7 +320,7 @@ function App({ settings }: { settings: SettingsState }) {
if (!payload?.sourceSession) return;
setPendingNewWindowSession(payload);
});
}, []);
}, [isPeerSessionWindow]);
useEffect(() => {
if (!isVaultInitialized || !pendingNewWindowSession?.sourceSession) return;
@@ -353,6 +369,8 @@ function App({ settings }: { settings: SettingsState }) {
snippets,
customGroups,
snippetPackages,
notes,
noteGroups,
knownHosts: getEffectiveKnownHosts(knownHosts),
groupConfigs,
},
@@ -366,6 +384,8 @@ function App({ settings }: { settings: SettingsState }) {
keys,
proxyProfiles,
knownHosts,
noteGroups,
notes,
portForwardingRulesForSync,
snippetPackages,
snippets,
@@ -401,7 +421,7 @@ function App({ settings }: { settings: SettingsState }) {
// hydrated enough to be backed up (or the user genuinely stays empty,
// in which case the effect continues to no-op).
useEffect(() => {
if (!isVaultInitialized || versionBackupAttemptedRef.current) return;
if (isPeerSessionWindow || !isVaultInitialized || versionBackupAttemptedRef.current) return;
const payload = buildCurrentSyncPayloadRef.current();
if (!hasMeaningfulSyncData(payload)) return;
versionBackupAttemptedRef.current = true;
@@ -425,7 +445,7 @@ function App({ settings }: { settings: SettingsState }) {
return () => {
cancelled = true;
};
}, [isVaultInitialized, hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, knownHosts]);
}, [isPeerSessionWindow, isVaultInitialized, hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, notes, noteGroups, knownHosts]);
// Memoized "apply a remote payload safely" callback. Stable identity
// across renders so useAutoSync's `syncNow` useCallback doesn't rebuild
@@ -455,6 +475,7 @@ function App({ settings }: { settings: SettingsState }) {
// Auto-sync hook for cloud sync
const { syncNow: handleSyncNow, emptyVaultConflict, resolveEmptyVaultConflict } = useAutoSync({
enabled: !isPeerSessionWindow,
hosts,
keys,
identities,
@@ -462,6 +483,8 @@ function App({ settings }: { settings: SettingsState }) {
snippets,
customGroups,
snippetPackages,
notes,
noteGroups,
portForwardingRules: portForwardingRulesForSync,
groupConfigs,
settingsVersion: settings.settingsVersion,
@@ -481,6 +504,7 @@ function App({ settings }: { settings: SettingsState }) {
// Update check hook - checks for new versions on startup
const { updateState, dismissUpdate, installUpdate } = useUpdateCheck({
enabled: !isPeerSessionWindow,
// Install blocked because an editor has unsaved changes (#1215). The main
// process broadcasts this; show an actionable toast telling the user to save
// and click "Restart Now" again.
@@ -495,9 +519,10 @@ function App({ settings }: { settings: SettingsState }) {
const _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleGlobalHotkeyKeyDownImpl(() => ({ HOTKEY_DEBUG, closeTabKeyStr, e, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding }), e); });
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleEscapeKeyDownImpl(() => ({ e, isQuickSwitcherOpen, setIsQuickSwitcherOpen }), e); });
useAppStartupEffects({ dismissUpdate, groupConfigs, hosts, identities, installUpdate, isVaultInitialized, keys, openSettingsWindow, portForwardingRules, proxyProfiles, sessions, setKeyboardInteractiveQueue, t, terminalSettings, updateState, workspaces });
useAppStartupEffects({ dismissUpdate, enabled: !isPeerSessionWindow, groupConfigs, hosts, identities, installUpdate, isVaultInitialized, keys, openSettingsWindow, portForwardingRules, proxyProfiles, sessions, setKeyboardInteractiveQueue, t, terminalSettings, updateState, workspaces });
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get();
if (!bridge?.onTrayFocusSession || !bridge?.onTrayTogglePortForward) return;
@@ -512,9 +537,10 @@ function App({ settings }: { settings: SettingsState }) {
unsubscribeFocus?.();
unsubscribeToggle?.();
};
}, []);
}, [isPeerSessionWindow]);
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get();
if (!bridge?.onTrayPanelJumpToSession || !bridge?.onTrayPanelConnectToHost) return;
@@ -528,7 +554,7 @@ function App({ settings }: { settings: SettingsState }) {
unsubscribeJump?.();
unsubscribeConnect?.();
};
}, []);
}, [isPeerSessionWindow]);
// Handle keyboard-interactive submit
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, updateHosts }), requestId, responses, savePassword); }, [keyboardInteractiveQueue, sessions, hosts, updateHosts]);
@@ -669,6 +695,12 @@ function App({ settings }: { settings: SettingsState }) {
const toggleScriptsSidePanelRef = useRef<(() => void) | null>(null);
const toggleSidePanelRef = useRef<(() => void) | null>(null);
const openNoteRequestIdRef = useRef(0);
const [openNoteRequest, setOpenNoteRequest] = useState<{
tabId: string;
noteId: 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.
const handleOpenSettingsRef = useRef<() => void>(() => {});
@@ -728,7 +760,7 @@ function App({ settings }: { settings: SettingsState }) {
);
// Shared hotkey action handler - used by both global handler and terminal callback
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => { return executeHotkeyActionImpl(() => ({ IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, action, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, e, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces }), action, e); }, [orderedTabs, editorTabs, sessions, workspaces, isQuickSwitcherOpen, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings, confirmIfBusyLocalTerminal]);
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => { return executeHotkeyActionImpl(() => ({ IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, action, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, e, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, workspaces }), action, e); }, [orderedTabs, editorTabs, sessions, workspaces, isQuickSwitcherOpen, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, toggleWorkspaceViewMode, settings, confirmIfBusyLocalTerminal]);
const handleWindowCommandCloseRequest = useCallback(async () => {
const openDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][data-state="open"]'));
@@ -794,15 +826,18 @@ function App({ settings }: { settings: SettingsState }) {
const quickResults = useMemo(() => {
if (!isQuickSwitcherOpen) return [];
const term = quickSearch.trim().toLowerCase();
const filtered = term
? hosts.filter(h =>
h.label.toLowerCase().includes(term) ||
h.hostname.toLowerCase().includes(term) ||
(h.group || '').toLowerCase().includes(term)
)
: hosts;
return filtered;
const term = quickSearch.trim();
if (!term) return hosts;
return hosts
.map((host) => ({ host, match: getHostSearchMatch(term, host) }))
.filter((entry) => entry.match.matched)
.sort((left, right) => {
if (left.match.score !== right.match.score) {
return right.match.score - left.match.score;
}
return left.host.label.localeCompare(right.host.label);
})
.map((entry) => entry.host);
}, [quickSearch, hosts, isQuickSwitcherOpen]);
const handleDeleteHost = useCallback((hostId: string) => {
@@ -864,6 +899,59 @@ function App({ settings }: { settings: SettingsState }) {
// Wrapper to connect to host with logging
const handleConnectToHost = useCallback((host: Host) => { return handleConnectToHostImpl(() => ({ addConnectionLog, connectToHost, host, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef }), host); }, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys]);
const _handleSshDeepLink = useEffectEvent((payload: { url?: string }) => {
const rawUrl = payload?.url || '';
const target = parseSshDeepLink(rawUrl);
if (!target) {
toast.warning(t('deepLink.ssh.invalid'));
return;
}
const effectiveHosts = hosts.map((host) => {
const effectiveHost = resolveEffectiveHost(host);
const resolvedAuth = resolveHostAuth({ host: effectiveHost, keys, identities });
return {
...effectiveHost,
username: resolvedAuth.username || effectiveHost.username,
};
});
const matchedEffectiveHost = findSshDeepLinkHost(effectiveHosts, target);
if (matchedEffectiveHost) {
const originalHost = hosts.find((host) => host.id === matchedEffectiveHost.id) ?? matchedEffectiveHost;
handleConnectToHost(buildSshDeepLinkConnectionHost(originalHost));
return;
}
setDeepLinkHostDraft(buildSshDeepLinkHostDraft(target, {
id: crypto.randomUUID(),
now: Date.now(),
}));
setNavigateToSection('hosts');
setActiveTabId('vault');
});
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get();
if (!bridge?.onSshDeepLink) return;
return bridge.onSshDeepLink((payload) => {
_handleSshDeepLink(payload);
});
}, [isPeerSessionWindow]);
const handleOpenHostFromVaultNote = useCallback((host: Host, source?: { noteId?: string }) => {
const tabId = handleConnectToHost(host);
if (source?.noteId && typeof tabId === 'string' && tabId) {
openNoteRequestIdRef.current += 1;
setOpenNoteRequest({
tabId,
noteId: source.noteId,
requestId: openNoteRequestIdRef.current,
});
}
return tabId;
}, [handleConnectToHost]);
// Wrap updateSessionStatus to track lastConnectedAt on successful connection
const handleSessionStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
updateSessionStatus(sessionId, status);
@@ -914,6 +1002,19 @@ 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);
}
if (update.terminalThemeLightId) {
setTerminalThemeLightId(update.terminalThemeLightId);
}
setTheme(update.appTheme);
}, [setTerminalThemeDarkId, setTerminalThemeLightId, setTheme]);
const handleOpenQuickSwitcher = useCallback(() => {
setIsQuickSwitcherOpen(true);
}, []);
@@ -988,13 +1089,17 @@ function App({ settings }: { settings: SettingsState }) {
logViews={logViews}
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, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateTerminalSetting, 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, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleOpenHostFromVaultNote, 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, 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, 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, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
</>
);
}
function AppWithProviders() {
const settings = useSettingsState();
const isPeerSessionWindow = typeof window !== 'undefined' && window.location.hash.startsWith('#/session-window');
const settings = useSettingsState({
enableSettingsSync: !isPeerSessionWindow,
enableSystemEffects: !isPeerSessionWindow,
});
useEffect(() => {
try {
+64
View File
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { handleConnectToHostImpl } from './app/AppHandlers.ts';
import type { Host } from '../types';
const baseHost: Host = {
id: 'host-1',
label: '10.2.0.32',
hostname: '10.2.0.32',
username: 'root',
tags: [],
os: 'linux',
protocol: 'ssh',
};
test('connect host handler returns the created terminal tab id', () => {
const logs: unknown[] = [];
const connectedHosts: Host[] = [];
const result = handleConnectToHostImpl(
() => ({
addConnectionLog: (entry: unknown) => logs.push(entry),
connectToHost: (host: Host) => {
connectedHosts.push(host);
return 'session-from-connect';
},
identities: [],
keys: [],
resolveEffectiveHost: (host: Host) => host,
resolveHostAuth: () => ({ username: 'root' }),
systemInfoRef: { current: { username: 'local-user', hostname: 'local-host' } },
}),
baseHost,
);
assert.equal(result, 'session-from-connect');
assert.equal(connectedHosts.length, 1);
assert.equal(logs.length, 1);
});
test('connect serial host handler returns the created terminal tab id', () => {
const serialHost: Host = {
...baseHost,
id: 'serial-1',
label: '',
hostname: '/dev/tty.usbserial',
protocol: 'serial',
};
const result = handleConnectToHostImpl(
() => ({
addConnectionLog: () => {},
connectToHost: () => 'serial-session',
identities: [],
keys: [],
resolveEffectiveHost: (host: Host) => host,
resolveHostAuth: () => ({ username: 'root' }),
systemInfoRef: { current: { username: 'local-user', hostname: 'local-host' } },
}),
serialHost,
);
assert.equal(result, 'serial-session');
});
+26 -1
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { executeHotkeyActionImpl, handleGlobalHotkeyKeyDownImpl } from './app/AppHandlers.ts';
import { executeHotkeyActionImpl, getLogHostVisualSnapshot, handleGlobalHotkeyKeyDownImpl } from './app/AppHandlers.ts';
import { matchesKeyBinding } from '../domain/models.ts';
import { DEFAULT_KEY_BINDINGS } from '../domain/models/keyBindings.ts';
@@ -169,3 +169,28 @@ test('quick switch hotkey toggles the quick switcher open state', () => {
executeHotkeyActionImpl(() => ({ ...baseCtx, isQuickSwitcherOpen: true }), 'quickSwitch', event);
assert.equal(isQuickSwitcherOpen, false);
});
test('connection log host snapshot includes custom host icon fields', () => {
assert.deepEqual(
getLogHostVisualSnapshot({
id: 'host-1',
label: 'Database',
hostname: 'db.example.com',
username: 'root',
tags: [],
os: 'linux',
distro: 'ubuntu',
iconMode: 'custom',
iconId: 'database',
iconColor: 'blue',
}),
{
hostOs: 'linux',
hostDistro: 'ubuntu',
hostIconMode: 'custom',
hostIconId: 'database',
hostIconColorMode: 'manual',
hostIconColor: 'blue',
},
);
});
@@ -0,0 +1,10 @@
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");
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\)/);
});
+61 -7
View File
@@ -3,16 +3,25 @@ import type React from 'react';
import type { Host, HostProtocol } from '../../types';
import type { PassphraseRequest } from '../../components/PassphraseModal';
import { getEffectiveHostDistro } from '../../domain/host';
import { sanitizeHostIconFields } from '../../domain/hostIcon';
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
type AppContextGetter = () => Record<string, any>;
const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions();
const getLogHostVisualSnapshot = (host: Host) => ({
hostOs: host.os,
hostDistro: getEffectiveHostDistro(host) || undefined,
});
export const getLogHostVisualSnapshot = (host: Host) => {
const icon = sanitizeHostIconFields(host);
return {
hostOs: host.os,
hostDistro: getEffectiveHostDistro(host) || undefined,
hostIconMode: icon.iconMode,
hostIconId: icon.iconId,
...(icon.iconColorMode ? { hostIconColorMode: icon.iconColorMode } : {}),
...(icon.iconColor ? { hostIconColor: icon.iconColor } : {}),
...(icon.iconColorCustom ? { hostIconColorCustom: icon.iconColorCustom } : {}),
};
};
export function handleTrayJumpToSessionImpl(getCtx: AppContextGetter, sessionId: string) {
const { sessions, setActiveTabId, setWorkspaceFocusedSession } = getCtx();
@@ -78,7 +87,7 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
localHostname: localHost,
saved: false,
});
return;
return sessionId;
}
const protocol = effectiveHost.etEnabled ? 'et' : effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
@@ -97,6 +106,7 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
localHostname: localHost,
saved: false,
});
return sessionId;
}
}
@@ -440,7 +450,7 @@ export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: st
}
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, workspaces } = getCtx();
{
// Build complete tab list: vault + (sftp when visible) + sessions/workspaces + editor tabs.
// Hiding the SFTP tab must also remove it from keyboard cycling so nextTab
@@ -539,6 +549,40 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
break;
}
case 'closeSession': {
const currentId = activeTabStore.getActiveTabId();
if (!currentId || currentId === 'vault' || currentId === 'sftp') break;
if (closeTabInFlightRef.current) break;
const session = sessions.find((s) => s.id === currentId) ?? null;
const workspace = workspaces.find((w) => w.id === currentId) ?? null;
closeTabInFlightRef.current = true;
(async () => {
try {
// If active tab is a workspace, close the focused session (pane)
if (workspace) {
// Validate focusedSessionId is still valid — it can become stale
// if the previously focused session was already closed
const aliveIds = collectSessionIds(workspace.root);
const focusedId = aliveIds.includes(workspace.focusedSessionId)
? workspace.focusedSessionId
: aliveIds[0];
if (focusedId) {
const ok = await confirmIfBusyLocalTerminal([focusedId]);
if (ok) closeSession(focusedId);
}
} else if (session) {
// Standalone session tab — close the session
const ok = await confirmIfBusyLocalTerminal([session.id]);
if (ok) closeSession(session.id);
}
} finally {
closeTabInFlightRef.current = false;
}
})();
break;
}
case 'newTab':
case 'openLocal':
// Add connection log for local terminal
@@ -644,6 +688,15 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
}
break;
}
case 'togglePaneZoom': {
// Toggle workspace between split and focus (zoom) mode
const currentId = activeTabStore.getActiveTabId();
const activeWs = workspaces.find(w => w.id === currentId);
if (activeWs) {
toggleWorkspaceViewMode(activeWs.id);
}
break;
}
case 'moveFocus': {
// Debounce to prevent double-triggering when focus switches between terminals
const now = Date.now();
@@ -735,7 +788,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host) {
localHostname: localHost,
saved: false,
});
return;
return sessionId;
}
const protocol = effectiveHost.etEnabled ? 'et' : effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
@@ -754,6 +807,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host) {
localHostname: localHost,
saved: false,
});
return sessionId;
}
}
+32 -3
View File
@@ -8,6 +8,7 @@ import type { GroupConfig, Host, TerminalSession, TerminalTheme, Workspace } fro
import {
isHostTreeWorkTabSurface,
resolveWorkTabActiveHostId,
resolveWorkTabHostTreeTheme,
} from './workTabSurface';
interface AppHostTreeLayerProps {
@@ -20,7 +21,12 @@ interface AppHostTreeLayerProps {
editorTabs: readonly EditorTab[];
logViews: readonly LogView[];
orderedTabs: readonly string[];
resolvedPreviewTheme: TerminalTheme;
accentMode: 'theme' | 'custom';
currentTerminalTheme: TerminalTheme;
customAccent: string;
followAppTerminalTheme: boolean;
hostById: ReadonlyMap<string, Host>;
themeById: ReadonlyMap<string, TerminalTheme>;
onConnect: (host: Host) => void;
onCreateLocalTerminal?: () => void;
}
@@ -43,7 +49,12 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
editorTabs,
logViews,
orderedTabs,
resolvedPreviewTheme,
accentMode,
currentTerminalTheme,
customAccent,
followAppTerminalTheme,
hostById,
themeById,
onConnect,
onCreateLocalTerminal,
}) => {
@@ -67,6 +78,24 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
workspaces,
}), [activeTabId, editorTabs, sessions, workspaces]);
const hostTreeTheme = useMemo(() => resolveWorkTabHostTreeTheme({
activeHostId,
accentMode,
currentTerminalTheme,
customAccent,
followAppTerminalTheme,
hostById,
themeById,
}), [
activeHostId,
accentMode,
currentTerminalTheme,
customAccent,
followAppTerminalTheme,
hostById,
themeById,
]);
return (
<div
className="absolute left-0 top-0 bottom-0 flex min-h-0"
@@ -79,7 +108,7 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
hosts={hosts}
customGroups={customGroups}
groupConfigs={groupConfigs}
resolvedPreviewTheme={resolvedPreviewTheme}
resolvedPreviewTheme={hostTreeTheme}
activeHostId={activeHostId}
onConnect={onConnect}
onCreateLocalTerminal={onCreateLocalTerminal}
+22
View File
@@ -14,6 +14,9 @@ Object.defineProperty(globalThis, 'localStorage', {
const { getLogViewWrapperStyle, shouldRenderTerminalLayerMount } = await import('./AppMounts.tsx');
const activeTabChromeSource = readFileSync(new URL('./AppActiveTabChrome.tsx', import.meta.url), 'utf8');
const appViewSource = readFileSync(new URL('./AppView.tsx', import.meta.url), 'utf8');
const appMountsSource = readFileSync(new URL('./AppMounts.tsx', import.meta.url), 'utf8');
const globalCssSource = readFileSync(new URL('../../index.css', import.meta.url), 'utf8');
test('visible log view leaves room for the terminal host sidebar', () => {
assert.deepEqual(getLogViewWrapperStyle(true, 220), {
@@ -37,6 +40,25 @@ test('terminal layer renders only after terminal content is visible or mounted',
assert.equal(shouldRenderTerminalLayerMount(false, false), false);
});
test('inactive app surfaces suppress background color transitions', () => {
assert.match(appMountsSource, /data-inactive-app-surface=\{isActive \? undefined : "true"\}/);
assert.match(appMountsSource, /data-inactive-app-surface=\{isVisible \? undefined : "true"\}/);
assert.match(globalCssSource, /\[data-inactive-app-surface\][\s\S]*transition: none !important;/);
});
test('vault activation suppresses inherited text color transitions', () => {
assert.match(appMountsSource, /data-app-surface-transition-suppressed/);
assert.match(appMountsSource, /setSuppressActiveTransition\(false\)/);
assert.match(globalCssSource, /\[data-app-surface-transition-suppressed\][\s\S]*transition: none !important;/);
});
test('vault surface carries app theme vars while terminal chrome is active', () => {
assert.match(appMountsSource, /appThemeStyle\?: React\.CSSProperties/);
assert.match(appMountsSource, /style=\{\{ \.\.\.appThemeStyle, \.\.\.containerStyle \}\}/);
assert.match(appViewSource, /buildAppThemeCssVars\(tokens, accentMode, customAccent\)/);
assert.match(appViewSource, /<VaultViewContainer appThemeStyle=\{appThemeStyle\}>/);
});
test('active tab chrome keeps removed theme side effects unmounted', () => {
const removedThemeHook = ['use', 'Im', 'mersive', 'Mode'].join('');
const removedThemeStoreSetter = ['set', 'Im', 'mersive', 'Active'].join('');
+82 -20
View File
@@ -1,22 +1,56 @@
import React, { Suspense, lazy, useEffect, useMemo, useState } from 'react';
import React, { Suspense, lazy, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useActiveTabId, useIsSftpActive, useIsVaultActive } from '../state/activeTabStore';
import { useTerminalHostTreeLayoutWidth } from '../state/terminalHostTreeStore';
import { isTerminalContentTabSurface } from './workTabSurface';
import { cn } from '../../lib/utils';
import { ConnectionLog, TerminalTheme } from '../../types';
import { LazyLoadBoundary } from '../../components/ui/lazy-load-boundary';
import type { LogView as LogViewType } from '../state/logViewState';
import type { SftpView as SftpViewComponent } from '../../components/SftpView';
import type { TerminalLayer as TerminalLayerComponent } from '../../components/TerminalLayer';
// Visibility container for VaultView - isolates isActive subscription
export const VaultViewContainer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
export const VaultViewContainer: React.FC<{
children: React.ReactNode;
appThemeStyle?: React.CSSProperties;
}> = ({ children, appThemeStyle }) => {
const isActive = useIsVaultActive();
const wasActiveRef = useRef(isActive);
const [suppressActiveTransition, setSuppressActiveTransition] = useState(false);
const isActivating = isActive && !wasActiveRef.current;
const shouldSuppressTransition = isActivating || suppressActiveTransition;
const containerStyle: React.CSSProperties = isActive
? {}
: { visibility: 'hidden', pointerEvents: 'none', position: 'absolute', zIndex: -1 };
useLayoutEffect(() => {
const wasActive = wasActiveRef.current;
wasActiveRef.current = isActive;
if (!isActive || wasActive) return;
setSuppressActiveTransition(true);
const view = window;
let firstFrame = 0;
let secondFrame = 0;
firstFrame = view.requestAnimationFrame(() => {
secondFrame = view.requestAnimationFrame(() => {
setSuppressActiveTransition(false);
});
});
return () => {
view.cancelAnimationFrame(firstFrame);
view.cancelAnimationFrame(secondFrame);
};
}, [isActive]);
return (
<div className={cn("absolute inset-0", isActive ? "z-20" : "")} style={containerStyle}>
<div
className={cn("absolute inset-0", isActive ? "z-20" : "")}
data-inactive-app-surface={isActive ? undefined : "true"}
data-app-surface-transition-suppressed={shouldSuppressTransition ? "true" : undefined}
style={{ ...appThemeStyle, ...containerStyle }}
>
{children}
</div>
);
@@ -51,17 +85,23 @@ export const LogViewWrapper: React.FC<LogViewWrapperProps> = ({ logView, default
const containerStyle = getLogViewWrapperStyle(isVisible, hostTreeLayoutWidth);
return (
<div className={cn("absolute inset-0", isVisible ? "z-20" : "")} style={containerStyle}>
<Suspense fallback={null}>
<LazyLogView
log={logView.log}
defaultTerminalTheme={defaultTerminalTheme}
defaultFontSize={defaultFontSize}
isVisible={isVisible}
onClose={onClose}
onUpdateLog={onUpdateLog}
/>
</Suspense>
<div
className={cn("absolute inset-0", isVisible ? "z-20" : "")}
data-inactive-app-surface={isVisible ? undefined : "true"}
style={containerStyle}
>
<LazyLoadBoundary name="Log view" resetKey={logView.id}>
<Suspense fallback={<LogViewFallback />}>
<LazyLogView
log={logView.log}
defaultTerminalTheme={defaultTerminalTheme}
defaultFontSize={defaultFontSize}
isVisible={isVisible}
onClose={onClose}
onUpdateLog={onUpdateLog}
/>
</Suspense>
</LazyLoadBoundary>
</div>
);
};
@@ -79,6 +119,24 @@ const LazyTerminalLayer = lazy(() =>
type SftpViewProps = React.ComponentProps<typeof SftpViewComponent>;
type TerminalLayerProps = React.ComponentProps<typeof TerminalLayerComponent>;
const LogViewFallback = () => (
<div className="netcatty-lazy-fade-in h-full min-h-0 bg-background" aria-hidden="true" />
);
const SftpViewFallback = ({ visible }: { visible: boolean }) => {
if (!visible) return null;
return (
<div className="netcatty-lazy-fade-in absolute inset-0 z-20 bg-background" aria-hidden="true" />
);
};
const TerminalLayerFallback = ({ visible }: { visible: boolean }) => {
if (!visible) return null;
return (
<div className="netcatty-lazy-fade-in absolute inset-0 z-20 bg-background" aria-hidden="true" />
);
};
export function shouldRenderTerminalLayerMount(
isVisible: boolean,
shouldMount: boolean,
@@ -97,9 +155,11 @@ export const SftpViewMount: React.FC<SftpViewProps> = (props) => {
if (!shouldMount) return null;
return (
<Suspense fallback={null}>
<LazySftpView {...props} />
</Suspense>
<LazyLoadBoundary name="SFTP" resetKey={isActive ? "active" : "idle"}>
<Suspense fallback={<SftpViewFallback visible={isActive} />}>
<LazySftpView {...props} />
</Suspense>
</LazyLoadBoundary>
);
};
@@ -138,8 +198,10 @@ export const TerminalLayerMount: React.FC<TerminalLayerProps> = (props) => {
if (!shouldRender) return null;
return (
<Suspense fallback={null}>
<LazyTerminalLayer {...props} />
</Suspense>
<LazyLoadBoundary name="Terminal" resetKey={activeTabId}>
<Suspense fallback={<TerminalLayerFallback visible={isVisible} />}>
<LazyTerminalLayer {...props} />
</Suspense>
</LazyLoadBoundary>
);
};
+142 -69
View File
@@ -1,24 +1,27 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { Suspense, lazy } from 'react';
import React, { Suspense, lazy, useMemo } from 'react';
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
import { activeTabStore, toEditorTabId } from '../state/activeTabStore';
import { activeTabStore, toEditorTabId, useIsEditorTabActive } from '../state/activeTabStore';
import { editorTabStore } from '../state/editorTabStore';
import { releaseEditorTabSaveCoordinator, saveEditorTab } from '../state/editorTabSave';
import { useTerminalHostTreeLayoutWidth } from '../state/terminalHostTreeStore';
import { TopTabs } from '../../components/TopTabs';
import { VaultView } from '../../components/VaultView';
import { QuickAddSnippetDialog } from '../../components/QuickAddSnippetDialog';
import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog';
import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal';
import { PassphraseModal } from '../../components/PassphraseModal';
import { TextEditorTabView } from '../../components/editor/TextEditorTabView';
import { UnsavedChangesProvider } from '../../components/editor/UnsavedChangesDialog';
import { SnippetExecutionProvider } from '../../components/SnippetExecutionProvider';
import { Button } from '../../components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../components/ui/dialog';
import { Input } from '../../components/ui/input';
import { Label } from '../../components/ui/label';
import { LazyLoadBoundary } from '../../components/ui/lazy-load-boundary';
import { toast } from '../../components/ui/toast';
import { AppHostTreeLayer } from './AppHostTreeLayer';
import { getUiThemeById } from '../../infrastructure/config/uiThemes';
import { buildAppThemeCssVars } from '../state/settingsStateDefaults';
const LazyProtocolSelectDialog = lazy(() => import('../../components/ProtocolSelectDialog'));
const LazyQuickSwitcher = lazy(() =>
@@ -27,6 +30,25 @@ const LazyQuickSwitcher = lazy(() =>
const LazyCreateWorkspaceDialog = lazy(() =>
import('../../components/CreateWorkspaceDialog').then((m) => ({ default: m.CreateWorkspaceDialog })),
);
const LazyTextEditorTabView = lazy(() =>
import('../../components/editor/TextEditorTabView').then((m) => ({ default: m.TextEditorTabView })),
);
const TextEditorTabFallback = ({ tabId }: { tabId: string }) => {
const isVisible = useIsEditorTabActive(tabId);
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
return (
<div
style={{
...(isVisible ? null : { pointerEvents: 'none', visibility: 'hidden' }),
zIndex: 20,
left: hostTreeLayoutWidth,
}}
className="netcatty-lazy-fade-in absolute top-0 right-0 bottom-0 min-h-0 flex flex-col bg-background"
aria-hidden="true"
/>
);
};
type AppViewContext = Record<string, any>;
@@ -35,26 +57,37 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, closeWorkspace, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell,
connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent,
customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost,
handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal,
hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions,
passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename,
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename,
resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen,
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, updateSessionFontSize, clearSessionFontSizeOverride,
sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen,
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, 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,
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog,
updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources,
updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces,
updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces,
VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper,
} = ctx;
const appThemeStyle = useMemo(() => {
const tokens = getUiThemeById(
resolvedTheme,
resolvedTheme === 'dark' ? settings.darkUiThemeId : settings.lightUiThemeId,
).tokens;
return {
...buildAppThemeCssVars(tokens, accentMode, customAccent),
colorScheme: resolvedTheme,
} as React.CSSProperties;
}, [accentMode, customAccent, resolvedTheme, settings.darkUiThemeId, settings.lightUiThemeId]);
return (
<SnippetExecutionProvider>
<UnsavedChangesProvider>
@@ -134,6 +167,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
onStartSessionDrag={setDraggingSessionId}
onEndSessionDrag={handleEndSessionDrag}
onReorderTabs={reorderWorkTabs}
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
showSftpTab={settings.showSftpTab}
showHostTreeSidebar={settings.showHostTreeSidebar}
editorTabs={editorTabs}
@@ -152,12 +186,17 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
editorTabs={editorTabs}
logViews={logViews}
orderedTabs={orderedTabsWithEditors}
resolvedPreviewTheme={currentTerminalTheme}
accentMode={accentMode}
currentTerminalTheme={currentTerminalTheme}
customAccent={customAccent}
followAppTerminalTheme={followAppTerminalTheme}
hostById={hostById}
themeById={themeById}
onConnect={handleConnectToHost}
onCreateLocalTerminal={handleCreateLocalTerminal}
/>
<VaultViewContainer>
<VaultViewContainer appThemeStyle={appThemeStyle}>
<VaultView
hosts={hosts}
keys={keys}
@@ -165,6 +204,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
proxyProfiles={proxyProfiles}
snippets={snippets}
snippetPackages={snippetPackages}
notes={notes}
noteGroups={noteGroups}
customGroups={customGroups}
knownHosts={effectiveKnownHosts}
shellHistory={shellHistory}
@@ -181,6 +222,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
onConnectSerial={handleConnectSerial}
onDeleteHost={handleDeleteHost}
onConnect={handleConnectToHost}
onOpenHostFromNote={handleOpenHostFromVaultNote}
groupConfigs={groupConfigs}
onUpdateGroupConfigs={updateGroupConfigs}
onUpdateHosts={updateHosts}
@@ -190,6 +232,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
onUpdateProxyProfiles={updateProxyProfiles}
onUpdateSnippets={updateSnippets}
onUpdateSnippetPackages={updateSnippetPackages}
onUpdateNotes={updateNotes}
onUpdateNoteGroups={updateNoteGroups}
onUpdateCustomGroups={updateCustomGroups}
onUpdateKnownHosts={updateKnownHosts}
onUpdateManagedSources={updateManagedSources}
@@ -206,6 +250,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
navigateToSection={navigateToSection}
onNavigateToSectionHandled={() => setNavigateToSection(null)}
deepLinkHostDraft={deepLinkHostDraft}
onDeepLinkHostDraftHandled={() => setDeepLinkHostDraft(null)}
terminalSettings={terminalSettings}
/>
</VaultViewContainer>
@@ -240,11 +286,14 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
identities={identities}
snippets={snippets}
snippetPackages={snippetPackages}
notes={notes}
noteGroups={noteGroups}
sessions={sessions}
workspaces={workspaces}
knownHosts={effectiveKnownHosts}
draggingSessionId={draggingSessionId}
terminalTheme={currentTerminalTheme}
terminalThemeId={terminalThemeId}
followAppTerminalTheme={followAppTerminalTheme}
accentMode={accentMode}
customAccent={customAccent}
@@ -253,12 +302,17 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
fontSize={terminalFontSize}
hotkeyScheme={hotkeyScheme}
disableTerminalFontZoom={settings.disableTerminalFontZoom}
restoreTerminalCwd={settings.restoreTerminalCwd}
keyBindings={keyBindings}
onHotkeyAction={handleHotkeyAction}
onUpdateTerminalThemeId={setTerminalThemeId}
onUpdateFollowAppTerminalThemeId={handleFollowAppTerminalThemeChange}
onUpdateTerminalFontFamilyId={setTerminalFontFamilyId}
onUpdateTerminalFontSize={setTerminalFontSize}
onUpdateSessionFontSize={updateSessionFontSize}
onUpdateSessionRestoreCwd={updateSessionRestoreCwd}
onUpdateSessionDynamicTitle={updateSessionDynamicTitle}
onUpdateSessionCodingCliProvider={updateSessionCodingCliProvider}
onClearSessionFontSizeOverride={clearSessionFontSizeOverride}
onUpdateTerminalFontWeight={(w) => updateTerminalSetting('fontWeight', w)}
onCloseSession={closeSession}
@@ -281,14 +335,20 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
onReorderWorkspaceSessions={reorderWorkspaceSessions}
onReorderTabs={reorderWorkTabs}
onCopySession={copySessionWithCurrentShell}
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
onSplitSession={splitSessionWithCurrentShell}
onConnectToHost={handleConnectToHost}
openNoteRequest={openNoteRequest}
onCreateLocalTerminal={handleCreateLocalTerminal}
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}
updateHosts={updateHosts}
updateSnippets={updateSnippets}
updateSnippetPackages={updateSnippetPackages}
updateNotes={updateNotes}
updateNoteGroups={updateNoteGroups}
sftpDefaultViewMode={sftpDefaultViewMode}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={sftpAutoSync}
@@ -307,6 +367,9 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
showHostTreeSidebar={settings.showHostTreeSidebar}
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
toggleSidePanelRef={toggleSidePanelRef}
onStartSessionRename={startSessionRename}
onSubmitSessionRename={submitSessionRename}
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
/>
{/* Log Views - readonly terminal replays */}
@@ -327,14 +390,17 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
{/* Editor Tabs — kept mounted for Monaco instance persistence; visibility toggled via CSS */}
{editorTabs.map((tab) => (
<TextEditorTabView
key={tab.id}
tabId={tab.id}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
hostById={hostById}
onRequestClose={(id) => handleRequestCloseEditorTabRef.current(id)}
/>
<LazyLoadBoundary key={tab.id} name="Editor" resetKey={tab.id}>
<Suspense fallback={<TextEditorTabFallback tabId={tab.id} />}>
<LazyTextEditorTabView
tabId={tab.id}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
hostById={hostById}
onRequestClose={(id) => handleRequestCloseEditorTabRef.current(id)}
/>
</Suspense>
</LazyLoadBoundary>
))}
</div>
@@ -398,38 +464,40 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
)}
{isQuickSwitcherOpen && (
<Suspense fallback={null}>
<LazyQuickSwitcher
isOpen={isQuickSwitcherOpen}
query={quickSearch}
results={quickResults}
sessions={sessions}
workspaces={workspaces}
showSftpTab={settings.showSftpTab}
onQueryChange={setQuickSearch}
onSelect={handleHostConnectWithProtocolCheck}
onSelectTab={(tabId) => {
setActiveTabId(tabId);
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}}
onCreateLocalTerminal={(shell) => {
handleCreateLocalTerminal(shell);
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}}
onCreateWorkspace={() => {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
setAddToWorkspaceDialog({ mode: 'create' });
}}
onClose={() => {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}}
keyBindings={keyBindings}
/>
</Suspense>
<LazyLoadBoundary name="Quick switcher" resetKey={quickSearch}>
<Suspense fallback={null}>
<LazyQuickSwitcher
isOpen={isQuickSwitcherOpen}
query={quickSearch}
results={quickResults}
sessions={sessions}
workspaces={workspaces}
showSftpTab={settings.showSftpTab}
onQueryChange={setQuickSearch}
onSelect={handleHostConnectWithProtocolCheck}
onSelectTab={(tabId) => {
setActiveTabId(tabId);
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}}
onCreateLocalTerminal={(shell) => {
handleCreateLocalTerminal(shell);
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}}
onCreateWorkspace={() => {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
setAddToWorkspaceDialog({ mode: 'create' });
}}
onClose={() => {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}}
keyBindings={keyBindings}
/>
</Suspense>
</LazyLoadBoundary>
)}
<Dialog open={!!sessionRenameTarget} onOpenChange={(open) => {
@@ -487,25 +555,29 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
</Dialog>
{isCreateWorkspaceOpen && (
<Suspense fallback={null}>
<LazyCreateWorkspaceDialog
isOpen={isCreateWorkspaceOpen}
onClose={() => setIsCreateWorkspaceOpen(false)}
hosts={hosts}
onCreate={createWorkspaceWithHosts}
/>
</Suspense>
<LazyLoadBoundary name="Create workspace" resetKey="create-workspace">
<Suspense fallback={null}>
<LazyCreateWorkspaceDialog
isOpen={isCreateWorkspaceOpen}
onClose={() => setIsCreateWorkspaceOpen(false)}
hosts={hosts}
onCreate={createWorkspaceWithHosts}
/>
</Suspense>
</LazyLoadBoundary>
)}
{/* Protocol Select Dialog for QuickSwitcher */}
{protocolSelectHost && (
<Suspense fallback={null}>
<LazyProtocolSelectDialog
host={protocolSelectHost}
onSelect={handleProtocolSelect}
onCancel={() => setProtocolSelectHost(null)}
/>
</Suspense>
<LazyLoadBoundary name="Protocol selector" resetKey={protocolSelectHost.id}>
<Suspense fallback={null}>
<LazyProtocolSelectDialog
host={protocolSelectHost}
onSelect={handleProtocolSelect}
onCancel={() => setProtocolSelectHost(null)}
/>
</Suspense>
</LazyLoadBoundary>
)}
{/* Global Keyboard-Interactive Authentication Modal (2FA/MFA) - processes queue */}
@@ -553,6 +625,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
hosts: emptyVaultConflict.hostCount,
keys: emptyVaultConflict.keyCount,
snippets: emptyVaultConflict.snippetCount,
notes: emptyVaultConflict.noteCount,
proxyProfiles: emptyVaultConflict.proxyProfileCount,
})}</div>
</div>
+21 -1
View File
@@ -39,7 +39,7 @@ const baseInput = {
workspaceById: new Map<string, Workspace>(),
};
test("editor tabs use the theme from their owning host", () => {
test("editor tabs use the owning host terminal theme when follow-app terminal theme is off", () => {
const editorTab = {
id: "editor-1",
hostId: "host-1",
@@ -58,6 +58,26 @@ test("editor tabs use the theme from their owning host", () => {
assert.equal(resolved?.id, hostTheme.id);
});
test("editor tabs use the followed terminal theme when follow-app terminal theme is on", () => {
const editorTab = {
id: "editor-1",
hostId: "host-1",
sessionId: "sftp-1",
};
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: toEditorTabId(editorTab.id),
editorTabs: [editorTab as unknown as EditorTab],
followAppTerminalTheme: true,
hostById: new Map([
["host-1", { id: "host-1", theme: hostTheme.id } as unknown as Host],
]),
});
assert.equal(resolved?.id, currentTheme.id);
});
test("log tabs use the saved log theme when available", () => {
const resolved = resolveActiveChromeTheme({
...baseInput,
+5 -6
View File
@@ -54,22 +54,21 @@ export function resolveActiveChromeTheme({
}: ResolveActiveChromeThemeInput): TerminalTheme | null {
if (activeTabId === "vault" || activeTabId === "sftp") return null;
const resolveSessionTheme = (session: TerminalSession): TerminalTheme => {
const resolveHostTheme = (hostId: string): TerminalTheme => {
if (followAppTerminalTheme) return currentTerminalTheme;
const host = hostById.get(session.hostId) ?? null;
const host = hostById.get(hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
};
const resolveSessionTheme = (session: TerminalSession): TerminalTheme => resolveHostTheme(session.hostId);
if (isEditorTabId(activeTabId)) {
const editorTabId = fromEditorTabId(activeTabId);
const editorTab = editorTabs.find((tab) => tab.id === editorTabId);
if (!editorTab) return null;
const host = hostById.get(editorTab.hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
return resolveHostTheme(editorTab.hostId);
}
const logView = logViews.find((item) => item.id === activeTabId);
@@ -0,0 +1,49 @@
import test from "node:test";
import assert from "node:assert/strict";
import { shouldQueueKeyboardInteractiveRequest } from "./useAppStartupEffects.ts";
const sessions = [{ id: "terminal-1" }, { id: "terminal-2" }];
test("terminal-scoped keyboard-interactive requests are limited to owned sessions", () => {
assert.equal(
shouldQueueKeyboardInteractiveRequest({ scope: "terminal", sessionId: "terminal-1" }, sessions),
true,
);
assert.equal(
shouldQueueKeyboardInteractiveRequest({ scope: "terminal", sessionId: "foreign-terminal" }, sessions),
false,
);
});
test("external keyboard-interactive requests are not filtered by terminal session ids", () => {
assert.equal(
shouldQueueKeyboardInteractiveRequest({ scope: "external", sessionId: "sftp-conn-1" }, sessions),
true,
);
assert.equal(
shouldQueueKeyboardInteractiveRequest({ scope: "external", sessionId: "tunnel-1" }, sessions),
true,
);
});
test("disabled peer windows still queue sender-targeted external keyboard-interactive requests", () => {
assert.equal(
shouldQueueKeyboardInteractiveRequest({ scope: "external", sessionId: "sftp-conn-1" }, sessions),
true,
);
});
test("disabled peer windows can still queue owned terminal keyboard-interactive requests", () => {
assert.equal(
shouldQueueKeyboardInteractiveRequest({ scope: "terminal", sessionId: "terminal-1" }, sessions),
true,
);
});
test("legacy unscoped keyboard-interactive requests remain visible", () => {
assert.equal(
shouldQueueKeyboardInteractiveRequest({ sessionId: "legacy-conn" }, sessions),
true,
);
});
@@ -9,6 +9,7 @@ test("active chrome theme applies top tab vars and clears them before vault rest
const effectsSource = readFileSync(new URL("../../components/terminalLayer/useTerminalLayerEffects.ts", import.meta.url), "utf8");
assert.match(chromeThemeSource, /applyTopTabsChromeThemeVars\(theme\)/);
assert.match(chromeThemeSource, /resolveReadableForegroundForHsl\(cursor\)/);
const restoreBlock = chromeThemeSource.match(
/clearTopTabsChromeThemeVars\(\);\s*runThemeTransition\(\(\) => \{\s*removeActiveChromeTheme\(\);/,
)?.[0] ?? "";
@@ -16,3 +17,14 @@ test("active chrome theme applies top tab vars and clears them before vault rest
assert.match(syncSource, /activeTabId === 'vault' \|\| activeTabId === 'sftp'\)[\s\S]*clearTopTabsChromeThemeVars\(\)/);
assert.match(effectsSource, /if \(!isTerminalLayerVisible\) \{[\s\S]*clearTopTabsPreviewVars\(\)/);
});
test("top tabs chrome theme keeps accent foreground in sync", () => {
const source = readFileSync(new URL("./topTabsChromeTheme.ts", import.meta.url), "utf8");
const supportSource = readFileSync(new URL("../../components/terminalLayer/TerminalLayerSupport.tsx", import.meta.url), "utf8");
assert.match(source, /--primary-foreground/);
assert.match(source, /--accent-foreground/);
assert.match(source, /resolveReadableForegroundForHsl\(accent\)/);
assert.match(supportSource, /removeStylePropertyIfSet\(tabsRoot, '--primary-foreground'\)/);
assert.match(supportSource, /removeStylePropertyIfSet\(tabsRoot, '--accent-foreground'\)/);
});
+6
View File
@@ -1,4 +1,5 @@
import type { TerminalTheme } from '../../types';
import { resolveReadableForegroundForHsl } from '../../domain/colorContrast';
function hexToHslToken(hex: string): string {
const normalized = hex.startsWith('#') ? hex : `#${hex}`;
@@ -61,7 +62,9 @@ const TOP_TABS_THEME_PROPERTIES = [
'--background',
'--foreground',
'--accent',
'--accent-foreground',
'--primary',
'--primary-foreground',
'--secondary',
'--border',
'--muted-foreground',
@@ -84,6 +87,7 @@ export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
const bg = hexToHslToken(theme.colors.background);
const fg = hexToHslToken(theme.colors.foreground);
const accent = hexToHslToken(theme.colors.cursor);
const accentForeground = resolveReadableForegroundForHsl(accent);
const isDark = theme.type === 'dark';
const secondary = adjustLightnessToken(bg, isDark ? 6 : -5);
const border = adjustLightnessToken(bg, isDark ? 12 : -10);
@@ -92,7 +96,9 @@ export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
setStylePropertyIfChanged(tabsRoot, '--background', bg);
setStylePropertyIfChanged(tabsRoot, '--foreground', fg);
setStylePropertyIfChanged(tabsRoot, '--accent', accent);
setStylePropertyIfChanged(tabsRoot, '--accent-foreground', accentForeground);
setStylePropertyIfChanged(tabsRoot, '--primary', accent);
setStylePropertyIfChanged(tabsRoot, '--primary-foreground', accentForeground);
setStylePropertyIfChanged(tabsRoot, '--secondary', secondary);
setStylePropertyIfChanged(tabsRoot, '--border', border);
setStylePropertyIfChanged(tabsRoot, '--muted-foreground', mutedFg);
+31 -6
View File
@@ -8,14 +8,35 @@ import { toast } from '../../components/ui/toast';
type StartupEffectsContext = Record<string, any>;
type KeyboardInteractiveScope = "terminal" | "external";
type KeyboardInteractiveRequestLike = {
scope?: KeyboardInteractiveScope;
sessionId?: string;
};
type SessionIdLike = { id: string };
export function shouldQueueKeyboardInteractiveRequest(
request: KeyboardInteractiveRequestLike,
sessions: SessionIdLike[],
): boolean {
if (request.scope !== "terminal") return true;
if (!request.sessionId) return false;
return sessions.some((session) => session.id === request.sessionId);
}
export function useAppStartupEffects(ctx: StartupEffectsContext) {
const {dismissUpdate, groupConfigs, hosts, identities,
const {dismissUpdate, enabled = true, groupConfigs, hosts, identities,
installUpdate, isVaultInitialized, keys, openSettingsWindow, portForwardingRules, proxyProfiles, sessions, setKeyboardInteractiveQueue,
t, terminalSettings, updateState, workspaces,
} = ctx;
const sessionsRef = useRef(sessions);
useEffect(() => {
sessionsRef.current = sessions;
}, [sessions]);
// Show toast notification when update is available (only when auto-download is idle)
useEffect(() => {
if (!enabled) return;
// Skip "update available" toast if auto-download has already started or completed
if (updateState.autoDownloadStatus !== 'idle') return;
// Don't show automatic notification when auto-update is disabled
@@ -41,12 +62,13 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
}
);
}
}, [updateState.hasUpdate, updateState.latestRelease, updateState.autoDownloadStatus, t, openSettingsWindow, dismissUpdate]);
}, [enabled, updateState.hasUpdate, updateState.latestRelease, updateState.autoDownloadStatus, t, openSettingsWindow, dismissUpdate]);
// Track previous autoDownloadStatus so toast effects fire only on actual transitions,
// not when unrelated deps (installUpdate, openSettingsWindow) change their reference.
const prevAutoDownloadStatusRef = useRef(updateState.autoDownloadStatus);
useEffect(() => {
if (!enabled) return;
const prev = prevAutoDownloadStatusRef.current;
prevAutoDownloadStatusRef.current = updateState.autoDownloadStatus;
if (prev === updateState.autoDownloadStatus) return;
@@ -72,10 +94,11 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
}
);
}
}, [updateState.autoDownloadStatus, updateState.latestRelease?.version, t, installUpdate, openSettingsWindow]);
}, [enabled, updateState.autoDownloadStatus, updateState.latestRelease?.version, t, installUpdate, openSettingsWindow]);
// Auto-start port forwarding rules on app launch
usePortForwardingAutoStart({
enabled,
isVaultInitialized,
hosts,
keys,
@@ -87,6 +110,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
// Sync tray menu data + handle tray actions
useEffect(() => {
if (!enabled) return;
const bridge = netcattyBridge.get();
if (!bridge?.updateTrayMenuData) return;
@@ -116,7 +140,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
cancelled = true;
clearTimeout(timer);
};
}, [sessions, portForwardingRules, workspaces]);
}, [enabled, sessions, portForwardingRules, workspaces]);
// Quit guard: block app exit while any editor tab has unsaved changes.
// Main process sends "app:query-dirty-editors"; we respond with the result.
@@ -146,7 +170,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
}
});
return unsub;
}, [t]);
}, [enabled, t]);
// Keyboard-interactive authentication (2FA/MFA) event listener
useEffect(() => {
@@ -154,6 +178,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
if (!bridge?.onKeyboardInteractive) return;
const unsubscribe = bridge.onKeyboardInteractive((request) => {
if (!shouldQueueKeyboardInteractiveRequest(request, sessionsRef.current)) return;
console.log('[App] Keyboard-interactive request received:', request);
// Add to queue instead of replacing - supports multiple concurrent sessions
setKeyboardInteractiveQueue(prev => [...prev, {
@@ -170,7 +195,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
return () => {
unsubscribe?.();
};
}, [setKeyboardInteractiveQueue]);
}, [enabled, setKeyboardInteractiveQueue]);
}
+124 -1
View File
@@ -6,10 +6,40 @@ import {
isHostTreeWorkTabSurface,
isRootPageTabId,
isTerminalContentTabSurface,
reorderWorkTabIds,
resolveWorkTabActiveHostId,
resolveWorkTabHostTreeTheme,
} from './workTabSurface';
import type { EditorTab } from '../state/editorTabStore';
import type { TerminalSession, Workspace } from '../../types';
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
const makeTheme = (id: string, type: TerminalTheme['type'], background: string): TerminalTheme => ({
id,
name: id,
type,
colors: {
background,
foreground: type === 'dark' ? '#ffffff' : '#000000',
cursor: '#888888',
selection: '#555555',
black: '#000000',
red: '#ff0000',
green: '#00ff00',
yellow: '#ffff00',
blue: '#0000ff',
magenta: '#ff00ff',
cyan: '#00ffff',
white: '#ffffff',
brightBlack: '#444444',
brightRed: '#ff5555',
brightGreen: '#55ff55',
brightYellow: '#ffff55',
brightBlue: '#5555ff',
brightMagenta: '#ff55ff',
brightCyan: '#55ffff',
brightWhite: '#ffffff',
},
});
test('work tab order keeps custom positions and appends new tabs', () => {
assert.deepEqual(
@@ -18,6 +48,29 @@ test('work tab order keeps custom positions and appends new tabs', () => {
);
});
test('work tab order removes duplicate ids before rendering', () => {
assert.deepEqual(
buildOrderedWorkTabIds(
['session-2', 'session-1', 'session-2', 'session-1'],
['session-1', 'session-2', 'session-3', 'session-3'],
),
['session-2', 'session-1', 'session-3'],
);
});
test('work tab order reorders with newly materialized tabs', () => {
assert.deepEqual(
reorderWorkTabIds(
['session-1', 'session-2', 'session-3'],
['session-1', 'session-2', 'session-3'],
'session-1',
'session-3',
'after',
),
['session-2', 'session-3', 'session-1'],
);
});
test('root pages are not work tab surfaces', () => {
assert.equal(isRootPageTabId('vault'), true);
assert.equal(isRootPageTabId('sftp'), true);
@@ -80,3 +133,73 @@ test('shared host tree resolves active host ids across work tab types', () => {
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'editor:file-1', sessions, workspaces, editorTabs }), 'host-3');
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'log-1', sessions, workspaces, editorTabs }), null);
});
test('shared host tree uses the active host theme when follow-app terminal theme is off', () => {
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
const hostTheme = makeTheme('host-light', 'light', '#fafafa');
const host = {
id: 'host-1',
label: 'Host',
hostname: 'host.local',
username: 'root',
tags: [],
os: 'linux',
theme: hostTheme.id,
themeOverride: true,
} as Host;
const resolved = resolveWorkTabHostTreeTheme({
activeHostId: host.id,
accentMode: 'theme',
currentTerminalTheme: currentTheme,
customAccent: '#8b5cf6',
followAppTerminalTheme: false,
hostById: new Map([[host.id, host]]),
themeById: new Map([[currentTheme.id, currentTheme], [hostTheme.id, hostTheme]]),
});
assert.equal(resolved.id, hostTheme.id);
});
test('shared host tree uses the followed terminal theme when follow-app terminal theme is on', () => {
const currentTheme = makeTheme('app-light', 'light', '#ffffff');
const hostTheme = makeTheme('host-dark', 'dark', '#050505');
const host = {
id: 'host-1',
label: 'Host',
hostname: 'host.local',
username: 'root',
tags: [],
os: 'linux',
theme: hostTheme.id,
themeOverride: true,
} as Host;
const resolved = resolveWorkTabHostTreeTheme({
activeHostId: host.id,
accentMode: 'theme',
currentTerminalTheme: currentTheme,
customAccent: '#8b5cf6',
followAppTerminalTheme: true,
hostById: new Map([[host.id, host]]),
themeById: new Map([[currentTheme.id, currentTheme], [hostTheme.id, hostTheme]]),
});
assert.equal(resolved.id, currentTheme.id);
});
test('shared host tree falls back to the current terminal theme without an active host', () => {
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
const resolved = resolveWorkTabHostTreeTheme({
activeHostId: null,
accentMode: 'theme',
currentTerminalTheme: currentTheme,
customAccent: '#8b5cf6',
followAppTerminalTheme: false,
hostById: new Map(),
themeById: new Map([[currentTheme.id, currentTheme]]),
});
assert.equal(resolved.id, currentTheme.id);
});
+70 -4
View File
@@ -2,8 +2,20 @@ import {
fromEditorTabId,
isEditorTabId,
} from '../state/activeTabStore';
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from '../../domain/terminalAppearance';
import type { EditorTab } from '../state/editorTabStore';
import type { TerminalSession, Workspace } from '../../types';
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
function uniqueTabIds(tabIds: readonly string[]): string[] {
const seen = new Set<string>();
const uniqueIds: string[] = [];
for (const tabId of tabIds) {
if (!tabId || seen.has(tabId)) continue;
seen.add(tabId);
uniqueIds.push(tabId);
}
return uniqueIds;
}
export function isRootPageTabId(activeTabId: string): boolean {
return activeTabId === 'vault' || activeTabId === 'sftp';
@@ -13,13 +25,42 @@ export function buildOrderedWorkTabIds(
tabOrder: readonly string[],
allTabIds: readonly string[],
): string[] {
const allTabIdSet = new Set(allTabIds);
const orderedIds = tabOrder.filter((id) => allTabIdSet.has(id));
const uniqueAllTabIds = uniqueTabIds(allTabIds);
const allTabIdSet = new Set(uniqueAllTabIds);
const orderedIds = uniqueTabIds(tabOrder.filter((id) => allTabIdSet.has(id)));
const orderedIdSet = new Set(orderedIds);
const newIds = allTabIds.filter((id) => !orderedIdSet.has(id));
const newIds = uniqueAllTabIds.filter((id) => !orderedIdSet.has(id));
return [...orderedIds, ...newIds];
}
export function reorderWorkTabIds(
tabOrder: readonly string[],
allTabIds: readonly string[],
draggedId: string,
targetId: string,
position: 'before' | 'after' = 'before',
): string[] {
if (draggedId === targetId) return buildOrderedWorkTabIds(tabOrder, allTabIds);
const currentOrder = buildOrderedWorkTabIds(tabOrder, allTabIds);
const draggedIndex = currentOrder.indexOf(draggedId);
const targetIndex = currentOrder.indexOf(targetId);
if (draggedIndex === -1 || targetIndex === -1) return [...tabOrder];
currentOrder.splice(draggedIndex, 1);
let nextTargetIndex = targetIndex;
if (draggedIndex < targetIndex) {
nextTargetIndex -= 1;
}
if (position === 'after') {
nextTargetIndex += 1;
}
currentOrder.splice(nextTargetIndex, 0, draggedId);
return currentOrder;
}
export function isHostTreeWorkTabSurface({
enabled,
activeTabId,
@@ -85,3 +126,28 @@ export function resolveWorkTabActiveHostId({
return null;
}
export function resolveWorkTabHostTreeTheme({
activeHostId,
accentMode,
currentTerminalTheme,
customAccent,
followAppTerminalTheme,
hostById,
themeById,
}: {
activeHostId: string | null;
accentMode: 'theme' | 'custom';
currentTerminalTheme: TerminalTheme;
customAccent: string;
followAppTerminalTheme: boolean;
hostById: ReadonlyMap<string, Host>;
themeById: ReadonlyMap<string, TerminalTheme>;
}): TerminalTheme {
if (!activeHostId || followAppTerminalTheme) return currentTerminalTheme;
const host = hostById.get(activeHostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}
+17
View File
@@ -71,6 +71,7 @@ export const enAiMessages: Messages = {
'ai.codex.notFoundHint': 'Could not find codex in PATH. Install it or specify the executable path below.',
'ai.codex.customPathPlaceholder': 'e.g. /usr/local/bin/codex',
'ai.codex.check': 'Check',
'ai.codex.resetPath': 'Reset',
'ai.codex.openLogin': 'Open Login',
'ai.codex.logout': 'Logout',
'ai.codex.connectChatGPT': 'Connect ChatGPT',
@@ -96,6 +97,7 @@ export const enAiMessages: Messages = {
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
'ai.claude.envVars.hint': 'One KEY=VALUE per line, passed to the Claude agent. Stored locally in plaintext — for API keys / credentials, prefer the config directory above (a `claude` login).',
'ai.claude.check': 'Check',
'ai.claude.resetPath': 'Reset',
// AI GitHub Copilot CLI
'ai.copilot.title': 'GitHub Copilot CLI',
@@ -107,6 +109,7 @@ export const enAiMessages: Messages = {
'ai.copilot.notFoundHint': 'Could not find copilot in PATH. Install it or specify the executable path below.',
'ai.copilot.customPathPlaceholder': 'e.g. /usr/local/bin/copilot',
'ai.copilot.check': 'Check',
'ai.copilot.resetPath': 'Reset',
// AI Cursor SDK
'ai.cursor.title': 'Cursor',
@@ -146,6 +149,7 @@ export const enAiMessages: Messages = {
'ai.codebuddy.notFoundHint': 'Could not find codebuddy in PATH. Install it or specify the executable path below.',
'ai.codebuddy.customPathPlaceholder': 'e.g. /usr/local/bin/codebuddy',
'ai.codebuddy.check': 'Check',
'ai.codebuddy.resetPath': 'Reset',
'ai.codebuddy.configSection': 'Authentication & config (optional)',
'ai.codebuddy.internetEnv': 'Internet Environment',
'ai.codebuddy.internetEnv.default': 'Default (overseas)',
@@ -156,6 +160,18 @@ export const enAiMessages: Messages = {
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': 'One KEY=VALUE per line, passed to the CodeBuddy agent. Set CODEBUDDY_API_KEY or CODEBUDDY_AUTH_TOKEN here for authentication. Stored locally in plaintext.',
// AI OpenCode
'ai.opencode.title': 'OpenCode',
'ai.opencode.description': 'Uses OpenCode via the official SDK. Configure providers and keys in OpenCode, then select it as an external coding agent.',
'ai.opencode.detecting': 'Detecting...',
'ai.opencode.detected': 'Detected',
'ai.opencode.notFound': 'Not found',
'ai.opencode.path': 'Path:',
'ai.opencode.notFoundHint': 'Could not find opencode in PATH. Install it or specify the executable path below.',
'ai.opencode.customPathPlaceholder': 'e.g. /usr/local/bin/opencode',
'ai.opencode.check': 'Check',
'ai.opencode.resetPath': 'Reset',
// AI Default Agent
'ai.defaultAgent': 'Default Agent',
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
@@ -316,6 +332,7 @@ export const enAiMessages: Messages = {
'terminal.layer.scripts': 'Scripts',
'terminal.layer.history': 'History',
'terminal.layer.theme': 'Theme',
'terminal.layer.notes': 'Notes',
'terminal.layer.aiChat': 'AI Chat',
'terminal.layer.movePanelLeft': 'Move panel to left',
'terminal.layer.movePanelRight': 'Move panel to right',
+37 -1
View File
@@ -144,6 +144,17 @@ export const enCoreMessages: Messages = {
'settings.update.autoUpdateEnabled': 'Automatic Updates',
'settings.update.autoUpdateEnabledDesc': 'Automatically check and download updates when available.',
// Settings > Session Restore
'settings.sessionRestore.title': 'Session Restore',
'settings.sessionRestore.restorePreviousSession': 'Restore previous tabs and workspaces',
'settings.sessionRestore.restorePreviousSessionDesc': 'Reopen terminal tabs and workspace layouts from the last app session.',
'settings.sessionRestore.restoreTerminalCwd': 'Restore terminal working directory',
'settings.sessionRestore.restoreTerminalCwdDesc': 'When reconnecting a restored terminal, try to return to its last known directory without blocking the connection.',
'terminal.restore.placeholder.title': 'Restored session',
'terminal.restore.placeholder.desc': 'This terminal is disconnected. Reconnect when you are ready to start a new shell for this tab.',
'terminal.restore.placeholder.reconnect': 'Reconnect',
'terminal.restore.cwdLog': 'Restoring working directory: {cwd}',
// Settings > Session Logs
'settings.sessionLogs.title': 'Session Logs',
'settings.sessionLogs.description': 'Configure session log export and auto-save settings.',
@@ -312,8 +323,19 @@ export const enCoreMessages: Messages = {
'settings.terminal.font.size.desc': 'Terminal text size',
'settings.terminal.font.weight': 'Font weight',
'settings.terminal.font.weight.desc': 'Weight for regular text (100-900)',
'settings.terminal.font.weight.thin': 'Thin',
'settings.terminal.font.weight.extraLight': 'Extra Light',
'settings.terminal.font.weight.light': 'Light',
'settings.terminal.font.weight.normal': 'Normal',
'settings.terminal.font.weight.medium': 'Medium',
'settings.terminal.font.weight.semiBold': 'Semi Bold',
'settings.terminal.font.weight.bold': 'Bold',
'settings.terminal.font.weight.extraBold': 'Extra Bold',
'settings.terminal.font.weight.black': 'Black',
'settings.terminal.font.weightBold': 'Bold font weight',
'settings.terminal.font.weightBold.desc': 'Weight for bold text (100-900)',
'settings.terminal.font.smoothing': 'Font smoothing',
'settings.terminal.font.smoothing.desc': 'Use native macOS font anti-aliasing',
'settings.terminal.font.linePadding': 'Line padding',
'settings.terminal.font.linePadding.desc': 'Additional space between lines (0-10)',
'settings.terminal.font.emulationType': 'Terminal emulation type',
@@ -457,6 +479,18 @@ export const enCoreMessages: Messages = {
'settings.terminal.rendering.renderer': 'Renderer',
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use DOM on low-memory devices. Changes take effect on new terminal sessions.',
'settings.terminal.rendering.auto': 'Auto',
'settings.terminal.rendering.hibernateHiddenTabs': 'Hibernate hidden tabs',
'settings.terminal.rendering.hibernateHiddenTabs.desc': 'Dispose the terminal renderer for off-screen tabs to save memory while keeping the SSH session connected. Skipped during file transfers.',
'settings.terminal.rendering.hibernateHiddenTabsDelay': 'Hibernate delay',
'settings.terminal.rendering.hibernateHiddenTabsDelay.desc': 'How long a tab must stay off-screen before its renderer is released (5–600 seconds).',
'settings.terminal.rendering.hibernateSkipAltScreen': 'Skip hibernate on full-screen apps',
'settings.terminal.rendering.hibernateSkipAltScreen.desc': 'Keep the terminal renderer alive while vim, htop, or agent TUIs own the alternate screen buffer.',
'settings.terminal.rendering.hibernateKeepRendererCount': 'Keep hidden renderers',
'settings.terminal.rendering.hibernateKeepRendererCount.desc': 'How many off-screen tabs keep their renderer alive (WebGL suspended) before full hibernate.',
'settings.terminal.rendering.hibernateReplayChunkBytes': 'Wake replay chunk size',
'settings.terminal.rendering.hibernateReplayChunkBytes.desc': 'Bytes replayed per animation frame when restoring a hibernated terminal (4–64 KB).',
'settings.terminal.rendering.hibernatePreferWasmSerialize': 'Prefer WASM serialize',
'settings.terminal.rendering.hibernatePreferWasmSerialize.desc': 'Use WASM terminal serialization when available; falls back to the JS serializer.',
// Settings > Terminal > Workspace Focus Indicator
'settings.terminal.section.workspaceFocus': 'Workspace Focus Indicator',
@@ -552,7 +586,7 @@ export const enCoreMessages: Messages = {
'sync.autoSync.emptyVaultConflict.restoreDesc': 'Recommended — recover your hosts, keys, and snippets from the cloud backup',
'sync.autoSync.emptyVaultConflict.keepEmpty': 'Keep Empty',
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': 'Start fresh with an empty vault',
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} hosts, {keys} keys, {snippets} snippets, {proxyProfiles} proxies',
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} hosts, {keys} keys, {snippets} snippets, {notes} notes, {proxyProfiles} proxies',
'sync.autoSync.emptyVaultManual': 'Cannot sync: the local vault is empty. Restore from a local backup or enable Force Push in the sync panel first.',
'sync.blocked.title': 'Sync paused',
@@ -589,6 +623,7 @@ export const enCoreMessages: Messages = {
'vault.nav.proxies': 'Proxies',
'vault.nav.portForwarding': 'Port Forwarding',
'vault.nav.snippets': 'Snippets',
'vault.nav.notes': 'Notes',
'vault.nav.knownHosts': 'Known Hosts',
'vault.nav.logs': 'Logs',
@@ -689,5 +724,6 @@ export const enCoreMessages: Messages = {
'vault.hosts.errors.nameRequired': 'Host name is required.',
'vault.hosts.empty.title': 'Set up your hosts',
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
'deepLink.ssh.invalid': 'Invalid ssh:// link',
};
+48 -1
View File
@@ -11,6 +11,7 @@ export const enTerminalMessages: Messages = {
'terminal.toolbar.more': 'More actions',
'terminal.toolbar.scripts': 'Scripts',
'terminal.toolbar.history': 'Command history',
'terminal.toolbar.configureOsc7': 'Configure directory tracking',
'history.scope.label': 'History scope',
'history.tab.host': 'Host',
'history.tab.global': 'Global',
@@ -51,6 +52,7 @@ export const enTerminalMessages: Messages = {
'terminal.composeBar.snippetClickHint': 'Click to insert · Shift+Click to send',
'terminal.toolbar.focus': 'Focus',
'terminal.toolbar.focusMode': 'Focus Mode',
'terminal.toolbar.detach': 'Detach to standalone tab',
'terminal.toolbar.encoding': 'Terminal Encoding',
'terminal.toolbar.encoding.utf8': 'UTF-8',
'terminal.toolbar.encoding.gb18030': 'GB18030',
@@ -101,6 +103,7 @@ export const enTerminalMessages: Messages = {
'terminal.search.nextMatch': 'Next match (Enter)',
'terminal.menu.copy': 'Copy',
'terminal.menu.paste': 'Paste',
'terminal.menu.uploadClipboardImage': 'Upload clipboard image',
'terminal.menu.addSelectionToAI': 'Add to Conversation',
'terminal.menu.pasteSelection': 'Paste Selection',
'terminal.menu.selectAll': 'Select All',
@@ -111,6 +114,17 @@ export const enTerminalMessages: Messages = {
'terminal.menu.splitVertical': 'Split Vertical',
'terminal.menu.clearBuffer': 'Clear Buffer',
'terminal.menu.closeTerminal': 'Close terminal',
'terminal.menu.rename': 'Rename',
'terminal.menu.detach': 'Detach from workspace',
'terminal.menu.detachSession': 'Detach {name}',
'terminal.clipboardImageUpload.noImage': 'Clipboard does not contain an image',
'terminal.clipboardImageUpload.failed': 'Could not upload clipboard image',
'terminal.osc7Setup.title': 'Configure directory tracking',
'terminal.osc7Setup.desc': 'Netcatty will add OSC 7 prompt hooks for the current remote user. This helps SFTP follow the terminal directory after sudo or su.',
'terminal.osc7Setup.targets': 'Possible files to update',
'terminal.osc7Setup.command': 'Command to run',
'terminal.osc7Setup.run': 'Run setup',
'terminal.osc7Setup.sent': 'Directory tracking setup sent to terminal',
'terminal.ymodem.selectFile': 'Select file to send',
'terminal.ymodem.allFiles': 'All files',
'terminal.ymodem.started': 'YMODEM sending {fileName}',
@@ -330,7 +344,7 @@ export const enTerminalMessages: Messages = {
'cloudSync.localBackups.reason.appVersionChange': 'Before app version change',
'cloudSync.localBackups.reason.beforeRestore': 'Before restore',
'cloudSync.localBackups.versionChange': '{from} -> {to}',
'cloudSync.localBackups.counts': '{hosts} hosts, {keys} keys, {snippets} snippets',
'cloudSync.localBackups.counts': '{hosts} hosts, {keys} keys, {snippets} snippets, {notes} notes',
'cloudSync.localBackups.restore': 'Restore',
'cloudSync.localBackups.restoreSuccess': 'Local backup restored.',
'cloudSync.localBackups.restoreFailedTitle': 'Restore failed',
@@ -355,6 +369,7 @@ export const enTerminalMessages: Messages = {
'cloudSync.revisionHistory.hosts': 'Hosts',
'cloudSync.revisionHistory.keys': 'Keys',
'cloudSync.revisionHistory.snippets': 'Snippets',
'cloudSync.revisionHistory.notes': 'Notes',
'cloudSync.revisionHistory.identities': 'Identities',
'cloudSync.revisionHistory.restoreButton': 'Restore This Version',
'cloudSync.revisionHistory.restored': 'Vault restored from selected revision.',
@@ -408,6 +423,8 @@ export const enTerminalMessages: Messages = {
'cloudSync.conflict.entity.identities': 'Identities',
'cloudSync.conflict.entity.proxyProfiles': 'Proxy profiles',
'cloudSync.conflict.entity.snippets': 'Snippets',
'cloudSync.conflict.entity.notes': 'Notes',
'cloudSync.conflict.entity.noteGroups': 'Note groups',
'cloudSync.conflict.entity.customGroups': 'Groups',
'cloudSync.conflict.entity.snippetPackages': 'Snippet packages',
'cloudSync.conflict.entity.portForwardingRules': 'Port forwarding',
@@ -559,6 +576,8 @@ export const enTerminalMessages: Messages = {
'snippets.searchPlaceholder': 'Search snippets...',
'snippets.action.newSnippet': 'New Snippet',
'snippets.action.newPackage': 'New Package',
'snippets.action.import': 'Import',
'snippets.action.selectSnippets': 'Select snippets',
'snippets.panel.newTitle': 'New Snippet',
'snippets.panel.editTitle': 'Edit Snippet',
'snippets.field.description': 'Action description',
@@ -596,6 +615,34 @@ export const enTerminalMessages: Messages = {
'snippets.commandFallback': 'Command',
'snippets.view.grid': 'Grid',
'snippets.view.list': 'List',
'snippets.selection.selected': '{count} selected',
'snippets.selection.selectVisible': 'Select visible',
'snippets.selection.deselectAll': 'Deselect all',
'snippets.selection.exportSelected': 'Export selected ({count})',
'snippets.export.snippet': 'Export snippet',
'snippets.export.package': 'Export package',
'snippets.export.toast.empty': 'No snippets to export.',
'snippets.export.toast.successTitle': 'Export ready',
'snippets.export.toast.success': 'Exported {count} snippet(s).',
'snippets.import.toast.empty': 'No importable snippets found.',
'snippets.import.toast.failedTitle': 'Import failed',
'snippets.import.toast.invalidDesc': 'This is not a valid Netcatty snippets file.',
'snippets.import.toast.successTitle': 'Import completed',
'snippets.import.toast.summary': 'Imported {imported}, overwritten {overwritten}, skipped {skipped}.',
'snippets.import.modal.title': 'Import snippets',
'snippets.import.modal.desc': 'Choose one or more Netcatty snippets JSON files. Netcatty will preview them before anything is imported.',
'snippets.import.modal.exampleTitle': 'Example JSON',
'snippets.import.modal.noFile': 'No file selected yet. Use the example format below, or a plain JSON array of snippets, then choose one or more files.',
'snippets.import.modal.chooseFile': 'Choose file',
'snippets.import.modal.downloadExamples': 'Download samples',
'snippets.import.modal.multipleFiles': '{count} files selected',
'snippets.import.modal.parsedSummary': '{files} file(s), {total} snippet(s), {packages} 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.parent': 'Parent: {parent}',
'snippets.packageDialog.root': 'Root',
+98 -2
View File
@@ -1,6 +1,34 @@
import type { Messages } from '../types';
export const enVaultMessages: Messages = {
// Notes
'notes.search.placeholder': 'Search notes, hosts, tags...',
'notes.action.newNote': 'New Note',
'notes.action.newGroup': 'New Folder',
'notes.action.importMarkdown': 'Import Markdown',
'notes.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.codeBlock.copied': 'Copied',
'notes.codeBlock.copyFailed': 'Failed to copy code block.',
'notes.group.prompt': 'Folder path',
'notes.group.all': 'Notebook',
'notes.group.ungrouped': 'Ungrouped',
'notes.section.notebooks': 'Notebook',
'notes.section.notes': 'Notes',
'notes.mode.edit': 'Edit',
'notes.mode.split': 'Split',
'notes.mode.preview': 'Preview',
'notes.title.placeholder': 'Note title',
'notes.editor.placeholder': 'Write Markdown notes here...',
'notes.preview.empty': 'Nothing to preview yet.',
'notes.empty.title': 'No notes yet',
'notes.empty.desc': 'Create Markdown notes for runbooks, reminders, and server handoff details.',
'notes.empty.group': 'No notes in this notebook.',
'notes.search.noResults': 'No notes found.',
// Vault import
'vault.import.title': 'Add data to your vault',
'vault.import.desc':
@@ -101,8 +129,9 @@ export const enVaultMessages: Messages = {
'pf.form.openWizardTitle': 'Open Port Forwarding Wizard',
'pf.view.grid': 'Grid',
'pf.view.list': 'List',
'pf.rule.summary.local': 'Local {bindAddress}:{localPort} -> SSH tunnel -> {remoteHost}:{remotePort}',
'pf.rule.summary.remote': 'Remote {bindAddress}:{localPort} -> SSH tunnel -> local {remoteHost}:{remotePort}',
'pf.rule.summary.dynamic': 'SOCKS on {bindAddress}:{localPort}',
'pf.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
'pf.tooltip.relayHost': 'Relay Host',
'pf.tooltip.hostLabel': 'Host',
'pf.tooltip.hostAddress': 'Address',
@@ -151,6 +180,9 @@ export const enVaultMessages: Messages = {
'sftp.moveTo.pathNotFound': 'Directory not found or inaccessible',
'sftp.context.download': 'Download',
'sftp.context.copyToOtherPane': 'Copy to other pane',
'sftp.copyCurrentPath': 'Copy current path',
'sftp.copyCurrentPath.success': 'Current path copied',
'sftp.copyCurrentPath.error': 'Could not copy current path',
'sftp.viewMode.label': 'View mode',
'sftp.viewMode.list': 'List view',
'sftp.viewMode.tree': 'Tree view',
@@ -344,6 +376,9 @@ export const enVaultMessages: Messages = {
'settings.sftp.doubleClickBehavior.transfer': 'Transfer to other pane',
'settings.sftp.doubleClickBehavior.openDesc': 'Open the file in the default application',
'settings.sftp.doubleClickBehavior.transferDesc': 'Transfer the file to the other pane\'s active host',
'settings.sshDeepLink.title': 'SSH links',
'settings.sshDeepLink.enable': 'Open ssh:// links with Netcatty',
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// links from browsers and other apps.',
// Settings > SFTP Auto Sync
'settings.sftp.autoSync': 'Auto-sync to remote',
@@ -465,6 +500,63 @@ export const enVaultMessages: Messages = {
'hostDetails.section.appearance': 'Appearance',
'hostDetails.distro.title': 'Linux Distribution',
'hostDetails.distro.desc': 'Auto-detect on connect, or override the distro icon manually.',
'hostDetails.icon.title': 'Host Icon',
'hostDetails.icon.sectionTitle': 'Color & Icon',
'hostDetails.icon.desc': 'Choose the icon source and color for this host.',
'hostDetails.icon.source': 'Source',
'hostDetails.icon.manualLabel': 'Manual icon',
'hostDetails.icon.tab.brand': 'Brand',
'hostDetails.icon.tab.type': 'Type',
'hostDetails.icon.mode.auto': 'Automatic',
'hostDetails.icon.mode.custom': 'Custom',
'hostDetails.icon.reset': 'Reset host icon',
'hostDetails.icon.colorLabel': 'Icon color',
'hostDetails.icon.colorDefault': 'Default',
'hostDetails.icon.colorAuto': 'Automatic',
'hostDetails.icon.colorManual': 'Manual',
'hostDetails.icon.colorChoice': 'Color',
'hostDetails.icon.autoColorLabel': 'Current color',
'hostDetails.icon.showLibrary': 'Show icon library',
'hostDetails.icon.hideLibrary': 'Hide icon library',
'hostDetails.icon.autoUsesDistro': 'Use Linux Distribution icon and selected color for this host.',
'hostDetails.icon.customOverridesDistro': 'Built-in icon replaces Linux Distribution for this host.',
'hostDetails.icon.option.server': 'Server',
'hostDetails.icon.option.terminal': 'Terminal',
'hostDetails.icon.option.database': 'Database',
'hostDetails.icon.option.cloud': 'Cloud',
'hostDetails.icon.option.router': 'Router',
'hostDetails.icon.option.shield': 'Shield',
'hostDetails.icon.option.code': 'Code',
'hostDetails.icon.option.box': 'Box',
'hostDetails.icon.option.globe': 'Globe',
'hostDetails.icon.option.cpu': 'CPU',
'hostDetails.icon.option.hard-drive': 'Storage',
'hostDetails.icon.option.network': 'Network',
'hostDetails.icon.option.wifi': 'Wireless',
'hostDetails.icon.option.lock': 'Lock',
'hostDetails.icon.option.key': 'Key',
'hostDetails.icon.option.monitor': 'Monitor',
'hostDetails.icon.option.container': 'Container',
'hostDetails.icon.option.activity': 'Activity',
'hostDetails.icon.option.zap': 'Fast',
'hostDetails.icon.option.server-cog': 'Server settings',
'hostDetails.icon.color.blue': 'Blue',
'hostDetails.icon.color.green': 'Green',
'hostDetails.icon.color.red': 'Red',
'hostDetails.icon.color.amber': 'Amber',
'hostDetails.icon.color.purple': 'Purple',
'hostDetails.icon.color.cyan': 'Cyan',
'hostDetails.icon.color.orange': 'Orange',
'hostDetails.icon.color.slate': 'Slate',
'hostDetails.icon.color.violet': 'Violet',
'hostDetails.icon.color.pink': 'Pink',
'hostDetails.icon.color.rose': 'Rose',
'hostDetails.icon.color.lime': 'Lime',
'hostDetails.icon.color.teal': 'Teal',
'hostDetails.icon.color.sky': 'Sky',
'hostDetails.icon.color.indigo': 'Indigo',
'hostDetails.icon.color.zinc': 'Zinc',
'hostDetails.icon.color.custom': 'Custom color',
'hostDetails.distro.mode': 'Source',
'hostDetails.distro.mode.auto': 'Auto-detect',
'hostDetails.distro.mode.manual': 'Manual override',
@@ -485,12 +577,14 @@ export const enVaultMessages: Messages = {
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
'hostDetails.distro.option.almalinux': 'AlmaLinux',
'hostDetails.distro.option.alinux': 'Alibaba Cloud Linux',
'hostDetails.distro.option.openeuler': 'openEuler',
'hostDetails.distro.option.oracle': 'Oracle Linux',
'hostDetails.distro.option.kali': 'Kali Linux',
'hostDetails.distro.option.cisco': 'Cisco',
'hostDetails.distro.option.juniper': 'Juniper Networks',
'hostDetails.distro.option.huawei': 'Huawei',
'hostDetails.distro.option.hpe': 'HPE / H3C',
'hostDetails.distro.option.h3c': 'H3C',
'hostDetails.distro.option.hpe': 'HPE',
'hostDetails.distro.option.mikrotik': 'MikroTik',
'hostDetails.distro.option.fortinet': 'Fortinet',
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
@@ -534,6 +628,8 @@ export const enVaultMessages: Messages = {
'hostDetails.section.terminalBehavior': 'Terminal Behavior',
'hostDetails.lineTimestamps': 'Show output timestamps',
'hostDetails.lineTimestamps.desc': 'Show local time beside visible output lines for this host without changing terminal text.',
'hostDetails.disableDynamicTabTitle': 'Disable dynamic tab title',
'hostDetails.disableDynamicTabTitle.desc': 'Keep the connection name on session tabs instead of following the shell-reported window title.',
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',
+5
View File
@@ -71,6 +71,7 @@ export const ruAiMessages: Messages = {
'ai.codex.notFoundHint': 'Не удалось найти codex в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
'ai.codex.customPathPlaceholder': 'например, /usr/local/bin/codex',
'ai.codex.check': 'Проверить',
'ai.codex.resetPath': 'Сбросить',
'ai.codex.openLogin': 'Открыть вход',
'ai.codex.logout': 'Выйти',
'ai.codex.connectChatGPT': 'Подключить ChatGPT',
@@ -96,6 +97,7 @@ export const ruAiMessages: Messages = {
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
'ai.claude.envVars.hint': 'По одному KEY=VALUE в строке, передаётся агенту Claude. Хранится локально в открытом виде — для API-ключей и учётных данных используйте «Каталог конфигурации» выше (вход `claude`).',
'ai.claude.check': 'Проверить',
'ai.claude.resetPath': 'Сбросить',
// AI GitHub Copilot CLI
'ai.copilot.title': 'GitHub Copilot CLI',
@@ -107,6 +109,7 @@ export const ruAiMessages: Messages = {
'ai.copilot.notFoundHint': 'Не удалось найти copilot в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
'ai.copilot.customPathPlaceholder': 'например, /usr/local/bin/copilot',
'ai.copilot.check': 'Проверить',
'ai.copilot.resetPath': 'Сбросить',
// AI Cursor SDK
'ai.cursor.title': 'Cursor',
@@ -146,6 +149,7 @@ export const ruAiMessages: Messages = {
'ai.codebuddy.notFoundHint': 'Не удалось найти codebuddy в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
'ai.codebuddy.customPathPlaceholder': 'например, /usr/local/bin/codebuddy',
'ai.codebuddy.check': 'Проверить',
'ai.codebuddy.resetPath': 'Сбросить',
'ai.codebuddy.configSection': 'Аутентификация и конфигурация (необязательно)',
'ai.codebuddy.internetEnv': 'Сетевая среда',
'ai.codebuddy.internetEnv.default': 'По умолчанию (зарубежная)',
@@ -316,6 +320,7 @@ export const ruAiMessages: Messages = {
'terminal.layer.scripts': 'Скрипты',
'terminal.layer.history': 'История',
'terminal.layer.theme': 'Тема',
'terminal.layer.notes': 'Заметки',
'terminal.layer.aiChat': 'AI-чат',
'terminal.layer.movePanelLeft': 'Переместить панель влево',
'terminal.layer.movePanelRight': 'Переместить панель вправо',
+19 -1
View File
@@ -312,8 +312,19 @@ export const ruCoreMessages: Messages = {
'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': 'Тип эмуляции терминала',
@@ -500,6 +511,7 @@ export const ruCoreMessages: Messages = {
'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': 'Вставить в терминал',
@@ -507,9 +519,13 @@ export const ruCoreMessages: Messages = {
'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',
@@ -589,7 +605,7 @@ export const ruCoreMessages: Messages = {
'sync.autoSync.emptyVaultConflict.restoreDesc': 'Рекомендуется — восстановить ваши хосты, ключи и сниппеты из облачной резервной копии',
'sync.autoSync.emptyVaultConflict.keepEmpty': 'Оставить пустым',
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': 'Начать заново с пустым хранилищем',
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} хостов, {keys} ключей, {snippets} сниппетов, {proxyProfiles} прокси',
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} хостов, {keys} ключей, {snippets} сниппетов, {notes} заметок, {proxyProfiles} прокси',
'sync.autoSync.emptyVaultManual': 'Синхронизация невозможна: локальное хранилище пусто. Сначала восстановите его из локальной резервной копии или включите принудительную отправку в панели синхронизации.',
'sync.blocked.title': 'Синхронизация приостановлена',
@@ -626,6 +642,7 @@ export const ruCoreMessages: Messages = {
'vault.nav.proxies': 'Прокси',
'vault.nav.portForwarding': 'Проброс портов',
'vault.nav.snippets': 'Сниппеты',
'vault.nav.notes': 'Заметки',
'vault.nav.knownHosts': 'Известные хосты',
'vault.nav.logs': 'Журналы',
@@ -689,5 +706,6 @@ export const ruCoreMessages: Messages = {
'vault.hosts.header.entries': 'Записей: {count}',
'vault.hosts.header.live': 'Активных: {count}',
'deepLink.ssh.invalid': 'Недопустимая ссылка ssh://',
};
+48 -1
View File
@@ -32,6 +32,7 @@ export const ruTerminalMessages: Messages = {
'terminal.toolbar.more': 'Другие действия',
'terminal.toolbar.scripts': 'Скрипты',
'terminal.toolbar.history': 'История команд',
'terminal.toolbar.configureOsc7': 'Настроить отслеживание каталога',
'history.scope.label': 'Область истории',
'history.tab.host': 'Хост',
'history.tab.global': 'Глобальная',
@@ -72,6 +73,7 @@ export const ruTerminalMessages: Messages = {
'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',
@@ -122,6 +124,7 @@ export const ruTerminalMessages: Messages = {
'terminal.search.nextMatch': 'Следующее совпадение (Enter)',
'terminal.menu.copy': 'Копировать',
'terminal.menu.paste': 'Вставить',
'terminal.menu.uploadClipboardImage': 'Загрузить изображение из буфера',
'terminal.menu.addSelectionToAI': 'Добавить в чат',
'terminal.menu.pasteSelection': 'Вставить выделенное',
'terminal.menu.selectAll': 'Выбрать всё',
@@ -132,6 +135,15 @@ export const ruTerminalMessages: Messages = {
'terminal.menu.splitVertical': 'Разделить по вертикали',
'terminal.menu.clearBuffer': 'Очистить буфер',
'terminal.menu.closeTerminal': 'Закрыть терминал',
'terminal.menu.rename': 'Переименовать',
'terminal.menu.detach': 'Открепить из рабочей области',
'terminal.menu.detachSession': 'Открепить {name}',
'terminal.osc7Setup.title': 'Настроить отслеживание каталога',
'terminal.osc7Setup.desc': 'Netcatty добавит хуки OSC 7 для текущего удалённого пользователя. Это помогает SFTP следовать за каталогом терминала после sudo или su.',
'terminal.osc7Setup.targets': 'Возможные файлы для обновления',
'terminal.osc7Setup.command': 'Команда для запуска',
'terminal.osc7Setup.run': 'Запустить настройку',
'terminal.osc7Setup.sent': 'Настройка отслеживания каталога отправлена в терминал',
'terminal.ymodem.selectFile': 'Выберите файл для отправки',
'terminal.ymodem.allFiles': 'Все файлы',
'terminal.ymodem.started': 'YMODEM отправляет {fileName}',
@@ -144,6 +156,8 @@ export const ruTerminalMessages: Messages = {
'terminal.ymodem.receiveEmpty': 'Файлы YMODEM не получены',
'terminal.ymodem.receiveFailed': 'Не удалось получить через YMODEM',
'terminal.ymodem.unavailable': 'YMODEM недоступен',
'terminal.clipboardImageUpload.noImage': 'В буфере обмена нет изображения',
'terminal.clipboardImageUpload.failed': 'Не удалось загрузить изображение из буфера обмена',
'terminal.selection.addToAI': 'Добавить в чат',
'terminal.selection.addToAIDesc': 'Прикрепить выбранный вывод терминала к черновику AI',
'terminal.auth.password': 'Пароль',
@@ -348,7 +362,7 @@ export const ruTerminalMessages: Messages = {
'cloudSync.localBackups.reason.appVersionChange': 'Перед сменой версии приложения',
'cloudSync.localBackups.reason.beforeRestore': 'Перед восстановлением',
'cloudSync.localBackups.versionChange': '{from} -> {to}',
'cloudSync.localBackups.counts': '{hosts} хостов, {keys} ключей, {snippets} сниппетов',
'cloudSync.localBackups.counts': '{hosts} хостов, {keys} ключей, {snippets} сниппетов, {notes} заметок',
'cloudSync.localBackups.restore': 'Восстановить',
'cloudSync.localBackups.restoreSuccess': 'Локальная резервная копия восстановлена.',
'cloudSync.localBackups.restoreFailedTitle': 'Ошибка восстановления',
@@ -373,6 +387,7 @@ export const ruTerminalMessages: Messages = {
'cloudSync.revisionHistory.hosts': 'Хосты',
'cloudSync.revisionHistory.keys': 'Ключи',
'cloudSync.revisionHistory.snippets': 'Сниппеты',
'cloudSync.revisionHistory.notes': 'Заметки',
'cloudSync.revisionHistory.identities': 'Идентификаторы',
'cloudSync.revisionHistory.restoreButton': 'Восстановить эту версию',
'cloudSync.revisionHistory.restored': 'Хранилище восстановлено из выбранной ревизии.',
@@ -426,6 +441,8 @@ export const ruTerminalMessages: Messages = {
'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': 'Проброс портов',
@@ -574,6 +591,8 @@ export const ruTerminalMessages: Messages = {
'snippets.searchPlaceholder': 'Поиск сниппетов...',
'snippets.action.newSnippet': 'Новый сниппет',
'snippets.action.newPackage': 'Новый пакет',
'snippets.action.import': 'Импорт',
'snippets.action.selectSnippets': 'Выбрать сниппеты',
'snippets.panel.newTitle': 'Новый сниппет',
'snippets.panel.editTitle': 'Редактировать сниппет',
'snippets.field.description': 'Описание действия',
@@ -622,6 +641,34 @@ export const ruTerminalMessages: Messages = {
'snippets.commandFallback': 'Команда',
'snippets.view.grid': 'Сетка',
'snippets.view.list': 'Список',
'snippets.selection.selected': 'Выбрано: {count}',
'snippets.selection.selectVisible': 'Выбрать видимые',
'snippets.selection.deselectAll': 'Снять выбор',
'snippets.selection.exportSelected': 'Экспорт выбранных ({count})',
'snippets.export.snippet': 'Экспорт сниппета',
'snippets.export.package': 'Экспорт пакета',
'snippets.export.toast.empty': 'Нет сниппетов для экспорта.',
'snippets.export.toast.successTitle': 'Экспорт готов',
'snippets.export.toast.success': 'Экспортировано сниппетов: {count}.',
'snippets.import.toast.empty': 'Не найдено сниппетов для импорта.',
'snippets.import.toast.failedTitle': 'Импорт не удался',
'snippets.import.toast.invalidDesc': 'Это невалидный файл сниппетов Netcatty.',
'snippets.import.toast.successTitle': 'Импорт завершён',
'snippets.import.toast.summary': 'Импортировано: {imported}, перезаписано: {overwritten}, пропущено: {skipped}.',
'snippets.import.modal.title': 'Импорт сниппетов',
'snippets.import.modal.desc': 'Выберите один или несколько JSON-файлов сниппетов Netcatty. Netcatty сначала покажет результат разбора и ничего не импортирует без подтверждения.',
'snippets.import.modal.exampleTitle': 'Пример JSON',
'snippets.import.modal.noFile': 'Файл ещё не выбран. Используйте пример ниже или обычный JSON-массив сниппетов, затем выберите один или несколько файлов.',
'snippets.import.modal.chooseFile': 'Выбрать файл',
'snippets.import.modal.downloadExamples': 'Скачать примеры',
'snippets.import.modal.multipleFiles': 'Выбрано файлов: {count}',
'snippets.import.modal.parsedSummary': '{files} файл(ов), {total} сниппет(ов), {packages} пакет(ов), повторяющихся команд: {conflicts}. Привязки к хостам игнорируются.',
'snippets.import.modal.confirm': 'Подтвердить импорт',
'snippets.import.conflict.title': 'Импортировать сниппеты?',
'snippets.import.conflict.desc': 'Файл «{file}» содержит {total} сниппет(ов), из них повторяющихся команд: {conflicts}.',
'snippets.import.conflict.hostBindingsNote': 'Привязки сниппетов к хостам не импортируются и не экспортируются.',
'snippets.import.conflict.skip': 'Пропустить повторы',
'snippets.import.conflict.overwrite': 'Перезаписать повторы',
'snippets.packageDialog.title': 'Новый пакет',
'snippets.packageDialog.parent': 'Родитель: {parent}',
'snippets.packageDialog.root': 'Корень',
+99 -3
View File
@@ -1,6 +1,34 @@
import type { Messages } from '../types';
export const ruVaultMessages: Messages = {
// Notes
'notes.search.placeholder': 'Поиск заметок, хостов, тегов...',
'notes.action.newNote': 'Новая заметка',
'notes.action.newGroup': 'Новая папка',
'notes.action.importMarkdown': 'Импорт Markdown',
'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.codeBlock.copied': 'Скопировано',
'notes.codeBlock.copyFailed': 'Не удалось скопировать блок кода.',
'notes.group.prompt': 'Путь папки',
'notes.group.all': 'Блокнот',
'notes.group.ungrouped': 'Без группы',
'notes.section.notebooks': 'Блокнот',
'notes.section.notes': 'Заметки',
'notes.mode.edit': 'Правка',
'notes.mode.split': 'Разделить',
'notes.mode.preview': 'Просмотр',
'notes.title.placeholder': 'Заголовок заметки',
'notes.editor.placeholder': 'Пишите Markdown-заметки здесь...',
'notes.preview.empty': 'Пока нечего просматривать.',
'notes.empty.title': 'Заметок пока нет',
'notes.empty.desc': 'Создавайте Markdown-заметки для инструкций, напоминаний и передачи серверов.',
'notes.empty.group': 'В этом блокноте пока нет заметок.',
'notes.search.noResults': 'Заметки не найдены.',
// Vault hosts header/actions
'vault.hosts.search.placeholder': 'Найти хост или ssh user@hostname / ssh -p 2222 user@hostname...',
'vault.hosts.connect': 'Подключиться',
@@ -136,8 +164,9 @@ export const ruVaultMessages: Messages = {
'pf.form.openWizardTitle': 'Открыть мастер проброса портов',
'pf.view.grid': 'Сетка',
'pf.view.list': 'Список',
'pf.rule.summary.local': 'Локально {bindAddress}:{localPort} -> SSH-туннель -> удалённо {remoteHost}:{remotePort}',
'pf.rule.summary.remote': 'Удалённо {bindAddress}:{localPort} -> SSH-туннель -> локально {remoteHost}:{remotePort}',
'pf.rule.summary.dynamic': 'SOCKS на {bindAddress}:{localPort}',
'pf.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
'pf.tooltip.relayHost': 'Промежуточный хост',
'pf.tooltip.hostLabel': 'Хост',
'pf.tooltip.hostAddress': 'Адрес',
@@ -186,6 +215,9 @@ export const ruVaultMessages: Messages = {
'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': 'Дерево',
@@ -379,6 +411,9 @@ export const ruVaultMessages: Messages = {
'settings.sftp.doubleClickBehavior.transfer': 'Передать в другую панель',
'settings.sftp.doubleClickBehavior.openDesc': 'Открыть файл в приложении по умолчанию',
'settings.sftp.doubleClickBehavior.transferDesc': 'Передать файл на активный хост другой панели',
'settings.sshDeepLink.title': 'SSH-ссылки',
'settings.sshDeepLink.enable': 'Открывать ssh:// ссылки в Netcatty',
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// ссылки из браузеров и других приложений.',
// Settings > SFTP Auto Sync
'settings.sftp.autoSync': 'Автосинхронизация с удалённым сервером',
@@ -499,7 +534,64 @@ export const ruVaultMessages: Messages = {
'hostDetails.section.portCredentials': 'Порт и учётные данные',
'hostDetails.section.appearance': 'Внешний вид',
'hostDetails.distro.title': 'Дистрибутив Linux',
'hostDetails.distro.desc': 'Автоопределение при подключении или ручное переопределение значка дистрибутива.',
'hostDetails.distro.desc': 'Автоопределение при подключении или ручная замена значка дистрибутива.',
'hostDetails.icon.title': 'Значок хоста',
'hostDetails.icon.sectionTitle': 'Цвет и значок',
'hostDetails.icon.desc': 'Выберите источник значка и цвет для этого хоста.',
'hostDetails.icon.source': 'Источник',
'hostDetails.icon.manualLabel': 'Значок вручную',
'hostDetails.icon.tab.brand': 'Бренд',
'hostDetails.icon.tab.type': 'Тип',
'hostDetails.icon.mode.auto': 'Авто',
'hostDetails.icon.mode.custom': 'Свой',
'hostDetails.icon.reset': 'Сбросить значок',
'hostDetails.icon.colorLabel': 'Цвет значка',
'hostDetails.icon.colorDefault': 'По умолчанию',
'hostDetails.icon.colorAuto': 'Авто',
'hostDetails.icon.colorManual': 'Вручную',
'hostDetails.icon.colorChoice': 'Цвет',
'hostDetails.icon.autoColorLabel': 'Текущий цвет',
'hostDetails.icon.showLibrary': 'Показать библиотеку значков',
'hostDetails.icon.hideLibrary': 'Скрыть библиотеку значков',
'hostDetails.icon.autoUsesDistro': 'Использует значок дистрибутива Linux и выбранный цвет для этого хоста.',
'hostDetails.icon.customOverridesDistro': 'Встроенный значок заменяет значок дистрибутива Linux для этого хоста.',
'hostDetails.icon.option.server': 'Сервер',
'hostDetails.icon.option.terminal': 'Терминал',
'hostDetails.icon.option.database': 'База данных',
'hostDetails.icon.option.cloud': 'Облако',
'hostDetails.icon.option.router': 'Маршрутизатор',
'hostDetails.icon.option.shield': 'Защита',
'hostDetails.icon.option.code': 'Код',
'hostDetails.icon.option.box': 'Узел',
'hostDetails.icon.option.globe': 'Глобус',
'hostDetails.icon.option.cpu': 'CPU',
'hostDetails.icon.option.hard-drive': 'Хранилище',
'hostDetails.icon.option.network': 'Сеть',
'hostDetails.icon.option.wifi': 'Wi-Fi',
'hostDetails.icon.option.lock': 'Замок',
'hostDetails.icon.option.key': 'Ключ',
'hostDetails.icon.option.monitor': 'Монитор',
'hostDetails.icon.option.container': 'Контейнер',
'hostDetails.icon.option.activity': 'Активность',
'hostDetails.icon.option.zap': 'Быстрый',
'hostDetails.icon.option.server-cog': 'Настройки сервера',
'hostDetails.icon.color.blue': 'Синий',
'hostDetails.icon.color.green': 'Зеленый',
'hostDetails.icon.color.red': 'Красный',
'hostDetails.icon.color.amber': 'Янтарный',
'hostDetails.icon.color.purple': 'Фиолетовый',
'hostDetails.icon.color.cyan': 'Голубой',
'hostDetails.icon.color.orange': 'Оранжевый',
'hostDetails.icon.color.slate': 'Серый',
'hostDetails.icon.color.violet': 'Фиолетово-синий',
'hostDetails.icon.color.pink': 'Розовый',
'hostDetails.icon.color.rose': 'Розово-красный',
'hostDetails.icon.color.lime': 'Лаймовый',
'hostDetails.icon.color.teal': 'Бирюзовый',
'hostDetails.icon.color.sky': 'Небесный',
'hostDetails.icon.color.indigo': 'Индиго',
'hostDetails.icon.color.zinc': 'Цинковый',
'hostDetails.icon.color.custom': 'Свой цвет',
'hostDetails.distro.mode': 'Источник',
'hostDetails.distro.mode.auto': 'Автоопределение',
'hostDetails.distro.mode.manual': 'Ручное переопределение',
@@ -520,12 +612,14 @@ export const ruVaultMessages: Messages = {
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
'hostDetails.distro.option.almalinux': 'AlmaLinux',
'hostDetails.distro.option.alinux': 'Alibaba Cloud Linux',
'hostDetails.distro.option.openeuler': 'openEuler',
'hostDetails.distro.option.oracle': 'Oracle Linux',
'hostDetails.distro.option.kali': 'Kali Linux',
'hostDetails.distro.option.cisco': 'Cisco',
'hostDetails.distro.option.juniper': 'Juniper Networks',
'hostDetails.distro.option.huawei': 'Huawei',
'hostDetails.distro.option.hpe': 'HPE / H3C',
'hostDetails.distro.option.h3c': 'H3C',
'hostDetails.distro.option.hpe': 'HPE',
'hostDetails.distro.option.mikrotik': 'MikroTik',
'hostDetails.distro.option.fortinet': 'Fortinet',
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
@@ -566,6 +660,8 @@ export const ruVaultMessages: Messages = {
'hostDetails.section.terminalBehavior': 'Поведение терминала',
'hostDetails.lineTimestamps': 'Показывать время вывода',
'hostDetails.lineTimestamps.desc': 'Показывать локальное время рядом с видимыми строками вывода для этого хоста, не изменяя текст терминала.',
'hostDetails.disableDynamicTabTitle': 'Отключить динамический заголовок вкладки',
'hostDetails.disableDynamicTabTitle.desc': 'Показывать имя подключения на вкладках сессии вместо заголовка окна от shell.',
'hostDetails.legacyAlgorithms': 'Разрешить устаревшие алгоритмы',
'hostDetails.legacyAlgorithms.desc': 'Включить устаревшие SSH-алгоритмы (diffie-hellman-group1, ssh-dss, 3des-cbc и т. д.) для подключения к старому сетевому оборудованию.',
'hostDetails.legacyAlgorithms.warning': 'У этих алгоритмов есть известные слабые места безопасности. Включайте только для устаревших устройств, которые не поддерживают современную криптографию.',
@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
import { HOST_ICON_COLORS, HOST_ICON_IDS } from "../../../domain/hostIcon.ts";
import zhCN from "./zh-CN.ts";
import ru from "./ru.ts";
const LOCALIZED_SETTINGS_LOCALES = [
{ name: "zh-CN", messages: zhCN },
{ name: "ru", messages: ru },
];
test("localized settings include names for every default shortcut", () => {
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
const missing = DEFAULT_KEY_BINDINGS
.map((binding) => `settings.shortcuts.binding.${binding.id}`)
.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing shortcut labels`);
}
});
test("localized settings include workspace focus indicator labels", () => {
const keys = [
"settings.terminal.section.workspaceFocus",
"settings.terminal.workspaceFocus.style",
"settings.terminal.workspaceFocus.style.desc",
"settings.terminal.workspaceFocus.dim",
"settings.terminal.workspaceFocus.border",
];
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
const missing = keys.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing workspace focus labels`);
}
});
test("localized settings include terminal font weight option labels", () => {
const keys = [
"settings.terminal.font.weight.thin",
"settings.terminal.font.weight.extraLight",
"settings.terminal.font.weight.light",
"settings.terminal.font.weight.normal",
"settings.terminal.font.weight.medium",
"settings.terminal.font.weight.semiBold",
"settings.terminal.font.weight.bold",
"settings.terminal.font.weight.extraBold",
"settings.terminal.font.weight.black",
];
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
const missing = keys.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing font weight labels`);
}
});
test("localized vault messages include host icon labels", () => {
const keys = [
"hostDetails.icon.title",
"hostDetails.icon.desc",
"hostDetails.icon.mode.auto",
"hostDetails.icon.mode.custom",
"hostDetails.icon.reset",
"hostDetails.icon.showLibrary",
"hostDetails.icon.hideLibrary",
"hostDetails.icon.autoUsesDistro",
"hostDetails.icon.customOverridesDistro",
...HOST_ICON_IDS.map((id) => `hostDetails.icon.option.${id}`),
...HOST_ICON_COLORS.map((color) => `hostDetails.icon.color.${color.id}`),
];
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
const missing = keys.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing host icon labels`);
}
});
@@ -0,0 +1,25 @@
import test from "node:test";
import assert from "node:assert/strict";
import en from "./en.ts";
import ru from "./ru.ts";
import zhCN from "./zh-CN.ts";
const osc7Keys = [
"terminal.toolbar.configureOsc7",
"terminal.osc7Setup.title",
"terminal.osc7Setup.desc",
"terminal.osc7Setup.targets",
"terminal.osc7Setup.command",
"terminal.osc7Setup.run",
"terminal.osc7Setup.sent",
] as const;
test("OSC 7 setup copy exists in every bundled locale", () => {
for (const [locale, messages] of Object.entries({ en, ru, zhCN })) {
for (const key of osc7Keys) {
assert.equal(typeof messages[key], "string", `${locale} is missing ${key}`);
assert.notEqual(messages[key], "", `${locale} has empty ${key}`);
}
}
});
+17
View File
@@ -71,6 +71,7 @@ export const zhCNAiMessages: Messages = {
'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',
@@ -96,6 +97,7 @@ export const zhCNAiMessages: Messages = {
'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',
@@ -107,6 +109,7 @@ export const zhCNAiMessages: Messages = {
'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',
@@ -146,6 +149,7 @@ export const zhCNAiMessages: Messages = {
'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': '默认(海外)',
@@ -156,6 +160,18 @@ export const zhCNAiMessages: Messages = {
'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',
@@ -316,6 +332,7 @@ export const zhCNAiMessages: Messages = {
'terminal.layer.scripts': '脚本',
'terminal.layer.history': '命令历史',
'terminal.layer.theme': '主题',
'terminal.layer.notes': '笔记',
'terminal.layer.aiChat': 'AI 助手',
'terminal.layer.movePanelLeft': '面板移至左侧',
'terminal.layer.movePanelRight': '面板移至右侧',
+17 -1
View File
@@ -128,6 +128,17 @@ export const zhCNCoreMessages: Messages = {
'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': '配置会话日志导出和自动保存设置。',
@@ -310,7 +321,7 @@ export const zhCNCoreMessages: Messages = {
'sync.autoSync.emptyVaultConflict.restoreDesc': '推荐 — 从云端备份恢复主机、密钥和代码片段',
'sync.autoSync.emptyVaultConflict.keepEmpty': '保持为空',
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': '从头开始,使用空的主机库',
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段,{proxyProfiles} 个代理',
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段,{notes} 条笔记,{proxyProfiles} 个代理',
'sync.autoSync.emptyVaultManual': '无法同步:本地 vault 为空。请先从本地备份恢复,或在同步面板里使用"强制推送"。',
'sync.blocked.title': '同步已暂停',
@@ -347,6 +358,7 @@ export const zhCNCoreMessages: Messages = {
'vault.nav.proxies': '代理',
'vault.nav.portForwarding': '端口转发',
'vault.nav.snippets': '代码片段',
'vault.nav.notes': '笔记',
'vault.nav.knownHosts': '已知主机',
'vault.nav.logs': '日志',
@@ -447,6 +459,7 @@ export const zhCNCoreMessages: Messages = {
'vault.hosts.errors.nameRequired': '主机名称不能为空。',
'vault.hosts.empty.title': '设置你的主机',
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
'deepLink.ssh.invalid': '无效的 ssh:// 链接',
// Vault import
'vault.import.title': '添加数据到你的 Vault',
@@ -573,6 +586,9 @@ export const zhCNCoreMessages: Messages = {
'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': '树形视图',
@@ -2,6 +2,19 @@ import type { Messages } from '../types';
export const zhCNTerminalMessages: Messages = {
'terminal.sudoHint.pressEnter': '按 Enter 粘贴 sudo 密码',
'terminal.menu.rename': '重命名',
'terminal.menu.uploadClipboardImage': '上传剪贴板图片',
'terminal.clipboardImageUpload.noImage': '剪贴板中没有图片',
'terminal.clipboardImageUpload.failed': '无法上传剪贴板图片',
'terminal.toolbar.configureOsc7': '配置目录追踪',
'terminal.toolbar.detach': '移出到独立标签',
'terminal.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.sent': '目录追踪配置已发送到终端',
'terminal.toolbar.timestampsEnable': '显示时间戳',
'terminal.toolbar.timestampsDisable': '隐藏时间戳',
'terminal.connection.protocol.et': 'EternalTerminal',
@@ -85,6 +98,9 @@ export const zhCNTerminalMessages: Messages = {
'settings.sftp.doubleClickBehavior.transfer': '传输到另一侧',
'settings.sftp.doubleClickBehavior.openDesc': '使用默认应用程序打开文件',
'settings.sftp.doubleClickBehavior.transferDesc': '将文件传输到另一窗格的活动主机',
'settings.sshDeepLink.title': 'SSH 链接',
'settings.sshDeepLink.enable': '用 Netcatty 打开 ssh:// 链接',
'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh:// 链接。',
// Settings > SFTP Auto Sync
'settings.sftp.autoSync': '自动同步到远程',
@@ -187,8 +203,19 @@ export const zhCNTerminalMessages: Messages = {
'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': '终端仿真类型',
@@ -324,6 +351,25 @@ export const zhCNTerminalMessages: Messages = {
'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': '自动补全',
@@ -359,18 +405,25 @@ export const zhCNTerminalMessages: Messages = {
'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': '快速切换',
@@ -386,6 +439,9 @@ export const zhCNTerminalMessages: Messages = {
'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/命令代理',
@@ -540,6 +596,8 @@ export const zhCNTerminalMessages: Messages = {
'snippets.searchPlaceholder': '搜索代码片段...',
'snippets.action.newSnippet': '新建代码片段',
'snippets.action.newPackage': '新建代码包',
'snippets.action.import': '导入',
'snippets.action.selectSnippets': '选择代码片段',
'snippets.panel.newTitle': '新建代码片段',
'snippets.panel.editTitle': '编辑代码片段',
'snippets.field.description': '描述',
@@ -577,6 +635,34 @@ export const zhCNTerminalMessages: Messages = {
'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': '根目录',
+100 -3
View File
@@ -1,6 +1,34 @@
import type { Messages } from '../types';
export const zhCNVaultMessages: Messages = {
// Notes
'notes.search.placeholder': '搜索笔记、主机、标签...',
'notes.action.newNote': '新建笔记',
'notes.action.newGroup': '新建文件夹',
'notes.action.importMarkdown': '导入 Markdown',
'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.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': '未找到主机',
@@ -46,6 +74,63 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.section.appearance': '外观',
'hostDetails.distro.title': 'Linux 发行版',
'hostDetails.distro.desc': '可在连接后自动探测,也可以手动覆盖图标所用的发行版。',
'hostDetails.icon.title': '主机图标',
'hostDetails.icon.sectionTitle': '颜色与图标',
'hostDetails.icon.desc': '选择此主机使用的图标来源和颜色。',
'hostDetails.icon.source': '来源',
'hostDetails.icon.manualLabel': '手动指定',
'hostDetails.icon.tab.brand': '品牌',
'hostDetails.icon.tab.type': '类型',
'hostDetails.icon.mode.auto': '自动',
'hostDetails.icon.mode.custom': '自定义',
'hostDetails.icon.reset': '重置主机图标',
'hostDetails.icon.colorLabel': '图标颜色',
'hostDetails.icon.colorDefault': '默认',
'hostDetails.icon.colorAuto': '自动',
'hostDetails.icon.colorManual': '手动',
'hostDetails.icon.colorChoice': '颜色',
'hostDetails.icon.autoColorLabel': '当前颜色',
'hostDetails.icon.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': '手动覆盖',
@@ -66,12 +151,14 @@ export const zhCNVaultMessages: Messages = {
'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.hpe': '慧与 / H3C',
'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',
@@ -115,6 +202,8 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.section.terminalBehavior': '终端行为',
'hostDetails.lineTimestamps': '显示输出时间',
'hostDetails.lineTimestamps.desc': '在终端输出行旁边显示本地时间,不改变终端文本内容。',
'hostDetails.disableDynamicTabTitle': '禁用动态标签页标题',
'hostDetails.disableDynamicTabTitle.desc': '标签页始终显示连接名称,不跟随 Shell 报告的窗口标题变化。',
'hostDetails.legacyAlgorithms': '允许旧版算法',
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法(diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
@@ -244,6 +333,7 @@ export const zhCNVaultMessages: Messages = {
'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',
@@ -304,6 +394,9 @@ export const zhCNVaultMessages: Messages = {
'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}',
@@ -520,7 +613,7 @@ export const zhCNVaultMessages: Messages = {
'cloudSync.localBackups.reason.appVersionChange': '版本变化前',
'cloudSync.localBackups.reason.beforeRestore': '恢复前',
'cloudSync.localBackups.versionChange': '{from} -> {to}',
'cloudSync.localBackups.counts': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段',
'cloudSync.localBackups.counts': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段,{notes} 条笔记',
'cloudSync.localBackups.restore': '恢复',
'cloudSync.localBackups.restoreSuccess': '已恢复本地备份。',
'cloudSync.localBackups.restoreFailedTitle': '恢复失败',
@@ -545,6 +638,7 @@ export const zhCNVaultMessages: Messages = {
'cloudSync.revisionHistory.hosts': '主机',
'cloudSync.revisionHistory.keys': '密钥',
'cloudSync.revisionHistory.snippets': '代码片段',
'cloudSync.revisionHistory.notes': '笔记',
'cloudSync.revisionHistory.identities': '身份',
'cloudSync.revisionHistory.restoreButton': '恢复此版本',
'cloudSync.revisionHistory.restored': '已从选中的修订恢复主机库数据。',
@@ -597,6 +691,8 @@ export const zhCNVaultMessages: Messages = {
'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': '端口转发',
@@ -662,8 +758,9 @@ export const zhCNVaultMessages: Messages = {
'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.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
'pf.tooltip.relayHost': '中转主机',
'pf.tooltip.hostLabel': '主机',
'pf.tooltip.hostAddress': '地址',
+1
View File
@@ -45,6 +45,7 @@ export interface LocalVaultBackupPreview {
hostCount: number;
keyCount: number;
snippetCount: number;
noteCount: number;
identityCount: number;
portForwardingRuleCount: number;
};
+26 -4
View File
@@ -65,7 +65,7 @@ test("pruneInactiveScopedTransientState removes closed workspace and terminal sc
});
});
test("pruneInactiveScopedSessions preserves restorable terminal external session ids across reconnects", () => {
test("pruneInactiveScopedSessions reports inactive targets without deleting persisted history", () => {
const sessions = [
createSession("terminal-restorable", {
type: "terminal",
@@ -98,10 +98,32 @@ test("pruneInactiveScopedSessions preserves restorable terminal external session
"terminal-local",
"workspace-closed",
]);
assert.deepEqual(next.sessions, [
sessions[0],
sessions[3],
assert.equal(next.sessions, sessions);
});
test("pruneInactiveScopedSessions preserves inactive workspace and local terminal history after restart", () => {
const sessions = [
createSession("terminal-local", {
type: "terminal",
targetId: "closed-local",
hostIds: ["local-shell"],
}, "ext-local"),
createSession("workspace-closed", {
type: "workspace",
targetId: "closed-workspace",
}, "ext-workspace"),
];
const next = pruneInactiveScopedSessions(
sessions,
new Set(),
);
assert.deepEqual(next.orphanedSessionIds, [
"terminal-local",
"workspace-closed",
]);
assert.equal(next.sessions, sessions);
});
test("pruneInactiveScopedSessions preserves original sessions when orphaned restorable chats are already detached", () => {
+1 -22
View File
@@ -90,12 +90,6 @@ export function pruneInactiveScopedTransientState(
};
}
function isRestorableTerminalSession(session: AISession): boolean {
return session.scope.type === "terminal"
&& !!session.scope.hostIds?.length
&& session.scope.hostIds.some((id) => !id.startsWith("local-") && !id.startsWith("serial-"));
}
export function pruneInactiveScopedSessions(
sessions: AISession[],
activeTargetIds: Set<string>,
@@ -123,23 +117,8 @@ export function pruneInactiveScopedSessions(
};
}
const orphanedSessionIdSet = new Set(orphanedSessionIds);
let sessionsChanged = false;
const nextSessions = sessions.flatMap((session) => {
if (!orphanedSessionIdSet.has(session.id)) {
return [session];
}
if (!isRestorableTerminalSession(session)) {
sessionsChanged = true;
return [];
}
return [session];
});
return {
sessions: sessionsChanged ? nextSessions : sessions,
sessions,
orphanedSessionIds,
};
}
@@ -0,0 +1,37 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveAutoSyncHashDecision } from "./autoSyncHashDecision.ts";
test("remote-applied data is skipped only when the current hash still matches it", () => {
assert.equal(
resolveAutoSyncHashDecision({
currentHash: "remote-applied",
lastSyncedHash: "old-local",
appliedSkipHash: "remote-applied",
}),
"skip-applied",
);
});
test("user edits after remote apply still sync even when they match the old baseline", () => {
assert.equal(
resolveAutoSyncHashDecision({
currentHash: "old-local",
lastSyncedHash: "old-local",
appliedSkipHash: "remote-applied",
}),
"sync",
);
});
test("unchanged data is skipped only when there is no pending remote-apply hash", () => {
assert.equal(
resolveAutoSyncHashDecision({
currentHash: "same",
lastSyncedHash: "same",
appliedSkipHash: null,
}),
"unchanged",
);
});
+16
View File
@@ -0,0 +1,16 @@
export type AutoSyncHashDecision = 'skip-applied' | 'unchanged' | 'sync';
export function resolveAutoSyncHashDecision({
currentHash,
lastSyncedHash,
appliedSkipHash,
}: {
currentHash: string;
lastSyncedHash: string;
appliedSkipHash: string | null;
}): AutoSyncHashDecision {
if (appliedSkipHash !== null) {
return currentHash === appliedSkipHash ? 'skip-applied' : 'sync';
}
return currentHash === lastSyncedHash ? 'unchanged' : 'sync';
}
+28 -14
View File
@@ -1,31 +1,45 @@
import type { SessionCapabilities } from '../../domain/systemManager/types';
/** Internal entry: capabilities plus computed expiry timestamp. */
interface StoreEntry {
capabilities: SessionCapabilities;
expiresAt: number;
}
type Listener = () => void;
const capabilitiesBySessionId = new Map<string, SessionCapabilities>();
const capabilitiesBySessionId = new Map<string, StoreEntry>();
const listenersBySessionId = new Map<string, Set<Listener>>();
function isExpired(entry: StoreEntry): boolean {
return Date.now() > entry.expiresAt;
}
function notifySession(sessionId: string) {
listenersBySessionId.get(sessionId)?.forEach((listener) => listener());
}
export const sessionCapabilitiesStore = {
get(sessionId: string): SessionCapabilities | undefined {
return capabilitiesBySessionId.get(sessionId);
const entry = capabilitiesBySessionId.get(sessionId);
if (!entry) return undefined;
if (isExpired(entry)) {
capabilitiesBySessionId.delete(sessionId);
notifySession(sessionId);
return undefined;
}
return entry.capabilities;
},
set(sessionId: string, capabilities: SessionCapabilities) {
const prev = capabilitiesBySessionId.get(sessionId);
if (
prev
&& prev.targetOs === capabilities.targetOs
&& prev.hasTmux === capabilities.hasTmux
&& prev.hasDocker === capabilities.hasDocker
&& prev.probedAt === capabilities.probedAt
) {
return;
}
capabilitiesBySessionId.set(sessionId, capabilities);
set(sessionId: string, capabilities: SessionCapabilities, ttlMs: number) {
const entry: StoreEntry = {
capabilities: {
...capabilities,
probedAt: Date.now(),
},
expiresAt: Date.now() + ttlMs,
};
capabilitiesBySessionId.set(sessionId, entry);
notifySession(sessionId);
},
@@ -0,0 +1,191 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
DEFAULT_RESTORE_TERMINAL_CWD,
DEFAULT_RESTORE_PREVIOUS_SESSION,
resolveRestoreTerminalCwdSetting,
resolveRestorePreviousSessionSetting,
} from "./sessionRestoreSettings.ts";
test("restore previous session setting defaults on", () => {
assert.equal(DEFAULT_RESTORE_PREVIOUS_SESSION, true);
assert.equal(resolveRestorePreviousSessionSetting(null), true);
});
test("restore previous session setting preserves explicit stored values", () => {
assert.equal(resolveRestorePreviousSessionSetting(true), true);
assert.equal(resolveRestorePreviousSessionSetting(false), false);
});
test("restore terminal cwd setting defaults off", () => {
assert.equal(DEFAULT_RESTORE_TERMINAL_CWD, false);
assert.equal(resolveRestoreTerminalCwdSetting(null), false);
});
test("restore terminal cwd setting preserves explicit stored values", () => {
assert.equal(resolveRestoreTerminalCwdSetting(true), true);
assert.equal(resolveRestoreTerminalCwdSetting(false), false);
});
test("restore previous session setting participates in cross-window settings sync", () => {
const storageSyncSource = readFileSync(new URL("./settingsStorageSync.ts", import.meta.url), "utf8");
const ipcSyncSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
assert.match(storageSyncSource, /STORAGE_KEY_RESTORE_PREVIOUS_SESSION/);
assert.match(storageSyncSource, /setRestorePreviousSessionState/);
assert.match(storageSyncSource, /e\.key === STORAGE_KEY_RESTORE_PREVIOUS_SESSION/);
assert.match(ipcSyncSource, /STORAGE_KEY_RESTORE_PREVIOUS_SESSION/);
assert.match(ipcSyncSource, /setRestorePreviousSessionState/);
assert.match(ipcSyncSource, /key === STORAGE_KEY_RESTORE_PREVIOUS_SESSION/);
});
test("disabling restore previous session clears the stored restore snapshot", () => {
const settingsSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
const importIndex = settingsSource.indexOf("sessionRestoreStorage");
const setterIndex = settingsSource.indexOf("const setRestorePreviousSession = useCallback");
const clearIndex = settingsSource.indexOf("sessionRestoreStorage.clear()", setterIndex);
const writeIndex = settingsSource.indexOf("localStorageAdapter.writeBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION", setterIndex);
assert.notEqual(importIndex, -1);
assert.notEqual(setterIndex, -1);
assert.notEqual(clearIndex, -1);
assert.notEqual(writeIndex, -1);
assert.ok(
writeIndex < clearIndex,
"the setting should be persisted before clearing the restore snapshot",
);
});
test("session restore persistence re-arms when restore previous session is enabled", () => {
const source = readFileSync(new URL("./useSessionState.ts", import.meta.url), "utf8");
const adapterEventImportIndex = source.indexOf("LOCAL_STORAGE_ADAPTER_CHANGED_EVENT");
const revisionStateIndex = source.indexOf("restorePreviousSessionRevision");
const keyGuardIndex = source.indexOf("key !== STORAGE_KEY_RESTORE_PREVIOUS_SESSION");
const listenerIndex = source.indexOf("addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT");
const effectDependencyIndex = source.indexOf("restorePreviousSessionRevision, persistSessionRestore]", source.indexOf("beforeunload"));
assert.notEqual(adapterEventImportIndex, -1);
assert.notEqual(revisionStateIndex, -1);
assert.notEqual(keyGuardIndex, -1);
assert.notEqual(listenerIndex, -1);
assert.notEqual(effectDependencyIndex, -1);
});
test("session restore persistence can be disabled for non-main windows", () => {
const hookSource = readFileSync(new URL("./useSessionState.ts", import.meta.url), "utf8");
const traySource = readFileSync(new URL("../../components/TrayPanel.tsx", import.meta.url), "utf8");
const appSource = readFileSync(new URL("../../App.tsx", import.meta.url), "utf8");
const indexSource = readFileSync(new URL("../../index.tsx", import.meta.url), "utf8");
const registerBridgesSource = readFileSync(new URL("../../electron/main/registerBridges.cjs", import.meta.url), "utf8");
const mainWindowSource = readFileSync(new URL("../../electron/bridges/windowManager/mainWindow.cjs", import.meta.url), "utf8");
assert.match(hookSource, /persistSessionRestore\?: boolean/);
assert.match(hookSource, /restoreEnabled: persistSessionRestore && resolveRestorePreviousSessionSetting/);
assert.match(hookSource, /payload: persistSessionRestore \? sessionRestoreStorage\.read\(\) : null/);
assert.match(hookSource, /if \(!persistSessionRestore\) return;/);
assert.match(traySource, /useSessionState\(\{ persistSessionRestore: false \}\)/);
assert.match(appSource, /window\.location\.hash\.startsWith\('#\/session-window'\)/);
assert.match(appSource, /persistSessionRestore: !isPeerSessionWindow/);
assert.match(indexSource, /hash === '#\/session-window'/);
assert.match(registerBridgesSource, /route: "session-window"/);
assert.match(registerBridgesSource, /registerAsMainWindow: false/);
assert.match(mainWindowSource, /const rendererHash = typeof route === "string"/);
assert.match(mainWindowSource, /registerAsMainWindow = true/);
assert.match(mainWindowSource, /if \(registerAsMainWindow\)/);
assert.match(mainWindowSource, /loadURL\(`\$\{getDevRendererBaseUrl\(devServerUrl\)\}\$\{rendererHash\}`\)/);
assert.match(mainWindowSource, /loadURL\(`app:\/\/netcatty\/index\.html\$\{rendererHash\}`\)/);
});
test("session peer windows do not run main-window startup effects", () => {
const appSource = readFileSync(new URL("../../App.tsx", import.meta.url), "utf8");
const autoSyncSource = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
const startupEffectsSource = readFileSync(new URL("../app/useAppStartupEffects.ts", import.meta.url), "utf8");
const updateCheckSource = readFileSync(new URL("./useUpdateCheck.ts", import.meta.url), "utf8");
const settingsStateSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
const settingsIpcSyncSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
const storageSyncSource = readFileSync(new URL("./settingsStorageSync.ts", import.meta.url), "utf8");
const systemEffectsSource = readFileSync(new URL("./systemSettingsEffects.ts", import.meta.url), "utf8");
const trayFocusIndex = appSource.indexOf("onTrayFocusSession");
const trayPanelJumpIndex = appSource.indexOf("onTrayPanelJumpToSession");
assert.match(appSource, /const isPeerSessionWindow = typeof window !== 'undefined' && window\.location\.hash\.startsWith\('#\/session-window'\)/);
assert.match(appSource, /useSettingsState\(\{[^}]*enableSettingsSync: !isPeerSessionWindow[^}]*enableSystemEffects: !isPeerSessionWindow/s);
assert.match(appSource, /useAppStartupEffects\(\{[^}]*enabled: !isPeerSessionWindow/s);
assert.match(appSource, /useUpdateCheck\(\{[^}]*enabled: !isPeerSessionWindow/s);
assert.match(appSource, /if \(isPeerSessionWindow \|\| !isVaultInitialized \|\| versionBackupAttemptedRef\.current\) return;/);
assert.match(appSource, /useAutoSync\(\{[^}]*enabled: !isPeerSessionWindow/s);
assert.match(autoSyncSource, /enabled\?: boolean/);
assert.match(autoSyncSource, /const enabled = config\.enabled !== false/);
assert.match(autoSyncSource, /if \(!enabled\) return;/);
assert.match(updateCheckSource, /enabled\?: boolean/);
assert.match(updateCheckSource, /const enabled = options\?\.enabled !== false/);
assert.match(updateCheckSource, /if \(!enabled\) return;/);
assert.match(settingsStateSource, /enableSystemEffects\?: boolean/);
assert.match(settingsStateSource, /enableSettingsSync\?: boolean/);
assert.match(settingsStateSource, /const enableSettingsSync = options\.enableSettingsSync !== false/);
assert.match(settingsStateSource, /useSettingsIpcSync\(\{[^}]*enabled: enableSettingsSync/s);
assert.match(settingsStateSource, /useSettingsStorageSync\(\{[^}]*enabled: enableSettingsSync/s);
assert.match(settingsStateSource, /if \(!enableSettingsSync\) return;\s*try \{\s*netcattyBridge\.get\(\)\?\.notifySettingsChanged/s);
assert.match(settingsStateSource, /useSystemSettingsEffects\(\{[^}]*enabled: enableSystemEffects/s);
assert.match(settingsIpcSyncSource, /enabled\?: boolean/);
assert.match(settingsIpcSyncSource, /if \(!enabled\) return;/);
assert.match(storageSyncSource, /enabled\?: boolean/);
assert.match(storageSyncSource, /if \(!enabled\) return;/);
assert.match(systemEffectsSource, /enabled\?: boolean/);
assert.match(systemEffectsSource, /if \(!enabled\) return;/);
assert.ok(
appSource.lastIndexOf("if (isPeerSessionWindow) return;", trayFocusIndex) !== -1,
"peer session windows should not register tray focus/toggle listeners",
);
assert.ok(
appSource.lastIndexOf("if (isPeerSessionWindow) return;", trayPanelJumpIndex) !== -1,
"peer session windows should not register tray panel listeners",
);
assert.match(startupEffectsSource, /enabled = true/);
assert.match(startupEffectsSource, /if \(!enabled\) return;/);
assert.match(startupEffectsSource, /export function shouldQueueKeyboardInteractiveRequest/);
assert.match(startupEffectsSource, /request\.scope !== "terminal"/);
assert.match(startupEffectsSource, /shouldQueueKeyboardInteractiveRequest\(request, sessionsRef\.current\)/);
assert.doesNotMatch(
startupEffectsSource,
/if \(!enabled\) return;\s*const bridge = netcattyBridge\.get\(\);\s*if \(!bridge\?\.onCheckDirtyEditors\) return;/,
"dirty editor quit guard must remain registered in peer session windows",
);
});
test("restore-only settings do not bump the cloud sync settings version", () => {
const settingsSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
const settingsVersionIndex = settingsSource.indexOf("settingsVersion: useMemo");
const settingsVersionSource = settingsSource.slice(settingsVersionIndex);
assert.notEqual(settingsVersionIndex, -1);
assert.doesNotMatch(settingsVersionSource, /restorePreviousSession|restoreTerminalCwd/);
});
test("restore previous session re-arms after cross-window settings ipc sync", () => {
const hookSource = readFileSync(new URL("./useSessionState.ts", import.meta.url), "utf8");
const settingsIpcSyncSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
assert.match(settingsIpcSyncSource, /STORAGE_KEY_RESTORE_PREVIOUS_SESSION/);
assert.match(hookSource, /netcattyBridge/);
assert.match(hookSource, /onSettingsChanged/);
assert.match(hookSource, /handleRestorePreviousSessionChanged\(payload\?\.key\)/);
assert.match(hookSource, /key !== STORAGE_KEY_RESTORE_PREVIOUS_SESSION/);
assert.match(hookSource, /setRestorePreviousSessionRevision\(\(revision\) => revision \+ 1\)/);
});
test("restore terminal cwd setting participates in cross-window settings sync", () => {
const storageSyncSource = readFileSync(new URL("./settingsStorageSync.ts", import.meta.url), "utf8");
const ipcSyncSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
assert.match(storageSyncSource, /STORAGE_KEY_RESTORE_TERMINAL_CWD/);
assert.match(storageSyncSource, /setRestoreTerminalCwdState/);
assert.match(storageSyncSource, /e\.key === STORAGE_KEY_RESTORE_TERMINAL_CWD/);
assert.match(ipcSyncSource, /STORAGE_KEY_RESTORE_TERMINAL_CWD/);
assert.match(ipcSyncSource, /setRestoreTerminalCwdState/);
assert.match(ipcSyncSource, /key === STORAGE_KEY_RESTORE_TERMINAL_CWD/);
});
@@ -0,0 +1,8 @@
export const DEFAULT_RESTORE_PREVIOUS_SESSION = true;
export const DEFAULT_RESTORE_TERMINAL_CWD = false;
export const resolveRestorePreviousSessionSetting = (stored: boolean | null): boolean =>
stored ?? DEFAULT_RESTORE_PREVIOUS_SESSION;
export const resolveRestoreTerminalCwdSetting = (stored: boolean | null): boolean =>
stored ?? DEFAULT_RESTORE_TERMINAL_CWD;
@@ -0,0 +1,225 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
buildAndWriteSessionRestorePayload,
createInitialRestoredSessionState,
mergeSessionRestoreCwd,
updateRestoredSessionStatusState,
shouldPersistSessionRestoreState,
} from "./sessionRestoreState.ts";
import type { SessionRestorePayload } from "../../domain/sessionRestore.ts";
const payload: SessionRestorePayload = {
version: 1,
savedAt: 1,
activeTabId: "ws-1",
tabOrder: ["ws-1"],
sessions: [{
id: "s1",
hostId: "host-s1",
hostLabel: "Host s1",
hostname: "s1.example.test",
username: "root",
status: "disconnected",
workspaceId: "ws-1",
restoreState: "restored-disconnected",
}],
workspaces: [{
id: "ws-1",
title: "Workspace",
root: { id: "pane-1", type: "pane", sessionId: "s1" },
}],
};
test("restored session state hydrates sessions, workspaces, tab order, and active tab", () => {
const restored = createInitialRestoredSessionState({
restoreEnabled: true,
payload,
});
assert.equal(restored.sessions[0].status, "disconnected");
assert.equal(restored.sessions[0].restoreState, "restored-disconnected");
assert.equal(restored.workspaces[0].id, "ws-1");
assert.deepEqual(restored.tabOrder, ["ws-1"]);
assert.equal(restored.activeTabId, "ws-1");
});
test("restored session state is empty when restore is disabled", () => {
const restored = createInitialRestoredSessionState({ restoreEnabled: false, payload });
assert.deepEqual(restored.sessions, []);
assert.deepEqual(restored.workspaces, []);
assert.deepEqual(restored.tabOrder, []);
assert.equal(restored.activeTabId, "vault");
});
test("mergeSessionRestoreCwd records latest cwd metadata without terminal data", () => {
const next = mergeSessionRestoreCwd(payload, "s1", "/usr/local/src");
assert.equal(next.sessions[0].lastCwd, "/usr/local/src");
assert.equal("terminalData" in next.sessions[0], false);
});
test("mergeSessionRestoreCwd removes cwd when terminal reports null", () => {
const next = mergeSessionRestoreCwd({
...payload,
sessions: [{ ...payload.sessions[0], lastCwd: "/tmp" }],
}, "s1", null);
assert.equal(next.sessions[0].lastCwd, undefined);
});
test("updateRestoredSessionStatusState clears restore marker after reconnect starts", () => {
const next = updateRestoredSessionStatusState(payload.sessions, "s1", "connecting");
assert.equal(next[0].status, "connecting");
assert.equal(next[0].restoreState, undefined);
});
test("updateRestoredSessionStatusState keeps restore marker for disconnected placeholders", () => {
const next = updateRestoredSessionStatusState(payload.sessions, "s1", "disconnected");
assert.equal(next[0].status, "disconnected");
assert.equal(next[0].restoreState, "restored-disconnected");
});
test("shouldPersistSessionRestoreState skips transient empty startup state", () => {
assert.equal(shouldPersistSessionRestoreState([], [], []), false);
assert.equal(shouldPersistSessionRestoreState(payload.sessions, payload.workspaces, payload.tabOrder), true);
});
test("restored session state preserves lightweight workspace chrome only", () => {
const restored = createInitialRestoredSessionState({
restoreEnabled: true,
payload: {
...payload,
workspaces: [{
...payload.workspaces[0],
viewMode: "focus",
focusedSessionId: "s1",
focusSessionOrder: ["s1"],
broadcastEnabled: true,
transientPanelState: { selected: "history" },
} as never],
},
});
assert.equal(restored.workspaces[0].viewMode, "focus");
assert.equal(restored.workspaces[0].focusedSessionId, "s1");
assert.deepEqual(restored.workspaces[0].focusSessionOrder, ["s1"]);
assert.equal("transientPanelState" in restored.workspaces[0], false);
});
test("session restore flush writes through the same sanitized payload path", () => {
const writes: SessionRestorePayload[] = [];
const storage = {
write: (next: SessionRestorePayload) => {
writes.push(next);
return true;
},
clear: () => {
throw new Error("should not clear non-empty restore state");
},
};
const wrote = buildAndWriteSessionRestorePayload({
sessions: [{
...payload.sessions[0],
status: "connected",
terminalData: "do-not-store",
} as never],
workspaces: [{
...payload.workspaces[0],
transientPanelState: { selected: "history" },
} as never],
tabOrder: ["ws-1"],
activeTabId: "ws-1",
now: 42,
storage,
});
assert.equal(wrote, true);
assert.equal(writes.length, 1);
assert.equal(writes[0].sessions[0].status, "disconnected");
assert.equal("terminalData" in writes[0].sessions[0], false);
assert.equal("transientPanelState" in writes[0].workspaces[0], false);
assert.equal(writes[0].savedAt, 42);
});
test("session restore flush clears storage instead of writing when restore is disabled", () => {
const writes: SessionRestorePayload[] = [];
let clearCount = 0;
const storage = {
write: (next: SessionRestorePayload) => {
writes.push(next);
return true;
},
clear: () => {
clearCount += 1;
},
};
const wrote = buildAndWriteSessionRestorePayload({
restoreEnabled: false,
sessions: payload.sessions,
workspaces: payload.workspaces,
tabOrder: payload.tabOrder,
activeTabId: payload.activeTabId,
storage,
});
assert.equal(wrote, false);
assert.equal(clearCount, 1);
assert.equal(writes.length, 0);
});
test("session restore flush skips empty transient windows without clearing existing snapshots", () => {
const writes: SessionRestorePayload[] = [];
let clearCount = 0;
const storage = {
write: (next: SessionRestorePayload) => {
writes.push(next);
return true;
},
clear: () => {
clearCount += 1;
},
};
const wrote = buildAndWriteSessionRestorePayload({
sessions: [],
workspaces: [],
tabOrder: [],
activeTabId: "vault",
storage,
});
assert.equal(wrote, false);
assert.equal(clearCount, 0);
assert.equal(writes.length, 0);
});
test("session restore flush clears stale snapshots when main window becomes empty after restorable state existed", () => {
const writes: SessionRestorePayload[] = [];
let clearCount = 0;
const storage = {
write: (next: SessionRestorePayload) => {
writes.push(next);
return true;
},
clear: () => {
clearCount += 1;
},
};
const wrote = buildAndWriteSessionRestorePayload({
sessions: [],
workspaces: [],
tabOrder: [],
activeTabId: "vault",
clearOnEmpty: true,
storage,
});
assert.equal(wrote, false);
assert.equal(clearCount, 1);
assert.equal(writes.length, 0);
});
+146
View File
@@ -0,0 +1,146 @@
import type { TerminalSession, Workspace } from "../../domain/models";
import {
buildSessionRestorePayload,
sanitizeSessionRestorePayload,
type SessionRestorePayload,
} from "../../domain/sessionRestore";
export type InitialRestoredSessionState = {
sessions: TerminalSession[];
workspaces: Workspace[];
tabOrder: string[];
activeTabId: string;
};
export function createInitialRestoredSessionState({
restoreEnabled,
payload,
}: {
restoreEnabled: boolean;
payload: SessionRestorePayload | null;
}): InitialRestoredSessionState {
if (!restoreEnabled || !payload) {
return {
sessions: [],
workspaces: [],
tabOrder: [],
activeTabId: "vault",
};
}
const sanitized = sanitizeSessionRestorePayload(payload);
return {
sessions: sanitized.sessions,
workspaces: sanitized.workspaces,
tabOrder: sanitized.tabOrder,
activeTabId: sanitized.activeTabId,
};
}
export function shouldPersistSessionRestoreState(
sessions: readonly TerminalSession[],
workspaces: readonly Workspace[],
tabOrder: readonly string[],
): boolean {
return sessions.length > 0 || workspaces.length > 0 || tabOrder.length > 0;
}
export function buildPersistableSessionRestorePayload({
sessions,
workspaces,
tabOrder,
activeTabId,
now,
}: {
sessions: TerminalSession[];
workspaces: Workspace[];
tabOrder: string[];
activeTabId: string;
now?: number;
}): SessionRestorePayload | null {
if (!shouldPersistSessionRestoreState(sessions, workspaces, tabOrder)) return null;
return buildSessionRestorePayload({
sessions,
workspaces,
tabOrder,
activeTabId,
now,
});
}
export function buildAndWriteSessionRestorePayload({
restoreEnabled = true,
clearOnEmpty = false,
sessions,
workspaces,
tabOrder,
activeTabId,
now,
storage,
}: {
restoreEnabled?: boolean;
clearOnEmpty?: boolean;
sessions: TerminalSession[];
workspaces: Workspace[];
tabOrder: string[];
activeTabId: string;
now?: number;
storage: {
write(payload: SessionRestorePayload): boolean;
clear(): void;
};
}): boolean {
if (!restoreEnabled) {
storage.clear();
return false;
}
const payload = buildPersistableSessionRestorePayload({
sessions,
workspaces,
tabOrder,
activeTabId,
now,
});
if (!payload) {
if (clearOnEmpty) {
storage.clear();
}
return false;
}
return storage.write(payload);
}
export function mergeSessionRestoreCwd(
payload: SessionRestorePayload,
sessionId: string,
cwd: string | null,
): SessionRestorePayload {
return sanitizeSessionRestorePayload({
...payload,
sessions: payload.sessions.map((session) => {
if (session.id !== sessionId) return session;
const { lastCwd: _lastCwd, ...rest } = session;
return cwd ? { ...rest, lastCwd: cwd } : rest;
}),
});
}
export function updateRestoredSessionStatusState<T extends Pick<TerminalSession, "id" | "status" | "restoreState">>(
sessions: readonly T[],
sessionId: string,
status: TerminalSession["status"],
): T[] {
let changed = false;
const next = sessions.map((session) => {
if (session.id !== sessionId) return session;
const shouldClearRestoreState = status === "connecting" || status === "connected";
if (session.status === status && (!shouldClearRestoreState || session.restoreState === undefined)) {
return session;
}
changed = true;
if (!shouldClearRestoreState) return { ...session, status };
const { restoreState: _restoreState, ...rest } = session;
return { ...rest, status } as T;
});
return changed ? next : sessions as T[];
}
@@ -0,0 +1,99 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createSessionRestoreStorage } from "./sessionRestoreStorage.ts";
import type { SessionRestorePayload } from "../../domain/sessionRestore.ts";
const payload: SessionRestorePayload = {
version: 1,
savedAt: 1,
activeTabId: "vault",
tabOrder: [],
sessions: [],
workspaces: [],
};
test("session restore storage reads a valid payload", () => {
const backing = new Map<string, unknown>();
const storage = createSessionRestoreStorage({
read: <T,>(key: string): T | null => (backing.get(key) as T) ?? null,
write: <T,>(key: string, value: T): boolean => {
backing.set(key, value);
return true;
},
remove: (key: string) => {
backing.delete(key);
},
});
storage.write(payload);
assert.deepEqual(storage.read(), payload);
});
test("session restore storage removes invalid payloads", () => {
const backing = new Map<string, unknown>([["netcatty_session_restore_v1", { version: 999 }]]);
const storage = createSessionRestoreStorage({
read: <T,>(key: string): T | null => (backing.get(key) as T) ?? null,
write: () => true,
remove: (key: string) => {
backing.delete(key);
},
});
assert.equal(storage.read(), null);
assert.equal(backing.has("netcatty_session_restore_v1"), false);
});
test("session restore storage removes malformed payloads that pass shallow checks", () => {
const backing = new Map<string, unknown>([[
"netcatty_session_restore_v1",
{
version: 1,
savedAt: 1,
activeTabId: "vault",
tabOrder: [],
sessions: [null],
workspaces: [],
},
]]);
const storage = createSessionRestoreStorage({
read: <T,>(key: string): T | null => (backing.get(key) as T) ?? null,
write: () => true,
remove: (key: string) => {
backing.delete(key);
},
});
assert.equal(storage.read(), null);
assert.equal(backing.has("netcatty_session_restore_v1"), false);
});
test("session restore storage sanitizes payloads before writing", () => {
const backing = new Map<string, unknown>();
const storage = createSessionRestoreStorage({
read: <T,>(key: string): T | null => (backing.get(key) as T) ?? null,
write: <T,>(key: string, value: T): boolean => {
backing.set(key, value);
return true;
},
remove: (key: string) => {
backing.delete(key);
},
});
storage.write({
...payload,
sessions: [{
id: "s1",
hostId: "h1",
hostLabel: "Host 1",
hostname: "example.test",
username: "root",
status: "connected",
}],
tabOrder: ["s1"],
activeTabId: "s1",
} as unknown as SessionRestorePayload);
assert.equal(storage.read()?.sessions[0].status, "disconnected");
});
@@ -0,0 +1,68 @@
import {
STORAGE_KEY_SESSION_RESTORE,
} from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import {
SESSION_RESTORE_VERSION,
sanitizeSessionRestorePayload,
type SessionRestorePayload,
} from "../../domain/sessionRestore";
type RestoreStorageAdapter = {
read<T>(key: string): T | null;
write<T>(key: string, value: T): boolean;
remove(key: string): void;
};
const isRestorePayload = (value: unknown): value is SessionRestorePayload => {
if (!value || typeof value !== "object") return false;
const record = value as Partial<SessionRestorePayload>;
return record.version === SESSION_RESTORE_VERSION
&& Array.isArray(record.sessions)
&& record.sessions.every((session) => (
Boolean(session)
&& typeof session === "object"
&& typeof (session as { id?: unknown }).id === "string"
&& typeof (session as { hostId?: unknown }).hostId === "string"
&& typeof (session as { hostLabel?: unknown }).hostLabel === "string"
&& typeof (session as { hostname?: unknown }).hostname === "string"
&& typeof (session as { username?: unknown }).username === "string"
))
&& Array.isArray(record.workspaces)
&& record.workspaces.every((workspace) => (
Boolean(workspace)
&& typeof workspace === "object"
&& typeof (workspace as { id?: unknown }).id === "string"
&& Boolean((workspace as { root?: unknown }).root)
&& typeof (workspace as { root?: unknown }).root === "object"
))
&& Array.isArray(record.tabOrder)
&& record.tabOrder.every((tabId) => typeof tabId === "string")
&& typeof record.activeTabId === "string";
};
export function createSessionRestoreStorage(adapter: RestoreStorageAdapter = localStorageAdapter) {
return {
read(): SessionRestorePayload | null {
const payload = adapter.read<unknown>(STORAGE_KEY_SESSION_RESTORE);
if (!isRestorePayload(payload)) {
if (payload !== null) adapter.remove(STORAGE_KEY_SESSION_RESTORE);
return null;
}
try {
return sanitizeSessionRestorePayload(payload);
} catch {
adapter.remove(STORAGE_KEY_SESSION_RESTORE);
return null;
}
},
write(payload: SessionRestorePayload): boolean {
return adapter.write(STORAGE_KEY_SESSION_RESTORE, sanitizeSessionRestorePayload(payload));
},
clear(): void {
adapter.remove(STORAGE_KEY_SESSION_RESTORE);
},
};
}
export const sessionRestoreStorage = createSessionRestoreStorage();
@@ -0,0 +1,123 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { TerminalSession, Workspace } from "../../domain/models";
import {
closeSessionWorkspaceLayoutState,
detachSessionFromWorkspaceState,
replaceDissolvedWorkspaceTabOrder,
} from "./sessionWorkspaceDetach";
const session = (id: string, workspaceId = "ws-1"): TerminalSession => ({
id,
hostId: id,
hostLabel: id,
status: "connected",
workspaceId,
});
const workspace = (sessionIds: string[]): Workspace => ({
id: "ws-1",
title: "Workspace",
focusedSessionId: sessionIds[0],
focusSessionOrder: sessionIds,
root: sessionIds.length === 1
? { id: "pane-1", type: "pane", sessionId: sessionIds[0] }
: {
id: "split-1",
type: "split",
direction: "vertical",
children: sessionIds.map((sessionId, index) => ({
id: `pane-${index + 1}`,
type: "pane" as const,
sessionId,
})),
sizes: sessionIds.map(() => 1),
},
});
test("detach dissolves the original workspace when one session remains", () => {
const result = detachSessionFromWorkspaceState({
sessions: [session("s1"), session("s2")],
workspaces: [workspace(["s1", "s2"])],
sessionId: "s1",
});
assert.equal(result.changed, true);
assert.equal(result.activeTabId, "s1");
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
["s1", undefined],
["s2", undefined],
]);
assert.equal(result.workspaces.length, 0);
assert.equal(result.dissolvedWorkspaceId, "ws-1");
assert.deepEqual(result.replacementTabIds, ["s1", "s2"]);
});
test("detach preserves the other sessions in a multi-pane workspace", () => {
const result = detachSessionFromWorkspaceState({
sessions: [session("s1"), session("s2"), session("s3")],
workspaces: [workspace(["s1", "s2", "s3"])],
sessionId: "s2",
});
assert.equal(result.changed, true);
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
["s1", "ws-1"],
["s2", undefined],
["s3", "ws-1"],
]);
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s3"]);
assert.equal(result.workspaces[0].focusedSessionId, "s1");
assert.deepEqual(
result.workspaces[0].root.type === "split"
? result.workspaces[0].root.children.map((child) => child.type === "pane" ? child.sessionId : null)
: [],
["s1", "s3"],
);
});
test("dissolved workspace replacement preserves its tab position", () => {
assert.deepEqual(
replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]),
["log-1", "s1", "s2", "session-3"],
);
});
test("dissolved workspace replacement removes duplicate replacement ids", () => {
assert.deepEqual(
replaceDissolvedWorkspaceTabOrder(["s1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]),
["s1", "s2", "session-3"],
);
});
test("dissolved workspace replacement is idempotent", () => {
const once = replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]);
assert.deepEqual(
replaceDissolvedWorkspaceTabOrder(once, "ws-1", ["s1", "s2"]),
once,
);
});
test("single remaining session preserves dissolved workspace tab position", () => {
assert.deepEqual(
replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s2"]),
["log-1", "s2", "session-3"],
);
});
test("closing a workspace session dissolves the workspace when one terminal remains", () => {
const result = closeSessionWorkspaceLayoutState([workspace(["s1", "s2"])], "ws-1", "s1");
assert.equal(result.dissolvedWorkspaceId, "ws-1");
assert.equal(result.lastRemainingSessionId, "s2");
assert.deepEqual(result.workspaces, []);
assert.deepEqual(
replaceDissolvedWorkspaceTabOrder(
["log-1", result.dissolvedWorkspaceId!, "session-3"],
result.dissolvedWorkspaceId,
result.lastRemainingSessionId ? [result.lastRemainingSessionId] : undefined,
),
["log-1", "s2", "session-3"],
);
});
+182
View File
@@ -0,0 +1,182 @@
import type { TerminalSession, Workspace } from "../../domain/models";
import { collectSessionIds, pruneWorkspaceNode } from "../../domain/workspace";
export type DetachSessionFromWorkspaceStateResult = {
changed: boolean;
sessions: TerminalSession[];
workspaces: Workspace[];
activeTabId?: string;
dissolvedWorkspaceId?: string;
replacementTabIds?: string[];
};
export type CloseSessionWorkspaceLayoutResult = {
workspaces: Workspace[];
removedWorkspaceId?: string;
dissolvedWorkspaceId?: string;
lastRemainingSessionId?: string;
};
type DetachSessionFromWorkspaceStateOptions = {
sessions: TerminalSession[];
workspaces: Workspace[];
sessionId: string;
};
export function replaceDissolvedWorkspaceTabOrder(
tabOrder: readonly string[],
workspaceId: string | undefined,
replacementTabIds: readonly string[] | undefined,
): string[] {
if (!workspaceId || !replacementTabIds?.length) return [...tabOrder];
const uniqueReplacementIds = replacementTabIds.filter((tabId, index, list) => (
tabId && list.indexOf(tabId) === index
));
if (uniqueReplacementIds.length === 0) return [...tabOrder];
if (!tabOrder.includes(workspaceId)) {
const hasAllReplacementIds = uniqueReplacementIds.every((tabId) => tabOrder.includes(tabId));
return hasAllReplacementIds ? [...tabOrder] : [
...tabOrder,
...uniqueReplacementIds.filter((tabId) => !tabOrder.includes(tabId)),
];
}
const replacementIdSet = new Set(uniqueReplacementIds);
let inserted = false;
const nextOrder: string[] = [];
for (const tabId of tabOrder) {
if (tabId === workspaceId) {
if (!inserted) {
nextOrder.push(...uniqueReplacementIds);
inserted = true;
}
continue;
}
if (!replacementIdSet.has(tabId)) {
nextOrder.push(tabId);
}
}
return nextOrder;
}
export function closeSessionWorkspaceLayoutState(
workspaces: readonly Workspace[],
workspaceId: string | undefined,
sessionId: string,
): CloseSessionWorkspaceLayoutResult {
if (!workspaceId) return { workspaces: [...workspaces] };
let removedWorkspaceId: string | undefined;
let dissolvedWorkspaceId: string | undefined;
let lastRemainingSessionId: string | undefined;
const nextWorkspaces = workspaces
.map((workspace) => {
if (workspace.id !== workspaceId) return workspace;
const prunedRoot = pruneWorkspaceNode(workspace.root, sessionId);
if (!prunedRoot) {
removedWorkspaceId = workspace.id;
return null;
}
const remainingSessionIds = collectSessionIds(prunedRoot);
if (remainingSessionIds.length === 1) {
dissolvedWorkspaceId = workspace.id;
lastRemainingSessionId = remainingSessionIds[0];
return null;
}
return { ...workspace, root: prunedRoot };
})
.filter((workspace): workspace is Workspace => Boolean(workspace));
return {
workspaces: nextWorkspaces,
removedWorkspaceId,
dissolvedWorkspaceId,
lastRemainingSessionId,
};
}
export function detachSessionFromWorkspaceState({
sessions,
workspaces,
sessionId,
}: DetachSessionFromWorkspaceStateOptions): DetachSessionFromWorkspaceStateResult {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (!session?.workspaceId) {
return { changed: false, sessions, workspaces };
}
const workspaceId = session.workspaceId;
const targetWorkspace = workspaces.find((workspace) => workspace.id === workspaceId);
if (!targetWorkspace) {
return { changed: false, sessions, workspaces };
}
const prunedRoot = pruneWorkspaceNode(targetWorkspace.root, sessionId);
let nextSessions = sessions.map((candidate) => (
candidate.id === sessionId ? { ...candidate, workspaceId: undefined } : candidate
));
if (!prunedRoot) {
return {
changed: true,
sessions: nextSessions,
workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId),
activeTabId: sessionId,
dissolvedWorkspaceId: workspaceId,
replacementTabIds: [sessionId],
};
}
const remainingSessionIds = collectSessionIds(prunedRoot);
if (remainingSessionIds.length === 1) {
nextSessions = nextSessions.map((candidate) => (
candidate.id === remainingSessionIds[0] ? { ...candidate, workspaceId: undefined } : candidate
));
return {
changed: true,
sessions: nextSessions,
workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId),
activeTabId: sessionId,
dissolvedWorkspaceId: workspaceId,
replacementTabIds: [sessionId, ...remainingSessionIds],
};
}
const nextFocusedSessionId = remainingSessionIds.includes(targetWorkspace.focusedSessionId)
? targetWorkspace.focusedSessionId
: remainingSessionIds[0];
const nextFocusSessionOrder = (targetWorkspace.focusSessionOrder ?? [])
.filter((candidateId, index, list) => (
candidateId !== sessionId &&
remainingSessionIds.includes(candidateId) &&
list.indexOf(candidateId) === index
));
for (const remainingSessionId of remainingSessionIds) {
if (!nextFocusSessionOrder.includes(remainingSessionId)) {
nextFocusSessionOrder.push(remainingSessionId);
}
}
return {
changed: true,
sessions: nextSessions,
workspaces: workspaces.map((workspace) => (
workspace.id === workspaceId
? {
...workspace,
root: prunedRoot,
focusedSessionId: nextFocusedSessionId,
focusSessionOrder: nextFocusSessionOrder,
}
: workspace
)),
activeTabId: sessionId,
};
}
+25
View File
@@ -13,11 +13,14 @@ import {
STORAGE_KEY_HOTKEY_RECORDING,
STORAGE_KEY_HOTKEY_SCHEME,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
STORAGE_KEY_RESTORE_PREVIOUS_SESSION,
STORAGE_KEY_RESTORE_TERMINAL_CWD,
STORAGE_KEY_SESSION_LOGS_DIR,
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_FORMAT,
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
@@ -46,6 +49,7 @@ import {
} from './settingsStateDefaults';
interface UseSettingsIpcSyncParams {
enabled?: boolean;
syncAppearanceFromStorage: () => void;
syncCustomCssFromStorage: () => void;
setUiLanguage: Dispatch<SetStateAction<UILanguage>>;
@@ -63,6 +67,7 @@ interface UseSettingsIpcSyncParams {
setSessionLogsFormat: Dispatch<SetStateAction<SessionLogFormat>>;
setSessionLogsTimestampsEnabled: Dispatch<SetStateAction<boolean>>;
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
setSshDeepLinkEnabledState: (enabled: boolean) => void;
setHotkeyScheme: Dispatch<SetStateAction<HotkeyScheme>>;
applyIncomingCustomKeyBindings: (incoming: { bindings: CustomKeyBindings; version: number; origin: string }) => void;
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
@@ -75,10 +80,13 @@ interface UseSettingsIpcSyncParams {
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
setRestoreTerminalCwdState: Dispatch<SetStateAction<boolean>>;
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
}
export function useSettingsIpcSync({
enabled = true,
syncAppearanceFromStorage,
syncCustomCssFromStorage,
setUiLanguage,
@@ -96,6 +104,7 @@ export function useSettingsIpcSync({
setSessionLogsFormat,
setSessionLogsTimestampsEnabled,
setSshDebugLogsEnabled,
setSshDeepLinkEnabledState,
setHotkeyScheme,
applyIncomingCustomKeyBindings,
setIsHotkeyRecordingState,
@@ -108,10 +117,13 @@ export function useSettingsIpcSync({
setWorkspaceFocusStyleState,
setShowHostTreeSidebarState,
setDisableTerminalFontZoomState,
setRestorePreviousSessionState,
setRestoreTerminalCwdState,
setSftpTransferConcurrencyState,
}: UseSettingsIpcSyncParams) {
// Listen for settings changes from other windows via IPC
useEffect(() => {
if (!enabled) return;
const bridge = netcattyBridge.get();
if (!bridge?.onSettingsChanged) return;
const unsubscribe = bridge.onSettingsChanged((payload) => {
@@ -192,6 +204,9 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED && typeof value === 'boolean') {
setSshDebugLogsEnabled((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SSH_DEEP_LINK_ENABLED && typeof value === 'boolean') {
setSshDeepLinkEnabledState(value);
}
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
setHotkeyScheme(value);
}
@@ -234,6 +249,12 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM && typeof value === 'boolean') {
setDisableTerminalFontZoomState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_RESTORE_PREVIOUS_SESSION && typeof value === 'boolean') {
setRestorePreviousSessionState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_RESTORE_TERMINAL_CWD && typeof value === 'boolean') {
setRestoreTerminalCwdState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && typeof value === 'number') {
setSftpTransferConcurrencyState((prev) => (prev === value ? prev : value));
}
@@ -246,6 +267,7 @@ export function useSettingsIpcSync({
}
};
}, [
enabled,
applyIncomingCustomKeyBindings,
mergeIncomingTerminalSettings,
setAutoUpdateEnabled,
@@ -259,12 +281,15 @@ export function useSettingsIpcSync({
setSessionLogsEnabled,
setSessionLogsFormat,
setSessionLogsTimestampsEnabled,
setSshDeepLinkEnabledState,
setSshDebugLogsEnabled,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setShowHostTreeSidebarState,
setDisableTerminalFontZoomState,
setRestorePreviousSessionState,
setRestoreTerminalCwdState,
setSftpTransferConcurrencyState,
setTerminalFontFamilyId,
setTerminalFontSize,
@@ -0,0 +1,102 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
getContrastRatio,
buildAppThemeCssVars,
getHslTokenRelativeLuminance,
resolveReadableForegroundForHsl,
resolveThemeAccentForeground,
} from "./settingsStateDefaults.ts";
import type { UiThemeTokens } from "../../infrastructure/config/uiThemes.ts";
test("readable foreground picks white text for dark accent colors", () => {
assert.equal(resolveReadableForegroundForHsl("270 70% 45%"), "0 0% 100%");
});
test("readable foreground picks black text for light accent colors", () => {
assert.equal(resolveReadableForegroundForHsl("48 95% 72%"), "0 0% 0%");
});
test("computed contrast chooses the stronger black or white foreground", () => {
const purpleLuminance = getHslTokenRelativeLuminance("270 70% 45%");
assert.equal(typeof purpleLuminance, "number");
assert.ok(getContrastRatio(1, purpleLuminance as number) > getContrastRatio(0, purpleLuminance as number));
const yellowLuminance = getHslTokenRelativeLuminance("48 95% 72%");
assert.equal(typeof yellowLuminance, "number");
assert.ok(getContrastRatio(0, yellowLuminance as number) > getContrastRatio(1, yellowLuminance as number));
});
test("theme accent foreground uses the computed color for preset accent buttons", () => {
const tokens: UiThemeTokens = {
background: "0 0% 100%",
foreground: "222 47% 12%",
card: "0 0% 100%",
cardForeground: "222 47% 12%",
popover: "0 0% 100%",
popoverForeground: "222 47% 12%",
primary: "270 70% 45%",
primaryForeground: "0 0% 0%",
secondary: "220 12% 95%",
secondaryForeground: "222 47% 12%",
muted: "220 12% 95%",
mutedForeground: "220 10% 45%",
accent: "270 70% 45%",
accentForeground: "0 0% 0%",
destructive: "0 70% 50%",
destructiveForeground: "0 0% 100%",
border: "220 12% 88%",
input: "220 12% 88%",
ring: "270 70% 45%",
};
assert.equal(resolveThemeAccentForeground(tokens, "theme", "48 95% 72%"), "0 0% 100%");
assert.equal(resolveThemeAccentForeground(tokens, "custom", "48 95% 72%"), "0 0% 0%");
});
test("app surface theme vars isolate non-terminal pages from active terminal chrome", () => {
const tokens: UiThemeTokens = {
background: "0 0% 100%",
foreground: "222 47% 12%",
card: "0 0% 100%",
cardForeground: "222 47% 12%",
popover: "0 0% 100%",
popoverForeground: "222 47% 12%",
primary: "270 70% 45%",
primaryForeground: "0 0% 0%",
secondary: "220 12% 95%",
secondaryForeground: "222 47% 12%",
muted: "220 12% 95%",
mutedForeground: "220 10% 45%",
accent: "270 70% 45%",
accentForeground: "0 0% 0%",
destructive: "0 70% 50%",
destructiveForeground: "0 0% 100%",
border: "220 12% 88%",
input: "220 12% 88%",
ring: "270 70% 45%",
};
assert.deepEqual(buildAppThemeCssVars(tokens, "theme", "48 95% 72%"), {
"--background": "0 0% 100%",
"--foreground": "222 47% 12%",
"--card": "0 0% 100%",
"--card-foreground": "222 47% 12%",
"--popover": "0 0% 100%",
"--popover-foreground": "222 47% 12%",
"--primary": "270 70% 45%",
"--primary-foreground": "0 0% 100%",
"--secondary": "220 12% 95%",
"--secondary-foreground": "222 47% 12%",
"--muted": "220 12% 95%",
"--muted-foreground": "220 10% 45%",
"--accent": "270 70% 45%",
"--accent-foreground": "0 0% 100%",
"--destructive": "0 70% 50%",
"--destructive-foreground": "0 0% 100%",
"--border": "220 12% 88%",
"--input": "220 12% 88%",
"--ring": "270 70% 45%",
});
});
+53 -25
View File
@@ -6,6 +6,13 @@ import { UI_FONTS } from '../../infrastructure/config/uiFonts';
import { uiFontStore } from './uiFontStore';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { resolveReadableForegroundForHsl } from '../../domain/colorContrast';
export {
getContrastRatio,
getHslTokenRelativeLuminance,
resolveReadableForegroundForHsl,
} from '../../domain/colorContrast';
export const DEFAULT_THEME: 'light' | 'dark' | 'system' = 'dark';
export const DEFAULT_WINDOW_OPACITY = 1;
@@ -66,6 +73,7 @@ export const DEFAULT_SHOW_SFTP_TAB = true;
export const DEFAULT_SHOW_HOST_TREE_SIDEBAR = true;
export const DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS = false;
export const DEFAULT_DISABLE_TERMINAL_FONT_ZOOM = false;
export { DEFAULT_RESTORE_PREVIOUS_SESSION } from './sessionRestoreSettings';
// Editor defaults
export const DEFAULT_EDITOR_WORD_WRAP = false;
@@ -75,6 +83,7 @@ export const DEFAULT_SESSION_LOGS_ENABLED = false;
export const DEFAULT_SESSION_LOGS_FORMAT: SessionLogFormat = 'txt';
export const DEFAULT_SESSION_LOGS_TIMESTAMPS_ENABLED = false;
export const DEFAULT_SSH_DEBUG_LOGS_ENABLED = false;
export const DEFAULT_SSH_DEEP_LINK_ENABLED = true;
export const readStoredString = (key: string): string | null => {
const raw = localStorageAdapter.readString(key);
@@ -97,6 +106,46 @@ export const isValidHslToken = (value: string): boolean => {
return /^\s*\d+(\.\d+)?\s+\d+(\.\d+)?%\s+\d+(\.\d+)?%\s*$/.test(value);
};
export const resolveThemeAccentForeground = (
tokens: UiThemeTokens,
accentMode: 'theme' | 'custom',
accentOverride: string,
): string => {
const accentToken = accentMode === 'custom' ? accentOverride : tokens.accent;
return resolveReadableForegroundForHsl(accentToken, tokens.primaryForeground);
};
export const buildAppThemeCssVars = (
tokens: UiThemeTokens,
accentMode: 'theme' | 'custom',
accentOverride: string,
): Record<string, string> => {
const accentToken = accentMode === 'custom' ? accentOverride : tokens.accent;
const computedAccentForeground = resolveThemeAccentForeground(tokens, accentMode, accentOverride);
return {
'--background': tokens.background,
'--foreground': tokens.foreground,
'--card': tokens.card,
'--card-foreground': tokens.cardForeground,
'--popover': tokens.popover,
'--popover-foreground': tokens.popoverForeground,
'--primary': accentToken,
'--primary-foreground': computedAccentForeground,
'--secondary': tokens.secondary,
'--secondary-foreground': tokens.secondaryForeground,
'--muted': tokens.muted,
'--muted-foreground': tokens.mutedForeground,
'--accent': accentToken,
'--accent-foreground': computedAccentForeground,
'--destructive': tokens.destructive,
'--destructive-foreground': tokens.destructiveForeground,
'--border': tokens.border,
'--input': tokens.input,
'--ring': accentToken,
};
};
export const isValidUiThemeId = (theme: 'light' | 'dark', value: string): boolean => {
const list = theme === 'dark' ? DARK_UI_THEMES : LIGHT_UI_THEMES;
return list.some((preset) => preset.id === value);
@@ -134,31 +183,10 @@ export const applyThemeTokens = (
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(resolvedTheme);
root.style.setProperty('--background', tokens.background);
root.style.setProperty('--foreground', tokens.foreground);
root.style.setProperty('--card', tokens.card);
root.style.setProperty('--card-foreground', tokens.cardForeground);
root.style.setProperty('--popover', tokens.popover);
root.style.setProperty('--popover-foreground', tokens.popoverForeground);
const accentToken = accentMode === 'custom' ? accentOverride : tokens.accent;
const accentLightness = parseFloat(accentToken.split(/\s+/)[2]?.replace('%', '') || '');
const computedAccentForeground = resolvedTheme === 'dark'
? '220 40% 96%'
: (!Number.isNaN(accentLightness) && accentLightness < 55 ? '0 0% 98%' : '222 47% 12%');
root.style.setProperty('--primary', accentToken);
root.style.setProperty('--primary-foreground', accentMode === 'custom' ? computedAccentForeground : tokens.primaryForeground);
root.style.setProperty('--secondary', tokens.secondary);
root.style.setProperty('--secondary-foreground', tokens.secondaryForeground);
root.style.setProperty('--muted', tokens.muted);
root.style.setProperty('--muted-foreground', tokens.mutedForeground);
root.style.setProperty('--accent', accentToken);
root.style.setProperty('--accent-foreground', accentMode === 'custom' ? computedAccentForeground : tokens.accentForeground);
root.style.setProperty('--destructive', tokens.destructive);
root.style.setProperty('--destructive-foreground', tokens.destructiveForeground);
root.style.setProperty('--border', tokens.border);
root.style.setProperty('--input', tokens.input);
root.style.setProperty('--ring', accentToken);
const cssVars = buildAppThemeCssVars(tokens, accentMode, accentOverride);
for (const [property, value] of Object.entries(cssVars)) {
root.style.setProperty(property, value);
}
// Sync with native window title bar (Electron)
netcattyBridge.get()?.setTheme?.(themeSource);
+42 -8
View File
@@ -16,6 +16,9 @@ import {
STORAGE_KEY_SESSION_LOGS_FORMAT,
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
STORAGE_KEY_RESTORE_PREVIOUS_SESSION,
STORAGE_KEY_RESTORE_TERMINAL_CWD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
STORAGE_KEY_SFTP_AUTO_SYNC,
@@ -55,6 +58,7 @@ import {
} from './settingsStateDefaults';
interface UseSettingsStorageSyncParams {
enabled?: boolean;
theme: 'dark' | 'light' | 'system';
lightUiThemeId: string;
darkUiThemeId: string;
@@ -81,12 +85,15 @@ interface UseSettingsStorageSyncParams {
showHostTreeSidebar: boolean;
shellOnlyTabNumberShortcuts: boolean;
disableTerminalFontZoom: boolean;
restorePreviousSession: boolean;
restoreTerminalCwd: boolean;
editorWordWrap: boolean;
sessionLogsEnabled: boolean;
sessionLogsDir: string;
sessionLogsFormat: SessionLogFormat;
sessionLogsTimestampsEnabled: boolean;
sshDebugLogsEnabled: boolean;
sshDeepLinkEnabled: boolean;
globalHotkeyEnabled: boolean;
autoUpdateEnabled: boolean;
windowOpacity: number;
@@ -118,12 +125,15 @@ interface UseSettingsStorageSyncParams {
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
setShellOnlyTabNumberShortcutsState: Dispatch<SetStateAction<boolean>>;
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
setRestoreTerminalCwdState: Dispatch<SetStateAction<boolean>>;
setEditorWordWrapState: Dispatch<SetStateAction<boolean>>;
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
setSessionLogsDir: Dispatch<SetStateAction<string>>;
setSessionLogsFormat: Dispatch<SetStateAction<SessionLogFormat>>;
setSessionLogsTimestampsEnabled: Dispatch<SetStateAction<boolean>>;
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
setSshDeepLinkEnabledState: (enabled: boolean) => void;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setWindowOpacity: Dispatch<SetStateAction<number>>;
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
@@ -134,13 +144,14 @@ interface UseSettingsStorageSyncParams {
}
export function useSettingsStorageSync({
enabled = true,
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
@@ -148,8 +159,8 @@ export function useSettingsStorageSync({
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState,
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
}: UseSettingsStorageSyncParams) {
@@ -162,8 +173,8 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
});
settingsSnapshotRef.current = {
@@ -172,13 +183,14 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
};
// Listen for storage changes from other windows (cross-window sync)
useEffect(() => {
if (!enabled) return;
const handleStorageChange = (e: StorageEvent) => {
const s = settingsSnapshotRef.current;
if (e.key === STORAGE_KEY_THEME && e.newValue) {
@@ -336,6 +348,12 @@ export function useSettingsStorageSync({
setSshDebugLogsEnabled(newValue);
}
}
if (e.key === STORAGE_KEY_SSH_DEEP_LINK_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.sshDeepLinkEnabled) {
setSshDeepLinkEnabledState(newValue);
}
}
// Sync SFTP compressed upload setting from other windows
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
@@ -398,6 +416,18 @@ export function useSettingsStorageSync({
setDisableTerminalFontZoomState(newValue);
}
}
if (e.key === STORAGE_KEY_RESTORE_PREVIOUS_SESSION && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.restorePreviousSession) {
setRestorePreviousSessionState(newValue);
}
}
if (e.key === STORAGE_KEY_RESTORE_TERMINAL_CWD && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.restoreTerminalCwd) {
setRestoreTerminalCwdState(newValue);
}
}
// Sync global hotkey enabled setting from other windows
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
@@ -436,6 +466,7 @@ export function useSettingsStorageSync({
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [
enabled,
applyIncomingCustomKeyBindings,
mergeIncomingTerminalSettings,
setAccentMode,
@@ -453,6 +484,7 @@ export function useSettingsStorageSync({
setSessionLogsEnabled,
setSessionLogsFormat,
setSessionLogsTimestampsEnabled,
setSshDeepLinkEnabledState,
setSshDebugLogsEnabled,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
@@ -468,6 +500,8 @@ export function useSettingsStorageSync({
setShowSftpTabState,
setShellOnlyTabNumberShortcutsState,
setDisableTerminalFontZoomState,
setRestorePreviousSessionState,
setRestoreTerminalCwdState,
setTerminalFontFamilyId,
setTerminalFontSize,
setTerminalThemeDarkId,
@@ -0,0 +1,34 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
resolveSftpTransferConcurrency,
runSftpTransferWorkers,
} from "./transferConcurrency";
test("defaults folder file transfers to two concurrent files", () => {
assert.equal(resolveSftpTransferConcurrency(() => null), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
assert.equal(DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY, 2);
});
test("keeps explicit folder transfer concurrency within the supported range", () => {
assert.equal(resolveSftpTransferConcurrency(() => 1), 1);
assert.equal(resolveSftpTransferConcurrency(() => 16), 16);
assert.equal(resolveSftpTransferConcurrency(() => 0), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
assert.equal(resolveSftpTransferConcurrency(() => 17), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
});
test("limits default multi-file transfer scheduling to two concurrent workers", async () => {
let active = 0;
let maxActive = 0;
await runSftpTransferWorkers([1, 2, 3, 4], () => null, async () => {
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise((resolve) => setTimeout(resolve, 5));
active -= 1;
});
assert.equal(maxActive, 2);
});
@@ -0,0 +1,34 @@
export const DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY = 2;
export const MIN_SFTP_FILE_TRANSFER_CONCURRENCY = 1;
export const MAX_SFTP_FILE_TRANSFER_CONCURRENCY = 16;
export function resolveSftpTransferConcurrency(readStoredValue: () => number | null | undefined): number {
const stored = readStoredValue();
return stored != null &&
stored >= MIN_SFTP_FILE_TRANSFER_CONCURRENCY &&
stored <= MAX_SFTP_FILE_TRANSFER_CONCURRENCY
? stored
: DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY;
}
export async function runSftpTransferWorkers<T>(
items: T[],
readStoredConcurrency: () => number | null | undefined,
worker: (item: T, index: number) => Promise<void>,
): Promise<void> {
const concurrency = resolveSftpTransferConcurrency(readStoredConcurrency);
let nextIndex = 0;
const runNext = async () => {
while (nextIndex < items.length) {
const index = nextIndex++;
await worker(items[index], index);
}
};
const workers = Array.from(
{ length: Math.min(concurrency, items.length) },
() => runNext(),
);
await Promise.all(workers);
}
+8 -20
View File
@@ -4,6 +4,7 @@ import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from "../../../infrastructure/c
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { runSftpTransferWorkers } from "./transferConcurrency";
import { joinPath } from "./utils";
interface UseSftpDirectoryTransferOpsParams {
@@ -185,11 +186,6 @@ export function useSftpDirectoryTransferOps({
});
};
const getTransferConcurrency = () => {
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
};
/** Recursively count all files under a directory (for progress display). */
const countDirectoryFiles = async (
sourcePath: string,
@@ -308,8 +304,8 @@ export function useSftpDirectoryTransferOps({
// Process subdirectories sequentially to avoid unbounded concurrent SFTP
// requests from nested Promise.all + worker pools across the tree.
// File-level concurrency within each directory is still governed by
// getTransferConcurrency().
// File-level concurrency within each directory is still governed by the
// shared SFTP transfer worker scheduler below.
for (const dir of dirs) {
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
@@ -346,17 +342,16 @@ export function useSftpDirectoryTransferOps({
// Transfer files in parallel with concurrency limit
if (regularFiles.length > 0) {
let fileIndex = 0;
const errors: Error[] = [];
const worker = async () => {
while (fileIndex < regularFiles.length) {
await runSftpTransferWorkers(
regularFiles,
() => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
async (file) => {
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
}
const idx = fileIndex++;
const file = regularFiles[idx];
const fileId = crypto.randomUUID();
const fileSize = getEntrySize(file);
@@ -431,15 +426,8 @@ export function useSftpDirectoryTransferOps({
if (err instanceof Error && err.message === "Transfer cancelled") throw err;
errors.push(err instanceof Error ? err : new Error(String(err)));
}
}
};
const concurrency = getTransferConcurrency();
const workers = Array.from(
{ length: Math.min(concurrency, regularFiles.length) },
() => worker(),
},
);
await Promise.all(workers);
totalErrors += errors.length;
if (errors.length > 0) {
+40 -18
View File
@@ -3,7 +3,18 @@ import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { SftpPane } from "./types";
import { getFileName, getParentPath, isNavigableDirectory, isWindowsRoot, joinPath } from "./utils";
import {
getFileName,
getParentPath,
getSftpFilterAfterPathChange,
getSftpFilterAfterPathChangeError,
isNavigableDirectory,
isSameSftpPath,
isWindowsRoot,
joinPath,
normalizeSftpPathForCompare,
shouldClearSftpFilterForPathChange,
} from "./utils";
import { buildCacheKey, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
/** Shared empty set for navigation resets — never mutate this. */
@@ -32,8 +43,10 @@ interface UseSftpPaneActionsParams {
dirCacheTtlMs: number;
}
export type SftpNavigateResult = "reached" | "failed" | "aborted";
interface UseSftpPaneActionsResult {
navigateTo: (side: "left" | "right", path: string, options?: { force?: boolean; tabId?: string }) => Promise<void>;
navigateTo: (side: "left" | "right", path: string, options?: { force?: boolean; tabId?: string }) => Promise<SftpNavigateResult>;
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
navigateUp: (side: "left" | "right") => Promise<void>;
openEntry: (side: "left" | "right", entry: SftpFileEntry) => Promise<void>;
@@ -83,17 +96,12 @@ export const useSftpPaneActions = ({
dirCacheTtlMs,
}: UseSftpPaneActionsParams): UseSftpPaneActionsResult => {
const normalizePathForCompare = useCallback((path: string): string => {
if (isWindowsRoot(path)) return path.replace(/\//g, "\\").toLowerCase();
if (/^[A-Za-z]:/.test(path)) {
return path.replace(/\//g, "\\").replace(/[\\]+$/, "").toLowerCase();
}
if (path === "/") return "/";
return path.replace(/\/+$/, "");
return normalizeSftpPathForCompare(path);
}, []);
const isSamePath = useCallback((a: string, b: string): boolean => {
return normalizePathForCompare(a) === normalizePathForCompare(b);
}, [normalizePathForCompare]);
return isSameSftpPath(a, b);
}, []);
const isDescendantPath = useCallback((candidate: string, parent: string): boolean => {
const normalizedCandidate = normalizePathForCompare(candidate);
@@ -146,7 +154,7 @@ export const useSftpPaneActions = ({
// than an intermediate optimistic state from another in-flight navigation.
// Includes connectionId so stale entries from a previous host are ignored.
const lastConfirmedRef = useRef(
new Map<string, { connectionId: string; path: string; files: SftpFileEntry[]; selectedFiles: Set<string> }>(),
new Map<string, { connectionId: string; path: string; files: SftpFileEntry[]; selectedFiles: Set<string>; filter: string }>(),
);
const navigateTo = useCallback(
@@ -154,7 +162,7 @@ export const useSftpPaneActions = ({
side: "left" | "right",
path: string,
options?: { force?: boolean; tabId?: string },
) => {
): Promise<SftpNavigateResult> => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
// When tabId is specified, target that specific tab instead of the active one.
// This allows refreshing a background tab (e.g. after a transfer completes
@@ -165,12 +173,14 @@ export const useSftpPaneActions = ({
: getActivePane(side);
if (!pane?.connection || !targetTabId) {
return;
return "aborted";
}
const connectionId = pane.connection.id;
const requestId = ++navSeqRef.current[side];
const cacheKey = makeCacheKey(connectionId, path, pane.filenameEncoding);
const clearFilterForPathChange = shouldClearSftpFilterForPathChange(pane.connection.currentPath, path);
const nextConfirmedFilter = getSftpFilterAfterPathChange(pane.connection.currentPath, path, pane.filter);
const cached = options?.force
? undefined
: dirCacheRef.current.get(cacheKey);
@@ -186,6 +196,7 @@ export const useSftpPaneActions = ({
path,
files: cached.files,
selectedFiles: EMPTY_SET,
filter: nextConfirmedFilter,
});
updateTab(side, targetTabId, (prev) => ({
...prev,
@@ -196,6 +207,7 @@ export const useSftpPaneActions = ({
loading: false,
error: null,
selectedFiles: EMPTY_SET,
filter: clearFilterForPathChange ? "" : prev.filter,
}));
if (!pane.connection.isLocal) {
// Use hostId as the shared cache key — this is safe because the
@@ -210,7 +222,7 @@ export const useSftpPaneActions = ({
filenameEncoding: pane.filenameEncoding,
});
}
return;
return "reached";
}
// Re-seed confirmed state whenever the pane is settled (not loading), or
@@ -224,12 +236,14 @@ export const useSftpPaneActions = ({
path: pane.connection.currentPath,
files: pane.files,
selectedFiles: pane.selectedFiles,
filter: pane.filter,
});
}
const confirmed = lastConfirmedRef.current.get(targetTabId)!;
const previousPath = confirmed.path;
const previousFiles = confirmed.files;
const previousSelection = confirmed.selectedFiles;
const previousFilter = confirmed.filter;
tabNavSeqRef.current.set(targetTabId, requestId);
// Keep existing files visible during loading — the loading overlay
// (pointer-events-none) prevents interaction. This avoids blanking a tab
@@ -240,6 +254,7 @@ export const useSftpPaneActions = ({
? { ...prev.connection, currentPath: path }
: null,
selectedFiles: EMPTY_SET,
filter: clearFilterForPathChange ? "" : prev.filter,
loading: true,
error: null,
}));
@@ -264,7 +279,7 @@ export const useSftpPaneActions = ({
} else {
handleSessionError(side, new Error("SFTP session lost"));
}
return;
return "aborted";
}
try {
@@ -282,7 +297,7 @@ export const useSftpPaneActions = ({
} else {
handleSessionError(side, err as Error);
}
return;
return "aborted";
}
throw err as Error;
}
@@ -293,7 +308,7 @@ export const useSftpPaneActions = ({
// a connect/disconnect. Check if THIS tab's request is still current.
if (tabNavSeqRef.current.get(targetTabId) !== requestId) {
// This tab also has a newer navigation — drop completely.
return;
return "aborted";
}
// Side was superseded by another tab, but this tab's request is
// still current. The fetched files are valid — fall through to
@@ -310,6 +325,7 @@ export const useSftpPaneActions = ({
path,
files,
selectedFiles: EMPTY_SET,
filter: nextConfirmedFilter,
});
updateTab(side, targetTabId, (prev) => ({
@@ -320,6 +336,7 @@ export const useSftpPaneActions = ({
files,
loading: false,
selectedFiles: EMPTY_SET,
filter: clearFilterForPathChange ? "" : prev.filter,
}));
if (!pane.connection.isLocal) {
setSharedRemoteHostCache(getActivePaneCacheKey(side, pane.connection.hostId, pane.connection.id), {
@@ -329,28 +346,33 @@ export const useSftpPaneActions = ({
filenameEncoding: pane.filenameEncoding,
});
}
return "reached";
} catch (err) {
if (navSeqRef.current[side] !== requestId) {
if (tabNavSeqRef.current.get(targetTabId) !== requestId) {
return;
return "aborted";
}
// Side superseded by another tab, but this tab's request is
// current — fall through to show the error on this tab.
}
let navigationFailed = false;
updateTab(side, targetTabId, (prev) => {
if (prev.connection?.id !== connectionId) {
return prev;
}
navigationFailed = true;
return {
...prev,
connection: { ...prev.connection, currentPath: previousPath },
files: previousFiles,
selectedFiles: previousSelection,
filter: getSftpFilterAfterPathChangeError(clearFilterForPathChange, previousFilter, prev.filter),
error:
err instanceof Error ? err.message : "Failed to list directory",
loading: false,
};
});
return navigationFailed ? "failed" : "aborted";
}
},
[
+34 -1
View File
@@ -1,7 +1,13 @@
import test from "node:test";
import assert from "node:assert/strict";
import { isConcreteTransferTargetPath } from "./utils";
import {
getParentPath,
getSftpFilterAfterPathChange,
getSftpFilterAfterPathChangeError,
isConcreteTransferTargetPath,
shouldClearSftpFilterForPathChange,
} from "./utils";
test("concrete transfer target paths exclude temporary placeholders", () => {
assert.equal(isConcreteTransferTargetPath({ targetPath: "/Users/alice/Downloads/report.pdf" }), true);
@@ -9,3 +15,30 @@ test("concrete transfer target paths exclude temporary placeholders", () => {
assert.equal(isConcreteTransferTargetPath({ targetPath: "(temp)" }), false);
assert.equal(isConcreteTransferTargetPath({ targetPath: " " }), false);
});
test("SFTP filter clears when entering a different directory", () => {
assert.equal(shouldClearSftpFilterForPathChange("/srv", "/srv/app"), true);
assert.equal(getSftpFilterAfterPathChange("/srv", "/srv/app", "log"), "");
});
test("SFTP filter clears when navigating to the parent directory", () => {
assert.equal(shouldClearSftpFilterForPathChange("/srv/app", getParentPath("/srv/app")), true);
assert.equal(getSftpFilterAfterPathChange("/srv/app", getParentPath("/srv/app"), "dist"), "");
});
test("SFTP filter stays when refreshing the same directory", () => {
assert.equal(shouldClearSftpFilterForPathChange("/srv/app", "/srv/app"), false);
assert.equal(shouldClearSftpFilterForPathChange("/srv/app", "/srv/app/"), false);
assert.equal(shouldClearSftpFilterForPathChange("C:\\Users\\alice", "c:/Users/alice"), false);
assert.equal(getSftpFilterAfterPathChange("/srv/app", "/srv/app", "log"), "log");
assert.equal(getSftpFilterAfterPathChange("/srv/app", "/srv/app/", "log"), "log");
assert.equal(getSftpFilterAfterPathChange("C:\\Users\\alice", "c:/Users/alice", "docs"), "docs");
});
test("SFTP filter restores when changed-directory navigation fails", () => {
assert.equal(getSftpFilterAfterPathChangeError(true, "log", "typed-while-loading"), "log");
});
test("SFTP filter preserves in-flight edits when same-directory refresh fails", () => {
assert.equal(getSftpFilterAfterPathChangeError(false, "log", "typed-while-loading"), "typed-while-loading");
});
+36
View File
@@ -85,3 +85,39 @@ export const getFileName = (path: string): string => {
const parts = path.split(/[\\/]/).filter(Boolean);
return parts[parts.length - 1] || "";
};
export const normalizeSftpPathForCompare = (path: string): string => {
if (isWindowsRoot(path)) return path.replace(/\//g, "\\").toLowerCase();
if (/^[A-Za-z]:/.test(path)) {
return path.replace(/\//g, "\\").replace(/[\\]+$/, "").toLowerCase();
}
if (path === "/") return "/";
return path.replace(/\/+$/, "");
};
export const isSameSftpPath = (a: string, b: string): boolean => {
return normalizeSftpPathForCompare(a) === normalizeSftpPathForCompare(b);
};
export const shouldClearSftpFilterForPathChange = (
currentPath: string,
nextPath: string,
): boolean => {
return !isSameSftpPath(currentPath, nextPath);
};
export const getSftpFilterAfterPathChange = (
currentPath: string,
nextPath: string,
currentFilter: string,
): string => {
return shouldClearSftpFilterForPathChange(currentPath, nextPath) ? "" : currentFilter;
};
export const getSftpFilterAfterPathChangeError = (
clearFilterForPathChange: boolean,
previousFilter: string,
currentFilter: string,
): string => {
return clearFilterForPathChange ? previousFilter : currentFilter;
};
+14 -5
View File
@@ -10,6 +10,7 @@ import { localStorageAdapter } from '../../infrastructure/persistence/localStora
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
interface UseSystemSettingsEffectsParams {
enabled?: boolean;
toggleWindowHotkey: string;
globalHotkeyEnabled: boolean;
closeToTray: boolean;
@@ -22,6 +23,7 @@ interface UseSystemSettingsEffectsParams {
}
export function useSystemSettingsEffects({
enabled = true,
toggleWindowHotkey,
globalHotkeyEnabled,
closeToTray,
@@ -34,6 +36,7 @@ export function useSystemSettingsEffects({
}: UseSystemSettingsEffectsParams) {
// Persist and sync toggle window hotkey setting
useEffect(() => {
if (!enabled) return;
// Register/unregister the global hotkey in main process (needed on mount)
const bridge = netcattyBridge.get();
if (bridge?.registerGlobalHotkey) {
@@ -64,6 +67,7 @@ export function useSystemSettingsEffects({
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
}, [
toggleWindowHotkey,
enabled,
globalHotkeyEnabled,
notifySettingsChanged,
persistMountedRef,
@@ -72,13 +76,15 @@ export function useSystemSettingsEffects({
// Persist global hotkey enabled setting
useEffect(() => {
if (!enabled) return;
localStorageAdapter.writeString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled);
}, [globalHotkeyEnabled, notifySettingsChanged, persistMountedRef]);
}, [enabled, globalHotkeyEnabled, notifySettingsChanged, persistMountedRef]);
// Persist and sync close to tray setting
useEffect(() => {
if (!enabled) return;
// Update main process tray behavior (needed on mount)
const bridge = netcattyBridge.get();
if (bridge?.setCloseToTray) {
@@ -90,10 +96,11 @@ export function useSystemSettingsEffects({
// Skip IPC on initial mount
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
}, [closeToTray, notifySettingsChanged, persistMountedRef]);
}, [enabled, closeToTray, notifySettingsChanged, persistMountedRef]);
// Persist and sync window opacity
useEffect(() => {
if (!enabled) return;
const bridge = netcattyBridge.get();
bridge?.setWindowOpacity?.(windowOpacity).catch((err) => {
console.warn('[WindowOpacity] Failed to apply window opacity:', err);
@@ -101,12 +108,13 @@ export function useSystemSettingsEffects({
localStorageAdapter.writeString(STORAGE_KEY_WINDOW_OPACITY, String(windowOpacity));
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_WINDOW_OPACITY, windowOpacity);
}, [windowOpacity, notifySettingsChanged, persistMountedRef]);
}, [enabled, windowOpacity, notifySettingsChanged, persistMountedRef]);
// Hydrate auto-update state from the main-process preference file on mount.
// This reconciles localStorage (renderer) with auto-update-pref.json (main)
// in case localStorage was cleared or is stale.
useEffect(() => {
if (!enabled) return;
const bridge = netcattyBridge.get();
void bridge?.getAutoUpdate?.().then((result) => {
if (result && typeof result.enabled === 'boolean') {
@@ -118,11 +126,12 @@ export function useSystemSettingsEffects({
});
}
}).catch(() => { /* bridge unavailable */ });
}, [setAutoUpdateEnabled]);
}, [enabled, setAutoUpdateEnabled]);
// Persist auto-update enabled setting.
// Initial mount still writes localStorage, but skips cross-window/main-process IPC.
useEffect(() => {
if (!enabled) return;
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled);
@@ -131,7 +140,7 @@ export function useSystemSettingsEffects({
bridge?.setAutoUpdate?.(autoUpdateEnabled).catch((err: unknown) => {
console.warn('[AutoUpdate] Failed to set auto-update:', err);
});
}, [autoUpdateEnabled, notifySettingsChanged, persistMountedRef]);
}, [enabled, autoUpdateEnabled, notifySettingsChanged, persistMountedRef]);
}
+62
View File
@@ -0,0 +1,62 @@
export const WORKSPACE_SESSION_DRAG_TYPE = 'application/x-netcatty-workspace-session';
type DataTransferLike = {
types: DOMStringList | readonly string[];
getData: (format: string) => string;
};
export function dataTransferHasType(dataTransfer: Pick<DataTransferLike, 'types'>, type: string): boolean {
return Array.prototype.includes.call(dataTransfer.types, type);
}
export function hasWorkspaceSessionDrag(dataTransfer: Pick<DataTransferLike, 'types'>): boolean {
return dataTransferHasType(dataTransfer, WORKSPACE_SESSION_DRAG_TYPE);
}
export function getWorkspaceSessionDragId(dataTransfer: DataTransferLike): string {
return dataTransfer.getData(WORKSPACE_SESSION_DRAG_TYPE) || dataTransfer.getData('session-id');
}
export function isPointInsideRect(
point: { clientX: number; clientY: number },
rect: Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom'>,
): boolean {
return point.clientX >= rect.left
&& point.clientX <= rect.right
&& point.clientY >= rect.top
&& point.clientY <= rect.bottom;
}
export type TopTabInsertionTarget = {
tabId: string;
position: 'before' | 'after';
};
export function getTopTabInsertionTarget(
point: { clientX: number; clientY: number },
topTabsRoot: HTMLElement | null,
): TopTabInsertionTarget | null {
if (!topTabsRoot || !isPointInsideRect(point, topTabsRoot.getBoundingClientRect())) return null;
const tabs = Array.from(topTabsRoot.querySelectorAll<HTMLElement>('[data-tab-id]'))
.filter((tab) => tab.dataset.tabType !== 'root');
if (tabs.length === 0) return null;
for (const tab of tabs) {
const rect = tab.getBoundingClientRect();
const midpoint = rect.left + rect.width / 2;
const tabId = tab.dataset.tabId;
if (!tabId) continue;
if (point.clientX <= midpoint) {
return { tabId, position: 'before' };
}
if (point.clientX <= rect.right) {
return { tabId, position: 'after' };
}
}
const lastTab = tabs[tabs.length - 1];
const lastTabId = lastTab?.dataset.tabId;
return lastTabId ? { tabId: lastTabId, position: 'after' } : null;
}
@@ -0,0 +1,14 @@
import assert from "node:assert/strict";
import test from "node:test";
import { terminalHiddenRendererStore } from "./terminalHiddenRendererStore.ts";
test("terminalHiddenRendererStore evicts oldest soft-hidden session", () => {
terminalHiddenRendererStore.clearSoftHidden("a");
terminalHiddenRendererStore.clearSoftHidden("b");
terminalHiddenRendererStore.markSoftHidden("a");
terminalHiddenRendererStore.markSoftHidden("b");
assert.equal(terminalHiddenRendererStore.pickEvictionCandidate(2), "a");
terminalHiddenRendererStore.clearSoftHidden("a");
terminalHiddenRendererStore.clearSoftHidden("b");
});
@@ -0,0 +1,55 @@
type Listener = () => void;
const softHiddenSessions = new Map<string, number>();
const listeners = new Set<Listener>();
let evictionRequestSessionId: string | null = null;
function emit(): void {
listeners.forEach((listener) => listener());
}
export const terminalHiddenRendererStore = {
getSoftHiddenCount: () => softHiddenSessions.size,
isSoftHidden: (sessionId: string) => softHiddenSessions.has(sessionId),
markSoftHidden: (sessionId: string) => {
softHiddenSessions.set(sessionId, Date.now());
emit();
},
clearSoftHidden: (sessionId: string) => {
if (!softHiddenSessions.delete(sessionId)) return;
emit();
},
/** Returns the oldest soft-hidden session id when over the keep limit. */
pickEvictionCandidate: (keepCount: number): string | null => {
if (softHiddenSessions.size < keepCount) return null;
let oldestId: string | null = null;
let oldestTs = Number.POSITIVE_INFINITY;
for (const [sessionId, ts] of softHiddenSessions) {
if (ts < oldestTs) {
oldestTs = ts;
oldestId = sessionId;
}
}
return oldestId;
},
requestEviction: (sessionId: string) => {
evictionRequestSessionId = sessionId;
emit();
},
consumeEvictionRequest: (sessionId: string): boolean => {
if (evictionRequestSessionId !== sessionId) return false;
evictionRequestSessionId = null;
return true;
},
subscribe: (listener: Listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
@@ -14,3 +14,26 @@ test('terminalLayoutSuppressStore tracks nested begin/end', () => {
terminalLayoutSuppressStore.end();
assert.equal(terminalLayoutSuppressStore.getActive(), false);
});
test('terminalLayoutSuppressStore defers subscriber notifications', async () => {
let notifyCount = 0;
const unsubscribe = terminalLayoutSuppressStore.subscribe(() => {
notifyCount += 1;
});
terminalLayoutSuppressStore.begin();
assert.equal(notifyCount, 0, 'begin should not notify synchronously');
assert.equal(terminalLayoutSuppressStore.getActive(), true);
await new Promise<void>((resolve) => setTimeout(resolve, 0));
assert.equal(notifyCount, 1, 'begin should notify after the current turn');
terminalLayoutSuppressStore.end();
assert.equal(notifyCount, 1, 'end should not notify synchronously');
assert.equal(terminalLayoutSuppressStore.getActive(), false);
await new Promise<void>((resolve) => setTimeout(resolve, 0));
assert.equal(notifyCount, 2, 'end should notify after the current turn');
unsubscribe();
});
@@ -4,9 +4,19 @@ type Listener = () => void;
let suppressDepth = 0;
const listeners = new Set<Listener>();
let notifyRafId: number | null = null;
function emit() {
listeners.forEach((listener) => listener());
const scheduleFrame =
typeof requestAnimationFrame === 'function'
? requestAnimationFrame
: (cb: () => void) => setTimeout(cb, 0) as unknown as number;
function scheduleEmit() {
if (notifyRafId !== null) return;
notifyRafId = scheduleFrame(() => {
notifyRafId = null;
listeners.forEach((listener) => listener());
});
}
export const terminalLayoutSuppressStore = {
@@ -19,14 +29,14 @@ export const terminalLayoutSuppressStore = {
begin: () => {
suppressDepth += 1;
emit();
scheduleEmit();
},
end: () => {
const wasActive = suppressDepth > 0;
suppressDepth = Math.max(0, suppressDepth - 1);
if (wasActive) {
emit();
scheduleEmit();
}
},
};
@@ -0,0 +1,93 @@
import { useCallback, useState } from 'react';
import { STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_ORDER } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
export type TerminalSidePanelTabId = 'sftp' | 'scripts' | 'history' | 'theme' | 'system' | 'notes' | 'ai';
export const TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER: TerminalSidePanelTabId[] = [
'sftp',
'scripts',
'history',
'theme',
'system',
'notes',
'ai',
];
export const TERMINAL_SIDE_PANEL_TAB_IDS = new Set<TerminalSidePanelTabId>(TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER);
export function normalizeTerminalSidePanelTabOrder(value: unknown): TerminalSidePanelTabId[] {
if (!Array.isArray(value) || value.length !== TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER.length) {
return [...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER];
}
const seen = new Set<TerminalSidePanelTabId>();
const normalized: TerminalSidePanelTabId[] = [];
for (const candidate of value) {
if (typeof candidate !== 'string') return [...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER];
if (!TERMINAL_SIDE_PANEL_TAB_IDS.has(candidate as TerminalSidePanelTabId)) {
return [...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER];
}
const tab = candidate as TerminalSidePanelTabId;
if (seen.has(tab)) return [...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER];
seen.add(tab);
normalized.push(tab);
}
return normalized;
}
export function reorderTerminalSidePanelTab(
order: TerminalSidePanelTabId[],
draggedTab: TerminalSidePanelTabId,
targetTab: TerminalSidePanelTabId,
placement: 'before' | 'after' = 'before',
): TerminalSidePanelTabId[] {
if (draggedTab === targetTab) return order;
if (!order.includes(draggedTab) || !order.includes(targetTab)) return order;
const withoutDragged = order.filter((tab) => tab !== draggedTab);
const targetIndex = withoutDragged.indexOf(targetTab);
if (targetIndex === -1) return order;
const insertionIndex = placement === 'after' ? targetIndex + 1 : targetIndex;
return [
...withoutDragged.slice(0, insertionIndex),
draggedTab,
...withoutDragged.slice(insertionIndex),
];
}
function readTerminalSidePanelTabOrder(): TerminalSidePanelTabId[] {
try {
return normalizeTerminalSidePanelTabOrder(
localStorageAdapter.read<TerminalSidePanelTabId[]>(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_ORDER),
);
} catch {
return [...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER];
}
}
function persistTerminalSidePanelTabOrder(order: TerminalSidePanelTabId[]): void {
try {
localStorageAdapter.write(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_ORDER, order);
} catch {
// Best effort only; the toolbar still works with the in-memory order.
}
}
export function useTerminalSidePanelTabOrder(): {
sidePanelTabOrder: TerminalSidePanelTabId[];
setSidePanelTabOrder: (order: TerminalSidePanelTabId[]) => void;
} {
const [sidePanelTabOrder, setSidePanelTabOrderRaw] = useState<TerminalSidePanelTabId[]>(
readTerminalSidePanelTabOrder,
);
const setSidePanelTabOrder = useCallback((order: TerminalSidePanelTabId[]) => {
setSidePanelTabOrderRaw(order);
persistTerminalSidePanelTabOrder(order);
}, []);
return { sidePanelTabOrder, setSidePanelTabOrder };
}
@@ -7,6 +7,7 @@ import {
themeFingerprint,
} from "./useActiveChromeTheme.ts";
import { TERMINAL_THEMES } from "../../infrastructure/config/terminalThemes.ts";
import { readFileSync } from "node:fs";
function createInlineStyle() {
const values = new Map<string, string>();
@@ -93,3 +94,10 @@ test("syncActiveChromeTheme refreshes top tabs when the active theme fingerprint
}
}
});
test("active chrome theme foreground uses contrast calculation", () => {
const source = readFileSync(new URL("./useActiveChromeTheme.ts", import.meta.url), "utf8");
assert.match(source, /resolveReadableForegroundForHsl\(cursor\)/);
assert.doesNotMatch(source, /cursorLightness > 55/);
});
+2 -2
View File
@@ -7,6 +7,7 @@ import {
import { runThemeTransition } from "./themeTransition";
import { TERMINAL_THEMES } from "../../infrastructure/config/terminalThemes";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
import { resolveReadableForegroundForHsl } from "../../domain/colorContrast";
function hexToHsl(hex: string): string {
const r = parseInt(hex.slice(1, 3), 16) / 255;
@@ -79,8 +80,7 @@ function buildChromeCss(theme: TerminalTheme): string {
const muted = adjustLightness(bg, isDark ? 10 : -8);
const mutedFg = adjustSaturation(adjustLightness(fg, isDark ? -20 : 20), 0.5);
const border = adjustLightness(bg, isDark ? 12 : -10);
const cursorLightness = parseFloat(cursor.split(" ")[2] ?? "50");
const primaryFg = cursorLightness > 55 ? "0 0% 0%" : "0 0% 100%";
const primaryFg = resolveReadableForegroundForHsl(cursor);
const values = [
bg, fg, card, fg,
+69
View File
@@ -0,0 +1,69 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
test("auto-sync establishes the initial data baseline before debouncing edits", () => {
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
const baselineCommentIndex = source.indexOf("Establish the initial baseline immediately");
const initializationGuardIndex = source.indexOf("if (!isInitializedRef.current)", baselineCommentIndex);
const initializedAssignmentIndex = source.indexOf("isInitializedRef.current = true;", initializationGuardIndex);
const hashReadIndex = source.indexOf("const currentHash = await getDataHash();", initializationGuardIndex);
const debounceCommentIndex = source.indexOf("Debounce first, then build the expensive full-data hash", initializationGuardIndex);
const debounceTimerIndex = source.indexOf("syncTimeoutRef.current = setTimeout", debounceCommentIndex);
assert.notEqual(baselineCommentIndex, -1);
assert.notEqual(initializationGuardIndex, -1);
assert.notEqual(initializedAssignmentIndex, -1);
assert.notEqual(hashReadIndex, -1);
assert.notEqual(debounceCommentIndex, -1);
assert.notEqual(debounceTimerIndex, -1);
assert.ok(
initializedAssignmentIndex < hashReadIndex,
"initialization must be marked synchronously before reading the baseline hash",
);
assert.ok(
initializationGuardIndex < debounceTimerIndex,
"the first baseline hash must be captured before scheduling the debounced auto-sync timer",
);
});
test("auto-sync skips only the exact remote-applied data hash", () => {
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
const helperIndex = source.indexOf("const getSyncPayloadDataHash = (payload: SyncPayload): string");
const skipRefIndex = source.indexOf("const skipNextSyncHashRef = useRef<string | null>(null)");
const assignmentIndex = source.indexOf("skipNextSyncHashRef.current = getSyncPayloadDataHash(remotePayload)");
const debounceTimerIndex = source.indexOf("syncTimeoutRef.current = setTimeout", assignmentIndex);
const skipHashIndex = source.indexOf("const skipHash = skipNextSyncHashRef.current", debounceTimerIndex);
const decisionIndex = source.indexOf("resolveAutoSyncHashDecision({", skipHashIndex);
const appliedSkipIndex = source.indexOf("appliedSkipHash: skipHash", decisionIndex);
const syncingGuardIndex = source.indexOf("if (sync.isSyncing || isSyncRunningRef.current)", decisionIndex);
const restoreGuardIndex = source.indexOf("if (isRestoreInProgress())", decisionIndex);
const interruptedGuardIndex = source.indexOf("if (readInterruptedVaultApply())", decisionIndex);
const syncNowIndex = source.indexOf("const didSync = await syncNow();", interruptedGuardIndex);
const didSyncGuardIndex = source.indexOf("if (didSync && skipHash !== null", syncNowIndex);
const clearAfterSyncIndex = source.indexOf("skipNextSyncHashRef.current = null;", didSyncGuardIndex);
const booleanSkipIndex = source.indexOf("skipNextSyncRef");
assert.notEqual(helperIndex, -1);
assert.notEqual(skipRefIndex, -1);
assert.notEqual(assignmentIndex, -1);
assert.notEqual(debounceTimerIndex, -1);
assert.notEqual(skipHashIndex, -1);
assert.notEqual(decisionIndex, -1);
assert.notEqual(appliedSkipIndex, -1);
assert.notEqual(syncingGuardIndex, -1);
assert.notEqual(restoreGuardIndex, -1);
assert.notEqual(interruptedGuardIndex, -1);
assert.notEqual(syncNowIndex, -1);
assert.notEqual(didSyncGuardIndex, -1);
assert.notEqual(clearAfterSyncIndex, -1);
assert.equal(booleanSkipIndex, -1);
assert.ok(
skipHashIndex < decisionIndex,
"remote-apply skip must pass through the hash decision helper before suppressing a sync",
);
assert.ok(
interruptedGuardIndex < syncNowIndex && syncNowIndex < clearAfterSyncIndex,
"remote-apply skip hash must survive temporary sync blockers and clear only after a successful sync",
);
});
+148 -74
View File
@@ -20,9 +20,10 @@ import { mergeSyncPayloads } from '../../domain/syncMerge';
import { resolveCloudSyncConflictAction } from '../../domain/syncStrategy';
import {
SYNCABLE_SETTING_STORAGE_KEYS,
collectSyncableSettings,
collectCloudSyncableSettings,
getEffectivePortForwardingRulesForSync,
hasMeaningfulCloudSyncData,
sanitizePortForwardingRulesForSync,
shouldPromptCloudVaultRecovery,
} from '../syncPayload';
import { readInterruptedVaultApply } from '../localVaultBackups';
@@ -38,8 +39,10 @@ import {
getRuntimeRemoteCheckIntervalMs,
shouldRunRuntimeRemoteCheck,
} from './autoSyncRemoteSchedule';
import { resolveAutoSyncHashDecision } from './autoSyncHashDecision';
interface AutoSyncConfig {
enabled?: boolean;
// Data to sync
hosts: SyncPayload['hosts'];
keys: SyncPayload['keys'];
@@ -48,6 +51,8 @@ interface AutoSyncConfig {
snippets: SyncPayload['snippets'];
customGroups: SyncPayload['customGroups'];
snippetPackages?: SyncPayload['snippetPackages'];
notes?: SyncPayload['notes'];
noteGroups?: SyncPayload['noteGroups'];
portForwardingRules?: SyncPayload['portForwardingRules'];
groupConfigs?: SyncPayload['groupConfigs'];
/** Opaque token that changes whenever a synced setting changes. */
@@ -96,6 +101,23 @@ const isRestoreInProgress = (): boolean => {
return true;
};
const getSyncPayloadDataHash = (payload: SyncPayload): string => {
return JSON.stringify({
hosts: payload.hosts,
keys: payload.keys,
identities: payload.identities,
proxyProfiles: payload.proxyProfiles,
snippets: payload.snippets,
customGroups: payload.customGroups,
snippetPackages: payload.snippetPackages,
notes: payload.notes,
noteGroups: payload.noteGroups,
portForwardingRules: sanitizePortForwardingRulesForSync(payload.portForwardingRules),
groupConfigs: payload.groupConfigs,
settings: payload.settings,
});
};
type SyncTrigger = 'auto' | 'manual';
interface SyncNowOptions {
@@ -108,6 +130,7 @@ interface RemoteVersionCheckOptions {
}
export const useAutoSync = (config: AutoSyncConfig) => {
const enabled = config.enabled !== false;
const { t } = useI18n();
const tRef = useRef(t);
useEffect(() => {
@@ -126,7 +149,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const remoteCheckDoneRef = useRef(false);
const isInitializedRef = useRef(false);
const isSyncRunningRef = useRef(false);
const skipNextSyncRef = useRef(false);
const skipNextSyncHashRef = useRef<string | null>(null);
// State for the empty-vault-vs-cloud confirmation dialog (Fix D).
// When checkRemoteVersion detects that the local vault is empty but
@@ -138,6 +161,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
keyCount: number;
proxyProfileCount: number;
snippetCount: number;
noteCount: number;
} | null>(null);
const emptyVaultResolveRef = useRef<((action: 'restore' | 'keep-empty') => void) | null>(null);
@@ -181,6 +205,8 @@ export const useAutoSync = (config: AutoSyncConfig) => {
snippets: config.snippets,
customGroups: config.customGroups,
snippetPackages: config.snippetPackages,
notes: config.notes,
noteGroups: config.noteGroups,
portForwardingRules: getEffectivePortForwardingRulesForSync(config.portForwardingRules),
groupConfigs: config.groupConfigs,
};
@@ -192,26 +218,29 @@ export const useAutoSync = (config: AutoSyncConfig) => {
config.snippets,
config.customGroups,
config.snippetPackages,
config.notes,
config.noteGroups,
config.portForwardingRules,
config.groupConfigs,
]);
// Build sync payload
const buildPayload = useCallback((): SyncPayload => {
const buildPayload = useCallback(async (): Promise<SyncPayload> => {
return {
...getSyncSnapshot(),
settings: collectSyncableSettings(),
settings: await collectCloudSyncableSettings(),
syncedAt: Date.now(),
};
}, [getSyncSnapshot]);
// Create a hash of current data for comparison (includes settings)
const getDataHash = useCallback(() => {
return JSON.stringify({ ...getSyncSnapshot(), settings: collectSyncableSettings() });
const getDataHash = useCallback(async () => {
return JSON.stringify({ ...getSyncSnapshot(), settings: await collectCloudSyncableSettings() });
}, [getSyncSnapshot]);
// Sync now handler - get fresh state directly from manager
const syncNow = useCallback(async (options?: SyncNowOptions) => {
const syncNow = useCallback(async (options?: SyncNowOptions): Promise<boolean> => {
if (!enabled) return false;
const trigger: SyncTrigger = options?.trigger ?? 'auto';
isSyncRunningRef.current = true;
@@ -228,7 +257,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
if (syncing) {
if (trigger === 'auto') {
console.info('[AutoSync] Skipping overlapping auto-sync because another sync is already running.');
return;
return false;
}
throw new Error(t('sync.autoSync.alreadySyncing'));
}
@@ -248,7 +277,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
if (isRestoreInProgress()) {
if (trigger === 'auto') {
console.info('[AutoSync] Skipping: a vault restore is in progress in another window.');
return;
return false;
}
throw new Error(t('sync.autoSync.restoreInProgress'));
}
@@ -272,7 +301,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
'[AutoSync] Skipping: previous apply was interrupted — refusing to push partial state.',
interruptedApply,
);
return;
return false;
}
throw new Error(t('sync.autoSync.interruptedApplyMessage'));
}
@@ -295,8 +324,8 @@ export const useAutoSync = (config: AutoSyncConfig) => {
throw new Error(t('sync.autoSync.vaultLocked'));
}
const dataHash = getDataHash();
const payload = buildPayload();
const dataHash = await getDataHash();
const payload = await buildPayload();
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
if (encryptedCredentialPaths.length > 0) {
console.warn('[AutoSync] Blocked: encrypted credential placeholders found at:', encryptedCredentialPaths.join(', '));
@@ -317,7 +346,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
if (!hasMeaningfulCloudSyncData(payload)) {
if (trigger === 'auto') {
console.warn('[AutoSync] Blocked: refusing to auto-sync an empty vault to cloud');
return;
return false;
}
throw new Error(t('sync.autoSync.emptyVaultManual'));
}
@@ -338,7 +367,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
recordDownload: true,
});
}
skipNextSyncRef.current = allProvidersSynced;
skipNextSyncHashRef.current = allProvidersSynced ? getSyncPayloadDataHash(result.mergedPayload) : null;
if (!allProvidersSynced) {
console.warn('[AutoSync] Remote payload applied locally, but not every provider synced; leaving next auto-sync enabled for retry.');
}
@@ -368,6 +397,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// would require the user to manually sync after every edit.
hasCheckedRemoteRef.current = true;
remoteCheckDoneRef.current = true;
return true;
} catch (error) {
if (trigger === 'manual') {
throw error;
@@ -377,10 +407,11 @@ export const useAutoSync = (config: AutoSyncConfig) => {
error instanceof Error ? error.message : t('common.unknownError'),
t('sync.autoSync.failedTitle'),
);
return false;
} finally {
isSyncRunningRef.current = false;
}
}, [sync, buildPayload, getDataHash, onApplyPayload, t]);
}, [enabled, sync, buildPayload, getDataHash, onApplyPayload, t]);
// One-shot toast per mount when a previous apply was interrupted, so the
// user understands why auto-sync is silently paused and where to go to
@@ -389,6 +420,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// after the user completes a recovery.
const interruptedApplyNotifiedRef = useRef(false);
useEffect(() => {
if (!enabled) return;
if (interruptedApplyNotifiedRef.current) return;
if (!sync.isUnlocked) return;
const interrupted = readInterruptedVaultApply();
@@ -398,7 +430,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
t('sync.autoSync.interruptedApplyMessage'),
t('sync.autoSync.interruptedApplyTitle'),
);
}, [sync.isUnlocked, t]);
}, [enabled, sync.isUnlocked, t]);
// Stabilize the fields `checkRemoteVersion` reads from `config`.
// AutoSyncConfig is a fresh object literal on every App render, so a
@@ -439,6 +471,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// Check remote version and pull if newer (on startup)
const checkRemoteVersion = useCallback(async (options?: RemoteVersionCheckOptions) => {
if (!enabled) {
return;
}
if (checkRemoteInFlightRef.current) {
return;
}
@@ -487,7 +522,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const remoteFile = inspection.remoteFile;
const remotePayload = inspection.payload;
const localPayload = buildPayloadRef.current();
const localPayload = await buildPayloadRef.current();
// If local vault is empty but cloud has data, this almost certainly
// means the user's data was lost (update, storage corruption, etc.).
@@ -501,6 +536,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
keyCount: remotePayload.keys?.length ?? 0,
proxyProfileCount: remotePayload.proxyProfiles?.length ?? 0,
snippetCount: remotePayload.snippets?.length ?? 0,
noteCount: remotePayload.notes?.length ?? 0,
});
});
setEmptyVaultConflict(null);
@@ -516,7 +552,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload, {
recordDownload: true,
});
skipNextSyncRef.current = true;
skipNextSyncHashRef.current = getSyncPayloadDataHash(remotePayload);
startupConsistent = true;
notify.success(tRef.current('sync.autoSync.restoredMessage'), tRef.current('sync.autoSync.restoredTitle'));
} else {
@@ -553,7 +589,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const wasShrinkBlocked = roundTripResultList.some((result) => result.shrinkBlocked === true);
const roundTripFullySynced = roundTripResultList.length > 0
&& roundTripResultList.every((result) => result.success);
skipNextSyncRef.current = roundTripFullySynced || wasShrinkBlocked;
skipNextSyncHashRef.current = (roundTripFullySynced || wasShrinkBlocked)
? getSyncPayloadDataHash(remotePayload)
: null;
markCurrentDataSynced = roundTripFullySynced || wasShrinkBlocked;
if (wasShrinkBlocked) {
console.warn('[AutoSync] Cloud-wins round-trip was shrink-blocked; cloud data applied locally, leaving sync blocked for user review.');
@@ -633,7 +671,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// once React commits the applied state, since we've just
// already pushed that exact payload upstream. If some provider
// failed, allow the follow-up tick to retry the applied payload.
skipNextSyncRef.current = roundTripFullySynced || wasShrinkBlocked;
skipNextSyncHashRef.current = (roundTripFullySynced || wasShrinkBlocked)
? getSyncPayloadDataHash(mergeResult.payload)
: null;
markCurrentDataSynced = roundTripFullySynced || wasShrinkBlocked;
} catch (error) {
// Non-fatal: the next user edit will drive another sync cycle.
@@ -659,7 +699,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
isInitializedRef.current = true;
}
if (markCurrentDataSynced) {
lastSyncedDataRef.current = getDataHashRef.current();
lastSyncedDataRef.current = await getDataHashRef.current();
} else {
lastSyncedDataRef.current = '';
}
@@ -683,7 +723,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// callback and restart the retry-timer's exponential backoff.
// `t` is read through tRef so locale updates don't rebuild this
// callback and re-fire the startup retry effect on unrelated renders.
}, []);
}, [enabled]);
const checkRemoteVersionRef = useRef(checkRemoteVersion);
useEffect(() => {
@@ -692,6 +732,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// Debounced auto-sync when data changes
useEffect(() => {
if (!enabled) return;
// Skip if not ready
if (!sync.hasAnyConnectedProvider || !sync.autoSyncEnabled || !sync.isUnlocked) {
return;
@@ -705,62 +746,87 @@ export const useAutoSync = (config: AutoSyncConfig) => {
return;
}
// Skip initial render
if (!isInitializedRef.current) {
isInitializedRef.current = true;
lastSyncedDataRef.current = getDataHash();
return;
}
const currentHash = getDataHash();
// After a merge, onApplyPayload changes local state which triggers
// this effect. Skip that cycle and just update the hash baseline.
if (skipNextSyncRef.current) {
skipNextSyncRef.current = false;
lastSyncedDataRef.current = currentHash;
return;
}
// Skip if data hasn't changed
if (currentHash === lastSyncedDataRef.current) {
return;
}
// Wait for the current sync to finish, then this effect will re-run
// because sync.isSyncing changed.
if (sync.isSyncing || isSyncRunningRef.current) {
return;
}
// Hold off on scheduling a new push while another window is applying
// a restore — the restore is about to land via localStorage and the
// debounce-fired syncNow would otherwise race it. The next data-
// change tick after the restore barrier clears will re-enter here.
if (isRestoreInProgress()) {
return;
}
// Don't even schedule a push while the apply-in-progress sentinel
// is held. The syncNow path re-checks and refuses too, but dropping
// the debounced schedule here avoids spinning a 3-second timer for
// every keystroke while the user is in the Restore UI working
// through recovery.
if (readInterruptedVaultApply()) {
return;
}
// Clear existing timeout
let cancelled = false;
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current);
}
// Debounce sync by 3 seconds
// Establish the initial baseline immediately. If this were delayed by
// the debounce below, an edit made right after startup could become the
// baseline and never be pushed.
if (!isInitializedRef.current) {
isInitializedRef.current = true;
void (async () => {
const currentHash = await getDataHash();
if (cancelled) return;
lastSyncedDataRef.current = currentHash;
})();
return () => {
cancelled = true;
};
}
// Debounce first, then build the expensive full-data hash. This keeps
// rapid edit bursts from serializing the whole vault on every keystroke.
syncTimeoutRef.current = setTimeout(() => {
syncNow();
syncTimeoutRef.current = null;
void (async () => {
const currentHash = await getDataHash();
if (cancelled) return;
// After a remote apply or merge, skip only that exact applied data.
// If the user edits before this timer fires, the hash differs and the
// edit still syncs normally.
const skipHash = skipNextSyncHashRef.current;
const hashDecision = resolveAutoSyncHashDecision({
currentHash,
lastSyncedHash: lastSyncedDataRef.current,
appliedSkipHash: skipHash,
});
if (hashDecision === 'skip-applied') {
if (skipNextSyncHashRef.current === skipHash) {
skipNextSyncHashRef.current = null;
}
lastSyncedDataRef.current = currentHash;
return;
}
if (hashDecision === 'unchanged') {
return;
}
// Wait for the current sync to finish, then this effect will re-run
// because sync.isSyncing changed.
if (sync.isSyncing || isSyncRunningRef.current) {
return;
}
// Hold off on scheduling a new push while another window is applying
// a restore — the restore is about to land via localStorage and the
// debounce-fired syncNow would otherwise race it. The next data-
// change tick after the restore barrier clears will re-enter here.
if (isRestoreInProgress()) {
return;
}
// Don't even schedule a push while the apply-in-progress sentinel
// is held. The syncNow path re-checks and refuses too, but dropping
// the debounced schedule here avoids spinning a 3-second timer for
// every keystroke while the user is in the Restore UI working
// through recovery.
if (readInterruptedVaultApply()) {
return;
}
const didSync = await syncNow();
if (didSync && skipHash !== null && skipNextSyncHashRef.current === skipHash) {
skipNextSyncHashRef.current = null;
}
})();
}, 3000);
return () => {
cancelled = true;
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current);
}
@@ -773,6 +839,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
getDataHash,
syncNow,
config.settingsVersion,
enabled,
bookmarksVersion,
syncableSettingsStorageVersion,
]);
@@ -784,6 +851,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// sync from Settings — the 30s/60s/90s cadence below lets a short
// outage (network blip, provider rate-limit) self-heal.
useEffect(() => {
if (!enabled) return;
if (
!sync.hasAnyConnectedProvider ||
!sync.isUnlocked ||
@@ -838,9 +906,10 @@ export const useAutoSync = (config: AutoSyncConfig) => {
cancelled = true;
if (timerId) clearTimeout(timerId);
};
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady]);
}, [enabled, sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady]);
const runRuntimeRemoteCheck = useCallback(async (options?: { force?: boolean }) => {
if (!enabled) return;
const now = Date.now();
const minIntervalMs = getRuntimeRemoteCheckIntervalMs(sync.autoSyncInterval);
if (!shouldRunRuntimeRemoteCheck({
@@ -863,6 +932,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
await checkRemoteVersion({ force: true, notifyOnFailure: false });
}, [
checkRemoteVersion,
enabled,
sync.autoSyncEnabled,
sync.autoSyncInterval,
sync.hasAnyConnectedProvider,
@@ -874,6 +944,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// another device uploads changes after our startup inspection but before
// this device edits anything locally.
useEffect(() => {
if (!enabled) return;
if (!sync.hasAnyConnectedProvider || !sync.autoSyncEnabled || !sync.isUnlocked) {
return;
}
@@ -885,6 +956,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
return () => window.clearInterval(timerId);
}, [
enabled,
runRuntimeRemoteCheck,
sync.autoSyncEnabled,
sync.autoSyncInterval,
@@ -894,6 +966,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// Also re-check when the user returns to the app or the network comes back.
useEffect(() => {
if (!enabled) return;
if (typeof window === 'undefined' || typeof document === 'undefined') return;
const handleVisibilityChange = () => {
@@ -911,16 +984,17 @@ export const useAutoSync = (config: AutoSyncConfig) => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('online', handleOnline);
};
}, [runRuntimeRemoteCheck]);
}, [enabled, runRuntimeRemoteCheck]);
// Reset check flags when provider disconnects
useEffect(() => {
if (!enabled) return;
if (!sync.hasAnyConnectedProvider) {
hasCheckedRemoteRef.current = false;
remoteCheckDoneRef.current = false;
lastRuntimeRemoteCheckAtRef.current = null;
}
}, [sync.hasAnyConnectedProvider]);
}, [enabled, sync.hasAnyConnectedProvider]);
// On unmount, release any pending empty-vault confirmation. Without
// this, an unmount mid-dialog (window close, workspace switch) leaves
+1
View File
@@ -111,6 +111,7 @@ export interface CloudSyncHook {
hostCount: number;
keyCount: number;
snippetCount: number;
noteCount: number;
identityCount: number;
portForwardingRuleCount: number;
};
+2
View File
@@ -23,6 +23,7 @@ export const getAppLevelActions = (): Set<string> => {
'nextTab',
'prevTab',
'closeTab',
'closeSession',
'newTab',
'openHosts',
'openSftp',
@@ -35,6 +36,7 @@ export const getAppLevelActions = (): Set<string> => {
'splitVertical',
'moveFocus',
'broadcast',
'togglePaneZoom',
'openLocal',
'openSettings',
]);
@@ -18,6 +18,7 @@ import {
import { logger } from "../../lib/logger";
export interface UsePortForwardingAutoStartOptions {
enabled?: boolean;
isVaultInitialized: boolean;
hosts: Host[];
keys: SSHKey[];
@@ -98,6 +99,7 @@ export const getAutoStartRuleBlockReason = (
* This hook should be called at the App level to run on app launch.
*/
export const usePortForwardingAutoStart = ({
enabled = true,
isVaultInitialized,
hosts,
keys,
@@ -202,6 +204,7 @@ export const usePortForwardingAutoStart = ({
// Set up the reconnect callback
useEffect(() => {
if (!enabled) return;
const handleReconnect = async (
ruleId: string,
onStatusChange: (status: PortForwardingRule["status"], error?: string) => void,
@@ -249,10 +252,11 @@ export const usePortForwardingAutoStart = ({
return () => {
setReconnectCallback(null);
};
}, [isHostAuthReady, resolveEffectiveHost, resolveEffectiveHosts]);
}, [enabled, isHostAuthReady, resolveEffectiveHost, resolveEffectiveHosts]);
// Auto-start rules on app launch
useEffect(() => {
if (!enabled) return;
if (autoStartExecutedRef.current) return;
if (!isVaultInitialized) return;
@@ -319,6 +323,7 @@ export const usePortForwardingAutoStart = ({
void runAutoStart();
}, [
groupConfigs,
enabled,
hosts,
identities,
isHostAuthReady,
+310 -72
View File
@@ -1,4 +1,4 @@
import { MouseEvent,useCallback,useMemo,useRef,useState } from 'react';
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';
@@ -17,17 +17,48 @@ SplitHint,
updateWorkspaceSplitSizes,
} from '../../domain/workspace';
import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } from '../../domain/terminalAppearance';
import { buildOrderedWorkTabIds } from '../app/workTabSurface';
import { buildOrderedWorkTabIds, reorderWorkTabIds } from '../app/workTabSurface';
import { activeTabStore } from './activeTabStore';
import {
closeSessionWorkspaceLayoutState,
detachSessionFromWorkspaceState,
replaceDissolvedWorkspaceTabOrder,
} from './sessionWorkspaceDetach';
import {
createCopiedTerminalSessionClone,
createSplitTerminalSessionClone,
} from './terminalConnectionReuse';
import { STORAGE_KEY_RESTORE_PREVIOUS_SESSION } from '../../infrastructure/config/storageKeys';
import {
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
localStorageAdapter,
} from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { sessionRestoreStorage } from './sessionRestoreStorage';
import {
buildAndWriteSessionRestorePayload,
createInitialRestoredSessionState,
shouldPersistSessionRestoreState,
updateRestoredSessionStatusState,
} from './sessionRestoreState';
import { resolveRestorePreviousSessionSetting } from './sessionRestoreSettings';
import type { CodingCliProviderId } from '../../domain/codingCliProviders';
import { normalizeCodingCliDynamicTitleForStorage } from '../../domain/codingCliTitleParse';
export const useSessionState = () => {
const [sessions, setSessions] = useState<TerminalSession[]>([]);
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
export const useSessionState = ({
persistSessionRestore = true,
}: {
persistSessionRestore?: boolean;
} = {}) => {
const initialRestoreState = useMemo(() => createInitialRestoredSessionState({
restoreEnabled: persistSessionRestore && resolveRestorePreviousSessionSetting(
localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION),
),
payload: persistSessionRestore ? sessionRestoreStorage.read() : null,
}), [persistSessionRestore]);
const [sessions, setSessions] = useState<TerminalSession[]>(initialRestoreState.sessions);
const [workspaces, setWorkspaces] = useState<Workspace[]>(initialRestoreState.workspaces);
// Latest workspaces snapshot for synchronous existence checks outside
// setWorkspaces updaters — React doesn't guarantee updaters run
// synchronously, so relying on a flag flipped inside them to decide
@@ -42,11 +73,189 @@ export const useSessionState = () => {
const [workspaceRenameTarget, setWorkspaceRenameTarget] = useState<Workspace | null>(null);
const [workspaceRenameValue, setWorkspaceRenameValue] = useState('');
// Tab order: stores ordered list of tab IDs (orphan session IDs and workspace IDs)
const [tabOrder, setTabOrder] = useState<string[]>([]);
const [tabOrder, setTabOrder] = useState<string[]>(initialRestoreState.tabOrder);
// Broadcast mode: stores workspace IDs that have broadcast enabled
const [broadcastWorkspaceIds, setBroadcastWorkspaceIds] = useState<Set<string>>(new Set());
// Log views: stores open log replay tabs
const [logViews, setLogViews] = useState<LogView[]>([]);
const [restorePreviousSessionRevision, setRestorePreviousSessionRevision] = useState(0);
const sessionsRef = useRef(sessions);
const tabOrderRef = useRef(tabOrder);
const scheduleSessionRestorePersistRef = useRef<() => void>(() => {});
const sessionRestoreCwdByIdRef = useRef(
new Map(
initialRestoreState.sessions
.filter((session) => Boolean(session.lastCwd))
.map((session) => [session.id, session.lastCwd as string]),
),
);
const hasSeenRestorableSessionRestoreStateRef = useRef(
persistSessionRestore && shouldPersistSessionRestoreState(
initialRestoreState.sessions,
initialRestoreState.workspaces,
initialRestoreState.tabOrder,
),
);
sessionsRef.current = sessions;
tabOrderRef.current = tabOrder;
if (persistSessionRestore && shouldPersistSessionRestoreState(sessions, workspaces, tabOrder)) {
hasSeenRestorableSessionRestoreStateRef.current = true;
}
useEffect(() => {
if (initialRestoreState.activeTabId !== 'vault') {
activeTabStore.setActiveTabId(initialRestoreState.activeTabId);
}
}, [initialRestoreState.activeTabId]);
useEffect(() => {
const handleRestorePreviousSessionChanged = (key?: string) => {
if (key !== STORAGE_KEY_RESTORE_PREVIOUS_SESSION) return;
setRestorePreviousSessionRevision((revision) => revision + 1);
};
const handleLocalStorageAdapterChanged = (event: Event) => {
const detail = (event as CustomEvent<{ key?: string }>).detail;
handleRestorePreviousSessionChanged(detail?.key);
};
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
const unsubscribeSettingsSync = netcattyBridge.get()?.onSettingsChanged?.((payload) => {
handleRestorePreviousSessionChanged(payload?.key);
});
return () => {
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
unsubscribeSettingsSync?.();
};
}, []);
useEffect(() => {
if (!persistSessionRestore) return;
const restoreEnabled = resolveRestorePreviousSessionSetting(
localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION),
);
if (!restoreEnabled) {
scheduleSessionRestorePersistRef.current = () => {};
sessionRestoreStorage.clear();
hasSeenRestorableSessionRestoreStateRef.current = false;
return;
}
let timeout: number | undefined;
const persistNow = () => {
const sessionsForRestore = sessionsRef.current.map((session) => {
const cwd = sessionRestoreCwdByIdRef.current.get(session.id);
if (cwd) {
return session.lastCwd === cwd ? session : { ...session, lastCwd: cwd };
}
if (session.lastCwd === undefined) return session;
const { lastCwd: _lastCwd, ...rest } = session;
return rest;
});
const hasRestorableState = shouldPersistSessionRestoreState(
sessionsForRestore,
workspacesRef.current,
tabOrderRef.current,
);
const clearOnEmpty = hasSeenRestorableSessionRestoreStateRef.current && !hasRestorableState;
buildAndWriteSessionRestorePayload({
restoreEnabled: resolveRestorePreviousSessionSetting(
localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION),
),
clearOnEmpty,
sessions: sessionsForRestore,
workspaces: workspacesRef.current,
tabOrder: tabOrderRef.current,
activeTabId: activeTabStore.getActiveTabId(),
storage: sessionRestoreStorage,
});
};
const schedulePersist = () => {
if (timeout !== undefined) {
window.clearTimeout(timeout);
}
timeout = window.setTimeout(() => {
timeout = undefined;
persistNow();
}, 250);
};
schedulePersist();
scheduleSessionRestorePersistRef.current = schedulePersist;
const unsubscribeActiveTab = activeTabStore.subscribeSync(schedulePersist);
const handlePageHide = () => {
if (timeout !== undefined) {
window.clearTimeout(timeout);
timeout = undefined;
}
persistNow();
};
window.addEventListener("pagehide", handlePageHide);
window.addEventListener("beforeunload", handlePageHide);
return () => {
scheduleSessionRestorePersistRef.current = () => {};
if (timeout !== undefined) {
window.clearTimeout(timeout);
}
unsubscribeActiveTab();
window.removeEventListener("pagehide", handlePageHide);
window.removeEventListener("beforeunload", handlePageHide);
};
}, [sessions, workspaces, tabOrder, restorePreviousSessionRevision, persistSessionRestore]);
const updateSessionRestoreCwd = useCallback((sessionId: string, cwd: string | null) => {
const nextCwd = cwd && cwd.trim().length > 0 ? cwd : null;
const currentCwd = sessionRestoreCwdByIdRef.current.get(sessionId) ?? null;
if (currentCwd === nextCwd) return;
if (nextCwd) {
sessionRestoreCwdByIdRef.current.set(sessionId, nextCwd);
} else {
sessionRestoreCwdByIdRef.current.delete(sessionId);
}
scheduleSessionRestorePersistRef.current();
}, []);
const updateSessionDynamicTitle = useCallback((sessionId: string, title: string | null) => {
const normalizedTitle = title ? normalizeCodingCliDynamicTitleForStorage(title) : '';
const nextTitle = normalizedTitle.length > 0 ? normalizedTitle : null;
setSessions((prev) => {
const session = prev.find((candidate) => candidate.id === sessionId);
if (!session) return prev;
if ((session.dynamicTitle ?? null) === nextTitle) return prev;
return prev.map((candidate) => {
if (candidate.id !== sessionId) return candidate;
if (!nextTitle) {
const { dynamicTitle: _removed, ...rest } = candidate;
return rest;
}
return { ...candidate, dynamicTitle: nextTitle };
});
});
}, []);
const updateSessionCodingCliProvider = useCallback((
sessionId: string,
providerId: CodingCliProviderId | null,
) => {
setSessions((prev) => {
const session = prev.find((candidate) => candidate.id === sessionId);
if (!session) return prev;
if ((session.codingCliProviderId ?? null) === providerId) return prev;
return prev.map((candidate) => {
if (candidate.id !== sessionId) return candidate;
if (!providerId) {
const { codingCliProviderId: _removed, ...rest } = candidate;
return rest;
}
return { ...candidate, codingCliProviderId: providerId };
});
});
}, []);
const createLocalTerminal = useCallback((options?: LocalTerminalOptions) => {
const sessionId = crypto.randomUUID();
@@ -70,7 +279,7 @@ export const useSessionState = () => {
}, [setActiveTabId]);
const updateSessionStatus = useCallback((sessionId: string, status: TerminalSession['status']) => {
setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s));
setSessions(prev => updateRestoredSessionStatusState(prev, sessionId, status));
}, []);
const updateSessionFontSize = useCallback((sessionId: string, fontSize: number) => {
@@ -122,33 +331,12 @@ export const useSessionState = () => {
const wsId = targetSession?.workspaceId;
setWorkspaces(prevWorkspaces => {
let removedWorkspaceId: string | null = null;
let nextWorkspaces = prevWorkspaces;
let dissolvedWorkspaceId: string | null = null;
let lastRemainingSessionId: string | null = null;
if (wsId) {
nextWorkspaces = prevWorkspaces
.map(ws => {
if (ws.id !== wsId) return ws;
const pruned = pruneWorkspaceNode(ws.root, sessionId);
if (!pruned) {
removedWorkspaceId = ws.id;
return null;
}
// Check if only 1 session remains - dissolve workspace
const remainingSessionIds = collectSessionIds(pruned);
if (remainingSessionIds.length === 1) {
dissolvedWorkspaceId = ws.id;
lastRemainingSessionId = remainingSessionIds[0];
return null;
}
return { ...ws, root: pruned };
})
.filter((ws): ws is Workspace => Boolean(ws));
}
const {
workspaces: nextWorkspaces,
removedWorkspaceId,
dissolvedWorkspaceId,
lastRemainingSessionId,
} = closeSessionWorkspaceLayoutState(prevWorkspaces, wsId, sessionId);
const remainingSessions = prevSessions.filter(s => s.id !== sessionId);
const fallbackWorkspace = nextWorkspaces[nextWorkspaces.length - 1];
@@ -162,6 +350,14 @@ export const useSessionState = () => {
return 'vault';
};
if (dissolvedWorkspaceId && lastRemainingSessionId) {
setTabOrder(prevTabOrder => replaceDissolvedWorkspaceTabOrder(
prevTabOrder,
dissolvedWorkspaceId,
[lastRemainingSessionId],
));
}
if (dissolvedWorkspaceId && currentActiveTabId === dissolvedWorkspaceId) {
setActiveTabId(getFallback());
} else if (currentActiveTabId === sessionId) {
@@ -205,20 +401,39 @@ export const useSessionState = () => {
const target = prevSessions.find(s => s.id === sessionId);
if (target) {
setSessionRenameTarget(target);
setSessionRenameValue(target.hostLabel);
setSessionRenameValue(target.customName || target.hostLabel);
}
return prevSessions;
});
}, []);
const submitSessionRename = useCallback(() => {
const renameSessionInline = useCallback((sessionId: string, name: string) => {
const trimmed = name.trim();
if (!trimmed) return;
setSessions(prev => prev.map(s => (
s.id === sessionId ? { ...s, customName: trimmed, hostLabel: trimmed } : s
)));
}, []);
const submitSessionRename = useCallback((sessionId?: string, name?: string) => {
if (sessionId !== undefined && name !== undefined) {
const trimmed = name.trim();
if (!trimmed) return;
setSessions(prev => prev.map(s => (
s.id === sessionId ? { ...s, customName: trimmed, hostLabel: trimmed } : s
)));
return;
}
setSessionRenameValue(prevValue => {
const name = prevValue.trim();
if (!name) return prevValue;
const trimmed = prevValue.trim();
if (!trimmed) return prevValue;
setSessionRenameTarget(prevTarget => {
if (!prevTarget) return prevTarget;
setSessions(prev => prev.map(s => s.id === prevTarget.id ? { ...s, hostLabel: name } : s));
setSessions(prev => prev.map(s => (
s.id === prevTarget.id ? { ...s, customName: trimmed, hostLabel: trimmed } : s
)));
return null;
});
@@ -888,6 +1103,50 @@ export const useSessionState = () => {
[getOrderedWorkTabs],
);
const removeSessionFromWorkspace = useCallback((
sessionId: string,
tabInsertionTarget?: {
tabId: string;
position: 'before' | 'after';
additionalTabIds?: readonly string[];
},
) => {
setSessions(prevSessions => {
const result = detachSessionFromWorkspaceState({
sessions: prevSessions,
workspaces: workspacesRef.current,
sessionId,
});
if (!result.changed) return prevSessions;
setWorkspaces(result.workspaces);
setTabOrder(prevTabOrder => {
const replacedOrder = replaceDissolvedWorkspaceTabOrder(
prevTabOrder,
result.dissolvedWorkspaceId,
result.replacementTabIds,
);
if (!tabInsertionTarget) return replacedOrder;
const allTabIds = [
...result.sessions.filter(s => !s.workspaceId).map(s => s.id),
...result.workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
...(tabInsertionTarget.additionalTabIds ?? []),
];
return reorderWorkTabIds(
replacedOrder,
allTabIds,
sessionId,
tabInsertionTarget.tabId,
tabInsertionTarget.position,
);
});
if (result.activeTabId) setActiveTabId(result.activeTabId);
return result.sessions;
});
}, [logViews, setActiveTabId]);
const reorderTabs = useCallback((
draggedId: string,
targetId: string,
@@ -896,39 +1155,13 @@ export const useSessionState = () => {
) => {
if (draggedId === targetId) return;
setTabOrder(prevTabOrder => {
const allTabIds = [...baseWorkTabIds, ...additionalTabIds];
const allTabIdSet = new Set(allTabIds);
// Build current effective order: existing order + new tabs at end
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
const orderedIdSet = new Set(orderedIds);
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
const currentOrder = [...orderedIds, ...newIds];
const draggedIndex = currentOrder.indexOf(draggedId);
const targetIndex = currentOrder.indexOf(targetId);
if (draggedIndex === -1 || targetIndex === -1) return prevTabOrder;
// Remove dragged item first
currentOrder.splice(draggedIndex, 1);
// Calculate new target index (adjusted after removal)
let newTargetIndex = targetIndex;
if (draggedIndex < targetIndex) {
newTargetIndex -= 1;
}
// Insert at the correct position
if (position === 'after') {
newTargetIndex += 1;
}
currentOrder.splice(newTargetIndex, 0, draggedId);
return currentOrder;
});
setTabOrder(prevTabOrder => reorderWorkTabIds(
prevTabOrder,
[...baseWorkTabIds, ...additionalTabIds],
draggedId,
targetId,
position,
));
}, [baseWorkTabIds]);
return {
@@ -942,6 +1175,7 @@ export const useSessionState = () => {
sessionRenameValue,
setSessionRenameValue,
startSessionRename,
renameSessionInline,
submitSessionRename,
resetSessionRename,
workspaceRenameTarget,
@@ -962,6 +1196,7 @@ export const useSessionState = () => {
createWorkspaceFromTargets,
createWorkspaceFromSessions,
addSessionToWorkspace,
removeSessionFromWorkspace,
appendHostToWorkspace,
appendLocalTerminalToWorkspace,
updateSplitSizes,
@@ -985,5 +1220,8 @@ export const useSessionState = () => {
// Copy session
copySession,
createSessionFromCloneSource,
updateSessionRestoreCwd,
updateSessionDynamicTitle,
updateSessionCodingCliProvider,
};
};
+144 -13
View File
@@ -32,10 +32,13 @@ import {
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
STORAGE_KEY_EDITOR_WORD_WRAP,
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_RESTORE_PREVIOUS_SESSION,
STORAGE_KEY_RESTORE_TERMINAL_CWD,
STORAGE_KEY_SESSION_LOGS_DIR,
STORAGE_KEY_SESSION_LOGS_FORMAT,
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
@@ -63,10 +66,11 @@ import { TERMINAL_THEME_AUTO } from '../../domain/terminalAppearance';
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
import { getUiThemeById } from '../../infrastructure/config/uiThemes';
import { DEFAULT_UI_FONT_ID } from '../../infrastructure/config/uiFonts';
import { DEFAULT_UI_FONT_ID, withWindowsEmojiFallback } from '../../infrastructure/config/uiFonts';
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { resolveSftpTransferConcurrency } from './sftp/transferConcurrency';
import {
DEFAULT_ACCENT_MODE,
DEFAULT_CUSTOM_ACCENT,
@@ -92,6 +96,7 @@ import {
DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
DEFAULT_DISABLE_TERMINAL_FONT_ZOOM,
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
DEFAULT_SSH_DEEP_LINK_ENABLED,
DEFAULT_TERMINAL_THEME,
DEFAULT_THEME,
DEFAULT_WINDOW_OPACITY,
@@ -108,13 +113,17 @@ import {
readStoredString,
serializeTerminalSettings,
} from './settingsStateDefaults';
import { resolveRestorePreviousSessionSetting, resolveRestoreTerminalCwdSetting } from './sessionRestoreSettings';
import { sessionRestoreStorage } from './sessionRestoreStorage';
import { useSettingsStorageSync } from './settingsStorageSync';
import { useSettingsIpcSync } from './settingsIpcSync';
import { resolveCurrentTerminalTheme } from './settingsTerminalTheme';
import { useSystemSettingsEffects } from './systemSettingsEffects';
import { applyCustomCssToDocument } from '../../lib/customCss';
export const useSettingsState = () => {
export const useSettingsState = (options: { enableSettingsSync?: boolean; enableSystemEffects?: boolean } = {}) => {
const enableSettingsSync = options.enableSettingsSync !== false;
const enableSystemEffects = options.enableSystemEffects !== false;
const initialCustomKeyBindingsRecord =
parseCustomKeyBindingsStorageRecord(localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS));
const uiFontsLoaded = useUIFontsLoaded();
@@ -250,9 +259,18 @@ export const useSettingsState = () => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
return stored ?? DEFAULT_DISABLE_TERMINAL_FONT_ZOOM;
});
const [restorePreviousSession, setRestorePreviousSessionState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION);
return resolveRestorePreviousSessionSetting(stored);
});
const [restoreTerminalCwd, setRestoreTerminalCwdState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_TERMINAL_CWD);
return resolveRestoreTerminalCwdSetting(stored);
});
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
return resolveSftpTransferConcurrency(() =>
localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
);
});
// Editor Settings
@@ -282,6 +300,10 @@ export const useSettingsState = () => {
const stored = readStoredString(STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED);
return stored === 'true' ? true : DEFAULT_SSH_DEBUG_LOGS_ENABLED;
});
const [sshDeepLinkEnabled, setSshDeepLinkEnabledState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SSH_DEEP_LINK_ENABLED);
return stored ?? DEFAULT_SSH_DEEP_LINK_ENABLED;
});
// Global Toggle Window Settings (Quake Mode)
const [toggleWindowHotkey, setToggleWindowHotkey] = useState<string>(() => {
@@ -328,6 +350,9 @@ export const useSettingsState = () => {
const customKeyBindingsOriginRef = useRef(initialCustomKeyBindingsRecord?.origin || 'legacy');
const customKeyBindingsLocalOriginRef = useRef(createCustomKeyBindingsSyncOrigin());
const customKeyBindingsMutationSourceRef = useRef<'local' | 'incoming'>('local');
const sshDeepLinkMutationSourceRef = useRef<'local' | 'incoming'>('local');
const sshDeepLinkEnabledRef = useRef(sshDeepLinkEnabled);
const sshDeepLinkSetRequestIdRef = useRef(0);
// 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.
@@ -411,14 +436,24 @@ export const useSettingsState = () => {
});
}, []);
const applyIncomingSshDeepLinkEnabled = useCallback((enabled: boolean) => {
sshDeepLinkSetRequestIdRef.current += 1;
setSshDeepLinkEnabledState((prev) => {
if (prev === enabled) return prev;
sshDeepLinkMutationSourceRef.current = 'incoming';
return enabled;
});
}, []);
// Helper to notify other windows about settings changes via IPC
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
if (!enableSettingsSync) return;
try {
netcattyBridge.get()?.notifySettingsChanged?.({ key, value });
} catch {
// ignore - bridge may not be available
}
}, []);
}, [enableSettingsSync]);
const setSftpTransferConcurrency = useCallback((value: number) => {
@@ -529,6 +564,8 @@ export const useSettingsState = () => {
if (storedSshDebugLogsEnabled === 'true' || storedSshDebugLogsEnabled === 'false') {
setSshDebugLogsEnabled(storedSshDebugLogsEnabled === 'true');
}
const storedSshDeepLinkEnabled = localStorageAdapter.readBoolean(STORAGE_KEY_SSH_DEEP_LINK_ENABLED);
applyIncomingSshDeepLinkEnabled(storedSshDeepLinkEnabled ?? DEFAULT_SSH_DEEP_LINK_ENABLED);
// SFTP
const storedDblClick = readStoredString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
@@ -559,6 +596,10 @@ export const useSettingsState = () => {
setShellOnlyTabNumberShortcutsState(storedShellOnlyTabNumberShortcuts ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
const storedDisableTerminalFontZoom = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
setDisableTerminalFontZoomState(storedDisableTerminalFontZoom ?? DEFAULT_DISABLE_TERMINAL_FONT_ZOOM);
const storedRestorePreviousSession = localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION);
setRestorePreviousSessionState(resolveRestorePreviousSessionSetting(storedRestorePreviousSession));
const storedRestoreTerminalCwd = localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_TERMINAL_CWD);
setRestoreTerminalCwdState(resolveRestoreTerminalCwdSetting(storedRestoreTerminalCwd));
// Workspace focus style
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
@@ -566,7 +607,7 @@ export const useSettingsState = () => {
// Custom terminal themes
customThemeStore.loadFromStorage();
}, [applyIncomingCustomKeyBindings, syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
}, [applyIncomingCustomKeyBindings, applyIncomingSshDeepLinkEnabled, syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
useLayoutEffect(() => {
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
@@ -613,7 +654,7 @@ export const useSettingsState = () => {
// Re-run when fonts finish loading to get correct family for local fonts
useLayoutEffect(() => {
const font = uiFontStore.getFontById(uiFontFamilyId);
document.documentElement.style.setProperty('--font-sans', font.family);
document.documentElement.style.setProperty('--font-sans', withWindowsEmojiFallback(font.family));
localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
// Fix 1: Skip IPC broadcast on initial mount
if (persistMountedRef.current) {
@@ -622,6 +663,7 @@ export const useSettingsState = () => {
}, [uiFontFamilyId, uiFontsLoaded, notifySettingsChanged]);
useSettingsIpcSync({
enabled: enableSettingsSync,
syncAppearanceFromStorage,
syncCustomCssFromStorage,
setUiLanguage,
@@ -639,6 +681,7 @@ export const useSettingsState = () => {
setSessionLogsFormat,
setSessionLogsTimestampsEnabled,
setSshDebugLogsEnabled,
setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled,
setHotkeyScheme,
applyIncomingCustomKeyBindings,
setIsHotkeyRecordingState,
@@ -651,10 +694,13 @@ export const useSettingsState = () => {
setWorkspaceFocusStyleState,
setShowHostTreeSidebarState,
setDisableTerminalFontZoomState,
setRestorePreviousSessionState,
setRestoreTerminalCwdState,
setSftpTransferConcurrencyState,
});
useEffect(() => {
if (!enableSettingsSync) return;
const bridge = netcattyBridge.get();
if (!bridge?.onLanguageChanged) return;
const unsubscribe = bridge.onLanguageChanged((language) => {
@@ -669,16 +715,17 @@ export const useSettingsState = () => {
// ignore
}
};
}, []);
}, [enableSettingsSync]);
useSettingsStorageSync({
enabled: enableSettingsSync,
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
@@ -686,8 +733,8 @@ export const useSettingsState = () => {
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled,
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
});
@@ -814,6 +861,23 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM, enabled);
}, [notifySettingsChanged]);
const setRestorePreviousSession = useCallback((enabled: boolean) => {
setRestorePreviousSessionState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION, enabled);
if (!enabled) {
sessionRestoreStorage.clear();
}
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_RESTORE_PREVIOUS_SESSION, enabled);
}, [notifySettingsChanged]);
const setRestoreTerminalCwd = useCallback((enabled: boolean) => {
setRestoreTerminalCwdState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_RESTORE_TERMINAL_CWD, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_RESTORE_TERMINAL_CWD, enabled);
}, [notifySettingsChanged]);
// Apply and persist custom CSS
useEffect(() => {
applyCustomCssToDocument(customCSS);
@@ -903,7 +967,68 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED, sshDebugLogsEnabled);
}, [sshDebugLogsEnabled, notifySettingsChanged]);
useEffect(() => {
sshDeepLinkEnabledRef.current = sshDeepLinkEnabled;
}, [sshDeepLinkEnabled]);
useEffect(() => {
let cancelled = false;
const requestIdAtStart = sshDeepLinkSetRequestIdRef.current;
const bridge = netcattyBridge.get();
if (!bridge?.getSshDeepLinkEnabled) return;
void bridge.getSshDeepLinkEnabled().then((enabled) => {
if (cancelled || typeof enabled !== 'boolean') return;
if (sshDeepLinkSetRequestIdRef.current !== requestIdAtStart) return;
sshDeepLinkMutationSourceRef.current = 'incoming';
setSshDeepLinkEnabledState((prev) => (prev === enabled ? prev : enabled));
localStorageAdapter.writeBoolean(STORAGE_KEY_SSH_DEEP_LINK_ENABLED, enabled);
}).catch(() => {
// The renderer can still use its cached setting when the bridge is unavailable.
});
return () => {
cancelled = true;
};
}, []);
const setSshDeepLinkEnabled = useCallback((enabled: boolean) => {
const previous = sshDeepLinkEnabledRef.current;
const requestId = sshDeepLinkSetRequestIdRef.current + 1;
sshDeepLinkSetRequestIdRef.current = requestId;
sshDeepLinkMutationSourceRef.current = 'local';
setSshDeepLinkEnabledState(enabled);
const bridge = netcattyBridge.get();
if (!bridge?.setSshDeepLinkEnabled) return;
void bridge.setSshDeepLinkEnabled(enabled).then((result) => {
if (sshDeepLinkSetRequestIdRef.current !== requestId) return;
const success = typeof result === 'object' ? result.success : result;
if (success !== false) return;
const finalEnabled = typeof result === 'object' && typeof result.enabled === 'boolean'
? result.enabled
: previous;
sshDeepLinkMutationSourceRef.current = 'incoming';
setSshDeepLinkEnabledState(finalEnabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_SSH_DEEP_LINK_ENABLED, finalEnabled);
}).catch(() => {
if (sshDeepLinkSetRequestIdRef.current !== requestId) return;
sshDeepLinkMutationSourceRef.current = 'incoming';
setSshDeepLinkEnabledState(previous);
localStorageAdapter.writeBoolean(STORAGE_KEY_SSH_DEEP_LINK_ENABLED, previous);
});
}, []);
useEffect(() => {
localStorageAdapter.writeBoolean(STORAGE_KEY_SSH_DEEP_LINK_ENABLED, sshDeepLinkEnabled);
if (sshDeepLinkMutationSourceRef.current === 'incoming') {
sshDeepLinkMutationSourceRef.current = 'local';
return;
}
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SSH_DEEP_LINK_ENABLED, sshDeepLinkEnabled);
}, [sshDeepLinkEnabled, notifySettingsChanged]);
useSystemSettingsEffects({
enabled: enableSystemEffects,
toggleWindowHotkey,
globalHotkeyEnabled,
closeToTray,
@@ -1056,6 +1181,10 @@ export const useSettingsState = () => {
setShellOnlyTabNumberShortcuts,
disableTerminalFontZoom,
setDisableTerminalFontZoom,
restorePreviousSession,
setRestorePreviousSession,
restoreTerminalCwd,
setRestoreTerminalCwd,
sftpTransferConcurrency,
setSftpTransferConcurrency,
// Editor Settings
@@ -1076,6 +1205,8 @@ export const useSettingsState = () => {
setSessionLogsTimestampsEnabled,
sshDebugLogsEnabled,
setSshDebugLogsEnabled,
sshDeepLinkEnabled,
setSshDeepLinkEnabled,
// Global Toggle Window (Quake Mode)
toggleWindowHotkey,
setToggleWindowHotkey,
@@ -1101,7 +1232,7 @@ export const useSettingsState = () => {
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
]),
};
};
@@ -0,0 +1,34 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
test("ssh deep link native startup sync cannot overwrite a later setting change", () => {
const source = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
const getEnabledIndex = source.indexOf("bridge.getSshDeepLinkEnabled()");
const requestIdCaptureIndex = source.lastIndexOf(
"const requestIdAtStart = sshDeepLinkSetRequestIdRef.current",
getEnabledIndex,
);
const staleGuardIndex = source.indexOf(
"if (sshDeepLinkSetRequestIdRef.current !== requestIdAtStart) return;",
getEnabledIndex,
);
assert.notEqual(getEnabledIndex, -1);
assert.notEqual(requestIdCaptureIndex, -1);
assert.notEqual(staleGuardIndex, -1);
assert.ok(requestIdCaptureIndex < getEnabledIndex);
assert.ok(staleGuardIndex > getEnabledIndex);
});
test("incoming ssh deep link setting changes invalidate pending local responses", () => {
const source = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
const setterIndex = source.indexOf("const applyIncomingSshDeepLinkEnabled = useCallback");
const incrementIndex = source.indexOf("sshDeepLinkSetRequestIdRef.current += 1;", setterIndex);
const stateUpdateIndex = source.indexOf("setSshDeepLinkEnabledState((prev)", setterIndex);
assert.notEqual(setterIndex, -1);
assert.notEqual(incrementIndex, -1);
assert.notEqual(stateUpdateIndex, -1);
assert.ok(incrementIndex < stateUpdateIndex);
});
+5 -1
View File
@@ -14,6 +14,7 @@ export const useStoredNumber = (
clamp?: { min: number; max: number },
) => {
const [value, setValue] = useState<number>(() => {
if (typeof globalThis.localStorage === "undefined") return fallback;
const stored = localStorageAdapter.readNumber(storageKey);
if (stored === null) return fallback;
if (clamp) return Math.max(clamp.min, Math.min(clamp.max, stored));
@@ -21,7 +22,10 @@ export const useStoredNumber = (
});
const persist = useCallback(
(v: number) => localStorageAdapter.writeNumber(storageKey, v),
(v: number) => {
if (typeof globalThis.localStorage === "undefined") return false;
return localStorageAdapter.writeNumber(storageKey, v);
},
[storageKey],
);
+102
View File
@@ -0,0 +1,102 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
createStoredStringSyncHandlers,
readStoredStringValue,
resolveStoredStringUpdate,
} from "./useStoredString.ts";
type TestMode = "edit" | "preview";
const isTestMode = (value: string | null): value is TestMode =>
value === "edit" || value === "preview";
function installLocalStorage() {
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
const backing = new Map<string, string>();
const storage: Storage = {
get length() {
return backing.size;
},
clear() {
backing.clear();
},
getItem(key: string) {
return backing.get(key) ?? null;
},
key(index: number) {
return Array.from(backing.keys())[index] ?? null;
},
removeItem(key: string) {
backing.delete(key);
},
setItem(key: string, value: string) {
backing.set(key, value);
},
};
Object.defineProperty(globalThis, "localStorage", {
value: storage,
configurable: true,
});
return {
storage,
restore() {
if (previousLocalStorage) {
Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
return;
}
Reflect.deleteProperty(globalThis, "localStorage");
},
};
}
test("stored string sync handlers refresh from same-window and browser storage events", (t) => {
const env = installLocalStorage();
t.after(() => env.restore());
const storageKey = "netcatty:test-mode";
const syncedValues: TestMode[] = [];
const handlers = createStoredStringSyncHandlers<TestMode>({
storageKey,
fallback: "edit",
isAllowedValue: isTestMode,
onValue: (value) => syncedValues.push(value),
});
env.storage.setItem(storageKey, "preview");
handlers.handleAdapterChange({ detail: { key: storageKey } } as CustomEvent<{ key: string }>);
assert.deepEqual(syncedValues, ["preview"]);
env.storage.setItem(storageKey, "invalid");
handlers.handleBrowserStorage({ type: "storage", key: storageKey } as StorageEvent);
assert.deepEqual(syncedValues, ["preview", "edit"]);
env.storage.setItem(storageKey, "preview");
handlers.handleAdapterChange({ detail: { key: "other-key" } } as CustomEvent<{ key: string }>);
assert.deepEqual(syncedValues, ["preview", "edit"]);
});
test("stored string helpers read fallback and resolve updater-style toggles", (t) => {
const env = installLocalStorage();
t.after(() => env.restore());
const storageKey = "netcatty:test-mode";
assert.equal(readStoredStringValue(storageKey, "edit", isTestMode), "edit");
env.storage.setItem(storageKey, "preview");
assert.equal(readStoredStringValue(storageKey, "edit", isTestMode), "preview");
env.storage.setItem(storageKey, "invalid");
assert.equal(readStoredStringValue(storageKey, "edit", isTestMode), "edit");
assert.equal(resolveStoredStringUpdate<TestMode>("edit", "preview"), "preview");
assert.equal(
resolveStoredStringUpdate<TestMode>("preview", (currentValue) => (
currentValue === "edit" ? "preview" : "edit"
)),
"edit",
);
});
+99 -22
View File
@@ -1,28 +1,105 @@
import { useEffect, useState } from "react";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import { useCallback, useEffect, useState } from "react";
import {
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
localStorageAdapter,
} from "../../infrastructure/persistence/localStorageAdapter";
/**
* Hook for persisting a string value to localStorage.
* @param storageKey - The key to use for localStorage
* @param fallback - The default value if no stored value exists
* @param validate - Optional function to validate stored value; returns fallback if invalid
* @returns A tuple of [value, setValue] similar to useState
*/
export const useStoredString = <T extends string = string>(
storageKey: string,
fallback: T,
validate?: (value: string) => value is T,
type StoredStringSetter<T extends string> = (nextValue: T | ((currentValue: T) => T)) => void;
const canUseLocalStorage = () => typeof globalThis.localStorage !== "undefined";
export const readStoredStringValue = <T extends string>(
storageKey: string,
fallback: T,
isAllowedValue: (value: string | null) => value is T,
): T => {
if (!canUseLocalStorage()) return fallback;
const stored = localStorageAdapter.readString(storageKey);
return isAllowedValue(stored) ? stored : fallback;
};
export const resolveStoredStringUpdate = <T extends string>(
currentValue: T,
nextValue: T | ((currentValue: T) => T),
): T => (typeof nextValue === "function" ? nextValue(currentValue) : nextValue);
export const shouldSyncStoredStringEvent = (storageKey: string, event: Event): boolean => {
const changedKey = event.type === "storage"
? (event as StorageEvent).key
: (event as CustomEvent<{ key?: string }>).detail?.key;
return changedKey === storageKey;
};
export const createStoredStringSyncHandlers = <T extends string>({
storageKey,
fallback,
isAllowedValue,
onValue,
}: {
storageKey: string;
fallback: T;
isAllowedValue: (value: string | null) => value is T;
onValue: (value: T) => void;
}) => {
const syncFromStorage = () => {
onValue(readStoredStringValue(storageKey, fallback, isAllowedValue));
};
return {
handleAdapterChange(event: Event) {
if (shouldSyncStoredStringEvent(storageKey, event)) syncFromStorage();
},
handleBrowserStorage(event: Event) {
if (shouldSyncStoredStringEvent(storageKey, event)) syncFromStorage();
},
};
};
export const useStoredString = <T extends string>(
storageKey: string,
fallback: T,
isAllowedValue: (value: string | null) => value is T,
) => {
const [value, setValue] = useState<T>(() => {
const stored = localStorageAdapter.readString(storageKey);
if (stored === null) return fallback;
if (validate) return validate(stored) ? stored : fallback;
return stored as T;
const [value, setValue] = useState<T>(() => readStoredStringValue(
storageKey,
fallback,
isAllowedValue,
));
const setAndPersist = useCallback<StoredStringSetter<T>>((nextValue) => {
setValue((currentValue) => {
const resolvedValue = resolveStoredStringUpdate(currentValue, nextValue);
if (canUseLocalStorage()) {
localStorageAdapter.writeString(storageKey, resolvedValue);
}
return resolvedValue;
});
}, [storageKey]);
useEffect(() => {
const target = globalThis as typeof globalThis & {
addEventListener?: (type: string, listener: EventListener) => void;
removeEventListener?: (type: string, listener: EventListener) => void;
};
if (typeof target.addEventListener !== "function") return;
const {
handleAdapterChange,
handleBrowserStorage,
} = createStoredStringSyncHandlers({
storageKey,
fallback,
isAllowedValue,
onValue: setValue,
});
useEffect(() => {
localStorageAdapter.writeString(storageKey, value);
}, [storageKey, value]);
target.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleAdapterChange);
target.addEventListener("storage", handleBrowserStorage);
return () => {
target.removeEventListener?.(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleAdapterChange);
target.removeEventListener?.("storage", handleBrowserStorage);
};
}, [fallback, isAllowedValue, storageKey]);
return [value, setValue] as const;
return [value, setAndPersist] as const;
};
+1 -1
View File
@@ -74,7 +74,7 @@ export const useTerminalBackend = () => {
return bridge.execCommand(options);
}, []);
const writeToSession = useCallback((sessionId: string, data: string, options?: { automated?: boolean }) => {
const writeToSession = useCallback((sessionId: string, data: string, options?: Parameters<NonNullable<NetcattyBridge["writeToSession"]>>[2]) => {
const bridge = netcattyBridge.get();
bridge?.writeToSession?.(sessionId, data, options);
}, []);
+19 -9
View File
@@ -53,7 +53,8 @@ export interface UseUpdateCheckResult {
* - Respects dismissed version to avoid nagging
* - Provides manual check capability
*/
export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; onNeedsSave?: () => void }): UseUpdateCheckResult {
export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; enabled?: boolean; onNeedsSave?: () => void }): UseUpdateCheckResult {
const enabled = options?.enabled !== false;
// Accept auto-update toggle from the caller (e.g. useSettingsState) so it
// reacts immediately in the same window. Falls back to reading localStorage
// when no caller provides the value (e.g. in non-settings contexts).
@@ -115,6 +116,7 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; onNeedsS
// Get current app version
useEffect(() => {
if (!enabled) return;
const loadVersion = async () => {
try {
const bridge = netcattyBridge.get();
@@ -127,12 +129,13 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; onNeedsS
}
};
void loadVersion();
}, []);
}, [enabled]);
// Hydrate auto-download status from the main process so windows opened
// after the download started (e.g. Settings) immediately reflect the
// current state instead of showing stale 'idle'.
useEffect(() => {
if (!enabled) return;
const bridge = netcattyBridge.get();
void bridge?.getUpdateStatus?.().then((snapshot) => {
if (!snapshot || snapshot.status === 'idle') return;
@@ -173,11 +176,12 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; onNeedsS
};
});
});
}, []);
}, [enabled]);
// Subscribe to electron-updater auto-download IPC events.
// These fire automatically when autoDownload=true in the main process.
useEffect(() => {
if (!enabled) return;
const bridge = netcattyBridge.get();
// When electron-updater confirms no update in its feed, don't write
@@ -276,7 +280,7 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; onNeedsS
cleanupError?.();
cleanupNeedsSave?.();
};
}, []);
}, [enabled]);
const performCheck = useCallback(async (currentVersion: string): Promise<UpdateCheckResult | null> => {
debugLog('performCheck called', { currentVersion, IS_UPDATE_DEMO_MODE });
@@ -372,6 +376,8 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; onNeedsS
}, []);
const checkNow = useCallback(async (): Promise<UpdateCheckResult | null> => {
if (!enabled) return null;
// Prevent concurrent checks (performCheck owns isCheckingRef)
if (isCheckingRef.current) {
debugLog('checkNow: already checking, skipping');
@@ -499,7 +505,7 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; onNeedsS
}
return result;
}, [performCheck]);
}, [enabled, performCheck]);
const dismissUpdate = useCallback(() => {
if (updateState.latestRelease?.version) {
@@ -529,10 +535,12 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; onNeedsS
}, [updateState.latestRelease]);
const installUpdate = useCallback(() => {
if (!enabled) return;
netcattyBridge.get()?.installUpdate?.();
}, []);
}, [enabled]);
const startDownload = useCallback(async () => {
if (!enabled) return;
if (autoDownloadStatusRef.current === 'downloading' || autoDownloadStatusRef.current === 'ready') return;
const bridge = netcattyBridge.get();
try {
@@ -570,10 +578,11 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; onNeedsS
downloadError: 'Download failed',
}));
});
}, [openReleasePage]);
}, [enabled, openReleasePage]);
// Startup check with delay - runs once on mount
useEffect(() => {
if (!enabled) return;
debugLog('Startup check effect mounted, IS_UPDATE_DEMO_MODE:', IS_UPDATE_DEMO_MODE);
// In demo mode, trigger check immediately after a short delay
@@ -594,10 +603,11 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; onNeedsS
// Normal mode: wait for version to be loaded, then check
// This is handled by the version-dependent effect below
}, [performCheck]);
}, [enabled, performCheck]);
// Normal mode startup check - depends on currentVersion
useEffect(() => {
if (!enabled) return;
// Skip in demo mode (handled above)
if (IS_UPDATE_DEMO_MODE) {
return;
@@ -703,7 +713,7 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; onNeedsS
clearTimeout(startupCheckTimeoutRef.current);
}
};
}, [updateState.currentVersion, autoUpdateEnabled, performCheck]);
}, [enabled, updateState.currentVersion, autoUpdateEnabled, performCheck]);
return {
updateState,
+59 -1
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { migrateHostsFromLegacyLineTimestamps, normalizeDistroId, sanitizeHost } from "../../domain/host";
import { sanitizeGroupConfig } from "../../domain/groupConfig";
import { normalizeKnownHosts } from "../../domain/knownHosts";
import { normalizeNoteGroups, normalizeVaultNotes } from "../../domain/notes";
import {
ConnectionLog,
GroupConfig,
@@ -14,6 +15,7 @@ import {
ShellHistoryEntry,
Snippet,
SSHKey,
VaultNote,
} from "../../domain/models";
import {
INITIAL_HOSTS,
@@ -29,6 +31,8 @@ import {
STORAGE_KEY_KNOWN_HOSTS,
STORAGE_KEY_LEGACY_KEYS,
STORAGE_KEY_MANAGED_SOURCES,
STORAGE_KEY_NOTE_GROUPS,
STORAGE_KEY_NOTES,
STORAGE_KEY_PROXY_PROFILES,
STORAGE_KEY_SHELL_HISTORY,
STORAGE_KEY_SNIPPET_PACKAGES,
@@ -60,6 +64,8 @@ type ExportableVaultData = {
snippets: Snippet[];
customGroups: string[];
snippetPackages?: string[];
notes?: VaultNote[];
noteGroups?: string[];
knownHosts?: KnownHost[];
groupConfigs?: GroupConfig[];
};
@@ -151,6 +157,8 @@ export const useVaultState = () => {
const [snippets, setSnippets] = useState<Snippet[]>([]);
const [customGroups, setCustomGroups] = useState<string[]>([]);
const [snippetPackages, setSnippetPackages] = useState<string[]>([]);
const [notes, setNotes] = useState<VaultNote[]>([]);
const [noteGroups, setNoteGroups] = useState<string[]>([]);
const [knownHosts, setKnownHosts] = useState<KnownHost[]>([]);
const [shellHistory, setShellHistory] = useState<ShellHistoryEntry[]>([]);
const [connectionLogs, setConnectionLogs] = useState<ConnectionLog[]>([]);
@@ -264,6 +272,18 @@ export const useVaultState = () => {
localStorageAdapter.write(STORAGE_KEY_SNIPPET_PACKAGES, data);
}, []);
const updateNotes = useCallback((data: Partial<VaultNote>[]) => {
const cleaned = normalizeVaultNotes(data);
setNotes(cleaned);
localStorageAdapter.write(STORAGE_KEY_NOTES, cleaned);
}, []);
const updateNoteGroups = useCallback((data: unknown) => {
const cleaned = normalizeNoteGroups(data);
setNoteGroups(cleaned);
localStorageAdapter.write(STORAGE_KEY_NOTE_GROUPS, cleaned);
}, []);
const updateCustomGroups = useCallback((data: string[]) => {
setCustomGroups(data);
localStorageAdapter.write(STORAGE_KEY_GROUPS, data);
@@ -329,6 +349,8 @@ export const useVaultState = () => {
updateProxyProfiles([]);
updateSnippets([]);
updateSnippetPackages([]);
updateNotes([]);
updateNoteGroups([]);
updateCustomGroups([]);
updateKnownHosts([]);
updateManagedSources([]);
@@ -341,6 +363,8 @@ export const useVaultState = () => {
updateProxyProfiles,
updateSnippets,
updateSnippetPackages,
updateNotes,
updateNoteGroups,
updateCustomGroups,
updateKnownHosts,
updateManagedSources,
@@ -569,6 +593,8 @@ export const useVaultState = () => {
const savedSnippetPackages = localStorageAdapter.read<string[]>(
STORAGE_KEY_SNIPPET_PACKAGES,
);
const savedNotes = localStorageAdapter.read<VaultNote[]>(STORAGE_KEY_NOTES);
const savedNoteGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_NOTE_GROUPS);
if (savedSnippets) {
const orderedSnippets = normalizeVaultOrder(savedSnippets);
@@ -579,6 +605,16 @@ export const useVaultState = () => {
if (savedGroups) setCustomGroups(savedGroups);
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
if (savedNotes) {
const cleanedNotes = normalizeVaultNotes(savedNotes);
setNotes(cleanedNotes);
localStorageAdapter.write(STORAGE_KEY_NOTES, cleanedNotes);
}
if (savedNoteGroups) {
const cleanedNoteGroups = normalizeNoteGroups(savedNoteGroups);
setNoteGroups(cleanedNoteGroups);
localStorageAdapter.write(STORAGE_KEY_NOTE_GROUPS, cleanedNoteGroups);
}
// Load known hosts. Records imported from `~/.ssh/known_hosts` and
// records saved by older builds may be missing the `fingerprint` /
@@ -723,6 +759,18 @@ export const useVaultState = () => {
return;
}
if (key === STORAGE_KEY_NOTES) {
const next = safeParse<VaultNote[]>(event.newValue) ?? [];
setNotes(normalizeVaultNotes(next));
return;
}
if (key === STORAGE_KEY_NOTE_GROUPS) {
const next = safeParse<string[]>(event.newValue) ?? [];
setNoteGroups(normalizeNoteGroups(next));
return;
}
if (key === STORAGE_KEY_KNOWN_HOSTS) {
const next = safeParse<KnownHost[]>(event.newValue) ?? [];
setKnownHosts(normalizeVaultOrder(normalizeKnownHosts(next)));
@@ -804,10 +852,12 @@ export const useVaultState = () => {
snippets,
customGroups,
snippetPackages,
notes,
noteGroups,
knownHosts,
groupConfigs,
}),
[hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, knownHosts, groupConfigs],
[hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, notes, noteGroups, knownHosts, groupConfigs],
);
const importData = useCallback(
@@ -820,6 +870,8 @@ export const useVaultState = () => {
if (payload.snippets) updateSnippets(payload.snippets);
if (payload.customGroups) updateCustomGroups(payload.customGroups);
if (payload.snippetPackages) updateSnippetPackages(payload.snippetPackages);
if (payload.notes) updateNotes(payload.notes);
if (payload.noteGroups) updateNoteGroups(payload.noteGroups);
if (payload.knownHosts) updateKnownHosts(payload.knownHosts);
if (Array.isArray(payload.groupConfigs)) encryptedWrites.push(updateGroupConfigs(payload.groupConfigs));
return Promise.all(encryptedWrites).then(() => undefined);
@@ -832,6 +884,8 @@ export const useVaultState = () => {
updateSnippets,
updateCustomGroups,
updateSnippetPackages,
updateNotes,
updateNoteGroups,
updateKnownHosts,
updateGroupConfigs,
],
@@ -854,6 +908,8 @@ export const useVaultState = () => {
snippets,
customGroups,
snippetPackages,
notes,
noteGroups,
knownHosts,
shellHistory,
connectionLogs,
@@ -866,6 +922,8 @@ export const useVaultState = () => {
updateProxyProfiles,
updateSnippets,
updateSnippetPackages,
updateNotes,
updateNoteGroups,
updateCustomGroups,
updateKnownHosts,
updateManagedSources,
+142
View File
@@ -40,6 +40,7 @@ const {
applyLocalVaultPayload,
applySyncPayload,
buildLocalVaultPayload,
buildCloudSyncPayload,
buildSyncPayload,
hasCloudSyncEntityData,
hasMeaningfulCloudSyncData,
@@ -64,12 +65,20 @@ const vault = (knownHosts: KnownHost[] = [knownHost()]): SyncableVaultData => ({
snippets: [],
customGroups: [],
snippetPackages: [],
notes: [],
noteGroups: [],
knownHosts,
groupConfigs: [],
});
test.beforeEach(() => {
localStorage.clear();
Object.defineProperty(globalThis, "window", {
value: {
dispatchEvent: () => true,
},
configurable: true,
});
});
test("buildSyncPayload treats known hosts as local-only data", () => {
@@ -97,6 +106,24 @@ test("buildSyncPayload includes reusable proxy profiles", () => {
assert.deepEqual(payload.proxyProfiles, proxyProfiles);
});
test("buildCloudSyncPayload includes notes and note groups", async () => {
const payload = await buildCloudSyncPayload({
...vault([]),
notes: [{
id: "note-1",
title: "Runbook",
content: "# Runbook",
createdAt: 1,
updatedAt: 1,
}],
noteGroups: ["Ops"],
});
assert.equal(payload.notes?.length, 1);
assert.equal(payload.notes?.[0]?.title, "Runbook");
assert.deepEqual(payload.noteGroups, ["Ops"]);
});
test("buildSyncPayload includes AI configuration settings", () => {
const providers = [{
id: "openai-main",
@@ -192,6 +219,63 @@ test("buildSyncPayload omits device-bound encrypted AI API keys", () => {
assert.equal("apiKey" in (payload.settings?.ai?.webSearchConfig ?? {}), false);
});
test("buildCloudSyncPayload includes decrypted AI API keys for portable cloud sync", async () => {
Object.defineProperty(globalThis, "window", {
value: {
netcatty: {
credentialsDecrypt: async (value: string) => {
if (value === "enc:v1:djEwPROVIDER") return "sk-provider";
if (value === "enc:v1:djEwWEB") return "sk-web";
return value;
},
},
},
configurable: true,
});
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{
id: "openai-main",
providerId: "openai",
name: "OpenAI",
apiKey: "enc:v1:djEwPROVIDER",
enabled: true,
}]));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
providerId: "tavily",
apiKey: "enc:v1:djEwWEB",
enabled: true,
}));
const payload = await buildCloudSyncPayload(vault([]));
assert.equal(payload.settings?.ai?.providers?.[0]?.apiKey, "sk-provider");
assert.equal(payload.settings?.ai?.webSearchConfig?.apiKey, "sk-web");
});
test("buildCloudSyncPayload fails instead of deleting API keys when decrypt fails", async () => {
Object.defineProperty(globalThis, "window", {
value: {
netcatty: {
credentialsDecrypt: async (value: string) => value,
},
},
configurable: true,
});
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{
id: "openai-main",
providerId: "openai",
name: "OpenAI",
apiKey: "enc:v1:djEwPROVIDER",
enabled: true,
}]));
await assert.rejects(
() => buildCloudSyncPayload(vault([])),
/Unable to decrypt AI API key/,
);
});
test("applySyncPayload restores AI configuration settings", async () => {
const providers = [{
id: "anthropic-main",
@@ -249,6 +333,42 @@ test("applySyncPayload restores AI configuration settings", async () => {
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION), "false");
});
test("applySyncPayload encrypts synced plaintext AI API keys before saving locally", async () => {
Object.defineProperty(globalThis, "window", {
value: {
netcatty: {
credentialsEncrypt: async (value: string) => `enc:v1:djEwLOCAL_${value}`,
},
dispatchEvent: () => true,
},
configurable: true,
});
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
ai: {
providers: [
{ id: "openai-main", providerId: "openai", name: "OpenAI", apiKey: "sk-provider", enabled: true },
],
webSearchConfig: { providerId: "tavily", apiKey: "sk-web", enabled: true },
},
},
syncedAt: 1,
} as SyncPayload;
await applySyncPayload(payload, { importVaultData: () => {} });
const provider = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!)[0];
const webSearch = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!);
assert.equal(provider.apiKey, "enc:v1:djEwLOCAL_sk-provider");
assert.equal(webSearch.apiKey, "enc:v1:djEwLOCAL_sk-web");
});
test("applySyncPayload restores host tree sidebar visibility setting", async () => {
const payload: SyncPayload = {
hosts: [],
@@ -543,6 +663,7 @@ test("buildSyncPayload includes syncable terminal options from settings", () =>
terminalEmulationType: "vt100",
altAsMeta: true,
middleClickBehavior: "context-menu",
fontSmoothing: false,
showServerStats: false,
serverStatsRefreshInterval: 12,
rendererType: "dom",
@@ -556,6 +677,7 @@ test("buildSyncPayload includes syncable terminal options from settings", () =>
terminalEmulationType: "vt100",
altAsMeta: true,
middleClickBehavior: "context-menu",
fontSmoothing: false,
showServerStats: false,
serverStatsRefreshInterval: 12,
rendererType: "dom",
@@ -615,6 +737,26 @@ test("buildLocalVaultPayload preserves known hosts for local backups", () => {
assert.deepEqual(payload.knownHosts, [knownHost("kh-local")]);
});
test("buildLocalVaultPayload preserves local AI API keys for protective backups", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{
id: "openai-main",
providerId: "openai",
name: "OpenAI",
apiKey: "enc:v1:djEwPROVIDER",
enabled: true,
}]));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
providerId: "tavily",
apiKey: "enc:v1:djEwWEB",
enabled: true,
}));
const payload = buildLocalVaultPayload(vault([]));
assert.equal(payload.settings?.ai?.providers?.[0]?.apiKey, "enc:v1:djEwPROVIDER");
assert.equal(payload.settings?.ai?.webSearchConfig?.apiKey, "enc:v1:djEwWEB");
});
test("applySyncPayload ignores legacy cloud known hosts", async () => {
let imported: Record<string, unknown> | null = null;
const proxyProfiles = [
+115 -7
View File
@@ -17,6 +17,7 @@ import type {
SftpBookmark,
Snippet,
SSHKey,
VaultNote,
} from '../domain/models';
import {
CLOUD_SYNC_PAYLOAD_ENTITY_KEYS,
@@ -32,6 +33,7 @@ import {
} from '../domain/customKeyBindings';
import { isEncryptedCredentialPlaceholder } from '../domain/credentials';
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
import { decryptField, encryptField } from '../infrastructure/persistence/secureFieldAdapter';
import { sanitizeQuickMessages } from '../infrastructure/ai/quickMessages';
import { emitAIStateChanged } from './state/aiStateEvents';
import { rehydrateGlobalSftpBookmarks } from './state/sftp/globalSftpBookmarks';
@@ -102,6 +104,8 @@ export interface SyncableVaultData {
snippets: Snippet[];
customGroups: string[];
snippetPackages?: string[];
notes?: VaultNote[];
noteGroups?: string[];
/** Local trust records. Kept in local backups, excluded from cloud sync. */
knownHosts: KnownHost[];
groupConfigs?: GroupConfig[];
@@ -191,7 +195,7 @@ interface SyncPayloadImporters {
const SYNCABLE_TERMINAL_KEYS = [
'startupCommandDelayMs',
'scrollback', 'drawBoldInBrightColors', 'terminalEmulationType',
'fontLigatures', 'fontWeight', 'fontWeightBold', 'fallbackFont',
'fontLigatures', 'fontSmoothing', 'fontWeight', 'fontWeightBold', 'fallbackFont',
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
'altAsMeta', 'optionArrowWordJump', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'smoothScrolling',
@@ -276,6 +280,32 @@ const stripDeviceBoundApiKey = <T extends Record<string, unknown>>(value: T): T
return next;
};
const getApiKeyLabel = (value: Record<string, unknown>): string => {
if (typeof value.name === 'string' && value.name.trim()) return value.name;
if (typeof value.id === 'string' && value.id.trim()) return value.id;
if (typeof value.providerId === 'string' && value.providerId.trim()) return value.providerId;
return 'configured provider';
};
const withPortableApiKey = async <T extends Record<string, unknown>>(value: T): Promise<T> => {
const apiKey = value.apiKey;
if (typeof apiKey !== 'string' || !isEncryptedCredentialPlaceholder(apiKey)) return value;
const decrypted = await decryptField(apiKey).catch(() => undefined);
if (!decrypted || decrypted === apiKey || isEncryptedCredentialPlaceholder(decrypted)) {
throw new Error(`Unable to decrypt AI API key for ${getApiKeyLabel(value)}. Sync was stopped to avoid removing the key from cloud sync.`);
}
return { ...value, apiKey: decrypted };
};
const withLocalEncryptedApiKey = async <T extends Record<string, unknown>>(value: T): Promise<T> => {
const apiKey = value.apiKey;
if (typeof apiKey !== 'string' || isEncryptedCredentialPlaceholder(apiKey)) return value;
const encrypted = await encryptField(apiKey).catch(() => undefined);
return { ...value, apiKey: encrypted ?? apiKey };
};
/**
* `collectSyncableSettings` strips device-bound encrypted apiKeys before upload,
* so an incoming providers array typically has no apiKey for providers that
@@ -461,7 +491,7 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
const webSearchConfig = readRecordSetting(STORAGE_KEY_AI_WEB_SEARCH);
if (webSearchConfig) ai.webSearchConfig = stripDeviceBoundApiKey(webSearchConfig);
const quickMessages = readArraySetting(STORAGE_KEY_AI_QUICK_MESSAGES);
if (quickMessages) ai.quickMessages = sanitizeQuickMessages(quickMessages);
if (quickMessages) ai.quickMessages = sanitizeQuickMessages(quickMessages) as unknown as Array<Record<string, unknown>>;
const showTerminalSelectionAction = localStorageAdapter.readBoolean(STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION);
if (showTerminalSelectionAction != null) {
ai.showTerminalSelectionAction = showTerminalSelectionAction;
@@ -471,11 +501,57 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
return Object.keys(settings).length > 0 ? settings : undefined;
}
export async function collectCloudSyncableSettings(): Promise<SyncPayload['settings']> {
const settings = collectSyncableSettings();
const providers = readArraySetting(STORAGE_KEY_AI_PROVIDERS);
const webSearchConfig = readRecordSetting(STORAGE_KEY_AI_WEB_SEARCH);
if (!providers && !webSearchConfig) return settings;
const nextSettings: SyncPayload['settings'] = settings ? { ...settings } : {};
const ai: NonNullable<SyncPayload['settings']>['ai'] = {
...(settings?.ai ?? {}),
};
if (providers) {
ai.providers = await Promise.all(providers.map(withPortableApiKey));
}
if (webSearchConfig) {
ai.webSearchConfig = await withPortableApiKey(webSearchConfig);
}
nextSettings.ai = ai;
return Object.keys(nextSettings).length > 0 ? nextSettings : undefined;
}
function collectLocalBackupSettings(): SyncPayload['settings'] {
const settings = collectSyncableSettings();
const providers = readArraySetting(STORAGE_KEY_AI_PROVIDERS);
const webSearchConfig = readRecordSetting(STORAGE_KEY_AI_WEB_SEARCH);
if (!providers && !webSearchConfig) return settings;
const nextSettings: SyncPayload['settings'] = settings ? { ...settings } : {};
const ai: NonNullable<SyncPayload['settings']>['ai'] = {
...(settings?.ai ?? {}),
};
if (providers) {
ai.providers = providers;
}
if (webSearchConfig) {
ai.webSearchConfig = webSearchConfig;
}
nextSettings.ai = ai;
return Object.keys(nextSettings).length > 0 ? nextSettings : undefined;
}
/**
* Apply synced settings to localStorage. Merges terminal settings
* to preserve platform-specific fields.
*/
function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>): void {
async function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>): Promise<void> {
// Theme & Appearance
if (settings.theme != null) localStorageAdapter.writeString(STORAGE_KEY_THEME, settings.theme);
if (settings.lightUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, settings.lightUiThemeId);
@@ -591,9 +667,10 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
const ai = settings.ai;
if (ai) {
if (ai.providers != null) {
const providers = await Promise.all(ai.providers.map(withLocalEncryptedApiKey));
localStorageAdapter.write(
STORAGE_KEY_AI_PROVIDERS,
mergeAiProvidersPreservingLocalApiKeys(ai.providers),
mergeAiProvidersPreservingLocalApiKeys(providers),
);
}
if (ai.activeProviderId != null) localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, ai.activeProviderId);
@@ -613,9 +690,10 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
if (ai.webSearchConfig === null) {
localStorageAdapter.remove(STORAGE_KEY_AI_WEB_SEARCH);
} else {
const webSearchConfig = await withLocalEncryptedApiKey(ai.webSearchConfig);
localStorageAdapter.write(
STORAGE_KEY_AI_WEB_SEARCH,
mergeWebSearchConfigPreservingLocalApiKey(ai.webSearchConfig),
mergeWebSearchConfigPreservingLocalApiKey(webSearchConfig),
);
}
}
@@ -735,6 +813,8 @@ export function buildSyncPayload(
snippets: vault.snippets,
customGroups: vault.customGroups,
snippetPackages: vault.snippetPackages,
notes: vault.notes,
noteGroups: vault.noteGroups,
groupConfigs: vault.groupConfigs,
portForwardingRules: sanitizePortForwardingRulesForSync(portForwardingRules),
settings: collectSyncableSettings(),
@@ -742,6 +822,27 @@ export function buildSyncPayload(
};
}
export async function buildCloudSyncPayload(
vault: SyncableVaultData,
portForwardingRules?: PortForwardingRule[],
): Promise<SyncPayload> {
return {
hosts: vault.hosts,
keys: vault.keys,
identities: vault.identities,
proxyProfiles: vault.proxyProfiles,
snippets: vault.snippets,
customGroups: vault.customGroups,
snippetPackages: vault.snippetPackages,
notes: vault.notes,
noteGroups: vault.noteGroups,
groupConfigs: vault.groupConfigs,
portForwardingRules: sanitizePortForwardingRulesForSync(portForwardingRules),
settings: await collectCloudSyncableSettings(),
syncedAt: Date.now(),
};
}
/** Build a local backup/restore payload, including local-only trust records. */
export function buildLocalVaultPayload(
vault: SyncableVaultData,
@@ -749,6 +850,7 @@ export function buildLocalVaultPayload(
): SyncPayload {
return {
...buildSyncPayload(vault, portForwardingRules),
settings: collectLocalBackupSettings(),
knownHosts: vault.knownHosts,
};
}
@@ -778,6 +880,12 @@ function applyPayload(
if (payload.snippetPackages !== undefined) {
vaultImport.snippetPackages = payload.snippetPackages;
}
if (payload.notes !== undefined) {
vaultImport.notes = payload.notes;
}
if (payload.noteGroups !== undefined) {
vaultImport.noteGroups = payload.noteGroups;
}
if (options.includeLocalOnlyData && payload.knownHosts !== undefined) {
vaultImport.knownHosts = payload.knownHosts;
}
@@ -785,7 +893,7 @@ function applyPayload(
vaultImport.groupConfigs = payload.groupConfigs;
}
return Promise.resolve(importers.importVaultData(JSON.stringify(vaultImport))).then(() => {
return Promise.resolve(importers.importVaultData(JSON.stringify(vaultImport))).then(async () => {
// Only import port-forwarding rules when the payload explicitly carries
// them. Absent field = "payload was created before this feature existed",
// so local rules are preserved. Explicitly present [] = "remote has no
@@ -796,7 +904,7 @@ function applyPayload(
// Apply synced settings
if (payload.settings) {
applySyncableSettings(payload.settings);
await applySyncableSettings(payload.settings);
// Rehydrate in-memory bookmark snapshot after localStorage was updated
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalSftpBookmarks();
importers.onSettingsApplied?.();
@@ -1,9 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from '../application/i18n/I18nProvider.tsx';
import type { AIDraft, AISession } from '../infrastructure/ai/types';
import { TooltipProvider } from './ui/tooltip.tsx';
import {
aiChatSidePanelPropsAreEqual,
AIChatSidePanel,
hasAIChatSidePanelRetainedContent,
shouldKeepAIChatSidePanelMounted,
} from './AIChatSidePanel.tsx';
@@ -102,6 +107,32 @@ test('visible AI side panel is always mounted even when empty', () => {
assert.equal(shouldKeepAIChatSidePanelMounted(baseProps({ isVisible: true })), true);
});
test('visible empty draft renders the input immediately without preparing state', () => {
const markup = renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: 'en' },
React.createElement(
TooltipProvider,
null,
React.createElement(AIChatSidePanel, baseProps({
isVisible: true,
sessions: [session({ id: 'session-history' })],
draftsByScope: {
'terminal:terminal-1': draft(),
},
panelViewByScope: {
'terminal:terminal-1': { mode: 'draft' },
},
})),
),
),
);
assert.match(markup, /textarea/);
assert.doesNotMatch(markup, /data-section="ai-chat-panel-preparing"/);
});
test('AI side panel re-renders when retained content becomes visible again', () => {
const hiddenProps = baseProps({
isVisible: false,
+28 -9
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';
@@ -50,7 +51,13 @@ import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligib
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
import { useConversationExport } from './ai/hooks/useConversationExport';
import type { AIChatSidePanelProps } from './AIChatSidePanel.types';
import { generateId, modelPresetsContainId, shouldLoadSdkRuntimeModels } from './AIChatSidePanelHelpers';
import {
generateId,
normalizeSdkRuntimeModelPresets,
shouldAdoptSdkCurrentModel,
shouldLoadSdkRuntimeModels,
shouldUseStoredAgentModel,
} from './AIChatSidePanelHelpers';
import { AIChatPanelContent } from './AIChatPanelContent';
import {
getAIPanelProfilerProps,
@@ -88,6 +95,11 @@ if (typeof window !== 'undefined') {
subscribeUserSkillsStatusChanged(invalidateUserSkillsStatusCache);
}
function getManualAgentCommand(config: ExternalAgentConfig | null | undefined): string | undefined {
const command = String(config?.command || '').trim();
return config?.commandSource === 'manual' && command ? command : undefined;
}
function loadUserSkillsStatus(
bridge: ReturnType<typeof getNetcattyBridge>,
): Promise<UserSkillsStatusLoadResult> {
@@ -166,6 +178,9 @@ export function shouldKeepAIChatSidePanelMounted(props: AIChatSidePanelProps): b
function shouldDelayAIChatSidePanelActivation(props: AIChatSidePanelProps): boolean {
if (!(props.isVisible ?? true)) return false;
const scopeKey = `${props.scopeType}:${props.scopeTargetId ?? ''}`;
if (props.draftsByScope[scopeKey] || props.panelViewByScope[scopeKey]?.mode === 'draft') {
return false;
}
const sessionId = props.activeSessionIdMap[scopeKey] ?? null;
if (isAIChatSessionStreaming(sessionId)) return false;
return !hasAIChatSidePanelRetainedContent(props);
@@ -618,7 +633,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
if (!bridge?.aiCodexGetIntegration) return;
let cancelled = false;
void Promise.resolve(
bridge.aiCodexGetIntegration() as Promise<CodexIntegrationStatus>,
bridge.aiCodexGetIntegration({ codexPath: getManualAgentCommand(currentAgentConfig) }) as Promise<CodexIntegrationStatus>,
).then((info) => {
if (cancelled) return;
const hasCustom = info?.state === 'connected_custom_config';
@@ -631,7 +646,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
}
});
return () => { cancelled = true; };
}, [isVisible, isCodexManagedAgent, currentAgentId]);
}, [isVisible, isCodexManagedAgent, currentAgentId, currentAgentConfig]);
const agentModelMapRef = useRef(agentModelMap);
agentModelMapRef.current = agentModelMap;
@@ -652,23 +667,27 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
undefined,
`models_${currentAgentId}`,
currentAgentConfig.env,
getManualAgentCommand(currentAgentConfig),
).then((result) => {
if (cancelled || !result?.ok || !Array.isArray(result.models)) return;
if (result.models.length === 0) {
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;
}
const runtimePresets = result.models ?? [];
setRuntimeAgentModelPresets((prev) => ({
...prev,
[currentAgentId]: runtimePresets,
}));
const storedModelId = agentModelMapRef.current[currentAgentId];
if (result.currentModelId && (!storedModelId || !modelPresetsContainId(runtimePresets, storedModelId))) {
if (shouldAdoptSdkCurrentModel(result.currentModelId, storedModelId, runtimePresets)) {
setAgentModel(currentAgentId, result.currentModelId);
}
}).catch((err) => {
@@ -700,7 +719,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const selectedAgentModel = useMemo(() => {
const stored = agentModelMap[currentAgentId];
if (stored && modelPresetsContainId(agentModelPresets, stored)) {
if (shouldUseStoredAgentModel(stored, agentModelPresets, currentAgentConfig)) {
return stored;
}
if (agentModelPresets.length > 0) {
@@ -711,7 +730,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
return first.id;
}
return undefined;
}, [currentAgentId, agentModelMap, agentModelPresets]);
}, [currentAgentConfig, currentAgentId, agentModelMap, agentModelPresets]);
const inputAgentId = activeSession?.agentId ?? currentDraft?.agentId ?? currentAgentId;
const canSendCurrentAgent = useMemo(
@@ -3,7 +3,10 @@ import assert from 'node:assert/strict';
import {
modelPresetsContainId,
normalizeSdkRuntimeModelPresets,
shouldAdoptSdkCurrentModel,
shouldLoadSdkRuntimeModels,
shouldUseStoredAgentModel,
} from './AIChatSidePanelHelpers';
import type { AgentModelPreset, ExternalAgentConfig } from '../infrastructure/ai/types';
@@ -30,6 +33,55 @@ test('shouldLoadSdkRuntimeModels includes SDK agents with model catalogs', () =>
assert.equal(shouldLoadSdkRuntimeModels(agent('claude')), true);
assert.equal(shouldLoadSdkRuntimeModels(agent('copilot')), true);
assert.equal(shouldLoadSdkRuntimeModels(agent('codebuddy')), true);
assert.equal(shouldLoadSdkRuntimeModels(agent('opencode')), true);
assert.equal(shouldLoadSdkRuntimeModels(agent('codex')), false);
assert.equal(shouldLoadSdkRuntimeModels(undefined), false);
});
test('shouldAdoptSdkCurrentModel keeps SDK defaults when no runtime list is returned', () => {
assert.equal(shouldAdoptSdkCurrentModel('openai/gpt-5.1', undefined, []), true);
assert.equal(shouldAdoptSdkCurrentModel('openai/gpt-5.1', 'openai/gpt-5.1', []), true);
assert.equal(
shouldAdoptSdkCurrentModel('openai/gpt-5.1', 'anthropic/claude-sonnet', [
{ id: 'anthropic/claude-sonnet', name: 'Claude' },
]),
false,
);
assert.equal(shouldAdoptSdkCurrentModel(null, undefined, []), false);
});
test('normalizeSdkRuntimeModelPresets preserves SDK current model without a catalog', () => {
assert.deepEqual(normalizeSdkRuntimeModelPresets([], 'custom/provider-model'), [
{ id: 'custom/provider-model', name: 'custom/provider-model' },
]);
assert.deepEqual(normalizeSdkRuntimeModelPresets([], null), []);
assert.deepEqual(
normalizeSdkRuntimeModelPresets([{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }], 'custom/provider-model'),
[{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }],
);
});
test('shouldUseStoredAgentModel trusts SDK defaults when no runtime list is returned', () => {
const opencodeAgent: ExternalAgentConfig = {
id: 'managed_opencode',
name: 'OpenCode',
command: 'opencode',
enabled: true,
sdkBackend: 'opencode',
};
assert.equal(shouldUseStoredAgentModel('openai/gpt-5.1', [], opencodeAgent), true);
assert.equal(shouldUseStoredAgentModel('openai/gpt-5.1', [], undefined), false);
assert.equal(
shouldUseStoredAgentModel('anthropic/claude-sonnet', [
{ id: 'anthropic/claude-sonnet', name: 'Claude' },
], opencodeAgent),
true,
);
assert.equal(
shouldUseStoredAgentModel('openai/gpt-5.1', [
{ id: 'anthropic/claude-sonnet', name: 'Claude' },
], opencodeAgent),
false,
);
});
+34 -1
View File
@@ -30,7 +30,40 @@ export function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
export function shouldLoadSdkRuntimeModels(agent?: ExternalAgentConfig): boolean {
const sdkBackend = getExternalAgentSdkBackend(agent);
return sdkBackend === 'claude' || sdkBackend === 'copilot' || sdkBackend === 'codebuddy';
return sdkBackend === 'claude'
|| sdkBackend === 'copilot'
|| sdkBackend === 'codebuddy'
|| sdkBackend === 'opencode';
}
export function shouldAdoptSdkCurrentModel(
currentModelId: string | null | undefined,
storedModelId: string | null | undefined,
runtimePresets: AgentModelPreset[],
): boolean {
if (!currentModelId) return false;
return !storedModelId
|| runtimePresets.length === 0
|| !modelPresetsContainId(runtimePresets, storedModelId);
}
export function normalizeSdkRuntimeModelPresets(
models: AgentModelPreset[],
currentModelId: string | null | undefined,
): AgentModelPreset[] {
if (models.length > 0) return models;
if (!currentModelId) return [];
return [{ id: currentModelId, name: currentModelId }];
}
export function shouldUseStoredAgentModel(
storedModelId: string | null | undefined,
presets: AgentModelPreset[],
agent?: ExternalAgentConfig,
): boolean {
if (!storedModelId) return false;
return modelPresetsContainId(presets, storedModelId)
|| (presets.length === 0 && shouldLoadSdkRuntimeModels(agent));
}
export function generateId(): string {
+5 -5
View File
@@ -32,7 +32,7 @@ import { LocalBackupsPanel } from './cloud-sync/CloudSyncLocalBackupsPanel';
import { CloudSyncDialogs } from './cloud-sync/CloudSyncDialogs';
import { CloudSyncDashboardTabs } from './cloud-sync/CloudSyncDashboardTabs';
interface SyncDashboardProps {
onBuildPayload: () => SyncPayload;
onBuildPayload: () => SyncPayload | Promise<SyncPayload>;
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
onApplyLocalPayload?: (payload: SyncPayload) => void | Promise<void>;
onClearLocalData?: () => void;
@@ -138,7 +138,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
const [historyPreview, setHistoryPreview] = useState<{
sha: string;
payload: SyncPayload;
preview: { hostCount: number; keyCount: number; snippetCount: number; identityCount: number; portForwardingRuleCount: number };
preview: { hostCount: number; keyCount: number; snippetCount: number; noteCount: number; identityCount: number; portForwardingRuleCount: number };
deviceName?: string;
version?: number;
} | null>(null);
@@ -519,7 +519,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
// Sync to provider
const handleSync = async (provider: CloudProvider) => {
try {
const payload = onBuildPayload();
const payload = await onBuildPayload();
if (!ensureSyncablePayload(payload)) return;
const result = await sync.syncToProvider(provider, payload);
@@ -572,7 +572,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
// renderer's in-memory state (no onApplyPayload call), so
// the barrier is belt-and-suspenders against the other
// window's push, not ours.
const localPayload = onBuildPayload();
const localPayload = await onBuildPayload();
if (!ensureSyncablePayload(localPayload)) return;
let results: Map<CloudProvider, SyncResult> | null = null;
@@ -858,7 +858,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
// ============================================================================
interface CloudSyncSettingsProps {
onBuildPayload: () => SyncPayload;
onBuildPayload: () => SyncPayload | Promise<SyncPayload>;
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
onApplyLocalPayload?: (payload: SyncPayload) => void | Promise<void>;
onClearLocalData?: () => void;
+64
View File
@@ -0,0 +1,64 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import type { ConnectionLog } from "../types.ts";
import ConnectionLogsManager from "./ConnectionLogsManager.tsx";
import { TooltipProvider } from "./ui/tooltip.tsx";
const baseLog: ConnectionLog = {
id: "log-1",
hostId: "host-1",
hostLabel: "Database",
hostname: "db.example.com",
username: "root",
protocol: "ssh",
hostOs: "linux",
hostDistro: "ubuntu",
startTime: 1_700_000_000_000,
localUsername: "alice",
localHostname: "workstation",
saved: false,
};
const renderLogs = (log: ConnectionLog) =>
renderToStaticMarkup(
<I18nProvider locale="en">
<TooltipProvider>
<ConnectionLogsManager
logs={[log]}
hosts={[]}
onToggleSaved={() => {}}
onDelete={() => {}}
onClearUnsaved={() => {}}
onOpenLogView={() => {}}
/>
</TooltipProvider>
</I18nProvider>,
);
test("ConnectionLogsManager renders saved custom host icon snapshots", () => {
const markup = renderLogs({
...baseLog,
hostIconMode: "custom",
hostIconId: "database",
hostIconColor: "blue",
});
assert.match(markup, /background-color:#2563EB/i);
assert.doesNotMatch(markup, /bg-\[#E95420\]/);
});
test("ConnectionLogsManager renders saved distro icon snapshots with custom colors", () => {
const markup = renderLogs({
...baseLog,
hostIconMode: "auto",
hostIconColor: "violet",
});
assert.match(markup, /background-color:#7C3AED/i);
assert.match(markup, /src="\/distro\/ubuntu.svg"/);
assert.doesNotMatch(markup, /bg-\[#E95420\]/);
});
+14 -1
View File
@@ -9,6 +9,7 @@ import {
} from "lucide-react";
import React, { memo, useCallback, useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { resolveHostIconAppearance } from "../domain/hostIcon";
import { cn } from "../lib/utils";
import { ConnectionLog, Host } from "../types";
import { DistroAvatar } from "./DistroAvatar";
@@ -67,7 +68,14 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
const { t, resolvedLocale } = useI18n();
const isLocal = log.protocol === "local" || log.hostname === "localhost";
const isSerial = log.protocol === "serial";
const hasPersistedHostIcon = !isLocal && !isSerial && !!log.hostDistro;
const customHostIcon = resolveHostIconAppearance({
iconMode: log.hostIconMode,
iconId: log.hostIconId,
iconColorMode: log.hostIconColorMode,
iconColor: log.hostIconColor,
iconColorCustom: log.hostIconColorCustom,
});
const hasPersistedHostIcon = !isLocal && !isSerial && (!!log.hostDistro || !!customHostIcon);
return (
<div
@@ -101,6 +109,11 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
os: log.hostOs ?? "linux",
distro: log.hostDistro,
distroMode: "auto",
iconMode: log.hostIconMode,
iconId: log.hostIconId,
iconColorMode: log.hostIconColorMode,
iconColor: log.hostIconColor,
iconColorCustom: log.hostIconColorCustom,
}}
fallback={(log.hostOs ?? "linux")[0].toUpperCase()}
size="log"
+75
View File
@@ -0,0 +1,75 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { DistroAvatar } from "./DistroAvatar.tsx";
import type { Host } from "../types.ts";
const baseHost: Pick<Host, "distro" | "manualDistro" | "distroMode" | "os" | "protocol" | "iconMode" | "iconId" | "iconColor"> = {
os: "linux",
protocol: "ssh",
};
test("DistroAvatar renders custom host icon before distro color", () => {
const markup = renderToStaticMarkup(
<DistroAvatar
host={{ ...baseHost, distro: "ubuntu", iconMode: "custom", iconId: "database", iconColor: "blue" }}
fallback="DB"
/>,
);
assert.match(markup, /background-color:#2563EB/i);
assert.doesNotMatch(markup, /bg-\[#E95420\]/);
});
test("DistroAvatar keeps serial hosts on the USB icon", () => {
const markup = renderToStaticMarkup(
<DistroAvatar
host={{ ...baseHost, protocol: "serial", iconMode: "custom", iconId: "database", iconColor: "blue" }}
fallback="S"
/>,
);
assert.match(markup, /bg-amber-600/);
assert.doesNotMatch(markup, /background-color:#2563EB/i);
});
test("DistroAvatar tree size uses compact host icon corners", () => {
const markup = renderToStaticMarkup(
<DistroAvatar
host={{ ...baseHost, distro: "ubuntu" }}
fallback="U"
size="tree"
/>,
);
assert.match(markup, /h-6 w-6 rounded/);
assert.doesNotMatch(markup, /rounded-md/);
});
test("DistroAvatar keeps distro icon and applies custom palette color when icon mode is automatic", () => {
const markup = renderToStaticMarkup(
<DistroAvatar
host={{ ...baseHost, distro: "ubuntu", iconMode: "auto", iconId: "database", iconColor: "blue" }}
fallback="U"
/>,
);
assert.match(markup, /background-color:#2563EB/i);
assert.match(markup, /src="\/distro\/ubuntu.svg"/);
assert.doesNotMatch(markup, /bg-\[#E95420\]/);
});
test("DistroAvatar renders H3C logo without monochrome inversion", () => {
const markup = renderToStaticMarkup(
<DistroAvatar
host={{ ...baseHost, distro: "h3c" }}
fallback="H"
/>,
);
assert.match(markup, /src="\/distro\/h3c.svg"/);
assert.match(markup, /bg-white/);
assert.doesNotMatch(markup, /invert brightness-0/);
});
+29 -5
View File
@@ -1,8 +1,10 @@
import { Server, Usb } from "lucide-react";
import React, { memo } from "react";
import { getEffectiveHostDistro } from "../domain/host";
import { resolveHostIconAppearance, resolveHostIconColorAppearance } from "../domain/hostIcon";
import { cn } from "../lib/utils";
import { Host } from "../types";
import { renderHostIconGlyph } from "./hostIconRenderer";
export const DISTRO_LOGOS: Record<string, string> = {
ubuntu: "/distro/ubuntu.svg",
@@ -19,6 +21,7 @@ export const DISTRO_LOGOS: Record<string, string> = {
kali: "/distro/kali.svg",
almalinux: "/distro/almalinux.svg",
alinux: "/distro/alinux.svg",
openeuler: "/distro/openeuler.svg",
// OS-level logos (used by local terminal tab icons)
macos: "/distro/macos.svg",
windows: "/distro/windows.svg",
@@ -28,6 +31,7 @@ export const DISTRO_LOGOS: Record<string, string> = {
cisco: "/distro/cisco.svg",
juniper: "/distro/juniper.svg",
huawei: "/distro/huawei.svg",
h3c: "/distro/h3c.svg",
hpe: "/distro/hpe.svg",
mikrotik: "/distro/mikrotik.svg",
fortinet: "/distro/fortinet.svg",
@@ -50,6 +54,7 @@ export const DISTRO_COLORS: Record<string, string> = {
kali: "bg-[#0F6DB3]",
almalinux: "bg-[#173B66]",
alinux: "bg-[#FF6A00]",
openeuler: "bg-[#002FA7]",
// OS-level colors
macos: "bg-[#333333]",
windows: "bg-[#0078D4]",
@@ -58,6 +63,7 @@ export const DISTRO_COLORS: Record<string, string> = {
cisco: "bg-[#1BA0D7]",
juniper: "bg-[#0A6EB4]",
huawei: "bg-[#CF0A2C]",
h3c: "bg-white",
hpe: "bg-[#01A982]",
mikrotik: "bg-[#293239]",
fortinet: "bg-[#EE3124]",
@@ -69,7 +75,7 @@ export const DISTRO_COLORS: Record<string, string> = {
type DistroAvatarProps = {
host: Pick<Host, "distro" | "manualDistro" | "distroMode" | "os"> &
Partial<Pick<Host, "protocol">>;
Partial<Pick<Host, "protocol" | "iconMode" | "iconId" | "iconColorMode" | "iconColor" | "iconColorCustom">>;
fallback: string;
className?: string;
/** xs matches top tab bar icons (h-4 rounded rect) */
@@ -92,7 +98,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
xs: "h-4 w-4 rounded",
sm: "h-5 w-5 rounded",
md: "h-8 w-8 rounded",
tree: "h-8 w-8 rounded-lg",
tree: "h-6 w-6 rounded",
log: "h-9 w-9 rounded-xl",
lg: "h-11 w-11 rounded-xl",
};
@@ -100,7 +106,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
xs: "h-2.5 w-2.5",
sm: "h-3 w-3",
md: "h-4 w-4",
tree: "h-4 w-4",
tree: "h-3.5 w-3.5",
log: "h-5 w-5",
lg: "h-5 w-5",
};
@@ -123,20 +129,38 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
);
}
const customAppearance = resolveHostIconAppearance(host);
const customColor = resolveHostIconColorAppearance(host);
if (customAppearance) {
return (
<div
className={cn(
"shrink-0 rounded flex items-center justify-center text-white",
containerClass,
className,
)}
style={{ backgroundColor: customAppearance.colorHex }}
>
{renderHostIconGlyph(customAppearance.iconId, iconSize)}
</div>
);
}
if (logo && !errored) {
return (
<div
className={cn(
"shrink-0 rounded flex items-center justify-center overflow-hidden",
containerClass,
bg,
!customColor && bg,
className,
)}
style={customColor ? { backgroundColor: customColor.colorHex } : undefined}
>
<img
src={logo}
alt={distro || host.os}
className={cn("object-contain invert brightness-0", iconSize)}
className={distro === "h3c" ? "object-contain w-[80%]" : cn("object-contain invert brightness-0", iconSize)}
onError={() => setErrored(true)}
/>
</div>
@@ -376,6 +376,12 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
enabled={!!form.showLineTimestamps}
onToggle={() => update("showLineTimestamps", !form.showLineTimestamps)}
/>
<ToggleRow
label={t("hostDetails.disableDynamicTabTitle")}
hint={t("hostDetails.disableDynamicTabTitle.desc")}
enabled={!!form.disableDynamicTabTitle}
onToggle={() => update("disableDynamicTabTitle", !form.disableDynamicTabTitle)}
/>
<HostDetailsSettingRow label={t("hostDetails.backspaceBehavior")}>
<Select
value={form.backspaceBehavior ?? "default"}
@@ -0,0 +1,96 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { SSHKey } from "../types.ts";
import { HostDetailsConnectionSections } from "./HostDetailsConnectionSections.tsx";
import { TooltipProvider } from "./ui/tooltip.tsx";
const longCredentialLabel =
"D:\\download\\acdrOgxses_wuzh02.hpccube.com_really_really_long_private_key_name.pem";
const availableKey: SSHKey = {
id: "key-1",
label: longCredentialLabel,
type: "ED25519",
privateKey: "",
publicKey: "",
source: "imported",
category: "key",
created: 1,
};
const renderConnectionSections = (formOverrides: Record<string, unknown> = {}) =>
renderToStaticMarkup(
React.createElement(
TooltipProvider,
null,
React.createElement(HostDetailsConnectionSections, {
t: (key: string) => key,
form: {
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
port: 22,
protocol: "ssh",
os: "linux",
authMethod: "key",
identityFileId: availableKey.id,
...formOverrides,
},
update: () => {},
groupDefaults: undefined,
selectedIdentity: undefined,
clearIdentity: () => {},
identities: [],
identitySuggestionsOpen: false,
filteredIdentitySuggestions: [],
setIdentitySuggestionsOpen: () => {},
availableKeys: [availableKey],
applyIdentity: () => {},
showPassword: false,
setShowPassword: () => {},
pendingReferenceKeyPath: null,
setPendingReferenceKeyPath: () => {},
selectedCredentialType: null,
setSelectedCredentialType: () => {},
credentialPopoverOpen: false,
setCredentialPopoverOpen: () => {},
keysByCategory: { key: [availableKey], certificate: [] },
newKeyFilePath: "",
setNewKeyFilePath: () => {},
addLocalKeyFilePath: () => {},
handleDistroModeChange: () => {},
distroOptions: [],
effectiveFormDistro: undefined,
getDistroOptionLabel: () => "",
}),
),
);
test("selected host credential keeps the remove button visible with a long name", () => {
const markup = renderConnectionSections();
assert.match(markup, new RegExp(longCredentialLabel.replaceAll("\\", "\\\\")));
assert.match(
markup,
/class="flex items-center gap-2 min-w-0 overflow-hidden p-2 rounded-md bg-secondary\/50 border border-border\/60"/,
);
assert.match(markup, /class="text-sm min-w-0 flex-1 truncate"/);
assert.match(markup, /class="[^"]*h-6 w-6 shrink-0[^"]*"/);
});
test("color and icon settings render for non-Linux hosts", () => {
const markup = renderConnectionSections({
os: "macos",
distro: "macos",
iconMode: "custom",
iconId: "terminal",
});
assert.match(markup, /hostDetails\.icon\.sectionTitle/);
assert.match(markup, /hostDetails\.icon\.colorLabel/);
assert.match(markup, /hostDetails\.icon\.manualLabel/);
});
+33 -109
View File
@@ -1,8 +1,8 @@
import React from "react";
import { ChevronDown, Eye, EyeOff, FileKey, FolderLock, FolderOpen, Key, KeyRound, MapPin, Plus, Shield, Trash2, User, X } from "lucide-react";
import type { Host } from "../types";
import { cn } from "../lib/utils";
import { DistroAvatar } from "./DistroAvatar";
import { HostIconPicker } from "./HostIconPicker";
import { Button } from "./ui/button";
import { Combobox } from "./ui/combobox";
import { HostDetailsSection, HostDetailsSettingRow } from "./host-details";
@@ -41,7 +41,6 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
newKeyFilePath,
setNewKeyFilePath,
addLocalKeyFilePath,
handleDistroModeChange,
distroOptions,
effectiveFormDistro,
getDistroOptionLabel,
@@ -362,20 +361,20 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
{/* Selected credential display */}
{!selectedIdentity && form.identityFileId && (
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
<div className="flex items-center gap-2 min-w-0 overflow-hidden p-2 rounded-md bg-secondary/50 border border-border/60">
{form.authMethod === "certificate" ? (
<Shield size={14} className="text-primary" />
<Shield size={14} className="text-primary shrink-0" />
) : (
<Key size={14} className="text-primary" />
<Key size={14} className="text-primary shrink-0" />
)}
<span className="text-sm flex-1 truncate">
<span className="text-sm min-w-0 flex-1 truncate">
{availableKeys.find((k) => k.id === form.identityFileId)
?.label || "Key"}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
className="h-6 w-6 shrink-0"
onClick={() => {
update("identityFileId", undefined);
update("authMethod", "password");
@@ -630,108 +629,33 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
</HostDetailsSettingRow>
</HostDetailsSection>
{form.os === "linux" && (
<HostDetailsSection
icon={<img src="/distro/linux.svg" alt="Linux" className="h-3.5 w-3.5 opacity-70 dark:invert" />}
title={t("hostDetails.distro.title")}
hint={t("hostDetails.distro.desc")}
>
<div className="grid gap-2 md:grid-cols-2">
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.mode")}</span>
<Select
value={form.distroMode || "auto"}
onValueChange={(val) => handleDistroModeChange(val as "auto" | "manual")}
>
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.mode")}>
<span className="truncate whitespace-nowrap pr-2 text-left">
{form.distroMode === "manual"
? t("hostDetails.distro.mode.manual")
: t("hostDetails.distro.mode.auto")}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t("hostDetails.distro.mode.auto")}</SelectItem>
<SelectItem value="manual">{t("hostDetails.distro.mode.manual")}</SelectItem>
</SelectContent>
</Select>
</div>
{form.distroMode === "manual" ? (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.manualLabel")}</span>
<Select
value={form.manualDistro}
onValueChange={(val) => update("manualDistro", val)}
>
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.manualLabel")}>
{(() => {
const selectedOption = distroOptions.find((option) => option.value === form.manualDistro);
return selectedOption ? (
<div className="flex min-w-0 items-center gap-2 pr-2">
<div
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
selectedOption.bgClass,
)}
>
{selectedOption.icon ? (
<img
src={selectedOption.icon}
alt={selectedOption.label}
className="h-3 w-3 object-contain invert brightness-0"
/>
) : (
<div className="h-2 w-2 rounded-full bg-white/70" />
)}
</div>
<span className="truncate whitespace-nowrap">{selectedOption.label}</span>
</div>
) : (
<SelectValue placeholder={t("hostDetails.distro.unknown")} />
);
})()}
</SelectTrigger>
<SelectContent className="min-w-[14rem]">
{distroOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<div
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
option.bgClass,
)}
>
{option.icon ? (
<img
src={option.icon}
alt={option.label}
className="h-3 w-3 object-contain invert brightness-0"
/>
) : (
<div className="h-2 w-2 rounded-full bg-white/70" />
)}
</div>
<span className="whitespace-nowrap">{option.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.detectedLabel")}</span>
<div className="flex h-8 items-center rounded-md border border-border/60 bg-background/50 px-3 text-sm">
{effectiveFormDistro
? getDistroOptionLabel(effectiveFormDistro)
: t("hostDetails.distro.unknown")}
</div>
</div>
)}
</div>
</HostDetailsSection>
)}
<HostDetailsSection
icon={<DistroAvatar host={form as Host} fallback="H" size="sm" />}
title={t("hostDetails.icon.sectionTitle")}
hint={t("hostDetails.icon.desc")}
>
<HostIconPicker
distroMode={form.distroMode}
manualDistro={form.manualDistro}
effectiveDistro={effectiveFormDistro}
distroOptions={distroOptions}
getDistroOptionLabel={getDistroOptionLabel}
iconMode={form.iconMode}
iconId={form.iconId}
iconColorMode={form.iconColorMode}
iconColor={form.iconColor}
iconColorCustom={form.iconColorCustom}
onChange={(next) => {
if ("distroMode" in next) update("distroMode", next.distroMode);
if ("manualDistro" in next) update("manualDistro", next.manualDistro);
if ("iconMode" in next) update("iconMode", next.iconMode);
if ("iconId" in next) update("iconId", next.iconId);
if ("iconColorMode" in next) update("iconColorMode", next.iconColorMode);
if ("iconColor" in next) update("iconColor", next.iconColor);
if ("iconColorCustom" in next) update("iconColorCustom", next.iconColorCustom);
}}
/>
</HostDetailsSection>
</>
);
};
@@ -296,3 +296,21 @@ test("HostDetailsPanel does not offer to disable telnet when telnet is the prima
assert.ok(telnetHeader);
assert.doesNotMatch(telnetHeader[0], /hover:text-destructive/);
});
test("HostDetailsPanel shows color and icon controls in the connection settings", () => {
const markup = renderHostDetails({
...hostWithMissingProxyProfile,
proxyProfileId: undefined,
distroMode: "manual",
manualDistro: "linux",
iconColorMode: "manual",
iconColor: "blue",
});
assert.match(markup, /Color &amp; Icon/);
assert.match(markup, /Manual icon/);
assert.match(markup, /Generic Linux/);
assert.match(markup, /Icon color/);
assert.match(markup, /Blue/);
assert.match(markup, /IP or Hostname/);
});
+132
View File
@@ -0,0 +1,132 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import { HostIconPicker } from "./HostIconPicker.tsx";
import { TooltipProvider } from "./ui/tooltip.tsx";
const distroOptions = [
{
value: "linux",
label: "Generic Linux",
icon: "/distro/linux.svg",
bgClass: "bg-[#333333]",
},
{
value: "ubuntu",
label: "Ubuntu",
icon: "/distro/ubuntu.svg",
bgClass: "bg-[#E95420]",
},
];
const hostIconPickerSource = readFileSync(new URL("./HostIconPicker.tsx", import.meta.url), "utf8");
const renderPicker = (props: Partial<React.ComponentProps<typeof HostIconPicker>> = {}) =>
renderToStaticMarkup(
<I18nProvider locale="en">
<TooltipProvider>
<HostIconPicker
distroMode={props.distroMode}
manualDistro={props.manualDistro}
effectiveDistro={props.effectiveDistro}
distroOptions={props.distroOptions || distroOptions}
getDistroOptionLabel={props.getDistroOptionLabel || ((value) => distroOptions.find((option) => option.value === value)?.label || "Unknown")}
iconMode={props.iconMode}
iconId={props.iconId}
iconColorMode={props.iconColorMode}
iconColor={props.iconColor}
iconColorCustom={props.iconColorCustom}
manualIconMenuOpen={props.manualIconMenuOpen}
onChange={() => {}}
/>
</TooltipProvider>
</I18nProvider>,
);
test("HostIconPicker renders automatic source and automatic color by default", () => {
const markup = renderPicker({ effectiveDistro: "ubuntu" });
assert.match(markup, /Source/);
assert.match(markup, /Auto-detect/);
assert.match(markup, /Current/);
assert.match(markup, /Ubuntu/);
assert.match(markup, /Icon color/);
assert.match(markup, /Automatic/);
assert.match(markup, /Current color/);
assert.doesNotMatch(markup, /Brand/);
assert.doesNotMatch(markup, /Custom color/);
});
test("HostIconPicker renders a single manual icon dropdown in manual source mode", () => {
const markup = renderPicker({ distroMode: "manual", manualDistro: "linux", manualIconMenuOpen: true });
assert.match(markup, /Manual icon/);
assert.match(markup, /Generic Linux/);
assert.match(markup, /aria-label="Manual icon"/);
});
test("HostIconPicker renders built-in type icons when a custom type is selected", () => {
const markup = renderPicker({ distroMode: "manual", iconMode: "custom", iconId: "database", manualIconMenuOpen: true });
assert.match(markup, /Database/);
assert.match(markup, /Current color/);
assert.match(markup, /background-color:#0891B2/);
});
test("HostIconPicker shows the automatic brand color preview", () => {
const markup = renderPicker({ distroMode: "manual", manualDistro: "ubuntu" });
assert.match(markup, /Current color/);
assert.match(markup, /bg-\[#E95420\]/);
assert.match(markup, /Ubuntu/);
});
test("HostIconPicker ignores stale colors when color mode is automatic", () => {
const markup = renderPicker({
distroMode: "manual",
iconMode: "custom",
iconId: "database",
iconColorMode: "auto",
iconColor: "violet",
iconColorCustom: "#12ABEF",
});
assert.match(markup, /Automatic/);
assert.match(markup, /Current color/);
assert.match(markup, /Database/);
assert.match(markup, /background-color:#0891B2/);
assert.doesNotMatch(markup, /#12ABEF/);
assert.doesNotMatch(markup, /Violet/);
});
test("HostIconPicker keeps brand/type tabs inside the manual icon dropdown menu", () => {
assert.match(hostIconPickerSource, /<SelectContent className="min-w-\[16rem\]" hideScrollButtons>[\s\S]*role="tab"[\s\S]*hostDetails\.icon\.tab\.brand/);
assert.match(hostIconPickerSource, /<SelectContent className="min-w-\[16rem\]" hideScrollButtons>[\s\S]*hostDetails\.icon\.tab\.type/);
assert.doesNotMatch(hostIconPickerSource, /<TabsList/);
});
test("HostIconPicker renders preset and custom color choices in manual color mode", () => {
const markup = renderPicker({ iconColorMode: "manual", iconColor: "violet" });
assert.match(markup, /Manual/);
assert.match(markup, /Violet/);
});
test("HostIconPicker renders a custom hex color input", () => {
const markup = renderPicker({ iconColorMode: "manual", iconColorCustom: "#12ABEF" });
assert.match(markup, /Custom color/);
assert.match(markup, /#12ABEF/);
assert.match(markup, /type="color"/);
});
test("HostIconPicker does not expose image upload", () => {
const markup = renderPicker({ distroMode: "manual", iconMode: "custom", iconId: "database" });
assert.doesNotMatch(markup, /upload/i);
assert.doesNotMatch(markup, /choose file/i);
});
+380
View File
@@ -0,0 +1,380 @@
import React from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import {
DEFAULT_HOST_ICON_COLOR,
DEFAULT_HOST_ICON_ID,
HOST_ICON_COLORS,
HOST_ICON_IDS,
isHostIconColorId,
isHostIconCustomColor,
isHostIconId,
resolveHostIconDefaultColorHex,
} from "../domain/hostIcon";
import type { HostIconColorId, HostIconColorMode, HostIconId, HostIconMode } from "../domain/models";
import { cn } from "../lib/utils";
import { renderHostIconGlyph } from "./hostIconRenderer";
import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
type DistroOption = {
value: string;
label: string;
icon?: string;
bgClass: string;
};
type HostIconPickerProps = {
distroMode?: "auto" | "manual";
manualDistro?: string;
effectiveDistro?: string;
distroOptions: DistroOption[];
getDistroOptionLabel: (value?: string) => string;
iconMode?: HostIconMode;
iconId?: HostIconId;
iconColorMode?: HostIconColorMode;
iconColor?: HostIconColorId;
iconColorCustom?: string;
manualIconMenuOpen?: boolean;
onChange: (next: {
distroMode?: "auto" | "manual";
manualDistro?: string;
iconMode?: HostIconMode;
iconId?: HostIconId;
iconColorMode?: HostIconColorMode;
iconColor?: HostIconColorId;
iconColorCustom?: string;
}) => void;
};
const renderBrandOption = (option: DistroOption) => (
<div className="flex min-w-0 items-center gap-2">
<div
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
option.bgClass,
)}
>
{option.icon ? (
<img src={option.icon} alt={option.label} className="h-3 w-3 object-contain invert brightness-0" />
) : (
<div className="h-2 w-2 rounded-full bg-white/70" />
)}
</div>
<span className="truncate whitespace-nowrap">{option.label}</span>
</div>
);
const renderTypeOption = (iconId: HostIconId, label: string, colorHex: string) => (
<div className="flex min-w-0 items-center gap-2">
<div
className="flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px] text-white"
style={{ backgroundColor: colorHex }}
>
{renderHostIconGlyph(iconId, "h-3 w-3")}
</div>
<span className="truncate whitespace-nowrap">{label}</span>
</div>
);
const renderBrandColorPreview = (option: DistroOption, label: string) => (
<div className="flex min-w-0 items-center gap-2">
<span className={cn("h-4 w-4 shrink-0 rounded-full border border-border/70", option.bgClass)} />
<span className="truncate whitespace-nowrap">{label}</span>
</div>
);
const renderTypeColorPreview = (label: string, colorHex: string) => (
<div className="flex min-w-0 items-center gap-2">
<span className="h-4 w-4 shrink-0 rounded-full border border-border/70" style={{ backgroundColor: colorHex }} />
<span className="truncate whitespace-nowrap">{label}</span>
</div>
);
const colorHexForId = (colorId?: HostIconColorId) =>
HOST_ICON_COLORS.find((color) => color.id === colorId)?.hex || HOST_ICON_COLORS[0].hex;
export const HostIconPicker: React.FC<HostIconPickerProps> = ({
distroMode,
manualDistro,
effectiveDistro,
distroOptions,
getDistroOptionLabel,
iconMode,
iconId,
iconColorMode,
iconColor,
iconColorCustom,
manualIconMenuOpen,
onChange,
}) => {
const { t } = useI18n();
const manualSource = distroMode === "manual" || iconMode === "custom";
const selectedBrandId = manualDistro || effectiveDistro || "linux";
const selectedBrand = distroOptions.find((option) => option.value === selectedBrandId);
const selectedIconId = isHostIconId(iconId) ? iconId : DEFAULT_HOST_ICON_ID;
const selectedIconTab = iconMode === "custom" ? "type" : "brand";
const manualColor = iconColorMode === "manual" ||
(iconColorMode !== "auto" && (isHostIconColorId(iconColor) || isHostIconCustomColor(iconColorCustom)));
const selectedPresetColor = isHostIconColorId(iconColor) ? iconColor : DEFAULT_HOST_ICON_COLOR;
const selectedCustomColor = isHostIconCustomColor(iconColorCustom) ? iconColorCustom : colorHexForId(selectedPresetColor);
const selectedColorValue = isHostIconCustomColor(iconColorCustom) ? "custom" : selectedPresetColor;
const manualPreviewColor = isHostIconCustomColor(iconColorCustom)
? selectedCustomColor
: colorHexForId(selectedPresetColor);
const selectedTypePreviewColor = manualColor
? manualPreviewColor
: resolveHostIconDefaultColorHex(selectedIconId);
const autoColorPreviewLabel = selectedIconTab === "type"
? t(`hostDetails.icon.option.${selectedIconId}`)
: getDistroOptionLabel(selectedBrandId);
const selectedManualIconValue = selectedIconTab === "type" ? `type:${selectedIconId}` : `brand:${selectedBrandId}`;
const [customColorDraft, setCustomColorDraft] = React.useState(selectedCustomColor);
const [iconMenuTab, setIconMenuTab] = React.useState<"brand" | "type">(selectedIconTab);
React.useEffect(() => {
setCustomColorDraft(selectedCustomColor);
}, [selectedCustomColor]);
React.useEffect(() => {
setIconMenuTab(selectedIconTab);
}, [selectedIconTab]);
const setSource = (value: "auto" | "manual") => {
if (value === "auto") {
onChange({ distroMode: "auto", iconMode: undefined, iconId: undefined });
return;
}
onChange({
distroMode: "manual",
manualDistro: selectedBrandId,
iconMode: undefined,
iconId: undefined,
});
};
const setManualIcon = (value: string) => {
if (value.startsWith("type:")) {
const nextIconId = value.slice("type:".length);
if (isHostIconId(nextIconId)) {
onChange({ distroMode: "manual", iconMode: "custom", iconId: nextIconId });
}
return;
}
if (value.startsWith("brand:")) {
onChange({
distroMode: "manual",
manualDistro: value.slice("brand:".length),
iconMode: undefined,
iconId: undefined,
});
}
};
const switchIconMenuTab = (nextTab: "brand" | "type") => {
setIconMenuTab(nextTab);
};
const setColorMode = (value: "auto" | "manual") => {
if (value === "auto") {
onChange({ iconColorMode: "auto", iconColor: undefined, iconColorCustom: undefined });
return;
}
onChange({ iconColorMode: "manual", iconColor: selectedPresetColor, iconColorCustom: undefined });
};
const setColorChoice = (value: string) => {
if (value === "custom") {
onChange({ iconColorMode: "manual", iconColor: undefined, iconColorCustom: selectedCustomColor });
return;
}
if (isHostIconColorId(value)) {
onChange({ iconColorMode: "manual", iconColor: value, iconColorCustom: undefined });
}
};
const setCustomColor = (value: string) => {
setCustomColorDraft(value);
if (isHostIconCustomColor(value)) {
onChange({
iconColorMode: "manual",
iconColor: undefined,
iconColorCustom: value,
});
}
};
return (
<div className="space-y-4">
<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>
<Select value={manualSource ? "manual" : "auto"} onValueChange={(value) => setSource(value as "auto" | "manual")}>
<SelectTrigger className="h-8" aria-label={t("hostDetails.icon.source")}>
<span className="truncate whitespace-nowrap pr-2 text-left">
{manualSource ? t("hostDetails.distro.mode.manual") : t("hostDetails.distro.mode.auto")}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t("hostDetails.distro.mode.auto")}</SelectItem>
<SelectItem value="manual">{t("hostDetails.distro.mode.manual")}</SelectItem>
</SelectContent>
</Select>
</div>
{manualSource ? (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.icon.manualLabel")}</span>
<Select
value={selectedManualIconValue}
onValueChange={setManualIcon}
{...(manualIconMenuOpen === undefined ? {} : { open: manualIconMenuOpen })}
>
<SelectTrigger className="h-8" aria-label={t("hostDetails.icon.manualLabel")}>
{selectedIconTab === "type"
? renderTypeOption(selectedIconId, t(`hostDetails.icon.option.${selectedIconId}`), selectedTypePreviewColor)
: selectedBrand
? renderBrandOption(selectedBrand)
: <SelectValue placeholder={t("hostDetails.distro.unknown")} />}
</SelectTrigger>
<SelectContent className="min-w-[16rem]" hideScrollButtons>
<div className="sticky top-0 z-10 bg-popover px-1 pb-1">
<div className="grid h-8 grid-cols-2 rounded-md bg-muted/50 p-0.5">
<button
type="button"
role="tab"
aria-selected={iconMenuTab === "brand"}
className={cn(
"rounded-sm text-xs font-medium transition-colors",
iconMenuTab === "brand" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground",
)}
onPointerDown={(event) => {
event.preventDefault();
switchIconMenuTab("brand");
}}
>
{t("hostDetails.icon.tab.brand")}
</button>
<button
type="button"
role="tab"
aria-selected={iconMenuTab === "type"}
className={cn(
"rounded-sm text-xs font-medium transition-colors",
iconMenuTab === "type" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground",
)}
onPointerDown={(event) => {
event.preventDefault();
switchIconMenuTab("type");
}}
>
{t("hostDetails.icon.tab.type")}
</button>
</div>
</div>
{iconMenuTab === "brand"
? distroOptions.map((option) => (
<SelectItem key={option.value} value={`brand:${option.value}`}>
{renderBrandOption(option)}
</SelectItem>
))
: HOST_ICON_IDS.map((optionIconId) => (
<SelectItem key={optionIconId} value={`type:${optionIconId}`}>
{renderTypeOption(
optionIconId,
t(`hostDetails.icon.option.${optionIconId}`),
manualColor ? manualPreviewColor : resolveHostIconDefaultColorHex(optionIconId),
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.detectedLabel")}</span>
<div className="flex h-8 items-center rounded-md border border-border/60 bg-background/50 px-3 text-sm">
{effectiveDistro ? getDistroOptionLabel(effectiveDistro) : t("hostDetails.distro.unknown")}
</div>
</div>
)}
</div>
<div className="border-t border-border/60 pt-3">
<div className="grid gap-2 md:grid-cols-2">
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.icon.colorLabel")}</span>
<Select value={manualColor ? "manual" : "auto"} onValueChange={(value) => setColorMode(value as "auto" | "manual")}>
<SelectTrigger className="h-8" aria-label={t("hostDetails.icon.colorLabel")}>
<span className="truncate whitespace-nowrap pr-2 text-left">
{manualColor ? t("hostDetails.icon.colorManual") : t("hostDetails.icon.colorAuto")}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t("hostDetails.icon.colorAuto")}</SelectItem>
<SelectItem value="manual">{t("hostDetails.icon.colorManual")}</SelectItem>
</SelectContent>
</Select>
</div>
{manualColor ? (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.icon.colorChoice")}</span>
<Select value={selectedColorValue} onValueChange={setColorChoice}>
<SelectTrigger className="h-8" aria-label={t("hostDetails.icon.colorChoice")}>
<div className="flex min-w-0 items-center gap-2 pr-2">
<span className="h-4 w-4 shrink-0 rounded-full border border-border/70" style={{ backgroundColor: manualPreviewColor }} />
<span className="truncate whitespace-nowrap">
{selectedColorValue === "custom"
? t("hostDetails.icon.color.custom")
: t(`hostDetails.icon.color.${selectedPresetColor}`)}
</span>
</div>
</SelectTrigger>
<SelectContent>
{HOST_ICON_COLORS.map((color) => (
<SelectItem key={color.id} value={color.id}>
<div className="flex items-center gap-2">
<span className="h-4 w-4 rounded-full border border-border/70" style={{ backgroundColor: color.hex }} />
<span>{t(`hostDetails.icon.color.${color.id}`)}</span>
</div>
</SelectItem>
))}
<SelectItem value="custom">{t("hostDetails.icon.color.custom")}</SelectItem>
</SelectContent>
</Select>
</div>
) : (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.icon.autoColorLabel")}</span>
<div className="flex h-8 items-center rounded-md border border-border/60 bg-background/50 px-3 text-sm">
{selectedIconTab === "type"
? renderTypeColorPreview(autoColorPreviewLabel, selectedTypePreviewColor)
: selectedBrand
? renderBrandColorPreview(selectedBrand, autoColorPreviewLabel)
: renderTypeColorPreview(t("hostDetails.distro.unknown"), colorHexForId(DEFAULT_HOST_ICON_COLOR))}
</div>
</div>
)}
</div>
{manualColor && selectedColorValue === "custom" && (
<div className="mt-2 flex items-center gap-2">
<Input
type="color"
value={isHostIconCustomColor(customColorDraft) ? customColorDraft : selectedCustomColor}
onChange={(event) => setCustomColor(event.target.value)}
className="h-8 w-12 shrink-0 cursor-pointer p-1"
aria-label={t("hostDetails.icon.color.custom")}
/>
<Input
value={customColorDraft}
onChange={(event) => setCustomColor(event.target.value)}
className="h-8 font-mono text-xs"
aria-label={t("hostDetails.icon.color.custom")}
/>
</div>
)}
</div>
</div>
);
};
+105 -102
View File
@@ -1,4 +1,4 @@
import { CheckSquare, ChevronRight, Edit2, FileSymlink, Folder, FolderOpen, Server, Square, Expand, Minimize2 } from 'lucide-react';
import { CheckSquare, Edit2, FileSymlink, Server, Square, Expand, Minimize2 } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import {
@@ -11,15 +11,14 @@ import { applyGroupDefaults, resolveGroupDefaults } from '../domain/groupConfig'
import { resolveTelnetPort, resolveTelnetUsername, sanitizeHost } from '../domain/host';
import { sortByVaultOrder } from '../domain/vaultOrder';
import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys';
import { cn } from '../lib/utils';
import { GroupConfig, GroupNode, Host } from '../types';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
import { HostTreeGroupContextMenuContent, HostTreeHostContextMenuContent } from './host/HostTreeContextMenus';
import { HostTreeGroupInlineRenameInput } from './host/HostTreeGroupInlineRenameInput';
import { ContextMenu, ContextMenuTrigger } from './ui/context-menu';
import { DistroAvatar } from './DistroAvatar';
import { HostNotesIndicator } from './host/HostNotesIndicator';
import { Button } from './ui/button';
import { VaultTreeGroupRow, VaultTreeItemRow } from './vault/VaultTreeRow';
const getTreeGroupDropIntent = (
element: HTMLElement,
@@ -95,6 +94,7 @@ interface TreeNodeProps {
getDropTargetClasses?: (target: string) => string;
setDragOverDropTarget?: (target: string | null) => void;
groupConfigs: GroupConfig[];
groupDefaultsByPath: ReadonlyMap<string, Partial<GroupConfig>>;
}
@@ -126,6 +126,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
getDropTargetClasses,
setDragOverDropTarget,
groupConfigs,
groupDefaultsByPath,
}) => {
const inlineEdit = useHostTreeInlineGroupEdit();
const vaultTreeActions = useVaultHostTreeActions();
@@ -144,7 +145,6 @@ const TreeNode: React.FC<TreeNodeProps> = ({
return () => cancelAnimationFrame(frame);
}, [inlineEdit?.groupPath, inlineEdit?.shouldScrollIntoView, isInlineEditing]);
const hasChildren = node.children && Object.keys(node.children).length > 0;
const paddingLeft = `${depth * 20 + 12}px`;
const isManaged = managedGroupPaths?.has(node.path) ?? false;
const hostsCountInNode = node.totalHostCount ?? node.hosts.length;
@@ -216,13 +216,18 @@ const TreeNode: React.FC<TreeNodeProps> = ({
<ContextMenu>
<ContextMenuTrigger>
<CollapsibleTrigger asChild>
<div
ref={groupRowRef}
className={cn(
"vault-drop-indicator-row flex items-center py-2 pr-3 text-sm font-medium cursor-pointer transition-colors select-none group hover:bg-secondary/60 rounded-lg",
getDropTargetClasses?.(node.path),
)}
style={{ paddingLeft }}
<VaultTreeGroupRow
rowRef={groupRowRef}
name={node.name}
depth={depth}
expanded={isExpanded}
hasChildren={hasChildren || node.hosts.length > 0}
count={hostsCountInNode}
editing={isInlineEditing}
editingInitialName={inlineEdit?.initialName}
onRenameCommit={commitRename}
onRenameCancel={cancelRename}
className={getDropTargetClasses?.(node.path)}
data-section="host-tree-row"
data-row-type="group"
data-group-path={node.path}
@@ -261,52 +266,24 @@ const TreeNode: React.FC<TreeNodeProps> = ({
moveGroup(groupPath, node.path);
}
}}
>
<div className="mr-2 flex-shrink-0 w-4 h-4 flex items-center justify-center">
{(hasChildren || node.hosts.length > 0) && (
<div className={cn("transition-transform duration-200", isExpanded ? "rotate-90" : "")}>
<ChevronRight size={14} />
</div>
)}
</div>
<div className="mr-3 flex h-8 w-8 shrink-0 items-center justify-center text-primary transition-colors dark:text-primary">
{isExpanded ? (
<FolderOpen size={21} strokeWidth={2.35} />
) : (
<Folder size={21} strokeWidth={2.35} />
)}
</div>
{isInlineEditing && commitRename && cancelRename ? (
<HostTreeGroupInlineRenameInput
initialName={inlineEdit.initialName}
onCommit={commitRename}
onCancel={cancelRename}
className="flex-1 font-semibold"
/>
) : (
<span className="truncate flex-1 font-semibold">{node.name}</span>
)}
{isManaged && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium px-1.5 py-0.5 rounded bg-primary/15 text-primary shrink-0 mr-1.5">
meta={isManaged && (
<span className="mr-1.5 inline-flex shrink-0 items-center gap-1 rounded bg-primary/15 px-1.5 py-0.5 text-[10px] font-medium text-primary">
<FileSymlink size={10} />
Managed
</span>
)}
{(node.hosts.length > 0 || hasChildren) && (
<span className="text-xs opacity-70 bg-background/50 px-2 py-0.5 rounded-full border border-border">
{hostsCountInNode}
</span>
actions={(
<button
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md opacity-0 transition-colors hover:bg-secondary/80 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onEditGroup(node.path);
}}
>
<Edit2 size={13} />
</button>
)}
<button
className="h-7 w-7 flex items-center justify-center rounded-md hover:bg-secondary/80 transition-colors opacity-0 group-hover:opacity-100 shrink-0"
onClick={(e) => {
e.stopPropagation();
onEditGroup(node.path);
}}
>
<Edit2 size={13} />
</button>
</div>
/>
</CollapsibleTrigger>
</ContextMenuTrigger>
<HostTreeGroupContextMenuContent
@@ -351,6 +328,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
getDropTargetClasses={getDropTargetClasses}
setDragOverDropTarget={setDragOverDropTarget}
groupConfigs={groupConfigs}
groupDefaultsByPath={groupDefaultsByPath}
/>
))}
@@ -371,6 +349,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
groupConfigs={groupConfigs}
groupDefaultsByPath={groupDefaultsByPath}
/>
))}
</CollapsibleContent>
@@ -393,14 +372,16 @@ interface HostTreeItemProps {
selectedHostIds?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
groupConfigs: GroupConfig[];
groupDefaultsByPath: ReadonlyMap<string, Partial<GroupConfig>>;
}
export const getHostTreeDisplayDetails = (
host: Host,
groupConfigs: GroupConfig[] = [],
groupDefaultsByPath?: ReadonlyMap<string, Partial<GroupConfig>>,
) => {
const displayHost = host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs))
? applyGroupDefaults(host, groupDefaultsByPath?.get(host.group) ?? resolveGroupDefaults(host.group, groupConfigs))
: host;
const isTelnet = displayHost.protocol === 'telnet';
return {
@@ -428,13 +409,13 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
selectedHostIds,
toggleHostSelection,
groupConfigs,
groupDefaultsByPath,
}) => {
const paddingLeft = `${depth * 20 + 12}px`;
const safeHost = sanitizeHost(host);
const tags = host.tags || [];
const displayDetails = useMemo(
() => getHostTreeDisplayDetails(host, groupConfigs),
[groupConfigs, host],
() => getHostTreeDisplayDetails(host, groupConfigs, groupDefaultsByPath),
[groupConfigs, groupDefaultsByPath, host],
);
const displayProtocol = displayDetails.protocol;
const displayUsername = displayDetails.username;
@@ -443,13 +424,12 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
return (
<ContextMenu>
<ContextMenuTrigger>
<div
className={cn(
"vault-drop-indicator-row flex items-center py-2 pr-3 text-sm cursor-pointer transition-colors select-none group hover:bg-secondary/40 rounded-lg",
isSelected ? "bg-primary/10" : "",
)}
style={{ paddingLeft }}
<ContextMenuTrigger asChild>
<VaultTreeItemRow
label={host.label}
depth={depth}
selected={Boolean(isSelected)}
className="h-10 rounded-md py-1 pr-2 text-[13px]"
data-section="host-tree-row"
data-row-type="host"
data-host-id={host.id}
@@ -462,55 +442,61 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
onConnect(safeHost);
}
}}
>
{isMultiSelectMode && (
<div className="mr-2 flex-shrink-0" onClick={(e) => {
leading={isMultiSelectMode ? (
<div className="mr-2 flex h-5 w-4 flex-shrink-0 items-center justify-center" onClick={(e) => {
e.stopPropagation();
toggleHostSelection?.(host.id);
}}>
{isSelected ? (
<CheckSquare size={18} className="text-primary" />
<CheckSquare size={15} className="text-primary" />
) : (
<Square size={18} className="text-muted-foreground" />
<Square size={15} className="text-muted-foreground" />
)}
</div>
) : (
<div className="mr-2 h-4 w-4 flex-shrink-0" />
)}
{!isMultiSelectMode && <div className="mr-2 flex-shrink-0 w-4 h-4" />}
<div className="mr-3 flex-shrink-0">
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="tree" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium truncate flex items-center gap-1.5">
<span className="truncate">{host.label}</span>
<HostNotesIndicator notes={host.notes} />
icon={(
<div className="mr-2 flex-shrink-0">
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="tree" />
</div>
<div className="text-xs text-muted-foreground truncate">
{displayUsername}@{host.hostname}:{displayPort}
)}
content={(
<div className="min-w-0 flex-1 leading-tight">
<div className="flex items-center gap-1.5 truncate font-medium leading-4">
<span className="truncate">{host.label}</span>
<HostNotesIndicator notes={host.notes} />
</div>
<div className="truncate text-[11px] leading-4 text-muted-foreground">
{displayUsername}@{host.hostname}:{displayPort}
</div>
</div>
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
{displayProtocol && displayProtocol !== 'ssh' && (
<span className="text-xs px-1.5 py-0.5 bg-primary/10 text-primary rounded">
{displayProtocol.toUpperCase()}
</span>
)}
{tags.length > 0 && (
<span className="text-xs opacity-60">
{tags.slice(0, 2).join(', ')}
{tags.length > 2 && '...'}
</span>
)}
<button
className="h-7 w-7 flex items-center justify-center rounded-md hover:bg-secondary/80 transition-colors"
onClick={(e) => {
e.stopPropagation();
onEditHost(host);
}}
>
<Edit2 size={13} />
</button>
</div>
</div>
)}
actions={(
<div className="ml-2 flex shrink-0 items-center gap-1.5 opacity-0 transition-opacity group-hover:opacity-100">
{displayProtocol && displayProtocol !== 'ssh' && (
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] leading-none text-primary">
{displayProtocol.toUpperCase()}
</span>
)}
{tags.length > 0 && (
<span className="text-[10px] opacity-60">
{tags.slice(0, 2).join(', ')}
{tags.length > 2 && '...'}
</span>
)}
<button
className="flex h-6 w-6 items-center justify-center rounded-md transition-colors hover:bg-secondary/80"
onClick={(e) => {
e.stopPropagation();
onEditHost(host);
}}
>
<Edit2 size={12} />
</button>
</div>
)}
/>
</ContextMenuTrigger>
<HostTreeHostContextMenuContent
host={host}
@@ -595,6 +581,21 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
const allGroupPaths = useMemo(() => getAllGroupPaths(groupTree), [groupTree]);
const groupDefaultsByPath = useMemo(() => {
const paths = new Set(allGroupPaths);
for (const host of hosts) {
if (host.group) {
paths.add(host.group);
}
}
const defaultsByPath = new Map<string, Partial<GroupConfig>>();
for (const path of paths) {
defaultsByPath.set(path, resolveGroupDefaults(path, groupConfigs));
}
return defaultsByPath;
}, [allGroupPaths, groupConfigs, hosts]);
const handleExpandAll = () => {
expandAll(allGroupPaths);
};
@@ -701,6 +702,7 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
getDropTargetClasses={getDropTargetClasses}
setDragOverDropTarget={setDragOverDropTarget}
groupConfigs={groupConfigs}
groupDefaultsByPath={groupDefaultsByPath}
/>
))}
@@ -720,6 +722,7 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
groupConfigs={groupConfigs}
groupDefaultsByPath={groupDefaultsByPath}
/>
))}
+1
View File
@@ -25,6 +25,7 @@ export interface KeyboardInteractivePrompt {
export interface KeyboardInteractiveRequest {
requestId: string;
sessionId?: string;
scope?: "terminal" | "external";
name: string;
instructions: string;
prompts: KeyboardInteractivePrompt[];
+150
View File
@@ -0,0 +1,150 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import { STORAGE_KEY_VAULT_KEYS_VIEW_MODE } from "../infrastructure/config/storageKeys.ts";
import type { Identity, SSHKey } from "../types.ts";
import KeychainManager from "./KeychainManager.tsx";
import { IdentityCard } from "./keychain/IdentityCard.tsx";
import { KeyCard } from "./keychain/KeyCard.tsx";
const longLabel =
"sdakdjkasjakjskajskaijssdakdjkasjakjskajskaijssdakdjkasjakjskajskaijssdakdjkasjakjskajskaijs";
const renderWithI18n = (node: React.ReactElement) =>
renderToStaticMarkup(
React.createElement(I18nProvider, { locale: "en" }, node),
);
const installStorageStub = (viewMode: string | null = null) => {
const values = new Map<string, string>();
if (viewMode) {
values.set(STORAGE_KEY_VAULT_KEYS_VIEW_MODE, viewMode);
}
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => {
values.set(key, value);
},
removeItem: (key: string) => {
values.delete(key);
},
},
});
};
const installNavigatorStub = () => {
const currentNavigator = globalThis.navigator;
if (
typeof currentNavigator?.platform === "string" &&
typeof currentNavigator?.userAgent === "string"
) {
return;
}
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
platform: "MacIntel",
userAgent: "Mac OS",
},
});
};
const identity: Identity = {
id: "identity-1",
label: longLabel,
username: "root",
authMethod: "password",
password: "pw",
created: 1,
};
const keyItem: SSHKey = {
id: "key-1",
label: longLabel,
type: "ED25519",
privateKey: "",
publicKey: "",
source: "imported",
category: "key",
created: 1,
};
test("IdentityCard list layout constrains long labels", () => {
const markup = renderWithI18n(
React.createElement(IdentityCard, {
identity,
viewMode: "list",
isSelected: false,
onClick: () => {},
}),
);
assert.match(markup, /group cursor-pointer min-w-0 w-full max-w-full/);
assert.doesNotMatch(markup, /group cursor-pointer min-w-0 w-full max-w-full overflow-hidden/);
assert.match(markup, /flex items-center gap-3 h-full min-w-0/);
assert.doesNotMatch(markup, /flex items-center gap-3 h-full min-w-0 overflow-hidden/);
assert.match(markup, /min-w-0 flex-1 basis-0 overflow-hidden/);
assert.match(markup, /block max-w-full truncate text-sm font-semibold/);
});
test("KeyCard list layout constrains long labels", () => {
const markup = renderWithI18n(
React.createElement(KeyCard, {
keyItem,
viewMode: "list",
isSelected: false,
isMac: false,
onClick: () => {},
onEdit: () => {},
onExport: () => {},
onCopyPublicKey: () => {},
onDelete: () => {},
}),
);
assert.match(markup, /group cursor-pointer min-w-0 w-full max-w-full/);
assert.doesNotMatch(markup, /group cursor-pointer min-w-0 w-full max-w-full overflow-hidden/);
assert.match(markup, /flex items-center gap-3 h-full min-w-0/);
assert.doesNotMatch(markup, /flex items-center gap-3 h-full min-w-0 overflow-hidden/);
assert.match(markup, /min-w-0 flex-1 basis-0 overflow-hidden/);
assert.match(markup, /block max-w-full truncate text-sm font-semibold/);
});
test("KeychainManager list layout constrains long key and identity rows", () => {
installNavigatorStub();
installStorageStub("list");
const markup = renderWithI18n(
React.createElement(KeychainManager, {
keys: [keyItem],
identities: [identity],
hosts: [],
proxyProfiles: [],
customGroups: [],
groupConfigs: [],
managedSources: [],
onSave: () => {},
onUpdate: () => {},
onReorderKeys: () => {},
onDelete: () => {},
onSaveIdentity: () => {},
onReorderIdentities: () => {},
onDeleteIdentity: () => {},
}),
);
assert.match(markup, /h-full min-w-0 w-full overflow-hidden flex relative/);
assert.match(markup, /flex-1 min-w-0 w-full overflow-y-auto/);
assert.doesNotMatch(markup, /flex-1 min-w-0 w-full overflow-y-auto overflow-x-hidden/);
assert.match(markup, /flex min-w-0 w-full max-w-full flex-col gap-0/);
assert.match(markup, /block min-w-0 w-full max-w-full/);
assert.match(markup, /Keys/);
assert.match(markup, /Identities/);
});
Loaded 100 of 489 files, more files were not shown because too many files have changed in this diff. Show more