Compare commits

...

65 Commits

Author SHA1 Message Date
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
144 changed files with 14823 additions and 1670 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/

174
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,7 +627,7 @@ 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);
return;
@@ -808,9 +830,11 @@ function App({ settings }: { settings: SettingsState }) {
// Auto-start port forwarding rules on app launch
usePortForwardingAutoStart({
isVaultInitialized,
hosts,
keys,
identities,
proxyProfiles,
groupConfigs,
});
@@ -975,8 +999,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 +1050,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 +1123,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 +1171,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 +1462,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 +1575,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 +1634,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 +1828,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 +1991,7 @@ function App({ settings }: { settings: SettingsState }) {
hosts={hosts}
keys={keys}
identities={identities}
proxyProfiles={proxyProfiles}
snippets={snippets}
snippetPackages={snippetPackages}
customGroups={customGroups}
@@ -1869,7 +2014,9 @@ function App({ settings }: { settings: SettingsState }) {
onUpdateGroupConfigs={updateGroupConfigs}
onUpdateHosts={updateHosts}
onUpdateKeys={updateKeys}
onImportOrReuseKey={importOrReuseKey}
onUpdateIdentities={updateIdentities}
onUpdateProxyProfiles={updateProxyProfiles}
onUpdateSnippets={updateSnippets}
onUpdateSnippetPackages={updateSnippetPackages}
onUpdateCustomGroups={updateCustomGroups}
@@ -1895,6 +2042,7 @@ function App({ settings }: { settings: SettingsState }) {
hosts={hosts}
keys={keys}
identities={identities}
proxyProfiles={proxyProfiles}
groupConfigs={groupConfigs}
updateHosts={updateHosts}
sftpDefaultViewMode={sftpDefaultViewMode}
@@ -1911,6 +2059,7 @@ function App({ settings }: { settings: SettingsState }) {
<TerminalLayerMount
hosts={hosts}
groupConfigs={groupConfigs}
proxyProfiles={proxyProfiles}
keys={keys}
identities={identities}
snippets={snippets}
@@ -1937,7 +2086,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 +2365,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

@@ -481,7 +481,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 +499,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 +515,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 +774,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',
@@ -1114,6 +1136,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',
@@ -1301,6 +1329,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 +1701,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 +1829,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': '拖拽文件到这里',
@@ -902,6 +924,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': '字体',
@@ -1539,13 +1571,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 +1710,7 @@ const zhCN: Messages = {
'keychain.edit.publicKey': '公钥',
'keychain.edit.certificate': '证书',
'keychain.edit.certificatePlaceholder': '证书内容(可选)',
'keychain.edit.filePath': '文件路径',
'keychain.edit.keyExport': '密钥导出',
'keychain.edit.exportToHost': '导出到主机',
@@ -1799,6 +1838,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

@@ -281,7 +281,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 +305,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,7 +1,7 @@
import { useCallback } from "react";
import type { Host, Identity, SSHKey } from "../../../domain/models";
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
import { resolveHostAuth } from "../../../domain/sshAuth";
import { resolveBridgeKeyAuth, resolveHostAuth } from "../../../domain/sshAuth";
interface UseSftpHostCredentialsParams {
hosts: Host[];
@@ -9,94 +9,150 @@ interface UseSftpHostCredentialsParams {
identities: Identity[];
}
export const buildSftpHostCredentials = ({
host,
hosts,
keys,
identities,
}: UseSftpHostCredentialsParams & { host: Host }): NetcattySSHOptions => {
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.`);
}
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,
};
});
}
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.");
}
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,
};
};
export const useSftpHostCredentials = ({
hosts,
keys,
identities,
}: 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,
};
},
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities }),
[hosts, identities, keys],
);

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,26 +18,97 @@ import {
import { logger } from "../../lib/logger";
export interface UsePortForwardingAutoStartOptions {
isVaultInitialized: boolean;
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
proxyProfiles: ProxyProfile[];
groupConfigs: GroupConfig[];
}
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,
}: 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 isHostAuthReady = useCallback((host: Host, seen = new Set<string>()): boolean => {
@@ -77,16 +149,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 +208,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);
};
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 +267,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 +280,45 @@ 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
);
}
};
void runAutoStart();
}, [hosts, identities, isHostAuthReady, keys, resolveEffectiveHost]);
}, [
groupConfigs,
hosts,
identities,
isHostAuthReady,
isVaultInitialized,
keys,
proxyProfiles,
resolveEffectiveHost,
resolveEffectiveHosts,
updateStoredRuleStatus,
]);
};

View File

@@ -304,6 +304,8 @@ export const useSftpState = (
writeTextFileByConnection,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalFileList,
uploadExternalFolderPath,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
@@ -381,6 +383,8 @@ export const useSftpState = (
writeTextFileByConnection,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalFileList,
uploadExternalFolderPath,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
@@ -436,6 +440,8 @@ export const useSftpState = (
writeTextFileByConnection,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalFileList,
uploadExternalFolderPath,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
@@ -501,6 +507,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

@@ -8,6 +8,7 @@ import {
KeyCategory,
KnownHost,
ManagedSource,
ProxyProfile,
ShellHistoryEntry,
Snippet,
SSHKey,
@@ -26,6 +27,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 +38,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 +66,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 +86,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 +112,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 +128,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 +138,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 +154,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);
@@ -188,7 +242,7 @@ export const useVaultState = () => {
const updateGroupConfigs = useCallback((data: GroupConfig[]) => {
setGroupConfigs(data);
const ver = ++groupConfigsWriteVersion.current;
encryptGroupConfigs(data).then((enc) => {
return encryptGroupConfigs(data).then((enc) => {
if (ver === groupConfigsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
});
@@ -198,6 +252,7 @@ export const useVaultState = () => {
updateHosts([]);
updateKeys([]);
updateIdentities([]);
updateProxyProfiles([]);
updateSnippets([]);
updateSnippetPackages([]);
updateCustomGroups([]);
@@ -209,6 +264,7 @@ export const useVaultState = () => {
updateHosts,
updateKeys,
updateIdentities,
updateProxyProfiles,
updateSnippets,
updateSnippetPackages,
updateCustomGroups,
@@ -414,6 +470,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 =
@@ -528,6 +598,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);
@@ -621,30 +703,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 +741,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 +753,7 @@ export const useVaultState = () => {
hosts,
keys,
identities,
proxyProfiles,
snippets,
customGroups,
snippetPackages,
@@ -676,7 +764,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,96 @@ 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("applyLocalVaultPayload restores known hosts from local backups", async () => {
let imported: Record<string, unknown> | null = null;
const payload: SyncPayload = {
hosts: [],
@@ -128,7 +573,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',
'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

@@ -35,8 +35,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 +50,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 +71,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 +91,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 +138,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 +153,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 +175,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 +215,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 +242,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 +254,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 +294,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 +317,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 +349,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 +428,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 +476,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 +629,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
identityFileId: undefined,
identityFilePaths: undefined,
}));
setPendingReferenceKeyPath(null);
setSelectedCredentialType(null);
setCredentialPopoverOpen(false);
setIdentitySuggestionsOpen(false);
@@ -536,7 +663,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 +766,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
...(form.protocols || []),
{
protocol: "telnet" as const,
port: form.telnetPort || 23,
port: effectiveTelnetPort,
enabled: true,
theme: themeId,
},
@@ -1032,6 +1162,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 +1193,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onClick={() => {
update("identityFileId", undefined);
update("authMethod", "password");
setPendingReferenceKeyPath(null);
setSelectedCredentialType(null);
}}
>
@@ -1154,6 +1288,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 +1325,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 +1361,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 +1379,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);
}
}}
>
@@ -1758,35 +1887,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 +2003,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 +2091,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,6 +71,7 @@ interface PortForwardingProps {
customGroups: string[];
managedSources?: ManagedSource[];
groupConfigs?: GroupConfig[];
proxyProfiles?: ProxyProfile[];
onNewHost?: () => void;
onSaveHost?: (host: Host) => void;
onCreateGroup?: (groupPath: string) => void;
@@ -81,6 +84,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
customGroups: _customGroups,
managedSources = [],
groupConfigs = [],
proxyProfiles = [],
onNewHost: _onNewHost,
onSaveHost,
onCreateGroup: _onCreateGroup,
@@ -113,6 +117,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 +145,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 +155,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
const result = await startTunnel(
rule,
_host,
hosts,
effectiveHosts,
keys,
identities,
(status, error) => {
@@ -169,7 +186,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
});
}
},
[hosts, identities, keys, groupConfigs, setRuleStatus, startTunnel, t],
[hosts, identities, keys, resolveEffectiveHost, setRuleStatus, startTunnel, t],
);
// Stop a port forwarding tunnel
@@ -853,6 +870,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;
@@ -74,6 +75,7 @@ interface SftpSidePanelProps {
const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
hosts,
writableHosts,
keys,
identities,
updateHosts,
@@ -98,6 +100,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
onRequestTerminalFocus,
}) => {
const { t } = useI18n();
const hostWriteSource = writableHosts ?? hosts;
const fileWatchHandlers = useMemo(() => ({
onFileWatchSynced: (payload: { remotePath: string }) => {
@@ -622,6 +625,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
return (
<SftpContextProvider
hosts={hosts}
writableHosts={hostWriteSource}
updateHosts={updateHosts}
draggedFiles={draggedFiles}
dragCallbacks={dragCallbacks}
@@ -741,6 +745,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 &&

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";
@@ -71,6 +73,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
keys,
identities,
groupConfigs = [],
proxyProfiles = [],
updateHosts,
sftpDefaultViewMode,
sftpDoubleClickBehavior,
@@ -109,14 +112,15 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
// 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 +327,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
return (
<SftpContextProvider
hosts={hosts}
hosts={effectiveHosts}
writableHosts={hosts}
updateHosts={updateHosts}
draggedFiles={draggedFiles}
dragCallbacks={dragCallbacks}
@@ -462,7 +467,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
</div>
<SftpOverlays
hosts={hosts}
hosts={effectiveHosts}
sftp={sftp}
visibleTransfers={visibleTransfers}
showHostPickerLeft={showHostPickerLeft}
@@ -507,6 +512,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 &&

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

@@ -32,7 +32,6 @@ 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";
@@ -42,6 +41,8 @@ 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 +219,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
snippets,
chainHosts = [],
themePreviewId,
knownHosts: _knownHosts = [],
knownHosts = [],
isVisible,
inWorkspace,
isResizing,
@@ -624,8 +625,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 +640,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 +664,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;
@@ -749,6 +778,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
host,
keys,
identities,
knownHosts,
resolvedChainHosts,
sessionId,
startupCommand,
@@ -1487,12 +1517,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 +1548,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 +1578,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
pendingConnectionRef.current = null;
}
setPendingHostKeyInfo(null);
setPendingHostKeyRequestId(null);
};
const handleRetry = () => {
@@ -1614,7 +1649,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
};
const shouldShowConnectionDialog = status !== "connected"
&& !needsHostKeyVerification
&& !((isLocalConnection || isSerialConnection) && status === "connecting")
&& !(status === "disconnected" && isDisconnectedDialogDismissed);
@@ -2208,18 +2242,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 +2278,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}
@@ -2653,40 +2682,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";
@@ -117,10 +119,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,10 +341,14 @@ 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);
}

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;
@@ -163,6 +178,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
hosts,
keys,
identities,
proxyProfiles,
snippets,
snippetPackages,
customGroups,
@@ -183,7 +199,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onConnect,
onUpdateHosts,
onUpdateKeys,
onImportOrReuseKey,
onUpdateIdentities,
onUpdateProxyProfiles,
onUpdateSnippets,
onUpdateSnippetPackages,
onUpdateCustomGroups,
@@ -296,6 +314,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 +365,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 +377,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 +385,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 +497,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 +523,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 +540,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 +1200,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 +1690,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 +2535,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 +2868,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}
onRunSnippet={onRunSnippet}
availableKeys={keys}
proxyProfiles={proxyProfiles}
managedSources={managedSources}
onSaveHost={(host) => onUpdateHosts([...hosts, host])}
onCreateGroup={(groupPath) =>
@@ -2840,6 +2883,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 +2921,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}
@@ -2924,6 +2979,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 +3000,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
initialData={editingHost}
availableKeys={keys}
identities={identities}
proxyProfiles={proxyProfiles}
groups={allGroupPaths}
managedSources={managedSources}
allTags={allTags}
@@ -2953,6 +3010,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
terminalFontSize={terminalFontSize}
groupDefaults={editingHostGroupDefaults}
groupConfigs={groupConfigs}
onImportKey={onImportOrReuseKey}
onSave={(host) => {
onUpdateHosts(upsertHostById(hosts, host));
setIsHostPanelOpen(false);
@@ -3207,6 +3265,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 &&

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

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

@@ -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,18 @@ 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,
resolveTelnetPassword,
resolveTelnetPort,
resolveTelnetUsername,
} from "../../../domain/host";
/**
* Per-connection token for stale-timer detection. The renderer reuses the
@@ -68,10 +73,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 +112,7 @@ export type TerminalSessionStartersContext = {
host: Host;
keys: SSHKey[];
identities?: Identity[];
knownHosts?: KnownHost[];
resolvedChainHosts: Host[];
sessionId: string;
startupCommand?: string;
@@ -143,6 +157,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 +223,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 +278,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 +390,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 +443,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 +462,14 @@ 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 jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost, index) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
@@ -397,6 +483,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,7 +508,7 @@ 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);
}
@@ -419,7 +517,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
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 +533,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
}
: undefined,
identityFilePaths: jumpHost.identityFilePaths,
identityFilePaths: jumpIdentityFilePaths,
};
});
@@ -549,6 +647,17 @@ 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;
@@ -560,7 +669,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,
@@ -580,15 +689,14 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
keepaliveInterval: ctx.terminalSettings?.keepaliveInterval,
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 +763,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 +814,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 +933,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 +982,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 +1044,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`);

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

@@ -0,0 +1,184 @@
import test from "node:test";
import assert from "node:assert/strict";
import { applyGroupDefaults, resolveGroupDefaults } 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");
});

View File

@@ -1,5 +1,17 @@
import type { GroupConfig, Host } from './models';
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.
* For group "A/B/C", merges configs from A, A/B, A/B/C (child overrides parent).
@@ -7,6 +19,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 +30,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 +47,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 +75,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,13 @@ import test from "node:test";
import assert from "node:assert/strict";
import type { Host } from "./models.ts";
import { upsertHostById } from "./host.ts";
import {
normalizePrimaryTelnetState,
resolveTelnetPort,
resolveTelnetPassword,
resolveTelnetUsername,
upsertHostById,
} from "./host.ts";
const makeHost = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
@@ -49,3 +55,78 @@ 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);
});

View File

@@ -153,6 +153,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

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
@@ -137,7 +146,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 +163,7 @@ export interface SSHKey {
source: KeySource;
category: KeyCategory;
created: number;
filePath?: string;
}
// Identity combines username with authentication method
@@ -205,6 +215,7 @@ export interface GroupConfig {
port?: number;
protocol?: 'ssh' | 'telnet';
agentForwarding?: boolean;
proxyProfileId?: string;
proxyConfig?: ProxyConfig;
hostChain?: HostChainConfig;
startupCommand?: string;
@@ -415,6 +426,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' },
@@ -858,6 +870,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");
});

View File

@@ -344,6 +344,7 @@ export function mergeSyncPayloads(
hosts: [],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
customGroups: [],
snippetPackages: [],
@@ -363,6 +364,12 @@ export function mergeSyncPayloads(
const hosts = mergeEntityArrays(b.hosts ?? [], local.hosts ?? [], remote.hosts ?? []);
const keys = mergeEntityArrays(b.keys ?? [], local.keys ?? [], remote.keys ?? []);
const identities = mergeEntityArrays(b.identities ?? [], local.identities ?? [], remote.identities ?? []);
const baseProxyProfiles = b.proxyProfiles ?? [];
const proxyProfiles = mergeEntityArrays(
baseProxyProfiles,
local.proxyProfiles ?? baseProxyProfiles,
remote.proxyProfiles ?? baseProxyProfiles,
);
const snippets = mergeEntityArrays(b.snippets ?? [], local.snippets ?? [], remote.snippets ?? []);
const portForwardingRules = mergeEntityArrays(
b.portForwardingRules ?? [],
@@ -376,11 +383,16 @@ export function mergeSyncPayloads(
(arr ?? []).map(gc => ({ ...gc, id: gc.path }));
const unwrapGC = (arr: GCWithId[]): import('./models').GroupConfig[] =>
arr.map(({ id: _id, ...rest }) => rest as import('./models').GroupConfig);
const groupConfigsResult = mergeEntityArrays(wrapGC(b.groupConfigs), wrapGC(local.groupConfigs), wrapGC(remote.groupConfigs));
const baseGroupConfigs = b.groupConfigs ?? [];
const groupConfigsResult = mergeEntityArrays(
wrapGC(baseGroupConfigs),
wrapGC(local.groupConfigs ?? baseGroupConfigs),
wrapGC(remote.groupConfigs ?? baseGroupConfigs),
);
// Aggregate stats
const entityResults: Pick<EntityMergeResult<unknown>, 'added' | 'deleted' | 'modified' | 'conflicts'>[] =
[hosts, keys, identities, snippets, portForwardingRules, groupConfigsResult];
[hosts, keys, identities, proxyProfiles, snippets, portForwardingRules, groupConfigsResult];
for (const r of entityResults) {
summary.added.local += r.added.local;
summary.added.remote += r.added.remote;
@@ -416,15 +428,18 @@ export function mergeSyncPayloads(
});
}
const groupConfigs = unwrapGC(groupConfigsResult.merged);
const payload: SyncPayload = {
hosts: hosts.merged,
keys: keys.merged,
identities: identities.merged,
proxyProfiles: proxyProfiles.merged,
snippets: snippets.merged,
customGroups,
snippetPackages,
portForwardingRules: portForwardingRules.merged,
groupConfigs: unwrapGC(groupConfigsResult.merged),
groupConfigs,
settings,
syncedAt: Date.now(),
};

View File

@@ -168,6 +168,29 @@ function shouldUseShellForCommand(command) {
return normalized.endsWith(".cmd") || normalized.endsWith(".bat");
}
function quoteWindowsShellArg(value) {
const arg = String(value ?? "");
if (!arg) return "\"\"";
return `"${arg.replace(/"/g, '\\"')}"`;
}
function buildWindowsShellCommandLine(command, args) {
return [command, ...(args || [])].map(quoteWindowsShellArg).join(" ");
}
function prepareCommandForSpawn(command, args) {
const spawnArgs = Array.isArray(args) ? args : [];
if (!shouldUseShellForCommand(command)) {
return { command, args: spawnArgs, shell: false };
}
return {
command: buildWindowsShellCommandLine(command, spawnArgs),
args: [],
shell: true,
};
}
function resolveCliFromPath(command, shellEnv) {
// Validate command: only allow valid binary names (alphanumeric, hyphens, underscores, dots)
if (!command || !/^[a-zA-Z0-9._-]+$/.test(command)) {
@@ -380,6 +403,9 @@ module.exports = {
extractFirstNonLocalhostUrl,
normalizeCliPathForPlatform,
shouldUseShellForCommand,
quoteWindowsShellArg,
buildWindowsShellCommandLine,
prepareCommandForSpawn,
resolveCliFromPath,
resolveClaudeAcpBinaryPath,
toUnpackedAsarPath,

View File

@@ -2,10 +2,12 @@ const test = require("node:test");
const assert = require("node:assert/strict");
const {
buildWindowsShellCommandLine,
extractTrailingIdlePrompt,
getFreshIdlePrompt,
isDefaultPowerShellPromptLine,
isPlausibleCliVersionOutput,
prepareCommandForSpawn,
trackSessionIdlePrompt,
} = require("./shellUtils.cjs");
@@ -76,6 +78,30 @@ test("isPlausibleCliVersionOutput rejects stack traces and file URLs", () => {
assert.equal(isPlausibleCliVersionOutput("Usage: claude [options]"), false);
});
test("buildWindowsShellCommandLine quotes command paths and args with spaces", () => {
assert.equal(
buildWindowsShellCommandLine("C:\\Program Files\\Codex\\codex.cmd", ["login", "status"]),
"\"C:\\Program Files\\Codex\\codex.cmd\" \"login\" \"status\"",
);
});
test("prepareCommandForSpawn wraps Windows cmd shims as a single shell command", () => {
const result = prepareCommandForSpawn("C:\\Program Files\\Codex\\codex.cmd", ["--version"]);
if (process.platform === "win32") {
assert.deepEqual(result, {
command: "\"C:\\Program Files\\Codex\\codex.cmd\" \"--version\"",
args: [],
shell: true,
});
} else {
assert.deepEqual(result, {
command: "C:\\Program Files\\Codex\\codex.cmd",
args: ["--version"],
shell: false,
});
}
});
test("tracks PowerShell idle prompt after SSH output", () => {
const session = {};

View File

@@ -26,7 +26,7 @@ const {
const {
stripAnsi,
normalizeCliPathForPlatform,
shouldUseShellForCommand,
prepareCommandForSpawn,
resolveCliFromPath,
resolveClaudeAcpBinaryPath,
isPlausibleCliVersionOutput,
@@ -1392,11 +1392,12 @@ function registerHandlers(ipcMain) {
async function runCommand(command, args, options) {
return await new Promise((resolve, reject) => {
const child = spawn(command, args || [], {
const spawnSpec = prepareCommandForSpawn(command, args || []);
const child = spawn(spawnSpec.command, spawnSpec.args, {
stdio: ["ignore", "pipe", "pipe"],
cwd: options?.cwd || undefined,
env: options?.env || process.env,
shell: shouldUseShellForCommand(command),
shell: spawnSpec.shell,
windowsHide: true,
});
@@ -2006,10 +2007,11 @@ function registerHandlers(ipcMain) {
const shellEnv = await getShellEnv();
const codexCliPath = resolveCliFromPath("codex", shellEnv) || "codex";
const sessionId = `codex_login_${randomUUID()}`;
const child = spawn(codexCliPath, ["login"], {
const spawnSpec = prepareCommandForSpawn(codexCliPath, ["login"]);
const child = spawn(spawnSpec.command, spawnSpec.args, {
stdio: ["ignore", "pipe", "pipe"],
env: shellEnv,
shell: shouldUseShellForCommand(codexCliPath),
shell: spawnSpec.shell,
windowsHide: true,
});

View File

@@ -4,6 +4,7 @@ const fs = require("node:fs");
const Module = require("node:module");
const os = require("node:os");
const path = require("node:path");
const { prepareCommandForSpawn } = require("./ai/shellUtils.cjs");
function createIpcMainStub() {
const handlers = new Map();
@@ -116,6 +117,10 @@ function loadBridgeWithMocks(options = {}) {
? options.normalizeCliPathForPlatform(...args)
: args[0],
shouldUseShellForCommand: () => false,
prepareCommandForSpawn: (...args) =>
typeof options.prepareCommandForSpawn === "function"
? options.prepareCommandForSpawn(...args)
: prepareCommandForSpawn(...args),
isPlausibleCliVersionOutput: (value) =>
typeof options.isPlausibleCliVersionOutput === "function"
? options.isPlausibleCliVersionOutput(value)
@@ -532,6 +537,128 @@ test("resolve-cli accepts stored bundled Codex ACP path", async (t) => {
}
});
test("resolve-cli probes Windows cmd paths with spaces", { skip: process.platform !== "win32" }, async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty codex resolve "));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexPath = path.join(tempDir, "codex.cmd");
fs.writeFileSync(
codexPath,
"@echo off\r\necho codex-cli 1.2.3\r\n",
"utf8",
);
const { bridge, restore } = loadBridgeWithMocks({
prepareCommandForSpawn,
resolveCliFromPath: (command) => (command === "codex" ? codexPath : null),
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
assert.equal(typeof resolveHandler, "function");
const result = await resolveHandler({ sender: { id: 1 } }, { command: "codex", customPath: "" });
assert.deepEqual(result, {
path: codexPath,
version: "codex-cli 1.2.3",
available: true,
});
} finally {
restore();
}
});
test("resolve-cli probes Windows Claude cmd paths with spaces", { skip: process.platform !== "win32" }, async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty claude resolve "));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const claudePath = path.join(tempDir, "claude.cmd");
fs.writeFileSync(
claudePath,
"@echo off\r\necho 2.1.123 (Claude Code)\r\n",
"utf8",
);
const { bridge, restore } = loadBridgeWithMocks({
prepareCommandForSpawn,
resolveCliFromPath: (command) => (command === "claude" ? claudePath : null),
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
assert.equal(typeof resolveHandler, "function");
const result = await resolveHandler({ sender: { id: 1 } }, { command: "claude", customPath: "" });
assert.deepEqual(result, {
path: claudePath,
version: "2.1.123 (Claude Code)",
available: true,
});
} finally {
restore();
}
});
test("resolve-cli probes Windows Claude exe paths with spaces", { skip: process.platform !== "win32" }, async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty claude exe resolve "));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const claudePath = path.join(tempDir, "claude.exe");
fs.copyFileSync(process.execPath, claudePath);
const { bridge, restore } = loadBridgeWithMocks({
prepareCommandForSpawn,
resolveCliFromPath: (command) => (command === "claude" ? claudePath : null),
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
assert.equal(typeof resolveHandler, "function");
const result = await resolveHandler({ sender: { id: 1 } }, { command: "claude", customPath: "" });
assert.deepEqual(result, {
path: claudePath,
version: process.version,
available: true,
});
} finally {
restore();
}
});
test("resolve-cli falls back to bundled Codex ACP when a stored path is stale", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-acp-stale-"));
t.after(() => {

View File

@@ -48,6 +48,15 @@ const buildError = (message, details) => {
return err;
};
// Per RFC 7617, Basic Auth credentials must be UTF-8 encoded before base64.
// The upstream `webdav` package routes through `base-64`, which encodes as
// Latin1 — silently corrupting non-ASCII characters (e.g. `ö`, `ä`) and
// causing 401s against servers that follow the spec, like Hetzner Storage
// Box (#891). We build the header ourselves to avoid that path.
const buildBasicAuthHeader = (username, password) =>
"Basic " +
Buffer.from(`${username || ""}:${password || ""}`, "utf8").toString("base64");
const buildWebdavClient = (config) => {
if (!config) throw new Error("Missing WebDAV config");
const endpoint = normalizeEndpoint(config.endpoint);
@@ -74,9 +83,10 @@ const buildWebdavClient = (config) => {
});
}
return createClient(endpoint, {
authType: AuthType.Password,
username: config.username || "",
password: config.password || "",
authType: AuthType.None,
headers: {
Authorization: buildBasicAuthHeader(config.username, config.password),
},
...extraOpts,
});
};
@@ -286,4 +296,7 @@ const registerHandlers = (ipcMain) => {
module.exports = {
registerHandlers,
// Exposed for tests
handleWebdavInitialize,
buildBasicAuthHeader,
};

View File

@@ -0,0 +1,92 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const http = require("node:http");
const {
buildBasicAuthHeader,
handleWebdavInitialize,
} = require("./cloudSyncBridge.cjs");
// Per RFC 7617, Basic Auth credentials are UTF-8 encoded before base64.
// The upstream `webdav` npm package (via `base-64`) encodes them as Latin1
// instead, which silently corrupts non-ASCII characters like German umlauts
// (ö, ä) — exactly the case reported by Hetzner Storage Box users (#891).
//
// `ö` (U+00F6):
// Latin1 → 1 byte: F6
// UTF-8 → 2 bytes: C3 B6
const PASSWORD_WITH_UMLAUT = "6?G:ö9yZöäMF+H3";
const USERNAME = "uHetzner1";
test("buildBasicAuthHeader UTF-8 encodes credentials (RFC 7617)", () => {
const header = buildBasicAuthHeader("user", "ö");
// UTF-8 base64 of "user:ö" = "user" + ":" + 0xC3 0xB6
assert.equal(header, "Basic dXNlcjrDtg==");
});
test("buildBasicAuthHeader stays compatible with pure-ASCII credentials", () => {
// For ASCII, UTF-8 and Latin1 are byte-identical, so the header is unchanged.
const header = buildBasicAuthHeader("user", "password");
assert.equal(header, "Basic dXNlcjpwYXNzd29yZA==");
});
function startUtf8BasicAuthServer({ username, password }) {
const expected =
"Basic " +
Buffer.from(`${username}:${password}`, "utf8").toString("base64");
const server = http.createServer((req, res) => {
const got = req.headers["authorization"];
if (got !== expected) {
res.writeHead(401, {
"WWW-Authenticate": 'Basic realm="test", charset="UTF-8"',
});
res.end("Unauthorized");
return;
}
// Minimal but parseable PROPFIND response so client.exists() resolves true.
res.writeHead(207, { "Content-Type": "application/xml; charset=utf-8" });
res.end(
`<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>${req.url}</D:href>
<D:propstat>
<D:prop>
<D:displayname>netcatty-vault.json</D:displayname>
<D:getlastmodified>Sat, 10 May 2026 00:00:00 GMT</D:getlastmodified>
<D:getcontentlength>0</D:getcontentlength>
<D:resourcetype/>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`,
);
});
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const { port } = server.address();
resolve({ server, endpoint: `http://127.0.0.1:${port}` });
});
});
}
test("handleWebdavInitialize sends UTF-8 Basic Auth (Hetzner umlaut password #891)", async () => {
const { server, endpoint } = await startUtf8BasicAuthServer({
username: USERNAME,
password: PASSWORD_WITH_UMLAUT,
});
try {
const result = await handleWebdavInitialize({
endpoint,
authType: "password",
username: USERNAME,
password: PASSWORD_WITH_UMLAUT,
});
assert.equal(result.resourceId, "/netcatty-vault.json");
} finally {
await new Promise((resolve) => server.close(resolve));
}
});

View File

@@ -0,0 +1,264 @@
const crypto = require("node:crypto");
const { randomUUID } = require("node:crypto");
const { utils: sshUtils } = require("ssh2");
const REQUEST_TTL_MS = 2 * 60 * 1000;
const hostKeyRequests = new Map();
const normalizeFingerprint = (value) => {
if (typeof value !== "string") return "";
return value
.trim()
.replace(/^SHA256:/i, "")
.replace(/=+$/g, "");
};
const normalizeHostname = (value) => String(value || "").trim().toLowerCase();
const parseKnownHostPattern = (hostname) => {
const value = String(hostname || "").trim();
if (!value) return { hostname: "", port: undefined };
const first = value.split(",")[0];
const bracketMatch = first.match(/^\[([^\]]+)\]:(\d+)$/);
if (bracketMatch) {
return {
hostname: normalizeHostname(bracketMatch[1]),
port: Number.parseInt(bracketMatch[2], 10),
};
}
return { hostname: normalizeHostname(first), port: undefined };
};
const getKnownHostPort = (knownHost) => {
const parsed = parseKnownHostPattern(knownHost?.hostname);
if (Number.isFinite(knownHost?.port)) return Number(knownHost.port);
if (Number.isFinite(parsed.port)) return Number(parsed.port);
return 22;
};
const matchesHostAndPort = (knownHost, hostname, port) => {
const parsed = parseKnownHostPattern(knownHost?.hostname);
if (!parsed.hostname || parsed.hostname === "(hashed)") return false;
return parsed.hostname === normalizeHostname(hostname) && getKnownHostPort(knownHost) === (port || 22);
};
const describeRawPublicKeyBlob = (key) => {
if (!Buffer.isBuffer(key) || key.length < 8) return null;
const typeLength = key.readUInt32BE(0);
if (typeLength <= 0 || typeLength > 128 || 4 + typeLength > key.length) return null;
const keyType = key.subarray(4, 4 + typeLength).toString("ascii");
if (!/^[A-Za-z0-9@._+-]+$/.test(keyType)) return null;
return {
keyType,
publicKey: `${keyType} ${key.toString("base64")}`,
};
};
const fingerprintFromPublicKey = (publicKey) => {
if (typeof publicKey !== "string") return "";
const trimmed = publicKey.trim();
if (!trimmed) return "";
if (/^SHA256:/i.test(trimmed)) return normalizeFingerprint(trimmed);
const parts = trimmed.split(/\s+/);
if (parts.length >= 2 && /^ssh-|^ecdsa-|^sk-/.test(parts[0])) {
try {
return crypto.createHash("sha256")
.update(Buffer.from(parts[1], "base64"))
.digest("base64")
.replace(/=+$/g, "");
} catch {
return "";
}
}
return normalizeFingerprint(trimmed);
};
const getKnownHostFingerprint = (knownHost) => {
return normalizeFingerprint(knownHost?.fingerprint)
|| fingerprintFromPublicKey(knownHost?.publicKey);
};
const classifyHostKey = ({ knownHosts = [], hostname, port = 22, keyType, fingerprint }) => {
const normalizedFingerprint = normalizeFingerprint(fingerprint);
const candidates = Array.isArray(knownHosts)
? knownHosts.filter((knownHost) => matchesHostAndPort(knownHost, hostname, port))
: [];
if (candidates.length === 0) {
return { status: "unknown" };
}
const comparableCandidates = candidates
.map((knownHost) => ({
knownHost,
fingerprint: getKnownHostFingerprint(knownHost),
}))
.filter((entry) => entry.fingerprint);
const match = comparableCandidates.find((entry) => entry.fingerprint === normalizedFingerprint);
if (match) {
return { status: "trusted", knownHost: match.knownHost };
}
const normalizedKeyType = typeof keyType === "string" ? keyType.trim() : "";
const hasSpecificIncomingType = normalizedKeyType && normalizedKeyType !== "unknown";
let sameTypeMismatch;
if (hasSpecificIncomingType) {
sameTypeMismatch = comparableCandidates.find((entry) => entry.knownHost.keyType === normalizedKeyType);
if (!sameTypeMismatch && comparableCandidates.length === 1) {
const onlyCandidate = comparableCandidates[0];
if (!onlyCandidate.knownHost.keyType || onlyCandidate.knownHost.keyType === "unknown") {
sameTypeMismatch = onlyCandidate;
}
}
} else if (comparableCandidates.length === 1) {
sameTypeMismatch = comparableCandidates[0];
}
if (sameTypeMismatch) {
return {
status: "changed",
knownHost: sameTypeMismatch.knownHost,
expectedFingerprint: sameTypeMismatch.fingerprint,
};
}
return { status: "unknown" };
};
const describeHostKey = (rawKey) => {
const key = Buffer.isBuffer(rawKey) ? rawKey : Buffer.from(rawKey || "");
const fingerprint = crypto.createHash("sha256")
.update(key)
.digest("base64")
.replace(/=+$/g, "");
let keyType = "unknown";
let publicKey;
const rawPublicKey = describeRawPublicKeyBlob(key);
if (rawPublicKey) {
keyType = rawPublicKey.keyType;
publicKey = rawPublicKey.publicKey;
}
try {
const parsed = sshUtils.parseKey(key);
const parsedKey = Array.isArray(parsed) ? parsed[0] : parsed;
if (parsedKey && !(parsedKey instanceof Error)) {
keyType = parsedKey.type || keyType;
const publicSsh = parsedKey.getPublicSSH?.();
if (publicSsh) publicKey = publicSsh.toString("utf8");
}
} catch {
// Keep the fingerprint; key type/public key are best-effort metadata.
}
return { keyType, fingerprint, publicKey };
};
const generateRequestId = () => `hostkey-${randomUUID()}`;
const settleRequest = (requestId, response) => {
const pending = hostKeyRequests.get(requestId);
if (!pending) return { success: false, error: "Request not found" };
if (pending.timeoutId) clearTimeout(pending.timeoutId);
hostKeyRequests.delete(requestId);
pending.resolve(response);
return { success: true };
};
const requestHostKeyVerification = (sender, info) => new Promise((resolve) => {
if (!sender || sender.isDestroyed?.()) {
resolve({ accept: false });
return;
}
const requestId = generateRequestId();
const timeoutId = setTimeout(() => {
settleRequest(requestId, { accept: false, timeout: true });
}, REQUEST_TTL_MS);
hostKeyRequests.set(requestId, {
resolve,
timeoutId,
createdAt: Date.now(),
webContentsId: sender.id,
sessionId: info.sessionId,
});
try {
sender.send("netcatty:host-key:verify", {
requestId,
...info,
});
} catch {
settleRequest(requestId, { accept: false });
}
});
const createHostVerifier = ({
sender,
sessionId,
hostname,
port = 22,
knownHosts = [],
}) => (rawKey, callback) => {
const keyInfo = describeHostKey(rawKey);
const decision = classifyHostKey({
knownHosts,
hostname,
port,
keyType: keyInfo.keyType,
fingerprint: keyInfo.fingerprint,
});
if (decision.status === "trusted") {
callback(true);
return;
}
void requestHostKeyVerification(sender, {
sessionId,
hostname,
port,
status: decision.status,
keyType: keyInfo.keyType,
fingerprint: keyInfo.fingerprint,
publicKey: keyInfo.publicKey,
knownHostId: decision.knownHost?.id,
knownFingerprint: decision.expectedFingerprint,
}).then((response) => {
callback(Boolean(response?.accept));
}).catch(() => {
callback(false);
});
};
const handleResponse = (_event, payload) => {
const { requestId, accept, addToKnownHosts } = payload || {};
return settleRequest(requestId, {
accept: Boolean(accept),
addToKnownHosts: Boolean(addToKnownHosts),
});
};
const registerHandler = (ipcMain) => {
ipcMain.handle("netcatty:host-key:respond", handleResponse);
};
const getRequests = () => hostKeyRequests;
module.exports = {
classifyHostKey,
createHostVerifier,
describeHostKey,
getKnownHostFingerprint,
handleResponse,
normalizeFingerprint,
registerHandler,
requestHostKeyVerification,
getRequests,
};

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