Compare commits

...

44 Commits

Author SHA1 Message Date
陈大猫
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
148 changed files with 14871 additions and 1573 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/

79
App.tsx
View File

@@ -16,15 +16,16 @@ 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 { materializeHostProxyProfile } from './domain/proxyProfiles';
import { resolveHostAuth } from './domain/sshAuth';
import { resolveHostTerminalThemeId } from './domain/terminalAppearance';
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,
@@ -58,7 +59,7 @@ 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 +208,8 @@ function App({ settings }: { settings: SettingsState }) {
theme,
setTheme,
resolvedTheme,
accentMode,
customAccent,
terminalThemeId,
setTerminalThemeId,
followAppTerminalTheme,
@@ -251,6 +254,7 @@ function App({ settings }: { settings: SettingsState }) {
hosts,
keys,
identities,
proxyProfiles,
snippets,
customGroups,
snippetPackages,
@@ -261,6 +265,7 @@ function App({ settings }: { settings: SettingsState }) {
updateHosts,
updateKeys,
updateIdentities,
updateProxyProfiles,
updateSnippets,
updateSnippetPackages,
updateCustomGroups,
@@ -366,14 +371,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 +413,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 +451,12 @@ function App({ settings }: { settings: SettingsState }) {
}
}
return buildSyncPayload(
return buildLocalVaultPayload(
{
hosts,
keys,
identities,
proxyProfiles,
snippets,
customGroups,
snippetPackages,
@@ -460,6 +471,7 @@ function App({ settings }: { settings: SettingsState }) {
hosts,
identities,
keys,
proxyProfiles,
knownHosts,
portForwardingRulesForSync,
snippetPackages,
@@ -520,7 +532,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 +565,11 @@ function App({ settings }: { settings: SettingsState }) {
hosts,
keys,
identities,
proxyProfiles,
snippets,
customGroups,
snippetPackages,
portForwardingRules: portForwardingRulesForSync,
knownHosts,
groupConfigs,
settingsVersion: settings.settingsVersion,
startupReady: startupSyncSafetyReady,
@@ -599,7 +611,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 +814,11 @@ function App({ settings }: { settings: SettingsState }) {
// Auto-start port forwarding rules on app launch
usePortForwardingAutoStart({
isVaultInitialized,
hosts,
keys,
identities,
proxyProfiles,
groupConfigs,
});
@@ -1495,11 +1509,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) => {
@@ -1763,6 +1787,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 +1810,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 +1865,7 @@ function App({ settings }: { settings: SettingsState }) {
hosts={hosts}
keys={keys}
identities={identities}
proxyProfiles={proxyProfiles}
snippets={snippets}
snippetPackages={snippetPackages}
customGroups={customGroups}
@@ -1864,6 +1889,7 @@ function App({ settings }: { settings: SettingsState }) {
onUpdateHosts={updateHosts}
onUpdateKeys={updateKeys}
onUpdateIdentities={updateIdentities}
onUpdateProxyProfiles={updateProxyProfiles}
onUpdateSnippets={updateSnippets}
onUpdateSnippetPackages={updateSnippetPackages}
onUpdateCustomGroups={updateCustomGroups}
@@ -1889,6 +1915,7 @@ function App({ settings }: { settings: SettingsState }) {
hosts={hosts}
keys={keys}
identities={identities}
proxyProfiles={proxyProfiles}
groupConfigs={groupConfigs}
updateHosts={updateHosts}
sftpDefaultViewMode={sftpDefaultViewMode}
@@ -1905,6 +1932,7 @@ function App({ settings }: { settings: SettingsState }) {
<TerminalLayerMount
hosts={hosts}
groupConfigs={groupConfigs}
proxyProfiles={proxyProfiles}
keys={keys}
identities={identities}
snippets={snippets}
@@ -1915,6 +1943,8 @@ function App({ settings }: { settings: SettingsState }) {
draggingSessionId={draggingSessionId}
terminalTheme={currentTerminalTheme}
followAppTerminalTheme={followAppTerminalTheme}
accentMode={accentMode}
customAccent={customAccent}
terminalSettings={terminalSettings}
terminalFontFamilyId={terminalFontFamilyId}
fontSize={terminalFontSize}
@@ -2208,6 +2238,7 @@ function App({ settings }: { settings: SettingsState }) {
hosts: emptyVaultConflict.hostCount,
keys: emptyVaultConflict.keyCount,
snippets: emptyVaultConflict.snippetCount,
proxyProfiles: emptyVaultConflict.proxyProfileCount,
})}</div>
</div>
)}

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',
@@ -790,6 +796,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 +865,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 +1132,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',

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} 台主机',
@@ -562,6 +580,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': '定位到终端当前目录',
@@ -1214,8 +1235,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 +1486,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 +1557,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_ 为前缀的变量。',

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

@@ -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";
@@ -63,6 +63,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 +90,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 +503,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 +684,7 @@ export const useSftpExternalOperations = (
joinPath,
callbacks,
useCompressedUpload,
resolveConflict: createUploadConflictResolver(),
},
controller
);
@@ -624,6 +713,7 @@ export const useSftpExternalOperations = (
sftpSessionsRef,
createUploadCallbacks,
createUploadBridge,
createUploadConflictResolver,
useCompressedUpload,
],
);
@@ -680,6 +770,7 @@ export const useSftpExternalOperations = (
joinPath,
callbacks,
useCompressedUpload,
resolveConflict: createUploadConflictResolver(),
},
controller,
);
@@ -707,6 +798,7 @@ export const useSftpExternalOperations = (
connectionCacheKeyMapRef,
createUploadCallbacks,
createUploadBridge,
createUploadConflictResolver,
getActivePane,
refresh,
sftpSessionsRef,
@@ -716,11 +808,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> => {
@@ -744,5 +839,7 @@ export const useSftpExternalOperations = (
cancelExternalUpload,
selectApplication,
activeFileWatchCountRef,
uploadConflicts,
resolveUploadConflict,
};
};

View File

@@ -0,0 +1,53 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildSftpHostCredentials } from "./useSftpHostCredentials.ts";
import type { Host } 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/,
);
});

View File

@@ -9,94 +9,111 @@ 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 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,
};
};
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,44 @@
import test from "node:test";
import assert from "node:assert/strict";
import { uploadFromDataTransfer } from "../../lib/uploadService.ts";
function createDataTransfer(files: File[]): DataTransfer {
return {
items: { length: 0 },
files,
} as unknown as DataTransfer;
}
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"]);
});

View File

