Compare commits

...

81 Commits

Author SHA1 Message Date
陈大猫
7771592cf2 feat(shortcuts): Ctrl+W closes the tab directly + add configurable side-panel toggle (#1098)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
* feat(shortcuts): add resolveSidePanelToggleIntent pure resolver

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

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

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

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

* feat(shortcuts): dispatch toggleSidePanel hotkey to TerminalLayer

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

Related to #1084.

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

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

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

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

---------

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

---------

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

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

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

No behavior change; production build is warning-free.

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

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

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

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

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

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

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

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

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

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

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

* feat #1064: renderer prompt for rz overwrite conflicts

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix review issue

* fix(terminal): harden prompt newline handling

---------

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

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

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

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

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

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

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

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

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

* fix review issue

* Fix prompt cache initialization

* Serialize terminal output writes for prompt breaks

* Keep terminal status lines ordered with output

* Fix prompt arming without command callback

* Keep prompt display breaks out of session logs

* Avoid prompt breaks for output suffix matches

---------

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

* Added the Russian translation

* Complete Russian SFTP transfer translations

* Add Russian reconnect menu translation

---------

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

* Use workspace session id for context paste broadcast

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Approach:

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

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

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

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

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

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

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

* Address review: per-direction Telnet negotiation tracking

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

---------

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

* Fix serial line mode pasted chunks

* Narrow long paste display cleanup scope

* Strip only matched paste echo highlights

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Includes en + zh-CN i18n strings.

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

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

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

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

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

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

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

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

Results are cached per process; clearFontAvailabilityCache() invalidates.

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

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

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

This commit filters both dropdowns through isFontInstalled():

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

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

Both selects always keep the currently-selected value visible — even
when the underlying font is missing — so users can read and clear
their setting without surprise.

Also expands `npm test` globs to pick up infrastructure/config/*.test.ts
and lib/*.test.ts, which previously matched no patterns and meant the
new cjkFonts and fontAvailability suites were silently excluded from
CI runs.

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

* fix(fonts): never recommend proportional CJK fonts for terminal use

The previous PingFang SC / Microsoft YaHei UI / Hiragino Sans GB choices
were proportional sans-serif fonts whose CJK glyphs aren't designed to
fit a terminal's 2x cell grid — the rendered Chinese ended up visibly
wider than its allocated cells, breaking grid alignment (reported on
macOS with PingFang SC selected as the CJK font).

Changes:
  - TerminalCjkFontSelect: drops PingFang SC / Microsoft YaHei UI /
    Hiragino Sans GB from the dropdown. Legacy explicit selections
    still surface as a synthetic "not recommended" option so users can
    see and re-pick.
  - CJK_SYSTEM_FALLBACK_FONTS: monospace-only list. Sarasa Mono SC/TC,
    Maple Mono CN, LXGW WenKai Mono, Noto Sans Mono CJK SC, Source Han
    Mono SC, NSimSun, SimSun. Proportional fonts removed.
  - PER_FONT_CJK_PAIRING: every entry now points at a true monospace
    CJK font. Cascadia / Consolas / Menlo etc. all recommend Sarasa
    Mono SC, which the next commit bundles via @font-face.
  - getDefaultCjkFallback: Windows = SimSun (always installed,
    monospace); macOS = Sarasa Mono SC (will be bundled); Linux =
    Noto Sans Mono CJK SC. A regression test enforces that no
    per-OS default is a known proportional font.

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

* feat(fonts): bundle Sarasa Mono SC as the universal CJK monospace

Previous commit removed proportional CJK fonts (PingFang SC, etc.)
from the picker and switched per-OS defaults to true monospace, but
macOS ships NO system-installed monospace CJK font — leaving macOS
users with a broken default unless they manually install Sarasa or
similar. This commit closes that gap by bundling Sarasa Mono SC as
an @font-face webfont, so the recommended pairings and macOS default
"just work" out of the box.

Details:
  - public/fonts/SarasaMonoSC-Regular.woff2 (~4.8 MB): subsetted from
    be5invis/Sarasa-Gothic v1.0.37 SarasaMonoSC-Regular.ttf (24 MB).
    Covers ASCII, Latin-1, common punctuation/symbols, CJK Unified
    Ideographs main block, Hiragana/Katakana, halfwidth/fullwidth,
    box-drawing — the everyday-Chinese coverage that matters for a
    terminal. Rare CJK Ext-A/B/historical chars fall through to the
    system fallback stack.
  - public/fonts/SarasaMono-LICENSE.txt: OFL-1.1 verbatim, required
    by the license.
  - index.css: @font-face declaration with font-display: swap so the
    user doesn't see a flash of nothing while the woff2 loads.
  - KNOWN_BUNDLED_FAMILIES: "Sarasa Mono SC" added so the dropdown
    availability filter doesn't hide it.

Installer impact: ~+4.8 MB (vs current ~100-200 MB Electron baseline).
The font replaces what would otherwise have been "Chinese chars look
broken in the terminal" for every macOS user without a manually
installed CJK monospace font.

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

* fix(fonts): use Local Font Access API as the authoritative install check

document.fonts.check() turned out to be unreliable as an installed-font
signal in Chromium — it returns true for any syntactically-valid family
name regardless of whether the font is actually installed, as a
deliberate fingerprinting-mitigation. The previous detector took it as
a positive signal and ended up keeping uninstalled fonts in the dropdown
(reported by a macOS user seeing dozens of fonts they don't have).

This commit pivots the detection chain:

  - lib/localFonts.ts: getAllSystemFontFamilies() exposes the unfiltered
    set of installed family names from queryLocalFonts(), reusing the
    same underlying call as getMonospaceFonts() via a shared cache.

  - lib/fontAvailability.ts: drops the document.fonts.check fast-path.
    Adds setSystemFamilies() / hasAuthoritativeData(). When the set has
    been populated, isFontInstalled answers from membership lookup
    directly — no canvas guessing. Canvas remains as a fallback for
    environments where the Local Font Access API is unavailable or
    permission is denied.

  - application/state/fontStore.ts: during initialize(), runs the
    monospace-only query and the full-system-families query together,
    then pipes the result into fontAvailability.

  - TerminalFontSelect: with authoritative data, drops the "if filtered
    list is suspiciously small, show all" safety net. Empty would now
    really mean empty (highly unlikely since Sarasa Mono SC is bundled).

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

* fix(fonts): drop PingFang SC / Microsoft YaHei UI from primary dropdown

Step 1 of this PR removed proportional CJK fonts from the CJK fallback
picker but left them in BASE_TERMINAL_FONTS, so PingFang SC and
Microsoft YaHei UI were still selectable as the *primary* terminal
font. Picking PingFang SC as primary produced visibly bloated Latin
character spacing (xterm.js samples cell width from the primary font;
the wide proportional 'M' inflates every cell), reported by a macOS
user in the same thread that opened #931.

Both entries are removed from BASE_TERMINAL_FONTS. A new
infrastructure/config/fonts.test.ts asserts that no known proportional
CJK font name (including PingFang TC/HK, Microsoft YaHei variants,
Hiragino Sans GB, Heiti SC/TC) is ever shipped in TERMINAL_FONTS as a
primary choice.

Migration for users already saved to one of the removed ids:
useSettingsState rewrites STORAGE_KEY_TERM_FONT_FAMILY to the default
(Menlo) on read when it sees a deprecated id, so the bad value also
stops getting carried into cloud-sync uploads. Per-host fontFamily
overrides are NOT migrated automatically — they still gracefully
fall through to the dropdown's first entry via the existing
getFontById fallback; users can re-pick from the host settings UI.

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

* fix(fonts): drop Comic Sans MS — it's a proportional handwriting font

Same symptom as the PingFang SC / Microsoft YaHei UI removal: Comic
Sans MS was historically in the primary font dropdown labeled
"Casual, non-traditional terminal font", but Comic Sans is a
handwriting-style proportional sans-serif. Picking it as the terminal
primary inflates cell width and spaces every Latin character far
apart (reported in the same #931 thread).

- BASE_TERMINAL_FONTS: comic-sans-ms entry removed.
- DEPRECATED_PRIMARY_FONT_IDS: gains comic-sans-ms so existing
  selections silently migrate to Menlo on read.
- fonts.test.ts: the proportional-font ban list now also covers
  Latin proportional fonts (Comic Sans MS, Arial, Helvetica, Times
  New Roman, Georgia, Verdana, Trebuchet MS, Tahoma) so the test
  catches any future mislabeled body-text font from being added to
  the terminal dropdown.

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

* fix(fonts): keep monospace ahead of CJK fallbacks in composed stack

Addresses codex P1 review comment on PR #940
(https://github.com/binaricat/Netcatty/pull/940#discussion_r3216017737).

The previous behavior of withCjkFallback() had monospace immediately
after the primary family, before any CJK fallback. composeFontFamilyStack
had moved monospace to the very end, which means: when the primary
font isn't installed on the user's machine (common for Layer 3 CJK
choices that aren't bundled and not present on a given OS, or for any
built-in id like cascadia-code on a Linux system without it), CSS
per-glyph fallback resolves Latin glyphs from a CJK font's full-width
Latin variants before ever reaching monospace generic. That breaks
xterm.js's fixed cell-grid alignment.

The composed stack now reads:
  <primary>, monospace, <userFallback>, <recommended-cjk>,
  <system-cjk-stack>, <nerd-font-stack>

Per-glyph CSS fallback behavior:
  - Latin → primary if installed → monospace generic. Cell width
    stays consistent.
  - CJK → primary (no) → monospace (no Chinese glyphs) → walks into
    CJK fallbacks.
  - Nerd PUA → falls past all of the above into the Nerd Font stack.

Updates the position-invariant tests and adds a regression test that
explicitly asserts monospace appears before every CJK family in the
output stack.

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

* fix(fonts): dedupe Local Font Access API calls under concurrent init

Addresses codex P2 review on PR #940:
  https://github.com/binaricat/Netcatty/pull/940#discussion_r3216246xxx

fontStore.initialize() runs getMonospaceFonts() and
getAllSystemFontFamilies() in Promise.all; both internally called
queryAllSystemFontsOnce(), whose cache check (`if (cache) return`) was
only useful once the result had been written. Concurrent callers both
passed the empty-cache check and fired their own queryLocalFonts()
request — two real Local Font Access API invocations on cold start,
with the risk of one succeeding while the other was denied (leaving
the authoritative set unset).

Fix: cache the *in-flight promise itself*, so subsequent callers
await the same single invocation. The first await populates the
family-set cache as a side effect, and the resolved promise keeps
returning the same value to every subsequent caller.

Adds lib/localFonts.test.ts with three regression tests:
  - concurrent getMonospaceFonts + getAllSystemFontFamilies = 1 API call
  - sequential repeats also reuse the resolved promise
  - missing API returns null authoritative set (canvas fallback signal)

Exports __resetLocalFontsCacheForTesting() so each test gets a fresh
module-level state.

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

* fix(fonts): retry LFA on transient failure + notify on availability changes

Two follow-up fixes from codex P2 review on PR #940:

1) queryAllSystemFontsOnce() previously kept its in-flight promise even
   when queryLocalFonts threw. Subsequent callers reused the cached
   empty result for the rest of the session, so any transient failure
   at boot (permission state not ready, AbortError, etc.) permanently
   blinded the rest of the app to installed fonts. Catch now clears
   queryPromise so the next caller retries. Regression test added.

2) TerminalCjkFontSelect.visibleOptions and TerminalFontSelect
   .visibleFonts were memoized on [value] / [fonts, value] only, but
   the filter calls isFontInstalled() which reads module-level
   systemFamilies — a value that arrives asynchronously after the
   initial render. The memos never recomputed when authoritative
   availability data landed, so the dropdowns could continue showing
   stale "filtered" results until the user changed selection.

   fontAvailability now exposes subscribeFontAvailability() and
   getFontAvailabilityVersion() (monotonic counter bumped on
   setSystemFamilies / clearFontAvailabilityCache). Both selects
   subscribe via useSyncExternalStore and include the version in
   their memo deps; tests cover subscriber notification and version
   monotonicity.

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

* fix(fonts): migrate host/group deprecated font ids + localize CJK labels

Two follow-up fixes from codex review on PR #940:

P2 — Host/group level font migration
====================================
The earlier deprecated-id migration only rewrote
STORAGE_KEY_TERM_FONT_FAMILY, so hosts and group configs that had
explicitly opted into a now-removed font id (e.g. pingfang-sc,
microsoft-yahei, comic-sans-ms) kept `fontFamily` set with
`fontFamilyOverride=true`. After the dropdown entries were dropped
in 9f2bd282/c9b622d8, those records silently fell through to the
first font in the registry (Menlo) while the override flag still
read "true" — users saw a host claiming a custom font but rendering
the global default with no way to tell what happened.

Fix:
  - infrastructure/config/fonts.ts gains migrateDeprecatedFontOverride(),
    a structurally-shared helper that drops fontFamily and clears
    fontFamilyOverride when the id is deprecated.
  - sanitizeHost now runs it on every host load.
  - domain/groupConfig.ts grows sanitizeGroupConfig(); useVaultState
    applies it both on initial load and on cross-tab storage events.
  - Existing decrypt → sanitize → encrypt round-trip in useVaultState
    means the migrated values are persisted back to localStorage and
    propagate through cloud sync naturally.

Tests: two each in domain/host.test.ts and domain/groupConfig.test.ts
covering deprecated-id reset and untouched-valid-id preservation.

P3 — Localize CJK font option labels
====================================
TerminalCjkFontSelect previously hardcoded Chinese option labels
("Auto · 按主字体智能搭配", "Sarasa Mono SC (更纱黑体 简)", etc.) and
the synthetic "not recommended" warning. Non-Chinese locales saw a
mixed-language UI despite the rest of the setting going through i18n.

OPTIONS now references i18n keys; the component looks them up via
useI18n(). Both en and zh-CN locales gain matching keys, including
`...option.legacy` with `{font}` interpolation for the synthetic
"not recommended" item that surfaces saved-but-removed values.

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

* fix(fonts): also sanitize group configs on the write/import path

Addresses codex P2 review on PR #940:
  https://github.com/binaricat/Netcatty/pull/940#discussion_r3216314xxx

The previous commit (09c87820) added sanitizeGroupConfig() but only
plumbed it into the decrypt paths (initial load + storage event).
updateGroupConfigs() — which is also the write path used by
applySyncPayload / importVaultData when ingesting a legacy payload —
still set state from raw input. A sync from an older client carrying
{ fontFamily: "pingfang-sc", fontFamilyOverride: true } would land in
memory unsanitized AND be re-persisted with the bad override active
until the next reload re-ran the decrypt path.

Fix mirrors updateHosts → sanitizeHost: map every incoming entry
through sanitizeGroupConfig before both setGroupConfigs and the
encrypt-and-persist step. Same call site now feeds the cleaned data
to localStorage, so legacy values are scrubbed on first import.

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

* fix(fonts): migrate deprecated terminal font ids on every ingest path

Addresses codex P2 review on PR #940:
  https://github.com/binaricat/Netcatty/pull/940#discussion_r3216517xxx

The previous migration only ran in the initial useState() initializer
for terminalFontFamilyId, so deprecated ids (pingfang-sc /
microsoft-yahei / comic-sans-ms) could still re-enter state via:

  - rehydrateAllFromStorage() at line ~527 — runs on remote-import
    completion and re-reads STORAGE_KEY_TERM_FONT_FAMILY raw.
  - The notifySettingsChanged IPC handler at line ~663 — fires when a
    cloud sync or programmatic localStorage write announces a change.
  - The cross-window storage event handler at line ~873.

Any of these paths could pull a deprecated id back into state after
the initial migration ran, leaving the font selector with no matching
option and silently rendering the global default while continuing to
propagate the stale value through subsequent sync uploads.

Centralizes the migration in migrateIncomingTerminalFontId(raw):
  - returns null when raw is empty
  - if raw is deprecated, writes DEFAULT_FONT_FAMILY back to
    localStorage AND returns it
  - otherwise returns raw unchanged

All four ingest sites (initial init, rehydrate, IPC, storage event)
now route through this helper. The rewrite-on-deprecated semantics
also guarantee that the moment any path sees a bad value, the next
sync upload carries the cleaned default — not the deprecated id.

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

* fix(fonts): use bundled Latin-only fallback instead of monospace generic

Resolves the tension between codex's two P1 reviews on PR #940:

  Round 1 (da1fe4cd): "monospace must come BEFORE CJK fallbacks" —
    otherwise Latin glyphs fall into a CJK font's full-width Latin
    when the primary font is missing.

  Round 2 (this commit): "monospace must come AFTER CJK fallbacks" —
    otherwise on macOS Chrome, the generic `monospace` pulls in
    PingFang via Chromium's CJK system fallback and silently masks
    the user's CJK picker.

Both are right; using a single `monospace` token can't satisfy both
roles because `monospace` is a generic family whose CJK-glyph
coverage is platform-dependent.

Fix mirrors Tabby's approach (their "monospace-fallback" SourceCodePro
sitting before any CJK in the chain): insert a known Latin-only
bundled font between the primary and CJK fallbacks. JetBrains Mono is
already shipped via @fontsource/jetbrains-mono and carries no CJK
glyphs, so it catches Latin without intercepting Chinese.

New stack order:
  <primary>, "JetBrains Mono", <userFallback>, <recommended-cjk>,
  <system-cjk-stack>, <nerd-font-stack>, monospace

Per-glyph CSS fallback now behaves as intended on every platform:
  - Latin: primary (if installed) → JetBrains Mono. Cells stay aligned.
  - CJK: primary (no) → JetBrains Mono (no CJK glyphs) → user CJK pick.
  - Nerd PUA: all of the above → Nerd Font stack.

Replaces the two prior positional-invariant tests with one for each
codex review concern: JetBrains Mono precedes every CJK family
(Latin alignment), and user CJK precedes generic monospace (CJK
picker effectiveness).

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

* fix(fonts): use OR-of-fallbacks for canvas font detection

Addresses codex P2 review on PR #940:
  https://github.com/binaricat/Netcatty/pull/940#discussion_r3216556xxx

detectInstalledWithContext required the target font to produce a
different rendered width from *all three* generic fallbacks (serif,
sans-serif, monospace) to be counted as installed. That's too strict:
on macOS the `monospace` generic resolves to Menlo itself, so
measure(`"Menlo", monospace`) === measure(`monospace`), and the
detector reported Menlo as missing even when it was clearly installed.
The same false-negative trap exists for any font that happens to
share metrics with one of the three generics on a given platform.

Switches to OR-of-fallbacks: a font counts as installed if its
rendered width differs from at least one generic baseline. A truly
uninstalled font still falls through to each generic in turn and
matches all three baselines, so this doesn't introduce false positives.

Regression tests added for both directions:
  - Menlo with metrics identical to `monospace` generic → installed.
  - "Definitely Not Installed" font → still reported missing.

The path only fires when the Local Font Access API is unavailable or
denied — when LFA succeeds, `setSystemFamilies` short-circuits ahead of
canvas — so this primarily improves the degraded-permission scenario.

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

* fix(fonts): quote-aware tokenizer for font-family lists

Addresses codex P2 review on PR #940:
  https://github.com/binaricat/Netcatty/pull/940#discussion_r3216559xxx

composeFontFamilyStack and extractPrimaryFamily both tokenized their
input with a raw String.split(',') — which corrupts any CSS family
list whose quoted family name contains a comma (CSS allows that, e.g.
`"Foo, Inc. Mono"` is a single family). A naive split would shred
that into `"Foo` / `Inc. Mono"` and emit a malformed font-family back
out.

No current TERMINAL_FONTS entry hits this case, but lib/localFonts.ts
builds family strings from arbitrary system fonts via the Local Font
Access API — a user with a comma-bearing family name would have
silently broken filtering until now.

Adds splitFontFamilyList(css) in cjkFonts.ts: an exported quote-aware
tokenizer that splits on commas only when outside quoted segments
(handles both " and '). composeFontFamilyStack uses it instead of raw
split; extractPrimaryFamily in lib/fontAvailability.ts imports it for
symmetry so the two call sites can't drift.

Tests cover the tokenizer directly (simple list, quoted-with-comma,
single quotes, double commas) and end-to-end (a quoted primary with
an internal comma survives composition intact).

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

* fix(fonts): translate Layer 3 CJK font descriptions to English

The 4 CJK-coverage entries added in earlier commits (Sarasa Mono SC,
Sarasa Mono TC, Maple Mono CN, LXGW WenKai Mono) had hardcoded Chinese
description strings, while every other TERMINAL_FONTS entry uses
English ('Adobe's professional programming font', 'Iosevka variant
mimicking Berkeley Mono style', etc.). The dropdown rendered a
mixed-language list — flagged by the maintainer.

Converted the 4 descriptions to English in the same style as the
existing entries. No i18n scaffolding added; the existing convention
is "English-only `description` field, not routed through t()", and
the rest of the registry stays consistent with that.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:07:15 +08:00
288 changed files with 24007 additions and 4411 deletions

89
.github/scripts/bump-homebrew-cask.sh vendored Executable file
View File

@@ -0,0 +1,89 @@
#!/usr/bin/env bash
#
# bump-homebrew-cask.sh — push a new version of the Netcatty cask to the
# binaricat/homebrew-netcatty tap.
#
# Called from the release pipeline (`build.yml` → `homebrew-tap` job) after
# the GitHub Release has been published with the signed + notarized DMGs.
# Computes SHA-256 of the arm64 and x64 DMGs, rewrites the cask file, and
# pushes the bump back to the tap repository using HOMEBREW_TAP_TOKEN.
#
# Required env vars:
# VERSION — semver without leading "v" (e.g. 1.1.6)
# HOMEBREW_TAP_TOKEN — PAT with contents:write on the tap repo
#
# Optional env vars:
# TAP_REPO — default: binaricat/homebrew-netcatty
# ARTIFACTS_DIR — default: artifacts
# CASK_PATH — default: Casks/netcatty.rb
set -euo pipefail
: "${VERSION:?VERSION env var required (no leading v)}"
: "${HOMEBREW_TAP_TOKEN:?HOMEBREW_TAP_TOKEN env var required}"
TAP_REPO="${TAP_REPO:-binaricat/homebrew-netcatty}"
ARTIFACTS_DIR="${ARTIFACTS_DIR:-artifacts}"
CASK_PATH="${CASK_PATH:-Casks/netcatty.rb}"
ARM_DMG="${ARTIFACTS_DIR}/Netcatty-${VERSION}-mac-arm64.dmg"
X64_DMG="${ARTIFACTS_DIR}/Netcatty-${VERSION}-mac-x64.dmg"
for f in "$ARM_DMG" "$X64_DMG"; do
if [[ ! -f "$f" ]]; then
echo "::error::Required DMG artifact not found: $f"
exit 1
fi
done
ARM_SHA=$(shasum -a 256 "$ARM_DMG" | awk '{print $1}')
X64_SHA=$(shasum -a 256 "$X64_DMG" | awk '{print $1}')
echo "Computed checksums:"
echo " arm64: ${ARM_SHA}"
echo " x64 : ${X64_SHA}"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
git clone --depth 1 \
"https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/${TAP_REPO}.git" \
"$TMP/tap"
cd "$TMP/tap"
if [[ ! -f "$CASK_PATH" ]]; then
echo "::error::Cask file not found in tap: $CASK_PATH"
exit 1
fi
# Patch the cask in place. The three lines we touch are anchored well enough
# that we don't need anything fancier than sed:
# - the `version "X.Y.Z"` line (single line, anchored to start)
# - the `sha256 arm: "..."` line
# - the ` intel: "..."` line (anchor on "intel:" at start, after the
# leading whitespace, so we don't accidentally match the `arch arm:
# "...", intel: "..."` line earlier in the file)
sed -i -E 's|^(\s*version)\s+"[^"]+"|\1 "'"$VERSION"'"|' "$CASK_PATH"
sed -i -E 's|(sha256\s+arm:\s+)"[^"]+"|\1"'"$ARM_SHA"'"|' "$CASK_PATH"
sed -i -E 's|^(\s*intel:\s+)"[^"]+"|\1"'"$X64_SHA"'"|' "$CASK_PATH"
# Sanity-check: parsed file should still be valid Ruby. Catches a broken
# substitution before we push.
if command -v ruby >/dev/null 2>&1; then
ruby -c "$CASK_PATH" >/dev/null
fi
if git diff --quiet; then
echo "Cask already at ${VERSION} with matching checksums — nothing to push."
exit 0
fi
echo "Cask diff:"
git --no-pager diff "$CASK_PATH"
git config user.email "github-actions[bot]@users.noreply.github.com"
git config user.name "github-actions[bot]"
git add "$CASK_PATH"
git commit -m "Bump netcatty to ${VERSION}"
git push origin HEAD:main
echo "Pushed bump for ${VERSION} to ${TAP_REPO}."

View File

@@ -604,3 +604,33 @@ jobs:
generate_release_notes: true
fail_on_unmatched_files: false
token: ${{ secrets.RELEASE_TOKEN }}
homebrew-tap:
name: bump homebrew tap
runs-on: ubuntu-latest
needs: release
# Only stable release tags update the Cask. Prerelease tags
# (e.g. v1.2.0-rc.1) are skipped so brew users stay on stable.
if: |
startsWith(github.ref, 'refs/tags/v')
&& !contains(github.ref_name, '-')
&& (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download macOS artifacts
uses: actions/download-artifact@v4
with:
name: netcatty-macos
path: artifacts/
- name: Bump Cask in binaricat/homebrew-netcatty
env:
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
ARTIFACTS_DIR: artifacts
run: |
# Strip the leading "v" — Cask version is plain semver.
VERSION="${GITHUB_REF_NAME#v}"
export VERSION
bash .github/scripts/bump-homebrew-cask.sh

87
App.tsx
View File

@@ -28,7 +28,12 @@ import { upsertKnownHost } from './domain/knownHosts';
import { materializeHostProxyProfile } from './domain/proxyProfiles';
import { resolveHostAuth } from './domain/sshAuth';
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from './domain/terminalAppearance';
import {
applyCustomAccentToTerminalTheme,
mergeTerminalHostUpdate,
resolveHostTerminalThemeId,
} from './domain/terminalAppearance';
import { selectConnectionLogForTerminalDataCapture } from './domain/connectionLog';
import { collectSessionIds } from './domain/workspace';
import { resolveCloseIntent } from './application/state/resolveCloseIntent';
import { resolveSnippetsShortcutIntent } from './application/state/resolveSnippetsShortcutIntent';
@@ -55,6 +60,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Input } from './components/ui/input';
import { Label } from './components/ui/label';
import { ToastProvider, toast } from './components/ui/toast';
import { TooltipProvider } from './components/ui/tooltip';
import { VaultView, VaultSection } from './components/VaultView';
import { QuickAddSnippetDialog } from './components/QuickAddSnippetDialog';
import { AddToWorkspaceDialog } from './components/workspace/AddToWorkspaceDialog';
@@ -176,12 +182,22 @@ const TerminalLayerMount: React.FC<TerminalLayerProps> = (props) => {
useEffect(() => {
if (shouldMount) return;
// Warm up the terminal layer shortly after first paint to reduce latency when opening a session.
const id = window.setTimeout(() => setShouldMount(true), 1200);
type IdleWindow = Window & {
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
cancelIdleCallback?: (id: number) => void;
};
const idleWindow = window as IdleWindow;
if (typeof idleWindow.requestIdleCallback === "function") {
const id = idleWindow.requestIdleCallback(() => setShouldMount(true), { timeout: 5000 });
return () => idleWindow.cancelIdleCallback?.(id);
}
const id = window.setTimeout(() => setShouldMount(true), 5000);
return () => window.clearTimeout(id);
}, [shouldMount]);
if (!shouldMount) return null;
const shouldRender = shouldMount || isVisible;
if (!shouldRender) return null;
return (
<Suspense fallback={null}>
@@ -300,6 +316,16 @@ function App({ settings }: { settings: SettingsState }) {
keysRef.current = keys;
const knownHostsRef = useRef(knownHosts);
knownHostsRef.current = knownHosts;
// Bridge the gap while useVaultState hydrates: its async init awaits
// hosts/keys/identities/proxyProfiles decryption before reading knownHosts,
// so the state is briefly [] at boot even when localStorage has entries.
// Any SSH connect during that window (manual click or restored session)
// would otherwise see no trusted hosts and prompt for fingerprint
// re-confirmation. Mirrors the same fallback already used by sync payloads.
const effectiveKnownHosts = useMemo(
() => getEffectiveKnownHosts(knownHosts) ?? [],
[knownHosts],
);
const {
sessions,
@@ -335,6 +361,7 @@ function App({ settings }: { settings: SettingsState }) {
splitSession,
toggleWorkspaceViewMode,
setWorkspaceFocusedSession,
reorderWorkspaceSessions,
moveFocusInWorkspace,
runSnippet,
orphanSessions,
@@ -629,7 +656,7 @@ function App({ settings }: { settings: SettingsState }) {
const effectiveHost = resolveEffectiveHost(host);
void startTunnel(rule, effectiveHost, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart);
}, rule.autoStart, terminalSettings);
return;
}
@@ -836,6 +863,7 @@ function App({ settings }: { settings: SettingsState }) {
identities,
proxyProfiles,
groupConfigs,
terminalSettings,
});
// Sync tray menu data + handle tray actions
@@ -1169,12 +1197,11 @@ function App({ settings }: { settings: SettingsState }) {
const addConnectionLogRef = useRef(addConnectionLog);
addConnectionLogRef.current = addConnectionLog;
const closeSidePanelRef = useRef<(() => void) | null>(null);
const toggleScriptsSidePanelRef = useRef<(() => void) | null>(null);
const toggleSidePanelRef = useRef<(() => void) | 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>(() => {});
const activeSidePanelTabRef = useRef<string | null>(null);
const closeTabInFlightRef = useRef(false);
// Populated by UnsavedChangesProvider render-prop below so that the hotkey
// dispatcher (defined outside that scope) can still reach the dirty-confirm
@@ -1354,13 +1381,11 @@ function App({ settings }: { settings: SettingsState }) {
const workspace = workspaces.find((w) => w.id === currentId) ?? null;
const focusIsInsideTerminal = !!document.activeElement?.closest('[data-session-id]');
const activeSidePanel = activeSidePanelTabRef.current;
const intent = resolveCloseIntent({
activeTabId: currentId,
workspace: workspace ? { id: workspace.id, focusedSessionId: workspace.focusedSessionId } : null,
sessionForTab: session,
activeSidePanelTab: activeSidePanel,
focusIsInsideTerminal,
});
@@ -1374,10 +1399,6 @@ function App({ settings }: { settings: SettingsState }) {
if (ok) closeSession(intent.sessionId);
return;
}
case 'closeSidePanel': {
closeSidePanelRef.current?.();
return;
}
case 'closeWorkspace': {
const ids = sessions.filter((s) => s.workspaceId === intent.workspaceId).map((s) => s.id);
const ok = await confirmIfBusyLocalTerminal(ids);
@@ -1453,6 +1474,9 @@ function App({ settings }: { settings: SettingsState }) {
setNavigateToSection('snippets');
}
break;
case 'toggleSidePanel':
toggleSidePanelRef.current?.();
break;
case 'broadcast': {
// Toggle broadcast mode for the active workspace
const currentId = activeTabStore.getActiveTabId();
@@ -1703,6 +1727,12 @@ function App({ settings }: { settings: SettingsState }) {
}
}, [updateSessionStatus, updateHostLastConnected]);
const handleUpdateHostFromTerminal = useCallback((host: Host) => {
updateHosts(hosts.map((h) => (
h.id === host.id ? mergeTerminalHostUpdate(h, host) : h
)));
}, [hosts, updateHosts]);
// Wrapper to create serial session with logging
const handleConnectSerial = useCallback((config: SerialConfig, options?: { charset?: string }) => {
const { username, hostname } = systemInfoRef.current;
@@ -1729,15 +1759,10 @@ function App({ settings }: { settings: SettingsState }) {
if (IS_DEV) console.log('[handleTerminalDataCapture] Session', session);
if (IS_DEV) console.log('[handleTerminalDataCapture] All logs:', connectionLogs.map(l => ({ id: l.id, sessionId: l.sessionId, hostname: l.hostname, endTime: l.endTime, hasTerminalData: !!l.terminalData })));
// Prefer the persisted sessionId because the session may already have been
// removed from state by the time the terminal unmount cleanup runs.
const matchingLog = connectionLogs
.filter((log) => {
if (log.endTime || log.terminalData) return false;
if (log.sessionId) return log.sessionId === sessionId;
return !!session && log.hostname === session.hostname;
})
.sort((a, b) => b.startTime - a.startTime)[0];
const matchingLog = selectConnectionLogForTerminalDataCapture(
connectionLogs,
{ sessionId, hostname: session?.hostname },
);
if (IS_DEV) console.log('[handleTerminalDataCapture] Matching log', matchingLog);
@@ -1995,11 +2020,11 @@ function App({ settings }: { settings: SettingsState }) {
snippets={snippets}
snippetPackages={snippetPackages}
customGroups={customGroups}
knownHosts={knownHosts}
knownHosts={effectiveKnownHosts}
shellHistory={shellHistory}
connectionLogs={connectionLogs}
managedSources={managedSources}
sessions={sessions}
sessionCount={sessions.length}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
terminalThemeId={terminalThemeId}
@@ -2035,6 +2060,7 @@ function App({ settings }: { settings: SettingsState }) {
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
navigateToSection={navigateToSection}
onNavigateToSectionHandled={() => setNavigateToSection(null)}
terminalSettings={terminalSettings}
/>
</VaultViewContainer>
@@ -2054,6 +2080,7 @@ function App({ settings }: { settings: SettingsState }) {
keyBindings={keyBindings}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
terminalSettings={terminalSettings}
/>
<TerminalLayerMount
@@ -2066,7 +2093,7 @@ function App({ settings }: { settings: SettingsState }) {
snippetPackages={snippetPackages}
sessions={sessions}
workspaces={workspaces}
knownHosts={knownHosts}
knownHosts={effectiveKnownHosts}
draggingSessionId={draggingSessionId}
terminalTheme={currentTerminalTheme}
followAppTerminalTheme={followAppTerminalTheme}
@@ -2085,7 +2112,7 @@ function App({ settings }: { settings: SettingsState }) {
onCloseSession={closeSession}
onUpdateSessionStatus={handleSessionStatusChange}
onUpdateHostDistro={updateHostDistro}
onUpdateHost={(host) => updateHosts(hosts.map(h => h.id === host.id ? host : h))}
onUpdateHost={handleUpdateHostFromTerminal}
onAddKnownHost={handleAddKnownHost}
onCommandExecuted={(command, hostId, hostLabel, sessionId) => {
addShellHistoryEntry({ command, hostId, hostLabel, sessionId });
@@ -2100,6 +2127,7 @@ function App({ settings }: { settings: SettingsState }) {
onSetDraggingSessionId={setDraggingSessionId}
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
onReorderWorkspaceSessions={reorderWorkspaceSessions}
onSplitSession={splitSessionWithCurrentShell}
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}
@@ -2115,9 +2143,8 @@ function App({ settings }: { settings: SettingsState }) {
sessionLogsEnabled={sessionLogsEnabled}
sessionLogsDir={sessionLogsDir}
sessionLogsFormat={sessionLogsFormat}
closeSidePanelRef={closeSidePanelRef}
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
activeSidePanelTabRef={activeSidePanelTabRef}
toggleSidePanelRef={toggleSidePanelRef}
/>
{/* Log Views - readonly terminal replays */}
@@ -2423,7 +2450,9 @@ function AppWithProviders() {
return (
<I18nProvider locale={settings.uiLanguage}>
<ToastProvider>
<App settings={settings} />
<TooltipProvider delayDuration={300}>
<App settings={settings} />
</TooltipProvider>
</ToastProvider>
</I18nProvider>
);

View File

@@ -264,6 +264,10 @@ const en: Messages = {
'settings.terminal.theme.selectButton': 'Select Theme',
'settings.terminal.theme.followApp': 'Follow Application Theme',
'settings.terminal.theme.followApp.desc': 'Automatically match the terminal background to the current app theme for a seamless look.',
'settings.terminal.theme.darkTheme': 'Dark mode terminal theme',
'settings.terminal.theme.lightTheme': 'Light mode terminal theme',
'settings.terminal.theme.auto': 'Auto (match app theme)',
'settings.terminal.theme.autoDesc': 'Follows the active UI theme preset',
'settings.terminal.section.font': 'Font',
'settings.terminal.section.cursor': 'Cursor',
'settings.terminal.section.keyboard': 'Keyboard',
@@ -273,6 +277,17 @@ const en: Messages = {
'settings.terminal.section.keywordHighlight': 'Keyword highlighting',
'settings.terminal.font.family': 'Font',
'settings.terminal.font.family.desc': 'Terminal font family',
'settings.terminal.font.cjk': 'CJK font',
'settings.terminal.font.cjk.desc': 'Font used for Chinese / Japanese / Korean characters; "Auto" picks one based on the primary font',
'settings.terminal.font.cjk.option.auto': 'Auto · paired with the primary font',
'settings.terminal.font.cjk.option.sarasaSC': 'Sarasa Mono SC (Iosevka + Source Han SC)',
'settings.terminal.font.cjk.option.sarasaTC': 'Sarasa Mono TC (Iosevka + Source Han TC)',
'settings.terminal.font.cjk.option.mapleCN': 'Maple Mono CN',
'settings.terminal.font.cjk.option.sourceHan': 'Source Han Mono SC',
'settings.terminal.font.cjk.option.notoCJK': 'Noto Sans Mono CJK SC',
'settings.terminal.font.cjk.option.lxgwWenkai': 'LXGW WenKai Mono',
'settings.terminal.font.cjk.option.simSun': 'SimSun',
'settings.terminal.font.cjk.option.legacy': '{font} · not recommended (proportional font)',
'settings.terminal.font.size': 'Font size',
'settings.terminal.font.size.desc': 'Terminal text size',
'settings.terminal.font.weight': 'Font weight',
@@ -290,6 +305,9 @@ const en: Messages = {
'settings.terminal.keyboard.altAsMeta': 'Use Option as Meta key',
'settings.terminal.keyboard.altAsMeta.desc':
'Use Option (Alt) as the Meta key instead of for special characters',
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ jumps by word',
'settings.terminal.keyboard.optionArrowWordJump.desc':
'Send Meta-b / Meta-f on Option+Left/Right so the shell moves by word, instead of the default ^[[1;3D / ^[[1;3C',
'settings.terminal.accessibility.minimumContrastRatio': 'Minimum contrast ratio',
'settings.terminal.accessibility.minimumContrastRatio.desc':
'Adjust colors to meet contrast requirements (1 = disabled, 21 = max)',
@@ -312,6 +330,9 @@ const en: Messages = {
'settings.terminal.behavior.preserveSelectionOnInput': 'Keep selection while typing',
'settings.terminal.behavior.preserveSelectionOnInput.desc':
'Don\'t clear mouse-selected text when typing — useful for selecting a path then pasting it after a command prefix like `sz `.',
'settings.terminal.behavior.forcePromptNewLine': 'Prompt on a new line',
'settings.terminal.behavior.forcePromptNewLine.desc':
'When the final line of command output is not terminated by a newline, move the recognized shell prompt to the next visual line.',
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 clipboard',
'settings.terminal.behavior.osc52Clipboard.desc':
'Allow remote programs (tmux, vim, etc.) to access the local clipboard via OSC-52 escape sequences.',
@@ -345,14 +366,21 @@ const en: Messages = {
'settings.terminal.behavior.linkModifier.meta': 'Cmd / Win',
'settings.terminal.scrollback.desc': 'Limit number of terminal rows. Set to 0 for no limit.',
'settings.terminal.scrollback.rows': 'Number of rows *',
'settings.terminal.section.startupCommand': 'Startup command',
'settings.terminal.startupCommandDelay.label': 'Startup command delay (ms)',
'settings.terminal.startupCommandDelay.desc': 'How long to wait after connecting before sending the startup command. Also used between lines when the startup command has multiple lines. Increase for slow connections.',
'settings.terminal.keywordHighlight.title': 'Keyword highlighting',
'settings.terminal.keywordHighlight.resetColors': 'Reset to default colors',
'settings.terminal.keywordHighlight.resetDefaults': 'Reset built-ins to defaults',
'settings.terminal.keywordHighlight.resetBuiltIn': 'Restore default label and patterns',
'settings.terminal.keywordHighlight.addCustom': 'Add Custom Rule',
'settings.terminal.keywordHighlight.editCustom': 'Edit Rule',
'settings.terminal.keywordHighlight.editBuiltIn': 'Edit Built-in Rule',
'settings.terminal.keywordHighlight.labelField': 'Label & Color',
'settings.terminal.keywordHighlight.labelPlaceholder': 'Label (e.g., Down)',
'settings.terminal.keywordHighlight.patternField': 'Regex Pattern',
'settings.terminal.keywordHighlight.patternPlaceholder': 'Regex (e.g., \\bdown\\b)',
'settings.terminal.keywordHighlight.patternField': 'Regex Patterns',
'settings.terminal.keywordHighlight.patternPlaceholder': 'One regex per line (e.g., \\bdown\\b)',
'settings.terminal.keywordHighlight.patternHint': 'One regex per line. Patterns are matched case-insensitively with the global flag.',
'settings.terminal.keywordHighlight.invalidPattern': 'Invalid regex pattern',
'settings.terminal.keywordHighlight.preview': 'Preview',
'settings.terminal.section.localShell': 'Local Shell',
@@ -374,7 +402,9 @@ const en: Messages = {
'settings.terminal.localShell.startDir.isFile': 'Path is a file, not a directory',
'settings.terminal.section.connection': 'Connection',
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
'settings.terminal.connection.keepaliveInterval.desc': 'How often (in seconds) to send SSH-level keepalive packets to server. Set to 0 to disable.',
'settings.terminal.connection.keepaliveInterval.desc': 'How often (in seconds) to send SSH-level keepalive packets. Set to 0 to disable globally — note that individual hosts can override this in their own settings.',
'settings.terminal.connection.keepaliveCountMax': 'Max unanswered keepalives',
'settings.terminal.connection.keepaliveCountMax.desc': 'Unanswered keepalives before the connection is declared dead. Higher values are more forgiving of brief network glitches and SSH servers that respond slowly.',
'settings.terminal.connection.x11Display': 'X11 display',
'settings.terminal.connection.x11Display.desc': 'Optional local display address for X11 forwarding. Leave empty to use the system default.',
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 or DISPLAY)',
@@ -802,6 +832,11 @@ const en: Messages = {
'sftp.transfers.collapseChildList': 'Hide',
'sftp.transfers.retryAction': 'Retry',
'sftp.transfers.dismissAction': 'Dismiss',
'sftp.transfers.openTargetFolder': 'Open target folder',
'sftp.transfers.openTargetFolderError': 'Could not open target folder',
'sftp.transfers.copyTargetPath': 'Copy target path',
'sftp.transfers.copyTargetPathSuccess': 'Target path copied',
'sftp.transfers.copyTargetPathError': 'Could not copy target path',
'sftp.transfers.resizeNameColumn': 'Resize file name column',
'sftp.transfers.dragToResize': 'Drag to resize',
'sftp.goUp': 'Go up',
@@ -1119,6 +1154,12 @@ const en: Messages = {
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',
'hostDetails.section.keepalive': 'Keepalive',
'hostDetails.keepalive.override': 'Override global keepalive',
'hostDetails.keepalive.desc': 'Use a custom keepalive policy for this host instead of the global setting. Useful for older routers or switches whose SSH server does not reply to keepalive@openssh.com requests — set interval to 0 to disable keepalive entirely on this host.',
'hostDetails.keepalive.interval': 'Interval (seconds)',
'hostDetails.keepalive.countMax': 'Max unanswered keepalives',
'hostDetails.keepalive.disabledHint': 'Interval = 0 disables keepalive for this host. The session will rely on TCP-level timeouts to detect a dead connection.',
'hostDetails.backspaceBehavior': 'Backspace Behavior',
'hostDetails.backspaceBehavior.default': 'Default',
'hostDetails.jumpHosts': 'Proxy via Hosts',
@@ -1263,6 +1304,10 @@ const en: Messages = {
'terminal.toolbar.hostHighlight.clearAll': 'Clear All',
'terminal.toolbar.hostHighlight.changeColor': 'Change highlight color for',
'terminal.toolbar.hostHighlight.selectColor': 'Select color for new rule',
'terminal.statusbar.copyHostname.label': 'Copy host address',
'terminal.statusbar.copyHostname.tooltip': 'Copy host address ({hostname})',
'terminal.statusbar.copyHostname.toast': 'Copied host address: {hostname}',
'terminal.statusbar.copyHostname.error': 'Failed to copy host address to clipboard',
'terminal.serverStats.cpu': 'CPU Usage',
'terminal.serverStats.cpuCores': 'CPU Core Usage',
'terminal.serverStats.memory': 'Memory Usage',
@@ -1296,6 +1341,7 @@ const en: Messages = {
'terminal.menu.paste': 'Paste',
'terminal.menu.pasteSelection': 'Paste Selection',
'terminal.menu.selectAll': 'Select All',
'terminal.menu.reconnect': 'Reconnect',
'terminal.menu.splitHorizontal': 'Split Horizontal',
'terminal.menu.splitVertical': 'Split Vertical',
'terminal.menu.clearBuffer': 'Clear Buffer',
@@ -1899,13 +1945,20 @@ const en: Messages = {
// AI Claude Code
'ai.claude.title': 'Claude Code',
'ai.claude.description': "Anthropic's agentic coding assistant. Uses claude-agent-acp for ACP protocol streaming.",
'ai.claude.description': "Anthropic's agentic coding assistant. Requires the system Claude Code CLI.",
'ai.claude.detecting': 'Detecting...',
'ai.claude.detected': 'Detected',
'ai.claude.notFound': 'Not found',
'ai.claude.path': 'Path:',
'ai.claude.notFoundHint': 'Could not find claude in PATH. Install it or specify the executable path below.',
'ai.claude.customPathPlaceholder': 'e.g. /usr/local/bin/claude',
'ai.claude.configSection': 'Authentication & config (optional)',
'ai.claude.configDir': 'Config directory',
'ai.claude.configDir.placeholder': '~/.claude (leave blank for default)',
'ai.claude.configDir.hint': 'Sets CLAUDE_CONFIG_DIR — point at a folder where you have run `claude` login (contains settings.json + credentials).',
'ai.claude.envVars': 'Environment variables',
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
'ai.claude.envVars.hint': 'One KEY=VALUE per line, passed to the Claude agent. Stored locally in plaintext — for API keys / credentials, prefer the config directory above (a `claude` login).',
'ai.claude.check': 'Check',
// AI GitHub Copilot CLI
@@ -2031,6 +2084,37 @@ const en: Messages = {
'ai.safety.blocklist.reset': 'Reset to defaults',
'ai.safety.blocklist.add': 'Add pattern',
'ai.safety.note': 'Command Blocklist, Command Timeout, and Observer mode are enforced at the MCP Server level, applying to all agent types. Confirm mode and Max Iterations are fully enforced for the built-in agent; ACP agents may have their own internal controls for these settings.',
// Unified tooltips for terminal workspace and top tabs (issue #954)
'terminal.layer.addTerminal': 'Add Terminal',
'terminal.layer.switchToSplitView': 'Switch to Split View',
'terminal.layer.sftp': 'SFTP',
'terminal.layer.scripts': 'Scripts',
'terminal.layer.theme': 'Theme',
'terminal.layer.aiChat': 'AI Chat',
'terminal.layer.movePanelLeft': 'Move panel to left',
'terminal.layer.movePanelRight': 'Move panel to right',
'terminal.layer.closePanel': 'Close panel',
'topTabs.openQuickSwitcher': 'Open quick switcher',
'topTabs.moreTabs': 'More tabs',
'topTabs.aiAssistant': 'AI Assistant',
'topTabs.toggleTheme': 'Toggle theme',
'topTabs.openSettings': 'Open Settings',
'ai.chat.sessionHistory': 'Session history',
'ai.chat.attach': 'Attach',
'ai.chat.collapse': 'Collapse',
'ai.chat.expand': 'Expand',
'ai.chat.enableAgent': 'Enable {name}',
'zmodem.waitingForRemote': 'Waiting for remote...',
'zmodem.uploading': 'Uploading',
'zmodem.downloading': 'Downloading',
'zmodem.cancelTransfer': 'Cancel transfer (Ctrl+C)',
'zmodem.overwrite.title': 'Remote file already exists',
'zmodem.overwrite.applyToRest': 'Apply to remaining conflicts',
'zmodem.overwrite.overwrite': 'Overwrite',
'zmodem.overwrite.skip': 'Skip',
'zmodem.overwrite.cancel': 'Cancel',
'settings.shortcuts.resetToDefault': 'Reset to default',
};
export default en;

File diff suppressed because it is too large Load Diff

View File

@@ -586,6 +586,11 @@ const zhCN: Messages = {
'sftp.transfers.collapseChildList': '收起',
'sftp.transfers.retryAction': '重试',
'sftp.transfers.dismissAction': '移除',
'sftp.transfers.openTargetFolder': '打开目标目录',
'sftp.transfers.openTargetFolderError': '无法打开目标目录',
'sftp.transfers.copyTargetPath': '复制目标路径',
'sftp.transfers.copyTargetPathSuccess': '已复制目标路径',
'sftp.transfers.copyTargetPathError': '无法复制目标路径',
'sftp.transfers.resizeNameColumn': '调整文件名列宽',
'sftp.transfers.dragToResize': '拖拽调整高度',
'sftp.goUp': '上一级',
@@ -748,6 +753,12 @@ const zhCN: Messages = {
'hostDetails.legacyAlgorithms': '允许旧版算法',
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
'hostDetails.section.keepalive': '会话保活',
'hostDetails.keepalive.override': '为此主机单独配置',
'hostDetails.keepalive.desc': '为该主机使用专属的保活策略,而不是跟随全局设置。适用于不响应 keepalive@openssh.com 请求的老旧路由器 / 交换机——将间隔设为 0 可对该主机彻底关闭保活。',
'hostDetails.keepalive.interval': '间隔(秒)',
'hostDetails.keepalive.countMax': '最大无响应保活次数',
'hostDetails.keepalive.disabledHint': '间隔为 0 时该主机不发送保活包,仅依赖 TCP 层超时检测断连。',
'hostDetails.backspaceBehavior': 'Backspace 行为',
'hostDetails.backspaceBehavior.default': '默认',
'hostDetails.jumpHosts': '通过主机代理',
@@ -857,6 +868,10 @@ const zhCN: Messages = {
'terminal.toolbar.hostHighlight.clearAll': '清除全部',
'terminal.toolbar.hostHighlight.changeColor': '更改高亮颜色',
'terminal.toolbar.hostHighlight.selectColor': '选择新规则的颜色',
'terminal.statusbar.copyHostname.label': '复制主机地址',
'terminal.statusbar.copyHostname.tooltip': '复制主机地址({hostname}',
'terminal.statusbar.copyHostname.toast': '已复制主机地址:{hostname}',
'terminal.statusbar.copyHostname.error': '复制主机地址失败',
'terminal.serverStats.cpu': 'CPU 使用率',
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
'terminal.serverStats.memory': '内存使用',
@@ -890,6 +905,7 @@ const zhCN: Messages = {
'terminal.menu.paste': '粘贴',
'terminal.menu.pasteSelection': '粘贴选中文本',
'terminal.menu.selectAll': '全选',
'terminal.menu.reconnect': '重新连接',
'terminal.menu.splitHorizontal': '水平分屏',
'terminal.menu.splitVertical': '垂直分屏',
'terminal.menu.clearBuffer': '清空缓冲区',
@@ -1393,6 +1409,10 @@ const zhCN: Messages = {
'settings.terminal.theme.selectButton': '选择主题',
'settings.terminal.theme.followApp': '跟随应用主题',
'settings.terminal.theme.followApp.desc': '终端背景色自动匹配当前应用主题,保持视觉一致性。',
'settings.terminal.theme.darkTheme': '深色模式终端主题',
'settings.terminal.theme.lightTheme': '浅色模式终端主题',
'settings.terminal.theme.auto': '自动(跟随界面主题)',
'settings.terminal.theme.autoDesc': '跟随当前界面主题预设',
'settings.terminal.section.font': '字体',
'settings.terminal.section.cursor': '光标',
'settings.terminal.section.keyboard': '键盘',
@@ -1402,6 +1422,17 @@ const zhCN: Messages = {
'settings.terminal.section.keywordHighlight': '关键字高亮',
'settings.terminal.font.family': '字体',
'settings.terminal.font.family.desc': '终端字体',
'settings.terminal.font.cjk': '中文 / CJK 字体',
'settings.terminal.font.cjk.desc': '用于渲染中 / 日 / 韩字符的字体;"Auto" 会按主字体智能搭配',
'settings.terminal.font.cjk.option.auto': 'Auto · 按主字体智能搭配',
'settings.terminal.font.cjk.option.sarasaSC': 'Sarasa Mono SC (更纱黑体 简)',
'settings.terminal.font.cjk.option.sarasaTC': 'Sarasa Mono TC (更纱黑体 繁)',
'settings.terminal.font.cjk.option.mapleCN': 'Maple Mono CN',
'settings.terminal.font.cjk.option.sourceHan': 'Source Han Mono SC (思源等宽)',
'settings.terminal.font.cjk.option.notoCJK': 'Noto Sans Mono CJK SC',
'settings.terminal.font.cjk.option.lxgwWenkai': 'LXGW WenKai Mono (霞鹜文楷等宽)',
'settings.terminal.font.cjk.option.simSun': 'SimSun (宋体)',
'settings.terminal.font.cjk.option.legacy': '{font} · 不推荐(非等宽字体)',
'settings.terminal.font.size': '字体大小',
'settings.terminal.font.size.desc': '终端文字大小',
'settings.terminal.font.weight': '字重',
@@ -1418,6 +1449,8 @@ const zhCN: Messages = {
'settings.terminal.cursor.blink': '光标闪烁',
'settings.terminal.keyboard.altAsMeta': '将 Option 作为 Meta 键',
'settings.terminal.keyboard.altAsMeta.desc': '使用 Option (Alt) 作为 Meta 键,而不是用于输入特殊字符',
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ 按单词跳转',
'settings.terminal.keyboard.optionArrowWordJump.desc': '按 Option+左/右 时发送 Meta-b / Meta-f让 Shell 按单词移动光标(而非默认的 ^[[1;3D / ^[[1;3C',
'settings.terminal.accessibility.minimumContrastRatio': '最小对比度',
'settings.terminal.accessibility.minimumContrastRatio.desc': '调整颜色以满足对比度要求 (1 = 禁用, 21 = 最大)',
'settings.terminal.behavior.rightClick': '右键行为',
@@ -1438,6 +1471,9 @@ const zhCN: Messages = {
'settings.terminal.behavior.preserveSelectionOnInput': '输入时保留选区',
'settings.terminal.behavior.preserveSelectionOnInput.desc':
'键盘输入时不清除鼠标选中的文本,方便选中路径后输入 `sz ` 之类命令再粘贴。',
'settings.terminal.behavior.forcePromptNewLine': '提示符另起一行',
'settings.terminal.behavior.forcePromptNewLine.desc':
'当命令输出的最后一行未以换行符结束时,将识别到的 shell 提示符移动到下一行显示。',
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪贴板',
'settings.terminal.behavior.osc52Clipboard.desc':
'允许远程程序tmux、vim 等)通过 OSC-52 转义序列访问本地剪贴板。',
@@ -1467,14 +1503,21 @@ const zhCN: Messages = {
'settings.terminal.behavior.linkModifier.meta': 'Cmd / Win',
'settings.terminal.scrollback.desc': '限制终端行数。设为 0 表示不限制。',
'settings.terminal.scrollback.rows': '行数 *',
'settings.terminal.section.startupCommand': '启动命令',
'settings.terminal.startupCommandDelay.label': '启动命令延迟(毫秒)',
'settings.terminal.startupCommandDelay.desc': '连接建立后等待多久再发送启动命令;启动命令为多行时,行与行之间也使用该间隔。慢连接可调大。',
'settings.terminal.keywordHighlight.title': '关键字高亮',
'settings.terminal.keywordHighlight.resetColors': '重置为默认颜色',
'settings.terminal.keywordHighlight.resetDefaults': '把内置规则恢复为默认',
'settings.terminal.keywordHighlight.resetBuiltIn': '恢复内置标签与正则',
'settings.terminal.keywordHighlight.addCustom': '添加自定义规则',
'settings.terminal.keywordHighlight.editCustom': '编辑规则',
'settings.terminal.keywordHighlight.editBuiltIn': '编辑内置规则',
'settings.terminal.keywordHighlight.labelField': '标签与颜色',
'settings.terminal.keywordHighlight.labelPlaceholder': '标签(如 Down',
'settings.terminal.keywordHighlight.patternField': '正则表达式',
'settings.terminal.keywordHighlight.patternPlaceholder': '正则表达式(如 \\bdown\\b',
'settings.terminal.keywordHighlight.patternPlaceholder': '每行一个正则(如 \\bdown\\b',
'settings.terminal.keywordHighlight.patternHint': '每行一个正则。匹配忽略大小写,全局匹配。',
'settings.terminal.keywordHighlight.invalidPattern': '无效的正则表达式',
'settings.terminal.keywordHighlight.preview': '预览',
'settings.terminal.section.localShell': '本地 Shell',
@@ -1496,7 +1539,9 @@ const zhCN: Messages = {
'settings.terminal.localShell.startDir.isFile': '路径是文件,不是目录',
'settings.terminal.section.connection': '连接',
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
'settings.terminal.connection.keepaliveInterval.desc': '向服务器发送 SSH 级别保活数据包的频率(秒)。设为 0 表示禁用。',
'settings.terminal.connection.keepaliveInterval.desc': '向服务器发送 SSH 保活数据包的频率(秒)。设为 0 表示全局禁用——单个主机可在自己的设置里覆盖此值。',
'settings.terminal.connection.keepaliveCountMax': '最大无响应保活次数',
'settings.terminal.connection.keepaliveCountMax.desc': '判定连接死亡前允许的无响应保活次数。值越大对短暂网络抖动和响应慢的 SSH 服务越宽容。',
'settings.terminal.connection.x11Display': 'X11 显示地址',
'settings.terminal.connection.x11Display.desc': '可选的本机 X11 显示地址。留空则使用系统默认值。',
'settings.terminal.connection.x11Display.placeholder': '自动(:0 或 DISPLAY',
@@ -1561,6 +1606,7 @@ const zhCN: Messages = {
'settings.shortcuts.binding.new-workspace': '新建工作区',
'settings.shortcuts.binding.snippets': '打开代码片段',
'settings.shortcuts.binding.broadcast': '切换广播模式',
'settings.shortcuts.binding.toggle-side-panel': '切换侧边栏',
'settings.shortcuts.binding.sftp-copy': '复制文件',
'settings.shortcuts.binding.sftp-cut': '剪切文件',
'settings.shortcuts.binding.sftp-paste': '粘贴文件',
@@ -1908,13 +1954,20 @@ const zhCN: Messages = {
// AI Claude Code
'ai.claude.title': 'Claude Code',
'ai.claude.description': 'Anthropic 的智能编程助手。使用 claude-agent-acp 进行 ACP 协议流式传输。',
'ai.claude.description': 'Anthropic 的智能编程助手。需要系统中已安装 Claude Code CLI。',
'ai.claude.detecting': '检测中...',
'ai.claude.detected': '已检测到',
'ai.claude.notFound': '未找到',
'ai.claude.path': '路径:',
'ai.claude.notFoundHint': '在 PATH 中未找到 claude。请安装或在下方指定可执行文件路径。',
'ai.claude.customPathPlaceholder': '例如 /usr/local/bin/claude',
'ai.claude.configSection': '认证与配置(可选)',
'ai.claude.configDir': '配置目录',
'ai.claude.configDir.placeholder': '~/.claude留空用默认',
'ai.claude.configDir.hint': '设置 CLAUDE_CONFIG_DIR —— 指向你已运行 `claude` 登录的目录(含 settings.json 和凭据)。',
'ai.claude.envVars': '环境变量',
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
'ai.claude.envVars.hint': '每行一个 KEY=VALUE传给 Claude agent。明文存在本地——API key凭据建议用上面的「配置目录」claude 登录),不要放这里。',
'ai.claude.check': '检查',
// AI GitHub Copilot CLI
@@ -2040,6 +2093,37 @@ const zhCN: Messages = {
'ai.safety.blocklist.reset': '恢复默认',
'ai.safety.blocklist.add': '添加规则',
'ai.safety.note': '命令黑名单、命令超时和观察者模式通过 MCP Server 层强制执行,对所有 Agent 类型生效。确认模式和最大迭代次数对内置 Agent 完全强制执行ACP Agent 可能有自己的内部控制。',
// 统一终端工作区和顶部标签的 tooltip 文案 (issue #954)
'terminal.layer.addTerminal': '添加终端',
'terminal.layer.switchToSplitView': '切换到分屏视图',
'terminal.layer.sftp': '文件传输',
'terminal.layer.scripts': '脚本',
'terminal.layer.theme': '主题',
'terminal.layer.aiChat': 'AI 助手',
'terminal.layer.movePanelLeft': '面板移至左侧',
'terminal.layer.movePanelRight': '面板移至右侧',
'terminal.layer.closePanel': '关闭面板',
'topTabs.openQuickSwitcher': '打开快速切换',
'topTabs.moreTabs': '更多标签页',
'topTabs.aiAssistant': 'AI 助手',
'topTabs.toggleTheme': '切换主题',
'topTabs.openSettings': '打开设置',
'ai.chat.sessionHistory': '会话历史',
'ai.chat.attach': '附件',
'ai.chat.collapse': '收起',
'ai.chat.expand': '展开',
'ai.chat.enableAgent': '启用 {name}',
'zmodem.waitingForRemote': '等待远端...',
'zmodem.uploading': '上传中',
'zmodem.downloading': '下载中',
'zmodem.cancelTransfer': '取消传输 (Ctrl+C)',
'zmodem.overwrite.title': '远端已存在同名文件',
'zmodem.overwrite.applyToRest': '应用到其余冲突文件',
'zmodem.overwrite.overwrite': '覆盖',
'zmodem.overwrite.skip': '跳过',
'zmodem.overwrite.cancel': '取消',
'settings.shortcuts.resetToDefault': '重置为默认',
};
export default zhCN;

View File

@@ -1,11 +1,13 @@
import en, { type Messages } from './locales/en';
import zhCN from './locales/zh-CN';
import ru from './locales/ru';
// Keep keys stable; add new locales by adding another import and map entry.
export { type Messages };
export const MESSAGES_BY_LOCALE: Record<string, Messages> = {
en,
ru,
'zh-CN': zhCN,
};

View File

@@ -1,4 +1,4 @@
import { useCallback,useSyncExternalStore } from 'react';
import { useCallback, useSyncExternalStore } from 'react';
// Simple store for active tab that allows fine-grained subscriptions
type Listener = () => void;
@@ -92,7 +92,11 @@ export const useIsEditorTabActive = (tabId: string): boolean => {
// Check if terminal layer should be visible
// Editor tabs are NOT terminal tabs, so exclude them from the visibility condition.
export const useIsTerminalLayerVisible = (draggingSessionId: string | null) => {
const activeTabId = useActiveTabId();
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !isEditorTabId(activeTabId);
return isTerminalTab || !!draggingSessionId;
const getSnapshot = useCallback(() => {
const activeTabId = activeTabStore.getActiveTabId();
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !isEditorTabId(activeTabId);
return isTerminalTab || !!draggingSessionId;
}, [draggingSessionId]);
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot);
};

View File

@@ -0,0 +1,111 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
getRuntimeRemoteCheckIntervalMs,
shouldRunRuntimeRemoteCheck,
} from './autoSyncRemoteSchedule';
test("runtime remote checks wait for the startup check to finish", () => {
assert.equal(
shouldRunRuntimeRemoteCheck({
hasAnyConnectedProvider: true,
autoSyncEnabled: true,
isUnlocked: true,
startupRemoteCheckDone: false,
isSyncing: false,
isSyncRunning: false,
remoteCheckInFlight: false,
now: 10_000,
lastRemoteCheckAt: null,
minIntervalMs: 30_000,
}),
false,
);
});
test("runtime remote checks run immediately after startup gate opens", () => {
assert.equal(
shouldRunRuntimeRemoteCheck({
hasAnyConnectedProvider: true,
autoSyncEnabled: true,
isUnlocked: true,
startupRemoteCheckDone: true,
isSyncing: false,
isSyncRunning: false,
remoteCheckInFlight: false,
now: 10_000,
lastRemoteCheckAt: null,
minIntervalMs: 30_000,
}),
true,
);
});
test("runtime remote checks respect the minimum interval", () => {
const common = {
hasAnyConnectedProvider: true,
autoSyncEnabled: true,
isUnlocked: true,
startupRemoteCheckDone: true,
isSyncing: false,
isSyncRunning: false,
remoteCheckInFlight: false,
minIntervalMs: 30_000,
};
assert.equal(
shouldRunRuntimeRemoteCheck({
...common,
now: 35_000,
lastRemoteCheckAt: 10_000,
}),
false,
);
assert.equal(
shouldRunRuntimeRemoteCheck({
...common,
now: 40_000,
lastRemoteCheckAt: 10_000,
}),
true,
);
});
test("forced runtime remote checks bypass only the interval gate", () => {
const common = {
hasAnyConnectedProvider: true,
autoSyncEnabled: true,
isUnlocked: true,
startupRemoteCheckDone: true,
isSyncing: false,
isSyncRunning: false,
remoteCheckInFlight: false,
minIntervalMs: 30_000,
force: true,
};
assert.equal(
shouldRunRuntimeRemoteCheck({
...common,
now: 35_000,
lastRemoteCheckAt: 10_000,
}),
true,
);
assert.equal(
shouldRunRuntimeRemoteCheck({
...common,
isSyncing: true,
now: 35_000,
lastRemoteCheckAt: 10_000,
}),
false,
);
});
test("configured auto-sync intervals map to bounded remote recheck intervals", () => {
assert.equal(getRuntimeRemoteCheckIntervalMs(1), 30_000);
assert.equal(getRuntimeRemoteCheckIntervalMs(10), 300_000);
assert.equal(getRuntimeRemoteCheckIntervalMs(120), 300_000);
});

View File

@@ -0,0 +1,35 @@
const MIN_RUNTIME_REMOTE_CHECK_MS = 30_000;
const MAX_RUNTIME_REMOTE_CHECK_MS = 5 * 60_000;
export function getRuntimeRemoteCheckIntervalMs(autoSyncIntervalMinutes: number): number {
const configuredMs = Math.max(1, Number(autoSyncIntervalMinutes) || 1) * 60_000;
return Math.max(
MIN_RUNTIME_REMOTE_CHECK_MS,
Math.min(MAX_RUNTIME_REMOTE_CHECK_MS, Math.floor(configuredMs / 2)),
);
}
export interface RuntimeRemoteCheckInput {
hasAnyConnectedProvider: boolean;
autoSyncEnabled: boolean;
isUnlocked: boolean;
startupRemoteCheckDone: boolean;
isSyncing: boolean;
isSyncRunning: boolean;
remoteCheckInFlight: boolean;
force?: boolean;
now: number;
lastRemoteCheckAt: number | null;
minIntervalMs: number;
}
export function shouldRunRuntimeRemoteCheck(input: RuntimeRemoteCheckInput): boolean {
if (!input.hasAnyConnectedProvider) return false;
if (!input.autoSyncEnabled) return false;
if (!input.isUnlocked) return false;
if (!input.startupRemoteCheckDone) return false;
if (input.isSyncing || input.isSyncRunning || input.remoteCheckInFlight) return false;
if (input.force === true) return true;
if (input.lastRemoteCheckAt == null) return true;
return input.now - input.lastRemoteCheckAt >= input.minIntervalMs;
}

View File

@@ -244,16 +244,3 @@ export const useEditorTab = (id: EditorTabId): EditorTab | undefined => {
const getSnapshot = useCallback(() => editorTabStore.getTab(id), [id]);
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
};
export const useEditorDirty = (id: EditorTabId): boolean => {
const getSnapshot = useCallback(() => editorTabStore.isDirty(id), [id]);
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
};
export const useAnyEditorDirty = (): boolean => {
const getSnapshot = useCallback(
() => editorTabStore.getTabs().some((t) => t.content !== t.baselineContent),
[],
);
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
};

View File

@@ -1,6 +1,7 @@
import { useSyncExternalStore } from 'react';
import { TERMINAL_FONTS, type TerminalFont } from '../../infrastructure/config/fonts';
import { getMonospaceFonts } from '../../lib/localFonts';
import { getAllSystemFontFamilies, getMonospaceFonts } from '../../lib/localFonts';
import { setSystemFamilies } from '../../lib/fontAvailability';
/**
* Global font store - singleton pattern using useSyncExternalStore
@@ -60,7 +61,14 @@ class FontStore {
this.setState({ isLoading: true, error: null });
try {
const localFonts = await getMonospaceFonts();
// Populate the authoritative installed-family set used by
// fontAvailability.isFontInstalled. Runs in parallel with the
// monospace-only query (both share an underlying cache).
const [localFonts, systemFamilies] = await Promise.all([
getMonospaceFonts(),
getAllSystemFontFamilies(),
]);
setSystemFamilies(systemFamilies);
// Combine default fonts with local fonts, deduplicate by id
const fontMap = new Map<string, TerminalFont>();

View File

@@ -3,33 +3,27 @@ import assert from "node:assert/strict";
import { resolveCloseIntent } from "./resolveCloseIntent.ts";
const baseWorkspace = {
id: "w1",
focusedSessionId: "s1",
};
const baseWorkspace = { id: "w1", focusedSessionId: "s1" };
const baseSession = { id: "s1" };
test("non-workspace tab → closeSingleTab with session id", () => {
const result = resolveCloseIntent({
const r = resolveCloseIntent({
activeTabId: "s1",
workspace: null,
sessionForTab: baseSession,
activeSidePanelTab: null,
focusIsInsideTerminal: true,
});
assert.deepEqual(result, { kind: "closeSingleTab", sessionId: "s1" });
assert.deepEqual(r, { kind: "closeSingleTab", sessionId: "s1" });
});
test("non-workspace session tab + sidebar open → closeSidePanel (sidebar beats session close)", () => {
test("non-workspace session tab → closeSingleTab even when focus is outside the terminal", () => {
const r = resolveCloseIntent({
activeTabId: "s1",
workspace: null,
sessionForTab: { id: "s1" },
activeSidePanelTab: "ai",
focusIsInsideTerminal: true, // focus IS in terminal, but sidebar wins
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
assert.deepEqual(r, { kind: "closeSingleTab", sessionId: "s1" });
});
test("vault/sftp tab → noop", () => {
@@ -37,74 +31,37 @@ test("vault/sftp tab → noop", () => {
activeTabId: "vault",
workspace: null,
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "noop" });
});
test("workspace + focus in terminal + sidebar open → closeSidePanel wins (sidebar beats focus)", () => {
test("workspace + focus in terminal → closeTerminal (side panel no longer intercepts)", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: "ai",
focusIsInsideTerminal: true,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});
test("workspace + focus NOT in terminal + sidebar open → closeSidePanel", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: "sftp",
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});
test("workspace + sidebar closed + focus in terminal → closeTerminal", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: true,
});
assert.deepEqual(r, { kind: "closeTerminal", sessionId: "s1" });
});
test("workspace + sidebar closed + focus NOT in terminal → closeWorkspace", () => {
test("workspace + focus NOT in terminal → closeWorkspace", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
});
test("workspace with no focused session + sidebar closed → closeWorkspace", () => {
test("workspace with no focused session → closeWorkspace", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: { id: "w1", focusedSessionId: undefined },
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: true, // even if flag true, no focused id → cannot closeTerminal
focusIsInsideTerminal: true,
});
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
});
test("workspace with no focused session + sidebar open → closeSidePanel", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: { id: "w1", focusedSessionId: undefined },
sessionForTab: null,
activeSidePanelTab: "ai",
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});

View File

@@ -1,6 +1,5 @@
export type CloseIntent =
| { kind: 'closeTerminal'; sessionId: string }
| { kind: 'closeSidePanel' }
| { kind: 'closeWorkspace'; workspaceId: string }
| { kind: 'closeSingleTab'; sessionId: string }
| { kind: 'noop' };
@@ -9,22 +8,14 @@ export interface ResolveCloseInput {
activeTabId: string | null;
workspace: { id: string; focusedSessionId?: string } | null;
sessionForTab: { id: string } | null;
activeSidePanelTab: string | null;
focusIsInsideTerminal: boolean;
}
export function resolveCloseIntent(input: ResolveCloseInput): CloseIntent {
const { activeTabId, workspace, sessionForTab, activeSidePanelTab, focusIsInsideTerminal } = input;
const { activeTabId, workspace, sessionForTab, focusIsInsideTerminal } = input;
if (!activeTabId) return { kind: 'noop' };
// Sidebar always wins — applies to any tab type (workspace, single-session, etc.).
// Modals take priority over this but are intercepted upstream in App.tsx before the
// hotkey reaches resolveCloseIntent.
if (activeSidePanelTab !== null) {
return { kind: 'closeSidePanel' };
}
if (sessionForTab && !workspace) {
return { kind: 'closeSingleTab', sessionId: sessionForTab.id };
}

View File

@@ -0,0 +1,19 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveSidePanelToggleIntent } from "./resolveSidePanelToggleIntent.ts";
test("open: closed with a remembered tab → open that tab", () => {
const r = resolveSidePanelToggleIntent({ isOpen: false, lastTab: "sftp", fallbackTab: "scripts" });
assert.deepEqual(r, { kind: "open", tab: "sftp" });
});
test("open: closed with no memory → open the fallback tab", () => {
const r = resolveSidePanelToggleIntent({ isOpen: false, lastTab: null, fallbackTab: "scripts" });
assert.deepEqual(r, { kind: "open", tab: "scripts" });
});
test("close: already open → close", () => {
const r = resolveSidePanelToggleIntent({ isOpen: true, lastTab: "theme", fallbackTab: "sftp" });
assert.deepEqual(r, { kind: "close" });
});

View File

@@ -0,0 +1,18 @@
export type SidePanelToggleIntent<T extends string> =
| { kind: 'close' }
| { kind: 'open'; tab: T };
/**
* Decide what the "toggle side panel" shortcut should do.
* - If a panel is open → close it.
* - If closed → reopen the last-shown sub-panel for the tab, falling back to
* `fallbackTab` when the tab has no remembered panel.
*/
export function resolveSidePanelToggleIntent<T extends string>(input: {
isOpen: boolean;
lastTab: T | null;
fallbackTab: T;
}): SidePanelToggleIntent<T> {
if (input.isOpen) return { kind: 'close' };
return { kind: 'open', tab: input.lastTab ?? input.fallbackTab };
}

View File

@@ -0,0 +1,32 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveTerminalSessionExitIntent } from "./resolveTerminalSessionExitIntent.ts";
test("normal backend exited events close the session tab", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }),
{ kind: "closeSession" },
);
});
test("backend timeout events keep the tab and mark it disconnected", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "timeout", error: "idle timeout" }),
{ kind: "markDisconnected" },
);
});
test("backend error events keep the tab and mark it disconnected", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "error", error: "connection reset" }),
{ kind: "markDisconnected" },
);
});
test("backend closed events keep the tab and mark it disconnected", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "closed", exitCode: 0 }),
{ kind: "markDisconnected" },
);
});

View File

@@ -0,0 +1,22 @@
export type TerminalSessionExitEvent = {
exitCode?: number;
signal?: number;
error?: string;
reason?: "exited" | "error" | "timeout" | "closed";
};
export type TerminalSessionExitIntent =
| { kind: "closeSession" }
| { kind: "markDisconnected" };
export function resolveTerminalSessionExitIntent(
evt: TerminalSessionExitEvent,
): TerminalSessionExitIntent {
if (evt.reason === "exited") {
return { kind: "closeSession" };
}
// Timeouts, transport errors, and channel closes should keep the tab visible
// so the user can inspect output and reconnect.
return { kind: "markDisconnected" };
}

View File

@@ -0,0 +1,23 @@
import type { SftpBookmark } from "../../../domain/models";
const ROOT_PATH_RE = /^[A-Za-z]:[\\/]?$/;
export function getSftpBookmarkLabel(path: string): string {
const trimmed = path.trim();
if (trimmed === "/" || ROOT_PATH_RE.test(trimmed)) return trimmed;
return trimmed.split(/[\\/]/).filter(Boolean).pop() || trimmed;
}
export function createSftpBookmark(
path: string,
options: { global?: boolean; idPrefix?: string } = {},
): SftpBookmark {
const global = options.global === true;
const idPrefix = options.idPrefix ?? (global ? "gbm" : "bm");
return {
id: `${idPrefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
path,
label: getSftpBookmarkLabel(path),
...(global ? { global: true } : {}),
};
}

View File

@@ -0,0 +1,45 @@
import type { SftpBookmark } from "../../../domain/models";
import { STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
type Listener = () => void;
const listeners = new Set<Listener>();
let snapshot: SftpBookmark[] =
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) ?? [];
export function subscribeGlobalSftpBookmarks(listener: Listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function getGlobalSftpBookmarksSnapshot() {
return snapshot;
}
export function rehydrateGlobalSftpBookmarks() {
snapshot = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) ?? [];
for (const listener of listeners) listener();
}
export function setGlobalSftpBookmarks(
next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[]),
) {
snapshot = typeof next === "function" ? next(snapshot) : next;
localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, snapshot);
for (const listener of listeners) listener();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("sftp-bookmarks-changed"));
}
}
if (typeof window !== "undefined") {
window.addEventListener("storage", (event) => {
if (event.key === STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) {
rehydrateGlobalSftpBookmarks();
}
});
}

View File

@@ -64,4 +64,10 @@ export interface SftpStateOptions {
useCompressedUpload?: boolean;
defaultShowHiddenFiles?: boolean;
autoConnectLocalOnMount?: boolean;
/**
* Global SSH keepalive settings, forwarded through to per-SFTP-connection
* keepalive resolution so a host that has opted into its own override
* is honored for SFTP browsing too (not just the terminal session).
*/
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}

View File

@@ -11,6 +11,7 @@ interface UseSftpConnectionsParams {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
leftTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
rightTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
leftTabs: { tabs: SftpPane[] };
@@ -44,6 +45,7 @@ export const useSftpConnections = ({
hosts,
keys,
identities,
terminalSettings,
leftTabsRef,
rightTabsRef,
leftTabs,
@@ -65,7 +67,7 @@ export const useSftpConnections = ({
createEmptyPane,
autoConnectLocalOnMount = true,
}: UseSftpConnectionsParams): UseSftpConnectionsResult => {
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities });
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities, terminalSettings });
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
const connect = useCallback(

View File

@@ -1,12 +1,19 @@
import { useCallback } from "react";
import type { Host, Identity, SSHKey } from "../../../domain/models";
import type { Host, Identity, SSHKey, TerminalSettings } from "../../../domain/models";
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
import { resolveBridgeKeyAuth, resolveHostAuth } from "../../../domain/sshAuth";
import { resolveHostKeepalive } from "../../../domain/host";
// Fallback used when no global TerminalSettings are wired through (older
// call sites or tests). Matches DEFAULT_TERMINAL_SETTINGS so behavior is
// identical whether or not the caller passes settings.
const FALLBACK_KEEPALIVE = { keepaliveInterval: 30, keepaliveCountMax: 10 };
interface UseSftpHostCredentialsParams {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>;
}
export const buildSftpHostCredentials = ({
@@ -14,7 +21,9 @@ export const buildSftpHostCredentials = ({
hosts,
keys,
identities,
terminalSettings,
}: UseSftpHostCredentialsParams & { host: Host }): NetcattySSHOptions => {
const globalKeepalive = terminalSettings ?? FALLBACK_KEEPALIVE;
if (host.proxyProfileId && !host.proxyConfig) {
throw new Error(`Saved proxy for host "${host.label || host.hostname}" is missing. Open host settings and select a valid proxy.`);
}
@@ -79,6 +88,7 @@ export const buildSftpHostCredentials = ({
) {
throw new Error(`Saved credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter them.`);
}
const hopKeepalive = resolveHostKeepalive(jumpHost, globalKeepalive);
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
@@ -101,6 +111,8 @@ export const buildSftpHostCredentials = ({
}
: undefined,
identityFilePaths: jumpKeyAuth.identityFilePaths,
keepaliveInterval: hopKeepalive.interval,
keepaliveCountMax: hopKeepalive.countMax,
};
});
}
@@ -129,6 +141,7 @@ export const buildSftpHostCredentials = ({
throw new Error("Saved credentials cannot be decrypted on this device. Open host settings and re-enter them.");
}
const targetKeepalive = resolveHostKeepalive(host, globalKeepalive);
return {
hostname: host.hostname,
username: resolved.username,
@@ -144,6 +157,8 @@ export const buildSftpHostCredentials = ({
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
sudo: host.sftpSudo,
identityFilePaths: keyAuth.identityFilePaths,
keepaliveInterval: targetKeepalive.interval,
keepaliveCountMax: targetKeepalive.countMax,
};
};
@@ -151,8 +166,9 @@ export const useSftpHostCredentials = ({
hosts,
keys,
identities,
terminalSettings,
}: UseSftpHostCredentialsParams) =>
useCallback(
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities }),
[hosts, identities, keys],
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities, terminalSettings }),
[hosts, identities, keys, terminalSettings],
);

View File

@@ -0,0 +1,11 @@
import test from "node:test";
import assert from "node:assert/strict";
import { isConcreteTransferTargetPath } from "./utils";
test("concrete transfer target paths exclude temporary placeholders", () => {
assert.equal(isConcreteTransferTargetPath({ targetPath: "/Users/alice/Downloads/report.pdf" }), true);
assert.equal(isConcreteTransferTargetPath({ targetPath: "C:\\Users\\alice\\Downloads\\report.pdf" }), true);
assert.equal(isConcreteTransferTargetPath({ targetPath: "(temp)" }), false);
assert.equal(isConcreteTransferTargetPath({ targetPath: " " }), false);
});

View File

@@ -1,4 +1,4 @@
import { SftpFileEntry } from "../../../domain/models";
import { SftpFileEntry, TransferTask } from "../../../domain/models";
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "--";
@@ -76,6 +76,11 @@ export const getParentPath = (path: string): string => {
return result;
};
export const isConcreteTransferTargetPath = (task: Pick<TransferTask, "targetPath">): boolean => {
const targetPath = task.targetPath.trim();
return targetPath.length > 0 && targetPath !== "(temp)";
};
export const getFileName = (path: string): string => {
const parts = path.split(/[\\/]/).filter(Boolean);
return parts[parts.length - 1] || "";

View File

@@ -52,14 +52,19 @@ export function useAgentDiscovery(
);
if (!match) return ea;
// Check if args or ACP config differ
// Check if args, ACP config, or Claude's resolved system path differ
const currentArgs = JSON.stringify(ea.args || []);
const newArgs = JSON.stringify(match.args);
const acpChanged = ea.acpCommand !== match.acpCommand
|| JSON.stringify(ea.acpArgs || []) !== JSON.stringify(match.acpArgs || []);
if (currentArgs !== newArgs || acpChanged) {
const env = match.command === 'claude'
? { ...(ea.env ?? {}), CLAUDE_CODE_EXECUTABLE: match.path }
: ea.env;
const envChanged = match.command === 'claude'
&& ea.env?.CLAUDE_CODE_EXECUTABLE !== match.path;
if (currentArgs !== newArgs || acpChanged || envChanged) {
changed = true;
return { ...ea, args: match.args, acpCommand: match.acpCommand, acpArgs: match.acpArgs };
return { ...ea, args: match.args, acpCommand: match.acpCommand, acpArgs: match.acpArgs, ...(env ? { env } : {}) };
}
return ea;
});
@@ -86,6 +91,7 @@ export function useAgentDiscovery(
enabled: true,
acpCommand: agent.acpCommand,
acpArgs: agent.acpArgs,
...(agent.command === 'claude' ? { env: { CLAUDE_CODE_EXECUTABLE: agent.path } } : {}),
};
},
[],

View File

@@ -16,14 +16,15 @@ import {
findSyncPayloadEncryptedCredentialPaths,
} from '../../domain/credentials';
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
import { mergeSyncPayloads } from '../../domain/syncMerge';
import {
SYNCABLE_SETTING_STORAGE_KEYS,
collectSyncableSettings,
getEffectivePortForwardingRulesForSync,
hasMeaningfulCloudSyncData,
} from '../syncPayload';
import { readInterruptedVaultApply } from '../localVaultBackups';
import {
STORAGE_KEY_PORT_FORWARDING,
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
} from '../../infrastructure/config/storageKeys';
import {
@@ -31,6 +32,10 @@ import {
localStorageAdapter,
} from '../../infrastructure/persistence/localStorageAdapter';
import { notify } from '../notification';
import {
getRuntimeRemoteCheckIntervalMs,
shouldRunRuntimeRemoteCheck,
} from './autoSyncRemoteSchedule';
interface AutoSyncConfig {
// Data to sync
@@ -95,6 +100,11 @@ interface SyncNowOptions {
trigger?: SyncTrigger;
}
interface RemoteVersionCheckOptions {
force?: boolean;
notifyOnFailure?: boolean;
}
export const useAutoSync = (config: AutoSyncConfig) => {
const { t } = useI18n();
const sync = useCloudSync();
@@ -156,21 +166,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}, []);
const getSyncSnapshot = useCallback(() => {
let effectivePFRules = config.portForwardingRules;
if (!effectivePFRules || effectivePFRules.length === 0) {
const stored = localStorageAdapter.read<SyncPayload['portForwardingRules']>(
STORAGE_KEY_PORT_FORWARDING,
);
if (stored && Array.isArray(stored) && stored.length > 0) {
effectivePFRules = stored.map((rule) => ({
...rule,
status: 'inactive' as const,
error: undefined,
lastUsedAt: undefined,
}));
}
}
return {
hosts: config.hosts,
keys: config.keys,
@@ -179,7 +174,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
snippets: config.snippets,
customGroups: config.customGroups,
snippetPackages: config.snippetPackages,
portForwardingRules: effectivePFRules,
portForwardingRules: getEffectivePortForwardingRulesForSync(config.portForwardingRules),
groupConfigs: config.groupConfigs,
};
}, [
@@ -417,17 +412,20 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// windows but does NOT serialize same-window re-entry, so this
// in-flight guard closes that gap at the top of the call.
const checkRemoteInFlightRef = useRef(false);
const lastRuntimeRemoteCheckAtRef = useRef<number | null>(null);
// Check remote version and pull if newer (on startup)
const checkRemoteVersion = useCallback(async () => {
const checkRemoteVersion = useCallback(async (options?: RemoteVersionCheckOptions) => {
if (checkRemoteInFlightRef.current) {
return;
}
const force = options?.force === true;
const notifyOnFailure = options?.notifyOnFailure !== false;
const state = manager.getState();
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
const unlocked = state.securityState === 'UNLOCKED';
if (!hasProvider || !unlocked || hasCheckedRemoteRef.current || startupReadyRef.current === false) {
if (!hasProvider || !unlocked || (!force && hasCheckedRemoteRef.current) || startupReadyRef.current === false) {
return;
}
@@ -509,7 +507,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
return;
}
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
// Apply merged payload to local state BEFORE committing. If the apply
@@ -563,14 +560,16 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
} catch (error) {
console.error('[AutoSync] Failed to check remote version:', error);
// Surface a degraded-sync hint to the user rather than silently
// opening the auto-sync gate. Auto-sync will still retry on next
// data change (see finally block), but without this toast the user
// has no visible signal that startup reconciliation failed.
notify.error(
t('sync.autoSync.inspectFailedMessage'),
t('sync.autoSync.inspectFailedTitle'),
);
if (notifyOnFailure) {
// Surface a degraded-sync hint to the user rather than silently
// opening the auto-sync gate. Auto-sync will still retry on next
// data change (see finally block), but without this toast the user
// has no visible signal that startup reconciliation failed.
notify.error(
t('sync.autoSync.inspectFailedMessage'),
t('sync.autoSync.inspectFailedTitle'),
);
}
// Leave hasCheckedRemoteRef=false so the next startup (or the next
// provider/unlock transition) can retry.
} finally {
@@ -741,12 +740,86 @@ export const useAutoSync = (config: AutoSyncConfig) => {
if (timerId) clearTimeout(timerId);
};
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady, checkRemoteVersion]);
const runRuntimeRemoteCheck = useCallback(async (options?: { force?: boolean }) => {
const now = Date.now();
const minIntervalMs = getRuntimeRemoteCheckIntervalMs(sync.autoSyncInterval);
if (!shouldRunRuntimeRemoteCheck({
hasAnyConnectedProvider: sync.hasAnyConnectedProvider,
autoSyncEnabled: sync.autoSyncEnabled,
isUnlocked: sync.isUnlocked,
startupRemoteCheckDone: remoteCheckDoneRef.current,
isSyncing: sync.isSyncing,
isSyncRunning: isSyncRunningRef.current,
remoteCheckInFlight: checkRemoteInFlightRef.current,
force: options?.force === true,
now,
lastRemoteCheckAt: lastRuntimeRemoteCheckAtRef.current,
minIntervalMs,
})) {
return;
}
lastRuntimeRemoteCheckAtRef.current = now;
await checkRemoteVersion({ force: true, notifyOnFailure: false });
}, [
checkRemoteVersion,
sync.autoSyncEnabled,
sync.autoSyncInterval,
sync.hasAnyConnectedProvider,
sync.isSyncing,
sync.isUnlocked,
]);
// Keep checking the cloud while the app is open. This closes the gap where
// another device uploads changes after our startup inspection but before
// this device edits anything locally.
useEffect(() => {
if (!sync.hasAnyConnectedProvider || !sync.autoSyncEnabled || !sync.isUnlocked) {
return;
}
const intervalMs = getRuntimeRemoteCheckIntervalMs(sync.autoSyncInterval);
const timerId = window.setInterval(() => {
void runRuntimeRemoteCheck();
}, intervalMs);
return () => window.clearInterval(timerId);
}, [
runRuntimeRemoteCheck,
sync.autoSyncEnabled,
sync.autoSyncInterval,
sync.hasAnyConnectedProvider,
sync.isUnlocked,
]);
// Also re-check when the user returns to the app or the network comes back.
useEffect(() => {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void runRuntimeRemoteCheck({ force: true });
}
};
const handleOnline = () => {
void runRuntimeRemoteCheck({ force: true });
};
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('online', handleOnline);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('online', handleOnline);
};
}, [runRuntimeRemoteCheck]);
// Reset check flags when provider disconnects
useEffect(() => {
if (!sync.hasAnyConnectedProvider) {
hasCheckedRemoteRef.current = false;
remoteCheckDoneRef.current = false;
lastRuntimeRemoteCheckAtRef.current = null;
}
}, [sync.hasAnyConnectedProvider]);

View File

@@ -1,41 +1,5 @@
import { useCallback, useEffect, useRef } from 'react';
import { KeyBinding, matchesKeyBinding } from '../../domain/models';
interface HotkeyActions {
// Tab management
switchToTab: (tabIndex: number) => void;
nextTab: () => void;
prevTab: () => void;
closeTab: () => void;
newTab: () => void;
// Navigation
openHosts: () => void;
openSftp: () => void;
quickSwitch: () => void;
newWorkspace: () => void;
commandPalette: () => void;
portForwarding: () => void;
snippets: () => void;
// Terminal actions (handled per-terminal)
copy: () => void;
paste: () => void;
selectAll: () => void;
clearBuffer: () => void;
searchTerminal: () => void;
// Workspace/split actions
splitHorizontal: () => void;
splitVertical: () => void;
moveFocus: (direction: 'up' | 'down' | 'left' | 'right') => void;
// App features
broadcast: () => void;
openLocal: () => void;
openSettings: () => void;
}
// Check if keyboard event matches our app-level shortcuts
// Returns the matched binding action or null
export const checkAppShortcut = (
@@ -87,163 +51,3 @@ export const getTerminalPassthroughActions = (): Set<string> => {
'searchTerminal',
]);
};
interface UseGlobalHotkeysOptions {
hotkeyScheme: 'disabled' | 'mac' | 'pc';
keyBindings: KeyBinding[];
actions: Partial<HotkeyActions>;
orderedTabs: string[];
sessions: { id: string }[];
workspaces: { id: string }[];
isSettingsOpen?: boolean;
}
export const useGlobalHotkeys = ({
hotkeyScheme,
keyBindings,
actions,
orderedTabs,
sessions,
workspaces,
isSettingsOpen = false,
}: UseGlobalHotkeysOptions) => {
const actionsRef = useRef(actions);
actionsRef.current = actions;
const orderedTabsRef = useRef(orderedTabs);
orderedTabsRef.current = orderedTabs;
const sessionsRef = useRef(sessions);
sessionsRef.current = sessions;
const workspacesRef = useRef(workspaces);
workspacesRef.current = workspaces;
const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => {
if (hotkeyScheme === 'disabled') return;
if (isSettingsOpen) return; // Don't handle hotkeys when settings is open
const isMac = hotkeyScheme === 'mac';
const appLevelActions = getAppLevelActions();
// Check if this is an app-level shortcut
const matched = checkAppShortcut(e, keyBindings, isMac);
if (!matched) return;
const { action, binding: _binding } = matched;
// Only handle app-level actions here
// Terminal-level actions are handled by the terminal itself
if (!appLevelActions.has(action)) return;
e.preventDefault();
e.stopPropagation();
const currentActions = actionsRef.current;
switch (action) {
case 'switchToTab': {
const num = parseInt(e.key, 10);
if (num >= 1 && num <= 9) {
currentActions.switchToTab?.(num);
}
break;
}
case 'nextTab':
currentActions.nextTab?.();
break;
case 'prevTab':
currentActions.prevTab?.();
break;
case 'closeTab':
currentActions.closeTab?.();
break;
case 'newTab':
currentActions.newTab?.();
break;
case 'openHosts':
currentActions.openHosts?.();
break;
case 'openSftp':
currentActions.openSftp?.();
break;
case 'openLocal':
currentActions.openLocal?.();
break;
case 'quickSwitch':
currentActions.quickSwitch?.();
break;
case 'newWorkspace':
currentActions.newWorkspace?.();
break;
case 'commandPalette':
currentActions.commandPalette?.();
break;
case 'portForwarding':
currentActions.portForwarding?.();
break;
case 'snippets':
currentActions.snippets?.();
break;
case 'splitHorizontal':
currentActions.splitHorizontal?.();
break;
case 'splitVertical':
currentActions.splitVertical?.();
break;
case 'moveFocus': {
// Determine direction from arrow key
const key = e.key;
if (key === 'ArrowUp') currentActions.moveFocus?.('up');
else if (key === 'ArrowDown') currentActions.moveFocus?.('down');
else if (key === 'ArrowLeft') currentActions.moveFocus?.('left');
else if (key === 'ArrowRight') currentActions.moveFocus?.('right');
break;
}
case 'broadcast':
currentActions.broadcast?.();
break;
case 'openSettings':
currentActions.openSettings?.();
break;
}
}, [hotkeyScheme, keyBindings, isSettingsOpen]);
useEffect(() => {
// Use capture phase to intercept before xterm
window.addEventListener('keydown', handleGlobalKeyDown, true);
return () => window.removeEventListener('keydown', handleGlobalKeyDown, true);
}, [handleGlobalKeyDown]);
};
// Helper to create key event handler for xterm's attachCustomKeyEventHandler
// Returns false to let xterm handle the key, true to prevent xterm from handling
export const createXtermKeyHandler = (
keyBindings: KeyBinding[],
isMac: boolean,
onTerminalAction?: (action: string, e: KeyboardEvent) => void
) => {
const appLevelActions = getAppLevelActions();
const terminalActions = getTerminalPassthroughActions();
return (e: KeyboardEvent): boolean => {
const matched = checkAppShortcut(e, keyBindings, isMac);
if (!matched) return true; // Let xterm handle it
const { action } = matched;
// App-level actions: prevent xterm from handling, let global handler take over
if (appLevelActions.has(action)) {
return false; // Don't let xterm handle, will bubble to global handler
}
// Terminal-level actions: handle here and prevent default
if (terminalActions.has(action)) {
e.preventDefault();
e.stopPropagation();
onTerminalAction?.(action, e);
return false;
}
return true; // Let xterm handle other keys
};
};

View File

@@ -24,6 +24,7 @@ export interface UsePortForwardingAutoStartOptions {
identities: Identity[];
proxyProfiles: ProxyProfile[];
groupConfigs: GroupConfig[];
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}
const AUTO_START_PROXY_NOT_READY_ERROR = "Proxy or jump host configuration is not ready";
@@ -103,6 +104,7 @@ export const usePortForwardingAutoStart = ({
identities,
proxyProfiles,
groupConfigs,
terminalSettings,
}: UsePortForwardingAutoStartOptions): void => {
const autoStartExecutedRef = useRef(false);
const hostsRef = useRef<Host[]>(hosts);
@@ -110,6 +112,8 @@ export const usePortForwardingAutoStart = ({
const identitiesRef = useRef<Identity[]>(identities);
const proxyProfilesRef = useRef<ProxyProfile[]>(proxyProfiles);
const groupConfigsRef = useRef<GroupConfig[]>(groupConfigs);
const terminalSettingsRef = useRef(terminalSettings);
terminalSettingsRef.current = terminalSettings;
const isHostAuthReady = useCallback((host: Host, seen = new Set<string>()): boolean => {
if (!host || seen.has(host.id)) return true;
@@ -238,7 +242,7 @@ export const usePortForwardingAutoStart = ({
}
const host = resolveEffectiveHost(rawHost);
return startPortForward(rule, host, resolveEffectiveHosts(hostsRef.current), keysRef.current, identitiesRef.current, onStatusChange, true);
return startPortForward(rule, host, resolveEffectiveHosts(hostsRef.current), keysRef.current, identitiesRef.current, onStatusChange, true, terminalSettingsRef.current);
};
setReconnectCallback(handleReconnect);
@@ -304,6 +308,10 @@ export const usePortForwardingAutoStart = ({
updateStoredRuleStatus(rule.id, status, error);
},
true, // Enable reconnect for auto-start rules
// Read via ref so adjusting global keepalive after launch doesn't
// re-trigger the auto-start effect (its dep array is intentionally
// stable to fire once on vault init).
terminalSettingsRef.current,
);
}
};

View File

@@ -68,6 +68,7 @@ export interface UsePortForwardingStateResult {
identities: Identity[],
onStatusChange?: (status: PortForwardingRule["status"], error?: string) => void,
enableReconnect?: boolean,
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number },
) => Promise<{ success: boolean; error?: string }>;
stopTunnel: (
ruleId: string,
@@ -387,11 +388,12 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
error?: string,
) => void,
enableReconnect = false,
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number },
) => {
return startPortForward(rule, host, hosts, keys, identities, (status, error) => {
setRuleStatus(rule.id, status, error);
onStatusChange?.(status, error ?? undefined);
}, enableReconnect);
}, enableReconnect, terminalSettings);
},
[setRuleStatus],
);

View File

@@ -9,6 +9,7 @@ FocusDirection,
getNextFocusSessionId,
insertPaneIntoWorkspace,
pruneWorkspaceNode,
reorderWorkspaceFocusSessionOrder,
SplitDirection,
SplitHint,
updateWorkspaceSplitSizes,
@@ -759,6 +760,27 @@ export const useSessionState = () => {
}));
}, []);
const reorderWorkspaceSessions = useCallback((
workspaceId: string,
draggedSessionId: string,
targetSessionId: string,
position: 'before' | 'after' = 'before',
) => {
setWorkspaces(prev => prev.map(ws => {
if (ws.id !== workspaceId) return ws;
return {
...ws,
focusSessionOrder: reorderWorkspaceFocusSessionOrder(
ws.root,
ws.focusSessionOrder,
draggedSessionId,
targetSessionId,
position,
),
};
}));
}, []);
// Move focus between panes in a workspace
const moveFocusInWorkspace = useCallback((workspaceId: string, direction: FocusDirection): boolean => {
const workspace = workspaces.find(w => w.id === workspaceId);
@@ -1049,6 +1071,7 @@ export const useSessionState = () => {
splitSession,
toggleWorkspaceViewMode,
setWorkspaceFocusedSession,
reorderWorkspaceSessions,
moveFocusInWorkspace,
runSnippet,
orphanSessions,

View File

@@ -1,10 +1,12 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { SyncConfig, TerminalTheme, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
import {
STORAGE_KEY_COLOR,
STORAGE_KEY_SYNC,
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_TERM_THEME_DARK,
STORAGE_KEY_TERM_THEME_LIGHT,
STORAGE_KEY_THEME,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
@@ -49,9 +51,9 @@ import {
shouldApplyIncomingCustomKeyBindingsRecord,
updateCustomKeyBinding as updateCustomKeyBindingRecord,
} from '../../domain/customKeyBindings';
import { applyCustomAccentToTerminalTheme, getTerminalThemeForUiTheme } from '../../domain/terminalAppearance';
import { applyCustomAccentToTerminalTheme, resolveFollowedTerminalThemeId, TERMINAL_THEME_AUTO } from '../../domain/terminalAppearance';
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
import { DEFAULT_FONT_SIZE, isDeprecatedPrimaryFontId } from '../../infrastructure/config/fonts';
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
import { UI_FONTS, DEFAULT_UI_FONT_ID } from '../../infrastructure/config/uiFonts';
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
@@ -71,6 +73,28 @@ const DEFAULT_ACCENT_MODE: 'theme' | 'custom' = 'theme';
const DEFAULT_CUSTOM_ACCENT = '221.2 83.2% 53.3%';
const DEFAULT_TERMINAL_THEME = 'netcatty-dark';
const DEFAULT_FONT_FAMILY = 'menlo';
/**
* Migrate any terminal font id arriving from storage / IPC / sync to a
* safe value. If `raw` is a deprecated proportional id (pingfang-sc,
* microsoft-yahei, comic-sans-ms), persist the rewrite back to
* localStorage so subsequent ingest paths and cloud-sync uploads stop
* carrying it. Used by every place that reads STORAGE_KEY_TERM_FONT_FAMILY
* — initial useState init, rehydrateAllFromStorage, IPC notifySettings
* change listener, and cross-window storage event listener — so a
* single point of truth keeps deprecated ids from re-entering state.
*
* Returns null when there's nothing to apply (raw is empty); callers
* fall back to DEFAULT_FONT_FAMILY in that case.
*/
function migrateIncomingTerminalFontId(raw: string | null | undefined): string | null {
if (!raw) return null;
if (isDeprecatedPrimaryFontId(raw)) {
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, DEFAULT_FONT_FAMILY);
return DEFAULT_FONT_FAMILY;
}
return raw;
}
// Auto-detect default hotkey scheme based on platform
const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
@@ -232,7 +256,16 @@ export const useSettingsState = () => {
const isUpgrade = !!localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
return !isUpgrade;
});
const [terminalFontFamilyId, setTerminalFontFamilyId] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY) || DEFAULT_FONT_FAMILY);
const [terminalThemeDarkId, setTerminalThemeDarkId] = useState<string>(
() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_DARK) || TERMINAL_THEME_AUTO,
);
const [terminalThemeLightId, setTerminalThemeLightId] = useState<string>(
() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_LIGHT) || TERMINAL_THEME_AUTO,
);
const [terminalFontFamilyId, setTerminalFontFamilyId] = useState<string>(() => {
const stored = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
return migrateIncomingTerminalFontId(stored) ?? DEFAULT_FONT_FAMILY;
});
const [terminalFontSize, setTerminalFontSize] = useState<number>(() => localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE) || DEFAULT_FONT_SIZE);
const [uiLanguage, setUiLanguage] = useState<UILanguage>(() => {
const stored = readStoredString(STORAGE_KEY_UI_LANGUAGE);
@@ -511,8 +544,13 @@ export const useSettingsState = () => {
// Terminal
const storedTermTheme = readStoredString(STORAGE_KEY_TERM_THEME);
if (storedTermTheme) setTerminalThemeId(storedTermTheme);
const storedTermThemeDark = readStoredString(STORAGE_KEY_TERM_THEME_DARK);
if (storedTermThemeDark) setTerminalThemeDarkId(storedTermThemeDark);
const storedTermThemeLight = readStoredString(STORAGE_KEY_TERM_THEME_LIGHT);
if (storedTermThemeLight) setTerminalThemeLightId(storedTermThemeLight);
const storedTermFont = readStoredString(STORAGE_KEY_TERM_FONT_FAMILY);
if (storedTermFont) setTerminalFontFamilyId(storedTermFont);
const migratedTermFont = migrateIncomingTerminalFontId(storedTermFont);
if (migratedTermFont) setTerminalFontFamilyId(migratedTermFont);
const storedTermSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
if (storedTermSize != null) setTerminalFontSize(storedTermSize);
const storedTermSettings = readStoredString(STORAGE_KEY_TERM_SETTINGS);
@@ -643,12 +681,19 @@ export const useSettingsState = () => {
if (key === STORAGE_KEY_TERM_THEME && typeof value === 'string') {
setTerminalThemeId(value);
}
if (key === STORAGE_KEY_TERM_THEME_DARK && typeof value === 'string') {
setTerminalThemeDarkId(value);
}
if (key === STORAGE_KEY_TERM_THEME_LIGHT && typeof value === 'string') {
setTerminalThemeLightId(value);
}
if (key === STORAGE_KEY_TERM_FOLLOW_APP_THEME) {
const next = value === true || value === 'true';
setFollowAppTerminalThemeState((prev) => (prev === next ? prev : next));
}
if (key === STORAGE_KEY_TERM_FONT_FAMILY && typeof value === 'string') {
setTerminalFontFamilyId(value);
const migrated = migrateIncomingTerminalFontId(value);
if (migrated) setTerminalFontFamilyId(migrated);
}
if (key === STORAGE_KEY_TERM_FONT_SIZE && typeof value === 'number') {
setTerminalFontSize(value);
@@ -835,6 +880,15 @@ export const useSettingsState = () => {
setTerminalThemeId(e.newValue);
}
}
// Sync per-mode follow terminal themes from other windows
if (e.key === STORAGE_KEY_TERM_THEME_DARK && e.newValue) {
const next = e.newValue;
setTerminalThemeDarkId((prev) => (prev === next ? prev : next));
}
if (e.key === STORAGE_KEY_TERM_THEME_LIGHT && e.newValue) {
const next = e.newValue;
setTerminalThemeLightId((prev) => (prev === next ? prev : next));
}
// Sync follow-app-theme toggle from other windows
if (e.key === STORAGE_KEY_TERM_FOLLOW_APP_THEME && e.newValue) {
const next = e.newValue === 'true';
@@ -844,8 +898,9 @@ export const useSettingsState = () => {
}
// Sync terminal font family from other windows
if (e.key === STORAGE_KEY_TERM_FONT_FAMILY && e.newValue) {
if (e.newValue !== s.terminalFontFamilyId) {
setTerminalFontFamilyId(e.newValue);
const migrated = migrateIncomingTerminalFontId(e.newValue);
if (migrated && migrated !== s.terminalFontFamilyId) {
setTerminalFontFamilyId(migrated);
}
}
// Sync terminal font size from other windows
@@ -983,6 +1038,18 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(followAppTerminalTheme));
}, [followAppTerminalTheme, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_DARK, terminalThemeDarkId);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_THEME_DARK, terminalThemeDarkId);
}, [terminalThemeDarkId, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_LIGHT, terminalThemeLightId);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_THEME_LIGHT, terminalThemeLightId);
}, [terminalThemeLightId, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
if (!persistMountedRef.current) return;
@@ -1265,25 +1332,32 @@ export const useSettingsState = () => {
const customThemes = useCustomThemes();
const currentTerminalTheme = useMemo(() => {
let baseTheme: TerminalTheme;
// When "Follow Application Theme" is enabled, pick the terminal theme
// whose background matches the active UI theme preset.
// When "Follow Application Theme" is enabled, honor the per-mode override
// (or auto-match the active UI theme preset when set to auto).
if (followAppTerminalTheme) {
const activeUiThemeId = resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId;
const mapped = getTerminalThemeForUiTheme(activeUiThemeId);
if (mapped) {
const found = TERMINAL_THEMES.find(t => t.id === mapped);
if (found) {
baseTheme = found;
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}
const followedId = resolveFollowedTerminalThemeId({
resolvedTheme,
terminalThemeDarkId,
terminalThemeLightId,
lightUiThemeId,
darkUiThemeId,
fallbackThemeId: terminalThemeId,
});
const followed = TERMINAL_THEMES.find(t => t.id === followedId)
|| customThemes.find(t => t.id === followedId);
if (followed) {
return applyCustomAccentToTerminalTheme(followed, accentMode, customAccent);
}
// Explicit override pointing at a deleted theme: fall through to the
// manual theme below.
}
baseTheme = TERMINAL_THEMES.find(t => t.id === terminalThemeId)
const baseTheme = TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|| customThemes.find(t => t.id === terminalThemeId)
|| TERMINAL_THEMES[0];
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}, [terminalThemeId, customThemes, followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
}, [terminalThemeId, terminalThemeDarkId, terminalThemeLightId, customThemes,
followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId,
accentMode, customAccent]);
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
key: K,
@@ -1320,6 +1394,10 @@ export const useSettingsState = () => {
setTerminalThemeId,
followAppTerminalTheme,
setFollowAppTerminalTheme: setFollowAppTerminalThemeState,
terminalThemeDarkId,
setTerminalThemeDarkId,
terminalThemeLightId,
setTerminalThemeLightId,
currentTerminalTheme,
terminalFontFamilyId,
setTerminalFontFamilyId,

View File

@@ -150,6 +150,16 @@ export const useSftpBackend = () => {
return bridge.getHomeDir();
}, []);
const listDrives = useCallback(async () => {
return await netcattyBridge.get()?.listDrives?.() ?? [];
}, []);
const openPath = useCallback(async (path: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.openPath) throw new Error("openPath unavailable");
return bridge.openPath(path);
}, []);
const startStreamTransfer = useCallback(
async (
options: Parameters<NonNullable<NetcattyBridge["startStreamTransfer"]>>[0],
@@ -268,6 +278,8 @@ export const useSftpBackend = () => {
mkdirLocal,
statLocal,
getHomeDir,
listDrives,
openPath,
startStreamTransfer,
cancelTransfer,

View File

@@ -174,6 +174,7 @@ export const useSftpState = (
hosts,
keys,
identities,
terminalSettings: options?.terminalSettings,
leftTabsRef,
rightTabsRef,
leftTabs,

View File

@@ -73,6 +73,11 @@ export const useTerminalBackend = () => {
bridge?.resizeSession?.(sessionId, cols, rows);
}, []);
const setSessionFlowPaused = useCallback((sessionId: string, paused: boolean) => {
const bridge = netcattyBridge.get();
bridge?.setSessionFlowPaused?.(sessionId, paused);
}, []);
const closeSession = useCallback((sessionId: string) => {
const bridge = netcattyBridge.get();
bridge?.closeSession?.(sessionId);
@@ -208,6 +213,7 @@ export const useTerminalBackend = () => {
getServerStats,
writeToSession,
resizeSession,
setSessionFlowPaused,
closeSession,
setSessionEncoding,
onSessionData,
@@ -240,6 +246,7 @@ export const useTerminalBackend = () => {
getServerStats,
writeToSession,
resizeSession,
setSessionFlowPaused,
closeSession,
setSessionEncoding,
onSessionData,

View File

@@ -1,5 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { normalizeDistroId, sanitizeHost } from "../../domain/host";
import { sanitizeGroupConfig } from "../../domain/groupConfig";
import { normalizeKnownHosts } from "../../domain/knownHosts";
import {
ConnectionLog,
GroupConfig,
@@ -240,9 +242,15 @@ export const useVaultState = () => {
}, []);
const updateGroupConfigs = useCallback((data: GroupConfig[]) => {
setGroupConfigs(data);
// Sanitize on the write path too — applySyncPayload / importVaultData
// route legacy payloads through here, and without this step a saved
// pingfang-sc / comic-sans-ms override from an older client would
// sit in memory and re-persist with `fontFamilyOverride: true` until
// the next reload. Mirrors updateHosts → sanitizeHost.
const cleaned = data.map(sanitizeGroupConfig);
setGroupConfigs(cleaned);
const ver = ++groupConfigsWriteVersion.current;
return encryptGroupConfigs(data).then((enc) => {
return encryptGroupConfigs(cleaned).then((enc) => {
if (ver === groupConfigsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
});
@@ -498,11 +506,22 @@ export const useVaultState = () => {
if (savedGroups) setCustomGroups(savedGroups);
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
// Load known hosts
// Load known hosts. Records imported from `~/.ssh/known_hosts` and
// records saved by older builds may be missing the `fingerprint` /
// `keyType` fields the verifier compares against; backfill them now
// so the next SSH connect can match without falling into the brittle
// re-derivation path that caused the repeated "fingerprint changed"
// warnings in #972.
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
STORAGE_KEY_KNOWN_HOSTS,
);
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
if (savedKnownHosts) {
const normalized = normalizeKnownHosts(savedKnownHosts);
setKnownHosts(normalized);
if (normalized !== savedKnownHosts) {
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, normalized);
}
}
// Load shell history
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
@@ -528,8 +547,9 @@ export const useVaultState = () => {
const gcVer = ++groupConfigsWriteVersion.current;
const decryptedGC = await decryptGroupConfigs(savedGroupConfigs);
if (gcVer === groupConfigsWriteVersion.current) {
setGroupConfigs(decryptedGC);
encryptGroupConfigs(decryptedGC).then((enc) => {
const sanitizedGC = decryptedGC.map(sanitizeGroupConfig);
setGroupConfigs(sanitizedGC);
encryptGroupConfigs(sanitizedGC).then((enc) => {
if (gcVer === groupConfigsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
});
@@ -630,7 +650,7 @@ export const useVaultState = () => {
if (key === STORAGE_KEY_KNOWN_HOSTS) {
const next = safeParse<KnownHost[]>(event.newValue) ?? [];
setKnownHosts(next);
setKnownHosts(normalizeKnownHosts(next));
return;
}
@@ -659,7 +679,7 @@ export const useVaultState = () => {
const writeAtStart = groupConfigsWriteVersion.current;
decryptGroupConfigs(next).then((dec) => {
if (seq === groupConfigsReadSeq.current && writeAtStart === groupConfigsWriteVersion.current)
setGroupConfigs(dec);
setGroupConfigs(dec.map(sanitizeGroupConfig));
});
return;
}

View File

@@ -561,6 +561,75 @@ test("applySyncPayload waits for async vault imports", async () => {
assert.equal(finished, true);
});
test("buildSyncPayload includes fallbackFont when present in TERM_SETTINGS", () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 5000, fallbackFont: "PingFang SC", fontLigatures: true }),
);
const payload = buildSyncPayload(vault());
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
assert.equal(termSettings.fallbackFont, "PingFang SC");
});
test("buildSyncPayload omits fallbackFont when TERM_SETTINGS does not set it", () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 5000, fontLigatures: true }),
);
const payload = buildSyncPayload(vault());
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
assert.equal("fallbackFont" in termSettings, false);
});
test("applySyncPayload writes incoming fallbackFont into local TERM_SETTINGS", async () => {
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
settings: { terminalSettings: { fallbackFont: "Sarasa Mono SC" } },
};
await applySyncPayload(payload, {
importVaultData: () => {},
});
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
assert.ok(raw, "TERM_SETTINGS should be written");
const parsed = JSON.parse(raw!);
assert.equal(parsed.fallbackFont, "Sarasa Mono SC");
});
test("applySyncPayload from legacy client (no fallbackFont) preserves local value", async () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 5000, fallbackFont: "Microsoft YaHei UI" }),
);
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
settings: { terminalSettings: { scrollback: 9999 } },
};
await applySyncPayload(payload, {
importVaultData: () => {},
});
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
const parsed = JSON.parse(raw!);
assert.equal(parsed.fallbackFont, "Microsoft YaHei UI", "legacy payload must not wipe local fallbackFont");
assert.equal(parsed.scrollback, 9999);
});
test("applyLocalVaultPayload restores known hosts from local backups", async () => {
let imported: Record<string, unknown> | null = null;
const payload: SyncPayload = {

View File

@@ -18,7 +18,12 @@ import type {
Snippet,
SSHKey,
} from '../domain/models';
import type { SyncPayload } from '../domain/sync';
import {
CLOUD_SYNC_PAYLOAD_ENTITY_KEYS,
SYNC_PAYLOAD_ENTITY_KEYS,
hasSyncPayloadEntityData,
type SyncPayload,
} from '../domain/sync';
import {
nextCustomKeyBindingsSyncVersion,
parseCustomKeyBindingsStorageRecord,
@@ -26,7 +31,7 @@ import {
} from '../domain/customKeyBindings';
import { isEncryptedCredentialPlaceholder } from '../domain/credentials';
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
import { rehydrateGlobalBookmarks } from '../components/sftp/hooks/useGlobalSftpBookmarks';
import { rehydrateGlobalSftpBookmarks } from './state/sftp/globalSftpBookmarks';
import {
STORAGE_KEY_THEME,
STORAGE_KEY_UI_THEME_LIGHT,
@@ -38,6 +43,8 @@ import {
STORAGE_KEY_CUSTOM_CSS,
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_TERM_THEME_DARK,
STORAGE_KEY_TERM_THEME_LIGHT,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
STORAGE_KEY_TERM_SETTINGS,
@@ -67,6 +74,7 @@ import {
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
STORAGE_KEY_PORT_FORWARDING,
} from '../infrastructure/config/storageKeys';
// ---------------------------------------------------------------------------
@@ -94,19 +102,7 @@ export interface SyncableVaultData {
* protecting or syncing.
*/
export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
const hasEntities =
(payload.hosts?.length ?? 0) > 0 ||
(payload.keys?.length ?? 0) > 0 ||
(payload.snippets?.length ?? 0) > 0 ||
(payload.identities?.length ?? 0) > 0 ||
(payload.proxyProfiles?.length ?? 0) > 0 ||
(payload.customGroups?.length ?? 0) > 0 ||
(payload.snippetPackages?.length ?? 0) > 0 ||
(payload.portForwardingRules?.length ?? 0) > 0 ||
(payload.knownHosts?.length ?? 0) > 0 ||
(payload.groupConfigs?.length ?? 0) > 0;
if (hasEntities) return true;
if (hasSyncPayloadEntityData(payload, SYNC_PAYLOAD_ENTITY_KEYS)) return true;
return Boolean(
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
@@ -118,24 +114,39 @@ export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
* Local-only trust records are intentionally ignored.
*/
export function hasMeaningfulCloudSyncData(payload: SyncPayload): boolean {
const hasEntities =
(payload.hosts?.length ?? 0) > 0 ||
(payload.keys?.length ?? 0) > 0 ||
(payload.snippets?.length ?? 0) > 0 ||
(payload.identities?.length ?? 0) > 0 ||
(payload.proxyProfiles?.length ?? 0) > 0 ||
(payload.customGroups?.length ?? 0) > 0 ||
(payload.snippetPackages?.length ?? 0) > 0 ||
(payload.portForwardingRules?.length ?? 0) > 0 ||
(payload.groupConfigs?.length ?? 0) > 0;
if (hasEntities) return true;
if (hasSyncPayloadEntityData(payload, CLOUD_SYNC_PAYLOAD_ENTITY_KEYS)) return true;
return Boolean(
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
);
}
export function sanitizePortForwardingRulesForSync(
rules: PortForwardingRule[] | undefined,
): PortForwardingRule[] | undefined {
if (!rules) return rules;
return rules.map((rule) => ({
...rule,
status: 'inactive' as const,
error: undefined,
lastUsedAt: undefined,
}));
}
export function getEffectivePortForwardingRulesForSync(
rules: PortForwardingRule[] | undefined,
): PortForwardingRule[] | undefined {
let effectiveRules = rules;
if (!effectiveRules || effectiveRules.length === 0) {
const stored = localStorageAdapter.read<PortForwardingRule[]>(STORAGE_KEY_PORT_FORWARDING);
if (Array.isArray(stored) && stored.length > 0) {
effectiveRules = stored;
}
}
return sanitizePortForwardingRulesForSync(effectiveRules);
}
/** Callbacks used by `applySyncPayload` to import data into local state. */
interface SyncPayloadImporters {
/** Import vault data. Cloud sync excludes local-only known hosts by default. */
@@ -152,15 +163,16 @@ interface SyncPayloadImporters {
/** Terminal settings keys that are safe to sync (platform-agnostic). */
const SYNCABLE_TERMINAL_KEYS = [
'startupCommandDelayMs',
'scrollback', 'drawBoldInBrightColors', 'terminalEmulationType',
'fontLigatures', 'fontWeight', 'fontWeightBold', 'fallbackFont',
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
'altAsMeta', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'altAsMeta', 'optionArrowWordJump', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'smoothScrolling',
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'disableBracketedPaste', 'clearWipesScrollback',
'preserveSelectionOnInput', 'osc52Clipboard', 'showServerStats',
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'showServerStats',
'serverStatsRefreshInterval', 'rendererType',
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
@@ -177,6 +189,8 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
STORAGE_KEY_CUSTOM_CSS,
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_TERM_THEME_DARK,
STORAGE_KEY_TERM_THEME_LIGHT,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
STORAGE_KEY_TERM_SETTINGS,
@@ -300,6 +314,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (followAppTermTheme === 'true' || followAppTermTheme === 'false') {
settings.followAppTerminalTheme = followAppTermTheme === 'true';
}
const termThemeDark = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_DARK);
if (termThemeDark) settings.terminalThemeDark = termThemeDark;
const termThemeLight = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_LIGHT);
if (termThemeLight) settings.terminalThemeLight = termThemeLight;
const termFont = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
if (termFont) settings.terminalFontFamily = termFont;
const termSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
@@ -423,6 +441,8 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
if (settings.followAppTerminalTheme != null) {
localStorageAdapter.writeString(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(settings.followAppTerminalTheme));
}
if (settings.terminalThemeDark != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_DARK, settings.terminalThemeDark);
if (settings.terminalThemeLight != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_LIGHT, settings.terminalThemeLight);
if (settings.terminalFontFamily != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, settings.terminalFontFamily);
if (settings.terminalFontSize != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_SIZE, String(settings.terminalFontSize));
@@ -550,7 +570,7 @@ export function buildSyncPayload(
customGroups: vault.customGroups,
snippetPackages: vault.snippetPackages,
groupConfigs: vault.groupConfigs,
portForwardingRules,
portForwardingRules: sanitizePortForwardingRulesForSync(portForwardingRules),
settings: collectSyncableSettings(),
syncedAt: Date.now(),
};
@@ -611,7 +631,7 @@ function applyPayload(
if (payload.settings) {
applySyncableSettings(payload.settings);
// Rehydrate in-memory bookmark snapshot after localStorage was updated
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalBookmarks();
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalSftpBookmarks();
importers.onSettingsApplied?.();
}
});

View File

@@ -38,6 +38,7 @@ import { matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
import { Button } from './ui/button';
import { ScrollArea } from './ui/scroll-area';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import AgentSelector from './ai/AgentSelector';
import ChatInput from './ai/ChatInput';
import ChatMessageList from './ai/ChatMessageList';
@@ -636,6 +637,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
undefined,
undefined,
`models_${currentAgentId}`,
currentAgentConfig.env,
).then((result) => {
if (cancelled || !result?.ok || !Array.isArray(result.models)) return;
// If the probe came back empty, drop any stale cached catalog for this
@@ -1035,24 +1037,32 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
session={activeSession}
onExport={handleExport}
/>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
onClick={() => setShowHistory(!showHistory)}
title="Session history"
>
<History size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
onClick={handleNewChat}
title="New chat"
>
<Plus size={15} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
onClick={() => setShowHistory(!showHistory)}
>
<History size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.sessionHistory')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
onClick={handleNewChat}
>
<Plus size={15} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.newChat')}</TooltipContent>
</Tooltip>
</div>
</div>
@@ -1199,13 +1209,17 @@ const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
<span className={SESSION_HISTORY_ROW_CLASSNAMES.time}>
{timeStr}
</span>
<button
onClick={(e) => onDelete(e, session.id)}
className={SESSION_HISTORY_ROW_CLASSNAMES.deleteButton}
title="Delete"
>
<Trash2 size={12} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => onDelete(e, session.id)}
className={SESSION_HISTORY_ROW_CLASSNAMES.deleteButton}
>
<Trash2 size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{t('common.delete')}</TooltipContent>
</Tooltip>
</div>
</div>
);

View File

@@ -54,6 +54,7 @@ import { Label } from './ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { toast } from './ui/toast';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
// ============================================================================
// Provider Icons
@@ -377,12 +378,14 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
</span>
</div>
) : error ? (
<p
className="text-xs text-red-500 truncate mt-1 max-w-[360px] cursor-help"
title={error}
>
{error}
</p>
<Tooltip>
<TooltipTrigger asChild>
<p className="text-xs text-red-500 truncate mt-1 max-w-[360px] cursor-help">
{error}
</p>
</TooltipTrigger>
<TooltipContent>{error}</TooltipContent>
</Tooltip>
) : (
<p className="text-xs text-muted-foreground mt-1">
{isConnecting ? t('cloudSync.provider.connecting') : t('cloudSync.provider.notConnected')}
@@ -1904,9 +1907,14 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
</div>
</div>
{entry.error && (
<span className="text-xs text-red-500 truncate max-w-24" title={entry.error}>
{t('cloudSync.history.error')}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs text-red-500 truncate max-w-24 cursor-default">
{t('cloudSync.history.error')}
</span>
</TooltipTrigger>
<TooltipContent>{entry.error}</TooltipContent>
</Tooltip>
)}
</div>
))}

View File

@@ -12,6 +12,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
import { cn } from "../lib/utils";
import { ConnectionLog, Host } from "../types";
import { ScrollArea } from "./ui/scroll-area";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
interface ConnectionLogsManagerProps {
logs: ConnectionLog[];
@@ -108,31 +109,39 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
{/* Saved column */}
<div className="flex items-center gap-2 shrink-0">
<button
onClick={(e) => {
e.stopPropagation();
onToggleSaved(log.id);
}}
className={cn(
"p-1.5 rounded-md transition-colors",
log.saved
? "text-primary bg-primary/10"
: "text-muted-foreground hover:text-primary hover:bg-primary/10"
)}
title={log.saved ? t("logs.action.unsave") : t("logs.action.save")}
>
<Bookmark size={16} fill={log.saved ? "currentColor" : "none"} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete(log.id);
}}
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100"
title={t("logs.action.delete")}
>
<Trash2 size={16} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation();
onToggleSaved(log.id);
}}
className={cn(
"p-1.5 rounded-md transition-colors",
log.saved
? "text-primary bg-primary/10"
: "text-muted-foreground hover:text-primary hover:bg-primary/10"
)}
>
<Bookmark size={16} fill={log.saved ? "currentColor" : "none"} />
</button>
</TooltipTrigger>
<TooltipContent>{log.saved ? t("logs.action.unsave") : t("logs.action.save")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation();
onDelete(log.id);
}}
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100"
>
<Trash2 size={16} />
</button>
</TooltipTrigger>
<TooltipContent>{t("logs.action.delete")}</TooltipContent>
</Tooltip>
</div>
</div>
);

View File

@@ -17,7 +17,7 @@ interface FileOpenerDialogProps {
onSelectSystemApp: () => Promise<SystemAppInfo | null>;
}
export const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
open,
onClose,
fileName,

View File

@@ -51,9 +51,11 @@ import { Combobox } from "./ui/combobox";
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
import { Input } from "./ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { TerminalFontSelect } from "./settings/TerminalFontSelect";
import { useAvailableFonts } from "../application/state/fontStore";
import { toast } from "./ui/toast";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
type SubPanel = "none" | "proxy" | "chain" | "env-vars" | "theme-select";
@@ -814,29 +816,33 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
}
}}
/>
<Button
variant="secondary"
size="icon"
className="h-8 w-8 shrink-0"
title={t("hostDetails.credential.browseKeyFile")}
onClick={async () => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (!bridge?.selectFile) return;
const filePath = await bridge.selectFile(
"Select SSH Private Key",
undefined,
[{ name: "All Files", extensions: ["*"] }]
);
if (filePath) {
const paths = [...(form.identityFilePaths || []), filePath];
update("identityFilePaths", paths);
update("identityFileId", undefined);
update("authMethod", "key");
}
}}
>
<FolderOpen size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className="h-8 w-8 shrink-0"
onClick={async () => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (!bridge?.selectFile) return;
const filePath = await bridge.selectFile(
"Select SSH Private Key",
undefined,
[{ name: "All Files", extensions: ["*"] }]
);
if (filePath) {
const paths = [...(form.identityFilePaths || []), filePath];
update("identityFilePaths", paths);
update("identityFileId", undefined);
update("authMethod", "key");
}
}}
>
<FolderOpen size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("hostDetails.credential.browseKeyFile")}</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon"
@@ -871,16 +877,20 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
/>
{/* Backspace behavior */}
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">{t("hostDetails.backspaceBehavior")}</p>
<select
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
value={form.backspaceBehavior ?? ""}
onChange={(e) => update("backspaceBehavior", e.target.value || undefined)}
<Select
value={form.backspaceBehavior ?? "default"}
onValueChange={(v) => update("backspaceBehavior", v === "default" ? undefined : v)}
>
<option value="">{t("hostDetails.backspaceBehavior.default")}</option>
<option value="ctrl-h">^H (0x08)</option>
</select>
<SelectTrigger className="h-8 w-auto text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">{t("hostDetails.backspaceBehavior.default")}</SelectItem>
<SelectItem value="ctrl-h">^H (0x08)</SelectItem>
</SelectContent>
</Select>
</div>
{/* Proxy */}
@@ -895,14 +905,19 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
</div>
<div className="flex min-w-0 items-center gap-2">
{(form.proxyConfig?.host || form.proxyProfileId) && (
<div title={proxySummaryLabel} className="min-w-0">
<Badge
variant="secondary"
className="max-w-[160px] truncate text-xs"
>
{proxySummaryLabel}
</Badge>
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="min-w-0 cursor-default">
<Badge
variant="secondary"
className="max-w-[160px] truncate text-xs"
>
{proxySummaryLabel}
</Badge>
</div>
</TooltipTrigger>
<TooltipContent>{proxySummaryLabel}</TooltipContent>
</Tooltip>
)}
<ChevronRight size={14} className="text-muted-foreground" />
</div>

View File

@@ -6,6 +6,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import type { Host } from "../types.ts";
import HostDetailsPanel, { parseOptionalPortInput } from "./HostDetailsPanel.tsx";
import { TooltipProvider } from "./ui/tooltip.tsx";
const hostWithMissingProxyProfile: Host = {
id: "host-1",
@@ -26,20 +27,24 @@ const renderHostDetails = (initialData: Host = hostWithMissingProxyProfile) =>
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(HostDetailsPanel, {
initialData,
availableKeys: [],
identities: [],
proxyProfiles: [],
groups: [],
managedSources: [],
allTags: [],
allHosts: [],
terminalThemeId: "default",
terminalFontSize: 14,
onSave: () => {},
onCancel: () => {},
}),
React.createElement(
TooltipProvider,
null,
React.createElement(HostDetailsPanel, {
initialData,
availableKeys: [],
identities: [],
proxyProfiles: [],
groups: [],
managedSources: [],
allTags: [],
allHosts: [],
terminalThemeId: "default",
terminalFontSize: 14,
onSave: () => {},
onCancel: () => {},
}),
),
),
);
@@ -111,29 +116,33 @@ test("HostDetailsPanel displays inherited telnet port before falling back to 23"
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(HostDetailsPanel, {
initialData: {
...hostWithMissingProxyProfile,
protocol: "telnet",
telnetEnabled: true,
telnetPort: undefined,
port: undefined,
group: "network",
proxyProfileId: undefined,
},
availableKeys: [],
identities: [],
proxyProfiles: [],
groups: ["network"],
managedSources: [],
allTags: [],
allHosts: [],
terminalThemeId: "default",
terminalFontSize: 14,
groupConfigs: [{ path: "network", telnetPort: 2325 }],
onSave: () => {},
onCancel: () => {},
}),
React.createElement(
TooltipProvider,
null,
React.createElement(HostDetailsPanel, {
initialData: {
...hostWithMissingProxyProfile,
protocol: "telnet",
telnetEnabled: true,
telnetPort: undefined,
port: undefined,
group: "network",
proxyProfileId: undefined,
},
availableKeys: [],
identities: [],
proxyProfiles: [],
groups: ["network"],
managedSources: [],
allTags: [],
allHosts: [],
terminalThemeId: "default",
terminalFontSize: 14,
groupConfigs: [{ path: "network", telnetPort: 2325 }],
onSave: () => {},
onCancel: () => {},
}),
),
),
);
@@ -145,29 +154,33 @@ test("HostDetailsPanel uses group telnet port instead of ssh port for optional t
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(HostDetailsPanel, {
initialData: {
...hostWithMissingProxyProfile,
protocol: "ssh",
telnetEnabled: true,
telnetPort: undefined,
port: 2222,
group: "network",
proxyProfileId: undefined,
},
availableKeys: [],
identities: [],
proxyProfiles: [],
groups: ["network"],
managedSources: [],
allTags: [],
allHosts: [],
terminalThemeId: "default",
terminalFontSize: 14,
groupConfigs: [{ path: "network", telnetPort: 2325 }],
onSave: () => {},
onCancel: () => {},
}),
React.createElement(
TooltipProvider,
null,
React.createElement(HostDetailsPanel, {
initialData: {
...hostWithMissingProxyProfile,
protocol: "ssh",
telnetEnabled: true,
telnetPort: undefined,
port: 2222,
group: "network",
proxyProfileId: undefined,
},
availableKeys: [],
identities: [],
proxyProfiles: [],
groups: ["network"],
managedSources: [],
allTags: [],
allHosts: [],
terminalThemeId: "default",
terminalFontSize: 14,
groupConfigs: [{ path: "network", telnetPort: 2325 }],
onSave: () => {},
onCancel: () => {},
}),
),
),
);
@@ -181,35 +194,39 @@ test("HostDetailsPanel displays inherited telnet credentials", () => {
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(HostDetailsPanel, {
initialData: {
...hostWithMissingProxyProfile,
protocol: "telnet",
telnetEnabled: true,
telnetUsername: undefined,
telnetPassword: undefined,
username: "ssh-user",
password: "ssh-password",
group: "network",
proxyProfileId: undefined,
},
availableKeys: [],
identities: [],
proxyProfiles: [],
groups: ["network"],
managedSources: [],
allTags: [],
allHosts: [],
terminalThemeId: "default",
terminalFontSize: 14,
groupConfigs: [{
path: "network",
telnetUsername: "group-telnet-user",
telnetPassword: "group-telnet-password",
}],
onSave: () => {},
onCancel: () => {},
}),
React.createElement(
TooltipProvider,
null,
React.createElement(HostDetailsPanel, {
initialData: {
...hostWithMissingProxyProfile,
protocol: "telnet",
telnetEnabled: true,
telnetUsername: undefined,
telnetPassword: undefined,
username: "ssh-user",
password: "ssh-password",
group: "network",
proxyProfileId: undefined,
},
availableKeys: [],
identities: [],
proxyProfiles: [],
groups: ["network"],
managedSources: [],
allTags: [],
allHosts: [],
terminalThemeId: "default",
terminalFontSize: 14,
groupConfigs: [{
path: "network",
telnetUsername: "group-telnet-user",
telnetPassword: "group-telnet-password",
}],
onSave: () => {},
onCancel: () => {},
}),
),
),
);

View File

@@ -8,6 +8,7 @@ import {
FolderPlus,
Forward,
Globe,
HeartPulse,
Key,
KeyRound,
Link2,
@@ -937,15 +938,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{selectedIdentity.label}
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={clearIdentity}
title={t("common.clear")}
>
<X size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={clearIdentity}
>
<X size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.clear")}</TooltipContent>
</Tooltip>
</div>
) : form.identityId ? (
<div className="flex items-center gap-2 h-10 px-3 rounded-md border border-border/70 bg-secondary/60">
@@ -955,15 +960,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{t("hostDetails.identity.missing")}
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={clearIdentity}
title={t("common.clear")}
>
<X size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={clearIdentity}
>
<X size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.clear")}</TooltipContent>
</Tooltip>
</div>
) : (
(() => {
@@ -1018,29 +1027,33 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
}}
className="h-10 pr-9"
/>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => {
setIdentitySuggestionsOpen((prev) => {
if (prev) return false;
const q = (form.username || "")
.toLowerCase()
.trim();
const matches = q
? identities.filter(
(i) =>
i.label.toLowerCase().includes(q) ||
i.username.toLowerCase().includes(q),
)
: identities;
return matches.length > 0;
});
}}
title={t("hostDetails.identity.suggestions")}
>
<ChevronDown size={16} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => {
setIdentitySuggestionsOpen((prev) => {
if (prev) return false;
const q = (form.username || "")
.toLowerCase()
.trim();
const matches = q
? identities.filter(
(i) =>
i.label.toLowerCase().includes(q) ||
i.username.toLowerCase().includes(q),
)
: identities;
return matches.length > 0;
});
}}
>
<ChevronDown size={16} />
</button>
</TooltipTrigger>
<TooltipContent>{t("hostDetails.identity.suggestions")}</TooltipContent>
</Tooltip>
</div>
</PopoverTrigger>
<PopoverContent
@@ -1122,14 +1135,18 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onChange={(e) => update("password", e.target.value)}
className="h-10 pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
title={showPassword ? t("hostDetails.password.hide") : t("hostDetails.password.show")}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</TooltipTrigger>
<TooltipContent>{showPassword ? t("hostDetails.password.hide") : t("hostDetails.password.show")}</TooltipContent>
</Tooltip>
</div>
)}
@@ -1152,9 +1169,14 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{form.identityFilePaths.map((keyPath, idx) => (
<div key={idx} className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60 overflow-hidden">
<FileKey size={14} className="text-primary shrink-0" />
<span className="text-xs w-0 flex-1 truncate font-mono" title={keyPath}>
{keyPath}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs w-0 flex-1 truncate font-mono cursor-default">
{keyPath}
</span>
</TooltipTrigger>
<TooltipContent>{keyPath}</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon"
@@ -1365,26 +1387,30 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
}
}}
/>
<Button
variant="secondary"
size="icon"
className="h-8 w-8 shrink-0"
title={t("hostDetails.credential.browseKeyFile")}
onClick={async () => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (!bridge?.selectFile) return;
const filePath = await bridge.selectFile(
"Select SSH Private Key",
undefined,
[{ name: "All Files", extensions: ["*"] }]
);
if (filePath) {
addLocalKeyFilePath(filePath);
}
}}
>
<FolderOpen size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className="h-8 w-8 shrink-0"
onClick={async () => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (!bridge?.selectFile) return;
const filePath = await bridge.selectFile(
"Select SSH Private Key",
undefined,
[{ name: "All Files", extensions: ["*"] }]
);
if (filePath) {
addLocalKeyFilePath(filePath);
}
}}
>
<FolderOpen size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("hostDetails.credential.browseKeyFile")}</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon"
@@ -1793,19 +1819,89 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</p>
</div>
)}
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">{t("hostDetails.backspaceBehavior")}</p>
<select
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
value={form.backspaceBehavior ?? ""}
onChange={(e) => update("backspaceBehavior", e.target.value || undefined)}
<Select
value={form.backspaceBehavior ?? "default"}
onValueChange={(v) => update("backspaceBehavior", v === "default" ? undefined : v)}
>
<option value="">{t("hostDetails.backspaceBehavior.default")}</option>
<option value="ctrl-h">^H (0x08)</option>
</select>
<SelectTrigger className="h-8 w-auto text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">{t("hostDetails.backspaceBehavior.default")}</SelectItem>
<SelectItem value="ctrl-h">^H (0x08)</SelectItem>
</SelectContent>
</Select>
</div>
</Card>
{/* Per-host keepalive override */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center gap-2">
<HeartPulse size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.section.keepalive")}</p>
</div>
<ToggleRow
label={t("hostDetails.keepalive.override")}
enabled={!!form.keepaliveOverride}
onToggle={() => {
const next = !form.keepaliveOverride;
update("keepaliveOverride", next);
// Seed sensible per-host defaults the first time the user
// turns the override on so the inputs aren't empty.
if (next) {
if (form.keepaliveInterval == null) update("keepaliveInterval", 0);
if (form.keepaliveCountMax == null) update("keepaliveCountMax", 3);
}
}}
/>
<p className="text-xs text-muted-foreground break-words">
{t("hostDetails.keepalive.desc")}
</p>
{form.keepaliveOverride && (
<div className="space-y-2 pt-1">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">{t("hostDetails.keepalive.interval")}</p>
<input
type="number"
min={0}
max={3600}
className="h-8 w-24 rounded-md border border-input bg-background px-2 text-xs"
value={form.keepaliveInterval ?? 0}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
if (!Number.isFinite(v)) return;
if (v < 0 || v > 3600) return;
update("keepaliveInterval", v);
}}
/>
</div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">{t("hostDetails.keepalive.countMax")}</p>
<input
type="number"
min={1}
max={100}
className="h-8 w-24 rounded-md border border-input bg-background px-2 text-xs"
value={form.keepaliveCountMax ?? 3}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
if (!Number.isFinite(v)) return;
if (v < 1 || v > 100) return;
update("keepaliveCountMax", v);
}}
/>
</div>
{(form.keepaliveInterval ?? 0) === 0 && (
<p className="text-xs text-muted-foreground break-words pl-1">
{t("hostDetails.keepalive.disabledHint")}
</p>
)}
</div>
)}
</Card>
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center justify-between">

View File

@@ -54,6 +54,7 @@ import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Textarea } from "./ui/textarea";
import { toast } from "./ui/toast";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
// Import utilities and components from keychain module
import {
@@ -1168,9 +1169,14 @@ echo $3 >> "$FILE"`);
</Label>
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
<FileKey size={14} className="text-primary shrink-0" />
<span className="text-xs font-mono truncate" title={draftKey.filePath}>
{draftKey.filePath}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs font-mono truncate cursor-default">
{draftKey.filePath}
</span>
</TooltipTrigger>
<TooltipContent>{draftKey.filePath}</TooltipContent>
</Tooltip>
</div>
</div>
)}

View File

@@ -22,6 +22,7 @@ import React, {
import { useI18n } from "../application/i18n/I18nProvider";
import { useKnownHostsBackend } from "../application/state/useKnownHostsBackend";
import { useStoredViewMode, ViewMode } from "../application/state/useStoredViewMode";
import { fingerprintFromPublicKey } from "../domain/knownHosts";
import { STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE } from "../infrastructure/config/storageKeys";
import { logger } from "../lib/logger";
import { cn } from "../lib/utils";
@@ -37,6 +38,7 @@ import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
import { Input } from "./ui/input";
import { ScrollArea } from "./ui/scroll-area";
import { SortDropdown, SortMode } from "./ui/sort-dropdown";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { toast } from "./ui/toast";
interface KnownHostsManagerProps {
@@ -79,12 +81,20 @@ const parseKnownHostsFile = (content: string): KnownHost[] => {
hostname = "(hashed)";
}
const fullPublicKey = `${keyType} ${publicKey}`;
// Compute the fingerprint up front so the SSH host verifier can match
// against this record directly instead of re-deriving on every connect —
// the re-derivation path is where the false "fingerprint changed"
// warnings in #972 originated.
const fingerprint = fingerprintFromPublicKey(fullPublicKey);
parsed.push({
id: `kh-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
hostname,
port,
keyType,
publicKey: `${keyType} ${publicKey}`,
publicKey: fullPublicKey,
fingerprint: fingerprint || undefined,
discoveredAt: Date.now(),
});
} catch {
@@ -122,27 +132,35 @@ const HostItem = React.memo<HostItemProps>(
{/* Quick action buttons on hover */}
<div className="absolute top-1 right-1 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{!converted && (
<button
className="p-1 rounded hover:bg-primary/20 text-primary"
onClick={(e) => {
e.stopPropagation();
onConvertToHost(knownHost);
}}
title={t("action.convertToHost")}
>
<ArrowRight size={12} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
className="p-1 rounded hover:bg-primary/20 text-primary"
onClick={(e) => {
e.stopPropagation();
onConvertToHost(knownHost);
}}
>
<ArrowRight size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{t("action.convertToHost")}</TooltipContent>
</Tooltip>
)}
<button
className="p-1 rounded hover:bg-destructive/20 text-destructive"
onClick={(e) => {
e.stopPropagation();
onDelete(knownHost.id);
}}
title={t("action.remove")}
>
<Trash2 size={12} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
className="p-1 rounded hover:bg-destructive/20 text-destructive"
onClick={(e) => {
e.stopPropagation();
onDelete(knownHost.id);
}}
>
<Trash2 size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{t("action.remove")}</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-3 h-full">
<div className="h-11 w-11 rounded-xl bg-primary/10 text-primary flex items-center justify-center flex-shrink-0">
@@ -193,18 +211,22 @@ const HostItem = React.memo<HostItemProps>(
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{!converted && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
onConvertToHost(knownHost);
}}
title={t("action.convertToHost")}
>
<ArrowRight size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
onConvertToHost(knownHost);
}}
>
<ArrowRight size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("action.convertToHost")}</TooltipContent>
</Tooltip>
)}
</div>
</div>

View File

@@ -277,7 +277,6 @@ const LogViewComponent: React.FC<LogViewProps> = ({
className="gap-1.5 h-8 px-2"
onClick={handleExport}
disabled={isExporting}
title={t("logView.export")}
>
<Download size={14} />
<span className="text-xs">{t("logView.export")}</span>
@@ -290,7 +289,6 @@ const LogViewComponent: React.FC<LogViewProps> = ({
size="sm"
className="gap-1.5 h-8 px-2"
onClick={() => setThemeModalOpen(true)}
title={t("logView.customizeAppearance")}
>
<Palette size={14} />
<span className="text-xs">{t("logView.appearance")}</span>

View File

@@ -75,6 +75,7 @@ interface PortForwardingProps {
onNewHost?: () => void;
onSaveHost?: (host: Host) => void;
onCreateGroup?: (groupPath: string) => void;
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}
const PortForwarding: React.FC<PortForwardingProps> = ({
@@ -88,6 +89,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
onNewHost: _onNewHost,
onSaveHost,
onCreateGroup: _onCreateGroup,
terminalSettings,
}) => {
const { t } = useI18n();
const {
@@ -169,6 +171,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
}
},
rule.autoStart, // Enable reconnect for auto-start rules
terminalSettings,
);
// Show error from result only if not already shown
if (!result.success && result.error && !errorShown) {
@@ -186,7 +189,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
});
}
},
[hosts, identities, keys, resolveEffectiveHost, setRuleStatus, startTunnel, t],
[hosts, identities, keys, resolveEffectiveHost, setRuleStatus, startTunnel, t, terminalSettings],
);
// Stop a port forwarding tunnel

View File

@@ -298,7 +298,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
onClose();
}}
className="ml-auto inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground border border-border rounded px-1.5 py-0.5 transition-colors hover:bg-muted/50"
title="New Workspace"
>
<Plus size={11} />
<span>New Workspace</span>

View File

@@ -249,15 +249,19 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
className="h-7 pl-7 text-xs bg-muted/30 border-none"
/>
</div>
<button
type="button"
onClick={handleAddSnippet}
title={t('snippets.action.newSnippet')}
aria-label={t('snippets.action.newSnippet')}
className="shrink-0 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
>
<Plus size={14} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleAddSnippet}
aria-label={t('snippets.action.newSnippet')}
className="shrink-0 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
>
<Plus size={14} />
</button>
</TooltipTrigger>
<TooltipContent>{t('snippets.action.newSnippet')}</TooltipContent>
</Tooltip>
</div>
{/* Content */}

View File

@@ -20,6 +20,7 @@ import {
} from './ui/dialog';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
interface SerialPort {
@@ -262,35 +263,41 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
<select
id="data-bits"
value={dataBits}
onChange={(e) => setDataBits(parseInt(e.target.value, 10) as 5 | 6 | 7 | 8)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
<Select
value={String(dataBits)}
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
>
{DATA_BITS.map((bits) => (
<option key={bits} value={bits}>
{bits}
</option>
))}
</select>
<SelectTrigger id="data-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATA_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Stop Bits */}
<div className="space-y-2">
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
<select
id="stop-bits"
value={stopBits}
onChange={(e) => setStopBits(parseFloat(e.target.value) as 1 | 1.5 | 2)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
<Select
value={String(stopBits)}
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
>
{STOP_BITS.map((bits) => (
<option key={bits} value={bits}>
{bits}
</option>
))}
</select>
<SelectTrigger id="stop-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STOP_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
{isStopBits15 && (
<p className="text-xs text-yellow-500">
{t('serial.field.stopBits15Warning')}
@@ -302,35 +309,41 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
{/* Parity */}
<div className="space-y-2">
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
<select
id="parity"
<Select
value={parity}
onChange={(e) => setParity(e.target.value as SerialParity)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
onValueChange={(v) => setParity(v as SerialParity)}
>
{PARITY_OPTIONS.map((option) => (
<option key={option} value={option}>
{t(`serial.parity.${option}`)}
</option>
))}
</select>
<SelectTrigger id="parity">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PARITY_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.parity.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Flow Control */}
<div className="space-y-2">
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
<select
id="flow-control"
<Select
value={flowControl}
onChange={(e) => setFlowControl(e.target.value as SerialFlowControl)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
>
{FLOW_CONTROL_OPTIONS.map((option) => (
<option key={option} value={option}>
{t(`serial.flowControl.${option}`)}
</option>
))}
</select>
<SelectTrigger id="flow-control">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FLOW_CONTROL_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.flowControl.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Terminal Options */}

View File

@@ -12,6 +12,7 @@ import { Button } from './ui/button';
import { Combobox, ComboboxOption, MultiCombobox } from './ui/combobox';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
import {
AsidePanel,
@@ -291,35 +292,41 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
<select
id="data-bits"
value={dataBits}
onChange={(e) => setDataBits(parseInt(e.target.value, 10) as 5 | 6 | 7 | 8)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
<Select
value={String(dataBits)}
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
>
{DATA_BITS.map((bits) => (
<option key={bits} value={bits}>
{bits}
</option>
))}
</select>
<SelectTrigger id="data-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATA_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Stop Bits */}
<div className="space-y-2">
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
<select
id="stop-bits"
value={stopBits}
onChange={(e) => setStopBits(parseFloat(e.target.value) as 1 | 1.5 | 2)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
<Select
value={String(stopBits)}
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
>
{STOP_BITS.map((bits) => (
<option key={bits} value={bits}>
{bits}
</option>
))}
</select>
<SelectTrigger id="stop-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STOP_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
{isStopBits15 && (
<p className="text-xs text-yellow-500">
{t('serial.field.stopBits15Warning')}
@@ -331,35 +338,41 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
{/* Parity */}
<div className="space-y-2">
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
<select
id="parity"
<Select
value={parity}
onChange={(e) => setParity(e.target.value as SerialParity)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
onValueChange={(v) => setParity(v as SerialParity)}
>
{PARITY_OPTIONS.map((option) => (
<option key={option} value={option}>
{t(`serial.parity.${option}`)}
</option>
))}
</select>
<SelectTrigger id="parity">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PARITY_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.parity.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Flow Control */}
<div className="space-y-2">
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
<select
id="flow-control"
<Select
value={flowControl}
onChange={(e) => setFlowControl(e.target.value as SerialFlowControl)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
>
{FLOW_CONTROL_OPTIONS.map((option) => (
<option key={option} value={option}>
{t(`serial.flowControl.${option}`)}
</option>
))}
</select>
<SelectTrigger id="flow-control">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FLOW_CONTROL_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.flowControl.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Terminal Options */}

View File

@@ -12,6 +12,7 @@ import { useWindowControls } from "../application/state/useWindowControls";
import { useUpdateCheck } from "../application/state/useUpdateCheck";
import { useAIState } from "../application/state/useAIState";
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
import { sanitizePortForwardingRulesForSync } from "../application/syncPayload";
import SettingsApplicationTab from "./SettingsApplicationTab";
import SettingsAppearanceTab from "./settings/tabs/SettingsAppearanceTab";
import SettingsFileAssociationsTab from "./settings/tabs/SettingsFileAssociationsTab";
@@ -20,6 +21,7 @@ import SettingsTerminalTab from "./settings/tabs/SettingsTerminalTab";
import SettingsSystemTab from "./settings/tabs/SettingsSystemTab";
const SettingsAITab = React.lazy(() => import("./settings/tabs/SettingsAITab"));
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
@@ -49,6 +51,11 @@ type SettingsState = ReturnType<typeof useSettingsState>;
const SettingsSyncTab = React.lazy(() => import("./settings/tabs/SettingsSyncTab"));
const settingsTabTriggerClassName =
"w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors overflow-hidden";
const settingsTabIconClassName = "shrink-0";
const settingsTabLabelClassName = "min-w-0 truncate";
const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ settings }) => {
const availableFonts = useAvailableFonts();
@@ -58,6 +65,12 @@ const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ s
setTerminalThemeId={settings.setTerminalThemeId}
followAppTerminalTheme={settings.followAppTerminalTheme}
setFollowAppTerminalTheme={settings.setFollowAppTerminalTheme}
terminalThemeDarkId={settings.terminalThemeDarkId}
setTerminalThemeDarkId={settings.setTerminalThemeDarkId}
terminalThemeLightId={settings.terminalThemeLightId}
setTerminalThemeLightId={settings.setTerminalThemeLightId}
lightUiThemeId={settings.lightUiThemeId}
darkUiThemeId={settings.darkUiThemeId}
terminalFontFamilyId={settings.terminalFontFamilyId}
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
terminalFontSize={settings.terminalFontSize}
@@ -127,13 +140,7 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
// Strip transient runtime fields before passing to sync
const portForwardingRulesForSync = useMemo(
() =>
portForwardingRules.map((rule) => ({
...rule,
status: "inactive" as const,
error: undefined,
lastUsedAt: undefined,
})),
() => sanitizePortForwardingRulesForSync(portForwardingRules) ?? [],
[portForwardingRules],
);
@@ -187,13 +194,17 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
<div className="flex items-center justify-between px-4 py-2">
<h1 className="text-lg font-semibold">{t("settings.title")}</h1>
{!isMac && (
<button
onClick={handleClose}
className="app-no-drag w-8 h-8 flex items-center justify-center rounded-md hover:bg-destructive/20 hover:text-destructive transition-colors text-muted-foreground"
title={t("common.close")}
>
<X size={16} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleClose}
className="app-no-drag w-8 h-8 flex items-center justify-center rounded-md hover:bg-destructive/20 hover:text-destructive transition-colors text-muted-foreground"
>
<X size={16} />
</button>
</TooltipTrigger>
<TooltipContent>{t("common.close")}</TooltipContent>
</Tooltip>
)}
</div>
</div>
@@ -208,51 +219,59 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
<TabsList className="flex flex-col h-auto bg-transparent gap-1 p-0 justify-start">
<TabsTrigger
value="application"
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
className={settingsTabTriggerClassName}
>
<AppWindow size={14} /> {t("settings.tab.application")}
<AppWindow size={14} className={settingsTabIconClassName} />
<span className={settingsTabLabelClassName}>{t("settings.tab.application")}</span>
</TabsTrigger>
<TabsTrigger
value="appearance"
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
className={settingsTabTriggerClassName}
>
<Palette size={14} /> {t("settings.tab.appearance")}
<Palette size={14} className={settingsTabIconClassName} />
<span className={settingsTabLabelClassName}>{t("settings.tab.appearance")}</span>
</TabsTrigger>
<TabsTrigger
value="terminal"
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
className={settingsTabTriggerClassName}
>
<TerminalSquare size={14} /> {t("settings.tab.terminal")}
<TerminalSquare size={14} className={settingsTabIconClassName} />
<span className={settingsTabLabelClassName}>{t("settings.tab.terminal")}</span>
</TabsTrigger>
<TabsTrigger
value="shortcuts"
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
className={settingsTabTriggerClassName}
>
<Keyboard size={14} /> {t("settings.tab.shortcuts")}
<Keyboard size={14} className={settingsTabIconClassName} />
<span className={settingsTabLabelClassName}>{t("settings.tab.shortcuts")}</span>
</TabsTrigger>
<TabsTrigger
value="file-associations"
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
className={settingsTabTriggerClassName}
>
<FileType size={14} /> {t("settings.tab.sftpFileAssociations")}
<FileType size={14} className={settingsTabIconClassName} />
<span className={settingsTabLabelClassName}>{t("settings.tab.sftpFileAssociations")}</span>
</TabsTrigger>
<TabsTrigger
value="ai"
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
className={settingsTabTriggerClassName}
>
<Sparkles size={14} /> AI
<Sparkles size={14} className={settingsTabIconClassName} />
<span className={settingsTabLabelClassName}>AI</span>
</TabsTrigger>
<TabsTrigger
value="sync"
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
className={settingsTabTriggerClassName}
>
<Cloud size={14} /> {t("settings.tab.syncCloud")}
<Cloud size={14} className={settingsTabIconClassName} />
<span className={settingsTabLabelClassName}>{t("settings.tab.syncCloud")}</span>
</TabsTrigger>
<TabsTrigger
value="system"
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
className={settingsTabTriggerClassName}
>
<HardDrive size={14} /> {t("settings.tab.system")}
<HardDrive size={14} className={settingsTabIconClassName} />
<span className={settingsTabLabelClassName}>{t("settings.tab.system")}</span>
</TabsTrigger>
</TabsList>
</div>

View File

@@ -19,13 +19,14 @@ import { editorTabStore } from "../application/state/editorTabStore";
import { releaseEditorTabSaveCoordinator } from "../application/state/editorTabSave";
import { useSftpBackend } from "../application/state/useSftpBackend";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
import { getParentPath } from "../application/state/sftp/utils";
import { getParentPath, isConcreteTransferTargetPath } from "../application/state/sftp/utils";
import { buildCacheKey } from "../application/state/sftp/sharedRemoteHostCache";
import { logger } from "../lib/logger";
import type { DropEntry } from "../lib/sftpFileUtils";
import { Host, Identity, SSHKey } from "../types";
import type { TransferTask } from "../types";
import { toast } from "./ui/toast";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { DistroAvatar } from "./DistroAvatar";
import { SftpPaneView } from "./sftp/SftpPaneView";
@@ -71,6 +72,7 @@ interface SftpSidePanelProps {
setEditorWordWrap: (value: boolean) => void;
onGetTerminalCwd?: () => Promise<string | null>;
onRequestTerminalFocus?: () => void;
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}
const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
@@ -98,6 +100,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
setEditorWordWrap,
onGetTerminalCwd,
onRequestTerminalFocus,
terminalSettings,
}) => {
const { t } = useI18n();
const hostWriteSource = writableHosts ?? hosts;
@@ -119,7 +122,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
useCompressedUpload: sftpUseCompressedUpload,
defaultShowHiddenFiles: sftpShowHiddenFiles,
autoConnectLocalOnMount: false,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
terminalSettings,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings]);
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
const {
@@ -130,6 +134,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
mkdirLocal,
deleteLocalFile,
listLocalDir,
listDrives,
openPath,
} = useSftpBackend();
const sftpRef = useRef(sftp);
@@ -293,6 +299,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
startStreamTransfer,
getSftpIdForConnection: sftp.getSftpIdForConnection,
listLocalFiles: listLocalDir,
listDrives,
});
const {
@@ -570,18 +577,35 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
const handleRevealTransferTarget = useCallback(
async (task: TransferTask) => {
if (!isConcreteTransferTargetPath(task)) return;
const connection = sftpRef.current.leftPane.connection;
const revealPath = task.isDirectory ? task.targetPath : getParentPath(task.targetPath);
if (task.targetConnectionId === "local") {
try {
const result = await openPath(revealPath);
if (result.success) return;
} catch {
// Show the localized error below.
}
toast.error(t("sftp.transfers.openTargetFolderError"), "SFTP");
return;
}
if (!connection || connection.isLocal) return;
const revealPath = task.isDirectory ? task.targetPath : getParentPath(task.targetPath);
await sftpRef.current.navigateTo("left", revealPath, { force: true });
},
[],
[openPath, t],
);
const canRevealTransferTarget = useCallback(
(task: TransferTask) => {
if (task.status !== "completed") return false;
if (!isConcreteTransferTargetPath(task)) return false;
if (task.targetConnectionId === "local") {
return true;
}
if (task.direction !== "upload" && task.direction !== "remote-to-remote") return false;
const connection = sftp.leftPane.connection;
@@ -602,6 +626,24 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
[sftp.leftPane.connection],
);
const canCopyTransferTargetPath = useCallback(
(task: TransferTask) => task.status === "completed" && isConcreteTransferTargetPath(task),
[],
);
const handleCopyTransferTargetPath = useCallback(
async (task: TransferTask) => {
if (!isConcreteTransferTargetPath(task)) return;
try {
await navigator.clipboard.writeText(task.targetPath);
toast.success(t("sftp.transfers.copyTargetPathSuccess"), "SFTP");
} catch {
toast.error(t("sftp.transfers.copyTargetPathError"), "SFTP");
}
},
[t],
);
// When the auto-connect effect defers a switch (active transfers or open
// editor), the panel still operates on the current connection, not
// activeHost. Use the connected host for the header so the label matches
@@ -648,18 +690,22 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
size="sm"
className="h-5 w-5 rounded-sm shrink-0"
/>
<div
className="min-w-0 flex-1 max-w-[calc(100%-1.75rem)] text-[11px] leading-5 truncate"
title={`${displayHost.label} · ${(displayHost.username || "root")}@${formatHostPort(displayHost.hostname, displayHost.port || 22)}`}
>
<span className="font-medium">
{displayHost.label}
</span>
<span className="mx-1 text-muted-foreground">·</span>
<span className="font-mono text-muted-foreground">
{(displayHost.username || "root")}@{displayHost.hostname}:{displayHost.port || 22}
</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="min-w-0 flex-1 max-w-[calc(100%-1.75rem)] text-[11px] leading-5 truncate cursor-default">
<span className="font-medium">
{displayHost.label}
</span>
<span className="mx-1 text-muted-foreground">·</span>
<span className="font-mono text-muted-foreground">
{(displayHost.username || "root")}@{displayHost.hostname}:{displayHost.port || 22}
</span>
</div>
</TooltipTrigger>
<TooltipContent>
{`${displayHost.label} · ${(displayHost.username || "root")}@${formatHostPort(displayHost.hostname, displayHost.port || 22)}`}
</TooltipContent>
</Tooltip>
</div>
</div>
)}
@@ -696,6 +742,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
allTransfers={sftp.transfers}
canRevealTransferTarget={canRevealTransferTarget}
onRevealTransferTarget={handleRevealTransferTarget}
canCopyTransferTargetPath={canCopyTransferTargetPath}
onCopyTransferTargetPath={handleCopyTransferTargetPath}
/>
</div>
@@ -705,6 +753,10 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
sftp={sftp}
visibleTransfers={visibleTransfers}
showTransferQueue={false}
canRevealTransferTarget={canRevealTransferTarget}
onRevealTransferTarget={handleRevealTransferTarget}
canCopyTransferTargetPath={canCopyTransferTargetPath}
onCopyTransferTargetPath={handleCopyTransferTargetPath}
showHostPickerLeft={showHostPickerLeft}
showHostPickerRight={showHostPickerRight}
hostSearchLeft={hostSearchLeft}
@@ -767,7 +819,11 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
prev.onGetTerminalCwd === next.onGetTerminalCwd &&
prev.onRequestTerminalFocus === next.onRequestTerminalFocus &&
prev.initialLocation?.hostId === next.initialLocation?.hostId &&
prev.initialLocation?.path === next.initialLocation?.path;
prev.initialLocation?.path === next.initialLocation?.path &&
// Only the keepalive fields of terminalSettings affect SFTP connection
// resolution today; compare them directly rather than the whole object.
prev.terminalSettings?.keepaliveInterval === next.terminalSettings?.keepaliveInterval &&
prev.terminalSettings?.keepaliveCountMax === next.terminalSettings?.keepaliveCountMax;
export const SftpSidePanel = memo(SftpSidePanelInner, sidePanelAreEqual);
SftpSidePanel.displayName = "SftpSidePanel";

View File

@@ -136,3 +136,31 @@ test("keeps reveal target and child toggle as separate buttons", () => {
assert.match(markup, /aria-expanded="false"/);
assert.match(markup, /aria-controls="children-transfer-1"/);
});
test("renders explicit target actions for completed local downloads", () => {
const markup = renderTransferItem(
{
...baseTask,
id: "download-1",
fileName: "report.pdf",
sourcePath: "/remote/report.pdf",
targetPath: "/Users/alice/Downloads/report.pdf",
targetConnectionId: "local",
direction: "download",
status: "completed",
error: undefined,
transferredBytes: 1024,
},
{
canRevealTarget: true,
onRevealTarget: () => {},
canCopyTargetPath: true,
onCopyTargetPath: () => {},
},
);
assert.match(markup, /aria-label="Open target folder: report\.pdf"/);
assert.match(markup, /aria-label="Copy target path: report\.pdf"/);
assert.match(markup, /lucide-folder-open/);
assert.match(markup, /lucide-clipboard-copy/);
});

View File

@@ -19,12 +19,13 @@ import { useI18n } from "../application/i18n/I18nProvider";
import { useIsSftpActive } from "../application/state/activeTabStore";
import { useSftpState } from "../application/state/useSftpState";
import { useSftpBackend } from "../application/state/useSftpBackend";
import { getParentPath, isConcreteTransferTargetPath } from "../application/state/sftp/utils";
import { HotkeyScheme, KeyBinding } from "../domain/models";
import { logger } from "../lib/logger";
import { useRenderTracker } from "../lib/useRenderTracker";
import { cn } from "../lib/utils";
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
import { Host, Identity, ProxyProfile, SSHKey } from "../types";
import { Host, Identity, ProxyProfile, SSHKey, TransferTask } from "../types";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
@@ -66,6 +67,7 @@ interface SftpViewProps {
keyBindings: KeyBinding[];
editorWordWrap: boolean;
setEditorWordWrap: (enabled: boolean) => void;
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}
const SftpViewInner: React.FC<SftpViewProps> = ({
@@ -84,6 +86,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
keyBindings,
editorWordWrap,
setEditorWordWrap,
terminalSettings,
}) => {
const { t } = useI18n();
const isActive = useIsSftpActive();
@@ -109,7 +112,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
...fileWatchHandlers,
useCompressedUpload: sftpUseCompressedUpload,
defaultShowHiddenFiles: sftpShowHiddenFiles,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
terminalSettings,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings]);
// Pre-resolve group defaults so SFTP connections inherit group config
const effectiveHosts = useMemo(() => {
@@ -133,6 +137,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
mkdirLocal,
deleteLocalFile,
listLocalDir,
listDrives,
openPath,
} = useSftpBackend();
// Store sftp in a ref so callbacks can access the latest instance
@@ -259,6 +265,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
startStreamTransfer,
getSftpIdForConnection: sftp.getSftpIdForConnection,
listLocalFiles: listLocalDir,
listDrives,
});
const visibleTransfers = useMemo(
@@ -266,6 +273,75 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
[sftp.transfers],
);
const getTransferTargetDirectory = useCallback(
(task: TransferTask) => (task.isDirectory ? task.targetPath : getParentPath(task.targetPath)),
[],
);
const findRemoteTransferTargetTab = useCallback((task: TransferTask) => {
const state = sftpRef.current;
for (const side of ["left", "right"] as const) {
const tabs = side === "left" ? state.leftTabs.tabs : state.rightTabs.tabs;
const pane = tabs.find((tab) => tab.connection?.id === task.targetConnectionId);
if (pane?.connection && !pane.connection.isLocal) {
return { side, tabId: pane.id };
}
}
return null;
}, []);
const canRevealTransferTarget = useCallback(
(task: TransferTask) => {
if (task.status !== "completed") return false;
if (!isConcreteTransferTargetPath(task)) return false;
if (task.targetConnectionId === "local") {
return true;
}
return !!findRemoteTransferTargetTab(task);
},
[findRemoteTransferTargetTab],
);
const handleRevealTransferTarget = useCallback(
async (task: TransferTask) => {
if (!isConcreteTransferTargetPath(task)) return;
const targetDirectory = getTransferTargetDirectory(task);
if (task.targetConnectionId === "local") {
try {
const result = await openPath(targetDirectory);
if (result.success) return;
} catch {
// Show the localized error below.
}
toast.error(t("sftp.transfers.openTargetFolderError"), "SFTP");
return;
}
const targetTab = findRemoteTransferTargetTab(task);
if (!targetTab) return;
await sftpRef.current.navigateTo(targetTab.side, targetDirectory, { force: true, tabId: targetTab.tabId });
},
[findRemoteTransferTargetTab, getTransferTargetDirectory, openPath, t],
);
const canCopyTransferTargetPath = useCallback(
(task: TransferTask) => task.status === "completed" && isConcreteTransferTargetPath(task),
[],
);
const handleCopyTransferTargetPath = useCallback(
async (task: TransferTask) => {
if (!isConcreteTransferTargetPath(task)) return;
try {
await navigator.clipboard.writeText(task.targetPath);
toast.success(t("sftp.transfers.copyTargetPathSuccess"), "SFTP");
} catch {
toast.error(t("sftp.transfers.copyTargetPathError"), "SFTP");
}
},
[t],
);
const containerStyle: React.CSSProperties = isActive
? {}
: {
@@ -470,6 +546,10 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
hosts={effectiveHosts}
sftp={sftp}
visibleTransfers={visibleTransfers}
canRevealTransferTarget={canRevealTransferTarget}
onRevealTransferTarget={handleRevealTransferTarget}
canCopyTransferTargetPath={canCopyTransferTargetPath}
onCopyTransferTargetPath={handleCopyTransferTargetPath}
showHostPickerLeft={showHostPickerLeft}
showHostPickerRight={showHostPickerRight}
hostSearchLeft={hostSearchLeft}
@@ -521,7 +601,12 @@ const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
prev.hotkeyScheme === next.hotkeyScheme &&
prev.keyBindings === next.keyBindings &&
prev.editorWordWrap === next.editorWordWrap &&
prev.setEditorWordWrap === next.setEditorWordWrap;
prev.setEditorWordWrap === next.setEditorWordWrap &&
// Only the keepalive fields of terminalSettings affect SFTP connection
// resolution today; compare them directly rather than the whole object
// so unrelated terminal-setting changes don't tear the panel down.
prev.terminalSettings?.keepaliveInterval === next.terminalSettings?.keepaliveInterval &&
prev.terminalSettings?.keepaliveCountMax === next.terminalSettings?.keepaliveCountMax;
export const SftpView = memo(SftpViewInner, sftpViewAreEqual);
SftpView.displayName = "SftpView";

View File

@@ -745,21 +745,25 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
actions={
<>
{editingSnippet.id && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => {
const id = editingSnippet.id;
if (!id) return;
onDelete(id);
handleClosePanel();
}}
aria-label={t('common.delete')}
title={t('common.delete')}
>
<Trash2 size={16} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => {
const id = editingSnippet.id;
if (!id) return;
onDelete(id);
handleClosePanel();
}}
aria-label={t('common.delete')}
>
<Trash2 size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common.delete')}</TooltipContent>
</Tooltip>
)}
<Button
variant="ghost"
@@ -839,18 +843,22 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-muted-foreground">{t('snippets.field.shortkey')}</p>
{editingSnippet.shortkey && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => {
setEditingSnippet(prev => ({ ...prev, shortkey: undefined }));
setShortkeyError(null);
}}
title={t('snippets.shortkey.clear')}
>
<RotateCcw size={12} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => {
setEditingSnippet(prev => ({ ...prev, shortkey: undefined }));
setShortkeyError(null);
}}
>
<RotateCcw size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('snippets.shortkey.clear')}</TooltipContent>
</Tooltip>
)}
</div>
<button
@@ -1269,7 +1277,6 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
value={newPackageName}
onChange={(e) => setNewPackageName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && createPackage()}
title="Package names can contain letters, numbers, hyphens, underscores, and forward slashes. Can optionally start with /"
/>
<p className="text-[11px] text-muted-foreground">{t('snippets.packageDialog.hint')}</p>
</div>

View File

@@ -35,6 +35,7 @@ import {
PopoverTrigger,
} from './ui/popover';
import { toast } from './ui/toast';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
// ============================================================================
// Provider Icons
@@ -169,26 +170,30 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 relative text-muted-foreground hover:text-foreground app-no-drag",
className
)}
title={t('sync.cloudSync')}
>
{getButtonIcon()}
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 relative text-muted-foreground hover:text-foreground app-no-drag",
className
)}
>
{getButtonIcon()}
{/* Status indicator dot */}
<StatusIndicator
status={overallStatus}
size="sm"
className="absolute top-0.5 right-0.5 ring-2 ring-background"
/>
</Button>
</PopoverTrigger>
{/* Status indicator dot */}
<StatusIndicator
status={overallStatus}
size="sm"
className="absolute top-0.5 right-0.5 ring-2 ring-background"
/>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{t('sync.cloudSync')}</TooltipContent>
</Tooltip>
<PopoverContent
key={syncStateKey}
@@ -222,16 +227,20 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
</div>
{onOpenSettings && (
<button
onClick={() => {
setIsOpen(false);
onOpenSettings();
}}
className="p-1 rounded hover:bg-muted transition-colors"
title={t('sync.settings')}
>
<Settings size={14} className="text-muted-foreground" />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => {
setIsOpen(false);
onOpenSettings();
}}
className="p-1 rounded hover:bg-muted transition-colors"
>
<Settings size={14} className="text-muted-foreground" />
</button>
</TooltipTrigger>
<TooltipContent>{t('sync.settings')}</TooltipContent>
</Tooltip>
)}
</div>
</div>

View File

@@ -3,9 +3,8 @@ import { FitAddon } from "@xterm/addon-fit";
import { SerializeAddon } from "@xterm/addon-serialize";
import { SearchAddon } from "@xterm/addon-search";
import "@xterm/xterm/css/xterm.css";
import { Cpu, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
import { Cpu, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import ReactDOM from "react-dom";
import { useI18n } from "../application/i18n/I18nProvider";
import { logger } from "../lib/logger";
import { cn, normalizeLineEndings, wrapBracketedPaste } from "../lib/utils";
@@ -29,14 +28,16 @@ import {
applyCustomAccentToTerminalTheme,
resolveHostTerminalThemeId,
} from "../domain/terminalAppearance";
import { classifyDistroId } from "../domain/host";
import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
import { resolveHostAuth } from "../domain/sshAuth";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
// SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer
import { Button } from "./ui/button";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { toast } from "./ui/toast";
import { useAvailableFonts } from "../application/state/fontStore";
import { composeFontFamilyStack, type SupportedPlatform } from "../infrastructure/config/cjkFonts";
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
import { useCustomThemes } from "../application/state/customThemeStore";
@@ -47,11 +48,20 @@ import { TerminalToolbar } from "./terminal/TerminalToolbar";
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
import { ZmodemOverwriteDialog } from "./terminal/ZmodemOverwriteDialog";
import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
import { createReplaySafeTerminalLogSanitizer } from "./terminal/replaySafeTerminalLog";
import { createConnectionLogBuffer } from "./terminal/connectionLogBuffer";
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
import { applyUserCursorPreference } from "./terminal/runtime/cursorPreference";
import { terminalAltKeyOptions } from "./terminal/runtime/altKeyOptions";
import {
createPromptLineBreakState,
type PromptLineBreakState,
} from "./terminal/runtime/promptLineBreak";
import { recordTerminalCommandExecution } from "./terminal/runtime/terminalCommandExecution";
import { shouldPreserveTerminalFocusOnMouseDown } from "./terminal/toolbarFocus";
import { preserveTerminalViewportInScrollback } from "./terminal/clearTerminalViewport";
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
@@ -60,7 +70,10 @@ import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextAc
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
import { useServerStats } from "./terminal/hooks/useServerStats";
import { extractDropEntries, getPathForFile, DropEntry } from "../lib/sftpFileUtils";
import { useTerminalAutocomplete, AutocompletePopup } from "./terminal/autocomplete";
import { TerminalAutocomplete } from "./terminal/TerminalAutocomplete";
import { createTerminalCwdTracker, resolvePreferredTerminalCwd } from "./terminal/sftpCwd";
const MAX_CONNECTION_LOG_DATA_CHARS = 1_000_000;
/**
* Extract unique root paths from drop entries for local terminal path insertion.
@@ -161,6 +174,7 @@ interface TerminalProps {
pendingUploadEntries?: DropEntry[],
sourceSessionId?: string,
) => void;
onTerminalCwdChange?: (sessionId: string, cwd: string | null) => void;
onOpenScripts?: () => void;
onOpenTheme?: () => void;
isBroadcastEnabled?: boolean;
@@ -251,6 +265,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onSplitHorizontal,
onSplitVertical,
onOpenSftp,
onTerminalCwdChange,
onOpenScripts,
onOpenTheme,
isBroadcastEnabled,
@@ -271,6 +286,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const serializeAddonRef = useRef<SerializeAddon | null>(null);
const searchAddonRef = useRef<SearchAddon | null>(null);
const xtermRuntimeRef = useRef<XTermRuntime | null>(null);
const terminalCwdTracker = useMemo(() => createTerminalCwdTracker(), []);
const knownCwdRef = useRef<string | undefined>(undefined);
const disposeDataRef = useRef<(() => void) | null>(null);
const disposeExitRef = useRef<(() => void) | null>(null);
@@ -283,8 +299,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// cancelled retry can't fire a startNewSession after the fact.
const retryTokenRef = useRef<symbol | null>(null);
const terminalDataCapturedRef = useRef(false);
const connectionLogBufferRef = useRef(createConnectionLogBuffer(MAX_CONNECTION_LOG_DATA_CHARS));
const terminalLogSanitizerRef = useRef(createReplaySafeTerminalLogSanitizer());
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
const commandBufferRef = useRef<string>("");
const promptLineBreakStateRef = useRef<PromptLineBreakState>(createPromptLineBreakState());
const [hasMouseTracking, setHasMouseTracking] = useState(false);
const mouseTrackingRef = useRef(false);
const serialLineBufferRef = useRef<string>("");
@@ -298,6 +317,26 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const lastFittedSizeRef = useRef<{ width: number; height: number } | null>(null);
const fontWeightFixupDoneRef = useRef(false);
const captureTerminalLogData = useCallback((data: string) => {
const replaySafeData = terminalLogSanitizerRef.current.append(data);
if (!replaySafeData) return;
connectionLogBufferRef.current.append(replaySafeData);
}, []);
const finalizeTerminalLogData = useCallback(() => {
const replaySafeData = terminalLogSanitizerRef.current.finish();
if (replaySafeData) {
connectionLogBufferRef.current.append(replaySafeData);
}
return connectionLogBufferRef.current.toString();
}, []);
const writeLocalTerminalData = useCallback((data: string) => {
if (!data) return;
captureTerminalLogData(data);
termRef.current?.write(data);
}, [captureTerminalLogData]);
useEffect(() => {
if (xtermRuntimeRef.current) {
// Merge global rules with host-level rules
@@ -344,10 +383,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const snippetsRef = useRef(snippets);
snippetsRef.current = snippets;
// Autocomplete handler refs (set after hook initialization)
// Autocomplete handler refs — populated by <TerminalAutocomplete> so the
// xterm runtime (and a few effects here) can drive the hook without making
// Terminal re-render on every suggestion update.
const autocompleteKeyEventRef = useRef<((e: KeyboardEvent) => boolean) | undefined>(undefined);
const autocompleteInputRef = useRef<((data: string) => void) | undefined>(undefined);
const autocompleteRepositionRef = useRef<(() => void) | undefined>(undefined);
const autocompleteCloseRef = useRef<(() => void) | undefined>(undefined);
const terminalBackend = useTerminalBackend();
const { resizeSession, setSessionEncoding } = terminalBackend;
@@ -435,20 +477,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const line = serialLineBufferRef.current + "\r";
terminalBackend.writeToSession(id, line);
serialLineBufferRef.current = "";
if (serialConfig?.localEcho) termRef.current?.write("\r\n");
if (serialConfig?.localEcho) writeLocalTerminalData("\r\n");
} else if (ch === "\x15") {
if (serialConfig?.localEcho && serialLineBufferRef.current.length > 0) {
termRef.current?.write("\b \b".repeat(serialLineBufferRef.current.length));
writeLocalTerminalData("\b \b".repeat(serialLineBufferRef.current.length));
}
serialLineBufferRef.current = "";
} else if (ch === "\b" || ch === "\x7f") {
if (serialLineBufferRef.current.length > 0) {
serialLineBufferRef.current = serialLineBufferRef.current.slice(0, -1);
if (serialConfig?.localEcho) termRef.current?.write("\b \b");
if (serialConfig?.localEcho) writeLocalTerminalData("\b \b");
}
} else if (ch.charCodeAt(0) >= 32) {
serialLineBufferRef.current += ch;
if (serialConfig?.localEcho) termRef.current?.write(ch);
if (serialConfig?.localEcho) writeLocalTerminalData(ch);
}
}
// Still update commandBuffer and broadcast for serial line mode
@@ -458,9 +500,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalBackend.writeToSession(id, text);
for (const ch of text) {
if (ch === "\r") {
termRef.current?.write("\r\n");
writeLocalTerminalData("\r\n");
} else if (ch.charCodeAt(0) >= 32) {
termRef.current?.write(ch);
writeLocalTerminalData(ch);
}
}
} else {
@@ -475,9 +517,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Update command buffer for onCommandExecuted tracking
for (const ch of text) {
if (ch === "\r" || ch === "\n") {
const cmd = commandBufferRef.current.trim();
if (cmd && onCommandExecuted) onCommandExecuted(cmd, host.id, host.label, sessionId);
commandBufferRef.current = "";
const rawCommand = commandBufferRef.current;
recordTerminalCommandExecution(rawCommand, {
host,
sessionId,
onCommandExecuted,
commandBufferRef,
promptLineBreakStateRef,
}, termRef.current);
} else if (ch === "\x15") {
// Ctrl+U: clear line — reset command buffer (fuzzy match sends this)
commandBufferRef.current = "";
@@ -491,35 +538,54 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
};
const autocomplete = useTerminalAutocomplete({
termRef,
sessionId,
hostId: host.id,
hostOs: host.os || (host.protocol === "local"
? (navigator.platform?.startsWith("Win") ? "windows" : navigator.platform?.startsWith("Mac") ? "macos" : "linux")
: "linux"),
settings: terminalSettings ? {
enabled: terminalSettings.autocompleteEnabled ?? true,
showGhostText: terminalSettings.autocompleteGhostText ?? true,
showPopupMenu: terminalSettings.autocompletePopupMenu ?? true,
debounceMs: terminalSettings.autocompleteDebounceMs ?? 100,
minChars: terminalSettings.autocompleteMinChars ?? 1,
maxSuggestions: terminalSettings.autocompleteMaxSuggestions ?? 8,
} : undefined,
onAcceptText: (text) => autocompleteAcceptTextRef.current?.(text),
protocol: host.protocol,
getCwd: () => knownCwdRef.current ?? xtermRuntimeRef.current?.currentCwd,
});
// Autocomplete config — the hook itself lives in <TerminalAutocomplete> so
// its state updates don't re-render this component (see render below).
const autocompleteHostOs: "linux" | "windows" | "macos" = host.os || (host.protocol === "local"
? (navigator.platform?.startsWith("Win") ? "windows" : navigator.platform?.startsWith("Mac") ? "macos" : "linux")
: "linux");
const autocompleteSettings = terminalSettings ? {
enabled: terminalSettings.autocompleteEnabled ?? true,
showGhostText: terminalSettings.autocompleteGhostText ?? true,
showPopupMenu: terminalSettings.autocompletePopupMenu ?? true,
debounceMs: terminalSettings.autocompleteDebounceMs ?? 100,
minChars: terminalSettings.autocompleteMinChars ?? 1,
maxSuggestions: terminalSettings.autocompleteMaxSuggestions ?? 8,
} : undefined;
// Wire up autocomplete handler refs so createXTermRuntime can use them
autocompleteKeyEventRef.current = autocomplete.handleKeyEvent;
autocompleteInputRef.current = autocomplete.handleInput;
autocompleteRepositionRef.current = autocomplete.repositionPopup;
const autocompleteClosePopup = autocomplete.closePopup;
const resolveSftpInitialPath = useCallback(async (): Promise<string | undefined> => {
const cwd = await resolvePreferredTerminalCwd({
rendererCwd: terminalCwdTracker.getRendererCwd(),
sessionId: sessionRef.current,
getSessionPwd: (id) => terminalBackend.getSessionPwd(id),
});
return cwd ?? undefined;
}, [terminalBackend, terminalCwdTracker]);
const clearTerminalCwd = useCallback(() => {
terminalCwdTracker.clearRendererCwd();
knownCwdRef.current = undefined;
onTerminalCwdChange?.(sessionId, null);
}, [onTerminalCwdChange, sessionId, terminalCwdTracker]);
useEffect(() => {
knownCwdRef.current = undefined;
}, [sessionId, host.id]);
clearTerminalCwd();
return clearTerminalCwd;
}, [clearTerminalCwd, host.id]);
// Classify the host's device family from the *detected* distro and the
// explicit deviceType only. This intentionally bypasses
// getEffectiveHostDistro(): the manual distro override (`distroMode:
// 'manual'` + `manualDistro`) is a purely cosmetic icon choice, and a
// user who pinned e.g. an "ubuntu" icon on what is actually a Cisco /
// Huawei host must not silently re-enable POSIX-shell probes against it.
// Several features gate on this — the working-directory probe below, the
// /etc/os-release probe, and the periodic server-stats poll (#674) —
// because each opens an extra exec channel that strict network-device
// CLIs reject or log as a new AAA session, and on Huawei VRP closes the
// whole session (#1043).
const detectedDeviceClass = classifyDistroId(host.distro);
const isNetworkDevice =
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
useEffect(() => {
if (host.protocol === "local" || host.protocol === "serial" || host.protocol === "telnet") {
@@ -529,10 +595,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
let cancelled = false;
const timer = setTimeout(async () => {
if (!sessionRef.current) return;
const id = sessionRef.current;
if (!id) return;
try {
const result = await terminalBackend.getSessionPwd(sessionRef.current);
if (!cancelled && result.success && result.cwd) {
// The pwd probe opens an extra POSIX-shell exec channel, which strict
// network-device CLIs like Huawei VRP answer by closing the whole
// session (#1043). Skip it for known network devices; for a brand-new
// host (distro not classified yet on the first connect) consult the
// SSH banner, which is captured for free at handshake time.
const info = await terminalBackend.getSessionRemoteInfo?.(id);
if (cancelled || id !== sessionRef.current) return;
if (!shouldProbeSessionCwd({ isNetworkDevice, remoteSshVersion: info?.remoteSshVersion })) {
return;
}
const result = await terminalBackend.getSessionPwd(id);
if (!cancelled && !terminalCwdTracker.getRendererCwd() && result.success && result.cwd) {
knownCwdRef.current = result.cwd;
}
} catch {
@@ -544,37 +621,22 @@ const TerminalComponent: React.FC<TerminalProps> = ({
cancelled = true;
clearTimeout(timer);
};
}, [host.protocol, status, terminalBackend]);
}, [host.protocol, status, terminalBackend, terminalCwdTracker, isNetworkDevice]);
useEffect(() => {
if (!isVisible) {
autocompleteClosePopup();
autocompleteCloseRef.current?.();
}
}, [isVisible, autocompleteClosePopup]);
}, [isVisible]);
// Check if this is a local or serial connection (doesn't need connection dialog during connecting)
const isLocalConnection = host.protocol === "local";
const isSerialConnection = host.protocol === "serial";
// Server stats (CPU, Memory, Disk) — only for Linux/macOS, and never
// for hosts classified as network devices (either via explicit
// deviceType='network' or via SSH banner detection that populated
// host.distro with a network-vendor ID). See #674: polling the stats
// command on Cisco / Huawei / Juniper etc. generates one AAA session
// log entry per poll because each exec channel is counted as a new
// session on those devices.
//
// IMPORTANT: this gating must NOT go through getEffectiveHostDistro()
// because that honors the manual distro override (`distroMode: 'manual'`
// + `manualDistro`) which is purely a cosmetic icon choice. A user who
// pinned an "ubuntu" icon on what is actually a Cisco host would
// otherwise silently re-enable the polling loop and re-introduce the
// AAA log flood this patch is meant to eliminate. The display icon can
// still be overridden (see DistroAvatar) — gating uses the raw detected
// `host.distro` and the explicit `host.deviceType` only.
const detectedDeviceClass = classifyDistroId(host.distro);
const isNetworkDevice =
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
// Server stats (CPU, Memory, Disk) — only for Linux/macOS, never for
// network devices. See isNetworkDevice above for why the gating uses the
// raw detected distro / explicit deviceType (not getEffectiveHostDistro);
// #674 covers the AAA-log-flood motivation for stats specifically.
const isSupportedOs =
!isNetworkDevice &&
(host.os === 'linux' || host.os === 'macos' || detectedDeviceClass === 'linux-like');
@@ -709,8 +771,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
? host.fontFamily
: fontFamilyId;
const resolvedFontId = hostFontId || "menlo";
return (availableFonts.find((f) => f.id === resolvedFontId) || availableFonts[0]).family;
}, [availableFonts, fontFamilyId, hasFontFamilyOverride, host.fontFamily]);
const selectedFont = availableFonts.find((f) => f.id === resolvedFontId) || availableFonts[0];
const platform: SupportedPlatform =
typeof navigator !== "undefined" && /Mac/i.test(navigator.platform)
? "darwin"
: typeof navigator !== "undefined" && /Win/i.test(navigator.platform)
? "win32"
: "linux";
return composeFontFamilyStack({
primaryFamily: selectedFont.family,
userFallback: terminalSettings?.fallbackFont ?? "",
latinFontId: resolvedFontId,
platform,
});
}, [availableFonts, fontFamilyId, hasFontFamilyOverride, host.fontFamily, terminalSettings?.fallbackFont]);
const effectiveTheme = useMemo(() => {
// When "Follow Application Theme" is on and there's no active
@@ -740,12 +814,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
hasConnectedRef.current = next === "connected";
onStatusChange?.(sessionId, next);
};
const handleTerminalDataCaptureOnce = useCallback((capturedSessionId: string, data: string) => {
const captureHandler = onTerminalDataCaptureRef.current;
if (!captureHandler || terminalDataCapturedRef.current) return;
terminalDataCapturedRef.current = true;
captureHandler(capturedSessionId, data);
}, []);
const replaySafeLogData = finalizeTerminalLogData();
const capturedData = replaySafeLogData || data;
captureHandler(capturedSessionId, capturedData);
}, [finalizeTerminalLogData]);
const cleanupSession = () => {
disposeDataRef.current?.();
@@ -797,6 +874,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
fitAddonRef,
serializeAddonRef,
pendingAuthRef,
promptLineBreakStateRef,
updateStatus,
setStatus,
setError,
@@ -808,6 +886,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setChainProgress,
t,
onSessionAttached: (id: string) => {
clearTerminalCwd();
// SSH: always sync. Its backend starts in utf-8 regardless of
// host.charset, so the push is what keeps the UI state aligned
// across reconnects — including localhost SSH targets, hence
@@ -831,8 +910,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setSessionEncoding(id, terminalEncodingRef.current);
}
},
onSessionExit,
onSessionExit: (closedSessionId, evt) => {
clearTerminalCwd();
onSessionExit?.(closedSessionId, evt);
},
onTerminalDataCapture: handleTerminalDataCaptureOnce,
onTerminalLogData: captureTerminalLogData,
onOsDetected,
onCommandExecuted,
sessionLog,
@@ -842,6 +925,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useEffect(() => {
let disposed = false;
terminalDataCapturedRef.current = false;
connectionLogBufferRef.current.reset();
terminalLogSanitizerRef.current = createReplaySafeTerminalLogSanitizer();
setError(null);
hasConnectedRef.current = false;
pendingOutputScrollRef.current = false;
@@ -849,6 +934,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setShowLogs(false);
setIsCancelling(false);
setIsDisconnectedDialogDismissed(false);
promptLineBreakStateRef.current = createPromptLineBreakState();
const boot = async () => {
try {
@@ -873,13 +959,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
statusRef,
onCommandExecuted,
commandBufferRef,
promptLineBreakStateRef,
setIsSearchOpen,
// Serial-specific options
serialLocalEcho: serialConfig?.localEcho,
serialLineMode: serialConfig?.lineMode,
serialLineBufferRef,
onTerminalLogData: captureTerminalLogData,
onCwdChange: (cwd: string) => {
terminalCwdTracker.setRendererCwd(cwd);
knownCwdRef.current = cwd;
onTerminalCwdChange?.(sessionId, cwd);
},
onOsc52ReadRequest: handleOsc52ReadRequest,
// Autocomplete integration
@@ -1144,11 +1234,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
: 0;
termRef.current.options.scrollOnUserInput =
shouldEnableNativeUserInputAutoScroll(terminalSettings);
termRef.current.options.altClickMovesCursor = !terminalSettings.altAsMeta;
const altKeyOpts = terminalAltKeyOptions(terminalSettings.altAsMeta);
termRef.current.options.macOptionIsMeta = altKeyOpts.macOptionIsMeta;
termRef.current.options.altClickMovesCursor = altKeyOpts.altClickMovesCursor;
termRef.current.options.wordSeparator = terminalSettings.wordSeparators;
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
}
// Changing the font can leave the WebGL renderer drawing stale glyphs from
// the old metrics (xterm.js #3280), surfacing as garbled text (issue #1049).
// Clear the texture atlas so glyphs re-rasterize with the new font.
xtermRuntimeRef.current?.clearTextureAtlas();
if (isVisibleRef.current) {
setTimeout(() => safeFit({ force: true, requireVisible: true }), 50);
} else {
@@ -1161,6 +1258,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (!isVisible) return;
const timer = setTimeout(() => {
safeFit({ requireVisible: true });
// Recover the WebGL renderer now that this tab is visible again. Hidden
// panes stay mounted off-screen (visibility:hidden) so each keeps a live
// WebGL context; creating another terminal's context — or the GPU dropping
// a non-composited off-screen canvas — can leave this terminal's drawing
// buffer corrupted ("花屏", issue #1063). Because a hidden pane keeps its
// dimensions, becoming visible triggers no resize and therefore no redraw,
// so the corruption persists until the user resizes the window. Force the
// same recovery a resize performs: clear the texture atlas (no-op on the
// DOM renderer) and synchronously repaint every row.
xtermRuntimeRef.current?.clearTextureAtlas();
const visibleTerm = termRef.current;
if (visibleTerm) forceSyncRenderAfterResize(visibleTerm);
if (pendingOutputScrollRef.current) {
termRef.current?.scrollToBottom();
if (typeof requestAnimationFrame === "function") {
@@ -1381,14 +1490,32 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (!el) return;
const handleContextMenuCapture = (e: MouseEvent) => {
if (mouseTrackingRef.current) {
e.preventDefault();
e.stopImmediatePropagation();
if (!mouseTrackingRef.current) return;
if (statusRef.current !== 'connected') return;
e.preventDefault();
e.stopImmediatePropagation();
// stopImmediatePropagation blocks the event from reaching React's
// bubble-phase root listener, so the onContextMenu handler in
// TerminalContextMenu (which dispatches paste / select-word) never
// fires inside a mouse-tracking TUI. Without dispatching the user's
// chosen action here, right-click paste silently stops working in
// opencode, tmux with `mouse on`, vim with `set mouse=a`, etc. (#941).
// Middle-click still works because its auxclick listener lives in
// createXTermRuntime and isn't gated by mouseTracking.
const behavior = terminalSettingsRef.current?.rightClickBehavior;
if (behavior === 'paste') {
void terminalContextActionsRef.current?.onPaste?.();
} else if (behavior === 'select-word') {
terminalContextActionsRef.current?.onSelectWord?.();
}
// 'context-menu' is intentionally not handled — Radix opens the
// menu via its own pointerdown listener, which our capture handler
// does not intercept.
};
const handleMouseUpCapture = (e: MouseEvent) => {
if (e.button === 2 && mouseTrackingRef.current) {
if (e.button === 2 && mouseTrackingRef.current && statusRef.current === 'connected') {
e.stopImmediatePropagation();
}
};
@@ -1456,10 +1583,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
if (!noAutoRun) data = `${data}\r`;
// Broadcast the exact bytes the active session receives so peers mirror it,
// including the bracketed-paste wrapping and the auto-run \r. Broadcasting
// the raw (un-wrapped) form would let a multi-line noAutoRun snippet run
// line-by-line on peers, since handleBroadcastInput writes bytes directly
// without re-wrapping. Without broadcasting at all, accepting a snippet in
// broadcast mode would clear peer input (the clear keystrokes already go
// through the broadcast-aware path) but never send the command.
if (isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
onBroadcastInputRef.current(data, sessionId);
}
terminalBackend.writeToSession(id, data);
scrollToBottomAfterProgrammaticInput(data);
term.focus();
}, [scrollToBottomAfterProgrammaticInput, terminalBackend]);
}, [scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId]);
// Only register the snippet executor once the terminal session is ready.
// Before that, TerminalLayer falls back to raw writeToSession which is the
@@ -1475,12 +1613,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const terminalContextActions = useTerminalContextActions({
termRef,
sourceSessionId: sessionId,
sessionRef,
terminalBackend,
onHasSelectionChange: setHasSelection,
disableBracketedPasteRef,
scrollOnPasteRef,
isBroadcastEnabledRef,
onBroadcastInputRef,
});
// Kept fresh on every render so the mouseTracking capture handler at
// handleContextMenuCapture (which is bound once per sessionId) can
// still invoke the latest paste / select-word callbacks without
// re-binding on every action identity change. See #941.
const terminalContextActionsRef = useRef(terminalContextActions);
terminalContextActionsRef.current = terminalContextActions;
const handleSetTerminalEncoding = (encoding: 'utf-8' | 'gb18030') => {
setTerminalEncoding(encoding);
@@ -1493,17 +1638,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const handleOpenSFTP = async () => {
if (onOpenSftp) {
// Delegate to parent (TerminalLayer) for shared SFTP side panel
let initialPath: string | undefined = undefined;
if (sessionRef.current) {
try {
const result = await terminalBackend.getSessionPwd(sessionRef.current);
if (result.success && result.cwd) {
initialPath = result.cwd;
}
} catch {
// Silently fail
}
}
const initialPath = await resolveSftpInitialPath();
onOpenSftp(host, initialPath, undefined, sessionId);
return;
}
@@ -1716,17 +1851,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
} else {
// Remote terminal: Trigger SFTP upload via parent
if (onOpenSftp) {
let initialPath: string | undefined = undefined;
if (sessionRef.current) {
try {
const result = await terminalBackend.getSessionPwd(sessionRef.current);
if (result.success && result.cwd) {
initialPath = result.cwd;
}
} catch {
// Silently fail
}
}
const initialPath = await resolveSftpInitialPath();
onOpenSftp(host, initialPath, dropEntries, sessionId);
}
}
@@ -1785,6 +1910,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onSelectWord={terminalContextActions.onSelectWord}
onSplitHorizontal={onSplitHorizontal}
onSplitVertical={onSplitVertical}
isReconnectable={status === "disconnected"}
onReconnect={handleRetry}
onClose={inWorkspace ? () => onCloseSession?.(sessionId) : undefined}
>
<div
@@ -1842,6 +1969,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
statusDotTone,
)}
/>
{host.protocol !== "local" && host.hostname && host.hostname !== "localhost" && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="ml-0.5 p-0.5 rounded hover:bg-[color:var(--terminal-toolbar-btn-hover)] transition-colors opacity-60 hover:opacity-100 flex-shrink-0"
onClick={() => {
void navigator.clipboard.writeText(host.hostname).then(() => {
toast.success(t("terminal.statusbar.copyHostname.toast", { hostname: host.hostname }));
}).catch(() => {
toast.error(t("terminal.statusbar.copyHostname.error"));
});
}}
aria-label={t("terminal.statusbar.copyHostname.label")}
>
<Copy size={10} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{t("terminal.statusbar.copyHostname.tooltip", { hostname: host.hostname })}</TooltipContent>
</Tooltip>
)}
</div>
{/* Server Stats Display */}
{terminalSettings?.showServerStats && status === 'connected' && serverStats.lastUpdated && (
@@ -1851,7 +1999,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<HoverCardTrigger asChild>
<button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
title={t("terminal.serverStats.cpu")}
aria-label={t("terminal.serverStats.cpu")}
>
<Cpu size={10} className="flex-shrink-0" />
<span>
@@ -1920,7 +2068,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<HoverCardTrigger asChild>
<button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
title={t("terminal.serverStats.memory")}
aria-label={t("terminal.serverStats.memory")}
>
<MemoryStick size={10} className="flex-shrink-0" />
<span>
@@ -1942,12 +2090,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
{serverStats.memTotal !== null && (
<div className="space-y-1.5">
<div className="w-full h-3 bg-muted rounded overflow-hidden flex">
{/* Used (green) */}
{/* Used (green) — exact value shown in legend below */}
{serverStats.memUsed !== null && serverStats.memUsed > 0 && (
<div
className="h-full bg-emerald-500"
style={{ width: `${(serverStats.memUsed / serverStats.memTotal) * 100}%` }}
title={`${t("terminal.serverStats.memUsed")}: ${(serverStats.memUsed / 1024).toFixed(1)}G`}
/>
)}
{/* Buffers (blue) */}
@@ -1955,7 +2102,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<div
className="h-full bg-blue-500"
style={{ width: `${(serverStats.memBuffers / serverStats.memTotal) * 100}%` }}
title={`${t("terminal.serverStats.memBuffers")}: ${(serverStats.memBuffers / 1024).toFixed(1)}G`}
/>
)}
{/* Cached (amber/orange) */}
@@ -1963,7 +2109,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<div
className="h-full bg-amber-500"
style={{ width: `${(serverStats.memCached / serverStats.memTotal) * 100}%` }}
title={`${t("terminal.serverStats.memCached")}: ${(serverStats.memCached / 1024).toFixed(1)}G`}
/>
)}
</div>
@@ -1997,7 +2142,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<div
className="h-full bg-rose-500"
style={{ width: `${(serverStats.swapUsed / serverStats.swapTotal) * 100}%` }}
title={`${t("terminal.serverStats.swapUsed")}: ${(serverStats.swapUsed / 1024).toFixed(1)}G`}
/>
)}
</div>
@@ -2030,9 +2174,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
style={{ width: `${Math.min(100, proc.memPercent * 2)}%` }}
/>
</div>
<span className="flex-shrink-0 font-mono truncate max-w-[140px]" title={proc.command}>
{proc.command.split('/').pop()?.split(' ')[0] || proc.command}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex-shrink-0 font-mono truncate max-w-[140px] cursor-default">
{proc.command.split('/').pop()?.split(' ')[0] || proc.command}
</span>
</TooltipTrigger>
<TooltipContent>{proc.command}</TooltipContent>
</Tooltip>
</div>
))}
</div>
@@ -2046,7 +2195,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<HoverCardTrigger asChild>
<button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
title={t("terminal.serverStats.disk")}
aria-label={t("terminal.serverStats.disk")}
>
<HardDrive size={10} className="flex-shrink-0" />
<span className={cn(
@@ -2074,9 +2223,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
{serverStats.disks.map((disk, index) => (
<div key={index} className="flex flex-col gap-1 min-w-[180px]">
<div className="flex items-center justify-between gap-4">
<span className="text-[10px] text-muted-foreground font-mono truncate max-w-[120px]" title={disk.mountPoint}>
{disk.mountPoint}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-[10px] text-muted-foreground font-mono truncate max-w-[120px] cursor-default">
{disk.mountPoint}
</span>
</TooltipTrigger>
<TooltipContent>{disk.mountPoint}</TooltipContent>
</Tooltip>
<span className={cn(
"text-[11px] font-medium whitespace-nowrap",
disk.percent >= 90 ? "text-red-400" : disk.percent >= 80 ? "text-amber-400" : "text-emerald-400"
@@ -2108,7 +2262,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<HoverCardTrigger asChild>
<button
className="flex items-center gap-1 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
title={t("terminal.serverStats.network")}
aria-label={t("terminal.serverStats.network")}
>
<ArrowDownToLine size={9} className="flex-shrink-0 text-emerald-400" />
<span>{formatNetSpeed(serverStats.netRxSpeed)}</span>
@@ -2152,40 +2306,48 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<div className="flex-1" />
<div className="flex items-center gap-0.5 flex-shrink-0">
{inWorkspace && onToggleBroadcast && (
<Button
variant="secondary"
size="icon"
className={cn(
"h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)]",
"bg-transparent hover:bg-transparent",
isBroadcastEnabled && "text-green-500",
)}
onClick={onToggleBroadcast}
title={
isBroadcastEnabled
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className={cn(
"h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)]",
"bg-transparent hover:bg-transparent",
isBroadcastEnabled && "text-green-500",
)}
onClick={onToggleBroadcast}
aria-label={
isBroadcastEnabled
? t("terminal.toolbar.broadcastDisable")
: t("terminal.toolbar.broadcastEnable")
}
>
<Radio size={12} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{isBroadcastEnabled
? t("terminal.toolbar.broadcastDisable")
: t("terminal.toolbar.broadcastEnable")
}
aria-label={
isBroadcastEnabled
? t("terminal.toolbar.broadcastDisable")
: t("terminal.toolbar.broadcastEnable")
}
>
<Radio size={12} />
</Button>
: t("terminal.toolbar.broadcastEnable")}
</TooltipContent>
</Tooltip>
)}
{inWorkspace && !isFocusMode && onExpandToFocus && (
<Button
variant="secondary"
size="icon"
className="h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent"
onClick={onExpandToFocus}
title={t("terminal.toolbar.focusMode")}
aria-label={t("terminal.toolbar.focusMode")}
>
<Maximize2 size={12} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className="h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent"
onClick={onExpandToFocus}
aria-label={t("terminal.toolbar.focusMode")}
>
<Maximize2 size={12} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t("terminal.toolbar.focusMode")}</TooltipContent>
</Tooltip>
)}
{renderControls({ showClose: inWorkspace })}
</div>
@@ -2218,29 +2380,29 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}}
/>
{/* Autocomplete popup — rendered via Portal to escape overflow:hidden */}
{isVisible && autocomplete.state.popupVisible && autocomplete.state.suggestions.length > 0 &&
ReactDOM.createPortal(
<AutocompletePopup
suggestions={autocomplete.state.suggestions}
selectedIndex={autocomplete.state.selectedIndex}
position={autocomplete.state.popupPosition}
cursorLineTop={autocomplete.state.popupCursorLineTop}
cursorLineBottom={autocomplete.state.popupCursorLineBottom}
visible={autocomplete.state.popupVisible}
expandUpward={autocomplete.state.expandUpward}
themeColors={effectiveTheme.colors}
onSelect={autocomplete.selectSuggestion}
subDirPanels={autocomplete.state.subDirPanels}
subDirFocusLevel={autocomplete.state.subDirFocusLevel}
containerRef={containerRef}
onRequestReposition={autocomplete.repositionPopup}
searchBarOffset={isSearchOpen ? 64 : 30}
onDismiss={autocompleteClosePopup}
/>,
document.body,
)
}
{/* Autocomplete — owns the hook + popup in its own component so
suggestion/selection updates don't re-render Terminal. Mounted
unconditionally; it gates the popup on `visible` internally. */}
<TerminalAutocomplete
termRef={termRef}
sessionId={sessionId}
hostId={host.id}
hostOs={autocompleteHostOs}
settings={autocompleteSettings}
protocol={host.protocol}
getCwd={() => terminalCwdTracker.getRendererCwd() ?? knownCwdRef.current}
onAcceptText={(text) => autocompleteAcceptTextRef.current?.(text)}
snippets={snippets}
onAcceptSnippet={(snippet) => executeSnippetCommand(snippet.command, snippet.noAutoRun)}
visible={isVisible}
themeColors={effectiveTheme.colors}
containerRef={containerRef}
searchBarOffset={isSearchOpen ? 64 : 30}
keyEventRef={autocompleteKeyEventRef}
inputRef={autocompleteInputRef}
repositionRef={autocompleteRepositionRef}
closeRef={autocompleteCloseRef}
/>
{/* OSC-52 clipboard read prompt */}
{osc52ReadPromptVisible && (
@@ -2331,6 +2493,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
/>
</div>
)}
{/* ZMODEM overwrite conflict dialog */}
{zmodem.overwriteRequest && (
<ZmodemOverwriteDialog
filename={zmodem.overwriteRequest.filename}
onRespond={zmodem.respondOverwrite}
/>
)}
</div>
{/* Compose Bar (solo sessions only; workspace uses TerminalLayer's global bar) */}

View File

@@ -35,6 +35,8 @@ const baseProps = {
onAddKnownHost: () => {},
onToggleWorkspaceViewMode: () => {},
onSetWorkspaceFocusedSession: () => {},
isBroadcastEnabled: () => false,
onToggleBroadcast: () => {},
onSplitSession: () => {},
toggleScriptsSidePanelRef: { current: null },
};
@@ -96,3 +98,23 @@ test("TerminalLayer re-renders when proxy profiles change", () => {
false,
);
});
test("TerminalLayer re-renders when broadcast state changes", () => {
assert.equal(
terminalLayerAreEqual(
baseProps as never,
{ ...baseProps, isBroadcastEnabled: () => true } as never,
),
false,
);
});
test("TerminalLayer re-renders when broadcast toggle handler changes", () => {
assert.equal(
terminalLayerAreEqual(
baseProps as never,
{ ...baseProps, onToggleBroadcast: () => {} } as never,
),
false,
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -73,7 +73,7 @@ interface TextEditorModalProps {
onPromoteToTab?: (snapshot: TextEditorModalSnapshot) => void;
}
export const TextEditorModal: React.FC<TextEditorModalProps> = ({
const TextEditorModal: React.FC<TextEditorModalProps> = ({
open,
onClose,
fileName,

View File

@@ -2,15 +2,16 @@
* Shared theme list component used by both ThemeSelectPanel and ThemeSelectModal
*/
import React, { memo, useMemo } from 'react';
import { Check } from 'lucide-react';
import { Check, Wand2 } from 'lucide-react';
import { useI18n } from '../application/i18n/I18nProvider';
import { TERMINAL_THEMES, USER_VISIBLE_TERMINAL_THEMES, isUiMatchTerminalThemeId } from '../infrastructure/config/terminalThemes';
import { TERMINAL_THEME_AUTO } from '../domain/terminalAppearance';
import { useCustomThemes } from '../application/state/customThemeStore';
import { cn } from '../lib/utils';
import { TerminalTheme } from '../types';
// Memoized theme item component
export const ThemeItem = memo(({
const ThemeItem = memo(({
theme,
isSelected,
onSelect
@@ -53,13 +54,18 @@ ThemeItem.displayName = 'ThemeItem';
interface ThemeListProps {
selectedThemeId: string;
onSelect: (themeId: string) => void;
/** Restrict the list to a single type; omit to show both sections. */
filterType?: 'dark' | 'light';
/** Render an "Auto (match app theme)" entry at the top. */
showAutoOption?: boolean;
}
export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect }) => {
export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect, filterType, showAutoOption }) => {
const { t } = useI18n();
const customThemes = useCustomThemes();
const deletedSelectedTheme = useMemo(
() => (selectedThemeId
&& selectedThemeId !== TERMINAL_THEME_AUTO
&& !isUiMatchTerminalThemeId(selectedThemeId)
&& !TERMINAL_THEMES.some((theme) => theme.id === selectedThemeId)
&& !customThemes.some((theme) => theme.id === selectedThemeId)
@@ -80,8 +86,33 @@ export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect
return { darkThemes: dark, lightThemes: light };
}, []);
const visibleCustomThemes = filterType
? customThemes.filter(theme => theme.type === filterType)
: customThemes;
const isAutoSelected = selectedThemeId === TERMINAL_THEME_AUTO;
return (
<>
{showAutoOption && (
<button
onClick={() => onSelect(TERMINAL_THEME_AUTO)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 mb-3 rounded-md text-left transition-all',
isAutoSelected ? 'bg-primary/10' : 'hover:bg-muted',
)}
>
<div className="w-12 h-8 rounded-[4px] flex-shrink-0 flex items-center justify-center border border-border/50 bg-gradient-to-br from-muted to-background">
<Wand2 size={14} className="text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className={cn('text-sm font-medium truncate', isAutoSelected ? 'text-primary' : 'text-foreground')}>
{t('settings.terminal.theme.auto')}
</div>
<div className="text-[10px] text-muted-foreground">{t('settings.terminal.theme.autoDesc')}</div>
</div>
{isAutoSelected && <Check size={16} className="text-primary flex-shrink-0" />}
</button>
)}
{hiddenSelectedTheme && (
<div className="mb-4 rounded-lg border border-border/60 bg-muted/30 px-3 py-2.5">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1 font-semibold">
@@ -105,6 +136,7 @@ export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect
</div>
)}
{/* Dark Themes Section */}
{(!filterType || filterType === 'dark') && (
<div className="mb-4">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
{t('settings.terminal.themeModal.darkThemes')}
@@ -120,8 +152,10 @@ export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect
))}
</div>
</div>
)}
{/* Light Themes Section */}
{(!filterType || filterType === 'light') && (
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
{t('settings.terminal.themeModal.lightThemes')}
@@ -137,15 +171,16 @@ export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect
))}
</div>
</div>
)}
{/* Custom Themes Section */}
{customThemes.length > 0 && (
{visibleCustomThemes.length > 0 && (
<div className="mt-4">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
{t('terminal.customTheme.section')}
</div>
<div className="space-y-1">
{customThemes.map(theme => (
{visibleCustomThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}

File diff suppressed because it is too large Load Diff

View File

@@ -190,5 +190,3 @@ export const TrafficDiagram: React.FC<TrafficDiagramProps> = ({ type, isAnimatin
</div>
);
};
export default TrafficDiagram;

View File

@@ -4,6 +4,7 @@ import { useSessionState } from "../application/state/useSessionState";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
import { useVaultState } from "../application/state/useVaultState";
import { toast } from "./ui/toast";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { cn } from "../lib/utils";
import { useI18n } from "../application/i18n/I18nProvider";
import { I18nProvider } from "../application/i18n/I18nProvider";
@@ -78,28 +79,31 @@ const WorkspaceGroup: React.FC<{
{expanded && (
<div className="ml-4 mt-0.5 space-y-0.5">
{sessions.map((s) => (
<button
key={s.id}
title={s.hostLabel || s.label}
onClick={() => {
// Jump to session (using session id)
void jumpToSession(s.id);
}}
className={cn(
"w-full text-left px-2 py-1 rounded hover:bg-muted flex items-center justify-between text-sm",
s.status === "connected" ? "" : "text-muted-foreground",
activeTabId === s.id ? "bg-muted/60" : "",
)}
>
<span className="flex items-center gap-2 min-w-0">
<StatusDot
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
spinning={s.status === "connecting"}
/>
<span className="truncate">{s.hostLabel || s.label}</span>
</span>
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
</button>
<Tooltip key={s.id}>
<TooltipTrigger asChild>
<button
onClick={() => {
// Jump to session (using session id)
void jumpToSession(s.id);
}}
className={cn(
"w-full text-left px-2 py-1 rounded hover:bg-muted flex items-center justify-between text-sm",
s.status === "connected" ? "" : "text-muted-foreground",
activeTabId === s.id ? "bg-muted/60" : "",
)}
>
<span className="flex items-center gap-2 min-w-0">
<StatusDot
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
spinning={s.status === "connecting"}
/>
<span className="truncate">{s.hostLabel || s.label}</span>
</span>
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
</button>
</TooltipTrigger>
<TooltipContent>{s.hostLabel || s.label}</TooltipContent>
</Tooltip>
))}
</div>
)}
@@ -107,7 +111,11 @@ const WorkspaceGroup: React.FC<{
);
};
const TrayPanelContent: React.FC = () => {
interface TrayPanelContentProps {
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}
const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings }) => {
const { t } = useI18n();
const {
hideTrayPanel,
@@ -215,17 +223,20 @@ const TrayPanelContent: React.FC = () => {
<span className="text-sm font-medium">Netcatty</span>
</div>
<div className="flex items-center gap-1">
<button
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
onClick={handleOpenMain}
title={t("tray.openMainWindow")}
>
<Maximize2 size={14} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
onClick={handleOpenMain}
>
<Maximize2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent>{t("tray.openMainWindow")}</TooltipContent>
</Tooltip>
<button
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
onClick={handleClose}
title="Close"
>
<X size={14} />
</button>
@@ -273,27 +284,30 @@ const TrayPanelContent: React.FC = () => {
))}
{/* Solo sessions */}
{soloSessions.map((s) => (
<button
key={s.id}
title={s.hostLabel || s.label}
onClick={() => {
void jumpToSession(s.id);
}}
className={cn(
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
s.status === "connected" ? "" : "text-muted-foreground",
activeTabId === s.id ? "bg-muted" : "",
)}
>
<span className="flex items-center gap-2 min-w-0">
<StatusDot
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
spinning={s.status === "connecting"}
/>
<span className="truncate">{s.hostLabel || s.label}</span>
</span>
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
</button>
<Tooltip key={s.id}>
<TooltipTrigger asChild>
<button
onClick={() => {
void jumpToSession(s.id);
}}
className={cn(
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
s.status === "connected" ? "" : "text-muted-foreground",
activeTabId === s.id ? "bg-muted" : "",
)}
>
<span className="flex items-center gap-2 min-w-0">
<StatusDot
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
spinning={s.status === "connecting"}
/>
<span className="truncate">{s.hostLabel || s.label}</span>
</span>
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
</button>
</TooltipTrigger>
<TooltipContent>{s.hostLabel || s.label}</TooltipContent>
</Tooltip>
))}
</div>
</div>
@@ -303,16 +317,20 @@ const TrayPanelContent: React.FC = () => {
{activeSession && (
<div>
<div className="px-2 py-1 text-xs text-muted-foreground">Current</div>
<Button
variant="ghost"
className="w-full justify-start px-2 h-8"
title={activeSession.hostLabel || activeSession.label}
onClick={() => {
void jumpToSession(activeSession.id);
}}
>
<span className="truncate">{activeSession.hostLabel || activeSession.label}</span>
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
className="w-full justify-start px-2 h-8"
onClick={() => {
void jumpToSession(activeSession.id);
}}
>
<span className="truncate">{activeSession.hostLabel || activeSession.label}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{activeSession.hostLabel || activeSession.label}</TooltipContent>
</Tooltip>
</div>
)}
@@ -328,55 +346,58 @@ const TrayPanelContent: React.FC = () => {
: `${rule.localPort}${rule.remoteHost}:${rule.remotePort}`);
return (
<button
key={rule.id}
disabled={isConnecting}
title={label}
onClick={() => {
const rawHost = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
if (!rawHost) {
toast.error(t("pf.error.hostNotFound"));
return;
}
if (isActive) {
void stopTunnel(rule.id);
} else {
const resolveEffectiveHost = (host: Host) => {
const withGroupDefaults = host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
};
const host = resolveEffectiveHost(rawHost);
void startTunnel(rule, host, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart);
}
}}
className={cn(
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
isConnecting ? "opacity-60" : "",
)}
>
<span className="flex items-center gap-2 min-w-0">
<StatusDot
status={
rule.status === "active"
? "success"
: rule.status === "connecting"
? "warning"
: rule.status === "error"
? "error"
: "neutral"
}
spinning={rule.status === "connecting"}
/>
<span className="truncate">{label}</span>
</span>
<span className="ml-2 text-xs text-muted-foreground">
{t(`tray.status.${rule.status}`)}
</span>
</button>
<Tooltip key={rule.id}>
<TooltipTrigger asChild>
<button
disabled={isConnecting}
onClick={() => {
const rawHost = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
if (!rawHost) {
toast.error(t("pf.error.hostNotFound"));
return;
}
if (isActive) {
void stopTunnel(rule.id);
} else {
const resolveEffectiveHost = (host: Host) => {
const withGroupDefaults = host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
};
const host = resolveEffectiveHost(rawHost);
void startTunnel(rule, host, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart, terminalSettings);
}
}}
className={cn(
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
isConnecting ? "opacity-60" : "",
)}
>
<span className="flex items-center gap-2 min-w-0">
<StatusDot
status={
rule.status === "active"
? "success"
: rule.status === "connecting"
? "warning"
: rule.status === "error"
? "error"
: "neutral"
}
spinning={rule.status === "connecting"}
/>
<span className="truncate">{label}</span>
</span>
<span className="ml-2 text-xs text-muted-foreground">
{t(`tray.status.${rule.status}`)}
</span>
</button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
})}
</div>
@@ -411,7 +432,7 @@ const TrayPanel: React.FC = () => {
const settings = useSettingsState();
return (
<I18nProvider locale={settings.uiLanguage}>
<TrayPanelContent />
<TrayPanelContent terminalSettings={settings.terminalSettings} />
</I18nProvider>
);
};

View File

@@ -0,0 +1,142 @@
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_HOSTS_SORT_MODE } from "../infrastructure/config/storageKeys.ts";
import type { Host, SSHKey } from "../types.ts";
import { VaultView } from "./VaultView.tsx";
import { TooltipProvider } from "./ui/tooltip.tsx";
const installStorageStub = (sortMode: string | null) => {
const values = new Map<string, string>();
if (sortMode !== null) {
values.set(STORAGE_KEY_VAULT_HOSTS_SORT_MODE, sortMode);
}
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 host = (id: string, label: string, createdAt: number, group = ""): Host => ({
id,
label,
hostname: `${id}.example.com`,
username: "root",
tags: [],
os: "linux",
port: 22,
protocol: "ssh",
authMethod: "password",
createdAt,
group,
});
const fallbackKey: SSHKey = {
id: "key-1",
label: "Fallback key",
type: "ED25519",
privateKey: "",
source: "generated",
category: "key",
created: 1,
};
const renderVault = (sortMode: string | null, hosts: Host[]) => {
installStorageStub(sortMode);
const noop = () => {};
return renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(
TooltipProvider,
null,
React.createElement(VaultView, {
hosts,
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
snippetPackages: [],
customGroups: [],
knownHosts: [],
shellHistory: [],
connectionLogs: [],
managedSources: [],
sessionCount: 0,
hotkeyScheme: "mac",
keyBindings: [],
terminalThemeId: "default",
terminalFontSize: 14,
onOpenSettings: noop,
onOpenQuickSwitcher: noop,
onCreateLocalTerminal: noop,
onDeleteHost: noop,
onConnect: noop,
onUpdateHosts: noop,
onUpdateKeys: noop,
onImportOrReuseKey: () => fallbackKey,
onUpdateIdentities: noop,
onUpdateProxyProfiles: noop,
onUpdateSnippets: noop,
onUpdateSnippetPackages: noop,
onUpdateCustomGroups: noop,
onUpdateKnownHosts: noop,
onUpdateManagedSources: noop,
onConvertKnownHost: noop,
onToggleConnectionLogSaved: noop,
onDeleteConnectionLog: noop,
onClearUnsavedConnectionLogs: noop,
onOpenLogView: noop,
groupConfigs: [],
onUpdateGroupConfigs: noop,
showRecentHosts: false,
showOnlyUngroupedHostsInRoot: false,
}),
),
),
);
};
test("Hosts sort mode is restored from storage", () => {
const markup = renderVault("za", [
host("alpha", "Alpha Host", 1),
host("zulu", "Zulu Host", 2),
]);
assert.ok(markup.indexOf("Zulu Host") < markup.indexOf("Alpha Host"));
});
test("Hosts grouped sort mode is restored from storage", () => {
const markup = renderVault("group", [
host("beta", "Beta Host", 1, "Beta Group"),
host("alpha", "Alpha Host", 2, "Alpha Group"),
]);
assert.match(
markup,
/<span class="text-sm font-medium text-muted-foreground">Alpha Group<\/span><span class="text-xs text-muted-foreground\/60">\(1\)<\/span>/,
);
});
test("Hosts sort mode falls back safely when storage contains an invalid value", () => {
const markup = renderVault("unknown-sort", [
host("zulu", "Zulu Host", 2),
host("alpha", "Alpha Host", 1),
]);
assert.ok(markup.indexOf("Alpha Host") < markup.indexOf("Zulu Host"));
});

View File

@@ -35,6 +35,7 @@ import React, { Suspense, lazy, memo, startTransition, useCallback, useEffect, u
import { useI18n } from "../application/i18n/I18nProvider";
import { useStoredViewMode } from "../application/state/useStoredViewMode";
import { useStoredBoolean } from "../application/state/useStoredBoolean";
import { useStoredString } from "../application/state/useStoredString";
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
import { sanitizeCredentialValue } from "../domain/credentials";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
@@ -50,6 +51,7 @@ import { upsertKnownHost } from "../domain/knownHosts";
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
import type { VaultImportFormat } from "../domain/vaultImport";
import {
STORAGE_KEY_VAULT_HOSTS_SORT_MODE,
STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED,
STORAGE_KEY_VAULT_HOSTS_VIEW_MODE,
STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED,
@@ -70,7 +72,6 @@ import {
SSHKey,
ShellHistoryEntry,
Snippet,
TerminalSession,
} from "../types";
import { AppLogo } from "./AppLogo";
import { DistroAvatar } from "./DistroAvatar";
@@ -122,6 +123,13 @@ type DropTarget =
| { kind: "root" }
| { kind: "group"; path: string };
const isSortMode = (value: string): value is SortMode =>
value === "az" ||
value === "za" ||
value === "newest" ||
value === "oldest" ||
value === "group";
// Props without isActive - it's now subscribed internally
interface VaultViewProps {
hosts: Host[];
@@ -135,7 +143,7 @@ interface VaultViewProps {
shellHistory: ShellHistoryEntry[];
connectionLogs: ConnectionLog[];
managedSources: ManagedSource[];
sessions: TerminalSession[];
sessionCount: number;
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
terminalThemeId: string;
@@ -172,6 +180,7 @@ interface VaultViewProps {
// Optional: navigate to a specific section on mount or when changed
navigateToSection?: VaultSection | null;
onNavigateToSectionHandled?: () => void;
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}
const VaultViewInner: React.FC<VaultViewProps> = ({
@@ -186,7 +195,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
shellHistory,
connectionLogs,
managedSources,
sessions,
sessionCount,
hotkeyScheme,
keyBindings,
terminalThemeId,
@@ -222,6 +231,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
showOnlyUngroupedHostsInRoot,
navigateToSection,
onNavigateToSectionHandled,
terminalSettings,
}) => {
const { t } = useI18n();
const rootRef = useRef<HTMLDivElement>(null);
@@ -279,7 +289,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
"grid",
);
const treeExpandedState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
const [sortMode, setSortMode] = useState<SortMode>("az");
const [sortMode, setSortMode] = useStoredString<SortMode>(
STORAGE_KEY_VAULT_HOSTS_SORT_MODE,
"az",
isSortMode,
);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedHostIds, setSelectedHostIds] = useState<Set<string>>(new Set());
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
@@ -1905,21 +1919,25 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onChange={setSortMode}
className="h-10 w-10"
/>
<Button
variant={isMultiSelectMode ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => {
if (isMultiSelectMode) {
clearHostSelection();
} else {
setIsMultiSelectMode(true);
}
}}
title={t("vault.hosts.multiSelect")}
>
<CheckSquare size={16} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={isMultiSelectMode ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => {
if (isMultiSelectMode) {
clearHostSelection();
} else {
setIsMultiSelectMode(true);
}
}}
>
<CheckSquare size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("vault.hosts.multiSelect")}</TooltipContent>
</Tooltip>
</div>
{/* New Host split button — collapses with an animation when the
host details / new-host aside panel is open, since the button
@@ -2227,6 +2245,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<ContextMenuItem onClick={() => handleEditHost(host)}>
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
</ContextMenuItem>
<ContextMenuItem onClick={() => handleDuplicateHost(host)}>
<Copy className="mr-2 h-4 w-4" /> {t('action.duplicate')}
</ContextMenuItem>
<ContextMenuItem onClick={() => handleCopyCredentials(host)}>
<ClipboardCopy className="mr-2 h-4 w-4" /> {t('vault.hosts.copyCredentials')}
</ContextMenuItem>
<ContextMenuItem onClick={() => toggleHostPinned(host.id)}>
<Pin className="mr-2 h-4 w-4" /> {t('vault.hosts.unpin')}
</ContextMenuItem>
@@ -2326,6 +2350,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<ContextMenuItem onClick={() => handleEditHost(host)}>
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
</ContextMenuItem>
<ContextMenuItem onClick={() => handleDuplicateHost(host)}>
<Copy className="mr-2 h-4 w-4" /> {t('action.duplicate')}
</ContextMenuItem>
<ContextMenuItem onClick={() => handleCopyCredentials(host)}>
<ClipboardCopy className="mr-2 h-4 w-4" /> {t('vault.hosts.copyCredentials')}
</ContextMenuItem>
<ContextMenuItem onClick={() => toggleHostPinned(host.id)}>
<Pin className="mr-2 h-4 w-4" /> {host.pinned ? t('vault.hosts.unpin') : t('vault.hosts.pinToTop')}
</ContextMenuItem>
@@ -2493,7 +2523,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
{t("vault.hosts.header.entries", { count: viewMode === "tree" ? treeViewHosts.length : visibleDisplayedHosts.length })}
</span>
<div className="bg-secondary/80 border border-border/70 rounded-md px-2 py-1 text-[11px]">
{t("vault.hosts.header.live", { count: sessions.length })}
{t("vault.hosts.header.live", { count: sessionCount })}
</div>
</div>
</div>
@@ -2946,6 +2976,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
Array.from(new Set([...customGroups, groupPath])),
)
}
terminalSettings={terminalSettings}
/>
)}
{/* Always render KnownHostsManager but hide with CSS to prevent unmounting */}
@@ -3272,12 +3303,18 @@ export const vaultViewAreEqual = (
prev.knownHosts === next.knownHosts &&
prev.shellHistory === next.shellHistory &&
prev.connectionLogs === next.connectionLogs &&
prev.sessions === next.sessions &&
prev.sessionCount === next.sessionCount &&
prev.managedSources === next.managedSources &&
prev.groupConfigs === next.groupConfigs &&
prev.terminalThemeId === next.terminalThemeId &&
prev.terminalFontSize === next.terminalFontSize &&
prev.navigateToSection === next.navigateToSection;
prev.navigateToSection === next.navigateToSection &&
// Only the keepalive fields of terminalSettings are forwarded to
// PortForwarding inside the vault, so compare them directly. Other
// terminal settings (fonts, themes, etc.) don't affect this subtree
// and we don't want to re-render for them.
prev.terminalSettings?.keepaliveInterval === next.terminalSettings?.keepaliveInterval &&
prev.terminalSettings?.keepaliveCountMax === next.terminalSettings?.keepaliveCountMax;
return isEqual;
};

View File

@@ -4,6 +4,7 @@ import { code } from '@streamdown/code';
import type { ComponentProps, HTMLAttributes } from 'react';
import { memo } from 'react';
import { Streamdown } from 'streamdown';
import { createSafeCodeHighlighter } from './streamdownCodeHighlighter';
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: 'user' | 'assistant' | 'system' | 'tool';
@@ -46,21 +47,8 @@ export const MessageContent = ({ children, className, from, ...props }: MessageC
</div>
);
export type MessageActionsProps = ComponentProps<'div'>;
export const MessageActions = ({ className, children, ...props }: MessageActionsProps) => (
<div
className={cn(
'flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity',
className,
)}
{...props}
>
{children}
</div>
);
const streamdownPlugins = { cjk, code };
const safeCode = createSafeCodeHighlighter(code);
const streamdownPlugins = { cjk, code: safeCode };
export type MessageResponseProps = ComponentProps<typeof Streamdown>;

View File

@@ -11,7 +11,6 @@ import type {
FormEvent,
HTMLAttributes,
KeyboardEvent,
ReactNode,
} from 'react';
import { forwardRef, useCallback, useRef } from 'react';
import { cn } from '../../lib/utils';
@@ -145,37 +144,6 @@ export const PromptInputTools = forwardRef<HTMLDivElement, PromptInputToolsProps
);
PromptInputTools.displayName = 'PromptInputTools';
// ---------------------------------------------------------------------------
// PromptInputButton (toolbar button with optional tooltip)
// ---------------------------------------------------------------------------
export interface PromptInputButtonProps extends ComponentProps<typeof InputGroupButton> {
tooltip?: ReactNode;
tooltipSide?: 'top' | 'bottom' | 'left' | 'right';
}
export const PromptInputButton = forwardRef<HTMLButtonElement, PromptInputButtonProps>(
({ tooltip, tooltipSide = 'top', ...props }, ref) => {
const button = <InputGroupButton ref={ref} {...props} />;
if (!tooltip) return button;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side={tooltipSide}>{tooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
);
PromptInputButton.displayName = 'PromptInputButton';
// ---------------------------------------------------------------------------
// PromptInputSubmit
// ---------------------------------------------------------------------------
export type PromptInputStatus = 'idle' | 'submitted' | 'streaming' | 'error';
export interface PromptInputSubmitProps extends ComponentProps<typeof InputGroupButton> {
@@ -244,4 +212,3 @@ export const PromptInputSubmit = forwardRef<HTMLButtonElement, PromptInputSubmit
},
);
PromptInputSubmit.displayName = 'PromptInputSubmit';

View File

@@ -0,0 +1,76 @@
import type {
CodeHighlighterPlugin,
HighlightOptions,
HighlightResult,
} from 'streamdown';
import type { BundledLanguage } from 'shiki';
const PLAIN_TEXT_LANGUAGES = new Set([
'',
'plain',
'plaintext',
'text',
'txt',
]);
const LANGUAGE_ALIASES: Record<string, BundledLanguage> = {
cfg: 'ini',
conf: 'ini',
config: 'ini',
};
export const createPlainCodeHighlightResult = (source: string): HighlightResult => {
const code = source.replace(/\n+$/, '');
return {
bg: 'transparent',
fg: 'inherit',
tokens: code.split('\n').map((line) => [
{
content: line,
color: 'inherit',
bgColor: 'transparent',
htmlStyle: {},
offset: 0,
},
]),
};
};
const normalizeLanguageKey = (language: string): string =>
language.trim().toLowerCase();
export const resolveSupportedCodeLanguage = (
highlighter: CodeHighlighterPlugin,
language: string,
): BundledLanguage | null => {
const key = normalizeLanguageKey(language);
if (PLAIN_TEXT_LANGUAGES.has(key)) return null;
const direct = key as BundledLanguage;
if (highlighter.supportsLanguage(direct)) return direct;
const alias = LANGUAGE_ALIASES[key];
if (alias && highlighter.supportsLanguage(alias)) return alias;
return null;
};
export const createSafeCodeHighlighter = (
highlighter: CodeHighlighterPlugin,
): CodeHighlighterPlugin => ({
...highlighter,
supportsLanguage(language) {
return resolveSupportedCodeLanguage(highlighter, language) !== null;
},
highlight(options: HighlightOptions, callback?: (result: HighlightResult) => void) {
const supportedLanguage = resolveSupportedCodeLanguage(highlighter, options.language);
if (!supportedLanguage) {
return createPlainCodeHighlightResult(options.code);
}
return highlighter.highlight(
{ ...options, language: supportedLanguage },
callback,
);
},
});

View File

@@ -4,6 +4,7 @@ import { cn } from '../../lib/utils';
import { Check, ChevronDown, ChevronRight, CheckCircle2, Loader2, ShieldAlert, X, XCircle, Slash } from 'lucide-react';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { useI18n } from '../../application/i18n/I18nProvider';
/**
@@ -142,9 +143,14 @@ export const ToolCall = ({
: <ChevronRight size={12} className="text-muted-foreground/40 shrink-0" />
}
{name === 'terminal_execute' && args?.command ? (
<span className="font-mono text-muted-foreground/70 truncate" title={String(args.command)}>
<span className="text-muted-foreground/40">$ </span>{String(args.command)}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="font-mono text-muted-foreground/70 truncate cursor-default">
<span className="text-muted-foreground/40">$ </span>{String(args.command)}
</span>
</TooltipTrigger>
<TooltipContent>{String(args.command)}</TooltipContent>
</Tooltip>
) : (
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
)}

View File

@@ -20,6 +20,7 @@ import {
DropdownContent,
DropdownTrigger,
} from '../ui/dropdown';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
interface AgentSelectorProps {
currentAgentId: string;
@@ -80,6 +81,7 @@ const DiscoveredAgentRow: React.FC<{
agent: DiscoveredAgent;
onEnable: () => void;
}> = ({ agent, onEnable }) => {
const { t } = useI18n();
const agentLike: AgentInfo = {
id: `discovered_${agent.command}`,
name: agent.name,
@@ -98,13 +100,17 @@ const DiscoveredAgentRow: React.FC<{
{agent.version || agent.path}
</span>
</div>
<button
onClick={onEnable}
className="shrink-0 rounded-md px-2 py-0.5 text-[11px] font-medium text-primary/80 hover:bg-primary/10 hover:text-primary transition-colors cursor-pointer"
title={`Enable ${agent.name}`}
>
<Plus size={12} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onEnable}
className="shrink-0 rounded-md px-2 py-0.5 text-[11px] font-medium text-primary/80 hover:bg-primary/10 hover:text-primary transition-colors cursor-pointer"
>
<Plus size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.enableAgent', { name: agent.name })}</TooltipContent>
</Tooltip>
</div>
);
};
@@ -250,14 +256,18 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
<SectionLabel
action={
onRediscover && (
<button
onClick={onRediscover}
disabled={isDiscovering}
className="text-[10px] text-muted-foreground/40 hover:text-muted-foreground/70 transition-colors cursor-pointer disabled:opacity-50"
title={t('ai.chat.rescan')}
>
<RefreshCw size={10} className={cn(isDiscovering && 'animate-spin')} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onRediscover}
disabled={isDiscovering}
className="text-[10px] text-muted-foreground/40 hover:text-muted-foreground/70 transition-colors cursor-pointer disabled:opacity-50"
>
<RefreshCw size={10} className={cn(isDiscovering && 'animate-spin')} />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.rescan')}</TooltipContent>
</Tooltip>
)
}
>

View File

@@ -22,6 +22,7 @@ import type { PromptInputStatus } from '../ai-elements/prompt-input';
import { formatThinkingLabel } from '../../infrastructure/ai/types';
import type { AgentModelPreset, AIPermissionMode, UploadedFile } from '../../infrastructure/ai/types';
import { ScrollArea } from '../ui/scroll-area';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
// Keep in sync with the popover's Tailwind max-width below.
const MODEL_PICKER_MAX_WIDTH = 360;
@@ -415,24 +416,27 @@ const ChatInput: React.FC<ChatInputProps> = ({
<div className="px-3 pt-3 pb-1.5">
<div className="flex flex-wrap gap-2">
{selectedUserSkills.map((skill) => (
<div
key={skill.id}
className={selectedSkillChipClassName}
title={skill.description || skill.name || skill.slug}
>
<Package size={11} className="text-primary/72 shrink-0" />
<span className="truncate max-w-[180px]">
{skill.name && skill.name !== skill.slug ? skill.name : `/${skill.slug}`}
</span>
<button
type="button"
onClick={() => onRemoveUserSkill?.(skill.slug)}
className="inline-flex h-4.5 w-4.5 items-center justify-center rounded-full text-foreground/42 hover:bg-primary/10 hover:text-foreground/72 transition-colors cursor-pointer"
aria-label={`Remove skill ${skill.name || skill.slug}`}
>
<X size={9} />
</button>
</div>
<Tooltip key={skill.id}>
<TooltipTrigger asChild>
<div
className={selectedSkillChipClassName}
>
<Package size={11} className="text-primary/72 shrink-0" />
<span className="truncate max-w-[180px]">
{skill.name && skill.name !== skill.slug ? skill.name : `/${skill.slug}`}
</span>
<button
type="button"
onClick={() => onRemoveUserSkill?.(skill.slug)}
className="inline-flex h-4.5 w-4.5 items-center justify-center rounded-full text-foreground/42 hover:bg-primary/10 hover:text-foreground/72 transition-colors cursor-pointer"
aria-label={`Remove skill ${skill.name || skill.slug}`}
>
<X size={9} />
</button>
</div>
</TooltipTrigger>
<TooltipContent>{skill.description || skill.name || skill.slug}</TooltipContent>
</Tooltip>
))}
</div>
</div>
@@ -450,14 +454,18 @@ const ChatInput: React.FC<ChatInputProps> = ({
].filter(Boolean).join(' ')}
maxLength={100000}
/>
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className="absolute top-3.5 right-3 rounded-md p-1 text-muted-foreground/38 hover:text-muted-foreground/72 hover:bg-muted/25 transition-colors cursor-pointer"
title={expanded ? 'Collapse' : 'Expand'}
>
<Expand size={12} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className="absolute top-3.5 right-3 rounded-md p-1 text-muted-foreground/38 hover:text-muted-foreground/72 hover:bg-muted/25 transition-colors cursor-pointer"
>
<Expand size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{expanded ? t('ai.chat.collapse') : t('ai.chat.expand')}</TooltipContent>
</Tooltip>
</div>
{/* @ mention popover */}
@@ -557,25 +565,29 @@ const ChatInput: React.FC<ChatInputProps> = ({
{/* Footer toolbar */}
<PromptInputFooter className="gap-1.5 border-t-0 bg-transparent px-3 pb-2 pt-0">
<PromptInputTools className="gap-1 flex-wrap">
<button
ref={attachBtnRef}
type="button"
onClick={() => {
if (!showAttachMenu) {
const rect = attachBtnRef.current?.getBoundingClientRect();
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
setActiveMenu('attach');
} else {
closeAllMenus();
}
}}
className={iconButtonClassName}
title="Attach"
aria-label="Attach file"
aria-expanded={showAttachMenu}
>
<Plus size={13} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
ref={attachBtnRef}
type="button"
onClick={() => {
if (!showAttachMenu) {
const rect = attachBtnRef.current?.getBoundingClientRect();
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
setActiveMenu('attach');
} else {
closeAllMenus();
}
}}
className={iconButtonClassName}
aria-label={t('ai.chat.attach')}
aria-expanded={showAttachMenu}
>
<Plus size={13} />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.attach')}</TooltipContent>
</Tooltip>
{showAttachMenu && menuPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
@@ -743,33 +755,37 @@ const ChatInput: React.FC<ChatInputProps> = ({
{/* Permission mode chip — only for Catty Agent */}
{permissionMode && onPermissionModeChange && (
<>
<button
ref={permBtnRef}
type="button"
onClick={() => {
if (!showPermPicker) {
const rect = permBtnRef.current?.getBoundingClientRect();
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
setActiveMenu('perm');
} else {
closeAllMenus();
}
}}
className={`${chipClassName} cursor-pointer hover:bg-muted/24 transition-colors`}
title={t('ai.safety.permissionMode')}
aria-label="Permission mode"
aria-expanded={showPermPicker}
>
{permissionMode === 'observer' && <Eye size={11} className="text-blue-400/70" />}
{permissionMode === 'confirm' && <ShieldCheck size={11} className="text-yellow-400/70" />}
{permissionMode === 'autonomous' && <Zap size={11} className="text-green-400/70" />}
<span className="truncate max-w-[72px]">
{permissionMode === 'observer' && t('ai.chat.permObserver')}
{permissionMode === 'confirm' && t('ai.chat.permConfirm')}
{permissionMode === 'autonomous' && t('ai.chat.permAuto')}
</span>
<ChevronDown size={9} className="text-muted-foreground/50" />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
ref={permBtnRef}
type="button"
onClick={() => {
if (!showPermPicker) {
const rect = permBtnRef.current?.getBoundingClientRect();
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
setActiveMenu('perm');
} else {
closeAllMenus();
}
}}
className={`${chipClassName} cursor-pointer hover:bg-muted/24 transition-colors`}
aria-label={t('ai.safety.permissionMode')}
aria-expanded={showPermPicker}
>
{permissionMode === 'observer' && <Eye size={11} className="text-blue-400/70" />}
{permissionMode === 'confirm' && <ShieldCheck size={11} className="text-yellow-400/70" />}
{permissionMode === 'autonomous' && <Zap size={11} className="text-green-400/70" />}
<span className="truncate max-w-[72px]">
{permissionMode === 'observer' && t('ai.chat.permObserver')}
{permissionMode === 'confirm' && t('ai.chat.permConfirm')}
{permissionMode === 'autonomous' && t('ai.chat.permAuto')}
</span>
<ChevronDown size={9} className="text-muted-foreground/50" />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.safety.permissionMode')}</TooltipContent>
</Tooltip>
{showPermPicker && menuPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />

View File

@@ -15,6 +15,7 @@ import {
DropdownContent,
DropdownTrigger,
} from '../ui/dropdown';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
interface ConversationExportProps {
session: AISession | null;
@@ -45,32 +46,36 @@ const ConversationExport: React.FC<ConversationExportProps> = ({
return (
<Dropdown>
<DropdownTrigger asChild>
<Button
variant="ghost"
size="icon"
className={className ?? 'h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground'}
disabled={!hasMessages}
title={t('ai.chat.exportConversation')}
>
<Download size={14} />
</Button>
</DropdownTrigger>
<Tooltip>
<TooltipTrigger asChild>
<DropdownTrigger asChild>
<Button
variant="ghost"
size="icon"
className={className ?? 'h-7 w-7 rounded-md text-muted-foreground/70 hover:bg-accent/60 hover:text-foreground'}
disabled={!hasMessages}
>
<Download size={14} />
</Button>
</DropdownTrigger>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.exportConversation')}</TooltipContent>
</Tooltip>
<DropdownContent
align="end"
sideOffset={6}
className="w-40 rounded-xl border border-border/45 bg-[#111111]/98 p-1.5 text-foreground shadow-[0_20px_48px_rgba(0,0,0,0.48)] supports-[backdrop-filter]:bg-[#111111]/92 supports-[backdrop-filter]:backdrop-blur-sm"
className="w-40 rounded-xl border border-border/60 bg-popover p-1.5 text-popover-foreground shadow-lg supports-[backdrop-filter]:bg-popover/95 supports-[backdrop-filter]:backdrop-blur-sm"
>
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground/48">
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground/70">
{t('ai.chat.exportAs')}
</div>
{EXPORT_OPTIONS.map(({ format, labelKey, icon: Icon }) => (
<button
key={format}
onClick={() => handleExport(format)}
className="w-full flex items-center gap-2 px-2 py-1.5 text-[13px] rounded-lg transition-colors cursor-pointer hover:bg-white/[0.04]"
className="w-full flex items-center gap-2 px-2 py-1.5 text-[13px] rounded-lg transition-colors cursor-pointer hover:bg-accent hover:text-accent-foreground"
>
<Icon size={13} className="shrink-0 text-muted-foreground/70" />
<Icon size={13} className="shrink-0 text-muted-foreground" />
<span>{t(labelKey)}</span>
</button>
))}

View File

@@ -0,0 +1,61 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
splitClaudeEnv,
buildClaudeEnv,
parseEnvLines,
serializeEnvLines,
} from "../settings/tabs/ai/claudeConfigEnv";
test("splitClaudeEnv pulls out config dir and hides CLAUDE_CODE_EXECUTABLE", () => {
const result = splitClaudeEnv({
CLAUDE_CONFIG_DIR: "/cfg",
CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude",
ANTHROPIC_API_KEY: "sk-x",
});
assert.equal(result.configDir, "/cfg");
assert.equal(result.envText, "ANTHROPIC_API_KEY=sk-x");
});
test("splitClaudeEnv handles undefined env", () => {
assert.deepEqual(splitClaudeEnv(undefined), { configDir: "", envText: "" });
});
test("parseEnvLines parses KEY=VALUE, trims keys, keeps value as-is, skips blanks/comments", () => {
assert.deepEqual(
parseEnvLines("ANTHROPIC_API_KEY = sk-x\n# comment\n\nANTHROPIC_BASE_URL=https://h/?a=b"),
{ ANTHROPIC_API_KEY: "sk-x", ANTHROPIC_BASE_URL: "https://h/?a=b" },
);
});
test("serializeEnvLines is the inverse for simple entries", () => {
assert.equal(serializeEnvLines({ A: "1", B: "2" }), "A=1\nB=2");
});
test("buildClaudeEnv merges config dir + parsed env, preserves CLAUDE_CODE_EXECUTABLE, drops empties", () => {
const prev = { CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude", OLD: "x" };
const next = buildClaudeEnv(prev, "/cfg", "ANTHROPIC_API_KEY=sk-x");
assert.deepEqual(next, {
CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude",
CLAUDE_CONFIG_DIR: "/cfg",
ANTHROPIC_API_KEY: "sk-x",
});
});
test("buildClaudeEnv omits config dir when blank and returns undefined when empty", () => {
assert.equal(buildClaudeEnv(undefined, " ", ""), undefined);
});
test("buildClaudeEnv ignores managed keys typed into the env editor", () => {
const next = buildClaudeEnv(
{ CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude" },
"/cfg",
"CLAUDE_CODE_EXECUTABLE=/evil/claude\nCLAUDE_CONFIG_DIR=/evil/dir\nANTHROPIC_API_KEY=sk-x",
);
assert.deepEqual(next, {
CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude",
CLAUDE_CONFIG_DIR: "/cfg",
ANTHROPIC_API_KEY: "sk-x",
});
});

View File

@@ -143,6 +143,7 @@ export interface PanelBridge extends NetcattyBridge {
cwd?: string,
providerId?: string,
chatSessionId?: string,
agentEnv?: Record<string, string>,
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[] }>; currentModelId?: string | null; error?: string }>;
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
aiUserSkillsGetStatus?: () => Promise<{

View File

@@ -68,6 +68,21 @@ test('buildManagedAgentState keeps unrelated defaults when removing stale manage
assert.equal(state.defaultAgentId, 'custom-agent');
});
test('buildManagedAgentState stores the system Claude executable for ACP runs', () => {
const state = buildManagedAgentState(
[],
'catty',
'claude',
{ path: '/opt/homebrew/bin/claude', version: '2.1.145 (Claude Code)', available: true },
);
assert.equal(state.agents.length, 1);
assert.equal(state.agents[0].command, '/opt/homebrew/bin/claude');
assert.deepEqual(state.agents[0].env, {
CLAUDE_CODE_EXECUTABLE: '/opt/homebrew/bin/claude',
});
});
test('buildManagedAgentState does not remove user-created matching agents', () => {
const agents: ExternalAgentConfig[] = [
{

View File

@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type {
CodeHighlighterPlugin,
HighlightOptions,
HighlightResult,
} from 'streamdown';
import {
createPlainCodeHighlightResult,
createSafeCodeHighlighter,
resolveSupportedCodeLanguage,
} from '../ai-elements/streamdownCodeHighlighter';
const createFakeHighlighter = (
supportedLanguages: string[],
highlightImpl?: CodeHighlighterPlugin['highlight'],
): CodeHighlighterPlugin => ({
name: 'shiki',
type: 'code-highlighter',
getSupportedLanguages: () => supportedLanguages as ReturnType<CodeHighlighterPlugin['getSupportedLanguages']>,
getThemes: () => ['github-light', 'github-dark'],
supportsLanguage: (language) => supportedLanguages.includes(language),
highlight: highlightImpl ?? ((options: HighlightOptions): HighlightResult => ({
tokens: [[{ content: options.language, offset: 0 }]],
})),
});
test('maps generic conf fences to ini for Streamdown highlighting', () => {
const highlighter = createFakeHighlighter(['ini']);
assert.equal(resolveSupportedCodeLanguage(highlighter, 'conf'), 'ini');
assert.equal(resolveSupportedCodeLanguage(highlighter, ' config '), 'ini');
});
test('falls back to plain tokens for unsupported languages', () => {
const highlighter = createSafeCodeHighlighter(
createFakeHighlighter([], () => {
throw new Error('delegate should not be called for unsupported languages');
}),
);
const result = highlighter.highlight({
code: '*.* action(type="omfwd"\n Target="10.185.3.1")\n',
language: 'conf',
themes: ['github-light', 'github-dark'],
});
assert.deepEqual(
result?.tokens.map((line) => line.map((token) => token.content).join('')),
['*.* action(type="omfwd"', ' Target="10.185.3.1")'],
);
});
test('uses supported aliases when highlighting generic config blocks', () => {
let receivedLanguage: string | null = null;
const highlighter = createSafeCodeHighlighter(
createFakeHighlighter(['ini'], (options: HighlightOptions): HighlightResult => {
receivedLanguage = options.language;
return createPlainCodeHighlightResult(options.code);
}),
);
const result = highlighter.highlight({
code: '*.* action(type="omfwd")',
language: 'conf',
themes: ['github-light', 'github-dark'],
});
assert.equal(receivedLanguage, 'ini');
assert.equal(result?.tokens[0][0].content, '*.* action(type="omfwd")');
});
test('treats text fences as plain code without calling the delegate', () => {
const highlighter = createSafeCodeHighlighter(
createFakeHighlighter(['ini'], () => {
throw new Error('delegate should not be called for text fences');
}),
);
const result = highlighter.highlight({
code: 'hello\nworld',
language: 'text',
themes: ['github-light', 'github-dark'],
});
assert.deepEqual(
result?.tokens.map((line) => line[0].content),
['hello', 'world'],
);
});

View File

@@ -8,6 +8,10 @@ import {
isTextEditorReadOnly,
TextEditorPromoteButton,
} from "./TextEditorPane.tsx";
import { TooltipProvider } from "../ui/tooltip.tsx";
const wrap = (child: React.ReactElement) =>
React.createElement(TooltipProvider, null, child);
test("disables promoting a modal editor to a tab while a save is running", () => {
assert.equal(canPromoteTextEditor({ saving: true }), false);
@@ -18,18 +22,22 @@ test("disables promoting a modal editor to a tab while a save is running", () =>
test("renders the promote button disabled while a save is running", () => {
const savingMarkup = renderToStaticMarkup(
React.createElement(TextEditorPromoteButton, {
saving: true,
onPromoteToTab: () => {},
title: "Maximize",
}),
wrap(
React.createElement(TextEditorPromoteButton, {
saving: true,
onPromoteToTab: () => {},
title: "Maximize",
}),
),
);
const idleMarkup = renderToStaticMarkup(
React.createElement(TextEditorPromoteButton, {
saving: false,
onPromoteToTab: () => {},
title: "Maximize",
}),
wrap(
React.createElement(TextEditorPromoteButton, {
saving: false,
onPromoteToTab: () => {},
title: "Maximize",
}),
),
);
assert.match(savingMarkup, /disabled=""/);

View File

@@ -28,6 +28,7 @@ import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../../domain/models
import { getLanguageName, getSupportedLanguages } from '../../lib/sftpFileUtils';
import { Button } from '../ui/button';
import { Combobox } from '../ui/combobox';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
// Map our language IDs to Monaco language IDs
const languageIdToMonaco = (langId: string): string => {
@@ -186,16 +187,20 @@ export const TextEditorPromoteButton: React.FC<{
onPromoteToTab: () => void;
title: string;
}> = ({ saving, onPromoteToTab, title }) => (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onPromoteToTab}
disabled={!canPromoteTextEditor({ saving })}
title={title}
>
<Maximize2 size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onPromoteToTab}
disabled={!canPromoteTextEditor({ saving })}
>
<Maximize2 size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{title}</TooltipContent>
</Tooltip>
);
export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
@@ -479,34 +484,47 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
{fileName}
</span>
{subtitle && (
<span className="text-xs text-muted-foreground truncate" title={subtitle}>
{subtitle}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs text-muted-foreground truncate cursor-default">
{subtitle}
</span>
</TooltipTrigger>
<TooltipContent>{subtitle}</TooltipContent>
</Tooltip>
)}
{saveError && <span className="text-xs text-destructive truncate">{saveError}</span>}
</div>
<div className="flex items-center gap-2 min-w-0">
{/* Search button */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleSearch}
title={t('common.search')}
>
<Search size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleSearch}
>
<Search size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common.search')}</TooltipContent>
</Tooltip>
{/* Word wrap toggle */}
<Button
variant={wordWrap ? 'secondary' : 'ghost'}
size="icon"
className="h-7 w-7"
onClick={onToggleWordWrap}
title={t('sftp.editor.wordWrap')}
>
<WrapText size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={wordWrap ? 'secondary' : 'ghost'}
size="icon"
className="h-7 w-7"
onClick={onToggleWordWrap}
>
<WrapText size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('sftp.editor.wordWrap')}</TooltipContent>
</Tooltip>
{/* Language selector */}
<Combobox

View File

@@ -11,6 +11,7 @@ import { Combobox } from '../ui/combobox';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Popover,PopoverContent,PopoverTrigger } from '../ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
interface IdentityPanelProps {
draftIdentity: Partial<Identity>;
@@ -129,15 +130,19 @@ export const IdentityPanel: React.FC<IdentityPanelProps> = ({
<span className="text-sm flex-1 truncate">
{selectedKey?.label || t('hostDetails.credential.missing')}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={clearSelectedKey}
title={t('common.clear')}
>
<X size={12} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={clearSelectedKey}
>
<X size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common.clear')}</TooltipContent>
</Tooltip>
</div>
)}
@@ -202,15 +207,19 @@ export const IdentityPanel: React.FC<IdentityPanelProps> = ({
icon={<Key size={14} className="text-muted-foreground" />}
className="flex-1"
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => setSelectedCredentialType(null)}
title={t('common.cancel')}
>
<X size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => setSelectedCredentialType(null)}
>
<X size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common.cancel')}</TooltipContent>
</Tooltip>
</div>
)}
@@ -230,15 +239,19 @@ export const IdentityPanel: React.FC<IdentityPanelProps> = ({
icon={<Shield size={14} className="text-muted-foreground" />}
className="flex-1"
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => setSelectedCredentialType(null)}
title={t('common.cancel')}
>
<X size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => setSelectedCredentialType(null)}
>
<X size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common.cancel')}</TooltipContent>
</Tooltip>
</div>
)}

View File

@@ -6,8 +6,7 @@
// Utilities and types
export {
copyToClipboard,detectKeyType,generateMockKeyPair,getKeyIcon,
getKeyTypeDisplay,isMacOS,type FilterTab,type PanelMode
isMacOS,type FilterTab,type PanelMode
} from './utils';
// Card components

View File

@@ -7,33 +7,6 @@ import React from 'react';
import { logger } from '../../lib/logger';
import { KeyType, SSHKey } from '../../types';
/**
* Generate mock key pair (for fallback when Electron backend is unavailable)
*/
export const generateMockKeyPair = (type: KeyType, label: string, keySize?: number): { privateKey: string; publicKey: string } => {
const typeMap: Record<KeyType, string> = {
'ED25519': 'ed25519',
'ECDSA': `ecdsa-sha2-nistp${keySize || 256}`,
'RSA': 'rsa',
};
const randomId = crypto.randomUUID().replace(/-/g, '').substring(0, 32);
// Generate size-appropriate random data for more realistic keys
const keyLength = type === 'RSA' ? (keySize || 4096) / 8 : 32;
const randomData = Array.from(crypto.getRandomValues(new Uint8Array(keyLength)))
.map(b => b.toString(16).padStart(2, '0')).join('');
const privateKey = `-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACB${randomId}AAAEC${randomData.substring(0, 64)}
-----END OPENSSH PRIVATE KEY-----`;
const publicKey = `ssh-${typeMap[type]} AAAAC3NzaC1lZDI1NTE5AAAAI${randomId.substring(0, 20)} ${label}@netcatty`;
return { privateKey, publicKey };
};
/**
* Get icon element for key source
*/

View File

@@ -12,6 +12,7 @@ import { TrafficDiagram } from '../TrafficDiagram';
import { AsidePanel,AsidePanelContent,AsidePanelFooter } from '../ui/aside-panel';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { Label } from '../ui/label';
import { Switch } from '../ui/switch';
import { getTypeLabel } from './utils';
@@ -183,14 +184,18 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
>
{t('common.cancel')}
</Button>
<button
className="text-xs text-muted-foreground hover:text-foreground/80 flex items-center gap-1 px-2 py-1 rounded hover:bg-foreground/5 transition-colors"
onClick={onOpenWizard}
title={t('pf.form.openWizardTitle')}
>
<Zap size={12} />
{t('pf.form.openWizard')}
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
className="text-xs text-muted-foreground hover:text-foreground/80 flex items-center gap-1 px-2 py-1 rounded hover:bg-foreground/5 transition-colors"
onClick={onOpenWizard}
>
<Zap size={12} />
{t('pf.form.openWizard')}
</button>
</TooltipTrigger>
<TooltipContent>{t('pf.form.openWizardTitle')}</TooltipContent>
</Tooltip>
</div>
</AsidePanelFooter>
</AsidePanel>

View File

@@ -68,13 +68,26 @@ export const RuleCard: React.FC<RuleCardProps> = ({
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold truncate">{rule.label}</span>
<span
className={cn(
"h-2 w-2 rounded-full flex-shrink-0",
getStatusColor(rule.status)
)}
title={rule.status === 'error' && rule.error ? rule.error : undefined}
/>
{rule.status === 'error' && rule.error ? (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"h-2 w-2 rounded-full flex-shrink-0 cursor-default",
getStatusColor(rule.status)
)}
/>
</TooltipTrigger>
<TooltipContent>{rule.error}</TooltipContent>
</Tooltip>
) : (
<span
className={cn(
"h-2 w-2 rounded-full flex-shrink-0",
getStatusColor(rule.status)
)}
/>
)}
</div>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<TooltipProvider delayDuration={300}>

View File

@@ -1,29 +1,17 @@
/**
* Port Forwarding components module
* Re-exports all port forwarding sub-components
* Re-exports the entries consumed by the top-level port forwarding view.
*/
export {
TYPE_DESCRIPTION_KEYS,
TYPE_LABEL_KEYS,
TYPE_MENU_LABEL_KEYS,
TYPE_ICONS,
generateRuleLabel,
getStatusColor,
getTypeColor,
getTypeDescription,
getTypeLabel,
getTypeMenuLabel,
} from './utils';
export { RuleCard } from './RuleCard';
export type { RuleCardProps,ViewMode } from './RuleCard';
export { WizardContent } from './WizardContent';
export type { WizardContentProps,WizardStep } from './WizardContent';
export { EditPanel } from './EditPanel';
export type { EditPanelProps } from './EditPanel';
export { NewFormPanel } from './NewFormPanel';
export type { NewFormPanelProps } from './NewFormPanel';

View File

@@ -1,23 +1,21 @@
/**
* Port Forwarding utilities and constants
*/
import { Globe,Server,Shuffle } from 'lucide-react';
import React from 'react';
import { PortForwardingType } from '../../domain/models';
export const TYPE_LABEL_KEYS: Record<PortForwardingType, string> = {
const TYPE_LABEL_KEYS: Record<PortForwardingType, string> = {
local: 'pf.type.local',
remote: 'pf.type.remote',
dynamic: 'pf.type.dynamic',
};
export const TYPE_MENU_LABEL_KEYS: Record<PortForwardingType, string> = {
const TYPE_MENU_LABEL_KEYS: Record<PortForwardingType, string> = {
local: 'pf.type.menu.local',
remote: 'pf.type.menu.remote',
dynamic: 'pf.type.menu.dynamic',
};
export const TYPE_DESCRIPTION_KEYS: Record<PortForwardingType, string> = {
const TYPE_DESCRIPTION_KEYS: Record<PortForwardingType, string> = {
local: 'pf.type.local.desc',
remote: 'pf.type.remote.desc',
dynamic: 'pf.type.dynamic.desc',
@@ -44,12 +42,6 @@ export function getTypeDescription(
return t(TYPE_DESCRIPTION_KEYS[type]);
}
export const TYPE_ICONS: Record<PortForwardingType, React.ReactNode> = {
local: <Globe size={16} />,
remote: <Server size={16} />,
dynamic: <Shuffle size={16} />,
};
/**
* Get status color class for a rule
*/

View File

@@ -0,0 +1,153 @@
import React, { useMemo, useSyncExternalStore } from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '../../lib/utils';
import { useI18n } from '../../application/i18n/I18nProvider';
import {
getFontAvailabilityVersion,
isFontInstalled,
subscribeFontAvailability,
} from '../../lib/fontAvailability';
const AUTO_SENTINEL = '__auto__';
interface CjkFontOption {
value: string;
/** i18n key looked up via t(). Use '' for the Auto sentinel. */
labelKey: string;
}
// Only true monospace CJK fonts. Proportional CJK fonts (PingFang SC,
// Microsoft YaHei UI, Hiragino Sans GB) render at non-2x widths and
// break terminal grid alignment — they are deliberately excluded here
// even though they are the OS defaults.
const OPTIONS: CjkFontOption[] = [
{ value: '', labelKey: 'settings.terminal.font.cjk.option.auto' },
{ value: 'Sarasa Mono SC', labelKey: 'settings.terminal.font.cjk.option.sarasaSC' },
{ value: 'Sarasa Mono TC', labelKey: 'settings.terminal.font.cjk.option.sarasaTC' },
{ value: 'Maple Mono CN', labelKey: 'settings.terminal.font.cjk.option.mapleCN' },
{ value: 'Source Han Mono SC', labelKey: 'settings.terminal.font.cjk.option.sourceHan' },
{ value: 'Noto Sans Mono CJK SC', labelKey: 'settings.terminal.font.cjk.option.notoCJK' },
{ value: 'LXGW WenKai Mono', labelKey: 'settings.terminal.font.cjk.option.lxgwWenkai' },
{ value: 'SimSun', labelKey: 'settings.terminal.font.cjk.option.simSun' },
];
interface Props {
value: string;
onChange: (next: string) => void;
className?: string;
disabled?: boolean;
}
export const TerminalCjkFontSelect: React.FC<Props> = ({
value,
onChange,
className,
disabled,
}) => {
const { t } = useI18n();
const matchedOption = OPTIONS.find((o) => o.value === value);
const radixValue = value === '' ? AUTO_SENTINEL : (matchedOption?.value ?? value);
const triggerLabel = matchedOption
? t(matchedOption.labelKey)
: value
? t('settings.terminal.font.cjk.option.legacy', { font: value })
: value;
// Subscribe to font availability so the filter re-evaluates after the
// Local Font Access API populates the authoritative install set
// asynchronously (otherwise the dropdown would show stale availability
// until the user manually changed `value`).
const availabilityVersion = useSyncExternalStore(
subscribeFontAvailability,
getFontAvailabilityVersion,
getFontAvailabilityVersion,
);
// "Auto" is always present; concrete fonts only appear when installed;
// the currently-selected value (if any) is also always shown so users
// can see and clear their setting even on a machine without the font.
// Legacy selections (e.g. "PingFang SC" saved before we dropped
// proportional fonts) are appended as a synthetic option with a
// "not recommended" label so the user can see them and re-pick.
const visibleOptions = useMemo(() => {
// The version is read here only so eslint-react-hooks sees it
// used; in practice we depend on it to invalidate this memo when
// setSystemFamilies bumps it (isFontInstalled below reads module
// state, so we need an explicit signal).
void availabilityVersion;
const filtered: Array<{ value: string; label: string }> = OPTIONS.filter(
(opt) =>
opt.value === '' ||
opt.value === value ||
isFontInstalled(opt.value),
).map((opt) => ({ value: opt.value, label: t(opt.labelKey) }));
if (value && !OPTIONS.some((o) => o.value === value)) {
filtered.push({
value,
label: t('settings.terminal.font.cjk.option.legacy', { font: value }),
});
}
return filtered;
}, [value, availabilityVersion, t]);
return (
<SelectPrimitive.Root
value={radixValue}
onValueChange={(next) => onChange(next === AUTO_SENTINEL ? '' : next)}
disabled={disabled}
>
<SelectPrimitive.Trigger
className={cn(
'flex h-9 items-center justify-between rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className,
)}
>
<SelectPrimitive.Value>
<span style={{ fontFamily: value ? `"${value}", monospace` : undefined }}>
{triggerLabel}
</span>
</SelectPrimitive.Value>
<SelectPrimitive.Icon asChild>
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Content
className="z-[200000] max-h-80 min-w-[14rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1"
position="popper"
sideOffset={4}
>
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
<SelectPrimitive.Viewport className="p-1 h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]">
{visibleOptions.map((opt) => (
<SelectPrimitive.Item
key={opt.value || AUTO_SENTINEL}
value={opt.value || AUTO_SENTINEL}
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>
<span style={{ fontFamily: opt.value ? `"${opt.value}", monospace` : undefined }}>
{opt.label}
</span>
</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))}
</SelectPrimitive.Viewport>
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
</SelectPrimitive.Root>
);
};
export default TerminalCjkFontSelect;

View File

@@ -1,7 +1,14 @@
import React from 'react';
import React, { useMemo, useSyncExternalStore } from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '../../lib/utils';
import {
extractPrimaryFamily,
getFontAvailabilityVersion,
hasAuthoritativeData,
isFontInstalled,
subscribeFontAvailability,
} from '../../lib/fontAvailability';
import type { TerminalFont } from '../../infrastructure/config/fonts';
interface TerminalFontSelectProps {
@@ -21,6 +28,37 @@ export const TerminalFontSelect: React.FC<TerminalFontSelectProps> = ({
}) => {
const selectedFont = fonts.find(f => f.id === value);
// Subscribe to font availability so the filter re-evaluates after the
// Local Font Access API populates the authoritative install set
// asynchronously, even if the `fonts` prop ref hasn't changed.
const availabilityVersion = useSyncExternalStore(
subscribeFontAvailability,
getFontAvailabilityVersion,
getFontAvailabilityVersion,
);
// Hide fonts that aren't actually rendered on this machine so users
// don't pick a font and then see no visible change. The currently
// selected font is always shown so the user can read their setting.
//
// When the Local Font Access API has populated authoritative data,
// trust it: an empty or near-empty result means the user really has
// few monospace fonts (Layer 3 still gives at least one option via
// bundled Sarasa Mono SC). When canvas-only fallback is in play,
// we keep a safety net at length>=1 to avoid an empty dropdown if
// detection misfires.
const visibleFonts = useMemo(() => {
// Referenced so eslint-react-hooks sees the dep used; the real
// purpose is to invalidate this memo when setSystemFamilies bumps
// the version (isFontInstalled reads module state).
void availabilityVersion;
const filtered = fonts.filter(
(f) => f.id === value || isFontInstalled(extractPrimaryFamily(f.family)),
);
if (hasAuthoritativeData()) return filtered;
return filtered.length >= 1 ? filtered : fonts;
}, [fonts, value, availabilityVersion]);
return (
<SelectPrimitive.Root value={value} onValueChange={onChange} disabled={disabled}>
<SelectPrimitive.Trigger
@@ -48,7 +86,7 @@ export const TerminalFontSelect: React.FC<TerminalFontSelectProps> = ({
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
<SelectPrimitive.Viewport className="p-1 h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]">
{fonts.map((font) => (
{visibleFonts.map((font) => (
<SelectPrimitive.Item
key={font.id}
value={font.id}

View File

@@ -15,6 +15,8 @@ interface ThemeSelectModalProps {
onClose: () => void;
selectedThemeId: string;
onSelect: (themeId: string) => void;
filterType?: 'dark' | 'light';
showAutoOption?: boolean;
}
export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
@@ -22,6 +24,8 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
onClose,
selectedThemeId,
onSelect,
filterType,
showAutoOption,
}) => {
const { t } = useI18n();
@@ -85,6 +89,8 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
<ThemeList
selectedThemeId={selectedThemeId}
onSelect={handleThemeSelect}
filterType={filterType}
showAutoOption={showAutoOption}
/>
</div>

View File

@@ -48,6 +48,7 @@ import {
buildManagedAgentState,
getInitialManagedAgentPaths,
} from "./ai/managedAgentState";
import { splitClaudeEnv, buildClaudeEnv } from "./ai/claudeConfigEnv";
// ---------------------------------------------------------------------------
// Props
@@ -125,6 +126,29 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
const [claudePathInfo, setClaudePathInfo] = useState<AgentPathInfo | null>(null);
const [claudeCustomPath, setClaudeCustomPath] = useState("");
const [isResolvingClaude, setIsResolvingClaude] = useState(false);
const claudeManagedEnv = useMemo(
() => externalAgents.find((a) => a.id === "discovered_claude")?.env,
[externalAgents],
);
const { configDir: claudeConfigDir, envText: claudeEnvText } = useMemo(
() => splitClaudeEnv(claudeManagedEnv),
[claudeManagedEnv],
);
const updateClaudeEnv = useCallback(
(nextConfigDir: string, nextEnvText: string) => {
setExternalAgents((prev) =>
prev.map((a) =>
a.id === "discovered_claude"
? { ...a, env: buildClaudeEnv(a.env, nextConfigDir, nextEnvText) }
: a,
),
);
},
[setExternalAgents],
);
const initialManagedPathsRef = useRef<{
codex: string;
claude: string;
@@ -542,6 +566,10 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
customPath={claudeCustomPath}
onCustomPathChange={setClaudeCustomPath}
onRecheckPath={() => void handleCheckCustomPath("claude")}
configDir={claudeConfigDir}
onConfigDirChange={(v) => updateClaudeEnv(v, claudeEnvText)}
envText={claudeEnvText}
onEnvTextChange={(v) => updateClaudeEnv(claudeConfigDir, v)}
/>
</div>

View File

@@ -7,6 +7,7 @@ import { SUPPORTED_UI_LOCALES } from "../../../infrastructure/config/i18n";
import { cn } from "../../../lib/utils";
import { SectionHeader, SettingsTabContent, SettingRow, Toggle, Select } from "../settings-ui";
import { FontSelect } from "../FontSelect";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
export default function SettingsAppearanceTab(props: {
theme: "dark" | "light" | "system";
@@ -122,20 +123,23 @@ export default function SettingsAppearanceTab(props: {
) => (
<div className="flex flex-wrap gap-2 justify-end">
{options.map((preset) => (
<button
key={preset.id}
onClick={() => onChange(preset.id)}
className={cn(
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm border border-border/70",
value === preset.id
? "ring-2 ring-offset-2 ring-foreground scale-110"
: "hover:scale-105",
)}
style={getHslStyle(preset.tokens.background)}
title={preset.name}
>
{value === preset.id && <Check className="text-white drop-shadow-md" size={10} />}
</button>
<Tooltip key={preset.id}>
<TooltipTrigger asChild>
<button
onClick={() => onChange(preset.id)}
className={cn(
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm border border-border/70",
value === preset.id
? "ring-2 ring-offset-2 ring-foreground scale-110"
: "hover:scale-105",
)}
style={getHslStyle(preset.tokens.background)}
>
{value === preset.id && <Check className="text-white drop-shadow-md" size={10} />}
</button>
</TooltipTrigger>
<TooltipContent>{preset.name}</TooltipContent>
</Tooltip>
))}
</div>
);
@@ -212,42 +216,49 @@ export default function SettingsAppearanceTab(props: {
<div className="text-sm font-medium">{t("settings.appearance.accentColor.custom")}</div>
<div className="flex flex-wrap gap-2">
{ACCENT_COLORS.map((c) => (
<button
key={c.name}
onClick={() => setCustomAccent(c.value)}
className={cn(
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm",
customAccent === c.value
? "ring-2 ring-offset-2 ring-foreground scale-110"
: "hover:scale-105",
)}
style={getHslStyle(c.value)}
title={c.name}
>
{customAccent === c.value && <Check className="text-white drop-shadow-md" size={10} />}
</button>
<Tooltip key={c.name}>
<TooltipTrigger asChild>
<button
onClick={() => setCustomAccent(c.value)}
className={cn(
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm",
customAccent === c.value
? "ring-2 ring-offset-2 ring-foreground scale-110"
: "hover:scale-105",
)}
style={getHslStyle(c.value)}
>
{customAccent === c.value && <Check className="text-white drop-shadow-md" size={10} />}
</button>
</TooltipTrigger>
<TooltipContent>{c.name}</TooltipContent>
</Tooltip>
))}
<label
className={cn(
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm cursor-pointer",
"bg-gradient-to-br from-pink-500 via-purple-500 to-blue-500",
!ACCENT_COLORS.some((c) => c.value === customAccent)
? "ring-2 ring-offset-2 ring-foreground scale-110"
: "hover:scale-105",
)}
title={t("settings.appearance.customColor")}
>
<input
type="color"
className="sr-only"
onChange={(e) => setCustomAccent(hexToHsl(e.target.value))}
/>
{!ACCENT_COLORS.some((c) => c.value === customAccent) ? (
<Check className="text-white drop-shadow-md" size={10} />
) : (
<Palette size={12} className="text-white drop-shadow-md" />
)}
</label>
<Tooltip>
<TooltipTrigger asChild>
<label
className={cn(
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm cursor-pointer",
"bg-gradient-to-br from-pink-500 via-purple-500 to-blue-500",
!ACCENT_COLORS.some((c) => c.value === customAccent)
? "ring-2 ring-offset-2 ring-foreground scale-110"
: "hover:scale-105",
)}
>
<input
type="color"
className="sr-only"
onChange={(e) => setCustomAccent(hexToHsl(e.target.value))}
/>
{!ACCENT_COLORS.some((c) => c.value === customAccent) ? (
<Check className="text-white drop-shadow-md" size={10} />
) : (
<Palette size={12} className="text-white drop-shadow-md" />
)}
</label>
</TooltipTrigger>
<TooltipContent>{t("settings.appearance.customColor")}</TooltipContent>
</Tooltip>
</div>
</div>
)}

View File

@@ -11,6 +11,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
import { cn } from "../../../lib/utils";
import { Button } from "../../ui/button";
import { Label } from "../../ui/label";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
import { SectionHeader, SettingsTabContent } from "../settings-ui";
const getOpenerLabel = (
@@ -527,31 +528,44 @@ export default function SettingsFileAssociationsTab() {
</td>
<td className="px-4 py-3 text-muted-foreground">
{openerType === 'system-app' && systemApp ? (
<span title={systemApp.path}>{systemApp.name}</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default">{systemApp.name}</span>
</TooltipTrigger>
<TooltipContent>{systemApp.path}</TooltipContent>
</Tooltip>
) : (
getOpenerLabel(openerType, systemApp, t)
)}
</td>
<td className="px-4 py-3 text-right space-x-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEdit(extension)}
disabled={editingExtension === extension}
title={t('common.edit')}
>
<Pencil size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleRemove(extension)}
title={t('settings.sftpFileAssociations.remove')}
>
<Trash2 size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEdit(extension)}
disabled={editingExtension === extension}
>
<Pencil size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common.edit')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleRemove(extension)}
>
<Trash2 size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('settings.sftpFileAssociations.remove')}</TooltipContent>
</Tooltip>
</td>
</tr>
))}

View File

@@ -230,7 +230,7 @@ export default function SettingsShortcutsTab(props: {
<button
onClick={() => updateKeyBinding?.(binding.id, scheme, "Disabled")}
className="p-1 hover:bg-muted rounded"
title={t("settings.shortcuts.setDisabled")}
aria-label={t("settings.shortcuts.setDisabled")}
>
<Ban size={12} />
</button>
@@ -238,7 +238,7 @@ export default function SettingsShortcutsTab(props: {
<button
onClick={() => resetKeyBinding?.(binding.id, scheme)}
className="p-1 hover:bg-muted rounded"
title="Reset to default"
aria-label={t("settings.shortcuts.resetToDefault")}
>
<RotateCcw size={12} />
</button>

View File

@@ -6,12 +6,11 @@ import {
buildLocalVaultPayload,
buildSyncPayload,
applySyncPayload,
getEffectivePortForwardingRulesForSync,
} from "../../../application/syncPayload";
import { applyProtectedSyncPayload } from "../../../application/localVaultBackups";
import type { SyncableVaultData } from "../../../application/syncPayload";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { STORAGE_KEY_PORT_FORWARDING } from "../../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { getEffectiveKnownHosts } from "../../../infrastructure/syncHelpers";
import { CloudSyncSettings } from "../../CloudSyncSettings";
import { SettingsTabContent } from "../settings-ui";
@@ -35,28 +34,7 @@ export default function SettingsSyncTab(props: {
const { t } = useI18n();
const getEffectivePortForwardingRules = useCallback((): PortForwardingRule[] => {
// If hook state is empty but localStorage has data, the async store
// initialization hasn't finished yet. Read from localStorage directly
// to avoid uploading empty arrays and overwriting the remote snapshot.
let effectiveRules = portForwardingRules;
if (effectiveRules.length === 0) {
const stored = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
);
if (stored && Array.isArray(stored) && stored.length > 0) {
// Strip transient per-device fields (status, error, lastUsedAt)
// that setGlobalRules persists to localStorage but shouldn't be
// included in the cloud sync snapshot.
effectiveRules = stored.map(({ status: _status, error: _error, ...rest }) => ({
...rest,
status: "inactive" as const,
error: undefined,
lastUsedAt: undefined,
}));
}
}
return effectiveRules;
return getEffectivePortForwardingRulesForSync(portForwardingRules) ?? [];
}, [portForwardingRules]);
const onBuildPayload = useCallback((): SyncPayload => {

View File

@@ -10,6 +10,7 @@ import type { UpdateState } from '../../../application/state/useUpdateCheck';
import { SessionLogFormat, keyEventToString } from "../../../domain/models";
import { TabsContent } from "../../ui/tabs";
import { Button } from "../../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
import { Toggle, Select, SettingRow } from "../settings-ui";
import { cn } from "../../../lib/utils";
@@ -637,9 +638,14 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
if (entry.uptimeSeconds != null) parts.push(`Uptime: ${entry.uptimeSeconds}s`);
const text = parts.join(' ');
return text ? (
<div className="text-muted-foreground truncate" title={text}>
{text}
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-muted-foreground truncate cursor-default">
{text}
</div>
</TooltipTrigger>
<TooltipContent>{text}</TooltipContent>
</Tooltip>
) : null;
})()}
{entry.stack && (
@@ -678,14 +684,18 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
<Trash2 size={14} />
{t("settings.system.crashLogs.clear")}
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleOpenCrashLogsDir}
title={t("settings.system.openFolder")}
>
<FolderOpen size={16} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleOpenCrashLogsDir}
>
<FolderOpen size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("settings.system.openFolder")}</TooltipContent>
</Tooltip>
</div>
{crashLogClearResult && (
@@ -716,16 +726,20 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
{isLoading ? "..." : (tempDirInfo?.path ?? "-")}
</p>
</div>
<Button
variant="ghost"
size="icon"
className="shrink-0"
onClick={handleOpenTempDir}
disabled={!tempDirInfo?.path}
title={t("settings.system.openFolder")}
>
<FolderOpen size={16} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="shrink-0"
onClick={handleOpenTempDir}
disabled={!tempDirInfo?.path}
>
<FolderOpen size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("settings.system.openFolder")}</TooltipContent>
</Tooltip>
</div>
{/* Stats */}
@@ -823,15 +837,19 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
{t("settings.sessionLogs.browse")}
</Button>
{sessionLogsDir && (
<Button
variant="ghost"
size="icon"
onClick={handleOpenSessionLogsDir}
className="shrink-0"
title={t("settings.sessionLogs.openFolder")}
>
<FolderOpen size={16} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleOpenSessionLogsDir}
className="shrink-0"
>
<FolderOpen size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("settings.sessionLogs.openFolder")}</TooltipContent>
</Tooltip>
)}
</div>
<p className="text-xs text-muted-foreground">
@@ -902,13 +920,17 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
: toggleWindowHotkey || t("settings.globalHotkey.notSet")}
</button>
{toggleWindowHotkey && (
<button
onClick={handleResetHotkey}
className="p-1 hover:bg-muted rounded"
title={t("settings.globalHotkey.reset")}
>
<RotateCcw size={14} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleResetHotkey}
className="p-1 hover:bg-muted rounded"
>
<RotateCcw size={14} />
</button>
</TooltipTrigger>
<TooltipContent>{t("settings.globalHotkey.reset")}</TooltipContent>
</Tooltip>
)}
</div>
</SettingRow>

View File

@@ -19,11 +19,15 @@ import { Button } from "../../ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../../ui/dialog";
import { Input } from "../../ui/input";
import { Label } from "../../ui/label";
import { Textarea } from "../../ui/textarea";
import { Select as ShadcnSelect, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../ui/select";
import { SectionHeader, Select, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
import { ThemeSelectModal } from "../ThemeSelectModal";
import { TerminalFontSelect } from "../TerminalFontSelect";
import { TerminalCjkFontSelect } from "../TerminalCjkFontSelect";
import { CustomThemeModal } from "../../terminal/CustomThemeModal";
import type { TerminalTheme } from "../../../domain/models";
import { resolveFollowedTerminalThemeId, TERMINAL_THEME_AUTO } from "../../../domain/terminalAppearance";
// Keyword highlight rules editor for global settings
const DEFAULT_NEW_RULE_COLOR = '#F87171';
@@ -32,21 +36,25 @@ const AddCustomRuleDialog: React.FC<{
open: boolean;
onOpenChange: (open: boolean) => void;
editRule?: KeywordHighlightRule | null;
isBuiltIn?: boolean;
onAdd: (rule: KeywordHighlightRule) => void;
}> = ({ open, onOpenChange, editRule, onAdd }) => {
}> = ({ open, onOpenChange, editRule, isBuiltIn = false, onAdd }) => {
const { t } = useI18n();
const [label, setLabel] = useState('');
const [pattern, setPattern] = useState('');
// Multi-line text: one regex pattern per line. Built-in rules typically
// ship multiple patterns (e.g. several spellings of "error"), and the user
// is allowed to add as many as they like.
const [patternsText, setPatternsText] = useState('');
const [color, setColor] = useState(DEFAULT_NEW_RULE_COLOR);
const [patternError, setPatternError] = useState<string | null>(null);
const reset = () => { setLabel(''); setPattern(''); setColor(DEFAULT_NEW_RULE_COLOR); setPatternError(null); };
const reset = () => { setLabel(''); setPatternsText(''); setColor(DEFAULT_NEW_RULE_COLOR); setPatternError(null); };
// Populate form when editing
useEffect(() => {
if (open && editRule) {
setLabel(editRule.label);
setPattern(editRule.patterns[0] || '');
setPatternsText(editRule.patterns.join('\n'));
setColor(editRule.color);
setPatternError(null);
} else if (!open) {
@@ -55,25 +63,43 @@ const AddCustomRuleDialog: React.FC<{
}, [open, editRule]);
const handleSubmit = () => {
if (!label.trim() || !pattern.trim()) return;
try { new RegExp(pattern, 'gi'); } catch {
setPatternError(t('settings.terminal.keywordHighlight.invalidPattern'));
return;
if (!label.trim()) return;
const patterns = patternsText
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
if (patterns.length === 0) return;
for (const p of patterns) {
try { new RegExp(p, 'gi'); } catch {
setPatternError(t('settings.terminal.keywordHighlight.invalidPattern'));
return;
}
}
// When editing, replace only the first pattern and keep any additional ones
const patterns = editRule
? [pattern, ...editRule.patterns.slice(1)]
: [pattern];
onAdd({ id: editRule?.id ?? crypto.randomUUID(), label: label.trim(), patterns, color, enabled: editRule?.enabled ?? true });
onAdd({
id: editRule?.id ?? crypto.randomUUID(),
label: label.trim(),
patterns,
color,
enabled: editRule?.enabled ?? true,
// Editing a built-in rule flips it into "user-customized" mode so the
// normalizer keeps the user's patterns across restarts.
customized: isBuiltIn ? true : editRule?.customized,
});
reset();
onOpenChange(false);
};
const dialogTitleKey = editRule
? (isBuiltIn
? 'settings.terminal.keywordHighlight.editBuiltIn'
: 'settings.terminal.keywordHighlight.editCustom')
: 'settings.terminal.keywordHighlight.addCustom';
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) reset(); onOpenChange(v); }}>
<DialogContent className="sm:max-w-[400px]">
<DialogContent className="sm:max-w-[440px]">
<DialogHeader>
<DialogTitle>{editRule ? t('settings.terminal.keywordHighlight.editCustom') : t('settings.terminal.keywordHighlight.addCustom')}</DialogTitle>
<DialogTitle>{t(dialogTitleKey)}</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="space-y-1.5">
@@ -93,16 +119,19 @@ const AddCustomRuleDialog: React.FC<{
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t('settings.terminal.keywordHighlight.patternField')}</Label>
<Input
<Textarea
placeholder={t('settings.terminal.keywordHighlight.patternPlaceholder')}
value={pattern}
onChange={(e) => { setPattern(e.target.value); if (patternError) setPatternError(null); }}
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }}
className={cn("font-mono", patternError && "border-destructive")}
value={patternsText}
onChange={(e) => { setPatternsText(e.target.value); if (patternError) setPatternError(null); }}
rows={Math.max(3, Math.min(10, patternsText.split('\n').length + 1))}
className={cn("font-mono text-xs", patternError && "border-destructive")}
/>
<p className="text-[11px] text-muted-foreground">
{t('settings.terminal.keywordHighlight.patternHint')}
</p>
{patternError && <div className="text-xs text-destructive">{patternError}</div>}
</div>
{label.trim() && pattern.trim() && !patternError && (
{label.trim() && patternsText.trim() && !patternError && (
<div className="flex items-center gap-2 p-2 rounded-md bg-muted/50">
<span className="text-xs text-muted-foreground">{t('settings.terminal.keywordHighlight.preview')}:</span>
<span className="text-sm font-medium" style={{ color }}>{label}</span>
@@ -111,7 +140,7 @@ const AddCustomRuleDialog: React.FC<{
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { reset(); onOpenChange(false); }}>{t('common.cancel')}</Button>
<Button onClick={handleSubmit} disabled={!label.trim() || !pattern.trim()}>{editRule ? t('common.save') : t('common.add')}</Button>
<Button onClick={handleSubmit} disabled={!label.trim() || !patternsText.trim()}>{editRule ? t('common.save') : t('common.add')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -131,26 +160,43 @@ const KeywordHighlightRulesEditor: React.FC<{
return (
<div className="space-y-2.5">
{rules.map((rule) => {
const custom = !isBuiltIn(rule.id);
const builtIn = isBuiltIn(rule.id);
const customized = builtIn && rule.customized;
return (
<div key={rule.id} className="flex items-center gap-2 group">
<div className="flex-1 min-w-0 flex items-center gap-1.5">
<span className={cn("text-sm truncate", !rule.enabled && "text-muted-foreground line-through")} style={rule.enabled ? { color: rule.color } : undefined}>
{rule.label}
</span>
{custom && (
<>
<Pencil
size={10}
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer"
onClick={() => { setEditingRule(rule); setAddDialogOpen(true); }}
/>
<Trash2
size={10}
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer"
onClick={() => onChange(rules.filter((r) => r.id !== rule.id))}
/>
</>
<Pencil
size={10}
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer hover:text-foreground"
onClick={() => { setEditingRule(rule); setAddDialogOpen(true); }}
/>
{!builtIn && (
<Trash2
size={10}
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer hover:text-destructive"
onClick={() => onChange(rules.filter((r) => r.id !== rule.id))}
/>
)}
{customized && (
<RotateCcw
size={10}
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer hover:text-foreground"
aria-label={t('settings.terminal.keywordHighlight.resetBuiltIn')}
onClick={() => {
// Drop the user's customizations and restore the shipped
// defaults for label/patterns. Color stays whatever the
// user picked (color is the only built-in property they
// can edit without flipping `customized`).
const def = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((r) => r.id === rule.id);
if (!def) return;
onChange(rules.map((r) => r.id === rule.id
? { ...def, color: r.color, enabled: r.enabled, customized: false }
: r));
}}
/>
)}
</div>
<label className="relative flex-shrink-0">
@@ -184,14 +230,18 @@ const KeywordHighlightRulesEditor: React.FC<{
size="sm"
className="flex-1 text-muted-foreground hover:text-foreground"
onClick={() => {
// Restore every built-in rule back to shipped defaults
// (label/patterns/color), drop customizations, and keep the user's
// custom rules untouched.
onChange(rules.map((rule) => {
const def = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((r) => r.id === rule.id);
return def ? { ...rule, color: def.color } : rule;
if (!def) return rule;
return { ...def, enabled: rule.enabled, customized: false };
}));
}}
>
<RotateCcw size={14} className="mr-1.5" />
{t("settings.terminal.keywordHighlight.resetColors")}
{t("settings.terminal.keywordHighlight.resetDefaults")}
</Button>
</div>
@@ -199,6 +249,7 @@ const KeywordHighlightRulesEditor: React.FC<{
open={addDialogOpen}
onOpenChange={(v) => { setAddDialogOpen(v); if (!v) setEditingRule(null); }}
editRule={editingRule}
isBuiltIn={editingRule ? isBuiltIn(editingRule.id) : false}
onAdd={(rule) => {
if (editingRule) {
onChange(rules.map((r) => r.id === editingRule.id ? rule : r));
@@ -265,6 +316,12 @@ export default function SettingsTerminalTab(props: {
setTerminalThemeId: (id: string) => void;
followAppTerminalTheme: boolean;
setFollowAppTerminalTheme: (value: boolean) => void;
terminalThemeDarkId: string;
setTerminalThemeDarkId: (id: string) => void;
terminalThemeLightId: string;
setTerminalThemeLightId: (id: string) => void;
lightUiThemeId: string;
darkUiThemeId: string;
terminalFontFamilyId: string;
setTerminalFontFamilyId: (id: string) => void;
terminalFontSize: number;
@@ -283,6 +340,12 @@ export default function SettingsTerminalTab(props: {
setTerminalThemeId,
followAppTerminalTheme,
setFollowAppTerminalTheme,
terminalThemeDarkId,
setTerminalThemeDarkId,
terminalThemeLightId,
setTerminalThemeLightId,
lightUiThemeId,
darkUiThemeId,
terminalFontFamilyId,
setTerminalFontFamilyId,
terminalFontSize,
@@ -314,6 +377,7 @@ export default function SettingsTerminalTab(props: {
setShowCustomShellInput(!discoveredShells.some(s => s.id === terminalSettings.localShell));
}, [discoveredShells, terminalSettings.localShell]);
const [themeModalOpen, setThemeModalOpen] = useState(false);
const [themeModalSlot, setThemeModalSlot] = useState<'dark' | 'light' | null>(null);
// Subscribe to custom theme changes so editing in-place triggers re-render
const customThemes = useCustomThemes();
@@ -325,6 +389,38 @@ export default function SettingsTerminalTab(props: {
|| TERMINAL_THEMES[0];
}, [terminalThemeId, customThemes]);
// Preview themes for the follow-app per-mode pickers. resolvedTheme is
// forced per slot so each preview reflects exactly that mode's selection.
const darkPreviewTheme = useMemo(() => {
const id = resolveFollowedTerminalThemeId({
resolvedTheme: 'dark',
terminalThemeDarkId, terminalThemeLightId,
lightUiThemeId, darkUiThemeId, fallbackThemeId: terminalThemeId,
});
return TERMINAL_THEMES.find(t => t.id === id)
|| customThemes.find(t => t.id === id)
// Mirror the runtime fallback in useSettingsState.currentTerminalTheme:
// a deleted per-mode override falls back to the manual theme, not [0].
|| TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|| customThemes.find(t => t.id === terminalThemeId)
|| TERMINAL_THEMES[0];
}, [terminalThemeDarkId, terminalThemeLightId, lightUiThemeId, darkUiThemeId, terminalThemeId, customThemes]);
const lightPreviewTheme = useMemo(() => {
const id = resolveFollowedTerminalThemeId({
resolvedTheme: 'light',
terminalThemeDarkId, terminalThemeLightId,
lightUiThemeId, darkUiThemeId, fallbackThemeId: terminalThemeId,
});
return TERMINAL_THEMES.find(t => t.id === id)
|| customThemes.find(t => t.id === id)
// Mirror the runtime fallback in useSettingsState.currentTerminalTheme:
// a deleted per-mode override falls back to the manual theme, not [0].
|| TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|| customThemes.find(t => t.id === terminalThemeId)
|| TERMINAL_THEMES[0];
}, [terminalThemeDarkId, terminalThemeLightId, lightUiThemeId, darkUiThemeId, terminalThemeId, customThemes]);
const handleAutocompleteGhostTextChange = useCallback((enabled: boolean) => {
updateTerminalSetting("autocompleteGhostText", enabled);
if (enabled) {
@@ -506,7 +602,34 @@ export default function SettingsTerminalTab(props: {
/>
</SettingRow>
</div>
{!followAppTerminalTheme && (
{followAppTerminalTheme ? (
<div className="space-y-2">
<div>
<div className="text-xs text-muted-foreground mb-1.5 px-1">
{t("settings.terminal.theme.darkTheme")}
</div>
<ThemePreviewButton
theme={darkPreviewTheme}
onClick={() => setThemeModalSlot('dark')}
buttonLabel={terminalThemeDarkId === TERMINAL_THEME_AUTO
? t("settings.terminal.theme.auto")
: t("settings.terminal.theme.selectButton")}
/>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1.5 px-1">
{t("settings.terminal.theme.lightTheme")}
</div>
<ThemePreviewButton
theme={lightPreviewTheme}
onClick={() => setThemeModalSlot('light')}
buttonLabel={terminalThemeLightId === TERMINAL_THEME_AUTO
? t("settings.terminal.theme.auto")
: t("settings.terminal.theme.selectButton")}
/>
</div>
</div>
) : (
<ThemePreviewButton
theme={currentTheme}
onClick={() => setThemeModalOpen(true)}
@@ -520,6 +643,17 @@ export default function SettingsTerminalTab(props: {
selectedThemeId={terminalThemeId}
onSelect={setTerminalThemeId}
/>
<ThemeSelectModal
open={themeModalSlot !== null}
onClose={() => setThemeModalSlot(null)}
selectedThemeId={themeModalSlot === 'dark' ? terminalThemeDarkId : terminalThemeLightId}
onSelect={(id) => {
if (themeModalSlot === 'dark') setTerminalThemeDarkId(id);
else if (themeModalSlot === 'light') setTerminalThemeLightId(id);
}}
filterType={themeModalSlot === 'light' ? 'light' : 'dark'}
showAutoOption
/>
{/* Theme action buttons */}
<div className="flex items-center gap-2 -mt-1">
@@ -615,6 +749,17 @@ export default function SettingsTerminalTab(props: {
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.font.cjk")}
description={t("settings.terminal.font.cjk.desc")}
>
<TerminalCjkFontSelect
value={terminalSettings.fallbackFont ?? ""}
onChange={(next) => updateTerminalSetting("fallbackFont", next)}
className="w-48"
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.font.size")}
description={t("settings.terminal.font.size.desc")}
@@ -749,6 +894,12 @@ export default function SettingsTerminalTab(props: {
>
<Toggle checked={terminalSettings.altAsMeta} onChange={(v) => updateTerminalSetting("altAsMeta", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.keyboard.optionArrowWordJump")}
description={t("settings.terminal.keyboard.optionArrowWordJump.desc")}
>
<Toggle checked={terminalSettings.optionArrowWordJump} onChange={(v) => updateTerminalSetting("optionArrowWordJump", v)} />
</SettingRow>
</div>
<SectionHeader title={t("settings.terminal.section.accessibility")} />
@@ -829,6 +980,13 @@ export default function SettingsTerminalTab(props: {
<Toggle checked={terminalSettings.preserveSelectionOnInput ?? false} onChange={(v) => updateTerminalSetting("preserveSelectionOnInput", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.forcePromptNewLine")}
description={t("settings.terminal.behavior.forcePromptNewLine.desc")}
>
<Toggle checked={terminalSettings.forcePromptNewLine ?? false} onChange={(v) => updateTerminalSetting("forcePromptNewLine", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.osc52Clipboard")}
description={t("settings.terminal.behavior.osc52Clipboard.desc")}
@@ -922,6 +1080,29 @@ export default function SettingsTerminalTab(props: {
</div>
</div>
<SectionHeader title={t("settings.terminal.section.startupCommand")} />
<div className="rounded-lg border bg-card p-4">
<p className="text-sm text-muted-foreground mb-3">
{t("settings.terminal.startupCommandDelay.desc")}
</p>
<div className="space-y-1">
<Label className="text-xs">{t("settings.terminal.startupCommandDelay.label")}</Label>
<Input
type="number"
min={0}
max={10000}
value={terminalSettings.startupCommandDelayMs}
onChange={(e) => {
const val = parseInt(e.target.value);
if (!isNaN(val) && val >= 0 && val <= 10000) {
updateTerminalSetting("startupCommandDelayMs", val);
}
}}
className="w-full"
/>
</div>
</div>
<SectionHeader title={t("settings.terminal.section.keywordHighlight")} />
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center justify-between mb-4">
@@ -948,35 +1129,41 @@ export default function SettingsTerminalTab(props: {
description={t("settings.terminal.localShell.shell.desc")}
>
<div className="flex flex-col gap-1 items-end">
<select
className="h-9 w-48 rounded-md border border-input bg-background px-3 text-sm"
<ShadcnSelect
value={
showCustomShellInput
? "__custom__"
: terminalSettings.localShell || ""
: (terminalSettings.localShell || "__default__")
}
onChange={(e) => {
const value = e.target.value;
onValueChange={(value) => {
if (value === "__custom__") {
setCustomShellDraft(terminalSettings.localShell || "");
setCustomShellModalOpen(true);
} else if (value === "__default__") {
setShowCustomShellInput(false);
updateTerminalSetting("localShell", "");
} else {
setShowCustomShellInput(false);
updateTerminalSetting("localShell", value);
}
}}
>
<option value="">
{t("settings.terminal.localShell.shell.default")}
{defaultShell ? ` (${defaultShell.split(/[/\\]/).pop()})` : ""}
</option>
{discoveredShells.map((shell) => (
<option key={shell.id} value={shell.id}>
{shell.name}
</option>
))}
<option value="__custom__">{t("settings.terminal.localShell.shell.custom")}</option>
</select>
<SelectTrigger className="h-9 w-48 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">
{t("settings.terminal.localShell.shell.default")}
{defaultShell ? ` (${defaultShell.split(/[/\\]/).pop()})` : ""}
</SelectItem>
{discoveredShells.map((shell) => (
<SelectItem key={shell.id} value={shell.id}>
{shell.name}
</SelectItem>
))}
<SelectItem value="__custom__">{t("settings.terminal.localShell.shell.custom")}</SelectItem>
</SelectContent>
</ShadcnSelect>
{showCustomShellInput && (
<span className="text-xs text-muted-foreground truncate max-w-48">
{terminalSettings.localShell}
@@ -1034,6 +1221,24 @@ export default function SettingsTerminalTab(props: {
className="w-24"
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.connection.keepaliveCountMax")}
description={t("settings.terminal.connection.keepaliveCountMax.desc")}
>
<Input
type="number"
min={1}
max={100}
value={terminalSettings.keepaliveCountMax}
onChange={(e) => {
const val = parseInt(e.target.value) || 1;
if (val >= 1 && val <= 100) {
updateTerminalSetting("keepaliveCountMax", val);
}
}}
className="w-24"
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.connection.x11Display")}
description={t("settings.terminal.connection.x11Display.desc")}

View File

@@ -1,10 +1,11 @@
import React from "react";
import { RefreshCw } from "lucide-react";
import React, { useEffect, useState } from "react";
import { ChevronDown, RefreshCw } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { cn } from "../../../../lib/utils";
import type { AgentPathInfo } from "./types";
import { ProviderIconBadge } from "./ProviderIconBadge";
import { parseEnvLines, serializeEnvLines } from "./claudeConfigEnv";
export const ClaudeCodeCard: React.FC<{
pathInfo: AgentPathInfo | null;
@@ -12,15 +13,40 @@ export const ClaudeCodeCard: React.FC<{
customPath: string;
onCustomPathChange: (path: string) => void;
onRecheckPath: () => void;
configDir: string;
onConfigDirChange: (value: string) => void;
envText: string;
onEnvTextChange: (value: string) => void;
}> = ({
pathInfo,
isResolvingPath,
customPath,
onCustomPathChange,
onRecheckPath,
configDir,
onConfigDirChange,
envText,
onEnvTextChange,
}) => {
const { t } = useI18n();
const found = pathInfo?.available;
// Collapsed by default; auto-expand when the user already has config so it
// isn't hidden. Local UI state — not persisted.
const [configOpen, setConfigOpen] = useState(
() => Boolean(configDir.trim() || envText.trim()),
);
// The env editor keeps the raw text the user types. Persisting parses it into
// a record (dropping incomplete lines), so binding the textarea directly to
// the persisted value would erase a key the moment it's typed before its "=".
// Only resync from the persisted value when it changes for some reason other
// than our own parse→serialize round-trip.
const [envDraft, setEnvDraft] = useState(envText);
useEffect(() => {
setEnvDraft((prev) =>
serializeEnvLines(parseEnvLines(prev)) === envText ? prev : envText,
);
}, [envText]);
const statusText = isResolvingPath
? t('ai.claude.detecting')
@@ -83,6 +109,53 @@ export const ClaudeCodeCard: React.FC<{
</div>
</div>
) : null}
{/* Authentication & config (optional, collapsible) */}
<div className="border-t border-border/60 pt-3">
<button
type="button"
onClick={() => setConfigOpen((v) => !v)}
aria-expanded={configOpen}
className="flex w-full items-center justify-between gap-2 text-left"
>
<span className="text-xs font-medium text-muted-foreground">
{t('ai.claude.configSection')}
</span>
<ChevronDown
size={14}
className={cn("text-muted-foreground transition-transform", configOpen && "rotate-180")}
/>
</button>
{configOpen && (
<div className="space-y-3 mt-3">
<div className="space-y-1.5">
<label htmlFor="claude-config-dir" className="text-xs text-muted-foreground">{t('ai.claude.configDir')}</label>
<input
id="claude-config-dir"
type="text"
value={configDir}
onChange={(e) => onConfigDirChange(e.target.value)}
placeholder={t('ai.claude.configDir.placeholder')}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.claude.configDir.hint')}</p>
</div>
<div className="space-y-1.5">
<label htmlFor="claude-env-vars" className="text-xs text-muted-foreground">{t('ai.claude.envVars')}</label>
<textarea
id="claude-env-vars"
value={envDraft}
onChange={(e) => { setEnvDraft(e.target.value); onEnvTextChange(e.target.value); }}
placeholder={t('ai.claude.envVars.placeholder')}
rows={3}
spellCheck={false}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-y"
/>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.claude.envVars.hint')}</p>
</div>
</div>
)}
</div>
</div>
);
};

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