Compare commits

..

87 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
bincxz
1f0d3d8274 Handle cross-device mosh bundle moves
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
2026-05-01 17:10:13 +08:00
bincxz
d8c62a55f5 Fix Windows mosh bundle extraction 2026-05-01 16:54:57 +08:00
陈大猫
1b08e5ee88 [codex] Fix SFTP editor saved state (#887)
* Fix SFTP editor saved state

* Restore window input focus after SFTP editor

* Harden SFTP editor save flows
2026-05-01 16:31:58 +08:00
bincxz
de7057183c Increase AI code block top spacing 2026-05-01 13:48:42 +08:00
bincxz
dd910cc53d Tighten AI code block spacing 2026-05-01 13:43:06 +08:00
陈大猫
8ccefc821c [codex] Use dedicated mosh binary repository (#881)
* Use dedicated mosh binary repository

* Require bundled mosh client

* Auto-fill saved password for mosh SSH handshake

* Harden bundled mosh binary flow
2026-05-01 11:54:10 +08:00
陈大猫
863397fc7d Fix DeepSeek reasoning replay for tool loops (#882)
* Fix OpenAI-compatible reasoning replay for tool loops

* Fix reasoning continuation replay
2026-05-01 11:45:47 +08:00
陈大猫
6a39ed05a9 [codex] Tighten AI chat spacing (#883)
* Tighten AI chat spacing

* Scope AI table spacing styles
2026-05-01 11:33:07 +08:00
陈大猫
470d9b5aae [codex] Improve ACP agent error diagnostics (#880) 2026-05-01 08:00:50 +08:00
陈大猫
20694a47dd Fix Codex ACP model picker (#879) 2026-05-01 08:00:05 +08:00
陈大猫
d86c5ed05a [codex] Remove mosh client path setting (#878)
* fix(terminal): remove mosh client path setting

* fix(terminal): remove stale mosh detection bridge
2026-04-30 17:54:35 +08:00
陈大猫
fdaaaf62d8 [codex] Preserve provider reasoning context (#877)
* fix(ai): preserve provider reasoning context

* fix(ai): harden provider continuation replay
2026-04-30 17:08:19 +08:00
秋秋
2ceea46b50 feat(ssh): enhance getSessionPwd to support fish shell and improve cwd retrieval (#869)
* feat(ssh): enhance getSessionPwd to support fish shell and improve cwd retrieval

* fix ssh cwd detection review issues

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-04-30 15:27:45 +08:00
Eric Chan
5a1d6931a5 Fix Tab completion preferring history over local files (#867)
* Fix spec-aware path completion priority

Use resolved Fig spec args when deciding when filesystem suggestions should outrank command history. Add a regression test covering a spec-driven file argument command.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix generator-only spec path completion

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-04-30 14:42:01 +08:00
yuzifu
fb97e242ee feat: add SFTP upload conflict handling (#874)
* feat: add SFTP upload conflict handling
Add conflict resolution for SFTP uploads so files and folders can be stopped, skipped, replaced, duplicated, or merged depending on the target state. Support batch uploads with Apply to All behavior, route external upload conflicts through the shared SFTP conflict dialog, and add the bridge operations needed to stat and delete existing upload targets.

* fix review issue

* Fix SFTP conflict cancellation cleanup

---------

Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-04-30 14:22:00 +08:00
YumeSaku
68040ebdd7 fix(autocomplete): recognize Nerd Font / Powerline glyphs as prompt terminators (#871)
* fix(autocomplete): recognize Nerd Font / Powerline glyphs as prompt terminators

oh-my-posh and similar themed prompts end with PUA codepoints (e.g. U+F105
chevron, U+E0B0 powerline arrow) that aren't in the hardcoded PROMPT_CHARS
set, so findPromptBoundary returned -1 and both ghost-text and popup
autocomplete went silent. Treat any Private Use Area char (U+E000-U+F8FF)
followed by a space as a candidate prompt terminator — real shell commands
essentially never contain PUA codepoints, so this is high-confidence.

* Fix Powerline glyph prompt splitting

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-04-30 13:57:07 +08:00
Blossom
cca6dac543 fix(sftp): use custom tooltips in transfer queue (#872)
* fix(sftp): replace transfer queue native tooltips

* Fix SFTP transfer tooltip regressions

* Improve SFTP transfer tooltip accessibility

* Cover SFTP cancel tooltip label

---------

Co-authored-by: Mack Ding <mackding@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-04-30 13:23:51 +08:00
陈大猫
d86b720748 Run CI on every push/PR; gate release on strict v tags (#868)
* Run CI on every push/PR; gate release on strict v<X>.<Y>.<Z> tags

The build-packages workflow used to trigger only on `push: tags: v*`,
so branches and PRs never built and the only way to test the matrix
was to push a tag — which also auto-published a GitHub Release. That
made it impossible to verify a CI change without either skipping
testing or shipping a junk release.

Restructure the triggers:

- `push: branches: ['**']` + `pull_request` so any push or PR runs
  the build matrix and uploads workflow artifacts.
- `push: tags` accepts only strict semver: `v<MAJOR>.<MINOR>.<PATCH>`
  with an optional pre-release suffix like `v1.2.3-rc.1`. Loose tags
  (`v-test`, `vNEXT`, `v1.0`) no longer match.
- The release job's `if:` enforces the same rule independently — even
  if someone re-broadens the trigger later, branches and PRs can't
  publish a release.
- `Set version` produces semver-compliant `0.0.0-sha.<short>` for
  non-tag runs so `npm pkg set` / electron-builder don't choke on a
  bare commit SHA like `abc1234`.
- Add a concurrency group that cancels superseded branch/PR builds
  to save runner minutes; tag builds use a unique group so releases
  never get cancelled by a follow-up commit.

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

* Apply strict-semver Set-version step to Linux jobs too

The previous commit only patched the matrix job's Set version step
(macOS/Windows) because the Linux legs had a slightly different
template (no comments). The Linux Set version step kept setting
package.json's version to a bare 7-char commit SHA like "812f296",
which electron-builder rejects with `Invalid version: "812f296"`
during normalizePackageData.

Replicate the same strict regex + 0.0.0-sha.<short> fallback in both
Linux jobs so non-tag runs produce a valid semver across the matrix.

Reproduced from build-linux-x64 logs of the run on 112bf3a1:
  Setting version to 812f296
  ⨯ Invalid version: "812f296"  failedTask=build

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

* Fix build workflow trigger review issues

* Address build workflow review findings

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 12:22:50 +08:00
陈大猫
aa192c66c3 Wire bundled mosh release flow
* Wire bundled mosh release flow

* Fix bundled mosh release flow review findings
2026-04-30 09:28:08 +08:00
陈大猫
7dd25a55bb Bundle mosh-client + Node-side PTY handshake
* Bundle mosh-client via CI build pipeline

Add a GitHub Actions workflow that builds a static, distro-portable
mosh-client for linux-x64, linux-arm64, darwin-universal (arm64+x86_64)
from upstream mobile-shell/mosh source, plus a pinned win32-x64 binary
sourced from FluentTerminal (GPL-3.0). Releases attach SHA256SUMS so
scripts/fetch-mosh-binaries.cjs can verify and pull the right binary
into resources/mosh/<platform-arch>/ during npm run pack.

electron-builder.config.cjs gains a moshExtraResources() helper that
adds the binary to extraResources only when present on disk, keeping
local dev packages working without bundled mosh.

terminalBridge.cjs now exports bundledMoshClient() and prefers the
bundled static client over whatever the system mosh wrapper would
resolve via PATH (via the MOSH_CLIENT env var). The Windows branch
throws a clear error pointing at Settings instead of silently falling
back to a literal "mosh.exe" string when no wrapper is installed.

This is Phase 1 — Phase 2 (follow-up) replaces the FluentTerminal
Windows binary with an in-CI Cygwin static build and adds a Node-side
mosh-server bootstrap so Mosh works out-of-the-box on Windows.

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

* Phase 2: Node-side Mosh handshake (no Perl wrapper required)

Reimplement what the upstream Mosh Perl wrapper does in pure Node:
spawn `ssh [user@]host -- mosh-server new`, sniff the byte stream
for `MOSH CONNECT <port> <key>`, then spawn `mosh-client` locally
with MOSH_KEY in the environment.

The new electron/bridges/moshHandshake.cjs module exposes the parser,
sniffer, and command builders as pure functions so they can be unit
tested without spawning real ssh. terminalBridge.startMoshSession now
prefers this path whenever a bare mosh-client (bundled, explicit, or
system) and ssh (in-box OpenSSH on Win10 1809+, system everywhere
else) are both detectable. The legacy path through the system mosh
Perl wrapper is preserved as a fallback so users with custom mosh
setups don't regress.

Auth is delegated to system ssh, so keys, agent, ssh_config, and
known_hosts all keep working. Password / 2FA need a controlling TTY
which the bootstrap doesn't provide; affected users keep the legacy
wrapper path until interactive UI lands.

Tests:
- moshHandshake.test.cjs (20 tests) — parser corner cases, command
  builders, sniffer split-chunk handling, ring-buffer trim, exec
  resolver
- terminalBridge.bareMoshClient.test.cjs (4 tests) — explicit-path
  basename gating

317 → 341 passing tests; lint clean.

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

* Phase 3: in-CI Cygwin Windows build + visible PTY handshake

Phase 3a — in-CI Cygwin Windows build
- scripts/build-mosh/build-windows.sh builds mosh-client.exe from
  upstream mobile-shell/mosh source inside Cygwin, then walks the
  cygcheck import graph to bundle every required Cygwin DLL
  (cygwin1.dll, cygcrypto, cygprotobuf, cygncursesw, etc) into a
  tar.gz alongside the exe.
- The `build-mosh-binaries` workflow swaps the FluentTerminal-pinned
  fetch job for a real Cygwin build (windows-latest + cygwin-install-
  action). fetch-windows.sh is preserved as an emergency fallback but
  no longer wired into the matrix.
- fetch-mosh-binaries.cjs unpacks the tar.gz into resources/mosh/
  win32-x64/ so mosh-client.exe sits next to its DLLs.
- mosh-extra-resources.cjs ships the entire win32-x64/ dir
  (exe + DLL bundle) into Resources/mosh/, so the packaged installer
  runs on a stock Windows host with no Cygwin install.

Phase 3b — visible PTY handshake (password / 2FA prompts)
- terminalBridge.startMoshSession now spawns ssh inside node-pty so
  the user sees and can answer password / 2FA / known-hosts prompts
  in their terminal. When `MOSH CONNECT` is sniffed from the byte
  stream, session.proc is atomically swapped from the ssh PTY to a
  freshly-spawned mosh-client PTY. The MOSH CONNECT line itself is
  redacted from the visible output.
- writeToSession / resizeSession read session.proc lazily, so input
  arriving after the swap goes to mosh-client without extra wiring.
- The ZMODEM sentry is recreated for the new proc since its
  writeToRemote closure captured the previous handle.
- Removes the earlier non-PTY child_process.spawn handshake — the
  PTY-based one supersedes it.

Phase 3c — win32-arm64 deferred
- Cygwin's arm64 port has no stable cygwin1.dll release yet, so we
  do not attempt an arm64 Windows build. arm64 Windows installs fall
  through to the legacy `mosh` wrapper path that the bridge already
  handles. Documented in the workflow.

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

* Allow branch/PR pushes to test the mosh-binaries workflow

Mirrors the build-packages workflow change in #868: any push or PR
that touches the mosh build pipeline triggers the matrix (artifacts
only, no release), while only `mosh-bin-*` tag pushes (or an
explicit workflow_dispatch with release_tag) publish a release.

`paths` filter keeps unrelated commits from running this expensive
workflow (~30min for the Cygwin leg). Concurrency group cancels
superseded branch/PR builds; tag builds use a unique group so a
follow-up commit can't kill an in-progress release.

Release job's `if:` enforces the same rule independently — even if
the trigger gets re-broadened, branches/PRs can't leak a release.

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

* Fix mosh binary workflow runners

* Fix Windows mosh workflow invocation

* Keep shell scripts LF in workflow checkouts

* Trigger mosh workflow on attributes changes

* Fix mosh build tool dependencies

* Fix Linux mosh static build

* Fix macOS mosh build tool lookup

* Skip macOS ncurses terminfo install

* Fix mosh PR review findings

* Allow Linux system mosh dependencies

* Fix Windows mosh DLL bundling

* Limit bundled Windows mosh DLLs

* Honor configured PATH for mosh handshake

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:25:57 +08:00
陈大猫
e4e1b54374 Fix terminal custom accent color (#864) 2026-04-29 11:21:29 +08:00
陈大猫
4dd2465388 Keep known hosts local during sync (#863) 2026-04-29 11:01:21 +08:00
206 changed files with 24481 additions and 2480 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.sh text eol=lf

View File

@@ -56,8 +56,7 @@ const files = {
x64: `Netcatty-${version}-mac-x64.dmg`
},
win: {
x64: `Netcatty-${version}-win-x64.exe`,
arm64: `Netcatty-${version}-win-arm64.exe`
x64: `Netcatty-${version}-win-x64.exe`
},
linux: {
appimage: {
@@ -77,8 +76,7 @@ const files = {
const badges = {
win: {
setup_x64: `[![Setup x64](https://img.shields.io/badge/Setup-x64-0078D6?style=flat-square&logo=windows)](${baseUrl}/${files.win.x64})`,
setup_arm64: `[![Setup arm64](https://img.shields.io/badge/Setup-arm64-0078D6?style=flat-square&logo=windows)](${baseUrl}/${files.win.arm64})`
setup_x64: `[![Setup x64](https://img.shields.io/badge/Setup-x64-0078D6?style=flat-square&logo=windows)](${baseUrl}/${files.win.x64})`
},
mac: {
apple_silicon: `[![DMG Apple Silicon](https://img.shields.io/badge/DMG-Apple_Silicon-000000?style=flat-square&logo=apple)](${baseUrl}/${files.mac.arm64})`,
@@ -99,7 +97,7 @@ const content = `
| OS | Download |
| :--- | :--- |
| **Windows** | ${badges.win.setup_x64} ${badges.win.setup_arm64} |
| **Windows** | ${badges.win.setup_x64} |
| **macOS** | ${badges.mac.apple_silicon} ${badges.mac.intel} |
| **Linux** | ${badges.linux.appimage_x64} ${badges.linux.deb_x64} ${badges.linux.rpm_x64} <br> ${badges.linux.appimage_arm64} ${badges.linux.deb_arm64} ${badges.linux.rpm_arm64} |
`;

View File

@@ -0,0 +1,233 @@
name: build-mosh-binaries
# Trigger philosophy (mirrors build.yml):
# - Pushes that touch the mosh build pipeline + PRs run the matrix
# so we can validate workflow / script changes without tagging.
# Artifacts upload as workflow artifacts only; *no* release.
# - Manual `workflow_dispatch` with `release_tag` publishes the
# binaries + SHA256SUMS to the dedicated binary repository
# (`binaricat/Netcatty-mosh-bin` by default).
#
# `paths` keeps unrelated commits (UI, bridges, etc) from rebuilding
# or refreshing mosh binaries on every push.
on:
workflow_dispatch:
inputs:
mosh_ref:
description: "mosh upstream git ref (tag/branch/commit) — see https://github.com/mobile-shell/mosh"
type: string
default: "mosh-1.4.0"
release_tag:
description: "Optional release tag to attach binaries to (e.g. mosh-bin-1.4.0-1). Empty = artifacts only."
type: string
default: ""
release_repo:
description: "Repository that stores mosh-client binary releases."
type: string
default: "binaricat/Netcatty-mosh-bin"
push:
branches:
- "**"
paths:
- ".gitattributes"
- ".github/workflows/build-mosh-binaries.yml"
- "electron-builder.config.cjs"
- "package.json"
- "scripts/build-mosh/**"
- "scripts/fetch-mosh-binaries.cjs"
- "scripts/mosh-extra-resources.cjs"
pull_request:
paths:
- ".gitattributes"
- ".github/workflows/build-mosh-binaries.yml"
- "electron-builder.config.cjs"
- "package.json"
- "scripts/build-mosh/**"
- "scripts/fetch-mosh-binaries.cjs"
- "scripts/mosh-extra-resources.cjs"
# Cancel superseded branch / PR builds.
concurrency:
group: build-mosh-binaries-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
MOSH_REF: ${{ inputs.mosh_ref || 'mosh-1.4.0' }}
jobs:
# ------------------------------------------------------------------
# Linux x64 (manylinux2014 / glibc 2.17, broad distro compatibility).
# Static-links the heavy third-party deps where possible; the resulting
# mosh-client still depends on baseline Linux system libraries.
# ------------------------------------------------------------------
build-linux-x64:
name: build-linux-x64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build mosh-client (linux-x64)
run: |
# Run only the compiler inside manylinux2014. JavaScript actions
# need the host runner's newer glibc.
docker run --rm \
-e MOSH_REF="${MOSH_REF}" \
-e OUT_DIR=/work/out \
-e ARCH=x64 \
-v "${GITHUB_WORKSPACE}:/work" \
-w /work \
quay.io/pypa/manylinux2014_x86_64 \
bash scripts/build-mosh/build-linux.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: mosh-client-linux-x64
path: out/
build-linux-arm64:
name: build-linux-arm64
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- name: Build mosh-client (linux-arm64)
run: |
# Run only the compiler inside manylinux2014. JavaScript actions
# need the host runner's newer glibc.
docker run --rm \
-e MOSH_REF="${MOSH_REF}" \
-e OUT_DIR=/work/out \
-e ARCH=arm64 \
-v "${GITHUB_WORKSPACE}:/work" \
-w /work \
quay.io/pypa/manylinux2014_aarch64 \
bash scripts/build-mosh/build-linux.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: mosh-client-linux-arm64
path: out/
# ------------------------------------------------------------------
# macOS universal2 (arm64 + x86_64 lipo).
# Min deployment target: macOS 11 (Big Sur) — covers arm64 hardware.
# Static-links OpenSSL, protobuf, ncurses for both arches.
# ------------------------------------------------------------------
build-macos-universal:
name: build-macos-universal
runs-on: macos-15-intel
steps:
- uses: actions/checkout@v4
- name: Build mosh-client (darwin-universal)
env:
MOSH_REF: ${{ env.MOSH_REF }}
OUT_DIR: ${{ github.workspace }}/out
MACOSX_DEPLOYMENT_TARGET: "11.0"
run: bash scripts/build-mosh/build-macos.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: mosh-client-darwin-universal
path: out/
# ------------------------------------------------------------------
# Windows x64 pinned standalone client.
# Do not compile this in CI: the upstream Cygwin build can clear the
# terminal and never render output on Windows. Ship the SHA256-pinned
# FluentTerminal standalone binary verified by fetch-windows.sh.
# ------------------------------------------------------------------
fetch-windows-x64:
name: fetch-windows-x64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Fetch pinned mosh-client.exe (win32-x64)
run: |
set -euo pipefail
export OUT_DIR="${GITHUB_WORKSPACE}/out"
mkdir -p "$OUT_DIR"
bash scripts/build-mosh/fetch-windows.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: mosh-client-win32-x64
path: out/
# ------------------------------------------------------------------
# Windows arm64 — intentionally not built.
# The pinned upstream source only provides x64. arm64 Windows builds
# should be added only after we have a tested standalone arm64 client.
# ------------------------------------------------------------------
# ------------------------------------------------------------------
# Aggregate + optional release to the dedicated binary repository.
# ------------------------------------------------------------------
release:
name: release
needs:
- build-linux-x64
- build-linux-arm64
- build-macos-universal
- fetch-windows-x64
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' && inputs.release_tag != ''
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Stage release files
run: |
set -euo pipefail
mkdir -p release
for d in artifacts/*/; do
find "$d" -maxdepth 1 -type f -exec cp {} release/ \;
done
(cd release && find . -maxdepth 1 -type f ! -name SHA256SUMS -printf '%P\n' | sort | xargs sha256sum > SHA256SUMS)
ls -la release
cat release/SHA256SUMS
- name: Determine tag
id: tag
env:
RELEASE_TAG: ${{ inputs.release_tag }}
run: |
tag="${RELEASE_TAG}"
if [[ ! "$tag" =~ ^mosh-bin-[A-Za-z0-9._-]+$ ]]; then
echo "Invalid mosh binary release tag: $tag" >&2
exit 1
fi
printf 'name=%s\n' "$tag" >> "$GITHUB_OUTPUT"
- name: Create / update release
env:
GH_TOKEN: ${{ secrets.MOSH_BIN_RELEASE_TOKEN }}
RELEASE_REPO: ${{ inputs.release_repo }}
RELEASE_TAG: ${{ steps.tag.outputs.name }}
run: |
set -euo pipefail
if [[ -z "${GH_TOKEN:-}" ]]; then
echo "::error::MOSH_BIN_RELEASE_TOKEN is required to publish into ${RELEASE_REPO}."
exit 1
fi
{
printf '%s\n' 'Pre-built `mosh-client` binaries consumed by `scripts/fetch-mosh-binaries.cjs` during `npm run pack`.'
printf 'Linux/macOS artifacts are built from `mobile-shell/mosh` upstream ref `%s`.\n' "${MOSH_REF}"
printf '%s\n\n' 'Windows x64 is the SHA256-pinned FluentTerminal standalone `mosh-client.exe` fallback.'
printf '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.'
} > release-notes.md
if gh release view "${RELEASE_TAG}" --repo "${RELEASE_REPO}" >/dev/null 2>&1; then
gh release edit "${RELEASE_TAG}" \
--repo "${RELEASE_REPO}" \
--title "${RELEASE_TAG}" \
--notes-file release-notes.md
gh release upload "${RELEASE_TAG}" release/* \
--repo "${RELEASE_REPO}" \
--clobber
else
gh release create "${RELEASE_TAG}" release/* \
--repo "${RELEASE_REPO}" \
--title "${RELEASE_TAG}" \
--notes-file release-notes.md
fi

View File

@@ -1,5 +1,23 @@
name: build-packages
# Trigger philosophy
# - Any push to any branch + any PR -> run the build matrix so CI is
# always testable. Same-repo PR runs own package validation; matching
# branch push runs become a lightweight mirror only after a current
# open PR run for the same commit is visible. If lookup is slow or
# unavailable, the push run falls back to the full matrix. Artifacts
# upload as workflow artifacts only; *no* GitHub Release is published.
# - Tag push matching `v<MAJOR>.<MINOR>.<PATCH>` (with optional
# pre-release suffix like `v1.2.3-rc.1`) -> run the matrix and
# publish a GitHub Release. Loose tags like `v-test`, `vNEXT`, or
# `v1.0` no longer auto-publish.
# - Manual `workflow_dispatch` -> run the matrix on the selected ref.
# `publish_release` only publishes when the selected ref is also a
# strict version tag.
#
# The release job validates the exact same rule before publishing, so
# adding branches/PRs above is safe; accidental tag-like branch names
# won't leak a release.
on:
workflow_dispatch:
inputs:
@@ -7,13 +25,179 @@ on:
description: "Publish GitHub Release after build"
type: boolean
default: false
mosh_bin_release:
description: "Release tag containing bundled mosh-client binaries"
type: string
default: ""
push:
branches:
- "**"
tags:
- "v*"
- "v[0-9]+.[0-9]+.[0-9]+"
- "v[0-9]+.[0-9]+.[0-9]+-[0-9A-Za-z]*"
pull_request:
# A newer run for the same push branch or PR cancels older in-progress
# work. Push and PR events stay in separate groups so deduped push runs
# can mirror PR results cleanly instead of leaving cancelled checks on
# the PR. Publishing tag runs share a release group across push and
# manual dispatch; non-publishing manual tag runs use their own group.
concurrency:
group: build-packages-${{ github.workflow }}-${{ startsWith(github.ref, 'refs/tags/') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release)) && 'release' || github.event_name }}-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.ref_type }}-${{ github.event.pull_request.head.ref || github.ref_name }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
permissions:
actions: read
contents: read
pull-requests: read
env:
MOSH_BIN_RELEASE: ${{ github.event.inputs.mosh_bin_release || vars.MOSH_BIN_RELEASE || '' }}
BUNDLE_MOSH: ${{ (startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))) || (github.event_name == 'workflow_dispatch' && inputs.mosh_bin_release != '') }}
STRICT_VERSION_REF_RE: '^refs/tags/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[A-Za-z][0-9A-Za-z-]*|[0-9A-Za-z][0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[A-Za-z][0-9A-Za-z-]*|[0-9A-Za-z][0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?$'
jobs:
dedupe:
name: dedupe push run
runs-on: ubuntu-latest
outputs:
skip_heavy_ci: ${{ steps.detect.outputs.skip_heavy_ci }}
heavy_ci_pr_run_id: ${{ steps.detect.outputs.heavy_ci_pr_run_id }}
steps:
- name: Detect duplicate heavy CI
id: detect
shell: bash
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
REPOSITORY_OWNER: ${{ github.repository_owner }}
EVENT_NAME: ${{ github.event_name }}
REF: ${{ github.ref }}
HEAD_REF: ${{ github.ref_name }}
HEAD_SHA: ${{ github.sha }}
run: |
skip_heavy_ci=false
if [[ "$EVENT_NAME" == "push" && "$REF" == refs/heads/* ]]; then
pr_count=0
if ! pr_count="$(gh api --method GET "repos/${REPOSITORY}/pulls" \
-f state=open \
-f "head=${REPOSITORY_OWNER}:${HEAD_REF}" \
-F per_page=1 \
--jq 'length')"; then
echo "::warning::Could not check open PRs; running full push CI."
pr_count=0
fi
pr_run_id=""
if [[ "$pr_count" != "0" ]]; then
cutoff="$(date -u -d '20 minutes ago' +'%Y-%m-%dT%H:%M:%SZ')"
for attempt in {1..18}; do
if ! pr_run_id="$(gh api --method GET "repos/${REPOSITORY}/actions/workflows/build.yml/runs" \
-f event=pull_request \
-f "branch=${HEAD_REF}" \
-f "head_sha=${HEAD_SHA}" \
-F per_page=20 \
--jq "[.workflow_runs[] | select(.created_at >= \"${cutoff}\" and .conclusion != \"cancelled\" and .conclusion != \"skipped\")] | sort_by(.created_at, .id) | .[0].id // \"\"")"; then
echo "::warning::Could not check PR workflow runs; running full push CI."
pr_run_id=""
break
fi
if [[ -n "$pr_run_id" ]]; then
skip_heavy_ci=true
break
fi
if [[ "$attempt" == "18" ]]; then
break
fi
sleep 10
done
fi
if [[ -n "$pr_run_id" ]]; then
echo "heavy_ci_pr_run_id=${pr_run_id}" >> "$GITHUB_OUTPUT"
echo "heavy_ci_pr_run_id=${pr_run_id}"
fi
fi
echo "skip_heavy_ci=${skip_heavy_ci}" >> "$GITHUB_OUTPUT"
echo "skip_heavy_ci=${skip_heavy_ci}"
dedupe-result:
name: dedupe result
needs: dedupe
if: needs.dedupe.outputs.skip_heavy_ci == 'true'
runs-on: ubuntu-latest
steps:
- name: Mirror PR build result
shell: bash
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
PR_RUN_ID: ${{ needs.dedupe.outputs.heavy_ci_pr_run_id }}
run: |
if [[ -z "$PR_RUN_ID" ]]; then
echo "::error::No PR workflow run was selected for dedupe."
exit 1
fi
for attempt in {1..360}; do
if ! result="$(gh run view "$PR_RUN_ID" --repo "$REPOSITORY" --json status,conclusion --jq '.status + "|" + (.conclusion // "")')"; then
echo "::warning::Could not read PR workflow run ${PR_RUN_ID}; retrying."
sleep 30
continue
fi
status="${result%%|*}"
conclusion="${result#*|}"
echo "PR run ${PR_RUN_ID}: status=${status} conclusion=${conclusion:-pending}"
if [[ "$status" == "completed" ]]; then
if [[ "$conclusion" == "success" ]]; then
exit 0
fi
echo "::error::PR workflow run ${PR_RUN_ID} completed with conclusion '${conclusion}'."
exit 1
fi
sleep 30
done
echo "::error::Timed out waiting for PR workflow run ${PR_RUN_ID}."
exit 1
resolve-mosh:
name: resolve bundled mosh-client
needs: dedupe
if: |
needs.dedupe.outputs.skip_heavy_ci != 'true'
&& (
(startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release)))
|| (github.event_name == 'workflow_dispatch' && inputs.mosh_bin_release != '')
)
runs-on: ubuntu-latest
outputs:
mosh_bin_release: ${{ steps.resolve.outputs.mosh_bin_release }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve bundled mosh-client release
id: resolve
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
node scripts/resolve-mosh-bin-release.cjs
release="$(grep '^MOSH_BIN_RELEASE=' "$GITHUB_ENV" | tail -n 1 | cut -d= -f2-)"
if [[ -z "$release" ]]; then
echo "::error::MOSH_BIN_RELEASE was not resolved."
exit 1
fi
echo "mosh_bin_release=${release}" >> "$GITHUB_OUTPUT"
build:
name: build-${{ matrix.name }}
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && format('deduped build-{0}', matrix.name) || format('build-{0}', matrix.name) }}
needs: [dedupe, resolve-mosh]
if: |
always()
&& needs.dedupe.result == 'success'
&& needs.dedupe.outputs.skip_heavy_ci != 'true'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@@ -24,13 +208,28 @@ jobs:
pack_script: pack:mac
- name: windows
os: windows-latest
pack_script: pack:win
# The mosh binary workflow currently produces win32-x64 only.
# Keep official packages aligned with bundled-mosh coverage
# until Cygwin arm64 is stable enough to build win32-arm64.
pack_script: pack:win-x64
env:
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
steps:
- name: Validate bundled mosh-client release
if: env.BUNDLE_MOSH == 'true'
shell: bash
env:
RESOLVE_MOSH_RESULT: ${{ needs.resolve-mosh.result }}
run: |
if [[ "$RESOLVE_MOSH_RESULT" != "success" || -z "$MOSH_BIN_RELEASE" ]]; then
echo "::error::Bundled mosh-client release was not resolved for this package build."
exit 1
fi
- name: Checkout
uses: actions/checkout@v4
@@ -46,27 +245,40 @@ jobs:
- name: Install cross-platform native binaries
shell: bash
run: |
# npm ci only installs optional deps for the host platform, but
# electron-builder produces both arm64 and x64 binaries, so we
# need the native codex-acp binary for the other architecture too.
# npm ci only installs optional deps for the host platform.
# macOS packages still cover both arm64 and x64, so we need
# codex-acp for both architectures there.
# Platform-specific codex-acp packages declare cpu/os constraints,
# so --force is needed to install the non-host-arch binary.
CODEX_VER=$(node -e "console.log(require('./node_modules/@zed-industries/codex-acp/package.json').version)")
if [[ "${{ matrix.name }}" == "macos" ]]; then
npm install "@zed-industries/codex-acp-darwin-x64@${CODEX_VER}" "@zed-industries/codex-acp-darwin-arm64@${CODEX_VER}" --no-save --force
elif [[ "${{ matrix.name }}" == "windows" ]]; then
npm install "@zed-industries/codex-acp-win32-x64@${CODEX_VER}" "@zed-industries/codex-acp-win32-arm64@${CODEX_VER}" --no-save --force
npm install "@zed-industries/codex-acp-win32-x64@${CODEX_VER}" --no-save --force
fi
- name: Fetch bundled mosh-client
if: env.BUNDLE_MOSH == 'true'
shell: bash
run: |
if [[ "${{ matrix.name }}" == "macos" ]]; then
npm run fetch:mosh -- --platform=darwin --arch=universal
elif [[ "${{ matrix.name }}" == "windows" ]]; then
npm run fetch:mosh -- --platform=win32 --arch=x64
fi
- name: Set version
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
# Tag release: use version from tag
# Strict semver matches v<MAJOR>.<MINOR>.<PATCH>[-pre]; loose
# tags / branches / PRs fall through to a semver-pre-release
# form (`0.0.0-sha-<short-sha>`) so npm pkg / electron-builder
# accept it. Non-semver versions (e.g. bare "abc1234") cause
# downstream tooling to error or pick weird codepaths.
if [[ "$GITHUB_REF" =~ $STRICT_VERSION_REF_RE ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
# workflow_dispatch: use short commit ID
VERSION="${GITHUB_SHA:0:7}"
VERSION="0.0.0-sha-${GITHUB_SHA:0:7}"
fi
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
@@ -105,9 +317,15 @@ jobs:
# compatible with most current Linux distributions including Arch.
# See #264.
build-linux-x64:
name: build-linux-x64
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }}
needs: [dedupe, resolve-mosh]
if: |
always()
&& needs.dedupe.result == 'success'
&& needs.dedupe.outputs.skip_heavy_ci != 'true'
runs-on: ubuntu-22.04
env:
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
npm_config_arch: x64
npm_config_target_arch: x64
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
@@ -115,6 +333,17 @@ jobs:
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
steps:
- name: Validate bundled mosh-client release
if: env.BUNDLE_MOSH == 'true'
shell: bash
env:
RESOLVE_MOSH_RESULT: ${{ needs.resolve-mosh.result }}
run: |
if [[ "$RESOLVE_MOSH_RESULT" != "success" || -z "$MOSH_BIN_RELEASE" ]]; then
echo "::error::Bundled mosh-client release was not resolved for this package build."
exit 1
fi
- name: Checkout
uses: actions/checkout@v4
@@ -130,10 +359,13 @@ jobs:
- name: Set version
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
# See matrix job's Set version step for the strict-semver
# rationale; identical logic, duplicated because the Linux
# legs are standalone jobs.
if [[ "$GITHUB_REF" =~ $STRICT_VERSION_REF_RE ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="${GITHUB_SHA:0:7}"
VERSION="0.0.0-sha-${GITHUB_SHA:0:7}"
fi
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
@@ -143,6 +375,10 @@ jobs:
npm_config_arch: x64
run: bash scripts/ensure-node-pty-linux.sh prepare x64
- name: Fetch bundled mosh-client
if: env.BUNDLE_MOSH == 'true'
run: npm run fetch:mosh -- --platform=linux --arch=x64
- name: Build package
env:
npm_config_arch: x64
@@ -171,11 +407,17 @@ jobs:
# to ensure compatibility with older distros like UOS/Deepin (GLIBC 2.28).
# Key: GLIBC < 2.34 avoids the libpthread-merge symbol requirement.
build-linux-arm64:
name: build-linux-arm64
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }}
needs: [dedupe, resolve-mosh]
if: |
always()
&& needs.dedupe.result == 'success'
&& needs.dedupe.outputs.skip_heavy_ci != 'true'
runs-on: ubuntu-24.04-arm
container:
image: debian:bullseye
env:
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
npm_config_arch: arm64
npm_config_target_arch: arm64
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
@@ -183,6 +425,17 @@ jobs:
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
steps:
- name: Validate bundled mosh-client release
if: env.BUNDLE_MOSH == 'true'
shell: bash
env:
RESOLVE_MOSH_RESULT: ${{ needs.resolve-mosh.result }}
run: |
if [[ "$RESOLVE_MOSH_RESULT" != "success" || -z "$MOSH_BIN_RELEASE" ]]; then
echo "::error::Bundled mosh-client release was not resolved for this package build."
exit 1
fi
- name: Install build dependencies
run: |
apt-get update
@@ -201,10 +454,13 @@ jobs:
- name: Set version
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
# See matrix job's Set version step for the strict-semver
# rationale; identical logic, duplicated because the Linux
# legs are standalone jobs.
if [[ "$GITHUB_REF" =~ $STRICT_VERSION_REF_RE ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="${GITHUB_SHA:0:7}"
VERSION="0.0.0-sha-${GITHUB_SHA:0:7}"
fi
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
@@ -214,6 +470,10 @@ jobs:
npm_config_arch: arm64
run: bash scripts/ensure-node-pty-linux.sh prepare arm64
- name: Fetch bundled mosh-client
if: env.BUNDLE_MOSH == 'true'
run: npm run fetch:mosh -- --platform=linux --arch=arm64
- name: Build package
env:
npm_config_arch: arm64
@@ -242,7 +502,12 @@ jobs:
name: release
runs-on: ubuntu-latest
needs: [build, build-linux-x64, build-linux-arm64]
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
# Only release on a strict v<MAJOR>.<MINOR>.<PATCH>[-pre] tag.
# Manual workflow_dispatch can publish only when it is run from one
# of those tags. PRs and branch pushes skip this job.
if: |
startsWith(github.ref, 'refs/tags/v')
&& (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))
permissions:
contents: write
actions: read
@@ -250,6 +515,14 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Validate release tag
shell: bash
run: |
if [[ ! "$GITHUB_REF" =~ $STRICT_VERSION_REF_RE ]]; then
echo "::error::Release tags must be v<MAJOR>.<MINOR>.<PATCH> or v<MAJOR>.<MINOR>.<PATCH>-<prerelease>."
exit 1
fi
- name: Download artifacts
uses: actions/download-artifact@v4
with:
@@ -318,6 +591,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
body_path: release_notes.md
prerelease: ${{ contains(github.ref_name, '-') }}
files: |
artifacts/*.dmg
artifacts/*.zip

37
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: test
on:
pull_request:
push:
branches:
- "**"
concurrency:
group: test-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
name: lint-and-test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install deps
run: npm ci
- name: Lint
run: npm run lint
- name: Test
run: npm test

11
.gitignore vendored
View File

@@ -63,3 +63,14 @@ Directory.Build.props
Directory.Build.targets
build_with_vs.bat
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, 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/

218
App.tsx
View File

@@ -11,20 +11,31 @@ 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 { resolveHostTerminalThemeId } from './domain/terminalAppearance';
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from './domain/terminalAppearance';
import { collectSessionIds } from './domain/workspace';
import { resolveCloseIntent } from './application/state/resolveCloseIntent';
import { resolveSnippetsShortcutIntent } from './application/state/resolveSnippetsShortcutIntent';
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
import { useCustomThemes } from './application/state/customThemeStore';
import type { SyncPayload } from './domain/sync';
import { applySyncPayload, buildSyncPayload, hasMeaningfulSyncData } from './application/syncPayload';
import { applySyncPayload, buildLocalVaultPayload, hasMeaningfulSyncData } from './application/syncPayload';
import {
applyProtectedSyncPayload,
ensureVersionChangeBackup,
@@ -52,13 +63,13 @@ 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';
import { TextEditorTabView } from './components/editor/TextEditorTabView';
import { UnsavedChangesProvider } from './components/editor/UnsavedChangesDialog';
import { editorSftpWrite } from './application/state/editorSftpBridge';
import { releaseEditorTabSaveCoordinator, saveEditorTab } from './application/state/editorTabSave';
// Initialize fonts eagerly at app startup
initializeFonts();
@@ -207,6 +218,8 @@ function App({ settings }: { settings: SettingsState }) {
theme,
setTheme,
resolvedTheme,
accentMode,
customAccent,
terminalThemeId,
setTerminalThemeId,
followAppTerminalTheme,
@@ -251,6 +264,7 @@ function App({ settings }: { settings: SettingsState }) {
hosts,
keys,
identities,
proxyProfiles,
snippets,
customGroups,
snippetPackages,
@@ -260,7 +274,9 @@ function App({ settings }: { settings: SettingsState }) {
managedSources,
updateHosts,
updateKeys,
importOrReuseKey,
updateIdentities,
updateProxyProfiles,
updateSnippets,
updateSnippetPackages,
updateCustomGroups,
@@ -280,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,
@@ -366,14 +387,19 @@ function App({ settings }: { settings: SettingsState }) {
if (activeTabId === 'vault' || activeTabId === 'sftp') return null;
const resolveTheme = (s: TerminalSession): TerminalTheme => {
let baseTheme: TerminalTheme;
// When "Follow Application Theme" is on, the UI-matched terminal
// theme overrides everything — including per-host theme overrides.
// This ensures all terminals match the app chrome regardless of
// individual host settings.
if (followAppTerminalTheme) return currentTerminalTheme;
const host = hostById.get(s.hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
return themeById.get(themeId) || currentTerminalTheme;
if (followAppTerminalTheme) {
baseTheme = currentTerminalTheme;
} else {
const host = hostById.get(s.hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
baseTheme = themeById.get(themeId) || currentTerminalTheme;
}
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
};
// Workspace
@@ -403,7 +429,7 @@ function App({ settings }: { settings: SettingsState }) {
const session = sessionById.get(activeTabId);
if (!session) return null;
return resolveTheme(session);
}, [activeTabId, currentTerminalTheme, followAppTerminalTheme, hostById, sessionById, themeById, workspaceById]);
}, [accentMode, activeTabId, currentTerminalTheme, customAccent, followAppTerminalTheme, hostById, sessionById, themeById, workspaceById]);
useImmersiveMode({
activeTabId,
@@ -441,11 +467,12 @@ function App({ settings }: { settings: SettingsState }) {
}
}
return buildSyncPayload(
return buildLocalVaultPayload(
{
hosts,
keys,
identities,
proxyProfiles,
snippets,
customGroups,
snippetPackages,
@@ -460,6 +487,7 @@ function App({ settings }: { settings: SettingsState }) {
hosts,
identities,
keys,
proxyProfiles,
knownHosts,
portForwardingRulesForSync,
snippetPackages,
@@ -520,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
@@ -553,11 +581,11 @@ function App({ settings }: { settings: SettingsState }) {
hosts,
keys,
identities,
proxyProfiles,
snippets,
customGroups,
snippetPackages,
portForwardingRules: portForwardingRulesForSync,
knownHosts,
groupConfigs,
settingsVersion: settings.settingsVersion,
startupReady: startupSyncSafetyReady,
@@ -599,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;
@@ -802,9 +830,11 @@ function App({ settings }: { settings: SettingsState }) {
// Auto-start port forwarding rules on app launch
usePortForwardingAutoStart({
isVaultInitialized,
hosts,
keys,
identities,
proxyProfiles,
groupConfigs,
});
@@ -969,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,
@@ -982,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) => {
@@ -1034,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;
@@ -1044,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
@@ -1332,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);
@@ -1442,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;
@@ -1495,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) => {
@@ -1679,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);
@@ -1763,6 +1913,7 @@ function App({ settings }: { settings: SettingsState }) {
const closingTabId = toEditorTabId(id);
const list = orderedTabsWithEditors;
const idx = list.indexOf(closingTabId);
releaseEditorTabSaveCoordinator(id);
editorTabStore.close(id);
if (activeTabStore.getActiveTabId() !== closingTabId) return;
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
@@ -1785,16 +1936,15 @@ function App({ settings }: { settings: SettingsState }) {
return;
}
if (choice === 'save') {
try {
editorTabStore.setSavingState(id, 'saving');
await editorSftpWrite(tab.sessionId, tab.hostId, tab.remotePath, tab.content);
editorTabStore.markSaved(id, tab.content);
closeEditorAndActivateNeighbor(id);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Save failed';
editorTabStore.setSavingState(id, 'error', msg);
const ok = await saveEditorTab(id);
if (!ok) {
const msg = editorTabStore.getTab(id)?.saveError ?? 'Save failed';
toast.error(msg, 'SFTP');
return;
}
const latest = editorTabStore.getTab(id);
if (!latest || latest.content !== latest.baselineContent) return;
closeEditorAndActivateNeighbor(id);
}
};
@@ -1841,6 +1991,7 @@ function App({ settings }: { settings: SettingsState }) {
hosts={hosts}
keys={keys}
identities={identities}
proxyProfiles={proxyProfiles}
snippets={snippets}
snippetPackages={snippetPackages}
customGroups={customGroups}
@@ -1863,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}
@@ -1889,6 +2042,7 @@ function App({ settings }: { settings: SettingsState }) {
hosts={hosts}
keys={keys}
identities={identities}
proxyProfiles={proxyProfiles}
groupConfigs={groupConfigs}
updateHosts={updateHosts}
sftpDefaultViewMode={sftpDefaultViewMode}
@@ -1905,6 +2059,7 @@ function App({ settings }: { settings: SettingsState }) {
<TerminalLayerMount
hosts={hosts}
groupConfigs={groupConfigs}
proxyProfiles={proxyProfiles}
keys={keys}
identities={identities}
snippets={snippets}
@@ -1915,6 +2070,8 @@ function App({ settings }: { settings: SettingsState }) {
draggingSessionId={draggingSessionId}
terminalTheme={currentTerminalTheme}
followAppTerminalTheme={followAppTerminalTheme}
accentMode={accentMode}
customAccent={customAccent}
terminalSettings={terminalSettings}
terminalFontFamilyId={terminalFontFamilyId}
fontSize={terminalFontSize}
@@ -1929,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 });
}}
@@ -2208,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

@@ -378,18 +378,6 @@ const en: Messages = {
'settings.terminal.connection.x11Display': 'X11 display',
'settings.terminal.connection.x11Display.desc': 'Optional local display address for X11 forwarding. Leave empty to use the system default.',
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 or DISPLAY)',
'settings.terminal.mosh.client': 'Mosh client path',
'settings.terminal.mosh.client.desc': 'Absolute path to the local mosh executable. Leave empty to auto-detect on PATH and common install locations (Homebrew, MacPorts, ~/.nix-profile, ~/.cargo, ~/.local).',
'settings.terminal.mosh.client.placeholder': 'Auto-detect',
'settings.terminal.mosh.client.notFound': 'File not found at that path.',
'settings.terminal.mosh.client.isDirectory': 'Path points to a directory, not an executable.',
'settings.terminal.mosh.client.notExecutable': 'File exists but is not executable. Run `chmod +x` on it or pick another binary.',
'settings.terminal.mosh.client.notAbsolute': 'Path must be absolute. Use Browse… to pick the binary, leave the field empty to auto-detect, or enter a full path.',
'settings.terminal.mosh.detect': 'Detect',
'settings.terminal.mosh.browse': 'Browse…',
'settings.terminal.mosh.autoDetected': 'Auto-detected',
'settings.terminal.mosh.detected': 'Detected at',
'settings.terminal.mosh.notDetected': 'Mosh not found in:',
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
'settings.terminal.serverStats.show': 'Show Server Stats',
'settings.terminal.serverStats.show.desc': 'Display CPU, memory, and disk usage in the terminal statusbar (Linux servers only).',
@@ -493,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',
@@ -511,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',
@@ -526,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',
@@ -768,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',
@@ -790,6 +800,9 @@ const en: Messages = {
'sftp.transfers.collapseChildren': 'Hide files',
'sftp.transfers.expandChildList': 'Show detail',
'sftp.transfers.collapseChildList': 'Hide',
'sftp.transfers.retryAction': 'Retry',
'sftp.transfers.dismissAction': 'Dismiss',
'sftp.transfers.resizeNameColumn': 'Resize file name column',
'sftp.transfers.dragToResize': 'Drag to resize',
'sftp.goUp': 'Go up',
'sftp.goToTerminalCwd': 'Go to terminal directory',
@@ -856,8 +869,11 @@ const en: Messages = {
'sftp.conflict.size': 'Size:',
'sftp.conflict.modified': 'Modified:',
'sftp.conflict.applyToAll': 'Apply this action to all {count} remaining conflicts',
'sftp.conflict.action.stop': 'Stop',
'sftp.conflict.action.skip': 'Skip',
'sftp.conflict.action.keepBoth': 'Keep Both',
'sftp.conflict.action.duplicate': 'Duplicate',
'sftp.conflict.action.merge': 'Merge',
'sftp.conflict.action.replace': 'Replace',
// SFTP Upload Phases
@@ -1120,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',
@@ -1307,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',
@@ -1669,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',
@@ -1796,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': '拖拽文件到这里',
@@ -562,6 +584,9 @@ const zhCN: Messages = {
'sftp.transfers.collapseChildren': '收起文件',
'sftp.transfers.expandChildList': '展开详情',
'sftp.transfers.collapseChildList': '收起',
'sftp.transfers.retryAction': '重试',
'sftp.transfers.dismissAction': '移除',
'sftp.transfers.resizeNameColumn': '调整文件名列宽',
'sftp.transfers.dragToResize': '拖拽调整高度',
'sftp.goUp': '上一级',
'sftp.goToTerminalCwd': '定位到终端当前目录',
@@ -899,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': '字体',
@@ -1214,8 +1249,11 @@ const zhCN: Messages = {
'sftp.conflict.size': '大小:',
'sftp.conflict.modified': '修改时间:',
'sftp.conflict.applyToAll': '将此操作应用到剩余的 {count} 个冲突',
'sftp.conflict.action.stop': '停止',
'sftp.conflict.action.skip': '跳过',
'sftp.conflict.action.keepBoth': '保留两者',
'sftp.conflict.action.duplicate': '创建副本',
'sftp.conflict.action.merge': '合并',
'sftp.conflict.action.replace': '替换',
// SFTP Upload Phases
@@ -1462,18 +1500,6 @@ const zhCN: Messages = {
'settings.terminal.connection.x11Display': 'X11 显示地址',
'settings.terminal.connection.x11Display.desc': '可选的本机 X11 显示地址。留空则使用系统默认值。',
'settings.terminal.connection.x11Display.placeholder': '自动(:0 或 DISPLAY',
'settings.terminal.mosh.client': 'Mosh 客户端路径',
'settings.terminal.mosh.client.desc': '本机 mosh 可执行文件的绝对路径。留空则自动从 PATH 与常见安装目录中查找Homebrew、MacPorts、~/.nix-profile、~/.cargo、~/.local。',
'settings.terminal.mosh.client.placeholder': '自动探测',
'settings.terminal.mosh.client.notFound': '该路径下未找到文件。',
'settings.terminal.mosh.client.isDirectory': '该路径指向目录而非可执行文件。',
'settings.terminal.mosh.client.notExecutable': '文件存在但不可执行。请对其执行 `chmod +x`,或选择其它二进制文件。',
'settings.terminal.mosh.client.notAbsolute': '路径必须为绝对路径。请使用 浏览… 选择二进制、留空以自动探测,或输入完整路径。',
'settings.terminal.mosh.detect': '探测',
'settings.terminal.mosh.browse': '浏览…',
'settings.terminal.mosh.autoDetected': '自动检测到',
'settings.terminal.mosh.detected': '已找到',
'settings.terminal.mosh.notDetected': '在以下位置未找到 mosh',
'settings.terminal.section.serverStats': '服务器状态Linux',
'settings.terminal.serverStats.show': '显示服务器状态',
'settings.terminal.serverStats.show.desc': '在终端状态栏显示 CPU、内存和磁盘使用情况仅限 Linux 服务器)。',
@@ -1545,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_ 为前缀的变量。',
@@ -1678,6 +1710,7 @@ const zhCN: Messages = {
'keychain.edit.publicKey': '公钥',
'keychain.edit.certificate': '证书',
'keychain.edit.certificatePlaceholder': '证书内容(可选)',
'keychain.edit.filePath': '文件路径',
'keychain.edit.keyExport': '密钥导出',
'keychain.edit.exportToHost': '导出到主机',
@@ -1805,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

@@ -0,0 +1,88 @@
import test from "node:test";
import assert from "node:assert/strict";
import { EditorTabStore, type EditorTab } from "./editorTabStore.ts";
import { createEditorTabSaveService } from "./editorTabSave.ts";
const deferred = <T = void>() => {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const makeTab = (overrides: Partial<EditorTab> = {}): EditorTab => ({
id: "edt_1",
kind: "editor",
sessionId: "conn_1",
hostId: "host_1",
remotePath: "/tmp/file.txt",
fileName: "file.txt",
languageId: "plaintext",
content: "v1",
baselineContent: "old",
wordWrap: false,
viewState: null,
savingState: "idle",
saveError: null,
...overrides,
});
test("editor tab save service joins duplicate saves for the same content", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab());
const pending = deferred();
const writes: string[] = [];
const service = createEditorTabSaveService({
store,
write: async (_sessionId, _hostId, _remotePath, content) => {
writes.push(content);
await pending.promise;
},
});
const first = service.saveTab("edt_1");
const second = service.saveTab("edt_1", "v1");
assert.deepEqual(writes, ["v1"]);
pending.resolve();
assert.equal(await first, true);
assert.equal(await second, true);
assert.deepEqual(writes, ["v1"]);
assert.equal(store.getTab("edt_1")?.baselineContent, "v1");
assert.equal(store.getTab("edt_1")?.savingState, "idle");
});
test("editor tab save service queues newer tab content after an in-flight save", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab());
const firstSave = deferred();
const secondSave = deferred();
const writes: string[] = [];
const service = createEditorTabSaveService({
store,
write: async (_sessionId, _hostId, _remotePath, content) => {
writes.push(content);
await (content === "v1" ? firstSave.promise : secondSave.promise);
},
});
const first = service.saveTab("edt_1");
store.updateContent("edt_1", "v2", null);
const second = service.saveTab("edt_1");
assert.deepEqual(writes, ["v1"]);
firstSave.resolve();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
assert.deepEqual(writes, ["v1", "v2"]);
secondSave.resolve();
assert.equal(await first, true);
assert.equal(await second, true);
assert.equal(store.getTab("edt_1")?.baselineContent, "v2");
assert.equal(store.getTab("edt_1")?.content, "v2");
});

View File

@@ -0,0 +1,72 @@
import { editorSftpWrite, type EditorSftpWrite } from "./editorSftpBridge";
import { editorTabStore, type EditorTabId, type EditorTabStore } from "./editorTabStore";
import {
createTextEditorSaveCoordinator,
type TextEditorSaveCoordinator,
} from "./textEditorSaveCoordinator";
interface EditorTabSaveServiceDeps {
store: EditorTabStore;
write: EditorSftpWrite;
}
export interface EditorTabSaveService {
saveTab(id: EditorTabId, contentOverride?: string): Promise<boolean>;
releaseTab(id: EditorTabId): void;
}
const formatSaveError = (error: unknown): string =>
error instanceof Error ? error.message : "Save failed";
export const createEditorTabSaveService = ({
store,
write,
}: EditorTabSaveServiceDeps): EditorTabSaveService => {
const coordinators = new Map<EditorTabId, TextEditorSaveCoordinator>();
const getCoordinator = (id: EditorTabId): TextEditorSaveCoordinator => {
const existing = coordinators.get(id);
if (existing) return existing;
const coordinator = createTextEditorSaveCoordinator({
onSave: async (content) => {
const tab = store.getTab(id);
if (!tab) throw new Error("Editor tab closed before save completed");
await write(tab.sessionId, tab.hostId, tab.remotePath, content);
},
onSaveStart: () => {
store.setSavingState(id, "saving");
},
onSaveSuccess: (content) => {
store.markSaved(id, content);
},
onSaveError: (error) => {
store.setSavingState(id, "error", formatSaveError(error));
},
});
coordinators.set(id, coordinator);
return coordinator;
};
return {
saveTab: async (id, contentOverride) => {
const tab = store.getTab(id);
if (!tab) return false;
return getCoordinator(id).save(contentOverride ?? tab.content);
},
releaseTab: (id) => {
const coordinator = coordinators.get(id);
coordinator?.reset();
coordinators.delete(id);
},
};
};
const editorTabSaveService = createEditorTabSaveService({
store: editorTabStore,
write: editorSftpWrite,
});
export const saveEditorTab = editorTabSaveService.saveTab;
export const releaseEditorTabSaveCoordinator = editorTabSaveService.releaseTab;

View File

@@ -196,3 +196,24 @@ test("confirmCloseBySession invokes save callback for 'save' choice and only clo
assert.equal(ok, true);
assert.equal(store.getTab("edt_1"), undefined);
});
test("confirmCloseBySession reports every closed editor tab to cleanup callback", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ id: "edt_clean" }));
store._debugInsert(makeTab({ id: "edt_dirty", remotePath: "/b.txt", fileName: "b.txt", content: "new", baselineContent: "old" }));
const closed: string[] = [];
const ok = await store.confirmCloseBySession(
"conn_1",
async () => "save",
async (id) => {
const tab = store.getTab(id)!;
store.markSaved(id, tab.content);
},
(id) => closed.push(id),
);
assert.equal(ok, true);
assert.deepEqual(closed, ["edt_clean", "edt_dirty"]);
assert.equal(store.getTabs().length, 0);
});

View File

@@ -167,17 +167,23 @@ export class EditorTabStore {
sessionId: string,
promptChoice: (tab: EditorTab) => Promise<"save" | "discard" | "cancel">,
saveTab?: (tabId: EditorTabId) => Promise<void>,
onCloseTab?: (tabId: EditorTabId) => void,
): Promise<boolean> => {
const matching = this.tabs.filter((t) => t.sessionId === sessionId);
for (const tab of matching) {
const dirty = tab.content !== tab.baselineContent;
if (!dirty) {
onCloseTab?.(tab.id);
this.close(tab.id);
continue;
}
const choice = await promptChoice(tab);
if (choice === "cancel") return false;
if (choice === "discard") { this.close(tab.id); continue; }
if (choice === "discard") {
onCloseTab?.(tab.id);
this.close(tab.id);
continue;
}
if (choice === "save") {
if (!saveTab) throw new Error("saveTab callback required when 'save' choice is possible");
try {
@@ -186,6 +192,7 @@ export class EditorTabStore {
// Save failed — treat like cancel (keep tab open, abort batch so the user sees the error)
return false;
}
onCloseTab?.(tab.id);
this.close(tab.id);
}
}

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

@@ -1,5 +1,5 @@
import React, { useCallback, useRef, useMemo } from "react";
import { TransferTask, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
import React, { useCallback, useRef, useMemo, useState } from "react";
import { FileConflict, FileConflictAction, TransferTask, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { SftpPane } from "./types";
@@ -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[],
@@ -63,6 +75,8 @@ interface SftpExternalOperationsResult {
) => Promise<UploadResult[]>;
cancelExternalUpload: () => Promise<void>;
selectApplication: () => Promise<{ path: string; name: string } | null>;
uploadConflicts: FileConflict[];
resolveUploadConflict: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => void;
}
export const useSftpExternalOperations = (
@@ -88,6 +102,11 @@ export const useSftpExternalOperations = (
// Track active file watches so the side panel can block host-switching.
// Reset to 0 when the SFTP session disconnects (handled in SftpSidePanel).
const activeFileWatchCountRef = useRef(0);
const [uploadConflicts, setUploadConflicts] = useState<FileConflict[]>([]);
const uploadConflictResolversRef = useRef(new Map<string, {
resolve: (action: FileConflictAction) => void;
setDefault: (action: FileConflictAction) => void;
}>());
const readTextFile = useCallback(
async (side: "left" | "right", filePath: string): Promise<string> => {
@@ -496,18 +515,99 @@ export const useSftpExternalOperations = (
};
}, [addExternalUpload, updateExternalUpload, dismissExternalUpload]);
const resolveUploadConflict = useCallback((conflictId: string, action: FileConflictAction, applyToAll = false) => {
const conflict = uploadConflicts.find((item) => item.transferId === conflictId);
setUploadConflicts((prev) => prev.filter((item) => item.transferId !== conflictId));
const resolver = uploadConflictResolversRef.current.get(conflictId);
if (!resolver) return;
uploadConflictResolversRef.current.delete(conflictId);
if (conflict && applyToAll) {
resolver.setDefault(action);
}
resolver.resolve(action);
}, [uploadConflicts]);
const cancelPendingUploadConflicts = useCallback(() => {
const resolvers = Array.from(uploadConflictResolversRef.current.values());
if (resolvers.length === 0) return;
uploadConflictResolversRef.current.clear();
setUploadConflicts([]);
for (const resolver of resolvers) {
resolver.resolve("stop");
}
}, []);
const createUploadConflictResolver = useCallback(() => {
const conflictDefaults = new Map<string, FileConflictAction>();
return async (conflict: {
fileName: string;
targetPath: string;
isDirectory: boolean;
existingType?: 'file' | 'directory' | 'symlink';
existingSize: number;
newSize: number;
existingModified: number;
newModified: number;
applyToAllCount: number;
}): Promise<FileConflictAction> => {
const conflictType = conflict.isDirectory ? "directory" : "file";
const defaultAction = conflictDefaults.get(conflictType);
if (defaultAction) return defaultAction;
const conflictId = `upload-conflict-${crypto.randomUUID()}`;
const fileConflict: FileConflict = {
transferId: conflictId,
fileName: conflict.fileName,
sourcePath: "local",
targetPath: conflict.targetPath,
isDirectory: conflict.isDirectory,
existingType: conflict.existingType,
applyToAllCount: conflict.applyToAllCount,
existingSize: conflict.existingSize,
newSize: conflict.newSize,
existingModified: conflict.existingModified,
newModified: conflict.newModified,
};
setUploadConflicts((prev) => [...prev, fileConflict]);
return new Promise<FileConflictAction>((resolve) => {
uploadConflictResolversRef.current.set(conflictId, {
resolve,
setDefault: (action) => {
conflictDefaults.set(conflictType, action);
},
});
});
};
}, []);
// Create upload bridge that wraps netcattyBridge
const createUploadBridge = useMemo((): UploadBridge => {
const bridge = netcattyBridge.get();
return {
writeLocalFile: bridge?.writeLocalFile,
mkdirLocal: bridge?.mkdirLocal,
statLocal: bridge?.statLocal,
deleteLocalFile: bridge?.deleteLocalFile,
mkdirSftp: async (sftpId: string, path: string) => {
const b = netcattyBridge.get();
if (b?.mkdirSftp) {
await b.mkdirSftp(sftpId, path);
}
},
statSftp: async (sftpId: string, path: string) => {
const b = netcattyBridge.get();
if (!b?.statSftp) return null;
return b.statSftp(sftpId, path);
},
deleteSftp: async (sftpId: string, path: string) => {
const b = netcattyBridge.get();
if (b?.deleteSftp) {
await b.deleteSftp(sftpId, path);
}
},
writeSftpBinary: bridge?.writeSftpBinary,
// Wrap writeSftpBinaryWithProgress to adapt UploadBridge interface to NetcattyBridge interface
// UploadBridge: (sftpId, path, data, taskId, onProgress, onComplete, onError)
@@ -596,6 +696,7 @@ export const useSftpExternalOperations = (
joinPath,
callbacks,
useCompressedUpload,
resolveConflict: createUploadConflictResolver(),
},
controller
);
@@ -624,6 +725,217 @@ export const useSftpExternalOperations = (
sftpSessionsRef,
createUploadCallbacks,
createUploadBridge,
createUploadConflictResolver,
useCompressedUpload,
],
);
// 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,
],
);
@@ -680,6 +992,7 @@ export const useSftpExternalOperations = (
joinPath,
callbacks,
useCompressedUpload,
resolveConflict: createUploadConflictResolver(),
},
controller,
);
@@ -707,6 +1020,7 @@ export const useSftpExternalOperations = (
connectionCacheKeyMapRef,
createUploadCallbacks,
createUploadBridge,
createUploadConflictResolver,
getActivePane,
refresh,
sftpSessionsRef,
@@ -716,11 +1030,14 @@ export const useSftpExternalOperations = (
const cancelExternalUpload = useCallback(async () => {
const controller = uploadControllerRef.current;
let cancelPromise: Promise<void> | undefined;
if (controller) {
logger.info("[SFTP] Cancelling external upload");
await controller.cancel();
cancelPromise = controller.cancel();
}
}, []);
cancelPendingUploadConflicts();
await cancelPromise;
}, [cancelPendingUploadConflicts]);
const selectApplication = useCallback(
async (): Promise<{ path: string; name: string } | null> => {
@@ -740,9 +1057,13 @@ export const useSftpExternalOperations = (
writeTextFileByConnection,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalFileList,
uploadExternalFolderPath,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
activeFileWatchCountRef,
uploadConflicts,
resolveUploadConflict,
};
};

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,6 +1,7 @@
import React, { useCallback, useMemo, useRef, useState } from "react";
import {
FileConflict,
FileConflictAction,
SftpFileEntry,
SftpFilenameEncoding,
TransferDirection,
@@ -61,7 +62,7 @@ interface UseSftpTransfersResult {
retryTransfer: (transferId: string) => Promise<void>;
clearCompletedTransfers: () => void;
dismissTransfer: (transferId: string) => void;
resolveConflict: (conflictId: string, action: "replace" | "skip" | "duplicate") => Promise<void>;
resolveConflict: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => Promise<void>;
}
interface TransferResult {
@@ -96,6 +97,7 @@ export const useSftpTransfers = ({
const conflictsRef = useRef(conflicts);
conflictsRef.current = conflicts;
const completionHandlersRef = useRef<Map<string, (result: TransferResult) => void | Promise<void>>>(new Map());
const conflictDefaultsRef = useRef<Map<string, FileConflictAction>>(new Map());
const clearCancelledTask = useCallback((taskId: string) => {
cancelledTasksRef.current.delete(taskId);
@@ -122,6 +124,196 @@ export const useSftpTransfers = ({
[],
);
const conflictDefaultKey = useCallback(
(batchId: string | undefined, isDirectory: boolean) =>
`${batchId ?? "global"}:${isDirectory ? "directory" : "file"}`,
[],
);
const splitNameForDuplicate = useCallback((fileName: string, isDirectory: boolean) => {
if (isDirectory) return { baseName: fileName, ext: "" };
const lastDot = fileName.lastIndexOf(".");
if (lastDot <= 0) return { baseName: fileName, ext: "" };
return {
baseName: fileName.slice(0, lastDot),
ext: fileName.slice(lastDot),
};
}, []);
const statTargetPath = useCallback(
async (
targetPane: SftpPane,
targetSftpId: string | null,
targetPath: string,
targetEncoding: SftpFilenameEncoding,
): Promise<{ type?: "file" | "directory" | "symlink"; size: number; mtime: number } | null> => {
if (!targetPane.connection) return null;
if (targetPane.connection.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(targetPath);
if (!stat) return null;
return {
type: stat.type as "file" | "directory" | "symlink" | undefined,
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
}
if (!targetSftpId) return null;
const stat = await netcattyBridge.get()?.statSftp?.(
targetSftpId,
targetPath,
targetEncoding,
);
if (!stat) return null;
return {
type: stat.type as "file" | "directory" | "symlink" | undefined,
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
},
[],
);
const getDuplicateTarget = useCallback(
async (
task: TransferTask,
targetPane: SftpPane,
targetSftpId: string | null,
targetEncoding: SftpFilenameEncoding,
) => {
const parentPath = getParentPath(task.targetPath);
const { baseName, ext } = splitNameForDuplicate(task.fileName, task.isDirectory);
for (let index = 1; index < 1000; index++) {
const suffix = index === 1 ? " (copy)" : ` (copy ${index})`;
const fileName = `${baseName}${suffix}${ext}`;
const targetPath = joinPath(parentPath, fileName);
try {
const existing = await statTargetPath(targetPane, targetSftpId, targetPath, targetEncoding);
if (!existing) return { fileName, targetPath };
} catch {
return { fileName, targetPath };
}
}
const fallbackName = `${baseName} (copy ${Date.now()})${ext}`;
return { fileName: fallbackName, targetPath: joinPath(parentPath, fallbackName) };
},
[splitNameForDuplicate, statTargetPath],
);
const completeCancelledTask = useCallback(
async (task: TransferTask) => {
const completionHandler = completionHandlersRef.current.get(task.id);
if (completionHandler) {
try {
await completionHandler({
id: task.id,
fileName: task.fileName,
originalFileName: task.originalFileName ?? task.fileName,
status: "cancelled",
});
} finally {
completionHandlersRef.current.delete(task.id);
}
}
},
[],
);
const cancelBackendTransfers = useCallback(async (transferIds: string[]) => {
const idsToCancel = new Set<string>();
const currentTransfers = transfersRef.current;
for (const transferId of transferIds) {
idsToCancel.add(transferId);
const trackedChildren = activeChildIdsRef.current.get(transferId);
if (trackedChildren) {
for (const childId of trackedChildren) {
idsToCancel.add(childId);
cancelledTasksRef.current.add(childId);
}
}
for (const transfer of currentTransfers) {
if (
transfer.parentTaskId === transferId &&
(transfer.status === "transferring" || transfer.status === "pending")
) {
idsToCancel.add(transfer.id);
cancelledTasksRef.current.add(transfer.id);
}
}
}
const cancelTransferAtBackend = netcattyBridge.get()?.cancelTransfer;
if (!cancelTransferAtBackend) return;
await Promise.all(
Array.from(idsToCancel).map((id) =>
cancelTransferAtBackend(id).catch((err) => {
logger.warn("Failed to cancel transfer at backend:", err);
}),
),
);
}, []);
const markBatchStopped = useCallback(
async (task: TransferTask) => {
const batchId = task.batchId;
const affected = transfersRef.current.filter((candidate) =>
candidate.id === task.id ||
(!!batchId && candidate.batchId === batchId && (candidate.status === "pending" || candidate.status === "transferring")),
);
affected.forEach((candidate) => cancelledTasksRef.current.add(candidate.id));
const affectedIds = new Set(affected.map((candidate) => candidate.id));
setConflicts((prev) => prev.filter((conflict) => conflict.transferId !== task.id && (!batchId || conflict.batchId !== batchId)));
setTransfers((prev) => {
for (const candidate of prev) {
if (candidate.parentTaskId && affectedIds.has(candidate.parentTaskId)) {
cancelledTasksRef.current.add(candidate.id);
}
}
return prev
.filter((candidate) => !(candidate.parentTaskId && affectedIds.has(candidate.parentTaskId)))
.map((candidate) =>
affectedIds.has(candidate.id)
? { ...candidate, status: "cancelled" as TransferStatus, endTime: Date.now() }
: candidate,
);
});
await cancelBackendTransfers(affected.map((candidate) => candidate.id));
for (const candidate of affected) {
await completeCancelledTask(candidate);
}
},
[cancelBackendTransfers, completeCancelledTask],
);
const deleteTargetPath = useCallback(
async (
task: TransferTask,
targetPane: SftpPane,
targetSftpId: string | null,
targetEncoding: SftpFilenameEncoding,
) => {
if (!targetPane.connection) return;
if (targetPane.connection.isLocal) {
const deleteLocalFile = netcattyBridge.get()?.deleteLocalFile;
if (!deleteLocalFile) throw new Error("Local delete unavailable");
await deleteLocalFile(task.targetPath);
return;
}
if (!targetSftpId) throw new Error("Target SFTP session not found");
const deleteSftp = netcattyBridge.get()?.deleteSftp;
if (!deleteSftp) throw new Error("SFTP delete unavailable");
await deleteSftp(targetSftpId, task.targetPath, targetEncoding);
},
[],
);
const getEntrySize = useCallback((entry: SftpFileEntry): number => {
if (typeof entry.size === "string") {
const parsed = parseInt(entry.size, 10);
@@ -557,6 +749,10 @@ export const useSftpTransfers = ({
targetPane: SftpPane,
targetSide: "left" | "right",
): Promise<TransferStatus> => {
if (cancelledTasksRef.current.has(task.id)) {
return "cancelled";
}
const updateTask = (updates: Partial<TransferTask>) => {
setTransfers((prev) =>
prev.map((t) => (t.id === task.id ? { ...t, ...updates } : t)),
@@ -676,7 +872,7 @@ export const useSftpTransfers = ({
// Run size discovery and conflict check in parallel
const conflictCheckPromise = (async (): Promise<FileConflict | null> => {
if (task.skipConflictCheck || task.isDirectory || !targetPane.connection) return null;
if (task.skipConflictCheck || !targetPane.connection) return null;
const sourceStat: { size: number; mtime: number } | null =
(task.totalBytes > 0 || task.sourceLastModified)
@@ -684,30 +880,26 @@ export const useSftpTransfers = ({
: null;
try {
let existingStat: { size: number; mtime: number } | null = null;
if (targetPane.connection.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(task.targetPath);
if (stat) {
existingStat = { size: stat.size, mtime: stat.lastModified || Date.now() };
}
} else if (targetSftpId) {
const stat = await netcattyBridge.get()?.statSftp?.(
targetSftpId,
task.targetPath,
targetEncoding,
);
if (stat) {
existingStat = { size: stat.size, mtime: stat.lastModified || Date.now() };
}
}
const existingStat = await statTargetPath(targetPane, targetSftpId, task.targetPath, targetEncoding);
if (existingStat) {
return {
transferId: task.id,
batchId: task.batchId,
fileName: task.fileName,
sourcePath: task.sourcePath,
targetPath: task.targetPath,
isDirectory: task.isDirectory,
existingType: existingStat.type,
applyToAllCount: task.batchId
? transfersRef.current.filter((candidate) =>
candidate.batchId === task.batchId &&
candidate.isDirectory === task.isDirectory &&
!candidate.parentTaskId &&
candidate.status !== "completed" &&
candidate.status !== "cancelled",
).length
: 1,
existingSize: existingStat.size,
newSize: sourceStat?.size || task.totalBytes || 0,
existingModified: existingStat.mtime,
@@ -729,6 +921,44 @@ export const useSftpTransfers = ({
const conflict = await conflictCheckPromise;
if (conflict) {
const defaultAction = conflictDefaultsRef.current.get(conflictDefaultKey(task.batchId, task.isDirectory));
if (defaultAction) {
if (defaultAction === "stop") {
await markBatchStopped(task);
return "cancelled";
}
if (defaultAction === "skip") {
cancelledTasksRef.current.add(task.id);
updateTask({ status: "cancelled", endTime: Date.now() });
await completeCancelledTask(task);
return "cancelled";
}
const duplicateTarget = defaultAction === "duplicate"
? await getDuplicateTarget(task, targetPane, targetSftpId, targetEncoding)
: null;
const updatedTask: TransferTask = {
...task,
...(duplicateTarget
? {
fileName: duplicateTarget.fileName,
targetPath: duplicateTarget.targetPath,
}
: null),
skipConflictCheck: true,
replaceExistingTarget: defaultAction === "replace",
};
setTransfers((prev) =>
prev.map((t) =>
t.id === task.id
? { ...updatedTask, status: "pending" as TransferStatus }
: t,
),
);
return processTransfer(updatedTask, sourcePane, targetPane, targetSide);
}
setConflicts((prev) => [...prev, conflict]);
updateTask({
status: "pending",
@@ -741,6 +971,10 @@ export const useSftpTransfers = ({
let dirPartialFailure = false;
if (task.replaceExistingTarget) {
await deleteTargetPath(task, targetPane, targetSftpId, targetEncoding);
}
// Same-host exec-based paths are only safe for UTF-8 compatible encodings.
// "auto" is allowed here — the backend resolves it to the actual encoding
// and skips exec if it resolved to non-UTF-8 (e.g. gb18030).
@@ -816,6 +1050,10 @@ export const useSftpTransfers = ({
);
}
if (cancelledTasksRef.current.has(task.id)) {
throw new Error("Transfer cancelled");
}
const finalStatus: TransferStatus = dirPartialFailure ? "failed" : "completed";
setTransfers((prev) => {
return prev.map((t) => {
@@ -940,6 +1178,7 @@ export const useSftpTransfers = ({
const sourcePath = options?.sourcePath ?? sourcePane.connection.currentPath;
const targetPath = options?.targetPath ?? targetPane.connection.currentPath;
const sourceConnectionId = options?.sourceConnectionId ?? sourcePane.connection.id;
const batchId = crypto.randomUUID();
const newTasks: TransferTask[] = [];
@@ -965,6 +1204,7 @@ export const useSftpTransfers = ({
newTasks.push({
id: crypto.randomUUID(),
batchId,
fileName: file.name,
originalFileName: file.name,
sourcePath: joinPath(sourcePath, file.name),
@@ -1032,37 +1272,10 @@ export const useSftpTransfers = ({
setConflicts((prev) => prev.filter((c) => c.transferId !== transferId));
if (netcattyBridge.get()?.cancelTransfer) {
// Cancel parent and all active child streams at the backend.
// Use activeChildIdsRef for immediate visibility (not subject to
// React state batching delays like transfersRef).
const idsToCancel = [transferId];
const trackedChildren = activeChildIdsRef.current.get(transferId);
if (trackedChildren) {
for (const childId of trackedChildren) {
idsToCancel.push(childId);
cancelledTasksRef.current.add(childId);
}
}
// Also check rendered state as fallback for transfers started
// via other paths (e.g. startTransfer/processTransfer)
const currentTransfers = transfersRef.current;
for (const t of currentTransfers) {
if (t.parentTaskId === transferId && (t.status === "transferring" || t.status === "pending") && !idsToCancel.includes(t.id)) {
idsToCancel.push(t.id);
}
}
await Promise.all(
idsToCancel.map((id) =>
netcattyBridge.get()!.cancelTransfer!(id).catch((err) => {
logger.warn("Failed to cancel transfer at backend:", err);
}),
),
);
}
await cancelBackendTransfers([transferId]);
},
[],
[cancelBackendTransfers],
);
const retryTransfer = useCallback(
@@ -1155,79 +1368,123 @@ export const useSftpTransfers = ({
}, []);
const resolveConflict = useCallback(
async (conflictId: string, action: "replace" | "skip" | "duplicate") => {
async (conflictId: string, action: FileConflictAction, applyToAll = false) => {
const conflict = conflictsRef.current.find((c) => c.transferId === conflictId);
if (!conflict) return;
setConflicts((prev) => prev.filter((c) => c.transferId !== conflictId));
const task = transfersRef.current.find((t) => t.id === conflictId);
if (!task) return;
if (!task) {
setConflicts((prev) => prev.filter((c) => c.transferId !== conflictId));
return;
}
const selectedConflictKey = conflictDefaultKey(task.batchId, task.isDirectory);
const affectedConflicts = applyToAll
? conflictsRef.current.filter((candidate) =>
conflictDefaultKey(candidate.batchId, candidate.isDirectory) === selectedConflictKey,
)
: [conflict];
const affectedConflictIds = new Set(affectedConflicts.map((candidate) => candidate.transferId));
const affectedTasks = affectedConflicts
.map((candidate) => transfersRef.current.find((transfer) => transfer.id === candidate.transferId))
.filter((candidate): candidate is TransferTask => Boolean(candidate));
if (applyToAll) {
conflictDefaultsRef.current.set(selectedConflictKey, action);
}
setConflicts((prev) => prev.filter((c) => !affectedConflictIds.has(c.transferId)));
if (affectedTasks.length === 0) {
return;
}
if (action === "stop") {
await markBatchStopped(task);
return;
}
if (action === "skip") {
for (const affectedTask of affectedTasks) {
cancelledTasksRef.current.add(affectedTask.id);
}
setTransfers((prev) =>
prev.map((t) =>
t.id === conflictId
? { ...t, status: "cancelled" as TransferStatus }
prev.map((t) => affectedConflictIds.has(t.id)
? { ...t, status: "cancelled" as TransferStatus, endTime: Date.now() }
: t,
),
);
const completionHandler = completionHandlersRef.current.get(conflictId);
if (completionHandler) {
try {
await completionHandler({
id: task.id,
fileName: task.fileName,
originalFileName: task.originalFileName ?? task.fileName,
status: "cancelled",
});
} finally {
completionHandlersRef.current.delete(conflictId);
}
for (const affectedTask of affectedTasks) {
await completeCancelledTask(affectedTask);
}
return;
}
let updatedTask = { ...task };
const updatedTasks: TransferTask[] = [];
if (action === "duplicate") {
const ext = task.fileName.includes(".")
? "." + task.fileName.split(".").pop()
: "";
const baseName = task.fileName.includes(".")
? task.fileName.slice(0, task.fileName.lastIndexOf("."))
: task.fileName;
const newName = `${baseName} (copy)${ext}`;
const newTargetPath = joinPath(getParentPath(task.targetPath), newName);
updatedTask = {
...task,
fileName: newName,
targetPath: newTargetPath,
skipConflictCheck: true,
};
} else if (action === "replace") {
updatedTask = {
...task,
skipConflictCheck: true,
};
for (const affectedTask of affectedTasks) {
let updatedTask = { ...affectedTask };
if (action === "duplicate") {
const endpoints = resolveTaskEndpoints(affectedTask);
if (!endpoints) continue;
const targetSftpId = endpoints.targetPane.connection?.isLocal
? null
: sftpSessionsRef.current.get(endpoints.targetPane.connection!.id) ?? null;
const targetEncoding = endpoints.targetPane.connection?.isLocal
? "auto"
: endpoints.targetPane.filenameEncoding || "auto";
const duplicateTarget = await getDuplicateTarget(affectedTask, endpoints.targetPane, targetSftpId, targetEncoding);
updatedTask = {
...affectedTask,
fileName: duplicateTarget.fileName,
targetPath: duplicateTarget.targetPath,
skipConflictCheck: true,
};
} else if (action === "replace") {
updatedTask = {
...affectedTask,
skipConflictCheck: true,
replaceExistingTarget: true,
};
} else if (action === "merge") {
updatedTask = {
...affectedTask,
skipConflictCheck: true,
replaceExistingTarget: false,
};
}
updatedTasks.push(updatedTask);
}
const updatedTaskMap = new Map(updatedTasks.map((updatedTask) => [updatedTask.id, updatedTask]));
setTransfers((prev) =>
prev.map((t) =>
t.id === conflictId
prev.map((t) => {
const updatedTask = updatedTaskMap.get(t.id);
return updatedTask
? { ...updatedTask, status: "pending" as TransferStatus }
: t,
),
: t;
}),
);
setTimeout(async () => {
const endpoints = resolveTaskEndpoints(updatedTask);
if (!endpoints) return;
await processTransfer(updatedTask, endpoints.sourcePane, endpoints.targetPane, endpoints.targetSide);
}, 100);
for (const updatedTask of updatedTasks) {
setTimeout(async () => {
const endpoints = resolveTaskEndpoints(updatedTask);
if (!endpoints) return;
await processTransfer(updatedTask, endpoints.sourcePane, endpoints.targetPane, endpoints.targetSide);
}, 100);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline; transfers/conflicts accessed via refs
[resolveTaskEndpoints],
[
completeCancelledTask,
conflictDefaultKey,
getDuplicateTarget,
markBatchStopped,
resolveTaskEndpoints,
sftpSessionsRef,
],
);
const activeTransfersCount = useMemo(() => transfers.filter(

View File

@@ -0,0 +1,130 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createTextEditorSaveCoordinator } from "./textEditorSaveCoordinator.ts";
const deferred = <T = void>() => {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
test("text editor save coordinator joins duplicate saves already in flight", async () => {
const pending = deferred();
const saved: string[] = [];
const savingStates: boolean[] = [];
const coordinator = createTextEditorSaveCoordinator({
onSave: async (content) => {
saved.push(content);
await pending.promise;
},
onSavingChange: (saving) => savingStates.push(saving),
});
const first = coordinator.save("remote text");
const second = coordinator.save("remote text");
assert.deepEqual(saved, ["remote text"]);
pending.resolve();
assert.equal(await first, true);
assert.equal(await second, true);
assert.deepEqual(saved, ["remote text"]);
assert.deepEqual(savingStates, [true, false]);
});
test("text editor save coordinator saves newer content after an in-flight save finishes", async () => {
const firstSave = deferred();
const secondSave = deferred();
const saved: string[] = [];
const coordinator = createTextEditorSaveCoordinator({
onSave: async (content) => {
saved.push(content);
await (content === "v1" ? firstSave.promise : secondSave.promise);
},
});
const first = coordinator.save("v1");
const second = coordinator.save("v2");
assert.deepEqual(saved, ["v1"]);
firstSave.resolve();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
assert.deepEqual(saved, ["v1", "v2"]);
secondSave.resolve();
assert.equal(await first, true);
assert.equal(await second, true);
});
test("text editor save coordinator returns false to duplicate callers when the in-flight save fails", async () => {
const pending = deferred();
const errors: string[] = [];
const coordinator = createTextEditorSaveCoordinator({
onSave: async () => {
await pending.promise;
throw new Error("denied");
},
onSaveError: (error) => {
errors.push(error instanceof Error ? error.message : String(error));
},
});
const first = coordinator.save("content");
const second = coordinator.save("content");
pending.resolve();
assert.equal(await first, false);
assert.equal(await second, false);
assert.deepEqual(errors, ["denied"]);
});
test("text editor save coordinator reset prevents an old in-flight save from updating the next file", async () => {
const pending = deferred();
const successes: string[] = [];
const errors: string[] = [];
const savingStates: boolean[] = [];
const coordinator = createTextEditorSaveCoordinator({
onSave: async () => {
await pending.promise;
},
onSaveSuccess: (content) => successes.push(content),
onSaveError: (error) => errors.push(error instanceof Error ? error.message : String(error)),
onSavingChange: (saving) => savingStates.push(saving),
});
const save = coordinator.save("old file");
coordinator.reset();
pending.resolve();
assert.equal(await save, false);
assert.deepEqual(successes, []);
assert.deepEqual(errors, []);
assert.deepEqual(savingStates, [true, false]);
});
test("text editor save coordinator reset cancels queued stale saves", async () => {
const firstSave = deferred();
const saved: string[] = [];
const coordinator = createTextEditorSaveCoordinator({
onSave: async (content) => {
saved.push(content);
await firstSave.promise;
},
});
const first = coordinator.save("old v1");
const queued = coordinator.save("old v2");
coordinator.reset();
firstSave.resolve();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
assert.equal(await first, false);
assert.equal(await queued, false);
assert.deepEqual(saved, ["old v1"]);
});

View File

@@ -0,0 +1,90 @@
export interface TextEditorSaveCoordinator {
save(content: string): Promise<boolean>;
isSaving(): boolean;
reset(): void;
}
export interface TextEditorSaveCoordinatorOptions {
onSave: (content: string) => Promise<void>;
onSaveStart?: (content: string) => void;
onSaveSuccess?: (content: string) => void;
onSaveError?: (error: unknown) => void;
onSavingChange?: (saving: boolean) => void;
}
interface InFlightSave {
content: string;
promise: Promise<boolean>;
}
export const createTextEditorSaveCoordinator = (
options: TextEditorSaveCoordinatorOptions,
): TextEditorSaveCoordinator => {
let inFlight: InFlightSave | null = null;
let generation = 0;
const notifySavingChange = () => {
options.onSavingChange?.(inFlight !== null);
};
const startSave = (content: string): Promise<boolean> => {
const saveGeneration = generation;
options.onSaveStart?.(content);
const promise = (async () => {
try {
await options.onSave(content);
if (saveGeneration !== generation) {
return false;
}
if (saveGeneration === generation) {
options.onSaveSuccess?.(content);
}
return true;
} catch (error) {
if (saveGeneration !== generation) {
return false;
}
if (saveGeneration === generation) {
options.onSaveError?.(error);
}
return false;
}
})();
const entry = { content, promise };
inFlight = entry;
notifySavingChange();
void promise.finally(() => {
if (inFlight === entry) {
inFlight = null;
notifySavingChange();
}
});
return promise;
};
const save = async (content: string): Promise<boolean> => {
const current = inFlight;
if (current) {
const waitGeneration = generation;
const ok = await current.promise;
if (waitGeneration !== generation) return false;
if (!ok || current.content === content) return ok;
return save(content);
}
return startSave(content);
};
return {
save,
isSaving: () => inFlight !== null,
reset: () => {
generation += 1;
if (inFlight) {
inFlight = null;
notifySavingChange();
}
},
};
};

View File

@@ -0,0 +1,197 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
UploadController,
startUploadScanningTask,
uploadEntriesDirect,
uploadFromDataTransfer,
uploadFromFileList,
} from "../../lib/uploadService.ts";
function createDataTransfer(files: File[]): DataTransfer {
return {
items: { length: 0 },
files,
} 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 });
const results = await uploadFromDataTransfer(
createDataTransfer([file]),
{
targetPath: "/target",
sftpId: null,
isLocal: true,
bridge: {
mkdirSftp: async () => {},
statLocal: async () => ({ type: "file", size: 10, lastModified: 1000 }),
writeLocalFile: async () => {
throw new Error("skipped conflicts should not upload");
},
},
joinPath: (base, name) => `${base}/${name}`,
callbacks: {
onScanningStart: () => events.push("scan:start"),
onScanningEnd: () => events.push("scan:end"),
onTaskCreated: () => events.push("task:create"),
},
resolveConflict: async () => "skip",
},
);
assert.deepEqual(results, [
{ fileName: "conflict.txt", success: false, cancelled: true },
]);
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,14 +16,20 @@ import {
findSyncPayloadEncryptedCredentialPaths,
} from '../../domain/credentials';
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
import { collectSyncableSettings, hasMeaningfulSyncData } 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 { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
import {
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
localStorageAdapter,
} from '../../infrastructure/persistence/localStorageAdapter';
import { notify } from '../notification';
interface AutoSyncConfig {
@@ -31,11 +37,11 @@ interface AutoSyncConfig {
hosts: SyncPayload['hosts'];
keys: SyncPayload['keys'];
identities?: SyncPayload['identities'];
proxyProfiles?: SyncPayload['proxyProfiles'];
snippets: SyncPayload['snippets'];
customGroups: SyncPayload['customGroups'];
snippetPackages?: SyncPayload['snippetPackages'];
portForwardingRules?: SyncPayload['portForwardingRules'];
knownHosts?: SyncPayload['knownHosts'];
groupConfigs?: SyncPayload['groupConfigs'];
/** Opaque token that changes whenever a synced setting changes. */
settingsVersion?: number;
@@ -48,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
@@ -112,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);
@@ -124,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) {
@@ -140,28 +171,26 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
}
const effectiveKnownHosts = getEffectiveKnownHosts(config.knownHosts);
return {
hosts: config.hosts,
keys: config.keys,
identities: config.identities,
proxyProfiles: config.proxyProfiles,
snippets: config.snippets,
customGroups: config.customGroups,
snippetPackages: config.snippetPackages,
portForwardingRules: effectivePFRules,
knownHosts: effectiveKnownHosts,
groupConfigs: config.groupConfigs,
};
}, [
config.hosts,
config.keys,
config.identities,
config.proxyProfiles,
config.snippets,
config.customGroups,
config.snippetPackages,
config.portForwardingRules,
config.knownHosts,
config.groupConfigs,
]);
@@ -283,7 +312,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// checkRemoteVersion below: if inspect transiently errors we still
// let auto-sync run, trusting this guard to refuse if local is
// truly empty rather than letting an empty state clobber remote.
if (!hasMeaningfulSyncData(payload)) {
if (!hasMeaningfulCloudSyncData(payload)) {
if (trigger === 'auto') {
console.warn('[AutoSync] Blocked: refusing to auto-sync an empty vault to cloud');
return;
@@ -437,8 +466,8 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const remoteFile = inspection.remoteFile;
const remotePayload = inspection.payload;
const localPayload = buildPayloadRef.current();
const localIsEmpty = !hasMeaningfulSyncData(localPayload);
const remoteHasData = hasMeaningfulSyncData(remotePayload);
const localIsEmpty = !hasMeaningfulCloudSyncData(localPayload);
const remoteHasData = hasMeaningfulCloudSyncData(remotePayload);
// If local vault is empty but cloud has data, this almost certainly
// means the user's data was lost (update, storage corruption, etc.).
@@ -450,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,
});
});
@@ -640,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

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
import { SyncConfig, TerminalTheme, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
import {
STORAGE_KEY_COLOR,
STORAGE_KEY_SYNC,
@@ -49,7 +49,7 @@ import {
shouldApplyIncomingCustomKeyBindingsRecord,
updateCustomKeyBinding as updateCustomKeyBindingRecord,
} from '../../domain/customKeyBindings';
import { getTerminalThemeForUiTheme } from '../../domain/terminalAppearance';
import { applyCustomAccentToTerminalTheme, getTerminalThemeForUiTheme } from '../../domain/terminalAppearance';
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
@@ -1265,6 +1265,7 @@ export const useSettingsState = () => {
const customThemes = useCustomThemes();
const currentTerminalTheme = useMemo(() => {
let baseTheme: TerminalTheme;
// When "Follow Application Theme" is enabled, pick the terminal theme
// whose background matches the active UI theme preset.
if (followAppTerminalTheme) {
@@ -1272,13 +1273,17 @@ export const useSettingsState = () => {
const mapped = getTerminalThemeForUiTheme(activeUiThemeId);
if (mapped) {
const found = TERMINAL_THEMES.find(t => t.id === mapped);
if (found) return found;
if (found) {
baseTheme = found;
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}
}
}
return TERMINAL_THEMES.find(t => t.id === terminalThemeId)
baseTheme = TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|| customThemes.find(t => t.id === terminalThemeId)
|| TERMINAL_THEMES[0];
}, [terminalThemeId, customThemes, followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId]);
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}, [terminalThemeId, customThemes, followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
key: K,

View File

@@ -271,7 +271,7 @@ export const useSftpState = (
const {
transfers,
conflicts,
conflicts: transferConflicts,
activeTransfersCount,
startTransfer,
downloadToLocal,
@@ -282,7 +282,7 @@ export const useSftpState = (
retryTransfer,
clearCompletedTransfers,
dismissTransfer,
resolveConflict,
resolveConflict: resolveTransferConflict,
} = useSftpTransfers({
getActivePane,
getPaneByConnectionId,
@@ -304,10 +304,14 @@ export const useSftpState = (
writeTextFileByConnection,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalFileList,
uploadExternalFolderPath,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
activeFileWatchCountRef,
uploadConflicts,
resolveUploadConflict,
} = useSftpExternalOperations({
getActivePane,
getPaneByConnectionId,
@@ -322,6 +326,21 @@ export const useSftpState = (
dismissExternalUpload: dismissTransfer,
});
const conflicts = useMemo(
() => [...transferConflicts, ...uploadConflicts],
[transferConflicts, uploadConflicts],
);
const resolveAnyConflict = useCallback(
(...args: Parameters<typeof resolveTransferConflict>) => {
const [conflictId] = args;
if (uploadConflicts.some((conflict) => conflict.transferId === conflictId)) {
return resolveUploadConflict(...args);
}
return resolveTransferConflict(...args);
},
[resolveTransferConflict, resolveUploadConflict, uploadConflicts],
);
// Store methods in a ref to create stable wrapper functions
// This prevents callback reference changes from causing re-renders in consumers
const methodsRef = useRef({
@@ -364,6 +383,8 @@ export const useSftpState = (
writeTextFileByConnection,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalFileList,
uploadExternalFolderPath,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
@@ -375,7 +396,7 @@ export const useSftpState = (
retryTransfer,
clearCompletedTransfers,
dismissTransfer,
resolveConflict,
resolveConflict: resolveAnyConflict,
getSftpIdForConnection,
reportSessionError: handleSessionError,
});
@@ -419,6 +440,8 @@ export const useSftpState = (
writeTextFileByConnection,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalFileList,
uploadExternalFolderPath,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
@@ -430,7 +453,7 @@ export const useSftpState = (
retryTransfer,
clearCompletedTransfers,
dismissTransfer,
resolveConflict,
resolveConflict: resolveAnyConflict,
getSftpIdForConnection,
reportSessionError: handleSessionError,
};
@@ -484,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(),
@@ -496,7 +523,7 @@ export const useSftpState = (
retryTransfer: (...args: Parameters<typeof retryTransfer>) => methodsRef.current.retryTransfer(...args),
clearCompletedTransfers: () => methodsRef.current.clearCompletedTransfers(),
dismissTransfer: (...args: Parameters<typeof dismissTransfer>) => methodsRef.current.dismissTransfer(...args),
resolveConflict: (...args: Parameters<typeof resolveConflict>) => methodsRef.current.resolveConflict(...args),
resolveConflict: (...args: Parameters<typeof resolveAnyConflict>) => methodsRef.current.resolveConflict(...args),
getSftpIdForConnection: (...args: Parameters<typeof getSftpIdForConnection>) => methodsRef.current.getSftpIdForConnection(...args),
reportSessionError: (...args: Parameters<typeof handleSessionError>) => methodsRef.current.reportSessionError(...args),
activeFileWatchCountRef,

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

@@ -0,0 +1,25 @@
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
export const requestWindowInputFocus = (): void => {
try {
const result = netcattyBridge.get()?.windowFocus?.();
void result?.catch?.(() => undefined);
} catch {
// Browser preview or a disposed Electron bridge.
}
};
export const scheduleWindowInputFocus = (): void => {
const scheduleFrame: (callback: () => void) => unknown =
typeof requestAnimationFrame === "function"
? requestAnimationFrame
: (callback) => {
callback();
return undefined;
};
scheduleFrame(() => {
requestWindowInputFocus();
setTimeout(requestWindowInputFocus, 50);
});
};

View File

@@ -0,0 +1,584 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { SyncPayload } from "../domain/sync.ts";
import type { KnownHost } from "../domain/models.ts";
import type { SyncableVaultData } from "./syncPayload.ts";
type LocalStorageMock = {
clear(): void;
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
};
function installLocalStorage(): LocalStorageMock {
const store = new Map<string, string>();
const localStorage: LocalStorageMock = {
clear() {
store.clear();
},
getItem(key: string) {
return store.has(key) ? store.get(key)! : null;
},
setItem(key: string, value: string) {
store.set(key, String(value));
},
removeItem(key: string) {
store.delete(key);
},
};
Object.defineProperty(globalThis, "localStorage", {
value: localStorage,
configurable: true,
});
return localStorage;
}
const localStorage = installLocalStorage();
const {
applyLocalVaultPayload,
applySyncPayload,
buildLocalVaultPayload,
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",
publicKey: `SHA256:${id}`,
discoveredAt: 1,
});
const vault = (knownHosts: KnownHost[] = [knownHost()]): SyncableVaultData => ({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
snippetPackages: [],
knownHosts,
groupConfigs: [],
});
test.beforeEach(() => {
localStorage.clear();
});
test("buildSyncPayload treats known hosts as local-only data", () => {
const payload = buildSyncPayload(vault([knownHost("kh-cloud")]));
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({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
knownHosts: [knownHost("kh-only")],
syncedAt: 1,
}),
false,
);
});
test("buildLocalVaultPayload preserves known hosts for local backups", () => {
const payload = buildLocalVaultPayload(vault([knownHost("kh-local")]));
assert.deepEqual(payload.knownHosts, [knownHost("kh-local")]);
});
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: [],
identities: [],
snippets: [],
customGroups: [],
knownHosts: [knownHost("kh-legacy")],
proxyProfiles,
syncedAt: 1,
} as SyncPayload & { proxyProfiles: typeof proxyProfiles };
await applySyncPayload(payload, {
importVaultData: (json) => {
imported = JSON.parse(json);
},
});
assert.ok(imported);
assert.equal("knownHosts" in imported, false);
assert.deepEqual(imported.proxyProfiles, proxyProfiles);
});
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: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
knownHosts: [knownHost("kh-backup")],
syncedAt: 1,
};
await applyLocalVaultPayload(payload, {
importVaultData: (json) => {
imported = JSON.parse(json);
},
});
assert.ok(imported);
assert.deepEqual(imported.knownHosts, [knownHost("kh-backup")]);
});

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';
// ---------------------------------------------------------------------------
@@ -58,14 +75,16 @@ import {
const CUSTOM_KEY_BINDINGS_SYNC_PAYLOAD_ORIGIN = 'sync-payload';
/** All vault-owned data that participates in cloud sync. */
/** Vault-owned data. Some fields are local-only and excluded from cloud sync. */
export interface SyncableVaultData {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
proxyProfiles?: ProxyProfile[];
snippets: Snippet[];
customGroups: string[];
snippetPackages?: string[];
/** Local trust records. Kept in local backups, excluded from cloud sync. */
knownHosts: KnownHost[];
groupConfigs?: GroupConfig[];
}
@@ -80,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 ||
@@ -93,10 +113,33 @@ export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
);
}
/**
* Returns true when a payload contains cloud-sync data.
* Local-only trust records are intentionally ignored.
*/
export function hasMeaningfulCloudSyncData(payload: SyncPayload): boolean {
const hasEntities =
(payload.hosts?.length ?? 0) > 0 ||
(payload.keys?.length ?? 0) > 0 ||
(payload.snippets?.length ?? 0) > 0 ||
(payload.identities?.length ?? 0) > 0 ||
(payload.proxyProfiles?.length ?? 0) > 0 ||
(payload.customGroups?.length ?? 0) > 0 ||
(payload.snippetPackages?.length ?? 0) > 0 ||
(payload.portForwardingRules?.length ?? 0) > 0 ||
(payload.groupConfigs?.length ?? 0) > 0;
if (hasEntities) return true;
return Boolean(
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
);
}
/** Callbacks used by `applySyncPayload` to import data into local state. */
interface SyncPayloadImporters {
/** Import vault data (hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts). */
importVaultData: (jsonString: string) => void;
/** Import vault data. Cloud sync excludes local-only known hosts by default. */
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. */
@@ -109,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.
*/
@@ -148,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);
@@ -197,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);
@@ -209,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;
}
@@ -230,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));
@@ -278,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);
@@ -293,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),
);
}
}
}
}
// ---------------------------------------------------------------------------
@@ -314,10 +545,10 @@ export function buildSyncPayload(
hosts: vault.hosts,
keys: vault.keys,
identities: vault.identities,
proxyProfiles: vault.proxyProfiles,
snippets: vault.snippets,
customGroups: vault.customGroups,
snippetPackages: vault.snippetPackages,
knownHosts: vault.knownHosts,
groupConfigs: vault.groupConfigs,
portForwardingRules,
settings: collectSyncableSettings(),
@@ -325,52 +556,77 @@ export function buildSyncPayload(
};
}
/** Build a local backup/restore payload, including local-only trust records. */
export function buildLocalVaultPayload(
vault: SyncableVaultData,
portForwardingRules?: PortForwardingRule[],
): SyncPayload {
return {
...buildSyncPayload(vault, portForwardingRules),
knownHosts: vault.knownHosts,
};
}
/**
* Apply a downloaded `SyncPayload` to local state via the provided importers.
*
* This ensures both vault data and port-forwarding rules are imported
* consistently across windows.
*/
export function applySyncPayload(
function applyPayload(
payload: SyncPayload,
importers: SyncPayloadImporters,
): void {
// Build the vault import object. knownHosts is only included when the
// payload explicitly carries the field (even if it's []). Legacy cloud
// snapshots may omit it entirely — in that case we leave the local
// known-hosts list untouched rather than destructively wiping it.
options: { includeLocalOnlyData: boolean },
): 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,
};
if (payload.snippetPackages !== undefined) {
vaultImport.snippetPackages = payload.snippetPackages;
}
if (payload.knownHosts !== undefined) {
if (options.includeLocalOnlyData && payload.knownHosts !== undefined) {
vaultImport.knownHosts = payload.knownHosts;
}
if (Array.isArray(payload.groupConfigs)) {
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,
): Promise<void> {
return applyPayload(payload, importers, { includeLocalOnlyData: false });
}
export function applyLocalVaultPayload(
payload: SyncPayload,
importers: SyncPayloadImporters,
): Promise<void> {
return applyPayload(payload, importers, { includeLocalOnlyData: true });
}

View File

@@ -24,6 +24,7 @@ import type {
AIPanelView,
AIPermissionMode,
AIToolIntegrationMode,
AgentModelPreset,
AISession,
AISessionScope,
ChatMessage,
@@ -66,10 +67,22 @@ import {
type DefaultTargetSessionHint,
} from './ai/hooks/useAIChatStreaming';
import { buildAcpHistoryMessagesForBridge } from './ai/acpHistory';
import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligibility';
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
import { useConversationExport } from './ai/hooks/useConversationExport';
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
function modelPresetMatchesId(preset: AgentModelPreset, modelId: string): boolean {
if (preset.thinkingLevels?.length) {
return preset.thinkingLevels.some((level) => `${preset.id}/${level}` === modelId);
}
return preset.id === modelId;
}
function modelPresetsContainId(presets: AgentModelPreset[], modelId: string): boolean {
return presets.some((preset) => modelPresetMatchesId(preset, modelId));
}
function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
if (!agent) return false;
const tokens = [
@@ -231,7 +244,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const scopeKey = `${scopeType}:${scopeTargetId ?? ''}`;
const [showHistory, setShowHistory] = useState(false);
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, ReturnType<typeof getAgentModelPresets>>>({});
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, AgentModelPreset[]>>({});
const [userSkillOptions, setUserSkillOptions] = useState<UserSkillOption[]>([]);
const { openSettingsWindow } = useWindowControls();
const terminalSessionsRef = useRef(terminalSessions);
@@ -608,12 +621,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
useEffect(() => {
if (!currentAgentConfig?.acpCommand) return;
// Codex has its own path via aiCodexGetIntegration (reads config.toml).
// Everyone else that speaks ACP can be asked for their available models
// directly — in particular, Claude Code through claude-agent-acp
// advertises the real catalog (including Bedrock/Vertex model ids when
// the user configured those) instead of the hardcoded CLAUDE_MODEL_PRESETS.
if (!isCopilotExternalAgent && !isClaudeManagedAgent) return;
// ACP agents can expose their runtime model catalog during session setup.
// Codex also exposes model/reasoning selectors through ACP config options,
// which keeps the picker aligned with the user's installed CLI version.
if (!isCopilotExternalAgent && !isClaudeManagedAgent && !isCodexManagedAgent) return;
const bridge = getNetcattyBridge();
if (!bridge?.aiAcpListModels) return;
@@ -640,13 +651,13 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
});
return;
}
const knownModelIds = new Set(result.models.map((model) => model.id));
const runtimePresets = result.models ?? [];
setRuntimeAgentModelPresets((prev) => ({
...prev,
[currentAgentId]: result.models ?? [],
[currentAgentId]: runtimePresets,
}));
const storedModelId = agentModelMapRef.current[currentAgentId];
if (result.currentModelId && (!storedModelId || !knownModelIds.has(storedModelId))) {
if (result.currentModelId && (!storedModelId || !modelPresetsContainId(runtimePresets, storedModelId))) {
setAgentModel(currentAgentId, result.currentModelId);
}
}).catch((err) => {
@@ -658,7 +669,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
return () => {
cancelled = true;
};
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, isClaudeManagedAgent, setAgentModel]);
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, isClaudeManagedAgent, isCodexManagedAgent, setAgentModel]);
// When Codex is backed by a ~/.codex/config.toml custom provider, the
// stock CODEX_MODEL_PRESETS catalog is invalid for that endpoint.
@@ -668,7 +679,11 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent;
const agentModelPresets = useMemo(() => {
const runtimePresets = runtimeAgentModelPresets[currentAgentId];
if (hasCodexCustomConfig) {
if (runtimePresets) {
return runtimePresets;
}
// Config.toml with a pinned model → show just that model.
if (codexConfigModel) {
return [{ id: codexConfigModel, name: codexConfigModel }];
@@ -678,13 +693,13 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
// wouldn't work. Empty list disables the picker.
return [];
}
return runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command);
return runtimePresets ?? getAgentModelPresets(currentAgentConfig?.command);
}, [currentAgentConfig?.command, currentAgentId, runtimeAgentModelPresets, hasCodexCustomConfig, codexConfigModel]);
// Per-agent model: recall last selection or use first preset as default
const selectedAgentModel = useMemo(() => {
const stored = agentModelMap[currentAgentId];
if (stored && agentModelPresets.some(p => stored === p.id || stored.startsWith(p.id + '/'))) {
if (stored && modelPresetsContainId(agentModelPresets, stored)) {
return stored;
}
// Default to first preset; for models with thinking levels, use the default level
@@ -698,6 +713,12 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
return undefined;
}, [currentAgentId, agentModelMap, agentModelPresets]);
const inputAgentId = activeSession?.agentId ?? currentDraft?.agentId ?? currentAgentId;
const canSendCurrentAgent = useMemo(
() => canSendWithAgent(inputAgentId, externalAgents),
[inputAgentId, externalAgents],
);
const handleAgentModelSelect = useCallback((modelId: string) => {
setAgentModel(currentAgentId, modelId);
}, [currentAgentId, setAgentModel]);
@@ -800,6 +821,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
// immediately after the first send path starts; `isStreaming` alone does
// not protect the initial draft->session transition.
if (!trimmed || isStreaming) return;
const sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
const agentConfig = sendAgentId !== 'catty' ? findEnabledExternalAgent(externalAgents, sendAgentId) : undefined;
if (sendAgentId !== 'catty' && !agentConfig) return;
const selectedSkillSlugs = draft?.selectedUserSkillSlugs ?? [];
const attachments = (draft?.attachments ?? []).map((file) => ({
base64Data: file.base64Data,
@@ -816,8 +841,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
try {
let sessionId = currentSessionView?.id ?? null;
let currentSession = currentSessionView ?? null;
const sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
if (isDraftMode) {
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
const createdSession = createSession(scope, sendAgentId);
@@ -857,7 +880,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
setStreamingForScope(sessionId, true);
// Create assistant message placeholder with a tracked ID
const agentConfig = isExternalAgent ? externalAgents.find((agent) => agent.id === sendAgentId) : undefined;
const assistantMsgId = generateId();
addMessageToSession(sessionId, {
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
@@ -1088,6 +1110,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
onSend={handleSend}
onStop={handleStop}
isStreaming={isStreaming}
disabled={!canSendCurrentAgent}
providerName={providerDisplayName}
modelName={modelDisplayName}
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}

View File

@@ -638,6 +638,7 @@ const ConflictModal: React.FC<ConflictModalProps> = ({
interface SyncDashboardProps {
onBuildPayload: () => SyncPayload;
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
onApplyLocalPayload?: (payload: SyncPayload) => void | Promise<void>;
onClearLocalData?: () => void;
}
@@ -1055,6 +1056,7 @@ const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
const SyncDashboard: React.FC<SyncDashboardProps> = ({
onBuildPayload,
onApplyPayload,
onApplyLocalPayload,
onClearLocalData,
}) => {
const { t, resolvedLocale } = useI18n();
@@ -1916,7 +1918,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
<div ref={localBackupsRef}>
<LocalBackupsPanel
onApplyPayload={onApplyPayload}
onApplyPayload={onApplyLocalPayload ?? onApplyPayload}
/>
</div>
@@ -2612,6 +2614,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
interface CloudSyncSettingsProps {
onBuildPayload: () => SyncPayload;
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
onApplyLocalPayload?: (payload: SyncPayload) => void | Promise<void>;
onClearLocalData?: () => void;
}

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

@@ -16,6 +16,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
import { useSftpState } from "../application/state/useSftpState";
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
import { editorTabStore } from "../application/state/editorTabStore";
import { releaseEditorTabSaveCoordinator } from "../application/state/editorTabSave";
import { useSftpBackend } from "../application/state/useSftpBackend";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
import { getParentPath } from "../application/state/sftp/utils";
@@ -40,6 +41,7 @@ import { KeyBinding, HotkeyScheme } from "../domain/models";
interface SftpSidePanelProps {
hosts: Host[];
writableHosts?: Host[];
keys: SSHKey[];
identities: Identity[];
updateHosts: (hosts: Host[]) => void;
@@ -73,6 +75,7 @@ interface SftpSidePanelProps {
const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
hosts,
writableHosts,
keys,
identities,
updateHosts,
@@ -97,6 +100,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
onRequestTerminalFocus,
}) => {
const { t } = useI18n();
const hostWriteSource = writableHosts ?? hosts;
const fileWatchHandlers = useMemo(() => ({
onFileWatchSynced: (payload: { remotePath: string }) => {
@@ -167,7 +171,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
if (id) owned.add(id);
}
if (owned.size === 0) return;
editorTabStore.forceCloseBySessions([...owned]);
const closed = editorTabStore.forceCloseBySessions([...owned]);
closed.forEach(releaseEditorTabSaveCoordinator);
};
}, []);
@@ -620,6 +625,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
return (
<SftpContextProvider
hosts={hosts}
writableHosts={hostWriteSource}
updateHosts={updateHosts}
draggedFiles={draggedFiles}
dragCallbacks={dragCallbacks}
@@ -739,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

@@ -0,0 +1,138 @@
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 { TransferTask } from "../types.ts";
import { SftpTransferItem } from "./sftp/SftpTransferItem.tsx";
const baseTask: TransferTask = {
id: "transfer-1",
fileName: "archive.tar.gz",
sourcePath: "/local/archive.tar.gz",
targetPath: "/remote/archive.tar.gz",
sourceConnectionId: "local",
targetConnectionId: "remote",
direction: "upload",
status: "failed",
totalBytes: 1024,
transferredBytes: 512,
speed: 0,
error: "Network error",
startTime: 1,
isDirectory: false,
};
const renderTransferItem = (
task: TransferTask,
props: Partial<React.ComponentProps<typeof SftpTransferItem>> = {},
) =>
renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(SftpTransferItem, {
task,
onCancel: () => {},
onRetry: () => {},
onDismiss: () => {},
...props,
}),
),
);
test("renders failed transfer actions with custom tooltips and readable labels", () => {
const markup = renderTransferItem(baseTask);
assert.match(markup, /aria-label="Retry: archive\.tar\.gz"/);
assert.match(markup, /aria-label="Dismiss: archive\.tar\.gz"/);
assert.match(markup, /focus-visible:ring-1/);
});
test("renders active transfer cancel action with an item-specific label", () => {
const markup = renderTransferItem({
...baseTask,
status: "transferring",
error: undefined,
speed: 128,
});
assert.match(markup, /aria-label="Cancel: archive\.tar\.gz"/);
});
test("renders child resize handle as a keyboard-reachable separator", () => {
const markup = renderTransferItem(
{
...baseTask,
id: "child-transfer-1",
parentTaskId: "transfer-1",
status: "transferring",
error: undefined,
transferredBytes: 256,
speed: 128,
},
{
isChild: true,
childNameColumnWidth: 260,
onResizeNameColumn: () => {},
onSetNameColumnWidth: () => {},
},
);
assert.match(markup, /role="separator"/);
assert.match(markup, /aria-label="Resize file name column"/);
assert.match(markup, /aria-orientation="vertical"/);
assert.match(markup, /tabindex="0"/);
});
test("can remove duplicate child resize handles from the tab order", () => {
const markup = renderTransferItem(
{
...baseTask,
id: "child-transfer-2",
parentTaskId: "transfer-1",
status: "pending",
error: undefined,
},
{
isChild: true,
onResizeNameColumn: () => {},
onSetNameColumnWidth: () => {},
resizeHandleTabIndex: -1,
},
);
assert.match(markup, /role="separator"/);
assert.match(markup, /tabindex="-1"/);
});
test("keeps reveal target and child toggle as separate buttons", () => {
const markup = renderTransferItem(
{
...baseTask,
status: "completed",
error: undefined,
isDirectory: true,
},
{
canRevealTarget: true,
onRevealTarget: () => {},
canToggleChildren: true,
isExpanded: false,
childListId: "children-transfer-1",
onToggleChildren: () => {},
},
);
const revealStart = markup.indexOf('<button type="button" class="flex min-w-0 flex-1');
assert.notEqual(revealStart, -1);
const revealEnd = markup.indexOf("</button>", revealStart);
const toggleStart = markup.indexOf('aria-label="Show detail"');
assert.notEqual(toggleStart, -1);
assert.ok(toggleStart > revealEnd);
assert.match(markup, /aria-expanded="false"/);
assert.match(markup, /aria-controls="children-transfer-1"/);
});

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

@@ -26,12 +26,12 @@ import {
shouldScrollOnTerminalInput,
} from "../domain/terminalScroll";
import {
applyCustomAccentToTerminalTheme,
resolveHostTerminalThemeId,
} from "../domain/terminalAppearance";
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";
@@ -41,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";
@@ -127,6 +129,8 @@ interface TerminalProps {
fontSize: number;
terminalTheme: TerminalTheme;
followAppTerminalTheme?: boolean;
accentMode?: "theme" | "custom";
customAccent?: string;
terminalSettings?: TerminalSettings;
sessionId: string;
startupCommand?: string;
@@ -215,7 +219,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
snippets,
chainHosts = [],
themePreviewId,
knownHosts: _knownHosts = [],
knownHosts = [],
isVisible,
inWorkspace,
isResizing,
@@ -225,6 +229,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
fontSize,
terminalTheme,
followAppTerminalTheme = false,
accentMode = "theme",
customAccent = "",
terminalSettings,
sessionId,
startupCommand,
@@ -619,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,
@@ -628,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
@@ -651,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;
@@ -682,18 +716,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// When "Follow Application Theme" is on and there's no active
// preview, skip per-host overrides — all terminals should use the
// UI-matched theme passed via terminalTheme prop.
if (followAppTerminalTheme && !themePreviewId) return terminalTheme;
if (followAppTerminalTheme && !themePreviewId) {
return applyCustomAccentToTerminalTheme(terminalTheme, accentMode, customAccent);
}
const themeId = themePreviewId ?? resolveHostTerminalThemeId(
{ theme: host.theme, themeOverride: host.themeOverride } as Pick<Host, 'theme' | 'themeOverride'>,
terminalTheme.id,
);
let baseTheme = terminalTheme;
if (themeId) {
const hostTheme = TERMINAL_THEMES.find((t) => t.id === themeId)
|| customThemes.find((t) => t.id === themeId);
if (hostTheme) return hostTheme;
if (hostTheme) baseTheme = hostTheme;
}
return terminalTheme;
}, [customThemes, followAppTerminalTheme, host.theme, host.themeOverride, terminalTheme, themePreviewId]);
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}, [accentMode, customAccent, customThemes, followAppTerminalTheme, host.theme, host.themeOverride, terminalTheme, themePreviewId]);
const resolvedChainHosts =
chainHosts;
@@ -741,6 +778,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
host,
keys,
identities,
knownHosts,
resolvedChainHosts,
sessionId,
startupCommand,
@@ -1479,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();
@@ -1506,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) {
@@ -1536,6 +1578,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
pendingConnectionRef.current = null;
}
setPendingHostKeyInfo(null);
setPendingHostKeyRequestId(null);
};
const handleRetry = () => {
@@ -1606,7 +1649,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
};
const shouldShowConnectionDialog = status !== "connected"
&& !needsHostKeyVerification
&& !((isLocalConnection || isSerialConnection) && status === "connecting")
&& !(status === "disconnected" && isDisconnectedDialogDismissed);
@@ -1725,8 +1767,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
['--terminal-ui-border' as never]: `var(--terminal-preview-border, color-mix(in srgb, ${effectiveTheme.colors.foreground} 8%, ${effectiveTheme.colors.background} 92%))`,
['--terminal-ui-toolbar-btn' as never]: `var(--terminal-preview-toolbar-btn, color-mix(in srgb, ${effectiveTheme.colors.background} 88%, ${effectiveTheme.colors.foreground} 12%))`,
['--terminal-ui-toolbar-btn-hover' as never]: `var(--terminal-preview-toolbar-btn-hover, color-mix(in srgb, ${effectiveTheme.colors.background} 78%, ${effectiveTheme.colors.foreground} 22%))`,
['--terminal-ui-toolbar-btn-active' as never]: `var(--terminal-preview-toolbar-btn-active, color-mix(in srgb, ${effectiveTheme.colors.background} 68%, ${effectiveTheme.colors.foreground} 32%))`,
}), [effectiveTheme.colors.background, effectiveTheme.colors.foreground]);
['--terminal-ui-toolbar-btn-active' as never]: `var(--terminal-preview-toolbar-btn-active, color-mix(in srgb, ${effectiveTheme.colors.cursor} 78%, ${effectiveTheme.colors.background} 22%))`,
}), [effectiveTheme.colors.background, effectiveTheme.colors.cursor, effectiveTheme.colors.foreground]);
return (
<TerminalContextMenu
@@ -2200,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
@@ -2248,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

@@ -24,6 +24,7 @@ import {
resolveHostTerminalFontSize,
resolveHostTerminalFontWeight,
resolveHostTerminalThemeId,
applyCustomAccentToTerminalTheme,
} from '../domain/terminalAppearance';
import { cn, normalizeLineEndings } from '../lib/utils';
import { detectLocalOs } from '../lib/localShell';
@@ -35,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';
@@ -55,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';
@@ -385,6 +388,7 @@ AIChatPanelsHost.displayName = 'AIChatPanelsHost';
interface TerminalLayerProps {
hosts: Host[];
groupConfigs: GroupConfig[];
proxyProfiles: ProxyProfile[];
keys: SSHKey[];
identities: Identity[];
snippets: Snippet[];
@@ -395,6 +399,8 @@ interface TerminalLayerProps {
draggingSessionId: string | null;
terminalTheme: TerminalTheme;
followAppTerminalTheme?: boolean;
accentMode?: 'theme' | 'custom';
customAccent?: string;
terminalSettings?: TerminalSettings;
terminalFontFamilyId: string;
fontSize?: number;
@@ -445,6 +451,7 @@ interface TerminalLayerProps {
const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
hosts,
groupConfigs,
proxyProfiles,
keys,
identities,
snippets,
@@ -455,6 +462,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
draggingSessionId,
terminalTheme,
followAppTerminalTheme = false,
accentMode = 'theme',
customAccent = '',
terminalSettings,
terminalFontFamilyId,
fontSize = 14,
@@ -874,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(() => {
@@ -883,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;
@@ -927,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) {
@@ -940,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>();
@@ -1277,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.
@@ -1580,35 +1613,37 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
return;
}
const pane = document.querySelector<HTMLElement>(`[data-session-id="${sessionId}"]`);
const theme = TERMINAL_THEMES.find((entry) => entry.id === themeId)
const baseTheme = TERMINAL_THEMES.find((entry) => entry.id === themeId)
|| customThemes.find((entry) => entry.id === themeId);
if (!pane || !theme) {
if (!pane || !baseTheme) {
clearTerminalPreviewVars(sessionId);
return;
}
const theme = applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
pane.style.setProperty('--terminal-preview-bg', theme.colors.background);
pane.style.setProperty('--terminal-preview-fg', theme.colors.foreground);
pane.style.setProperty('--terminal-preview-border', `color-mix(in srgb, ${theme.colors.foreground} 8%, ${theme.colors.background} 92%)`);
pane.style.setProperty('--terminal-preview-toolbar-btn', `color-mix(in srgb, ${theme.colors.background} 88%, ${theme.colors.foreground} 12%)`);
pane.style.setProperty('--terminal-preview-toolbar-btn-hover', `color-mix(in srgb, ${theme.colors.background} 78%, ${theme.colors.foreground} 22%)`);
pane.style.setProperty('--terminal-preview-toolbar-btn-active', `color-mix(in srgb, ${theme.colors.background} 68%, ${theme.colors.foreground} 32%)`);
}, [customThemes]);
pane.style.setProperty('--terminal-preview-toolbar-btn-active', `color-mix(in srgb, ${theme.colors.cursor} 78%, ${theme.colors.background} 22%)`);
}, [accentMode, customAccent, customThemes]);
const applyTopTabsPreviewVars = useCallback((themeId: string | null) => {
if (!themeId || typeof document === 'undefined') {
clearTopTabsPreviewVars();
return;
}
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
const theme = TERMINAL_THEMES.find((entry) => entry.id === themeId)
const baseTheme = TERMINAL_THEMES.find((entry) => entry.id === themeId)
|| customThemes.find((entry) => entry.id === themeId);
if (!tabsRoot || !theme) {
if (!tabsRoot || !baseTheme) {
clearTopTabsPreviewVars();
return;
}
const theme = applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
const bg = hexToHslToken(theme.colors.background);
const fg = hexToHslToken(theme.colors.foreground);
const accent = fg;
const accent = hexToHslToken(theme.colors.cursor);
const isDark = theme.type === 'dark';
const secondary = adjustLightnessToken(bg, isDark ? 6 : -5);
const border = adjustLightnessToken(bg, isDark ? 12 : -10);
@@ -1625,8 +1660,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
tabsRoot.style.setProperty('--top-tabs-fg', 'hsl(var(--foreground))');
tabsRoot.style.setProperty('--top-tabs-muted', 'hsl(var(--muted-foreground))');
tabsRoot.style.setProperty('--top-tabs-active-bg', 'hsl(var(--background))');
tabsRoot.style.setProperty('--top-tabs-accent', 'hsl(var(--foreground))');
}, [customThemes]);
tabsRoot.style.setProperty('--top-tabs-accent', 'hsl(var(--accent))');
}, [accentMode, customAccent, customThemes]);
useEffect(() => {
return () => {
@@ -1889,10 +1924,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const resolvedPreviewTheme = useMemo(() => {
const themeId = previewedOrVisibleThemeId;
return TERMINAL_THEMES.find((theme) => theme.id === themeId)
const baseTheme = TERMINAL_THEMES.find((theme) => theme.id === themeId)
|| customThemes.find((theme) => theme.id === themeId)
|| terminalTheme;
}, [customThemes, previewedOrVisibleThemeId, terminalTheme]);
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}, [accentMode, customAccent, customThemes, previewedOrVisibleThemeId, terminalTheme]);
const sessionLogConfig = useMemo(
() =>
sessionLogsEnabled && sessionLogsDir
@@ -2201,6 +2237,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
style={{
['--terminal-sidepanel-bg' as never]: resolvedPreviewTheme.colors.background,
['--terminal-sidepanel-fg' as never]: resolvedPreviewTheme.colors.foreground,
['--terminal-sidepanel-accent' as never]: resolvedPreviewTheme.colors.cursor,
['--terminal-sidepanel-muted' as never]: `color-mix(in srgb, ${resolvedPreviewTheme.colors.foreground} 62%, ${resolvedPreviewTheme.colors.background} 38%)`,
['--terminal-sidepanel-border' as never]: `color-mix(in srgb, ${resolvedPreviewTheme.colors.foreground} 12%, ${resolvedPreviewTheme.colors.background} 88%)`,
backgroundColor: 'var(--terminal-sidepanel-bg)',
@@ -2223,6 +2260,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
data-state={activeSidePanelTab === 'sftp' ? 'active' : 'inactive'}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
backgroundColor: activeSidePanelTab === 'sftp'
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
: 'transparent',
color: activeSidePanelTab === 'sftp'
? 'var(--terminal-sidepanel-fg)'
: 'var(--terminal-sidepanel-muted)',
@@ -2240,6 +2280,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
data-state={activeSidePanelTab === 'scripts' ? 'active' : 'inactive'}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
backgroundColor: activeSidePanelTab === 'scripts'
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
: 'transparent',
color: activeSidePanelTab === 'scripts'
? 'var(--terminal-sidepanel-fg)'
: 'var(--terminal-sidepanel-muted)',
@@ -2257,6 +2300,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
data-state={activeSidePanelTab === 'theme' ? 'active' : 'inactive'}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
backgroundColor: activeSidePanelTab === 'theme'
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
: 'transparent',
color: activeSidePanelTab === 'theme'
? 'var(--terminal-sidepanel-fg)'
: 'var(--terminal-sidepanel-muted)',
@@ -2274,6 +2320,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
data-state={activeSidePanelTab === 'ai' ? 'active' : 'inactive'}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
backgroundColor: activeSidePanelTab === 'ai'
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
: 'transparent',
color: activeSidePanelTab === 'ai'
? 'var(--terminal-sidepanel-fg)'
: 'var(--terminal-sidepanel-muted)',
@@ -2317,7 +2366,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
return (
<SftpSidePanel
key={tabId}
hosts={hosts}
hosts={effectiveHosts}
writableHosts={hosts}
keys={keys}
identities={identities}
updateHosts={updateHosts}
@@ -2525,6 +2575,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
fontSize={fontSize}
terminalTheme={terminalTheme}
followAppTerminalTheme={followAppTerminalTheme}
accentMode={accentMode}
customAccent={customAccent}
terminalSettings={terminalSettings}
sessionId={session.id}
startupCommand={session.startupCommand}
@@ -2630,38 +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.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

@@ -0,0 +1,45 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createTextEditorModalSnapshot } from "./TextEditorModal.tsx";
import { createTextEditorSaveCoordinator } from "../application/state/textEditorSaveCoordinator.ts";
test("promotion snapshot uses the latest saved baseline after a save", async () => {
let baselineContent = "old";
let content = "saved";
const coordinator = createTextEditorSaveCoordinator({
onSave: async () => {},
onSaveSuccess: (savedContent) => {
baselineContent = savedContent;
},
});
await coordinator.save(content);
const snapshot = createTextEditorModalSnapshot({
fileName: "file.txt",
getBaselineContent: () => baselineContent,
getContent: () => content,
languageId: "plaintext",
wordWrap: false,
getViewState: () => null,
isSaving: () => false,
});
assert.equal(snapshot?.baselineContent, "saved");
assert.equal(snapshot?.content, "saved");
});
test("promotion snapshot is blocked while saving", () => {
const snapshot = createTextEditorModalSnapshot({
fileName: "file.txt",
getBaselineContent: () => "old",
getContent: () => "new",
languageId: "plaintext",
wordWrap: false,
getViewState: () => null,
isSaving: () => true,
});
assert.equal(snapshot, null);
});

View File

@@ -9,14 +9,20 @@ import { getLanguageId } from '../lib/sftpFileUtils';
import { Dialog, DialogContent, DialogTitle } from './ui/dialog';
import { toast } from './ui/toast';
import { TextEditorPane } from './editor/TextEditorPane';
import { promptUnsavedChanges } from './editor/UnsavedChangesDialog';
import { useI18n } from '../application/i18n/I18nProvider';
import { scheduleWindowInputFocus } from '../application/state/windowInputFocus';
import {
createTextEditorSaveCoordinator,
type TextEditorSaveCoordinator,
} from '../application/state/textEditorSaveCoordinator';
import type { HotkeyScheme, KeyBinding } from '../domain/models';
/** Snapshot passed to `onPromoteToTab` when the user clicks the maximize button. */
export interface TextEditorModalSnapshot {
/** The file name at the time of promotion (modal's fileName prop). */
fileName: string;
/** The clean baseline content at the time the modal was opened. */
/** The clean baseline content at the time of promotion. */
baselineContent: string;
/** The current (possibly-dirty) editor content. */
content: string;
@@ -28,6 +34,31 @@ export interface TextEditorModalSnapshot {
viewState: Monaco.editor.ICodeEditorViewState | null;
}
export interface TextEditorModalSnapshotSource {
fileName: string;
getBaselineContent: () => string;
getContent: () => string;
languageId: string;
wordWrap: boolean;
getViewState: () => Monaco.editor.ICodeEditorViewState | null;
isSaving: () => boolean;
}
export const createTextEditorModalSnapshot = (
source: TextEditorModalSnapshotSource,
): TextEditorModalSnapshot | null => {
if (source.isSaving()) return null;
return {
fileName: source.fileName,
baselineContent: source.getBaselineContent(),
content: source.getContent(),
languageId: source.languageId,
wordWrap: source.wordWrap,
viewState: source.getViewState(),
};
};
interface TextEditorModalProps {
open: boolean;
onClose: () => void;
@@ -57,51 +88,128 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
const { t } = useI18n();
const [content, setContent] = useState(initialContent);
const [baselineContent, setBaselineContent] = useState(initialContent);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [languageId, setLanguageId] = useState(() => getLanguageId(fileName));
const contentRef = useRef(initialContent);
const baselineContentRef = useRef(initialContent);
const savingRef = useRef(false);
const closePromptRef = useRef<Promise<void> | null>(null);
const onSaveRef = useRef(onSave);
const tRef = useRef(t);
const saveCoordinatorRef = useRef<TextEditorSaveCoordinator | null>(null);
// Latest view state captured from Pane's onContentChange — used by handlePromote
const viewStateRef = useRef<Monaco.editor.ICodeEditorViewState | null>(null);
// Derived: whether the current content differs from the clean baseline
const hasChanges = content !== initialContent;
const hasChanges = content !== baselineContent;
if (!saveCoordinatorRef.current) {
saveCoordinatorRef.current = createTextEditorSaveCoordinator({
onSave: (contentToSave) => onSaveRef.current(contentToSave),
onSaveStart: () => {
setSaveError(null);
},
onSaveSuccess: (savedContent) => {
setBaselineContent(savedContent);
baselineContentRef.current = savedContent;
toast.success(tRef.current('sftp.editor.saved'), 'SFTP');
},
onSaveError: (error) => {
const msg = error instanceof Error
? error.message
: tRef.current('sftp.editor.saveFailed');
setSaveError(msg);
toast.error(msg, 'SFTP');
},
onSavingChange: (nextSaving) => {
savingRef.current = nextSaving;
setSaving(nextSaving);
},
});
}
useEffect(() => {
onSaveRef.current = onSave;
}, [onSave]);
useEffect(() => {
tRef.current = t;
}, [t]);
// Reset all state when a new file is opened
useEffect(() => {
saveCoordinatorRef.current?.reset();
setContent(initialContent);
setBaselineContent(initialContent);
setSaveError(null);
setSaving(false);
setLanguageId(getLanguageId(fileName));
contentRef.current = initialContent;
baselineContentRef.current = initialContent;
savingRef.current = false;
closePromptRef.current = null;
viewStateRef.current = null;
}, [initialContent, fileName]);
const saveContent = useCallback(async (contentToSave = contentRef.current): Promise<boolean> => {
return saveCoordinatorRef.current?.save(contentToSave) ?? false;
}, []);
const handleSave = useCallback(async () => {
if (saving) return;
setSaving(true);
setSaveError(null);
try {
await onSave(content);
toast.success(t('sftp.editor.saved'), 'SFTP');
} catch (e) {
const msg = e instanceof Error ? e.message : t('sftp.editor.saveFailed');
setSaveError(msg);
toast.error(msg, 'SFTP');
} finally {
setSaving(false);
}
}, [content, onSave, saving, t]);
await saveContent();
}, [saveContent]);
const handleClose = useCallback(() => {
if (hasChanges) {
const confirmed = confirm(t('sftp.editor.unsavedChanges'));
if (!confirmed) return;
if (closePromptRef.current) return;
const closeTask = (async () => {
if (contentRef.current !== baselineContentRef.current) {
const choice = await promptUnsavedChanges(fileName);
if (choice === 'cancel') return;
if (choice === 'save') {
const saved = await saveContent();
if (!saved) return;
if (contentRef.current !== baselineContentRef.current) return;
}
}
onClose();
scheduleWindowInputFocus();
})().finally(() => {
closePromptRef.current = null;
});
closePromptRef.current = closeTask;
}, [fileName, onClose, saveContent]);
useEffect(() => {
contentRef.current = content;
}, [content]);
useEffect(() => {
baselineContentRef.current = baselineContent;
}, [baselineContent]);
useEffect(() => {
savingRef.current = saving;
}, [saving]);
useEffect(() => {
if (!open) {
closePromptRef.current = null;
}
onClose();
}, [hasChanges, onClose, t]);
}, [open]);
useEffect(() => {
if (open) scheduleWindowInputFocus();
}, [open]);
const handleContentChange = useCallback(
(nextContent: string, viewState: Monaco.editor.ICodeEditorViewState | null) => {
setContent(nextContent);
contentRef.current = nextContent;
viewStateRef.current = viewState;
},
[],
@@ -109,15 +217,17 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
const handlePromote = useCallback(() => {
if (!onPromoteToTab) return;
onPromoteToTab({
const snapshot = createTextEditorModalSnapshot({
fileName,
baselineContent: initialContent,
content,
getBaselineContent: () => baselineContentRef.current,
getContent: () => contentRef.current,
languageId,
wordWrap: editorWordWrap,
viewState: viewStateRef.current,
getViewState: () => viewStateRef.current,
isSaving: () => savingRef.current,
});
}, [onPromoteToTab, fileName, initialContent, content, languageId, editorWordWrap]);
if (snapshot) onPromoteToTab(snapshot);
}, [onPromoteToTab, fileName, languageId, editorWordWrap]);
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>

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

@@ -35,7 +35,7 @@ export const MessageContent = ({ children, className, from, ...props }: MessageC
<div
className={cn(
'ai-chat-message-content flex w-fit min-w-0 max-w-full flex-col gap-1.5 text-[13px] leading-relaxed',
'group-[.is-user]:ml-auto group-[.is-user]:overflow-hidden group-[.is-user]:rounded-lg group-[.is-user]:border group-[.is-user]:border-border/50 group-[.is-user]:bg-muted/50 group-[.is-user]:px-2.5 group-[.is-user]:py-2',
'group-[.is-user]:ml-auto group-[.is-user]:overflow-hidden group-[.is-user]:rounded-lg group-[.is-user]:border group-[.is-user]:border-border/50 group-[.is-user]:bg-muted/50 group-[.is-user]:px-2.5 group-[.is-user]:py-[7px]',
'group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground/90',
className,
)}

View File

@@ -233,7 +233,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
{message.content && (
isUser
? <div className="whitespace-pre-wrap break-words text-[13px]">{message.content}</div>
? <div className="whitespace-pre-wrap break-words text-[13px] leading-[1.45]">{message.content}</div>
: <MessageResponse isAnimating={isThisStreaming}>
{message.content}
</MessageResponse>

View File

@@ -0,0 +1,35 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { canSendWithAgent, findEnabledExternalAgent } from './agentSendEligibility';
import type { ExternalAgentConfig } from '../../infrastructure/ai/types';
const agents: ExternalAgentConfig[] = [
{
id: 'enabled-agent',
name: 'Enabled Agent',
command: '/usr/local/bin/enabled-agent',
enabled: true,
},
{
id: 'disabled-agent',
name: 'Disabled Agent',
command: '/usr/local/bin/disabled-agent',
enabled: false,
},
];
test('canSendWithAgent allows Catty and enabled external agents', () => {
assert.equal(canSendWithAgent('catty', agents), true);
assert.equal(canSendWithAgent('enabled-agent', agents), true);
});
test('canSendWithAgent blocks missing or disabled external agents', () => {
assert.equal(canSendWithAgent('disabled-agent', agents), false);
assert.equal(canSendWithAgent('missing-agent', agents), false);
});
test('findEnabledExternalAgent ignores disabled external agents', () => {
assert.equal(findEnabledExternalAgent(agents, 'enabled-agent')?.name, 'Enabled Agent');
assert.equal(findEnabledExternalAgent(agents, 'disabled-agent'), undefined);
});

View File

@@ -0,0 +1,15 @@
import type { ExternalAgentConfig } from "../../infrastructure/ai/types";
export function findEnabledExternalAgent(
agents: ExternalAgentConfig[],
agentId: string,
): ExternalAgentConfig | undefined {
return agents.find((agent) => agent.id === agentId && agent.enabled);
}
export function canSendWithAgent(
agentId: string,
agents: ExternalAgentConfig[],
): boolean {
return agentId === "catty" || Boolean(findEnabledExternalAgent(agents, agentId));
}

View File

@@ -31,6 +31,18 @@ import type { NetcattyBridge, ExecutorContext } from '../../../infrastructure/ai
import { runExternalAgentTurn } from '../../../infrastructure/ai/externalAgentAdapter';
import { runAcpAgentTurn } from '../../../infrastructure/ai/acpAgentAdapter';
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
import {
extractProviderContinuationFromRawChunk,
getOpenAIChatAssistantFieldsForHistoryMessage,
isProviderContinuationForSource,
mergeProviderContinuation,
normalizeProviderContinuationOptions,
withProviderContinuationSource,
type OpenAIChatAssistantFields,
type ProviderContinuation,
type ProviderContinuationOptions,
type ProviderContinuationSource,
} from '../../../infrastructure/ai/providerContinuation';
// -------------------------------------------------------------------
// Stream chunk type interfaces (Issue #13: replace unsafe casts)
@@ -41,12 +53,22 @@ interface TextDeltaChunk {
type: 'text' | 'text-delta';
text?: string;
textDelta?: string;
providerMetadata?: unknown;
}
/** Shape of a reasoning chunk from the Vercel AI SDK fullStream. */
interface ReasoningChunk {
type: 'reasoning' | 'reasoning-start' | 'reasoning-delta';
text?: string;
textDelta?: string;
delta?: string;
providerMetadata?: unknown;
}
/** Shape of a raw provider chunk from the Vercel AI SDK fullStream. */
interface RawChunk {
type: 'raw';
rawValue: unknown;
}
/** Shape of a tool-call chunk from the Vercel AI SDK fullStream. */
@@ -56,6 +78,7 @@ interface ToolCallChunk {
toolName: string;
input?: unknown;
args?: unknown;
providerMetadata?: unknown;
}
/** Shape of a tool-result chunk from the Vercel AI SDK fullStream. */
@@ -105,6 +128,7 @@ type StreamChunk =
| ToolCallChunk
| ToolResultChunk
| ErrorChunk
| RawChunk
| { type: 'reasoning-end' | 'text-start' | 'text-end' | 'start' | 'finish' | 'start-step' | 'finish-step' | 'tool-approval-request' };
/** Shape of the netcatty bridge exposed on `window` (panel-specific subset). */
@@ -119,7 +143,7 @@ export interface PanelBridge extends NetcattyBridge {
cwd?: string,
providerId?: string,
chatSessionId?: string,
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string }>; currentModelId?: string | null; error?: string }>;
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[] }>; currentModelId?: string | null; error?: string }>;
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
aiUserSkillsGetStatus?: () => Promise<{
ok: boolean;
@@ -153,6 +177,23 @@ export interface DefaultTargetSessionHint extends TerminalSessionInfo {
source: 'scope-target' | 'only-connected-in-scope';
}
interface CattyProviderContinuationContext {
source: ProviderContinuationSource;
openAIChatAssistantFields: Array<OpenAIChatAssistantFields | undefined>;
}
type AssistantContentPart =
| { type: 'reasoning'; text: string; providerOptions?: ProviderContinuationOptions }
| { type: 'text'; text: string; providerOptions?: ProviderContinuationOptions }
| { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown; providerOptions?: ProviderContinuationOptions };
function toAssistantModelContent(parts: AssistantContentPart[]): string | AssistantContentPart[] {
if (parts.length === 1 && parts[0].type === 'text' && !parts[0].providerOptions) {
return parts[0].text;
}
return parts;
}
/** Typed accessor for the netcatty bridge on the window object. */
export function getNetcattyBridge(): PanelBridge | undefined {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -251,6 +292,7 @@ export interface UseAIChatStreamingReturn {
signal: AbortSignal,
currentAssistantMsgId: string,
advancedParams?: ProviderAdvancedParams,
continuationContext?: CattyProviderContinuationContext,
) => Promise<void>;
/** Send a message to the Catty agent (built-in). */
sendToCattyAgent: (
@@ -389,6 +431,7 @@ export function useAIChatStreaming({
signal: AbortSignal,
currentAssistantMsgId: string,
advancedParams?: ProviderAdvancedParams,
continuationContext?: CattyProviderContinuationContext,
): Promise<void> => {
const result = streamText({
model,
@@ -397,6 +440,7 @@ export function useAIChatStreaming({
tools,
stopWhen: stepCountIs(maxIterations),
abortSignal: signal,
includeRawChunks: true,
...(advancedParams?.maxTokens != null && { maxOutputTokens: advancedParams.maxTokens }),
...(advancedParams?.temperature != null && { temperature: advancedParams.temperature }),
...(advancedParams?.topP != null && { topP: advancedParams.topP }),
@@ -412,6 +456,42 @@ export function useAIChatStreaming({
// -- Text-delta batching: accumulate deltas and flush periodically --
let pendingText = '';
let rafId: number | null = null;
const ensureAssistantMessage = (): string => {
if (lastAddedRole !== 'tool') return activeMsgId;
const newId = generateId();
addMessageToSession(streamSessionId, {
id: newId,
role: 'assistant',
content: '',
timestamp: Date.now(),
});
activeMsgId = newId;
lastAddedRole = 'assistant';
return activeMsgId;
};
const updateAssistantContinuation = (
messageId: string,
continuation: ProviderContinuation | undefined,
thinkingText = '',
) => {
if (!continuation && !thinkingText) return;
const sourcedContinuation = withProviderContinuationSource(continuation, continuationContext?.source);
updateMessageById(streamSessionId, messageId, msg => {
const providerContinuation = mergeProviderContinuation(msg.providerContinuation, sourcedContinuation);
return {
...msg,
...(providerContinuation ? { providerContinuation } : {}),
...(thinkingText ? { thinking: (msg.thinking || '') + thinkingText } : {}),
};
});
};
const getOpenAIReasoningText = (continuation: ProviderContinuation | undefined): string => {
const reasoningContent = continuation?.openAIChatAssistantFields?.reasoning_content;
return typeof reasoningContent === 'string' ? reasoningContent : '';
};
const flushText = () => {
if (pendingText) {
@@ -455,6 +535,11 @@ export function useAIChatStreaming({
case 'text-delta': {
const typedChunk = chunk as TextDeltaChunk;
const text = typedChunk.text ?? typedChunk.textDelta;
const providerOptions = normalizeProviderContinuationOptions(typedChunk.providerMetadata);
if (providerOptions) {
const messageId = ensureAssistantMessage();
updateAssistantContinuation(messageId, { textProviderOptions: providerOptions });
}
if (text) {
pendingText += text;
if (rafId === null) {
@@ -469,25 +554,30 @@ export function useAIChatStreaming({
cancelPendingFlush();
flushText();
const typedChunk = chunk as ReasoningChunk;
const rText = typedChunk.text;
if (rText) {
if (lastAddedRole === 'tool') {
const newId = generateId();
addMessageToSession(streamSessionId, {
id: newId,
role: 'assistant',
content: '',
thinking: rText,
timestamp: Date.now(),
});
activeMsgId = newId;
lastAddedRole = 'assistant';
} else {
updateMessageById(streamSessionId, activeMsgId, msg => ({
...msg,
thinking: (msg.thinking || '') + rText,
}));
}
const rText = typedChunk.text ?? typedChunk.textDelta ?? typedChunk.delta ?? '';
const providerOptions = normalizeProviderContinuationOptions(typedChunk.providerMetadata);
const continuation = rText || providerOptions
? {
reasoningParts: [{
text: rText,
...(providerOptions ? { providerOptions } : {}),
}],
} satisfies ProviderContinuation
: undefined;
if (continuation || rText) {
const messageId = ensureAssistantMessage();
updateAssistantContinuation(messageId, continuation, rText);
}
break;
}
case 'raw': {
const typedChunk = chunk as RawChunk;
const continuation = extractProviderContinuationFromRawChunk(typedChunk.rawValue);
if (continuation) {
cancelPendingFlush();
flushText();
const messageId = ensureAssistantMessage();
updateAssistantContinuation(messageId, continuation, getOpenAIReasoningText(continuation));
}
break;
}
@@ -503,7 +593,9 @@ export function useAIChatStreaming({
cancelPendingFlush();
flushText();
const typedChunk = chunk as ToolCallChunk;
updateMessageById(streamSessionId, activeMsgId, msg => ({
const messageId = ensureAssistantMessage();
const providerOptions = normalizeProviderContinuationOptions(typedChunk.providerMetadata);
updateMessageById(streamSessionId, messageId, msg => ({
...msg,
toolCalls: [...(msg.toolCalls || []), {
id: typedChunk.toolCallId,
@@ -513,6 +605,13 @@ export function useAIChatStreaming({
executionStatus: 'running',
statusText: undefined,
}));
if (providerOptions) {
updateAssistantContinuation(messageId, {
toolCallProviderOptionsById: {
[typedChunk.toolCallId]: providerOptions,
},
});
}
break;
}
case 'tool-result': {
@@ -778,20 +877,15 @@ export function useAIChatStreaming({
return;
}
// Create model with placeholder API key — the main process injects the real
// decrypted key when the HTTP request is proxied through IPC, so plaintext
// keys never transit the renderer ↔ main IPC boundary.
let model;
try {
model = createModelFromConfig({
...context.activeProvider,
defaultModel: context.activeModelId || context.activeProvider.defaultModel || '',
});
} catch (e) {
console.error('[Catty] Model creation failed:', e);
reportStreamError(sessionId, abortController.signal, `Model creation failed: ${e instanceof Error ? e.message : String(e)}`);
return;
}
const activeModelId = context.activeModelId || context.activeProvider.defaultModel || '';
const continuationContext: CattyProviderContinuationContext = {
source: {
providerConfigId: context.activeProvider.id,
providerType: context.activeProvider.providerId,
modelId: activeModelId,
},
openAIChatAssistantFields: [],
};
try {
// Issue #5: Build SDK messages including tool-call and tool-result messages
@@ -818,7 +912,9 @@ export function useAIChatStreaming({
};
const sdkMessages: Array<ModelMessage> = [];
let previousHistoryMessageWasToolResult = false;
for (const m of allMessages) {
const currentMessageFollowsToolResult = previousHistoryMessageWasToolResult;
if (m.role === 'user') {
// Build multimodal content when attachments are present (fallback to legacy `images` field)
const messageAttachments = m.attachments ?? m.images;
@@ -837,30 +933,76 @@ export function useAIChatStreaming({
sdkMessages.push({ role: 'user', content: m.content });
}
} else if (m.role === 'assistant') {
const activeContinuation = isProviderContinuationForSource(
m.providerContinuation,
continuationContext.source,
)
? m.providerContinuation
: undefined;
const openAIChatAssistantFields = getOpenAIChatAssistantFieldsForHistoryMessage(
m,
continuationContext.source,
);
if (m.toolCalls?.length) {
// Only include tool calls that have matching results
const resolvedCalls = m.toolCalls.filter(tc => resolvedToolCallIds.has(tc.id));
const contentParts: Array<
{ type: 'text'; text: string } |
{ type: 'tool-call'; toolCallId: string; toolName: string; input: unknown }
> = [];
const contentParts: AssistantContentPart[] = [];
if (resolvedCalls.length > 0) {
for (const part of activeContinuation?.reasoningParts ?? []) {
if (!part.text && !part.providerOptions) continue;
contentParts.push({
type: 'reasoning' as const,
text: part.text,
...(part.providerOptions ? { providerOptions: part.providerOptions } : {}),
});
}
}
if (m.content) {
contentParts.push({ type: 'text' as const, text: m.content });
contentParts.push({
type: 'text' as const,
text: m.content,
...(activeContinuation?.textProviderOptions ? { providerOptions: activeContinuation.textProviderOptions } : {}),
});
}
for (const tc of resolvedCalls) {
const providerOptions = activeContinuation?.toolCallProviderOptionsById?.[tc.id];
contentParts.push({
type: 'tool-call' as const,
toolCallId: tc.id,
toolName: tc.name,
input: tc.arguments ?? {},
...(providerOptions ? { providerOptions } : {}),
});
}
// If all tool calls were orphaned, just include the text content
if (contentParts.length > 0) {
sdkMessages.push({ role: 'assistant', content: contentParts.length === 1 && contentParts[0].type === 'text' ? (contentParts[0] as { type: 'text'; text: string }).text : contentParts });
sdkMessages.push({ role: 'assistant', content: toAssistantModelContent(contentParts) });
if (resolvedCalls.length > 0) {
continuationContext.openAIChatAssistantFields.push(openAIChatAssistantFields);
}
}
} else if (m.content) {
sdkMessages.push({ role: 'assistant', content: m.content });
const contentParts: AssistantContentPart[] = [];
for (const part of activeContinuation?.reasoningParts ?? []) {
if (!part.text && !part.providerOptions) continue;
contentParts.push({
type: 'reasoning' as const,
text: part.text,
...(part.providerOptions ? { providerOptions: part.providerOptions } : {}),
});
}
contentParts.push({
type: 'text' as const,
text: m.content,
...(activeContinuation?.textProviderOptions ? { providerOptions: activeContinuation.textProviderOptions } : {}),
});
sdkMessages.push({
role: 'assistant',
content: toAssistantModelContent(contentParts),
});
if (currentMessageFollowsToolResult) {
continuationContext.openAIChatAssistantFields.push(openAIChatAssistantFields);
}
}
} else if (m.role === 'tool' && m.toolResults?.length) {
sdkMessages.push({
@@ -873,6 +1015,7 @@ export function useAIChatStreaming({
})),
});
}
previousHistoryMessageWasToolResult = m.role === 'tool' && !!m.toolResults?.length;
}
// Build the current user message — include attachments as multimodal content
if (attachments?.length) {
@@ -890,7 +1033,37 @@ export function useAIChatStreaming({
sdkMessages.push({ role: 'user', content: trimmed });
}
await processCattyStream(sessionId, model, systemPrompt, tools, sdkMessages, abortController.signal, assistantMsgId, context.activeProvider?.advancedParams);
// Create model with placeholder API key — the main process injects the real
// decrypted key when the HTTP request is proxied through IPC, so plaintext
// keys never transit the renderer ↔ main IPC boundary.
let model;
try {
model = createModelFromConfig(
{
...context.activeProvider,
defaultModel: activeModelId,
},
{
getOpenAIChatAssistantFields: () => continuationContext.openAIChatAssistantFields,
},
);
} catch (e) {
console.error('[Catty] Model creation failed:', e);
reportStreamError(sessionId, abortController.signal, `Model creation failed: ${e instanceof Error ? e.message : String(e)}`);
return;
}
await processCattyStream(
sessionId,
model,
systemPrompt,
tools,
sdkMessages,
abortController.signal,
assistantMsgId,
context.activeProvider?.advancedParams,
continuationContext,
);
} catch (err) {
console.error('[Catty] streamText error:', err);
reportStreamError(sessionId, abortController.signal, err);

View File

@@ -0,0 +1,119 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildManagedAgentState } from '../settings/tabs/ai/managedAgentState';
import type { ExternalAgentConfig } from '../../infrastructure/ai/types';
test('buildManagedAgentState removes stale managed agents when path detection fails', () => {
const agents: ExternalAgentConfig[] = [
{
id: 'discovered_codex',
name: 'Codex CLI',
command: '/usr/local/bin/codex',
enabled: true,
acpCommand: 'codex-acp',
acpArgs: [],
},
{
id: 'custom-agent',
name: 'Custom Agent',
command: '/usr/local/bin/custom-agent',
enabled: true,
},
];
const state = buildManagedAgentState(
agents,
'discovered_codex',
'codex',
{ path: '/usr/local/bin/codex', version: null, available: false },
);
assert.deepEqual(
state.agents.map((agent) => agent.id),
['custom-agent'],
);
assert.equal(state.defaultAgentId, 'catty');
});
test('buildManagedAgentState keeps unrelated defaults when removing stale managed agents', () => {
const agents: ExternalAgentConfig[] = [
{
id: 'discovered_claude',
name: 'Claude Code',
command: '/usr/local/bin/claude',
enabled: true,
acpCommand: 'claude-agent-acp',
acpArgs: [],
},
{
id: 'custom-agent',
name: 'Custom Agent',
command: '/usr/local/bin/custom-agent',
enabled: true,
},
];
const state = buildManagedAgentState(
agents,
'custom-agent',
'claude',
{ path: '/usr/local/bin/claude', version: null, available: false },
);
assert.deepEqual(
state.agents.map((agent) => agent.id),
['custom-agent'],
);
assert.equal(state.defaultAgentId, 'custom-agent');
});
test('buildManagedAgentState does not remove user-created matching agents', () => {
const agents: ExternalAgentConfig[] = [
{
id: 'my-claude-wrapper',
name: 'My Claude Wrapper',
command: '/usr/local/bin/claude',
enabled: true,
acpCommand: 'claude-agent-acp',
acpArgs: [],
},
];
const state = buildManagedAgentState(
agents,
'my-claude-wrapper',
'claude',
{ path: '/usr/local/bin/claude', version: null, available: false },
);
assert.deepEqual(state.agents, agents);
assert.equal(state.defaultAgentId, 'my-claude-wrapper');
});
test('buildManagedAgentState only rewrites settings-managed discovered agents', () => {
const agents: ExternalAgentConfig[] = [
{
id: 'my-codex-wrapper',
name: 'My Codex Wrapper',
command: '/usr/local/bin/codex',
enabled: true,
acpCommand: 'codex-acp',
acpArgs: [],
},
];
const state = buildManagedAgentState(
agents,
'my-codex-wrapper',
'codex',
{ path: '/opt/netcatty/codex-acp', version: 'Bundled ACP', available: true },
);
assert.deepEqual(
state.agents.map((agent) => agent.id),
['my-codex-wrapper', 'discovered_codex'],
);
assert.equal(state.agents[0], agents[0]);
assert.equal(state.defaultAgentId, 'my-codex-wrapper');
});

View File

@@ -0,0 +1,37 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {
canPromoteTextEditor,
isTextEditorReadOnly,
TextEditorPromoteButton,
} from "./TextEditorPane.tsx";
test("disables promoting a modal editor to a tab while a save is running", () => {
assert.equal(canPromoteTextEditor({ saving: true }), false);
assert.equal(canPromoteTextEditor({ saving: false }), true);
assert.equal(isTextEditorReadOnly({ saving: true }), true);
assert.equal(isTextEditorReadOnly({ saving: false }), false);
});
test("renders the promote button disabled while a save is running", () => {
const savingMarkup = renderToStaticMarkup(
React.createElement(TextEditorPromoteButton, {
saving: true,
onPromoteToTab: () => {},
title: "Maximize",
}),
);
const idleMarkup = renderToStaticMarkup(
React.createElement(TextEditorPromoteButton, {
saving: false,
onPromoteToTab: () => {},
title: "Maximize",
}),
);
assert.match(savingMarkup, /disabled=""/);
assert.doesNotMatch(idleMarkup, /disabled=""/);
});

View File

@@ -16,9 +16,10 @@ import type * as Monaco from 'monaco-editor';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
// Configure Monaco to use local files instead of CDN
const monacoBasePath = import.meta.env.DEV
const viteEnv = import.meta.env ?? { BASE_URL: "/" };
const monacoBasePath = viteEnv.DEV
? './node_modules/monaco-editor/min/vs'
: `${import.meta.env.BASE_URL}monaco/vs`;
: `${viteEnv.BASE_URL}monaco/vs`;
loader.config({ paths: { vs: monacoBasePath } });
import { useI18n } from '../../application/i18n/I18nProvider';
@@ -116,6 +117,9 @@ const hslToHex = (hslString: string): string => {
// Read a CSS custom-property and convert from HSL to hex
const getCssColor = (varName: string, fallback: string): string => {
if (typeof document === 'undefined' || typeof getComputedStyle === 'undefined') {
return fallback;
}
const value = getComputedStyle(document.documentElement)
.getPropertyValue(varName)
.trim();
@@ -143,6 +147,9 @@ const getEditorColors = (isDark: boolean): EditorColors => ({
/** Build a fingerprint string so we can detect immersive-mode color changes cheaply. */
const getThemeSignal = (): string => {
if (typeof document === 'undefined' || typeof getComputedStyle === 'undefined') {
return '';
}
const root = document.documentElement;
return root.dataset.immersiveTheme
?? getComputedStyle(root).getPropertyValue('--background').trim();
@@ -170,6 +177,27 @@ export interface TextEditorPaneProps {
initialViewState?: Monaco.editor.ICodeEditorViewState | null;
}
export const isTextEditorReadOnly = ({ saving }: { saving: boolean }): boolean => saving;
export const canPromoteTextEditor = ({ saving }: { saving: boolean }): boolean => !saving;
export const TextEditorPromoteButton: React.FC<{
saving: boolean;
onPromoteToTab: () => void;
title: string;
}> = ({ saving, onPromoteToTab, title }) => (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onPromoteToTab}
disabled={!canPromoteTextEditor({ saving })}
title={title}
>
<Maximize2 size={14} />
</Button>
);
export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
fileName,
content,
@@ -202,7 +230,7 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
// Track theme from document.documentElement class (syncs with app theme)
const [isDarkTheme, setIsDarkTheme] = useState(() =>
document.documentElement.classList.contains('dark')
typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
);
// Track a signal that changes whenever immersive-mode or base theme colors change
@@ -253,6 +281,7 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
// Listen for theme changes via MutationObserver on <html> class, style, and immersive data attr
useEffect(() => {
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return;
const root = document.documentElement;
const updateTheme = () => {
setIsDarkTheme(root.classList.contains('dark'));
@@ -309,6 +338,7 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
}, [readClipboardText]);
const handlePaste = useCallback(async () => {
if (saving) return;
const editor = editorRef.current;
if (!editor) return;
@@ -337,16 +367,17 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
})),
);
editor.focus();
}, [readClipboardText]);
}, [readClipboardText, saving]);
useEffect(() => {
handlePasteRef.current = handlePaste;
}, [handlePaste]);
const handleEditorChange = useCallback((value: string | undefined) => {
if (saving) return;
const editor = editorRef.current;
onContentChange(value ?? '', editor ? editor.saveViewState() : null);
}, [onContentChange]);
}, [onContentChange, saving]);
const handleEditorMount: OnMount = useCallback((editor, monaco) => {
editorRef.current = editor;
@@ -504,15 +535,11 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
{/* Maximize button — modal chrome only, when onPromoteToTab is provided */}
{chrome === 'modal' && onPromoteToTab && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onPromoteToTab}
<TextEditorPromoteButton
saving={saving}
onPromoteToTab={onPromoteToTab}
title={t('sftp.editor.maximize')}
>
<Maximize2 size={14} />
</Button>
/>
)}
{/* Close button — modal chrome only */}
@@ -556,6 +583,8 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
tabSize: 2,
insertSpaces: true,
wordWrap: wordWrap ? 'on' : 'off',
readOnly: isTextEditorReadOnly({ saving }),
domReadOnly: isTextEditorReadOnly({ saving }),
folding: true,
renderWhitespace: 'selection',
bracketPairColorization: { enabled: true },

View File

@@ -8,7 +8,7 @@ import type * as Monaco from 'monaco-editor';
import React, { useCallback } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { editorSftpWrite } from '../../application/state/editorSftpBridge';
import { saveEditorTab } from '../../application/state/editorTabSave';
import { editorTabStore, useEditorTab, type EditorTabId } from '../../application/state/editorTabStore';
import type { HotkeyScheme, KeyBinding } from '../../domain/models';
import type { Host } from '../../types';
@@ -60,21 +60,11 @@ export const TextEditorTabView: React.FC<TextEditorTabViewProps> = ({
}, [tabId]);
const handleSave = useCallback(async () => {
// Read live store state at call time — React state snapshot lags the store
// by one microtask, so a keystroke between onChange and this save would
// otherwise leave us writing stale content and marking a stale baseline.
const current = editorTabStore.getTab(tabId);
if (!current) return;
if (current.savingState === 'saving') return;
editorTabStore.setSavingState(tabId, 'saving');
try {
await editorSftpWrite(current.sessionId, current.hostId, current.remotePath, current.content);
editorTabStore.markSaved(tabId, current.content);
const ok = await saveEditorTab(tabId);
if (ok) {
toast.success(t('sftp.editor.saved'), 'SFTP');
} catch (e) {
const msg = e instanceof Error ? e.message : t('sftp.editor.saveFailed');
editorTabStore.setSavingState(tabId, 'error', msg);
} else {
const msg = editorTabStore.getTab(tabId)?.saveError ?? t('sftp.editor.saveFailed');
toast.error(msg, 'SFTP');
}
}, [tabId, t]);

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

@@ -17,11 +17,7 @@ import type {
ProviderConfig,
WebSearchConfig,
} from "../../../infrastructure/ai/types";
import {
getManagedAgentStoredPath,
matchesManagedAgentConfig,
type ManagedAgentKey,
} from "../../../infrastructure/ai/managedAgents";
import type { ManagedAgentKey } from "../../../infrastructure/ai/managedAgents";
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { TabsContent } from "../../ui/tabs";
@@ -36,7 +32,6 @@ import type {
UserSkillsStatusResult,
} from "./ai/types";
import {
AGENT_DEFAULTS,
getBridge,
normalizeCodexBridgeError,
} from "./ai/types";
@@ -48,6 +43,11 @@ import { ClaudeCodeCard } from "./ai/ClaudeCodeCard";
import { CopilotCliCard } from "./ai/CopilotCliCard";
import { SafetySettings } from "./ai/SafetySettings";
import { WebSearchSettings } from "./ai/WebSearchSettings";
import {
areExternalAgentListsEqual,
buildManagedAgentState,
getInitialManagedAgentPaths,
} from "./ai/managedAgentState";
// ---------------------------------------------------------------------------
// Props
@@ -80,54 +80,6 @@ interface SettingsAITabProps {
setWebSearchConfig: (config: WebSearchConfig | null) => void;
}
function areExternalAgentListsEqual(
left: ExternalAgentConfig[],
right: ExternalAgentConfig[],
): boolean {
if (left.length !== right.length) return false;
return left.every((agent, index) => JSON.stringify(agent) === JSON.stringify(right[index]));
}
function buildManagedAgentState(
prevAgents: ExternalAgentConfig[],
defaultAgentId: string,
agentKey: ManagedAgentKey,
pathInfo: AgentPathInfo | null,
): { agents: ExternalAgentConfig[]; defaultAgentId: string } {
const managedId = `discovered_${agentKey}`;
const managedAgents = prevAgents.filter((agent) => matchesManagedAgentConfig(agent, agentKey));
const otherAgents = prevAgents.filter((agent) => !matchesManagedAgentConfig(agent, agentKey));
const storedPath = getManagedAgentStoredPath(prevAgents, agentKey);
if (!pathInfo?.available || !pathInfo.path) {
return {
agents: storedPath ? prevAgents : otherAgents,
defaultAgentId: storedPath
? defaultAgentId
: managedAgents.some((agent) => agent.id === defaultAgentId)
? "catty"
: defaultAgentId,
};
}
const existingManaged = managedAgents.find((agent) => agent.id === managedId);
const defaults = AGENT_DEFAULTS[agentKey];
const nextManagedAgent: ExternalAgentConfig = {
...existingManaged,
...defaults,
id: managedId,
command: pathInfo.path,
enabled: managedAgents.length === 0 ? true : managedAgents.some((agent) => agent.enabled),
};
return {
agents: [...otherAgents, nextManagedAgent],
defaultAgentId: managedAgents.some((agent) => agent.id === defaultAgentId)
? managedId
: defaultAgentId,
};
}
// ---------------------------------------------------------------------------
// Main Tab Component
// ---------------------------------------------------------------------------
@@ -179,11 +131,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
copilot: string;
} | null>(null);
if (!initialManagedPathsRef.current) {
initialManagedPathsRef.current = {
codex: getManagedAgentStoredPath(externalAgents, "codex") ?? "",
claude: getManagedAgentStoredPath(externalAgents, "claude") ?? "",
copilot: getManagedAgentStoredPath(externalAgents, "copilot") ?? "",
};
initialManagedPathsRef.current = getInitialManagedAgentPaths(externalAgents);
}
const [copilotPathInfo, setCopilotPathInfo] = useState<AgentPathInfo | null>(null);

View File

@@ -1,7 +1,12 @@
import React, { useCallback } from "react";
import type { PortForwardingRule } from "../../../domain/models";
import type { SyncPayload } from "../../../domain/sync";
import { buildSyncPayload, applySyncPayload } from "../../../application/syncPayload";
import {
applyLocalVaultPayload,
buildLocalVaultPayload,
buildSyncPayload,
applySyncPayload,
} from "../../../application/syncPayload";
import { applyProtectedSyncPayload } from "../../../application/localVaultBackups";
import type { SyncableVaultData } from "../../../application/syncPayload";
import { useI18n } from "../../../application/i18n/I18nProvider";
@@ -14,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;
@@ -29,7 +34,7 @@ export default function SettingsSyncTab(props: {
} = props;
const { t } = useI18n();
const onBuildPayload = useCallback((): SyncPayload => {
const getEffectivePortForwardingRules = useCallback((): PortForwardingRule[] => {
// If hook state is empty but localStorage has data, the async store
// initialization hasn't finished yet. Read from localStorage directly
// to avoid uploading empty arrays and overwriting the remote snapshot.
@@ -51,15 +56,26 @@ export default function SettingsSyncTab(props: {
}
}
return effectiveRules;
}, [portForwardingRules]);
const onBuildPayload = useCallback((): SyncPayload => {
return buildSyncPayload(vault, getEffectivePortForwardingRules());
}, [vault, getEffectivePortForwardingRules]);
const onBuildLocalPayload = useCallback((): SyncPayload => {
const effectiveKnownHosts = getEffectiveKnownHosts(vault.knownHosts);
return buildSyncPayload({ ...vault, knownHosts: effectiveKnownHosts }, effectiveRules);
}, [vault, portForwardingRules]);
return buildLocalVaultPayload(
{ ...vault, knownHosts: effectiveKnownHosts ?? [] },
getEffectivePortForwardingRules(),
);
}, [vault, getEffectivePortForwardingRules]);
const onApplyPayload = useCallback(
(payload: SyncPayload) =>
applyProtectedSyncPayload({
buildPreApplyPayload: onBuildPayload,
buildPreApplyPayload: onBuildLocalPayload,
applyPayload: () =>
applySyncPayload(payload, {
importVaultData: importDataFromString,
@@ -69,7 +85,23 @@ export default function SettingsSyncTab(props: {
translateProtectiveBackupFailure: (message) =>
t("cloudSync.localBackups.protectiveBackupFailed", { message }),
}),
[importDataFromString, importPortForwardingRules, onBuildPayload, onSettingsApplied, t],
[importDataFromString, importPortForwardingRules, onBuildLocalPayload, onSettingsApplied, t],
);
const onApplyLocalPayload = useCallback(
(payload: SyncPayload) =>
applyProtectedSyncPayload({
buildPreApplyPayload: onBuildLocalPayload,
applyPayload: () =>
applyLocalVaultPayload(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied,
}),
translateProtectiveBackupFailure: (message) =>
t("cloudSync.localBackups.protectiveBackupFailed", { message }),
}),
[importDataFromString, importPortForwardingRules, onBuildLocalPayload, onSettingsApplied, t],
);
const clearAllLocalData = useCallback(() => {
@@ -82,6 +114,7 @@ export default function SettingsSyncTab(props: {
<CloudSyncSettings
onBuildPayload={onBuildPayload}
onApplyPayload={onApplyPayload}
onApplyLocalPayload={onApplyLocalPayload}
onClearLocalData={clearAllLocalData}
/>
</SettingsTabContent>

View File

@@ -300,16 +300,6 @@ export default function SettingsTerminalTab(props: {
const [shellValidation, setShellValidation] = useState<{ valid: boolean; message?: string } | null>(null);
const [dirValidation, setDirValidation] = useState<{ valid: boolean; message?: string } | null>(null);
// Mosh settings state
const [moshValidation, setMoshValidation] = useState<{ valid: boolean; message?: string } | null>(null);
const [moshDetectStatus, setMoshDetectStatus] = useState<
| { kind: "idle" }
| { kind: "running" }
| { kind: "found"; path: string }
| { kind: "not-found"; searchedPaths: string[] }
>({ kind: "idle" });
const [autoDetectedMoshPath, setAutoDetectedMoshPath] = useState<string | null>(null);
const discoveredShells = useDiscoveredShells();
const [showCustomShellInput, setShowCustomShellInput] = useState(() => {
if (!terminalSettings.localShell) return false;
@@ -465,109 +455,6 @@ export default function SettingsTerminalTab(props: {
return () => clearTimeout(timeoutId);
}, [terminalSettings.localShell, discoveredShells, t]);
// Validate mosh client path when it changes (debounced)
useEffect(() => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
const moshPath = terminalSettings.moshClientPath;
if (!moshPath) {
setMoshValidation(null);
return;
}
// The shared validatePath bridge resolves bare names through PATH (good
// for localShell where "powershell.exe" is a valid choice), but
// startMoshSession treats moshClientPath as a literal filesystem path —
// so any non-absolute entry would look valid here yet fail at connect
// time. Gate on absolute paths first; accept ~ since the main process
// will expand it. Tolerant across platforms so e.g. a user pasting a
// Windows-style absolute path on macOS still gets a real error
// downstream rather than a misleading "not absolute".
const looksAbsolute =
moshPath.startsWith("/") ||
moshPath.startsWith("~") ||
/^[a-zA-Z]:[\\/]/.test(moshPath) ||
moshPath.startsWith("\\\\");
if (!looksAbsolute) {
setMoshValidation({ valid: false, message: t("settings.terminal.mosh.client.notAbsolute") });
return;
}
if (!bridge?.validatePath) {
setMoshValidation(null);
return;
}
const timeoutId = setTimeout(() => {
bridge.validatePath(moshPath, "file").then((result) => {
if (result.exists && result.isFile && !result.isExecutable) {
// Stays consistent with startMoshSession's isExecutableFile check —
// a regular file without the execute bit can't actually launch.
setMoshValidation({ valid: false, message: t("settings.terminal.mosh.client.notExecutable") });
} else if (result.exists && result.isFile) {
setMoshValidation({ valid: true });
} else if (result.exists && result.isDirectory) {
setMoshValidation({ valid: false, message: t("settings.terminal.mosh.client.isDirectory") });
} else {
setMoshValidation({ valid: false, message: t("settings.terminal.mosh.client.notFound") });
}
}).catch(() => {
setMoshValidation(null);
});
}, 300);
return () => clearTimeout(timeoutId);
}, [terminalSettings.moshClientPath, t]);
useEffect(() => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (!bridge?.detectMoshClient) return;
let canceled = false;
bridge.detectMoshClient()
.then((result) => {
if (!canceled) {
setAutoDetectedMoshPath(result.found && result.path ? result.path : null);
}
})
.catch(() => {
if (!canceled) setAutoDetectedMoshPath(null);
});
return () => {
canceled = true;
};
}, []);
const handleDetectMosh = useCallback(async () => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (!bridge?.detectMoshClient) return;
setMoshDetectStatus({ kind: "running" });
try {
const result = await bridge.detectMoshClient();
if (result.found && result.path) {
setMoshDetectStatus({ kind: "found", path: result.path });
// Auto-fill the input only when it is empty so we don't override
// a value the user is in the middle of editing.
if (!terminalSettings.moshClientPath) {
updateTerminalSetting("moshClientPath", result.path);
}
} else {
setMoshDetectStatus({ kind: "not-found", searchedPaths: result.searchedPaths });
}
} catch (err) {
console.error("[Settings] detectMoshClient failed:", err);
setMoshDetectStatus({ kind: "not-found", searchedPaths: [] });
}
}, [terminalSettings.moshClientPath, updateTerminalSetting]);
const handleBrowseMosh = useCallback(async () => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (!bridge?.pickMoshClient) return;
try {
const result = await bridge.pickMoshClient();
if (!result.canceled && result.filePath) {
updateTerminalSetting("moshClientPath", result.filePath);
setMoshDetectStatus({ kind: "idle" });
}
} catch (err) {
console.error("[Settings] pickMoshClient failed:", err);
}
}, [updateTerminalSetting]);
// Validate directory path when it changes
useEffect(() => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
@@ -1158,74 +1045,6 @@ export default function SettingsTerminalTab(props: {
className="w-48"
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.mosh.client")}
description={t("settings.terminal.mosh.client.desc")}
>
<div className="flex max-w-full flex-col gap-1.5" style={{ width: "min(420px, 100%)" }}>
<div className="grid grid-cols-[minmax(220px,1fr)_auto_auto] gap-2">
<Input
value={terminalSettings.moshClientPath}
placeholder={t("settings.terminal.mosh.client.placeholder")}
onChange={(e) => updateTerminalSetting("moshClientPath", e.target.value)}
className={cn(
"flex-1",
moshValidation && !moshValidation.valid && "border-destructive focus-visible:ring-destructive",
)}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleDetectMosh}
disabled={moshDetectStatus.kind === "running"}
>
{t("settings.terminal.mosh.detect")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleBrowseMosh}
>
{t("settings.terminal.mosh.browse")}
</Button>
</div>
{!terminalSettings.moshClientPath && autoDetectedMoshPath && moshDetectStatus.kind !== "found" && (
<span className="text-xs text-muted-foreground">
{t("settings.terminal.mosh.autoDetected")}: <span className="break-all font-mono">{autoDetectedMoshPath}</span>
</span>
)}
{moshValidation && !moshValidation.valid && moshValidation.message && (
<span className="text-xs text-destructive flex items-center gap-1">
<AlertCircle size={12} />
{moshValidation.message}
</span>
)}
{moshDetectStatus.kind === "found" && (
<span className="text-xs text-muted-foreground">
{t("settings.terminal.mosh.detected")}: <span className="break-all font-mono">{moshDetectStatus.path}</span>
</span>
)}
{moshDetectStatus.kind === "not-found" && (
<span className="text-xs text-destructive flex items-start gap-1">
<AlertCircle size={12} className="mt-0.5 shrink-0" />
<span>
{t("settings.terminal.mosh.notDetected")}
{moshDetectStatus.searchedPaths.length > 0 && (
<>
{" "}
<span className="text-muted-foreground">
({moshDetectStatus.searchedPaths.slice(0, 4).join(", ")}
{moshDetectStatus.searchedPaths.length > 4 ? "…" : ""})
</span>
</>
)}
</span>
</span>
)}
</div>
</SettingRow>
</div>
<SectionHeader title={t("settings.terminal.section.serverStats")} />

View File

@@ -0,0 +1,72 @@
import type { ExternalAgentConfig } from "../../../../infrastructure/ai/types";
import {
type ManagedAgentKey,
} from "../../../../infrastructure/ai/managedAgents";
import type { AgentPathInfo } from "./types";
import { AGENT_DEFAULTS } from "./types";
function isPathLikeCommand(command: string | undefined): boolean {
const normalized = String(command || "").trim();
return normalized.includes("/") || normalized.includes("\\");
}
function getAutoManagedAgentStoredPath(
agents: ExternalAgentConfig[],
agentKey: ManagedAgentKey,
): string | null {
const managed = agents.find((agent) => agent.id === `discovered_${agentKey}`);
return isPathLikeCommand(managed?.command) ? managed?.command ?? null : null;
}
export function areExternalAgentListsEqual(
left: ExternalAgentConfig[],
right: ExternalAgentConfig[],
): boolean {
if (left.length !== right.length) return false;
return left.every((agent, index) => JSON.stringify(agent) === JSON.stringify(right[index]));
}
export function buildManagedAgentState(
prevAgents: ExternalAgentConfig[],
defaultAgentId: string,
agentKey: ManagedAgentKey,
pathInfo: AgentPathInfo | null,
): { agents: ExternalAgentConfig[]; defaultAgentId: string } {
const managedId = `discovered_${agentKey}`;
const managedAgents = prevAgents.filter((agent) => agent.id === managedId);
const otherAgents = prevAgents.filter((agent) => agent.id !== managedId);
if (!pathInfo?.available || !pathInfo.path) {
return {
agents: otherAgents,
defaultAgentId: managedAgents.some((agent) => agent.id === defaultAgentId)
? "catty"
: defaultAgentId,
};
}
const existingManaged = managedAgents.find((agent) => agent.id === managedId);
const defaults = AGENT_DEFAULTS[agentKey];
const nextManagedAgent: ExternalAgentConfig = {
...existingManaged,
...defaults,
id: managedId,
command: pathInfo.path,
enabled: managedAgents.length === 0 ? true : managedAgents.some((agent) => agent.enabled),
};
return {
agents: [...otherAgents, nextManagedAgent],
defaultAgentId: managedAgents.some((agent) => agent.id === defaultAgentId)
? managedId
: defaultAgentId,
};
}
export function getInitialManagedAgentPaths(agents: ExternalAgentConfig[]) {
return {
codex: getAutoManagedAgentStoredPath(agents, "codex") ?? "",
claude: getAutoManagedAgentStoredPath(agents, "claude") ?? "",
copilot: getAutoManagedAgentStoredPath(agents, "copilot") ?? "",
};
}

View File

@@ -7,12 +7,16 @@ import React, { memo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import type { FileConflictAction } from '../../domain/models';
interface ConflictItem {
transferId: string;
fileName: string;
sourcePath: string;
targetPath: string;
isDirectory: boolean;
existingType?: 'file' | 'directory' | 'symlink';
applyToAllCount?: number;
existingSize: number;
newSize: number;
existingModified: number;
@@ -21,7 +25,7 @@ interface ConflictItem {
interface SftpConflictDialogProps {
conflicts: ConflictItem[];
onResolve: (conflictId: string, action: 'replace' | 'skip' | 'duplicate') => void;
onResolve: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => void;
formatFileSize: (size: number) => string;
}
@@ -36,13 +40,14 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
return new Date(timestamp).toLocaleString();
};
const handleAction = (action: 'replace' | 'skip' | 'duplicate') => {
if (applyToAll) {
// Apply to all conflicts
conflicts.forEach(c => onResolve(c.transferId, action));
} else {
onResolve(conflict.transferId, action);
}
const sameTypeConflictCount = Math.max(
conflict.applyToAllCount ?? 1,
conflicts.filter((item) => item.isDirectory === conflict.isDirectory).length,
);
const canMerge = conflict.isDirectory && conflict.existingType === 'directory';
const handleAction = (action: FileConflictAction) => {
onResolve(conflict.transferId, action, applyToAll);
setApplyToAll(false);
};
@@ -95,7 +100,7 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
</div>
</div>
{conflicts.length > 1 && (
{sameTypeConflictCount > 1 && (
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
@@ -103,12 +108,19 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
onChange={(e) => setApplyToAll(e.target.checked)}
className="rounded border-border"
/>
{t('sftp.conflict.applyToAll', { count: conflicts.length })}
{t('sftp.conflict.applyToAll', { count: sameTypeConflictCount })}
</label>
)}
</div>
<DialogFooter className="flex gap-2">
<DialogFooter className="flex flex-wrap gap-2 sm:justify-end sm:space-x-0">
<Button
variant="destructive"
onClick={() => handleAction('stop')}
className="flex-1"
>
{t('sftp.conflict.action.stop')}
</Button>
<Button
variant="outline"
onClick={() => handleAction('skip')}
@@ -121,8 +133,18 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
onClick={() => handleAction('duplicate')}
className="flex-1"
>
{t('sftp.conflict.action.keepBoth')}
{t('sftp.conflict.action.duplicate')}
</Button>
{conflict.isDirectory && (
<Button
variant="outline"
onClick={() => handleAction('merge')}
disabled={!canMerge}
className="flex-1"
>
{t('sftp.conflict.action.merge')}
</Button>
)}
<Button
variant="default"
onClick={() => handleAction('replace')}

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

@@ -39,24 +39,39 @@ interface SftpTransferItemProps {
isExpanded?: boolean;
visibleChildCount?: number;
onToggleChildren?: () => void;
onSetNameColumnWidth?: (width: number) => void;
childNameColumnMinWidth?: number;
childNameColumnMaxWidth?: number;
childListId?: string;
resizeHandleTabIndex?: number;
}
const TruncatedTextWithTooltip: React.FC<{
text: string;
className?: string;
}> = ({ text, className }) => (
<TooltipProvider delayDuration={300} skipDelayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<span className={cn("truncate", className)}>
{text}
</span>
</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-md break-all">
<Tooltip>
<TooltipTrigger asChild>
<span className={cn("truncate", className)}>
{text}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-md break-all">
{text}
</TooltipContent>
</Tooltip>
);
const IconButtonWithTooltip: React.FC<{
label: string;
children: React.ReactElement;
}> = ({ label, children }) => (
<Tooltip>
<TooltipTrigger asChild>
{children}
</TooltipTrigger>
<TooltipContent side="top">{label}</TooltipContent>
</Tooltip>
);
const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
@@ -73,6 +88,11 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
isExpanded = false,
visibleChildCount: _visibleChildCount = 0,
onToggleChildren,
onSetNameColumnWidth,
childNameColumnMinWidth = 160,
childNameColumnMaxWidth = 480,
childListId,
resizeHandleTabIndex = 0,
}) => {
const { t } = useI18n();
@@ -184,29 +204,65 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
const showTransferSizeCalculation = task.status === 'transferring' && !hasKnownTotal && !isDirParent;
const showFailedError = task.status === 'failed' && !!task.error;
const hasFooterContent = showTransferSizeCalculation || showFailedError;
const retryActionLabel = t('sftp.transfers.retryAction');
const cancelActionLabel = t('common.cancel');
const dismissActionLabel = t('sftp.transfers.dismissAction');
const resizeNameColumnLabel = t('sftp.transfers.resizeNameColumn');
const toggleChildrenLabel = isExpanded ? t('sftp.transfers.collapseChildList') : t('sftp.transfers.expandChildList');
const actionButtonClass = "h-6 w-6 focus-visible:ring-1 focus-visible:ring-primary/50";
const actionAriaLabel = (label: string) => `${label}: ${task.fileName}`;
const setNameColumnWidth = (width: number) => {
const nextWidth = Math.max(childNameColumnMinWidth, Math.min(childNameColumnMaxWidth, width));
onSetNameColumnWidth?.(nextWidth);
};
const handleResizeKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (!onSetNameColumnWidth) return;
const step = event.shiftKey ? 40 : 10;
if (event.key === 'ArrowLeft') {
event.preventDefault();
setNameColumnWidth(childNameColumnWidth - step);
} else if (event.key === 'ArrowRight') {
event.preventDefault();
setNameColumnWidth(childNameColumnWidth + step);
} else if (event.key === 'Home') {
event.preventDefault();
setNameColumnWidth(childNameColumnMinWidth);
} else if (event.key === 'End') {
event.preventDefault();
setNameColumnWidth(childNameColumnMaxWidth);
}
};
const actionButtons = (
<div className="flex items-center gap-1 shrink-0">
{task.status === 'failed' && task.retryable !== false && (
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onRetry} title="Retry">
<RefreshCw size={12} />
</Button>
<IconButtonWithTooltip label={retryActionLabel}>
<Button variant="ghost" size="icon" className={actionButtonClass} onClick={onRetry} aria-label={actionAriaLabel(retryActionLabel)}>
<RefreshCw size={12} />
</Button>
</IconButtonWithTooltip>
)}
{(task.status === 'pending' || task.status === 'transferring') && (
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive hover:text-destructive" onClick={onCancel} title="Cancel">
<X size={12} />
</Button>
<IconButtonWithTooltip label={cancelActionLabel}>
<Button variant="ghost" size="icon" className={cn(actionButtonClass, "text-destructive hover:text-destructive")} onClick={onCancel} aria-label={actionAriaLabel(cancelActionLabel)}>
<X size={12} />
</Button>
</IconButtonWithTooltip>
)}
{(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') && (
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onDismiss} title="Dismiss">
<X size={12} />
</Button>
<IconButtonWithTooltip label={dismissActionLabel}>
<Button variant="ghost" size="icon" className={actionButtonClass} onClick={onDismiss} aria-label={actionAriaLabel(dismissActionLabel)}>
<X size={12} />
</Button>
</IconButtonWithTooltip>
)}
</div>
);
if (isChild) {
return (
const content = isChild ? (
<div
className="grid h-7 items-stretch border-t border-border/20 bg-background/20 px-3"
style={{
@@ -222,13 +278,25 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
className="min-w-0 text-[11px] font-medium text-foreground/90"
/>
</div>
<div
className="flex h-full cursor-col-resize items-center justify-center text-muted-foreground/35 hover:text-foreground/70"
onMouseDown={onResizeNameColumn}
title="Resize file name column"
>
<GripVertical size={10} />
</div>
<Tooltip>
<TooltipTrigger asChild>
<div
className="flex h-full cursor-col-resize items-center justify-center text-muted-foreground/35 hover:text-foreground/70 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
onMouseDown={onResizeNameColumn}
onKeyDown={handleResizeKeyDown}
role="separator"
aria-label={resizeNameColumnLabel}
aria-orientation="vertical"
aria-valuemin={childNameColumnMinWidth}
aria-valuemax={childNameColumnMaxWidth}
aria-valuenow={childNameColumnWidth}
tabIndex={resizeHandleTabIndex}
>
<GripVertical size={10} />
</div>
</TooltipTrigger>
<TooltipContent side="top">{resizeNameColumnLabel}</TooltipContent>
</Tooltip>
<div className="min-w-0">
{childProgressBar}
</div>
@@ -236,12 +304,10 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
{actionButtons}
</div>
</div>
);
}
) : (() => {
const showBelowParentProgress = task.status === 'transferring' || task.status === 'pending';
const showBelowParentProgress = task.status === 'transferring' || task.status === 'pending';
const titleBlock = (
const titleBlock = (
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<TruncatedTextWithTooltip
text={task.fileName}
@@ -255,21 +321,29 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
canRevealTarget ? "text-primary/80" : "text-muted-foreground",
)}
/>
{canToggleChildren && (
<button
type="button"
className="inline-flex shrink-0 items-center gap-1 rounded border border-border/60 bg-secondary/60 px-1.5 py-0.5 text-[10px] text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
onClick={onToggleChildren}
title={isExpanded ? t('sftp.transfers.collapseChildList') : t('sftp.transfers.expandChildList')}
>
{isExpanded ? t('sftp.transfers.collapseChildList') : t('sftp.transfers.expandChildList')}
{isExpanded ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
</button>
)}
</div>
);
);
return (
const toggleChildrenButton = canToggleChildren ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex shrink-0 items-center gap-1 rounded border border-border/60 bg-secondary/60 px-1.5 py-0.5 text-[10px] text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
onClick={onToggleChildren}
aria-label={toggleChildrenLabel}
aria-expanded={isExpanded}
aria-controls={childListId}
>
{toggleChildrenLabel}
{isExpanded ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
</button>
</TooltipTrigger>
<TooltipContent side="top">{toggleChildrenLabel}</TooltipContent>
</Tooltip>
) : null;
return (
<div className="border-t border-border/40 bg-background/60 px-3 py-2.5 supports-[backdrop-filter]:backdrop-blur-sm">
<div className="flex items-center gap-1">
<div className="flex h-5 w-5 items-center justify-center shrink-0 -translate-y-px">
@@ -290,6 +364,8 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
</div>
)}
{toggleChildrenButton}
{progressSummaryText && (
<span className="ml-auto shrink-0 whitespace-nowrap text-[10px] text-muted-foreground font-mono">
{progressSummaryText}
@@ -341,6 +417,13 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
</div>
)}
</div>
);
})();
return (
<TooltipProvider delayDuration={300} skipDelayDuration={100}>
{content}
</TooltipProvider>
);
};
@@ -362,6 +445,10 @@ const arePropsEqual = (
if ((prevProps.canToggleChildren ?? false) !== (nextProps.canToggleChildren ?? false)) return false;
if ((prevProps.isExpanded ?? false) !== (nextProps.isExpanded ?? false)) return false;
if ((prevProps.visibleChildCount ?? 0) !== (nextProps.visibleChildCount ?? 0)) return false;
if ((prevProps.childNameColumnMinWidth ?? 160) !== (nextProps.childNameColumnMinWidth ?? 160)) return false;
if ((prevProps.childNameColumnMaxWidth ?? 480) !== (nextProps.childNameColumnMaxWidth ?? 480)) return false;
if ((prevProps.childListId ?? '') !== (nextProps.childListId ?? '')) return false;
if ((prevProps.resizeHandleTabIndex ?? 0) !== (nextProps.resizeHandleTabIndex ?? 0)) return false;
if (next.status === 'transferring') {
if (next.totalBytes <= 0 && prev.transferredBytes !== next.transferredBytes) return false;

View File

@@ -29,9 +29,11 @@ const MAX_CHILD_NAME_WIDTH = 480;
const CHILD_ROW_HEIGHT = 28;
const CHILD_VIRTUALIZE_THRESHOLD = 80;
const CHILD_OVERSCAN = 8;
const childListIdForTask = (taskId: string) => `sftp-transfer-children-${taskId.replace(/[^A-Za-z0-9_-]/g, "-")}`;
interface TransferChildListProps {
childTasks: TransferTask[];
childListId: string;
childNameWidth: number;
onResizeNameColumn: (event: React.MouseEvent<HTMLDivElement>) => void;
scrollContainerRef: React.RefObject<HTMLDivElement>;
@@ -40,10 +42,12 @@ interface TransferChildListProps {
onCancel: (taskId: string) => void;
onRetry: (taskId: string) => Promise<void>;
onDismiss: (taskId: string) => void;
onSetNameColumnWidth: (width: number) => void;
}
const TransferChildList: React.FC<TransferChildListProps> = ({
childTasks,
childListId,
childNameWidth,
onResizeNameColumn,
scrollContainerRef,
@@ -52,6 +56,7 @@ const TransferChildList: React.FC<TransferChildListProps> = ({
onCancel,
onRetry,
onDismiss,
onSetNameColumnWidth,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [contentTop, setContentTop] = useState(0);
@@ -102,6 +107,7 @@ const TransferChildList: React.FC<TransferChildListProps> = ({
return (
<div
id={childListId}
ref={containerRef}
className="border-t border-border/30 bg-background/30"
>
@@ -121,7 +127,11 @@ const TransferChildList: React.FC<TransferChildListProps> = ({
task={child}
isChild
childNameColumnWidth={childNameWidth}
childNameColumnMinWidth={MIN_CHILD_NAME_WIDTH}
childNameColumnMaxWidth={MAX_CHILD_NAME_WIDTH}
onResizeNameColumn={onResizeNameColumn}
onSetNameColumnWidth={onSetNameColumnWidth}
resizeHandleTabIndex={visibleIndex === 0 ? 0 : -1}
onCancel={() => onCancel(child.id)}
onRetry={() => onRetry(child.id)}
onDismiss={() => onDismiss(child.id)}
@@ -303,6 +313,12 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
document.body.style.userSelect = "none";
}, [childNameWidth]);
const handleChildColumnWidthSet = useCallback((width: number) => {
const nextWidth = Math.max(MIN_CHILD_NAME_WIDTH, Math.min(MAX_CHILD_NAME_WIDTH, width));
setChildNameWidth(nextWidth);
persistChildNameWidth(nextWidth);
}, [persistChildNameWidth, setChildNameWidth]);
const toggleExpanded = useCallback((taskId: string) => {
setExpandedParents((prev) => ({
...prev,
@@ -369,6 +385,7 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
{topLevelTransfers.map((task) => {
const childTasks = childrenByParent.get(task.id) ?? [];
const isExpanded = expandedParents[task.id] ?? true;
const childListId = childListIdForTask(task.id);
return (
<React.Fragment key={task.id}>
@@ -377,6 +394,7 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
canToggleChildren={childTasks.length > 0}
isExpanded={isExpanded}
visibleChildCount={childTasks.length}
childListId={childListId}
onToggleChildren={() => toggleExpanded(task.id)}
onCancel={() => {
if (task.sourceConnectionId === "external") {
@@ -399,8 +417,10 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
{isExpanded && childTasks.length > 0 && (
<TransferChildList
childTasks={childTasks}
childListId={childListId}
childNameWidth={childNameWidth}
onResizeNameColumn={handleChildColumnResizeStart}
onSetNameColumnWidth={handleChildColumnWidthSet}
scrollContainerRef={scrollContainerRef}
scrollTop={scrollTop}
viewportHeight={viewportHeight}

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

@@ -5,6 +5,7 @@ import type { SftpDragCallbacks, SftpTransferSource } from "../SftpContext";
import { keepOnlyActivePaneSelections } from "./selectionScope";
import { editorTabStore } from "../../../application/state/editorTabStore";
import type { EditorTab, EditorTabId } from "../../../application/state/editorTabStore";
import { releaseEditorTabSaveCoordinator, saveEditorTab } from "../../../application/state/editorTabSave";
import { promptUnsavedChanges } from "../../editor/UnsavedChangesDialog";
interface UseSftpViewPaneActionsParams {
@@ -139,12 +140,18 @@ export const useSftpViewPaneActions = ({
if (connectionId) {
const choice = (tab: EditorTab) => promptUnsavedChanges(tab.fileName);
const saveTab = async (id: EditorTabId) => {
const ok = await saveEditorTab(id);
const tab = editorTabStore.getTab(id);
if (!tab) return;
await sftpRef.current.writeTextFileByConnection(tab.sessionId, tab.hostId, tab.remotePath, tab.content);
editorTabStore.markSaved(id, tab.content);
if (!ok || (tab && tab.content !== tab.baselineContent)) {
throw new Error(tab?.saveError ?? "Save failed");
}
};
const ok = await editorTabStore.confirmCloseBySession(connectionId, choice, saveTab);
const ok = await editorTabStore.confirmCloseBySession(
connectionId,
choice,
saveTab,
releaseEditorTabSaveCoordinator,
);
if (!ok) return false;
}
sftpRef.current.disconnect("left");
@@ -155,12 +162,18 @@ export const useSftpViewPaneActions = ({
if (connectionId) {
const choice = (tab: EditorTab) => promptUnsavedChanges(tab.fileName);
const saveTab = async (id: EditorTabId) => {
const ok = await saveEditorTab(id);
const tab = editorTabStore.getTab(id);
if (!tab) return;
await sftpRef.current.writeTextFileByConnection(tab.sessionId, tab.hostId, tab.remotePath, tab.content);
editorTabStore.markSaved(id, tab.content);
if (!ok || (tab && tab.content !== tab.baselineContent)) {
throw new Error(tab?.saveError ?? "Save failed");
}
};
const ok = await editorTabStore.confirmCloseBySession(connectionId, choice, saveTab);
const ok = await editorTabStore.confirmCloseBySession(
connectionId,
choice,
saveTab,
releaseEditorTabSaveCoordinator,
);
if (!ok) return false;
}
sftpRef.current.disconnect("right");

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

@@ -2,6 +2,10 @@ import React, { useCallback, useMemo, useState } from "react";
import type { MutableRefObject } from "react";
import type { Host } from "../../../types";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import { editorTabStore } from "../../../application/state/editorTabStore";
import type { EditorTab, EditorTabId } from "../../../application/state/editorTabStore";
import { releaseEditorTabSaveCoordinator, saveEditorTab } from "../../../application/state/editorTabSave";
import { promptUnsavedChanges } from "../../editor/UnsavedChangesDialog";
interface UseSftpViewTabsParams {
sftp: SftpStateApi;
@@ -23,8 +27,8 @@ interface UseSftpViewTabsResult {
setHostSearchRight: React.Dispatch<React.SetStateAction<string>>;
handleAddTabLeft: () => string;
handleAddTabRight: () => string;
handleCloseTabLeft: (tabId: string) => void;
handleCloseTabRight: (tabId: string) => void;
handleCloseTabLeft: (tabId: string) => Promise<void>;
handleCloseTabRight: (tabId: string) => Promise<void>;
handleSelectTabLeft: (tabId: string) => void;
handleSelectTabRight: (tabId: string) => void;
handleReorderTabsLeft: (draggedId: string, targetId: string, position: "before" | "after") => void;
@@ -53,13 +57,41 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf
return tabId;
}, [sftpRef]);
const handleCloseTabLeft = useCallback((tabId: string) => {
sftpRef.current.closeTab("left", tabId);
}, [sftpRef]);
const confirmCloseEditorTabsByConnection = useCallback(async (connectionId: string): Promise<boolean> => {
const choice = (tab: EditorTab) => promptUnsavedChanges(tab.fileName);
const saveTab = async (id: EditorTabId) => {
const ok = await saveEditorTab(id);
const tab = editorTabStore.getTab(id);
if (!ok || (tab && tab.content !== tab.baselineContent)) {
throw new Error(tab?.saveError ?? "Save failed");
}
};
return editorTabStore.confirmCloseBySession(
connectionId,
choice,
saveTab,
releaseEditorTabSaveCoordinator,
);
}, []);
const handleCloseTabRight = useCallback((tabId: string) => {
sftpRef.current.closeTab("right", tabId);
}, [sftpRef]);
const handleCloseSftpTab = useCallback(async (side: "left" | "right", tabId: string) => {
const sideTabs = side === "left" ? sftpRef.current.leftTabs : sftpRef.current.rightTabs;
const pane = sideTabs.tabs.find((tab) => tab.id === tabId);
const connectionId = pane?.connection?.id;
if (connectionId) {
const ok = await confirmCloseEditorTabsByConnection(connectionId);
if (!ok) return;
}
sftpRef.current.closeTab(side, tabId);
}, [confirmCloseEditorTabsByConnection, sftpRef]);
const handleCloseTabLeft = useCallback((tabId: string) => (
handleCloseSftpTab("left", tabId)
), [handleCloseSftpTab]);
const handleCloseTabRight = useCallback((tabId: string) => (
handleCloseSftpTab("right", tabId)
), [handleCloseSftpTab]);
const handleSelectTabLeft = useCallback((tabId: string) => {
sftpRef.current.selectTab("left", tabId);

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

@@ -47,3 +47,72 @@ test("still trims prompt decorations out of the detected input", () => {
assert.equal(result.prompt.cursorOffset, 2);
assert.equal(result.alignedTyped, "do");
});
test("detects oh-my-posh Nerd Font chevron (U+F105) prompt terminator", () => {
// Real-world PS1 captured from oh-my-posh themed bash on a server:
// "<U+F31B> root@oracle ~ <U+F105> " then user input
const term = createFakeTerm(" root@oracle ~  ls", 21);
const result = getAlignedPrompt(term as never, "ls", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, " root@oracle ~  ");
assert.equal(result.prompt.userInput, "ls");
});
test("detects Powerline right-arrow (U+E0B0) prompt terminator", () => {
// oh-my-posh agnoster-style: colored block ending with U+E0B0 + space
const term = createFakeTerm(" root  ~  git", 16);
const result = getAlignedPrompt(term as never, "git", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.userInput, "git");
assert.ok(result.prompt.promptText.endsWith(" "));
});
test("PUA char without trailing space is not a prompt boundary", () => {
// A bare PUA glyph mid-token (e.g. paste artifact) should not trigger detection.
const term = createFakeTerm("echo foo", 13);
const result = getAlignedPrompt(term as never, "", true);
assert.equal(result.prompt.isAtPrompt, false);
});
test("keeps typed command intact when command text contains Powerline glyphs", () => {
const typedInput = "echo  foo";
const lineText = `$ ${typedInput}`;
const term = createFakeTerm(lineText, lineText.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, "$ ");
assert.equal(result.prompt.userInput, typedInput);
assert.equal(result.alignedTyped, typedInput);
});
test("prefers standard prompt terminator over later Powerline glyphs", () => {
const lineText = "$ echo  foo";
const term = createFakeTerm(lineText, lineText.length);
const result = getAlignedPrompt(term as never, "", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, "$ ");
assert.equal(result.prompt.userInput, "echo  foo");
});
test("keeps typed command intact for PUA-only prompts when command text contains Powerline glyphs", () => {
const typedInput = "echo  foo";
const lineText = ` root  ~  ${typedInput}`;
const term = createFakeTerm(lineText, lineText.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, " root  ~  ");
assert.equal(result.prompt.userInput, typedInput);
assert.equal(result.alignedTyped, typedInput);
});

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;

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