Compare commits

...

70 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Includes en + zh-CN i18n strings.

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

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

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

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

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

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

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

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

Results are cached per process; clearFontAvailabilityCache() invalidates.

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

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

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

This commit filters both dropdowns through isFontInstalled():

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This commit pivots the detection chain:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:07:15 +08:00
bincxz
478e148b40 Drop noisy [XTerm] renderer=... boot log
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
The line printed once per terminal session and offered no diagnostic
value beyond what window.__xtermRenderer already exposes for ad-hoc
introspection. Keep the detection + retry + window publish; just
stop polluting the console. Rename logRenderer → trackRenderer to
match the now-narrowed responsibility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:11:58 +08:00
陈大猫
231fb9c74c Merge pull request #936 from binaricat/fix/stable-use-terminal-backend
Stabilize useTerminalBackend return identity
2026-05-11 00:05:21 +08:00
bincxz
8870eb4de9 Stabilize useTerminalBackend return identity
The hook returned a fresh object literal every render. The 26 methods
inside were already useCallback([])-stable, but the wrapping object
was not — so every consumer's effect with `terminalBackend` in deps
(e.g. cwd polling, lifecycle wiring, write-to-session) re-ran on
every parent render even though nothing semantic had changed, and
ESLint flagged the one site that depended on a property access
(`terminalBackend.onHostKeyVerification`) because it could not prove
that path safe.

Wrap the return in useMemo with all stable callbacks listed as deps
so the object is computed once and cached for the hook's lifetime.
Switch the host-key-verification effect's dep to the now-stable
`terminalBackend`, clearing the warning at the root rather than
patching it locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:04:23 +08:00
陈大猫
c9114eb198 Merge pull request #935 from binaricat/fix/906-ghost-text-after-tab
Fix ghost text duplicating glyphs after Tab completion (#906)
2026-05-10 23:59:03 +08:00
bincxz
938d1ef48b Fix ghost text duplicating glyphs after Tab completion (#906)
The reliability gate at handleInput's adjustToInput call froze the
ghost at its last show()-time tail in any path where the typed buffer
becomes unreliable (Tab pass-through to shell, history recall, cursor
moves). When the user kept typing into that gap, the next render
advanced the cursor past the ghost's anchor while the ghost text
stayed put — a → -accept then pasted the stale tail on top of the
just-typed glyphs (e.g. "systemctl s" + typing "t" → screen showed
"systemctl sttop firewalld").

Add GhostTextAddon.applyKeystroke so the ghost can evolve its own
currentInput off raw keystrokes (printable / Backspace / Ctrl-W),
seeded by whatever the last show() captured from the live xterm
reading. handleInput now uses the existing adjustToInput on the
reliable path (preserves multi-char paste re-alignment) and routes
single-keystroke events through applyKeystroke on the unreliable
path, fixing the visual misalignment and the duplication-on-accept
in one shot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:53:11 +08:00
陈大猫
52c097d9f8 Merge pull request #928 from binaricat/binaricat/fix-issue-920
Sync AI/UI settings and fix multi-display settings window placement
2026-05-10 23:24:15 +08:00
bincxz
684c094d40 Drop externalAgents from cloud sync (device-local config)
ExternalAgentConfig.command/acpCommand/args/env are OS- and
machine-specific (binary paths, .exe suffixes, platform-dependent
environment values). Pushing them to other devices either fails to
resolve or silently runs the wrong thing.

Stop collecting/applying STORAGE_KEY_AI_EXTERNAL_AGENTS and remove the
field from the SyncPayload type. apply silently ignores the field on
legacy snapshots that still carry it, so existing remote data is safe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:16:37 +08:00
bincxz
d84c2cc902 Preserve local AI apiKeys when applying synced settings
`collectSyncableSettings` strips device-bound encrypted apiKeys from
provider entries and webSearchConfig before upload, but
`applySyncableSettings` was writing them back wholesale, silently wiping
local credentials whenever any other setting changed on a second device.

Merge by id (providers) and by providerId (web search) so a synced
payload only overrides the apiKey when it explicitly carries one.

Also include `application/*.test.ts` in the npm test glob so the
syncPayload tests added in this PR actually run in CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:17:41 +08:00
陈大猫
3a233a3279 Merge pull request #934 from binaricat/claude/suspicious-bohr-32d9f2
Fix WebDAV Basic Auth for non-ASCII passwords (Hetzner #891)
2026-05-10 21:41:37 +08:00
bincxz
ba675fa944 Use UTF-8 for WebDAV Basic Auth credentials
The upstream `webdav` package builds the `Authorization: Basic …` header
through `base-64`, which Latin1-encodes the credentials. RFC 7617 (and
servers that follow it, like Hetzner Storage Box) expect UTF-8, so any
non-ASCII character in the password (e.g. `ö`, `ä`) produces a different
byte sequence on the wire than what the server stored, and the request
gets a 401 even though the credentials are correct (#891).

Skip the upstream auth path for password mode and pass an Authorization
header we built ourselves with UTF-8 encoding. ASCII-only passwords are
byte-identical, so existing setups are unaffected. Digest and token
modes are untouched.

Tested with a local HTTP server that enforces UTF-8-encoded Basic Auth
for a password containing umlauts (the exact failing case from #891).
2026-05-10 21:37:52 +08:00
bincxz
c9da2a5893 Sync AI/UI settings and fix multi-display settings window
Extend cloud sync to cover AI provider config, external agents,
permission/tool modes, command policy, web search settings,
workspace focus style, terminal follow-app theme, SFTP default view,
and additional terminal options. Device-bound encrypted apiKey
placeholders are stripped from providers and webSearchConfig before
upload. Auto-sync now reacts to syncable localStorage changes via a
new adapter-level event.

Center the Settings window on the display of the window that opened
it instead of always using the main window, fixing issue #920.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 00:41:14 +08:00
陈大猫
a377d39446 Merge pull request #926 from binaricat/codex/fix-ssh-known-host-verification
Fix SSH known host verification
2026-05-09 23:56:31 +08:00
bincxz
4b7249997f Update changed known hosts in place 2026-05-09 23:42:08 +08:00
bincxz
eb3f55b477 Integrate host key confirmation into connection dialog 2026-05-09 20:15:22 +08:00
bincxz
bce33f34ee Fix SSH known host verification 2026-05-09 19:44:21 +08:00
陈大猫
b6c59b9683 Merge pull request #924 from bet4it/shift-enter-support
Support Shift+Enter
2026-05-09 19:12:30 +08:00
bincxz
ff6b75aba7 Harden Shift+Enter keyboard support 2026-05-09 19:12:08 +08:00
陈大猫
b65ed74ced Merge pull request #922 from binaricat/feat/915-sftp-upload-context-menu
Add Upload File(s) item to SFTP context menu
2026-05-09 18:01:35 +08:00
bincxz
6c6a051c0c Fix SFTP upload context menu handling 2026-05-09 17:47:45 +08:00
陈大猫
621eae28f4 Merge pull request #918 from gorgiaxx/main
feat: Optimization of SSH Key Passphrase and Keychain
2026-05-09 16:17:46 +08:00
bincxz
2329014e22 fix: harden SSH key passphrase flows 2026-05-09 16:16:17 +08:00
Bet4
5c5ab21b10 support Shift+Enter 2026-05-09 14:56:17 +08:00
bincxz
a01ee1da61 Hide SFTP upload on local panes; add folder picker
The SFTP file-list "Upload File(s)" context menu items only make sense
on remote panes — local panes have no upload semantic. Plumb a new
`isLocal` prop into SftpPaneFileList and suppress both the menu items
and the hidden file inputs when the active pane is local.

Also add an "Upload Folder..." item alongside "Upload File(s)..." that
opens a `<input type="file" webkitdirectory>` picker. The resulting
FileList is routed through a new `uploadExternalFolder` /
`onUploadExternalFolder` callback that calls `uploadFromFileList`, so
folder structure is preserved via webkitRelativePath without any new
IPC. When invoked from a directory row, the folder is uploaded INTO
that directory (matching drag-and-drop semantics).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:57:56 +08:00
陈大猫
c94ded1a77 Merge pull request #923 from binaricat/fix/916-session-log-on-reconnect
Restart session log stream on reconnect
2026-05-09 12:52:08 +08:00
陈大猫
59de39e2ab Merge pull request #921 from binaricat/feat/912-settings-hotkey
Add hotkey to open Settings panel
2026-05-09 12:51:44 +08:00
bincxz
4a3869369e Restart session log stream on reconnect
Fixes #916.

When the user clicks "Restart" after a session disconnects, the
renderer reuses the same sessionId and the bridges call startStream
again to open a fresh log file for the new connection. The previous
connection's close handlers (e.g. SSH conn.once('close'),
stream.on('close'), serial 'close', telnet 'close', mosh PTY exit)
all still fire asynchronously and call stopStream(sessionId)
unconditionally. If they land after the new stream is already
active, they silently destroy it and subsequent terminal output for
the reconnected session is dropped, matching the bug report where
the first connection's IO is saved but the reconnect's is not.

Make startStream return a unique token and require stopStream
callers to pass it. A stale stop call carrying the previous
incarnation's token is now a no-op, so a late close handler from
the previous connection cannot kill the freshly-started stream.

Each reconnect therefore produces its own timestamped log file,
which mirrors the existing auto-save-on-close semantics and is the
simpler of the two options the issue offered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:38:50 +08:00
bincxz
11856b09e5 Add Settings gear button to top tab bar
Provides a discoverable entry point to the Settings panel for users
who don't use the Cmd/Ctrl+, hotkey. Sits at the right edge of the
title bar on macOS and immediately to the left of the custom window
controls on Windows/Linux. Reuses the existing onOpenSettings prop
already wired through from App.tsx.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:31:52 +08:00
bincxz
76b013f128 Add Upload File(s) item to SFTP context menu
Right-click on an SFTP pane now offers an "Upload File(s)" menu item
that opens a native multi-file picker, so users no longer have to drag
and drop to upload (issue #915). Selected files are wrapped in a
DataTransfer and dispatched through the existing onUploadExternalFiles
pipeline; right-clicking a directory uploads into that folder. Folder
upload via the picker is intentionally out of scope.

Fixes #915

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:31:13 +08:00
bincxz
44abf420c2 Add hotkey to open Settings panel
Adds Cmd+, on macOS and Ctrl+, on Windows/Linux to open Settings,
matching the platform convention. Previously Settings was only
reachable via Vaults -> Settings (#912).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:28:51 +08:00
gorgiaxx
cb98bdba2b fix: Improve passphrase handling by purging cached passphrases only on specific errors 2026-05-08 23:44:10 +08:00
gorgiaxx
18d411bb95 fix: preserve reference SSH keys and retry passphrase prompts
Keep file-backed SSH keys intact across app restarts and keep bad key passphrases in the dedicated retry flow instead of falling back to generic SSH auth. Also clear invalid saved passphrases from both legacy storage and reference-key records after auth failures.
2026-05-08 18:50:40 +08:00
gorgiaxx
1e80337a46 Merge branch 'main' of github.com:gorgiaxx/Netcatty 2026-05-08 17:26:55 +08:00
gorgiaxx
f1cfce45cf feat: Enhance SSH key management with reference key support and UI updates 2026-05-08 17:23:07 +08:00
Gorgias
833f9d2cac Merge branch 'binaricat:main' into main 2026-05-07 22:41:58 +08:00
gorgiaxx
72847a05af fix: Refactor passphrase handling: remove auto-responded keys tracking and related logic 2026-05-07 22:41:14 +08:00
陈大猫
0eccb2a252 Merge pull request #911 from yuzifu/allow-quick-edit 2026-05-07 19:52:31 +08:00
gorgiaxx
8a44152b36 Add support for remembering SSH key passphrases and update UI accordingly 2026-05-07 17:38:17 +08:00
yuzifu
c20abd86d9 allow quick edit for grid mode of keychain view 2026-05-07 16:23:38 +08:00
陈大猫
3fc9622695 Merge pull request #909 from binaricat/codex/telnet-auto-login
[codex] Improve Telnet credential login
2026-05-07 13:12:49 +08:00
bincxz
eb1fd9c127 Harden Telnet auto-login 2026-05-07 12:57:54 +08:00
bincxz
5cf1dd1de6 Match Telnet port field width to SSH 2026-05-07 11:46:59 +08:00
bincxz
137f8affbb Handle concatenated Telnet login prompts 2026-05-07 11:37:17 +08:00
bincxz
b9ac14f497 Improve Telnet credential login 2026-05-07 11:22:24 +08:00
陈大猫
43097c43b1 Merge pull request #905 from binaricat/fix/mosh-strip-lc-env
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
Strip LC_* before mosh ssh handshake
2026-05-07 02:03:21 +08:00
bincxz
329e94752b Strip LC_* before mosh ssh handshake
macOS Terminal/iTerm export LC_CTYPE=UTF-8 (a bare value, not a real
locale name). The system ssh_config has SendEnv LC_*, so the value
leaks to the remote and bash warns "cannot change locale (UTF-8)" on
every login. mosh-server sets its own locale separately, so dropping
LC_* from the spawned ssh's env is the cleanest fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 02:01:57 +08:00
陈大猫
b6a34131f6 Merge pull request #904 from binaricat/fix/mosh-windows-pinned-asset-check
Fix Windows mosh binary fallback selection
2026-05-07 01:42:18 +08:00
LAPTOP-O016UC3M\Qi Chen
3f16818d8d Fix Windows mosh binary fallback selection 2026-05-07 01:36:15 +08:00
陈大猫
3efc9ada8e Fix Windows mosh startup
Fix Windows mosh startup
2026-05-07 01:31:09 +08:00
陈大猫
8efdd1c9cb Merge pull request #901 from binaricat/codex/proxy-library
[codex] add reusable proxy profiles
2026-05-06 18:03:19 +08:00
bincxz
585a654668 Polish proxy form headings 2026-05-06 17:42:28 +08:00
bincxz
72e305fb7a Add reusable proxy profiles 2026-05-06 17:33:46 +08:00
bincxz
012a6bf521 Tone down proxy add button 2026-05-06 15:40:26 +08:00
陈大猫
4c72d5e0af Merge pull request #899 from yuzifu/fix-agent-path
fix: handle Windows agent paths with spaces
2026-05-06 15:36:32 +08:00
bincxz
cedc7f6c5f Align proxy profiles vault styles 2026-05-06 15:34:40 +08:00
bincxz
155463f77c add reusable proxy profiles 2026-05-06 15:20:23 +08:00
yuzifu
e5a74058ad add test unit 2026-05-06 15:12:17 +08:00
yuzifu
4ced32257e fix: handle Windows agent paths with spaces
When the executable file is installed in a directory containing spaces, the Codex and Claude path/version detection do not work.
2026-05-06 13:58:52 +08:00
陈大猫
64e7719715 Merge pull request #896 from yuzifu/fix-session-log
Fix session log
2026-05-06 12:34:07 +08:00
yuzifu
04b5aba62d fix: Preserve pending screen across redundant ED2 2026-05-04 17:27:04 +08:00
yuzifu
9f97f3870d fix: Preserve ED2-cleared screen when no trailing ED3 arrives 2026-05-04 17:15:41 +08:00
yuzifu
6bfd0e17a2 add ED3 test unit 2026-05-04 14:10:30 +08:00
yuzifu
1ac538eedc fix preserve terminal history during log sanitization 2026-05-04 14:07:22 +08:00
yuzifu
d34e23c7b3 preserve history while sanitizing terminal clears
Add a stateful terminal log sanitizer for txt/html session logs so saved output handles backspace, carriage-return overwrites, erase controls, split CSI/OSC sequences, and ANSI styling without leaking terminal control bytes.

Stream txt/html logs through a persistent renderer and write rendered snapshots directly to the final file, avoiding raw temp files and redundant full rewrites.
Preserve prior log history across clear-screen transitions while coalescing TUI repaint loops to avoid stale frame growth.

  Add regression coverage for tmux/zellij-style clears, repeated ED2/ED3 clears, home-clear repaint loops, and shell clear behavior.
2026-05-04 14:01:37 +08:00
陈大猫
31bf5396cb Bundle mosh terminfo on Linux and macOS (#890) (#894) 2026-05-04 11:09:12 +08:00
陈大猫
2feecaa9b6 Fix Windows mosh terminfo bundle (#889) 2026-05-01 22:51:15 +08:00
163 changed files with 16999 additions and 2031 deletions

View File

@@ -9,7 +9,7 @@ name: build-mosh-binaries
# (`binaricat/Netcatty-mosh-bin` by default).
#
# `paths` keeps unrelated commits (UI, bridges, etc) from rebuilding
# mosh on every push — this workflow is expensive (~30min Cygwin leg).
# or refreshing mosh binaries on every push.
on:
workflow_dispatch:
inputs:
@@ -129,48 +129,22 @@ jobs:
path: out/
# ------------------------------------------------------------------
# Windows x64 — in-CI Cygwin build from upstream mobile-shell/mosh
# source. Cygwin's POSIX runtime can't be fully statically linked, so
# we accept the dynamic Cygwin DLL deps and bundle them alongside the
# exe (cygcheck-discovered, ~10 MB total). The pinned-FluentTerminal
# path is preserved as `fetch-windows.sh` for emergency fallback.
# Windows x64 pinned standalone client.
# Do not compile this in CI: the upstream Cygwin build can clear the
# terminal and never render output on Windows. Ship the SHA256-pinned
# FluentTerminal standalone binary verified by fetch-windows.sh.
# ------------------------------------------------------------------
build-windows-x64:
name: build-windows-x64
runs-on: windows-latest
fetch-windows-x64:
name: fetch-windows-x64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Cygwin
uses: cygwin/cygwin-install-action@v5
with:
add-to-path: false
# Keep package signature checks, but avoid the setup.exe hash
# fetch path that currently fails on windows-latest runners.
check-hash: false
packages: >
gcc-g++ make autoconf automake libtool perl perl_pods pkg-config git
openssl-devel libssl-devel libprotobuf-devel libncurses-devel
libncursesw-devel zlib-devel protobuf-compiler
- name: Build mosh-client.exe (win32-x64)
shell: pwsh
- name: Fetch pinned mosh-client.exe (win32-x64)
run: |
$ErrorActionPreference = "Stop"
$cygwinBin = "C:\cygwin\bin"
$workspace = (& "$cygwinBin\cygpath.exe" -u "$env:GITHUB_WORKSPACE").Trim()
$scriptPath = Join-Path $env:RUNNER_TEMP "build-mosh-windows.sh"
$script = @'
set -euo pipefail
cd "__WORKSPACE__"
export MOSH_REF="${MOSH_REF:?missing MOSH_REF}"
export ARCH=x64
export OUT_DIR="__WORKSPACE__/out"
export OUT_DIR="${GITHUB_WORKSPACE}/out"
mkdir -p "$OUT_DIR"
bash scripts/build-mosh/build-windows.sh
'@
$script = $script.Replace("__WORKSPACE__", $workspace).Replace("`r`n", "`n")
Set-Content -Path $scriptPath -Value $script -NoNewline -Encoding utf8
$scriptPathCygwin = (& "$cygwinBin\cygpath.exe" -u "$scriptPath").Trim()
& "$cygwinBin\bash.exe" --login "$scriptPathCygwin"
bash scripts/build-mosh/fetch-windows.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
@@ -179,12 +153,8 @@ jobs:
# ------------------------------------------------------------------
# Windows arm64 — intentionally not built.
# Cygwin's arm64 port is still experimental (no stable cygwin1.dll
# release for aarch64 as of this commit), so we don't attempt an
# arm64 mosh build. arm64 Windows installs fall through to the
# legacy `mosh` wrapper path in terminalBridge.startMoshSession.
# When upstream Cygwin ships a stable arm64 build, drop the same
# cygwin-install-action job below with `platform: arm64`.
# The pinned upstream source only provides x64. arm64 Windows builds
# should be added only after we have a tested standalone arm64 client.
# ------------------------------------------------------------------
# ------------------------------------------------------------------
@@ -196,7 +166,7 @@ jobs:
- build-linux-x64
- build-linux-arm64
- build-macos-universal
- build-windows-x64
- fetch-windows-x64
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' && inputs.release_tag != ''
permissions:
@@ -241,7 +211,8 @@ jobs:
fi
{
printf '%s\n' 'Pre-built `mosh-client` binaries consumed by `scripts/fetch-mosh-binaries.cjs` during `npm run pack`.'
printf 'Built from `mobile-shell/mosh` upstream ref `%s`.\n\n' "${MOSH_REF}"
printf 'Linux/macOS artifacts are built from `mobile-shell/mosh` upstream ref `%s`.\n' "${MOSH_REF}"
printf '%s\n\n' 'Windows x64 is the SHA256-pinned FluentTerminal standalone `mosh-client.exe` fallback.'
printf 'Source workflow: %s/%s/actions/runs/%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_RUN_ID}"
printf 'Source commit: `%s`\n\n' "${GITHUB_SHA}"
printf '%s\n' 'All artifacts are GPL-3.0; see `resources/mosh/README.md` for source provenance.'

5
.gitignore vendored
View File

@@ -66,10 +66,11 @@ build_with_vs2022.bat
# Bundled mosh-client binaries fetched at pack time by
# scripts/fetch-mosh-binaries.cjs. resources/mosh/README.md is
# committed; the actual binaries (and on Windows the Cygwin DLL
# bundle that ships alongside mosh-client.exe) are pulled from the
# committed; the actual binaries, the Cygwin DLL bundle (Windows),
# and the bundled ncurses terminfo database are all pulled from the
# dedicated mosh binary repository, never committed.
/resources/mosh/*/mosh-client
/resources/mosh/*/mosh-client.exe
/resources/mosh/*/mosh-client-*-dlls/
/resources/mosh/*/*.dll
/resources/mosh/*/terminfo/

179
App.tsx
View File

@@ -11,12 +11,23 @@ import { useUpdateCheck } from './application/state/useUpdateCheck';
import { useVaultState } from './application/state/useVaultState';
import { useWindowControls } from './application/state/useWindowControls';
import { useEditorTabs, editorTabStore } from './application/state/editorTabStore';
import {
clearReferenceKeyPassphrases,
clearKeyPassphrasesByIds,
loadDefaultKeyPassphrase,
rememberKeyPassphrase,
removeDefaultKeyPassphrases,
shouldUpdateReferenceKeyPassphrase,
} from './application/defaultKeyPassphrases';
import { initializeFonts } from './application/state/fontStore';
import { initializeUIFonts } from './application/state/uiFontStore';
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
import { matchesKeyBinding } from './domain/models';
import { resolveGroupDefaults, applyGroupDefaults } from './domain/groupConfig';
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 { collectSessionIds } from './domain/workspace';
import { resolveCloseIntent } from './application/state/resolveCloseIntent';
@@ -52,7 +63,7 @@ import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal
import { cn } from './lib/utils';
import { classifyLocalShellType } from './lib/localShell';
import { useDiscoveredShells, resolveShellSetting } from './lib/useDiscoveredShells';
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalSession, TerminalTheme } from './types';
import { ConnectionLog, Host, HostProtocol, KnownHost, SerialConfig, SSHKey, TerminalSession, TerminalTheme } from './types';
import { LogView as LogViewType } from './application/state/useSessionState';
import type { SftpView as SftpViewComponent } from './components/SftpView';
import type { TerminalLayer as TerminalLayerComponent } from './components/TerminalLayer';
@@ -253,6 +264,7 @@ function App({ settings }: { settings: SettingsState }) {
hosts,
keys,
identities,
proxyProfiles,
snippets,
customGroups,
snippetPackages,
@@ -262,7 +274,9 @@ function App({ settings }: { settings: SettingsState }) {
managedSources,
updateHosts,
updateKeys,
importOrReuseKey,
updateIdentities,
updateProxyProfiles,
updateSnippets,
updateSnippetPackages,
updateCustomGroups,
@@ -282,6 +296,11 @@ function App({ settings }: { settings: SettingsState }) {
updateGroupConfigs,
} = useVaultState();
const keysRef = useRef(keys);
keysRef.current = keys;
const knownHostsRef = useRef(knownHosts);
knownHostsRef.current = knownHosts;
const {
sessions,
workspaces,
@@ -453,6 +472,7 @@ function App({ settings }: { settings: SettingsState }) {
hosts,
keys,
identities,
proxyProfiles,
snippets,
customGroups,
snippetPackages,
@@ -467,6 +487,7 @@ function App({ settings }: { settings: SettingsState }) {
hosts,
identities,
keys,
proxyProfiles,
knownHosts,
portForwardingRulesForSync,
snippetPackages,
@@ -527,7 +548,7 @@ function App({ settings }: { settings: SettingsState }) {
return () => {
cancelled = true;
};
}, [isVaultInitialized, hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts]);
}, [isVaultInitialized, hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, knownHosts]);
// Memoized "apply a remote payload safely" callback. Stable identity
// across renders so useAutoSync's `syncNow` useCallback doesn't rebuild
@@ -560,6 +581,7 @@ function App({ settings }: { settings: SettingsState }) {
hosts,
keys,
identities,
proxyProfiles,
snippets,
customGroups,
snippetPackages,
@@ -605,9 +627,9 @@ function App({ settings }: { settings: SettingsState }) {
if (start) {
const effectiveHost = resolveEffectiveHost(host);
void startTunnel(rule, effectiveHost, hosts, keys, identities, (status, error) => {
void startTunnel(rule, effectiveHost, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart);
}, rule.autoStart, terminalSettings);
return;
}
@@ -808,10 +830,13 @@ function App({ settings }: { settings: SettingsState }) {
// Auto-start port forwarding rules on app launch
usePortForwardingAutoStart({
isVaultInitialized,
hosts,
keys,
identities,
proxyProfiles,
groupConfigs,
terminalSettings,
});
// Sync tray menu data + handle tray actions
@@ -975,8 +1000,46 @@ function App({ settings }: { settings: SettingsState }) {
const bridge = netcattyBridge.get();
if (!bridge?.onPassphraseRequest) return;
const unsubscribe = bridge.onPassphraseRequest((request) => {
const unsubscribe = bridge.onPassphraseRequest(async (request) => {
console.log('[App] Passphrase request received:', request);
// If the bridge already tried a passphrase and it was wrong, skip auto-respond
if (!request.passphraseInvalid) {
// Check if a reference key exists for this path — use its passphrase
const currentKeys = keysRef.current;
const refKey = currentKeys.find((k: SSHKey) => k.source === 'reference' && k.filePath === request.keyPath);
if (refKey?.passphrase && refKey.savePassphrase !== false && !isEncryptedCredentialPlaceholder(refKey.passphrase)) {
console.log('[App] Auto-responding with reference key passphrase for:', request.keyPath);
void bridge.respondPassphrase?.(request.requestId, refKey.passphrase, false);
return;
}
// Fallback: try old storage for passphrase
const saved = await loadDefaultKeyPassphrase(request.keyPath);
if (saved) {
console.log('[App] Auto-responding with saved passphrase for:', request.keyPath);
// Migrate to reference key if one exists
if (shouldUpdateReferenceKeyPassphrase(refKey)) {
try {
await rememberKeyPassphrase({
keyPath: request.keyPath,
passphrase: saved,
keys: currentKeys,
updateKeys,
setCurrentKeys: (updated) => {
keysRef.current = updated;
},
});
} catch (err) {
console.warn('[App] Failed to migrate passphrase to reference key:', err);
}
}
void bridge.respondPassphrase?.(request.requestId, saved, false);
return;
}
}
// No saved passphrase or it was invalid, show modal
setPassphraseQueue(prev => [...prev, {
requestId: request.requestId,
keyPath: request.keyPath,
@@ -988,16 +1051,37 @@ function App({ settings }: { settings: SettingsState }) {
return () => {
unsubscribe?.();
};
}, []);
}, [updateKeys]);
// Handle passphrase submit
const handlePassphraseSubmit = useCallback((requestId: string, passphrase: string) => {
const handlePassphraseSubmit = useCallback(async (requestId: string, passphrase: string, remember: boolean) => {
const bridge = netcattyBridge.get();
const request = passphraseQueue.find((r: PassphraseRequest) => r.requestId === requestId);
// Save passphrase if requested
if (remember && request?.keyPath) {
console.log('[App] Saving passphrase for:', request.keyPath);
try {
await rememberKeyPassphrase({
keyPath: request.keyPath,
passphrase,
keys: keysRef.current,
updateKeys,
setCurrentKeys: (updated) => {
keysRef.current = updated;
},
});
} catch (err) {
console.warn('[App] Failed to save passphrase:', err);
}
}
if (bridge?.respondPassphrase) {
void bridge.respondPassphrase(requestId, passphrase, false);
}
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
}, [passphraseQueue, updateKeys]);
// Handle passphrase cancel
const handlePassphraseCancel = useCallback((requestId: string) => {
@@ -1040,6 +1124,44 @@ function App({ settings }: { settings: SettingsState }) {
};
}, []);
// Handle passphrase cancellation (owning connection was stopped)
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onPassphraseCancelled) return;
const unsubscribe = bridge.onPassphraseCancelled((event) => {
console.log('[App] Passphrase request cancelled:', event.requestId);
setPassphraseQueue(prev => prev.filter(r => r.requestId !== event.requestId));
});
return () => {
unsubscribe?.();
};
}, []);
// Handle passphrase auth failure (saved passphrase was wrong, clear it)
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onPassphraseAuthFailed) return;
const unsubscribe = bridge.onPassphraseAuthFailed((event) => {
const keyPaths = event.keyPaths ?? [];
const keyIds = event.keyIds ?? [];
console.log('[App] Passphrase auth failed for keys:', { keyPaths, keyIds });
removeDefaultKeyPassphrases(keyPaths);
const withoutReferencePassphrases = clearReferenceKeyPassphrases(keysRef.current, keyPaths);
const updated = clearKeyPassphrasesByIds(withoutReferencePassphrases, keyIds);
if (updated !== keysRef.current) {
keysRef.current = updated;
void updateKeys(updated);
}
});
return () => {
unsubscribe?.();
};
}, [updateKeys]);
// Debounce ref for moveFocus to prevent double-triggering when focus switches
const lastMoveFocusTimeRef = useRef<number>(0);
const MOVE_FOCUS_DEBOUNCE_MS = 200;
@@ -1050,6 +1172,9 @@ function App({ settings }: { settings: SettingsState }) {
const closeSidePanelRef = useRef<(() => void) | null>(null);
const toggleScriptsSidePanelRef = 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
@@ -1338,6 +1463,9 @@ function App({ settings }: { settings: SettingsState }) {
}
break;
}
case 'openSettings':
handleOpenSettingsRef.current();
break;
case 'splitHorizontal': {
const currentId = activeTabStore.getActiveTabId();
const activeSession = sessions.find(s => s.id === currentId);
@@ -1448,6 +1576,12 @@ function App({ settings }: { settings: SettingsState }) {
updateHosts(hosts.filter(h => h.id !== hostId));
}, [hosts, updateHosts, t]);
const handleAddKnownHost = useCallback((kh: KnownHost) => {
const nextKnownHosts = upsertKnownHost(knownHostsRef.current, kh);
knownHostsRef.current = nextKnownHosts;
updateKnownHosts(nextKnownHosts);
}, [updateKnownHosts]);
// System info for connection logs
const hostsRef = useRef(hosts);
hostsRef.current = hosts;
@@ -1501,11 +1635,21 @@ function App({ settings }: { settings: SettingsState }) {
});
}, [addConnectionLog, createLocalTerminal, terminalSettings.localShell, discoveredShells]);
const proxyProfileIdSet = useMemo(
() => new Set(proxyProfiles.map((profile) => profile.id)),
[proxyProfiles],
);
const resolveEffectiveHost = useCallback((host: Host): Host => {
if (!host.group) return host;
const groupDefaults = resolveGroupDefaults(host.group, groupConfigs);
return applyGroupDefaults(host, groupDefaults);
}, [groupConfigs]);
const withGroupDefaults = host.group
? applyGroupDefaults(
host,
resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }),
{ validProxyProfileIds: proxyProfileIdSet },
)
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
}, [groupConfigs, proxyProfileIdSet, proxyProfiles]);
// Wrapper to connect to host with logging
const handleConnectToHost = useCallback((host: Host) => {
@@ -1685,6 +1829,7 @@ function App({ settings }: { settings: SettingsState }) {
if (!opened) toast.error(t('toast.settingsUnavailable'), t('common.settings'));
})();
}, [openSettingsWindow, t]);
handleOpenSettingsRef.current = handleOpenSettings;
const hasShownCredentialProtectionWarningRef = useRef(false);
@@ -1847,6 +1992,7 @@ function App({ settings }: { settings: SettingsState }) {
hosts={hosts}
keys={keys}
identities={identities}
proxyProfiles={proxyProfiles}
snippets={snippets}
snippetPackages={snippetPackages}
customGroups={customGroups}
@@ -1869,7 +2015,9 @@ function App({ settings }: { settings: SettingsState }) {
onUpdateGroupConfigs={updateGroupConfigs}
onUpdateHosts={updateHosts}
onUpdateKeys={updateKeys}
onImportOrReuseKey={importOrReuseKey}
onUpdateIdentities={updateIdentities}
onUpdateProxyProfiles={updateProxyProfiles}
onUpdateSnippets={updateSnippets}
onUpdateSnippetPackages={updateSnippetPackages}
onUpdateCustomGroups={updateCustomGroups}
@@ -1888,6 +2036,7 @@ function App({ settings }: { settings: SettingsState }) {
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
navigateToSection={navigateToSection}
onNavigateToSectionHandled={() => setNavigateToSection(null)}
terminalSettings={terminalSettings}
/>
</VaultViewContainer>
@@ -1895,6 +2044,7 @@ function App({ settings }: { settings: SettingsState }) {
hosts={hosts}
keys={keys}
identities={identities}
proxyProfiles={proxyProfiles}
groupConfigs={groupConfigs}
updateHosts={updateHosts}
sftpDefaultViewMode={sftpDefaultViewMode}
@@ -1906,11 +2056,13 @@ function App({ settings }: { settings: SettingsState }) {
keyBindings={keyBindings}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
terminalSettings={terminalSettings}
/>
<TerminalLayerMount
hosts={hosts}
groupConfigs={groupConfigs}
proxyProfiles={proxyProfiles}
keys={keys}
identities={identities}
snippets={snippets}
@@ -1937,7 +2089,7 @@ function App({ settings }: { settings: SettingsState }) {
onUpdateSessionStatus={handleSessionStatusChange}
onUpdateHostDistro={updateHostDistro}
onUpdateHost={(host) => updateHosts(hosts.map(h => h.id === host.id ? host : h))}
onAddKnownHost={(kh) => updateKnownHosts([...knownHosts, kh])}
onAddKnownHost={handleAddKnownHost}
onCommandExecuted={(command, hostId, hostLabel, sessionId) => {
addShellHistoryEntry({ command, hostId, hostLabel, sessionId });
}}
@@ -2216,6 +2368,7 @@ function App({ settings }: { settings: SettingsState }) {
hosts: emptyVaultConflict.hostCount,
keys: emptyVaultConflict.keyCount,
snippets: emptyVaultConflict.snippetCount,
proxyProfiles: emptyVaultConflict.proxyProfileCount,
})}</div>
</div>
)}

View File

@@ -0,0 +1,93 @@
import type { SSHKey } from "../domain/models";
import { isEncryptedCredentialPlaceholder } from "../domain/credentials";
import { STORAGE_KEY_DEFAULT_KEY_PASSPHRASES } from "../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../infrastructure/persistence/localStorageAdapter";
import { encryptField, decryptField } from "../infrastructure/persistence/secureFieldAdapter";
export async function saveDefaultKeyPassphrase(keyPath: string, passphrase: string): Promise<void> {
const store = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? {};
store[keyPath] = await encryptField(passphrase) ?? passphrase;
localStorageAdapter.write(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES, store);
}
export async function loadDefaultKeyPassphrase(keyPath: string): Promise<string | null> {
const store = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES);
const enc = store?.[keyPath];
if (!enc) return null;
const decrypted = await decryptField(enc);
if (!decrypted || isEncryptedCredentialPlaceholder(decrypted)) {
removeDefaultKeyPassphrases([keyPath]);
return null;
}
return decrypted;
}
export function removeDefaultKeyPassphrases(keyPaths: string[]): void {
const store = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES);
if (!store) return;
let changed = false;
for (const keyPath of keyPaths) {
if (keyPath in store) {
delete store[keyPath];
changed = true;
}
}
if (changed) {
localStorageAdapter.write(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES, store);
}
}
export function clearReferenceKeyPassphrases(keys: SSHKey[], keyPaths: string[]): SSHKey[] {
let changed = false;
const updated = keys.map((key) => {
if (key.source === "reference" && key.filePath && keyPaths.includes(key.filePath) && key.passphrase) {
changed = true;
return { ...key, passphrase: undefined, savePassphrase: false };
}
return key;
});
return changed ? updated : keys;
}
export function clearKeyPassphrasesByIds(keys: SSHKey[], keyIds: string[] = []): SSHKey[] {
if (keyIds.length === 0) return keys;
const ids = new Set(keyIds);
let changed = false;
const updated = keys.map((key) => {
if (ids.has(key.id) && key.passphrase) {
changed = true;
return { ...key, passphrase: undefined, savePassphrase: false };
}
return key;
});
return changed ? updated : keys;
}
export function shouldUpdateReferenceKeyPassphrase(key?: SSHKey | null): boolean {
return Boolean(
key &&
(!key.passphrase || isEncryptedCredentialPlaceholder(key.passphrase)),
);
}
export async function rememberKeyPassphrase(args: {
keyPath: string;
passphrase: string;
keys: SSHKey[];
updateKeys: (keys: SSHKey[]) => Promise<unknown> | unknown;
setCurrentKeys?: (keys: SSHKey[]) => void;
}): Promise<void> {
const { keyPath, passphrase, keys, updateKeys, setCurrentKeys } = args;
await saveDefaultKeyPassphrase(keyPath, passphrase);
const refKey = keys.find((key) => key.source === "reference" && key.filePath === keyPath);
if (!refKey) return;
const updated = keys.map((key) =>
key.id === refKey.id
? { ...key, passphrase, savePassphrase: true }
: key
);
setCurrentKeys?.(updated);
await updateKeys(updated);
}

View File

@@ -273,6 +273,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',
@@ -374,7 +385,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)',
@@ -481,7 +494,7 @@ const en: Messages = {
'sync.autoSync.emptyVaultConflict.restoreDesc': 'Recommended — recover your hosts, keys, and snippets from the cloud backup',
'sync.autoSync.emptyVaultConflict.keepEmpty': 'Keep Empty',
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': 'Start fresh with an empty vault',
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} hosts, {keys} keys, {snippets} snippets',
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} hosts, {keys} keys, {snippets} snippets, {proxyProfiles} proxies',
'sync.autoSync.emptyVaultManual': 'Cannot sync: the local vault is empty. Restore from a local backup or enable Force Push in the sync panel first.',
'sync.blocked.title': 'Sync paused',
@@ -499,6 +512,7 @@ const en: Messages = {
'sync.entityType.hosts': 'hosts',
'sync.entityType.keys': 'keys',
'sync.entityType.identities': 'identities',
'sync.entityType.proxyProfiles': 'proxy profiles',
'sync.entityType.snippets': 'snippets',
'sync.entityType.customGroups': 'groups',
'sync.entityType.snippetPackages': 'snippet packages',
@@ -514,11 +528,28 @@ const en: Messages = {
// Vault navigation
'vault.nav.hosts': 'Hosts',
'vault.nav.keychain': 'Keychain',
'vault.nav.proxies': 'Proxies',
'vault.nav.portForwarding': 'Port Forwarding',
'vault.nav.snippets': 'Snippets',
'vault.nav.knownHosts': 'Known Hosts',
'vault.nav.logs': 'Logs',
'proxyProfiles.action.add': 'Add Proxy',
'proxyProfiles.search.placeholder': 'Search proxies…',
'proxyProfiles.section.proxies': 'Proxies',
'proxyProfiles.count.items': '{count} items',
'proxyProfiles.empty.title': 'No Proxies',
'proxyProfiles.empty.desc': 'Create reusable HTTP or SOCKS5 proxies and select them from host details.',
'proxyProfiles.usage': '{count} linked',
'proxyProfiles.copyName': '{name} Copy',
'proxyProfiles.panel.newTitle': 'New Proxy',
'proxyProfiles.field.name': 'Proxy name',
'proxyProfiles.error.required': 'Name, host, and port are required.',
'proxyProfiles.error.port': 'Port must be between 1 and 65535.',
'proxyProfiles.viewMode': 'Proxy view mode',
'proxyProfiles.delete.title': 'Delete proxy?',
'proxyProfiles.delete.desc': 'Deleting "{name}" will unlink it from {count} host or group settings.',
'vault.groups.title': 'Groups',
'vault.groups.total': '{count} total',
'vault.groups.hostsCount': '{count} Hosts',
@@ -756,6 +787,10 @@ const en: Messages = {
'sftp.context.permissions': 'Permissions',
'sftp.context.delete': 'Delete',
'sftp.context.refresh': 'Refresh',
'sftp.context.uploadFiles': 'Upload File(s)...',
'sftp.context.uploadFilesHere': 'Upload File(s) Here...',
'sftp.context.uploadFolder': 'Upload Folder...',
'sftp.context.uploadFolderHere': 'Upload Folder Here...',
'sftp.context.downloadSelected': 'Download selected ({count})',
'sftp.context.deleteSelected': 'Delete selected ({count})',
'sftp.dropFilesHere': 'Drop files here',
@@ -1097,6 +1132,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',
@@ -1114,6 +1155,12 @@ const en: Messages = {
'hostDetails.proxyPanel.passwordPlaceholder': 'Password',
'hostDetails.proxyPanel.identities': 'Identities',
'hostDetails.proxyPanel.remove': 'Remove Proxy',
'hostDetails.proxyPanel.savedProxy': 'Saved proxy',
'hostDetails.proxyPanel.selectSaved': 'Select saved proxy',
'hostDetails.proxyPanel.customProxy': 'Custom proxy',
'hostDetails.proxyPanel.missing': 'Missing',
'hostDetails.proxyPanel.missingSaved': 'Missing saved proxy',
'hostDetails.proxyPanel.error.required': 'Proxy host and port are required.',
'hostDetails.envVars': 'Environment Variables',
'hostDetails.envVars.add': 'Add Environment Variable',
'hostDetails.envVars.title': 'Environment Variables',
@@ -1235,6 +1282,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',
@@ -1301,6 +1352,16 @@ const en: Messages = {
'terminal.connection.protocol.mosh': 'Mosh',
'terminal.connection.protocol.serial': 'Serial',
'terminal.connection.protocol.local': 'Local Shell',
'terminal.hostKey.unknownTitle': 'Confirm this host key',
'terminal.hostKey.changedTitle': 'Host key changed',
'terminal.hostKey.unknownDescription': 'The authenticity of {host} cannot be established yet.',
'terminal.hostKey.changedDescription': 'The saved key for {host} no longer matches this server.',
'terminal.hostKey.fingerprintLabel': '{keyType} fingerprint is SHA256:',
'terminal.hostKey.savedFingerprintLabel': 'Saved fingerprint',
'terminal.hostKey.unknownHint': 'Remember it if this fingerprint belongs to the server you expected.',
'terminal.hostKey.changedHint': 'Only continue if you expected this host to change.',
'terminal.hostKey.addAndContinue': 'Add and continue',
'terminal.hostKey.updateAndContinue': 'Update and continue',
'terminal.themeModal.title': 'Terminal Appearance',
'terminal.themeModal.tab.theme': 'Theme',
'terminal.themeModal.tab.font': 'Font',
@@ -1663,6 +1724,7 @@ const en: Messages = {
'keychain.edit.publicKey': 'Public key',
'keychain.edit.certificate': 'Certificate',
'keychain.edit.certificatePlaceholder': 'Certificate content (optional)',
'keychain.edit.filePath': 'File path',
'keychain.edit.keyExport': 'Key export',
'keychain.edit.exportToHost': 'Export to host',
@@ -1790,6 +1852,7 @@ const en: Messages = {
'passphrase.unlock': 'Unlock',
'passphrase.unlocking': 'Unlocking...',
'passphrase.skip': 'Skip',
'passphrase.remember': 'Remember this passphrase',
// Text Editor
'sftp.editor.wordWrap': 'Word Wrap',

View File

@@ -290,7 +290,7 @@ const zhCN: Messages = {
'sync.autoSync.emptyVaultConflict.restoreDesc': '推荐 — 从云端备份恢复主机、密钥和代码片段',
'sync.autoSync.emptyVaultConflict.keepEmpty': '保持为空',
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': '从头开始,使用空的主机库',
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段',
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段{proxyProfiles} 个代理',
'sync.autoSync.emptyVaultManual': '无法同步:本地 vault 为空。请先从本地备份恢复,或在同步面板里使用"强制推送"。',
'sync.blocked.title': '同步已暂停',
@@ -308,6 +308,7 @@ const zhCN: Messages = {
'sync.entityType.hosts': '主机',
'sync.entityType.keys': '密钥',
'sync.entityType.identities': '身份',
'sync.entityType.proxyProfiles': '代理配置',
'sync.entityType.snippets': '代码片段',
'sync.entityType.customGroups': '分组',
'sync.entityType.snippetPackages': '片段包',
@@ -323,11 +324,28 @@ const zhCN: Messages = {
// Vault navigation
'vault.nav.hosts': '主机',
'vault.nav.keychain': '钥匙串',
'vault.nav.proxies': '代理',
'vault.nav.portForwarding': '端口转发',
'vault.nav.snippets': '代码片段',
'vault.nav.knownHosts': '已知主机',
'vault.nav.logs': '日志',
'proxyProfiles.action.add': '添加代理',
'proxyProfiles.search.placeholder': '搜索代理…',
'proxyProfiles.section.proxies': '代理',
'proxyProfiles.count.items': '{count} 项',
'proxyProfiles.empty.title': '暂无代理',
'proxyProfiles.empty.desc': '创建可复用的 HTTP 或 SOCKS5 代理,然后在主机详情里选择。',
'proxyProfiles.usage': '已关联 {count} 处',
'proxyProfiles.copyName': '{name} 副本',
'proxyProfiles.panel.newTitle': '新建代理',
'proxyProfiles.field.name': '代理名称',
'proxyProfiles.error.required': '名称、主机和端口不能为空。',
'proxyProfiles.error.port': '端口必须在 1 到 65535 之间。',
'proxyProfiles.viewMode': '代理显示方式',
'proxyProfiles.delete.title': '删除代理?',
'proxyProfiles.delete.desc': '删除 "{name}" 会同时从 {count} 个主机或分组设置中解除关联。',
'vault.groups.title': '分组',
'vault.groups.total': '共 {count} 个',
'vault.groups.hostsCount': '{count} 台主机',
@@ -540,6 +558,10 @@ const zhCN: Messages = {
'sftp.context.permissions': '权限',
'sftp.context.delete': '删除',
'sftp.context.refresh': '刷新',
'sftp.context.uploadFiles': '上传文件...',
'sftp.context.uploadFilesHere': '上传文件到这里...',
'sftp.context.uploadFolder': '上传文件夹...',
'sftp.context.uploadFolderHere': '上传文件夹到这里...',
'sftp.context.downloadSelected': '下载选中项({count}',
'sftp.context.deleteSelected': '删除选中项({count}',
'sftp.dropFilesHere': '拖拽文件到这里',
@@ -726,6 +748,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': '通过主机代理',
@@ -835,6 +863,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': '内存使用',
@@ -902,6 +934,16 @@ const zhCN: Messages = {
'terminal.connection.protocol.mosh': 'Mosh',
'terminal.connection.protocol.serial': '串口',
'terminal.connection.protocol.local': '本地终端',
'terminal.hostKey.unknownTitle': '确认主机指纹',
'terminal.hostKey.changedTitle': '主机指纹已变化',
'terminal.hostKey.unknownDescription': '尚未确认 {host} 的真实性。',
'terminal.hostKey.changedDescription': '{host} 的已保存指纹与当前服务器不一致。',
'terminal.hostKey.fingerprintLabel': '{keyType} 指纹为 SHA256',
'terminal.hostKey.savedFingerprintLabel': '已保存的指纹',
'terminal.hostKey.unknownHint': '如果这个指纹属于你预期连接的服务器,可以记住它。',
'terminal.hostKey.changedHint': '只有在你确认这台主机确实变更过时才继续。',
'terminal.hostKey.addAndContinue': '记住并继续',
'terminal.hostKey.updateAndContinue': '更新并继续',
'terminal.themeModal.title': 'Terminal 外观',
'terminal.themeModal.tab.theme': '主题',
'terminal.themeModal.tab.font': '字体',
@@ -1370,6 +1412,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': '字重',
@@ -1464,7 +1517,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',
@@ -1539,13 +1594,19 @@ const zhCN: Messages = {
'settings.shortcuts.binding.sftp-new-folder': '新建文件夹',
// Host Details (sub-panels)
'hostDetails.proxyPanel.title': 'Proxy',
'hostDetails.proxyPanel.hostPlaceholder': 'Proxy host',
'hostDetails.proxyPanel.credentials': 'Credentials',
'hostDetails.proxyPanel.usernamePlaceholder': 'Username',
'hostDetails.proxyPanel.passwordPlaceholder': 'Password',
'hostDetails.proxyPanel.identities': 'Identities',
'hostDetails.proxyPanel.remove': '移除 Proxy',
'hostDetails.proxyPanel.title': '通过 HTTP/SOCKS5 代理',
'hostDetails.proxyPanel.hostPlaceholder': '代理主机',
'hostDetails.proxyPanel.credentials': '凭据',
'hostDetails.proxyPanel.usernamePlaceholder': '用户名',
'hostDetails.proxyPanel.passwordPlaceholder': '密码',
'hostDetails.proxyPanel.identities': '身份',
'hostDetails.proxyPanel.remove': '移除代理',
'hostDetails.proxyPanel.savedProxy': '已保存代理',
'hostDetails.proxyPanel.selectSaved': '选择已保存代理',
'hostDetails.proxyPanel.customProxy': '自定义代理',
'hostDetails.proxyPanel.missing': '缺失',
'hostDetails.proxyPanel.missingSaved': '保存的代理不存在',
'hostDetails.proxyPanel.error.required': '代理主机和端口不能为空。',
'hostDetails.envVars.title': '环境变量',
'hostDetails.envVars.desc': '为 {host} 设置环境变量。',
'hostDetails.envVars.note': '部分 SSH 服务器默认只允许以 LC_ 和 LANG_ 为前缀的变量。',
@@ -1672,6 +1733,7 @@ const zhCN: Messages = {
'keychain.edit.publicKey': '公钥',
'keychain.edit.certificate': '证书',
'keychain.edit.certificatePlaceholder': '证书内容(可选)',
'keychain.edit.filePath': '文件路径',
'keychain.edit.keyExport': '密钥导出',
'keychain.edit.exportToHost': '导出到主机',
@@ -1799,6 +1861,7 @@ const zhCN: Messages = {
'passphrase.unlock': '解锁',
'passphrase.unlocking': '解锁中...',
'passphrase.skip': '跳过',
'passphrase.remember': '记住此密码',
// Text Editor
'sftp.editor.wordWrap': '自动换行',

View File

@@ -0,0 +1,194 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
clearKeyPassphrasesByIds,
clearReferenceKeyPassphrases,
loadDefaultKeyPassphrase,
rememberKeyPassphrase,
shouldUpdateReferenceKeyPassphrase,
} from "../defaultKeyPassphrases";
import { STORAGE_KEY_DEFAULT_KEY_PASSPHRASES } from "../../infrastructure/config/storageKeys";
import type { SSHKey } from "../../domain/models";
function installLocalStorage(t: test.TestContext): void {
const store = new Map<string, string>();
const storage: Storage = {
get length() {
return store.size;
},
clear() {
store.clear();
},
getItem(key: string) {
return store.get(key) ?? null;
},
key(index: number) {
return Array.from(store.keys())[index] ?? null;
},
removeItem(key: string) {
store.delete(key);
},
setItem(key: string, value: string) {
store.set(key, value);
},
};
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: storage,
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { netcatty: undefined },
});
t.after(() => {
Reflect.deleteProperty(globalThis, "localStorage");
Reflect.deleteProperty(globalThis, "window");
});
}
const referenceKey = (): SSHKey => ({
id: "reference-key",
label: "id_ed25519",
type: "ED25519",
category: "key",
source: "reference",
filePath: "/Users/alice/.ssh/id_ed25519",
privateKey: "",
created: 1,
});
test("loadDefaultKeyPassphrase removes undecryptable credential placeholders", async (t) => {
installLocalStorage(t);
const keyPath = "/Users/alice/.ssh/id_ed25519";
globalThis.localStorage.setItem(
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({
[keyPath]: "enc:v1:djEwYWJj",
"/Users/alice/.ssh/id_rsa": "still-valid",
}),
);
const result = await loadDefaultKeyPassphrase(keyPath);
assert.equal(result, null);
assert.deepEqual(
JSON.parse(globalThis.localStorage.getItem(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? "{}"),
{ "/Users/alice/.ssh/id_rsa": "still-valid" },
);
});
test("loadDefaultKeyPassphrase returns plain stored passphrases", async (t) => {
installLocalStorage(t);
const keyPath = "/Users/alice/.ssh/id_ed25519";
globalThis.localStorage.setItem(
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({ [keyPath]: "correct horse battery staple" }),
);
assert.equal(await loadDefaultKeyPassphrase(keyPath), "correct horse battery staple");
});
test("clearReferenceKeyPassphrases clears matching reference key paths only", () => {
const keys: SSHKey[] = [
{
...referenceKey(),
passphrase: "bad",
savePassphrase: true,
},
{
...referenceKey(),
id: "other-key",
label: "other",
filePath: "/Users/alice/.ssh/other",
passphrase: "keep",
savePassphrase: true,
},
];
const updated = clearReferenceKeyPassphrases(keys, ["/Users/alice/.ssh/id_ed25519"]);
assert.equal(updated[0].passphrase, undefined);
assert.equal(updated[0].savePassphrase, false);
assert.equal(updated[1].passphrase, "keep");
});
test("clearKeyPassphrasesByIds clears matching saved key passphrases", () => {
const keys: SSHKey[] = [
{
...referenceKey(),
id: "inline-key",
source: "imported",
filePath: undefined,
privateKey: "PRIVATE KEY",
passphrase: "bad",
savePassphrase: true,
},
{
...referenceKey(),
id: "other-key",
label: "other",
passphrase: "keep",
savePassphrase: true,
},
];
const updated = clearKeyPassphrasesByIds(keys, ["inline-key"]);
assert.equal(updated[0].passphrase, undefined);
assert.equal(updated[0].savePassphrase, false);
assert.equal(updated[1].passphrase, "keep");
});
test("shouldUpdateReferenceKeyPassphrase replaces missing or undecryptable passphrases", () => {
assert.equal(shouldUpdateReferenceKeyPassphrase(null), false);
assert.equal(shouldUpdateReferenceKeyPassphrase(referenceKey()), true);
assert.equal(
shouldUpdateReferenceKeyPassphrase({
...referenceKey(),
passphrase: "enc:v1:djEwAAAA",
}),
true,
);
assert.equal(
shouldUpdateReferenceKeyPassphrase({
...referenceKey(),
passphrase: "saved",
}),
false,
);
});
test("rememberKeyPassphrase updates reference key state before completing", async (t) => {
installLocalStorage(t);
const keys = [referenceKey()];
let currentKeys = keys;
let releaseUpdate: (() => void) | undefined;
let rememberPromise: Promise<void> | undefined;
const updateStarted = new Promise<void>((resolve) => {
const updateKeys = async (updated: SSHKey[]) => {
assert.equal(currentKeys[0].passphrase, "saved");
assert.equal(updated[0].passphrase, "saved");
resolve();
await new Promise<void>((release) => {
releaseUpdate = release;
});
};
rememberPromise = rememberKeyPassphrase({
keyPath: "/Users/alice/.ssh/id_ed25519",
passphrase: "saved",
keys,
updateKeys,
setCurrentKeys: (updated) => {
currentKeys = updated;
},
});
});
await updateStarted;
assert.equal(currentKeys[0].passphrase, "saved");
releaseUpdate?.();
await rememberPromise;
});

View File

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

@@ -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(
@@ -281,7 +283,7 @@ export const useSftpConnections = ({
);
};
const hasKey = !!credentials.privateKey;
const hasKey = !!credentials.privateKey || !!credentials.identityFilePaths?.length;
const hasPassword = !!credentials.password;
let sftpId: string | undefined;
@@ -305,6 +307,7 @@ export const useSftpConnections = ({
publicKey: undefined,
keyId: undefined,
keySource: undefined,
identityFilePaths: undefined,
});
} else {
throw err;

View File

@@ -7,11 +7,13 @@ import { joinPath } from "./utils";
import {
UploadController,
uploadFromDataTransfer,
uploadFromFileList,
uploadEntriesDirect,
UploadBridge,
UploadCallbacks,
UploadResult,
UploadTaskInfo,
startUploadScanningTask,
} from "../../../lib/uploadService";
import type { DropEntry } from "../../../lib/sftpFileUtils";
@@ -56,6 +58,16 @@ interface SftpExternalOperationsResult {
dataTransfer: DataTransfer,
targetPath?: string
) => Promise<UploadResult[]>;
uploadExternalFileList: (
side: "left" | "right",
fileList: FileList | File[],
targetPath?: string
) => Promise<UploadResult[]>;
uploadExternalFolderPath: (
side: "left" | "right",
folderPath: string,
targetPath?: string
) => Promise<UploadResult[]>;
uploadExternalEntries: (
side: "left" | "right",
entries: DropEntry[],
@@ -718,6 +730,216 @@ export const useSftpExternalOperations = (
],
);
// Upload from a FileList. This keeps the original File objects from the file
// picker so Electron can resolve local file paths for stream uploads.
const uploadExternalFileList = useCallback(
async (
side: "left" | "right",
fileList: FileList | File[],
targetPath?: string,
): Promise<UploadResult[]> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No active connection");
}
const bridge = netcattyBridge.get();
if (!bridge) {
throw new Error("Bridge not available");
}
const sftpId = pane.connection.isLocal
? null
: sftpSessionsRef.current.get(pane.connection.id) || null;
if (!pane.connection.isLocal && !sftpId) {
throw new Error("SFTP session not found");
}
const uploadPaneId = pane.id;
const uploadTargetPath = targetPath || pane.connection.currentPath;
const controller = new UploadController();
uploadControllerRef.current = controller;
const callbacks = createUploadCallbacks(
pane.connection.id,
uploadTargetPath,
pane.connection.isLocal ? undefined : pane.connection.hostId,
pane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(pane.connection.id),
);
try {
const results = await uploadFromFileList(
fileList,
{
targetPath: uploadTargetPath,
sftpId,
isLocal: pane.connection.isLocal,
bridge: createUploadBridge,
joinPath,
callbacks,
useCompressedUpload,
resolveConflict: createUploadConflictResolver(),
},
controller,
);
if (clearDirCacheEntry && targetPath) {
clearDirCacheEntry(pane.connection.id, uploadTargetPath);
}
if (uploadTargetPath === pane.connection.currentPath) {
await refresh(side, { tabId: uploadPaneId });
}
return results;
} catch (error) {
logger.error("[SFTP] File picker upload failed:", error);
throw error;
} finally {
uploadControllerRef.current = null;
}
},
[
clearDirCacheEntry,
connectionCacheKeyMapRef,
getActivePane,
refresh,
sftpSessionsRef,
createUploadCallbacks,
createUploadBridge,
createUploadConflictResolver,
useCompressedUpload,
],
);
const uploadExternalFolderPath = useCallback(
async (
side: "left" | "right",
folderPath: string,
targetPath?: string,
): Promise<UploadResult[]> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No active connection");
}
const bridge = netcattyBridge.get();
if (!bridge) {
throw new Error("Bridge not available");
}
if (!bridge.listLocalTree) {
throw new Error("Folder upload not supported");
}
const sftpId = pane.connection.isLocal
? null
: sftpSessionsRef.current.get(pane.connection.id) || null;
if (!pane.connection.isLocal && !sftpId) {
throw new Error("SFTP session not found");
}
const uploadPaneId = pane.id;
const uploadTargetPath = targetPath || pane.connection.currentPath;
const controller = new UploadController();
uploadControllerRef.current = controller;
const callbacks = createUploadCallbacks(
pane.connection.id,
uploadTargetPath,
pane.connection.isLocal ? undefined : pane.connection.hostId,
pane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(pane.connection.id),
);
const scanningTask = startUploadScanningTask(callbacks);
try {
const localEntries = await bridge.listLocalTree(folderPath);
if (controller.isCancelled()) {
scanningTask.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
scanningTask.complete();
const entries: DropEntry[] = localEntries.map((entry) => {
if (entry.type === "directory") {
return {
file: null,
relativePath: entry.relativePath,
isDirectory: true,
};
}
const file = {
name: entry.relativePath.split("/").pop() || entry.relativePath,
size: entry.size,
lastModified: entry.lastModified,
type: "",
path: entry.localPath,
arrayBuffer: async () => {
const currentBridge = netcattyBridge.get();
if (!currentBridge?.readLocalFile) {
throw new Error("Local file reading not supported");
}
return currentBridge.readLocalFile(entry.localPath);
},
} as File & { path?: string };
return {
file,
relativePath: entry.relativePath,
isDirectory: false,
};
});
const results = await uploadEntriesDirect(
entries,
{
targetPath: uploadTargetPath,
sftpId,
isLocal: pane.connection.isLocal,
bridge: createUploadBridge,
joinPath,
callbacks,
useCompressedUpload,
resolveConflict: createUploadConflictResolver(),
},
controller,
);
if (clearDirCacheEntry) {
clearDirCacheEntry(pane.connection.id, uploadTargetPath);
}
if (uploadTargetPath === pane.connection.currentPath) {
await refresh(side, { tabId: uploadPaneId });
}
return results;
} catch (error) {
if (controller.isCancelled()) {
scanningTask.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
if (scanningTask.isOpen()) {
scanningTask.fail(error);
}
logger.error("[SFTP] Folder picker upload failed:", error);
throw error;
} finally {
uploadControllerRef.current = null;
}
},
[
clearDirCacheEntry,
connectionCacheKeyMapRef,
createUploadCallbacks,
createUploadBridge,
createUploadConflictResolver,
getActivePane,
refresh,
sftpSessionsRef,
useCompressedUpload,
],
);
const uploadExternalEntries = useCallback(
async (
side: "left" | "right",
@@ -835,6 +1057,8 @@ export const useSftpExternalOperations = (
writeTextFileByConnection,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalFileList,
uploadExternalFolderPath,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,

View File

@@ -0,0 +1,187 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildSftpHostCredentials } from "./useSftpHostCredentials.ts";
import type { Host, SSHKey } from "../../../domain/models.ts";
const host = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
...overrides,
});
test("buildSftpHostCredentials rejects missing jump hosts", () => {
assert.throws(
() => buildSftpHostCredentials({
host: host({ hostChain: { hostIds: ["missing-jump"] } }),
hosts: [],
keys: [],
identities: [],
}),
/Jump host "missing-jump" is missing/,
);
});
test("buildSftpHostCredentials rejects missing saved proxy profiles", () => {
assert.throws(
() => buildSftpHostCredentials({
host: host({ proxyProfileId: "missing-proxy" }),
hosts: [],
keys: [],
identities: [],
}),
/Saved proxy for host "Host" is missing/,
);
});
test("buildSftpHostCredentials rejects missing saved proxy profiles on jump hosts", () => {
const jumpHost = host({ id: "jump-1", label: "Jump", proxyProfileId: "missing-proxy" });
assert.throws(
() => buildSftpHostCredentials({
host: host({ hostChain: { hostIds: ["jump-1"] } }),
hosts: [jumpHost],
keys: [],
identities: [],
}),
/Saved proxy for jump host "Jump" is missing/,
);
});
test("buildSftpHostCredentials passes reference keys as identity file paths", () => {
const key: SSHKey = {
id: "key-1",
label: "Reference key",
type: "ED25519",
privateKey: "",
source: "reference",
category: "key",
created: 1,
filePath: "/Users/alice/.ssh/id_ed25519",
passphrase: "saved-passphrase",
};
const credentials = buildSftpHostCredentials({
host: host({ authMethod: "key", identityFileId: "key-1" }),
hosts: [],
keys: [key],
identities: [],
});
assert.equal(credentials.privateKey, undefined);
assert.deepEqual(credentials.identityFilePaths, ["/Users/alice/.ssh/id_ed25519"]);
assert.equal(credentials.passphrase, "saved-passphrase");
});
test("buildSftpHostCredentials passes jump host reference keys as identity file paths", () => {
const key: SSHKey = {
id: "jump-key",
label: "Jump key",
type: "ED25519",
privateKey: "",
source: "reference",
category: "key",
created: 1,
filePath: "/Users/alice/.ssh/jump_ed25519",
};
const jumpHost = host({
id: "jump-1",
label: "Jump",
authMethod: "key",
identityFileId: "jump-key",
});
const credentials = buildSftpHostCredentials({
host: host({ hostChain: { hostIds: ["jump-1"] } }),
hosts: [jumpHost],
keys: [key],
identities: [],
});
assert.equal(credentials.jumpHosts?.[0]?.privateKey, undefined);
assert.deepEqual(credentials.jumpHosts?.[0]?.identityFilePaths, ["/Users/alice/.ssh/jump_ed25519"]);
});
test("buildSftpHostCredentials rejects undecryptable saved password credentials", () => {
assert.throws(
() => buildSftpHostCredentials({
host: host({
authMethod: "password",
password: "enc:v1:djEwAAAA",
}),
hosts: [],
keys: [],
identities: [],
}),
/Saved credentials cannot be decrypted/,
);
});
test("buildSftpHostCredentials omits local key file paths for password auth", () => {
const credentials = buildSftpHostCredentials({
host: host({
authMethod: "password",
password: "secret",
identityFilePaths: ["/Users/alice/.ssh/id_ed25519"],
}),
hosts: [],
keys: [],
identities: [],
});
assert.equal(credentials.password, "secret");
assert.equal(credentials.privateKey, undefined);
assert.equal(credentials.identityFilePaths, undefined);
});
test("buildSftpHostCredentials rejects undecryptable saved key material without fallback credentials", () => {
const key: SSHKey = {
id: "key-1",
label: "Imported key",
type: "ED25519",
privateKey: "enc:v1:djEwAAAA",
source: "imported",
category: "key",
created: 1,
};
assert.throws(
() => buildSftpHostCredentials({
host: host({ authMethod: "key", identityFileId: "key-1" }),
hosts: [],
keys: [key],
identities: [],
}),
/Saved credentials cannot be decrypted/,
);
});
test("buildSftpHostCredentials does not use stale local key paths when a selected key is unavailable", () => {
const key: SSHKey = {
id: "key-1",
label: "Imported key",
type: "ED25519",
privateKey: "enc:v1:djEwAAAA",
source: "imported",
category: "key",
created: 1,
};
assert.throws(
() => buildSftpHostCredentials({
host: host({
authMethod: "key",
identityFileId: "key-1",
identityFilePaths: ["/Users/alice/.ssh/stale_ed25519"],
}),
hosts: [],
keys: [key],
identities: [],
}),
/Saved credentials cannot be decrypted/,
);
});

View File

@@ -1,102 +1,174 @@
import { useCallback } from "react";
import type { Host, Identity, SSHKey } from "../../../domain/models";
import type { Host, Identity, SSHKey, TerminalSettings } from "../../../domain/models";
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
import { resolveHostAuth } from "../../../domain/sshAuth";
import { resolveBridgeKeyAuth, resolveHostAuth } from "../../../domain/sshAuth";
import { resolveHostKeepalive } from "../../../domain/host";
// Fallback used when no global TerminalSettings are wired through (older
// call sites or tests). Matches DEFAULT_TERMINAL_SETTINGS so behavior is
// identical whether or not the caller passes settings.
const FALLBACK_KEEPALIVE = { keepaliveInterval: 30, keepaliveCountMax: 10 };
interface UseSftpHostCredentialsParams {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>;
}
export const buildSftpHostCredentials = ({
host,
hosts,
keys,
identities,
terminalSettings,
}: UseSftpHostCredentialsParams & { host: Host }): NetcattySSHOptions => {
const globalKeepalive = terminalSettings ?? FALLBACK_KEEPALIVE;
if (host.proxyProfileId && !host.proxyConfig) {
throw new Error(`Saved proxy for host "${host.label || host.hostname}" is missing. Open host settings and select a valid proxy.`);
}
const resolved = resolveHostAuth({ host, keys, identities });
const key = resolved.key || null;
const proxyConfig = host.proxyConfig
? {
type: host.proxyConfig.type,
host: host.proxyConfig.host,
port: host.proxyConfig.port,
username: host.proxyConfig.username,
password: sanitizeCredentialValue(host.proxyConfig.password),
}
: undefined;
let jumpHosts: NetcattyJumpHost[] | undefined;
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
jumpHosts = host.hostChain.hostIds.map((hostId) => {
const jumpHost = hosts.find((candidate) => candidate.id === hostId);
if (!jumpHost) {
throw new Error(`Jump host "${hostId}" is missing. Open host settings and repair the jump host chain.`);
}
if (jumpHost.proxyProfileId && !jumpHost.proxyConfig) {
throw new Error(`Saved proxy for jump host "${jumpHost.label || jumpHost.hostname}" is missing. Open host settings and select a valid proxy.`);
}
return jumpHost;
}).map((jumpHost, index) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
keys,
identities,
});
const jumpKey = jumpAuth.key;
const jumpPassword = sanitizeCredentialValue(jumpAuth.password);
const jumpKeyAuth = resolveBridgeKeyAuth({
key: jumpKey,
fallbackIdentityFilePaths: jumpAuth.authMethod === "password" || jumpAuth.keyId
? undefined
: jumpHost.identityFilePaths,
passphrase: jumpAuth.passphrase,
});
const hasJumpKeyMaterial = Boolean(jumpKeyAuth.privateKey || jumpKeyAuth.identityFilePaths?.length);
const hasConfiguredJumpProxyEndpoint =
index === 0 &&
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
if (
hasConfiguredJumpProxyEndpoint &&
jumpHost.proxyConfig?.username &&
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig.password) &&
!sanitizeCredentialValue(jumpHost.proxyConfig.password)
) {
throw new Error(`Proxy credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter the proxy password.`);
}
const hasUnreadableJumpCredential =
isEncryptedCredentialPlaceholder(jumpAuth.password) ||
isEncryptedCredentialPlaceholder(jumpKey?.privateKey) ||
isEncryptedCredentialPlaceholder(jumpAuth.passphrase);
if (
(jumpAuth.authMethod === "password" && isEncryptedCredentialPlaceholder(jumpAuth.password) && !jumpPassword) ||
(jumpAuth.authMethod !== "password" && hasUnreadableJumpCredential && !jumpPassword && !hasJumpKeyMaterial)
) {
throw new Error(`Saved credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter them.`);
}
const hopKeepalive = resolveHostKeepalive(jumpHost, globalKeepalive);
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
username: jumpAuth.username || "root",
password: jumpPassword,
privateKey: jumpKeyAuth.privateKey,
certificate: jumpKey?.certificate,
passphrase: jumpKeyAuth.passphrase,
publicKey: jumpKey?.publicKey,
keyId: jumpAuth.keyId,
keySource: jumpKey?.source,
label: jumpHost.label,
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
? {
type: jumpHost.proxyConfig.type,
host: jumpHost.proxyConfig.host,
port: jumpHost.proxyConfig.port,
username: jumpHost.proxyConfig.username,
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
}
: undefined,
identityFilePaths: jumpKeyAuth.identityFilePaths,
keepaliveInterval: hopKeepalive.interval,
keepaliveCountMax: hopKeepalive.countMax,
};
});
}
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts?.[0]?.proxy;
if (usesTargetProxyForFirstHop && host.proxyConfig?.username && isEncryptedCredentialPlaceholder(host.proxyConfig.password) && !proxyConfig?.password) {
throw new Error("Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.");
}
const keyAuth = resolveBridgeKeyAuth({
key,
fallbackIdentityFilePaths: resolved.authMethod === "password" || resolved.keyId
? undefined
: host.identityFilePaths,
passphrase: resolved.passphrase,
});
const password = sanitizeCredentialValue(resolved.password);
const hasKeyMaterial = Boolean(keyAuth.privateKey || keyAuth.identityFilePaths?.length);
const hasUnreadableCredential =
isEncryptedCredentialPlaceholder(resolved.password) ||
isEncryptedCredentialPlaceholder(key?.privateKey) ||
isEncryptedCredentialPlaceholder(resolved.passphrase);
if (
(resolved.authMethod === "password" && isEncryptedCredentialPlaceholder(resolved.password) && !password) ||
(resolved.authMethod !== "password" && hasUnreadableCredential && !password && !hasKeyMaterial)
) {
throw new Error("Saved credentials cannot be decrypted on this device. Open host settings and re-enter them.");
}
const targetKeepalive = resolveHostKeepalive(host, globalKeepalive);
return {
hostname: host.hostname,
username: resolved.username,
port: host.port || 22,
password,
privateKey: keyAuth.privateKey,
certificate: key?.certificate,
passphrase: keyAuth.passphrase,
publicKey: key?.publicKey,
keyId: resolved.keyId,
keySource: key?.source,
proxy: proxyConfig,
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
sudo: host.sftpSudo,
identityFilePaths: keyAuth.identityFilePaths,
keepaliveInterval: targetKeepalive.interval,
keepaliveCountMax: targetKeepalive.countMax,
};
};
export const useSftpHostCredentials = ({
hosts,
keys,
identities,
terminalSettings,
}: UseSftpHostCredentialsParams) =>
useCallback(
(host: Host): NetcattySSHOptions => {
const resolved = resolveHostAuth({ host, keys, identities });
const key = resolved.key || null;
const proxyConfig = host.proxyConfig
? {
type: host.proxyConfig.type,
host: host.proxyConfig.host,
port: host.proxyConfig.port,
username: host.proxyConfig.username,
password: sanitizeCredentialValue(host.proxyConfig.password),
}
: undefined;
let jumpHosts: NetcattyJumpHost[] | undefined;
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
jumpHosts = host.hostChain.hostIds
.map((hostId) => hosts.find((h) => h.id === hostId))
.filter((h): h is Host => !!h)
.map((jumpHost, index) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
keys,
identities,
});
const jumpKey = jumpAuth.key;
const hasConfiguredJumpProxyEndpoint =
index === 0 &&
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
if (
hasConfiguredJumpProxyEndpoint &&
jumpHost.proxyConfig?.username &&
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig.password) &&
!sanitizeCredentialValue(jumpHost.proxyConfig.password)
) {
throw new Error(`Proxy credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter the proxy password.`);
}
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
username: jumpAuth.username || "root",
password: jumpAuth.password,
privateKey: jumpKey?.privateKey,
certificate: jumpKey?.certificate,
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
publicKey: jumpKey?.publicKey,
keyId: jumpAuth.keyId,
keySource: jumpKey?.source,
label: jumpHost.label,
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
? {
type: jumpHost.proxyConfig.type,
host: jumpHost.proxyConfig.host,
port: jumpHost.proxyConfig.port,
username: jumpHost.proxyConfig.username,
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
}
: undefined,
identityFilePaths: jumpHost.identityFilePaths,
};
});
}
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts?.[0]?.proxy;
if (usesTargetProxyForFirstHop && host.proxyConfig?.username && isEncryptedCredentialPlaceholder(host.proxyConfig.password) && !proxyConfig?.password) {
throw new Error("Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.");
}
return {
hostname: host.hostname,
username: resolved.username,
port: host.port || 22,
password: resolved.password,
privateKey: key?.privateKey,
certificate: key?.certificate,
passphrase: resolved.passphrase || key?.passphrase,
publicKey: key?.publicKey,
keyId: resolved.keyId,
keySource: key?.source,
proxy: proxyConfig,
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
sudo: host.sftpSudo,
identityFilePaths: host.identityFilePaths,
};
},
[hosts, identities, keys],
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities, terminalSettings }),
[hosts, identities, keys, terminalSettings],
);

View File

@@ -1,7 +1,13 @@
import test from "node:test";
import assert from "node:assert/strict";
import { uploadFromDataTransfer } from "../../lib/uploadService.ts";
import {
UploadController,
startUploadScanningTask,
uploadEntriesDirect,
uploadFromDataTransfer,
uploadFromFileList,
} from "../../lib/uploadService.ts";
function createDataTransfer(files: File[]): DataTransfer {
return {
@@ -10,6 +16,37 @@ function createDataTransfer(files: File[]): DataTransfer {
} as unknown as DataTransfer;
}
function createDataTransferWithNullEntries(files: File[]): DataTransfer {
const items = files.map((file) => ({
kind: "file",
getAsFile: () => file,
webkitGetAsEntry: () => null,
}));
return {
items,
files,
} as unknown as DataTransfer;
}
test("upload scanning task can be shown and cancelled before transfers start", () => {
const events: string[] = [];
const scanningTask = startUploadScanningTask(
{
onScanningStart: (taskId) => events.push(`start:${taskId}`),
onScanningEnd: (taskId) => events.push(`end:${taskId}`),
onTaskCancelled: (taskId) => events.push(`cancel:${taskId}`),
},
"scan-folder-1",
);
assert.equal(scanningTask.isOpen(), true);
scanningTask.cancel();
scanningTask.complete();
assert.equal(scanningTask.isOpen(), false);
assert.deepEqual(events, ["start:scan-folder-1", "cancel:scan-folder-1"]);
});
test("clears the scanning placeholder when every dropped file is skipped by conflict resolution", async () => {
const events: string[] = [];
const file = new File(["local"], "conflict.txt", { lastModified: 1234 });
@@ -42,3 +79,119 @@ test("clears the scanning placeholder when every dropped file is skipped by conf
]);
assert.deepEqual(events, ["scan:start", "scan:end"]);
});
test("uploads DataTransfer files when entry extraction returns no entries", async () => {
const file = new File(["picked"], "picked.txt", { lastModified: 1234 });
const uploadedPaths: string[] = [];
const results = await uploadFromDataTransfer(
createDataTransferWithNullEntries([file]),
{
targetPath: "/target",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async () => {},
writeSftpBinary: async (_sftpId, path) => {
uploadedPaths.push(path);
},
},
joinPath: (base, name) => `${base}/${name}`,
},
);
assert.deepEqual(uploadedPaths, ["/target/picked.txt"]);
assert.deepEqual(results, [
{ fileName: "picked.txt", success: true },
]);
});
test("uploads picked folder files with their relative directory structure", async () => {
const file = new File(["nested"], "file.txt", { lastModified: 1234 });
Object.defineProperty(file, "webkitRelativePath", {
value: "folder/sub/file.txt",
});
const madeDirs: string[] = [];
const uploadedPaths: string[] = [];
const results = await uploadFromFileList(
[file],
{
targetPath: "/target",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async (_sftpId, path) => {
madeDirs.push(path);
},
writeSftpBinary: async (_sftpId, path) => {
uploadedPaths.push(path);
},
},
joinPath: (base, name) => `${base}/${name}`,
},
);
assert.deepEqual(madeDirs, ["/target/folder", "/target/folder/sub"]);
assert.deepEqual(uploadedPaths, ["/target/folder/sub/file.txt"]);
assert.deepEqual(results, [
{ fileName: "folder/sub/file.txt", success: true },
]);
});
test("reports empty directory creation failures", async () => {
const madeDirs: string[] = [];
const results = await uploadEntriesDirect(
[
{ file: null, relativePath: "folder", isDirectory: true },
{ file: null, relativePath: "folder/empty", isDirectory: true },
],
{
targetPath: "/target",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async (_sftpId, path) => {
madeDirs.push(path);
if (path.endsWith("/empty")) {
throw new Error("permission denied");
}
},
},
joinPath: (base, name) => `${base}/${name}`,
},
);
assert.deepEqual(madeDirs, ["/target/folder", "/target/folder/empty"]);
assert.deepEqual(results, [
{ fileName: "folder/empty", success: false, error: "permission denied" },
]);
});
test("does not restart a direct upload that was already cancelled", async () => {
const controller = new UploadController();
await controller.cancel();
let mkdirCalled = false;
const results = await uploadEntriesDirect(
[{ file: null, relativePath: "folder", isDirectory: true }],
{
targetPath: "/target",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async () => {
mkdirCalled = true;
},
},
joinPath: (base, name) => `${base}/${name}`,
},
controller,
);
assert.equal(mkdirCalled, false);
assert.deepEqual(results, [
{ fileName: "", success: false, cancelled: true },
]);
});

View File

@@ -16,13 +16,20 @@ import {
findSyncPayloadEncryptedCredentialPaths,
} from '../../domain/credentials';
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
import { collectSyncableSettings, hasMeaningfulCloudSyncData } from '../syncPayload';
import {
SYNCABLE_SETTING_STORAGE_KEYS,
collectSyncableSettings,
hasMeaningfulCloudSyncData,
} from '../syncPayload';
import { readInterruptedVaultApply } from '../localVaultBackups';
import {
STORAGE_KEY_PORT_FORWARDING,
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
} from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
localStorageAdapter,
} from '../../infrastructure/persistence/localStorageAdapter';
import { notify } from '../notification';
interface AutoSyncConfig {
@@ -30,6 +37,7 @@ interface AutoSyncConfig {
hosts: SyncPayload['hosts'];
keys: SyncPayload['keys'];
identities?: SyncPayload['identities'];
proxyProfiles?: SyncPayload['proxyProfiles'];
snippets: SyncPayload['snippets'];
customGroups: SyncPayload['customGroups'];
snippetPackages?: SyncPayload['snippetPackages'];
@@ -46,6 +54,7 @@ interface AutoSyncConfig {
// Get manager singleton for direct state access
const manager = getCloudSyncManager();
const AUTO_SYNC_PROVIDER_ORDER: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
const SYNCABLE_SETTING_STORAGE_KEY_SET = new Set<string>(SYNCABLE_SETTING_STORAGE_KEYS);
// Cross-window restore barrier: stored as an epoch-ms deadline. Any value
// in the future means a restore is applying in some window and auto-sync
@@ -110,6 +119,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
remotePayload: SyncPayload;
hostCount: number;
keyCount: number;
proxyProfileCount: number;
snippetCount: number;
} | null>(null);
const emptyVaultResolveRef = useRef<((action: 'restore' | 'keep-empty') => void) | null>(null);
@@ -122,6 +132,29 @@ export const useAutoSync = (config: AutoSyncConfig) => {
return () => window.removeEventListener('sftp-bookmarks-changed', handler);
}, []);
const [syncableSettingsStorageVersion, setSyncableSettingsStorageVersion] = useState(0);
useEffect(() => {
const bumpIfSyncableSetting = (key: string | null | undefined) => {
if (!key || !SYNCABLE_SETTING_STORAGE_KEY_SET.has(key)) return;
setSyncableSettingsStorageVersion((v) => v + 1);
};
const handleStorage = (event: StorageEvent) => {
bumpIfSyncableSetting(event.key);
};
const handleLocalStorageAdapterChanged = (event: Event) => {
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
bumpIfSyncableSetting(key);
};
window.addEventListener('storage', handleStorage);
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
return () => {
window.removeEventListener('storage', handleStorage);
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
};
}, []);
const getSyncSnapshot = useCallback(() => {
let effectivePFRules = config.portForwardingRules;
if (!effectivePFRules || effectivePFRules.length === 0) {
@@ -142,6 +175,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
hosts: config.hosts,
keys: config.keys,
identities: config.identities,
proxyProfiles: config.proxyProfiles,
snippets: config.snippets,
customGroups: config.customGroups,
snippetPackages: config.snippetPackages,
@@ -152,6 +186,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
config.hosts,
config.keys,
config.identities,
config.proxyProfiles,
config.snippets,
config.customGroups,
config.snippetPackages,
@@ -444,6 +479,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
remotePayload,
hostCount: remotePayload.hosts?.length ?? 0,
keyCount: remotePayload.keys?.length ?? 0,
proxyProfileCount: remotePayload.proxyProfiles?.length ?? 0,
snippetCount: remotePayload.snippets?.length ?? 0,
});
});
@@ -634,7 +670,17 @@ export const useAutoSync = (config: AutoSyncConfig) => {
clearTimeout(syncTimeoutRef.current);
}
};
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow, config.settingsVersion, bookmarksVersion]);
}, [
sync.hasAnyConnectedProvider,
sync.autoSyncEnabled,
sync.isUnlocked,
sync.isSyncing,
getDataHash,
syncNow,
config.settingsVersion,
bookmarksVersion,
syncableSettingsStorageVersion,
]);
// Check remote version on startup/unlock, then retry with backoff
// while the inspect keeps failing. Without the timer-based retry,

View File

@@ -33,6 +33,7 @@ interface HotkeyActions {
// App features
broadcast: () => void;
openLocal: () => void;
openSettings: () => void;
}
// Check if keyboard event matches our app-level shortcuts
@@ -71,6 +72,7 @@ export const getAppLevelActions = (): Set<string> => {
'moveFocus',
'broadcast',
'openLocal',
'openSettings',
]);
};
@@ -200,6 +202,9 @@ export const useGlobalHotkeys = ({
case 'broadcast':
currentActions.broadcast?.();
break;
case 'openSettings':
currentActions.openSettings?.();
break;
}
}, [hotkeyScheme, keyBindings, isSettingsOpen]);

View File

@@ -0,0 +1,117 @@
import test from "node:test";
import assert from "node:assert/strict";
import { getAutoStartRuleBlockReason, isAutoStartProxyReady } from "./usePortForwardingAutoStart.ts";
import type { GroupConfig, Host, PortForwardingRule, ProxyProfile } from "../../domain/models.ts";
const host = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
...overrides,
});
const proxyProfile = (id: string): ProxyProfile => ({
id,
label: "Proxy",
config: { type: "http", host: "proxy.example.com", port: 3128 },
createdAt: 1,
});
const rule = (overrides: Partial<PortForwardingRule> = {}): PortForwardingRule => ({
id: "rule-1",
label: "Rule",
type: "local",
localPort: 8080,
bindAddress: "127.0.0.1",
remoteHost: "127.0.0.1",
remotePort: 80,
hostId: "host-1",
autoStart: true,
status: "inactive",
createdAt: 1,
...overrides,
});
test("isAutoStartProxyReady waits when a host saved proxy is unresolved", () => {
assert.equal(
isAutoStartProxyReady(
host({ proxyProfileId: "missing-proxy" }),
[],
[],
[],
),
false,
);
});
test("isAutoStartProxyReady waits when a missing host proxy has a group fallback", () => {
const groupConfigs: GroupConfig[] = [{ path: "prod", proxyProfileId: "group-proxy" }];
const currentHost = host({ group: "prod", proxyProfileId: "missing-proxy" });
assert.equal(
isAutoStartProxyReady(
currentHost,
[currentHost],
[proxyProfile("group-proxy")],
groupConfigs,
),
false,
);
});
test("isAutoStartProxyReady waits when a group saved proxy is unresolved", () => {
const groupConfigs: GroupConfig[] = [{ path: "prod", proxyProfileId: "missing-proxy" }];
const currentHost = host({ group: "prod" });
assert.equal(
isAutoStartProxyReady(
currentHost,
[currentHost],
[],
groupConfigs,
),
false,
);
});
test("isAutoStartProxyReady checks group-inherited jump hosts", () => {
const currentHost = host({ group: "prod" });
const jumpHost = host({ id: "jump-1", proxyProfileId: "missing-proxy" });
assert.equal(
isAutoStartProxyReady(
currentHost,
[currentHost, jumpHost],
[],
[{ path: "prod", hostChain: { hostIds: ["jump-1"] } }],
),
false,
);
});
test("getAutoStartRuleBlockReason only blocks the affected rule", () => {
const goodHost = host();
const badHost = host({ id: "host-2", proxyProfileId: "missing-proxy" });
const hosts = [goodHost, badHost];
const isHostAuthReady = () => true;
assert.equal(
getAutoStartRuleBlockReason(rule({ id: "good", hostId: "host-1" }), hosts, [], [], isHostAuthReady),
undefined,
);
assert.equal(
getAutoStartRuleBlockReason(rule({ id: "bad", hostId: "host-2" }), hosts, [], [], isHostAuthReady),
"Proxy or jump host configuration is not ready",
);
});
test("getAutoStartRuleBlockReason marks rules without a host", () => {
assert.equal(
getAutoStartRuleBlockReason(rule({ hostId: undefined }), [], [], [], () => true),
"Rule host is not configured",
);
});

View File

@@ -4,8 +4,9 @@
* when the application starts, not when the user navigates to the port forwarding page.
*/
import { useCallback, useEffect, useRef } from "react";
import { GroupConfig, Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
import { GroupConfig, Host, Identity, PortForwardingRule, ProxyProfile, SSHKey } from "../../domain/models";
import { resolveGroupDefaults, applyGroupDefaults } from "../../domain/groupConfig";
import { materializeHostProxyProfile } from "../../domain/proxyProfiles";
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import {
@@ -17,27 +18,102 @@ import {
import { logger } from "../../lib/logger";
export interface UsePortForwardingAutoStartOptions {
isVaultInitialized: boolean;
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
proxyProfiles: ProxyProfile[];
groupConfigs: GroupConfig[];
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}
const AUTO_START_PROXY_NOT_READY_ERROR = "Proxy or jump host configuration is not ready";
const AUTO_START_AUTH_NOT_READY_ERROR = "Host authentication configuration is not ready";
export const isAutoStartProxyReady = (
host: Host,
allHosts: Host[],
proxyProfiles: ProxyProfile[],
groupConfigs: GroupConfig[],
seen = new Set<string>(),
): boolean => {
if (!host || seen.has(host.id)) return true;
seen.add(host.id);
const validProxyProfileIds: ReadonlySet<string> = new Set(proxyProfiles.map((profile) => profile.id));
const rawGroupDefaults = host.group
? resolveGroupDefaults(host.group, groupConfigs)
: {};
const groupDefaults = host.group
? resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds })
: {};
const missingHostProxyProfile = Boolean(
host.proxyProfileId && !validProxyProfileIds.has(host.proxyProfileId),
);
const missingGroupProxyProfile = Boolean(
!host.proxyConfig &&
!host.proxyProfileId &&
rawGroupDefaults.proxyProfileId &&
!validProxyProfileIds.has(rawGroupDefaults.proxyProfileId),
);
const effectiveHost = applyGroupDefaults(host, groupDefaults, { validProxyProfileIds });
const hasProxyReplacement = Boolean(
effectiveHost.proxyConfig ||
(effectiveHost.proxyProfileId && validProxyProfileIds.has(effectiveHost.proxyProfileId)),
);
if ((missingHostProxyProfile || missingGroupProxyProfile) && !hasProxyReplacement) {
return false;
}
const chainIds = effectiveHost.hostChain?.hostIds || [];
for (const chainId of chainIds) {
const chainHost = allHosts.find((candidate) => candidate.id === chainId);
if (!chainHost) return false;
if (!isAutoStartProxyReady(chainHost, allHosts, proxyProfiles, groupConfigs, seen)) return false;
}
return true;
};
export const getAutoStartRuleBlockReason = (
rule: PortForwardingRule,
hosts: Host[],
proxyProfiles: ProxyProfile[],
groupConfigs: GroupConfig[],
isHostAuthReady: (host: Host) => boolean,
): string | undefined => {
if (!rule.hostId) return "Rule host is not configured";
const host = hosts.find((candidate) => candidate.id === rule.hostId);
if (!host) return "Host not found";
if (!isHostAuthReady(host)) return AUTO_START_AUTH_NOT_READY_ERROR;
if (!isAutoStartProxyReady(host, hosts, proxyProfiles, groupConfigs)) {
return AUTO_START_PROXY_NOT_READY_ERROR;
}
return undefined;
};
/**
* Auto-starts port forwarding rules that have autoStart enabled.
* This hook should be called at the App level to run on app launch.
*/
export const usePortForwardingAutoStart = ({
isVaultInitialized,
hosts,
keys,
identities,
proxyProfiles,
groupConfigs,
terminalSettings,
}: UsePortForwardingAutoStartOptions): void => {
const autoStartExecutedRef = useRef(false);
const hostsRef = useRef<Host[]>(hosts);
const keysRef = useRef<SSHKey[]>(keys);
const identitiesRef = useRef<Identity[]>(identities);
const proxyProfilesRef = useRef<ProxyProfile[]>(proxyProfiles);
const groupConfigsRef = useRef<GroupConfig[]>(groupConfigs);
const terminalSettingsRef = useRef(terminalSettings);
terminalSettingsRef.current = terminalSettings;
const isHostAuthReady = useCallback((host: Host, seen = new Set<string>()): boolean => {
if (!host || seen.has(host.id)) return true;
@@ -77,16 +153,53 @@ export const usePortForwardingAutoStart = ({
identitiesRef.current = identities;
}, [identities]);
useEffect(() => {
proxyProfilesRef.current = proxyProfiles;
}, [proxyProfiles]);
useEffect(() => {
groupConfigsRef.current = groupConfigs;
}, [groupConfigs]);
const resolveEffectiveHost = useCallback((host: Host): Host => {
if (!host.group) return host;
const defaults = resolveGroupDefaults(host.group, groupConfigsRef.current);
return applyGroupDefaults(host, defaults);
const validProxyProfileIds: ReadonlySet<string> = new Set(proxyProfilesRef.current.map((profile) => profile.id));
const withGroupDefaults = host.group
? applyGroupDefaults(
host,
resolveGroupDefaults(host.group, groupConfigsRef.current, { validProxyProfileIds }),
{ validProxyProfileIds },
)
: applyGroupDefaults(host, {}, { validProxyProfileIds });
return materializeHostProxyProfile(withGroupDefaults, proxyProfilesRef.current);
}, []);
const resolveEffectiveHosts = useCallback(
(items: Host[]): Host[] => items.map((host) => resolveEffectiveHost(host)),
[resolveEffectiveHost],
);
const updateStoredRuleStatus = useCallback(
(ruleId: string, status: PortForwardingRule["status"], error?: string) => {
const currentRules = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
) ?? [];
const updatedRules = currentRules.map((rule) =>
rule.id === ruleId
? {
...rule,
status,
error,
lastUsedAt: status === "active" ? Date.now() : rule.lastUsedAt,
}
: rule,
);
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, updatedRules);
},
[],
);
// Set up the reconnect callback
useEffect(() => {
const handleReconnect = async (
@@ -99,40 +212,49 @@ export const usePortForwardingAutoStart = ({
) ?? [];
const rule = rules.find((r) => r.id === ruleId);
if (!rule || !rule.hostId) {
return { success: false, error: "Rule or host not found" };
if (!rule) {
const error = "Rule not found";
onStatusChange("error", error);
return { success: false, error };
}
if (!rule.hostId) {
const error = "Rule host is not configured";
onStatusChange("error", error);
return { success: false, error };
}
const rawHost = hostsRef.current.find((h) => h.id === rule.hostId);
if (!rawHost) {
return { success: false, error: "Host not found" };
const error = "Host not found";
onStatusChange("error", error);
return { success: false, error };
}
const blockReason = getAutoStartRuleBlockReason(
rule,
hostsRef.current,
proxyProfilesRef.current,
groupConfigsRef.current,
(host) => isHostAuthReady(host),
);
if (blockReason) {
onStatusChange("error", blockReason);
return { success: false, error: blockReason };
}
const host = resolveEffectiveHost(rawHost);
return startPortForward(rule, host, hostsRef.current, keysRef.current, identitiesRef.current, onStatusChange, true);
return startPortForward(rule, host, resolveEffectiveHosts(hostsRef.current), keysRef.current, identitiesRef.current, onStatusChange, true, terminalSettingsRef.current);
};
setReconnectCallback(handleReconnect);
return () => {
setReconnectCallback(null);
};
}, [resolveEffectiveHost]);
}, [isHostAuthReady, resolveEffectiveHost, resolveEffectiveHosts]);
// Auto-start rules on app launch
useEffect(() => {
if (autoStartExecutedRef.current) return;
if (hosts.length === 0) return;
const storedRules = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
) ?? [];
const pendingAutoStartRules = storedRules.filter((rule) => rule.autoStart && rule.hostId);
if (pendingAutoStartRules.some((rule) => {
const host = hosts.find((candidate) => candidate.id === rule.hostId);
return !host || !isHostAuthReady(host);
})) {
return;
}
if (!isVaultInitialized) return;
// Mark as executed immediately to prevent duplicate runs
// (React StrictMode or dependency changes could cause re-runs)
@@ -149,7 +271,7 @@ export const usePortForwardingAutoStart = ({
// Only start rules that are not already active
const autoStartRules = rules.filter((r) => {
if (!r.autoStart || !r.hostId) return false;
if (!r.autoStart) return false;
// Check if there's an active connection for this rule
const conn = getActiveConnection(r.id);
// Only start if not already connecting or active
@@ -162,39 +284,49 @@ export const usePortForwardingAutoStart = ({
// Start each auto-start rule
for (const rule of autoStartRules) {
const rawHost = hosts.find((h) => h.id === rule.hostId);
if (rawHost) {
const host = resolveEffectiveHost(rawHost);
void startPortForward(
rule,
host,
hosts,
keys,
identities,
(status, error) => {
// Update the rule status in storage
const currentRules = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
) ?? [];
const updatedRules = currentRules.map((r) =>
r.id === rule.id
? {
...r,
status,
error,
lastUsedAt: status === "active" ? Date.now() : r.lastUsedAt,
}
: r,
);
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, updatedRules);
},
true, // Enable reconnect for auto-start rules
);
const blockReason = getAutoStartRuleBlockReason(
rule,
hosts,
proxyProfiles,
groupConfigs,
(host) => isHostAuthReady(host),
);
if (blockReason) {
updateStoredRuleStatus(rule.id, "error", blockReason);
continue;
}
if (!rawHost) continue;
const host = resolveEffectiveHost(rawHost);
void startPortForward(
rule,
host,
resolveEffectiveHosts(hosts),
keys,
identities,
(status, error) => {
updateStoredRuleStatus(rule.id, status, error);
},
true, // Enable reconnect for auto-start rules
// Read via ref so adjusting global keepalive after launch doesn't
// re-trigger the auto-start effect (its dep array is intentionally
// stable to fire once on vault init).
terminalSettingsRef.current,
);
}
};
void runAutoStart();
}, [hosts, identities, isHostAuthReady, keys, resolveEffectiveHost]);
}, [
groupConfigs,
hosts,
identities,
isHostAuthReady,
isVaultInitialized,
keys,
proxyProfiles,
resolveEffectiveHost,
resolveEffectiveHosts,
updateStoredRuleStatus,
]);
};

View File

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

View File

@@ -51,7 +51,7 @@ import {
} from '../../domain/customKeyBindings';
import { applyCustomAccentToTerminalTheme, getTerminalThemeForUiTheme } 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 +71,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 +254,10 @@ 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 [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);
@@ -512,7 +537,8 @@ export const useSettingsState = () => {
const storedTermTheme = readStoredString(STORAGE_KEY_TERM_THEME);
if (storedTermTheme) setTerminalThemeId(storedTermTheme);
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);
@@ -648,7 +674,8 @@ export const useSettingsState = () => {
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);
@@ -844,8 +871,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

View File

@@ -174,6 +174,7 @@ export const useSftpState = (
hosts,
keys,
identities,
terminalSettings: options?.terminalSettings,
leftTabsRef,
rightTabsRef,
leftTabs,
@@ -304,6 +305,8 @@ export const useSftpState = (
writeTextFileByConnection,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalFileList,
uploadExternalFolderPath,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
@@ -381,6 +384,8 @@ export const useSftpState = (
writeTextFileByConnection,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalFileList,
uploadExternalFolderPath,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
@@ -436,6 +441,8 @@ export const useSftpState = (
writeTextFileByConnection,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalFileList,
uploadExternalFolderPath,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
@@ -501,6 +508,10 @@ export const useSftpState = (
methodsRef.current.writeTextFileByConnection(...args),
downloadToTempAndOpen: (...args: Parameters<typeof downloadToTempAndOpen>) => methodsRef.current.downloadToTempAndOpen(...args),
uploadExternalFiles: (...args: Parameters<typeof uploadExternalFiles>) => methodsRef.current.uploadExternalFiles(...args),
uploadExternalFileList: (...args: Parameters<typeof uploadExternalFileList>) =>
methodsRef.current.uploadExternalFileList(...args),
uploadExternalFolderPath: (...args: Parameters<typeof uploadExternalFolderPath>) =>
methodsRef.current.uploadExternalFolderPath(...args),
uploadExternalEntries: (...args: Parameters<typeof uploadExternalEntries>) =>
methodsRef.current.uploadExternalEntries(...args),
cancelExternalUpload: () => methodsRef.current.cancelExternalUpload(),

View File

@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
export const useTerminalBackend = () => {
@@ -63,9 +63,9 @@ export const useTerminalBackend = () => {
return bridge.execCommand(options);
}, []);
const writeToSession = useCallback((sessionId: string, data: string) => {
const writeToSession = useCallback((sessionId: string, data: string, options?: { automated?: boolean }) => {
const bridge = netcattyBridge.get();
bridge?.writeToSession?.(sessionId, data);
bridge?.writeToSession?.(sessionId, data, options);
}, []);
const resizeSession = useCallback((sessionId: string, cols: number, rows: number) => {
@@ -96,11 +96,38 @@ export const useTerminalBackend = () => {
return bridge.onSessionExit(sessionId, cb);
}, []);
const onTelnetAutoLoginComplete = useCallback((sessionId: string, cb: (evt: { sessionId: string }) => void) => {
const bridge = netcattyBridge.get();
return bridge?.onTelnetAutoLoginComplete?.(sessionId, cb);
}, []);
const onTelnetAutoLoginCancelled = useCallback((sessionId: string, cb: (evt: { sessionId: string }) => void) => {
const bridge = netcattyBridge.get();
return bridge?.onTelnetAutoLoginCancelled?.(sessionId, cb);
}, []);
const onChainProgress = useCallback((cb: (sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void) => {
const bridge = netcattyBridge.get();
return bridge?.onChainProgress?.(cb);
}, []);
const onHostKeyVerification = useCallback((cb: Parameters<NonNullable<NetcattyBridge["onHostKeyVerification"]>>[0]) => {
const bridge = netcattyBridge.get();
return bridge?.onHostKeyVerification?.(cb);
}, []);
const respondHostKeyVerification = useCallback(async (
requestId: string,
accept: boolean,
addToKnownHosts?: boolean,
) => {
const bridge = netcattyBridge.get();
if (!bridge?.respondHostKeyVerification) {
return { success: false, error: "respondHostKeyVerification unavailable" };
}
return bridge.respondHostKeyVerification(requestId, accept, addToKnownHosts);
}, []);
const openExternal = useCallback(async (url: string) => {
const bridge = netcattyBridge.get();
await bridge?.openExternal?.(url);
@@ -150,32 +177,79 @@ export const useTerminalBackend = () => {
return bridge.getServerStats(sessionId);
}, []);
return {
backendAvailable,
telnetAvailable,
moshAvailable,
localAvailable,
serialAvailable,
execAvailable,
openExternalAvailable,
startSSHSession,
startTelnetSession,
startMoshSession,
startLocalSession,
startSerialSession,
listSerialPorts,
execCommand,
getSessionPwd,
getSessionRemoteInfo,
getSessionDistroInfo,
getServerStats,
writeToSession,
resizeSession,
closeSession,
setSessionEncoding,
onSessionData,
onSessionExit,
onChainProgress,
openExternal,
};
// Memoize the returned object so its identity is stable across the
// hook's lifetime. Each method above is already useCallback([])-stable,
// so listing them as deps means useMemo recomputes once and then
// caches forever. Without this, every render produced a fresh object
// literal — making `terminalBackend` an unstable reference that
// forced consumers' useEffects (`}, [..., terminalBackend])`) to
// rerun on every parent render and forced lint to flag any deeper
// property dep (`}, [terminalBackend.onHostKeyVerification])`) it
// couldn't statically prove safe.
return useMemo(
() => ({
backendAvailable,
telnetAvailable,
moshAvailable,
localAvailable,
serialAvailable,
execAvailable,
openExternalAvailable,
startSSHSession,
startTelnetSession,
startMoshSession,
startLocalSession,
startSerialSession,
listSerialPorts,
execCommand,
getSessionPwd,
getSessionRemoteInfo,
getSessionDistroInfo,
getServerStats,
writeToSession,
resizeSession,
closeSession,
setSessionEncoding,
onSessionData,
onSessionExit,
onTelnetAutoLoginComplete,
onTelnetAutoLoginCancelled,
onChainProgress,
onHostKeyVerification,
respondHostKeyVerification,
openExternal,
}),
[
backendAvailable,
telnetAvailable,
moshAvailable,
localAvailable,
serialAvailable,
execAvailable,
openExternalAvailable,
startSSHSession,
startTelnetSession,
startMoshSession,
startLocalSession,
startSerialSession,
listSerialPorts,
execCommand,
getSessionPwd,
getSessionRemoteInfo,
getSessionDistroInfo,
getServerStats,
writeToSession,
resizeSession,
closeSession,
setSessionEncoding,
onSessionData,
onSessionExit,
onTelnetAutoLoginComplete,
onTelnetAutoLoginCancelled,
onChainProgress,
onHostKeyVerification,
respondHostKeyVerification,
openExternal,
],
);
};

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { normalizeDistroId, sanitizeHost } from "../../domain/host";
import { sanitizeGroupConfig } from "../../domain/groupConfig";
import {
ConnectionLog,
GroupConfig,
@@ -8,6 +9,7 @@ import {
KeyCategory,
KnownHost,
ManagedSource,
ProxyProfile,
ShellHistoryEntry,
Snippet,
SSHKey,
@@ -26,6 +28,7 @@ import {
STORAGE_KEY_KNOWN_HOSTS,
STORAGE_KEY_LEGACY_KEYS,
STORAGE_KEY_MANAGED_SOURCES,
STORAGE_KEY_PROXY_PROFILES,
STORAGE_KEY_SHELL_HISTORY,
STORAGE_KEY_SNIPPET_PACKAGES,
STORAGE_KEY_SNIPPETS,
@@ -36,16 +39,19 @@ import {
decryptHosts,
decryptIdentities,
decryptKeys,
decryptProxyProfiles,
encryptGroupConfigs,
encryptHosts,
encryptIdentities,
encryptKeys,
encryptProxyProfiles,
} from "../../infrastructure/persistence/secureFieldAdapter";
type ExportableVaultData = {
hosts: Host[];
keys: SSHKey[];
identities?: Identity[];
proxyProfiles?: ProxyProfile[];
snippets: Snippet[];
customGroups: string[];
snippetPackages?: string[];
@@ -61,7 +67,7 @@ const migrateKey = (key: Partial<SSHKey>): SSHKey => {
const label = key.label ?? `Key ${id.slice(0, 8)}`;
const source =
key.source === "generated" || key.source === "imported"
key.source === "generated" || key.source === "imported" || key.source === "reference"
? key.source
: key.privateKey
? "imported"
@@ -81,6 +87,7 @@ const migrateKey = (key: Partial<SSHKey>): SSHKey => {
key.category ||
((key.certificate ? "certificate" : "key") as KeyCategory),
created: key.created || Date.now(),
filePath: key.filePath,
};
};
@@ -106,6 +113,7 @@ export const useVaultState = () => {
const [hosts, setHosts] = useState<Host[]>([]);
const [keys, setKeys] = useState<SSHKey[]>([]);
const [identities, setIdentities] = useState<Identity[]>([]);
const [proxyProfiles, setProxyProfiles] = useState<ProxyProfile[]>([]);
const [snippets, setSnippets] = useState<Snippet[]>([]);
const [customGroups, setCustomGroups] = useState<string[]>([]);
const [snippetPackages, setSnippetPackages] = useState<string[]>([]);
@@ -121,6 +129,7 @@ export const useVaultState = () => {
const hostsWriteVersion = useRef(0);
const keysWriteVersion = useRef(0);
const identitiesWriteVersion = useRef(0);
const proxyProfilesWriteVersion = useRef(0);
const groupConfigsWriteVersion = useRef(0);
// Read-sequence counters for cross-window storage events. Each incoming
@@ -130,13 +139,14 @@ export const useVaultState = () => {
const hostsReadSeq = useRef(0);
const keysReadSeq = useRef(0);
const identitiesReadSeq = useRef(0);
const proxyProfilesReadSeq = useRef(0);
const groupConfigsReadSeq = useRef(0);
const updateHosts = useCallback((data: Host[]) => {
const cleaned = data.map(sanitizeHost);
setHosts(cleaned);
const ver = ++hostsWriteVersion.current;
encryptHosts(cleaned).then((enc) => {
return encryptHosts(cleaned).then((enc) => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
@@ -145,21 +155,66 @@ export const useVaultState = () => {
const updateKeys = useCallback((data: SSHKey[]) => {
setKeys(data);
const ver = ++keysWriteVersion.current;
encryptKeys(data).then((enc) => {
return encryptKeys(data).then((enc) => {
if (ver === keysWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
});
}, []);
const importOrReuseKey = useCallback((draft: Partial<SSHKey>): SSHKey => {
const existing = keys.find((k) => {
if (draft.source === 'reference' && draft.filePath) {
return k.source === 'reference' && k.filePath === draft.filePath;
}
if (draft.privateKey) {
return k.privateKey === draft.privateKey;
}
return false;
});
if (existing) return existing;
const newKey: SSHKey = {
id: crypto.randomUUID(),
label: draft.label || 'Imported Key',
type: draft.type || 'ED25519',
privateKey: draft.privateKey || '',
publicKey: draft.publicKey,
certificate: draft.certificate,
passphrase: draft.passphrase,
savePassphrase: draft.savePassphrase,
source: draft.source || 'imported',
category: (draft.category || 'key') as KeyCategory,
created: Date.now(),
filePath: draft.filePath,
};
const updated = [...keys, newKey];
setKeys(updated);
const ver = ++keysWriteVersion.current;
void encryptKeys(updated).then((enc) => {
if (ver === keysWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
});
return newKey;
}, [keys]);
const updateIdentities = useCallback((data: Identity[]) => {
setIdentities(data);
const ver = ++identitiesWriteVersion.current;
encryptIdentities(data).then((enc) => {
return encryptIdentities(data).then((enc) => {
if (ver === identitiesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
});
}, []);
const updateProxyProfiles = useCallback((data: ProxyProfile[]) => {
setProxyProfiles(data);
const ver = ++proxyProfilesWriteVersion.current;
return encryptProxyProfiles(data).then((enc) => {
if (ver === proxyProfilesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_PROXY_PROFILES, enc);
});
}, []);
const updateSnippets = useCallback((data: Snippet[]) => {
setSnippets(data);
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, data);
@@ -186,9 +241,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;
encryptGroupConfigs(data).then((enc) => {
return encryptGroupConfigs(cleaned).then((enc) => {
if (ver === groupConfigsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
});
@@ -198,6 +259,7 @@ export const useVaultState = () => {
updateHosts([]);
updateKeys([]);
updateIdentities([]);
updateProxyProfiles([]);
updateSnippets([]);
updateSnippetPackages([]);
updateCustomGroups([]);
@@ -209,6 +271,7 @@ export const useVaultState = () => {
updateHosts,
updateKeys,
updateIdentities,
updateProxyProfiles,
updateSnippets,
updateSnippetPackages,
updateCustomGroups,
@@ -414,6 +477,20 @@ export const useVaultState = () => {
}
}
const savedProxyProfiles =
localStorageAdapter.read<ProxyProfile[]>(STORAGE_KEY_PROXY_PROFILES);
if (savedProxyProfiles) {
const proxyVer = ++proxyProfilesWriteVersion.current;
const decryptedProfiles = await decryptProxyProfiles(savedProxyProfiles);
if (proxyVer === proxyProfilesWriteVersion.current) {
setProxyProfiles(decryptedProfiles);
encryptProxyProfiles(decryptedProfiles).then((enc) => {
if (proxyVer === proxyProfilesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_PROXY_PROFILES, enc);
});
}
}
// Read remaining non-encrypted data fresh after all async gaps above
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
const savedSnippets =
@@ -458,8 +535,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);
});
@@ -528,6 +606,18 @@ export const useVaultState = () => {
return;
}
if (key === STORAGE_KEY_PROXY_PROFILES) {
const next = safeParse<ProxyProfile[]>(event.newValue) ?? [];
++proxyProfilesWriteVersion.current;
const seq = ++proxyProfilesReadSeq.current;
const writeAtStart = proxyProfilesWriteVersion.current;
decryptProxyProfiles(next).then((dec) => {
if (seq === proxyProfilesReadSeq.current && writeAtStart === proxyProfilesWriteVersion.current)
setProxyProfiles(dec);
});
return;
}
if (key === STORAGE_KEY_SNIPPETS) {
const next = safeParse<Snippet[]>(event.newValue) ?? [];
setSnippets(next);
@@ -577,7 +667,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;
}
@@ -621,30 +711,35 @@ export const useVaultState = () => {
hosts,
keys,
identities,
proxyProfiles,
snippets,
customGroups,
snippetPackages,
knownHosts,
groupConfigs,
}),
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts, groupConfigs],
[hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, knownHosts, groupConfigs],
);
const importData = useCallback(
(payload: Partial<ExportableVaultData>) => {
if (payload.hosts) updateHosts(payload.hosts);
if (payload.keys) updateKeys(payload.keys);
if (payload.identities) updateIdentities(payload.identities);
(payload: Partial<ExportableVaultData>): Promise<void> => {
const encryptedWrites: Promise<void>[] = [];
if (payload.hosts) encryptedWrites.push(updateHosts(payload.hosts));
if (payload.keys) encryptedWrites.push(updateKeys(payload.keys));
if (payload.identities) encryptedWrites.push(updateIdentities(payload.identities));
if (Array.isArray(payload.proxyProfiles)) encryptedWrites.push(updateProxyProfiles(payload.proxyProfiles));
if (payload.snippets) updateSnippets(payload.snippets);
if (payload.customGroups) updateCustomGroups(payload.customGroups);
if (payload.snippetPackages) updateSnippetPackages(payload.snippetPackages);
if (payload.knownHosts) updateKnownHosts(payload.knownHosts);
if (Array.isArray(payload.groupConfigs)) updateGroupConfigs(payload.groupConfigs);
if (Array.isArray(payload.groupConfigs)) encryptedWrites.push(updateGroupConfigs(payload.groupConfigs));
return Promise.all(encryptedWrites).then(() => undefined);
},
[
updateHosts,
updateKeys,
updateIdentities,
updateProxyProfiles,
updateSnippets,
updateCustomGroups,
updateSnippetPackages,
@@ -654,9 +749,9 @@ export const useVaultState = () => {
);
const importDataFromString = useCallback(
(jsonString: string) => {
(jsonString: string): Promise<void> => {
const data = JSON.parse(jsonString);
importData(data);
return importData(data);
},
[importData],
);
@@ -666,6 +761,7 @@ export const useVaultState = () => {
hosts,
keys,
identities,
proxyProfiles,
snippets,
customGroups,
snippetPackages,
@@ -676,7 +772,9 @@ export const useVaultState = () => {
groupConfigs,
updateHosts,
updateKeys,
importOrReuseKey,
updateIdentities,
updateProxyProfiles,
updateSnippets,
updateSnippetPackages,
updateCustomGroups,

View File

@@ -43,13 +43,15 @@ const {
buildSyncPayload,
hasMeaningfulCloudSyncData,
} = await import("./syncPayload.ts");
const storageKeys = await import("../infrastructure/config/storageKeys.ts");
const knownHost = (id = "kh-1"): KnownHost => ({
id,
hostname: `${id}.example.com`,
port: 22,
keyType: "ssh-ed25519",
fingerprint: `SHA256:${id}`,
publicKey: `SHA256:${id}`,
discoveredAt: 1,
});
const vault = (knownHosts: KnownHost[] = [knownHost()]): SyncableVaultData => ({
@@ -73,6 +75,352 @@ test("buildSyncPayload treats known hosts as local-only data", () => {
assert.equal("knownHosts" in payload, false);
});
test("buildSyncPayload includes reusable proxy profiles", () => {
const proxyProfiles = [
{
id: "proxy-1",
label: "Office Proxy",
config: { type: "socks5", host: "proxy.example.com", port: 1080 },
createdAt: 1,
updatedAt: 1,
},
];
const payload = buildSyncPayload({
...vault(),
proxyProfiles,
} as SyncableVaultData & { proxyProfiles: typeof proxyProfiles });
assert.deepEqual(payload.proxyProfiles, proxyProfiles);
});
test("buildSyncPayload includes AI configuration settings", () => {
const providers = [{
id: "openai-main",
providerId: "openai",
name: "OpenAI",
apiKey: "enc:v1:test",
defaultModel: "gpt-test",
enabled: true,
}];
const webSearch = {
providerId: "tavily",
apiKey: "enc:v1:web",
enabled: true,
maxResults: 7,
};
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify(providers));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_ACTIVE_PROVIDER, "openai-main");
localStorage.setItem(storageKeys.STORAGE_KEY_AI_ACTIVE_MODEL, "gpt-test");
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PERMISSION_MODE, "autonomous");
localStorage.setItem(storageKeys.STORAGE_KEY_AI_TOOL_INTEGRATION_MODE, "skills");
localStorage.setItem(storageKeys.STORAGE_KEY_AI_DEFAULT_AGENT, "codex");
localStorage.setItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST, JSON.stringify(["rm -rf"]));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_COMMAND_TIMEOUT, "120");
localStorage.setItem(storageKeys.STORAGE_KEY_AI_MAX_ITERATIONS, "10");
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({ codex: "gpt-test" }));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify(webSearch));
const payload = buildSyncPayload(vault([]));
assert.deepEqual(payload.settings?.ai, {
providers,
activeProviderId: "openai-main",
activeModelId: "gpt-test",
globalPermissionMode: "autonomous",
toolIntegrationMode: "skills",
defaultAgentId: "codex",
commandBlocklist: ["rm -rf"],
commandTimeout: 120,
maxIterations: 10,
agentModelMap: { codex: "gpt-test" },
webSearchConfig: webSearch,
});
});
test("buildSyncPayload excludes externalAgents (device-local OS-bound config)", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_AGENTS, JSON.stringify([
{ id: "codex", name: "Codex", command: "/opt/homebrew/bin/codex", enabled: true },
]));
const payload = buildSyncPayload(vault([]));
assert.equal("ai" in (payload.settings ?? {}), false);
});
test("buildSyncPayload omits device-bound encrypted AI API keys", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{
id: "openai-main",
providerId: "openai",
name: "OpenAI",
apiKey: "enc:v1:djEwAAAA",
enabled: true,
}]));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
providerId: "tavily",
apiKey: "enc:v1:djEwAAAA",
enabled: true,
}));
const payload = buildSyncPayload(vault([]));
assert.equal("apiKey" in (payload.settings?.ai?.providers?.[0] ?? {}), false);
assert.equal("apiKey" in (payload.settings?.ai?.webSearchConfig ?? {}), false);
});
test("applySyncPayload restores AI configuration settings", async () => {
const providers = [{
id: "anthropic-main",
providerId: "anthropic",
name: "Anthropic",
apiKey: "enc:v1:test",
enabled: true,
}];
const webSearch = {
providerId: "exa",
apiKey: "enc:v1:web",
enabled: true,
};
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
ai: {
providers,
activeProviderId: "anthropic-main",
activeModelId: "claude-test",
globalPermissionMode: "observer",
toolIntegrationMode: "mcp",
defaultAgentId: "claude",
commandBlocklist: ["shutdown"],
commandTimeout: 30,
maxIterations: 5,
agentModelMap: { claude: "claude-test" },
webSearchConfig: webSearch,
},
},
syncedAt: 1,
} as SyncPayload;
await applySyncPayload(payload, { importVaultData: () => {} });
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!), providers);
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_ACTIVE_PROVIDER), "anthropic-main");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_ACTIVE_MODEL), "claude-test");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PERMISSION_MODE), "observer");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_TOOL_INTEGRATION_MODE), "mcp");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_DEFAULT_AGENT), "claude");
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST)!), ["shutdown"]);
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_TIMEOUT), "30");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_MAX_ITERATIONS), "5");
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP)!), { claude: "claude-test" });
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!), webSearch);
});
test("applySyncPayload preserves local externalAgents and ignores legacy payload field", async () => {
const localAgents = [
{ id: "codex", name: "Codex", command: "/usr/local/bin/codex", enabled: true },
];
localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_AGENTS, JSON.stringify(localAgents));
const payload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
ai: {
// Legacy snapshot still carries externalAgents; current code must ignore it.
externalAgents: [
{ id: "claude", name: "Claude", command: "C:\\Tools\\claude.exe", enabled: true },
],
},
},
syncedAt: 1,
} as unknown as SyncPayload;
await applySyncPayload(payload, { importVaultData: () => {} });
assert.deepEqual(
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_AGENTS)!),
localAgents,
);
});
test("applySyncPayload preserves local AI provider apiKeys when synced payload omits them", async () => {
const localProviders = [
{
id: "openai-main",
providerId: "openai",
name: "OpenAI",
apiKey: "enc:v1:djEwLOCAL",
enabled: true,
},
{
id: "anthropic-main",
providerId: "anthropic",
name: "Anthropic",
apiKey: "enc:v1:djEwANTHROPIC",
enabled: true,
},
];
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify(localProviders));
// Synced payload mirrors what `collectSyncableSettings` produces on another device:
// metadata is preserved but encrypted device-bound apiKeys are stripped.
const syncedProviders = [
{ id: "openai-main", providerId: "openai", name: "OpenAI (renamed)", enabled: true },
{ id: "anthropic-main", providerId: "anthropic", name: "Anthropic", enabled: false },
];
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: { ai: { providers: syncedProviders } },
syncedAt: 1,
} as SyncPayload;
await applySyncPayload(payload, { importVaultData: () => {} });
const stored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!);
assert.deepEqual(stored, [
{
id: "openai-main",
providerId: "openai",
name: "OpenAI (renamed)",
apiKey: "enc:v1:djEwLOCAL",
enabled: true,
},
{
id: "anthropic-main",
providerId: "anthropic",
name: "Anthropic",
apiKey: "enc:v1:djEwANTHROPIC",
enabled: false,
},
]);
});
test("applySyncPayload prefers explicit synced apiKey over local apiKey", async () => {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([
{ id: "openai-main", providerId: "openai", name: "OpenAI", apiKey: "enc:v1:djEwLOCAL", enabled: true },
]));
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
ai: {
providers: [
{ id: "openai-main", providerId: "openai", name: "OpenAI", apiKey: "plaintext-from-other-device", enabled: true },
],
},
},
syncedAt: 1,
} as SyncPayload;
await applySyncPayload(payload, { importVaultData: () => {} });
const stored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!);
assert.equal(stored[0].apiKey, "plaintext-from-other-device");
});
test("applySyncPayload preserves local web-search apiKey when synced config omits it", async () => {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
providerId: "tavily",
apiKey: "enc:v1:djEwWEB",
enabled: true,
maxResults: 7,
}));
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
ai: {
webSearchConfig: { providerId: "tavily", enabled: false, maxResults: 12 },
},
},
syncedAt: 1,
} as SyncPayload;
await applySyncPayload(payload, { importVaultData: () => {} });
const stored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!);
assert.deepEqual(stored, {
providerId: "tavily",
apiKey: "enc:v1:djEwWEB",
enabled: false,
maxResults: 12,
});
});
test("applySyncPayload drops local web-search apiKey when synced config switches provider", async () => {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
providerId: "tavily",
apiKey: "enc:v1:djEwWEB",
enabled: true,
}));
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
ai: {
webSearchConfig: { providerId: "exa", enabled: true },
},
},
syncedAt: 1,
} as SyncPayload;
await applySyncPayload(payload, { importVaultData: () => {} });
const stored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!);
assert.equal("apiKey" in stored, false);
assert.equal(stored.providerId, "exa");
});
test("buildSyncPayload includes syncable terminal options from settings", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_FOLLOW_APP_THEME, "true");
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_SETTINGS, JSON.stringify({
terminalEmulationType: "vt100",
altAsMeta: true,
showServerStats: false,
serverStatsRefreshInterval: 12,
rendererType: "dom",
localShell: "/bin/zsh",
}));
const payload = buildSyncPayload(vault([]));
assert.equal(payload.settings?.followAppTerminalTheme, true);
assert.deepEqual(payload.settings?.terminalSettings, {
terminalEmulationType: "vt100",
altAsMeta: true,
showServerStats: false,
serverStatsRefreshInterval: 12,
rendererType: "dom",
});
});
test("hasMeaningfulCloudSyncData ignores legacy cloud known hosts", () => {
assert.equal(
hasMeaningfulCloudSyncData({
@@ -94,8 +442,17 @@ test("buildLocalVaultPayload preserves known hosts for local backups", () => {
assert.deepEqual(payload.knownHosts, [knownHost("kh-local")]);
});
test("applySyncPayload ignores legacy cloud known hosts", () => {
test("applySyncPayload ignores legacy cloud known hosts", async () => {
let imported: Record<string, unknown> | null = null;
const proxyProfiles = [
{
id: "proxy-1",
label: "Office Proxy",
config: { type: "socks5", host: "proxy.example.com", port: 1080 },
createdAt: 1,
updatedAt: 1,
},
];
const payload: SyncPayload = {
hosts: [],
keys: [],
@@ -103,10 +460,11 @@ test("applySyncPayload ignores legacy cloud known hosts", () => {
snippets: [],
customGroups: [],
knownHosts: [knownHost("kh-legacy")],
proxyProfiles,
syncedAt: 1,
};
} as SyncPayload & { proxyProfiles: typeof proxyProfiles };
applySyncPayload(payload, {
await applySyncPayload(payload, {
importVaultData: (json) => {
imported = JSON.parse(json);
},
@@ -114,9 +472,165 @@ test("applySyncPayload ignores legacy cloud known hosts", () => {
assert.ok(imported);
assert.equal("knownHosts" in imported, false);
assert.deepEqual(imported.proxyProfiles, proxyProfiles);
});
test("applyLocalVaultPayload restores known hosts from local backups", () => {
test("applySyncPayload keeps missing proxy references visible to connection guards", async () => {
let imported: Record<string, unknown> | null = null;
const payload: SyncPayload = {
hosts: [{
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
proxyProfileId: "missing-proxy",
}],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
customGroups: [],
groupConfigs: [{ path: "prod", proxyProfileId: "missing-proxy" }],
syncedAt: 1,
};
await applySyncPayload(payload, {
importVaultData: (json) => {
imported = JSON.parse(json);
},
});
assert.ok(imported);
assert.equal((imported.hosts as SyncPayload["hosts"])[0]?.proxyProfileId, "missing-proxy");
assert.equal((imported.groupConfigs as SyncPayload["groupConfigs"])?.[0]?.proxyProfileId, "missing-proxy");
});
test("applySyncPayload preserves host proxy references when group configs are absent", async () => {
let imported: Record<string, unknown> | null = null;
const payload: SyncPayload = {
hosts: [{
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
proxyProfileId: "missing-proxy",
}],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
customGroups: [],
syncedAt: 1,
};
await applySyncPayload(payload, {
importVaultData: (json) => {
imported = JSON.parse(json);
},
});
assert.ok(imported);
assert.equal((imported.hosts as SyncPayload["hosts"])[0]?.proxyProfileId, "missing-proxy");
assert.equal("groupConfigs" in imported, false);
});
test("applySyncPayload waits for async vault imports", async () => {
let finished = false;
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
};
const promise = applySyncPayload(payload, {
importVaultData: async () => {
await new Promise((resolve) => setTimeout(resolve, 1));
finished = true;
},
});
assert.equal(finished, false);
await promise;
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 = {
hosts: [],
@@ -128,7 +642,7 @@ test("applyLocalVaultPayload restores known hosts from local backups", () => {
syncedAt: 1,
};
applyLocalVaultPayload(payload, {
await applyLocalVaultPayload(payload, {
importVaultData: (json) => {
imported = JSON.parse(json);
},

View File

@@ -13,6 +13,7 @@ import type {
Identity,
KnownHost,
PortForwardingRule,
ProxyProfile,
SftpBookmark,
Snippet,
SSHKey,
@@ -23,6 +24,7 @@ import {
parseCustomKeyBindingsStorageRecord,
serializeCustomKeyBindingsStorageRecord,
} from '../domain/customKeyBindings';
import { isEncryptedCredentialPlaceholder } from '../domain/credentials';
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
import { rehydrateGlobalBookmarks } from '../components/sftp/hooks/useGlobalSftpBookmarks';
import {
@@ -35,6 +37,7 @@ import {
STORAGE_KEY_UI_LANGUAGE,
STORAGE_KEY_CUSTOM_CSS,
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
STORAGE_KEY_TERM_SETTINGS,
@@ -45,11 +48,25 @@ import {
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
STORAGE_KEY_CUSTOM_THEMES,
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_AI_PROVIDERS,
STORAGE_KEY_AI_ACTIVE_PROVIDER,
STORAGE_KEY_AI_ACTIVE_MODEL,
STORAGE_KEY_AI_PERMISSION_MODE,
STORAGE_KEY_AI_TOOL_INTEGRATION_MODE,
STORAGE_KEY_AI_HOST_PERMISSIONS,
STORAGE_KEY_AI_DEFAULT_AGENT,
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
STORAGE_KEY_AI_COMMAND_TIMEOUT,
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
} from '../infrastructure/config/storageKeys';
// ---------------------------------------------------------------------------
@@ -63,6 +80,7 @@ export interface SyncableVaultData {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
proxyProfiles?: ProxyProfile[];
snippets: Snippet[];
customGroups: string[];
snippetPackages?: string[];
@@ -81,6 +99,7 @@ export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
(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 ||
@@ -104,6 +123,7 @@ export function hasMeaningfulCloudSyncData(payload: SyncPayload): boolean {
(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 ||
@@ -119,7 +139,7 @@ export function hasMeaningfulCloudSyncData(payload: SyncPayload): boolean {
/** Callbacks used by `applySyncPayload` to import data into local state. */
interface SyncPayloadImporters {
/** Import vault data. Cloud sync excludes local-only known hosts by default. */
importVaultData: (jsonString: string) => void;
importVaultData: (jsonString: string) => void | Promise<void>;
/** Import port-forwarding rules (lives outside the vault hook). */
importPortForwardingRules?: (rules: PortForwardingRule[]) => void;
/** Called after synced settings have been written to localStorage. */
@@ -132,18 +152,123 @@ interface SyncPayloadImporters {
/** Terminal settings keys that are safe to sync (platform-agnostic). */
const SYNCABLE_TERMINAL_KEYS = [
'scrollback', 'drawBoldInBrightColors', 'fontLigatures', 'fontWeight', 'fontWeightBold',
'scrollback', 'drawBoldInBrightColors', 'terminalEmulationType',
'fontLigatures', 'fontWeight', 'fontWeightBold', 'fallbackFont',
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'altAsMeta', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'smoothScrolling',
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'disableBracketedPaste', 'clearWipesScrollback',
'preserveSelectionOnInput', 'osc52Clipboard',
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
'preserveSelectionOnInput', 'osc52Clipboard', 'showServerStats',
'serverStatsRefreshInterval', 'rendererType',
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
] as const;
export const SYNCABLE_SETTING_STORAGE_KEYS = [
STORAGE_KEY_THEME,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_ACCENT_MODE,
STORAGE_KEY_COLOR,
STORAGE_KEY_UI_FONT_FAMILY,
STORAGE_KEY_UI_LANGUAGE,
STORAGE_KEY_CUSTOM_CSS,
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_CUSTOM_THEMES,
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
STORAGE_KEY_EDITOR_WORD_WRAP,
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
STORAGE_KEY_SFTP_AUTO_SYNC,
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_AI_PROVIDERS,
STORAGE_KEY_AI_ACTIVE_PROVIDER,
STORAGE_KEY_AI_ACTIVE_MODEL,
STORAGE_KEY_AI_PERMISSION_MODE,
STORAGE_KEY_AI_TOOL_INTEGRATION_MODE,
STORAGE_KEY_AI_HOST_PERMISSIONS,
STORAGE_KEY_AI_DEFAULT_AGENT,
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
STORAGE_KEY_AI_COMMAND_TIMEOUT,
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
] as const;
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const readArraySetting = <T = Record<string, unknown>>(key: string): T[] | null => {
const value = localStorageAdapter.read<T[]>(key);
return Array.isArray(value) ? value : null;
};
const readRecordSetting = <T extends Record<string, unknown> = Record<string, unknown>>(key: string): T | null => {
const value = localStorageAdapter.read<T>(key);
return isRecord(value) ? value as T : null;
};
const stripDeviceBoundApiKey = <T extends Record<string, unknown>>(value: T): T => {
if (!isEncryptedCredentialPlaceholder(value.apiKey as string | undefined)) return value;
const next = { ...value };
delete next.apiKey;
return next;
};
/**
* `collectSyncableSettings` strips device-bound encrypted apiKeys before upload,
* so an incoming providers array typically has no apiKey for providers that
* already exist locally. Re-attach the local apiKey by id; without this merge,
* applying any synced settings change would silently wipe credentials on the
* receiving device.
*/
const mergeAiProvidersPreservingLocalApiKeys = (
incoming: Array<Record<string, unknown>>,
): Array<Record<string, unknown>> => {
const local = readArraySetting(STORAGE_KEY_AI_PROVIDERS) ?? [];
const localById = new Map<string, Record<string, unknown>>();
for (const provider of local) {
if (typeof provider?.id === 'string') localById.set(provider.id, provider);
}
return incoming.map((provider) => {
if (provider.apiKey != null) return provider;
const id = typeof provider.id === 'string' ? provider.id : undefined;
const localProvider = id != null ? localById.get(id) : undefined;
if (localProvider && typeof localProvider.apiKey === 'string') {
return { ...provider, apiKey: localProvider.apiKey };
}
return provider;
});
};
/**
* Same rationale as `mergeAiProvidersPreservingLocalApiKeys`. Only restores the
* local apiKey when the incoming config still points at the same providerId —
* switching providers must not silently leak a key meant for a different one.
*/
const mergeWebSearchConfigPreservingLocalApiKey = (
incoming: Record<string, unknown>,
): Record<string, unknown> => {
if (incoming.apiKey != null) return incoming;
const local = readRecordSetting(STORAGE_KEY_AI_WEB_SEARCH);
if (!local || typeof local.apiKey !== 'string') return incoming;
if (local.providerId !== incoming.providerId) return incoming;
return { ...incoming, apiKey: local.apiKey };
};
/**
* Collect all syncable settings from localStorage.
*/
@@ -171,6 +296,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
// Terminal
const termTheme = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
if (termTheme) settings.terminalTheme = termTheme;
const followAppTermTheme = localStorageAdapter.readString(STORAGE_KEY_TERM_FOLLOW_APP_THEME);
if (followAppTermTheme === 'true' || followAppTermTheme === 'false') {
settings.followAppTerminalTheme = followAppTermTheme === 'true';
}
const termFont = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
if (termFont) settings.terminalFontFamily = termFont;
const termSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
@@ -220,6 +349,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (compress === 'true' || compress === 'false') settings.sftpUseCompressedUpload = compress === 'true';
const autoOpenSidebar = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
if (autoOpenSidebar === 'true' || autoOpenSidebar === 'false') settings.sftpAutoOpenSidebar = autoOpenSidebar === 'true';
const defaultViewMode = localStorageAdapter.readString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
if (defaultViewMode === 'list' || defaultViewMode === 'tree') settings.sftpDefaultViewMode = defaultViewMode;
// SFTP Bookmarks (global only — local bookmarks are device-specific)
const globalBookmarks = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS);
@@ -232,6 +363,42 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (showOnlyUngroupedHostsInRoot != null) settings.showOnlyUngroupedHostsInRoot = showOnlyUngroupedHostsInRoot;
const showSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
const workspaceFocusStyle = localStorageAdapter.readString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
if (workspaceFocusStyle === 'dim' || workspaceFocusStyle === 'border') {
settings.workspaceFocusStyle = workspaceFocusStyle;
}
const ai: NonNullable<SyncPayload['settings']>['ai'] = {};
const providers = readArraySetting(STORAGE_KEY_AI_PROVIDERS);
if (providers) ai.providers = providers.map(stripDeviceBoundApiKey);
const activeProviderId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER);
if (activeProviderId != null) ai.activeProviderId = activeProviderId;
const activeModelId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL);
if (activeModelId != null) ai.activeModelId = activeModelId;
const permissionMode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
if (permissionMode === 'observer' || permissionMode === 'confirm' || permissionMode === 'autonomous') {
ai.globalPermissionMode = permissionMode;
}
const toolIntegrationMode = localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE);
if (toolIntegrationMode === 'mcp' || toolIntegrationMode === 'skills') {
ai.toolIntegrationMode = toolIntegrationMode;
}
const hostPermissions = readArraySetting(STORAGE_KEY_AI_HOST_PERMISSIONS);
if (hostPermissions) ai.hostPermissions = hostPermissions;
// externalAgents intentionally not collected: command/args/env are device-local.
const defaultAgentId = localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT);
if (defaultAgentId != null) ai.defaultAgentId = defaultAgentId;
const commandBlocklist = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
if (Array.isArray(commandBlocklist)) ai.commandBlocklist = commandBlocklist;
const commandTimeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT);
if (commandTimeout != null && Number.isFinite(commandTimeout)) ai.commandTimeout = commandTimeout;
const maxIterations = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS);
if (maxIterations != null && Number.isFinite(maxIterations)) ai.maxIterations = maxIterations;
const agentModelMap = readRecordSetting<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP);
if (agentModelMap) ai.agentModelMap = agentModelMap;
const webSearchConfig = readRecordSetting(STORAGE_KEY_AI_WEB_SEARCH);
if (webSearchConfig) ai.webSearchConfig = stripDeviceBoundApiKey(webSearchConfig);
if (Object.keys(ai).length > 0) settings.ai = ai;
return Object.keys(settings).length > 0 ? settings : undefined;
}
@@ -253,6 +420,9 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
// Terminal
if (settings.terminalTheme != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, settings.terminalTheme);
if (settings.followAppTerminalTheme != null) {
localStorageAdapter.writeString(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(settings.followAppTerminalTheme));
}
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));
@@ -301,6 +471,9 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
if (settings.sftpShowHiddenFiles != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, String(settings.sftpShowHiddenFiles));
if (settings.sftpUseCompressedUpload != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, String(settings.sftpUseCompressedUpload));
if (settings.sftpAutoOpenSidebar != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, String(settings.sftpAutoOpenSidebar));
if (settings.sftpDefaultViewMode != null) {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE, settings.sftpDefaultViewMode);
}
// SFTP Bookmarks (global only)
if (settings.sftpGlobalBookmarks != null) localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, settings.sftpGlobalBookmarks);
@@ -316,6 +489,41 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
if (settings.showSftpTab != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
}
if (settings.workspaceFocusStyle != null) {
localStorageAdapter.writeString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, settings.workspaceFocusStyle);
}
const ai = settings.ai;
if (ai) {
if (ai.providers != null) {
localStorageAdapter.write(
STORAGE_KEY_AI_PROVIDERS,
mergeAiProvidersPreservingLocalApiKeys(ai.providers),
);
}
if (ai.activeProviderId != null) localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, ai.activeProviderId);
if (ai.activeModelId != null) localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_MODEL, ai.activeModelId);
if (ai.globalPermissionMode != null) localStorageAdapter.writeString(STORAGE_KEY_AI_PERMISSION_MODE, ai.globalPermissionMode);
if (ai.toolIntegrationMode != null) localStorageAdapter.writeString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE, ai.toolIntegrationMode);
if (ai.hostPermissions != null) localStorageAdapter.write(STORAGE_KEY_AI_HOST_PERMISSIONS, ai.hostPermissions);
// externalAgents intentionally not applied: device-local. Legacy snapshots
// that still carry an `externalAgents` field are silently ignored.
if (ai.defaultAgentId != null) localStorageAdapter.writeString(STORAGE_KEY_AI_DEFAULT_AGENT, ai.defaultAgentId);
if (ai.commandBlocklist != null) localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, ai.commandBlocklist);
if (ai.commandTimeout != null) localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, ai.commandTimeout);
if (ai.maxIterations != null) localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, ai.maxIterations);
if (ai.agentModelMap != null) localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, ai.agentModelMap);
if (ai.webSearchConfig !== undefined) {
if (ai.webSearchConfig === null) {
localStorageAdapter.remove(STORAGE_KEY_AI_WEB_SEARCH);
} else {
localStorageAdapter.write(
STORAGE_KEY_AI_WEB_SEARCH,
mergeWebSearchConfigPreservingLocalApiKey(ai.webSearchConfig),
);
}
}
}
}
// ---------------------------------------------------------------------------
@@ -337,6 +545,7 @@ export function buildSyncPayload(
hosts: vault.hosts,
keys: vault.keys,
identities: vault.identities,
proxyProfiles: vault.proxyProfiles,
snippets: vault.snippets,
customGroups: vault.customGroups,
snippetPackages: vault.snippetPackages,
@@ -368,13 +577,14 @@ function applyPayload(
payload: SyncPayload,
importers: SyncPayloadImporters,
options: { includeLocalOnlyData: boolean },
): void {
): Promise<void> {
// Build the vault import object. Cloud sync intentionally ignores
// local-only trust records even if legacy cloud snapshots still carry them.
const vaultImport: Record<string, unknown> = {
hosts: payload.hosts,
keys: payload.keys,
identities: payload.identities,
proxyProfiles: payload.proxyProfiles,
snippets: payload.snippets,
customGroups: payload.customGroups,
};
@@ -388,35 +598,35 @@ function applyPayload(
vaultImport.groupConfigs = payload.groupConfigs;
}
importers.importVaultData(JSON.stringify(vaultImport));
return Promise.resolve(importers.importVaultData(JSON.stringify(vaultImport))).then(() => {
// Only import port-forwarding rules when the payload explicitly carries
// them. Absent field = "payload was created before this feature existed",
// so local rules are preserved. Explicitly present [] = "remote has no
// rules, clear local state".
if (payload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
importers.importPortForwardingRules(payload.portForwardingRules);
}
// Only import port-forwarding rules when the payload explicitly carries
// them. Absent field = "payload was created before this feature existed",
// so local rules are preserved. Explicitly present [] = "remote has no
// rules, clear local state".
if (payload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
importers.importPortForwardingRules(payload.portForwardingRules);
}
// Apply synced settings
if (payload.settings) {
applySyncableSettings(payload.settings);
// Rehydrate in-memory bookmark snapshot after localStorage was updated
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalBookmarks();
importers.onSettingsApplied?.();
}
// Apply synced settings
if (payload.settings) {
applySyncableSettings(payload.settings);
// Rehydrate in-memory bookmark snapshot after localStorage was updated
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalBookmarks();
importers.onSettingsApplied?.();
}
});
}
export function applySyncPayload(
payload: SyncPayload,
importers: SyncPayloadImporters,
): void {
applyPayload(payload, importers, { includeLocalOnlyData: false });
): Promise<void> {
return applyPayload(payload, importers, { includeLocalOnlyData: false });
}
export function applyLocalVaultPayload(
payload: SyncPayload,
importers: SyncPayloadImporters,
): void {
applyPayload(payload, importers, { includeLocalOnlyData: true });
): Promise<void> {
return applyPayload(payload, importers, { includeLocalOnlyData: true });
}

View File

@@ -22,6 +22,7 @@ import React, { useCallback, useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { customThemeStore } from "../application/state/customThemeStore";
import { resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/groupConfig";
import { isCompleteProxyConfig, normalizeManualProxyConfig } from "../domain/proxyProfiles";
import { cn } from "../lib/utils";
import {
EnvVar,
@@ -29,6 +30,7 @@ import {
Host,
Identity,
ProxyConfig,
ProxyProfile,
SSHKey,
} from "../types";
import ThemeSelectPanel from "./ThemeSelectPanel";
@@ -51,6 +53,7 @@ import { Input } from "./ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { TerminalFontSelect } from "./settings/TerminalFontSelect";
import { useAvailableFonts } from "../application/state/fontStore";
import { toast } from "./ui/toast";
type SubPanel = "none" | "proxy" | "chain" | "env-vars" | "theme-select";
@@ -59,6 +62,7 @@ interface GroupDetailsPanelProps {
config: GroupConfig | undefined;
availableKeys: SSHKey[];
identities: Identity[];
proxyProfiles?: ProxyProfile[];
allHosts: Host[];
groups: string[];
terminalThemeId: string;
@@ -74,6 +78,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
config,
availableKeys,
identities: _identities,
proxyProfiles = [],
allHosts,
groups,
terminalThemeId,
@@ -105,7 +110,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
c.protocol === 'ssh' ||
c.port !== undefined || !!c.username || !!c.password || !!c.identityFileId ||
c.agentForwarding !== undefined || c.authMethod !== undefined || !!c.identityId ||
!!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.legacyAlgorithms !== undefined || c.backspaceBehavior !== undefined ||
!!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.legacyAlgorithms !== undefined || c.backspaceBehavior !== undefined ||
(c.environmentVariables && c.environmentVariables.length > 0) ||
c.moshEnabled !== undefined || !!c.moshServerPath ||
(c.identityFilePaths && c.identityFilePaths.length > 0);
@@ -132,6 +137,16 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
// Environment variables state
const [newEnvName, setNewEnvName] = useState("");
const [newEnvValue, setNewEnvValue] = useState("");
const selectedProxyProfile = useMemo(
() => proxyProfiles.find((profile) => profile.id === form.proxyProfileId),
[form.proxyProfileId, proxyProfiles],
);
const hasMissingProxyProfile = Boolean(form.proxyProfileId && !selectedProxyProfile);
const proxySummaryLabel = hasMissingProxyProfile
? t("hostDetails.proxyPanel.missingSaved")
: selectedProxyProfile
? selectedProxyProfile.label
: `${form.proxyConfig?.type?.toUpperCase()} ${form.proxyConfig?.host}:${form.proxyConfig?.port}`;
const update = <K extends keyof GroupConfig>(key: K, value: GroupConfig[K] | undefined) => {
setForm((prev) => ({ ...prev, [key]: value }));
@@ -156,6 +171,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
delete next.startupCommand;
delete next.legacyAlgorithms;
delete next.backspaceBehavior;
delete next.proxyProfileId;
delete next.proxyConfig;
delete next.hostChain;
delete next.environmentVariables;
@@ -182,27 +198,38 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
// Proxy helpers
const updateProxyConfig = useCallback(
(field: keyof ProxyConfig, value: string | number) => {
setForm((prev) => ({
...prev,
proxyConfig: {
type: prev.proxyConfig?.type || "http",
host: prev.proxyConfig?.host || "",
port: prev.proxyConfig?.port || 8080,
...prev.proxyConfig,
[field]: value,
},
}));
setForm((prev) => {
const { proxyProfileId: _proxyProfileId, ...rest } = prev;
return {
...rest,
proxyConfig: {
type: prev.proxyConfig?.type || "http",
host: prev.proxyConfig?.host || "",
port: prev.proxyConfig?.port || 8080,
...prev.proxyConfig,
[field]: value,
},
};
});
},
[],
);
const clearProxyConfig = useCallback(() => {
setForm((prev) => {
const { proxyConfig: _proxyConfig, ...rest } = prev;
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
return rest;
});
}, []);
const selectProxyProfile = useCallback((profileId: string | undefined) => {
setForm((prev) => {
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
if (!profileId) return rest;
return { ...rest, proxyProfileId: profileId };
});
}, []);
// Chain helpers
const chainedHosts = useMemo(() => {
const ids = form.hostChain?.hostIds || [];
@@ -297,6 +324,19 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
setNameError(t("vault.groups.errors.invalidChars"));
return;
}
const normalizedProxyConfig = normalizeManualProxyConfig(form.proxyConfig);
if (normalizedProxyConfig && !isCompleteProxyConfig(normalizedProxyConfig)) {
toast.error(
normalizedProxyConfig.host ? t("proxyProfiles.error.port") : t("hostDetails.proxyPanel.error.required"),
);
setActiveSubPanel("proxy");
return;
}
if (sshEnabled && hasMissingProxyProfile) {
toast.error(t("hostDetails.proxyPanel.missingSaved"));
setActiveSubPanel("proxy");
return;
}
setNameError(null);
const newPath = parentGroup
@@ -320,7 +360,8 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
...(form.startupCommand !== undefined && { startupCommand: form.startupCommand }),
...(form.legacyAlgorithms !== undefined && { legacyAlgorithms: form.legacyAlgorithms }),
...(form.backspaceBehavior !== undefined && { backspaceBehavior: form.backspaceBehavior }),
...(form.proxyConfig !== undefined && { proxyConfig: form.proxyConfig }),
...(form.proxyProfileId !== undefined && { proxyProfileId: form.proxyProfileId }),
...(normalizedProxyConfig !== undefined && { proxyConfig: normalizedProxyConfig }),
...(form.hostChain !== undefined && { hostChain: form.hostChain }),
...(form.environmentVariables !== undefined && { environmentVariables: form.environmentVariables }),
...(form.moshEnabled !== undefined && { moshEnabled: form.moshEnabled }),
@@ -360,7 +401,10 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
return (
<ProxyPanel
proxyConfig={form.proxyConfig}
proxyProfiles={proxyProfiles}
selectedProxyProfileId={form.proxyProfileId}
onUpdateProxy={updateProxyConfig}
onSelectProxyProfile={selectProxyProfile}
onClearProxy={clearProxyConfig}
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
@@ -849,11 +893,16 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
<Globe size={14} className="text-muted-foreground" />
<span className="text-sm">{t("hostDetails.proxy")}</span>
</div>
<div className="flex items-center gap-2">
{form.proxyConfig?.host && (
<Badge variant="secondary" className="text-xs">
{form.proxyConfig.type?.toUpperCase()} {form.proxyConfig.host}:{form.proxyConfig.port}
</Badge>
<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>
)}
<ChevronRight size={14} className="text-muted-foreground" />
</div>

View File

@@ -0,0 +1,239 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import type { Host } from "../types.ts";
import HostDetailsPanel, { parseOptionalPortInput } from "./HostDetailsPanel.tsx";
const hostWithMissingProxyProfile: Host = {
id: "host-1",
label: "DB",
hostname: "db.example.com",
username: "root",
tags: [],
os: "linux",
port: 22,
protocol: "ssh",
authMethod: "password",
proxyProfileId: "missing-proxy",
createdAt: 1,
};
const renderHostDetails = (initialData: Host = hostWithMissingProxyProfile) =>
renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(HostDetailsPanel, {
initialData,
availableKeys: [],
identities: [],
proxyProfiles: [],
groups: [],
managedSources: [],
allTags: [],
allHosts: [],
terminalThemeId: "default",
terminalFontSize: 14,
onSave: () => {},
onCancel: () => {},
}),
),
);
const findInputByValue = (markup: string, value: string) => {
const match = markup.match(new RegExp(`<input(?=[^>]*value="${value}")[^>]*>`));
assert.ok(match, `expected input with value ${value}`);
return match[0];
};
const classTokens = (markup: string) => {
const classMatch = markup.match(/class="([^"]*)"/);
assert.ok(classMatch, "expected class attribute");
return new Set(classMatch[1].split(/\s+/).filter(Boolean));
};
test("HostDetailsPanel shows a missing saved proxy without undefined fields", () => {
const markup = renderHostDetails();
assert.match(markup, /Missing saved proxy/);
assert.doesNotMatch(markup, /undefined:undefined/);
});
test("HostDetailsPanel keeps explicitly cleared telnet credentials empty", () => {
const markup = renderHostDetails({
...hostWithMissingProxyProfile,
protocol: "telnet",
telnetEnabled: true,
telnetPort: 23,
username: "root",
password: "ssh-password",
telnetUsername: "",
telnetPassword: "",
proxyProfileId: undefined,
});
assert.match(markup, /placeholder="Telnet Username"[^>]*value=""/);
assert.match(markup, /placeholder="Telnet Password"[^>]*value=""/);
assert.doesNotMatch(markup, /placeholder="Telnet Username"[^>]*value="root"/);
assert.doesNotMatch(markup, /placeholder="Telnet Password"[^>]*value="ssh-password"/);
});
test("HostDetailsPanel gives the telnet port field the same roomy layout as SSH", () => {
const markup = renderHostDetails({
...hostWithMissingProxyProfile,
protocol: "telnet",
telnetEnabled: true,
telnetPort: 2325,
proxyProfileId: undefined,
});
const telnetMarkup = markup.slice(markup.indexOf("Telnet on"));
const wrapperMatch = telnetMarkup.match(/<div class="([^"]*w-1\/2[^"]*)"/);
assert.ok(wrapperMatch, "expected telnet port wrapper");
const wrapperClasses = new Set(wrapperMatch[1].split(/\s+/).filter(Boolean));
assert.ok(wrapperClasses.has("ml-auto"));
assert.ok(wrapperClasses.has("w-1/2"));
assert.ok(wrapperClasses.has("min-w-0"));
assert.ok(wrapperClasses.has("justify-end"));
const telnetPortInput = findInputByValue(markup, "2325");
const inputClasses = classTokens(telnetPortInput);
assert.ok(inputClasses.has("flex-1"));
assert.ok(inputClasses.has("min-w-0"));
assert.ok(inputClasses.has("text-center"));
assert.equal(inputClasses.has("w-16"), false);
});
test("HostDetailsPanel displays inherited telnet port before falling back to 23", () => {
const markup = renderToStaticMarkup(
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: () => {},
}),
),
);
assert.match(findInputByValue(markup, "2325"), /type="number"/);
});
test("HostDetailsPanel uses group telnet port instead of ssh port for optional telnet", () => {
const markup = renderToStaticMarkup(
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: () => {},
}),
),
);
const telnetMarkup = markup.slice(markup.indexOf("Telnet on"));
assert.match(findInputByValue(telnetMarkup, "2325"), /type="number"/);
assert.doesNotMatch(telnetMarkup, /value="2222"/);
});
test("HostDetailsPanel displays inherited telnet credentials", () => {
const markup = renderToStaticMarkup(
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: () => {},
}),
),
);
assert.match(markup, /placeholder="Telnet Username"[^>]*value="group-telnet-user"/);
assert.match(markup, /placeholder="Telnet Password"[^>]*value="group-telnet-password"/);
assert.doesNotMatch(markup, /placeholder="Telnet Username"[^>]*value="ssh-user"/);
assert.doesNotMatch(markup, /placeholder="Telnet Password"[^>]*value="ssh-password"/);
});
test("parseOptionalPortInput clears empty port values", () => {
assert.equal(parseOptionalPortInput(""), undefined);
assert.equal(parseOptionalPortInput("2325"), 2325);
});
test("HostDetailsPanel does not offer to disable telnet when telnet is the primary protocol", () => {
const markup = renderHostDetails({
...hostWithMissingProxyProfile,
protocol: "telnet",
telnetEnabled: true,
telnetPort: 23,
proxyProfileId: undefined,
});
const telnetHeader = markup.match(/Telnet on[\s\S]*?Credentials/);
assert.ok(telnetHeader);
assert.doesNotMatch(telnetHeader[0], /hover:text-destructive/);
});

View File

@@ -8,6 +8,7 @@ import {
FolderPlus,
Forward,
Globe,
HeartPulse,
Key,
KeyRound,
Link2,
@@ -35,8 +36,10 @@ import { resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/gro
import {
getEffectiveHostDistro,
LINUX_DISTRO_OPTIONS,
normalizePrimaryTelnetState,
NETWORK_DEVICE_OPTIONS,
} from "../domain/host";
import { isCompleteProxyConfig, normalizeManualProxyConfig } from "../domain/proxyProfiles";
import { customThemeStore } from "../application/state/customThemeStore";
import {
clearHostFontSizeOverride,
@@ -48,7 +51,7 @@ import {
} from "../domain/terminalAppearance";
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts";
import { cn } from "../lib/utils";
import { EnvVar, GroupConfig, Host, Identity, ManagedSource, ProxyConfig, SSHKey } from "../types";
import { EnvVar, GroupConfig, Host, Identity, ManagedSource, ProxyConfig, ProxyProfile, SSHKey } from "../types";
import { DISTRO_COLORS, DISTRO_LOGOS } from "./DistroAvatar";
import { DistroAvatar } from "./DistroAvatar";
import ThemeSelectPanel from "./ThemeSelectPanel";
@@ -69,6 +72,7 @@ import { Textarea } from "./ui/textarea";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { ScrollArea } from "./ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { toast } from "./ui/toast";
// Import host-details sub-panels
import {
@@ -88,6 +92,44 @@ type SubPanel =
| "theme-select"
| "telnet-theme-select";
export const parseOptionalPortInput = (value: string): number | undefined =>
value ? Number(value) : undefined;
const resolveDetailsTelnetPort = (
host: Host,
groupDefaults?: Partial<GroupConfig>,
): number => {
if (host.telnetPort !== undefined && host.telnetPort !== null) return host.telnetPort;
if (groupDefaults?.telnetPort !== undefined && groupDefaults.telnetPort !== null) {
return groupDefaults.telnetPort;
}
if (host.protocol === "telnet") {
if (host.port !== undefined && host.port !== null) return host.port;
if (groupDefaults?.port !== undefined && groupDefaults.port !== null) return groupDefaults.port;
}
return 23;
};
const resolveDetailsTelnetUsername = (
host: Host,
groupDefaults?: Partial<GroupConfig>,
): string =>
host.telnetUsername !== undefined
? host.telnetUsername
: groupDefaults?.telnetUsername !== undefined
? groupDefaults.telnetUsername
: host.username ?? groupDefaults?.username ?? "";
const resolveDetailsTelnetPassword = (
host: Host,
groupDefaults?: Partial<GroupConfig>,
): string =>
host.telnetPassword !== undefined
? host.telnetPassword
: groupDefaults?.telnetPassword !== undefined
? groupDefaults.telnetPassword
: host.password ?? groupDefaults?.password ?? "";
const LINUX_DISTRO_OPTION_IDS = [
...LINUX_DISTRO_OPTIONS,
...NETWORK_DEVICE_OPTIONS,
@@ -97,6 +139,7 @@ interface HostDetailsPanelProps {
initialData?: Host | null;
availableKeys: SSHKey[];
identities: Identity[];
proxyProfiles?: ProxyProfile[];
groups: string[];
managedSources?: ManagedSource[];
allTags?: string[]; // All available tags for autocomplete
@@ -111,12 +154,14 @@ interface HostDetailsPanelProps {
groupDefaults?: Partial<import('../domain/models').GroupConfig>;
groupConfigs?: GroupConfig[];
layout?: AsidePanelLayout;
onImportKey?: (draft: Partial<SSHKey>) => SSHKey;
}
const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
initialData,
availableKeys,
identities,
proxyProfiles = [],
groups,
managedSources = [],
allTags = [],
@@ -131,12 +176,13 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
groupDefaults,
groupConfigs = [],
layout = "overlay",
onImportKey,
}) => {
const { t } = useI18n();
const { checkSshAgent } = useApplicationBackend();
const [form, setForm] = useState<Host>(
() =>
initialData ||
(initialData ? normalizePrimaryTelnetState(initialData) : null) ||
({
id: crypto.randomUUID(),
label: "",
@@ -170,6 +216,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
// Local key file path input state
const [newKeyFilePath, setNewKeyFilePath] = useState("");
const [pendingReferenceKeyPath, setPendingReferenceKeyPath] = useState<string | null>(null);
// New group creation state
const [newGroupName, setNewGroupName] = useState("");
@@ -196,15 +243,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
useEffect(() => {
if (initialData) {
// Ensure telnetEnabled is set when protocol is telnet
const updatedData = { ...initialData };
if (initialData.protocol === "telnet" && !initialData.telnetEnabled) {
updatedData.telnetEnabled = true;
updatedData.telnetPort =
initialData.telnetPort || initialData.port || 23;
}
setForm(updatedData);
setForm(normalizePrimaryTelnetState(initialData));
setGroupInputValue(initialData.group || "");
setPendingReferenceKeyPath(null);
// Reset password visibility when host changes for privacy
setShowPassword(false);
}
@@ -214,6 +255,20 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
setForm((prev) => ({ ...prev, [key]: value }));
};
const addLocalKeyFilePath = useCallback((path: string) => {
const trimmed = path.trim();
if (!trimmed) return;
setForm((prev) => ({
...prev,
identityFilePaths: onImportKey ? [trimmed] : [...(prev.identityFilePaths || []), trimmed],
identityFileId: undefined,
authMethod: "key",
}));
setPendingReferenceKeyPath(onImportKey ? trimmed : null);
setNewKeyFilePath("");
setSelectedCredentialType(null);
}, [onImportKey]);
const effectiveGroupDefaults = useMemo(() => {
const currentGroupPath = form.group || defaultGroup;
if (currentGroupPath && groupConfigs.length > 0) {
@@ -240,6 +295,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
);
const effectiveTelnetThemeId =
form.protocols?.find((p) => p.protocol === "telnet")?.theme || effectiveThemeId;
const effectiveTelnetPort = resolveDetailsTelnetPort(form, effectiveGroupDefaults);
const effectiveTelnetUsername = resolveDetailsTelnetUsername(form, effectiveGroupDefaults);
const effectiveTelnetPassword = resolveDetailsTelnetPassword(form, effectiveGroupDefaults);
const distroOptions = useMemo(
() =>
LINUX_DISTRO_OPTION_IDS.map((value) => ({
@@ -260,6 +318,24 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
);
const effectiveFormDistro = getEffectiveHostDistro(form);
const selectedProxyProfile = useMemo(
() => proxyProfiles.find((profile) => profile.id === form.proxyProfileId),
[form.proxyProfileId, proxyProfiles],
);
const hasMissingProxyProfile = Boolean(form.proxyProfileId && !selectedProxyProfile);
const proxySummaryType = hasMissingProxyProfile
? t("hostDetails.proxyPanel.missing")
: (selectedProxyProfile?.config.type || form.proxyConfig?.type || "http").toUpperCase();
const proxySummaryLabel = hasMissingProxyProfile
? t("hostDetails.proxyPanel.missingSaved")
: selectedProxyProfile
? selectedProxyProfile.label
: `${form.proxyConfig?.host}:${form.proxyConfig?.port}`;
const proxySummaryTooltip = hasMissingProxyProfile
? t("hostDetails.proxyPanel.missingSaved")
: selectedProxyProfile
? `${selectedProxyProfile.label} - ${selectedProxyProfile.config.host}:${selectedProxyProfile.config.port}`
: `${form.proxyConfig?.type?.toUpperCase()} ${form.proxyConfig?.host}:${form.proxyConfig?.port}`;
const handleDistroModeChange = useCallback((mode: "auto" | "manual") => {
setForm((prev) => ({
@@ -274,27 +350,38 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
const updateProxyConfig = useCallback(
(field: keyof ProxyConfig, value: string | number) => {
setForm((prev) => ({
...prev,
proxyConfig: {
type: prev.proxyConfig?.type || "http",
host: prev.proxyConfig?.host || "",
port: prev.proxyConfig?.port || 8080,
...prev.proxyConfig,
[field]: value,
},
}));
setForm((prev) => {
const { proxyProfileId: _proxyProfileId, ...rest } = prev;
return {
...rest,
proxyConfig: {
type: prev.proxyConfig?.type || "http",
host: prev.proxyConfig?.host || "",
port: prev.proxyConfig?.port || 8080,
...prev.proxyConfig,
[field]: value,
},
} as Host;
});
},
[],
);
const clearProxyConfig = useCallback(() => {
setForm((prev) => {
const { proxyConfig: _proxyConfig, ...rest } = prev;
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
return rest as Host;
});
}, []);
const selectProxyProfile = useCallback((profileId: string | undefined) => {
setForm((prev) => {
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
if (!profileId) return rest as Host;
return { ...rest, proxyProfileId: profileId } as Host;
});
}, []);
const addHostToChain = (hostId: string) => {
setForm((prev) => ({
...prev,
@@ -342,6 +429,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
const handleSubmit = () => {
if (!form.hostname) return;
const normalizedProxyConfig = normalizeManualProxyConfig(form.proxyConfig);
if (normalizedProxyConfig && !isCompleteProxyConfig(normalizedProxyConfig)) {
toast.error(
normalizedProxyConfig.host ? t("proxyProfiles.error.port") : t("hostDetails.proxyPanel.error.required"),
);
setActiveSubPanel("proxy");
return;
}
if (hasMissingProxyProfile) {
toast.error(t("hostDetails.proxyPanel.missingSaved"));
setActiveSubPanel("proxy");
return;
}
// If label is empty, use hostname as label
let finalLabel = form.label?.trim() || form.hostname;
const finalGroup = groupInputValue.trim() || form.group || "";
@@ -377,16 +477,43 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
finalManagedSourceId = undefined;
}
const cleaned: Host = {
...form,
const { proxyConfig: _draftProxyConfig, ...formWithoutProxyDraft } = form;
const finalPort =
form.protocol === "telnet"
? form.port
: form.port ?? (groupDefaults?.port ? undefined : 22);
let cleaned: Host = {
...formWithoutProxyDraft,
...(normalizedProxyConfig && { proxyConfig: normalizedProxyConfig }),
label: finalLabel,
group: finalGroup,
tags: form.tags || [],
port: form.port ?? (groupDefaults?.port ? undefined : 22),
port: finalPort,
// Clear password if savePassword is explicitly set to false
password: form.savePassword === false ? undefined : form.password,
managedSourceId: finalManagedSourceId,
};
cleaned = normalizePrimaryTelnetState(cleaned);
if (
onImportKey &&
pendingReferenceKeyPath &&
cleaned.identityFilePaths?.includes(pendingReferenceKeyPath)
) {
const fileName = pendingReferenceKeyPath.split('/').pop() || pendingReferenceKeyPath;
const key = onImportKey({
source: 'reference',
filePath: pendingReferenceKeyPath,
label: fileName,
privateKey: '',
category: 'key',
});
cleaned = {
...cleaned,
identityFileId: key.id,
identityFilePaths: [pendingReferenceKeyPath],
authMethod: "key",
};
}
const preserveLegacyTheme = initialData?.theme != null && cleaned.themeOverride !== false;
const preserveLegacyFontFamily = initialData?.fontFamily != null && cleaned.fontFamilyOverride !== false;
const preserveLegacyFontSize = initialData?.fontSize != null && cleaned.fontSizeOverride !== false;
@@ -503,6 +630,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
identityFileId: undefined,
identityFilePaths: undefined,
}));
setPendingReferenceKeyPath(null);
setSelectedCredentialType(null);
setCredentialPopoverOpen(false);
setIdentitySuggestionsOpen(false);
@@ -536,7 +664,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
return (
<ProxyPanel
proxyConfig={form.proxyConfig}
proxyProfiles={proxyProfiles}
selectedProxyProfileId={form.proxyProfileId}
onUpdateProxy={updateProxyConfig}
onSelectProxyProfile={selectProxyProfile}
onClearProxy={clearProxyConfig}
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
@@ -636,7 +767,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
...(form.protocols || []),
{
protocol: "telnet" as const,
port: form.telnetPort || 23,
port: effectiveTelnetPort,
enabled: true,
theme: themeId,
},
@@ -1032,6 +1163,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onClick={() => {
const paths = form.identityFilePaths?.filter((_, i) => i !== idx) || [];
update("identityFilePaths", paths.length > 0 ? paths : undefined);
if (keyPath === pendingReferenceKeyPath) {
setPendingReferenceKeyPath(null);
}
}}
>
<Trash2 size={12} />
@@ -1060,6 +1194,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onClick={() => {
update("identityFileId", undefined);
update("authMethod", "password");
setPendingReferenceKeyPath(null);
setSelectedCredentialType(null);
}}
>
@@ -1154,6 +1289,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
update("identityFileId", val);
update("authMethod", "key");
update("identityFilePaths", undefined);
setPendingReferenceKeyPath(null);
setSelectedCredentialType(null);
}}
placeholder={t("hostDetails.keys.search")}
@@ -1190,6 +1326,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
update("identityFileId", val);
update("authMethod", "certificate");
update("identityFilePaths", undefined);
setPendingReferenceKeyPath(null);
setSelectedCredentialType(null);
}}
placeholder={t("hostDetails.certs.search")}
@@ -1225,11 +1362,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onKeyDown={(e) => {
if (e.key === "Enter" && newKeyFilePath.trim()) {
e.preventDefault();
const paths = [...(form.identityFilePaths || []), newKeyFilePath.trim()];
update("identityFilePaths", paths);
update("identityFileId", undefined);
update("authMethod", "key");
setNewKeyFilePath("");
addLocalKeyFilePath(newKeyFilePath);
}
}}
/>
@@ -1247,10 +1380,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
[{ name: "All Files", extensions: ["*"] }]
);
if (filePath) {
const paths = [...(form.identityFilePaths || []), filePath];
update("identityFilePaths", paths);
update("identityFileId", undefined);
update("authMethod", "key");
addLocalKeyFilePath(filePath);
}
}}
>
@@ -1677,6 +1807,72 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</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">
@@ -1758,35 +1954,40 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<Globe size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.proxy")}</p>
</div>
{form.proxyConfig?.host ? (
<button
className="w-full min-w-0 grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer overflow-hidden"
onClick={() => setActiveSubPanel("proxy")}
>
<Badge variant="secondary" className="text-xs shrink-0">
{form.proxyConfig.type?.toUpperCase()}
</Badge>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
{form.proxyConfig.host}:{form.proxyConfig.port}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-w-xs break-all">
{form.proxyConfig.type?.toUpperCase()} {form.proxyConfig.host}:{form.proxyConfig.port}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<X
size={14}
className="text-muted-foreground hover:text-destructive flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
clearProxyConfig();
}}
/>
</button>
{form.proxyConfig?.host || form.proxyProfileId ? (
<div className="w-full min-w-0 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1">
<button
type="button"
className="min-w-0 grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer overflow-hidden"
onClick={() => setActiveSubPanel("proxy")}
>
<Badge variant="secondary" className="text-xs shrink-0">
{proxySummaryType}
</Badge>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
{proxySummaryLabel}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-w-xs break-all">
{proxySummaryTooltip}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 text-muted-foreground hover:text-destructive shrink-0"
aria-label={t("hostDetails.proxyPanel.remove")}
onClick={clearProxyConfig}
>
<X size={14} />
</Button>
</div>
) : (
<Button
variant="ghost"
@@ -1869,42 +2070,46 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{form.telnetEnabled || form.protocol === "telnet" ? (
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 bg-secondary/70 border border-border/70 rounded-md px-2 py-1">
<div className="flex-1 min-w-0 h-10 flex items-center gap-2 bg-secondary/70 border border-border/70 rounded-md px-3">
<span className="text-xs text-muted-foreground">{t("hostDetails.telnetOn")}</span>
<Input
type="number"
value={form.telnetPort || 23}
onChange={(e) => update("telnetPort", Number(e.target.value))}
className="h-8 w-16 text-center"
/>
<span className="text-xs text-muted-foreground">{t("hostDetails.port")}</span>
<div className="ml-auto w-1/2 min-w-0 flex items-center gap-2 justify-end">
<Input
type="number"
value={effectiveTelnetPort}
onChange={(e) => update("telnetPort", parseOptionalPortInput(e.target.value))}
className="h-8 flex-1 min-w-0 text-center"
/>
<span className="text-xs text-muted-foreground">{t("hostDetails.port")}</span>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => update("telnetEnabled", false)}
>
<X size={14} />
</Button>
{form.protocol !== "telnet" && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => update("telnetEnabled", false)}
>
<X size={14} />
</Button>
)}
</div>
{/* Telnet Credentials */}
<p className="text-xs font-semibold">{t("hostDetails.telnet.credentials")}</p>
<Input
placeholder={t("hostDetails.telnet.username")}
value={form.telnetUsername || form.username || ""}
onChange={(e) =>
update("telnetUsername" as keyof Host, e.target.value)
}
<Input
placeholder={t("hostDetails.telnet.username")}
value={effectiveTelnetUsername}
onChange={(e) =>
update("telnetUsername" as keyof Host, e.target.value)
}
className="h-10"
/>
<Input
placeholder={t("hostDetails.telnet.password")}
type="password"
value={form.telnetPassword || form.password || ""}
onChange={(e) =>
update("telnetPassword" as keyof Host, e.target.value)
placeholder={t("hostDetails.telnet.password")}
type="password"
value={effectiveTelnetPassword}
onChange={(e) =>
update("telnetPassword" as keyof Host, e.target.value)
}
className="h-10"
/>
@@ -1953,7 +2158,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
className="w-full h-10 justify-start gap-2 border border-dashed border-border/60"
onClick={() => {
update("telnetEnabled", true);
update("telnetPort", 23);
}}
>
<Plus size={14} />

View File

@@ -0,0 +1,58 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { GroupConfig, Host } from "../types.ts";
import { getHostTreeDisplayDetails } from "./HostTreeView.tsx";
const baseHost: Host = {
id: "host-1",
label: "Router",
hostname: "router.example.com",
username: "ssh-user",
port: 2222,
protocol: "telnet",
tags: [],
os: "linux",
createdAt: 1,
};
test("HostTreeView display details include inherited telnet defaults", () => {
const host: Host = {
...baseHost,
group: "network",
username: "ssh-user",
port: 2222,
telnetUsername: undefined,
telnetPort: undefined,
};
const groupConfigs: GroupConfig[] = [{
path: "network",
telnetUsername: "group-telnet-user",
telnetPort: 2325,
}];
assert.deepEqual(getHostTreeDisplayDetails(host, groupConfigs), {
protocol: "telnet",
username: "group-telnet-user",
port: 2325,
});
});
test("HostTreeView display details keep explicit cleared telnet username", () => {
const host: Host = {
...baseHost,
group: "network",
telnetUsername: "",
};
const groupConfigs: GroupConfig[] = [{
path: "network",
telnetUsername: "group-telnet-user",
telnetPort: 2325,
}];
assert.deepEqual(getHostTreeDisplayDetails(host, groupConfigs), {
protocol: "telnet",
username: "",
port: 2325,
});
});

View File

@@ -2,10 +2,11 @@ import { CheckSquare, ChevronRight, Edit2, FileSymlink, Folder, FolderOpen, Moni
import React, { useMemo } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { useTreeExpandedState } from '../application/state/useTreeExpandedState';
import { sanitizeHost } from '../domain/host';
import { applyGroupDefaults, resolveGroupDefaults } from '../domain/groupConfig';
import { resolveTelnetPort, resolveTelnetUsername, sanitizeHost } from '../domain/host';
import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys';
import { cn } from '../lib/utils';
import { GroupNode, Host } from '../types';
import { GroupConfig, GroupNode, Host } from '../types';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
import { DistroAvatar } from './DistroAvatar';
@@ -38,6 +39,7 @@ interface HostTreeViewProps {
toggleHostSelection?: (hostId: string) => void;
getDropTargetClasses?: (target: string) => string;
setDragOverDropTarget?: (target: string | null) => void;
groupConfigs?: GroupConfig[];
}
interface TreeNodeProps {
@@ -65,6 +67,7 @@ interface TreeNodeProps {
toggleHostSelection?: (hostId: string) => void;
getDropTargetClasses?: (target: string) => string;
setDragOverDropTarget?: (target: string | null) => void;
groupConfigs: GroupConfig[];
}
@@ -93,6 +96,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
toggleHostSelection,
getDropTargetClasses,
setDragOverDropTarget,
groupConfigs,
}) => {
const { t } = useI18n();
const isExpanded = expandedPaths.has(node.path);
@@ -255,13 +259,14 @@ const TreeNode: React.FC<TreeNodeProps> = ({
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={onUnmanageGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
getDropTargetClasses={getDropTargetClasses}
setDragOverDropTarget={setDragOverDropTarget}
/>
))}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
getDropTargetClasses={getDropTargetClasses}
setDragOverDropTarget={setDragOverDropTarget}
groupConfigs={groupConfigs}
/>
))}
{/* Hosts in this group */}
{sortedHosts.map((host) => (
@@ -276,11 +281,12 @@ const TreeNode: React.FC<TreeNodeProps> = ({
onCopyCredentials={onCopyCredentials}
moveHostToGroup={moveHostToGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
/>
))}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
groupConfigs={groupConfigs}
/>
))}
</CollapsibleContent>
</Collapsible>
</div>
@@ -300,8 +306,28 @@ interface HostTreeItemProps {
isMultiSelectMode?: boolean;
selectedHostIds?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
groupConfigs: GroupConfig[];
}
export const getHostTreeDisplayDetails = (
host: Host,
groupConfigs: GroupConfig[] = [],
) => {
const displayHost = host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs))
: host;
const isTelnet = displayHost.protocol === 'telnet';
return {
protocol: displayHost.protocol,
username: isTelnet
? (resolveTelnetUsername(displayHost) || '')
: (displayHost.username?.trim() || ''),
port: isTelnet
? resolveTelnetPort(displayHost)
: (displayHost.port ?? 22),
};
};
const HostTreeItem: React.FC<HostTreeItemProps> = ({
host,
depth,
@@ -315,18 +341,19 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
isMultiSelectMode,
selectedHostIds,
toggleHostSelection,
groupConfigs,
}) => {
const { t } = useI18n();
const paddingLeft = `${depth * 20 + 12}px`;
const safeHost = sanitizeHost(host);
const tags = host.tags || [];
const isTelnet = host.protocol === 'telnet';
const displayUsername = isTelnet
? (host.telnetUsername?.trim() || host.username?.trim() || '')
: (host.username?.trim() || '');
const displayPort = isTelnet
? (host.telnetPort ?? host.port ?? 23)
: (host.port ?? 22);
const displayDetails = useMemo(
() => getHostTreeDisplayDetails(host, groupConfigs),
[groupConfigs, host],
);
const displayProtocol = displayDetails.protocol;
const displayUsername = displayDetails.username;
const displayPort = displayDetails.port;
const isSelected = isMultiSelectMode && selectedHostIds?.has(host.id);
return (
@@ -371,11 +398,11 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
</div>
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
{host.protocol && host.protocol !== 'ssh' && (
<span className="text-xs px-1.5 py-0.5 bg-primary/10 text-primary rounded">
{host.protocol.toUpperCase()}
</span>
)}
{displayProtocol && displayProtocol !== 'ssh' && (
<span className="text-xs px-1.5 py-0.5 bg-primary/10 text-primary rounded">
{displayProtocol.toUpperCase()}
</span>
)}
{tags.length > 0 && (
<span className="text-xs opacity-60">
{tags.slice(0, 2).join(', ')}
@@ -445,6 +472,7 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
toggleHostSelection,
getDropTargetClasses,
setDragOverDropTarget,
groupConfigs = [],
}) => {
const { t } = useI18n();
@@ -568,9 +596,10 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
getDropTargetClasses={getDropTargetClasses}
setDragOverDropTarget={setDragOverDropTarget}
/>
getDropTargetClasses={getDropTargetClasses}
setDragOverDropTarget={setDragOverDropTarget}
groupConfigs={groupConfigs}
/>
))}
{/* Ungrouped hosts at root level */}
@@ -586,9 +615,10 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
onCopyCredentials={onCopyCredentials}
moveHostToGroup={moveHostToGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
/>
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
groupConfigs={groupConfigs}
/>
))}
{/* Empty state */}

View File

@@ -3,6 +3,9 @@ import {
ChevronDown,
ChevronRight,
Edit2,
Eye,
EyeOff,
FileKey,
Info,
Key,
LayoutGrid,
@@ -18,11 +21,12 @@ import {
import React, { useCallback, useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useStoredViewMode } from "../application/state/useStoredViewMode";
import { resolveHostAuth } from "../domain/sshAuth";
import { sanitizeCredentialValue } from "../domain/credentials";
import { resolveBridgeKeyAuth, resolveHostAuth } from "../domain/sshAuth";
import { STORAGE_KEY_VAULT_KEYS_VIEW_MODE } from "../infrastructure/config/storageKeys";
import { logger } from "../lib/logger";
import { cn } from "../lib/utils";
import { Host, Identity, KeyType, SSHKey } from "../types";
import { Host, Identity, KeyType, ProxyProfile, SSHKey } from "../types";
import { ManagedSource } from "../domain/models";
import { useKeychainBackend } from "../application/state/useKeychainBackend";
import SelectHostPanel from "./SelectHostPanel";
@@ -68,6 +72,7 @@ interface KeychainManagerProps {
keys: SSHKey[];
identities?: Identity[];
hosts?: Host[];
proxyProfiles?: ProxyProfile[];
customGroups?: string[];
managedSources?: ManagedSource[];
onSave: (key: SSHKey) => void;
@@ -84,6 +89,7 @@ const KeychainManager: React.FC<KeychainManagerProps> = ({
keys,
identities = [],
hosts = [],
proxyProfiles = [],
customGroups = [],
managedSources = [],
onSave,
@@ -173,7 +179,7 @@ echo $3 >> "$FILE"`);
switch (activeFilter) {
case "key":
result = result.filter(
(k) => k.source === "generated" || k.source === "imported",
(k) => k.source === "generated" || k.source === "imported" || k.source === "reference",
);
break;
case "certificate":
@@ -1027,16 +1033,26 @@ echo $3 >> "$FILE"`);
keys,
identities,
});
const exportKeyAuth = resolveBridgeKeyAuth({
key: exportAuth.key,
fallbackIdentityFilePaths: exportAuth.authMethod === "password" || exportAuth.keyId
? undefined
: exportHost.identityFilePaths,
passphrase: exportAuth.passphrase,
});
const exportPassword = sanitizeCredentialValue(exportAuth.password);
// Need either password or a usable key to run remote command.
if (!exportAuth.password && !exportAuth.key?.privateKey) {
if (
!exportPassword &&
!exportKeyAuth.privateKey &&
!exportKeyAuth.identityFilePaths?.length
) {
throw new Error(
t("keychain.export.missingCredentials"),
);
}
const hostPrivateKey = exportAuth.key?.privateKey;
// Escape the public key for shell (single quotes, escape existing quotes)
const escapedPublicKey = panel.key.publicKey.replace(
/'/g,
@@ -1057,8 +1073,14 @@ echo $3 >> "$FILE"`);
hostname: exportHost.hostname,
username: exportAuth.username,
port: exportHost.port || 22,
password: exportAuth.password,
privateKey: hostPrivateKey,
password: exportPassword,
privateKey: exportKeyAuth.privateKey,
certificate: exportAuth.key?.certificate,
publicKey: exportAuth.key?.publicKey,
keyId: exportAuth.keyId,
keySource: exportAuth.key?.source,
passphrase: exportKeyAuth.passphrase,
identityFilePaths: exportKeyAuth.identityFilePaths,
command,
timeout: 30000,
enableKeyboardInteractive: true,
@@ -1138,71 +1160,134 @@ echo $3 >> "$FILE"`);
/>
</div>
<div className="space-y-2">
<Label className="text-destructive">
{t("keychain.edit.privateKeyRequired")}
</Label>
<Textarea
value={draftKey.privateKey || ""}
onChange={(e) =>
setDraftKey({ ...draftKey, privateKey: e.target.value })
}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
className="min-h-[180px] font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">
{t("keychain.edit.publicKey")}
</Label>
<Textarea
value={draftKey.publicKey || ""}
onChange={(e) =>
setDraftKey({ ...draftKey, publicKey: e.target.value })
}
placeholder="ssh-ed25519 AAAA..."
className="min-h-[80px] font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">
{t("keychain.edit.certificate")}
</Label>
<Textarea
value={draftKey.certificate || ""}
onChange={(e) =>
setDraftKey({ ...draftKey, certificate: e.target.value })
}
placeholder={t("keychain.edit.certificatePlaceholder")}
className="min-h-[60px] font-mono text-xs"
/>
</div>
{/* Key Export section */}
<div className="pt-4 mt-4 border-t border-border/60">
<div className="flex items-center gap-2 mb-3">
<span className="text-sm font-medium">
{t("keychain.edit.keyExport")}
</span>
<div className="h-4 w-4 rounded-full bg-muted flex items-center justify-center">
<Info size={10} className="text-muted-foreground" />
{/* Reference key: show file path read-only */}
{draftKey.source === 'reference' && draftKey.filePath && (
<div className="space-y-2">
<Label className="text-muted-foreground">
{t("keychain.edit.filePath")}
</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>
</div>
</div>
<Button
className="w-full h-11"
onClick={() => openKeyExport(panel.key)}
>
{t("keychain.edit.exportToHost")}
</Button>
)}
{/* Managed key: show private key editor */}
{draftKey.source !== 'reference' && (
<div className="space-y-2">
<Label className="text-destructive">
{t("keychain.edit.privateKeyRequired")}
</Label>
<Textarea
value={draftKey.privateKey || ""}
onChange={(e) =>
setDraftKey({ ...draftKey, privateKey: e.target.value })
}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
className="min-h-[180px] font-mono text-xs"
/>
</div>
)}
{draftKey.source !== 'reference' && (
<div className="space-y-2">
<Label className="text-muted-foreground">
{t("keychain.edit.publicKey")}
</Label>
<Textarea
value={draftKey.publicKey || ""}
onChange={(e) =>
setDraftKey({ ...draftKey, publicKey: e.target.value })
}
placeholder="ssh-ed25519 AAAA..."
className="min-h-[80px] font-mono text-xs"
/>
</div>
)}
{draftKey.source !== 'reference' && (
<div className="space-y-2">
<Label className="text-muted-foreground">
{t("keychain.edit.certificate")}
</Label>
<Textarea
value={draftKey.certificate || ""}
onChange={(e) =>
setDraftKey({ ...draftKey, certificate: e.target.value })
}
placeholder={t("keychain.edit.certificatePlaceholder")}
className="min-h-[60px] font-mono text-xs"
/>
</div>
)}
{/* Passphrase section */}
<div className="space-y-2">
<Label>{t('terminal.auth.passphrase')}</Label>
<div className="relative">
<Input
type={showPassphrase ? 'text' : 'password'}
value={draftKey.passphrase || ''}
onChange={(e) =>
setDraftKey({ ...draftKey, passphrase: e.target.value })
}
placeholder={t('keychain.generate.passphrasePlaceholder')}
className="pr-10"
/>
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8"
onClick={() => setShowPassphrase(!showPassphrase)}
>
{showPassphrase ? <EyeOff size={14} /> : <Eye size={14} />}
</Button>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="editSavePassphrase"
checked={draftKey.savePassphrase || false}
onChange={(e) =>
setDraftKey({ ...draftKey, savePassphrase: e.target.checked })
}
className="h-4 w-4 rounded border-border"
/>
<Label htmlFor="editSavePassphrase" className="text-sm font-normal cursor-pointer">
{t('keychain.generate.savePassphrase')}
</Label>
</div>
</div>
{/* Key Export section - only for managed keys */}
{draftKey.source !== 'reference' && (
<div className="pt-4 mt-4 border-t border-border/60">
<div className="flex items-center gap-2 mb-3">
<span className="text-sm font-medium">
{t("keychain.edit.keyExport")}
</span>
<div className="h-4 w-4 rounded-full bg-muted flex items-center justify-center">
<Info size={10} className="text-muted-foreground" />
</div>
</div>
<Button
className="w-full h-11"
onClick={() => openKeyExport(panel.key)}
>
{t("keychain.edit.exportToHost")}
</Button>
</div>
)}
{/* Save button */}
<Button
className="w-full h-11 mt-4"
disabled={
!draftKey.label?.trim() || !draftKey.privateKey?.trim()
!draftKey.label?.trim() ||
(draftKey.source !== 'reference' && !draftKey.privateKey?.trim())
}
onClick={() => {
if (draftKey.id) {
@@ -1234,6 +1319,7 @@ echo $3 >> "$FILE"`);
onBack={() => setShowHostSelector(false)}
onContinue={() => setShowHostSelector(false)}
availableKeys={keys}
proxyProfiles={proxyProfiles}
managedSources={managedSources}
onSaveHost={onSaveHost}
onCreateGroup={onCreateGroup}

View File

@@ -1,113 +0,0 @@
import { ShieldCheck } from 'lucide-react';
import React from 'react';
import { Host } from '../types';
import { DistroAvatar } from './DistroAvatar';
import { Button } from './ui/button';
export interface HostKeyInfo {
hostname: string;
port: number;
keyType: string; // ssh-rsa, ssh-ed25519, ecdsa-sha2-nistp256, etc.
fingerprint: string; // SHA256 fingerprint
publicKey?: string; // Full public key
}
interface KnownHostConfirmDialogProps {
host: Host;
hostKeyInfo: HostKeyInfo;
onClose: () => void;
onContinue: () => void; // Continue without adding to known hosts
onAddAndContinue: () => void; // Add to known hosts and continue
}
const KnownHostConfirmDialog: React.FC<KnownHostConfirmDialogProps> = ({
host,
hostKeyInfo,
onClose,
onContinue,
onAddAndContinue,
}) => {
return (
<div className="flex flex-col items-center justify-center h-full p-8 max-w-2xl mx-auto">
{/* Header with host info */}
<div className="flex items-center gap-3 mb-6">
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-12 w-12" />
<div>
<h2 className="text-base font-semibold">{host.label}</h2>
<p className="text-xs text-muted-foreground font-mono">
SSH {host.hostname}:{host.port || 22}
</p>
</div>
<Button variant="outline" size="sm" className="ml-4">
Show logs
</Button>
</div>
{/* Progress indicator */}
<div className="flex items-center gap-3 w-full max-w-md mb-8">
<div className="h-8 w-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center">
<div className="h-2 w-2 rounded-full bg-primary-foreground" />
</div>
<div className="flex-1 h-0.5 bg-primary" />
<div className="h-8 w-8 rounded-full bg-primary/20 border-2 border-primary text-primary flex items-center justify-center">
<ShieldCheck size={14} />
</div>
<div className="flex-1 h-0.5 bg-muted" />
<div className="h-8 w-8 rounded-full bg-muted text-muted-foreground flex items-center justify-center text-xs font-mono">
{'>_'}
</div>
</div>
{/* Warning message */}
<div className="text-center mb-6">
<h3 className="text-lg font-semibold text-amber-500 mb-2">
Are you sure you want to connect?
</h3>
<p className="text-sm text-muted-foreground">
The authenticity of <span className="font-mono font-medium text-foreground">{hostKeyInfo.hostname}</span> can not be established.
</p>
</div>
{/* Fingerprint info */}
<div className="w-full max-w-md space-y-3 mb-8">
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">{hostKeyInfo.keyType} fingerprint is SHA256:</span>
</div>
<div className="bg-secondary/80 rounded-lg p-3 border border-border/60">
<code className="text-sm font-mono text-foreground break-all">
{hostKeyInfo.fingerprint}
</code>
</div>
<p className="text-sm text-muted-foreground">
Do you want to add it to the list of known hosts?
</p>
</div>
{/* Action buttons */}
<div className="flex items-center gap-3">
<Button
variant="secondary"
className="min-w-[100px]"
onClick={onClose}
>
Close
</Button>
<Button
variant="outline"
className="min-w-[100px]"
onClick={onContinue}
>
Continue
</Button>
<Button
className="min-w-[140px]"
onClick={onAddAndContinue}
>
Add and continue
</Button>
</div>
</div>
);
};
export default KnownHostConfirmDialog;

View File

@@ -84,7 +84,7 @@ const parseKnownHostsFile = (content: string): KnownHost[] => {
hostname,
port,
keyType,
publicKey: publicKey.slice(0, 64) + "...",
publicKey: `${keyType} ${publicKey}`,
discoveredAt: Date.now(),
});
} catch {

View File

@@ -25,7 +25,7 @@ export interface PassphraseRequest {
interface PassphraseModalProps {
request: PassphraseRequest | null;
onSubmit: (requestId: string, passphrase: string) => void;
onSubmit: (requestId: string, passphrase: string, remember: boolean) => void;
onCancel: (requestId: string) => void;
onSkip?: (requestId: string) => void;
}
@@ -40,6 +40,7 @@ export const PassphraseModal: React.FC<PassphraseModalProps> = ({
const [passphrase, setPassphrase] = useState("");
const [showPassphrase, setShowPassphrase] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [rememberPassphrase, setRememberPassphrase] = useState(true);
// Reset state when request changes
useEffect(() => {
@@ -47,14 +48,15 @@ export const PassphraseModal: React.FC<PassphraseModalProps> = ({
setPassphrase("");
setShowPassphrase(false);
setIsSubmitting(false);
setRememberPassphrase(true);
}
}, [request]);
const handleSubmit = useCallback(() => {
if (!request || isSubmitting || !passphrase) return;
setIsSubmitting(true);
onSubmit(request.requestId, passphrase);
}, [request, passphrase, onSubmit, isSubmitting]);
onSubmit(request.requestId, passphrase, rememberPassphrase);
}, [request, passphrase, onSubmit, isSubmitting, rememberPassphrase]);
const handleCancel = useCallback(() => {
if (!request) return;
@@ -82,15 +84,15 @@ export const PassphraseModal: React.FC<PassphraseModalProps> = ({
return (
<Dialog open={!!request} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent className="sm:max-w-[425px]" hideCloseButton>
<DialogContent className="sm:max-w-[500px]" hideCloseButton>
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<KeyRound className="h-5 w-5 text-primary" />
</div>
<div>
<div className="min-w-0 flex-1">
<DialogTitle>{t("passphrase.title")}</DialogTitle>
<DialogDescription className="mt-1">
<DialogDescription className="mt-1 break-words">
{request.hostname
? t("passphrase.descWithHost", { keyName: keyDisplayName, hostname: request.hostname })
: t("passphrase.desc", { keyName: keyDisplayName })}
@@ -125,9 +127,21 @@ export const PassphraseModal: React.FC<PassphraseModalProps> = ({
{showPassphrase ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
<p className="text-xs text-muted-foreground">
{t("passphrase.keyPath")}: <code className="text-xs">{request.keyPath}</code>
<p className="text-xs text-muted-foreground break-all">
{t("passphrase.keyPath")}: <code className="text-xs break-all">{request.keyPath}</code>
</p>
<label className="flex items-center gap-2 cursor-pointer select-none mt-2">
<input
type="checkbox"
checked={rememberPassphrase}
onChange={(e) => setRememberPassphrase(e.target.checked)}
disabled={isSubmitting}
className="accent-primary"
/>
<span className="text-xs text-muted-foreground">
{t("passphrase.remember")}
</span>
</label>
</div>
</div>

View File

@@ -10,7 +10,7 @@ import {
Shuffle,
Zap,
} from "lucide-react";
import React, { useCallback, useState } from "react";
import React, { useCallback, useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
import {
@@ -19,9 +19,11 @@ import {
ManagedSource,
PortForwardingRule,
PortForwardingType,
ProxyProfile,
SSHKey,
} from "../domain/models";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
import { cn } from "../lib/utils";
import SelectHostPanel from "./SelectHostPanel";
import {
@@ -69,9 +71,11 @@ interface PortForwardingProps {
customGroups: string[];
managedSources?: ManagedSource[];
groupConfigs?: GroupConfig[];
proxyProfiles?: ProxyProfile[];
onNewHost?: () => void;
onSaveHost?: (host: Host) => void;
onCreateGroup?: (groupPath: string) => void;
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}
const PortForwarding: React.FC<PortForwardingProps> = ({
@@ -81,9 +85,11 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
customGroups: _customGroups,
managedSources = [],
groupConfigs = [],
proxyProfiles = [],
onNewHost: _onNewHost,
onSaveHost,
onCreateGroup: _onCreateGroup,
terminalSettings,
}) => {
const { t } = useI18n();
const {
@@ -113,6 +119,20 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
const [pendingOperations, setPendingOperations] = useState<Set<string>>(
new Set(),
);
const proxyProfileIdSet = useMemo(
() => new Set(proxyProfiles.map((profile) => profile.id)),
[proxyProfiles],
);
const resolveEffectiveHost = useCallback(
(host: Host): Host => {
const withGroupDefaults = host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
},
[groupConfigs, proxyProfileIdSet, proxyProfiles],
);
// Start a port forwarding tunnel
const handleStartTunnel = useCallback(
@@ -127,9 +147,8 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
return;
}
const _host = _rawHost.group
? applyGroupDefaults(_rawHost, resolveGroupDefaults(_rawHost.group, groupConfigs))
: _rawHost;
const _host = resolveEffectiveHost(_rawHost);
const effectiveHosts = hosts.map((host) => resolveEffectiveHost(host));
setPendingOperations((prev) => new Set([...prev, rule.id]));
let errorShown = false;
@@ -138,7 +157,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
const result = await startTunnel(
rule,
_host,
hosts,
effectiveHosts,
keys,
identities,
(status, error) => {
@@ -152,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) {
@@ -169,7 +189,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
});
}
},
[hosts, identities, keys, groupConfigs, setRuleStatus, startTunnel, t],
[hosts, identities, keys, resolveEffectiveHost, setRuleStatus, startTunnel, t, terminalSettings],
);
// Stop a port forwarding tunnel
@@ -853,6 +873,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
onContinue={() => setShowHostSelector(false)}
availableKeys={keys}
identities={identities}
proxyProfiles={proxyProfiles}
managedSources={managedSources}
onSaveHost={onSaveHost}
onCreateGroup={_onCreateGroup}

View File

@@ -0,0 +1,80 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import type { ProxyProfile } from "../types.ts";
import { ProxyPanel } from "./host-details/ProxyPanel.tsx";
const proxyProfile: ProxyProfile = {
id: "proxy-1",
label: "Office Proxy",
config: {
type: "socks5",
host: "office-proxy.example.com",
port: 1080,
},
createdAt: 1,
};
const renderPanel = (props: Partial<React.ComponentProps<typeof ProxyPanel>> = {}) =>
renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(ProxyPanel, {
proxyConfig: undefined,
proxyProfiles: [],
selectedProxyProfileId: undefined,
onUpdateProxy: () => {},
onSelectProxyProfile: () => {},
onClearProxy: () => {},
onBack: () => {},
onCancel: () => {},
layout: "inline",
...props,
}),
),
);
test("ProxyPanel shows saved proxy selection when reusable profiles exist", () => {
const markup = renderPanel({
proxyProfiles: [proxyProfile],
selectedProxyProfileId: proxyProfile.id,
});
assert.match(markup, /Saved proxy/);
assert.match(markup, /office-proxy\.example\.com:1080/);
assert.doesNotMatch(markup, /Proxy host/);
});
test("ProxyPanel keeps manual proxy fields available without a saved profile selection", () => {
const markup = renderPanel({
proxyProfiles: [proxyProfile],
proxyConfig: { type: "http", host: "manual-proxy.example.com", port: 3128 },
});
assert.match(markup, /Saved proxy/);
assert.match(markup, /Proxy host/);
assert.match(markup, /manual-proxy\.example\.com/);
});
test("ProxyPanel shows a clear missing state for stale saved proxy selections", () => {
const markup = renderPanel({
proxyProfiles: [proxyProfile],
selectedProxyProfileId: "missing-proxy",
});
assert.match(markup, /Missing saved proxy/);
assert.match(markup, /Proxy host/);
});
test("ProxyPanel disables saving invalid manual proxy ports", () => {
const markup = renderPanel({
proxyConfig: { type: "http", host: "manual-proxy.example.com", port: 65536 },
});
assert.match(markup, /Port must be between 1 and 65535/);
assert.match(markup, /disabled=""/);
});

View File

@@ -0,0 +1,85 @@
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 { isValidProxyPort } from "../domain/proxyProfiles.ts";
import { STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE } from "../infrastructure/config/storageKeys.ts";
import type { ProxyProfile } from "../types.ts";
import { ProxyProfilesManager } from "./ProxyProfilesManager.tsx";
const proxyProfile: ProxyProfile = {
id: "proxy-1",
label: "Office Proxy",
config: {
type: "http",
host: "127.0.0.1",
port: 8080,
},
createdAt: 1,
};
const installStorageStub = (viewMode: string | null = null) => {
const values = new Map<string, string>();
if (viewMode) {
values.set(STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE, viewMode);
}
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => {
values.set(key, value);
},
removeItem: (key: string) => {
values.delete(key);
},
},
});
};
const renderManager = (viewMode: string | null = null) => {
installStorageStub(viewMode);
return renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(ProxyProfilesManager, {
proxyProfiles: [proxyProfile],
hosts: [],
groupConfigs: [],
onUpdateProxyProfiles: () => {},
onUpdateHosts: () => {},
onUpdateGroupConfigs: () => {},
}),
),
);
};
test("ProxyProfilesManager uses the shared Vault grid card style by default", () => {
const markup = renderManager();
assert.match(markup, /Add Proxy/);
assert.match(markup, /aria-label="Search proxies…"/);
assert.match(markup, /aria-label="Office Proxy, HTTP, 127\.0\.0\.1:8080, 0 linked"/);
assert.match(markup, /Office Proxy/);
assert.match(markup, /127\.0\.0\.1:8080/);
});
test("ProxyProfilesManager uses the shared Vault list row style when persisted", () => {
const markup = renderManager("list");
assert.match(markup, /aria-label="Office Proxy, HTTP, 127\.0\.0\.1:8080, 0 linked"/);
assert.match(markup, /Office Proxy/);
assert.match(markup, /127\.0\.0\.1:8080/);
});
test("ProxyProfilesManager validates proxy ports", () => {
assert.equal(isValidProxyPort(1), true);
assert.equal(isValidProxyPort(65535), true);
assert.equal(isValidProxyPort(0), false);
assert.equal(isValidProxyPort(65536), false);
assert.equal(isValidProxyPort(10.5), false);
});

View File

@@ -0,0 +1,538 @@
import {
AlertTriangle,
Check,
ChevronDown,
Copy,
Globe,
KeyRound,
LayoutGrid,
List as ListIcon,
Pencil,
Plus,
Search,
Settings2,
Trash2,
} from "lucide-react";
import React, { useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useStoredViewMode } from "../application/state/useStoredViewMode";
import { isValidProxyPort, removeProxyProfileReferences } from "../domain/proxyProfiles";
import {
STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE,
} from "../infrastructure/config/storageKeys";
import { cn } from "../lib/utils";
import type { GroupConfig, Host, ProxyConfig, ProxyProfile } from "../types";
import {
AsidePanel,
AsidePanelContent,
AsidePanelFooter,
} from "./ui/aside-panel";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Card } from "./ui/card";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "./ui/context-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
import { Input } from "./ui/input";
import { toast } from "./ui/toast";
interface ProxyProfilesManagerProps {
proxyProfiles: ProxyProfile[];
hosts: Host[];
groupConfigs: GroupConfig[];
onUpdateProxyProfiles: (profiles: ProxyProfile[]) => void;
onUpdateHosts: (hosts: Host[]) => void;
onUpdateGroupConfigs: (configs: GroupConfig[]) => void;
}
const createDraftProfile = (): ProxyProfile => {
const now = Date.now();
return {
id: crypto.randomUUID(),
label: "",
config: {
type: "http",
host: "",
port: 8080,
},
createdAt: now,
updatedAt: now,
};
};
const getProfileUsageCount = (
profileId: string,
hosts: Host[],
groupConfigs: GroupConfig[],
): number =>
hosts.filter((host) => host.proxyProfileId === profileId).length +
groupConfigs.filter((config) => config.proxyProfileId === profileId).length;
type ProxyProfilesViewMode = "grid" | "list";
interface ProxyProfileCardProps {
profile: ProxyProfile;
usageCount: number;
viewMode: ProxyProfilesViewMode;
isSelected: boolean;
onClick: () => void;
onEdit: () => void;
onDuplicate: () => void;
onDelete: () => void;
}
const ProxyProfileCard: React.FC<ProxyProfileCardProps> = ({
profile,
usageCount,
viewMode,
isSelected,
onClick,
onEdit,
onDuplicate,
onDelete,
}) => {
const { t } = useI18n();
const usageLabel = t("proxyProfiles.usage", { count: usageCount });
const accessibleLabel = `${profile.label}, ${profile.config.type.toUpperCase()}, ${profile.config.host}:${profile.config.port}, ${usageLabel}`;
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<button
type="button"
aria-label={accessibleLabel}
className={cn(
"group w-full text-left focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
viewMode === "grid"
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
isSelected && "ring-2 ring-primary",
)}
onClick={onClick}
>
<div className="flex items-center gap-3 h-full">
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center">
<Globe size={18} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
<div className="text-sm font-semibold truncate">{profile.label}</div>
<Badge variant="secondary" className="text-[10px] shrink-0">
{profile.config.type.toUpperCase()}
</Badge>
</div>
<div className="text-[11px] font-mono text-muted-foreground truncate">
{profile.config.host}:{profile.config.port} -{" "}
{usageLabel}
</div>
</div>
</div>
</button>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={onEdit}>
<Pencil size={14} className="mr-2" />
{t("action.edit")}
</ContextMenuItem>
<ContextMenuItem onClick={onDuplicate}>
<Copy size={14} className="mr-2" />
{t("action.duplicate")}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
<Trash2 size={14} className="mr-2" />
{t("action.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};
export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
proxyProfiles,
hosts,
groupConfigs,
onUpdateProxyProfiles,
onUpdateHosts,
onUpdateGroupConfigs,
}) => {
const { t } = useI18n();
const [search, setSearch] = useState("");
const [viewMode, setViewMode] = useStoredViewMode(
STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE,
"grid",
);
const proxyProfilesViewMode: ProxyProfilesViewMode =
viewMode === "list" ? "list" : "grid";
const [draft, setDraft] = useState<ProxyProfile | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ProxyProfile | null>(null);
const usageByProfileId = useMemo(() => {
const map = new Map<string, number>();
for (const profile of proxyProfiles) {
map.set(profile.id, getProfileUsageCount(profile.id, hosts, groupConfigs));
}
return map;
}, [groupConfigs, hosts, proxyProfiles]);
const filteredProfiles = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return proxyProfiles;
return proxyProfiles.filter((profile) =>
profile.label.toLowerCase().includes(q) ||
profile.config.host.toLowerCase().includes(q) ||
profile.config.type.toLowerCase().includes(q),
);
}, [proxyProfiles, search]);
const updateDraftConfig = (field: keyof ProxyConfig, value: string | number) => {
setDraft((prev) => {
if (!prev) return prev;
return {
...prev,
config: {
...prev.config,
[field]: value,
},
};
});
};
const openCreate = () => {
setDraft(createDraftProfile());
};
const openEdit = (profile: ProxyProfile) => {
setDraft({
...profile,
config: { ...profile.config },
});
};
const duplicateProfile = (profile: ProxyProfile) => {
const now = Date.now();
onUpdateProxyProfiles([
...proxyProfiles,
{
...profile,
id: crypto.randomUUID(),
label: t("proxyProfiles.copyName", { name: profile.label }),
config: { ...profile.config },
createdAt: now,
updatedAt: now,
},
]);
};
const saveDraft = () => {
if (!draft) return;
const label = draft.label.trim();
const host = draft.config.host.trim();
if (!label || !host || !draft.config.port) {
toast.error(t("proxyProfiles.error.required"));
return;
}
if (!isValidProxyPort(draft.config.port)) {
toast.error(t("proxyProfiles.error.port"));
return;
}
const saved: ProxyProfile = {
...draft,
label,
config: {
...draft.config,
host,
port: Number(draft.config.port),
username: draft.config.username?.trim() || undefined,
password: draft.config.password || undefined,
},
updatedAt: Date.now(),
};
onUpdateProxyProfiles(
proxyProfiles.some((profile) => profile.id === saved.id)
? proxyProfiles.map((profile) => profile.id === saved.id ? saved : profile)
: [...proxyProfiles, saved],
);
setDraft(null);
};
const confirmDelete = () => {
if (!deleteTarget) return;
const cleaned = removeProxyProfileReferences(deleteTarget.id, {
hosts,
groupConfigs,
});
onUpdateProxyProfiles(proxyProfiles.filter((profile) => profile.id !== deleteTarget.id));
onUpdateHosts(cleaned.hosts);
onUpdateGroupConfigs(cleaned.groupConfigs);
if (draft?.id === deleteTarget.id) {
setDraft(null);
}
setDeleteTarget(null);
};
return (
<div className="h-full flex relative">
<div className={cn("flex-1 flex flex-col min-h-0 transition-all duration-200", draft && "mr-[380px]")}>
<header className="border-b border-border/50 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm shrink-0">
<div className="h-14 px-4 py-2 flex items-center gap-3">
<Button
onClick={openCreate}
variant="secondary"
className="h-10 px-3 gap-2 bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
>
<Plus size={14} />
{t("proxyProfiles.action.add")}
</Button>
<div className="ml-auto flex items-center gap-2 min-w-0 flex-shrink">
<div className="relative flex-shrink min-w-[100px]">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label={t("proxyProfiles.search.placeholder")}
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("proxyProfiles.search.placeholder")}
className="h-10 pl-9 w-full bg-secondary border-border/60 text-sm"
/>
</div>
<Dropdown>
<DropdownTrigger asChild>
<Button
aria-label={t("proxyProfiles.viewMode")}
variant="ghost"
size="icon"
className="h-10 w-10 flex-shrink-0"
>
{proxyProfilesViewMode === "grid" ? (
<LayoutGrid size={16} />
) : (
<ListIcon size={16} />
)}
<ChevronDown size={10} className="ml-0.5" />
</Button>
</DropdownTrigger>
<DropdownContent className="w-32" align="end">
<Button
variant={proxyProfilesViewMode === "grid" ? "secondary" : "ghost"}
className="w-full justify-start gap-2 h-9"
onClick={() => setViewMode("grid")}
>
<LayoutGrid size={14} /> {t("vault.view.grid")}
</Button>
<Button
variant={proxyProfilesViewMode === "list" ? "secondary" : "ghost"}
className="w-full justify-start gap-2 h-9"
onClick={() => setViewMode("list")}
>
<ListIcon size={14} /> {t("vault.view.list")}
</Button>
</DropdownContent>
</Dropdown>
</div>
</div>
</header>
<div className="flex-1 overflow-y-auto">
<div className="space-y-3 p-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-muted-foreground">
{t("proxyProfiles.section.proxies")}
</h2>
<span className="text-xs text-muted-foreground">
{t("proxyProfiles.count.items", { count: filteredProfiles.length })}
</span>
</div>
{filteredProfiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
<Globe size={32} className="opacity-60" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
{t("proxyProfiles.empty.title")}
</h3>
<p className="text-sm text-center max-w-sm mb-4">
{t("proxyProfiles.empty.desc")}
</p>
<Button onClick={openCreate}>
<Plus size={14} className="mr-2" />
{t("proxyProfiles.action.add")}
</Button>
</div>
) : (
<div
className={
proxyProfilesViewMode === "grid"
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
: "flex flex-col gap-0"
}
>
{filteredProfiles.map((profile) => (
<ProxyProfileCard
key={profile.id}
profile={profile}
usageCount={usageByProfileId.get(profile.id) ?? 0}
viewMode={proxyProfilesViewMode}
isSelected={draft?.id === profile.id}
onClick={() => openEdit(profile)}
onEdit={() => openEdit(profile)}
onDuplicate={() => duplicateProfile(profile)}
onDelete={() => setDeleteTarget(profile)}
/>
))}
</div>
)}
</div>
</div>
</div>
{draft && (
<AsidePanel
open={true}
onClose={() => setDraft(null)}
title={draft.label || t("proxyProfiles.panel.newTitle")}
>
<AsidePanelContent>
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center gap-2">
<Settings2 size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("proxyProfiles.field.name")}</p>
</div>
<Input
aria-label={t("proxyProfiles.field.name")}
value={draft.label}
onChange={(event) => setDraft({ ...draft, label: event.target.value })}
placeholder={t("proxyProfiles.field.name")}
className="h-10"
/>
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Globe size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("field.type")}</p>
</div>
<div className="flex gap-2">
<Button
variant={draft.config.type === "http" ? "secondary" : "ghost"}
size="sm"
className={cn("h-8", draft.config.type === "http" && "bg-primary/15")}
onClick={() => updateDraftConfig("type", "http")}
>
<Check size={14} className={cn("mr-1", draft.config.type !== "http" && "opacity-0")} />
HTTP
</Button>
<Button
variant={draft.config.type === "socks5" ? "secondary" : "ghost"}
size="sm"
className={cn("h-8", draft.config.type === "socks5" && "bg-primary/15")}
onClick={() => updateDraftConfig("type", "socks5")}
>
<Check size={14} className={cn("mr-1", draft.config.type !== "socks5" && "opacity-0")} />
SOCKS5
</Button>
</div>
</div>
<div className="flex gap-2">
<Input
aria-label={t("hostDetails.proxyPanel.hostPlaceholder")}
value={draft.config.host}
onChange={(event) => updateDraftConfig("host", event.target.value)}
placeholder={t("hostDetails.proxyPanel.hostPlaceholder")}
className="h-10 flex-1"
/>
<Input
aria-label={t("hostDetails.port")}
type="number"
value={draft.config.port || ""}
onChange={(event) => updateDraftConfig("port", event.target.value === "" ? 0 : Number(event.target.value))}
placeholder="3128"
min={1}
max={65535}
step={1}
className="h-10 w-24 text-center"
/>
</div>
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<KeyRound size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.proxyPanel.credentials")}</p>
</div>
<Badge variant="secondary" className="text-xs">{t("common.optional")}</Badge>
</div>
<Input
aria-label={t("hostDetails.proxyPanel.usernamePlaceholder")}
value={draft.config.username || ""}
onChange={(event) => updateDraftConfig("username", event.target.value)}
placeholder={t("hostDetails.proxyPanel.usernamePlaceholder")}
className="h-10"
/>
<Input
aria-label={t("hostDetails.proxyPanel.passwordPlaceholder")}
type="password"
value={draft.config.password || ""}
onChange={(event) => updateDraftConfig("password", event.target.value)}
placeholder={t("hostDetails.proxyPanel.passwordPlaceholder")}
className="h-10"
/>
</Card>
</AsidePanelContent>
<AsidePanelFooter>
<Button className="w-full" onClick={saveDraft}>
{t("common.save")}
</Button>
</AsidePanelFooter>
</AsidePanel>
)}
<Dialog open={Boolean(deleteTarget)} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle size={18} className="text-destructive" />
{t("proxyProfiles.delete.title")}
</DialogTitle>
<DialogDescription>
{deleteTarget
? t("proxyProfiles.delete.desc", {
name: deleteTarget.label,
count: usageByProfileId.get(deleteTarget.id) ?? 0,
})
: ""}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
{t("common.cancel")}
</Button>
<Button variant="destructive" onClick={confirmDelete}>
{t("action.delete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export default ProxyProfilesManager;

View File

@@ -8,7 +8,7 @@ import {
import React, { useMemo, useState } from "react";
import { cn } from "../lib/utils";
import { useI18n } from "../application/i18n/I18nProvider";
import { Host, SSHKey } from "../types";
import { Host, ProxyProfile, SSHKey } from "../types";
import { ManagedSource } from "../domain/models";
import { DistroAvatar } from "./DistroAvatar";
import HostDetailsPanel from "./HostDetailsPanel";
@@ -37,6 +37,7 @@ interface SelectHostPanelProps {
// Props for inline host creation
availableKeys?: SSHKey[];
identities?: import('../domain/models').Identity[];
proxyProfiles?: ProxyProfile[];
managedSources?: ManagedSource[];
onSaveHost?: (host: Host) => void;
onCreateGroup?: (groupPath: string) => void;
@@ -57,6 +58,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
onNewHost,
availableKeys = [],
identities = [],
proxyProfiles = [],
managedSources = [],
onSaveHost,
onCreateGroup,
@@ -411,6 +413,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
initialData={null}
availableKeys={availableKeys}
identities={identities}
proxyProfiles={proxyProfiles}
groups={customGroups}
managedSources={managedSources}
allHosts={hosts}

View File

@@ -113,6 +113,7 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
hosts,
keys,
identities,
proxyProfiles,
snippets,
customGroups,
snippetPackages,
@@ -137,8 +138,8 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
);
const vault = useMemo(
() => ({ hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts, groupConfigs }),
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts, groupConfigs],
() => ({ hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, knownHosts, groupConfigs }),
[hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, knownHosts, groupConfigs],
);
return (

View File

@@ -0,0 +1,72 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { SftpFileEntry } from "../types.ts";
import {
getSftpListUploadFilesTargetPath,
getSftpTreeUploadFilesTargetPath,
getSftpUploadFilesLabelKey,
getSftpUploadFolderLabelKey,
shouldShowSftpUploadFolderMenu,
shouldShowSftpUploadFilesMenu,
} from "./sftp/sftpUploadMenu.ts";
const baseEntry: SftpFileEntry = {
name: "notes.txt",
type: "file",
size: 1,
sizeFormatted: "1 B",
lastModified: 1,
lastModifiedFormatted: "now",
};
test("upload file menu is shown only for remote panes with a picker upload handler", () => {
assert.equal(shouldShowSftpUploadFilesMenu({ isLocal: false, hasFileListUpload: true }), true);
assert.equal(shouldShowSftpUploadFilesMenu({ isLocal: true, hasFileListUpload: true }), false);
assert.equal(shouldShowSftpUploadFilesMenu({ isLocal: false, hasFileListUpload: false }), false);
});
test("upload folder menu is shown only for remote panes with a folder upload handler", () => {
assert.equal(shouldShowSftpUploadFolderMenu({ isLocal: false, hasFolderUpload: true }), true);
assert.equal(shouldShowSftpUploadFolderMenu({ isLocal: true, hasFolderUpload: true }), false);
assert.equal(shouldShowSftpUploadFolderMenu({ isLocal: false, hasFolderUpload: false }), false);
});
test("directory row upload targets that directory without using its name in the label", () => {
const directoryEntry: SftpFileEntry = {
...baseEntry,
name: "a-very-long-folder-name-that-should-not-expand-the-context-menu",
type: "directory",
};
assert.equal(
getSftpListUploadFilesTargetPath(directoryEntry, "/home/app"),
"/home/app/a-very-long-folder-name-that-should-not-expand-the-context-menu",
);
assert.equal(getSftpUploadFilesLabelKey(directoryEntry), "sftp.context.uploadFilesHere");
assert.equal(getSftpUploadFolderLabelKey(directoryEntry), "sftp.context.uploadFolderHere");
});
test("file row upload targets the current directory", () => {
assert.equal(getSftpListUploadFilesTargetPath(baseEntry, "/home/app"), undefined);
assert.equal(getSftpUploadFilesLabelKey(baseEntry), "sftp.context.uploadFiles");
assert.equal(getSftpUploadFolderLabelKey(baseEntry), "sftp.context.uploadFolder");
});
test("tree directory row upload targets that directory", () => {
const directoryEntry: SftpFileEntry = {
...baseEntry,
name: "logs",
type: "directory",
};
assert.equal(getSftpTreeUploadFilesTargetPath(directoryEntry, "/var/logs"), "/var/logs");
assert.equal(getSftpUploadFilesLabelKey(directoryEntry), "sftp.context.uploadFilesHere");
assert.equal(getSftpUploadFolderLabelKey(directoryEntry), "sftp.context.uploadFolderHere");
});
test("tree file row upload targets the file parent directory", () => {
assert.equal(getSftpTreeUploadFilesTargetPath(baseEntry, "/var/logs/app.log"), "/var/logs");
assert.equal(getSftpUploadFilesLabelKey(baseEntry), "sftp.context.uploadFiles");
assert.equal(getSftpUploadFolderLabelKey(baseEntry), "sftp.context.uploadFolder");
});

View File

@@ -41,6 +41,7 @@ import { KeyBinding, HotkeyScheme } from "../domain/models";
interface SftpSidePanelProps {
hosts: Host[];
writableHosts?: Host[];
keys: SSHKey[];
identities: Identity[];
updateHosts: (hosts: Host[]) => void;
@@ -70,10 +71,12 @@ interface SftpSidePanelProps {
setEditorWordWrap: (value: boolean) => void;
onGetTerminalCwd?: () => Promise<string | null>;
onRequestTerminalFocus?: () => void;
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}
const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
hosts,
writableHosts,
keys,
identities,
updateHosts,
@@ -96,8 +99,10 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
setEditorWordWrap,
onGetTerminalCwd,
onRequestTerminalFocus,
terminalSettings,
}) => {
const { t } = useI18n();
const hostWriteSource = writableHosts ?? hosts;
const fileWatchHandlers = useMemo(() => ({
onFileWatchSynced: (payload: { remotePath: string }) => {
@@ -116,7 +121,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 {
@@ -622,6 +628,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
return (
<SftpContextProvider
hosts={hosts}
writableHosts={hostWriteSource}
updateHosts={updateHosts}
draggedFiles={draggedFiles}
dragCallbacks={dragCallbacks}
@@ -741,6 +748,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps): boolean =>
prev.hosts === next.hosts &&
prev.writableHosts === next.writableHosts &&
prev.keys === next.keys &&
prev.identities === next.identities &&
prev.updateHosts === next.updateHosts &&
@@ -762,7 +770,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

@@ -24,8 +24,9 @@ import { logger } from "../lib/logger";
import { useRenderTracker } from "../lib/useRenderTracker";
import { cn } from "../lib/utils";
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
import { Host, Identity, SSHKey } from "../types";
import { Host, Identity, ProxyProfile, SSHKey } from "../types";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
import { toast } from "./ui/toast";
@@ -54,6 +55,7 @@ interface SftpViewProps {
keys: SSHKey[];
identities: Identity[];
groupConfigs?: import('../domain/models').GroupConfig[];
proxyProfiles?: ProxyProfile[];
updateHosts: (hosts: Host[]) => void;
sftpDefaultViewMode: "list" | "tree";
sftpDoubleClickBehavior: "open" | "transfer";
@@ -64,6 +66,7 @@ interface SftpViewProps {
keyBindings: KeyBinding[];
editorWordWrap: boolean;
setEditorWordWrap: (enabled: boolean) => void;
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}
const SftpViewInner: React.FC<SftpViewProps> = ({
@@ -71,6 +74,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
keys,
identities,
groupConfigs = [],
proxyProfiles = [],
updateHosts,
sftpDefaultViewMode,
sftpDoubleClickBehavior,
@@ -81,6 +85,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
keyBindings,
editorWordWrap,
setEditorWordWrap,
terminalSettings,
}) => {
const { t } = useI18n();
const isActive = useIsSftpActive();
@@ -106,17 +111,19 @@ 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(() =>
hosts.map(h => {
if (!h.group) return h;
const defaults = resolveGroupDefaults(h.group, groupConfigs);
return applyGroupDefaults(h, defaults);
}),
[hosts, groupConfigs],
);
const effectiveHosts = useMemo(() => {
const validProxyProfileIds = new Set(proxyProfiles.map((profile) => profile.id));
return hosts.map(h => {
const withGroupDefaults = h.group
? applyGroupDefaults(h, resolveGroupDefaults(h.group, groupConfigs, { validProxyProfileIds }), { validProxyProfileIds })
: applyGroupDefaults(h, {}, { validProxyProfileIds });
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
});
}, [hosts, groupConfigs, proxyProfiles]);
const sftp = useSftpState(effectiveHosts, keys, identities, sftpOptions);
@@ -323,7 +330,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
return (
<SftpContextProvider
hosts={hosts}
hosts={effectiveHosts}
writableHosts={hosts}
updateHosts={updateHosts}
draggedFiles={draggedFiles}
dragCallbacks={dragCallbacks}
@@ -462,7 +470,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
</div>
<SftpOverlays
hosts={hosts}
hosts={effectiveHosts}
sftp={sftp}
visibleTransfers={visibleTransfers}
showHostPickerLeft={showHostPickerLeft}
@@ -507,6 +515,7 @@ const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
prev.keys === next.keys &&
prev.identities === next.identities &&
prev.groupConfigs === next.groupConfigs &&
prev.proxyProfiles === next.proxyProfiles &&
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
prev.sftpAutoSync === next.sftpAutoSync &&
@@ -515,7 +524,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

@@ -4,7 +4,7 @@ import { useI18n } from '../application/i18n/I18nProvider';
import { useStoredViewMode } from '../application/state/useStoredViewMode';
import { STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE } from '../infrastructure/config/storageKeys';
import { cn, isMacPlatform } from '../lib/utils';
import { Host, ShellHistoryEntry, Snippet, SSHKey } from '../types';
import { Host, ProxyProfile, ShellHistoryEntry, Snippet, SSHKey } from '../types';
import { HotkeyScheme, KeyBinding, keyEventToString, ManagedSource, matchesKeyBinding, parseKeyCombo } from '../domain/models';
import { DistroAvatar } from './DistroAvatar';
import SelectHostPanel from './SelectHostPanel';
@@ -35,6 +35,7 @@ interface SnippetsManagerProps {
onRunSnippet?: (snippet: Snippet, targetHosts: Host[]) => void;
// Props for inline host creation
availableKeys?: SSHKey[];
proxyProfiles?: ProxyProfile[];
managedSources?: ManagedSource[];
onSaveHost?: (host: Host) => void;
onCreateGroup?: (groupPath: string) => void;
@@ -58,6 +59,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
onPackagesChange,
onRunSnippet,
availableKeys = [],
proxyProfiles = [],
managedSources = [],
onSaveHost,
onCreateGroup,
@@ -723,6 +725,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
onBack={handleTargetPickerBack}
onContinue={handleTargetPickerBack}
availableKeys={availableKeys}
proxyProfiles={proxyProfiles}
managedSources={managedSources}
onSaveHost={onSaveHost}
onCreateGroup={onCreateGroup}

View File

@@ -3,7 +3,7 @@ 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";
@@ -32,16 +32,18 @@ import {
import { classifyDistroId } from "../domain/host";
import { resolveHostAuth } from "../domain/sshAuth";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog";
// SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer
import { Button } from "./ui/button";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
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";
import { TerminalConnectionDialog } from "./terminal/TerminalConnectionDialog";
import { HostKeyInfo } from "./terminal/TerminalHostKeyVerification";
import { createKnownHostFromHostKeyInfo, toHostKeyInfo } from "./terminal/hostKeyVerification";
import { TerminalToolbar } from "./terminal/TerminalToolbar";
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
@@ -218,7 +220,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
snippets,
chainHosts = [],
themePreviewId,
knownHosts: _knownHosts = [],
knownHosts = [],
isVisible,
inWorkspace,
isResizing,
@@ -624,8 +626,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
pendingAuthRef,
termRef,
onUpdateHost,
onStartSsh: (term) => {
sessionStartersRef.current?.startSSH(term);
onStartSession: (term) => {
const starters = sessionStartersRef.current;
if (!starters) return;
if (host.moshEnabled) {
starters.startMosh(term);
return;
}
starters.startSSH(term);
},
setStatus: (next) => setStatus(next),
setProgressLogs,
@@ -633,6 +641,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const [needsHostKeyVerification, setNeedsHostKeyVerification] = useState(false);
const [pendingHostKeyInfo, setPendingHostKeyInfo] = useState<HostKeyInfo | null>(null);
const [pendingHostKeyRequestId, setPendingHostKeyRequestId] = useState<string | null>(null);
const pendingConnectionRef = useRef<(() => void) | null>(null);
// OSC-52 clipboard read prompt
@@ -656,6 +665,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef.current?.focus();
}, []);
useEffect(() => {
const dispose = terminalBackend.onHostKeyVerification?.((request) => {
if (request.sessionId !== sessionId) return;
setPendingHostKeyRequestId(request.requestId);
setPendingHostKeyInfo(toHostKeyInfo(request));
setNeedsHostKeyVerification(true);
setError(null);
setProgressLogs((prev) => [
...prev,
request.status === 'changed'
? `Host key changed for ${request.hostname}. Waiting for confirmation...`
: `Host key verification required for ${request.hostname}.`,
]);
});
return () => {
dispose?.();
};
}, [sessionId, terminalBackend]);
const handleTopOverlayMouseDownCapture = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
if (!shouldPreserveTerminalFocusOnMouseDown(e.target)) return;
@@ -680,8 +710,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
@@ -749,6 +791,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
host,
keys,
identities,
knownHosts,
resolvedChainHosts,
sessionId,
startupCommand,
@@ -1351,10 +1394,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (!el) return;
const handleContextMenuCapture = (e: MouseEvent) => {
if (mouseTrackingRef.current) {
e.preventDefault();
e.stopImmediatePropagation();
if (!mouseTrackingRef.current) 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) => {
@@ -1451,6 +1511,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
disableBracketedPasteRef,
scrollOnPasteRef,
});
// 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);
@@ -1487,12 +1553,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
};
const handleCancelConnect = () => {
if (pendingHostKeyRequestId) {
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
}
retryTokenRef.current = null;
setIsCancelling(true);
auth.setNeedsAuth(false);
auth.setAuthRetryMessage(null);
setNeedsHostKeyVerification(false);
setPendingHostKeyInfo(null);
setPendingHostKeyRequestId(null);
setError("Connection cancelled");
setProgressLogs((prev) => [...prev, "Cancelled by user."]);
cleanupSession();
@@ -1514,29 +1584,29 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const handleHostKeyClose = () => {
setNeedsHostKeyVerification(false);
setPendingHostKeyInfo(null);
setPendingHostKeyRequestId(null);
handleCancelConnect();
};
const handleHostKeyContinue = () => {
if (pendingHostKeyRequestId) {
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, true, false);
}
setNeedsHostKeyVerification(false);
if (pendingConnectionRef.current) {
pendingConnectionRef.current();
pendingConnectionRef.current = null;
}
setPendingHostKeyInfo(null);
setPendingHostKeyRequestId(null);
};
const handleHostKeyAddAndContinue = () => {
if (pendingHostKeyInfo && onAddKnownHost) {
const newKnownHost: KnownHost = {
id: `kh-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
hostname: pendingHostKeyInfo.hostname,
port: pendingHostKeyInfo.port || host.port || 22,
keyType: pendingHostKeyInfo.keyType,
publicKey: pendingHostKeyInfo.fingerprint,
discoveredAt: Date.now(),
};
onAddKnownHost(newKnownHost);
onAddKnownHost(createKnownHostFromHostKeyInfo(pendingHostKeyInfo, host));
}
if (pendingHostKeyRequestId) {
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, true, true);
}
setNeedsHostKeyVerification(false);
if (pendingConnectionRef.current) {
@@ -1544,6 +1614,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
pendingConnectionRef.current = null;
}
setPendingHostKeyInfo(null);
setPendingHostKeyRequestId(null);
};
const handleRetry = () => {
@@ -1614,7 +1685,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
};
const shouldShowConnectionDialog = status !== "connected"
&& !needsHostKeyVerification
&& !((isLocalConnection || isSerialConnection) && status === "connecting")
&& !(status === "disconnected" && isDisconnectedDialogDismissed);
@@ -1808,6 +1878,23 @@ const TerminalComponent: React.FC<TerminalProps> = ({
statusDotTone,
)}
/>
{host.protocol !== "local" && host.hostname && host.hostname !== "localhost" && (
<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"));
});
}}
title={t("terminal.statusbar.copyHostname.tooltip", { hostname: host.hostname })}
aria-label={t("terminal.statusbar.copyHostname.label")}
>
<Copy size={10} />
</button>
)}
</div>
{/* Server Stats Display */}
{terminalSettings?.showServerStats && status === 'connected' && serverStats.lastUpdated && (
@@ -2208,18 +2295,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
)
}
{needsHostKeyVerification && pendingHostKeyInfo && (
<div className="absolute inset-0 z-30 bg-background">
<KnownHostConfirmDialog
host={host}
hostKeyInfo={pendingHostKeyInfo}
onClose={handleHostKeyClose}
onContinue={handleHostKeyContinue}
onAddAndContinue={handleHostKeyAddAndContinue}
/>
</div>
)}
{/* OSC-52 clipboard read prompt */}
{osc52ReadPromptVisible && (
<div
@@ -2256,6 +2331,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
_setShowLogs={setShowLogs}
keys={keys}
onDismissDisconnected={handleDismissDisconnectedDialog}
hostKeyVerification={needsHostKeyVerification && pendingHostKeyInfo ? {
hostKeyInfo: pendingHostKeyInfo,
onClose: handleHostKeyClose,
onContinue: handleHostKeyContinue,
onAddAndContinue: handleHostKeyAddAndContinue,
} : undefined}
authProps={{
authMethod: auth.authMethod,
setAuthMethod: auth.setAuthMethod,

View File

@@ -0,0 +1,98 @@
import test from "node:test";
import assert from "node:assert/strict";
import { terminalLayerAreEqual } from "./terminalLayerMemo.ts";
const baseProps = {
hosts: [],
groupConfigs: [],
proxyProfiles: [],
keys: [],
identities: [],
snippets: [],
snippetPackages: [],
sessions: [],
workspaces: [],
knownHosts: [],
draggingSessionId: null,
terminalTheme: {},
accentMode: "theme",
customAccent: null,
terminalSettings: {},
fontSize: 14,
hotkeyScheme: "default",
keyBindings: [],
sftpDefaultViewMode: "list",
sftpDoubleClickBehavior: "open",
sftpAutoSync: false,
sftpShowHiddenFiles: false,
sftpUseCompressedUpload: false,
sftpAutoOpenSidebar: false,
editorWordWrap: false,
setEditorWordWrap: () => {},
onHotkeyAction: () => {},
onUpdateHost: () => {},
onAddKnownHost: () => {},
onToggleWorkspaceViewMode: () => {},
onSetWorkspaceFocusedSession: () => {},
onSplitSession: () => {},
toggleScriptsSidePanelRef: { current: null },
};
test("TerminalLayer re-renders when group configs change", () => {
assert.equal(
terminalLayerAreEqual(
baseProps as never,
{ ...baseProps, groupConfigs: [{ path: "prod", proxyProfileId: "proxy-1" }] } as never,
),
false,
);
});
test("TerminalLayer re-renders when known hosts change", () => {
assert.equal(
terminalLayerAreEqual(
baseProps as never,
{
...baseProps,
knownHosts: [{
id: "kh-1",
hostname: "switch.local",
port: 22,
keyType: "ssh-ed25519",
fingerprint: "fingerprint",
discoveredAt: 1,
}],
} as never,
),
false,
);
});
test("TerminalLayer re-renders when the known host save handler changes", () => {
assert.equal(
terminalLayerAreEqual(
baseProps as never,
{ ...baseProps, onAddKnownHost: () => {} } as never,
),
false,
);
});
test("TerminalLayer re-renders when proxy profiles change", () => {
assert.equal(
terminalLayerAreEqual(
baseProps as never,
{
...baseProps,
proxyProfiles: [{
id: "proxy-1",
label: "Office Proxy",
config: { type: "http", host: "proxy.example.com", port: 3128 },
createdAt: 1,
}],
} as never,
),
false,
);
});

View File

@@ -36,9 +36,10 @@ import {
} from '../infrastructure/config/storageKeys';
import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
import type { DropEntry } from '../lib/sftpFileUtils';
import { GroupConfig, Host, Identity, KnownHost, SSHKey, Snippet, TerminalSession, TerminalTheme, Workspace, WorkspaceNode } from '../types';
import { GroupConfig, Host, Identity, KnownHost, ProxyProfile, SSHKey, Snippet, TerminalSession, TerminalTheme, Workspace, WorkspaceNode } from '../types';
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
import { resolveGroupDefaults, applyGroupDefaults } from '../domain/groupConfig';
import { materializeHostProxyProfile } from '../domain/proxyProfiles';
import { DistroAvatar } from './DistroAvatar';
import Terminal from './Terminal';
import { SftpSidePanel } from './SftpSidePanel';
@@ -56,6 +57,7 @@ import { RippleButton } from './ui/ripple';
import { ScrollArea } from './ui/scroll-area';
import { setupMcpApprovalBridge } from '../infrastructure/ai/shared/approvalGate';
import { resolveScriptsSidePanelShortcutIntent } from '../application/state/resolveSnippetsShortcutIntent';
import { terminalLayerAreEqual } from './terminalLayerMemo';
type SidePanelTab = 'sftp' | 'scripts' | 'theme' | 'ai';
@@ -386,6 +388,7 @@ AIChatPanelsHost.displayName = 'AIChatPanelsHost';
interface TerminalLayerProps {
hosts: Host[];
groupConfigs: GroupConfig[];
proxyProfiles: ProxyProfile[];
keys: SSHKey[];
identities: Identity[];
snippets: Snippet[];
@@ -448,6 +451,7 @@ interface TerminalLayerProps {
const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
hosts,
groupConfigs,
proxyProfiles,
keys,
identities,
snippets,
@@ -879,6 +883,22 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
for (const h of hosts) map.set(h.id, h);
return map;
}, [hosts]);
const proxyProfileIdSet = useMemo(
() => new Set(proxyProfiles.map((profile) => profile.id)),
[proxyProfiles],
);
const effectiveHosts = useMemo(
() => hosts.map((host) => {
const groupDefaults = host.group
? resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet })
: {};
return materializeHostProxyProfile(
applyGroupDefaults(host, groupDefaults, { validProxyProfileIds: proxyProfileIdSet }),
proxyProfiles,
);
}),
[groupConfigs, hosts, proxyProfileIdSet, proxyProfiles],
);
// Pre-compute fallback hosts to avoid creating new objects on every render
const sessionHostsMap = useMemo(() => {
@@ -888,9 +908,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
if (rawHost) {
// Apply group config defaults so Terminal sees the merged host
const groupDefaults = rawHost.group
? resolveGroupDefaults(rawHost.group, groupConfigs)
? resolveGroupDefaults(rawHost.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet })
: {};
const existingHost = applyGroupDefaults(rawHost, groupDefaults);
const existingHost = materializeHostProxyProfile(
applyGroupDefaults(rawHost, groupDefaults, { validProxyProfileIds: proxyProfileIdSet }),
proxyProfiles,
);
const protocol = session.protocol ?? existingHost.protocol;
const port = session.port ?? existingHost.port;
@@ -932,7 +955,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}
}
return map;
}, [sessions, hostMap, groupConfigs]);
}, [sessions, hostMap, groupConfigs, proxyProfileIdSet, proxyProfiles]);
const sessionChainHostsMap = useMemo(() => {
const map = new Map<string, Host[]>();
for (const session of sessions) {
@@ -945,15 +968,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const rawChainHost = hostMap.get(hostId);
if (!rawChainHost) return undefined;
const chainGroupDefaults = rawChainHost.group
? resolveGroupDefaults(rawChainHost.group, groupConfigs)
? resolveGroupDefaults(rawChainHost.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet })
: {};
return applyGroupDefaults(rawChainHost, chainGroupDefaults);
return materializeHostProxyProfile(
applyGroupDefaults(rawChainHost, chainGroupDefaults, { validProxyProfileIds: proxyProfileIdSet }),
proxyProfiles,
);
})
.filter((value): value is Host => Boolean(value)),
);
}
return map;
}, [sessions, sessionHostsMap, hostMap, groupConfigs]);
}, [sessions, sessionHostsMap, hostMap, groupConfigs, proxyProfileIdSet, proxyProfiles]);
const validAIScopeTargetIds = useMemo(() => {
const ids = new Set<string>();
@@ -1282,9 +1308,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
if (activeWorkspace && focusedSessionId) {
return sessionHostsMap.get(focusedSessionId) ?? sftpHostForTab.get(activeTabId) ?? null;
}
// For solo session: use stored host (from when SFTP was opened)
if (activeSession) {
return sessionHostsMap.get(activeSession.id) ?? sftpHostForTab.get(activeTabId) ?? null;
}
return sftpHostForTab.get(activeTabId) ?? null;
}, [isSftpOpenForCurrentTab, activeTabId, activeWorkspace, focusedSessionId, sessionHostsMap, sftpHostForTab]);
}, [isSftpOpenForCurrentTab, activeTabId, activeWorkspace, activeSession, focusedSessionId, sessionHostsMap, sftpHostForTab]);
// Keep sftpHostForTab in sync with focus changes in workspace mode
// so that the toggle check uses the currently displayed host.
@@ -2338,7 +2366,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
return (
<SftpSidePanel
key={tabId}
hosts={hosts}
hosts={effectiveHosts}
writableHosts={hosts}
keys={keys}
identities={identities}
updateHosts={updateHosts}
@@ -2365,6 +2394,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
setEditorWordWrap={setEditorWordWrap}
onGetTerminalCwd={getTerminalCwd}
onRequestTerminalFocus={refocusActiveTerminalSession}
terminalSettings={terminalSettings}
/>
);
})}
@@ -2653,40 +2683,5 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
);
};
// Only re-render when data props change - activeTabId/isVisible are now managed internally via store subscription
const terminalLayerAreEqual = (prev: TerminalLayerProps, next: TerminalLayerProps): boolean => {
return (
prev.hosts === next.hosts &&
prev.keys === next.keys &&
prev.snippets === next.snippets &&
prev.snippetPackages === next.snippetPackages &&
prev.sessions === next.sessions &&
prev.workspaces === next.workspaces &&
prev.draggingSessionId === next.draggingSessionId &&
prev.terminalTheme === next.terminalTheme &&
prev.accentMode === next.accentMode &&
prev.customAccent === next.customAccent &&
prev.terminalSettings === next.terminalSettings &&
prev.fontSize === next.fontSize &&
prev.hotkeyScheme === next.hotkeyScheme &&
prev.keyBindings === next.keyBindings &&
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
prev.sftpAutoSync === next.sftpAutoSync &&
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
prev.sftpAutoOpenSidebar === next.sftpAutoOpenSidebar &&
prev.editorWordWrap === next.editorWordWrap &&
prev.setEditorWordWrap === next.setEditorWordWrap &&
prev.onHotkeyAction === next.onHotkeyAction &&
prev.onUpdateHost === next.onUpdateHost &&
prev.onToggleWorkspaceViewMode === next.onToggleWorkspaceViewMode &&
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
prev.onSplitSession === next.onSplitSession &&
prev.toggleScriptsSidePanelRef === next.toggleScriptsSidePanelRef &&
prev.identities === next.identities
);
};
export const TerminalLayer = memo(TerminalLayerInner, terminalLayerAreEqual);
TerminalLayer.displayName = 'TerminalLayer';

View File

@@ -1,4 +1,4 @@
import { Bell, Copy, FileCode, FileText, Folder, FolderLock, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, Sparkles, Square, Sun, TerminalSquare, Usb, X } from 'lucide-react';
import { Bell, Copy, FileCode, FileText, Folder, FolderLock, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, Settings, Sparkles, Square, Sun, TerminalSquare, Usb, X } from 'lucide-react';
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { activeTabStore, fromEditorTabId, isEditorTabId, useActiveTabId } from '../application/state/activeTabStore';
import type { EditorTab } from '../application/state/editorTabStore';
@@ -1082,6 +1082,17 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</Button>
</div>
{/* Settings gear button - sits to the left of WindowControls on win/linux, at the right edge on mac */}
<div className="self-stretch flex items-stretch">
<button
onClick={onOpenSettings}
className="h-full w-10 flex items-center justify-center transition-all duration-150 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
title="Open Settings"
>
<Settings size={16} />
</button>
</div>
{/* Custom window controls for Windows/Linux */}
{!isMacClient && <div className="self-stretch flex items-stretch"><WindowControls /></div>}
{/* Small drag shim to the right edge (macOS only on Windows the close button should touch the edge) */}

View File

@@ -11,6 +11,8 @@ import { useSettingsState } from "../application/state/useSettingsState";
import { useTrayPanelBackend } from "../application/state/useTrayPanelBackend";
import { useActiveTabId } from "../application/state/activeTabStore";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
import type { Host } from "../domain/models";
import { X, Maximize2, ChevronRight, ChevronDown, Power } from "lucide-react";
import { AppLogo } from "./AppLogo";
@@ -105,7 +107,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,
@@ -117,10 +123,14 @@ const TrayPanelContent: React.FC = () => {
onTrayPanelMenuData,
} = useTrayPanelBackend();
const { hosts, keys, identities, groupConfigs } = useVaultState();
const { hosts, keys, identities, proxyProfiles, groupConfigs } = useVaultState();
useSessionState();
const { rules: portForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
const activeTabId = useActiveTabId();
const proxyProfileIdSet = useMemo(
() => new Set(proxyProfiles.map((profile) => profile.id)),
[proxyProfiles],
);
const [traySessions, setTraySessions] = useState<TraySession[]>([]);
@@ -335,12 +345,16 @@ const TrayPanelContent: React.FC = () => {
if (isActive) {
void stopTunnel(rule.id);
} else {
const host = rawHost.group
? applyGroupDefaults(rawHost, resolveGroupDefaults(rawHost.group, groupConfigs))
: rawHost;
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
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);
}, rule.autoStart, terminalSettings);
}
}}
className={cn(
@@ -401,7 +415,7 @@ const TrayPanel: React.FC = () => {
const settings = useSettingsState();
return (
<I18nProvider locale={settings.uiLanguage}>
<TrayPanelContent />
<TrayPanelContent terminalSettings={settings.terminalSettings} />
</I18nProvider>
);
};

View File

@@ -8,6 +8,7 @@ test("VaultView re-renders when an external section navigation request changes",
hosts: [],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
snippetPackages: [],
customGroups: [],
@@ -30,3 +31,42 @@ test("VaultView re-renders when an external section navigation request changes",
false,
);
});
test("VaultView re-renders when proxy profiles change", () => {
const baseProps = {
hosts: [],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
snippetPackages: [],
customGroups: [],
knownHosts: [],
shellHistory: [],
connectionLogs: [],
sessions: [],
managedSources: [],
groupConfigs: {},
terminalThemeId: "default",
terminalFontSize: 14,
navigateToSection: null,
};
assert.equal(
vaultViewAreEqual(
baseProps as never,
{
...baseProps,
proxyProfiles: [
{
id: "proxy-1",
label: "Proxy",
config: { type: "http", host: "proxy.example.com", port: 3128 },
createdAt: 1,
},
],
} as never,
),
false,
);
});

View File

@@ -12,6 +12,7 @@ import {
FileSymlink,
FolderPlus,
FolderTree,
Globe,
Key,
LayoutGrid,
List,
@@ -35,8 +36,17 @@ import { useI18n } from "../application/i18n/I18nProvider";
import { useStoredViewMode } from "../application/state/useStoredViewMode";
import { useStoredBoolean } from "../application/state/useStoredBoolean";
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
import { sanitizeCredentialValue } from "../domain/credentials";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { getEffectiveHostDistro, sanitizeHost, upsertHostById } from "../domain/host";
import {
getEffectiveHostDistro,
resolveTelnetPassword,
resolveTelnetPort,
resolveTelnetUsername,
sanitizeHost,
upsertHostById,
} from "../domain/host";
import { upsertKnownHost } from "../domain/knownHosts";
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
import type { VaultImportFormat } from "../domain/vaultImport";
import {
@@ -55,6 +65,7 @@ import {
Identity,
KnownHost,
ManagedSource,
ProxyProfile,
SerialConfig,
SSHKey,
ShellHistoryEntry,
@@ -69,6 +80,7 @@ import { HostTreeView } from "./HostTreeView";
import KeychainManager from "./KeychainManager";
import KnownHostsManager from "./KnownHostsManager";
import PortForwarding from "./PortForwardingNew";
import ProxyProfilesManager from "./ProxyProfilesManager";
import QuickConnectWizard from "./QuickConnectWizard";
import { isQuickConnectInput, parseQuickConnectInputWithWarnings } from "../domain/quickConnect";
import SerialConnectModal from "./SerialConnectModal";
@@ -104,7 +116,7 @@ import { HotkeyScheme, KeyBinding } from "../domain/models";
const LazyProtocolSelectDialog = lazy(() => import("./ProtocolSelectDialog"));
const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager"));
export type VaultSection = "hosts" | "keys" | "snippets" | "port" | "knownhosts" | "logs";
export type VaultSection = "hosts" | "keys" | "proxies" | "snippets" | "port" | "knownhosts" | "logs";
type DropTarget =
| { kind: "root" }
@@ -115,6 +127,7 @@ interface VaultViewProps {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
proxyProfiles: ProxyProfile[];
snippets: Snippet[];
snippetPackages: string[];
customGroups: string[];
@@ -135,7 +148,9 @@ interface VaultViewProps {
onConnect: (host: Host) => void;
onUpdateHosts: (hosts: Host[]) => void;
onUpdateKeys: (keys: SSHKey[]) => void;
onImportOrReuseKey: (draft: Partial<SSHKey>) => SSHKey;
onUpdateIdentities: (identities: Identity[]) => void;
onUpdateProxyProfiles: (profiles: ProxyProfile[]) => void;
onUpdateSnippets: (snippets: Snippet[]) => void;
onUpdateSnippetPackages: (pkgs: string[]) => void;
onUpdateCustomGroups: (groups: string[]) => void;
@@ -157,12 +172,14 @@ 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> = ({
hosts,
keys,
identities,
proxyProfiles,
snippets,
snippetPackages,
customGroups,
@@ -183,7 +200,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onConnect,
onUpdateHosts,
onUpdateKeys,
onImportOrReuseKey,
onUpdateIdentities,
onUpdateProxyProfiles,
onUpdateSnippets,
onUpdateSnippetPackages,
onUpdateCustomGroups,
@@ -204,6 +223,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
showOnlyUngroupedHostsInRoot,
navigateToSection,
onNavigateToSectionHandled,
terminalSettings,
}) => {
const { t } = useI18n();
const rootRef = useRef<HTMLDivElement>(null);
@@ -296,6 +316,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
if (!group) return undefined;
return resolveGroupDefaults(group, groupConfigs);
}, [editingHost, newHostGroupPath, selectedGroupPath, groupConfigs]);
const proxyProfileIdSet = useMemo(
() => new Set(proxyProfiles.map((profile) => profile.id)),
[proxyProfiles],
);
// Quick connect state
const [quickConnectTarget, setQuickConnectTarget] = useState<{
hostname: string;
@@ -343,8 +367,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
// Check if host has multiple protocols enabled (using effective/resolved host)
const hasMultipleProtocols = useCallback((host: Host) => {
const effective = host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs))
: host;
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
let count = 0;
// SSH is always available as base protocol (unless explicitly set to something else)
if (effective.protocol === "ssh" || !effective.protocol) count++;
@@ -355,7 +379,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
// If protocol is explicitly telnet (not ssh), count it
if (effective.protocol === "telnet" && !effective.telnetEnabled) count++;
return count > 1;
}, [groupConfigs]);
}, [groupConfigs, proxyProfileIdSet]);
// Handle host connect with protocol selection
const handleHostConnect = useCallback(
@@ -363,14 +387,14 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
if (hasMultipleProtocols(host)) {
// Pass effective host to protocol dialog so it shows correct ports/protocols
const effective = host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs))
: host;
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
setProtocolSelectHost(effective);
} else {
onConnect(host);
}
},
[hasMultipleProtocols, onConnect, groupConfigs],
[hasMultipleProtocols, onConnect, groupConfigs, proxyProfileIdSet],
);
// Handle protocol selection
@@ -475,16 +499,14 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
const handleCopyCredentials = useCallback((host: Host) => {
// Apply group defaults so inherited credentials are included
const effective = host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs))
: host;
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
// Only use telnet-specific port and credentials when protocol is explicitly telnet
// Don't treat telnetEnabled as primary - that's just an optional protocol
const isTelnet = effective.protocol === "telnet";
const defaultPort = isTelnet ? 23 : 22;
const effectivePort = isTelnet
? (effective.telnetPort ?? effective.port ?? 23)
: (effective.port ?? 22);
const effectivePort = isTelnet ? resolveTelnetPort(effective) : (effective.port ?? 22);
// Bracket IPv6 addresses when appending non-default port
let address: string;
@@ -503,12 +525,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
: undefined;
const username = isTelnet
? (effective.telnetUsername?.trim() || effective.username?.trim())
? resolveTelnetUsername(effective)
: (identity?.username?.trim() || effective.username?.trim());
const password = isTelnet
? (effective.telnetPassword || effective.password)
const rawPassword = isTelnet
? resolveTelnetPassword(effective)
: (identity?.password || effective.password);
const password = sanitizeCredentialValue(rawPassword);
if (!password) {
toast.warning(t('vault.hosts.copyCredentials.toast.noPassword'));
@@ -519,7 +542,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
navigator.clipboard.writeText(text).then(() => {
toast.success(t('vault.hosts.copyCredentials.toast.success'));
});
}, [identities, groupConfigs, t]);
}, [identities, groupConfigs, proxyProfileIdSet, t]);
const [lastPinnedId, setLastPinnedId] = useState<string | null>(null);
const toggleHostPinned = useCallback((hostId: string) => {
@@ -1179,7 +1202,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
// Stable callbacks that read from refs
const handleSaveKnownHost = useCallback((kh: KnownHost) => {
onUpdateKnownHostsRef.current([...knownHostsRef.current, kh]);
onUpdateKnownHostsRef.current(upsertKnownHost(knownHostsRef.current, kh));
}, []);
const handleUpdateKnownHost = useCallback((kh: KnownHost) => {
@@ -1669,6 +1692,26 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
</TooltipTrigger>
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.keychain")}</TooltipContent>}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "proxies" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
currentSection === "proxies" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => {
setCurrentSection("proxies");
}}
>
<Globe size={16} className="flex-shrink-0" />
{!sidebarCollapsed && t("vault.nav.proxies")}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.proxies")}</TooltipContent>}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
@@ -2494,11 +2537,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
getDropTargetClasses={(path) =>
getDropTargetClasses({ kind: "group", path })
}
setDragOverDropTarget={setGroupDragOverDropTarget}
/>
getDropTargetClasses={(path) =>
getDropTargetClasses({ kind: "group", path })
}
setDragOverDropTarget={setGroupDragOverDropTarget}
groupConfigs={groupConfigs}
/>
) : sortMode === "group" && groupedDisplayHosts ? (
<div className="space-y-6">
{groupedDisplayHosts.map((group) => (
@@ -2826,6 +2870,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}
onRunSnippet={onRunSnippet}
availableKeys={keys}
proxyProfiles={proxyProfiles}
managedSources={managedSources}
onSaveHost={(host) => onUpdateHosts([...hosts, host])}
onCreateGroup={(groupPath) =>
@@ -2840,6 +2885,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
keys={keys}
identities={identities}
hosts={hosts}
proxyProfiles={proxyProfiles}
customGroups={customGroups}
managedSources={managedSources}
onSave={(k) => onUpdateKeys([...keys, k])}
@@ -2877,11 +2923,22 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}
/>
)}
{currentSection === "proxies" && (
<ProxyProfilesManager
proxyProfiles={proxyProfiles}
hosts={hosts}
groupConfigs={groupConfigs}
onUpdateProxyProfiles={onUpdateProxyProfiles}
onUpdateHosts={onUpdateHosts}
onUpdateGroupConfigs={onUpdateGroupConfigs}
/>
)}
{currentSection === "port" && (
<PortForwarding
hosts={hosts}
keys={keys}
identities={identities}
proxyProfiles={proxyProfiles}
customGroups={customGroups}
managedSources={managedSources}
groupConfigs={groupConfigs}
@@ -2891,6 +2948,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
Array.from(new Set([...customGroups, groupPath])),
)
}
terminalSettings={terminalSettings}
/>
)}
{/* Always render KnownHostsManager but hide with CSS to prevent unmounting */}
@@ -2924,6 +2982,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
config={groupConfigs.find(c => c.path === editingGroupPath)}
availableKeys={keys}
identities={identities}
proxyProfiles={proxyProfiles}
allHosts={hosts}
groups={allGroupPaths}
terminalThemeId={terminalThemeId}
@@ -2944,6 +3003,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
initialData={editingHost}
availableKeys={keys}
identities={identities}
proxyProfiles={proxyProfiles}
groups={allGroupPaths}
managedSources={managedSources}
allTags={allTags}
@@ -2953,6 +3013,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
terminalFontSize={terminalFontSize}
groupDefaults={editingHostGroupDefaults}
groupConfigs={groupConfigs}
onImportKey={onImportOrReuseKey}
onSave={(host) => {
onUpdateHosts(upsertHostById(hosts, host));
setIsHostPanelOpen(false);
@@ -3207,6 +3268,7 @@ export const vaultViewAreEqual = (
prev.hosts === next.hosts &&
prev.keys === next.keys &&
prev.identities === next.identities &&
prev.proxyProfiles === next.proxyProfiles &&
prev.snippets === next.snippets &&
prev.snippetPackages === next.snippetPackages &&
prev.customGroups === next.customGroups &&
@@ -3218,7 +3280,13 @@ export const vaultViewAreEqual = (
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

@@ -49,7 +49,7 @@ const ConversationExport: React.FC<ConversationExportProps> = ({
<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'}
className={className ?? 'h-7 w-7 rounded-md text-muted-foreground/70 hover:bg-accent/60 hover:text-foreground'}
disabled={!hasMessages}
title={t('ai.chat.exportConversation')}
>
@@ -59,18 +59,18 @@ const ConversationExport: React.FC<ConversationExportProps> = ({
<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

@@ -2,20 +2,25 @@
* Proxy Configuration Sub-Panel
* Panel for configuring HTTP/SOCKS5 proxy settings
*/
import { Check,Trash2 } from 'lucide-react';
import React from 'react';
import { Check, Globe, KeyRound, Trash2 } from 'lucide-react';
import React, { useCallback, useMemo } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { isValidProxyPort } from '../../domain/proxyProfiles';
import { cn } from '../../lib/utils';
import { ProxyConfig } from '../../types';
import { AsidePanel,AsidePanelContent,type AsidePanelLayout } from '../ui/aside-panel';
import { ProxyConfig, ProxyProfile } from '../../types';
import { AsidePanel, AsidePanelContent, type AsidePanelLayout } from '../ui/aside-panel';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
import { Input } from '../ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
export interface ProxyPanelProps {
proxyConfig?: ProxyConfig;
proxyProfiles?: ProxyProfile[];
selectedProxyProfileId?: string;
onUpdateProxy: (field: keyof ProxyConfig, value: string | number) => void;
onSelectProxyProfile?: (profileId: string | undefined) => void;
onClearProxy: () => void;
onBack: () => void;
onCancel: () => void;
@@ -24,97 +29,180 @@ export interface ProxyPanelProps {
export const ProxyPanel: React.FC<ProxyPanelProps> = ({
proxyConfig,
proxyProfiles = [],
selectedProxyProfileId,
onUpdateProxy,
onSelectProxyProfile,
onClearProxy,
onBack,
onCancel,
layout = 'overlay',
}) => {
const { t } = useI18n();
const customValue = '__custom__';
const selectedProfile = useMemo(
() => proxyProfiles.find((profile) => profile.id === selectedProxyProfileId),
[proxyProfiles, selectedProxyProfileId],
);
const hasMissingProfile = Boolean(selectedProxyProfileId && !selectedProfile);
const selectedValue = selectedProfile ? selectedProfile.id : customValue;
const isUsingProfile = Boolean(selectedProfile);
const hasManualProxyHost = Boolean(proxyConfig?.host?.trim());
const hasInvalidManualProxyPort = hasManualProxyHost && !isValidProxyPort(proxyConfig?.port);
const canSave = isUsingProfile || (hasManualProxyHost && !hasInvalidManualProxyPort);
const handleBack = useCallback(() => {
if (hasInvalidManualProxyPort) return;
onBack();
}, [hasInvalidManualProxyPort, onBack]);
return (
<AsidePanel
open={true}
onClose={onCancel}
title={t('hostDetails.proxyPanel.title')}
showBackButton={true}
onBack={onBack}
onBack={handleBack}
layout={layout}
actions={
<Button size="sm" onClick={onBack} disabled={!proxyConfig?.host}>
<Button size="sm" onClick={handleBack} disabled={!canSave}>
{t('common.save')}
</Button>
}
>
<AsidePanelContent>
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold">{t('field.type')}</p>
<div className="flex gap-2">
<Button
variant={proxyConfig?.type === 'http' ? "secondary" : "ghost"}
size="sm"
className={cn("h-8", proxyConfig?.type === 'http' && "bg-primary/15")}
onClick={() => onUpdateProxy('type', 'http')}
>
<Check size={14} className={cn("mr-1", proxyConfig?.type !== 'http' && "opacity-0")} />
HTTP
</Button>
<Button
variant={proxyConfig?.type === 'socks5' ? "secondary" : "ghost"}
size="sm"
className={cn("h-8", proxyConfig?.type === 'socks5' && "bg-primary/15")}
onClick={() => onUpdateProxy('type', 'socks5')}
>
<Check size={14} className={cn("mr-1", proxyConfig?.type !== 'socks5' && "opacity-0")} />
SOCKS5
</Button>
{(proxyProfiles.length > 0 || hasMissingProfile) && onSelectProxyProfile && (
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center gap-2">
<Globe size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t('hostDetails.proxyPanel.savedProxy')}</p>
</div>
</div>
<Select
value={selectedValue}
onValueChange={(value) => onSelectProxyProfile(value === customValue ? undefined : value)}
>
<SelectTrigger
aria-label={t('hostDetails.proxyPanel.savedProxy')}
className="h-10"
>
<SelectValue placeholder={t('hostDetails.proxyPanel.selectSaved')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={customValue}>{t('hostDetails.proxyPanel.customProxy')}</SelectItem>
{proxyProfiles.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
{profile.label}
</SelectItem>
))}
</SelectContent>
</Select>
{hasMissingProfile && (
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
{t('hostDetails.proxyPanel.missingSaved')}
</div>
)}
{selectedProfile && (
<div className="min-w-0 rounded-md bg-secondary/50 p-2 text-sm">
<div className="flex min-w-0 items-center gap-2">
<Badge variant="secondary" className="text-xs shrink-0">
{selectedProfile.config.type.toUpperCase()}
</Badge>
<span className="truncate">
{selectedProfile.config.host}:{selectedProfile.config.port}
</span>
</div>
</div>
)}
</Card>
)}
<div className="flex gap-2">
<Input
placeholder={t('hostDetails.proxyPanel.hostPlaceholder')}
value={proxyConfig?.host || ""}
onChange={(e) => onUpdateProxy('host', e.target.value)}
className="h-10 flex-1"
/>
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">{t('hostDetails.port')}</span>
{!isUsingProfile && (
<>
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Globe size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t('field.type')}</p>
</div>
<div className="flex gap-2">
<Button
variant={proxyConfig?.type === 'http' ? "secondary" : "ghost"}
size="sm"
className={cn("h-8", proxyConfig?.type === 'http' && "bg-primary/15")}
onClick={() => onUpdateProxy('type', 'http')}
>
<Check size={14} className={cn("mr-1", proxyConfig?.type !== 'http' && "opacity-0")} />
HTTP
</Button>
<Button
variant={proxyConfig?.type === 'socks5' ? "secondary" : "ghost"}
size="sm"
className={cn("h-8", proxyConfig?.type === 'socks5' && "bg-primary/15")}
onClick={() => onUpdateProxy('type', 'socks5')}
>
<Check size={14} className={cn("mr-1", proxyConfig?.type !== 'socks5' && "opacity-0")} />
SOCKS5
</Button>
</div>
</div>
<div className="flex gap-2">
<Input
aria-label={t('hostDetails.proxyPanel.hostPlaceholder')}
placeholder={t('hostDetails.proxyPanel.hostPlaceholder')}
value={proxyConfig?.host || ""}
onChange={(e) => onUpdateProxy('host', e.target.value)}
className="h-10 flex-1"
/>
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">{t('hostDetails.port')}</span>
<Input
aria-label={t('hostDetails.port')}
type="number"
placeholder="3128"
min={1}
max={65535}
step={1}
value={proxyConfig?.port || ""}
onChange={(e) => onUpdateProxy('port', parseInt(e.target.value) || 0)}
className="h-10 w-20 text-center"
/>
</div>
</div>
{hasInvalidManualProxyPort && (
<p className="text-xs text-destructive">
{t('proxyProfiles.error.port')}
</p>
)}
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<KeyRound size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t('hostDetails.proxyPanel.credentials')}</p>
</div>
<Badge variant="secondary" className="text-xs">{t('common.optional')}</Badge>
</div>
<Input
type="number"
placeholder="3128"
value={proxyConfig?.port || ""}
onChange={(e) => onUpdateProxy('port', parseInt(e.target.value) || 0)}
className="h-10 w-20 text-center"
aria-label={t('hostDetails.proxyPanel.usernamePlaceholder')}
placeholder={t('hostDetails.proxyPanel.usernamePlaceholder')}
value={proxyConfig?.username || ""}
onChange={(e) => onUpdateProxy('username', e.target.value)}
className="h-10"
/>
</div>
</div>
</Card>
<Input
aria-label={t('hostDetails.proxyPanel.passwordPlaceholder')}
placeholder={t('hostDetails.proxyPanel.passwordPlaceholder')}
type="password"
value={proxyConfig?.password || ""}
onChange={(e) => onUpdateProxy('password', e.target.value)}
className="h-10"
/>
</Card>
</>
)}
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold">{t('hostDetails.proxyPanel.credentials')}</p>
<Badge variant="secondary" className="text-xs">{t('common.optional')}</Badge>
</div>
<Input
placeholder={t('hostDetails.proxyPanel.usernamePlaceholder')}
value={proxyConfig?.username || ""}
onChange={(e) => onUpdateProxy('username', e.target.value)}
className="h-10"
/>
<Input
placeholder={t('hostDetails.proxyPanel.passwordPlaceholder')}
type="password"
value={proxyConfig?.password || ""}
onChange={(e) => onUpdateProxy('password', e.target.value)}
className="h-10"
/>
<Button variant="ghost" size="sm" className="text-primary" onClick={() => { }}>
{t('hostDetails.proxyPanel.identities')}
</Button>
</Card>
{proxyConfig?.host && (
{(proxyConfig?.host || selectedProxyProfileId) && (
<Button variant="ghost" className="w-full h-10 text-destructive" onClick={onClearProxy}>
<Trash2 size={14} className="mr-2" /> {t('hostDetails.proxyPanel.remove')}
</Button>

View File

@@ -61,20 +61,18 @@ export const IdentityCard: React.FC<IdentityCardProps> = ({
{summary}
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{viewMode === 'list' && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
onClick();
}}
>
<Pencil size={14} />
</Button>
)}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
onClick();
}}
>
<Pencil size={14} />
</Button>
</div>
</div>
</div>

View File

@@ -69,20 +69,18 @@ export const KeyCard: React.FC<KeyCardProps> = ({
Type {getKeyTypeDisplay(keyItem, isMac)}
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{viewMode === 'list' && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<Pencil size={14} />
</Button>
)}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<Pencil size={14} />
</Button>
</div>
</div>
</div>

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

@@ -19,7 +19,7 @@ import { SettingsTabContent } from "../settings-ui";
export default function SettingsSyncTab(props: {
vault: SyncableVaultData;
portForwardingRules: PortForwardingRule[];
importDataFromString: (data: string) => void;
importDataFromString: (data: string) => void | Promise<void>;
importPortForwardingRules: (rules: PortForwardingRule[]) => void;
clearVaultData: () => void;
onSettingsApplied?: () => void;

View File

@@ -22,6 +22,7 @@ import { Label } from "../../ui/label";
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";
@@ -615,6 +616,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")}
@@ -1034,6 +1046,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

@@ -55,6 +55,10 @@ export interface SftpPaneCallbacks {
onDownloadFiles?: (entries: SftpFileEntry[]) => void; // Batch download — picks one target directory for remote panes
// External file upload (supports folders via DataTransfer)
onUploadExternalFiles?: (dataTransfer: DataTransfer, targetPath?: string) => Promise<void>;
// External file upload from <input type="file" multiple> picker (FileList).
onUploadExternalFileList?: (fileList: FileList, targetPath?: string) => Promise<void>;
// External folder upload from native directory picker.
onUploadExternalFolder?: (targetPath?: string) => Promise<void>;
onListDirectory: (path: string) => Promise<SftpFileEntry[]>;
}
@@ -108,6 +112,8 @@ export const useIsPaneActive = (side: "left" | "right", paneId: string): boolean
export interface SftpContextValue {
// Hosts list for connection picker
hosts: Host[];
// Raw hosts list for bookmark persistence and other host writes.
writableHosts: Host[];
// Host updater for bookmark persistence
updateHosts: (hosts: Host[]) => void;
@@ -159,6 +165,12 @@ export const useSftpHosts = () => {
return context.hosts;
};
// Hook to get raw hosts for writeback
export const useSftpWritableHosts = () => {
const context = useSftpContext();
return context.writableHosts;
};
// Hook to get host updater
export const useSftpUpdateHosts = () => {
const context = useSftpContext();
@@ -167,6 +179,7 @@ export const useSftpUpdateHosts = () => {
interface SftpContextProviderProps {
hosts: Host[];
writableHosts?: Host[];
updateHosts: (hosts: Host[]) => void;
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
dragCallbacks: SftpDragCallbacks;
@@ -177,6 +190,7 @@ interface SftpContextProviderProps {
export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
hosts,
writableHosts,
updateHosts,
draggedFiles,
dragCallbacks,
@@ -188,11 +202,12 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
const value = useMemo<SftpContextValue>(
() => ({
hosts,
writableHosts: writableHosts ?? hosts,
updateHosts,
leftCallbacks,
rightCallbacks,
}),
[hosts, updateHosts, leftCallbacks, rightCallbacks],
[hosts, writableHosts, updateHosts, leftCallbacks, rightCallbacks],
);
// Memoize drag context separately so only drag consumers re-render on drag state changes

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ArrowDown, ArrowRight, ArrowUp, ChevronDown, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2, Unplug } from "lucide-react";
import { ArrowDown, ArrowRight, ArrowUp, ChevronDown, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2, Unplug, Upload } from "lucide-react";
import { Button } from "../ui/button";
import {
ContextMenu,
@@ -18,6 +18,13 @@ import { buildSftpColumnTemplate, type ColumnWidths, type SortField, type SortOr
import { isNavigableDirectory } from "./index";
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
import { SftpFileRow } from "./index";
import {
getSftpListUploadFilesTargetPath,
getSftpUploadFilesLabelKey,
getSftpUploadFolderLabelKey,
shouldShowSftpUploadFolderMenu,
shouldShowSftpUploadFilesMenu,
} from "./sftpUploadMenu";
interface SftpPaneFileListProps {
t: (key: string, params?: Record<string, unknown>) => string;
@@ -60,6 +67,11 @@ interface SftpPaneFileListProps {
onDownloadFile?: (entry: SftpFileEntry) => void;
onDownloadFiles?: (entries: SftpFileEntry[]) => void;
onEditPermissions?: (entry: SftpFileEntry) => void;
onUploadExternalFileList?: (fileList: FileList, targetPath?: string) => Promise<void> | void;
onUploadExternalFolder?: (targetPath?: string) => Promise<void> | void;
// Whether this pane is rendering a local filesystem. Upload menu items only
// make sense for remote (SFTP) panes, so they are suppressed when isLocal.
isLocal?: boolean;
openRenameDialog: (name: string) => void;
openDeleteConfirm: (targets: string[]) => void;
rowHeight: number;
@@ -146,6 +158,9 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
onDownloadFile,
onDownloadFiles,
onEditPermissions,
onUploadExternalFileList,
onUploadExternalFolder,
isLocal = false,
openRenameDialog,
openDeleteConfirm,
rowHeight,
@@ -192,6 +207,45 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
onClearSelection();
}, [onClearSelection, pane.selectedFiles.size]);
// Hidden file input backing the "Upload File(s)" context menu item. It sends
// the original FileList through uploadFromFileList so Electron can still
// resolve local paths for stream uploads.
const uploadEnabled = shouldShowSftpUploadFilesMenu({
isLocal,
hasFileListUpload: !!onUploadExternalFileList,
});
const folderUploadEnabled = shouldShowSftpUploadFolderMenu({
isLocal,
hasFolderUpload: !!onUploadExternalFolder,
});
const uploadInputRef = useRef<HTMLInputElement>(null);
const uploadTargetPathRef = useRef<string | undefined>(undefined);
const triggerUploadPicker = useCallback((targetPath?: string) => {
if (isLocal || !onUploadExternalFileList) return;
const input = uploadInputRef.current;
if (!input) return;
uploadTargetPathRef.current = targetPath;
// Reset value so selecting the same files twice still fires onChange.
input.value = "";
input.click();
}, [isLocal, onUploadExternalFileList]);
const handleUploadInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) {
uploadTargetPathRef.current = undefined;
return;
}
if (!onUploadExternalFileList) {
uploadTargetPathRef.current = undefined;
return;
}
const targetPath = uploadTargetPathRef.current;
uploadTargetPathRef.current = undefined;
void onUploadExternalFileList(files, targetPath);
}, [onUploadExternalFileList]);
const renderRow = useCallback(
(entry: SftpFileEntry, index: number) => (
<ContextMenu>
@@ -349,6 +403,28 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
<ContextMenuItem onClick={() => setShowNewFileDialog(true)}>
<FilePlus size={14} className="mr-2" /> {t("sftp.newFile")}
</ContextMenuItem>
{uploadEnabled && onUploadExternalFileList && (
<ContextMenuItem
onClick={() => {
const target = getSftpListUploadFilesTargetPath(entry, pane.connection?.currentPath ?? "");
triggerUploadPicker(target);
}}
>
<Upload size={14} className="mr-2" />{" "}
{t(getSftpUploadFilesLabelKey(entry))}
</ContextMenuItem>
)}
{folderUploadEnabled && onUploadExternalFolder && (
<ContextMenuItem
onClick={() => {
const target = getSftpListUploadFilesTargetPath(entry, pane.connection?.currentPath ?? "");
void onUploadExternalFolder(target);
}}
>
<Upload size={14} className="mr-2" />{" "}
{t(getSftpUploadFolderLabelKey(entry))}
</ContextMenuItem>
)}
</ContextMenuContent>
)}
</ContextMenu>
@@ -374,6 +450,10 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
onNavigateTo,
onOpenFileWith,
onRefresh,
onUploadExternalFileList,
onUploadExternalFolder,
uploadEnabled,
folderUploadEnabled,
openDeleteConfirm,
openRenameDialog,
pane.connection,
@@ -381,6 +461,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
setShowNewFolderDialog,
setShowNewFileDialog,
t,
triggerUploadPicker,
],
);
@@ -552,9 +633,30 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
}}>
<FilePlus size={14} className="mr-2" />{t("sftp.newFile")}
</ContextMenuItem>
{uploadEnabled && onUploadExternalFileList && (
<ContextMenuItem onClick={() => triggerUploadPicker(undefined)}>
<Upload size={14} className="mr-2" />{t("sftp.context.uploadFiles")}
</ContextMenuItem>
)}
{folderUploadEnabled && onUploadExternalFolder && (
<ContextMenuItem onClick={() => void onUploadExternalFolder(undefined)}>
<Upload size={14} className="mr-2" />{t("sftp.context.uploadFolder")}
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
{/* Hidden file input backing the "Upload File(s)" context menu item. */}
{uploadEnabled && onUploadExternalFileList && (
<input
ref={uploadInputRef}
type="file"
multiple
className="hidden"
onChange={handleUploadInputChange}
/>
)}
{/* Footer */}
<div className="h-9 shrink-0 px-4 flex items-center justify-between text-[11px] text-muted-foreground border-t border-border/40 bg-secondary/30">
<span>

View File

@@ -20,6 +20,7 @@ import {
RefreshCw,
Shield,
Trash2,
Upload,
} from 'lucide-react';
import { Button } from '../ui/button';
import {
@@ -47,6 +48,13 @@ import { sftpTreeSelectionStore, useSftpTreeSelectionState } from './hooks/useSf
import { sftpKeyboardSelectionStore, sftpTreeEnterStore } from './hooks/useSftpKeyboardShortcuts';
import { useI18n } from '../../application/i18n/I18nProvider';
import { isKnownBinaryFile } from '../../lib/sftpFileUtils';
import {
getSftpTreeUploadFilesTargetPath,
getSftpUploadFilesLabelKey,
getSftpUploadFolderLabelKey,
shouldShowSftpUploadFolderMenu,
shouldShowSftpUploadFilesMenu,
} from './sftpUploadMenu';
type NodeDescriptor =
| { type: 'node'; entry: SftpFileEntry; entryPath: string; depth: number; isExpanded: boolean; isLoading: boolean }
@@ -76,6 +84,8 @@ interface SftpPaneTreeViewProps {
openNewFolderDialog: (targetPath: string) => void;
openNewFileDialog: (targetPath: string) => void;
onUploadExternalFiles?: (dataTransfer: DataTransfer, targetPath?: string) => Promise<void>;
onUploadExternalFileList?: (fileList: FileList, targetPath?: string) => Promise<void>;
onUploadExternalFolder?: (targetPath?: string) => Promise<void>;
columnWidths: ColumnWidths;
handleSort: (field: SortField) => void;
handleResizeStart: (field: keyof ColumnWidths, e: React.MouseEvent) => void;
@@ -281,6 +291,8 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
openNewFolderDialog,
openNewFileDialog,
onUploadExternalFiles,
onUploadExternalFileList,
onUploadExternalFolder,
columnWidths,
handleSort,
handleResizeStart,
@@ -297,6 +309,38 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
const [dragOverNodePath, setDragOverNodePath] = useState<string | null>(null);
const onUploadExternalFilesRef = useRef(onUploadExternalFiles);
onUploadExternalFilesRef.current = onUploadExternalFiles;
const onUploadExternalFileListRef = useRef(onUploadExternalFileList);
onUploadExternalFileListRef.current = onUploadExternalFileList;
const uploadInputRef = useRef<HTMLInputElement>(null);
const uploadTargetPathRef = useRef<string | undefined>(undefined);
const uploadEnabled = shouldShowSftpUploadFilesMenu({
isLocal: !!pane.connection?.isLocal,
hasFileListUpload: !!onUploadExternalFileList,
});
const folderUploadEnabled = shouldShowSftpUploadFolderMenu({
isLocal: !!pane.connection?.isLocal,
hasFolderUpload: !!onUploadExternalFolder,
});
const triggerUploadPicker = useCallback((targetPath?: string) => {
if (!uploadEnabled) return;
const input = uploadInputRef.current;
if (!input) return;
uploadTargetPathRef.current = targetPath;
input.value = '';
input.click();
}, [uploadEnabled]);
const handleUploadInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) {
uploadTargetPathRef.current = undefined;
return;
}
const targetPath = uploadTargetPathRef.current;
uploadTargetPathRef.current = undefined;
void onUploadExternalFileListRef.current?.(files, targetPath);
}, []);
// ── Virtual scrolling state ──────────────────────────────────────
const scrollContainerRef = useRef<HTMLDivElement>(null);
@@ -1303,6 +1347,24 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
<ContextMenuItem onClick={() => openNewFileDialogRef.current(isDir ? entryPath : getParentPath(entryPath))}>
<FilePlus size={14} className="mr-2" />{tRef.current('sftp.newFile')}
</ContextMenuItem>
{uploadEnabled && (
<ContextMenuItem
onClick={() => {
triggerUploadPicker(getSftpTreeUploadFilesTargetPath(entry, entryPath));
}}
>
<Upload size={14} className="mr-2" />{tRef.current(getSftpUploadFilesLabelKey(entry))}
</ContextMenuItem>
)}
{folderUploadEnabled && (
<ContextMenuItem
onClick={() => {
void onUploadExternalFolder?.(getSftpTreeUploadFilesTargetPath(entry, entryPath));
}}
>
<Upload size={14} className="mr-2" />{tRef.current(getSftpUploadFolderLabelKey(entry))}
</ContextMenuItem>
)}
</ContextMenuContent>
);
}, [
@@ -1315,6 +1377,10 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
getActionPaths,
toTransferSources,
executeMoveAction,
triggerUploadPicker,
uploadEnabled,
folderUploadEnabled,
onUploadExternalFolder,
]);
return (
@@ -1412,6 +1478,16 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
{contextMenuContent}
</ContextMenu>
{uploadEnabled && (
<input
ref={uploadInputRef}
type="file"
multiple
className="hidden"
onChange={handleUploadInputChange}
/>
)}
{pane.loading && !pane.reconnecting && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-background/40 backdrop-blur-[1px] z-10 pointer-events-none">
<Loader2 size={24} className="animate-spin text-muted-foreground" />

View File

@@ -14,6 +14,7 @@ import {
useSftpHosts,
useSftpPaneCallbacks,
useSftpUpdateHosts,
useSftpWritableHosts,
} from "./index";
import type { SftpPane } from "../../application/state/sftp/types";
import { joinPath } from "../../application/state/sftp/utils";
@@ -96,6 +97,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
const callbacks = useSftpPaneCallbacks(side);
const { draggedFiles, onDragStart, onDragEnd } = useSftpDrag();
const hosts = useSftpHosts();
const writableHosts = useSftpWritableHosts();
const { t } = useI18n();
const hostId = pane.connection?.hostId;
@@ -141,12 +143,12 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
// Bookmark support
const updateHosts = useSftpUpdateHosts();
const currentHost = useMemo(
() => hosts.find((h) => h.id === pane.connection?.hostId),
[hosts, pane.connection?.hostId],
() => writableHosts.find((h) => h.id === pane.connection?.hostId),
[writableHosts, pane.connection?.hostId],
);
const onUpdateHost = useCallback(
(updated: Host) => updateHosts(hosts.map((h) => (h.id === updated.id ? updated : h))),
[hosts, updateHosts],
(updated: Host) => updateHosts(writableHosts.map((h) => (h.id === updated.id ? updated : h))),
[updateHosts, writableHosts],
);
const remoteBookmarks = useSftpBookmarks({
host: currentHost,
@@ -277,6 +279,22 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
}
}, [callbacks, pane.connection?.currentPath, requestTreeReload]);
const handleUploadExternalFileList = useCallback(async (fileList: FileList, targetPath?: string) => {
await callbacks.onUploadExternalFileList?.(fileList, targetPath);
const affectedPath = targetPath ?? pane.connection?.currentPath;
if (affectedPath && affectedPath !== pane.connection?.currentPath) {
requestTreeReload([affectedPath]);
}
}, [callbacks, pane.connection?.currentPath, requestTreeReload]);
const handleUploadExternalFolder = useCallback(async (targetPath?: string) => {
await callbacks.onUploadExternalFolder?.(targetPath);
const affectedPath = targetPath ?? pane.connection?.currentPath;
if (affectedPath && affectedPath !== pane.connection?.currentPath) {
requestTreeReload([affectedPath]);
}
}, [callbacks, pane.connection?.currentPath, requestTreeReload]);
const handleMoveEntriesToPath = useCallback(async (sourcePaths: string[], targetPath: string) => {
await callbacks.onMoveEntriesToPath(sourcePaths, targetPath);
}, [callbacks]);
@@ -522,6 +540,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
openNewFolderDialog={openNewFolderDialogAtPath}
openNewFileDialog={openNewFileDialogAtPath}
onUploadExternalFiles={handleUploadExternalFiles}
onUploadExternalFileList={handleUploadExternalFileList}
onUploadExternalFolder={handleUploadExternalFolder}
columnWidths={columnWidths}
handleSort={handleSortWithTransition}
handleResizeStart={handleResizeStart}
@@ -572,6 +592,9 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
onDownloadFile={callbacks.onDownloadFile}
onDownloadFiles={callbacks.onDownloadFiles}
onEditPermissions={callbacks.onEditPermissions}
onUploadExternalFileList={handleUploadExternalFileList}
onUploadExternalFolder={handleUploadExternalFolder}
isLocal={!!pane.connection?.isLocal}
openRenameDialog={openRenameDialog}
openDeleteConfirm={openDeleteConfirm}
rowHeight={rowHeight}

View File

@@ -106,6 +106,10 @@ interface UseSftpViewFileOpsResult {
onDownloadFilesRight: (files: SftpFileEntry[]) => void;
onUploadExternalFilesLeft: (dataTransfer: DataTransfer, targetPath?: string) => void;
onUploadExternalFilesRight: (dataTransfer: DataTransfer, targetPath?: string) => void;
onUploadExternalFileListLeft: (fileList: FileList, targetPath?: string) => void;
onUploadExternalFileListRight: (fileList: FileList, targetPath?: string) => void;
onUploadExternalFolderLeft: (targetPath?: string) => Promise<void>;
onUploadExternalFolderRight: (targetPath?: string) => Promise<void>;
}
export const useSftpViewFileOps = ({
@@ -418,6 +422,110 @@ export const useSftpViewFileOps = ({
[handleUploadExternalFilesForSide],
);
const handleUploadExternalFileListForSide = useCallback(
async (side: "left" | "right", fileList: FileList, targetPath?: string) => {
try {
const results = await sftpRef.current.uploadExternalFileList(side, fileList, targetPath);
if (results.some((r) => r.cancelled)) {
toast.info(t("sftp.upload.cancelled"), "SFTP");
return;
}
const failCount = results.filter((r) => !r.success && !r.cancelled).length;
const successCount = results.filter((r) => r.success).length;
if (failCount === 0) {
const message =
successCount === 1
? `${t("sftp.upload")}: ${results[0].fileName}`
: `${t("sftp.uploadFiles")}: ${successCount}`;
toast.success(message, "SFTP");
} else {
const failedFiles = results.filter((r) => !r.success && !r.cancelled);
failedFiles.forEach((failed) => {
const errorMsg = failed.error ? ` - ${failed.error}` : "";
toast.error(
`${t("sftp.error.uploadFailed")}: ${failed.fileName}${errorMsg}`,
"SFTP",
);
});
}
} catch (error) {
logger.error("[SftpView] Failed to upload picked files:", error);
toast.error(
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
"SFTP",
);
}
},
[sftpRef, t],
);
const onUploadExternalFileListLeft = useCallback(
(fileList: FileList, targetPath?: string) => handleUploadExternalFileListForSide("left", fileList, targetPath),
[handleUploadExternalFileListForSide],
);
const onUploadExternalFileListRight = useCallback(
(fileList: FileList, targetPath?: string) => handleUploadExternalFileListForSide("right", fileList, targetPath),
[handleUploadExternalFileListForSide],
);
const handleUploadExternalFolderForSide = useCallback(
async (side: "left" | "right", targetPath?: string) => {
if (!selectDirectory) {
toast.error(t("sftp.error.uploadFailed"), "SFTP");
return;
}
const selectedDirectory = await selectDirectory(t("sftp.context.uploadFolder"));
if (!selectedDirectory) return;
try {
const results = await sftpRef.current.uploadExternalFolderPath(side, selectedDirectory, targetPath);
if (results.some((r) => r.cancelled)) {
toast.info(t("sftp.upload.cancelled"), "SFTP");
return;
}
const failCount = results.filter((r) => !r.success && !r.cancelled).length;
if (failCount === 0) {
const folderName = selectedDirectory.split(/[/\\]/).filter(Boolean).pop() || selectedDirectory;
toast.success(`${t("sftp.uploadFolder")}: ${folderName}`, "SFTP");
return;
}
const failedFiles = results.filter((r) => !r.success && !r.cancelled);
failedFiles.forEach((failed) => {
const errorMsg = failed.error ? ` - ${failed.error}` : "";
toast.error(
`${t("sftp.error.uploadFailed")}: ${failed.fileName}${errorMsg}`,
"SFTP",
);
});
} catch (error) {
logger.error("[SftpView] Failed to upload picked folder:", error);
toast.error(
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
"SFTP",
);
}
},
[selectDirectory, sftpRef, t],
);
const onUploadExternalFolderLeft = useCallback(
(targetPath?: string) => handleUploadExternalFolderForSide("left", targetPath),
[handleUploadExternalFolderForSide],
);
const onUploadExternalFolderRight = useCallback(
(targetPath?: string) => handleUploadExternalFolderForSide("right", targetPath),
[handleUploadExternalFolderForSide],
);
const handleDownloadFileForSide = useCallback(
async (side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
@@ -885,5 +993,9 @@ export const useSftpViewFileOps = ({
onDownloadFilesRight,
onUploadExternalFilesLeft,
onUploadExternalFilesRight,
onUploadExternalFileListLeft,
onUploadExternalFileListRight,
onUploadExternalFolderLeft,
onUploadExternalFolderRight,
};
};

View File

@@ -171,6 +171,8 @@ export const useSftpViewPaneCallbacks = ({
onDownloadFile: fileOps.onDownloadFileLeft,
onDownloadFiles: fileOps.onDownloadFilesLeft,
onUploadExternalFiles: fileOps.onUploadExternalFilesLeft,
onUploadExternalFileList: fileOps.onUploadExternalFileListLeft,
onUploadExternalFolder: fileOps.onUploadExternalFolderLeft,
onListDirectory: makeListDirectory("left", () => sftpRef.current.leftPane),
}),
[],
@@ -209,6 +211,8 @@ export const useSftpViewPaneCallbacks = ({
onDownloadFile: fileOps.onDownloadFileRight,
onDownloadFiles: fileOps.onDownloadFilesRight,
onUploadExternalFiles: fileOps.onUploadExternalFilesRight,
onUploadExternalFileList: fileOps.onUploadExternalFileListRight,
onUploadExternalFolder: fileOps.onUploadExternalFolderRight,
onListDirectory: makeListDirectory("right", () => sftpRef.current.rightPane),
}),
[],

View File

@@ -18,6 +18,7 @@ export {
useSftpPaneCallbacks,
useSftpDrag,
useSftpHosts,
useSftpWritableHosts,
useSftpUpdateHosts,
useActiveTabId,
useIsPaneActive,

View File

@@ -0,0 +1,49 @@
import type { SftpFileEntry } from "../../types";
import { getParentPath, joinPath } from "../../application/state/sftp/utils";
import { isNavigableDirectory } from "./utils";
export const shouldShowSftpUploadFilesMenu = ({
isLocal,
hasFileListUpload,
}: {
isLocal: boolean;
hasFileListUpload: boolean;
}) => !isLocal && hasFileListUpload;
export const shouldShowSftpUploadFolderMenu = ({
isLocal,
hasFolderUpload,
}: {
isLocal: boolean;
hasFolderUpload: boolean;
}) => !isLocal && hasFolderUpload;
export const getSftpListUploadFilesTargetPath = (
entry: SftpFileEntry,
currentPath: string,
): string | undefined => {
if (!isNavigableDirectory(entry) || entry.name === "..") {
return undefined;
}
return joinPath(currentPath, entry.name);
};
export const getSftpTreeUploadFilesTargetPath = (
entry: SftpFileEntry,
entryPath: string,
): string | undefined => {
if (entry.name === "..") {
return undefined;
}
return isNavigableDirectory(entry) ? entryPath : getParentPath(entryPath);
};
export const getSftpUploadFilesLabelKey = (entry: SftpFileEntry): string =>
isNavigableDirectory(entry) && entry.name !== ".."
? "sftp.context.uploadFilesHere"
: "sftp.context.uploadFiles";
export const getSftpUploadFolderLabelKey = (entry: SftpFileEntry): string =>
isNavigableDirectory(entry) && entry.name !== ".."
? "sftp.context.uploadFolderHere"
: "sftp.context.uploadFolder";

View File

@@ -323,3 +323,130 @@ test("hides ghost immediately when input no longer matches suggestion", () => {
restoreDocument();
}
});
test("applyKeystroke: printable char trims ghost tail when buffer is unreliable (issue #906)", () => {
// Repro for issue #906: after Tab passes to shell and the typed-buffer
// is flagged unreliable, the ghost addon's currentInput is the only
// source of truth for what the user has typed since the last show().
// Without applyKeystroke, line 798's reliability gate prevents
// adjustToInput from firing and the ghost retains its show-time tail
// — when the next keystroke advances the cursor, the stale tail
// overlaps the just-typed glyph (e.g., typing 't' after 'systemctl s'
// makes the screen read 'systemctl sttop firewalld').
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("systemctl stop firewalld", "systemctl s");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.textContent, "top firewalld");
addon.applyKeystroke("t");
// Ghost tail must shrink by exactly one char so when the shell
// echoes 't', the next visible glyph after the cursor is 'o', not
// 't' (which would render as 'sttop').
assert.equal(ghost.textContent, "op firewalld");
assert.equal(addon.isActive(), true);
} finally {
restoreDocument();
}
});
test("applyKeystroke: backspace re-grows ghost tail by one char", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("docker", "doc");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.textContent, "ker");
addon.applyKeystroke("\x7f");
assert.equal(ghost.textContent, "cker");
} finally {
restoreDocument();
}
});
test("applyKeystroke: Ctrl+W word-erases trailing word from currentInput", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
// Mid-suggestion: user has typed two words; Ctrl+W should drop the
// tail word and let the ghost regrow to cover what was erased.
addon.show("git commit -m wip", "git com");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.textContent, "mit -m wip");
addon.applyKeystroke("\x17");
// The same /\s*\S+\s*$/ regex used by handleInput consumes the
// leading whitespace too, so "git com" → "git"; the ghost regrows
// to cover the now-uncovered leading space + remainder.
assert.equal(ghost.textContent, " commit -m wip");
} finally {
restoreDocument();
}
});
test("applyKeystroke: hides ghost when next char diverges from suggestion", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("docker", "do");
const ghost = ghostElement();
assert.ok(ghost);
// 'x' breaks the prefix invariant — ghost must hide immediately so
// a → -accept after this point can't pull a stale tail onto a line
// that no longer matches the suggestion.
addon.applyKeystroke("x");
assert.equal(ghost.style.display, "none");
assert.equal(addon.isActive(), false);
} finally {
restoreDocument();
}
});
test("applyKeystroke: ignores non-typing data (escape sequences, control codes)", () => {
// Escape sequences and other control codes are routed through
// clearState() in handleInput, not propagated to the ghost — but we
// want applyKeystroke to be a safe no-op if accidentally called with
// them (defense in depth).
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("docker", "do");
const ghost = ghostElement();
assert.ok(ghost);
const tailBefore = ghost.textContent;
addon.applyKeystroke("\x1b[A"); // up-arrow escape sequence
addon.applyKeystroke("\x01"); // Ctrl+A
addon.applyKeystroke(""); // empty
assert.equal(ghost.textContent, tailBefore);
assert.equal(addon.isActive(), true);
} finally {
restoreDocument();
}
});

View File

@@ -0,0 +1,134 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
import type { Host } from "../../types.ts";
import { TerminalConnectionDialog } from "./TerminalConnectionDialog.tsx";
const host: Host = {
id: "host-1",
label: "10.2.0.32",
hostname: "10.2.0.32",
port: 22,
username: "root",
tags: [],
os: "linux",
protocol: "ssh",
};
const renderDialog = (
props: Partial<React.ComponentProps<typeof TerminalConnectionDialog>> = {},
) => renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(TerminalConnectionDialog, {
host,
status: "connecting",
error: null,
progressValue: 55,
chainProgress: null,
needsAuth: false,
showLogs: false,
_setShowLogs: () => {},
keys: [],
authProps: {
authMethod: "password",
setAuthMethod: () => {},
authUsername: "root",
setAuthUsername: () => {},
authPassword: "",
setAuthPassword: () => {},
authKeyId: null,
setAuthKeyId: () => {},
authPassphrase: "",
setAuthPassphrase: () => {},
showAuthPassphrase: false,
setShowAuthPassphrase: () => {},
showAuthPassword: false,
setShowAuthPassword: () => {},
authRetryMessage: null,
onSubmit: () => {},
onCancel: () => {},
isValid: true,
},
progressProps: {
timeLeft: 20,
isCancelling: false,
progressLogs: ["Host key verification required for 10.2.0.32."],
onCancelConnect: () => {},
onCloseSession: () => {},
onRetry: () => {},
},
...props,
}),
),
);
test("renders host key confirmation inside the connection dialog", () => {
const markup = renderDialog({
showLogs: true,
hostKeyVerification: {
hostKeyInfo: {
hostname: "10.2.0.32",
port: 22,
keyType: "ssh-ed25519",
fingerprint: "abc123",
status: "unknown",
},
onClose: () => {},
onContinue: () => {},
onAddAndContinue: () => {},
},
});
assert.match(markup, /Confirm this host key/);
assert.match(markup, /abc123/);
assert.match(markup, /Add and continue/);
assert.match(markup, /Host key verification required for 10\.2\.0\.32\./);
assert.equal(markup.includes("Timeout in"), false);
});
test("renders changed host key warning in the same connection dialog", () => {
const markup = renderDialog({
hostKeyVerification: {
hostKeyInfo: {
hostname: "10.2.0.32",
port: 22,
keyType: "ssh-ed25519",
fingerprint: "new-fingerprint",
knownFingerprint: "old-fingerprint",
status: "changed",
},
onClose: () => {},
onContinue: () => {},
onAddAndContinue: () => {},
},
});
assert.match(markup, /Host key changed/);
assert.match(markup, /new-fingerprint/);
assert.match(markup, /Saved fingerprint/);
assert.match(markup, /old-fingerprint/);
assert.match(markup, /Update and continue/);
});
test("keeps the second progress segment parked until the first segment finishes", () => {
const markup = renderDialog({ progressValue: 75 });
assert.match(markup, /style="width:100%"/);
assert.match(markup, /style="width:0%"/);
});
test("fills both progress segments for disconnected states", () => {
const markup = renderDialog({
status: "disconnected",
error: "Connection timed out.",
progressValue: 5,
});
const fullSegments = markup.match(/style="width:100%"/g) ?? [];
assert.equal(fullSegments.length >= 2, true);
});

View File

@@ -2,16 +2,17 @@
* Terminal Connection Dialog
* Full connection overlay with host info, progress indicator, and auth/progress content
*/
import { Loader2, Plug, TerminalSquare, X } from 'lucide-react';
import { Fingerprint, Loader2, Plug, TerminalSquare, X } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { Host, SSHKey } from '../../types';
import { formatHostPort } from '../../domain/host';
import { formatHostPort, resolveTelnetPort } from '../../domain/host';
import { DistroAvatar } from '../DistroAvatar';
import { Button } from '../ui/button';
import { TerminalAuthDialog, TerminalAuthDialogProps } from './TerminalAuthDialog';
import { TerminalConnectionProgress, TerminalConnectionProgressProps } from './TerminalConnectionProgress';
import { HostKeyInfo, TerminalHostKeyVerification } from './TerminalHostKeyVerification';
export interface ChainProgress {
currentHop: number;
@@ -32,6 +33,12 @@ export interface TerminalConnectionDialogProps {
authProps: Omit<TerminalAuthDialogProps, 'keys'>;
keys: SSHKey[];
onDismissDisconnected?: () => void;
hostKeyVerification?: {
hostKeyInfo: HostKeyInfo;
onClose: () => void;
onContinue: () => void;
onAddAndContinue: () => void;
};
// Progress props
progressProps: Omit<TerminalConnectionProgressProps, 'status' | 'error' | 'showLogs'>;
}
@@ -48,7 +55,7 @@ const getProtocolInfo = (host: Host): { i18nKey: string; showPort: boolean; port
return { i18nKey: 'terminal.connection.protocol.local', showPort: false, port: 0 };
case 'telnet':
// Telnet uses telnetPort, not port (which is SSH port)
return { i18nKey: 'terminal.connection.protocol.telnet', showPort: true, port: host.telnetPort ?? host.port ?? 23 };
return { i18nKey: 'terminal.connection.protocol.telnet', showPort: true, port: resolveTelnetPort(host) };
case 'mosh':
return { i18nKey: 'terminal.connection.protocol.mosh', showPort: true, port: host.port || 22 };
case 'serial':
@@ -71,6 +78,7 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
authProps,
keys,
onDismissDisconnected,
hostKeyVerification,
progressProps,
}) => {
const { t } = useI18n();
@@ -78,6 +86,50 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
const isConnecting = status === 'connecting';
const canDismissDisconnected = status === 'disconnected' && !needsAuth && !!onDismissDisconnected;
const protocolInfo = getProtocolInfo(host);
const isVerifyingHostKey = Boolean(hostKeyVerification);
const isHostKeyChanged = hostKeyVerification?.hostKeyInfo.status === 'changed';
const shouldCompleteProgress = hasError || (!isConnecting && !needsAuth);
const targetFirstSegmentWidth = isVerifyingHostKey || shouldCompleteProgress
? 100
: Math.min(100, progressValue * 2);
const targetSecondSegmentWidth = isVerifyingHostKey
? 0
: shouldCompleteProgress
? 100
: Math.max(0, Math.min(100, (progressValue - 50) * 2));
const [secondSegmentUnlocked, setSecondSegmentUnlocked] = React.useState(
() => shouldCompleteProgress || targetSecondSegmentWidth <= 0
);
const secondSegmentUnlockTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(() => {
return () => {
if (secondSegmentUnlockTimerRef.current) {
clearTimeout(secondSegmentUnlockTimerRef.current);
}
};
}, []);
React.useEffect(() => {
if (needsAuth || isVerifyingHostKey || targetSecondSegmentWidth <= 0 || shouldCompleteProgress) {
if (secondSegmentUnlockTimerRef.current) {
clearTimeout(secondSegmentUnlockTimerRef.current);
secondSegmentUnlockTimerRef.current = null;
}
setSecondSegmentUnlocked(shouldCompleteProgress);
return;
}
if (secondSegmentUnlocked || secondSegmentUnlockTimerRef.current) return;
secondSegmentUnlockTimerRef.current = setTimeout(() => {
secondSegmentUnlockTimerRef.current = null;
setSecondSegmentUnlocked(true);
}, 320);
}, [isVerifyingHostKey, needsAuth, secondSegmentUnlocked, shouldCompleteProgress, targetSecondSegmentWidth]);
const firstSegmentWidth = targetFirstSegmentWidth;
const secondSegmentWidth = shouldCompleteProgress || secondSegmentUnlocked ? targetSecondSegmentWidth : 0;
return (
<div className={cn(
@@ -85,7 +137,7 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
needsAuth ? "bg-black" : "bg-black/30"
)}>
<div
className="w-[480px] max-w-[88vw] rounded-xl shadow-xl p-4 space-y-3"
className="w-[540px] max-w-[88vw] rounded-xl shadow-xl p-4 space-y-3 transition-all duration-200"
style={{
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-bg, var(--background)) 95%, transparent)',
border: '1px solid color-mix(in srgb, var(--terminal-ui-fg, var(--foreground)) 12%, var(--terminal-ui-bg, var(--background)) 88%)',
@@ -139,7 +191,7 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
{showLogs ? t('terminal.connection.hideLogs') : t('terminal.connection.showLogs')}
</Button>
)}
{status === 'connecting' && !needsAuth && (
{status === 'connecting' && !needsAuth && !isVerifyingHostKey && (
<Button
size="sm"
variant="outline"
@@ -169,11 +221,11 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
<div className="flex items-center gap-3">
<div className={cn(
"h-7 w-7 rounded-md flex items-center justify-center flex-shrink-0",
needsAuth
needsAuth || isVerifyingHostKey
? "bg-primary text-primary-foreground"
: hasError
? "bg-destructive/20 text-destructive"
: isConnecting
: isConnecting
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground"
)}>
@@ -185,9 +237,30 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
"absolute inset-y-0 left-0 rounded-full transition-all duration-300",
error ? "bg-destructive" : "bg-primary"
)}
style={{
width: needsAuth ? '0%' : status === 'connecting' ? `${progressValue}%` : error ? '100%' : '100%',
}}
style={{ width: needsAuth ? '0%' : `${firstSegmentWidth}%` }}
/>
</div>
<div className={cn(
"h-7 w-7 rounded-md flex items-center justify-center flex-shrink-0 transition-all duration-200",
isHostKeyChanged
? "bg-destructive/15 text-destructive ring-2 ring-destructive/25 animate-pulse"
: isVerifyingHostKey
? "bg-amber-500/15 text-amber-400 ring-2 ring-amber-400/25 animate-pulse"
: progressValue > 50 && !hasError
? "bg-primary/15 text-primary"
: hasError
? "bg-destructive/20 text-destructive"
: "bg-muted text-muted-foreground"
)}>
<Fingerprint size={13} />
</div>
<div className="flex-1 h-1.5 rounded-full bg-border/60 overflow-hidden relative">
<div
className={cn(
"absolute inset-y-0 left-0 rounded-full transition-all duration-300",
error ? "bg-destructive" : "bg-primary"
)}
style={{ width: needsAuth || isVerifyingHostKey ? '0%' : `${secondSegmentWidth}%` }}
/>
</div>
<div className={cn(
@@ -205,6 +278,15 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
{needsAuth ? (
<TerminalAuthDialog {...authProps} keys={keys} />
) : hostKeyVerification ? (
<TerminalHostKeyVerification
hostKeyInfo={hostKeyVerification.hostKeyInfo}
showLogs={showLogs}
progressLogs={progressProps.progressLogs}
onClose={hostKeyVerification.onClose}
onContinue={hostKeyVerification.onContinue}
onAddAndContinue={hostKeyVerification.onAddAndContinue}
/>
) : (
<TerminalConnectionProgress
status={status}

View File

@@ -20,6 +20,35 @@ export interface TerminalConnectionProgressProps {
onRetry: () => void;
}
export interface TerminalConnectionLogListProps {
progressLogs: string[];
error?: string | null;
}
export const TerminalConnectionLogList: React.FC<TerminalConnectionLogListProps> = ({
progressLogs,
error,
}) => (
<div className="rounded-md border border-border/35 bg-background/40">
<ScrollArea className="max-h-44 p-2.5">
<div className="space-y-1 text-xs text-foreground/90">
{progressLogs.map((line, idx) => (
<div key={idx} className="flex items-start gap-2">
<div className="mt-[0.4rem] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-emerald-500" />
<div className="min-w-0 break-words leading-5">{line}</div>
</div>
))}
{error && (
<div className="flex items-start gap-2 text-destructive">
<div className="mt-[0.4rem] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-destructive" />
<div className="min-w-0 break-words leading-5">{error}</div>
</div>
)}
</div>
</ScrollArea>
</div>
);
export const TerminalConnectionProgress: React.FC<TerminalConnectionProgressProps> = ({
status,
error,
@@ -56,24 +85,7 @@ export const TerminalConnectionProgress: React.FC<TerminalConnectionProgressProp
</div>
{showLogs && (
<div className="rounded-md border border-border/35 bg-background/40">
<ScrollArea className="max-h-44 p-2.5">
<div className="space-y-1 text-xs text-foreground/90">
{progressLogs.map((line, idx) => (
<div key={idx} className="flex items-start gap-2">
<div className="mt-[0.4rem] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-emerald-500" />
<div className="min-w-0 break-words leading-5">{line}</div>
</div>
))}
{error && (
<div className="flex items-start gap-2 text-destructive">
<div className="mt-[0.4rem] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-destructive" />
<div className="min-w-0 break-words leading-5">{error}</div>
</div>
)}
</div>
</ScrollArea>
</div>
<TerminalConnectionLogList progressLogs={progressLogs} error={error} />
)}
<div className="flex justify-end gap-2">

View File

@@ -0,0 +1,128 @@
import { AlertTriangle, Fingerprint } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { Button } from '../ui/button';
import { TerminalConnectionLogList } from './TerminalConnectionProgress';
export interface HostKeyInfo {
hostname: string;
port: number;
keyType: string;
fingerprint: string;
publicKey?: string;
status?: 'unknown' | 'changed';
knownHostId?: string;
knownFingerprint?: string;
}
export interface TerminalHostKeyVerificationProps {
hostKeyInfo: HostKeyInfo;
showLogs: boolean;
progressLogs: string[];
onClose: () => void;
onContinue: () => void;
onAddAndContinue: () => void;
}
export const TerminalHostKeyVerification: React.FC<TerminalHostKeyVerificationProps> = ({
hostKeyInfo,
showLogs,
progressLogs,
onClose,
onContinue,
onAddAndContinue,
}) => {
const { t } = useI18n();
const isChanged = hostKeyInfo.status === 'changed';
const Icon = isChanged ? AlertTriangle : Fingerprint;
return (
<div className="space-y-3 animate-in fade-in-0 slide-in-from-bottom-1 duration-200">
<div
className={cn(
"rounded-xl border px-3 py-2.5",
isChanged
? "border-destructive/25 bg-destructive/8"
: "border-amber-500/20 bg-amber-500/8",
)}
>
<div className="flex items-start gap-2.5">
<div
className={cn(
"mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg",
isChanged
? "bg-destructive/15 text-destructive"
: "bg-amber-500/15 text-amber-400",
)}
>
<Icon size={15} />
</div>
<div className="min-w-0 flex-1 space-y-1">
<div
className={cn(
"text-sm font-semibold",
isChanged ? "text-destructive" : "text-amber-400",
)}
>
{isChanged
? t('terminal.hostKey.changedTitle')
: t('terminal.hostKey.unknownTitle')}
</div>
<p className="text-xs leading-5 text-muted-foreground">
{isChanged
? t('terminal.hostKey.changedDescription', { host: hostKeyInfo.hostname })
: t('terminal.hostKey.unknownDescription', { host: hostKeyInfo.hostname })}
</p>
</div>
</div>
</div>
<div className="space-y-2">
<div className="text-[11px] text-muted-foreground">
{t('terminal.hostKey.fingerprintLabel', { keyType: hostKeyInfo.keyType })}
</div>
<div className="rounded-lg border border-border/50 bg-background/45 p-3">
<code className="block break-all font-mono text-xs leading-5 text-foreground/90">
{hostKeyInfo.fingerprint}
</code>
</div>
{isChanged && hostKeyInfo.knownFingerprint && (
<div className="rounded-lg border border-destructive/25 bg-destructive/8 p-3">
<div className="mb-1 text-[11px] font-medium text-destructive">
{t('terminal.hostKey.savedFingerprintLabel')}
</div>
<code className="block break-all font-mono text-xs leading-5 text-foreground/90">
{hostKeyInfo.knownFingerprint}
</code>
</div>
)}
<p className="text-xs leading-5 text-muted-foreground">
{isChanged
? t('terminal.hostKey.changedHint')
: t('terminal.hostKey.unknownHint')}
</p>
</div>
{showLogs && (
<TerminalConnectionLogList progressLogs={progressLogs} />
)}
<div className="flex justify-end gap-2 pt-1">
<Button variant="ghost" size="sm" className="h-7 px-3 text-[11px]" onClick={onClose}>
{t('common.close')}
</Button>
<Button variant="outline" size="sm" className="h-7 px-3 text-[11px]" onClick={onContinue}>
{t('common.continue')}
</Button>
<Button size="sm" className="h-7 px-3 text-[11px]" onClick={onAddAndContinue}>
{isChanged
? t('terminal.hostKey.updateAndContinue')
: t('terminal.hostKey.addAndContinue')}
</Button>
</div>
</div>
);
};
export default TerminalHostKeyVerification;

View File

@@ -213,6 +213,40 @@ export class GhostTextAddon implements IDisposable {
this.ghostElement.style.display = "block";
}
/**
* Apply a single keystroke's effect to the ghost without consulting the
* outer typed-input buffer. Used when that buffer's reliability flag is
* off (post-Tab, history recall, cursor moves) — without this hook the
* gate at handleInput's adjustToInput call would freeze the ghost at
* the previous show()'s tail, and a subsequent → -accept would paste
* that stale tail on top of the chars typed in the meantime
* (sttop/dduplicate-glyph bug, issue #906).
*
* Only forwards events the ghost can locally re-derive: a printable
* char appends, Backspace/DEL slices off one char, Ctrl-W performs
* the same trailing-word erase as zsh/bash. Anything else (escape
* sequences, other control codes) is treated as a no-op — those
* paths already clearState() in handleInput, so by the time the user
* could trigger an accept, the ghost is gone.
*/
applyKeystroke(data: string): void {
if (this.disposed || !this.currentSuggestion || !data) return;
let nextInput: string;
if (data === "\x7f" || data === "\b") {
if (this.currentInput.length === 0) return;
nextInput = this.currentInput.slice(0, -1);
} else if (data === "\x17") {
const erased = this.currentInput.replace(/\s*\S+\s*$/, "");
if (erased === this.currentInput) return;
nextInput = erased;
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
nextInput = this.currentInput + data;
} else {
return;
}
this.adjustToInput(nextInput);
}
getSuggestion(): string {
return this.currentSuggestion;
}

View File

@@ -789,14 +789,26 @@ export function useTerminalAutocomplete(
// immediately. Without this the ghost keeps the tail it captured at
// show() time; a fast "type + press →" sequence then pastes the
// pre-update tail on top of the new input ("doc" + "cker ls" →
// "doccker ls"). Only safe to call when the buffer is reliable —
// otherwise its content doesn't correspond to the live line and
// adjustToInput would make the ghost lie. Also skip when the user
// has turned showGhostText off mid-session: otherwise a ghost that
// was active before the toggle would keep moving around under a
// setting the user just said to disable (Codex #815 P2).
if (typedBufferReliableRef.current && settingsRef.current.showGhostText) {
ghostAddonRef.current?.adjustToInput(typedInputBufferRef.current);
// "doccker ls"). Skip when the user has turned showGhostText off
// mid-session: otherwise a ghost that was active before the toggle
// would keep moving around under a setting the user just said to
// disable (Codex #815 P2).
//
// Reliable buffer: feed adjustToInput the full post-mutation buffer
// so multi-char pastes refresh the ghost as one batch. Unreliable
// buffer (post Tab / cursor-move / history recall): the buffer
// is just the suffix typed since unreliability began, so feeding
// it to adjustToInput would fail the prefix invariant and hide
// the ghost. Instead let the addon evolve its own currentInput
// off the keystroke directly (issue #906) — that input was seeded
// by the last show() with the live xterm reading, which is the
// only post-Tab source-of-truth we have.
if (settingsRef.current.showGhostText) {
if (typedBufferReliableRef.current) {
ghostAddonRef.current?.adjustToInput(typedInputBufferRef.current);
} else {
ghostAddonRef.current?.applyKeystroke(data);
}
}
// Fast typing suppression: if typing faster than threshold, skip this debounce cycle

View File

@@ -11,7 +11,7 @@ export const useTerminalAuthState = ({
pendingAuthRef,
termRef,
onUpdateHost,
onStartSsh,
onStartSession,
setStatus,
setProgressLogs,
}: {
@@ -19,7 +19,7 @@ export const useTerminalAuthState = ({
pendingAuthRef: RefObject<PendingAuth>;
termRef: RefObject<XTerm | null>;
onUpdateHost?: (host: Host) => void;
onStartSsh: (term: XTerm) => void;
onStartSession: (term: XTerm) => void;
setStatus: (status: TerminalSession["status"]) => void;
setProgressLogs: (next: string[] | ((prev: string[]) => string[])) => void;
}) => {
@@ -106,7 +106,7 @@ export const useTerminalAuthState = ({
logger.warn("Failed to clear terminal", err);
}
onStartSsh(term);
onStartSession(term);
},
[
authKeyId,
@@ -116,7 +116,7 @@ export const useTerminalAuthState = ({
authUsername,
host,
isValid,
onStartSsh,
onStartSession,
onUpdateHost,
pendingAuthRef,
saveCredentials,

View File

@@ -0,0 +1,34 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createKnownHostFromHostKeyInfo, toHostKeyInfo } from "./hostKeyVerification";
test("host key verification keeps the existing known host id when saving", () => {
const hostKeyInfo = toHostKeyInfo({
hostname: "switch.local",
port: 22,
keyType: "unknown",
fingerprint: "new-fingerprint",
status: "changed",
knownHostId: "kh-existing",
knownFingerprint: "old-fingerprint",
});
const knownHost = createKnownHostFromHostKeyInfo(
hostKeyInfo,
{ port: 2200 },
200,
"generated",
);
assert.equal(hostKeyInfo.knownHostId, "kh-existing");
assert.deepEqual(knownHost, {
id: "kh-existing",
hostname: "switch.local",
port: 22,
keyType: "unknown",
publicKey: "SHA256:new-fingerprint",
fingerprint: "new-fingerprint",
discoveredAt: 200,
});
});

View File

@@ -0,0 +1,39 @@
import type { Host, KnownHost } from "../../types";
import type { HostKeyInfo } from "./TerminalHostKeyVerification";
export type HostKeyVerificationRequest = {
hostname: string;
port?: number;
keyType: string;
fingerprint: string;
publicKey?: string;
status?: "unknown" | "changed";
knownHostId?: string;
knownFingerprint?: string;
};
export const toHostKeyInfo = (request: HostKeyVerificationRequest): HostKeyInfo => ({
hostname: request.hostname,
port: request.port,
keyType: request.keyType,
fingerprint: request.fingerprint,
publicKey: request.publicKey,
status: request.status,
knownHostId: request.knownHostId,
knownFingerprint: request.knownFingerprint,
});
export const createKnownHostFromHostKeyInfo = (
hostKeyInfo: HostKeyInfo,
host: Pick<Host, "port">,
now = Date.now(),
idSuffix = Math.random().toString(36).slice(2, 11),
): KnownHost => ({
id: hostKeyInfo.knownHostId || `kh-${now}-${idSuffix}`,
hostname: hostKeyInfo.hostname,
port: hostKeyInfo.port || host.port || 22,
keyType: hostKeyInfo.keyType,
publicKey: hostKeyInfo.publicKey || `SHA256:${hostKeyInfo.fingerprint}`,
fingerprint: hostKeyInfo.fingerprint,
discoveredAt: now,
});

View File

@@ -4,13 +4,19 @@ import type { Terminal as XTerm } from "@xterm/xterm";
import type { Dispatch, RefObject, SetStateAction } from "react";
import { shouldScrollOnTerminalOutput } from "../../../domain/terminalScroll";
import { logger } from "../../../lib/logger";
import type { Host, Identity, SerialConfig, SSHKey, TerminalSession, TerminalSettings } from "../../../types";
import type { Host, Identity, KnownHost, SerialConfig, SSHKey, TerminalSession, TerminalSettings } from "../../../types";
import {
isEncryptedCredentialPlaceholder,
sanitizeCredentialValue,
} from "../../../domain/credentials";
import { resolveHostAuth } from "../../../domain/sshAuth";
import { detectVendorFromSshVersion } from "../../../domain/host";
import {
detectVendorFromSshVersion,
resolveHostKeepalive,
resolveTelnetPassword,
resolveTelnetPort,
resolveTelnetUsername,
} from "../../../domain/host";
/**
* Per-connection token for stale-timer detection. The renderer reuses the
@@ -68,10 +74,18 @@ type TerminalBackendApi = {
sessionId: string,
cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void,
) => () => void;
onTelnetAutoLoginComplete?: (
sessionId: string,
cb: (evt: { sessionId: string }) => void,
) => (() => void) | undefined;
onTelnetAutoLoginCancelled?: (
sessionId: string,
cb: (evt: { sessionId: string }) => void,
) => (() => void) | undefined;
onChainProgress: (
cb: (sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void,
) => (() => void) | undefined;
writeToSession: (sessionId: string, data: string) => void;
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean }) => void;
resizeSession: (sessionId: string, cols: number, rows: number) => void;
};
@@ -99,6 +113,7 @@ export type TerminalSessionStartersContext = {
host: Host;
keys: SSHKey[];
identities?: Identity[];
knownHosts?: KnownHost[];
resolvedChainHosts: Host[];
sessionId: string;
startupCommand?: string;
@@ -143,6 +158,16 @@ export type TerminalSessionStartersContext = {
) => void;
};
export const getMissingChainHostIds = (
host: Host,
resolvedChainHosts: Host[],
): string[] => {
const requestedIds = host.hostChain?.hostIds ?? [];
if (requestedIds.length === 0) return [];
const resolvedIds = new Set(resolvedChainHosts.map((chainHost) => chainHost.id));
return requestedIds.filter((hostId) => !resolvedIds.has(hostId));
};
const buildTermEnv = (host: Host, terminalSettings?: TerminalSettings) => {
const env: Record<string, string> = {
TERM: terminalSettings?.terminalEmulationType ?? "xterm-256color",
@@ -199,6 +224,7 @@ const attachSessionToTerminal = (
opts?: {
onExitMessage?: (evt: { exitCode?: number; signal?: number; error?: string; reason?: string }) => string;
onConnected?: () => void;
onExit?: (evt: { exitCode?: number; signal?: number; error?: string; reason?: string }) => void;
// For serial: convert lone LF to CRLF to avoid "staircase effect"
convertLfToCrlf?: boolean;
},
@@ -253,10 +279,38 @@ const attachSessionToTerminal = (
// (previously they would see the old token still in the map and pass).
connectionTokensBySessionId.delete(ctx.sessionId);
opts?.onExit?.(evt);
ctx.onSessionExit?.(ctx.sessionId, evt);
});
};
const scheduleStartupCommand = (
ctx: TerminalSessionStartersContext,
id: string,
onSettled?: () => void,
): (() => void) | undefined => {
const commandToRun = ctx.startupCommand || ctx.host.startupCommand;
if (!commandToRun || ctx.hasRunStartupCommandRef.current) return undefined;
ctx.hasRunStartupCommandRef.current = true;
const scheduledSessionId = id;
const timeoutId = setTimeout(() => {
if (!ctx.sessionRef.current || ctx.sessionRef.current !== scheduledSessionId) {
onSettled?.();
return;
}
const suffix = ctx.noAutoRun ? "" : "\r";
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}${suffix}`, {
automated: true,
});
onSettled?.();
if (!ctx.noAutoRun && ctx.onCommandExecuted) {
ctx.onCommandExecuted(commandToRun, ctx.host.id, ctx.host.label, ctx.sessionId);
}
}, 600);
return () => clearTimeout(timeoutId);
};
const runDistroDetection = async (
ctx: TerminalSessionStartersContext,
sessionId: string,
@@ -337,6 +391,24 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
return;
}
const missingChainHostIds = getMissingChainHostIds(ctx.host, ctx.resolvedChainHosts);
if (missingChainHostIds.length > 0) {
const base = tr(
"terminal.auth.jumpHostMissing",
"A configured jump host is missing. Open host settings and repair the jump host chain.",
);
const suffix = missingChainHostIds.length > 2
? ` +${missingChainHostIds.length - 2}`
: "";
const message = `${base} (${missingChainHostIds.slice(0, 2).join(", ")}${suffix})`;
ctx.setNeedsAuth(false);
ctx.setAuthRetryMessage(null);
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
const pendingAuth = ctx.pendingAuthRef.current;
const resolvedAuth = resolveHostAuth({
host: ctx.host,
@@ -372,6 +444,13 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
const rawProxyPassword = ctx.host.proxyConfig?.password;
if (ctx.host.proxyProfileId && !ctx.host.proxyConfig) {
const message = `Saved proxy for host "${ctx.host.label || ctx.host.hostname}" is missing. Open host settings and select a valid proxy.`;
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
const hasEncryptedProxyPassword = isEncryptedCredentialPlaceholder(rawProxyPassword);
const proxyConfig = ctx.host.proxyConfig
? {
@@ -384,6 +463,15 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
: undefined;
const jumpHostsWithUnavailableCredentials: string[] = [];
const unresolvedJumpProxyHost = ctx.resolvedChainHosts.find((jumpHost) => jumpHost.proxyProfileId && !jumpHost.proxyConfig);
if (unresolvedJumpProxyHost) {
const message = `Saved proxy for jump host "${unresolvedJumpProxyHost.label || unresolvedJumpProxyHost.hostname}" is missing. Open host settings and select a valid proxy.`;
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
const globalKeepalive = ctx.terminalSettings ?? { keepaliveInterval: 30, keepaliveCountMax: 10 };
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost, index) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
@@ -397,6 +485,18 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
const jumpPassword = sanitizeCredentialValue(rawJumpPassword);
const jumpPrivateKey = sanitizeCredentialValue(rawJumpPrivateKey);
const jumpPassphrase = sanitizeCredentialValue(rawJumpPassphrase);
const jumpAllowsLocalIdentityFallback = !jumpAuth.keyId;
const jumpReferenceKeyPath = jumpAuth.authMethod === "password"
? undefined
: jumpKey?.source === 'reference' ? jumpKey.filePath : undefined;
const jumpIdentityFilePaths = jumpAuth.authMethod === "password"
? undefined
: jumpReferenceKeyPath
? [jumpReferenceKeyPath]
: jumpAllowsLocalIdentityFallback
? jumpHost.identityFilePaths
: undefined;
const hasJumpKeyMaterial = Boolean(jumpPrivateKey || jumpIdentityFilePaths?.length);
const hasConfiguredJumpProxyEndpoint =
index === 0 &&
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
@@ -410,16 +510,21 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
isEncryptedCredentialPlaceholder(rawJumpPrivateKey) ||
isEncryptedCredentialPlaceholder(rawJumpPassphrase);
if (hasEncryptedJumpProxyCredential || (hasEncryptedJumpCredential && !jumpPassword && !jumpPrivateKey)) {
if (hasEncryptedJumpProxyCredential || (hasEncryptedJumpCredential && !jumpPassword && !hasJumpKeyMaterial)) {
jumpHostsWithUnavailableCredentials.push(jumpHost.label || jumpHost.hostname);
}
// Resolve keepalive for THIS hop. Each jump host carries its own
// override toggle, so a bastion that is a router (interval=0) can
// coexist with a cloud target host (interval=30) in the same chain.
const hopKeepalive = resolveHostKeepalive(jumpHost, globalKeepalive);
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
username: jumpAuth.username || "root",
password: jumpPassword,
privateKey: jumpPrivateKey,
privateKey: jumpKey?.source === 'reference' ? undefined : jumpPrivateKey,
certificate: jumpKey?.certificate,
passphrase: jumpPassphrase,
publicKey: jumpKey?.publicKey,
@@ -435,7 +540,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
}
: undefined,
identityFilePaths: jumpHost.identityFilePaths,
identityFilePaths: jumpIdentityFilePaths,
keepaliveInterval: hopKeepalive.interval,
keepaliveCountMax: hopKeepalive.countMax,
};
});
@@ -549,10 +656,29 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
try {
const termEnv = buildTermEnv(ctx.host, ctx.terminalSettings);
const authMethod = resolvedAuth.authMethod;
const allowsLocalIdentityFallback = !resolvedAuth.keyId;
const targetReferenceKeyPath = key?.source === 'reference' ? key.filePath : undefined;
const targetIdentityFilePaths = authMethod === "password"
? undefined
: targetReferenceKeyPath
? [targetReferenceKeyPath]
: allowsLocalIdentityFallback
? ctx.host.identityFilePaths
: undefined;
const startAttempt = async (attempt: {
password?: string;
key?: SSHKey;
}): Promise<string> => {
// Resolve keepalive per-host: a host can opt into its own values
// (e.g. set interval=0 on an embedded device whose SSH stack
// doesn't reply to keepalive@openssh.com) while everything else
// inherits the cloud-friendly global setting.
const keepalive = resolveHostKeepalive(
ctx.host,
ctx.terminalSettings ?? { keepaliveInterval: 30, keepaliveCountMax: 10 },
);
return ctx.terminalBackend.startSSHSession({
sessionId: ctx.sessionId,
hostLabel: ctx.host.label,
@@ -560,7 +686,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
username: effectiveUsername,
port: ctx.host.port || 22,
password: attempt.password,
privateKey: attempt.key?.privateKey,
privateKey: attempt.key?.source === 'reference' ? undefined : sanitizeCredentialValue(attempt.key?.privateKey),
certificate: attempt.key?.certificate,
publicKey: attempt.key?.publicKey,
keyId: attempt.key?.id,
@@ -578,17 +704,17 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
env: termEnv,
proxy: proxyConfig,
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
keepaliveInterval: ctx.terminalSettings?.keepaliveInterval,
keepaliveInterval: keepalive.interval,
keepaliveCountMax: keepalive.countMax,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
// Only pass local key paths if no vault key is explicitly configured
identityFilePaths: attempt.key ? undefined : ctx.host.identityFilePaths,
identityFilePaths: attempt.password ? undefined : targetIdentityFilePaths,
knownHosts: ctx.knownHosts,
});
};
let id: string;
// Respect explicit auth method selection - don't use key if password auth was explicitly selected
const authMethod = resolvedAuth.authMethod;
const hasKeyMaterial = !!sanitizeCredentialValue(key?.privateKey) && authMethod !== 'password';
const hasKeyMaterial = (!!sanitizeCredentialValue(key?.privateKey) || !!targetIdentityFilePaths?.length) && authMethod !== 'password';
const hasPassword = !!effectivePassword;
const needsCredentialReentry =
@@ -655,21 +781,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
`\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
});
const commandToRun = ctx.startupCommand || ctx.host.startupCommand;
if (commandToRun && !ctx.hasRunStartupCommandRef.current) {
ctx.hasRunStartupCommandRef.current = true;
const scheduledSessionId = id;
setTimeout(() => {
// Guard against stale timers: if the session changed (e.g. user
// clicked Start Over quickly), skip to avoid double execution
if (!ctx.sessionRef.current || ctx.sessionRef.current !== scheduledSessionId) return;
const suffix = ctx.noAutoRun ? '' : '\r';
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}${suffix}`);
if (!ctx.noAutoRun && ctx.onCommandExecuted) {
ctx.onCommandExecuted(commandToRun, ctx.host.id, ctx.host.label, ctx.sessionId);
}
}, 600);
}
scheduleStartupCommand(ctx, id);
// Run OS detection only after successful connection. Mint a fresh
// token for this specific connection attempt and register it as
@@ -720,24 +832,109 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
return;
}
if (ctx.host.proxyProfileId && !ctx.host.proxyConfig) {
const message = `Saved proxy for host "${ctx.host.label || ctx.host.hostname}" is missing. Open host settings and select a valid proxy.`;
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
if (ctx.host.proxyConfig?.host && ctx.host.proxyConfig?.port) {
const message = "Telnet does not support proxy connections. Use SSH for this host or remove the proxy from this connection.";
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
let disposeAutoLoginComplete: (() => void) | undefined;
let disposeAutoLoginCancelled: (() => void) | undefined;
let cancelPendingStartupCommand: (() => void) | undefined;
const disposeAutoLoginListener = () => {
disposeAutoLoginComplete?.();
disposeAutoLoginComplete = undefined;
};
const disposeAutoLoginCancelListener = () => {
disposeAutoLoginCancelled?.();
disposeAutoLoginCancelled = undefined;
};
const cleanupTelnetStartupWait = () => {
disposeAutoLoginListener();
disposeAutoLoginCancelListener();
cancelPendingStartupCommand?.();
cancelPendingStartupCommand = undefined;
};
try {
const telnetEnv = buildTermEnv(ctx.host, ctx.terminalSettings);
const telnetUsername = resolveTelnetUsername(ctx.host);
const rawTelnetPassword = resolveTelnetPassword(ctx.host);
const telnetPassword = sanitizeCredentialValue(rawTelnetPassword);
const hasTelnetPasswordForAutoLogin = rawTelnetPassword !== undefined;
if (isEncryptedCredentialPlaceholder(rawTelnetPassword)) {
const message = tr(
"terminal.auth.credentialsUnavailable",
"Saved credentials cannot be decrypted on this device. Please re-enter and save them again.",
);
ctx.setNeedsAuth(false);
ctx.setAuthRetryMessage(null);
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
const commandToRun = ctx.startupCommand || ctx.host.startupCommand;
const waitsForAutoLogin = Boolean(
commandToRun &&
(telnetUsername || hasTelnetPasswordForAutoLogin) &&
ctx.terminalBackend.onTelnetAutoLoginComplete,
);
let telnetSessionId = ctx.sessionId;
if (waitsForAutoLogin) {
disposeAutoLoginComplete = ctx.terminalBackend.onTelnetAutoLoginComplete?.(
ctx.sessionId,
() => {
disposeAutoLoginListener();
cancelPendingStartupCommand = scheduleStartupCommand(ctx, telnetSessionId, () => {
cancelPendingStartupCommand = undefined;
disposeAutoLoginCancelListener();
});
},
);
disposeAutoLoginCancelled = ctx.terminalBackend.onTelnetAutoLoginCancelled?.(
ctx.sessionId,
cleanupTelnetStartupWait,
);
}
const id = await ctx.terminalBackend.startTelnetSession({
sessionId: ctx.sessionId,
hostname: ctx.host.hostname,
port: ctx.host.telnetPort || ctx.host.port || 23,
port: resolveTelnetPort(ctx.host),
username: telnetUsername,
password: telnetPassword,
cols: term.cols,
rows: term.rows,
charset: ctx.host.charset,
env: telnetEnv,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
});
telnetSessionId = id;
attachSessionToTerminal(ctx, term, id, {
onExitMessage: (evt) =>
`\r\n[Telnet session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
});
} catch (err) {
attachSessionToTerminal(ctx, term, id, {
onExitMessage: (evt) =>
`\r\n[Telnet session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
onExit: cleanupTelnetStartupWait,
});
const disposeTelnetExit = ctx.disposeExitRef.current;
ctx.disposeExitRef.current = () => {
cleanupTelnetStartupWait();
disposeTelnetExit?.();
};
if (waitsForAutoLogin) {
return;
}
} catch (err) {
cleanupTelnetStartupWait();
const message = err instanceof Error ? err.message : String(err);
ctx.setError(message);
term.writeln(`\r\n[Failed to start Telnet: ${message}]`);
@@ -754,6 +951,39 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
try {
const stopMosh = (message: string) => {
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
ctx.updateStatus("disconnected");
};
if (ctx.host.proxyProfileId && !ctx.host.proxyConfig) {
stopMosh(`Saved proxy for host "${ctx.host.label || ctx.host.hostname}" is missing. Open host settings and select a valid proxy.`);
return;
}
const hasConfiguredJumpHostChain =
(ctx.host.hostChain?.hostIds?.length || 0) > 0 ||
ctx.resolvedChainHosts.length > 0;
if (hasConfiguredJumpHostChain) {
stopMosh("Mosh does not support jump host chains. Use SSH for this host or remove the jump hosts from this connection.");
return;
}
const unresolvedJumpProxyHost = ctx.resolvedChainHosts.find((jumpHost) => jumpHost.proxyProfileId && !jumpHost.proxyConfig);
if (unresolvedJumpProxyHost) {
stopMosh(`Saved proxy for jump host "${unresolvedJumpProxyHost.label || unresolvedJumpProxyHost.hostname}" is missing. Open host settings and select a valid proxy.`);
return;
}
const hasConfiguredProxy =
Boolean(ctx.host.proxyConfig?.host && ctx.host.proxyConfig?.port) ||
ctx.resolvedChainHosts.some((jumpHost) => Boolean(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port));
if (hasConfiguredProxy) {
stopMosh("Mosh does not support proxy connections. Use SSH for this host or remove the proxy from this connection.");
return;
}
const pendingAuth = ctx.pendingAuthRef.current;
const resolvedAuth = resolveHostAuth({
host: ctx.host,
@@ -770,12 +1000,53 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
: null,
});
const effectivePassword = sanitizeCredentialValue(resolvedAuth.password);
const effectivePassphrase = sanitizeCredentialValue(resolvedAuth.passphrase);
const authMethod = resolvedAuth.authMethod;
const key = authMethod === "password" ? undefined : resolvedAuth.key;
const hasEncryptedPrimaryPassword = isEncryptedCredentialPlaceholder(resolvedAuth.password);
const hasEncryptedPrimaryKey = isEncryptedCredentialPlaceholder(resolvedAuth.key?.privateKey);
const allowsLocalIdentityFallback = !resolvedAuth.keyId;
const moshReferenceKeyPath = key?.source === 'reference' ? key.filePath : undefined;
const moshIdentityFilePaths = authMethod === "password"
? undefined
: moshReferenceKeyPath
? [moshReferenceKeyPath]
: allowsLocalIdentityFallback
? ctx.host.identityFilePaths
: undefined;
const hasKeyMaterial = (!!sanitizeCredentialValue(key?.privateKey) || !!moshIdentityFilePaths?.length) && authMethod !== "password";
const hasPassword = !!effectivePassword;
const needsCredentialReentry =
(authMethod === "password" && hasEncryptedPrimaryPassword && !hasPassword) ||
(authMethod !== "password" && hasEncryptedPrimaryKey && !hasKeyMaterial && !hasPassword);
if (needsCredentialReentry) {
ctx.setError(null);
ctx.setNeedsAuth(true);
ctx.setAuthRetryMessage(
tr(
"terminal.auth.credentialsUnavailable",
"Saved credentials cannot be decrypted on this device. Please re-enter and save them again.",
),
);
ctx.setAuthPassword("");
ctx.setStatus("connecting");
return;
}
const moshEnv = buildTermEnv(ctx.host, ctx.terminalSettings);
const id = await ctx.terminalBackend.startMoshSession({
sessionId: ctx.sessionId,
hostname: ctx.host.hostname,
username: resolvedAuth.username || "root",
password: effectivePassword,
privateKey: key?.source === 'reference' ? undefined : sanitizeCredentialValue(key?.privateKey),
certificate: key?.certificate,
keyId: key?.id,
passphrase: key
? (effectivePassphrase || sanitizeCredentialValue(key.passphrase))
: undefined,
identityFilePaths: moshIdentityFilePaths,
port: ctx.host.port || 22,
moshServerPath: ctx.host.moshServerPath,
agentForwarding: ctx.host.agentForwarding,
@@ -791,19 +1062,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
`\r\n[Mosh session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
});
const commandToRun = ctx.startupCommand || ctx.host.startupCommand;
if (commandToRun && !ctx.hasRunStartupCommandRef.current) {
ctx.hasRunStartupCommandRef.current = true;
const scheduledSessionId = id;
setTimeout(() => {
if (!ctx.sessionRef.current || ctx.sessionRef.current !== scheduledSessionId) return;
const suffix = ctx.noAutoRun ? '' : '\r';
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}${suffix}`);
if (!ctx.noAutoRun && ctx.onCommandExecuted) {
ctx.onCommandExecuted(commandToRun, ctx.host.id, ctx.host.label, ctx.sessionId);
}
}, 600);
}
scheduleStartupCommand(ctx, id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
ctx.setError(message);

View File

@@ -37,6 +37,11 @@ import {
isEraseScrollbackSequence,
preserveTerminalViewportInScrollback,
} from "../clearTerminalViewport";
import {
createKittyKeyboardModeState,
encodeKittyControlKey,
} from "./kittyKeyboardProtocol";
import { installKittyKeyboardProtocolHandlers } from "./kittyKeyboardRuntime";
import { installUserCursorPreferenceGuard } from "./cursorPreference";
import type {
Host,
@@ -205,6 +210,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const wordSeparator = settings?.wordSeparators ?? " ()[]{}'\"";
const keywordHighlightRules = settings?.keywordHighlightRules ?? [];
const keywordHighlightEnabled = settings?.keywordHighlightEnabled ?? false;
const kittyKeyboardMode = createKittyKeyboardModeState();
const resolvedFontWeightBold = (() => {
if (typeof document === "undefined" || !document.fonts?.check) {
@@ -286,7 +292,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
};
};
const logRenderer = (attempt = 0) => {
const trackRenderer = (attempt = 0) => {
const introspected = term as IntrospectableTerminal;
const renderer = introspected._core?._renderService?._renderer;
const candidates = [
@@ -304,11 +310,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
? "canvas"
: rendererName
: "unknown";
logger.info(`[XTerm] renderer=${normalized}`);
const scopedWindow = window as Window & { __xtermRenderer?: string };
scopedWindow.__xtermRenderer = normalized;
if (normalized === "unknown" && attempt < 3) {
setTimeout(() => logRenderer(attempt + 1), 150);
setTimeout(() => trackRenderer(attempt + 1), 150);
}
};
@@ -396,7 +401,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
term.loadAddon(unicodeGraphemes);
term.unicode.activeVersion = '15-graphemes';
logRenderer();
trackRenderer();
const appLevelActions = getAppLevelActions();
const terminalActions = getTerminalPassthroughActions();
@@ -515,73 +520,87 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
const currentBindings = ctx.keyBindingsRef.current;
if (currentScheme === "disabled" || currentBindings.length === 0) {
return true;
}
if (currentScheme !== "disabled" && currentBindings.length > 0) {
const matched = checkAppShortcut(e, currentBindings, isMac);
if (matched) {
const { action } = matched;
const matched = checkAppShortcut(e, currentBindings, isMac);
if (!matched) return true;
const { action } = matched;
if (appLevelActions.has(action)) {
return true; // Let app-level handler process it
}
if (terminalActions.has(action)) {
e.preventDefault();
e.stopPropagation();
switch (action) {
case "copy": {
const selection = term.getSelection();
if (selection) navigator.clipboard.writeText(selection);
break;
if (appLevelActions.has(action)) {
return true; // Let app-level handler process it
}
case "paste": {
navigator.clipboard.readText().then((text) => {
const id = ctx.sessionRef.current;
if (id) {
const rawData = normalizeLineEndings(text);
const data = term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste
? wrapBracketedPaste(rawData)
: rawData;
// Notify autocomplete with the final bytes so bracketed
// pastes preserve their inner newlines as literal input.
ctx.onAutocompleteInput?.(data);
ctx.terminalBackend.writeToSession(id, data);
scrollToBottomAfterPaste();
if (terminalActions.has(action)) {
e.preventDefault();
e.stopPropagation();
switch (action) {
case "copy": {
const selection = term.getSelection();
if (selection) navigator.clipboard.writeText(selection);
break;
}
case "paste": {
navigator.clipboard.readText().then((text) => {
const id = ctx.sessionRef.current;
if (id) {
const rawData = normalizeLineEndings(text);
const data = term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste
? wrapBracketedPaste(rawData)
: rawData;
// Notify autocomplete with the final bytes so bracketed
// pastes preserve their inner newlines as literal input.
ctx.onAutocompleteInput?.(data);
ctx.terminalBackend.writeToSession(id, data);
scrollToBottomAfterPaste();
}
});
break;
}
case "pasteSelection": {
const selection = term.getSelection();
const id = ctx.sessionRef.current;
if (selection && id) {
const rawData = normalizeLineEndings(selection);
const data = term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste
? wrapBracketedPaste(rawData)
: rawData;
ctx.onAutocompleteInput?.(data);
ctx.terminalBackend.writeToSession(id, data);
scrollToBottomAfterPaste();
}
break;
}
case "selectAll": {
term.selectAll();
break;
}
case "clearBuffer": {
clearTerminalViewport(term);
break;
}
case "searchTerminal": {
ctx.setIsSearchOpen(true);
break;
}
});
break;
}
case "pasteSelection": {
const selection = term.getSelection();
const id = ctx.sessionRef.current;
if (selection && id) {
const rawData = normalizeLineEndings(selection);
const data = term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste
? wrapBracketedPaste(rawData)
: rawData;
ctx.onAutocompleteInput?.(data);
ctx.terminalBackend.writeToSession(id, data);
scrollToBottomAfterPaste();
}
break;
}
case "selectAll": {
term.selectAll();
break;
}
case "clearBuffer": {
clearTerminalViewport(term);
break;
}
case "searchTerminal": {
ctx.setIsSearchOpen(true);
break;
return false;
}
}
return false;
}
const kittyControlSequence = encodeKittyControlKey(kittyKeyboardMode, e);
if (kittyControlSequence) {
const id = ctx.sessionRef.current;
if (id) {
e.preventDefault();
e.stopPropagation();
ctx.onAutocompleteInput?.(kittyControlSequence);
ctx.terminalBackend.writeToSession(id, kittyControlSequence);
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
ctx.onBroadcastInputRef.current(kittyControlSequence, ctx.sessionId);
}
scrollToBottomAfterInput(kittyControlSequence);
return false;
}
}
return true;
@@ -733,6 +752,18 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
return !wipeAllowed;
});
const writeKittyKeyboardReply = (payload: string) => {
const id = ctx.sessionRef.current;
if (!id) return;
ctx.terminalBackend.writeToSession(id, payload);
};
const kittyKeyboardDisposable = installKittyKeyboardProtocolHandlers(
term.parser,
kittyKeyboardMode,
writeKittyKeyboardReply,
);
// Register OSC 7 handler using xterm.js parser
// OSC 7 is the standard way for shells to report the current working directory
const osc7Disposable = term.parser.registerOscHandler(7, (data) => {
@@ -858,6 +889,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
cleanupMiddleClick?.();
keywordHighlighter.dispose();
eraseScrollbackDisposable.dispose();
kittyKeyboardDisposable.dispose();
osc7Disposable.dispose();
osc52Disposable.dispose();
cursorPreferenceDisposable?.dispose();

View File

@@ -0,0 +1,244 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
buildKittyKeyboardModeQueryResponse,
createKittyKeyboardModeState,
encodeKittyControlKey,
popKittyKeyboardModeFlags,
pushKittyKeyboardModeFlags,
setKittyKeyboardAlternateScreenActive,
setKittyKeyboardModeFlags,
} from "./kittyKeyboardProtocol";
import {
installKittyKeyboardProtocolHandlers,
readKittyKeyboardCsiParam,
type KittyKeyboardCsiParams,
} from "./kittyKeyboardRuntime";
type CsiHandlerId = {
prefix?: string;
intermediates?: string;
final: string;
};
type CsiHandler = (params: KittyKeyboardCsiParams) => boolean;
const csiKey = (id: CsiHandlerId): string => (
`${id.prefix ?? ""}|${id.intermediates ?? ""}|${id.final}`
);
const createFakeCsiParser = () => {
const handlers = new Map<string, CsiHandler[]>();
return {
parser: {
registerCsiHandler(id: CsiHandlerId, callback: CsiHandler) {
const key = csiKey(id);
const list = handlers.get(key) ?? [];
list.push(callback);
handlers.set(key, list);
return {
dispose: () => {
const current = handlers.get(key);
if (!current) return;
const index = current.indexOf(callback);
if (index >= 0) current.splice(index, 1);
if (current.length === 0) handlers.delete(key);
},
};
},
},
dispatch(id: CsiHandlerId, params: KittyKeyboardCsiParams = []) {
const list = handlers.get(csiKey(id));
assert.ok(list?.length, `missing CSI handler for ${csiKey(id)}`);
for (let index = list.length - 1; index >= 0; index -= 1) {
if (list[index](params)) return true;
}
return false;
},
hasHandler(id: CsiHandlerId) {
return handlers.has(csiKey(id));
},
};
};
test("kitty keyboard query reports the active screen flags", () => {
const state = createKittyKeyboardModeState();
setKittyKeyboardModeFlags(state, 1, 1);
assert.equal(buildKittyKeyboardModeQueryResponse(state), "\u001b[?1u");
setKittyKeyboardAlternateScreenActive(state, true);
assert.equal(buildKittyKeyboardModeQueryResponse(state), "\u001b[?0u");
});
test("kitty keyboard set mode respects replace, union, and subtract semantics", () => {
const state = createKittyKeyboardModeState();
setKittyKeyboardModeFlags(state, 1, 1);
assert.equal(buildKittyKeyboardModeQueryResponse(state), "\u001b[?1u");
setKittyKeyboardModeFlags(state, 8, 2);
assert.equal(buildKittyKeyboardModeQueryResponse(state), "\u001b[?9u");
setKittyKeyboardModeFlags(state, 8, 3);
assert.equal(buildKittyKeyboardModeQueryResponse(state), "\u001b[?1u");
});
test("kitty keyboard mode ignores unsupported progressive enhancement flags", () => {
const state = createKittyKeyboardModeState();
setKittyKeyboardModeFlags(state, 1 | 2 | 4 | 8 | 16, 1);
assert.equal(buildKittyKeyboardModeQueryResponse(state), "\u001b[?9u");
setKittyKeyboardModeFlags(state, 2 | 4 | 16, 1);
assert.equal(buildKittyKeyboardModeQueryResponse(state), "\u001b[?0u");
});
test("kitty keyboard mode stacks are independent for main and alternate screen", () => {
const state = createKittyKeyboardModeState();
setKittyKeyboardModeFlags(state, 1, 1);
pushKittyKeyboardModeFlags(state, 0);
assert.equal(buildKittyKeyboardModeQueryResponse(state), "\u001b[?0u");
setKittyKeyboardAlternateScreenActive(state, true);
assert.equal(buildKittyKeyboardModeQueryResponse(state), "\u001b[?0u");
setKittyKeyboardModeFlags(state, 1, 1);
assert.equal(buildKittyKeyboardModeQueryResponse(state), "\u001b[?1u");
popKittyKeyboardModeFlags(state, 1);
assert.equal(buildKittyKeyboardModeQueryResponse(state), "\u001b[?0u");
setKittyKeyboardAlternateScreenActive(state, false);
assert.equal(buildKittyKeyboardModeQueryResponse(state), "\u001b[?0u");
popKittyKeyboardModeFlags(state, 1);
assert.equal(buildKittyKeyboardModeQueryResponse(state), "\u001b[?1u");
});
test("kitty control key encoding keeps bare enter legacy but disambiguates modified enter", () => {
const state = createKittyKeyboardModeState();
setKittyKeyboardModeFlags(state, 1, 1);
assert.equal(
encodeKittyControlKey(state, { key: "Enter" }),
null,
);
assert.equal(
encodeKittyControlKey(state, { key: "Enter", shiftKey: true }),
"\u001b[13;2u",
);
assert.equal(
encodeKittyControlKey(state, { key: "Escape" }),
"\u001b[27u",
);
assert.equal(
encodeKittyControlKey(state, { key: "Backspace", ctrlKey: true, altKey: true }),
"\u001b[127;7u",
);
});
test("kitty keyboard CSI param reader applies fallbacks for odd params", () => {
assert.equal(readKittyKeyboardCsiParam([], 0, 7), 7);
assert.equal(readKittyKeyboardCsiParam([0], 0, 7), 7);
assert.equal(readKittyKeyboardCsiParam([-1], 0, 7), 7);
assert.equal(readKittyKeyboardCsiParam([[8, 9]], 0, 7), 8);
assert.equal(readKittyKeyboardCsiParam([1], 1, 7), 7);
});
test("kitty keyboard CSI handlers negotiate mode and enable Shift+Enter encoding", () => {
const state = createKittyKeyboardModeState();
const fake = createFakeCsiParser();
const replies: string[] = [];
const disposable = installKittyKeyboardProtocolHandlers(
fake.parser,
state,
(payload) => replies.push(payload),
);
assert.equal(fake.dispatch({ prefix: "?", final: "u" }), true);
assert.deepEqual(replies, ["\u001b[?0u"]);
assert.equal(fake.dispatch({ prefix: "=", final: "u" }, [1]), true);
assert.equal(
encodeKittyControlKey(state, { key: "Enter", shiftKey: true }),
"\u001b[13;2u",
);
assert.equal(encodeKittyControlKey(state, { key: "Enter" }), null);
fake.dispatch({ prefix: "?", final: "u" });
assert.equal(replies.at(-1), "\u001b[?1u");
disposable.dispose();
assert.equal(fake.hasHandler({ prefix: "?", final: "u" }), false);
assert.equal(fake.hasHandler({ prefix: "=", final: "u" }), false);
assert.equal(fake.hasHandler({ prefix: ">", final: "u" }), false);
assert.equal(fake.hasHandler({ prefix: "<", final: "u" }), false);
assert.equal(fake.hasHandler({ prefix: "?", final: "h" }), false);
assert.equal(fake.hasHandler({ prefix: "?", final: "l" }), false);
});
test("kitty keyboard CSI handlers handle invalid modes and stack defaults", () => {
const state = createKittyKeyboardModeState();
const fake = createFakeCsiParser();
const replies: string[] = [];
installKittyKeyboardProtocolHandlers(fake.parser, state, (payload) => replies.push(payload));
fake.dispatch({ prefix: "=", final: "u" }, [8]);
fake.dispatch({ prefix: "=", final: "u" }, [1, 99]);
fake.dispatch({ prefix: "?", final: "u" });
assert.equal(replies.at(-1), "\u001b[?1u");
fake.dispatch({ prefix: "=", final: "u" }, [8, 2]);
fake.dispatch({ prefix: "?", final: "u" });
assert.equal(replies.at(-1), "\u001b[?9u");
fake.dispatch({ prefix: "=", final: "u" }, [8, 3]);
fake.dispatch({ prefix: "?", final: "u" });
assert.equal(replies.at(-1), "\u001b[?1u");
fake.dispatch({ prefix: ">", final: "u" }, [1 | 2 | 4 | 8 | 16]);
fake.dispatch({ prefix: "?", final: "u" });
assert.equal(replies.at(-1), "\u001b[?9u");
fake.dispatch({ prefix: "<", final: "u" }, [0]);
fake.dispatch({ prefix: "?", final: "u" });
assert.equal(replies.at(-1), "\u001b[?1u");
});
test("kitty keyboard CSI handlers keep main and alternate screen state separate", () => {
const state = createKittyKeyboardModeState();
const fake = createFakeCsiParser();
const replies: string[] = [];
installKittyKeyboardProtocolHandlers(fake.parser, state, (payload) => replies.push(payload));
fake.dispatch({ prefix: "=", final: "u" }, [1]);
assert.equal(fake.dispatch({ prefix: "?", final: "h" }, [[1049]]), false);
fake.dispatch({ prefix: "?", final: "u" });
assert.equal(replies.at(-1), "\u001b[?0u");
fake.dispatch({ prefix: "=", final: "u" }, [8]);
fake.dispatch({ prefix: "?", final: "u" });
assert.equal(replies.at(-1), "\u001b[?8u");
assert.equal(fake.dispatch({ prefix: "?", final: "l" }, [1049]), false);
fake.dispatch({ prefix: "?", final: "u" });
assert.equal(replies.at(-1), "\u001b[?1u");
});
test("kitty report-all mode enables the supported modified control key subset", () => {
const state = createKittyKeyboardModeState();
setKittyKeyboardModeFlags(state, 8, 1);
assert.equal(buildKittyKeyboardModeQueryResponse(state), "\u001b[?8u");
assert.equal(
encodeKittyControlKey(state, { key: "Enter", shiftKey: true }),
"\u001b[13;2u",
);
assert.equal(
encodeKittyControlKey(state, { key: "Tab", shiftKey: true }),
"\u001b[9;2u",
);
assert.equal(
encodeKittyControlKey(state, { key: "Enter" }),
null,
);
});

View File

@@ -0,0 +1,165 @@
export const KITTY_KEYBOARD_DISAMBIGUATE_ESC_CODES = 0b1;
export const KITTY_KEYBOARD_REPORT_ALL_KEYS_AS_ESC_CODES = 0b1000;
export const KITTY_SUPPORTED_KEYBOARD_FLAGS =
KITTY_KEYBOARD_DISAMBIGUATE_ESC_CODES |
KITTY_KEYBOARD_REPORT_ALL_KEYS_AS_ESC_CODES;
const MAX_KEYBOARD_MODE_STACK_DEPTH = 32;
export type KittyKeyboardModeState = {
mainFlags: number;
alternateFlags: number;
mainStack: number[];
alternateStack: number[];
alternateScreenActive: boolean;
};
export type KittyKeyboardModeApplyMode = 1 | 2 | 3;
export type KittyKeyboardControlEvent = {
key: string;
shiftKey?: boolean;
altKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
};
const CONTROL_KEY_CODES: Record<string, number> = {
Escape: 27,
Tab: 9,
Enter: 13,
Backspace: 127,
};
const sanitizeFlags = (flags: number): number => {
return flags & KITTY_SUPPORTED_KEYBOARD_FLAGS;
};
const clampPositiveInteger = (value: number, fallback: number): number => {
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
};
export const createKittyKeyboardModeState = (): KittyKeyboardModeState => ({
mainFlags: 0,
alternateFlags: 0,
mainStack: [],
alternateStack: [],
alternateScreenActive: false,
});
export const getKittyKeyboardModeFlags = (state: KittyKeyboardModeState): number => {
return state.alternateScreenActive ? state.alternateFlags : state.mainFlags;
};
export const setKittyKeyboardAlternateScreenActive = (
state: KittyKeyboardModeState,
active: boolean,
): void => {
state.alternateScreenActive = active;
};
export const setKittyKeyboardModeFlags = (
state: KittyKeyboardModeState,
flags: number,
mode: KittyKeyboardModeApplyMode = 1,
): number => {
const sanitized = sanitizeFlags(flags);
const current = getKittyKeyboardModeFlags(state);
let next = current;
switch (mode) {
case 1:
next = sanitized;
break;
case 2:
next = current | sanitized;
break;
case 3:
next = current & ~sanitized;
break;
}
if (state.alternateScreenActive) {
state.alternateFlags = next;
} else {
state.mainFlags = next;
}
return next;
};
export const pushKittyKeyboardModeFlags = (
state: KittyKeyboardModeState,
flags = 0,
): number => {
const stack = state.alternateScreenActive ? state.alternateStack : state.mainStack;
stack.push(getKittyKeyboardModeFlags(state));
if (stack.length > MAX_KEYBOARD_MODE_STACK_DEPTH) {
stack.shift();
}
return setKittyKeyboardModeFlags(state, flags, 1);
};
export const popKittyKeyboardModeFlags = (
state: KittyKeyboardModeState,
count = 1,
): number => {
const stack = state.alternateScreenActive ? state.alternateStack : state.mainStack;
const total = clampPositiveInteger(count, 1);
let next = 0;
for (let i = 0; i < total; i += 1) {
next = stack.pop() ?? 0;
}
if (state.alternateScreenActive) {
state.alternateFlags = next;
} else {
state.mainFlags = next;
}
return next;
};
export const buildKittyKeyboardModeQueryResponse = (
state: KittyKeyboardModeState,
): string => {
return `\u001b[?${getKittyKeyboardModeFlags(state)}u`;
};
const getKittyModifierBits = (event: KittyKeyboardControlEvent): number => {
let bits = 0;
if (event.shiftKey) bits |= 0b1;
if (event.altKey) bits |= 0b10;
if (event.ctrlKey) bits |= 0b100;
if (event.metaKey) bits |= 0b1000;
return bits;
};
export const encodeKittyControlKey = (
state: KittyKeyboardModeState,
event: KittyKeyboardControlEvent,
): string | null => {
const activeFlags = getKittyKeyboardModeFlags(state);
const controlKeyEncodingFlags =
KITTY_KEYBOARD_DISAMBIGUATE_ESC_CODES |
KITTY_KEYBOARD_REPORT_ALL_KEYS_AS_ESC_CODES;
if ((activeFlags & controlKeyEncodingFlags) === 0) {
return null;
}
const keyCode = CONTROL_KEY_CODES[event.key];
if (!keyCode) return null;
const modifiers = getKittyModifierBits(event);
// Keep bare Enter/Tab/Backspace on legacy bytes so the terminal remains
// usable after a crashed app, but still allow modified forms like
// Shift+Enter for tool UIs that need a distinct key event.
if (event.key !== "Escape" && modifiers === 0) {
return null;
}
return `\u001b[${keyCode}${modifiers ? `;${modifiers + 1}` : ""}u`;
};

View File

@@ -0,0 +1,116 @@
import type { IDisposable } from "@xterm/xterm";
import {
buildKittyKeyboardModeQueryResponse,
popKittyKeyboardModeFlags,
pushKittyKeyboardModeFlags,
setKittyKeyboardAlternateScreenActive,
setKittyKeyboardModeFlags,
type KittyKeyboardModeApplyMode,
type KittyKeyboardModeState,
} from "./kittyKeyboardProtocol";
export type KittyKeyboardCsiParams = readonly (number | number[])[];
type CsiHandlerId = {
prefix?: string;
intermediates?: string;
final: string;
};
type KittyKeyboardParser = {
registerCsiHandler: (
id: CsiHandlerId,
callback: (params: KittyKeyboardCsiParams) => boolean,
) => IDisposable;
};
export const readKittyKeyboardCsiParam = (
params: KittyKeyboardCsiParams,
index: number,
fallback: number,
): number => {
const value = params[index];
if (Array.isArray(value)) return typeof value[0] === "number" ? value[0] : fallback;
return typeof value === "number" && value > 0 ? value : fallback;
};
const normalizeKittyKeyboardApplyMode = (mode: number): KittyKeyboardModeApplyMode => {
return mode === 2 || mode === 3 ? mode : 1;
};
const paramsIncludeAny = (
params: KittyKeyboardCsiParams,
targets: readonly number[],
): boolean => {
return params.some((param) => (
Array.isArray(param)
? param.some((value) => targets.includes(value))
: targets.includes(param)
));
};
export const installKittyKeyboardProtocolHandlers = (
parser: KittyKeyboardParser,
state: KittyKeyboardModeState,
writeReply: (payload: string) => void,
): IDisposable => {
const disposables = [
parser.registerCsiHandler(
{ prefix: "?", final: "u" },
() => {
writeReply(buildKittyKeyboardModeQueryResponse(state));
return true;
},
),
parser.registerCsiHandler(
{ prefix: "=", final: "u" },
(params) => {
const flags = readKittyKeyboardCsiParam(params, 0, 0);
const mode = normalizeKittyKeyboardApplyMode(readKittyKeyboardCsiParam(params, 1, 1));
setKittyKeyboardModeFlags(state, flags, mode);
return true;
},
),
parser.registerCsiHandler(
{ prefix: ">", final: "u" },
(params) => {
pushKittyKeyboardModeFlags(state, readKittyKeyboardCsiParam(params, 0, 0));
return true;
},
),
parser.registerCsiHandler(
{ prefix: "<", final: "u" },
(params) => {
popKittyKeyboardModeFlags(state, readKittyKeyboardCsiParam(params, 0, 1));
return true;
},
),
parser.registerCsiHandler(
{ prefix: "?", final: "h" },
(params) => {
if (paramsIncludeAny(params, [47, 1047, 1049])) {
setKittyKeyboardAlternateScreenActive(state, true);
}
return false;
},
),
parser.registerCsiHandler(
{ prefix: "?", final: "l" },
(params) => {
if (paramsIncludeAny(params, [47, 1047, 1049])) {
setKittyKeyboardAlternateScreenActive(state, false);
}
return false;
},
),
];
return {
dispose: () => {
for (const disposable of disposables) {
disposable.dispose();
}
},
};
};

View File

@@ -0,0 +1,38 @@
export const terminalLayerAreEqual = (
prev: Record<string, unknown>,
next: Record<string, unknown>,
): boolean => (
prev.hosts === next.hosts &&
prev.groupConfigs === next.groupConfigs &&
prev.proxyProfiles === next.proxyProfiles &&
prev.keys === next.keys &&
prev.snippets === next.snippets &&
prev.snippetPackages === next.snippetPackages &&
prev.sessions === next.sessions &&
prev.workspaces === next.workspaces &&
prev.knownHosts === next.knownHosts &&
prev.draggingSessionId === next.draggingSessionId &&
prev.terminalTheme === next.terminalTheme &&
prev.accentMode === next.accentMode &&
prev.customAccent === next.customAccent &&
prev.terminalSettings === next.terminalSettings &&
prev.fontSize === next.fontSize &&
prev.hotkeyScheme === next.hotkeyScheme &&
prev.keyBindings === next.keyBindings &&
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
prev.sftpAutoSync === next.sftpAutoSync &&
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
prev.sftpAutoOpenSidebar === next.sftpAutoOpenSidebar &&
prev.editorWordWrap === next.editorWordWrap &&
prev.setEditorWordWrap === next.setEditorWordWrap &&
prev.onHotkeyAction === next.onHotkeyAction &&
prev.onUpdateHost === next.onUpdateHost &&
prev.onAddKnownHost === next.onAddKnownHost &&
prev.onToggleWorkspaceViewMode === next.onToggleWorkspaceViewMode &&
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
prev.onSplitSession === next.onSplitSession &&
prev.toggleScriptsSidePanelRef === next.toggleScriptsSidePanelRef &&
prev.identities === next.identities
);

View File

@@ -88,6 +88,12 @@ export const findSyncPayloadEncryptedCredentialPaths = (
}
});
payload.proxyProfiles?.forEach((profile, index) => {
if (isEncryptedCredentialPlaceholder(profile.config.password)) {
issues.push(`proxyProfiles[${index}].config.password`);
}
});
payload.groupConfigs?.forEach((config, index) => {
if (isEncryptedCredentialPlaceholder(config.password)) {
issues.push(`groupConfigs[${index}].password`);

210
domain/groupConfig.test.ts Normal file
View File

@@ -0,0 +1,210 @@
import test from "node:test";
import assert from "node:assert/strict";
import { applyGroupDefaults, resolveGroupDefaults, sanitizeGroupConfig } from "./groupConfig.ts";
import { resolveTelnetPassword, resolveTelnetUsername } from "./host.ts";
import type { GroupConfig, Host } from "./models.ts";
const host = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
...overrides,
});
test("applyGroupDefaults lets a host proxy profile override a group custom proxy", () => {
const groupDefaults: Partial<GroupConfig> = {
proxyConfig: { type: "http", host: "group-proxy.example.com", port: 3128 },
};
const result = applyGroupDefaults(host({ proxyProfileId: "proxy-1" }), groupDefaults);
assert.equal(result.proxyProfileId, "proxy-1");
assert.equal(result.proxyConfig, undefined);
});
test("applyGroupDefaults lets a host custom proxy override a group proxy profile", () => {
const groupDefaults: Partial<GroupConfig> = {
proxyProfileId: "group-proxy",
};
const customProxy = { type: "socks5" as const, host: "host-proxy.example.com", port: 1080 };
const result = applyGroupDefaults(host({ proxyConfig: customProxy }), groupDefaults);
assert.equal(result.proxyProfileId, undefined);
assert.deepEqual(result.proxyConfig, customProxy);
});
test("resolveGroupDefaults treats saved and custom proxies as one inherited setting", () => {
const resolved = resolveGroupDefaults("prod/api", [
{
path: "prod",
proxyConfig: { type: "http", host: "parent-proxy.example.com", port: 3128 },
},
{
path: "prod/api",
proxyProfileId: "child-proxy",
},
]);
assert.equal(resolved.proxyProfileId, "child-proxy");
assert.equal(resolved.proxyConfig, undefined);
});
test("applyGroupDefaults keeps a missing host proxy profile instead of using group proxy", () => {
const groupDefaults: Partial<GroupConfig> = {
proxyProfileId: "group-proxy",
};
const result = applyGroupDefaults(
host({ proxyProfileId: "missing-proxy" }),
groupDefaults,
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(result.proxyProfileId, "missing-proxy");
assert.equal(result.proxyConfig, undefined);
});
test("applyGroupDefaults keeps a missing host proxy profile when no group fallback exists", () => {
const result = applyGroupDefaults(
host({ proxyProfileId: "missing-proxy" }),
{},
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(result.proxyProfileId, "missing-proxy");
assert.equal(result.proxyConfig, undefined);
});
test("applyGroupDefaults keeps a missing host proxy profile instead of using group custom proxy", () => {
const groupProxy = { type: "http" as const, host: "group-proxy.example.com", port: 3128 };
const result = applyGroupDefaults(
host({ proxyProfileId: "missing-proxy" }),
{ proxyConfig: groupProxy },
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(result.proxyProfileId, "missing-proxy");
assert.equal(result.proxyConfig, undefined);
});
test("resolveGroupDefaults keeps a missing group proxy marker when there is no fallback", () => {
const resolved = resolveGroupDefaults(
"prod",
[{ path: "prod", proxyProfileId: "missing-proxy" }],
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(resolved.proxyProfileId, "missing-proxy");
});
test("applyGroupDefaults inherits a missing group proxy marker so connect paths can fail", () => {
const result = applyGroupDefaults(
host({ group: "prod" }),
{ proxyProfileId: "missing-proxy" },
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(result.proxyProfileId, "missing-proxy");
assert.equal(result.proxyConfig, undefined);
});
test("resolveGroupDefaults keeps missing child proxy profiles instead of using parent proxy", () => {
const resolved = resolveGroupDefaults(
"prod/api",
[
{
path: "prod",
proxyConfig: { type: "http", host: "parent-proxy.example.com", port: 3128 },
},
{
path: "prod/api",
proxyProfileId: "missing-proxy",
},
],
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(resolved.proxyProfileId, "missing-proxy");
assert.equal(resolved.proxyConfig, undefined);
});
test("applyGroupDefaults preserves explicitly cleared telnet credentials", () => {
const result = applyGroupDefaults(
host({
username: "ssh-user",
password: "ssh-password",
telnetUsername: "",
telnetPassword: "",
}),
{
telnetUsername: "group-telnet-user",
telnetPassword: "group-telnet-password",
},
);
assert.equal(result.telnetUsername, "");
assert.equal(result.telnetPassword, "");
assert.equal(resolveTelnetUsername(result), "");
assert.equal(resolveTelnetPassword(result), "");
});
test("applyGroupDefaults still inherits telnet credentials when host fields are unset", () => {
const result = applyGroupDefaults(
host({
username: "ssh-user",
password: "ssh-password",
}),
{
telnetUsername: "group-telnet-user",
telnetPassword: "group-telnet-password",
},
);
assert.equal(result.telnetUsername, "group-telnet-user");
assert.equal(result.telnetPassword, "group-telnet-password");
assert.equal(resolveTelnetUsername(result), "group-telnet-user");
assert.equal(resolveTelnetPassword(result), "group-telnet-password");
});
test("applyGroupDefaults continues to inherit empty ssh username from the group", () => {
const result = applyGroupDefaults(
host({
username: "",
}),
{
username: "group-ssh-user",
},
);
assert.equal(result.username, "group-ssh-user");
});
test("sanitizeGroupConfig migrates a deprecated fontFamily and clears the override flag", () => {
// Regression guard for codex P2 review on PR #940: groups saved with
// pingfang-sc / microsoft-yahei / comic-sans-ms must shed the
// override so member hosts inherit the global default instead of
// silently falling through to fonts[0] under an enabled override.
const before: GroupConfig = {
path: "team",
fontFamily: "pingfang-sc",
fontFamilyOverride: true,
};
const after = sanitizeGroupConfig(before);
assert.equal(after.fontFamily, undefined);
assert.equal(after.fontFamilyOverride, false);
});
test("sanitizeGroupConfig keeps a still-valid fontFamily untouched", () => {
const before: GroupConfig = {
path: "team",
fontFamily: "jetbrains-mono",
fontFamilyOverride: true,
};
const after = sanitizeGroupConfig(before);
assert.equal(after.fontFamily, "jetbrains-mono");
assert.equal(after.fontFamilyOverride, true);
});

View File

@@ -1,4 +1,27 @@
import type { GroupConfig, Host } from './models';
import { migrateDeprecatedFontOverride } from '../infrastructure/config/fonts';
/**
* Migrate deprecated primary-font ids out of a GroupConfig's
* font-override fields. Symmetrical to sanitizeHost; both run on load
* to keep the same proportional-font protection working for group
* defaults too.
*/
export function sanitizeGroupConfig(config: GroupConfig): GroupConfig {
return migrateDeprecatedFontOverride(config);
}
export interface ApplyGroupDefaultsOptions {
validProxyProfileIds?: ReadonlySet<string>;
}
const hasUsableProxyProfileId = (
proxyProfileId: string | undefined,
options?: ApplyGroupDefaultsOptions,
): boolean => {
if (!proxyProfileId) return false;
return !options?.validProxyProfileIds || options.validProxyProfileIds.has(proxyProfileId);
};
/**
* Resolve merged group defaults by walking the ancestor chain.
@@ -7,6 +30,7 @@ import type { GroupConfig, Host } from './models';
export function resolveGroupDefaults(
groupPath: string,
groupConfigs: GroupConfig[],
options?: ApplyGroupDefaultsOptions,
): Partial<GroupConfig> {
const configMap = new Map(groupConfigs.map((c) => [c.path, c]));
const parts = groupPath.split('/').filter(Boolean);
@@ -17,6 +41,14 @@ export function resolveGroupDefaults(
const config = configMap.get(ancestorPath);
if (config) {
for (const [key, value] of Object.entries(config)) {
if (
key === 'proxyProfileId' &&
typeof value === 'string' &&
options?.validProxyProfileIds &&
!options.validProxyProfileIds.has(value)
) {
delete merged.proxyConfig;
}
if (
(key === 'theme' && config.themeOverride === false) ||
(key === 'fontFamily' && config.fontFamilyOverride === false) ||
@@ -26,6 +58,12 @@ export function resolveGroupDefaults(
continue;
}
if (key !== 'path' && value !== undefined) {
if (key === 'proxyProfileId') {
delete merged.proxyConfig;
}
if (key === 'proxyConfig') {
delete merged.proxyProfileId;
}
merged[key] = value;
}
}
@@ -48,23 +86,43 @@ export function resolveGroupDefaults(
const INHERITABLE_KEYS: (keyof GroupConfig)[] = [
'username', 'password', 'savePassword', 'authMethod', 'identityId', 'identityFileId', 'identityFilePaths',
'port', 'protocol', 'agentForwarding', 'proxyConfig', 'hostChain', 'startupCommand',
'port', 'protocol', 'agentForwarding', 'proxyProfileId', 'proxyConfig', 'hostChain', 'startupCommand',
'legacyAlgorithms', 'environmentVariables', 'charset', 'moshEnabled', 'moshServerPath',
'telnetEnabled', 'telnetPort', 'telnetUsername', 'telnetPassword',
'theme', 'themeOverride', 'fontFamily', 'fontFamilyOverride', 'fontSize', 'fontSizeOverride', 'fontWeight', 'fontWeightOverride',
'backspaceBehavior',
];
const EMPTY_STRING_OVERRIDES_GROUP_DEFAULT = new Set<keyof GroupConfig>([
'telnetUsername',
'telnetPassword',
]);
/**
* Apply group defaults to a host. Only fills in fields the host doesn't already have.
* Returns a new host object — does NOT mutate the original.
*/
export function applyGroupDefaults(host: Host, groupDefaults: Partial<GroupConfig>): Host {
export function applyGroupDefaults(
host: Host,
groupDefaults: Partial<GroupConfig>,
options?: ApplyGroupDefaultsOptions,
): Host {
const effective = { ...host };
const hostHasUsableProxyProfile = hasUsableProxyProfileId(host.proxyProfileId, options);
for (const key of INHERITABLE_KEYS) {
const hostValue = (host as unknown as Record<string, unknown>)[key];
if (key === 'proxyProfileId') {
if (host.proxyConfig !== undefined || !groupDefaults.proxyProfileId) continue;
}
if (key === 'proxyConfig' && (host.proxyProfileId !== undefined || hostHasUsableProxyProfile)) continue;
const hostValue = (effective as unknown as Record<string, unknown>)[key];
const groupValue = (groupDefaults as unknown as Record<string, unknown>)[key];
if ((hostValue === undefined || hostValue === '' || hostValue === null) && groupValue !== undefined) {
const emptyStringIsOverride = EMPTY_STRING_OVERRIDES_GROUP_DEFAULT.has(key);
const shouldInherit =
hostValue === undefined ||
hostValue === null ||
(hostValue === '' && !emptyStringIsOverride);
if (shouldInherit && groupValue !== undefined) {
(effective as unknown as Record<string, unknown>)[key] = groupValue;
}
}

View File

@@ -2,7 +2,15 @@ import test from "node:test";
import assert from "node:assert/strict";
import type { Host } from "./models.ts";
import { upsertHostById } from "./host.ts";
import {
normalizePrimaryTelnetState,
resolveHostKeepalive,
resolveTelnetPort,
resolveTelnetPassword,
resolveTelnetUsername,
sanitizeHost,
upsertHostById,
} from "./host.ts";
const makeHost = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
@@ -49,3 +57,158 @@ test("upsertHostById appends a duplicated host with a fresh id", () => {
assert.deepEqual(upsertHostById([existing], duplicate), [existing, duplicate]);
});
test("telnet credential helpers preserve explicitly cleared values", () => {
const host = makeHost({
username: "ssh-user",
password: "ssh-password",
telnetUsername: "",
telnetPassword: "",
});
assert.equal(resolveTelnetUsername(host), "");
assert.equal(resolveTelnetPassword(host), "");
});
test("telnet credential helpers fall back only when telnet fields are unset", () => {
const host = makeHost({
username: " ssh-user ",
password: "ssh-password",
telnetUsername: undefined,
telnetPassword: undefined,
});
assert.equal(resolveTelnetUsername(host), "ssh-user");
assert.equal(resolveTelnetPassword(host), "ssh-password");
});
test("normalizePrimaryTelnetState enables primary telnet without materializing a port", () => {
const result = normalizePrimaryTelnetState(makeHost({
protocol: "telnet",
telnetEnabled: false,
telnetPort: undefined,
port: undefined,
}));
assert.equal(result.telnetEnabled, true);
assert.equal(result.telnetPort, undefined);
assert.equal(result.port, undefined);
});
test("normalizePrimaryTelnetState leaves optional telnet hosts unchanged", () => {
const result = normalizePrimaryTelnetState(makeHost({
protocol: "ssh",
telnetEnabled: false,
telnetPort: undefined,
}));
assert.equal(result.telnetEnabled, false);
assert.equal(result.telnetPort, undefined);
});
test("normalizePrimaryTelnetState preserves an explicit telnet port", () => {
const result = normalizePrimaryTelnetState(makeHost({
protocol: "telnet",
telnetEnabled: false,
telnetPort: 2325,
}));
assert.equal(result.telnetEnabled, true);
assert.equal(result.telnetPort, 2325);
});
test("resolveTelnetPort ignores ssh ports for optional telnet", () => {
assert.equal(resolveTelnetPort(makeHost({
protocol: "ssh",
port: 2222,
telnetPort: undefined,
})), 23);
});
test("resolveTelnetPort uses primary telnet port fallback", () => {
assert.equal(resolveTelnetPort(makeHost({
protocol: "telnet",
port: 2325,
telnetPort: undefined,
})), 2325);
});
test("sanitizeHost migrates a deprecated fontFamily and clears the override flag", () => {
// Regression guard for codex P2 review on PR #940: hosts saved with
// pingfang-sc / microsoft-yahei / comic-sans-ms in fontFamily must
// have the override dropped so they fall back to the global default
// instead of silently rendering the wrong font while still claiming
// an override is active.
const before = makeHost({
fontFamily: "comic-sans-ms",
fontFamilyOverride: true,
});
const after = sanitizeHost(before);
assert.equal(after.fontFamily, undefined);
assert.equal(after.fontFamilyOverride, false);
});
test("sanitizeHost keeps a still-valid fontFamily untouched", () => {
const before = makeHost({
fontFamily: "fira-code",
fontFamilyOverride: true,
});
const after = sanitizeHost(before);
assert.equal(after.fontFamily, "fira-code");
assert.equal(after.fontFamilyOverride, true);
});
const GLOBAL_KEEPALIVE = { keepaliveInterval: 30, keepaliveCountMax: 10 };
test("resolveHostKeepalive falls back to global when override is not set", () => {
const host = makeHost();
assert.deepEqual(
resolveHostKeepalive(host, GLOBAL_KEEPALIVE),
{ interval: 30, countMax: 10, source: "global" },
);
});
test("resolveHostKeepalive falls back to global when override is explicitly false", () => {
const host = makeHost({
keepaliveOverride: false,
keepaliveInterval: 0,
keepaliveCountMax: 3,
});
// Override flag is the gate; the host's stored values stay parked and
// unused so toggling the flag back on later restores them.
assert.deepEqual(
resolveHostKeepalive(host, GLOBAL_KEEPALIVE),
{ interval: 30, countMax: 10, source: "global" },
);
});
test("resolveHostKeepalive uses host values when override is true", () => {
const host = makeHost({
keepaliveOverride: true,
keepaliveInterval: 0,
keepaliveCountMax: 3,
});
assert.deepEqual(
resolveHostKeepalive(host, GLOBAL_KEEPALIVE),
{ interval: 0, countMax: 3, source: "host" },
);
});
test("resolveHostKeepalive lets each field fall back independently", () => {
// Override on, but only `interval` set on the host: inherit global countMax.
assert.deepEqual(
resolveHostKeepalive(
makeHost({ keepaliveOverride: true, keepaliveInterval: 5 }),
GLOBAL_KEEPALIVE,
),
{ interval: 5, countMax: 10, source: "host" },
);
// Override on, but only countMax set: inherit global interval.
assert.deepEqual(
resolveHostKeepalive(
makeHost({ keepaliveOverride: true, keepaliveCountMax: 50 }),
GLOBAL_KEEPALIVE,
),
{ interval: 30, countMax: 50, source: "host" },
);
});

View File

@@ -1,4 +1,5 @@
import { Host } from './models';
import { Host, TerminalSettings } from './models';
import { migrateDeprecatedFontOverride } from '../infrastructure/config/fonts';
export const LINUX_DISTRO_OPTIONS = [
'linux',
@@ -153,6 +154,35 @@ export const formatHostPort = (hostname: string, port?: number | null): string =
return `${display}:${port}`;
};
export const resolveTelnetUsername = (
host: Pick<Host, 'telnetUsername' | 'username'>,
): string | undefined =>
host.telnetUsername !== undefined
? host.telnetUsername.trim()
: host.username?.trim();
export const resolveTelnetPassword = (
host: Pick<Host, 'telnetPassword' | 'password'>,
): string | undefined =>
host.telnetPassword !== undefined
? host.telnetPassword
: host.password;
export const resolveTelnetPort = (
host: Pick<Host, 'protocol' | 'telnetPort' | 'port'>,
): number => {
if (host.telnetPort !== undefined && host.telnetPort !== null) return host.telnetPort;
if (host.protocol === 'telnet' && host.port !== undefined && host.port !== null) {
return host.port;
}
return 23;
};
export const normalizePrimaryTelnetState = (host: Host): Host =>
host.protocol === 'telnet' && !host.telnetEnabled
? { ...host, telnetEnabled: true }
: host;
export const upsertHostById = (hosts: Host[], host: Host): Host[] => {
const hostExists = hosts.some((entry) => entry.id === host.id);
return hostExists
@@ -160,6 +190,40 @@ export const upsertHostById = (hosts: Host[], host: Host): Host[] => {
: [...hosts, host];
};
export interface ResolvedKeepalive {
interval: number; // Seconds; 0 = disabled
countMax: number; // Unanswered keepalives before declaring dead
source: 'host' | 'global';
}
/**
* Decide which SSH keepalive values to apply to a connection. A host can opt
* into its own values via `keepaliveOverride === true` — useful when a
* specific device (older router / switch / NOKIA / ALCATEL SSH stack) doesn't
* reply to keepalive@openssh.com and the global aggressive setting would
* cause the session to be declared dead after a handful of unanswered probes.
* When the override is off (the default), the host inherits the global
* TerminalSettings values which are tuned for cloud / NAT'd hosts.
*
* Each field falls back independently: a host can override only the interval
* while still inheriting the global countMax, and vice versa.
*/
export const resolveHostKeepalive = (
host: Pick<Host, 'keepaliveOverride' | 'keepaliveInterval' | 'keepaliveCountMax'>,
globalSettings: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>,
): ResolvedKeepalive => {
const globalInterval = globalSettings.keepaliveInterval;
const globalCountMax = globalSettings.keepaliveCountMax;
if (host.keepaliveOverride !== true) {
return { interval: globalInterval, countMax: globalCountMax, source: 'global' };
}
return {
interval: host.keepaliveInterval ?? globalInterval,
countMax: host.keepaliveCountMax ?? globalCountMax,
source: 'host',
};
};
export const sanitizeHost = (host: Host): Host => {
const cleanHostname = (host.hostname || '').split(/\s+/)[0];
const cleanDistro = normalizeDistroId(host.distro);
@@ -170,8 +234,9 @@ export const sanitizeHost = (host: Host): Host => {
: host.distroMode === 'auto'
? 'auto'
: undefined;
const migrated = migrateDeprecatedFontOverride(host);
return {
...host,
...migrated,
hostname: cleanHostname,
distro: cleanDistro,
distroMode: cleanDistroMode,

99
domain/knownHosts.test.ts Normal file
View File

@@ -0,0 +1,99 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { KnownHost } from "./models";
import { upsertKnownHost } from "./knownHosts";
const knownHost = (overrides: Partial<KnownHost> = {}): KnownHost => ({
id: "kh-existing",
hostname: "10.2.0.32",
port: 22,
keyType: "ssh-ed25519",
publicKey: "ssh-ed25519 old-key",
fingerprint: "old-fingerprint",
discoveredAt: 100,
...overrides,
});
test("upsertKnownHost updates an existing host key instead of appending a duplicate", () => {
const existing = knownHost({ convertedToHostId: "host-1" });
const incoming = knownHost({
id: "kh-new",
publicKey: "ssh-ed25519 new-key",
fingerprint: "new-fingerprint",
discoveredAt: 200,
});
const result = upsertKnownHost([existing], incoming);
assert.equal(result.length, 1);
assert.deepEqual(result[0], {
...existing,
publicKey: "ssh-ed25519 new-key",
fingerprint: "new-fingerprint",
lastSeen: 200,
});
});
test("upsertKnownHost updates by id even when the incoming key type is unknown", () => {
const existing = knownHost({
id: "kh-1",
keyType: "ssh-ed25519",
publicKey: "SHA256:old-key",
fingerprint: "old-fingerprint",
discoveredAt: 100,
});
const incoming = knownHost({
id: "kh-1",
keyType: "unknown",
publicKey: undefined,
fingerprint: "new-fingerprint",
discoveredAt: 200,
});
const result = upsertKnownHost([existing], incoming);
assert.equal(result.length, 1);
assert.equal(result[0].id, "kh-1");
assert.equal(result[0].keyType, "unknown");
assert.equal(result[0].fingerprint, "new-fingerprint");
assert.equal(result[0].lastSeen, 200);
});
test("upsertKnownHost prefers the matching id over an earlier selector match", () => {
const duplicate = knownHost({
id: "kh-duplicate",
fingerprint: "duplicate-fingerprint",
discoveredAt: 50,
});
const target = knownHost({
id: "kh-target",
fingerprint: "target-fingerprint",
discoveredAt: 100,
});
const incoming = knownHost({
id: "kh-target",
fingerprint: "new-fingerprint",
discoveredAt: 200,
});
const result = upsertKnownHost([duplicate, target], incoming);
assert.equal(result.length, 2);
assert.equal(result[0].fingerprint, "duplicate-fingerprint");
assert.equal(result[1].id, "kh-target");
assert.equal(result[1].fingerprint, "new-fingerprint");
});
test("upsertKnownHost appends genuinely new host keys", () => {
const existing = knownHost();
const incoming = knownHost({
id: "kh-other",
hostname: "10.2.0.33",
fingerprint: "other-fingerprint",
});
const result = upsertKnownHost([existing], incoming);
assert.deepEqual(result, [existing, incoming]);
});

38
domain/knownHosts.ts Normal file
View File

@@ -0,0 +1,38 @@
import type { KnownHost } from "./models";
const normalizeHost = (value: string) => value.trim().toLowerCase();
const sameKnownHostSelector = (a: KnownHost, b: KnownHost) =>
normalizeHost(a.hostname) === normalizeHost(b.hostname) &&
a.port === b.port &&
a.keyType === b.keyType;
export const upsertKnownHost = (
knownHosts: KnownHost[],
incoming: KnownHost,
): KnownHost[] => {
const idIndex = knownHosts.findIndex((existing) => existing.id === incoming.id);
const index = idIndex !== -1
? idIndex
: knownHosts.findIndex((existing) => sameKnownHostSelector(existing, incoming));
if (index === -1) {
return [...knownHosts, incoming];
}
const existing = knownHosts[index];
const updated: KnownHost = {
...existing,
...incoming,
id: existing.id,
discoveredAt: existing.discoveredAt,
convertedToHostId: existing.convertedToHostId ?? incoming.convertedToHostId,
lastSeen: incoming.lastSeen ?? incoming.discoveredAt,
};
return [
...knownHosts.slice(0, index),
updated,
...knownHosts.slice(index + 1),
];
};

View File

@@ -11,6 +11,14 @@ export interface ProxyConfig {
password?: string;
}
export interface ProxyProfile {
id: string;
label: string;
config: ProxyConfig;
createdAt: number;
updatedAt?: number;
}
// Host chain configuration for jump host / bastion connections
export interface HostChainConfig {
hostIds: string[]; // Array of host IDs in order (first = closest to client)
@@ -83,6 +91,7 @@ export interface Host {
startupCommand?: string;
hostChaining?: string; // Deprecated: use hostChain instead
proxy?: string; // Deprecated: use proxyConfig instead
proxyProfileId?: string; // Reference to reusable proxy profile
proxyConfig?: ProxyConfig; // New structured proxy configuration
hostChain?: HostChainConfig; // New structured host chain configuration
envVars?: string; // Deprecated: use environmentVariables instead
@@ -120,6 +129,15 @@ export interface Host {
keywordHighlightEnabled?: boolean;
// Legacy SSH algorithm support for older network equipment (switches, routers)
legacyAlgorithms?: boolean;
// Per-host SSH keepalive override. When `keepaliveOverride === true`, the
// host uses its own `keepaliveInterval` / `keepaliveCountMax` instead of
// inheriting the global TerminalSettings values. Lets a user keep an
// aggressive cloud-friendly keepalive globally while disabling it for a
// specific router / embedded device whose SSH stack doesn't reply to
// OpenSSH keepalive global requests (issue #581 / #939).
keepaliveInterval?: number; // Seconds; 0 = disabled
keepaliveCountMax?: number; // Unanswered keepalives before declaring dead
keepaliveOverride?: boolean;
// What the Backspace key sends: undefined = xterm default (no interception), 'ctrl-h' = ^H (0x08)
backspaceBehavior?: 'ctrl-h';
// Local SSH key file paths (from SSH config IdentityFile or user-added)
@@ -137,7 +155,7 @@ export interface Host {
}
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
type KeySource = 'generated' | 'imported';
type KeySource = 'generated' | 'imported' | 'reference';
export type KeyCategory = 'key' | 'certificate' | 'identity';
type IdentityAuthMethod = 'password' | 'key' | 'certificate';
@@ -154,6 +172,7 @@ export interface SSHKey {
source: KeySource;
category: KeyCategory;
created: number;
filePath?: string;
}
// Identity combines username with authentication method
@@ -205,6 +224,7 @@ export interface GroupConfig {
port?: number;
protocol?: 'ssh' | 'telnet';
agentForwarding?: boolean;
proxyProfileId?: string;
proxyConfig?: ProxyConfig;
hostChain?: HostChainConfig;
startupCommand?: string;
@@ -415,6 +435,7 @@ export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
{ id: 'new-workspace', action: 'newWorkspace', label: 'New Workspace', mac: '⌘ + Shift + J', pc: 'Ctrl + Shift + J', category: 'app' },
{ id: 'snippets', action: 'snippets', label: 'Open Snippets', mac: '⌘ + Shift + S', pc: 'Ctrl + Shift + S', category: 'app' },
{ id: 'broadcast', action: 'broadcast', label: 'Switch the Broadcast Mode', mac: '⌘ + B', pc: 'Ctrl + B', category: 'app' },
{ id: 'open-settings', action: 'openSettings', label: 'Open Settings', mac: '⌘ + ,', pc: 'Ctrl + ,', category: 'app' },
// SFTP Operations
{ id: 'sftp-copy', action: 'sftpCopy', label: 'Copy Files', mac: '⌘ + C', pc: 'Ctrl + C', category: 'sftp' },
@@ -491,6 +512,7 @@ export interface TerminalSettings {
// SSH Connection
keepaliveInterval: number; // Seconds between SSH-level keepalive packets (0 = disabled)
keepaliveCountMax: number; // Unanswered keepalives before declaring the connection dead
x11Display: string; // Optional local X11 DISPLAY override (empty = use system DISPLAY/default)
// Mosh Connection
@@ -641,7 +663,13 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
keywordHighlightRules: DEFAULT_KEYWORD_HIGHLIGHT_RULES,
localShell: '', // Empty = use system default
localStartDir: '', // Empty = use home directory
keepaliveInterval: 0, // 0 = disabled (use SSH library defaults)
// Cloud-friendly defaults: 30s interval keeps NAT/LB state tables alive,
// and 10 unanswered keepalives provides headroom for brief network glitches
// before declaring the session dead (~5 min). Hosts whose SSH stack doesn't
// reply to keepalive@openssh.com (older routers/switches) should set their
// own per-host keepaliveOverride and dial these values down.
keepaliveInterval: 30,
keepaliveCountMax: 10,
x11Display: '', // Empty = use DISPLAY/default local X server
moshClientPath: '', // Legacy mosh-client override; normal UI uses bundled mosh-client
showServerStats: true, // Show server stats by default
@@ -858,6 +886,7 @@ export interface KnownHost {
port: number;
keyType: string; // ssh-rsa, ssh-ed25519, ecdsa-sha2-nistp256, etc.
publicKey: string; // The host's public key fingerprint or full key
fingerprint?: string; // SHA256 fingerprint without the SHA256: prefix
discoveredAt: number;
lastSeen?: number;
convertedToHostId?: string; // If converted to managed host

View File

@@ -0,0 +1,91 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { Host, ProxyProfile } from "./models.ts";
import {
isCompleteProxyConfig,
normalizeManualProxyConfig,
materializeHostProxyProfile,
removeProxyProfileReferences,
} from "./proxyProfiles.ts";
const profile = (overrides: Partial<ProxyProfile> = {}): ProxyProfile => ({
id: "proxy-1",
label: "Office Proxy",
config: {
type: "socks5",
host: "proxy.example.com",
port: 1080,
username: "alice",
password: "secret",
},
createdAt: 1,
updatedAt: 1,
...overrides,
});
const host = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
label: "Server",
hostname: "server.example.com",
username: "root",
os: "linux",
tags: [],
protocol: "ssh",
...overrides,
});
test("materializeHostProxyProfile resolves a selected proxy profile", () => {
const resolved = materializeHostProxyProfile(
host({ proxyProfileId: "proxy-1" }),
[profile()],
);
assert.deepEqual(resolved.proxyConfig, profile().config);
});
test("materializeHostProxyProfile keeps explicit custom proxy ahead of profile reference", () => {
const customProxy = {
type: "http" as const,
host: "custom.example.com",
port: 3128,
};
const resolved = materializeHostProxyProfile(
host({ proxyProfileId: "proxy-1", proxyConfig: customProxy }),
[profile()],
);
assert.deepEqual(resolved.proxyConfig, customProxy);
});
test("removeProxyProfileReferences clears hosts and group configs that use a deleted profile", () => {
const result = removeProxyProfileReferences("proxy-1", {
hosts: [
host({ id: "host-1", proxyProfileId: "proxy-1" }),
host({ id: "host-2", proxyProfileId: "proxy-2" }),
],
groupConfigs: [
{ path: "prod", proxyProfileId: "proxy-1" },
{ path: "dev", proxyProfileId: "proxy-2" },
],
});
assert.equal(result.hosts[0].proxyProfileId, undefined);
assert.equal(result.hosts[1].proxyProfileId, "proxy-2");
assert.equal(result.groupConfigs[0].proxyProfileId, undefined);
assert.equal(result.groupConfigs[1].proxyProfileId, "proxy-2");
});
test("normalizeManualProxyConfig clears empty proxy drafts", () => {
assert.equal(
normalizeManualProxyConfig({ type: "http", host: "", port: 8080 }),
undefined,
);
});
test("isCompleteProxyConfig requires host and a valid port", () => {
assert.equal(isCompleteProxyConfig({ type: "http", host: "", port: 8080 }), false);
assert.equal(isCompleteProxyConfig({ type: "http", host: "proxy.example.com", port: 0 }), false);
assert.equal(isCompleteProxyConfig({ type: "http", host: "proxy.example.com", port: 3128 }), true);
});

77
domain/proxyProfiles.ts Normal file
View File

@@ -0,0 +1,77 @@
import type { GroupConfig, Host, ProxyConfig, ProxyProfile } from "./models";
const cloneProxyConfig = (config: ProxyConfig): ProxyConfig => ({
...config,
});
export const isValidProxyPort = (port: unknown): boolean => {
const value = Number(port);
return Number.isInteger(value) && value >= 1 && value <= 65535;
};
export const isEmptyProxyConfigDraft = (config: ProxyConfig | undefined): boolean => {
if (!config) return true;
return !config.host.trim() && !config.username?.trim() && !config.password?.trim();
};
export const isCompleteProxyConfig = (config: ProxyConfig | undefined): boolean => {
return Boolean(config?.host.trim()) && isValidProxyPort(config?.port);
};
export const normalizeManualProxyConfig = (
config: ProxyConfig | undefined,
): ProxyConfig | undefined => {
if (!config || isEmptyProxyConfigDraft(config)) return undefined;
return {
...config,
host: config.host.trim(),
username: config.username?.trim() || undefined,
password: config.password || undefined,
};
};
export function findProxyProfile(
proxyProfileId: string | undefined,
proxyProfiles: ProxyProfile[],
): ProxyProfile | undefined {
if (!proxyProfileId) return undefined;
return proxyProfiles.find((profile) => profile.id === proxyProfileId);
}
export function materializeHostProxyProfile<T extends Host>(
host: T,
proxyProfiles: ProxyProfile[],
): T {
if (host.proxyConfig || !host.proxyProfileId) return host;
const profile = findProxyProfile(host.proxyProfileId, proxyProfiles);
if (!profile) return host;
return {
...host,
proxyConfig: cloneProxyConfig(profile.config),
};
}
const clearProxyProfileId = <T extends { proxyProfileId?: string }>(
item: T,
proxyProfileId: string,
): T => {
if (item.proxyProfileId !== proxyProfileId) return item;
const { proxyProfileId: _proxyProfileId, ...rest } = item;
return rest as T;
};
export function removeProxyProfileReferences(
proxyProfileId: string,
data: {
hosts: Host[];
groupConfigs: GroupConfig[];
},
): {
hosts: Host[];
groupConfigs: GroupConfig[];
} {
return {
hosts: data.hosts.map((host) => clearProxyProfileId(host, proxyProfileId)),
groupConfigs: data.groupConfigs.map((config) => clearProxyProfileId(config, proxyProfileId)),
};
}

99
domain/sshAuth.test.ts Normal file
View File

@@ -0,0 +1,99 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveBridgeKeyAuth, resolveHostAuth } from "./sshAuth.ts";
import type { Host, SSHKey } from "./models.ts";
const referenceKey: SSHKey = {
id: "key-1",
label: "Reference key",
type: "ED25519",
privateKey: "",
source: "reference",
category: "key",
created: 1,
filePath: "/Users/alice/.ssh/id_ed25519",
};
test("resolveBridgeKeyAuth passes reference keys as identity file paths", () => {
assert.deepEqual(
resolveBridgeKeyAuth({
key: referenceKey,
fallbackIdentityFilePaths: ["/legacy/key"],
passphrase: "saved-passphrase",
}),
{
privateKey: undefined,
identityFilePaths: ["/Users/alice/.ssh/id_ed25519"],
passphrase: "saved-passphrase",
},
);
});
test("resolveBridgeKeyAuth ignores undecryptable passphrase placeholders", () => {
assert.equal(
resolveBridgeKeyAuth({
key: {
...referenceKey,
passphrase: "enc:v1:djEwAAAA",
},
}).passphrase,
undefined,
);
});
test("resolveBridgeKeyAuth ignores undecryptable private key placeholders", () => {
assert.equal(
resolveBridgeKeyAuth({
key: {
...referenceKey,
source: "imported",
filePath: undefined,
privateKey: "enc:v1:djEwAAAA",
},
}).privateKey,
undefined,
);
});
test("resolveBridgeKeyAuth preserves imported key material", () => {
const importedKey: SSHKey = {
...referenceKey,
source: "imported",
privateKey: "PRIVATE KEY",
filePath: undefined,
};
assert.deepEqual(
resolveBridgeKeyAuth({
key: importedKey,
fallbackIdentityFilePaths: ["/legacy/key"],
}),
{
privateKey: "PRIVATE KEY",
identityFilePaths: ["/legacy/key"],
passphrase: undefined,
},
);
});
test("resolveHostAuth respects password auth over stale key selections", () => {
const host: Host = {
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
authMethod: "password",
identityFileId: "key-1",
};
const resolved = resolveHostAuth({
host,
keys: [referenceKey],
identities: [],
});
assert.equal(resolved.authMethod, "password");
assert.equal(resolved.key, undefined);
assert.equal(resolved.keyId, undefined);
});

View File

@@ -1,4 +1,5 @@
import type { Host, Identity, SSHKey } from "./models";
import { sanitizeCredentialValue } from "./credentials";
type HostAuthMethod = "password" | "key" | "certificate";
@@ -18,6 +19,7 @@ type ResolvedHostAuth = {
keyId?: string;
key?: SSHKey;
passphrase?: string;
identityFilePath?: string;
};
const inferAuthMethod = (opts: {
@@ -57,9 +59,15 @@ export const resolveHostAuth = (args: {
host.username?.trim() ||
"";
// Don't load key when explicit password auth is requested
// This ensures user's auth method selection is strictly respected
const keyId = override?.authMethod === 'password'
const selectedAuthMethod = (
override?.authMethod ||
identity?.authMethod ||
host.authMethod
) as HostAuthMethod | undefined;
// Don't load key when password auth is selected.
// This ensures the user's auth method selection is strictly respected.
const keyId = selectedAuthMethod === "password"
? undefined
: (override?.keyId || identity?.keyId || host.identityFileId || undefined);
@@ -78,6 +86,10 @@ export const resolveHostAuth = (args: {
const passphrase = override?.passphrase || key?.passphrase || undefined;
const identityFilePath = key?.source === 'reference' && key.filePath
? key.filePath
: undefined;
return {
identity,
authMethod,
@@ -86,5 +98,27 @@ export const resolveHostAuth = (args: {
keyId,
key,
passphrase,
identityFilePath,
};
};
export const resolveBridgeKeyAuth = (args: {
key?: SSHKey | null;
fallbackIdentityFilePaths?: string[];
passphrase?: string;
}): {
privateKey?: string;
identityFilePaths?: string[];
passphrase?: string;
} => {
const { key, fallbackIdentityFilePaths, passphrase } = args;
const identityFilePaths = key?.source === "reference" && key.filePath
? [key.filePath]
: fallbackIdentityFilePaths;
return {
privateKey: key?.source === "reference" ? undefined : sanitizeCredentialValue(key?.privateKey),
identityFilePaths,
passphrase: sanitizeCredentialValue(passphrase ?? key?.passphrase),
};
};

View File

@@ -164,6 +164,7 @@ export interface SyncPayload {
hosts: import('./models').Host[];
keys: import('./models').SSHKey[];
identities?: import('./models').Identity[];
proxyProfiles?: import('./models').ProxyProfile[];
snippets: import('./models').Snippet[];
customGroups: string[];
snippetPackages?: string[];
@@ -190,6 +191,7 @@ export interface SyncPayload {
customCSS?: string;
// Terminal
terminalTheme?: string;
followAppTerminalTheme?: boolean;
terminalFontFamily?: string;
terminalFontSize?: number;
terminalSettings?: Record<string, unknown>;
@@ -204,6 +206,7 @@ export interface SyncPayload {
sftpShowHiddenFiles?: boolean;
sftpUseCompressedUpload?: boolean;
sftpAutoOpenSidebar?: boolean;
sftpDefaultViewMode?: 'list' | 'tree';
sftpGlobalBookmarks?: import('./models').SftpBookmark[];
// Immersive mode
immersiveMode?: boolean;
@@ -213,6 +216,25 @@ export interface SyncPayload {
showOnlyUngroupedHostsInRoot?: boolean;
// Top tabs: show standalone SFTP view tab
showSftpTab?: boolean;
// Workspace focus indicator style
workspaceFocusStyle?: 'dim' | 'border';
// AI configuration
ai?: {
providers?: Array<Record<string, unknown>>;
activeProviderId?: string;
activeModelId?: string;
globalPermissionMode?: 'observer' | 'confirm' | 'autonomous';
toolIntegrationMode?: 'mcp' | 'skills';
hostPermissions?: Array<Record<string, unknown>>;
// externalAgents intentionally omitted: command/args/env are device-local
// (binary paths, OS-specific values) and don't survive cross-device sync.
defaultAgentId?: string;
commandBlocklist?: string[];
commandTimeout?: number;
maxIterations?: number;
agentModelMap?: Record<string, string>;
webSearchConfig?: Record<string, unknown> | null;
};
};
// Sync metadata

View File

@@ -156,6 +156,26 @@ test("only non-hosts entity shrinks → reports that entity", () => {
}
});
test("proxy profile shrink is protected like other synced vault entities", () => {
const proxyProfiles = (n: number) =>
Array.from({ length: n }, (_, i) => ({
id: `proxy-${i}`,
label: `Proxy ${i}`,
config: { type: "http", host: `proxy-${i}.example.com`, port: 3128 },
createdAt: i,
}));
const base = payload({ proxyProfiles: proxyProfiles(10) } as Partial<SyncPayload>);
const out = payload({ proxyProfiles: [] } as Partial<SyncPayload>);
const result = detectSuspiciousShrink(out, base);
assert.equal(result.suspicious, true);
if (result.suspicious) {
assert.equal(result.entityType, "proxyProfiles");
assert.equal(result.reason, "large-shrink");
}
});
test("knownHosts shrink is ignored because known hosts are local-only", () => {
const kh = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `kh${i}`, hostname: `h${i}`, port: 22, keyType: "rsa", fingerprint: "x" })) as unknown as SyncPayload["knownHosts"];
const base = payload({ knownHosts: kh(12) });

View File

@@ -9,6 +9,7 @@ export type ShrinkFinding =
| 'hosts'
| 'keys'
| 'identities'
| 'proxyProfiles'
| 'snippets'
| 'customGroups'
| 'snippetPackages'
@@ -28,6 +29,7 @@ const CHECKED_ENTITIES = [
'hosts',
'keys',
'identities',
'proxyProfiles',
'snippets',
'customGroups',
'snippetPackages',

View File

@@ -26,8 +26,9 @@ const knownHosts = (n: number): SyncPayload["knownHosts"] =>
hostname: `host-${i}.example.com`,
port: 22,
keyType: "ssh-ed25519",
fingerprint: `SHA256:${i}`,
})) as SyncPayload["knownHosts"];
publicKey: `SHA256:${i}`,
discoveredAt: 1,
}));
test("mergeSyncPayloads does not carry legacy known hosts forward", () => {
const result = mergeSyncPayloads(
@@ -38,3 +39,100 @@ test("mergeSyncPayloads does not carry legacy known hosts forward", () => {
assert.equal("knownHosts" in result.payload, false);
});
test("mergeSyncPayloads merges reusable proxy profiles by id", () => {
const localProfile = {
id: "proxy-local",
label: "Local Proxy",
config: { type: "http", host: "local.example.com", port: 3128 },
createdAt: 1,
updatedAt: 1,
};
const remoteProfile = {
id: "proxy-remote",
label: "Remote Proxy",
config: { type: "socks5", host: "remote.example.com", port: 1080 },
createdAt: 2,
updatedAt: 2,
};
const result = mergeSyncPayloads(
payload(),
payload({ proxyProfiles: [localProfile] } as Partial<SyncPayload>),
payload({ proxyProfiles: [remoteProfile] } as Partial<SyncPayload>),
);
assert.deepEqual(result.payload.proxyProfiles?.map((item) => item.id).sort(), [
"proxy-local",
"proxy-remote",
]);
});
test("mergeSyncPayloads preserves proxy profiles when remote payload predates them", () => {
const proxy = {
id: "proxy-1",
label: "Office Proxy",
config: { type: "http", host: "proxy.example.com", port: 3128 },
createdAt: 1,
};
const result = mergeSyncPayloads(
payload({ proxyProfiles: [proxy] } as Partial<SyncPayload>),
payload({ proxyProfiles: [proxy] } as Partial<SyncPayload>),
payload(),
);
assert.deepEqual(result.payload.proxyProfiles, [proxy]);
});
test("mergeSyncPayloads keeps missing proxy references visible to connection guards", () => {
const result = mergeSyncPayloads(
payload({
hosts: [{
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
proxyProfileId: "proxy-1",
}],
proxyProfiles: [{
id: "proxy-1",
label: "Old Proxy",
config: { type: "http", host: "old.example.com", port: 3128 },
createdAt: 1,
}],
groupConfigs: [{ path: "prod", proxyProfileId: "proxy-1" }],
}),
payload({
hosts: [{
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
proxyProfileId: "proxy-1",
}],
proxyProfiles: [],
groupConfigs: [{ path: "prod", proxyProfileId: "proxy-1" }],
}),
payload({
hosts: [{
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
proxyProfileId: "proxy-1",
}],
proxyProfiles: [],
groupConfigs: [{ path: "prod", proxyProfileId: "proxy-1" }],
}),
);
assert.equal(result.payload.hosts[0]?.proxyProfileId, "proxy-1");
assert.equal(result.payload.groupConfigs?.[0]?.proxyProfileId, "proxy-1");
});

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