@@ -16,14 +16,13 @@ import {
findSyncPayloadEncryptedCredentialPaths,
} from '../../domain/credentials';
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
import { collectSyncableSettings, hasMeaningfulSyncData } from '../syncPayload';
import { 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 { notify } from '../notification';
interface AutoSyncConfig {
@@ -31,11 +30,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;
@@ -112,6 +111,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);
@@ -140,28 +140,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 +281,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 +435,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 +448,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,
});
});

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,
@@ -308,6 +308,8 @@ export const useSftpState = (
cancelExternalUpload,
selectApplication,
activeFileWatchCountRef,
uploadConflicts,
resolveUploadConflict,
} = useSftpExternalOperations({
getActivePane,
getPaneByConnectionId,
@@ -322,6 +324,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({
@@ -375,7 +392,7 @@ export const useSftpState = (
retryTransfer,
clearCompletedTransfers,
dismissTransfer,
resolveConflict,
resolveConflict: resolveAnyConflict,
getSftpIdForConnection,
reportSessionError: handleSessionError,
});
@@ -430,7 +447,7 @@ export const useSftpState = (
retryTransfer,
clearCompletedTransfers,
dismissTransfer,
resolveConflict,
resolveConflict: resolveAnyConflict,
getSftpIdForConnection,
reportSessionError: handleSessionError,
};
@@ -496,7 +513,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

@@ -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[];
@@ -106,6 +111,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 +127,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 +137,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,7 +153,7 @@ 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);
});
@@ -154,12 +162,21 @@ export const useVaultState = () => {
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 +205,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 +215,7 @@ export const useVaultState = () => {
updateHosts([]);
updateKeys([]);
updateIdentities([]);
updateProxyProfiles([]);
updateSnippets([]);
updateSnippetPackages([]);
updateCustomGroups([]);
@@ -209,6 +227,7 @@ export const useVaultState = () => {
updateHosts,
updateKeys,
updateIdentities,
updateProxyProfiles,
updateSnippets,
updateSnippetPackages,
updateCustomGroups,
@@ -414,6 +433,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 +561,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 +666,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 +704,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 +716,7 @@ export const useVaultState = () => {
hosts,
keys,
identities,
proxyProfiles,
snippets,
customGroups,
snippetPackages,
@@ -677,6 +728,7 @@ export const useVaultState = () => {
updateHosts,
updateKeys,
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,256 @@
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 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("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,
@@ -58,14 +59,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 +83,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 +97,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. */
@@ -314,10 +341,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 +352,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,51 @@
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 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 = () =>
renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(HostDetailsPanel, {
initialData: hostWithMissingProxyProfile,
availableKeys: [],
identities: [],
proxyProfiles: [],
groups: [],
managedSources: [],
allTags: [],
allHosts: [],
terminalThemeId: "default",
terminalFontSize: 14,
onSave: () => {},
onCancel: () => {},
}),
),
);
test("HostDetailsPanel shows a missing saved proxy without undefined fields", () => {
const markup = renderHostDetails();
assert.match(markup, /Missing saved proxy/);
assert.doesNotMatch(markup, /undefined:undefined/);
});

View File

@@ -37,6 +37,7 @@ import {
LINUX_DISTRO_OPTIONS,
NETWORK_DEVICE_OPTIONS,
} from "../domain/host";
import { isCompleteProxyConfig, normalizeManualProxyConfig } from "../domain/proxyProfiles";
import { customThemeStore } from "../application/state/customThemeStore";
import {
clearHostFontSizeOverride,
@@ -48,7 +49,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 +70,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 {
@@ -97,6 +99,7 @@ interface HostDetailsPanelProps {
initialData?: Host | null;
availableKeys: SSHKey[];
identities: Identity[];
proxyProfiles?: ProxyProfile[];
groups: string[];
managedSources?: ManagedSource[];
allTags?: string[]; // All available tags for autocomplete
@@ -117,6 +120,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
initialData,
availableKeys,
identities,
proxyProfiles = [],
groups,
managedSources = [],
allTags = [],
@@ -260,6 +264,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 +296,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 +375,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,8 +423,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
finalManagedSourceId = undefined;
}
const { proxyConfig: _draftProxyConfig, ...formWithoutProxyDraft } = form;
const cleaned: Host = {
...form,
...formWithoutProxyDraft,
...(normalizedProxyConfig && { proxyConfig: normalizedProxyConfig }),
label: finalLabel,
group: finalGroup,
tags: form.tags || [],
@@ -536,7 +584,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}
@@ -1758,35 +1809,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"

View File

@@ -22,7 +22,7 @@ import { 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 +68,7 @@ interface KeychainManagerProps {
keys: SSHKey[];
identities?: Identity[];
hosts?: Host[];
proxyProfiles?: ProxyProfile[];
customGroups?: string[];
managedSources?: ManagedSource[];
onSave: (key: SSHKey) => void;
@@ -84,6 +85,7 @@ const KeychainManager: React.FC<KeychainManagerProps> = ({
keys,
identities = [],
hosts = [],
proxyProfiles = [],
customGroups = [],
managedSources = [],
onSave,
@@ -1234,6 +1236,7 @@ echo $3 >> "$FILE"`);
onBack={() => setShowHostSelector(false)}
onContinue={() => setShowHostSelector(false)}
availableKeys={keys}
proxyProfiles={proxyProfiles}
managedSources={managedSources}
onSaveHost={onSaveHost}
onCreateGroup={onCreateGroup}

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

@@ -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,6 +26,7 @@ import {
shouldScrollOnTerminalInput,
} from "../domain/terminalScroll";
import {
applyCustomAccentToTerminalTheme,
resolveHostTerminalThemeId,
} from "../domain/terminalAppearance";
import { classifyDistroId } from "../domain/host";
@@ -127,6 +128,8 @@ interface TerminalProps {
fontSize: number;
terminalTheme: TerminalTheme;
followAppTerminalTheme?: boolean;
accentMode?: "theme" | "custom";
customAccent?: string;
terminalSettings?: TerminalSettings;
sessionId: string;
startupCommand?: string;
@@ -225,6 +228,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
fontSize,
terminalTheme,
followAppTerminalTheme = false,
accentMode = "theme",
customAccent = "",
terminalSettings,
sessionId,
startupCommand,
@@ -619,8 +624,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,
@@ -682,18 +693,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;
@@ -1725,8 +1739,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

View File

@@ -0,0 +1,66 @@
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: [],
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: () => {},
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 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

@@ -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,
@@ -55,6 +56,7 @@ import {
Identity,
KnownHost,
ManagedSource,
ProxyProfile,
SerialConfig,
SSHKey,
ShellHistoryEntry,
@@ -69,6 +71,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 +107,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 +118,7 @@ interface VaultViewProps {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
proxyProfiles: ProxyProfile[];
snippets: Snippet[];
snippetPackages: string[];
customGroups: string[];
@@ -136,6 +140,7 @@ interface VaultViewProps {
onUpdateHosts: (hosts: Host[]) => void;
onUpdateKeys: (keys: SSHKey[]) => void;
onUpdateIdentities: (identities: Identity[]) => void;
onUpdateProxyProfiles: (profiles: ProxyProfile[]) => void;
onUpdateSnippets: (snippets: Snippet[]) => void;
onUpdateSnippetPackages: (pkgs: string[]) => void;
onUpdateCustomGroups: (groups: string[]) => void;
@@ -163,6 +168,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
hosts,
keys,
identities,
proxyProfiles,
snippets,
snippetPackages,
customGroups,
@@ -184,6 +190,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onUpdateHosts,
onUpdateKeys,
onUpdateIdentities,
onUpdateProxyProfiles,
onUpdateSnippets,
onUpdateSnippetPackages,
onUpdateCustomGroups,
@@ -296,6 +303,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 +354,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 +366,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 +374,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,8 +486,8 @@ 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";
@@ -519,7 +530,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) => {
@@ -1669,6 +1680,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
@@ -2826,6 +2857,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}
onRunSnippet={onRunSnippet}
availableKeys={keys}
proxyProfiles={proxyProfiles}
managedSources={managedSources}
onSaveHost={(host) => onUpdateHosts([...hosts, host])}
onCreateGroup={(groupPath) =>
@@ -2840,6 +2872,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 +2910,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 +2968,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 +2989,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
initialData={editingHost}
availableKeys={keys}
identities={identities}
proxyProfiles={proxyProfiles}
groups={allGroupPaths}
managedSources={managedSources}
allTags={allTags}
@@ -3207,6 +3253,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

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

@@ -108,6 +108,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 +161,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 +175,7 @@ export const useSftpUpdateHosts = () => {
interface SftpContextProviderProps {
hosts: Host[];
writableHosts?: Host[];
updateHosts: (hosts: Host[]) => void;
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
dragCallbacks: SftpDragCallbacks;
@@ -177,6 +186,7 @@ interface SftpContextProviderProps {
export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
hosts,
writableHosts,
updateHosts,
draggedFiles,
dragCallbacks,
@@ -188,11 +198,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

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

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

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

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

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

@@ -17,7 +17,11 @@ const sshHost: Host = {
protocol: "ssh",
};
const renderToolbar = (host: Host, status: "connecting" | "connected" | "disconnected" = "connected") =>
const renderToolbar = (
host: Host,
status: "connecting" | "connected" | "disconnected" = "connected",
props: Partial<React.ComponentProps<typeof TerminalToolbar>> = {},
) =>
renderToStaticMarkup(
React.createElement(
I18nProvider,
@@ -28,6 +32,7 @@ const renderToolbar = (host: Host, status: "connecting" | "connected" | "disconn
onOpenSFTP: () => {},
onOpenScripts: () => {},
onOpenTheme: () => {},
...props,
}),
),
);
@@ -52,3 +57,15 @@ test("hides SFTP for local terminal sessions", () => {
assert.equal(markup.includes('aria-label="Open SFTP"'), false);
});
test("uses the terminal active button color for pressed toolbar actions", () => {
const markup = renderToolbar(sshHost, "connected", {
isSearchOpen: true,
onToggleSearch: () => {},
});
assert.match(
markup,
/aria-label="Search terminal"[^>]*style="background-color:var\(--terminal-toolbar-btn-active\)"/,
);
});

View File

@@ -71,6 +71,9 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
const hidesSftp = isLocalTerminal || isSerialTerminal;
const menuItemClass = "w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors";
const activeButtonStyle: React.CSSProperties = {
backgroundColor: 'var(--terminal-toolbar-btn-active)',
};
return (
<TooltipProvider delayDuration={500} skipDelayDuration={100} disableHoverableContent>
@@ -111,6 +114,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
aria-label={t("terminal.toolbar.composeBar")}
aria-pressed={isComposeBarOpen}
onClick={onToggleComposeBar}
style={isComposeBarOpen ? activeButtonStyle : undefined}
>
<TextCursorInput size={12} />
</Button>
@@ -127,6 +131,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
aria-label={t("terminal.toolbar.searchTerminal")}
aria-pressed={isSearchOpen}
onClick={onToggleSearch}
style={isSearchOpen ? activeButtonStyle : undefined}
>
<Search size={12} />
</Button>

View File

@@ -1,8 +1,8 @@
/**
* Context-aware completion engine.
* Combines multiple data sources:
* 1. Command history (highest priority)
* 2. @withfig/autocomplete specs (subcommands, options, args)
* 1. Context-aware path completions and @withfig/autocomplete specs
* 2. Command history
* 3. Fuzzy history matching (fallback)
*
* Parses the current command line to determine context (command, subcommand,
@@ -66,6 +66,11 @@ export interface CompletionContext {
isOptionArg: boolean;
}
interface SpecSuggestionResult {
suggestions: CompletionSuggestion[];
pathArgs?: FigSubcommand["args"];
}
export function shellEscape(name: string): string {
if (!name) return name;
if (/[\\$'"|!<>;#~` ]/.test(name)) {
@@ -170,10 +175,13 @@ export async function getCompletions(
if (!input || input.trim().length === 0) return [];
const ctx = parseCommandLine(input);
const specResult: SpecSuggestionResult = ctx.commandName && ctx.wordIndex >= 0
? await getSpecSuggestions(ctx)
: { suggestions: [] };
const suggestions: CompletionSuggestion[] = [];
const seenSuggestionTexts = new Set<string>();
const pathCheck = ctx.commandName && ctx.wordIndex >= 1
? shouldDoPathCompletion(ctx, undefined)
? shouldDoPathCompletion(ctx, specResult.pathArgs)
: { shouldComplete: false, foldersOnly: false };
const preferPathSuggestions = pathCheck.shouldComplete;
const resultLimit = preferPathSuggestions ? Math.max(maxResults, 24) : maxResults;
@@ -226,21 +234,16 @@ export async function getCompletions(
const canQueryPaths = options.protocol === "local" || options.sessionId !== undefined;
const specPromise = ctx.commandName && ctx.wordIndex >= 0
? getSpecSuggestions(ctx)
: Promise.resolve([]);
const pathPromise = canQueryPaths && pathCheck.shouldComplete
? getPathSuggestions(ctx, {
const pathEntries = canQueryPaths && pathCheck.shouldComplete
? await getPathSuggestions(ctx, {
sessionId: options.sessionId,
protocol: options.protocol,
cwd: options.cwd,
foldersOnly: pathCheck.foldersOnly,
})
: Promise.resolve([]);
: [];
const [specSugs, pathEntries] = await Promise.all([specPromise, pathPromise]);
for (const suggestion of specSugs) {
for (const suggestion of specResult.suggestions) {
suggestions.push(suggestion);
seenSuggestionTexts.add(suggestion.text);
}
@@ -313,26 +316,26 @@ function normalizeHistoryPathPrefix(token: string): string {
/**
* Get suggestions from Fig spec + return resolved args (for path detection reuse).
*/
async function getSpecSuggestions(ctx: CompletionContext): Promise<CompletionSuggestion[]> {
async function getSpecSuggestions(ctx: CompletionContext): Promise<SpecSuggestionResult> {
const suggestions: CompletionSuggestion[] = [];
const specAvailable = await hasSpec(ctx.commandName);
if (!specAvailable) {
if (ctx.wordIndex === 0 && ctx.currentWord.length >= 1) {
return await getCommandNameSuggestions(ctx.currentWord);
return { suggestions: await getCommandNameSuggestions(ctx.currentWord) };
}
return [];
return { suggestions };
}
const spec = await loadSpec(ctx.commandName);
if (!spec) return [];
if (!spec) return { suggestions };
// If we're still typing the command name (partial match, not yet complete)
if (ctx.wordIndex === 0) {
const typedLower = ctx.currentWord.toLowerCase();
const specNames = resolveNames(spec.name);
const isExactMatch = specNames.some((n) => n.toLowerCase() === typedLower);
if (!isExactMatch) return [];
if (!isExactMatch) return { suggestions };
// Show subcommands as preview (user typed full command but no space yet)
if (spec.subcommands) {
@@ -348,11 +351,11 @@ async function getSpecSuggestions(ctx: CompletionContext): Promise<CompletionSug
if (suggestions.length >= 10) break;
}
}
return suggestions;
return { suggestions };
}
// Navigate the spec tree based on typed tokens
let resolved = resolveSpecContext(spec, ctx.tokens.slice(1, ctx.wordIndex));
const resolved = resolveSpecContext(spec, ctx.tokens.slice(1, ctx.wordIndex));
const currentToken = ctx.currentWord;
// Check if currentToken exactly matches a subcommand — if so, navigate into it
@@ -387,7 +390,7 @@ async function getSpecSuggestions(ctx: CompletionContext): Promise<CompletionSug
childResolved.options?.length ? childResolved.options : childResolved.fallbackOptions,
15,
);
return suggestions;
return { suggestions };
}
}
@@ -442,7 +445,10 @@ async function getSpecSuggestions(ctx: CompletionContext): Promise<CompletionSug
}
}
return suggestions;
return {
suggestions,
pathArgs: resolved.args,
};
}
/**

View File

@@ -3,8 +3,8 @@
* Detects whether the user is currently at a shell prompt (vs. inside a running program).
* Uses xterm.js buffer analysis to identify common prompt patterns.
*
* Strategy: scan left-to-right for the FIRST prompt-ending character ($ # % > etc.)
* followed by a space. Exclude false positives like $HOME, $PATH, etc.
* Strategy: scan prompt-looking boundaries ($ # % >, Powerline/Nerd Font glyphs,
* etc.) and choose the most reliable split for prompt text vs. user input.
*/
import type { Terminal as XTerm } from "@xterm/xterm";
@@ -62,6 +62,16 @@ function replacePromptUserInput(
};
}
function getCursorLinePrefix(term: XTerm): string | null {
const buffer = term.buffer.active;
const cursorY = buffer.cursorY + buffer.baseY;
const line = buffer.getLine(cursorY);
if (!line) return null;
return line.translateToString(false).substring(0, Math.max(0, buffer.cursorX));
}
/**
* Detect whether the terminal cursor is at a shell prompt and extract the current user input.
*/
@@ -141,9 +151,23 @@ export function detectPrompt(term: XTerm): PromptDetectionResult {
/** Characters that commonly end a shell prompt */
const PROMPT_CHARS = new Set(["$", "#", "%", ">", "", "", "→", "➜", "➤", "⟩", "»", ""]);
/**
* Whether a character lives in the Unicode Private Use Area (U+E000U+F8FF).
* Powerline separators (U+E0B0..) and Nerd Font icons (U+E200.., U+F000..) all
* fall here. A PUA char followed by a space is common in themed prompt
* terminators (oh-my-posh, starship, p10k, etc.), but commands can still echo
* those glyphs, so PUA boundaries are kept lower priority than standard prompt
* characters and reconciled with the typed buffer when available.
*/
function isPuaChar(ch: string): boolean {
if (!ch) return false;
const code = ch.charCodeAt(0);
return code >= 0xE000 && code <= 0xF8FF;
}
/**
* Find the boundary between prompt and user input.
* Scans left-to-right within the first 80 chars for a prompt character followed by space.
* Scans left-to-right within the first 200 chars for a prompt character followed by space.
* Avoids false positives: $VAR, $(...), ${...} are not prompt endings.
* Returns the character index where user input begins, or -1 if no prompt detected.
*/
@@ -154,15 +178,18 @@ function findPromptBoundary(lineText: string): number {
// confused with shell syntax in a prompt position.
const lineLen = lineText.trimEnd().length;
const scanLimit = Math.min(lineLen, 200);
let lastBoundary = -1;
let lastStandardBoundary = -1;
let lastPuaBoundary = -1;
// Ambiguous chars (>) only scan first 60% to avoid matching redirections
const ambiguousScanLimit = Math.min(scanLimit, Math.max(40, Math.floor(lineLen * 0.6)));
for (let i = 0; i < scanLimit; i++) {
const ch = lineText[i];
const isStandard = PROMPT_CHARS.has(ch);
const isPua = !isStandard && isPuaChar(ch);
if (!PROMPT_CHARS.has(ch)) continue;
if (!isStandard && !isPua) continue;
// For ambiguous prompt chars like >, only accept in the first 60% of the line
if ((ch === ">" || ch === "") && i >= ambiguousScanLimit) continue;
@@ -222,11 +249,17 @@ function findPromptBoundary(lineText: string): number {
}
}
// Record this as a candidate boundary
lastBoundary = nextChar === " " ? i + 2 : i + 1;
// Record this as a candidate boundary. A standard shell prompt terminator
// is more reliable than a later Powerline/Nerd Font glyph in command text.
const boundary = nextChar === " " ? i + 2 : i + 1;
if (isStandard) {
lastStandardBoundary = boundary;
} else {
lastPuaBoundary = boundary;
}
}
return lastBoundary;
return lastStandardBoundary >= 0 ? lastStandardBoundary : lastPuaBoundary;
}
/**
@@ -312,6 +345,21 @@ export function getAlignedPrompt(
alignedTyped: typedBuffer,
};
}
const cursorLinePrefix = getCursorLinePrefix(term);
if (cursorLinePrefix?.endsWith(typedBuffer)) {
const promptText = cursorLinePrefix.slice(0, cursorLinePrefix.length - typedBuffer.length);
if (promptText.length > 0) {
return {
prompt: {
isAtPrompt: true,
promptText,
userInput: typedBuffer,
cursorOffset: typedBuffer.length,
},
alignedTyped: typedBuffer,
};
}
}
return { prompt: raw, alignedTyped: null };
}

View File

@@ -107,11 +107,6 @@ export function shouldDoPathCompletion(
foldersOnly: templates.includes("folders") && !templates.includes("filepaths"),
};
}
// Generators field often indicates path completion (e.g., cd)
if (arg.generators) {
const foldersOnly = FOLDER_ONLY_COMMANDS.has(ctx.commandName);
return { shouldComplete: true, foldersOnly };
}
}
}

View File

@@ -0,0 +1,123 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { FigSpec } from "./autocomplete/figSpecLoader.ts";
type LocalStorageMock = {
clear(): void;
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
};
type MockDirEntry = {
name: string;
type: "file" | "directory" | "symlink";
};
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 storySpec: FigSpec = {
name: "story",
subcommands: [
{
name: "open",
args: { template: "filepaths" },
},
{
name: "pick",
args: { name: "item", generators: {} },
},
],
};
const bridgeState: { localEntries: MockDirEntry[] } = {
localEntries: [],
};
Object.defineProperty(globalThis, "window", {
value: {
netcatty: {
listFigSpecs: async () => ["story"],
loadFigSpec: async (commandName: string) => commandName === "story" ? storySpec : null,
listAutocompleteLocalDir: async (
_path: string,
foldersOnly: boolean,
filterPrefix?: string,
limit?: number,
) => {
const prefix = (filterPrefix ?? "").toLowerCase();
const entries = bridgeState.localEntries
.filter((entry) => !foldersOnly || entry.type === "directory")
.filter((entry) => !prefix || entry.name.toLowerCase().startsWith(prefix))
.slice(0, limit ?? bridgeState.localEntries.length);
return { success: true, entries };
},
},
},
configurable: true,
});
const { getCompletions } = await import("./autocomplete/completionEngine.ts");
const { clearHistory, recordCommand } = await import("./autocomplete/commandHistoryStore.ts");
test.beforeEach(() => {
localStorage.clear();
clearHistory();
bridgeState.localEntries = [{ name: "package.json", type: "file" }];
});
test("getCompletions prioritizes spec-driven path suggestions over history", async () => {
recordCommand("story open package-lock.json", "host-1");
const completions = await getCompletions("story open pa", {
hostId: "host-1",
protocol: "local",
cwd: "/repo",
});
assert.ok(completions.length > 0);
assert.equal(completions[0]?.source, "path");
assert.equal(completions[0]?.text, "story open package.json");
const historyIndex = completions.findIndex((entry) =>
entry.source === "history" && entry.text === "story open package-lock.json"
);
assert.ok(historyIndex > 0);
});
test("getCompletions does not treat generator-only spec args as path contexts", async () => {
recordCommand("story pick package-choice", "host-1");
const completions = await getCompletions("story pick pa", {
hostId: "host-1",
protocol: "local",
cwd: "/repo",
});
assert.ok(completions.length > 0);
assert.equal(completions[0]?.source, "history");
assert.equal(completions[0]?.text, "story pick package-choice");
assert.equal(completions.some((entry) => entry.source === "path"), false);
});

View File

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

View File

@@ -0,0 +1,787 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createTerminalSessionStarters, getMissingChainHostIds } from "./createTerminalSessionStarters";
const noop = () => undefined;
test("getMissingChainHostIds reports unresolved jump hosts", () => {
assert.deepEqual(
getMissingChainHostIds(
{
id: "host-1",
label: "Example",
hostname: "example.test",
username: "alice",
hostChain: { hostIds: ["jump-1", "jump-2"] },
} as never,
[{ id: "jump-1" }] as never,
),
["jump-2"],
);
});
test("startMosh does not pass legacy configured mosh client paths to the backend", async () => {
let capturedOptions: Record<string, unknown> | null = null;
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async (options: Record<string, unknown>) => {
capturedOptions = options;
return "mosh-session";
},
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = {
host: {
id: "host-1",
label: "Example",
hostname: "example.test",
username: "alice",
port: 2200,
},
keys: [],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: {
terminalEmulationType: "xterm-256color",
moshClientPath: "/usr/local/bin/mosh-client",
},
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: noop,
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
};
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
assert.ok(capturedOptions);
assert.equal("moshClientPath" in capturedOptions, false);
assert.equal(capturedOptions.hostname, "example.test");
assert.equal(capturedOptions.port, 2200);
});
test("startMosh passes the saved password to the mosh backend", async () => {
let capturedOptions: Record<string, unknown> | null = null;
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async (options: Record<string, unknown>) => {
capturedOptions = options;
return "mosh-session";
},
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = {
host: {
id: "host-1",
label: "Example",
hostname: "example.test",
username: "alice",
password: "saved-secret",
port: 2200,
},
keys: [],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: {},
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: noop,
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
};
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
assert.ok(capturedOptions);
assert.equal(capturedOptions.username, "alice");
assert.equal(capturedOptions.password, "saved-secret");
});
test("startMosh passes configured key material to the mosh backend", async () => {
let capturedOptions: Record<string, unknown> | null = null;
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async (options: Record<string, unknown>) => {
capturedOptions = options;
return "mosh-session";
},
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = {
host: {
id: "host-1",
label: "Example",
hostname: "example.test",
username: "alice",
password: "wrong-password",
authMethod: "key",
identityFileId: "key-1",
identityFilePaths: ["/should/not/be/used"],
port: 2200,
},
keys: [{
id: "key-1",
label: "Deploy key",
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----",
passphrase: "key-passphrase",
}],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: {},
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: noop,
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
};
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
assert.ok(capturedOptions);
assert.equal(capturedOptions.password, "wrong-password");
assert.equal(capturedOptions.privateKey, "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----");
assert.equal(capturedOptions.keyId, "key-1");
assert.equal(capturedOptions.passphrase, "key-passphrase");
assert.equal(capturedOptions.identityFilePaths, undefined);
});
test("startMosh asks for credential re-entry when saved key material cannot be decrypted", async () => {
let started = false;
let needsAuth = false;
let retryMessage: string | null = null;
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async () => {
started = true;
return "mosh-session";
},
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = {
host: {
id: "host-1",
label: "Example",
hostname: "example.test",
username: "alice",
authMethod: "key",
identityFileId: "key-1",
port: 2200,
},
keys: [{
id: "key-1",
label: "Deploy key",
privateKey: "enc:v1:djEwAAAA",
}],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: {},
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: noop,
setNeedsAuth: (value: boolean) => { needsAuth = value; },
setAuthRetryMessage: (message: string | null) => { retryMessage = message; },
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
};
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
assert.equal(started, false);
assert.equal(needsAuth, true);
assert.match(retryMessage || "", /Saved credentials cannot be decrypted/);
});
test("startMosh omits identity file paths when password auth is explicit", async () => {
let capturedOptions: Record<string, unknown> | null = null;
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async (options: Record<string, unknown>) => {
capturedOptions = options;
return "mosh-session";
},
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = {
host: {
id: "host-1",
label: "Example",
hostname: "example.test",
username: "alice",
authMethod: "password",
password: "saved-secret",
identityFilePaths: ["/should/not/be/used"],
port: 2200,
},
keys: [],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: {},
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: noop,
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
};
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
assert.ok(capturedOptions);
assert.equal(capturedOptions.password, "saved-secret");
assert.equal(capturedOptions.identityFilePaths, undefined);
});
test("startMosh rejects missing saved proxy profiles", async () => {
let started = false;
let error = "";
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async () => {
started = true;
return "mosh-session";
},
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = {
host: {
id: "host-1",
label: "Example",
hostname: "example.test",
username: "alice",
port: 2200,
proxyProfileId: "missing-proxy",
},
keys: [],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: {},
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: (message: string) => { error = message; },
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
};
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
assert.equal(started, false);
assert.match(error, /Saved proxy/);
});
test("startMosh rejects configured proxies instead of connecting directly", async () => {
let started = false;
let error = "";
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async () => {
started = true;
return "mosh-session";
},
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = {
host: {
id: "host-1",
label: "Example",
hostname: "example.test",
username: "alice",
port: 2200,
proxyProfileId: "proxy-1",
proxyConfig: { type: "http", host: "proxy.example.com", port: 3128 },
},
keys: [],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: {},
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: (message: string) => { error = message; },
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
};
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
assert.equal(started, false);
assert.match(error, /Mosh does not support proxy/);
});
test("startMosh rejects jump host chains instead of connecting directly", async () => {
let started = false;
let error = "";
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async () => {
started = true;
return "mosh-session";
},
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = {
host: {
id: "host-1",
label: "Example",
hostname: "example.test",
username: "alice",
hostChain: { hostIds: ["jump-1"] },
port: 2200,
},
keys: [],
resolvedChainHosts: [{ id: "jump-1", hostname: "jump.example.test" }],
sessionId: "session-1",
terminalSettings: {},
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: (message: string) => { error = message; },
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
};
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
assert.equal(started, false);
assert.match(error, /Mosh does not support jump host chains/);
});
test("startTelnet rejects missing saved proxy profiles", async () => {
let started = false;
let error = "";
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => {
started = true;
return "telnet-session";
},
startMoshSession: async () => "mosh-session",
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = {
host: {
id: "host-1",
label: "Example",
hostname: "example.test",
username: "alice",
telnetPort: 2323,
proxyProfileId: "missing-proxy",
},
keys: [],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: {},
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: (message: string) => { error = message; },
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
};
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startTelnet(term as never);
assert.equal(started, false);
assert.match(error, /Saved proxy/);
});
test("startTelnet rejects configured proxies instead of connecting directly", async () => {
let started = false;
let error = "";
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => {
started = true;
return "telnet-session";
},
startMoshSession: async () => "mosh-session",
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = {
host: {
id: "host-1",
label: "Example",
hostname: "example.test",
username: "alice",
telnetPort: 2323,
proxyProfileId: "proxy-1",
proxyConfig: { type: "http", host: "proxy.example.com", port: 3128 },
},
keys: [],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: {},
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: (message: string) => { error = message; },
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
};
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startTelnet(term as never);
assert.equal(started, false);
assert.match(error, /Telnet does not support proxy/);
});

View File

@@ -143,6 +143,16 @@ export type TerminalSessionStartersContext = {
) => void;
};
export const getMissingChainHostIds = (
host: Host,
resolvedChainHosts: Host[],
): string[] => {
const requestedIds = host.hostChain?.hostIds ?? [];
if (requestedIds.length === 0) return [];
const resolvedIds = new Set(resolvedChainHosts.map((chainHost) => chainHost.id));
return requestedIds.filter((hostId) => !resolvedIds.has(hostId));
};
const buildTermEnv = (host: Host, terminalSettings?: TerminalSettings) => {
const env: Record<string, string> = {
TERM: terminalSettings?.terminalEmulationType ?? "xterm-256color",
@@ -337,6 +347,24 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
return;
}
const missingChainHostIds = getMissingChainHostIds(ctx.host, ctx.resolvedChainHosts);
if (missingChainHostIds.length > 0) {
const base = tr(
"terminal.auth.jumpHostMissing",
"A configured jump host is missing. Open host settings and repair the jump host chain.",
);
const suffix = missingChainHostIds.length > 2
? ` +${missingChainHostIds.length - 2}`
: "";
const message = `${base} (${missingChainHostIds.slice(0, 2).join(", ")}${suffix})`;
ctx.setNeedsAuth(false);
ctx.setAuthRetryMessage(null);
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
const pendingAuth = ctx.pendingAuthRef.current;
const resolvedAuth = resolveHostAuth({
host: ctx.host,
@@ -372,6 +400,13 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
const rawProxyPassword = ctx.host.proxyConfig?.password;
if (ctx.host.proxyProfileId && !ctx.host.proxyConfig) {
const message = `Saved proxy for host "${ctx.host.label || ctx.host.hostname}" is missing. Open host settings and select a valid proxy.`;
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
const hasEncryptedProxyPassword = isEncryptedCredentialPlaceholder(rawProxyPassword);
const proxyConfig = ctx.host.proxyConfig
? {
@@ -384,6 +419,14 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
: undefined;
const jumpHostsWithUnavailableCredentials: string[] = [];
const unresolvedJumpProxyHost = ctx.resolvedChainHosts.find((jumpHost) => jumpHost.proxyProfileId && !jumpHost.proxyConfig);
if (unresolvedJumpProxyHost) {
const message = `Saved proxy for jump host "${unresolvedJumpProxyHost.label || unresolvedJumpProxyHost.hostname}" is missing. Open host settings and select a valid proxy.`;
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost, index) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
@@ -720,6 +763,22 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
return;
}
if (ctx.host.proxyProfileId && !ctx.host.proxyConfig) {
const message = `Saved proxy for host "${ctx.host.label || ctx.host.hostname}" is missing. Open host settings and select a valid proxy.`;
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
if (ctx.host.proxyConfig?.host && ctx.host.proxyConfig?.port) {
const message = "Telnet does not support proxy connections. Use SSH for this host or remove the proxy from this connection.";
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
try {
const telnetEnv = buildTermEnv(ctx.host, ctx.terminalSettings);
const id = await ctx.terminalBackend.startTelnetSession({
@@ -754,14 +813,95 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
try {
const stopMosh = (message: string) => {
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
ctx.updateStatus("disconnected");
};
if (ctx.host.proxyProfileId && !ctx.host.proxyConfig) {
stopMosh(`Saved proxy for host "${ctx.host.label || ctx.host.hostname}" is missing. Open host settings and select a valid proxy.`);
return;
}
const hasConfiguredJumpHostChain =
(ctx.host.hostChain?.hostIds?.length || 0) > 0 ||
ctx.resolvedChainHosts.length > 0;
if (hasConfiguredJumpHostChain) {
stopMosh("Mosh does not support jump host chains. Use SSH for this host or remove the jump hosts from this connection.");
return;
}
const unresolvedJumpProxyHost = ctx.resolvedChainHosts.find((jumpHost) => jumpHost.proxyProfileId && !jumpHost.proxyConfig);
if (unresolvedJumpProxyHost) {
stopMosh(`Saved proxy for jump host "${unresolvedJumpProxyHost.label || unresolvedJumpProxyHost.hostname}" is missing. Open host settings and select a valid proxy.`);
return;
}
const hasConfiguredProxy =
Boolean(ctx.host.proxyConfig?.host && ctx.host.proxyConfig?.port) ||
ctx.resolvedChainHosts.some((jumpHost) => Boolean(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port));
if (hasConfiguredProxy) {
stopMosh("Mosh does not support proxy connections. Use SSH for this host or remove the proxy from this connection.");
return;
}
const pendingAuth = ctx.pendingAuthRef.current;
const resolvedAuth = resolveHostAuth({
host: ctx.host,
keys: ctx.keys,
identities: ctx.identities,
override: pendingAuth
? {
authMethod: pendingAuth.authMethod,
username: pendingAuth.username,
password: pendingAuth.password,
keyId: pendingAuth.keyId,
passphrase: pendingAuth.passphrase,
}
: null,
});
const effectivePassword = sanitizeCredentialValue(resolvedAuth.password);
const effectivePassphrase = sanitizeCredentialValue(resolvedAuth.passphrase);
const authMethod = resolvedAuth.authMethod;
const key = authMethod === "password" ? undefined : resolvedAuth.key;
const hasEncryptedPrimaryPassword = isEncryptedCredentialPlaceholder(resolvedAuth.password);
const hasEncryptedPrimaryKey = isEncryptedCredentialPlaceholder(resolvedAuth.key?.privateKey);
const hasKeyMaterial = !!sanitizeCredentialValue(key?.privateKey) && authMethod !== "password";
const hasPassword = !!effectivePassword;
const needsCredentialReentry =
(authMethod === "password" && hasEncryptedPrimaryPassword && !hasPassword) ||
(authMethod !== "password" && hasEncryptedPrimaryKey && !hasKeyMaterial && !hasPassword);
if (needsCredentialReentry) {
ctx.setError(null);
ctx.setNeedsAuth(true);
ctx.setAuthRetryMessage(
tr(
"terminal.auth.credentialsUnavailable",
"Saved credentials cannot be decrypted on this device. Please re-enter and save them again.",
),
);
ctx.setAuthPassword("");
ctx.setStatus("connecting");
return;
}
const moshEnv = buildTermEnv(ctx.host, ctx.terminalSettings);
const id = await ctx.terminalBackend.startMoshSession({
sessionId: ctx.sessionId,
hostname: ctx.host.hostname,
username: ctx.host.username || "root",
username: resolvedAuth.username || "root",
password: effectivePassword,
privateKey: sanitizeCredentialValue(key?.privateKey),
certificate: key?.certificate,
keyId: key?.id,
passphrase: key
? (effectivePassphrase || sanitizeCredentialValue(key.passphrase))
: undefined,
identityFilePaths: authMethod !== "password" && !key ? ctx.host.identityFilePaths : undefined,
port: ctx.host.port || 22,
moshServerPath: ctx.host.moshServerPath,
moshClientPath: ctx.terminalSettings?.moshClientPath || undefined,
agentForwarding: ctx.host.agentForwarding,
cols: term.cols,
rows: term.rows,

View File

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

View File

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

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

@@ -0,0 +1,132 @@
import test from "node:test";
import assert from "node:assert/strict";
import { applyGroupDefaults, resolveGroupDefaults } from "./groupConfig.ts";
import type { GroupConfig, Host } from "./models.ts";
const host = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
...overrides,
});
test("applyGroupDefaults lets a host proxy profile override a group custom proxy", () => {
const groupDefaults: Partial<GroupConfig> = {
proxyConfig: { type: "http", host: "group-proxy.example.com", port: 3128 },
};
const result = applyGroupDefaults(host({ proxyProfileId: "proxy-1" }), groupDefaults);
assert.equal(result.proxyProfileId, "proxy-1");
assert.equal(result.proxyConfig, undefined);
});
test("applyGroupDefaults lets a host custom proxy override a group proxy profile", () => {
const groupDefaults: Partial<GroupConfig> = {
proxyProfileId: "group-proxy",
};
const customProxy = { type: "socks5" as const, host: "host-proxy.example.com", port: 1080 };
const result = applyGroupDefaults(host({ proxyConfig: customProxy }), groupDefaults);
assert.equal(result.proxyProfileId, undefined);
assert.deepEqual(result.proxyConfig, customProxy);
});
test("resolveGroupDefaults treats saved and custom proxies as one inherited setting", () => {
const resolved = resolveGroupDefaults("prod/api", [
{
path: "prod",
proxyConfig: { type: "http", host: "parent-proxy.example.com", port: 3128 },
},
{
path: "prod/api",
proxyProfileId: "child-proxy",
},
]);
assert.equal(resolved.proxyProfileId, "child-proxy");
assert.equal(resolved.proxyConfig, undefined);
});
test("applyGroupDefaults keeps a missing host proxy profile instead of using group proxy", () => {
const groupDefaults: Partial<GroupConfig> = {
proxyProfileId: "group-proxy",
};
const result = applyGroupDefaults(
host({ proxyProfileId: "missing-proxy" }),
groupDefaults,
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(result.proxyProfileId, "missing-proxy");
assert.equal(result.proxyConfig, undefined);
});
test("applyGroupDefaults keeps a missing host proxy profile when no group fallback exists", () => {
const result = applyGroupDefaults(
host({ proxyProfileId: "missing-proxy" }),
{},
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(result.proxyProfileId, "missing-proxy");
assert.equal(result.proxyConfig, undefined);
});
test("applyGroupDefaults keeps a missing host proxy profile instead of using group custom proxy", () => {
const groupProxy = { type: "http" as const, host: "group-proxy.example.com", port: 3128 };
const result = applyGroupDefaults(
host({ proxyProfileId: "missing-proxy" }),
{ proxyConfig: groupProxy },
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(result.proxyProfileId, "missing-proxy");
assert.equal(result.proxyConfig, undefined);
});
test("resolveGroupDefaults keeps a missing group proxy marker when there is no fallback", () => {
const resolved = resolveGroupDefaults(
"prod",
[{ path: "prod", proxyProfileId: "missing-proxy" }],
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(resolved.proxyProfileId, "missing-proxy");
});
test("applyGroupDefaults inherits a missing group proxy marker so connect paths can fail", () => {
const result = applyGroupDefaults(
host({ group: "prod" }),
{ proxyProfileId: "missing-proxy" },
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(result.proxyProfileId, "missing-proxy");
assert.equal(result.proxyConfig, undefined);
});
test("resolveGroupDefaults keeps missing child proxy profiles instead of using parent proxy", () => {
const resolved = resolveGroupDefaults(
"prod/api",
[
{
path: "prod",
proxyConfig: { type: "http", host: "parent-proxy.example.com", port: 3128 },
},
{
path: "prod/api",
proxyProfileId: "missing-proxy",
},
],
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(resolved.proxyProfileId, "missing-proxy");
assert.equal(resolved.proxyConfig, undefined);
});

View File

@@ -1,5 +1,17 @@
import type { GroupConfig, Host } from './models';
export interface ApplyGroupDefaultsOptions {
validProxyProfileIds?: ReadonlySet<string>;
}
const hasUsableProxyProfileId = (
proxyProfileId: string | undefined,
options?: ApplyGroupDefaultsOptions,
): boolean => {
if (!proxyProfileId) return false;
return !options?.validProxyProfileIds || options.validProxyProfileIds.has(proxyProfileId);
};
/**
* Resolve merged group defaults by walking the ancestor chain.
* For group "A/B/C", merges configs from A, A/B, A/B/C (child overrides parent).
@@ -7,6 +19,7 @@ import type { GroupConfig, Host } from './models';
export function resolveGroupDefaults(
groupPath: string,
groupConfigs: GroupConfig[],
options?: ApplyGroupDefaultsOptions,
): Partial<GroupConfig> {
const configMap = new Map(groupConfigs.map((c) => [c.path, c]));
const parts = groupPath.split('/').filter(Boolean);
@@ -17,6 +30,14 @@ export function resolveGroupDefaults(
const config = configMap.get(ancestorPath);
if (config) {
for (const [key, value] of Object.entries(config)) {
if (
key === 'proxyProfileId' &&
typeof value === 'string' &&
options?.validProxyProfileIds &&
!options.validProxyProfileIds.has(value)
) {
delete merged.proxyConfig;
}
if (
(key === 'theme' && config.themeOverride === false) ||
(key === 'fontFamily' && config.fontFamilyOverride === false) ||
@@ -26,6 +47,12 @@ export function resolveGroupDefaults(
continue;
}
if (key !== 'path' && value !== undefined) {
if (key === 'proxyProfileId') {
delete merged.proxyConfig;
}
if (key === 'proxyConfig') {
delete merged.proxyProfileId;
}
merged[key] = value;
}
}
@@ -48,7 +75,7 @@ export function resolveGroupDefaults(
const INHERITABLE_KEYS: (keyof GroupConfig)[] = [
'username', 'password', 'savePassword', 'authMethod', 'identityId', 'identityFileId', 'identityFilePaths',
'port', 'protocol', 'agentForwarding', 'proxyConfig', 'hostChain', 'startupCommand',
'port', 'protocol', 'agentForwarding', 'proxyProfileId', 'proxyConfig', 'hostChain', 'startupCommand',
'legacyAlgorithms', 'environmentVariables', 'charset', 'moshEnabled', 'moshServerPath',
'telnetEnabled', 'telnetPort', 'telnetUsername', 'telnetPassword',
'theme', 'themeOverride', 'fontFamily', 'fontFamilyOverride', 'fontSize', 'fontSizeOverride', 'fontWeight', 'fontWeightOverride',
@@ -59,10 +86,20 @@ const INHERITABLE_KEYS: (keyof GroupConfig)[] = [
* Apply group defaults to a host. Only fills in fields the host doesn't already have.
* Returns a new host object — does NOT mutate the original.
*/
export function applyGroupDefaults(host: Host, groupDefaults: Partial<GroupConfig>): Host {
export function applyGroupDefaults(
host: Host,
groupDefaults: Partial<GroupConfig>,
options?: ApplyGroupDefaultsOptions,
): Host {
const effective = { ...host };
const hostHasUsableProxyProfile = hasUsableProxyProfileId(host.proxyProfileId, options);
for (const key of INHERITABLE_KEYS) {
const hostValue = (host as unknown as Record<string, unknown>)[key];
if (key === 'proxyProfileId') {
if (host.proxyConfig !== undefined || !groupDefaults.proxyProfileId) continue;
}
if (key === 'proxyConfig' && (host.proxyProfileId !== undefined || hostHasUsableProxyProfile)) continue;
const hostValue = (effective as unknown as Record<string, unknown>)[key];
const groupValue = (groupDefaults as unknown as Record<string, unknown>)[key];
if ((hostValue === undefined || hostValue === '' || hostValue === null) && groupValue !== undefined) {
(effective as unknown as Record<string, unknown>)[key] = groupValue;

View File

@@ -11,6 +11,14 @@ export interface ProxyConfig {
password?: string;
}
export interface ProxyProfile {
id: string;
label: string;
config: ProxyConfig;
createdAt: number;
updatedAt?: number;
}
// Host chain configuration for jump host / bastion connections
export interface HostChainConfig {
hostIds: string[]; // Array of host IDs in order (first = closest to client)
@@ -83,6 +91,7 @@ export interface Host {
startupCommand?: string;
hostChaining?: string; // Deprecated: use hostChain instead
proxy?: string; // Deprecated: use proxyConfig instead
proxyProfileId?: string; // Reference to reusable proxy profile
proxyConfig?: ProxyConfig; // New structured proxy configuration
hostChain?: HostChainConfig; // New structured host chain configuration
envVars?: string; // Deprecated: use environmentVariables instead
@@ -205,6 +214,7 @@ export interface GroupConfig {
port?: number;
protocol?: 'ssh' | 'telnet';
agentForwarding?: boolean;
proxyProfileId?: string;
proxyConfig?: ProxyConfig;
hostChain?: HostChainConfig;
startupCommand?: string;
@@ -494,9 +504,8 @@ export interface TerminalSettings {
x11Display: string; // Optional local X11 DISPLAY override (empty = use system DISPLAY/default)
// Mosh Connection
// Absolute path to the local `mosh` client binary. Empty triggers
// auto-discovery (PATH + Homebrew/MacPorts/nix fallbacks). When set,
// the value is used as-is and a missing file produces a clear error.
// Legacy override retained for old settings payloads and internal callers.
// The normal UI path uses Netcatty's bundled mosh-client.
moshClientPath: string;
// Server Stats Display (Linux only)
@@ -644,7 +653,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
localStartDir: '', // Empty = use home directory
keepaliveInterval: 0, // 0 = disabled (use SSH library defaults)
x11Display: '', // Empty = use DISPLAY/default local X server
moshClientPath: '', // Empty = auto-detect mosh on PATH / common install dirs
moshClientPath: '', // Legacy mosh-client override; normal UI uses bundled mosh-client
showServerStats: true, // Show server stats by default
serverStatsRefreshInterval: 5, // Refresh every 5 seconds
disableBracketedPaste: false, // Bracketed paste enabled by default
@@ -781,6 +790,7 @@ export type TransferDirection = 'upload' | 'download' | 'remote-to-remote' | 'lo
export interface TransferTask {
id: string;
batchId?: string;
fileName: string;
originalFileName?: string;
sourcePath: string;
@@ -805,14 +815,21 @@ export interface TransferTask {
parentTaskId?: string;
sourceLastModified?: number; // Cached from file list to avoid redundant stat
skipConflictCheck?: boolean; // Skip conflict check for replace operations
replaceExistingTarget?: boolean; // Delete the existing target before transferring
retryable?: boolean; // False for task types that cannot be safely replayed through generic retry
}
export type FileConflictAction = 'stop' | 'skip' | 'replace' | 'duplicate' | 'merge';
export interface FileConflict {
transferId: string;
batchId?: string;
fileName: string;
sourcePath: string;
targetPath: string;
isDirectory: boolean;
existingType?: 'file' | 'directory' | 'symlink';
applyToAllCount?: number;
existingSize: number;
newSize: number;
existingModified: number;

View File

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

77
domain/proxyProfiles.ts Normal file
View File

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

View File

@@ -164,6 +164,7 @@ export interface SyncPayload {
hosts: import('./models').Host[];
keys: import('./models').SSHKey[];
identities?: import('./models').Identity[];
proxyProfiles?: import('./models').ProxyProfile[];
snippets: import('./models').Snippet[];
customGroups: string[];
snippetPackages?: string[];

View File

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

View File

@@ -9,10 +9,10 @@ export type ShrinkFinding =
| 'hosts'
| 'keys'
| 'identities'
| 'proxyProfiles'
| 'snippets'
| 'customGroups'
| 'snippetPackages'
| 'knownHosts'
| 'portForwardingRules'
| 'groupConfigs';
baseCount: number;
@@ -29,10 +29,10 @@ const CHECKED_ENTITIES = [
'hosts',
'keys',
'identities',
'proxyProfiles',
'snippets',
'customGroups',
'snippetPackages',
'knownHosts',
'portForwardingRules',
'groupConfigs',
] as const;

138
domain/syncMerge.test.ts Normal file
View File

@@ -0,0 +1,138 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mergeSyncPayloads } from "./syncMerge.ts";
import type { SyncPayload } from "./sync.ts";
function payload(overrides: Partial<SyncPayload> = {}): SyncPayload {
return {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
snippetPackages: [],
portForwardingRules: [],
groupConfigs: [],
settings: undefined,
syncedAt: 0,
...overrides,
};
}
const knownHosts = (n: number): SyncPayload["knownHosts"] =>
Array.from({ length: n }, (_, i) => ({
id: `kh-${i}`,
hostname: `host-${i}.example.com`,
port: 22,
keyType: "ssh-ed25519",
publicKey: `SHA256:${i}`,
discoveredAt: 1,
}));
test("mergeSyncPayloads does not carry legacy known hosts forward", () => {
const result = mergeSyncPayloads(
payload({ knownHosts: knownHosts(2) }),
payload(),
payload({ knownHosts: knownHosts(3) }),
);
assert.equal("knownHosts" in result.payload, false);
});
test("mergeSyncPayloads merges reusable proxy profiles by id", () => {
const localProfile = {
id: "proxy-local",
label: "Local Proxy",
config: { type: "http", host: "local.example.com", port: 3128 },
createdAt: 1,
updatedAt: 1,
};
const remoteProfile = {
id: "proxy-remote",
label: "Remote Proxy",
config: { type: "socks5", host: "remote.example.com", port: 1080 },
createdAt: 2,
updatedAt: 2,
};
const result = mergeSyncPayloads(
payload(),
payload({ proxyProfiles: [localProfile] } as Partial<SyncPayload>),
payload({ proxyProfiles: [remoteProfile] } as Partial<SyncPayload>),
);
assert.deepEqual(result.payload.proxyProfiles?.map((item) => item.id).sort(), [
"proxy-local",
"proxy-remote",
]);
});
test("mergeSyncPayloads preserves proxy profiles when remote payload predates them", () => {
const proxy = {
id: "proxy-1",
label: "Office Proxy",
config: { type: "http", host: "proxy.example.com", port: 3128 },
createdAt: 1,
};
const result = mergeSyncPayloads(
payload({ proxyProfiles: [proxy] } as Partial<SyncPayload>),
payload({ proxyProfiles: [proxy] } as Partial<SyncPayload>),
payload(),
);
assert.deepEqual(result.payload.proxyProfiles, [proxy]);
});
test("mergeSyncPayloads keeps missing proxy references visible to connection guards", () => {
const result = mergeSyncPayloads(
payload({
hosts: [{
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
proxyProfileId: "proxy-1",
}],
proxyProfiles: [{
id: "proxy-1",
label: "Old Proxy",
config: { type: "http", host: "old.example.com", port: 3128 },
createdAt: 1,
}],
groupConfigs: [{ path: "prod", proxyProfileId: "proxy-1" }],
}),
payload({
hosts: [{
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
proxyProfileId: "proxy-1",
}],
proxyProfiles: [],
groupConfigs: [{ path: "prod", proxyProfileId: "proxy-1" }],
}),
payload({
hosts: [{
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
proxyProfileId: "proxy-1",
}],
proxyProfiles: [],
groupConfigs: [{ path: "prod", proxyProfileId: "proxy-1" }],
}),
);
assert.equal(result.payload.hosts[0]?.proxyProfileId, "proxy-1");
assert.equal(result.payload.groupConfigs?.[0]?.proxyProfileId, "proxy-1");
});

View File

@@ -344,10 +344,10 @@ export function mergeSyncPayloads(
hosts: [],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
customGroups: [],
snippetPackages: [],
knownHosts: [],
portForwardingRules: [],
settings: undefined,
syncedAt: 0,
@@ -364,20 +364,13 @@ export function mergeSyncPayloads(
const hosts = mergeEntityArrays(b.hosts ?? [], local.hosts ?? [], remote.hosts ?? []);
const keys = mergeEntityArrays(b.keys ?? [], local.keys ?? [], remote.keys ?? []);
const identities = mergeEntityArrays(b.identities ?? [], local.identities ?? [], remote.identities ?? []);
const baseProxyProfiles = b.proxyProfiles ?? [];
const proxyProfiles = mergeEntityArrays(
baseProxyProfiles,
local.proxyProfiles ?? baseProxyProfiles,
remote.proxyProfiles ?? baseProxyProfiles,
);
const snippets = mergeEntityArrays(b.snippets ?? [], local.snippets ?? [], remote.snippets ?? []);
const knownHostsRaw = mergeEntityArrays(b.knownHosts ?? [], local.knownHosts ?? [], remote.knownHosts ?? []);
// Deduplicate known hosts by (hostname, port, keyType) since IDs are random per device
const knownHostSeen = new Set<string>();
const knownHosts = {
...knownHostsRaw,
merged: knownHostsRaw.merged.filter((kh) => {
const entry = kh as unknown as { hostname: string; port: number; keyType: string };
const fp = `${entry.hostname}:${entry.port}:${entry.keyType}`;
if (knownHostSeen.has(fp)) return false;
knownHostSeen.add(fp);
return true;
}),
};
const portForwardingRules = mergeEntityArrays(
b.portForwardingRules ?? [],
local.portForwardingRules ?? [],
@@ -390,11 +383,16 @@ export function mergeSyncPayloads(
(arr ?? []).map(gc => ({ ...gc, id: gc.path }));
const unwrapGC = (arr: GCWithId[]): import('./models').GroupConfig[] =>
arr.map(({ id: _id, ...rest }) => rest as import('./models').GroupConfig);
const groupConfigsResult = mergeEntityArrays(wrapGC(b.groupConfigs), wrapGC(local.groupConfigs), wrapGC(remote.groupConfigs));
const baseGroupConfigs = b.groupConfigs ?? [];
const groupConfigsResult = mergeEntityArrays(
wrapGC(baseGroupConfigs),
wrapGC(local.groupConfigs ?? baseGroupConfigs),
wrapGC(remote.groupConfigs ?? baseGroupConfigs),
);
// Aggregate stats
const entityResults: Pick<EntityMergeResult<unknown>, 'added' | 'deleted' | 'modified' | 'conflicts'>[] =
[hosts, keys, identities, snippets, knownHosts, portForwardingRules, groupConfigsResult];
[hosts, keys, identities, proxyProfiles, snippets, portForwardingRules, groupConfigsResult];
for (const r of entityResults) {
summary.added.local += r.added.local;
summary.added.remote += r.added.remote;
@@ -430,16 +428,18 @@ export function mergeSyncPayloads(
});
}
const groupConfigs = unwrapGC(groupConfigsResult.merged);
const payload: SyncPayload = {
hosts: hosts.merged,
keys: keys.merged,
identities: identities.merged,
proxyProfiles: proxyProfiles.merged,
snippets: snippets.merged,
customGroups,
snippetPackages,
knownHosts: knownHosts.merged,
portForwardingRules: portForwardingRules.merged,
groupConfigs: unwrapGC(groupConfigsResult.merged),
groupConfigs,
settings,
syncedAt: Date.now(),
};

View File

@@ -0,0 +1,48 @@
import test from "node:test";
import assert from "node:assert/strict";
import { applyCustomAccentToTerminalTheme } from "./terminalAppearance";
import type { TerminalTheme } from "./models";
const baseTheme: TerminalTheme = {
id: "ui-snow",
name: "Snow",
type: "light",
colors: {
background: "#f1f4f8",
foreground: "#24292f",
cursor: "#0969da",
selection: "#add6ff",
black: "#24292f",
red: "#cf222e",
green: "#116329",
yellow: "#9a6700",
blue: "#0969da",
magenta: "#8250df",
cyan: "#0e7574",
white: "#6e7781",
brightBlack: "#57606a",
brightRed: "#a40e26",
brightGreen: "#1a7f37",
brightYellow: "#7d4e00",
brightBlue: "#218bff",
brightMagenta: "#a475f9",
brightCyan: "#0c7875",
brightWhite: "#8c959f",
},
};
test("applies a custom accent to terminal cursor and selection colors", () => {
const accented = applyCustomAccentToTerminalTheme(baseTheme, "custom", "160 70% 40%");
assert.notEqual(accented, baseTheme);
assert.equal(accented.colors.cursor, "#1fad7e");
assert.equal(accented.colors.selection, "#b1f1dc");
assert.equal(baseTheme.colors.cursor, "#0969da");
assert.equal(baseTheme.colors.selection, "#add6ff");
});
test("keeps terminal theme unchanged without a valid custom accent", () => {
assert.equal(applyCustomAccentToTerminalTheme(baseTheme, "theme", "160 70% 40%"), baseTheme);
assert.equal(applyCustomAccentToTerminalTheme(baseTheme, "custom", "not-a-color"), baseTheme);
});

View File

@@ -1,4 +1,4 @@
import { Host } from './models';
import { Host, TerminalTheme } from './models';
const hasLegacyStringValue = (value: string | undefined): boolean =>
typeof value === 'string' && value.trim().length > 0;
@@ -69,6 +69,95 @@ const UI_TO_TERMINAL_THEME: Record<string, string> = {
export const getTerminalThemeForUiTheme = (uiThemeId: string): string | undefined =>
UI_TO_TERMINAL_THEME[uiThemeId];
type ParsedHslToken = {
hue: number;
saturation: number;
lightness: number;
};
const parseHslToken = (value: string): ParsedHslToken | null => {
const match = value.trim().match(/^(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)%\s+(\d+(?:\.\d+)?)%$/);
if (!match) return null;
const hue = Number(match[1]);
const saturation = Number(match[2]);
const lightness = Number(match[3]);
if (!Number.isFinite(hue) || !Number.isFinite(saturation) || !Number.isFinite(lightness)) return null;
if (saturation < 0 || saturation > 100 || lightness < 0 || lightness > 100) return null;
return {
hue: ((hue % 360) + 360) % 360,
saturation,
lightness,
};
};
const toHexChannel = (value: number): string =>
Math.round(Math.max(0, Math.min(255, value)))
.toString(16)
.padStart(2, '0');
const hslToHex = ({ hue, saturation, lightness }: ParsedHslToken): string => {
const s = saturation / 100;
const l = lightness / 100;
const c = (1 - Math.abs(2 * l - 1)) * s;
const hp = hue / 60;
const x = c * (1 - Math.abs((hp % 2) - 1));
let r = 0;
let g = 0;
let b = 0;
if (hp < 1) {
r = c;
g = x;
} else if (hp < 2) {
r = x;
g = c;
} else if (hp < 3) {
g = c;
b = x;
} else if (hp < 4) {
g = x;
b = c;
} else if (hp < 5) {
r = x;
b = c;
} else {
r = c;
b = x;
}
const m = l - c / 2;
return `#${toHexChannel((r + m) * 255)}${toHexChannel((g + m) * 255)}${toHexChannel((b + m) * 255)}`;
};
const terminalSelectionFromAccent = (accent: ParsedHslToken, type: TerminalTheme['type']): ParsedHslToken => ({
...accent,
lightness: type === 'dark'
? Math.max(18, Math.min(32, accent.lightness * 0.55))
: Math.max(72, Math.min(88, accent.lightness + 42)),
});
export const applyCustomAccentToTerminalTheme = (
theme: TerminalTheme,
accentMode: 'theme' | 'custom',
customAccent: string,
): TerminalTheme => {
if (accentMode !== 'custom') return theme;
const accent = parseHslToken(customAccent);
if (!accent) return theme;
return {
...theme,
colors: {
...theme.colors,
cursor: hslToHex(accent),
selection: hslToHex(terminalSelectionFromAccent(accent, theme.type)),
},
};
};
export const resolveHostTerminalFontFamilyId = (host: Host | null | undefined, defaultFontFamilyId: string): string =>
hasHostFontFamilyOverride(host) && host?.fontFamily ? host.fontFamily : defaultFontFamilyId;
@@ -86,4 +175,3 @@ export const clearHostFontWeightOverride = (host: Host): Host => ({
export const resolveHostTerminalFontWeight = (host: Host | null | undefined, defaultFontWeight: number): number =>
hasHostFontWeightOverride(host) && host?.fontWeight != null ? host.fontWeight : defaultFontWeight;

View File

@@ -1,3 +1,5 @@
const { moshExtraResources } = require('./scripts/mosh-extra-resources.cjs');
/**
* @type {import('electron-builder').Configuration}
*/
@@ -79,7 +81,8 @@ module.exports = {
NSCameraUsageDescription: 'Netcatty may use the camera for video calls',
NSMicrophoneUsageDescription: 'Netcatty may use the microphone for audio',
NSLocalNetworkUsageDescription: 'Netcatty needs local network access for SSH connections'
}
},
extraResources: moshExtraResources('darwin')
},
dmg: {
title: '${productName}',
@@ -105,7 +108,8 @@ module.exports = {
target: 'portable',
arch: ['x64', 'arm64']
}
]
],
extraResources: moshExtraResources('win32')
},
portable: {
artifactName: '${productName}-${version}-portable-${os}-${arch}.${ext}',
@@ -125,7 +129,8 @@ module.exports = {
// GNOME launchers or AppImage integrations.
icon: 'public/icon-win.png',
target: ['AppImage', 'deb', 'rpm'],
category: 'Development'
category: 'Development',
extraResources: moshExtraResources('linux')
},
deb: {
// Use gzip instead of default xz(lzma) for better compatibility with

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