Compare commits
135 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
109d0a7ab7 | ||
|
|
92ecd84edf | ||
|
|
311f44525b | ||
|
|
b4e185e1c6 | ||
|
|
92dd898eb4 | ||
|
|
478e148b40 | ||
|
|
231fb9c74c | ||
|
|
8870eb4de9 | ||
|
|
c9114eb198 | ||
|
|
938d1ef48b | ||
|
|
52c097d9f8 | ||
|
|
684c094d40 | ||
|
|
d84c2cc902 | ||
|
|
3a233a3279 | ||
|
|
ba675fa944 | ||
|
|
c9da2a5893 | ||
|
|
a377d39446 | ||
|
|
4b7249997f | ||
|
|
eb3f55b477 | ||
|
|
bce33f34ee | ||
|
|
b6c59b9683 | ||
|
|
ff6b75aba7 | ||
|
|
b65ed74ced | ||
|
|
6c6a051c0c | ||
|
|
621eae28f4 | ||
|
|
2329014e22 | ||
|
|
5c5ab21b10 | ||
|
|
a01ee1da61 | ||
|
|
c94ded1a77 | ||
|
|
59de39e2ab | ||
|
|
4a3869369e | ||
|
|
11856b09e5 | ||
|
|
76b013f128 | ||
|
|
44abf420c2 | ||
|
|
cb98bdba2b | ||
|
|
18d411bb95 | ||
|
|
1e80337a46 | ||
|
|
f1cfce45cf | ||
|
|
833f9d2cac | ||
|
|
72847a05af | ||
|
|
0eccb2a252 | ||
|
|
8a44152b36 | ||
|
|
c20abd86d9 | ||
|
|
3fc9622695 | ||
|
|
eb1fd9c127 | ||
|
|
5cf1dd1de6 | ||
|
|
137f8affbb | ||
|
|
b9ac14f497 | ||
|
|
43097c43b1 | ||
|
|
329e94752b | ||
|
|
b6a34131f6 | ||
|
|
3f16818d8d | ||
|
|
3efc9ada8e | ||
|
|
8efdd1c9cb | ||
|
|
585a654668 | ||
|
|
72e305fb7a | ||
|
|
012a6bf521 | ||
|
|
4c72d5e0af | ||
|
|
cedc7f6c5f | ||
|
|
155463f77c | ||
|
|
e5a74058ad | ||
|
|
4ced32257e | ||
|
|
64e7719715 | ||
|
|
04b5aba62d | ||
|
|
9f97f3870d | ||
|
|
6bfd0e17a2 | ||
|
|
1ac538eedc | ||
|
|
d34e23c7b3 | ||
|
|
31bf5396cb | ||
|
|
2feecaa9b6 | ||
|
|
1f0d3d8274 | ||
|
|
d8c62a55f5 | ||
|
|
1b08e5ee88 | ||
|
|
de7057183c | ||
|
|
dd910cc53d | ||
|
|
8ccefc821c | ||
|
|
863397fc7d | ||
|
|
6a39ed05a9 | ||
|
|
470d9b5aae | ||
|
|
20694a47dd | ||
|
|
d86c5ed05a | ||
|
|
fdaaaf62d8 | ||
|
|
2ceea46b50 | ||
|
|
5a1d6931a5 | ||
|
|
fb97e242ee | ||
|
|
68040ebdd7 | ||
|
|
cca6dac543 | ||
|
|
d86b720748 | ||
|
|
aa192c66c3 | ||
|
|
7dd25a55bb | ||
|
|
e4e1b54374 | ||
|
|
4dd2465388 | ||
|
|
b6734b9ef9 | ||
|
|
fb443541aa | ||
|
|
7622c43c38 | ||
|
|
a4a5c703b1 | ||
|
|
2063a5ccfe | ||
|
|
1fcf77ef4d | ||
|
|
8296c2c780 | ||
|
|
d1e6857f76 | ||
|
|
eccb9f2cfc | ||
|
|
74d56cdcb8 | ||
|
|
cd04b0b33c | ||
|
|
a29953f831 | ||
|
|
c941038e68 | ||
|
|
b1ab4d7105 | ||
|
|
08e566adb0 | ||
|
|
df25d6c4b0 | ||
|
|
324301e61a | ||
|
|
2c3a8e7fb8 | ||
|
|
bd2642be74 | ||
|
|
23151c9db8 | ||
|
|
8215dfe6a1 | ||
|
|
a1866747a5 | ||
|
|
78fc4628b9 | ||
|
|
c721591466 | ||
|
|
8514c75301 | ||
|
|
c30d872852 | ||
|
|
c58f018d24 | ||
|
|
dd1d97ffff | ||
|
|
3c6d888ca9 | ||
|
|
73b27ad7c4 | ||
|
|
4090483738 | ||
|
|
9bf4aed44f | ||
|
|
a5b5f15343 | ||
|
|
5b26a4a447 | ||
|
|
6565e984b4 | ||
|
|
587071cfea | ||
|
|
08f00ed143 | ||
|
|
b9e9a0d59c | ||
|
|
d02e91a14d | ||
|
|
f38afd8bfc | ||
|
|
c3dabbfef2 | ||
|
|
d5c937b7a9 | ||
|
|
c32a8e603f |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
8
.github/scripts/generate-release-note.js
vendored
8
.github/scripts/generate-release-note.js
vendored
@@ -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: `[](${baseUrl}/${files.win.x64})`,
|
||||
setup_arm64: `[](${baseUrl}/${files.win.arm64})`
|
||||
setup_x64: `[](${baseUrl}/${files.win.x64})`
|
||||
},
|
||||
mac: {
|
||||
apple_silicon: `[](${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} |
|
||||
`;
|
||||
|
||||
233
.github/workflows/build-mosh-binaries.yml
vendored
Normal file
233
.github/workflows/build-mosh-binaries.yml
vendored
Normal 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
|
||||
310
.github/workflows/build.yml
vendored
310
.github/workflows/build.yml
vendored
@@ -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
37
.github/workflows/test.yml
vendored
Normal 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
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -55,8 +55,22 @@ coverage
|
||||
# Serena MCP project config (local only)
|
||||
/.serena/
|
||||
|
||||
# Git worktrees (local isolated workspaces)
|
||||
/.worktrees/
|
||||
|
||||
# Windows VS Build environment scripts (local dev only)
|
||||
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/
|
||||
|
||||
357
App.tsx
357
App.tsx
@@ -1,5 +1,5 @@
|
||||
import React, { Suspense, lazy, useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react';
|
||||
import { activeTabStore, useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive } from './application/state/activeTabStore';
|
||||
import { activeTabStore, useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive, toEditorTabId, fromEditorTabId, isEditorTabId } from './application/state/activeTabStore';
|
||||
import { useAutoSync } from './application/state/useAutoSync';
|
||||
import { useImmersiveMode } from './application/state/useImmersiveMode';
|
||||
import { useManagedSourceSync } from './application/state/useManagedSourceSync';
|
||||
@@ -10,19 +10,32 @@ import { useSettingsState } from './application/state/useSettingsState';
|
||||
import { useUpdateCheck } from './application/state/useUpdateCheck';
|
||||
import { useVaultState } from './application/state/useVaultState';
|
||||
import { useWindowControls } from './application/state/useWindowControls';
|
||||
import { useEditorTabs, editorTabStore } from './application/state/editorTabStore';
|
||||
import {
|
||||
clearReferenceKeyPassphrases,
|
||||
clearKeyPassphrasesByIds,
|
||||
loadDefaultKeyPassphrase,
|
||||
rememberKeyPassphrase,
|
||||
removeDefaultKeyPassphrases,
|
||||
shouldUpdateReferenceKeyPassphrase,
|
||||
} from './application/defaultKeyPassphrases';
|
||||
import { initializeFonts } from './application/state/fontStore';
|
||||
import { initializeUIFonts } from './application/state/uiFontStore';
|
||||
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
|
||||
import { matchesKeyBinding } from './domain/models';
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from './domain/groupConfig';
|
||||
import { upsertKnownHost } from './domain/knownHosts';
|
||||
import { materializeHostProxyProfile } from './domain/proxyProfiles';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { resolveHostTerminalThemeId } from './domain/terminalAppearance';
|
||||
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
|
||||
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from './domain/terminalAppearance';
|
||||
import { collectSessionIds } from './domain/workspace';
|
||||
import { resolveCloseIntent } from './application/state/resolveCloseIntent';
|
||||
import { resolveSnippetsShortcutIntent } from './application/state/resolveSnippetsShortcutIntent';
|
||||
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from './application/state/customThemeStore';
|
||||
import type { SyncPayload } from './domain/sync';
|
||||
import { applySyncPayload, buildSyncPayload, hasMeaningfulSyncData } from './application/syncPayload';
|
||||
import { applySyncPayload, buildLocalVaultPayload, hasMeaningfulSyncData } from './application/syncPayload';
|
||||
import {
|
||||
applyProtectedSyncPayload,
|
||||
ensureVersionChangeBackup,
|
||||
@@ -50,10 +63,13 @@ import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal
|
||||
import { cn } from './lib/utils';
|
||||
import { classifyLocalShellType } from './lib/localShell';
|
||||
import { useDiscoveredShells, resolveShellSetting } from './lib/useDiscoveredShells';
|
||||
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalSession, TerminalTheme } from './types';
|
||||
import { ConnectionLog, Host, HostProtocol, KnownHost, SerialConfig, SSHKey, TerminalSession, TerminalTheme } from './types';
|
||||
import { LogView as LogViewType } from './application/state/useSessionState';
|
||||
import type { SftpView as SftpViewComponent } from './components/SftpView';
|
||||
import type { TerminalLayer as TerminalLayerComponent } from './components/TerminalLayer';
|
||||
import { TextEditorTabView } from './components/editor/TextEditorTabView';
|
||||
import { UnsavedChangesProvider } from './components/editor/UnsavedChangesDialog';
|
||||
import { releaseEditorTabSaveCoordinator, saveEditorTab } from './application/state/editorTabSave';
|
||||
|
||||
// Initialize fonts eagerly at app startup
|
||||
initializeFonts();
|
||||
@@ -202,6 +218,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
theme,
|
||||
setTheme,
|
||||
resolvedTheme,
|
||||
accentMode,
|
||||
customAccent,
|
||||
terminalThemeId,
|
||||
setTerminalThemeId,
|
||||
followAppTerminalTheme,
|
||||
@@ -246,6 +264,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
@@ -255,7 +274,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
managedSources,
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
importOrReuseKey,
|
||||
updateIdentities,
|
||||
updateProxyProfiles,
|
||||
updateSnippets,
|
||||
updateSnippetPackages,
|
||||
updateCustomGroups,
|
||||
@@ -275,6 +296,11 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
updateGroupConfigs,
|
||||
} = useVaultState();
|
||||
|
||||
const keysRef = useRef(keys);
|
||||
keysRef.current = keys;
|
||||
const knownHostsRef = useRef(knownHosts);
|
||||
knownHostsRef.current = knownHosts;
|
||||
|
||||
const {
|
||||
sessions,
|
||||
workspaces,
|
||||
@@ -330,6 +356,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// ---------------------------------------------------------------------------
|
||||
const activeTabId = useActiveTabId();
|
||||
const customThemes = useCustomThemes();
|
||||
const editorTabs = useEditorTabs();
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings.showSftpTab && activeTabId === 'sftp') {
|
||||
@@ -360,14 +387,19 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
if (activeTabId === 'vault' || activeTabId === 'sftp') return null;
|
||||
|
||||
const resolveTheme = (s: TerminalSession): TerminalTheme => {
|
||||
let baseTheme: TerminalTheme;
|
||||
// When "Follow Application Theme" is on, the UI-matched terminal
|
||||
// theme overrides everything — including per-host theme overrides.
|
||||
// This ensures all terminals match the app chrome regardless of
|
||||
// individual host settings.
|
||||
if (followAppTerminalTheme) return currentTerminalTheme;
|
||||
const host = hostById.get(s.hostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
return themeById.get(themeId) || currentTerminalTheme;
|
||||
if (followAppTerminalTheme) {
|
||||
baseTheme = currentTerminalTheme;
|
||||
} else {
|
||||
const host = hostById.get(s.hostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
baseTheme = themeById.get(themeId) || currentTerminalTheme;
|
||||
}
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
};
|
||||
|
||||
// Workspace
|
||||
@@ -397,7 +429,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const session = sessionById.get(activeTabId);
|
||||
if (!session) return null;
|
||||
return resolveTheme(session);
|
||||
}, [activeTabId, currentTerminalTheme, followAppTerminalTheme, hostById, sessionById, themeById, workspaceById]);
|
||||
}, [accentMode, activeTabId, currentTerminalTheme, customAccent, followAppTerminalTheme, hostById, sessionById, themeById, workspaceById]);
|
||||
|
||||
useImmersiveMode({
|
||||
activeTabId,
|
||||
@@ -435,11 +467,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
}
|
||||
|
||||
return buildSyncPayload(
|
||||
return buildLocalVaultPayload(
|
||||
{
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
@@ -454,6 +487,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
hosts,
|
||||
identities,
|
||||
keys,
|
||||
proxyProfiles,
|
||||
knownHosts,
|
||||
portForwardingRulesForSync,
|
||||
snippetPackages,
|
||||
@@ -514,7 +548,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isVaultInitialized, hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts]);
|
||||
}, [isVaultInitialized, hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, knownHosts]);
|
||||
|
||||
// Memoized "apply a remote payload safely" callback. Stable identity
|
||||
// across renders so useAutoSync's `syncNow` useCallback doesn't rebuild
|
||||
@@ -547,11 +581,11 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
portForwardingRules: portForwardingRulesForSync,
|
||||
knownHosts,
|
||||
groupConfigs,
|
||||
settingsVersion: settings.settingsVersion,
|
||||
startupReady: startupSyncSafetyReady,
|
||||
@@ -593,9 +627,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
if (start) {
|
||||
const effectiveHost = resolveEffectiveHost(host);
|
||||
void startTunnel(rule, effectiveHost, hosts, keys, identities, (status, error) => {
|
||||
void startTunnel(rule, effectiveHost, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart);
|
||||
}, rule.autoStart, terminalSettings);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -796,10 +830,13 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
// Auto-start port forwarding rules on app launch
|
||||
usePortForwardingAutoStart({
|
||||
isVaultInitialized,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
groupConfigs,
|
||||
terminalSettings,
|
||||
});
|
||||
|
||||
// Sync tray menu data + handle tray actions
|
||||
@@ -869,6 +906,36 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Quit guard: block app exit while any editor tab has unsaved changes.
|
||||
// Main process sends "app:query-dirty-editors"; we respond with the result.
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onCheckDirtyEditors) return;
|
||||
const unsub = bridge.onCheckDirtyEditors(() => {
|
||||
// Always report SOMETHING so the main process doesn't time out for
|
||||
// 5 s on an unhandled exception. If we can't determine the state,
|
||||
// fail open — losing unsaved work is bad, but stranding the user
|
||||
// on a slow quit and then quitting anyway after the timeout is
|
||||
// exactly the same outcome.
|
||||
let hasDirty = false;
|
||||
try {
|
||||
hasDirty = editorTabStore.getTabs().some((tab) => tab.content !== tab.baselineContent);
|
||||
if (hasDirty) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
|
||||
} catch (err) {
|
||||
console.error('[App] dirty-editors check failed:', err);
|
||||
}
|
||||
try {
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty);
|
||||
} catch (err) {
|
||||
// Reporting itself shouldn't throw, but if the IPC bridge is in a
|
||||
// bad state we'd rather log than bubble out of the listener and
|
||||
// disable the quit guard for the rest of the session.
|
||||
console.error('[App] reportDirtyEditorsResult failed:', err);
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
}, [t]);
|
||||
|
||||
// Keyboard-interactive authentication (2FA/MFA) event listener
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -933,8 +1000,46 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onPassphraseRequest) return;
|
||||
|
||||
const unsubscribe = bridge.onPassphraseRequest((request) => {
|
||||
const unsubscribe = bridge.onPassphraseRequest(async (request) => {
|
||||
console.log('[App] Passphrase request received:', request);
|
||||
|
||||
// If the bridge already tried a passphrase and it was wrong, skip auto-respond
|
||||
if (!request.passphraseInvalid) {
|
||||
// Check if a reference key exists for this path — use its passphrase
|
||||
const currentKeys = keysRef.current;
|
||||
const refKey = currentKeys.find((k: SSHKey) => k.source === 'reference' && k.filePath === request.keyPath);
|
||||
if (refKey?.passphrase && refKey.savePassphrase !== false && !isEncryptedCredentialPlaceholder(refKey.passphrase)) {
|
||||
console.log('[App] Auto-responding with reference key passphrase for:', request.keyPath);
|
||||
void bridge.respondPassphrase?.(request.requestId, refKey.passphrase, false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: try old storage for passphrase
|
||||
const saved = await loadDefaultKeyPassphrase(request.keyPath);
|
||||
if (saved) {
|
||||
console.log('[App] Auto-responding with saved passphrase for:', request.keyPath);
|
||||
// Migrate to reference key if one exists
|
||||
if (shouldUpdateReferenceKeyPassphrase(refKey)) {
|
||||
try {
|
||||
await rememberKeyPassphrase({
|
||||
keyPath: request.keyPath,
|
||||
passphrase: saved,
|
||||
keys: currentKeys,
|
||||
updateKeys,
|
||||
setCurrentKeys: (updated) => {
|
||||
keysRef.current = updated;
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[App] Failed to migrate passphrase to reference key:', err);
|
||||
}
|
||||
}
|
||||
void bridge.respondPassphrase?.(request.requestId, saved, false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No saved passphrase or it was invalid, show modal
|
||||
setPassphraseQueue(prev => [...prev, {
|
||||
requestId: request.requestId,
|
||||
keyPath: request.keyPath,
|
||||
@@ -946,16 +1051,37 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
}, [updateKeys]);
|
||||
|
||||
// Handle passphrase submit
|
||||
const handlePassphraseSubmit = useCallback((requestId: string, passphrase: string) => {
|
||||
const handlePassphraseSubmit = useCallback(async (requestId: string, passphrase: string, remember: boolean) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
const request = passphraseQueue.find((r: PassphraseRequest) => r.requestId === requestId);
|
||||
|
||||
// Save passphrase if requested
|
||||
if (remember && request?.keyPath) {
|
||||
console.log('[App] Saving passphrase for:', request.keyPath);
|
||||
try {
|
||||
await rememberKeyPassphrase({
|
||||
keyPath: request.keyPath,
|
||||
passphrase,
|
||||
keys: keysRef.current,
|
||||
updateKeys,
|
||||
setCurrentKeys: (updated) => {
|
||||
keysRef.current = updated;
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[App] Failed to save passphrase:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (bridge?.respondPassphrase) {
|
||||
void bridge.respondPassphrase(requestId, passphrase, false);
|
||||
}
|
||||
|
||||
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}, []);
|
||||
}, [passphraseQueue, updateKeys]);
|
||||
|
||||
// Handle passphrase cancel
|
||||
const handlePassphraseCancel = useCallback((requestId: string) => {
|
||||
@@ -998,6 +1124,44 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle passphrase cancellation (owning connection was stopped)
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onPassphraseCancelled) return;
|
||||
|
||||
const unsubscribe = bridge.onPassphraseCancelled((event) => {
|
||||
console.log('[App] Passphrase request cancelled:', event.requestId);
|
||||
setPassphraseQueue(prev => prev.filter(r => r.requestId !== event.requestId));
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle passphrase auth failure (saved passphrase was wrong, clear it)
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onPassphraseAuthFailed) return;
|
||||
|
||||
const unsubscribe = bridge.onPassphraseAuthFailed((event) => {
|
||||
const keyPaths = event.keyPaths ?? [];
|
||||
const keyIds = event.keyIds ?? [];
|
||||
console.log('[App] Passphrase auth failed for keys:', { keyPaths, keyIds });
|
||||
removeDefaultKeyPassphrases(keyPaths);
|
||||
const withoutReferencePassphrases = clearReferenceKeyPassphrases(keysRef.current, keyPaths);
|
||||
const updated = clearKeyPassphrasesByIds(withoutReferencePassphrases, keyIds);
|
||||
if (updated !== keysRef.current) {
|
||||
keysRef.current = updated;
|
||||
void updateKeys(updated);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [updateKeys]);
|
||||
|
||||
// Debounce ref for moveFocus to prevent double-triggering when focus switches
|
||||
const lastMoveFocusTimeRef = useRef<number>(0);
|
||||
const MOVE_FOCUS_DEBOUNCE_MS = 200;
|
||||
@@ -1007,8 +1171,16 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
addConnectionLogRef.current = addConnectionLog;
|
||||
|
||||
const closeSidePanelRef = useRef<(() => void) | null>(null);
|
||||
const toggleScriptsSidePanelRef = useRef<(() => void) | null>(null);
|
||||
// Populated below so the hotkey dispatcher can open the Settings window
|
||||
// even though `handleOpenSettings` is declared further down in the file.
|
||||
const handleOpenSettingsRef = useRef<() => void>(() => {});
|
||||
const activeSidePanelTabRef = useRef<string | null>(null);
|
||||
const closeTabInFlightRef = useRef(false);
|
||||
// Populated by UnsavedChangesProvider render-prop below so that the hotkey
|
||||
// dispatcher (defined outside that scope) can still reach the dirty-confirm
|
||||
// close flow.
|
||||
const handleRequestCloseEditorTabRef = useRef<(id: string) => void>(() => {});
|
||||
|
||||
const createLocalTerminalWithCurrentShell = useCallback(() => {
|
||||
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
||||
@@ -1127,13 +1299,13 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
// Shared hotkey action handler - used by both global handler and terminal callback
|
||||
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
|
||||
// Build complete tab list: vault + (sftp when visible) + sessions/workspaces.
|
||||
// Build complete tab list: vault + (sftp when visible) + sessions/workspaces + editor tabs.
|
||||
// Hiding the SFTP tab must also remove it from keyboard cycling so nextTab
|
||||
// doesn't land on a hidden tab (which would get redirected back) and so
|
||||
// number shortcuts don't shift.
|
||||
const allTabs = settings.showSftpTab
|
||||
? ['vault', 'sftp', ...orderedTabs]
|
||||
: ['vault', ...orderedTabs];
|
||||
? ['vault', 'sftp', ...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))]
|
||||
: ['vault', ...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))];
|
||||
switch (action) {
|
||||
case 'switchToTab': {
|
||||
// Get the number key pressed (1-9)
|
||||
@@ -1172,6 +1344,13 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
if (!currentId || currentId === 'vault' || currentId === 'sftp') break;
|
||||
if (closeTabInFlightRef.current) break;
|
||||
|
||||
// Editor tabs route through their own dirty-confirm close flow.
|
||||
if (isEditorTabId(currentId)) {
|
||||
const editorId = fromEditorTabId(currentId);
|
||||
if (editorId) handleRequestCloseEditorTabRef.current(editorId);
|
||||
break;
|
||||
}
|
||||
|
||||
const session = sessions.find((s) => s.id === currentId) ?? null;
|
||||
const workspace = workspaces.find((w) => w.id === currentId) ?? null;
|
||||
|
||||
@@ -1257,9 +1436,23 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
setNavigateToSection('port');
|
||||
break;
|
||||
case 'snippets':
|
||||
// Navigate to vault and open snippets section
|
||||
setActiveTabId('vault');
|
||||
setNavigateToSection('snippets');
|
||||
{
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
const intent = resolveSnippetsShortcutIntent({
|
||||
activeTabId: currentId,
|
||||
sessionForTab: sessions.find((s) => s.id === currentId) ?? null,
|
||||
workspaceForTab: workspaces.find((w) => w.id === currentId) ?? null,
|
||||
terminalScriptsToggleAvailable: !!toggleScriptsSidePanelRef.current,
|
||||
});
|
||||
|
||||
if (intent.kind === 'toggleTerminalScripts') {
|
||||
toggleScriptsSidePanelRef.current();
|
||||
break;
|
||||
}
|
||||
|
||||
setActiveTabId('vault');
|
||||
setNavigateToSection('snippets');
|
||||
}
|
||||
break;
|
||||
case 'broadcast': {
|
||||
// Toggle broadcast mode for the active workspace
|
||||
@@ -1270,6 +1463,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'openSettings':
|
||||
handleOpenSettingsRef.current();
|
||||
break;
|
||||
case 'splitHorizontal': {
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
const activeSession = sessions.find(s => s.id === currentId);
|
||||
@@ -1333,7 +1529,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings.showSftpTab, confirmIfBusyLocalTerminal]);
|
||||
}, [orderedTabs, editorTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings.showSftpTab, confirmIfBusyLocalTerminal]);
|
||||
|
||||
// Callback for terminal to invoke app-level hotkey actions
|
||||
const handleHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
|
||||
@@ -1380,6 +1576,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
updateHosts(hosts.filter(h => h.id !== hostId));
|
||||
}, [hosts, updateHosts, t]);
|
||||
|
||||
const handleAddKnownHost = useCallback((kh: KnownHost) => {
|
||||
const nextKnownHosts = upsertKnownHost(knownHostsRef.current, kh);
|
||||
knownHostsRef.current = nextKnownHosts;
|
||||
updateKnownHosts(nextKnownHosts);
|
||||
}, [updateKnownHosts]);
|
||||
|
||||
// System info for connection logs
|
||||
const hostsRef = useRef(hosts);
|
||||
hostsRef.current = hosts;
|
||||
@@ -1433,11 +1635,21 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
});
|
||||
}, [addConnectionLog, createLocalTerminal, terminalSettings.localShell, discoveredShells]);
|
||||
|
||||
const proxyProfileIdSet = useMemo(
|
||||
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
[proxyProfiles],
|
||||
);
|
||||
|
||||
const resolveEffectiveHost = useCallback((host: Host): Host => {
|
||||
if (!host.group) return host;
|
||||
const groupDefaults = resolveGroupDefaults(host.group, groupConfigs);
|
||||
return applyGroupDefaults(host, groupDefaults);
|
||||
}, [groupConfigs]);
|
||||
const withGroupDefaults = host.group
|
||||
? applyGroupDefaults(
|
||||
host,
|
||||
resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }),
|
||||
{ validProxyProfileIds: proxyProfileIdSet },
|
||||
)
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
}, [groupConfigs, proxyProfileIdSet, proxyProfiles]);
|
||||
|
||||
// Wrapper to connect to host with logging
|
||||
const handleConnectToHost = useCallback((host: Host) => {
|
||||
@@ -1617,6 +1829,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
if (!opened) toast.error(t('toast.settingsUnavailable'), t('common.settings'));
|
||||
})();
|
||||
}, [openSettingsWindow, t]);
|
||||
handleOpenSettingsRef.current = handleOpenSettings;
|
||||
|
||||
const hasShownCredentialProtectionWarningRef = useRef(false);
|
||||
|
||||
@@ -1687,7 +1900,59 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
// Combined ordered tab list including editor tab ids (for TopTabs scrollable area)
|
||||
const orderedTabsWithEditors = useMemo(
|
||||
() => [...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))],
|
||||
[orderedTabs, editorTabs],
|
||||
);
|
||||
|
||||
return (
|
||||
<UnsavedChangesProvider>
|
||||
{({ prompt }) => {
|
||||
// Helper: close an editor tab and activate the neighbor (left-preference), or vault.
|
||||
const closeEditorAndActivateNeighbor = (id: string) => {
|
||||
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';
|
||||
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
|
||||
};
|
||||
|
||||
// Real dirty-confirm close handler.
|
||||
const handleRequestCloseEditorTab = async (id: string) => {
|
||||
const tab = editorTabStore.getTab(id);
|
||||
if (!tab) return;
|
||||
const dirty = tab.content !== tab.baselineContent;
|
||||
if (!dirty) {
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
return;
|
||||
}
|
||||
const choice = await prompt(tab.fileName);
|
||||
if (choice === 'cancel') return;
|
||||
if (choice === 'discard') {
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
return;
|
||||
}
|
||||
if (choice === 'save') {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// Expose to the hotkey dispatcher (Cmd/Ctrl+W).
|
||||
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-screen text-foreground font-sans netcatty-shell", activeTerminalTheme && "immersive-transition")} onContextMenu={handleRootContextMenu}>
|
||||
<TopTabs
|
||||
theme={resolvedTheme}
|
||||
@@ -1697,7 +1962,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
orphanSessions={orphanSessions}
|
||||
workspaces={workspaces}
|
||||
logViews={logViews}
|
||||
orderedTabs={orderedTabs}
|
||||
orderedTabs={orderedTabsWithEditors}
|
||||
draggingSessionId={draggingSessionId}
|
||||
isMacClient={isMacClient}
|
||||
onCloseSession={closeSession}
|
||||
@@ -1716,6 +1981,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onEndSessionDrag={handleEndSessionDrag}
|
||||
onReorderTabs={reorderTabs}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
editorTabs={editorTabs}
|
||||
onRequestCloseEditorTab={handleRequestCloseEditorTab}
|
||||
hostById={hostById}
|
||||
/>
|
||||
|
||||
<div className="flex-1 relative min-h-0">
|
||||
@@ -1724,6 +1992,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
proxyProfiles={proxyProfiles}
|
||||
snippets={snippets}
|
||||
snippetPackages={snippetPackages}
|
||||
customGroups={customGroups}
|
||||
@@ -1746,7 +2015,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onUpdateGroupConfigs={updateGroupConfigs}
|
||||
onUpdateHosts={updateHosts}
|
||||
onUpdateKeys={updateKeys}
|
||||
onImportOrReuseKey={importOrReuseKey}
|
||||
onUpdateIdentities={updateIdentities}
|
||||
onUpdateProxyProfiles={updateProxyProfiles}
|
||||
onUpdateSnippets={updateSnippets}
|
||||
onUpdateSnippetPackages={updateSnippetPackages}
|
||||
onUpdateCustomGroups={updateCustomGroups}
|
||||
@@ -1765,6 +2036,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
|
||||
navigateToSection={navigateToSection}
|
||||
onNavigateToSectionHandled={() => setNavigateToSection(null)}
|
||||
terminalSettings={terminalSettings}
|
||||
/>
|
||||
</VaultViewContainer>
|
||||
|
||||
@@ -1772,6 +2044,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
proxyProfiles={proxyProfiles}
|
||||
groupConfigs={groupConfigs}
|
||||
updateHosts={updateHosts}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
@@ -1783,11 +2056,13 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
keyBindings={keyBindings}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
terminalSettings={terminalSettings}
|
||||
/>
|
||||
|
||||
<TerminalLayerMount
|
||||
hosts={hosts}
|
||||
groupConfigs={groupConfigs}
|
||||
proxyProfiles={proxyProfiles}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
snippets={snippets}
|
||||
@@ -1798,6 +2073,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
draggingSessionId={draggingSessionId}
|
||||
terminalTheme={currentTerminalTheme}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
accentMode={accentMode}
|
||||
customAccent={customAccent}
|
||||
terminalSettings={terminalSettings}
|
||||
terminalFontFamilyId={terminalFontFamilyId}
|
||||
fontSize={terminalFontSize}
|
||||
@@ -1812,7 +2089,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onUpdateSessionStatus={handleSessionStatusChange}
|
||||
onUpdateHostDistro={updateHostDistro}
|
||||
onUpdateHost={(host) => updateHosts(hosts.map(h => h.id === host.id ? host : h))}
|
||||
onAddKnownHost={(kh) => updateKnownHosts([...knownHosts, kh])}
|
||||
onAddKnownHost={handleAddKnownHost}
|
||||
onCommandExecuted={(command, hostId, hostLabel, sessionId) => {
|
||||
addShellHistoryEntry({ command, hostId, hostLabel, sessionId });
|
||||
}}
|
||||
@@ -1842,6 +2119,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sessionLogsDir={sessionLogsDir}
|
||||
sessionLogsFormat={sessionLogsFormat}
|
||||
closeSidePanelRef={closeSidePanelRef}
|
||||
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
|
||||
activeSidePanelTabRef={activeSidePanelTabRef}
|
||||
/>
|
||||
|
||||
@@ -1860,6 +2138,19 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Editor Tabs — kept mounted for Monaco instance persistence; visibility toggled via CSS */}
|
||||
{editorTabs.map((tab) => (
|
||||
<TextEditorTabView
|
||||
key={tab.id}
|
||||
tabId={tab.id}
|
||||
isVisible={activeTabId === toEditorTabId(tab.id)}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
hostById={hostById}
|
||||
onRequestClose={(id) => handleRequestCloseEditorTabRef.current(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Global "quick add / edit snippet" dialog, triggered by the
|
||||
@@ -2077,6 +2368,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
hosts: emptyVaultConflict.hostCount,
|
||||
keys: emptyVaultConflict.keyCount,
|
||||
snippets: emptyVaultConflict.snippetCount,
|
||||
proxyProfiles: emptyVaultConflict.proxyProfileCount,
|
||||
})}</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -2106,6 +2398,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</UnsavedChangesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
45
README.md
45
README.md
@@ -40,7 +40,8 @@
|
||||
|
||||
---
|
||||
|
||||
[](screenshots/main-window-dark.png)
|
||||
<img width="2868" height="1784" alt="netcatty SSH (Window) 2026-04-23 11:19 PM" src="https://github.com/user-attachments/assets/d6df734f-9ebc-452a-8b7d-e8a0fdc9463a" />
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -48,11 +49,6 @@
|
||||
# 🔥 Catty Agent — Your IT Ops AI Partner
|
||||
|
||||
> 🚀 **Boost your IT ops daily work with AI power.** Catty Agent is the built-in AI assistant that understands your servers, executes commands, and handles complex multi-host operations — all through natural conversation.
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/ai-feature.png" alt="Catty Agent Interface" width="800">
|
||||
</p>
|
||||
|
||||
### 🔥 What can Catty Agent do?
|
||||
|
||||
- 🚀 **Natural language server management** — just tell it what you need, no more memorizing commands
|
||||
@@ -68,7 +64,10 @@
|
||||
Ask Catty Agent to check a server's health, and it runs the right commands, analyzes the output, and gives you a clear summary — all in seconds.
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/eecf08f1-80bd-49db-886d-b36e93388865
|
||||
|
||||
https://github.com/user-attachments/assets/f819a1b6-8cba-4910-8017-97dfc080b477
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -78,8 +77,9 @@ https://github.com/user-attachments/assets/eecf08f1-80bd-49db-886d-b36e93388865
|
||||
Watch Catty Agent orchestrate a Docker Swarm cluster across two servers in one conversation. It handles the init, token exchange, and node joining — you just tell it what you want.
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/52fd30b8-9f02-43d4-a3b2-142691e8e3ec
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/282027aa-5c9e-4bb1-b2c3-5eea9df2b203
|
||||
|
||||
|
||||
|
||||
@@ -160,21 +160,27 @@ Video previews (stored in `screenshots/gifs/`), rendered inline on GitHub:
|
||||
### Vault views: grid / list / tree
|
||||
Switch between different Vault views to match your workflow: overview in grid, dense scanning in list, and hierarchical navigation in tree.
|
||||
|
||||
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
|
||||
|
||||
https://github.com/user-attachments/assets/1ff1f3f1-e5ae-40ea-b35a-0e5148c3afeb
|
||||
|
||||
|
||||
|
||||
### Split terminals + session management
|
||||
Work in multiple sessions at once with split panes. Keep related tasks side-by-side and reduce context switching.
|
||||
|
||||
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/9c24b519-4b4b-4910-a22a-590d04c9af31
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
### SFTP: drag & drop + built-in editor
|
||||
Move files with drag & drop, then edit quickly using the built-in editor without leaving the app.
|
||||
|
||||
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
|
||||
|
||||
https://github.com/user-attachments/assets/f3afdb36-399d-4330-b9f3-4678f178f6db
|
||||
|
||||
|
||||
|
||||
@@ -182,7 +188,11 @@ https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
|
||||
### Drag file upload
|
||||
Drop files into the app to kick off uploads without hunting through dialogs.
|
||||
|
||||
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/e1e26f7a-3489-41cc-975e-8dccba56ea85
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -190,7 +200,10 @@ https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
|
||||
### Custom themes
|
||||
Make Netcatty yours: customize themes and UI appearance.
|
||||
|
||||
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/1a6049aa-9a4c-4d52-a13d-0b007a791b00
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -198,7 +211,11 @@ https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
|
||||
### Keyword highlighting
|
||||
Highlight important terminal output so errors, warnings, and key events stand out at a glance.
|
||||
|
||||
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/1a1db7bd-948b-4f3c-97cd-8fd0cbe7cce7
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
93
application/defaultKeyPassphrases.ts
Normal file
93
application/defaultKeyPassphrases.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { SSHKey } from "../domain/models";
|
||||
import { isEncryptedCredentialPlaceholder } from "../domain/credentials";
|
||||
import { STORAGE_KEY_DEFAULT_KEY_PASSPHRASES } from "../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../infrastructure/persistence/localStorageAdapter";
|
||||
import { encryptField, decryptField } from "../infrastructure/persistence/secureFieldAdapter";
|
||||
|
||||
export async function saveDefaultKeyPassphrase(keyPath: string, passphrase: string): Promise<void> {
|
||||
const store = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? {};
|
||||
store[keyPath] = await encryptField(passphrase) ?? passphrase;
|
||||
localStorageAdapter.write(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES, store);
|
||||
}
|
||||
|
||||
export async function loadDefaultKeyPassphrase(keyPath: string): Promise<string | null> {
|
||||
const store = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES);
|
||||
const enc = store?.[keyPath];
|
||||
if (!enc) return null;
|
||||
const decrypted = await decryptField(enc);
|
||||
if (!decrypted || isEncryptedCredentialPlaceholder(decrypted)) {
|
||||
removeDefaultKeyPassphrases([keyPath]);
|
||||
return null;
|
||||
}
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
export function removeDefaultKeyPassphrases(keyPaths: string[]): void {
|
||||
const store = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES);
|
||||
if (!store) return;
|
||||
let changed = false;
|
||||
for (const keyPath of keyPaths) {
|
||||
if (keyPath in store) {
|
||||
delete store[keyPath];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
localStorageAdapter.write(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES, store);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearReferenceKeyPassphrases(keys: SSHKey[], keyPaths: string[]): SSHKey[] {
|
||||
let changed = false;
|
||||
const updated = keys.map((key) => {
|
||||
if (key.source === "reference" && key.filePath && keyPaths.includes(key.filePath) && key.passphrase) {
|
||||
changed = true;
|
||||
return { ...key, passphrase: undefined, savePassphrase: false };
|
||||
}
|
||||
return key;
|
||||
});
|
||||
return changed ? updated : keys;
|
||||
}
|
||||
|
||||
export function clearKeyPassphrasesByIds(keys: SSHKey[], keyIds: string[] = []): SSHKey[] {
|
||||
if (keyIds.length === 0) return keys;
|
||||
const ids = new Set(keyIds);
|
||||
let changed = false;
|
||||
const updated = keys.map((key) => {
|
||||
if (ids.has(key.id) && key.passphrase) {
|
||||
changed = true;
|
||||
return { ...key, passphrase: undefined, savePassphrase: false };
|
||||
}
|
||||
return key;
|
||||
});
|
||||
return changed ? updated : keys;
|
||||
}
|
||||
|
||||
export function shouldUpdateReferenceKeyPassphrase(key?: SSHKey | null): boolean {
|
||||
return Boolean(
|
||||
key &&
|
||||
(!key.passphrase || isEncryptedCredentialPlaceholder(key.passphrase)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function rememberKeyPassphrase(args: {
|
||||
keyPath: string;
|
||||
passphrase: string;
|
||||
keys: SSHKey[];
|
||||
updateKeys: (keys: SSHKey[]) => Promise<unknown> | unknown;
|
||||
setCurrentKeys?: (keys: SSHKey[]) => void;
|
||||
}): Promise<void> {
|
||||
const { keyPath, passphrase, keys, updateKeys, setCurrentKeys } = args;
|
||||
await saveDefaultKeyPassphrase(keyPath, passphrase);
|
||||
|
||||
const refKey = keys.find((key) => key.source === "reference" && key.filePath === keyPath);
|
||||
if (!refKey) return;
|
||||
|
||||
const updated = keys.map((key) =>
|
||||
key.id === refKey.id
|
||||
? { ...key, passphrase, savePassphrase: true }
|
||||
: key
|
||||
);
|
||||
setCurrentKeys?.(updated);
|
||||
await updateKeys(updated);
|
||||
}
|
||||
@@ -273,6 +273,17 @@ const en: Messages = {
|
||||
'settings.terminal.section.keywordHighlight': 'Keyword highlighting',
|
||||
'settings.terminal.font.family': 'Font',
|
||||
'settings.terminal.font.family.desc': 'Terminal font family',
|
||||
'settings.terminal.font.cjk': 'CJK font',
|
||||
'settings.terminal.font.cjk.desc': 'Font used for Chinese / Japanese / Korean characters; "Auto" picks one based on the primary font',
|
||||
'settings.terminal.font.cjk.option.auto': 'Auto · paired with the primary font',
|
||||
'settings.terminal.font.cjk.option.sarasaSC': 'Sarasa Mono SC (Iosevka + Source Han SC)',
|
||||
'settings.terminal.font.cjk.option.sarasaTC': 'Sarasa Mono TC (Iosevka + Source Han TC)',
|
||||
'settings.terminal.font.cjk.option.mapleCN': 'Maple Mono CN',
|
||||
'settings.terminal.font.cjk.option.sourceHan': 'Source Han Mono SC',
|
||||
'settings.terminal.font.cjk.option.notoCJK': 'Noto Sans Mono CJK SC',
|
||||
'settings.terminal.font.cjk.option.lxgwWenkai': 'LXGW WenKai Mono',
|
||||
'settings.terminal.font.cjk.option.simSun': 'SimSun',
|
||||
'settings.terminal.font.cjk.option.legacy': '{font} · not recommended (proportional font)',
|
||||
'settings.terminal.font.size': 'Font size',
|
||||
'settings.terminal.font.size.desc': 'Terminal text size',
|
||||
'settings.terminal.font.weight': 'Font weight',
|
||||
@@ -374,7 +385,12 @@ const en: Messages = {
|
||||
'settings.terminal.localShell.startDir.isFile': 'Path is a file, not a directory',
|
||||
'settings.terminal.section.connection': 'Connection',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': 'How often (in seconds) to send SSH-level keepalive packets to server. Set to 0 to disable.',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': 'How often (in seconds) to send SSH-level keepalive packets. Set to 0 to disable globally — note that individual hosts can override this in their own settings.',
|
||||
'settings.terminal.connection.keepaliveCountMax': 'Max unanswered keepalives',
|
||||
'settings.terminal.connection.keepaliveCountMax.desc': 'Unanswered keepalives before the connection is declared dead. Higher values are more forgiving of brief network glitches and SSH servers that respond slowly.',
|
||||
'settings.terminal.connection.x11Display': 'X11 display',
|
||||
'settings.terminal.connection.x11Display.desc': 'Optional local display address for X11 forwarding. Leave empty to use the system default.',
|
||||
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 or DISPLAY)',
|
||||
'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).',
|
||||
@@ -478,7 +494,7 @@ const en: Messages = {
|
||||
'sync.autoSync.emptyVaultConflict.restoreDesc': 'Recommended — recover your hosts, keys, and snippets from the cloud backup',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmpty': 'Keep Empty',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': 'Start fresh with an empty vault',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} hosts, {keys} keys, {snippets} snippets',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} hosts, {keys} keys, {snippets} snippets, {proxyProfiles} proxies',
|
||||
'sync.autoSync.emptyVaultManual': 'Cannot sync: the local vault is empty. Restore from a local backup or enable Force Push in the sync panel first.',
|
||||
|
||||
'sync.blocked.title': 'Sync paused',
|
||||
@@ -496,6 +512,7 @@ const en: Messages = {
|
||||
'sync.entityType.hosts': 'hosts',
|
||||
'sync.entityType.keys': 'keys',
|
||||
'sync.entityType.identities': 'identities',
|
||||
'sync.entityType.proxyProfiles': 'proxy profiles',
|
||||
'sync.entityType.snippets': 'snippets',
|
||||
'sync.entityType.customGroups': 'groups',
|
||||
'sync.entityType.snippetPackages': 'snippet packages',
|
||||
@@ -511,11 +528,28 @@ const en: Messages = {
|
||||
// Vault navigation
|
||||
'vault.nav.hosts': 'Hosts',
|
||||
'vault.nav.keychain': 'Keychain',
|
||||
'vault.nav.proxies': 'Proxies',
|
||||
'vault.nav.portForwarding': 'Port Forwarding',
|
||||
'vault.nav.snippets': 'Snippets',
|
||||
'vault.nav.knownHosts': 'Known Hosts',
|
||||
'vault.nav.logs': 'Logs',
|
||||
|
||||
'proxyProfiles.action.add': 'Add Proxy',
|
||||
'proxyProfiles.search.placeholder': 'Search proxies…',
|
||||
'proxyProfiles.section.proxies': 'Proxies',
|
||||
'proxyProfiles.count.items': '{count} items',
|
||||
'proxyProfiles.empty.title': 'No Proxies',
|
||||
'proxyProfiles.empty.desc': 'Create reusable HTTP or SOCKS5 proxies and select them from host details.',
|
||||
'proxyProfiles.usage': '{count} linked',
|
||||
'proxyProfiles.copyName': '{name} Copy',
|
||||
'proxyProfiles.panel.newTitle': 'New Proxy',
|
||||
'proxyProfiles.field.name': 'Proxy name',
|
||||
'proxyProfiles.error.required': 'Name, host, and port are required.',
|
||||
'proxyProfiles.error.port': 'Port must be between 1 and 65535.',
|
||||
'proxyProfiles.viewMode': 'Proxy view mode',
|
||||
'proxyProfiles.delete.title': 'Delete proxy?',
|
||||
'proxyProfiles.delete.desc': 'Deleting "{name}" will unlink it from {count} host or group settings.',
|
||||
|
||||
'vault.groups.title': 'Groups',
|
||||
'vault.groups.total': '{count} total',
|
||||
'vault.groups.hostsCount': '{count} Hosts',
|
||||
@@ -753,6 +787,10 @@ const en: Messages = {
|
||||
'sftp.context.permissions': 'Permissions',
|
||||
'sftp.context.delete': 'Delete',
|
||||
'sftp.context.refresh': 'Refresh',
|
||||
'sftp.context.uploadFiles': 'Upload File(s)...',
|
||||
'sftp.context.uploadFilesHere': 'Upload File(s) Here...',
|
||||
'sftp.context.uploadFolder': 'Upload Folder...',
|
||||
'sftp.context.uploadFolderHere': 'Upload Folder Here...',
|
||||
'sftp.context.downloadSelected': 'Download selected ({count})',
|
||||
'sftp.context.deleteSelected': 'Delete selected ({count})',
|
||||
'sftp.dropFilesHere': 'Drop files here',
|
||||
@@ -775,6 +813,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',
|
||||
@@ -841,8 +882,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
|
||||
@@ -1077,6 +1121,9 @@ const en: Messages = {
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not available',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': 'No SSH agent detected. Enable OpenSSH Authentication Agent in Windows Services, or use a compatible agent such as Bitwarden, 1Password, or gpg-agent.',
|
||||
'hostDetails.section.agentForwarding': 'SSH Agent',
|
||||
'hostDetails.x11Forwarding': 'Forward X11 apps',
|
||||
'hostDetails.x11Forwarding.desc': 'Show remote graphical apps on your local desktop when a local X server is running.',
|
||||
'hostDetails.section.x11Forwarding': 'X11 Forwarding',
|
||||
'hostDetails.section.deviceType': 'Device Type',
|
||||
'hostDetails.deviceType': 'Network Device Mode',
|
||||
'hostDetails.deviceType.desc': 'Enable for network equipment (switches, routers, firewalls) connected via SSH. Commands are sent as-is without shell wrapping, compatible with vendor CLIs like Huawei VRP and Cisco IOS.',
|
||||
@@ -1085,6 +1132,12 @@ const en: Messages = {
|
||||
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
|
||||
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
|
||||
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',
|
||||
'hostDetails.section.keepalive': 'Keepalive',
|
||||
'hostDetails.keepalive.override': 'Override global keepalive',
|
||||
'hostDetails.keepalive.desc': 'Use a custom keepalive policy for this host instead of the global setting. Useful for older routers or switches whose SSH server does not reply to keepalive@openssh.com requests — set interval to 0 to disable keepalive entirely on this host.',
|
||||
'hostDetails.keepalive.interval': 'Interval (seconds)',
|
||||
'hostDetails.keepalive.countMax': 'Max unanswered keepalives',
|
||||
'hostDetails.keepalive.disabledHint': 'Interval = 0 disables keepalive for this host. The session will rely on TCP-level timeouts to detect a dead connection.',
|
||||
'hostDetails.backspaceBehavior': 'Backspace Behavior',
|
||||
'hostDetails.backspaceBehavior.default': 'Default',
|
||||
'hostDetails.jumpHosts': 'Proxy via Hosts',
|
||||
@@ -1102,6 +1155,12 @@ const en: Messages = {
|
||||
'hostDetails.proxyPanel.passwordPlaceholder': 'Password',
|
||||
'hostDetails.proxyPanel.identities': 'Identities',
|
||||
'hostDetails.proxyPanel.remove': 'Remove Proxy',
|
||||
'hostDetails.proxyPanel.savedProxy': 'Saved proxy',
|
||||
'hostDetails.proxyPanel.selectSaved': 'Select saved proxy',
|
||||
'hostDetails.proxyPanel.customProxy': 'Custom proxy',
|
||||
'hostDetails.proxyPanel.missing': 'Missing',
|
||||
'hostDetails.proxyPanel.missingSaved': 'Missing saved proxy',
|
||||
'hostDetails.proxyPanel.error.required': 'Proxy host and port are required.',
|
||||
'hostDetails.envVars': 'Environment Variables',
|
||||
'hostDetails.envVars.add': 'Add Environment Variable',
|
||||
'hostDetails.envVars.title': 'Environment Variables',
|
||||
@@ -1223,6 +1282,10 @@ const en: Messages = {
|
||||
'terminal.toolbar.hostHighlight.clearAll': 'Clear All',
|
||||
'terminal.toolbar.hostHighlight.changeColor': 'Change highlight color for',
|
||||
'terminal.toolbar.hostHighlight.selectColor': 'Select color for new rule',
|
||||
'terminal.statusbar.copyHostname.label': 'Copy host address',
|
||||
'terminal.statusbar.copyHostname.tooltip': 'Copy host address ({hostname})',
|
||||
'terminal.statusbar.copyHostname.toast': 'Copied host address: {hostname}',
|
||||
'terminal.statusbar.copyHostname.error': 'Failed to copy host address to clipboard',
|
||||
'terminal.serverStats.cpu': 'CPU Usage',
|
||||
'terminal.serverStats.cpuCores': 'CPU Core Usage',
|
||||
'terminal.serverStats.memory': 'Memory Usage',
|
||||
@@ -1289,6 +1352,16 @@ const en: Messages = {
|
||||
'terminal.connection.protocol.mosh': 'Mosh',
|
||||
'terminal.connection.protocol.serial': 'Serial',
|
||||
'terminal.connection.protocol.local': 'Local Shell',
|
||||
'terminal.hostKey.unknownTitle': 'Confirm this host key',
|
||||
'terminal.hostKey.changedTitle': 'Host key changed',
|
||||
'terminal.hostKey.unknownDescription': 'The authenticity of {host} cannot be established yet.',
|
||||
'terminal.hostKey.changedDescription': 'The saved key for {host} no longer matches this server.',
|
||||
'terminal.hostKey.fingerprintLabel': '{keyType} fingerprint is SHA256:',
|
||||
'terminal.hostKey.savedFingerprintLabel': 'Saved fingerprint',
|
||||
'terminal.hostKey.unknownHint': 'Remember it if this fingerprint belongs to the server you expected.',
|
||||
'terminal.hostKey.changedHint': 'Only continue if you expected this host to change.',
|
||||
'terminal.hostKey.addAndContinue': 'Add and continue',
|
||||
'terminal.hostKey.updateAndContinue': 'Update and continue',
|
||||
'terminal.themeModal.title': 'Terminal Appearance',
|
||||
'terminal.themeModal.tab.theme': 'Theme',
|
||||
'terminal.themeModal.tab.font': 'Font',
|
||||
@@ -1518,6 +1591,7 @@ const en: Messages = {
|
||||
'cloudSync.conflict.keepLocal': 'Overwrite cloud (keep local)',
|
||||
'cloudSync.conflict.useCloud': 'Download cloud (overwrite local)',
|
||||
'cloudSync.connect.browserContinue': 'Complete authorization in browser',
|
||||
'cloudSync.connect.browserCancelled': 'Previous browser authorization was cancelled',
|
||||
'cloudSync.connect.github.success': 'GitHub connected successfully',
|
||||
'cloudSync.connect.github.failedTitle': 'GitHub connection failed',
|
||||
'cloudSync.connect.github.timeout': 'GitHub connection timed out. Check your network or proxy settings.',
|
||||
@@ -1650,6 +1724,7 @@ const en: Messages = {
|
||||
'keychain.edit.publicKey': 'Public key',
|
||||
'keychain.edit.certificate': 'Certificate',
|
||||
'keychain.edit.certificatePlaceholder': 'Certificate content (optional)',
|
||||
'keychain.edit.filePath': 'File path',
|
||||
'keychain.edit.keyExport': 'Key export',
|
||||
'keychain.edit.exportToHost': 'Export to host',
|
||||
|
||||
@@ -1777,9 +1852,16 @@ const en: Messages = {
|
||||
'passphrase.unlock': 'Unlock',
|
||||
'passphrase.unlocking': 'Unlocking...',
|
||||
'passphrase.skip': 'Skip',
|
||||
'passphrase.remember': 'Remember this passphrase',
|
||||
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': 'Word Wrap',
|
||||
'sftp.editor.maximize': 'Maximize',
|
||||
'sftp.editor.unsavedTitle': 'Unsaved changes',
|
||||
'sftp.editor.unsavedMessage': '{fileName} has unsaved changes. Save before closing?',
|
||||
'sftp.editor.discardChanges': 'Discard',
|
||||
'sftp.editor.saveAndClose': 'Save and close',
|
||||
'sftp.editor.quitBlockedByDirty': 'Unsaved editors — please save or discard before quitting',
|
||||
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent Settings',
|
||||
|
||||
@@ -290,7 +290,7 @@ const zhCN: Messages = {
|
||||
'sync.autoSync.emptyVaultConflict.restoreDesc': '推荐 — 从云端备份恢复主机、密钥和代码片段',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmpty': '保持为空',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': '从头开始,使用空的主机库',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段,{proxyProfiles} 个代理',
|
||||
'sync.autoSync.emptyVaultManual': '无法同步:本地 vault 为空。请先从本地备份恢复,或在同步面板里使用"强制推送"。',
|
||||
|
||||
'sync.blocked.title': '同步已暂停',
|
||||
@@ -308,6 +308,7 @@ const zhCN: Messages = {
|
||||
'sync.entityType.hosts': '主机',
|
||||
'sync.entityType.keys': '密钥',
|
||||
'sync.entityType.identities': '身份',
|
||||
'sync.entityType.proxyProfiles': '代理配置',
|
||||
'sync.entityType.snippets': '代码片段',
|
||||
'sync.entityType.customGroups': '分组',
|
||||
'sync.entityType.snippetPackages': '片段包',
|
||||
@@ -323,11 +324,28 @@ const zhCN: Messages = {
|
||||
// Vault navigation
|
||||
'vault.nav.hosts': '主机',
|
||||
'vault.nav.keychain': '钥匙串',
|
||||
'vault.nav.proxies': '代理',
|
||||
'vault.nav.portForwarding': '端口转发',
|
||||
'vault.nav.snippets': '代码片段',
|
||||
'vault.nav.knownHosts': '已知主机',
|
||||
'vault.nav.logs': '日志',
|
||||
|
||||
'proxyProfiles.action.add': '添加代理',
|
||||
'proxyProfiles.search.placeholder': '搜索代理…',
|
||||
'proxyProfiles.section.proxies': '代理',
|
||||
'proxyProfiles.count.items': '{count} 项',
|
||||
'proxyProfiles.empty.title': '暂无代理',
|
||||
'proxyProfiles.empty.desc': '创建可复用的 HTTP 或 SOCKS5 代理,然后在主机详情里选择。',
|
||||
'proxyProfiles.usage': '已关联 {count} 处',
|
||||
'proxyProfiles.copyName': '{name} 副本',
|
||||
'proxyProfiles.panel.newTitle': '新建代理',
|
||||
'proxyProfiles.field.name': '代理名称',
|
||||
'proxyProfiles.error.required': '名称、主机和端口不能为空。',
|
||||
'proxyProfiles.error.port': '端口必须在 1 到 65535 之间。',
|
||||
'proxyProfiles.viewMode': '代理显示方式',
|
||||
'proxyProfiles.delete.title': '删除代理?',
|
||||
'proxyProfiles.delete.desc': '删除 "{name}" 会同时从 {count} 个主机或分组设置中解除关联。',
|
||||
|
||||
'vault.groups.title': '分组',
|
||||
'vault.groups.total': '共 {count} 个',
|
||||
'vault.groups.hostsCount': '{count} 台主机',
|
||||
@@ -540,6 +558,10 @@ const zhCN: Messages = {
|
||||
'sftp.context.permissions': '权限',
|
||||
'sftp.context.delete': '删除',
|
||||
'sftp.context.refresh': '刷新',
|
||||
'sftp.context.uploadFiles': '上传文件...',
|
||||
'sftp.context.uploadFilesHere': '上传文件到这里...',
|
||||
'sftp.context.uploadFolder': '上传文件夹...',
|
||||
'sftp.context.uploadFolderHere': '上传文件夹到这里...',
|
||||
'sftp.context.downloadSelected': '下载选中项({count})',
|
||||
'sftp.context.deleteSelected': '删除选中项({count})',
|
||||
'sftp.dropFilesHere': '拖拽文件到这里',
|
||||
@@ -562,6 +584,9 @@ const zhCN: Messages = {
|
||||
'sftp.transfers.collapseChildren': '收起文件',
|
||||
'sftp.transfers.expandChildList': '展开详情',
|
||||
'sftp.transfers.collapseChildList': '收起',
|
||||
'sftp.transfers.retryAction': '重试',
|
||||
'sftp.transfers.dismissAction': '移除',
|
||||
'sftp.transfers.resizeNameColumn': '调整文件名列宽',
|
||||
'sftp.transfers.dragToResize': '拖拽调整高度',
|
||||
'sftp.goUp': '上一级',
|
||||
'sftp.goToTerminalCwd': '定位到终端当前目录',
|
||||
@@ -712,6 +737,9 @@ const zhCN: Messages = {
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 不可用',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': '未检测到 SSH Agent。请启用 Windows OpenSSH Authentication Agent 服务,或使用兼容的 Agent(如 Bitwarden、1Password、gpg-agent)。',
|
||||
'hostDetails.section.agentForwarding': 'SSH 代理',
|
||||
'hostDetails.x11Forwarding': '转发 X11 图形应用',
|
||||
'hostDetails.x11Forwarding.desc': '本机运行 X 服务时,让远程图形程序显示在本地桌面。',
|
||||
'hostDetails.section.x11Forwarding': 'X11 转发',
|
||||
'hostDetails.section.deviceType': '设备类型',
|
||||
'hostDetails.deviceType': '网络设备模式',
|
||||
'hostDetails.deviceType.desc': '适用于通过 SSH 连接的网络设备(交换机、路由器、防火墙)。命令将原样发送,不进行 Shell 包装,兼容华为 VRP、Cisco IOS 等厂商 CLI。',
|
||||
@@ -720,6 +748,12 @@ const zhCN: Messages = {
|
||||
'hostDetails.legacyAlgorithms': '允许旧版算法',
|
||||
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法(diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
|
||||
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
|
||||
'hostDetails.section.keepalive': '会话保活',
|
||||
'hostDetails.keepalive.override': '为此主机单独配置',
|
||||
'hostDetails.keepalive.desc': '为该主机使用专属的保活策略,而不是跟随全局设置。适用于不响应 keepalive@openssh.com 请求的老旧路由器 / 交换机——将间隔设为 0 可对该主机彻底关闭保活。',
|
||||
'hostDetails.keepalive.interval': '间隔(秒)',
|
||||
'hostDetails.keepalive.countMax': '最大无响应保活次数',
|
||||
'hostDetails.keepalive.disabledHint': '间隔为 0 时该主机不发送保活包,仅依赖 TCP 层超时检测断连。',
|
||||
'hostDetails.backspaceBehavior': 'Backspace 行为',
|
||||
'hostDetails.backspaceBehavior.default': '默认',
|
||||
'hostDetails.jumpHosts': '通过主机代理',
|
||||
@@ -829,6 +863,10 @@ const zhCN: Messages = {
|
||||
'terminal.toolbar.hostHighlight.clearAll': '清除全部',
|
||||
'terminal.toolbar.hostHighlight.changeColor': '更改高亮颜色',
|
||||
'terminal.toolbar.hostHighlight.selectColor': '选择新规则的颜色',
|
||||
'terminal.statusbar.copyHostname.label': '复制主机地址',
|
||||
'terminal.statusbar.copyHostname.tooltip': '复制主机地址({hostname})',
|
||||
'terminal.statusbar.copyHostname.toast': '已复制主机地址:{hostname}',
|
||||
'terminal.statusbar.copyHostname.error': '复制主机地址失败',
|
||||
'terminal.serverStats.cpu': 'CPU 使用率',
|
||||
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
||||
'terminal.serverStats.memory': '内存使用',
|
||||
@@ -896,6 +934,16 @@ const zhCN: Messages = {
|
||||
'terminal.connection.protocol.mosh': 'Mosh',
|
||||
'terminal.connection.protocol.serial': '串口',
|
||||
'terminal.connection.protocol.local': '本地终端',
|
||||
'terminal.hostKey.unknownTitle': '确认主机指纹',
|
||||
'terminal.hostKey.changedTitle': '主机指纹已变化',
|
||||
'terminal.hostKey.unknownDescription': '尚未确认 {host} 的真实性。',
|
||||
'terminal.hostKey.changedDescription': '{host} 的已保存指纹与当前服务器不一致。',
|
||||
'terminal.hostKey.fingerprintLabel': '{keyType} 指纹为 SHA256:',
|
||||
'terminal.hostKey.savedFingerprintLabel': '已保存的指纹',
|
||||
'terminal.hostKey.unknownHint': '如果这个指纹属于你预期连接的服务器,可以记住它。',
|
||||
'terminal.hostKey.changedHint': '只有在你确认这台主机确实变更过时才继续。',
|
||||
'terminal.hostKey.addAndContinue': '记住并继续',
|
||||
'terminal.hostKey.updateAndContinue': '更新并继续',
|
||||
'terminal.themeModal.title': 'Terminal 外观',
|
||||
'terminal.themeModal.tab.theme': '主题',
|
||||
'terminal.themeModal.tab.font': '字体',
|
||||
@@ -1123,6 +1171,7 @@ const zhCN: Messages = {
|
||||
'cloudSync.conflict.keepLocal': '覆盖云端(保留本地)',
|
||||
'cloudSync.conflict.useCloud': '下载云端(覆盖本地)',
|
||||
'cloudSync.connect.browserContinue': '请在浏览器中完成授权',
|
||||
'cloudSync.connect.browserCancelled': '已取消上一个浏览器授权流程',
|
||||
'cloudSync.connect.github.success': 'GitHub 已连接',
|
||||
'cloudSync.connect.github.failedTitle': 'GitHub 连接失败',
|
||||
'cloudSync.connect.github.timeout': '连接 GitHub 超时,请检查网络或代理设置。',
|
||||
@@ -1210,8 +1259,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
|
||||
@@ -1360,6 +1412,17 @@ const zhCN: Messages = {
|
||||
'settings.terminal.section.keywordHighlight': '关键字高亮',
|
||||
'settings.terminal.font.family': '字体',
|
||||
'settings.terminal.font.family.desc': '终端字体',
|
||||
'settings.terminal.font.cjk': '中文 / CJK 字体',
|
||||
'settings.terminal.font.cjk.desc': '用于渲染中 / 日 / 韩字符的字体;"Auto" 会按主字体智能搭配',
|
||||
'settings.terminal.font.cjk.option.auto': 'Auto · 按主字体智能搭配',
|
||||
'settings.terminal.font.cjk.option.sarasaSC': 'Sarasa Mono SC (更纱黑体 简)',
|
||||
'settings.terminal.font.cjk.option.sarasaTC': 'Sarasa Mono TC (更纱黑体 繁)',
|
||||
'settings.terminal.font.cjk.option.mapleCN': 'Maple Mono CN',
|
||||
'settings.terminal.font.cjk.option.sourceHan': 'Source Han Mono SC (思源等宽)',
|
||||
'settings.terminal.font.cjk.option.notoCJK': 'Noto Sans Mono CJK SC',
|
||||
'settings.terminal.font.cjk.option.lxgwWenkai': 'LXGW WenKai Mono (霞鹜文楷等宽)',
|
||||
'settings.terminal.font.cjk.option.simSun': 'SimSun (宋体)',
|
||||
'settings.terminal.font.cjk.option.legacy': '{font} · 不推荐(非等宽字体)',
|
||||
'settings.terminal.font.size': '字体大小',
|
||||
'settings.terminal.font.size.desc': '终端文字大小',
|
||||
'settings.terminal.font.weight': '字重',
|
||||
@@ -1454,7 +1517,12 @@ const zhCN: Messages = {
|
||||
'settings.terminal.localShell.startDir.isFile': '路径是文件,不是目录',
|
||||
'settings.terminal.section.connection': '连接',
|
||||
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': '向服务器发送 SSH 级别保活数据包的频率(秒)。设为 0 表示禁用。',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': '向服务器发送 SSH 保活数据包的频率(秒)。设为 0 表示全局禁用——单个主机可在自己的设置里覆盖此值。',
|
||||
'settings.terminal.connection.keepaliveCountMax': '最大无响应保活次数',
|
||||
'settings.terminal.connection.keepaliveCountMax.desc': '判定连接死亡前允许的无响应保活次数。值越大对短暂网络抖动和响应慢的 SSH 服务越宽容。',
|
||||
'settings.terminal.connection.x11Display': 'X11 显示地址',
|
||||
'settings.terminal.connection.x11Display.desc': '可选的本机 X11 显示地址。留空则使用系统默认值。',
|
||||
'settings.terminal.connection.x11Display.placeholder': '自动(:0 或 DISPLAY)',
|
||||
'settings.terminal.section.serverStats': '服务器状态(Linux)',
|
||||
'settings.terminal.serverStats.show': '显示服务器状态',
|
||||
'settings.terminal.serverStats.show.desc': '在终端状态栏显示 CPU、内存和磁盘使用情况(仅限 Linux 服务器)。',
|
||||
@@ -1526,13 +1594,19 @@ const zhCN: Messages = {
|
||||
'settings.shortcuts.binding.sftp-new-folder': '新建文件夹',
|
||||
|
||||
// Host Details (sub-panels)
|
||||
'hostDetails.proxyPanel.title': 'Proxy',
|
||||
'hostDetails.proxyPanel.hostPlaceholder': 'Proxy host',
|
||||
'hostDetails.proxyPanel.credentials': 'Credentials',
|
||||
'hostDetails.proxyPanel.usernamePlaceholder': 'Username',
|
||||
'hostDetails.proxyPanel.passwordPlaceholder': 'Password',
|
||||
'hostDetails.proxyPanel.identities': 'Identities',
|
||||
'hostDetails.proxyPanel.remove': '移除 Proxy',
|
||||
'hostDetails.proxyPanel.title': '通过 HTTP/SOCKS5 代理',
|
||||
'hostDetails.proxyPanel.hostPlaceholder': '代理主机',
|
||||
'hostDetails.proxyPanel.credentials': '凭据',
|
||||
'hostDetails.proxyPanel.usernamePlaceholder': '用户名',
|
||||
'hostDetails.proxyPanel.passwordPlaceholder': '密码',
|
||||
'hostDetails.proxyPanel.identities': '身份',
|
||||
'hostDetails.proxyPanel.remove': '移除代理',
|
||||
'hostDetails.proxyPanel.savedProxy': '已保存代理',
|
||||
'hostDetails.proxyPanel.selectSaved': '选择已保存代理',
|
||||
'hostDetails.proxyPanel.customProxy': '自定义代理',
|
||||
'hostDetails.proxyPanel.missing': '缺失',
|
||||
'hostDetails.proxyPanel.missingSaved': '保存的代理不存在',
|
||||
'hostDetails.proxyPanel.error.required': '代理主机和端口不能为空。',
|
||||
'hostDetails.envVars.title': '环境变量',
|
||||
'hostDetails.envVars.desc': '为 {host} 设置环境变量。',
|
||||
'hostDetails.envVars.note': '部分 SSH 服务器默认只允许以 LC_ 和 LANG_ 为前缀的变量。',
|
||||
@@ -1659,6 +1733,7 @@ const zhCN: Messages = {
|
||||
'keychain.edit.publicKey': '公钥',
|
||||
'keychain.edit.certificate': '证书',
|
||||
'keychain.edit.certificatePlaceholder': '证书内容(可选)',
|
||||
'keychain.edit.filePath': '文件路径',
|
||||
'keychain.edit.keyExport': '密钥导出',
|
||||
'keychain.edit.exportToHost': '导出到主机',
|
||||
|
||||
@@ -1786,9 +1861,16 @@ const zhCN: Messages = {
|
||||
'passphrase.unlock': '解锁',
|
||||
'passphrase.unlocking': '解锁中...',
|
||||
'passphrase.skip': '跳过',
|
||||
'passphrase.remember': '记住此密码',
|
||||
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': '自动换行',
|
||||
'sftp.editor.maximize': '最大化',
|
||||
'sftp.editor.unsavedTitle': '未保存的修改',
|
||||
'sftp.editor.unsavedMessage': '{fileName} 有未保存的修改,是否保存后关闭?',
|
||||
'sftp.editor.discardChanges': '不保存',
|
||||
'sftp.editor.saveAndClose': '保存并关闭',
|
||||
'sftp.editor.quitBlockedByDirty': '存在未保存的编辑器,请先处理后再退出',
|
||||
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 设置',
|
||||
|
||||
@@ -3,6 +3,18 @@ import { useCallback,useSyncExternalStore } from 'react';
|
||||
// Simple store for active tab that allows fine-grained subscriptions
|
||||
type Listener = () => void;
|
||||
|
||||
// ----- Editor tab id helpers -----
|
||||
export const EDITOR_PREFIX = 'editor:';
|
||||
|
||||
/** Returns true when `id` is an editor tab id (starts with "editor:"). */
|
||||
export const isEditorTabId = (id: string): boolean => id.startsWith(EDITOR_PREFIX);
|
||||
|
||||
/** Convert an editorTab's internal id to a top-tab id understood by the tab bar. */
|
||||
export const toEditorTabId = (editorId: string): string => `${EDITOR_PREFIX}${editorId}`;
|
||||
|
||||
/** Strip the "editor:" prefix to recover the internal editorTab id. */
|
||||
export const fromEditorTabId = (tabId: string): string => tabId.slice(EDITOR_PREFIX.length);
|
||||
|
||||
class ActiveTabStore {
|
||||
private activeTabId: string = 'vault';
|
||||
private listeners = new Set<Listener>();
|
||||
@@ -70,9 +82,17 @@ export const useIsSftpActive = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Check if a specific editor tab is currently active
|
||||
export const useIsEditorTabActive = (tabId: string): boolean => {
|
||||
const editorTopId = toEditorTabId(tabId);
|
||||
const getSnapshot = useCallback(() => activeTabStore.getActiveTabId() === editorTopId, [editorTopId]);
|
||||
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot);
|
||||
};
|
||||
|
||||
// Check if terminal layer should be visible
|
||||
// Editor tabs are NOT terminal tabs, so exclude them from the visibility condition.
|
||||
export const useIsTerminalLayerVisible = (draggingSessionId: string | null) => {
|
||||
const activeTabId = useActiveTabId();
|
||||
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp';
|
||||
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !isEditorTabId(activeTabId);
|
||||
return isTerminalTab || !!draggingSessionId;
|
||||
};
|
||||
|
||||
194
application/state/defaultKeyPassphrases.test.ts
Normal file
194
application/state/defaultKeyPassphrases.test.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
clearKeyPassphrasesByIds,
|
||||
clearReferenceKeyPassphrases,
|
||||
loadDefaultKeyPassphrase,
|
||||
rememberKeyPassphrase,
|
||||
shouldUpdateReferenceKeyPassphrase,
|
||||
} from "../defaultKeyPassphrases";
|
||||
import { STORAGE_KEY_DEFAULT_KEY_PASSPHRASES } from "../../infrastructure/config/storageKeys";
|
||||
import type { SSHKey } from "../../domain/models";
|
||||
|
||||
function installLocalStorage(t: test.TestContext): void {
|
||||
const store = new Map<string, string>();
|
||||
const storage: Storage = {
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
clear() {
|
||||
store.clear();
|
||||
},
|
||||
getItem(key: string) {
|
||||
return store.get(key) ?? null;
|
||||
},
|
||||
key(index: number) {
|
||||
return Array.from(store.keys())[index] ?? null;
|
||||
},
|
||||
removeItem(key: string) {
|
||||
store.delete(key);
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store.set(key, value);
|
||||
},
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: storage,
|
||||
});
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: undefined },
|
||||
});
|
||||
|
||||
t.after(() => {
|
||||
Reflect.deleteProperty(globalThis, "localStorage");
|
||||
Reflect.deleteProperty(globalThis, "window");
|
||||
});
|
||||
}
|
||||
|
||||
const referenceKey = (): SSHKey => ({
|
||||
id: "reference-key",
|
||||
label: "id_ed25519",
|
||||
type: "ED25519",
|
||||
category: "key",
|
||||
source: "reference",
|
||||
filePath: "/Users/alice/.ssh/id_ed25519",
|
||||
privateKey: "",
|
||||
created: 1,
|
||||
});
|
||||
|
||||
test("loadDefaultKeyPassphrase removes undecryptable credential placeholders", async (t) => {
|
||||
installLocalStorage(t);
|
||||
const keyPath = "/Users/alice/.ssh/id_ed25519";
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({
|
||||
[keyPath]: "enc:v1:djEwYWJj",
|
||||
"/Users/alice/.ssh/id_rsa": "still-valid",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await loadDefaultKeyPassphrase(keyPath);
|
||||
|
||||
assert.equal(result, null);
|
||||
assert.deepEqual(
|
||||
JSON.parse(globalThis.localStorage.getItem(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? "{}"),
|
||||
{ "/Users/alice/.ssh/id_rsa": "still-valid" },
|
||||
);
|
||||
});
|
||||
|
||||
test("loadDefaultKeyPassphrase returns plain stored passphrases", async (t) => {
|
||||
installLocalStorage(t);
|
||||
const keyPath = "/Users/alice/.ssh/id_ed25519";
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({ [keyPath]: "correct horse battery staple" }),
|
||||
);
|
||||
|
||||
assert.equal(await loadDefaultKeyPassphrase(keyPath), "correct horse battery staple");
|
||||
});
|
||||
|
||||
test("clearReferenceKeyPassphrases clears matching reference key paths only", () => {
|
||||
const keys: SSHKey[] = [
|
||||
{
|
||||
...referenceKey(),
|
||||
passphrase: "bad",
|
||||
savePassphrase: true,
|
||||
},
|
||||
{
|
||||
...referenceKey(),
|
||||
id: "other-key",
|
||||
label: "other",
|
||||
filePath: "/Users/alice/.ssh/other",
|
||||
passphrase: "keep",
|
||||
savePassphrase: true,
|
||||
},
|
||||
];
|
||||
|
||||
const updated = clearReferenceKeyPassphrases(keys, ["/Users/alice/.ssh/id_ed25519"]);
|
||||
|
||||
assert.equal(updated[0].passphrase, undefined);
|
||||
assert.equal(updated[0].savePassphrase, false);
|
||||
assert.equal(updated[1].passphrase, "keep");
|
||||
});
|
||||
|
||||
test("clearKeyPassphrasesByIds clears matching saved key passphrases", () => {
|
||||
const keys: SSHKey[] = [
|
||||
{
|
||||
...referenceKey(),
|
||||
id: "inline-key",
|
||||
source: "imported",
|
||||
filePath: undefined,
|
||||
privateKey: "PRIVATE KEY",
|
||||
passphrase: "bad",
|
||||
savePassphrase: true,
|
||||
},
|
||||
{
|
||||
...referenceKey(),
|
||||
id: "other-key",
|
||||
label: "other",
|
||||
passphrase: "keep",
|
||||
savePassphrase: true,
|
||||
},
|
||||
];
|
||||
|
||||
const updated = clearKeyPassphrasesByIds(keys, ["inline-key"]);
|
||||
|
||||
assert.equal(updated[0].passphrase, undefined);
|
||||
assert.equal(updated[0].savePassphrase, false);
|
||||
assert.equal(updated[1].passphrase, "keep");
|
||||
});
|
||||
|
||||
test("shouldUpdateReferenceKeyPassphrase replaces missing or undecryptable passphrases", () => {
|
||||
assert.equal(shouldUpdateReferenceKeyPassphrase(null), false);
|
||||
assert.equal(shouldUpdateReferenceKeyPassphrase(referenceKey()), true);
|
||||
assert.equal(
|
||||
shouldUpdateReferenceKeyPassphrase({
|
||||
...referenceKey(),
|
||||
passphrase: "enc:v1:djEwAAAA",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldUpdateReferenceKeyPassphrase({
|
||||
...referenceKey(),
|
||||
passphrase: "saved",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("rememberKeyPassphrase updates reference key state before completing", async (t) => {
|
||||
installLocalStorage(t);
|
||||
const keys = [referenceKey()];
|
||||
let currentKeys = keys;
|
||||
let releaseUpdate: (() => void) | undefined;
|
||||
let rememberPromise: Promise<void> | undefined;
|
||||
const updateStarted = new Promise<void>((resolve) => {
|
||||
const updateKeys = async (updated: SSHKey[]) => {
|
||||
assert.equal(currentKeys[0].passphrase, "saved");
|
||||
assert.equal(updated[0].passphrase, "saved");
|
||||
resolve();
|
||||
await new Promise<void>((release) => {
|
||||
releaseUpdate = release;
|
||||
});
|
||||
};
|
||||
|
||||
rememberPromise = rememberKeyPassphrase({
|
||||
keyPath: "/Users/alice/.ssh/id_ed25519",
|
||||
passphrase: "saved",
|
||||
keys,
|
||||
updateKeys,
|
||||
setCurrentKeys: (updated) => {
|
||||
currentKeys = updated;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await updateStarted;
|
||||
assert.equal(currentKeys[0].passphrase, "saved");
|
||||
releaseUpdate?.();
|
||||
await rememberPromise;
|
||||
});
|
||||
69
application/state/editorSftpBridge.ts
Normal file
69
application/state/editorSftpBridge.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { SftpFilenameEncoding } from "../../types";
|
||||
|
||||
export interface EditorSftpWrite {
|
||||
(
|
||||
connectionId: string,
|
||||
expectedHostId: string,
|
||||
filePath: string,
|
||||
content: string,
|
||||
filenameEncoding?: SftpFilenameEncoding,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
// `useSftpState` is instantiated in at least two places (the top-level SftpView
|
||||
// and the per-terminal SftpSidePanel), each owning its own pane registry. An
|
||||
// editor tab opened from either path must be saved via the matching instance,
|
||||
// so the bridge tracks all currently-mounted writers and dispatches by
|
||||
// attempting each in turn until one succeeds.
|
||||
//
|
||||
// Each writer throws synchronously (or rejects) if the connectionId isn't in
|
||||
// its pane registry; we use "connection no longer available" text as the
|
||||
// signal to fall through to the next writer. Any other error is re-thrown
|
||||
// immediately because it represents a real save failure the user must see.
|
||||
const writers = new Set<EditorSftpWrite>();
|
||||
|
||||
const NOT_MY_CONNECTION_RE = /SFTP connection is no longer available/i;
|
||||
|
||||
export const registerEditorSftpWriter = (fn: EditorSftpWrite | null) => {
|
||||
// Pass `null` on cleanup — but cleanup also needs to know WHICH writer to
|
||||
// remove. Callers who register once per mount should instead use
|
||||
// `registerEditorSftpWriterScoped` below, which returns an unregister fn.
|
||||
// This legacy signature is preserved for callers that prefer the
|
||||
// register/unregister-with-null pattern: we clear ALL writers on null.
|
||||
if (fn === null) {
|
||||
writers.clear();
|
||||
return;
|
||||
}
|
||||
writers.add(fn);
|
||||
};
|
||||
|
||||
export const registerEditorSftpWriterScoped = (fn: EditorSftpWrite): (() => void) => {
|
||||
writers.add(fn);
|
||||
return () => {
|
||||
writers.delete(fn);
|
||||
};
|
||||
};
|
||||
|
||||
export const editorSftpWrite: EditorSftpWrite = async (...args) => {
|
||||
if (writers.size === 0) {
|
||||
throw new Error("SFTP editor bridge not registered — cannot save (no SFTP view mounted)");
|
||||
}
|
||||
let lastNotMine: Error | null = null;
|
||||
for (const fn of writers) {
|
||||
try {
|
||||
await fn(...args);
|
||||
return;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (NOT_MY_CONNECTION_RE.test(msg)) {
|
||||
// This writer doesn't own the connectionId — try the next one.
|
||||
lastNotMine = err instanceof Error ? err : new Error(msg);
|
||||
continue;
|
||||
}
|
||||
// Real save error — surface it.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
// No writer owned the connectionId.
|
||||
throw lastNotMine ?? new Error("SFTP connection is no longer available");
|
||||
};
|
||||
88
application/state/editorTabSave.test.ts
Normal file
88
application/state/editorTabSave.test.ts
Normal 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");
|
||||
});
|
||||
72
application/state/editorTabSave.ts
Normal file
72
application/state/editorTabSave.ts
Normal 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;
|
||||
219
application/state/editorTabStore.test.ts
Normal file
219
application/state/editorTabStore.test.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { EditorTabStore, type EditorTab } from "./editorTabStore.ts";
|
||||
|
||||
const makeTab = (overrides: Partial<EditorTab> = {}): EditorTab => ({
|
||||
id: "edt_1",
|
||||
kind: "editor",
|
||||
sessionId: "conn_1",
|
||||
hostId: "host_1",
|
||||
remotePath: "/etc/nginx/nginx.conf",
|
||||
fileName: "nginx.conf",
|
||||
languageId: "ini",
|
||||
content: "worker_processes auto;",
|
||||
baselineContent: "worker_processes auto;",
|
||||
wordWrap: false,
|
||||
viewState: null,
|
||||
savingState: "idle",
|
||||
saveError: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("updateContent stores content and viewState; dirty flag derives from baseline", () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab());
|
||||
store.updateContent("edt_1", "worker_processes 4;", null);
|
||||
const tab = store.getTab("edt_1")!;
|
||||
assert.equal(tab.content, "worker_processes 4;");
|
||||
assert.equal(store.isDirty("edt_1"), true);
|
||||
});
|
||||
|
||||
test("markSaved moves baseline to current content and clears dirty", () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab({ content: "changed", baselineContent: "orig" }));
|
||||
assert.equal(store.isDirty("edt_1"), true);
|
||||
store.markSaved("edt_1", "changed");
|
||||
assert.equal(store.isDirty("edt_1"), false);
|
||||
assert.equal(store.getTab("edt_1")!.baselineContent, "changed");
|
||||
});
|
||||
|
||||
test("setWordWrap updates only that tab", () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab({ id: "edt_1" }));
|
||||
store._debugInsert(makeTab({ id: "edt_2", remotePath: "/b.txt", fileName: "b.txt" }));
|
||||
store.setWordWrap("edt_1", true);
|
||||
assert.equal(store.getTab("edt_1")!.wordWrap, true);
|
||||
assert.equal(store.getTab("edt_2")!.wordWrap, false);
|
||||
});
|
||||
|
||||
test("setSavingState transitions and clears error on idle", () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab());
|
||||
store.setSavingState("edt_1", "saving");
|
||||
assert.equal(store.getTab("edt_1")!.savingState, "saving");
|
||||
store.setSavingState("edt_1", "error", "EACCES");
|
||||
assert.equal(store.getTab("edt_1")!.saveError, "EACCES");
|
||||
store.setSavingState("edt_1", "idle");
|
||||
assert.equal(store.getTab("edt_1")!.saveError, null);
|
||||
});
|
||||
|
||||
test("close removes the tab and returns remaining ids in order", () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab({ id: "edt_1" }));
|
||||
store._debugInsert(makeTab({ id: "edt_2", remotePath: "/b.txt", fileName: "b.txt" }));
|
||||
store.close("edt_1");
|
||||
assert.equal(store.getTab("edt_1"), undefined);
|
||||
assert.deepEqual(store.getTabs().map((t) => t.id), ["edt_2"]);
|
||||
});
|
||||
|
||||
test("subscribers fire on change and not on read", () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab());
|
||||
let count = 0;
|
||||
const unsub = store.subscribe(() => { count++; });
|
||||
store.getTab("edt_1");
|
||||
store.getTabs();
|
||||
assert.equal(count, 0);
|
||||
store.updateContent("edt_1", "x", null);
|
||||
// notifications are microtask-deferred, flush via awaiting a resolved promise
|
||||
return Promise.resolve().then(() => {
|
||||
assert.equal(count, 1);
|
||||
unsub();
|
||||
});
|
||||
});
|
||||
|
||||
test("promoteFromModal creates a new tab and returns its id", () => {
|
||||
const store = new EditorTabStore();
|
||||
const id = store.promoteFromModal({
|
||||
sessionId: "conn_1",
|
||||
hostId: "host_1",
|
||||
remotePath: "/etc/nginx/nginx.conf",
|
||||
fileName: "nginx.conf",
|
||||
languageId: "ini",
|
||||
content: "x",
|
||||
baselineContent: "x",
|
||||
wordWrap: false,
|
||||
viewState: null,
|
||||
});
|
||||
const tab = store.getTab(id)!;
|
||||
assert.equal(tab.remotePath, "/etc/nginx/nginx.conf");
|
||||
assert.equal(tab.fileName, "nginx.conf");
|
||||
assert.equal(tab.kind, "editor");
|
||||
});
|
||||
|
||||
test("promoteFromModal focuses existing tab for same sessionId+normalized path and overrides content", () => {
|
||||
const store = new EditorTabStore();
|
||||
const first = store.promoteFromModal({
|
||||
sessionId: "conn_1",
|
||||
hostId: "host_1",
|
||||
remotePath: "/etc/nginx/./nginx.conf",
|
||||
fileName: "nginx.conf",
|
||||
languageId: "ini",
|
||||
content: "v1",
|
||||
baselineContent: "v1",
|
||||
wordWrap: false,
|
||||
viewState: null,
|
||||
});
|
||||
const second = store.promoteFromModal({
|
||||
sessionId: "conn_1",
|
||||
hostId: "host_1",
|
||||
remotePath: "/etc/nginx/nginx.conf",
|
||||
fileName: "nginx.conf",
|
||||
languageId: "ini",
|
||||
content: "v2",
|
||||
baselineContent: "v1",
|
||||
wordWrap: false,
|
||||
viewState: null,
|
||||
});
|
||||
assert.equal(second, first);
|
||||
assert.equal(store.getTab(first)!.content, "v2");
|
||||
assert.equal(store.getTabs().length, 1);
|
||||
});
|
||||
|
||||
test("dedup scope is per-sessionId — same path on different sessions are distinct tabs", () => {
|
||||
const store = new EditorTabStore();
|
||||
const a = store.promoteFromModal({
|
||||
sessionId: "conn_A",
|
||||
hostId: "host_1",
|
||||
remotePath: "/etc/hosts",
|
||||
fileName: "hosts",
|
||||
languageId: "plaintext",
|
||||
content: "", baselineContent: "", wordWrap: false, viewState: null,
|
||||
});
|
||||
const b = store.promoteFromModal({
|
||||
sessionId: "conn_B",
|
||||
hostId: "host_2",
|
||||
remotePath: "/etc/hosts",
|
||||
fileName: "hosts",
|
||||
languageId: "plaintext",
|
||||
content: "", baselineContent: "", wordWrap: false, viewState: null,
|
||||
});
|
||||
assert.notEqual(a, b);
|
||||
assert.equal(store.getTabs().length, 2);
|
||||
});
|
||||
|
||||
test("confirmCloseBySession returns true when no tabs match", async () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab());
|
||||
const ok = await store.confirmCloseBySession("other_conn", async () => "discard");
|
||||
assert.equal(ok, true);
|
||||
assert.equal(store.getTabs().length, 1);
|
||||
});
|
||||
|
||||
test("confirmCloseBySession discards all dirty matching tabs when prompt returns 'discard'", async () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab({ id: "edt_1", content: "x", baselineContent: "y" }));
|
||||
store._debugInsert(makeTab({ id: "edt_2", remotePath: "/b.txt", fileName: "b.txt", content: "x", baselineContent: "y" }));
|
||||
const ok = await store.confirmCloseBySession("conn_1", async () => "discard");
|
||||
assert.equal(ok, true);
|
||||
assert.equal(store.getTabs().length, 0);
|
||||
});
|
||||
|
||||
test("confirmCloseBySession closes clean tabs without prompting; aborts on cancel", async () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab({ id: "edt_clean" })); // content == baseline
|
||||
store._debugInsert(makeTab({ id: "edt_dirty", remotePath: "/b.txt", fileName: "b.txt", content: "x", baselineContent: "y" }));
|
||||
let prompts = 0;
|
||||
const ok = await store.confirmCloseBySession("conn_1", async () => { prompts++; return "cancel"; });
|
||||
assert.equal(ok, false);
|
||||
assert.equal(prompts, 1, "prompt fires only for dirty tab");
|
||||
// clean tab was closed before the dirty cancel aborted the batch
|
||||
assert.equal(store.getTab("edt_clean"), undefined);
|
||||
assert.ok(store.getTab("edt_dirty"));
|
||||
});
|
||||
|
||||
test("confirmCloseBySession invokes save callback for 'save' choice and only closes on save success", async () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab({ id: "edt_1", content: "new", baselineContent: "old" }));
|
||||
let saved = false;
|
||||
const ok = await store.confirmCloseBySession("conn_1", async () => "save", async (id) => {
|
||||
assert.equal(id, "edt_1");
|
||||
saved = true;
|
||||
store.markSaved(id, "new");
|
||||
});
|
||||
assert.equal(saved, true);
|
||||
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);
|
||||
});
|
||||
259
application/state/editorTabStore.ts
Normal file
259
application/state/editorTabStore.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
import type * as Monaco from "monaco-editor";
|
||||
|
||||
import { activeTabStore, fromEditorTabId, isEditorTabId } from "./activeTabStore";
|
||||
|
||||
// POSIX-style normalization: collapse "/./" and duplicate slashes, not ".." (remote paths
|
||||
// may contain semantic ".." segments we don't want to resolve client-side).
|
||||
const normalizePath = (p: string): string => {
|
||||
const collapsed = p.replace(/\/+/g, "/").replace(/\/\.(?=\/|$)/g, "");
|
||||
return collapsed.length > 1 && collapsed.endsWith("/") ? collapsed.slice(0, -1) : collapsed;
|
||||
};
|
||||
|
||||
export type EditorTabId = string;
|
||||
|
||||
export type EditorSavingState = "idle" | "saving" | "error";
|
||||
|
||||
export interface EditorTab {
|
||||
id: EditorTabId;
|
||||
kind: "editor";
|
||||
/** SFTP connection id (matches SftpConnection.id). Session lookup key. */
|
||||
sessionId: string;
|
||||
/** Stable endpoint id; used to verify the session is still the one we opened against. */
|
||||
hostId: string;
|
||||
remotePath: string;
|
||||
fileName: string;
|
||||
languageId: string;
|
||||
content: string;
|
||||
baselineContent: string;
|
||||
wordWrap: boolean;
|
||||
viewState: Monaco.editor.ICodeEditorViewState | null;
|
||||
savingState: EditorSavingState;
|
||||
saveError: string | null;
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
let idCounter = 0;
|
||||
const genId = (): EditorTabId => `edt_${Date.now().toString(36)}_${(++idCounter).toString(36)}`;
|
||||
|
||||
export class EditorTabStore {
|
||||
private tabs: EditorTab[] = [];
|
||||
private listeners = new Set<Listener>();
|
||||
private pendingNotify = false;
|
||||
|
||||
getTabs = (): readonly EditorTab[] => this.tabs;
|
||||
getTab = (id: EditorTabId): EditorTab | undefined => this.tabs.find((t) => t.id === id);
|
||||
isDirty = (id: EditorTabId): boolean => {
|
||||
const t = this.getTab(id);
|
||||
return !!t && t.content !== t.baselineContent;
|
||||
};
|
||||
|
||||
updateContent = (
|
||||
id: EditorTabId,
|
||||
content: string,
|
||||
viewState: Monaco.editor.ICodeEditorViewState | null,
|
||||
) => {
|
||||
this.patch(id, { content, viewState });
|
||||
};
|
||||
|
||||
markSaved = (id: EditorTabId, newBaseline: string) => {
|
||||
this.patch(id, { baselineContent: newBaseline, savingState: "idle", saveError: null });
|
||||
};
|
||||
|
||||
setWordWrap = (id: EditorTabId, value: boolean) => {
|
||||
this.patch(id, { wordWrap: value });
|
||||
};
|
||||
|
||||
setLanguage = (id: EditorTabId, languageId: string) => {
|
||||
this.patch(id, { languageId });
|
||||
};
|
||||
|
||||
setSavingState = (id: EditorTabId, state: EditorSavingState, error: string | null = null) => {
|
||||
const patch: Partial<EditorTab> = { savingState: state };
|
||||
if (state === "idle") patch.saveError = null;
|
||||
else if (state === "error") patch.saveError = error;
|
||||
this.patch(id, patch);
|
||||
};
|
||||
|
||||
close = (id: EditorTabId) => {
|
||||
const next = this.tabs.filter((t) => t.id !== id);
|
||||
if (next.length !== this.tabs.length) {
|
||||
this.tabs = next;
|
||||
this.notify();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Force-close every tab bound to any of the given sessionIds, with no dirty
|
||||
* prompt. Intended for cases where the owning SFTP instance has gone away
|
||||
* entirely (e.g. the hosting terminal tab was closed) and there is no
|
||||
* realistic save channel anyway. Returns the closed tab ids.
|
||||
*/
|
||||
forceCloseBySessions = (sessionIds: readonly string[]): EditorTabId[] => {
|
||||
if (sessionIds.length === 0) return [];
|
||||
const idSet = new Set(sessionIds);
|
||||
const removed = this.tabs.filter((t) => idSet.has(t.sessionId)).map((t) => t.id);
|
||||
if (removed.length === 0) return [];
|
||||
this.tabs = this.tabs.filter((t) => !idSet.has(t.sessionId));
|
||||
this.notify();
|
||||
|
||||
// If the current active tab was one of the editor tabs we just removed,
|
||||
// fall back to 'vault' so the user doesn't end up on a stale id (empty
|
||||
// chrome + no content). Any better neighbor choice would need the full
|
||||
// orderedTabs list, which isn't available here; 'vault' is always valid.
|
||||
const activeId = activeTabStore.getActiveTabId();
|
||||
if (isEditorTabId(activeId)) {
|
||||
const activeEditorId = fromEditorTabId(activeId);
|
||||
if (activeEditorId && removed.includes(activeEditorId)) {
|
||||
activeTabStore.setActiveTabId('vault');
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
};
|
||||
|
||||
promoteFromModal = (snapshot: {
|
||||
sessionId: string;
|
||||
hostId: string;
|
||||
remotePath: string;
|
||||
fileName: string;
|
||||
languageId: string;
|
||||
content: string;
|
||||
baselineContent: string;
|
||||
wordWrap: boolean;
|
||||
viewState: Monaco.editor.ICodeEditorViewState | null;
|
||||
}): EditorTabId => {
|
||||
const normalized = normalizePath(snapshot.remotePath);
|
||||
const existing = this.tabs.find(
|
||||
(t) => t.sessionId === snapshot.sessionId && normalizePath(t.remotePath) === normalized,
|
||||
);
|
||||
if (existing) {
|
||||
this.patch(existing.id, {
|
||||
content: snapshot.content,
|
||||
baselineContent: snapshot.baselineContent,
|
||||
wordWrap: snapshot.wordWrap,
|
||||
viewState: snapshot.viewState,
|
||||
// keep languageId/hostId/fileName stable; they shouldn't change for the same path
|
||||
});
|
||||
return existing.id;
|
||||
}
|
||||
const tab: EditorTab = {
|
||||
id: this.makeId(),
|
||||
kind: "editor",
|
||||
sessionId: snapshot.sessionId,
|
||||
hostId: snapshot.hostId,
|
||||
remotePath: snapshot.remotePath,
|
||||
fileName: snapshot.fileName,
|
||||
languageId: snapshot.languageId,
|
||||
content: snapshot.content,
|
||||
baselineContent: snapshot.baselineContent,
|
||||
wordWrap: snapshot.wordWrap,
|
||||
viewState: snapshot.viewState,
|
||||
savingState: "idle",
|
||||
saveError: null,
|
||||
};
|
||||
this.tabs = [...this.tabs, tab];
|
||||
this.notify();
|
||||
return tab.id;
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk all editor tabs bound to `sessionId`. Clean tabs close silently; dirty tabs
|
||||
* prompt via `promptChoice`. 'save' invokes `saveTab` and closes only on its success.
|
||||
* Any 'cancel' aborts the batch (subsequent dirty tabs are preserved) and returns false.
|
||||
*/
|
||||
confirmCloseBySession = async (
|
||||
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") {
|
||||
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 {
|
||||
await saveTab(tab.id);
|
||||
} catch {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => { this.listeners.delete(listener); };
|
||||
};
|
||||
|
||||
/** TEST-ONLY: seed a tab without going through promote/openOrFocus. */
|
||||
_debugInsert = (tab: EditorTab) => {
|
||||
this.tabs = [...this.tabs, tab];
|
||||
this.notify();
|
||||
};
|
||||
|
||||
protected makeId = genId;
|
||||
|
||||
protected patch = (id: EditorTabId, patch: Partial<EditorTab>) => {
|
||||
let changed = false;
|
||||
this.tabs = this.tabs.map((t) => {
|
||||
if (t.id !== id) return t;
|
||||
changed = true;
|
||||
return { ...t, ...patch };
|
||||
});
|
||||
if (changed) this.notify();
|
||||
};
|
||||
|
||||
protected notify = () => {
|
||||
if (this.pendingNotify) return;
|
||||
this.pendingNotify = true;
|
||||
Promise.resolve().then(() => {
|
||||
this.pendingNotify = false;
|
||||
this.listeners.forEach((l) => l());
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const editorTabStore = new EditorTabStore();
|
||||
|
||||
// Hooks
|
||||
const getTabsSnapshot = () => editorTabStore.getTabs();
|
||||
|
||||
export const useEditorTabs = (): readonly EditorTab[] =>
|
||||
useSyncExternalStore(editorTabStore.subscribe, getTabsSnapshot);
|
||||
|
||||
export const useEditorTab = (id: EditorTabId): EditorTab | undefined => {
|
||||
const getSnapshot = useCallback(() => editorTabStore.getTab(id), [id]);
|
||||
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
|
||||
};
|
||||
|
||||
export const useEditorDirty = (id: EditorTabId): boolean => {
|
||||
const getSnapshot = useCallback(() => editorTabStore.isDirty(id), [id]);
|
||||
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
|
||||
};
|
||||
|
||||
export const useAnyEditorDirty = (): boolean => {
|
||||
const getSnapshot = useCallback(
|
||||
() => editorTabStore.getTabs().some((t) => t.content !== t.baselineContent),
|
||||
[],
|
||||
);
|
||||
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { TERMINAL_FONTS, type TerminalFont } from '../../infrastructure/config/fonts';
|
||||
import { getMonospaceFonts } from '../../lib/localFonts';
|
||||
import { getAllSystemFontFamilies, getMonospaceFonts } from '../../lib/localFonts';
|
||||
import { setSystemFamilies } from '../../lib/fontAvailability';
|
||||
|
||||
/**
|
||||
* Global font store - singleton pattern using useSyncExternalStore
|
||||
@@ -60,7 +61,14 @@ class FontStore {
|
||||
this.setState({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const localFonts = await getMonospaceFonts();
|
||||
// Populate the authoritative installed-family set used by
|
||||
// fontAvailability.isFontInstalled. Runs in parallel with the
|
||||
// monospace-only query (both share an underlying cache).
|
||||
const [localFonts, systemFamilies] = await Promise.all([
|
||||
getMonospaceFonts(),
|
||||
getAllSystemFontFamilies(),
|
||||
]);
|
||||
setSystemFamilies(systemFamilies);
|
||||
|
||||
// Combine default fonts with local fonts, deduplicate by id
|
||||
const fontMap = new Map<string, TerminalFont>();
|
||||
|
||||
64
application/state/resolveSnippetsShortcutIntent.test.ts
Normal file
64
application/state/resolveSnippetsShortcutIntent.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
resolveScriptsSidePanelShortcutIntent,
|
||||
resolveSnippetsShortcutIntent,
|
||||
} from "./resolveSnippetsShortcutIntent.ts";
|
||||
|
||||
test("active single terminal tab toggles the terminal scripts panel", () => {
|
||||
const result = resolveSnippetsShortcutIntent({
|
||||
activeTabId: "s1",
|
||||
sessionForTab: { id: "s1" },
|
||||
workspaceForTab: null,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { kind: "toggleTerminalScripts" });
|
||||
});
|
||||
|
||||
test("active workspace tab toggles the terminal scripts panel", () => {
|
||||
const result = resolveSnippetsShortcutIntent({
|
||||
activeTabId: "w1",
|
||||
sessionForTab: null,
|
||||
workspaceForTab: { id: "w1" },
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { kind: "toggleTerminalScripts" });
|
||||
});
|
||||
|
||||
test("non-terminal tabs navigate to the vault snippets section", () => {
|
||||
for (const activeTabId of ["vault", "sftp", "editor:notes", "log1", null]) {
|
||||
const result = resolveSnippetsShortcutIntent({
|
||||
activeTabId,
|
||||
sessionForTab: null,
|
||||
workspaceForTab: null,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { kind: "openVaultSnippets" });
|
||||
}
|
||||
});
|
||||
|
||||
test("terminal tabs fall back to vault snippets when terminal toggle is unavailable", () => {
|
||||
const result = resolveSnippetsShortcutIntent({
|
||||
activeTabId: "s1",
|
||||
sessionForTab: { id: "s1" },
|
||||
workspaceForTab: null,
|
||||
terminalScriptsToggleAvailable: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { kind: "openVaultSnippets" });
|
||||
});
|
||||
|
||||
test("scripts panel shortcut closes when scripts is already open", () => {
|
||||
const result = resolveScriptsSidePanelShortcutIntent("scripts");
|
||||
|
||||
assert.deepEqual(result, { kind: "closeTerminalSidePanel" });
|
||||
});
|
||||
|
||||
test("scripts panel shortcut opens scripts from closed or other panel states", () => {
|
||||
for (const activePanel of [null, "sftp", "theme", "ai"]) {
|
||||
const result = resolveScriptsSidePanelShortcutIntent(activePanel);
|
||||
|
||||
assert.deepEqual(result, { kind: "openTerminalScripts" });
|
||||
}
|
||||
});
|
||||
42
application/state/resolveSnippetsShortcutIntent.ts
Normal file
42
application/state/resolveSnippetsShortcutIntent.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export type SnippetsShortcutIntent =
|
||||
| { kind: 'toggleTerminalScripts' }
|
||||
| { kind: 'openVaultSnippets' };
|
||||
|
||||
export type ScriptsSidePanelShortcutIntent =
|
||||
| { kind: 'closeTerminalSidePanel' }
|
||||
| { kind: 'openTerminalScripts' };
|
||||
|
||||
export interface ResolveSnippetsShortcutIntentInput {
|
||||
activeTabId: string | null;
|
||||
sessionForTab: { id: string } | null;
|
||||
workspaceForTab: { id: string } | null;
|
||||
terminalScriptsToggleAvailable?: boolean;
|
||||
}
|
||||
|
||||
export function resolveSnippetsShortcutIntent(
|
||||
input: ResolveSnippetsShortcutIntentInput,
|
||||
): SnippetsShortcutIntent {
|
||||
const {
|
||||
activeTabId,
|
||||
sessionForTab,
|
||||
workspaceForTab,
|
||||
terminalScriptsToggleAvailable = true,
|
||||
} = input;
|
||||
if (!activeTabId) return { kind: 'openVaultSnippets' };
|
||||
|
||||
if ((sessionForTab || workspaceForTab) && terminalScriptsToggleAvailable) {
|
||||
return { kind: 'toggleTerminalScripts' };
|
||||
}
|
||||
|
||||
return { kind: 'openVaultSnippets' };
|
||||
}
|
||||
|
||||
export function resolveScriptsSidePanelShortcutIntent(
|
||||
activePanel: string | null,
|
||||
): ScriptsSidePanelShortcutIntent {
|
||||
if (activePanel === 'scripts') {
|
||||
return { kind: 'closeTerminalSidePanel' };
|
||||
}
|
||||
|
||||
return { kind: 'openTerminalScripts' };
|
||||
}
|
||||
@@ -64,4 +64,10 @@ export interface SftpStateOptions {
|
||||
useCompressedUpload?: boolean;
|
||||
defaultShowHiddenFiles?: boolean;
|
||||
autoConnectLocalOnMount?: boolean;
|
||||
/**
|
||||
* Global SSH keepalive settings, forwarded through to per-SFTP-connection
|
||||
* keepalive resolution so a host that has opted into its own override
|
||||
* is honored for SFTP browsing too (not just the terminal session).
|
||||
*/
|
||||
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ interface UseSftpConnectionsParams {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
|
||||
leftTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
|
||||
rightTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
|
||||
leftTabs: { tabs: SftpPane[] };
|
||||
@@ -44,6 +45,7 @@ export const useSftpConnections = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
terminalSettings,
|
||||
leftTabsRef,
|
||||
rightTabsRef,
|
||||
leftTabs,
|
||||
@@ -65,7 +67,7 @@ export const useSftpConnections = ({
|
||||
createEmptyPane,
|
||||
autoConnectLocalOnMount = true,
|
||||
}: UseSftpConnectionsParams): UseSftpConnectionsResult => {
|
||||
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities });
|
||||
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities, terminalSettings });
|
||||
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
|
||||
|
||||
const connect = useCallback(
|
||||
@@ -281,7 +283,7 @@ export const useSftpConnections = ({
|
||||
);
|
||||
};
|
||||
|
||||
const hasKey = !!credentials.privateKey;
|
||||
const hasKey = !!credentials.privateKey || !!credentials.identityFilePaths?.length;
|
||||
const hasPassword = !!credentials.password;
|
||||
|
||||
let sftpId: string | undefined;
|
||||
@@ -305,6 +307,7 @@ export const useSftpConnections = ({
|
||||
publicKey: undefined,
|
||||
keyId: undefined,
|
||||
keySource: undefined,
|
||||
identityFilePaths: undefined,
|
||||
});
|
||||
} else {
|
||||
throw err;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useRef, useMemo } from "react";
|
||||
import { TransferTask, TransferStatus } from "../../../domain/models";
|
||||
import React, { useCallback, useRef, useMemo, useState } from "react";
|
||||
import { FileConflict, FileConflictAction, TransferTask, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { SftpPane } from "./types";
|
||||
@@ -7,11 +7,13 @@ import { joinPath } from "./utils";
|
||||
import {
|
||||
UploadController,
|
||||
uploadFromDataTransfer,
|
||||
uploadFromFileList,
|
||||
uploadEntriesDirect,
|
||||
UploadBridge,
|
||||
UploadCallbacks,
|
||||
UploadResult,
|
||||
UploadTaskInfo,
|
||||
startUploadScanningTask,
|
||||
} from "../../../lib/uploadService";
|
||||
import type { DropEntry } from "../../../lib/sftpFileUtils";
|
||||
|
||||
@@ -20,6 +22,7 @@ export type { UploadResult };
|
||||
|
||||
interface UseSftpExternalOperationsParams {
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
getPaneByConnectionId: (connectionId: string) => SftpPane | null;
|
||||
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
|
||||
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
|
||||
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
|
||||
@@ -35,6 +38,13 @@ interface SftpExternalOperationsResult {
|
||||
readTextFile: (side: "left" | "right", filePath: string) => Promise<string>;
|
||||
readBinaryFile: (side: "left" | "right", filePath: string) => Promise<ArrayBuffer>;
|
||||
writeTextFile: (side: "left" | "right", filePath: string, content: string) => Promise<void>;
|
||||
writeTextFileByConnection: (
|
||||
connectionId: string,
|
||||
expectedHostId: string,
|
||||
filePath: string,
|
||||
content: string,
|
||||
filenameEncoding?: SftpFilenameEncoding,
|
||||
) => Promise<void>;
|
||||
downloadToTempAndOpen: (
|
||||
side: "left" | "right",
|
||||
remotePath: string,
|
||||
@@ -48,6 +58,16 @@ interface SftpExternalOperationsResult {
|
||||
dataTransfer: DataTransfer,
|
||||
targetPath?: string
|
||||
) => Promise<UploadResult[]>;
|
||||
uploadExternalFileList: (
|
||||
side: "left" | "right",
|
||||
fileList: FileList | File[],
|
||||
targetPath?: string
|
||||
) => Promise<UploadResult[]>;
|
||||
uploadExternalFolderPath: (
|
||||
side: "left" | "right",
|
||||
folderPath: string,
|
||||
targetPath?: string
|
||||
) => Promise<UploadResult[]>;
|
||||
uploadExternalEntries: (
|
||||
side: "left" | "right",
|
||||
entries: DropEntry[],
|
||||
@@ -55,6 +75,8 @@ interface SftpExternalOperationsResult {
|
||||
) => Promise<UploadResult[]>;
|
||||
cancelExternalUpload: () => Promise<void>;
|
||||
selectApplication: () => Promise<{ path: string; name: string } | null>;
|
||||
uploadConflicts: FileConflict[];
|
||||
resolveUploadConflict: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => void;
|
||||
}
|
||||
|
||||
export const useSftpExternalOperations = (
|
||||
@@ -62,6 +84,7 @@ export const useSftpExternalOperations = (
|
||||
): SftpExternalOperationsResult => {
|
||||
const {
|
||||
getActivePane,
|
||||
getPaneByConnectionId,
|
||||
refresh,
|
||||
sftpSessionsRef,
|
||||
connectionCacheKeyMapRef,
|
||||
@@ -79,6 +102,11 @@ export const useSftpExternalOperations = (
|
||||
// Track active file watches so the side panel can block host-switching.
|
||||
// Reset to 0 when the SFTP session disconnects (handled in SftpSidePanel).
|
||||
const activeFileWatchCountRef = useRef(0);
|
||||
const [uploadConflicts, setUploadConflicts] = useState<FileConflict[]>([]);
|
||||
const uploadConflictResolversRef = useRef(new Map<string, {
|
||||
resolve: (action: FileConflictAction) => void;
|
||||
setDefault: (action: FileConflictAction) => void;
|
||||
}>());
|
||||
|
||||
const readTextFile = useCallback(
|
||||
async (side: "left" | "right", filePath: string): Promise<string> => {
|
||||
@@ -173,6 +201,41 @@ export const useSftpExternalOperations = (
|
||||
[getActivePane, sftpSessionsRef],
|
||||
);
|
||||
|
||||
const writeTextFileByConnection = useCallback(
|
||||
async (
|
||||
connectionId: string,
|
||||
expectedHostId: string,
|
||||
filePath: string,
|
||||
content: string,
|
||||
filenameEncoding?: SftpFilenameEncoding,
|
||||
): Promise<void> => {
|
||||
const pane = getPaneByConnectionId(connectionId);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("SFTP connection is no longer available");
|
||||
}
|
||||
if (pane.connection.hostId !== expectedHostId) {
|
||||
throw new Error("SFTP connection changed while editing — file not saved to prevent writing to wrong host");
|
||||
}
|
||||
|
||||
if (pane.connection.isLocal) {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.writeLocalFile) throw new Error("Local file writing not supported");
|
||||
const data = new TextEncoder().encode(content);
|
||||
await bridge.writeLocalFile(filePath, data.buffer);
|
||||
return;
|
||||
}
|
||||
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) throw new Error("SFTP session not found");
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge) throw new Error("Bridge not available");
|
||||
|
||||
await bridge.writeSftp(sftpId, filePath, content, filenameEncoding ?? pane.filenameEncoding);
|
||||
},
|
||||
[getPaneByConnectionId, sftpSessionsRef],
|
||||
);
|
||||
|
||||
const downloadToTempAndOpen = useCallback(
|
||||
async (
|
||||
side: "left" | "right",
|
||||
@@ -452,18 +515,99 @@ export const useSftpExternalOperations = (
|
||||
};
|
||||
}, [addExternalUpload, updateExternalUpload, dismissExternalUpload]);
|
||||
|
||||
const resolveUploadConflict = useCallback((conflictId: string, action: FileConflictAction, applyToAll = false) => {
|
||||
const conflict = uploadConflicts.find((item) => item.transferId === conflictId);
|
||||
setUploadConflicts((prev) => prev.filter((item) => item.transferId !== conflictId));
|
||||
const resolver = uploadConflictResolversRef.current.get(conflictId);
|
||||
if (!resolver) return;
|
||||
uploadConflictResolversRef.current.delete(conflictId);
|
||||
if (conflict && applyToAll) {
|
||||
resolver.setDefault(action);
|
||||
}
|
||||
resolver.resolve(action);
|
||||
}, [uploadConflicts]);
|
||||
|
||||
const cancelPendingUploadConflicts = useCallback(() => {
|
||||
const resolvers = Array.from(uploadConflictResolversRef.current.values());
|
||||
if (resolvers.length === 0) return;
|
||||
|
||||
uploadConflictResolversRef.current.clear();
|
||||
setUploadConflicts([]);
|
||||
for (const resolver of resolvers) {
|
||||
resolver.resolve("stop");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createUploadConflictResolver = useCallback(() => {
|
||||
const conflictDefaults = new Map<string, FileConflictAction>();
|
||||
|
||||
return async (conflict: {
|
||||
fileName: string;
|
||||
targetPath: string;
|
||||
isDirectory: boolean;
|
||||
existingType?: 'file' | 'directory' | 'symlink';
|
||||
existingSize: number;
|
||||
newSize: number;
|
||||
existingModified: number;
|
||||
newModified: number;
|
||||
applyToAllCount: number;
|
||||
}): Promise<FileConflictAction> => {
|
||||
const conflictType = conflict.isDirectory ? "directory" : "file";
|
||||
const defaultAction = conflictDefaults.get(conflictType);
|
||||
if (defaultAction) return defaultAction;
|
||||
|
||||
const conflictId = `upload-conflict-${crypto.randomUUID()}`;
|
||||
const fileConflict: FileConflict = {
|
||||
transferId: conflictId,
|
||||
fileName: conflict.fileName,
|
||||
sourcePath: "local",
|
||||
targetPath: conflict.targetPath,
|
||||
isDirectory: conflict.isDirectory,
|
||||
existingType: conflict.existingType,
|
||||
applyToAllCount: conflict.applyToAllCount,
|
||||
existingSize: conflict.existingSize,
|
||||
newSize: conflict.newSize,
|
||||
existingModified: conflict.existingModified,
|
||||
newModified: conflict.newModified,
|
||||
};
|
||||
|
||||
setUploadConflicts((prev) => [...prev, fileConflict]);
|
||||
return new Promise<FileConflictAction>((resolve) => {
|
||||
uploadConflictResolversRef.current.set(conflictId, {
|
||||
resolve,
|
||||
setDefault: (action) => {
|
||||
conflictDefaults.set(conflictType, action);
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Create upload bridge that wraps netcattyBridge
|
||||
const createUploadBridge = useMemo((): UploadBridge => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return {
|
||||
writeLocalFile: bridge?.writeLocalFile,
|
||||
mkdirLocal: bridge?.mkdirLocal,
|
||||
statLocal: bridge?.statLocal,
|
||||
deleteLocalFile: bridge?.deleteLocalFile,
|
||||
mkdirSftp: async (sftpId: string, path: string) => {
|
||||
const b = netcattyBridge.get();
|
||||
if (b?.mkdirSftp) {
|
||||
await b.mkdirSftp(sftpId, path);
|
||||
}
|
||||
},
|
||||
statSftp: async (sftpId: string, path: string) => {
|
||||
const b = netcattyBridge.get();
|
||||
if (!b?.statSftp) return null;
|
||||
return b.statSftp(sftpId, path);
|
||||
},
|
||||
deleteSftp: async (sftpId: string, path: string) => {
|
||||
const b = netcattyBridge.get();
|
||||
if (b?.deleteSftp) {
|
||||
await b.deleteSftp(sftpId, path);
|
||||
}
|
||||
},
|
||||
writeSftpBinary: bridge?.writeSftpBinary,
|
||||
// Wrap writeSftpBinaryWithProgress to adapt UploadBridge interface to NetcattyBridge interface
|
||||
// UploadBridge: (sftpId, path, data, taskId, onProgress, onComplete, onError)
|
||||
@@ -552,6 +696,7 @@ export const useSftpExternalOperations = (
|
||||
joinPath,
|
||||
callbacks,
|
||||
useCompressedUpload,
|
||||
resolveConflict: createUploadConflictResolver(),
|
||||
},
|
||||
controller
|
||||
);
|
||||
@@ -580,6 +725,217 @@ export const useSftpExternalOperations = (
|
||||
sftpSessionsRef,
|
||||
createUploadCallbacks,
|
||||
createUploadBridge,
|
||||
createUploadConflictResolver,
|
||||
useCompressedUpload,
|
||||
],
|
||||
);
|
||||
|
||||
// Upload from a FileList. This keeps the original File objects from the file
|
||||
// picker so Electron can resolve local file paths for stream uploads.
|
||||
const uploadExternalFileList = useCallback(
|
||||
async (
|
||||
side: "left" | "right",
|
||||
fileList: FileList | File[],
|
||||
targetPath?: string,
|
||||
): Promise<UploadResult[]> => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("No active connection");
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge) {
|
||||
throw new Error("Bridge not available");
|
||||
}
|
||||
|
||||
const sftpId = pane.connection.isLocal
|
||||
? null
|
||||
: sftpSessionsRef.current.get(pane.connection.id) || null;
|
||||
|
||||
if (!pane.connection.isLocal && !sftpId) {
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
|
||||
const uploadPaneId = pane.id;
|
||||
const uploadTargetPath = targetPath || pane.connection.currentPath;
|
||||
const controller = new UploadController();
|
||||
uploadControllerRef.current = controller;
|
||||
|
||||
const callbacks = createUploadCallbacks(
|
||||
pane.connection.id,
|
||||
uploadTargetPath,
|
||||
pane.connection.isLocal ? undefined : pane.connection.hostId,
|
||||
pane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(pane.connection.id),
|
||||
);
|
||||
|
||||
try {
|
||||
const results = await uploadFromFileList(
|
||||
fileList,
|
||||
{
|
||||
targetPath: uploadTargetPath,
|
||||
sftpId,
|
||||
isLocal: pane.connection.isLocal,
|
||||
bridge: createUploadBridge,
|
||||
joinPath,
|
||||
callbacks,
|
||||
useCompressedUpload,
|
||||
resolveConflict: createUploadConflictResolver(),
|
||||
},
|
||||
controller,
|
||||
);
|
||||
|
||||
if (clearDirCacheEntry && targetPath) {
|
||||
clearDirCacheEntry(pane.connection.id, uploadTargetPath);
|
||||
}
|
||||
if (uploadTargetPath === pane.connection.currentPath) {
|
||||
await refresh(side, { tabId: uploadPaneId });
|
||||
}
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error("[SFTP] File picker upload failed:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
uploadControllerRef.current = null;
|
||||
}
|
||||
},
|
||||
[
|
||||
clearDirCacheEntry,
|
||||
connectionCacheKeyMapRef,
|
||||
getActivePane,
|
||||
refresh,
|
||||
sftpSessionsRef,
|
||||
createUploadCallbacks,
|
||||
createUploadBridge,
|
||||
createUploadConflictResolver,
|
||||
useCompressedUpload,
|
||||
],
|
||||
);
|
||||
|
||||
const uploadExternalFolderPath = useCallback(
|
||||
async (
|
||||
side: "left" | "right",
|
||||
folderPath: string,
|
||||
targetPath?: string,
|
||||
): Promise<UploadResult[]> => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("No active connection");
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge) {
|
||||
throw new Error("Bridge not available");
|
||||
}
|
||||
if (!bridge.listLocalTree) {
|
||||
throw new Error("Folder upload not supported");
|
||||
}
|
||||
|
||||
const sftpId = pane.connection.isLocal
|
||||
? null
|
||||
: sftpSessionsRef.current.get(pane.connection.id) || null;
|
||||
|
||||
if (!pane.connection.isLocal && !sftpId) {
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
|
||||
const uploadPaneId = pane.id;
|
||||
const uploadTargetPath = targetPath || pane.connection.currentPath;
|
||||
const controller = new UploadController();
|
||||
uploadControllerRef.current = controller;
|
||||
|
||||
const callbacks = createUploadCallbacks(
|
||||
pane.connection.id,
|
||||
uploadTargetPath,
|
||||
pane.connection.isLocal ? undefined : pane.connection.hostId,
|
||||
pane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(pane.connection.id),
|
||||
);
|
||||
|
||||
const scanningTask = startUploadScanningTask(callbacks);
|
||||
|
||||
try {
|
||||
const localEntries = await bridge.listLocalTree(folderPath);
|
||||
if (controller.isCancelled()) {
|
||||
scanningTask.cancel();
|
||||
return [{ fileName: "", success: false, cancelled: true }];
|
||||
}
|
||||
scanningTask.complete();
|
||||
|
||||
const entries: DropEntry[] = localEntries.map((entry) => {
|
||||
if (entry.type === "directory") {
|
||||
return {
|
||||
file: null,
|
||||
relativePath: entry.relativePath,
|
||||
isDirectory: true,
|
||||
};
|
||||
}
|
||||
|
||||
const file = {
|
||||
name: entry.relativePath.split("/").pop() || entry.relativePath,
|
||||
size: entry.size,
|
||||
lastModified: entry.lastModified,
|
||||
type: "",
|
||||
path: entry.localPath,
|
||||
arrayBuffer: async () => {
|
||||
const currentBridge = netcattyBridge.get();
|
||||
if (!currentBridge?.readLocalFile) {
|
||||
throw new Error("Local file reading not supported");
|
||||
}
|
||||
return currentBridge.readLocalFile(entry.localPath);
|
||||
},
|
||||
} as File & { path?: string };
|
||||
|
||||
return {
|
||||
file,
|
||||
relativePath: entry.relativePath,
|
||||
isDirectory: false,
|
||||
};
|
||||
});
|
||||
|
||||
const results = await uploadEntriesDirect(
|
||||
entries,
|
||||
{
|
||||
targetPath: uploadTargetPath,
|
||||
sftpId,
|
||||
isLocal: pane.connection.isLocal,
|
||||
bridge: createUploadBridge,
|
||||
joinPath,
|
||||
callbacks,
|
||||
useCompressedUpload,
|
||||
resolveConflict: createUploadConflictResolver(),
|
||||
},
|
||||
controller,
|
||||
);
|
||||
|
||||
if (clearDirCacheEntry) {
|
||||
clearDirCacheEntry(pane.connection.id, uploadTargetPath);
|
||||
}
|
||||
if (uploadTargetPath === pane.connection.currentPath) {
|
||||
await refresh(side, { tabId: uploadPaneId });
|
||||
}
|
||||
return results;
|
||||
} catch (error) {
|
||||
if (controller.isCancelled()) {
|
||||
scanningTask.cancel();
|
||||
return [{ fileName: "", success: false, cancelled: true }];
|
||||
}
|
||||
if (scanningTask.isOpen()) {
|
||||
scanningTask.fail(error);
|
||||
}
|
||||
logger.error("[SFTP] Folder picker upload failed:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
uploadControllerRef.current = null;
|
||||
}
|
||||
},
|
||||
[
|
||||
clearDirCacheEntry,
|
||||
connectionCacheKeyMapRef,
|
||||
createUploadCallbacks,
|
||||
createUploadBridge,
|
||||
createUploadConflictResolver,
|
||||
getActivePane,
|
||||
refresh,
|
||||
sftpSessionsRef,
|
||||
useCompressedUpload,
|
||||
],
|
||||
);
|
||||
@@ -636,6 +992,7 @@ export const useSftpExternalOperations = (
|
||||
joinPath,
|
||||
callbacks,
|
||||
useCompressedUpload,
|
||||
resolveConflict: createUploadConflictResolver(),
|
||||
},
|
||||
controller,
|
||||
);
|
||||
@@ -663,6 +1020,7 @@ export const useSftpExternalOperations = (
|
||||
connectionCacheKeyMapRef,
|
||||
createUploadCallbacks,
|
||||
createUploadBridge,
|
||||
createUploadConflictResolver,
|
||||
getActivePane,
|
||||
refresh,
|
||||
sftpSessionsRef,
|
||||
@@ -672,11 +1030,14 @@ export const useSftpExternalOperations = (
|
||||
|
||||
const cancelExternalUpload = useCallback(async () => {
|
||||
const controller = uploadControllerRef.current;
|
||||
let cancelPromise: Promise<void> | undefined;
|
||||
if (controller) {
|
||||
logger.info("[SFTP] Cancelling external upload");
|
||||
await controller.cancel();
|
||||
cancelPromise = controller.cancel();
|
||||
}
|
||||
}, []);
|
||||
cancelPendingUploadConflicts();
|
||||
await cancelPromise;
|
||||
}, [cancelPendingUploadConflicts]);
|
||||
|
||||
const selectApplication = useCallback(
|
||||
async (): Promise<{ path: string; name: string } | null> => {
|
||||
@@ -693,11 +1054,16 @@ export const useSftpExternalOperations = (
|
||||
readTextFile,
|
||||
readBinaryFile,
|
||||
writeTextFile,
|
||||
writeTextFileByConnection,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
uploadExternalFileList,
|
||||
uploadExternalFolderPath,
|
||||
uploadExternalEntries,
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
activeFileWatchCountRef,
|
||||
uploadConflicts,
|
||||
resolveUploadConflict,
|
||||
};
|
||||
};
|
||||
|
||||
187
application/state/sftp/useSftpHostCredentials.test.ts
Normal file
187
application/state/sftp/useSftpHostCredentials.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { buildSftpHostCredentials } from "./useSftpHostCredentials.ts";
|
||||
import type { Host, SSHKey } from "../../../domain/models.ts";
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials rejects missing jump hosts", () => {
|
||||
assert.throws(
|
||||
() => buildSftpHostCredentials({
|
||||
host: host({ hostChain: { hostIds: ["missing-jump"] } }),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
}),
|
||||
/Jump host "missing-jump" is missing/,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials rejects missing saved proxy profiles", () => {
|
||||
assert.throws(
|
||||
() => buildSftpHostCredentials({
|
||||
host: host({ proxyProfileId: "missing-proxy" }),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
}),
|
||||
/Saved proxy for host "Host" is missing/,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials rejects missing saved proxy profiles on jump hosts", () => {
|
||||
const jumpHost = host({ id: "jump-1", label: "Jump", proxyProfileId: "missing-proxy" });
|
||||
|
||||
assert.throws(
|
||||
() => buildSftpHostCredentials({
|
||||
host: host({ hostChain: { hostIds: ["jump-1"] } }),
|
||||
hosts: [jumpHost],
|
||||
keys: [],
|
||||
identities: [],
|
||||
}),
|
||||
/Saved proxy for jump host "Jump" is missing/,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials passes reference keys as identity file paths", () => {
|
||||
const key: SSHKey = {
|
||||
id: "key-1",
|
||||
label: "Reference key",
|
||||
type: "ED25519",
|
||||
privateKey: "",
|
||||
source: "reference",
|
||||
category: "key",
|
||||
created: 1,
|
||||
filePath: "/Users/alice/.ssh/id_ed25519",
|
||||
passphrase: "saved-passphrase",
|
||||
};
|
||||
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({ authMethod: "key", identityFileId: "key-1" }),
|
||||
hosts: [],
|
||||
keys: [key],
|
||||
identities: [],
|
||||
});
|
||||
|
||||
assert.equal(credentials.privateKey, undefined);
|
||||
assert.deepEqual(credentials.identityFilePaths, ["/Users/alice/.ssh/id_ed25519"]);
|
||||
assert.equal(credentials.passphrase, "saved-passphrase");
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials passes jump host reference keys as identity file paths", () => {
|
||||
const key: SSHKey = {
|
||||
id: "jump-key",
|
||||
label: "Jump key",
|
||||
type: "ED25519",
|
||||
privateKey: "",
|
||||
source: "reference",
|
||||
category: "key",
|
||||
created: 1,
|
||||
filePath: "/Users/alice/.ssh/jump_ed25519",
|
||||
};
|
||||
const jumpHost = host({
|
||||
id: "jump-1",
|
||||
label: "Jump",
|
||||
authMethod: "key",
|
||||
identityFileId: "jump-key",
|
||||
});
|
||||
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({ hostChain: { hostIds: ["jump-1"] } }),
|
||||
hosts: [jumpHost],
|
||||
keys: [key],
|
||||
identities: [],
|
||||
});
|
||||
|
||||
assert.equal(credentials.jumpHosts?.[0]?.privateKey, undefined);
|
||||
assert.deepEqual(credentials.jumpHosts?.[0]?.identityFilePaths, ["/Users/alice/.ssh/jump_ed25519"]);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials rejects undecryptable saved password credentials", () => {
|
||||
assert.throws(
|
||||
() => buildSftpHostCredentials({
|
||||
host: host({
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
}),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
}),
|
||||
/Saved credentials cannot be decrypted/,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials omits local key file paths for password auth", () => {
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
identityFilePaths: ["/Users/alice/.ssh/id_ed25519"],
|
||||
}),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
});
|
||||
|
||||
assert.equal(credentials.password, "secret");
|
||||
assert.equal(credentials.privateKey, undefined);
|
||||
assert.equal(credentials.identityFilePaths, undefined);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials rejects undecryptable saved key material without fallback credentials", () => {
|
||||
const key: SSHKey = {
|
||||
id: "key-1",
|
||||
label: "Imported key",
|
||||
type: "ED25519",
|
||||
privateKey: "enc:v1:djEwAAAA",
|
||||
source: "imported",
|
||||
category: "key",
|
||||
created: 1,
|
||||
};
|
||||
|
||||
assert.throws(
|
||||
() => buildSftpHostCredentials({
|
||||
host: host({ authMethod: "key", identityFileId: "key-1" }),
|
||||
hosts: [],
|
||||
keys: [key],
|
||||
identities: [],
|
||||
}),
|
||||
/Saved credentials cannot be decrypted/,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials does not use stale local key paths when a selected key is unavailable", () => {
|
||||
const key: SSHKey = {
|
||||
id: "key-1",
|
||||
label: "Imported key",
|
||||
type: "ED25519",
|
||||
privateKey: "enc:v1:djEwAAAA",
|
||||
source: "imported",
|
||||
category: "key",
|
||||
created: 1,
|
||||
};
|
||||
|
||||
assert.throws(
|
||||
() => buildSftpHostCredentials({
|
||||
host: host({
|
||||
authMethod: "key",
|
||||
identityFileId: "key-1",
|
||||
identityFilePaths: ["/Users/alice/.ssh/stale_ed25519"],
|
||||
}),
|
||||
hosts: [],
|
||||
keys: [key],
|
||||
identities: [],
|
||||
}),
|
||||
/Saved credentials cannot be decrypted/,
|
||||
);
|
||||
});
|
||||
@@ -1,102 +1,174 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Host, Identity, SSHKey } from "../../../domain/models";
|
||||
import type { Host, Identity, SSHKey, TerminalSettings } from "../../../domain/models";
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
|
||||
import { resolveHostAuth } from "../../../domain/sshAuth";
|
||||
import { resolveBridgeKeyAuth, resolveHostAuth } from "../../../domain/sshAuth";
|
||||
import { resolveHostKeepalive } from "../../../domain/host";
|
||||
|
||||
// Fallback used when no global TerminalSettings are wired through (older
|
||||
// call sites or tests). Matches DEFAULT_TERMINAL_SETTINGS so behavior is
|
||||
// identical whether or not the caller passes settings.
|
||||
const FALLBACK_KEEPALIVE = { keepaliveInterval: 30, keepaliveCountMax: 10 };
|
||||
|
||||
interface UseSftpHostCredentialsParams {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>;
|
||||
}
|
||||
|
||||
export const buildSftpHostCredentials = ({
|
||||
host,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
terminalSettings,
|
||||
}: UseSftpHostCredentialsParams & { host: Host }): NetcattySSHOptions => {
|
||||
const globalKeepalive = terminalSettings ?? FALLBACK_KEEPALIVE;
|
||||
if (host.proxyProfileId && !host.proxyConfig) {
|
||||
throw new Error(`Saved proxy for host "${host.label || host.hostname}" is missing. Open host settings and select a valid proxy.`);
|
||||
}
|
||||
|
||||
const resolved = resolveHostAuth({ host, keys, identities });
|
||||
const key = resolved.key || null;
|
||||
|
||||
const proxyConfig = host.proxyConfig
|
||||
? {
|
||||
type: host.proxyConfig.type,
|
||||
host: host.proxyConfig.host,
|
||||
port: host.proxyConfig.port,
|
||||
username: host.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(host.proxyConfig.password),
|
||||
}
|
||||
: undefined;
|
||||
let jumpHosts: NetcattyJumpHost[] | undefined;
|
||||
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
|
||||
jumpHosts = host.hostChain.hostIds.map((hostId) => {
|
||||
const jumpHost = hosts.find((candidate) => candidate.id === hostId);
|
||||
if (!jumpHost) {
|
||||
throw new Error(`Jump host "${hostId}" is missing. Open host settings and repair the jump host chain.`);
|
||||
}
|
||||
if (jumpHost.proxyProfileId && !jumpHost.proxyConfig) {
|
||||
throw new Error(`Saved proxy for jump host "${jumpHost.label || jumpHost.hostname}" is missing. Open host settings and select a valid proxy.`);
|
||||
}
|
||||
return jumpHost;
|
||||
}).map((jumpHost, index) => {
|
||||
const jumpAuth = resolveHostAuth({
|
||||
host: jumpHost,
|
||||
keys,
|
||||
identities,
|
||||
});
|
||||
const jumpKey = jumpAuth.key;
|
||||
const jumpPassword = sanitizeCredentialValue(jumpAuth.password);
|
||||
const jumpKeyAuth = resolveBridgeKeyAuth({
|
||||
key: jumpKey,
|
||||
fallbackIdentityFilePaths: jumpAuth.authMethod === "password" || jumpAuth.keyId
|
||||
? undefined
|
||||
: jumpHost.identityFilePaths,
|
||||
passphrase: jumpAuth.passphrase,
|
||||
});
|
||||
const hasJumpKeyMaterial = Boolean(jumpKeyAuth.privateKey || jumpKeyAuth.identityFilePaths?.length);
|
||||
const hasConfiguredJumpProxyEndpoint =
|
||||
index === 0 &&
|
||||
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
|
||||
if (
|
||||
hasConfiguredJumpProxyEndpoint &&
|
||||
jumpHost.proxyConfig?.username &&
|
||||
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig.password) &&
|
||||
!sanitizeCredentialValue(jumpHost.proxyConfig.password)
|
||||
) {
|
||||
throw new Error(`Proxy credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter the proxy password.`);
|
||||
}
|
||||
const hasUnreadableJumpCredential =
|
||||
isEncryptedCredentialPlaceholder(jumpAuth.password) ||
|
||||
isEncryptedCredentialPlaceholder(jumpKey?.privateKey) ||
|
||||
isEncryptedCredentialPlaceholder(jumpAuth.passphrase);
|
||||
if (
|
||||
(jumpAuth.authMethod === "password" && isEncryptedCredentialPlaceholder(jumpAuth.password) && !jumpPassword) ||
|
||||
(jumpAuth.authMethod !== "password" && hasUnreadableJumpCredential && !jumpPassword && !hasJumpKeyMaterial)
|
||||
) {
|
||||
throw new Error(`Saved credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter them.`);
|
||||
}
|
||||
const hopKeepalive = resolveHostKeepalive(jumpHost, globalKeepalive);
|
||||
return {
|
||||
hostname: jumpHost.hostname,
|
||||
port: jumpHost.port || 22,
|
||||
username: jumpAuth.username || "root",
|
||||
password: jumpPassword,
|
||||
privateKey: jumpKeyAuth.privateKey,
|
||||
certificate: jumpKey?.certificate,
|
||||
passphrase: jumpKeyAuth.passphrase,
|
||||
publicKey: jumpKey?.publicKey,
|
||||
keyId: jumpAuth.keyId,
|
||||
keySource: jumpKey?.source,
|
||||
label: jumpHost.label,
|
||||
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
|
||||
? {
|
||||
type: jumpHost.proxyConfig.type,
|
||||
host: jumpHost.proxyConfig.host,
|
||||
port: jumpHost.proxyConfig.port,
|
||||
username: jumpHost.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
|
||||
}
|
||||
: undefined,
|
||||
identityFilePaths: jumpKeyAuth.identityFilePaths,
|
||||
keepaliveInterval: hopKeepalive.interval,
|
||||
keepaliveCountMax: hopKeepalive.countMax,
|
||||
};
|
||||
});
|
||||
}
|
||||
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts?.[0]?.proxy;
|
||||
if (usesTargetProxyForFirstHop && host.proxyConfig?.username && isEncryptedCredentialPlaceholder(host.proxyConfig.password) && !proxyConfig?.password) {
|
||||
throw new Error("Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.");
|
||||
}
|
||||
|
||||
const keyAuth = resolveBridgeKeyAuth({
|
||||
key,
|
||||
fallbackIdentityFilePaths: resolved.authMethod === "password" || resolved.keyId
|
||||
? undefined
|
||||
: host.identityFilePaths,
|
||||
passphrase: resolved.passphrase,
|
||||
});
|
||||
const password = sanitizeCredentialValue(resolved.password);
|
||||
const hasKeyMaterial = Boolean(keyAuth.privateKey || keyAuth.identityFilePaths?.length);
|
||||
const hasUnreadableCredential =
|
||||
isEncryptedCredentialPlaceholder(resolved.password) ||
|
||||
isEncryptedCredentialPlaceholder(key?.privateKey) ||
|
||||
isEncryptedCredentialPlaceholder(resolved.passphrase);
|
||||
if (
|
||||
(resolved.authMethod === "password" && isEncryptedCredentialPlaceholder(resolved.password) && !password) ||
|
||||
(resolved.authMethod !== "password" && hasUnreadableCredential && !password && !hasKeyMaterial)
|
||||
) {
|
||||
throw new Error("Saved credentials cannot be decrypted on this device. Open host settings and re-enter them.");
|
||||
}
|
||||
|
||||
const targetKeepalive = resolveHostKeepalive(host, globalKeepalive);
|
||||
return {
|
||||
hostname: host.hostname,
|
||||
username: resolved.username,
|
||||
port: host.port || 22,
|
||||
password,
|
||||
privateKey: keyAuth.privateKey,
|
||||
certificate: key?.certificate,
|
||||
passphrase: keyAuth.passphrase,
|
||||
publicKey: key?.publicKey,
|
||||
keyId: resolved.keyId,
|
||||
keySource: key?.source,
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
sudo: host.sftpSudo,
|
||||
identityFilePaths: keyAuth.identityFilePaths,
|
||||
keepaliveInterval: targetKeepalive.interval,
|
||||
keepaliveCountMax: targetKeepalive.countMax,
|
||||
};
|
||||
};
|
||||
|
||||
export const useSftpHostCredentials = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
terminalSettings,
|
||||
}: UseSftpHostCredentialsParams) =>
|
||||
useCallback(
|
||||
(host: Host): NetcattySSHOptions => {
|
||||
const resolved = resolveHostAuth({ host, keys, identities });
|
||||
const key = resolved.key || null;
|
||||
|
||||
const proxyConfig = host.proxyConfig
|
||||
? {
|
||||
type: host.proxyConfig.type,
|
||||
host: host.proxyConfig.host,
|
||||
port: host.proxyConfig.port,
|
||||
username: host.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(host.proxyConfig.password),
|
||||
}
|
||||
: undefined;
|
||||
let jumpHosts: NetcattyJumpHost[] | undefined;
|
||||
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
|
||||
jumpHosts = host.hostChain.hostIds
|
||||
.map((hostId) => hosts.find((h) => h.id === hostId))
|
||||
.filter((h): h is Host => !!h)
|
||||
.map((jumpHost, index) => {
|
||||
const jumpAuth = resolveHostAuth({
|
||||
host: jumpHost,
|
||||
keys,
|
||||
identities,
|
||||
});
|
||||
const jumpKey = jumpAuth.key;
|
||||
const hasConfiguredJumpProxyEndpoint =
|
||||
index === 0 &&
|
||||
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
|
||||
if (
|
||||
hasConfiguredJumpProxyEndpoint &&
|
||||
jumpHost.proxyConfig?.username &&
|
||||
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig.password) &&
|
||||
!sanitizeCredentialValue(jumpHost.proxyConfig.password)
|
||||
) {
|
||||
throw new Error(`Proxy credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter the proxy password.`);
|
||||
}
|
||||
return {
|
||||
hostname: jumpHost.hostname,
|
||||
port: jumpHost.port || 22,
|
||||
username: jumpAuth.username || "root",
|
||||
password: jumpAuth.password,
|
||||
privateKey: jumpKey?.privateKey,
|
||||
certificate: jumpKey?.certificate,
|
||||
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
|
||||
publicKey: jumpKey?.publicKey,
|
||||
keyId: jumpAuth.keyId,
|
||||
keySource: jumpKey?.source,
|
||||
label: jumpHost.label,
|
||||
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
|
||||
? {
|
||||
type: jumpHost.proxyConfig.type,
|
||||
host: jumpHost.proxyConfig.host,
|
||||
port: jumpHost.proxyConfig.port,
|
||||
username: jumpHost.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
|
||||
}
|
||||
: undefined,
|
||||
identityFilePaths: jumpHost.identityFilePaths,
|
||||
};
|
||||
});
|
||||
}
|
||||
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts?.[0]?.proxy;
|
||||
if (usesTargetProxyForFirstHop && host.proxyConfig?.username && isEncryptedCredentialPlaceholder(host.proxyConfig.password) && !proxyConfig?.password) {
|
||||
throw new Error("Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.");
|
||||
}
|
||||
|
||||
return {
|
||||
hostname: host.hostname,
|
||||
username: resolved.username,
|
||||
port: host.port || 22,
|
||||
password: resolved.password,
|
||||
privateKey: key?.privateKey,
|
||||
certificate: key?.certificate,
|
||||
passphrase: resolved.passphrase || key?.passphrase,
|
||||
publicKey: key?.publicKey,
|
||||
keyId: resolved.keyId,
|
||||
keySource: key?.source,
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
sudo: host.sftpSudo,
|
||||
identityFilePaths: host.identityFilePaths,
|
||||
};
|
||||
},
|
||||
[hosts, identities, keys],
|
||||
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities, terminalSettings }),
|
||||
[hosts, identities, keys, terminalSettings],
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
130
application/state/textEditorSaveCoordinator.test.ts
Normal file
130
application/state/textEditorSaveCoordinator.test.ts
Normal 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"]);
|
||||
});
|
||||
90
application/state/textEditorSaveCoordinator.ts
Normal file
90
application/state/textEditorSaveCoordinator.ts
Normal 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();
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
197
application/state/uploadService.test.ts
Normal file
197
application/state/uploadService.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
UploadController,
|
||||
startUploadScanningTask,
|
||||
uploadEntriesDirect,
|
||||
uploadFromDataTransfer,
|
||||
uploadFromFileList,
|
||||
} from "../../lib/uploadService.ts";
|
||||
|
||||
function createDataTransfer(files: File[]): DataTransfer {
|
||||
return {
|
||||
items: { length: 0 },
|
||||
files,
|
||||
} as unknown as DataTransfer;
|
||||
}
|
||||
|
||||
function createDataTransferWithNullEntries(files: File[]): DataTransfer {
|
||||
const items = files.map((file) => ({
|
||||
kind: "file",
|
||||
getAsFile: () => file,
|
||||
webkitGetAsEntry: () => null,
|
||||
}));
|
||||
return {
|
||||
items,
|
||||
files,
|
||||
} as unknown as DataTransfer;
|
||||
}
|
||||
|
||||
test("upload scanning task can be shown and cancelled before transfers start", () => {
|
||||
const events: string[] = [];
|
||||
const scanningTask = startUploadScanningTask(
|
||||
{
|
||||
onScanningStart: (taskId) => events.push(`start:${taskId}`),
|
||||
onScanningEnd: (taskId) => events.push(`end:${taskId}`),
|
||||
onTaskCancelled: (taskId) => events.push(`cancel:${taskId}`),
|
||||
},
|
||||
"scan-folder-1",
|
||||
);
|
||||
|
||||
assert.equal(scanningTask.isOpen(), true);
|
||||
scanningTask.cancel();
|
||||
scanningTask.complete();
|
||||
|
||||
assert.equal(scanningTask.isOpen(), false);
|
||||
assert.deepEqual(events, ["start:scan-folder-1", "cancel:scan-folder-1"]);
|
||||
});
|
||||
|
||||
test("clears the scanning placeholder when every dropped file is skipped by conflict resolution", async () => {
|
||||
const events: string[] = [];
|
||||
const file = new File(["local"], "conflict.txt", { lastModified: 1234 });
|
||||
|
||||
const results = await uploadFromDataTransfer(
|
||||
createDataTransfer([file]),
|
||||
{
|
||||
targetPath: "/target",
|
||||
sftpId: null,
|
||||
isLocal: true,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
statLocal: async () => ({ type: "file", size: 10, lastModified: 1000 }),
|
||||
writeLocalFile: async () => {
|
||||
throw new Error("skipped conflicts should not upload");
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
callbacks: {
|
||||
onScanningStart: () => events.push("scan:start"),
|
||||
onScanningEnd: () => events.push("scan:end"),
|
||||
onTaskCreated: () => events.push("task:create"),
|
||||
},
|
||||
resolveConflict: async () => "skip",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(results, [
|
||||
{ fileName: "conflict.txt", success: false, cancelled: true },
|
||||
]);
|
||||
assert.deepEqual(events, ["scan:start", "scan:end"]);
|
||||
});
|
||||
|
||||
test("uploads DataTransfer files when entry extraction returns no entries", async () => {
|
||||
const file = new File(["picked"], "picked.txt", { lastModified: 1234 });
|
||||
const uploadedPaths: string[] = [];
|
||||
|
||||
const results = await uploadFromDataTransfer(
|
||||
createDataTransferWithNullEntries([file]),
|
||||
{
|
||||
targetPath: "/target",
|
||||
sftpId: "sftp-1",
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
writeSftpBinary: async (_sftpId, path) => {
|
||||
uploadedPaths.push(path);
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(uploadedPaths, ["/target/picked.txt"]);
|
||||
assert.deepEqual(results, [
|
||||
{ fileName: "picked.txt", success: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test("uploads picked folder files with their relative directory structure", async () => {
|
||||
const file = new File(["nested"], "file.txt", { lastModified: 1234 });
|
||||
Object.defineProperty(file, "webkitRelativePath", {
|
||||
value: "folder/sub/file.txt",
|
||||
});
|
||||
const madeDirs: string[] = [];
|
||||
const uploadedPaths: string[] = [];
|
||||
|
||||
const results = await uploadFromFileList(
|
||||
[file],
|
||||
{
|
||||
targetPath: "/target",
|
||||
sftpId: "sftp-1",
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async (_sftpId, path) => {
|
||||
madeDirs.push(path);
|
||||
},
|
||||
writeSftpBinary: async (_sftpId, path) => {
|
||||
uploadedPaths.push(path);
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(madeDirs, ["/target/folder", "/target/folder/sub"]);
|
||||
assert.deepEqual(uploadedPaths, ["/target/folder/sub/file.txt"]);
|
||||
assert.deepEqual(results, [
|
||||
{ fileName: "folder/sub/file.txt", success: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test("reports empty directory creation failures", async () => {
|
||||
const madeDirs: string[] = [];
|
||||
|
||||
const results = await uploadEntriesDirect(
|
||||
[
|
||||
{ file: null, relativePath: "folder", isDirectory: true },
|
||||
{ file: null, relativePath: "folder/empty", isDirectory: true },
|
||||
],
|
||||
{
|
||||
targetPath: "/target",
|
||||
sftpId: "sftp-1",
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async (_sftpId, path) => {
|
||||
madeDirs.push(path);
|
||||
if (path.endsWith("/empty")) {
|
||||
throw new Error("permission denied");
|
||||
}
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(madeDirs, ["/target/folder", "/target/folder/empty"]);
|
||||
assert.deepEqual(results, [
|
||||
{ fileName: "folder/empty", success: false, error: "permission denied" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not restart a direct upload that was already cancelled", async () => {
|
||||
const controller = new UploadController();
|
||||
await controller.cancel();
|
||||
let mkdirCalled = false;
|
||||
|
||||
const results = await uploadEntriesDirect(
|
||||
[{ file: null, relativePath: "folder", isDirectory: true }],
|
||||
{
|
||||
targetPath: "/target",
|
||||
sftpId: "sftp-1",
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {
|
||||
mkdirCalled = true;
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
},
|
||||
controller,
|
||||
);
|
||||
|
||||
assert.equal(mkdirCalled, false);
|
||||
assert.deepEqual(results, [
|
||||
{ fileName: "", success: false, cancelled: true },
|
||||
]);
|
||||
});
|
||||
@@ -16,14 +16,20 @@ import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { collectSyncableSettings, hasMeaningfulSyncData } from '../syncPayload';
|
||||
import {
|
||||
SYNCABLE_SETTING_STORAGE_KEYS,
|
||||
collectSyncableSettings,
|
||||
hasMeaningfulCloudSyncData,
|
||||
} from '../syncPayload';
|
||||
import { readInterruptedVaultApply } from '../localVaultBackups';
|
||||
import {
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
|
||||
import {
|
||||
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
|
||||
localStorageAdapter,
|
||||
} from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { notify } from '../notification';
|
||||
|
||||
interface AutoSyncConfig {
|
||||
@@ -31,11 +37,11 @@ interface AutoSyncConfig {
|
||||
hosts: SyncPayload['hosts'];
|
||||
keys: SyncPayload['keys'];
|
||||
identities?: SyncPayload['identities'];
|
||||
proxyProfiles?: SyncPayload['proxyProfiles'];
|
||||
snippets: SyncPayload['snippets'];
|
||||
customGroups: SyncPayload['customGroups'];
|
||||
snippetPackages?: SyncPayload['snippetPackages'];
|
||||
portForwardingRules?: SyncPayload['portForwardingRules'];
|
||||
knownHosts?: SyncPayload['knownHosts'];
|
||||
groupConfigs?: SyncPayload['groupConfigs'];
|
||||
/** Opaque token that changes whenever a synced setting changes. */
|
||||
settingsVersion?: number;
|
||||
@@ -48,6 +54,7 @@ interface AutoSyncConfig {
|
||||
// Get manager singleton for direct state access
|
||||
const manager = getCloudSyncManager();
|
||||
const AUTO_SYNC_PROVIDER_ORDER: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
|
||||
const SYNCABLE_SETTING_STORAGE_KEY_SET = new Set<string>(SYNCABLE_SETTING_STORAGE_KEYS);
|
||||
|
||||
// Cross-window restore barrier: stored as an epoch-ms deadline. Any value
|
||||
// in the future means a restore is applying in some window and auto-sync
|
||||
@@ -112,6 +119,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
remotePayload: SyncPayload;
|
||||
hostCount: number;
|
||||
keyCount: number;
|
||||
proxyProfileCount: number;
|
||||
snippetCount: number;
|
||||
} | null>(null);
|
||||
const emptyVaultResolveRef = useRef<((action: 'restore' | 'keep-empty') => void) | null>(null);
|
||||
@@ -124,6 +132,29 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
return () => window.removeEventListener('sftp-bookmarks-changed', handler);
|
||||
}, []);
|
||||
|
||||
const [syncableSettingsStorageVersion, setSyncableSettingsStorageVersion] = useState(0);
|
||||
useEffect(() => {
|
||||
const bumpIfSyncableSetting = (key: string | null | undefined) => {
|
||||
if (!key || !SYNCABLE_SETTING_STORAGE_KEY_SET.has(key)) return;
|
||||
setSyncableSettingsStorageVersion((v) => v + 1);
|
||||
};
|
||||
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
bumpIfSyncableSetting(event.key);
|
||||
};
|
||||
const handleLocalStorageAdapterChanged = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
bumpIfSyncableSetting(key);
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorage);
|
||||
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorage);
|
||||
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getSyncSnapshot = useCallback(() => {
|
||||
let effectivePFRules = config.portForwardingRules;
|
||||
if (!effectivePFRules || effectivePFRules.length === 0) {
|
||||
@@ -140,28 +171,26 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveKnownHosts = getEffectiveKnownHosts(config.knownHosts);
|
||||
|
||||
return {
|
||||
hosts: config.hosts,
|
||||
keys: config.keys,
|
||||
identities: config.identities,
|
||||
proxyProfiles: config.proxyProfiles,
|
||||
snippets: config.snippets,
|
||||
customGroups: config.customGroups,
|
||||
snippetPackages: config.snippetPackages,
|
||||
portForwardingRules: effectivePFRules,
|
||||
knownHosts: effectiveKnownHosts,
|
||||
groupConfigs: config.groupConfigs,
|
||||
};
|
||||
}, [
|
||||
config.hosts,
|
||||
config.keys,
|
||||
config.identities,
|
||||
config.proxyProfiles,
|
||||
config.snippets,
|
||||
config.customGroups,
|
||||
config.snippetPackages,
|
||||
config.portForwardingRules,
|
||||
config.knownHosts,
|
||||
config.groupConfigs,
|
||||
]);
|
||||
|
||||
@@ -283,7 +312,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// checkRemoteVersion below: if inspect transiently errors we still
|
||||
// let auto-sync run, trusting this guard to refuse if local is
|
||||
// truly empty rather than letting an empty state clobber remote.
|
||||
if (!hasMeaningfulSyncData(payload)) {
|
||||
if (!hasMeaningfulCloudSyncData(payload)) {
|
||||
if (trigger === 'auto') {
|
||||
console.warn('[AutoSync] Blocked: refusing to auto-sync an empty vault to cloud');
|
||||
return;
|
||||
@@ -437,8 +466,8 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const remoteFile = inspection.remoteFile;
|
||||
const remotePayload = inspection.payload;
|
||||
const localPayload = buildPayloadRef.current();
|
||||
const localIsEmpty = !hasMeaningfulSyncData(localPayload);
|
||||
const remoteHasData = hasMeaningfulSyncData(remotePayload);
|
||||
const localIsEmpty = !hasMeaningfulCloudSyncData(localPayload);
|
||||
const remoteHasData = hasMeaningfulCloudSyncData(remotePayload);
|
||||
|
||||
// If local vault is empty but cloud has data, this almost certainly
|
||||
// means the user's data was lost (update, storage corruption, etc.).
|
||||
@@ -450,6 +479,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
remotePayload,
|
||||
hostCount: remotePayload.hosts?.length ?? 0,
|
||||
keyCount: remotePayload.keys?.length ?? 0,
|
||||
proxyProfileCount: remotePayload.proxyProfiles?.length ?? 0,
|
||||
snippetCount: remotePayload.snippets?.length ?? 0,
|
||||
});
|
||||
});
|
||||
@@ -640,7 +670,17 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
clearTimeout(syncTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow, config.settingsVersion, bookmarksVersion]);
|
||||
}, [
|
||||
sync.hasAnyConnectedProvider,
|
||||
sync.autoSyncEnabled,
|
||||
sync.isUnlocked,
|
||||
sync.isSyncing,
|
||||
getDataHash,
|
||||
syncNow,
|
||||
config.settingsVersion,
|
||||
bookmarksVersion,
|
||||
syncableSettingsStorageVersion,
|
||||
]);
|
||||
|
||||
// Check remote version on startup/unlock, then retry with backoff
|
||||
// while the inspect keeps failing. Without the timer-based retry,
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface CloudSyncHook {
|
||||
remoteVersion: number;
|
||||
remoteUpdatedAt: number;
|
||||
syncHistory: SyncHistoryEntry[];
|
||||
pendingBrowserAuthProvider: 'google' | 'onedrive' | null;
|
||||
|
||||
// Computed
|
||||
hasAnyConnectedProvider: boolean;
|
||||
@@ -72,7 +73,9 @@ export interface CloudSyncHook {
|
||||
deviceCode: string,
|
||||
interval: number,
|
||||
expiresAt: number,
|
||||
onPending?: () => void
|
||||
onPending?: () => void,
|
||||
signal?: AbortSignal,
|
||||
authAttemptId?: number
|
||||
) => Promise<void>;
|
||||
connectGoogle: () => Promise<string>;
|
||||
connectOneDrive: () => Promise<string>;
|
||||
@@ -126,6 +129,47 @@ export interface CloudSyncHook {
|
||||
getShrinkBlockedFinding: () => Extract<ShrinkFinding, { suspicious: true }> | null;
|
||||
}
|
||||
|
||||
type PendingBrowserAuthState = {
|
||||
provider: 'google' | 'onedrive';
|
||||
sessionId: string;
|
||||
authAttemptId?: number;
|
||||
} | null;
|
||||
|
||||
let pendingBrowserAuthState: PendingBrowserAuthState = null;
|
||||
const pendingBrowserAuthListeners = new Set<() => void>();
|
||||
let activeOAuthBrowserHandoff:
|
||||
| { sessionId: string; cancel: () => void }
|
||||
| null = null;
|
||||
const cancelledOAuthSessionIds = new Set<string>();
|
||||
|
||||
const getPendingBrowserAuthState = (): PendingBrowserAuthState => pendingBrowserAuthState;
|
||||
|
||||
const subscribePendingBrowserAuthState = (callback: () => void) => {
|
||||
pendingBrowserAuthListeners.add(callback);
|
||||
return () => pendingBrowserAuthListeners.delete(callback);
|
||||
};
|
||||
|
||||
const setPendingBrowserAuthState = (next: PendingBrowserAuthState) => {
|
||||
pendingBrowserAuthState = next;
|
||||
pendingBrowserAuthListeners.forEach((callback) => callback());
|
||||
};
|
||||
|
||||
const clearPendingBrowserAuthState = (
|
||||
match?: { provider: 'google' | 'onedrive'; sessionId: string; authAttemptId?: number }
|
||||
) => {
|
||||
if (!match) {
|
||||
setPendingBrowserAuthState(null);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
pendingBrowserAuthState &&
|
||||
pendingBrowserAuthState.provider === match.provider &&
|
||||
pendingBrowserAuthState.sessionId === match.sessionId
|
||||
) {
|
||||
setPendingBrowserAuthState(null);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Hook Implementation
|
||||
// ============================================================================
|
||||
@@ -146,6 +190,15 @@ const getSnapshot = (): SyncManagerState => {
|
||||
export const useCloudSync = (): CloudSyncHook => {
|
||||
// Use useSyncExternalStore for real-time state sync across all components
|
||||
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
const pendingBrowserAuth = useSyncExternalStore(
|
||||
subscribePendingBrowserAuthState,
|
||||
getPendingBrowserAuthState,
|
||||
getPendingBrowserAuthState
|
||||
);
|
||||
const activeOAuthSessionIdRef = useRef<string | null>(null);
|
||||
const activeOAuthProviderRef = useRef<'google' | 'onedrive' | null>(null);
|
||||
const activeGitHubAuthAbortRef = useRef<AbortController | null>(null);
|
||||
const activeGitHubAuthAttemptIdRef = useRef<number | null>(null);
|
||||
|
||||
// Auto-unlock: if a master key exists, retrieve the persisted password (Electron safeStorage)
|
||||
// and unlock silently so users don't have to manage a LOCKED state in the UI.
|
||||
@@ -262,107 +315,277 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
if (result.type !== 'device_code') {
|
||||
throw new Error('Unexpected auth type');
|
||||
}
|
||||
return result.data as DeviceFlowState;
|
||||
activeGitHubAuthAttemptIdRef.current = result.data.authAttemptId ?? null;
|
||||
return result.data;
|
||||
}, []);
|
||||
|
||||
const completeGitHubAuth = useCallback(async (
|
||||
deviceCode: string,
|
||||
interval: number,
|
||||
expiresAt: number,
|
||||
onPending?: () => void
|
||||
onPending?: () => void,
|
||||
signal?: AbortSignal,
|
||||
authAttemptId?: number
|
||||
): Promise<void> => {
|
||||
await manager.completeGitHubAuth(deviceCode, interval, expiresAt, onPending);
|
||||
}, []);
|
||||
|
||||
const connectGoogle = useCallback(async (): Promise<string> => {
|
||||
const result = await manager.startProviderAuth('google');
|
||||
if (result.type !== 'url') {
|
||||
throw new Error('Unexpected auth type');
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort();
|
||||
|
||||
if (signal?.aborted) {
|
||||
abort();
|
||||
} else if (signal) {
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
}
|
||||
const data = result.data as { url: string; redirectUri: string };
|
||||
|
||||
// Start OAuth callback server in Electron and wait for authorization
|
||||
const bridge = netcattyBridge.get();
|
||||
const startCallback = bridge?.startOAuthCallback;
|
||||
if (startCallback) {
|
||||
// Get state from adapter for CSRF protection
|
||||
const adapter = manager.getAdapter('google') as { getPKCEState?: () => string | null } | undefined;
|
||||
const expectedState = adapter?.getPKCEState?.() || undefined;
|
||||
activeGitHubAuthAbortRef.current = controller;
|
||||
|
||||
// Start callback server and open system browser
|
||||
const callbackPromise = startCallback(expectedState);
|
||||
|
||||
// Use system browser to avoid white-screen issues in popup windows (#563)
|
||||
// Race: if browser launch fails, surface the error immediately
|
||||
let openTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const browserPromise = new Promise<never>((_resolve, reject) => {
|
||||
openTimer = setTimeout(async () => {
|
||||
try {
|
||||
await bridge?.openExternal(data.url);
|
||||
} catch (err) {
|
||||
bridge?.cancelOAuthCallback?.();
|
||||
reject(err instanceof Error ? err : new Error('Failed to open browser for authentication'));
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
try {
|
||||
const { code } = await Promise.race([callbackPromise, browserPromise]);
|
||||
|
||||
// Complete auth with the received code
|
||||
await manager.completePKCEAuth('google', code, data.redirectUri);
|
||||
} finally {
|
||||
if (openTimer) clearTimeout(openTimer);
|
||||
try {
|
||||
await manager.completeGitHubAuth(
|
||||
deviceCode,
|
||||
interval,
|
||||
expiresAt,
|
||||
onPending,
|
||||
controller.signal,
|
||||
authAttemptId
|
||||
);
|
||||
} finally {
|
||||
if (signal) {
|
||||
signal.removeEventListener('abort', abort);
|
||||
}
|
||||
if (activeGitHubAuthAbortRef.current === controller) {
|
||||
activeGitHubAuthAbortRef.current = null;
|
||||
}
|
||||
if (activeGitHubAuthAttemptIdRef.current === (authAttemptId ?? null)) {
|
||||
activeGitHubAuthAttemptIdRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
return data.url;
|
||||
}, []);
|
||||
|
||||
const cancelActivePKCEAuth = useCallback(async () => {
|
||||
const pending = getPendingBrowserAuthState();
|
||||
const sessionId = pending?.sessionId ?? activeOAuthSessionIdRef.current;
|
||||
const provider = pending?.provider ?? activeOAuthProviderRef.current;
|
||||
const authAttemptId = pending?.authAttemptId;
|
||||
if (!sessionId || !provider) return;
|
||||
|
||||
cancelledOAuthSessionIds.add(sessionId);
|
||||
if (activeOAuthBrowserHandoff?.sessionId === sessionId) {
|
||||
activeOAuthBrowserHandoff.cancel();
|
||||
activeOAuthBrowserHandoff = null;
|
||||
}
|
||||
manager.cancelProviderAuthAttempt(provider, authAttemptId);
|
||||
activeOAuthSessionIdRef.current = null;
|
||||
activeOAuthProviderRef.current = null;
|
||||
clearPendingBrowserAuthState(
|
||||
pending
|
||||
? {
|
||||
provider: pending.provider,
|
||||
sessionId: pending.sessionId,
|
||||
authAttemptId: pending.authAttemptId,
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
|
||||
try {
|
||||
await netcattyBridge.get()?.cancelOAuthCallback?.(sessionId);
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
}, []);
|
||||
|
||||
const runPKCEAuth = useCallback(
|
||||
async (provider: 'google' | 'onedrive'): Promise<string> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
const prepare = bridge?.prepareOAuthCallback;
|
||||
const awaitCallback = bridge?.awaitOAuthCallback;
|
||||
const openExternal = bridge?.openExternal;
|
||||
if (!prepare || !awaitCallback || !openExternal) {
|
||||
throw new Error('OAuth bridge is unavailable');
|
||||
}
|
||||
|
||||
// Only one loopback OAuth flow can be active at a time. If the user
|
||||
// starts another provider while a previous browser hop is still pending,
|
||||
// cancel the stale one first so the new attempt owns the callback port.
|
||||
await cancelActivePKCEAuth();
|
||||
|
||||
// Bind the loopback callback server first so we know which port to put
|
||||
// in the provider's redirect_uri (#823: 45678 may be in use).
|
||||
const { redirectUri, sessionId } = await prepare();
|
||||
activeOAuthSessionIdRef.current = sessionId;
|
||||
activeOAuthProviderRef.current = provider;
|
||||
setPendingBrowserAuthState({ provider, sessionId });
|
||||
|
||||
try {
|
||||
const result = await manager.startProviderAuth(provider, redirectUri);
|
||||
if (result.type !== 'url') {
|
||||
throw new Error('Unexpected auth type');
|
||||
}
|
||||
const data = result.data;
|
||||
|
||||
if (cancelledOAuthSessionIds.has(sessionId)) {
|
||||
throw new Error('OAuth flow cancelled');
|
||||
}
|
||||
|
||||
const adapter = manager.getAdapter(provider) as
|
||||
| { getPKCEState?: () => string | null }
|
||||
| undefined;
|
||||
const expectedState = adapter?.getPKCEState?.() || undefined;
|
||||
|
||||
const callbackPromise = awaitCallback(expectedState, sessionId);
|
||||
|
||||
// Use system browser to avoid white-screen issues in popup windows (#563).
|
||||
// Once the browser has opened, let the rest of the PKCE handshake
|
||||
// continue in the background so closing the browser later does not
|
||||
// leave the whole settings page locked waiting on a timeout.
|
||||
let openTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let browserOpened = false;
|
||||
let rejectBrowserPromise: ((error: Error) => void) | null = null;
|
||||
const browserPromise = new Promise<void>((resolve, reject) => {
|
||||
rejectBrowserPromise = reject;
|
||||
openTimer = setTimeout(async () => {
|
||||
try {
|
||||
await openExternal(data.url);
|
||||
browserOpened = true;
|
||||
resolve();
|
||||
} catch (err) {
|
||||
bridge?.cancelOAuthCallback?.(sessionId);
|
||||
reject(
|
||||
err instanceof Error
|
||||
? err
|
||||
: new Error('Failed to open browser for authentication')
|
||||
);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
activeOAuthBrowserHandoff = {
|
||||
sessionId,
|
||||
cancel: () => {
|
||||
if (openTimer) {
|
||||
clearTimeout(openTimer);
|
||||
openTimer = null;
|
||||
}
|
||||
if (rejectBrowserPromise) {
|
||||
rejectBrowserPromise(new Error('OAuth flow cancelled'));
|
||||
rejectBrowserPromise = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
browserPromise,
|
||||
callbackPromise.then(
|
||||
() => {
|
||||
throw new Error('OAuth callback completed before browser handoff');
|
||||
},
|
||||
(error) => {
|
||||
if (browserOpened) {
|
||||
return new Promise<void>(() => {});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
),
|
||||
]);
|
||||
} finally {
|
||||
if (openTimer) clearTimeout(openTimer);
|
||||
if (activeOAuthBrowserHandoff?.sessionId === sessionId) {
|
||||
activeOAuthBrowserHandoff = null;
|
||||
}
|
||||
}
|
||||
setPendingBrowserAuthState({
|
||||
provider,
|
||||
sessionId,
|
||||
authAttemptId: data.authAttemptId,
|
||||
});
|
||||
|
||||
const completionPromise = (async () => {
|
||||
try {
|
||||
const { code } = await callbackPromise;
|
||||
await manager.completePKCEAuth(provider, code, data.redirectUri, data.authAttemptId);
|
||||
} catch (error) {
|
||||
const ownsActiveSession =
|
||||
activeOAuthSessionIdRef.current === sessionId &&
|
||||
activeOAuthProviderRef.current === provider;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const cancelledOrSuperseded =
|
||||
message.includes('cancelled') || message.includes('auth superseded');
|
||||
const timedOut = message.toLowerCase().includes('timeout');
|
||||
if (ownsActiveSession && (cancelledOrSuperseded || timedOut)) {
|
||||
activeOAuthSessionIdRef.current = null;
|
||||
activeOAuthProviderRef.current = null;
|
||||
cancelledOAuthSessionIds.delete(sessionId);
|
||||
clearPendingBrowserAuthState({
|
||||
provider,
|
||||
sessionId,
|
||||
authAttemptId: data.authAttemptId,
|
||||
});
|
||||
manager.resetProviderStatus(provider);
|
||||
} else if (ownsActiveSession) {
|
||||
activeOAuthSessionIdRef.current = null;
|
||||
activeOAuthProviderRef.current = null;
|
||||
cancelledOAuthSessionIds.delete(sessionId);
|
||||
clearPendingBrowserAuthState({
|
||||
provider,
|
||||
sessionId,
|
||||
authAttemptId: data.authAttemptId,
|
||||
});
|
||||
manager.setProviderError(provider, message);
|
||||
}
|
||||
} finally {
|
||||
if (
|
||||
activeOAuthSessionIdRef.current === sessionId &&
|
||||
activeOAuthProviderRef.current === provider
|
||||
) {
|
||||
activeOAuthSessionIdRef.current = null;
|
||||
activeOAuthProviderRef.current = null;
|
||||
}
|
||||
cancelledOAuthSessionIds.delete(sessionId);
|
||||
clearPendingBrowserAuthState({
|
||||
provider,
|
||||
sessionId,
|
||||
authAttemptId: data.authAttemptId,
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
// Release the transient "connecting" UI once the browser handoff has
|
||||
// happened. The callback session remains active in the background and
|
||||
// will mark the provider connected when the redirect completes.
|
||||
// Do NOT use resetProviderStatus here — it would restore from the
|
||||
// auth snapshot and delete the adapter we just created, making the
|
||||
// eventual completePKCEAuth call fail with "adapter not initialized".
|
||||
manager.clearConnectingStatus(provider);
|
||||
manager.clearProviderError(provider);
|
||||
void completionPromise;
|
||||
return data.url;
|
||||
} catch (err) {
|
||||
const ownsActiveSession =
|
||||
activeOAuthSessionIdRef.current === sessionId &&
|
||||
activeOAuthProviderRef.current === provider;
|
||||
try {
|
||||
await bridge?.cancelOAuthCallback?.(sessionId);
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
if (ownsActiveSession) {
|
||||
activeOAuthSessionIdRef.current = null;
|
||||
activeOAuthProviderRef.current = null;
|
||||
manager.cancelProviderAuthAttempt(provider);
|
||||
manager.resetProviderStatus(provider);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[cancelActivePKCEAuth]
|
||||
);
|
||||
|
||||
const connectGoogle = useCallback(async (): Promise<string> => {
|
||||
return runPKCEAuth('google');
|
||||
}, [runPKCEAuth]);
|
||||
|
||||
const connectOneDrive = useCallback(async (): Promise<string> => {
|
||||
const result = await manager.startProviderAuth('onedrive');
|
||||
if (result.type !== 'url') {
|
||||
throw new Error('Unexpected auth type');
|
||||
}
|
||||
const data = result.data as { url: string; redirectUri: string };
|
||||
return runPKCEAuth('onedrive');
|
||||
}, [runPKCEAuth]);
|
||||
|
||||
// Start OAuth callback server in Electron and wait for authorization
|
||||
const bridge = netcattyBridge.get();
|
||||
const startCallback = bridge?.startOAuthCallback;
|
||||
if (startCallback) {
|
||||
// Get state from adapter for CSRF protection
|
||||
const adapter = manager.getAdapter('onedrive') as { getPKCEState?: () => string | null } | undefined;
|
||||
const expectedState = adapter?.getPKCEState?.() || undefined;
|
||||
|
||||
// Start callback server and open system browser
|
||||
const callbackPromise = startCallback(expectedState);
|
||||
|
||||
// Use system browser to avoid white-screen issues in popup windows (#563)
|
||||
let openTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const browserPromise = new Promise<never>((_resolve, reject) => {
|
||||
openTimer = setTimeout(async () => {
|
||||
try {
|
||||
await bridge?.openExternal(data.url);
|
||||
} catch (err) {
|
||||
bridge?.cancelOAuthCallback?.();
|
||||
reject(err instanceof Error ? err : new Error('Failed to open browser for authentication'));
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
try {
|
||||
const { code } = await Promise.race([callbackPromise, browserPromise]);
|
||||
|
||||
// Complete auth with the received code
|
||||
await manager.completePKCEAuth('onedrive', code, data.redirectUri);
|
||||
} finally {
|
||||
if (openTimer) clearTimeout(openTimer);
|
||||
}
|
||||
}
|
||||
|
||||
return data.url;
|
||||
}, []);
|
||||
|
||||
const completePKCEAuth = useCallback(async (
|
||||
provider: 'google' | 'onedrive',
|
||||
code: string,
|
||||
@@ -388,9 +611,16 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
}, []);
|
||||
|
||||
const cancelOAuthConnect = useCallback(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.cancelOAuthCallback?.();
|
||||
}, []);
|
||||
const githubAbort = activeGitHubAuthAbortRef.current;
|
||||
if (githubAbort) {
|
||||
manager.cancelProviderAuthAttempt('github', activeGitHubAuthAttemptIdRef.current ?? undefined);
|
||||
activeGitHubAuthAttemptIdRef.current = null;
|
||||
githubAbort.abort();
|
||||
return;
|
||||
}
|
||||
|
||||
void cancelActivePKCEAuth();
|
||||
}, [cancelActivePKCEAuth]);
|
||||
|
||||
// ========== Settings ==========
|
||||
|
||||
@@ -478,6 +708,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
remoteVersion: state.remoteVersion,
|
||||
remoteUpdatedAt: state.remoteUpdatedAt,
|
||||
syncHistory: state.syncHistory,
|
||||
pendingBrowserAuthProvider: pendingBrowserAuth?.provider ?? null,
|
||||
|
||||
// Computed
|
||||
hasAnyConnectedProvider,
|
||||
|
||||
@@ -33,6 +33,7 @@ interface HotkeyActions {
|
||||
// App features
|
||||
broadcast: () => void;
|
||||
openLocal: () => void;
|
||||
openSettings: () => void;
|
||||
}
|
||||
|
||||
// Check if keyboard event matches our app-level shortcuts
|
||||
@@ -71,6 +72,7 @@ export const getAppLevelActions = (): Set<string> => {
|
||||
'moveFocus',
|
||||
'broadcast',
|
||||
'openLocal',
|
||||
'openSettings',
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -200,6 +202,9 @@ export const useGlobalHotkeys = ({
|
||||
case 'broadcast':
|
||||
currentActions.broadcast?.();
|
||||
break;
|
||||
case 'openSettings':
|
||||
currentActions.openSettings?.();
|
||||
break;
|
||||
}
|
||||
}, [hotkeyScheme, keyBindings, isSettingsOpen]);
|
||||
|
||||
|
||||
117
application/state/usePortForwardingAutoStart.test.ts
Normal file
117
application/state/usePortForwardingAutoStart.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
@@ -4,8 +4,9 @@
|
||||
* when the application starts, not when the user navigates to the port forwarding page.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { GroupConfig, Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
|
||||
import { GroupConfig, Host, Identity, PortForwardingRule, ProxyProfile, SSHKey } from "../../domain/models";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../../domain/groupConfig";
|
||||
import { materializeHostProxyProfile } from "../../domain/proxyProfiles";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
@@ -17,27 +18,102 @@ import {
|
||||
import { logger } from "../../lib/logger";
|
||||
|
||||
export interface UsePortForwardingAutoStartOptions {
|
||||
isVaultInitialized: boolean;
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
proxyProfiles: ProxyProfile[];
|
||||
groupConfigs: GroupConfig[];
|
||||
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
|
||||
}
|
||||
|
||||
const AUTO_START_PROXY_NOT_READY_ERROR = "Proxy or jump host configuration is not ready";
|
||||
const AUTO_START_AUTH_NOT_READY_ERROR = "Host authentication configuration is not ready";
|
||||
|
||||
export const isAutoStartProxyReady = (
|
||||
host: Host,
|
||||
allHosts: Host[],
|
||||
proxyProfiles: ProxyProfile[],
|
||||
groupConfigs: GroupConfig[],
|
||||
seen = new Set<string>(),
|
||||
): boolean => {
|
||||
if (!host || seen.has(host.id)) return true;
|
||||
seen.add(host.id);
|
||||
|
||||
const validProxyProfileIds: ReadonlySet<string> = new Set(proxyProfiles.map((profile) => profile.id));
|
||||
const rawGroupDefaults = host.group
|
||||
? resolveGroupDefaults(host.group, groupConfigs)
|
||||
: {};
|
||||
const groupDefaults = host.group
|
||||
? resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds })
|
||||
: {};
|
||||
const missingHostProxyProfile = Boolean(
|
||||
host.proxyProfileId && !validProxyProfileIds.has(host.proxyProfileId),
|
||||
);
|
||||
const missingGroupProxyProfile = Boolean(
|
||||
!host.proxyConfig &&
|
||||
!host.proxyProfileId &&
|
||||
rawGroupDefaults.proxyProfileId &&
|
||||
!validProxyProfileIds.has(rawGroupDefaults.proxyProfileId),
|
||||
);
|
||||
const effectiveHost = applyGroupDefaults(host, groupDefaults, { validProxyProfileIds });
|
||||
const hasProxyReplacement = Boolean(
|
||||
effectiveHost.proxyConfig ||
|
||||
(effectiveHost.proxyProfileId && validProxyProfileIds.has(effectiveHost.proxyProfileId)),
|
||||
);
|
||||
|
||||
if ((missingHostProxyProfile || missingGroupProxyProfile) && !hasProxyReplacement) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const chainIds = effectiveHost.hostChain?.hostIds || [];
|
||||
for (const chainId of chainIds) {
|
||||
const chainHost = allHosts.find((candidate) => candidate.id === chainId);
|
||||
if (!chainHost) return false;
|
||||
if (!isAutoStartProxyReady(chainHost, allHosts, proxyProfiles, groupConfigs, seen)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getAutoStartRuleBlockReason = (
|
||||
rule: PortForwardingRule,
|
||||
hosts: Host[],
|
||||
proxyProfiles: ProxyProfile[],
|
||||
groupConfigs: GroupConfig[],
|
||||
isHostAuthReady: (host: Host) => boolean,
|
||||
): string | undefined => {
|
||||
if (!rule.hostId) return "Rule host is not configured";
|
||||
const host = hosts.find((candidate) => candidate.id === rule.hostId);
|
||||
if (!host) return "Host not found";
|
||||
if (!isHostAuthReady(host)) return AUTO_START_AUTH_NOT_READY_ERROR;
|
||||
if (!isAutoStartProxyReady(host, hosts, proxyProfiles, groupConfigs)) {
|
||||
return AUTO_START_PROXY_NOT_READY_ERROR;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Auto-starts port forwarding rules that have autoStart enabled.
|
||||
* This hook should be called at the App level to run on app launch.
|
||||
*/
|
||||
export const usePortForwardingAutoStart = ({
|
||||
isVaultInitialized,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
groupConfigs,
|
||||
terminalSettings,
|
||||
}: UsePortForwardingAutoStartOptions): void => {
|
||||
const autoStartExecutedRef = useRef(false);
|
||||
const hostsRef = useRef<Host[]>(hosts);
|
||||
const keysRef = useRef<SSHKey[]>(keys);
|
||||
const identitiesRef = useRef<Identity[]>(identities);
|
||||
const proxyProfilesRef = useRef<ProxyProfile[]>(proxyProfiles);
|
||||
const groupConfigsRef = useRef<GroupConfig[]>(groupConfigs);
|
||||
const terminalSettingsRef = useRef(terminalSettings);
|
||||
terminalSettingsRef.current = terminalSettings;
|
||||
|
||||
const isHostAuthReady = useCallback((host: Host, seen = new Set<string>()): boolean => {
|
||||
if (!host || seen.has(host.id)) return true;
|
||||
@@ -77,16 +153,53 @@ export const usePortForwardingAutoStart = ({
|
||||
identitiesRef.current = identities;
|
||||
}, [identities]);
|
||||
|
||||
useEffect(() => {
|
||||
proxyProfilesRef.current = proxyProfiles;
|
||||
}, [proxyProfiles]);
|
||||
|
||||
useEffect(() => {
|
||||
groupConfigsRef.current = groupConfigs;
|
||||
}, [groupConfigs]);
|
||||
|
||||
const resolveEffectiveHost = useCallback((host: Host): Host => {
|
||||
if (!host.group) return host;
|
||||
const defaults = resolveGroupDefaults(host.group, groupConfigsRef.current);
|
||||
return applyGroupDefaults(host, defaults);
|
||||
const validProxyProfileIds: ReadonlySet<string> = new Set(proxyProfilesRef.current.map((profile) => profile.id));
|
||||
const withGroupDefaults = host.group
|
||||
? applyGroupDefaults(
|
||||
host,
|
||||
resolveGroupDefaults(host.group, groupConfigsRef.current, { validProxyProfileIds }),
|
||||
{ validProxyProfileIds },
|
||||
)
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds });
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfilesRef.current);
|
||||
}, []);
|
||||
|
||||
const resolveEffectiveHosts = useCallback(
|
||||
(items: Host[]): Host[] => items.map((host) => resolveEffectiveHost(host)),
|
||||
[resolveEffectiveHost],
|
||||
);
|
||||
|
||||
const updateStoredRuleStatus = useCallback(
|
||||
(ruleId: string, status: PortForwardingRule["status"], error?: string) => {
|
||||
const currentRules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
) ?? [];
|
||||
|
||||
const updatedRules = currentRules.map((rule) =>
|
||||
rule.id === ruleId
|
||||
? {
|
||||
...rule,
|
||||
status,
|
||||
error,
|
||||
lastUsedAt: status === "active" ? Date.now() : rule.lastUsedAt,
|
||||
}
|
||||
: rule,
|
||||
);
|
||||
|
||||
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, updatedRules);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Set up the reconnect callback
|
||||
useEffect(() => {
|
||||
const handleReconnect = async (
|
||||
@@ -99,40 +212,49 @@ export const usePortForwardingAutoStart = ({
|
||||
) ?? [];
|
||||
|
||||
const rule = rules.find((r) => r.id === ruleId);
|
||||
if (!rule || !rule.hostId) {
|
||||
return { success: false, error: "Rule or host not found" };
|
||||
if (!rule) {
|
||||
const error = "Rule not found";
|
||||
onStatusChange("error", error);
|
||||
return { success: false, error };
|
||||
}
|
||||
if (!rule.hostId) {
|
||||
const error = "Rule host is not configured";
|
||||
onStatusChange("error", error);
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
const rawHost = hostsRef.current.find((h) => h.id === rule.hostId);
|
||||
if (!rawHost) {
|
||||
return { success: false, error: "Host not found" };
|
||||
const error = "Host not found";
|
||||
onStatusChange("error", error);
|
||||
return { success: false, error };
|
||||
}
|
||||
const blockReason = getAutoStartRuleBlockReason(
|
||||
rule,
|
||||
hostsRef.current,
|
||||
proxyProfilesRef.current,
|
||||
groupConfigsRef.current,
|
||||
(host) => isHostAuthReady(host),
|
||||
);
|
||||
if (blockReason) {
|
||||
onStatusChange("error", blockReason);
|
||||
return { success: false, error: blockReason };
|
||||
}
|
||||
|
||||
const host = resolveEffectiveHost(rawHost);
|
||||
return startPortForward(rule, host, hostsRef.current, keysRef.current, identitiesRef.current, onStatusChange, true);
|
||||
return startPortForward(rule, host, resolveEffectiveHosts(hostsRef.current), keysRef.current, identitiesRef.current, onStatusChange, true, terminalSettingsRef.current);
|
||||
};
|
||||
|
||||
setReconnectCallback(handleReconnect);
|
||||
return () => {
|
||||
setReconnectCallback(null);
|
||||
};
|
||||
}, [resolveEffectiveHost]);
|
||||
}, [isHostAuthReady, resolveEffectiveHost, resolveEffectiveHosts]);
|
||||
|
||||
// Auto-start rules on app launch
|
||||
useEffect(() => {
|
||||
if (autoStartExecutedRef.current) return;
|
||||
if (hosts.length === 0) return;
|
||||
|
||||
const storedRules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
) ?? [];
|
||||
const pendingAutoStartRules = storedRules.filter((rule) => rule.autoStart && rule.hostId);
|
||||
if (pendingAutoStartRules.some((rule) => {
|
||||
const host = hosts.find((candidate) => candidate.id === rule.hostId);
|
||||
return !host || !isHostAuthReady(host);
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
if (!isVaultInitialized) return;
|
||||
|
||||
// Mark as executed immediately to prevent duplicate runs
|
||||
// (React StrictMode or dependency changes could cause re-runs)
|
||||
@@ -149,7 +271,7 @@ export const usePortForwardingAutoStart = ({
|
||||
|
||||
// Only start rules that are not already active
|
||||
const autoStartRules = rules.filter((r) => {
|
||||
if (!r.autoStart || !r.hostId) return false;
|
||||
if (!r.autoStart) return false;
|
||||
// Check if there's an active connection for this rule
|
||||
const conn = getActiveConnection(r.id);
|
||||
// Only start if not already connecting or active
|
||||
@@ -162,39 +284,49 @@ export const usePortForwardingAutoStart = ({
|
||||
// Start each auto-start rule
|
||||
for (const rule of autoStartRules) {
|
||||
const rawHost = hosts.find((h) => h.id === rule.hostId);
|
||||
if (rawHost) {
|
||||
const host = resolveEffectiveHost(rawHost);
|
||||
void startPortForward(
|
||||
rule,
|
||||
host,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
(status, error) => {
|
||||
// Update the rule status in storage
|
||||
const currentRules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
) ?? [];
|
||||
|
||||
const updatedRules = currentRules.map((r) =>
|
||||
r.id === rule.id
|
||||
? {
|
||||
...r,
|
||||
status,
|
||||
error,
|
||||
lastUsedAt: status === "active" ? Date.now() : r.lastUsedAt,
|
||||
}
|
||||
: r,
|
||||
);
|
||||
|
||||
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, updatedRules);
|
||||
},
|
||||
true, // Enable reconnect for auto-start rules
|
||||
);
|
||||
const blockReason = getAutoStartRuleBlockReason(
|
||||
rule,
|
||||
hosts,
|
||||
proxyProfiles,
|
||||
groupConfigs,
|
||||
(host) => isHostAuthReady(host),
|
||||
);
|
||||
if (blockReason) {
|
||||
updateStoredRuleStatus(rule.id, "error", blockReason);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!rawHost) continue;
|
||||
const host = resolveEffectiveHost(rawHost);
|
||||
void startPortForward(
|
||||
rule,
|
||||
host,
|
||||
resolveEffectiveHosts(hosts),
|
||||
keys,
|
||||
identities,
|
||||
(status, error) => {
|
||||
updateStoredRuleStatus(rule.id, status, error);
|
||||
},
|
||||
true, // Enable reconnect for auto-start rules
|
||||
// Read via ref so adjusting global keepalive after launch doesn't
|
||||
// re-trigger the auto-start effect (its dep array is intentionally
|
||||
// stable to fire once on vault init).
|
||||
terminalSettingsRef.current,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
void runAutoStart();
|
||||
}, [hosts, identities, isHostAuthReady, keys, resolveEffectiveHost]);
|
||||
}, [
|
||||
groupConfigs,
|
||||
hosts,
|
||||
identities,
|
||||
isHostAuthReady,
|
||||
isVaultInitialized,
|
||||
keys,
|
||||
proxyProfiles,
|
||||
resolveEffectiveHost,
|
||||
resolveEffectiveHosts,
|
||||
updateStoredRuleStatus,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface UsePortForwardingStateResult {
|
||||
identities: Identity[],
|
||||
onStatusChange?: (status: PortForwardingRule["status"], error?: string) => void,
|
||||
enableReconnect?: boolean,
|
||||
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number },
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
stopTunnel: (
|
||||
ruleId: string,
|
||||
@@ -387,11 +388,12 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
error?: string,
|
||||
) => void,
|
||||
enableReconnect = false,
|
||||
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number },
|
||||
) => {
|
||||
return startPortForward(rule, host, hosts, keys, identities, (status, error) => {
|
||||
setRuleStatus(rule.id, status, error);
|
||||
onStatusChange?.(status, error ?? undefined);
|
||||
}, enableReconnect);
|
||||
}, enableReconnect, terminalSettings);
|
||||
},
|
||||
[setRuleStatus],
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
@@ -40,9 +40,18 @@ import {
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
import { getTerminalThemeForUiTheme } from '../../domain/terminalAppearance';
|
||||
import {
|
||||
areCustomKeyBindingsEqual,
|
||||
nextCustomKeyBindingsSyncVersion,
|
||||
parseCustomKeyBindingsStorageRecord,
|
||||
resetCustomKeyBinding,
|
||||
serializeCustomKeyBindingsStorageRecord,
|
||||
shouldApplyIncomingCustomKeyBindingsRecord,
|
||||
updateCustomKeyBinding as updateCustomKeyBindingRecord,
|
||||
} from '../../domain/customKeyBindings';
|
||||
import { applyCustomAccentToTerminalTheme, getTerminalThemeForUiTheme } from '../../domain/terminalAppearance';
|
||||
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
|
||||
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
|
||||
import { DEFAULT_FONT_SIZE, isDeprecatedPrimaryFontId } from '../../infrastructure/config/fonts';
|
||||
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
import { UI_FONTS, DEFAULT_UI_FONT_ID } from '../../infrastructure/config/uiFonts';
|
||||
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
|
||||
@@ -62,6 +71,28 @@ const DEFAULT_ACCENT_MODE: 'theme' | 'custom' = 'theme';
|
||||
const DEFAULT_CUSTOM_ACCENT = '221.2 83.2% 53.3%';
|
||||
const DEFAULT_TERMINAL_THEME = 'netcatty-dark';
|
||||
const DEFAULT_FONT_FAMILY = 'menlo';
|
||||
|
||||
/**
|
||||
* Migrate any terminal font id arriving from storage / IPC / sync to a
|
||||
* safe value. If `raw` is a deprecated proportional id (pingfang-sc,
|
||||
* microsoft-yahei, comic-sans-ms), persist the rewrite back to
|
||||
* localStorage so subsequent ingest paths and cloud-sync uploads stop
|
||||
* carrying it. Used by every place that reads STORAGE_KEY_TERM_FONT_FAMILY
|
||||
* — initial useState init, rehydrateAllFromStorage, IPC notifySettings
|
||||
* change listener, and cross-window storage event listener — so a
|
||||
* single point of truth keeps deprecated ids from re-entering state.
|
||||
*
|
||||
* Returns null when there's nothing to apply (raw is empty); callers
|
||||
* fall back to DEFAULT_FONT_FAMILY in that case.
|
||||
*/
|
||||
function migrateIncomingTerminalFontId(raw: string | null | undefined): string | null {
|
||||
if (!raw) return null;
|
||||
if (isDeprecatedPrimaryFontId(raw)) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, DEFAULT_FONT_FAMILY);
|
||||
return DEFAULT_FONT_FAMILY;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
// Auto-detect default hotkey scheme based on platform
|
||||
const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
|
||||
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
|
||||
@@ -124,6 +155,14 @@ const serializeTerminalSettings = (settings: TerminalSettings): string =>
|
||||
const areTerminalSettingsEqual = (a: TerminalSettings, b: TerminalSettings): boolean =>
|
||||
serializeTerminalSettings(a) === serializeTerminalSettings(b);
|
||||
|
||||
const createCustomKeyBindingsSyncOrigin = (): string => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
};
|
||||
|
||||
const applyThemeTokens = (
|
||||
themeSource: 'light' | 'dark' | 'system',
|
||||
resolvedTheme: 'light' | 'dark',
|
||||
@@ -169,6 +208,8 @@ const applyThemeTokens = (
|
||||
};
|
||||
|
||||
export const useSettingsState = () => {
|
||||
const initialCustomKeyBindingsRecord =
|
||||
parseCustomKeyBindingsStorageRecord(localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS));
|
||||
const uiFontsLoaded = useUIFontsLoaded();
|
||||
const [theme, setTheme] = useState<'dark' | 'light' | 'system'>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_THEME);
|
||||
@@ -213,7 +254,10 @@ export const useSettingsState = () => {
|
||||
const isUpgrade = !!localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
|
||||
return !isUpgrade;
|
||||
});
|
||||
const [terminalFontFamilyId, setTerminalFontFamilyId] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY) || DEFAULT_FONT_FAMILY);
|
||||
const [terminalFontFamilyId, setTerminalFontFamilyId] = useState<string>(() => {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
|
||||
return migrateIncomingTerminalFontId(stored) ?? DEFAULT_FONT_FAMILY;
|
||||
});
|
||||
const [terminalFontSize, setTerminalFontSize] = useState<number>(() => localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE) || DEFAULT_FONT_SIZE);
|
||||
const [uiLanguage, setUiLanguage] = useState<UILanguage>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_UI_LANGUAGE);
|
||||
@@ -231,8 +275,8 @@ export const useSettingsState = () => {
|
||||
}
|
||||
return DEFAULT_HOTKEY_SCHEME;
|
||||
});
|
||||
const [customKeyBindings, setCustomKeyBindings] = useState<CustomKeyBindings>(() =>
|
||||
localStorageAdapter.read<CustomKeyBindings>(STORAGE_KEY_CUSTOM_KEY_BINDINGS) || {}
|
||||
const [customKeyBindings, setCustomKeyBindingsState] = useState<CustomKeyBindings>(() =>
|
||||
initialCustomKeyBindingsRecord?.bindings || {}
|
||||
);
|
||||
const [isHotkeyRecording, setIsHotkeyRecordingState] = useState(false);
|
||||
const [customCSS, setCustomCSS] = useState<string>(() =>
|
||||
@@ -330,6 +374,10 @@ export const useSettingsState = () => {
|
||||
const incomingTerminalSettingsSignatureRef = useRef<string | null>(null);
|
||||
const localTerminalSettingsVersionRef = useRef(0);
|
||||
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
|
||||
const customKeyBindingsVersionRef = useRef(initialCustomKeyBindingsRecord?.version || 0);
|
||||
const customKeyBindingsOriginRef = useRef(initialCustomKeyBindingsRecord?.origin || 'legacy');
|
||||
const customKeyBindingsLocalOriginRef = useRef(createCustomKeyBindingsSyncOrigin());
|
||||
const customKeyBindingsMutationSourceRef = useRef<'local' | 'incoming'>('local');
|
||||
|
||||
// Fix 1: Mount guard — skip redundant IPC broadcasts & localStorage writes on initial mount.
|
||||
// Set to true by the LAST useEffect declaration; all persist effects see false on first render.
|
||||
@@ -361,6 +409,51 @@ export const useSettingsState = () => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setCustomKeyBindings = useCallback((nextValue: SetStateAction<CustomKeyBindings>) => {
|
||||
setCustomKeyBindingsState((prev) => {
|
||||
const candidate = typeof nextValue === 'function'
|
||||
? (nextValue as (prevState: CustomKeyBindings) => CustomKeyBindings)(prev)
|
||||
: nextValue;
|
||||
if (areCustomKeyBindingsEqual(prev, candidate)) {
|
||||
return prev;
|
||||
}
|
||||
customKeyBindingsVersionRef.current = nextCustomKeyBindingsSyncVersion(
|
||||
customKeyBindingsVersionRef.current,
|
||||
);
|
||||
customKeyBindingsOriginRef.current = customKeyBindingsLocalOriginRef.current;
|
||||
customKeyBindingsMutationSourceRef.current = 'local';
|
||||
return candidate;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const applyIncomingCustomKeyBindings = useCallback((incoming: {
|
||||
bindings: CustomKeyBindings;
|
||||
version: number;
|
||||
origin: string;
|
||||
}) => {
|
||||
setCustomKeyBindingsState((prev) => {
|
||||
if (!shouldApplyIncomingCustomKeyBindingsRecord(
|
||||
{
|
||||
version: customKeyBindingsVersionRef.current,
|
||||
origin: customKeyBindingsOriginRef.current,
|
||||
},
|
||||
{
|
||||
version: incoming.version,
|
||||
origin: incoming.origin,
|
||||
},
|
||||
)) {
|
||||
return prev;
|
||||
}
|
||||
customKeyBindingsVersionRef.current = incoming.version;
|
||||
customKeyBindingsOriginRef.current = incoming.origin;
|
||||
customKeyBindingsMutationSourceRef.current = 'incoming';
|
||||
if (areCustomKeyBindingsEqual(prev, incoming.bindings)) {
|
||||
return prev;
|
||||
}
|
||||
return incoming.bindings;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Helper to notify other windows about settings changes via IPC
|
||||
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
|
||||
try {
|
||||
@@ -444,7 +537,8 @@ export const useSettingsState = () => {
|
||||
const storedTermTheme = readStoredString(STORAGE_KEY_TERM_THEME);
|
||||
if (storedTermTheme) setTerminalThemeId(storedTermTheme);
|
||||
const storedTermFont = readStoredString(STORAGE_KEY_TERM_FONT_FAMILY);
|
||||
if (storedTermFont) setTerminalFontFamilyId(storedTermFont);
|
||||
const migratedTermFont = migrateIncomingTerminalFontId(storedTermFont);
|
||||
if (migratedTermFont) setTerminalFontFamilyId(migratedTermFont);
|
||||
const storedTermSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
|
||||
if (storedTermSize != null) setTerminalFontSize(storedTermSize);
|
||||
const storedTermSettings = readStoredString(STORAGE_KEY_TERM_SETTINGS);
|
||||
@@ -456,11 +550,11 @@ export const useSettingsState = () => {
|
||||
}
|
||||
|
||||
// Keyboard
|
||||
const storedKb = readStoredString(STORAGE_KEY_CUSTOM_KEY_BINDINGS);
|
||||
const storedKb = parseCustomKeyBindingsStorageRecord(
|
||||
localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS),
|
||||
);
|
||||
if (storedKb) {
|
||||
try {
|
||||
setCustomKeyBindings(JSON.parse(storedKb));
|
||||
} catch { /* ignore */ }
|
||||
applyIncomingCustomKeyBindings(storedKb);
|
||||
}
|
||||
|
||||
// Editor
|
||||
@@ -493,7 +587,7 @@ export const useSettingsState = () => {
|
||||
|
||||
// Custom terminal themes
|
||||
customThemeStore.loadFromStorage();
|
||||
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
|
||||
}, [applyIncomingCustomKeyBindings, syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
@@ -580,7 +674,8 @@ export const useSettingsState = () => {
|
||||
setFollowAppTerminalThemeState((prev) => (prev === next ? prev : next));
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_FONT_FAMILY && typeof value === 'string') {
|
||||
setTerminalFontFamilyId(value);
|
||||
const migrated = migrateIncomingTerminalFontId(value);
|
||||
if (migrated) setTerminalFontFamilyId(migrated);
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_FONT_SIZE && typeof value === 'number') {
|
||||
setTerminalFontSize(value);
|
||||
@@ -616,14 +711,9 @@ export const useSettingsState = () => {
|
||||
setHotkeyScheme(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_CUSTOM_KEY_BINDINGS) {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
setCustomKeyBindings(JSON.parse(value) as CustomKeyBindings);
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
} else if (value && typeof value === 'object') {
|
||||
setCustomKeyBindings(value as CustomKeyBindings);
|
||||
const parsed = parseCustomKeyBindingsStorageRecord(value);
|
||||
if (parsed) {
|
||||
applyIncomingCustomKeyBindings(parsed);
|
||||
}
|
||||
}
|
||||
if (key === STORAGE_KEY_HOTKEY_RECORDING && typeof value === 'boolean') {
|
||||
@@ -657,7 +747,7 @@ export const useSettingsState = () => {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
}, [mergeIncomingTerminalSettings, syncAppearanceFromStorage, syncCustomCssFromStorage]);
|
||||
}, [applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings, syncAppearanceFromStorage, syncCustomCssFromStorage]);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -752,11 +842,9 @@ export const useSettingsState = () => {
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_CUSTOM_KEY_BINDINGS && e.newValue) {
|
||||
try {
|
||||
const newBindings = JSON.parse(e.newValue) as CustomKeyBindings;
|
||||
setCustomKeyBindings(newBindings);
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
const parsed = parseCustomKeyBindingsStorageRecord(e.newValue);
|
||||
if (parsed) {
|
||||
applyIncomingCustomKeyBindings(parsed);
|
||||
}
|
||||
}
|
||||
// Sync terminal settings from other windows
|
||||
@@ -783,8 +871,9 @@ export const useSettingsState = () => {
|
||||
}
|
||||
// Sync terminal font family from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_FONT_FAMILY && e.newValue) {
|
||||
if (e.newValue !== s.terminalFontFamilyId) {
|
||||
setTerminalFontFamilyId(e.newValue);
|
||||
const migrated = migrateIncomingTerminalFontId(e.newValue);
|
||||
if (migrated && migrated !== s.terminalFontFamilyId) {
|
||||
setTerminalFontFamilyId(migrated);
|
||||
}
|
||||
}
|
||||
// Sync terminal font size from other windows
|
||||
@@ -908,7 +997,7 @@ export const useSettingsState = () => {
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [mergeIncomingTerminalSettings]); // Fix 4: stable deps only — state comparisons use settingsSnapshotRef
|
||||
}, [applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings]); // Fix 4: stable deps only — state comparisons use settingsSnapshotRef
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
@@ -956,9 +1045,21 @@ export const useSettingsState = () => {
|
||||
}, [hotkeyScheme, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.write(STORAGE_KEY_CUSTOM_KEY_BINDINGS, customKeyBindings);
|
||||
const payload = serializeCustomKeyBindingsStorageRecord({
|
||||
version: customKeyBindingsVersionRef.current,
|
||||
origin: customKeyBindingsOriginRef.current,
|
||||
bindings: customKeyBindings,
|
||||
});
|
||||
if (localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS) !== payload) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_KEY_BINDINGS, payload);
|
||||
}
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_CUSTOM_KEY_BINDINGS, customKeyBindings);
|
||||
if (customKeyBindingsMutationSourceRef.current === 'incoming') return;
|
||||
notifySettingsChanged(STORAGE_KEY_CUSTOM_KEY_BINDINGS, {
|
||||
version: customKeyBindingsVersionRef.current,
|
||||
origin: customKeyBindingsOriginRef.current,
|
||||
bindings: customKeyBindings,
|
||||
});
|
||||
}, [customKeyBindings, notifySettingsChanged]);
|
||||
|
||||
const setIsHotkeyRecording = useCallback((isRecording: boolean) => {
|
||||
@@ -1170,37 +1271,18 @@ export const useSettingsState = () => {
|
||||
|
||||
// Update a single key binding
|
||||
const updateKeyBinding = useCallback((bindingId: string, scheme: 'mac' | 'pc', newKey: string) => {
|
||||
setCustomKeyBindings(prev => ({
|
||||
...prev,
|
||||
[bindingId]: {
|
||||
...prev[bindingId],
|
||||
[scheme]: newKey,
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
setCustomKeyBindings(prev => updateCustomKeyBindingRecord(prev, bindingId, scheme, newKey));
|
||||
}, [setCustomKeyBindings]);
|
||||
|
||||
// Reset a key binding to default
|
||||
const resetKeyBinding = useCallback((bindingId: string, scheme?: 'mac' | 'pc') => {
|
||||
setCustomKeyBindings(prev => {
|
||||
const next = { ...prev };
|
||||
if (scheme) {
|
||||
if (next[bindingId]) {
|
||||
delete next[bindingId][scheme];
|
||||
if (Object.keys(next[bindingId]).length === 0) {
|
||||
delete next[bindingId];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
delete next[bindingId];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
setCustomKeyBindings(prev => resetCustomKeyBinding(prev, bindingId, scheme));
|
||||
}, [setCustomKeyBindings]);
|
||||
|
||||
// Reset all key bindings to defaults
|
||||
const resetAllKeyBindings = useCallback(() => {
|
||||
setCustomKeyBindings({});
|
||||
}, []);
|
||||
}, [setCustomKeyBindings]);
|
||||
|
||||
const updateSyncConfig = useCallback((config: SyncConfig | null) => {
|
||||
setSyncConfig(config);
|
||||
@@ -1211,6 +1293,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) {
|
||||
@@ -1218,13 +1301,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,
|
||||
|
||||
@@ -174,6 +174,7 @@ export const useSftpState = (
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
terminalSettings: options?.terminalSettings,
|
||||
leftTabsRef,
|
||||
rightTabsRef,
|
||||
leftTabs,
|
||||
@@ -271,7 +272,7 @@ export const useSftpState = (
|
||||
|
||||
const {
|
||||
transfers,
|
||||
conflicts,
|
||||
conflicts: transferConflicts,
|
||||
activeTransfersCount,
|
||||
startTransfer,
|
||||
downloadToLocal,
|
||||
@@ -282,7 +283,7 @@ export const useSftpState = (
|
||||
retryTransfer,
|
||||
clearCompletedTransfers,
|
||||
dismissTransfer,
|
||||
resolveConflict,
|
||||
resolveConflict: resolveTransferConflict,
|
||||
} = useSftpTransfers({
|
||||
getActivePane,
|
||||
getPaneByConnectionId,
|
||||
@@ -301,14 +302,20 @@ export const useSftpState = (
|
||||
readTextFile,
|
||||
readBinaryFile,
|
||||
writeTextFile,
|
||||
writeTextFileByConnection,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
uploadExternalFileList,
|
||||
uploadExternalFolderPath,
|
||||
uploadExternalEntries,
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
activeFileWatchCountRef,
|
||||
uploadConflicts,
|
||||
resolveUploadConflict,
|
||||
} = useSftpExternalOperations({
|
||||
getActivePane,
|
||||
getPaneByConnectionId,
|
||||
refresh,
|
||||
sftpSessionsRef,
|
||||
connectionCacheKeyMapRef,
|
||||
@@ -320,6 +327,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({
|
||||
@@ -359,8 +381,11 @@ export const useSftpState = (
|
||||
readTextFile,
|
||||
readBinaryFile,
|
||||
writeTextFile,
|
||||
writeTextFileByConnection,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
uploadExternalFileList,
|
||||
uploadExternalFolderPath,
|
||||
uploadExternalEntries,
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
@@ -372,7 +397,7 @@ export const useSftpState = (
|
||||
retryTransfer,
|
||||
clearCompletedTransfers,
|
||||
dismissTransfer,
|
||||
resolveConflict,
|
||||
resolveConflict: resolveAnyConflict,
|
||||
getSftpIdForConnection,
|
||||
reportSessionError: handleSessionError,
|
||||
});
|
||||
@@ -413,8 +438,11 @@ export const useSftpState = (
|
||||
readTextFile,
|
||||
readBinaryFile,
|
||||
writeTextFile,
|
||||
writeTextFileByConnection,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
uploadExternalFileList,
|
||||
uploadExternalFolderPath,
|
||||
uploadExternalEntries,
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
@@ -426,7 +454,7 @@ export const useSftpState = (
|
||||
retryTransfer,
|
||||
clearCompletedTransfers,
|
||||
dismissTransfer,
|
||||
resolveConflict,
|
||||
resolveConflict: resolveAnyConflict,
|
||||
getSftpIdForConnection,
|
||||
reportSessionError: handleSessionError,
|
||||
};
|
||||
@@ -476,8 +504,14 @@ export const useSftpState = (
|
||||
readTextFile: (...args: Parameters<typeof readTextFile>) => methodsRef.current.readTextFile(...args),
|
||||
readBinaryFile: (...args: Parameters<typeof readBinaryFile>) => methodsRef.current.readBinaryFile(...args),
|
||||
writeTextFile: (...args: Parameters<typeof writeTextFile>) => methodsRef.current.writeTextFile(...args),
|
||||
writeTextFileByConnection: (...args: Parameters<typeof writeTextFileByConnection>) =>
|
||||
methodsRef.current.writeTextFileByConnection(...args),
|
||||
downloadToTempAndOpen: (...args: Parameters<typeof downloadToTempAndOpen>) => methodsRef.current.downloadToTempAndOpen(...args),
|
||||
uploadExternalFiles: (...args: Parameters<typeof uploadExternalFiles>) => methodsRef.current.uploadExternalFiles(...args),
|
||||
uploadExternalFileList: (...args: Parameters<typeof uploadExternalFileList>) =>
|
||||
methodsRef.current.uploadExternalFileList(...args),
|
||||
uploadExternalFolderPath: (...args: Parameters<typeof uploadExternalFolderPath>) =>
|
||||
methodsRef.current.uploadExternalFolderPath(...args),
|
||||
uploadExternalEntries: (...args: Parameters<typeof uploadExternalEntries>) =>
|
||||
methodsRef.current.uploadExternalEntries(...args),
|
||||
cancelExternalUpload: () => methodsRef.current.cancelExternalUpload(),
|
||||
@@ -490,7 +524,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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
|
||||
export const useTerminalBackend = () => {
|
||||
@@ -63,9 +63,9 @@ export const useTerminalBackend = () => {
|
||||
return bridge.execCommand(options);
|
||||
}, []);
|
||||
|
||||
const writeToSession = useCallback((sessionId: string, data: string) => {
|
||||
const writeToSession = useCallback((sessionId: string, data: string, options?: { automated?: boolean }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.writeToSession?.(sessionId, data);
|
||||
bridge?.writeToSession?.(sessionId, data, options);
|
||||
}, []);
|
||||
|
||||
const resizeSession = useCallback((sessionId: string, cols: number, rows: number) => {
|
||||
@@ -96,11 +96,38 @@ export const useTerminalBackend = () => {
|
||||
return bridge.onSessionExit(sessionId, cb);
|
||||
}, []);
|
||||
|
||||
const onTelnetAutoLoginComplete = useCallback((sessionId: string, cb: (evt: { sessionId: string }) => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onTelnetAutoLoginComplete?.(sessionId, cb);
|
||||
}, []);
|
||||
|
||||
const onTelnetAutoLoginCancelled = useCallback((sessionId: string, cb: (evt: { sessionId: string }) => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onTelnetAutoLoginCancelled?.(sessionId, cb);
|
||||
}, []);
|
||||
|
||||
const onChainProgress = useCallback((cb: (sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onChainProgress?.(cb);
|
||||
}, []);
|
||||
|
||||
const onHostKeyVerification = useCallback((cb: Parameters<NonNullable<NetcattyBridge["onHostKeyVerification"]>>[0]) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onHostKeyVerification?.(cb);
|
||||
}, []);
|
||||
|
||||
const respondHostKeyVerification = useCallback(async (
|
||||
requestId: string,
|
||||
accept: boolean,
|
||||
addToKnownHosts?: boolean,
|
||||
) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.respondHostKeyVerification) {
|
||||
return { success: false, error: "respondHostKeyVerification unavailable" };
|
||||
}
|
||||
return bridge.respondHostKeyVerification(requestId, accept, addToKnownHosts);
|
||||
}, []);
|
||||
|
||||
const openExternal = useCallback(async (url: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.openExternal?.(url);
|
||||
@@ -150,32 +177,79 @@ export const useTerminalBackend = () => {
|
||||
return bridge.getServerStats(sessionId);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
backendAvailable,
|
||||
telnetAvailable,
|
||||
moshAvailable,
|
||||
localAvailable,
|
||||
serialAvailable,
|
||||
execAvailable,
|
||||
openExternalAvailable,
|
||||
startSSHSession,
|
||||
startTelnetSession,
|
||||
startMoshSession,
|
||||
startLocalSession,
|
||||
startSerialSession,
|
||||
listSerialPorts,
|
||||
execCommand,
|
||||
getSessionPwd,
|
||||
getSessionRemoteInfo,
|
||||
getSessionDistroInfo,
|
||||
getServerStats,
|
||||
writeToSession,
|
||||
resizeSession,
|
||||
closeSession,
|
||||
setSessionEncoding,
|
||||
onSessionData,
|
||||
onSessionExit,
|
||||
onChainProgress,
|
||||
openExternal,
|
||||
};
|
||||
// Memoize the returned object so its identity is stable across the
|
||||
// hook's lifetime. Each method above is already useCallback([])-stable,
|
||||
// so listing them as deps means useMemo recomputes once and then
|
||||
// caches forever. Without this, every render produced a fresh object
|
||||
// literal — making `terminalBackend` an unstable reference that
|
||||
// forced consumers' useEffects (`}, [..., terminalBackend])`) to
|
||||
// rerun on every parent render and forced lint to flag any deeper
|
||||
// property dep (`}, [terminalBackend.onHostKeyVerification])`) it
|
||||
// couldn't statically prove safe.
|
||||
return useMemo(
|
||||
() => ({
|
||||
backendAvailable,
|
||||
telnetAvailable,
|
||||
moshAvailable,
|
||||
localAvailable,
|
||||
serialAvailable,
|
||||
execAvailable,
|
||||
openExternalAvailable,
|
||||
startSSHSession,
|
||||
startTelnetSession,
|
||||
startMoshSession,
|
||||
startLocalSession,
|
||||
startSerialSession,
|
||||
listSerialPorts,
|
||||
execCommand,
|
||||
getSessionPwd,
|
||||
getSessionRemoteInfo,
|
||||
getSessionDistroInfo,
|
||||
getServerStats,
|
||||
writeToSession,
|
||||
resizeSession,
|
||||
closeSession,
|
||||
setSessionEncoding,
|
||||
onSessionData,
|
||||
onSessionExit,
|
||||
onTelnetAutoLoginComplete,
|
||||
onTelnetAutoLoginCancelled,
|
||||
onChainProgress,
|
||||
onHostKeyVerification,
|
||||
respondHostKeyVerification,
|
||||
openExternal,
|
||||
}),
|
||||
[
|
||||
backendAvailable,
|
||||
telnetAvailable,
|
||||
moshAvailable,
|
||||
localAvailable,
|
||||
serialAvailable,
|
||||
execAvailable,
|
||||
openExternalAvailable,
|
||||
startSSHSession,
|
||||
startTelnetSession,
|
||||
startMoshSession,
|
||||
startLocalSession,
|
||||
startSerialSession,
|
||||
listSerialPorts,
|
||||
execCommand,
|
||||
getSessionPwd,
|
||||
getSessionRemoteInfo,
|
||||
getSessionDistroInfo,
|
||||
getServerStats,
|
||||
writeToSession,
|
||||
resizeSession,
|
||||
closeSession,
|
||||
setSessionEncoding,
|
||||
onSessionData,
|
||||
onSessionExit,
|
||||
onTelnetAutoLoginComplete,
|
||||
onTelnetAutoLoginCancelled,
|
||||
onChainProgress,
|
||||
onHostKeyVerification,
|
||||
respondHostKeyVerification,
|
||||
openExternal,
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { normalizeDistroId, sanitizeHost } from "../../domain/host";
|
||||
import { sanitizeGroupConfig } from "../../domain/groupConfig";
|
||||
import {
|
||||
ConnectionLog,
|
||||
GroupConfig,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
KeyCategory,
|
||||
KnownHost,
|
||||
ManagedSource,
|
||||
ProxyProfile,
|
||||
ShellHistoryEntry,
|
||||
Snippet,
|
||||
SSHKey,
|
||||
@@ -26,6 +28,7 @@ import {
|
||||
STORAGE_KEY_KNOWN_HOSTS,
|
||||
STORAGE_KEY_LEGACY_KEYS,
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
STORAGE_KEY_PROXY_PROFILES,
|
||||
STORAGE_KEY_SHELL_HISTORY,
|
||||
STORAGE_KEY_SNIPPET_PACKAGES,
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
@@ -36,16 +39,19 @@ import {
|
||||
decryptHosts,
|
||||
decryptIdentities,
|
||||
decryptKeys,
|
||||
decryptProxyProfiles,
|
||||
encryptGroupConfigs,
|
||||
encryptHosts,
|
||||
encryptIdentities,
|
||||
encryptKeys,
|
||||
encryptProxyProfiles,
|
||||
} from "../../infrastructure/persistence/secureFieldAdapter";
|
||||
|
||||
type ExportableVaultData = {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities?: Identity[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
snippets: Snippet[];
|
||||
customGroups: string[];
|
||||
snippetPackages?: string[];
|
||||
@@ -61,7 +67,7 @@ const migrateKey = (key: Partial<SSHKey>): SSHKey => {
|
||||
const label = key.label ?? `Key ${id.slice(0, 8)}`;
|
||||
|
||||
const source =
|
||||
key.source === "generated" || key.source === "imported"
|
||||
key.source === "generated" || key.source === "imported" || key.source === "reference"
|
||||
? key.source
|
||||
: key.privateKey
|
||||
? "imported"
|
||||
@@ -81,6 +87,7 @@ const migrateKey = (key: Partial<SSHKey>): SSHKey => {
|
||||
key.category ||
|
||||
((key.certificate ? "certificate" : "key") as KeyCategory),
|
||||
created: key.created || Date.now(),
|
||||
filePath: key.filePath,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -106,6 +113,7 @@ export const useVaultState = () => {
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
||||
const [identities, setIdentities] = useState<Identity[]>([]);
|
||||
const [proxyProfiles, setProxyProfiles] = useState<ProxyProfile[]>([]);
|
||||
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
||||
const [customGroups, setCustomGroups] = useState<string[]>([]);
|
||||
const [snippetPackages, setSnippetPackages] = useState<string[]>([]);
|
||||
@@ -121,6 +129,7 @@ export const useVaultState = () => {
|
||||
const hostsWriteVersion = useRef(0);
|
||||
const keysWriteVersion = useRef(0);
|
||||
const identitiesWriteVersion = useRef(0);
|
||||
const proxyProfilesWriteVersion = useRef(0);
|
||||
const groupConfigsWriteVersion = useRef(0);
|
||||
|
||||
// Read-sequence counters for cross-window storage events. Each incoming
|
||||
@@ -130,13 +139,14 @@ export const useVaultState = () => {
|
||||
const hostsReadSeq = useRef(0);
|
||||
const keysReadSeq = useRef(0);
|
||||
const identitiesReadSeq = useRef(0);
|
||||
const proxyProfilesReadSeq = useRef(0);
|
||||
const groupConfigsReadSeq = useRef(0);
|
||||
|
||||
const updateHosts = useCallback((data: Host[]) => {
|
||||
const cleaned = data.map(sanitizeHost);
|
||||
setHosts(cleaned);
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
encryptHosts(cleaned).then((enc) => {
|
||||
return encryptHosts(cleaned).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
@@ -145,21 +155,66 @@ export const useVaultState = () => {
|
||||
const updateKeys = useCallback((data: SSHKey[]) => {
|
||||
setKeys(data);
|
||||
const ver = ++keysWriteVersion.current;
|
||||
encryptKeys(data).then((enc) => {
|
||||
return encryptKeys(data).then((enc) => {
|
||||
if (ver === keysWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const importOrReuseKey = useCallback((draft: Partial<SSHKey>): SSHKey => {
|
||||
const existing = keys.find((k) => {
|
||||
if (draft.source === 'reference' && draft.filePath) {
|
||||
return k.source === 'reference' && k.filePath === draft.filePath;
|
||||
}
|
||||
if (draft.privateKey) {
|
||||
return k.privateKey === draft.privateKey;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
const newKey: SSHKey = {
|
||||
id: crypto.randomUUID(),
|
||||
label: draft.label || 'Imported Key',
|
||||
type: draft.type || 'ED25519',
|
||||
privateKey: draft.privateKey || '',
|
||||
publicKey: draft.publicKey,
|
||||
certificate: draft.certificate,
|
||||
passphrase: draft.passphrase,
|
||||
savePassphrase: draft.savePassphrase,
|
||||
source: draft.source || 'imported',
|
||||
category: (draft.category || 'key') as KeyCategory,
|
||||
created: Date.now(),
|
||||
filePath: draft.filePath,
|
||||
};
|
||||
const updated = [...keys, newKey];
|
||||
setKeys(updated);
|
||||
const ver = ++keysWriteVersion.current;
|
||||
void encryptKeys(updated).then((enc) => {
|
||||
if (ver === keysWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
|
||||
});
|
||||
return newKey;
|
||||
}, [keys]);
|
||||
|
||||
const updateIdentities = useCallback((data: Identity[]) => {
|
||||
setIdentities(data);
|
||||
const ver = ++identitiesWriteVersion.current;
|
||||
encryptIdentities(data).then((enc) => {
|
||||
return encryptIdentities(data).then((enc) => {
|
||||
if (ver === identitiesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateProxyProfiles = useCallback((data: ProxyProfile[]) => {
|
||||
setProxyProfiles(data);
|
||||
const ver = ++proxyProfilesWriteVersion.current;
|
||||
return encryptProxyProfiles(data).then((enc) => {
|
||||
if (ver === proxyProfilesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_PROXY_PROFILES, enc);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateSnippets = useCallback((data: Snippet[]) => {
|
||||
setSnippets(data);
|
||||
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, data);
|
||||
@@ -186,9 +241,15 @@ export const useVaultState = () => {
|
||||
}, []);
|
||||
|
||||
const updateGroupConfigs = useCallback((data: GroupConfig[]) => {
|
||||
setGroupConfigs(data);
|
||||
// Sanitize on the write path too — applySyncPayload / importVaultData
|
||||
// route legacy payloads through here, and without this step a saved
|
||||
// pingfang-sc / comic-sans-ms override from an older client would
|
||||
// sit in memory and re-persist with `fontFamilyOverride: true` until
|
||||
// the next reload. Mirrors updateHosts → sanitizeHost.
|
||||
const cleaned = data.map(sanitizeGroupConfig);
|
||||
setGroupConfigs(cleaned);
|
||||
const ver = ++groupConfigsWriteVersion.current;
|
||||
encryptGroupConfigs(data).then((enc) => {
|
||||
return encryptGroupConfigs(cleaned).then((enc) => {
|
||||
if (ver === groupConfigsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
|
||||
});
|
||||
@@ -198,6 +259,7 @@ export const useVaultState = () => {
|
||||
updateHosts([]);
|
||||
updateKeys([]);
|
||||
updateIdentities([]);
|
||||
updateProxyProfiles([]);
|
||||
updateSnippets([]);
|
||||
updateSnippetPackages([]);
|
||||
updateCustomGroups([]);
|
||||
@@ -209,6 +271,7 @@ export const useVaultState = () => {
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
updateIdentities,
|
||||
updateProxyProfiles,
|
||||
updateSnippets,
|
||||
updateSnippetPackages,
|
||||
updateCustomGroups,
|
||||
@@ -414,6 +477,20 @@ export const useVaultState = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const savedProxyProfiles =
|
||||
localStorageAdapter.read<ProxyProfile[]>(STORAGE_KEY_PROXY_PROFILES);
|
||||
if (savedProxyProfiles) {
|
||||
const proxyVer = ++proxyProfilesWriteVersion.current;
|
||||
const decryptedProfiles = await decryptProxyProfiles(savedProxyProfiles);
|
||||
if (proxyVer === proxyProfilesWriteVersion.current) {
|
||||
setProxyProfiles(decryptedProfiles);
|
||||
encryptProxyProfiles(decryptedProfiles).then((enc) => {
|
||||
if (proxyVer === proxyProfilesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_PROXY_PROFILES, enc);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Read remaining non-encrypted data fresh after all async gaps above
|
||||
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
|
||||
const savedSnippets =
|
||||
@@ -458,8 +535,9 @@ export const useVaultState = () => {
|
||||
const gcVer = ++groupConfigsWriteVersion.current;
|
||||
const decryptedGC = await decryptGroupConfigs(savedGroupConfigs);
|
||||
if (gcVer === groupConfigsWriteVersion.current) {
|
||||
setGroupConfigs(decryptedGC);
|
||||
encryptGroupConfigs(decryptedGC).then((enc) => {
|
||||
const sanitizedGC = decryptedGC.map(sanitizeGroupConfig);
|
||||
setGroupConfigs(sanitizedGC);
|
||||
encryptGroupConfigs(sanitizedGC).then((enc) => {
|
||||
if (gcVer === groupConfigsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
|
||||
});
|
||||
@@ -528,6 +606,18 @@ export const useVaultState = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_PROXY_PROFILES) {
|
||||
const next = safeParse<ProxyProfile[]>(event.newValue) ?? [];
|
||||
++proxyProfilesWriteVersion.current;
|
||||
const seq = ++proxyProfilesReadSeq.current;
|
||||
const writeAtStart = proxyProfilesWriteVersion.current;
|
||||
decryptProxyProfiles(next).then((dec) => {
|
||||
if (seq === proxyProfilesReadSeq.current && writeAtStart === proxyProfilesWriteVersion.current)
|
||||
setProxyProfiles(dec);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_SNIPPETS) {
|
||||
const next = safeParse<Snippet[]>(event.newValue) ?? [];
|
||||
setSnippets(next);
|
||||
@@ -577,7 +667,7 @@ export const useVaultState = () => {
|
||||
const writeAtStart = groupConfigsWriteVersion.current;
|
||||
decryptGroupConfigs(next).then((dec) => {
|
||||
if (seq === groupConfigsReadSeq.current && writeAtStart === groupConfigsWriteVersion.current)
|
||||
setGroupConfigs(dec);
|
||||
setGroupConfigs(dec.map(sanitizeGroupConfig));
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -621,30 +711,35 @@ export const useVaultState = () => {
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
knownHosts,
|
||||
groupConfigs,
|
||||
}),
|
||||
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts, groupConfigs],
|
||||
[hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, knownHosts, groupConfigs],
|
||||
);
|
||||
|
||||
const importData = useCallback(
|
||||
(payload: Partial<ExportableVaultData>) => {
|
||||
if (payload.hosts) updateHosts(payload.hosts);
|
||||
if (payload.keys) updateKeys(payload.keys);
|
||||
if (payload.identities) updateIdentities(payload.identities);
|
||||
(payload: Partial<ExportableVaultData>): Promise<void> => {
|
||||
const encryptedWrites: Promise<void>[] = [];
|
||||
if (payload.hosts) encryptedWrites.push(updateHosts(payload.hosts));
|
||||
if (payload.keys) encryptedWrites.push(updateKeys(payload.keys));
|
||||
if (payload.identities) encryptedWrites.push(updateIdentities(payload.identities));
|
||||
if (Array.isArray(payload.proxyProfiles)) encryptedWrites.push(updateProxyProfiles(payload.proxyProfiles));
|
||||
if (payload.snippets) updateSnippets(payload.snippets);
|
||||
if (payload.customGroups) updateCustomGroups(payload.customGroups);
|
||||
if (payload.snippetPackages) updateSnippetPackages(payload.snippetPackages);
|
||||
if (payload.knownHosts) updateKnownHosts(payload.knownHosts);
|
||||
if (Array.isArray(payload.groupConfigs)) updateGroupConfigs(payload.groupConfigs);
|
||||
if (Array.isArray(payload.groupConfigs)) encryptedWrites.push(updateGroupConfigs(payload.groupConfigs));
|
||||
return Promise.all(encryptedWrites).then(() => undefined);
|
||||
},
|
||||
[
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
updateIdentities,
|
||||
updateProxyProfiles,
|
||||
updateSnippets,
|
||||
updateCustomGroups,
|
||||
updateSnippetPackages,
|
||||
@@ -654,9 +749,9 @@ export const useVaultState = () => {
|
||||
);
|
||||
|
||||
const importDataFromString = useCallback(
|
||||
(jsonString: string) => {
|
||||
(jsonString: string): Promise<void> => {
|
||||
const data = JSON.parse(jsonString);
|
||||
importData(data);
|
||||
return importData(data);
|
||||
},
|
||||
[importData],
|
||||
);
|
||||
@@ -666,6 +761,7 @@ export const useVaultState = () => {
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
@@ -676,7 +772,9 @@ export const useVaultState = () => {
|
||||
groupConfigs,
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
importOrReuseKey,
|
||||
updateIdentities,
|
||||
updateProxyProfiles,
|
||||
updateSnippets,
|
||||
updateSnippetPackages,
|
||||
updateCustomGroups,
|
||||
|
||||
25
application/state/windowInputFocus.ts
Normal file
25
application/state/windowInputFocus.ts
Normal 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);
|
||||
});
|
||||
};
|
||||
653
application/syncPayload.test.ts
Normal file
653
application/syncPayload.test.ts
Normal file
@@ -0,0 +1,653 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { SyncPayload } from "../domain/sync.ts";
|
||||
import type { KnownHost } from "../domain/models.ts";
|
||||
import type { SyncableVaultData } from "./syncPayload.ts";
|
||||
|
||||
type LocalStorageMock = {
|
||||
clear(): void;
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
};
|
||||
|
||||
function installLocalStorage(): LocalStorageMock {
|
||||
const store = new Map<string, string>();
|
||||
const localStorage: LocalStorageMock = {
|
||||
clear() {
|
||||
store.clear();
|
||||
},
|
||||
getItem(key: string) {
|
||||
return store.has(key) ? store.get(key)! : null;
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store.set(key, String(value));
|
||||
},
|
||||
removeItem(key: string) {
|
||||
store.delete(key);
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: localStorage,
|
||||
configurable: true,
|
||||
});
|
||||
return localStorage;
|
||||
}
|
||||
|
||||
const localStorage = installLocalStorage();
|
||||
const {
|
||||
applyLocalVaultPayload,
|
||||
applySyncPayload,
|
||||
buildLocalVaultPayload,
|
||||
buildSyncPayload,
|
||||
hasMeaningfulCloudSyncData,
|
||||
} = await import("./syncPayload.ts");
|
||||
const storageKeys = await import("../infrastructure/config/storageKeys.ts");
|
||||
|
||||
const knownHost = (id = "kh-1"): KnownHost => ({
|
||||
id,
|
||||
hostname: `${id}.example.com`,
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
publicKey: `SHA256:${id}`,
|
||||
discoveredAt: 1,
|
||||
});
|
||||
|
||||
const vault = (knownHosts: KnownHost[] = [knownHost()]): SyncableVaultData => ({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
snippetPackages: [],
|
||||
knownHosts,
|
||||
groupConfigs: [],
|
||||
});
|
||||
|
||||
test.beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
test("buildSyncPayload treats known hosts as local-only data", () => {
|
||||
const payload = buildSyncPayload(vault([knownHost("kh-cloud")]));
|
||||
|
||||
assert.equal("knownHosts" in payload, false);
|
||||
});
|
||||
|
||||
test("buildSyncPayload includes reusable proxy profiles", () => {
|
||||
const proxyProfiles = [
|
||||
{
|
||||
id: "proxy-1",
|
||||
label: "Office Proxy",
|
||||
config: { type: "socks5", host: "proxy.example.com", port: 1080 },
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const payload = buildSyncPayload({
|
||||
...vault(),
|
||||
proxyProfiles,
|
||||
} as SyncableVaultData & { proxyProfiles: typeof proxyProfiles });
|
||||
|
||||
assert.deepEqual(payload.proxyProfiles, proxyProfiles);
|
||||
});
|
||||
|
||||
test("buildSyncPayload includes AI configuration settings", () => {
|
||||
const providers = [{
|
||||
id: "openai-main",
|
||||
providerId: "openai",
|
||||
name: "OpenAI",
|
||||
apiKey: "enc:v1:test",
|
||||
defaultModel: "gpt-test",
|
||||
enabled: true,
|
||||
}];
|
||||
const webSearch = {
|
||||
providerId: "tavily",
|
||||
apiKey: "enc:v1:web",
|
||||
enabled: true,
|
||||
maxResults: 7,
|
||||
};
|
||||
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify(providers));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_ACTIVE_PROVIDER, "openai-main");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_ACTIVE_MODEL, "gpt-test");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PERMISSION_MODE, "autonomous");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_TOOL_INTEGRATION_MODE, "skills");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_DEFAULT_AGENT, "codex");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST, JSON.stringify(["rm -rf"]));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_COMMAND_TIMEOUT, "120");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_MAX_ITERATIONS, "10");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({ codex: "gpt-test" }));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify(webSearch));
|
||||
|
||||
const payload = buildSyncPayload(vault([]));
|
||||
|
||||
assert.deepEqual(payload.settings?.ai, {
|
||||
providers,
|
||||
activeProviderId: "openai-main",
|
||||
activeModelId: "gpt-test",
|
||||
globalPermissionMode: "autonomous",
|
||||
toolIntegrationMode: "skills",
|
||||
defaultAgentId: "codex",
|
||||
commandBlocklist: ["rm -rf"],
|
||||
commandTimeout: 120,
|
||||
maxIterations: 10,
|
||||
agentModelMap: { codex: "gpt-test" },
|
||||
webSearchConfig: webSearch,
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSyncPayload excludes externalAgents (device-local OS-bound config)", () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_AGENTS, JSON.stringify([
|
||||
{ id: "codex", name: "Codex", command: "/opt/homebrew/bin/codex", enabled: true },
|
||||
]));
|
||||
|
||||
const payload = buildSyncPayload(vault([]));
|
||||
|
||||
assert.equal("ai" in (payload.settings ?? {}), false);
|
||||
});
|
||||
|
||||
test("buildSyncPayload omits device-bound encrypted AI API keys", () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{
|
||||
id: "openai-main",
|
||||
providerId: "openai",
|
||||
name: "OpenAI",
|
||||
apiKey: "enc:v1:djEwAAAA",
|
||||
enabled: true,
|
||||
}]));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
||||
providerId: "tavily",
|
||||
apiKey: "enc:v1:djEwAAAA",
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
const payload = buildSyncPayload(vault([]));
|
||||
|
||||
assert.equal("apiKey" in (payload.settings?.ai?.providers?.[0] ?? {}), false);
|
||||
assert.equal("apiKey" in (payload.settings?.ai?.webSearchConfig ?? {}), false);
|
||||
});
|
||||
|
||||
test("applySyncPayload restores AI configuration settings", async () => {
|
||||
const providers = [{
|
||||
id: "anthropic-main",
|
||||
providerId: "anthropic",
|
||||
name: "Anthropic",
|
||||
apiKey: "enc:v1:test",
|
||||
enabled: true,
|
||||
}];
|
||||
const webSearch = {
|
||||
providerId: "exa",
|
||||
apiKey: "enc:v1:web",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
settings: {
|
||||
ai: {
|
||||
providers,
|
||||
activeProviderId: "anthropic-main",
|
||||
activeModelId: "claude-test",
|
||||
globalPermissionMode: "observer",
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultAgentId: "claude",
|
||||
commandBlocklist: ["shutdown"],
|
||||
commandTimeout: 30,
|
||||
maxIterations: 5,
|
||||
agentModelMap: { claude: "claude-test" },
|
||||
webSearchConfig: webSearch,
|
||||
},
|
||||
},
|
||||
syncedAt: 1,
|
||||
} as SyncPayload;
|
||||
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!), providers);
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_ACTIVE_PROVIDER), "anthropic-main");
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_ACTIVE_MODEL), "claude-test");
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PERMISSION_MODE), "observer");
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_TOOL_INTEGRATION_MODE), "mcp");
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_DEFAULT_AGENT), "claude");
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST)!), ["shutdown"]);
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_TIMEOUT), "30");
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_MAX_ITERATIONS), "5");
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP)!), { claude: "claude-test" });
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!), webSearch);
|
||||
});
|
||||
|
||||
test("applySyncPayload preserves local externalAgents and ignores legacy payload field", async () => {
|
||||
const localAgents = [
|
||||
{ id: "codex", name: "Codex", command: "/usr/local/bin/codex", enabled: true },
|
||||
];
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_AGENTS, JSON.stringify(localAgents));
|
||||
|
||||
const payload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
settings: {
|
||||
ai: {
|
||||
// Legacy snapshot still carries externalAgents; current code must ignore it.
|
||||
externalAgents: [
|
||||
{ id: "claude", name: "Claude", command: "C:\\Tools\\claude.exe", enabled: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
syncedAt: 1,
|
||||
} as unknown as SyncPayload;
|
||||
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
|
||||
assert.deepEqual(
|
||||
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_AGENTS)!),
|
||||
localAgents,
|
||||
);
|
||||
});
|
||||
|
||||
test("applySyncPayload preserves local AI provider apiKeys when synced payload omits them", async () => {
|
||||
const localProviders = [
|
||||
{
|
||||
id: "openai-main",
|
||||
providerId: "openai",
|
||||
name: "OpenAI",
|
||||
apiKey: "enc:v1:djEwLOCAL",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "anthropic-main",
|
||||
providerId: "anthropic",
|
||||
name: "Anthropic",
|
||||
apiKey: "enc:v1:djEwANTHROPIC",
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify(localProviders));
|
||||
|
||||
// Synced payload mirrors what `collectSyncableSettings` produces on another device:
|
||||
// metadata is preserved but encrypted device-bound apiKeys are stripped.
|
||||
const syncedProviders = [
|
||||
{ id: "openai-main", providerId: "openai", name: "OpenAI (renamed)", enabled: true },
|
||||
{ id: "anthropic-main", providerId: "anthropic", name: "Anthropic", enabled: false },
|
||||
];
|
||||
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
settings: { ai: { providers: syncedProviders } },
|
||||
syncedAt: 1,
|
||||
} as SyncPayload;
|
||||
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
|
||||
const stored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!);
|
||||
assert.deepEqual(stored, [
|
||||
{
|
||||
id: "openai-main",
|
||||
providerId: "openai",
|
||||
name: "OpenAI (renamed)",
|
||||
apiKey: "enc:v1:djEwLOCAL",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "anthropic-main",
|
||||
providerId: "anthropic",
|
||||
name: "Anthropic",
|
||||
apiKey: "enc:v1:djEwANTHROPIC",
|
||||
enabled: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("applySyncPayload prefers explicit synced apiKey over local apiKey", async () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([
|
||||
{ id: "openai-main", providerId: "openai", name: "OpenAI", apiKey: "enc:v1:djEwLOCAL", enabled: true },
|
||||
]));
|
||||
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
settings: {
|
||||
ai: {
|
||||
providers: [
|
||||
{ id: "openai-main", providerId: "openai", name: "OpenAI", apiKey: "plaintext-from-other-device", enabled: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
syncedAt: 1,
|
||||
} as SyncPayload;
|
||||
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
|
||||
const stored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!);
|
||||
assert.equal(stored[0].apiKey, "plaintext-from-other-device");
|
||||
});
|
||||
|
||||
test("applySyncPayload preserves local web-search apiKey when synced config omits it", async () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
||||
providerId: "tavily",
|
||||
apiKey: "enc:v1:djEwWEB",
|
||||
enabled: true,
|
||||
maxResults: 7,
|
||||
}));
|
||||
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
settings: {
|
||||
ai: {
|
||||
webSearchConfig: { providerId: "tavily", enabled: false, maxResults: 12 },
|
||||
},
|
||||
},
|
||||
syncedAt: 1,
|
||||
} as SyncPayload;
|
||||
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
|
||||
const stored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!);
|
||||
assert.deepEqual(stored, {
|
||||
providerId: "tavily",
|
||||
apiKey: "enc:v1:djEwWEB",
|
||||
enabled: false,
|
||||
maxResults: 12,
|
||||
});
|
||||
});
|
||||
|
||||
test("applySyncPayload drops local web-search apiKey when synced config switches provider", async () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
||||
providerId: "tavily",
|
||||
apiKey: "enc:v1:djEwWEB",
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
settings: {
|
||||
ai: {
|
||||
webSearchConfig: { providerId: "exa", enabled: true },
|
||||
},
|
||||
},
|
||||
syncedAt: 1,
|
||||
} as SyncPayload;
|
||||
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
|
||||
const stored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!);
|
||||
assert.equal("apiKey" in stored, false);
|
||||
assert.equal(stored.providerId, "exa");
|
||||
});
|
||||
|
||||
test("buildSyncPayload includes syncable terminal options from settings", () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_FOLLOW_APP_THEME, "true");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_SETTINGS, JSON.stringify({
|
||||
terminalEmulationType: "vt100",
|
||||
altAsMeta: true,
|
||||
showServerStats: false,
|
||||
serverStatsRefreshInterval: 12,
|
||||
rendererType: "dom",
|
||||
localShell: "/bin/zsh",
|
||||
}));
|
||||
|
||||
const payload = buildSyncPayload(vault([]));
|
||||
|
||||
assert.equal(payload.settings?.followAppTerminalTheme, true);
|
||||
assert.deepEqual(payload.settings?.terminalSettings, {
|
||||
terminalEmulationType: "vt100",
|
||||
altAsMeta: true,
|
||||
showServerStats: false,
|
||||
serverStatsRefreshInterval: 12,
|
||||
rendererType: "dom",
|
||||
});
|
||||
});
|
||||
|
||||
test("hasMeaningfulCloudSyncData ignores legacy cloud known hosts", () => {
|
||||
assert.equal(
|
||||
hasMeaningfulCloudSyncData({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
knownHosts: [knownHost("kh-only")],
|
||||
syncedAt: 1,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildLocalVaultPayload preserves known hosts for local backups", () => {
|
||||
const payload = buildLocalVaultPayload(vault([knownHost("kh-local")]));
|
||||
|
||||
assert.deepEqual(payload.knownHosts, [knownHost("kh-local")]);
|
||||
});
|
||||
|
||||
test("applySyncPayload ignores legacy cloud known hosts", async () => {
|
||||
let imported: Record<string, unknown> | null = null;
|
||||
const proxyProfiles = [
|
||||
{
|
||||
id: "proxy-1",
|
||||
label: "Office Proxy",
|
||||
config: { type: "socks5", host: "proxy.example.com", port: 1080 },
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
];
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
knownHosts: [knownHost("kh-legacy")],
|
||||
proxyProfiles,
|
||||
syncedAt: 1,
|
||||
} as SyncPayload & { proxyProfiles: typeof proxyProfiles };
|
||||
|
||||
await applySyncPayload(payload, {
|
||||
importVaultData: (json) => {
|
||||
imported = JSON.parse(json);
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(imported);
|
||||
assert.equal("knownHosts" in imported, false);
|
||||
assert.deepEqual(imported.proxyProfiles, proxyProfiles);
|
||||
});
|
||||
|
||||
test("applySyncPayload keeps missing proxy references visible to connection guards", async () => {
|
||||
let imported: Record<string, unknown> | null = null;
|
||||
const payload: SyncPayload = {
|
||||
hosts: [{
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
proxyProfileId: "missing-proxy",
|
||||
}],
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
groupConfigs: [{ path: "prod", proxyProfileId: "missing-proxy" }],
|
||||
syncedAt: 1,
|
||||
};
|
||||
|
||||
await applySyncPayload(payload, {
|
||||
importVaultData: (json) => {
|
||||
imported = JSON.parse(json);
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(imported);
|
||||
assert.equal((imported.hosts as SyncPayload["hosts"])[0]?.proxyProfileId, "missing-proxy");
|
||||
assert.equal((imported.groupConfigs as SyncPayload["groupConfigs"])?.[0]?.proxyProfileId, "missing-proxy");
|
||||
});
|
||||
|
||||
test("applySyncPayload preserves host proxy references when group configs are absent", async () => {
|
||||
let imported: Record<string, unknown> | null = null;
|
||||
const payload: SyncPayload = {
|
||||
hosts: [{
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
proxyProfileId: "missing-proxy",
|
||||
}],
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
};
|
||||
|
||||
await applySyncPayload(payload, {
|
||||
importVaultData: (json) => {
|
||||
imported = JSON.parse(json);
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(imported);
|
||||
assert.equal((imported.hosts as SyncPayload["hosts"])[0]?.proxyProfileId, "missing-proxy");
|
||||
assert.equal("groupConfigs" in imported, false);
|
||||
});
|
||||
|
||||
test("applySyncPayload waits for async vault imports", async () => {
|
||||
let finished = false;
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
};
|
||||
|
||||
const promise = applySyncPayload(payload, {
|
||||
importVaultData: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
finished = true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(finished, false);
|
||||
await promise;
|
||||
assert.equal(finished, true);
|
||||
});
|
||||
|
||||
test("buildSyncPayload includes fallbackFont when present in TERM_SETTINGS", () => {
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ scrollback: 5000, fallbackFont: "PingFang SC", fontLigatures: true }),
|
||||
);
|
||||
|
||||
const payload = buildSyncPayload(vault());
|
||||
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
|
||||
assert.equal(termSettings.fallbackFont, "PingFang SC");
|
||||
});
|
||||
|
||||
test("buildSyncPayload omits fallbackFont when TERM_SETTINGS does not set it", () => {
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ scrollback: 5000, fontLigatures: true }),
|
||||
);
|
||||
|
||||
const payload = buildSyncPayload(vault());
|
||||
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
|
||||
assert.equal("fallbackFont" in termSettings, false);
|
||||
});
|
||||
|
||||
test("applySyncPayload writes incoming fallbackFont into local TERM_SETTINGS", async () => {
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
settings: { terminalSettings: { fallbackFont: "Sarasa Mono SC" } },
|
||||
};
|
||||
|
||||
await applySyncPayload(payload, {
|
||||
importVaultData: () => {},
|
||||
});
|
||||
|
||||
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
|
||||
assert.ok(raw, "TERM_SETTINGS should be written");
|
||||
const parsed = JSON.parse(raw!);
|
||||
assert.equal(parsed.fallbackFont, "Sarasa Mono SC");
|
||||
});
|
||||
|
||||
test("applySyncPayload from legacy client (no fallbackFont) preserves local value", async () => {
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ scrollback: 5000, fallbackFont: "Microsoft YaHei UI" }),
|
||||
);
|
||||
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
settings: { terminalSettings: { scrollback: 9999 } },
|
||||
};
|
||||
|
||||
await applySyncPayload(payload, {
|
||||
importVaultData: () => {},
|
||||
});
|
||||
|
||||
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
|
||||
const parsed = JSON.parse(raw!);
|
||||
assert.equal(parsed.fallbackFont, "Microsoft YaHei UI", "legacy payload must not wipe local fallbackFont");
|
||||
assert.equal(parsed.scrollback, 9999);
|
||||
});
|
||||
|
||||
test("applyLocalVaultPayload restores known hosts from local backups", async () => {
|
||||
let imported: Record<string, unknown> | null = null;
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
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")]);
|
||||
});
|
||||
@@ -13,11 +13,18 @@ import type {
|
||||
Identity,
|
||||
KnownHost,
|
||||
PortForwardingRule,
|
||||
ProxyProfile,
|
||||
SftpBookmark,
|
||||
Snippet,
|
||||
SSHKey,
|
||||
} from '../domain/models';
|
||||
import type { SyncPayload } from '../domain/sync';
|
||||
import {
|
||||
nextCustomKeyBindingsSyncVersion,
|
||||
parseCustomKeyBindingsStorageRecord,
|
||||
serializeCustomKeyBindingsStorageRecord,
|
||||
} from '../domain/customKeyBindings';
|
||||
import { isEncryptedCredentialPlaceholder } from '../domain/credentials';
|
||||
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
||||
import { rehydrateGlobalBookmarks } from '../components/sftp/hooks/useGlobalSftpBookmarks';
|
||||
import {
|
||||
@@ -30,6 +37,7 @@ import {
|
||||
STORAGE_KEY_UI_LANGUAGE,
|
||||
STORAGE_KEY_CUSTOM_CSS,
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
@@ -40,25 +48,43 @@ import {
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
|
||||
STORAGE_KEY_CUSTOM_THEMES,
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_AI_PROVIDERS,
|
||||
STORAGE_KEY_AI_ACTIVE_PROVIDER,
|
||||
STORAGE_KEY_AI_ACTIVE_MODEL,
|
||||
STORAGE_KEY_AI_PERMISSION_MODE,
|
||||
STORAGE_KEY_AI_TOOL_INTEGRATION_MODE,
|
||||
STORAGE_KEY_AI_HOST_PERMISSIONS,
|
||||
STORAGE_KEY_AI_DEFAULT_AGENT,
|
||||
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
|
||||
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** All vault-owned data that participates in cloud sync. */
|
||||
const CUSTOM_KEY_BINDINGS_SYNC_PAYLOAD_ORIGIN = 'sync-payload';
|
||||
|
||||
/** 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[];
|
||||
}
|
||||
@@ -73,6 +99,7 @@ export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
|
||||
(payload.keys?.length ?? 0) > 0 ||
|
||||
(payload.snippets?.length ?? 0) > 0 ||
|
||||
(payload.identities?.length ?? 0) > 0 ||
|
||||
(payload.proxyProfiles?.length ?? 0) > 0 ||
|
||||
(payload.customGroups?.length ?? 0) > 0 ||
|
||||
(payload.snippetPackages?.length ?? 0) > 0 ||
|
||||
(payload.portForwardingRules?.length ?? 0) > 0 ||
|
||||
@@ -86,10 +113,33 @@ export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when a payload contains cloud-sync data.
|
||||
* Local-only trust records are intentionally ignored.
|
||||
*/
|
||||
export function hasMeaningfulCloudSyncData(payload: SyncPayload): boolean {
|
||||
const hasEntities =
|
||||
(payload.hosts?.length ?? 0) > 0 ||
|
||||
(payload.keys?.length ?? 0) > 0 ||
|
||||
(payload.snippets?.length ?? 0) > 0 ||
|
||||
(payload.identities?.length ?? 0) > 0 ||
|
||||
(payload.proxyProfiles?.length ?? 0) > 0 ||
|
||||
(payload.customGroups?.length ?? 0) > 0 ||
|
||||
(payload.snippetPackages?.length ?? 0) > 0 ||
|
||||
(payload.portForwardingRules?.length ?? 0) > 0 ||
|
||||
(payload.groupConfigs?.length ?? 0) > 0;
|
||||
|
||||
if (hasEntities) return true;
|
||||
|
||||
return Boolean(
|
||||
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
/** Callbacks used by `applySyncPayload` to import data into local state. */
|
||||
interface SyncPayloadImporters {
|
||||
/** Import vault data (hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts). */
|
||||
importVaultData: (jsonString: string) => void;
|
||||
/** Import vault data. Cloud sync excludes local-only known hosts by default. */
|
||||
importVaultData: (jsonString: string) => void | Promise<void>;
|
||||
/** Import port-forwarding rules (lives outside the vault hook). */
|
||||
importPortForwardingRules?: (rules: PortForwardingRule[]) => void;
|
||||
/** Called after synced settings have been written to localStorage. */
|
||||
@@ -102,18 +152,123 @@ interface SyncPayloadImporters {
|
||||
|
||||
/** Terminal settings keys that are safe to sync (platform-agnostic). */
|
||||
const SYNCABLE_TERMINAL_KEYS = [
|
||||
'scrollback', 'drawBoldInBrightColors', 'fontLigatures', 'fontWeight', 'fontWeightBold',
|
||||
'scrollback', 'drawBoldInBrightColors', 'terminalEmulationType',
|
||||
'fontLigatures', 'fontWeight', 'fontWeightBold', 'fallbackFont',
|
||||
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
|
||||
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'altAsMeta', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'smoothScrolling',
|
||||
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'disableBracketedPaste', 'clearWipesScrollback',
|
||||
'preserveSelectionOnInput', 'osc52Clipboard',
|
||||
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
|
||||
'preserveSelectionOnInput', 'osc52Clipboard', 'showServerStats',
|
||||
'serverStatsRefreshInterval', 'rendererType',
|
||||
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
|
||||
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
|
||||
] as const;
|
||||
|
||||
export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_ACCENT_MODE,
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_UI_FONT_FAMILY,
|
||||
STORAGE_KEY_UI_LANGUAGE,
|
||||
STORAGE_KEY_CUSTOM_CSS,
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
STORAGE_KEY_CUSTOM_THEMES,
|
||||
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
||||
STORAGE_KEY_EDITOR_WORD_WRAP,
|
||||
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_AI_PROVIDERS,
|
||||
STORAGE_KEY_AI_ACTIVE_PROVIDER,
|
||||
STORAGE_KEY_AI_ACTIVE_MODEL,
|
||||
STORAGE_KEY_AI_PERMISSION_MODE,
|
||||
STORAGE_KEY_AI_TOOL_INTEGRATION_MODE,
|
||||
STORAGE_KEY_AI_HOST_PERMISSIONS,
|
||||
STORAGE_KEY_AI_DEFAULT_AGENT,
|
||||
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
|
||||
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
] as const;
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const readArraySetting = <T = Record<string, unknown>>(key: string): T[] | null => {
|
||||
const value = localStorageAdapter.read<T[]>(key);
|
||||
return Array.isArray(value) ? value : null;
|
||||
};
|
||||
|
||||
const readRecordSetting = <T extends Record<string, unknown> = Record<string, unknown>>(key: string): T | null => {
|
||||
const value = localStorageAdapter.read<T>(key);
|
||||
return isRecord(value) ? value as T : null;
|
||||
};
|
||||
|
||||
const stripDeviceBoundApiKey = <T extends Record<string, unknown>>(value: T): T => {
|
||||
if (!isEncryptedCredentialPlaceholder(value.apiKey as string | undefined)) return value;
|
||||
const next = { ...value };
|
||||
delete next.apiKey;
|
||||
return next;
|
||||
};
|
||||
|
||||
/**
|
||||
* `collectSyncableSettings` strips device-bound encrypted apiKeys before upload,
|
||||
* so an incoming providers array typically has no apiKey for providers that
|
||||
* already exist locally. Re-attach the local apiKey by id; without this merge,
|
||||
* applying any synced settings change would silently wipe credentials on the
|
||||
* receiving device.
|
||||
*/
|
||||
const mergeAiProvidersPreservingLocalApiKeys = (
|
||||
incoming: Array<Record<string, unknown>>,
|
||||
): Array<Record<string, unknown>> => {
|
||||
const local = readArraySetting(STORAGE_KEY_AI_PROVIDERS) ?? [];
|
||||
const localById = new Map<string, Record<string, unknown>>();
|
||||
for (const provider of local) {
|
||||
if (typeof provider?.id === 'string') localById.set(provider.id, provider);
|
||||
}
|
||||
return incoming.map((provider) => {
|
||||
if (provider.apiKey != null) return provider;
|
||||
const id = typeof provider.id === 'string' ? provider.id : undefined;
|
||||
const localProvider = id != null ? localById.get(id) : undefined;
|
||||
if (localProvider && typeof localProvider.apiKey === 'string') {
|
||||
return { ...provider, apiKey: localProvider.apiKey };
|
||||
}
|
||||
return provider;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Same rationale as `mergeAiProvidersPreservingLocalApiKeys`. Only restores the
|
||||
* local apiKey when the incoming config still points at the same providerId —
|
||||
* switching providers must not silently leak a key meant for a different one.
|
||||
*/
|
||||
const mergeWebSearchConfigPreservingLocalApiKey = (
|
||||
incoming: Record<string, unknown>,
|
||||
): Record<string, unknown> => {
|
||||
if (incoming.apiKey != null) return incoming;
|
||||
const local = readRecordSetting(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
if (!local || typeof local.apiKey !== 'string') return incoming;
|
||||
if (local.providerId !== incoming.providerId) return incoming;
|
||||
return { ...incoming, apiKey: local.apiKey };
|
||||
};
|
||||
|
||||
/**
|
||||
* Collect all syncable settings from localStorage.
|
||||
*/
|
||||
@@ -141,6 +296,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
// Terminal
|
||||
const termTheme = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
|
||||
if (termTheme) settings.terminalTheme = termTheme;
|
||||
const followAppTermTheme = localStorageAdapter.readString(STORAGE_KEY_TERM_FOLLOW_APP_THEME);
|
||||
if (followAppTermTheme === 'true' || followAppTermTheme === 'false') {
|
||||
settings.followAppTerminalTheme = followAppTermTheme === 'true';
|
||||
}
|
||||
const termFont = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
|
||||
if (termFont) settings.terminalFontFamily = termFont;
|
||||
const termSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
|
||||
@@ -171,9 +330,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
// Keyboard
|
||||
const kb = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS);
|
||||
if (kb) {
|
||||
try {
|
||||
settings.customKeyBindings = JSON.parse(kb);
|
||||
} catch { /* ignore */ }
|
||||
const parsed = parseCustomKeyBindingsStorageRecord(kb);
|
||||
if (parsed) settings.customKeyBindings = parsed.bindings;
|
||||
}
|
||||
|
||||
// Editor
|
||||
@@ -191,6 +349,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (compress === 'true' || compress === 'false') settings.sftpUseCompressedUpload = compress === 'true';
|
||||
const autoOpenSidebar = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
||||
if (autoOpenSidebar === 'true' || autoOpenSidebar === 'false') settings.sftpAutoOpenSidebar = autoOpenSidebar === 'true';
|
||||
const defaultViewMode = localStorageAdapter.readString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
|
||||
if (defaultViewMode === 'list' || defaultViewMode === 'tree') settings.sftpDefaultViewMode = defaultViewMode;
|
||||
|
||||
// SFTP Bookmarks (global only — local bookmarks are device-specific)
|
||||
const globalBookmarks = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS);
|
||||
@@ -203,6 +363,42 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (showOnlyUngroupedHostsInRoot != null) settings.showOnlyUngroupedHostsInRoot = showOnlyUngroupedHostsInRoot;
|
||||
const showSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
|
||||
const workspaceFocusStyle = localStorageAdapter.readString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
|
||||
if (workspaceFocusStyle === 'dim' || workspaceFocusStyle === 'border') {
|
||||
settings.workspaceFocusStyle = workspaceFocusStyle;
|
||||
}
|
||||
|
||||
const ai: NonNullable<SyncPayload['settings']>['ai'] = {};
|
||||
const providers = readArraySetting(STORAGE_KEY_AI_PROVIDERS);
|
||||
if (providers) ai.providers = providers.map(stripDeviceBoundApiKey);
|
||||
const activeProviderId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER);
|
||||
if (activeProviderId != null) ai.activeProviderId = activeProviderId;
|
||||
const activeModelId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL);
|
||||
if (activeModelId != null) ai.activeModelId = activeModelId;
|
||||
const permissionMode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
|
||||
if (permissionMode === 'observer' || permissionMode === 'confirm' || permissionMode === 'autonomous') {
|
||||
ai.globalPermissionMode = permissionMode;
|
||||
}
|
||||
const toolIntegrationMode = localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE);
|
||||
if (toolIntegrationMode === 'mcp' || toolIntegrationMode === 'skills') {
|
||||
ai.toolIntegrationMode = toolIntegrationMode;
|
||||
}
|
||||
const hostPermissions = readArraySetting(STORAGE_KEY_AI_HOST_PERMISSIONS);
|
||||
if (hostPermissions) ai.hostPermissions = hostPermissions;
|
||||
// externalAgents intentionally not collected: command/args/env are device-local.
|
||||
const defaultAgentId = localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT);
|
||||
if (defaultAgentId != null) ai.defaultAgentId = defaultAgentId;
|
||||
const commandBlocklist = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
|
||||
if (Array.isArray(commandBlocklist)) ai.commandBlocklist = commandBlocklist;
|
||||
const commandTimeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT);
|
||||
if (commandTimeout != null && Number.isFinite(commandTimeout)) ai.commandTimeout = commandTimeout;
|
||||
const maxIterations = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS);
|
||||
if (maxIterations != null && Number.isFinite(maxIterations)) ai.maxIterations = maxIterations;
|
||||
const agentModelMap = readRecordSetting<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP);
|
||||
if (agentModelMap) ai.agentModelMap = agentModelMap;
|
||||
const webSearchConfig = readRecordSetting(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
if (webSearchConfig) ai.webSearchConfig = stripDeviceBoundApiKey(webSearchConfig);
|
||||
if (Object.keys(ai).length > 0) settings.ai = ai;
|
||||
|
||||
return Object.keys(settings).length > 0 ? settings : undefined;
|
||||
}
|
||||
@@ -224,6 +420,9 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
|
||||
// Terminal
|
||||
if (settings.terminalTheme != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, settings.terminalTheme);
|
||||
if (settings.followAppTerminalTheme != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(settings.followAppTerminalTheme));
|
||||
}
|
||||
if (settings.terminalFontFamily != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, settings.terminalFontFamily);
|
||||
if (settings.terminalFontSize != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_SIZE, String(settings.terminalFontSize));
|
||||
|
||||
@@ -250,7 +449,17 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
|
||||
// Keyboard
|
||||
if (settings.customKeyBindings != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_KEY_BINDINGS, JSON.stringify(settings.customKeyBindings));
|
||||
const previous = parseCustomKeyBindingsStorageRecord(
|
||||
localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS),
|
||||
);
|
||||
localStorageAdapter.writeString(
|
||||
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
||||
serializeCustomKeyBindingsStorageRecord({
|
||||
version: nextCustomKeyBindingsSyncVersion(previous?.version || 0),
|
||||
origin: CUSTOM_KEY_BINDINGS_SYNC_PAYLOAD_ORIGIN,
|
||||
bindings: settings.customKeyBindings,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Editor
|
||||
@@ -262,6 +471,9 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
if (settings.sftpShowHiddenFiles != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, String(settings.sftpShowHiddenFiles));
|
||||
if (settings.sftpUseCompressedUpload != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, String(settings.sftpUseCompressedUpload));
|
||||
if (settings.sftpAutoOpenSidebar != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, String(settings.sftpAutoOpenSidebar));
|
||||
if (settings.sftpDefaultViewMode != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE, settings.sftpDefaultViewMode);
|
||||
}
|
||||
|
||||
// SFTP Bookmarks (global only)
|
||||
if (settings.sftpGlobalBookmarks != null) localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, settings.sftpGlobalBookmarks);
|
||||
@@ -277,6 +489,41 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
if (settings.showSftpTab != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
|
||||
}
|
||||
if (settings.workspaceFocusStyle != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, settings.workspaceFocusStyle);
|
||||
}
|
||||
|
||||
const ai = settings.ai;
|
||||
if (ai) {
|
||||
if (ai.providers != null) {
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_AI_PROVIDERS,
|
||||
mergeAiProvidersPreservingLocalApiKeys(ai.providers),
|
||||
);
|
||||
}
|
||||
if (ai.activeProviderId != null) localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, ai.activeProviderId);
|
||||
if (ai.activeModelId != null) localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_MODEL, ai.activeModelId);
|
||||
if (ai.globalPermissionMode != null) localStorageAdapter.writeString(STORAGE_KEY_AI_PERMISSION_MODE, ai.globalPermissionMode);
|
||||
if (ai.toolIntegrationMode != null) localStorageAdapter.writeString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE, ai.toolIntegrationMode);
|
||||
if (ai.hostPermissions != null) localStorageAdapter.write(STORAGE_KEY_AI_HOST_PERMISSIONS, ai.hostPermissions);
|
||||
// externalAgents intentionally not applied: device-local. Legacy snapshots
|
||||
// that still carry an `externalAgents` field are silently ignored.
|
||||
if (ai.defaultAgentId != null) localStorageAdapter.writeString(STORAGE_KEY_AI_DEFAULT_AGENT, ai.defaultAgentId);
|
||||
if (ai.commandBlocklist != null) localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, ai.commandBlocklist);
|
||||
if (ai.commandTimeout != null) localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, ai.commandTimeout);
|
||||
if (ai.maxIterations != null) localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, ai.maxIterations);
|
||||
if (ai.agentModelMap != null) localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, ai.agentModelMap);
|
||||
if (ai.webSearchConfig !== undefined) {
|
||||
if (ai.webSearchConfig === null) {
|
||||
localStorageAdapter.remove(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
} else {
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
mergeWebSearchConfigPreservingLocalApiKey(ai.webSearchConfig),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -298,10 +545,10 @@ export function buildSyncPayload(
|
||||
hosts: vault.hosts,
|
||||
keys: vault.keys,
|
||||
identities: vault.identities,
|
||||
proxyProfiles: vault.proxyProfiles,
|
||||
snippets: vault.snippets,
|
||||
customGroups: vault.customGroups,
|
||||
snippetPackages: vault.snippetPackages,
|
||||
knownHosts: vault.knownHosts,
|
||||
groupConfigs: vault.groupConfigs,
|
||||
portForwardingRules,
|
||||
settings: collectSyncableSettings(),
|
||||
@@ -309,52 +556,77 @@ export function buildSyncPayload(
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a local backup/restore payload, including local-only trust records. */
|
||||
export function buildLocalVaultPayload(
|
||||
vault: SyncableVaultData,
|
||||
portForwardingRules?: PortForwardingRule[],
|
||||
): SyncPayload {
|
||||
return {
|
||||
...buildSyncPayload(vault, portForwardingRules),
|
||||
knownHosts: vault.knownHosts,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a downloaded `SyncPayload` to local state via the provided importers.
|
||||
*
|
||||
* This ensures both vault data and port-forwarding rules are imported
|
||||
* consistently across windows.
|
||||
*/
|
||||
export function applySyncPayload(
|
||||
function applyPayload(
|
||||
payload: SyncPayload,
|
||||
importers: SyncPayloadImporters,
|
||||
): void {
|
||||
// Build the vault import object. knownHosts is only included when the
|
||||
// payload explicitly carries the field (even if it's []). Legacy cloud
|
||||
// snapshots may omit it entirely — in that case we leave the local
|
||||
// known-hosts list untouched rather than destructively wiping it.
|
||||
options: { includeLocalOnlyData: boolean },
|
||||
): Promise<void> {
|
||||
// Build the vault import object. Cloud sync intentionally ignores
|
||||
// local-only trust records even if legacy cloud snapshots still carry them.
|
||||
const vaultImport: Record<string, unknown> = {
|
||||
hosts: payload.hosts,
|
||||
keys: payload.keys,
|
||||
identities: payload.identities,
|
||||
proxyProfiles: payload.proxyProfiles,
|
||||
snippets: payload.snippets,
|
||||
customGroups: payload.customGroups,
|
||||
};
|
||||
if (payload.snippetPackages !== undefined) {
|
||||
vaultImport.snippetPackages = payload.snippetPackages;
|
||||
}
|
||||
if (payload.knownHosts !== undefined) {
|
||||
if (options.includeLocalOnlyData && payload.knownHosts !== undefined) {
|
||||
vaultImport.knownHosts = payload.knownHosts;
|
||||
}
|
||||
if (Array.isArray(payload.groupConfigs)) {
|
||||
vaultImport.groupConfigs = payload.groupConfigs;
|
||||
}
|
||||
|
||||
importers.importVaultData(JSON.stringify(vaultImport));
|
||||
return Promise.resolve(importers.importVaultData(JSON.stringify(vaultImport))).then(() => {
|
||||
// Only import port-forwarding rules when the payload explicitly carries
|
||||
// them. Absent field = "payload was created before this feature existed",
|
||||
// so local rules are preserved. Explicitly present [] = "remote has no
|
||||
// rules, clear local state".
|
||||
if (payload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
|
||||
importers.importPortForwardingRules(payload.portForwardingRules);
|
||||
}
|
||||
|
||||
// Only import port-forwarding rules when the payload explicitly carries
|
||||
// them. Absent field = "payload was created before this feature existed",
|
||||
// so local rules are preserved. Explicitly present [] = "remote has no
|
||||
// rules, clear local state".
|
||||
if (payload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
|
||||
importers.importPortForwardingRules(payload.portForwardingRules);
|
||||
}
|
||||
|
||||
// Apply synced settings
|
||||
if (payload.settings) {
|
||||
applySyncableSettings(payload.settings);
|
||||
// Rehydrate in-memory bookmark snapshot after localStorage was updated
|
||||
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalBookmarks();
|
||||
importers.onSettingsApplied?.();
|
||||
}
|
||||
// Apply synced settings
|
||||
if (payload.settings) {
|
||||
applySyncableSettings(payload.settings);
|
||||
// Rehydrate in-memory bookmark snapshot after localStorage was updated
|
||||
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalBookmarks();
|
||||
importers.onSettingsApplied?.();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function applySyncPayload(
|
||||
payload: SyncPayload,
|
||||
importers: SyncPayloadImporters,
|
||||
): Promise<void> {
|
||||
return applyPayload(payload, importers, { includeLocalOnlyData: false });
|
||||
}
|
||||
|
||||
export function applyLocalVaultPayload(
|
||||
payload: SyncPayload,
|
||||
importers: SyncPayloadImporters,
|
||||
): Promise<void> {
|
||||
return applyPayload(payload, importers, { includeLocalOnlyData: true });
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -433,7 +433,7 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onCancelConnect}
|
||||
className="gap-1"
|
||||
className="gap-1 min-w-[136px] justify-center"
|
||||
>
|
||||
<X size={14} />
|
||||
{t('common.cancel')}
|
||||
@@ -442,7 +442,7 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => { onConnect(); }}
|
||||
className="gap-1"
|
||||
className="gap-1 min-w-[136px] justify-center"
|
||||
disabled={disabled || isConnecting}
|
||||
>
|
||||
{isConnecting ? <Loader2 size={14} className="animate-spin" /> : <Cloud size={14} />}
|
||||
@@ -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();
|
||||
@@ -1121,6 +1123,10 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
};
|
||||
|
||||
const disconnectOtherProviders = async (current: CloudProvider) => {
|
||||
if (sync.pendingBrowserAuthProvider && sync.pendingBrowserAuthProvider !== current) {
|
||||
toast.info(t('cloudSync.connect.browserCancelled'));
|
||||
}
|
||||
sync.cancelOAuthConnect();
|
||||
const providers: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
|
||||
for (const provider of providers) {
|
||||
if (provider === current) continue;
|
||||
@@ -1135,6 +1141,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
const [gitHubUserCode, setGitHubUserCode] = useState('');
|
||||
const [gitHubVerificationUri, setGitHubVerificationUri] = useState('');
|
||||
const [isPollingGitHub, setIsPollingGitHub] = useState(false);
|
||||
const activeGitHubAttemptIdRef = useRef<number | null>(null);
|
||||
|
||||
// Conflict modal
|
||||
const [showConflictModal, setShowConflictModal] = useState(false);
|
||||
@@ -1152,6 +1159,40 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
} | null>(null);
|
||||
const [historyPreviewLoading, setHistoryPreviewLoading] = useState(false);
|
||||
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||
const [pendingConnectProvider, setPendingConnectProvider] = useState<CloudProvider | null>(null);
|
||||
const pendingConnectProviderRef = useRef<CloudProvider | null>(null);
|
||||
|
||||
const hasConnectingProvider = (Object.values(sync.providers) as Array<{ status: string }>).some(
|
||||
(provider) => provider.status === 'connecting'
|
||||
);
|
||||
|
||||
const isConnectDisabled = (provider: CloudProvider): boolean => {
|
||||
if (pendingConnectProvider && pendingConnectProvider !== provider) {
|
||||
return true;
|
||||
}
|
||||
if (pendingConnectProvider === provider) {
|
||||
return true;
|
||||
}
|
||||
if (hasConnectingProvider && sync.providers[provider].status !== 'connecting') {
|
||||
return true;
|
||||
}
|
||||
return sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers[provider]);
|
||||
};
|
||||
|
||||
const beginPendingConnect = (provider: CloudProvider): boolean => {
|
||||
if (pendingConnectProviderRef.current) {
|
||||
return false;
|
||||
}
|
||||
pendingConnectProviderRef.current = provider;
|
||||
setPendingConnectProvider(provider);
|
||||
return true;
|
||||
};
|
||||
|
||||
const endPendingConnect = (provider: CloudProvider) => {
|
||||
if (pendingConnectProviderRef.current !== provider) return;
|
||||
pendingConnectProviderRef.current = null;
|
||||
setPendingConnectProvider((current) => (current === provider ? null : current));
|
||||
};
|
||||
|
||||
// Change master key dialog
|
||||
const [showChangeKeyDialog, setShowChangeKeyDialog] = useState(false);
|
||||
@@ -1275,9 +1316,14 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
|
||||
// Connect GitHub (disconnect others first - single provider only)
|
||||
const handleConnectGitHub = async () => {
|
||||
if (!beginPendingConnect('github')) return;
|
||||
const cancelController = new AbortController();
|
||||
let authAttemptId: number | null = null;
|
||||
try {
|
||||
await disconnectOtherProviders('github');
|
||||
const deviceFlow = await sync.connectGitHub();
|
||||
authAttemptId = deviceFlow.authAttemptId ?? null;
|
||||
activeGitHubAttemptIdRef.current = authAttemptId;
|
||||
setGitHubUserCode(deviceFlow.userCode);
|
||||
setGitHubVerificationUri(deviceFlow.verificationUri);
|
||||
setShowGitHubModal(true);
|
||||
@@ -1287,59 +1333,78 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
deviceFlow.deviceCode,
|
||||
deviceFlow.interval,
|
||||
deviceFlow.expiresAt,
|
||||
() => { } // onPending callback
|
||||
() => { }, // onPending callback
|
||||
cancelController.signal,
|
||||
authAttemptId ?? undefined
|
||||
);
|
||||
|
||||
setIsPollingGitHub(false);
|
||||
setShowGitHubModal(false);
|
||||
if (activeGitHubAttemptIdRef.current === authAttemptId) {
|
||||
activeGitHubAttemptIdRef.current = null;
|
||||
setIsPollingGitHub(false);
|
||||
setShowGitHubModal(false);
|
||||
}
|
||||
toast.success(t('cloudSync.connect.github.success'));
|
||||
} catch (error) {
|
||||
setIsPollingGitHub(false);
|
||||
setShowGitHubModal(false);
|
||||
// Reset provider status so button is clickable again (without tearing down existing connections)
|
||||
sync.resetProviderStatus('github');
|
||||
if (activeGitHubAttemptIdRef.current === authAttemptId) {
|
||||
activeGitHubAttemptIdRef.current = null;
|
||||
setIsPollingGitHub(false);
|
||||
setShowGitHubModal(false);
|
||||
}
|
||||
const message = getNetworkErrorMessage(error, t('common.unknownError'));
|
||||
toast.error(message, t('cloudSync.connect.github.failedTitle'));
|
||||
if (!message.toLowerCase().includes('cancelled')) {
|
||||
toast.error(message, t('cloudSync.connect.github.failedTitle'));
|
||||
}
|
||||
} finally {
|
||||
cancelController.abort();
|
||||
if (activeGitHubAttemptIdRef.current == null) {
|
||||
endPendingConnect('github');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Connect Google (disconnect others first - single provider only)
|
||||
const handleConnectGoogle = async () => {
|
||||
if (!beginPendingConnect('google')) return;
|
||||
try {
|
||||
await disconnectOtherProviders('google');
|
||||
await sync.connectGoogle();
|
||||
// Note: Auth flow is handled automatically by oauthBridge
|
||||
toast.info(t('cloudSync.connect.browserContinue'));
|
||||
} catch (error) {
|
||||
// Reset provider status so button is clickable again (without tearing down existing connections)
|
||||
sync.resetProviderStatus('google');
|
||||
const msg = error instanceof Error ? error.message : t('common.unknownError');
|
||||
// Don't show toast for user-initiated cancellation (popup closed)
|
||||
if (!msg.includes('cancelled')) {
|
||||
toast.error(msg, t('cloudSync.connect.google.failedTitle'));
|
||||
}
|
||||
} finally {
|
||||
endPendingConnect('google');
|
||||
}
|
||||
};
|
||||
|
||||
// Connect OneDrive (disconnect others first - single provider only)
|
||||
const handleConnectOneDrive = async () => {
|
||||
if (!beginPendingConnect('onedrive')) return;
|
||||
try {
|
||||
await disconnectOtherProviders('onedrive');
|
||||
await sync.connectOneDrive();
|
||||
// Note: Auth flow is handled automatically by oauthBridge
|
||||
toast.info(t('cloudSync.connect.browserContinue'));
|
||||
} catch (error) {
|
||||
// Reset provider status so button is clickable again (without tearing down existing connections)
|
||||
sync.resetProviderStatus('onedrive');
|
||||
const msg = error instanceof Error ? error.message : t('common.unknownError');
|
||||
// Don't show toast for user-initiated cancellation (popup closed)
|
||||
if (!msg.includes('cancelled')) {
|
||||
toast.error(msg, t('cloudSync.connect.onedrive.failedTitle'));
|
||||
}
|
||||
} finally {
|
||||
endPendingConnect('onedrive');
|
||||
}
|
||||
};
|
||||
|
||||
const openWebdavDialog = () => {
|
||||
if (sync.pendingBrowserAuthProvider) {
|
||||
toast.info(t('cloudSync.connect.browserCancelled'));
|
||||
}
|
||||
sync.cancelOAuthConnect();
|
||||
const config = sync.providers.webdav.config as WebDAVConfig | undefined;
|
||||
setWebdavEndpoint(config?.endpoint || '');
|
||||
setWebdavAuthType(config?.authType || 'basic');
|
||||
@@ -1354,6 +1419,10 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
};
|
||||
|
||||
const openS3Dialog = () => {
|
||||
if (sync.pendingBrowserAuthProvider) {
|
||||
toast.info(t('cloudSync.connect.browserCancelled'));
|
||||
}
|
||||
sync.cancelOAuthConnect();
|
||||
const config = sync.providers.s3.config as S3Config | undefined;
|
||||
setS3Endpoint(config?.endpoint || '');
|
||||
setS3Region(config?.region || '');
|
||||
@@ -1673,7 +1742,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
account={sync.providers.github.account}
|
||||
lastSync={sync.providers.github.lastSync}
|
||||
error={sync.providers.github.error}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.github)}
|
||||
disabled={isConnectDisabled('github')}
|
||||
onConnect={handleConnectGitHub}
|
||||
onDisconnect={() => sync.disconnectProvider('github')}
|
||||
onSync={() => handleSync('github')}
|
||||
@@ -1693,11 +1762,14 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
icon={<GoogleDriveIcon className="w-6 h-6" />}
|
||||
isConnected={isProviderReadyForSync(sync.providers.google)}
|
||||
isSyncing={sync.providers.google.status === 'syncing'}
|
||||
isConnecting={sync.providers.google.status === 'connecting'}
|
||||
isConnecting={
|
||||
sync.providers.google.status === 'connecting' ||
|
||||
sync.pendingBrowserAuthProvider === 'google'
|
||||
}
|
||||
account={sync.providers.google.account}
|
||||
lastSync={sync.providers.google.lastSync}
|
||||
error={sync.providers.google.error}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.google)}
|
||||
disabled={isConnectDisabled('google')}
|
||||
onConnect={handleConnectGoogle}
|
||||
onCancelConnect={sync.cancelOAuthConnect}
|
||||
onDisconnect={() => sync.disconnectProvider('google')}
|
||||
@@ -1710,11 +1782,14 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
icon={<OneDriveIcon className="w-6 h-6" />}
|
||||
isConnected={isProviderReadyForSync(sync.providers.onedrive)}
|
||||
isSyncing={sync.providers.onedrive.status === 'syncing'}
|
||||
isConnecting={sync.providers.onedrive.status === 'connecting'}
|
||||
isConnecting={
|
||||
sync.providers.onedrive.status === 'connecting' ||
|
||||
sync.pendingBrowserAuthProvider === 'onedrive'
|
||||
}
|
||||
account={sync.providers.onedrive.account}
|
||||
lastSync={sync.providers.onedrive.lastSync}
|
||||
error={sync.providers.onedrive.error}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.onedrive)}
|
||||
disabled={isConnectDisabled('onedrive')}
|
||||
onConnect={handleConnectOneDrive}
|
||||
onCancelConnect={sync.cancelOAuthConnect}
|
||||
onDisconnect={() => sync.disconnectProvider('onedrive')}
|
||||
@@ -1731,7 +1806,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
account={sync.providers.webdav.account}
|
||||
lastSync={sync.providers.webdav.lastSync}
|
||||
error={sync.providers.webdav.error}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.webdav)}
|
||||
disabled={isConnectDisabled('webdav')}
|
||||
onEdit={openWebdavDialog}
|
||||
onConnect={openWebdavDialog}
|
||||
onDisconnect={() => sync.disconnectProvider('webdav')}
|
||||
@@ -1748,7 +1823,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
account={sync.providers.s3.account}
|
||||
lastSync={sync.providers.s3.lastSync}
|
||||
error={sync.providers.s3.error}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.s3)}
|
||||
disabled={isConnectDisabled('s3')}
|
||||
onEdit={openS3Dialog}
|
||||
onConnect={openS3Dialog}
|
||||
onDisconnect={() => sync.disconnectProvider('s3')}
|
||||
@@ -1843,7 +1918,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
|
||||
<div ref={localBackupsRef}>
|
||||
<LocalBackupsPanel
|
||||
onApplyPayload={onApplyPayload}
|
||||
onApplyPayload={onApplyLocalPayload ?? onApplyPayload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1876,11 +1951,11 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
verificationUri={gitHubVerificationUri}
|
||||
isPolling={isPollingGitHub}
|
||||
onClose={() => {
|
||||
activeGitHubAttemptIdRef.current = null;
|
||||
setShowGitHubModal(false);
|
||||
setIsPollingGitHub(false);
|
||||
// Reset provider status so button is clickable again.
|
||||
// The background polling will continue until expiry but is harmless.
|
||||
sync.resetProviderStatus('github');
|
||||
endPendingConnect('github');
|
||||
sync.cancelOAuthConnect();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -2539,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
239
components/HostDetailsPanel.proxyProfile.test.tsx
Normal file
239
components/HostDetailsPanel.proxyProfile.test.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import type { Host } from "../types.ts";
|
||||
import HostDetailsPanel, { parseOptionalPortInput } from "./HostDetailsPanel.tsx";
|
||||
|
||||
const hostWithMissingProxyProfile: Host = {
|
||||
id: "host-1",
|
||||
label: "DB",
|
||||
hostname: "db.example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
port: 22,
|
||||
protocol: "ssh",
|
||||
authMethod: "password",
|
||||
proxyProfileId: "missing-proxy",
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const renderHostDetails = (initialData: Host = hostWithMissingProxyProfile) =>
|
||||
renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData,
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: [],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const findInputByValue = (markup: string, value: string) => {
|
||||
const match = markup.match(new RegExp(`<input(?=[^>]*value="${value}")[^>]*>`));
|
||||
assert.ok(match, `expected input with value ${value}`);
|
||||
return match[0];
|
||||
};
|
||||
|
||||
const classTokens = (markup: string) => {
|
||||
const classMatch = markup.match(/class="([^"]*)"/);
|
||||
assert.ok(classMatch, "expected class attribute");
|
||||
return new Set(classMatch[1].split(/\s+/).filter(Boolean));
|
||||
};
|
||||
|
||||
test("HostDetailsPanel shows a missing saved proxy without undefined fields", () => {
|
||||
const markup = renderHostDetails();
|
||||
|
||||
assert.match(markup, /Missing saved proxy/);
|
||||
assert.doesNotMatch(markup, /undefined:undefined/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel keeps explicitly cleared telnet credentials empty", () => {
|
||||
const markup = renderHostDetails({
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "telnet",
|
||||
telnetEnabled: true,
|
||||
telnetPort: 23,
|
||||
username: "root",
|
||||
password: "ssh-password",
|
||||
telnetUsername: "",
|
||||
telnetPassword: "",
|
||||
proxyProfileId: undefined,
|
||||
});
|
||||
|
||||
assert.match(markup, /placeholder="Telnet Username"[^>]*value=""/);
|
||||
assert.match(markup, /placeholder="Telnet Password"[^>]*value=""/);
|
||||
assert.doesNotMatch(markup, /placeholder="Telnet Username"[^>]*value="root"/);
|
||||
assert.doesNotMatch(markup, /placeholder="Telnet Password"[^>]*value="ssh-password"/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel gives the telnet port field the same roomy layout as SSH", () => {
|
||||
const markup = renderHostDetails({
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "telnet",
|
||||
telnetEnabled: true,
|
||||
telnetPort: 2325,
|
||||
proxyProfileId: undefined,
|
||||
});
|
||||
|
||||
const telnetMarkup = markup.slice(markup.indexOf("Telnet on"));
|
||||
const wrapperMatch = telnetMarkup.match(/<div class="([^"]*w-1\/2[^"]*)"/);
|
||||
assert.ok(wrapperMatch, "expected telnet port wrapper");
|
||||
const wrapperClasses = new Set(wrapperMatch[1].split(/\s+/).filter(Boolean));
|
||||
assert.ok(wrapperClasses.has("ml-auto"));
|
||||
assert.ok(wrapperClasses.has("w-1/2"));
|
||||
assert.ok(wrapperClasses.has("min-w-0"));
|
||||
assert.ok(wrapperClasses.has("justify-end"));
|
||||
const telnetPortInput = findInputByValue(markup, "2325");
|
||||
const inputClasses = classTokens(telnetPortInput);
|
||||
assert.ok(inputClasses.has("flex-1"));
|
||||
assert.ok(inputClasses.has("min-w-0"));
|
||||
assert.ok(inputClasses.has("text-center"));
|
||||
assert.equal(inputClasses.has("w-16"), false);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel displays inherited telnet port before falling back to 23", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData: {
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "telnet",
|
||||
telnetEnabled: true,
|
||||
telnetPort: undefined,
|
||||
port: undefined,
|
||||
group: "network",
|
||||
proxyProfileId: undefined,
|
||||
},
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: ["network"],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
groupConfigs: [{ path: "network", telnetPort: 2325 }],
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(findInputByValue(markup, "2325"), /type="number"/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel uses group telnet port instead of ssh port for optional telnet", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData: {
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "ssh",
|
||||
telnetEnabled: true,
|
||||
telnetPort: undefined,
|
||||
port: 2222,
|
||||
group: "network",
|
||||
proxyProfileId: undefined,
|
||||
},
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: ["network"],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
groupConfigs: [{ path: "network", telnetPort: 2325 }],
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const telnetMarkup = markup.slice(markup.indexOf("Telnet on"));
|
||||
assert.match(findInputByValue(telnetMarkup, "2325"), /type="number"/);
|
||||
assert.doesNotMatch(telnetMarkup, /value="2222"/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel displays inherited telnet credentials", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData: {
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "telnet",
|
||||
telnetEnabled: true,
|
||||
telnetUsername: undefined,
|
||||
telnetPassword: undefined,
|
||||
username: "ssh-user",
|
||||
password: "ssh-password",
|
||||
group: "network",
|
||||
proxyProfileId: undefined,
|
||||
},
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: ["network"],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
groupConfigs: [{
|
||||
path: "network",
|
||||
telnetUsername: "group-telnet-user",
|
||||
telnetPassword: "group-telnet-password",
|
||||
}],
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(markup, /placeholder="Telnet Username"[^>]*value="group-telnet-user"/);
|
||||
assert.match(markup, /placeholder="Telnet Password"[^>]*value="group-telnet-password"/);
|
||||
assert.doesNotMatch(markup, /placeholder="Telnet Username"[^>]*value="ssh-user"/);
|
||||
assert.doesNotMatch(markup, /placeholder="Telnet Password"[^>]*value="ssh-password"/);
|
||||
});
|
||||
|
||||
test("parseOptionalPortInput clears empty port values", () => {
|
||||
assert.equal(parseOptionalPortInput(""), undefined);
|
||||
assert.equal(parseOptionalPortInput("2325"), 2325);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel does not offer to disable telnet when telnet is the primary protocol", () => {
|
||||
const markup = renderHostDetails({
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "telnet",
|
||||
telnetEnabled: true,
|
||||
telnetPort: 23,
|
||||
proxyProfileId: undefined,
|
||||
});
|
||||
const telnetHeader = markup.match(/Telnet on[\s\S]*?Credentials/);
|
||||
|
||||
assert.ok(telnetHeader);
|
||||
assert.doesNotMatch(telnetHeader[0], /hover:text-destructive/);
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
FolderPlus,
|
||||
Forward,
|
||||
Globe,
|
||||
HeartPulse,
|
||||
Key,
|
||||
KeyRound,
|
||||
Link2,
|
||||
@@ -35,8 +36,10 @@ import { resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/gro
|
||||
import {
|
||||
getEffectiveHostDistro,
|
||||
LINUX_DISTRO_OPTIONS,
|
||||
normalizePrimaryTelnetState,
|
||||
NETWORK_DEVICE_OPTIONS,
|
||||
} from "../domain/host";
|
||||
import { isCompleteProxyConfig, normalizeManualProxyConfig } from "../domain/proxyProfiles";
|
||||
import { customThemeStore } from "../application/state/customThemeStore";
|
||||
import {
|
||||
clearHostFontSizeOverride,
|
||||
@@ -48,7 +51,7 @@ import {
|
||||
} from "../domain/terminalAppearance";
|
||||
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts";
|
||||
import { cn } from "../lib/utils";
|
||||
import { EnvVar, GroupConfig, Host, Identity, ManagedSource, ProxyConfig, SSHKey } from "../types";
|
||||
import { EnvVar, GroupConfig, Host, Identity, ManagedSource, ProxyConfig, ProxyProfile, SSHKey } from "../types";
|
||||
import { DISTRO_COLORS, DISTRO_LOGOS } from "./DistroAvatar";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import ThemeSelectPanel from "./ThemeSelectPanel";
|
||||
@@ -69,6 +72,7 @@ import { Textarea } from "./ui/textarea";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
import { toast } from "./ui/toast";
|
||||
|
||||
// Import host-details sub-panels
|
||||
import {
|
||||
@@ -88,6 +92,44 @@ type SubPanel =
|
||||
| "theme-select"
|
||||
| "telnet-theme-select";
|
||||
|
||||
export const parseOptionalPortInput = (value: string): number | undefined =>
|
||||
value ? Number(value) : undefined;
|
||||
|
||||
const resolveDetailsTelnetPort = (
|
||||
host: Host,
|
||||
groupDefaults?: Partial<GroupConfig>,
|
||||
): number => {
|
||||
if (host.telnetPort !== undefined && host.telnetPort !== null) return host.telnetPort;
|
||||
if (groupDefaults?.telnetPort !== undefined && groupDefaults.telnetPort !== null) {
|
||||
return groupDefaults.telnetPort;
|
||||
}
|
||||
if (host.protocol === "telnet") {
|
||||
if (host.port !== undefined && host.port !== null) return host.port;
|
||||
if (groupDefaults?.port !== undefined && groupDefaults.port !== null) return groupDefaults.port;
|
||||
}
|
||||
return 23;
|
||||
};
|
||||
|
||||
const resolveDetailsTelnetUsername = (
|
||||
host: Host,
|
||||
groupDefaults?: Partial<GroupConfig>,
|
||||
): string =>
|
||||
host.telnetUsername !== undefined
|
||||
? host.telnetUsername
|
||||
: groupDefaults?.telnetUsername !== undefined
|
||||
? groupDefaults.telnetUsername
|
||||
: host.username ?? groupDefaults?.username ?? "";
|
||||
|
||||
const resolveDetailsTelnetPassword = (
|
||||
host: Host,
|
||||
groupDefaults?: Partial<GroupConfig>,
|
||||
): string =>
|
||||
host.telnetPassword !== undefined
|
||||
? host.telnetPassword
|
||||
: groupDefaults?.telnetPassword !== undefined
|
||||
? groupDefaults.telnetPassword
|
||||
: host.password ?? groupDefaults?.password ?? "";
|
||||
|
||||
const LINUX_DISTRO_OPTION_IDS = [
|
||||
...LINUX_DISTRO_OPTIONS,
|
||||
...NETWORK_DEVICE_OPTIONS,
|
||||
@@ -97,6 +139,7 @@ interface HostDetailsPanelProps {
|
||||
initialData?: Host | null;
|
||||
availableKeys: SSHKey[];
|
||||
identities: Identity[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
groups: string[];
|
||||
managedSources?: ManagedSource[];
|
||||
allTags?: string[]; // All available tags for autocomplete
|
||||
@@ -111,12 +154,14 @@ interface HostDetailsPanelProps {
|
||||
groupDefaults?: Partial<import('../domain/models').GroupConfig>;
|
||||
groupConfigs?: GroupConfig[];
|
||||
layout?: AsidePanelLayout;
|
||||
onImportKey?: (draft: Partial<SSHKey>) => SSHKey;
|
||||
}
|
||||
|
||||
const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
initialData,
|
||||
availableKeys,
|
||||
identities,
|
||||
proxyProfiles = [],
|
||||
groups,
|
||||
managedSources = [],
|
||||
allTags = [],
|
||||
@@ -131,12 +176,13 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
groupDefaults,
|
||||
groupConfigs = [],
|
||||
layout = "overlay",
|
||||
onImportKey,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { checkSshAgent } = useApplicationBackend();
|
||||
const [form, setForm] = useState<Host>(
|
||||
() =>
|
||||
initialData ||
|
||||
(initialData ? normalizePrimaryTelnetState(initialData) : null) ||
|
||||
({
|
||||
id: crypto.randomUUID(),
|
||||
label: "",
|
||||
@@ -170,6 +216,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
|
||||
// Local key file path input state
|
||||
const [newKeyFilePath, setNewKeyFilePath] = useState("");
|
||||
const [pendingReferenceKeyPath, setPendingReferenceKeyPath] = useState<string | null>(null);
|
||||
|
||||
// New group creation state
|
||||
const [newGroupName, setNewGroupName] = useState("");
|
||||
@@ -196,15 +243,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
// Ensure telnetEnabled is set when protocol is telnet
|
||||
const updatedData = { ...initialData };
|
||||
if (initialData.protocol === "telnet" && !initialData.telnetEnabled) {
|
||||
updatedData.telnetEnabled = true;
|
||||
updatedData.telnetPort =
|
||||
initialData.telnetPort || initialData.port || 23;
|
||||
}
|
||||
setForm(updatedData);
|
||||
setForm(normalizePrimaryTelnetState(initialData));
|
||||
setGroupInputValue(initialData.group || "");
|
||||
setPendingReferenceKeyPath(null);
|
||||
// Reset password visibility when host changes for privacy
|
||||
setShowPassword(false);
|
||||
}
|
||||
@@ -214,6 +255,20 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const addLocalKeyFilePath = useCallback((path: string) => {
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed) return;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
identityFilePaths: onImportKey ? [trimmed] : [...(prev.identityFilePaths || []), trimmed],
|
||||
identityFileId: undefined,
|
||||
authMethod: "key",
|
||||
}));
|
||||
setPendingReferenceKeyPath(onImportKey ? trimmed : null);
|
||||
setNewKeyFilePath("");
|
||||
setSelectedCredentialType(null);
|
||||
}, [onImportKey]);
|
||||
|
||||
const effectiveGroupDefaults = useMemo(() => {
|
||||
const currentGroupPath = form.group || defaultGroup;
|
||||
if (currentGroupPath && groupConfigs.length > 0) {
|
||||
@@ -240,6 +295,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
);
|
||||
const effectiveTelnetThemeId =
|
||||
form.protocols?.find((p) => p.protocol === "telnet")?.theme || effectiveThemeId;
|
||||
const effectiveTelnetPort = resolveDetailsTelnetPort(form, effectiveGroupDefaults);
|
||||
const effectiveTelnetUsername = resolveDetailsTelnetUsername(form, effectiveGroupDefaults);
|
||||
const effectiveTelnetPassword = resolveDetailsTelnetPassword(form, effectiveGroupDefaults);
|
||||
const distroOptions = useMemo(
|
||||
() =>
|
||||
LINUX_DISTRO_OPTION_IDS.map((value) => ({
|
||||
@@ -260,6 +318,24 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
);
|
||||
|
||||
const effectiveFormDistro = getEffectiveHostDistro(form);
|
||||
const selectedProxyProfile = useMemo(
|
||||
() => proxyProfiles.find((profile) => profile.id === form.proxyProfileId),
|
||||
[form.proxyProfileId, proxyProfiles],
|
||||
);
|
||||
const hasMissingProxyProfile = Boolean(form.proxyProfileId && !selectedProxyProfile);
|
||||
const proxySummaryType = hasMissingProxyProfile
|
||||
? t("hostDetails.proxyPanel.missing")
|
||||
: (selectedProxyProfile?.config.type || form.proxyConfig?.type || "http").toUpperCase();
|
||||
const proxySummaryLabel = hasMissingProxyProfile
|
||||
? t("hostDetails.proxyPanel.missingSaved")
|
||||
: selectedProxyProfile
|
||||
? selectedProxyProfile.label
|
||||
: `${form.proxyConfig?.host}:${form.proxyConfig?.port}`;
|
||||
const proxySummaryTooltip = hasMissingProxyProfile
|
||||
? t("hostDetails.proxyPanel.missingSaved")
|
||||
: selectedProxyProfile
|
||||
? `${selectedProxyProfile.label} - ${selectedProxyProfile.config.host}:${selectedProxyProfile.config.port}`
|
||||
: `${form.proxyConfig?.type?.toUpperCase()} ${form.proxyConfig?.host}:${form.proxyConfig?.port}`;
|
||||
|
||||
const handleDistroModeChange = useCallback((mode: "auto" | "manual") => {
|
||||
setForm((prev) => ({
|
||||
@@ -274,27 +350,38 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
|
||||
const updateProxyConfig = useCallback(
|
||||
(field: keyof ProxyConfig, value: string | number) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
proxyConfig: {
|
||||
type: prev.proxyConfig?.type || "http",
|
||||
host: prev.proxyConfig?.host || "",
|
||||
port: prev.proxyConfig?.port || 8080,
|
||||
...prev.proxyConfig,
|
||||
[field]: value,
|
||||
},
|
||||
}));
|
||||
setForm((prev) => {
|
||||
const { proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
return {
|
||||
...rest,
|
||||
proxyConfig: {
|
||||
type: prev.proxyConfig?.type || "http",
|
||||
host: prev.proxyConfig?.host || "",
|
||||
port: prev.proxyConfig?.port || 8080,
|
||||
...prev.proxyConfig,
|
||||
[field]: value,
|
||||
},
|
||||
} as Host;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clearProxyConfig = useCallback(() => {
|
||||
setForm((prev) => {
|
||||
const { proxyConfig: _proxyConfig, ...rest } = prev;
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
return rest as Host;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectProxyProfile = useCallback((profileId: string | undefined) => {
|
||||
setForm((prev) => {
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
if (!profileId) return rest as Host;
|
||||
return { ...rest, proxyProfileId: profileId } as Host;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addHostToChain = (hostId: string) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
@@ -342,6 +429,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!form.hostname) return;
|
||||
const normalizedProxyConfig = normalizeManualProxyConfig(form.proxyConfig);
|
||||
if (normalizedProxyConfig && !isCompleteProxyConfig(normalizedProxyConfig)) {
|
||||
toast.error(
|
||||
normalizedProxyConfig.host ? t("proxyProfiles.error.port") : t("hostDetails.proxyPanel.error.required"),
|
||||
);
|
||||
setActiveSubPanel("proxy");
|
||||
return;
|
||||
}
|
||||
if (hasMissingProxyProfile) {
|
||||
toast.error(t("hostDetails.proxyPanel.missingSaved"));
|
||||
setActiveSubPanel("proxy");
|
||||
return;
|
||||
}
|
||||
// If label is empty, use hostname as label
|
||||
let finalLabel = form.label?.trim() || form.hostname;
|
||||
const finalGroup = groupInputValue.trim() || form.group || "";
|
||||
@@ -377,16 +477,43 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
finalManagedSourceId = undefined;
|
||||
}
|
||||
|
||||
const cleaned: Host = {
|
||||
...form,
|
||||
const { proxyConfig: _draftProxyConfig, ...formWithoutProxyDraft } = form;
|
||||
const finalPort =
|
||||
form.protocol === "telnet"
|
||||
? form.port
|
||||
: form.port ?? (groupDefaults?.port ? undefined : 22);
|
||||
let cleaned: Host = {
|
||||
...formWithoutProxyDraft,
|
||||
...(normalizedProxyConfig && { proxyConfig: normalizedProxyConfig }),
|
||||
label: finalLabel,
|
||||
group: finalGroup,
|
||||
tags: form.tags || [],
|
||||
port: form.port ?? (groupDefaults?.port ? undefined : 22),
|
||||
port: finalPort,
|
||||
// Clear password if savePassword is explicitly set to false
|
||||
password: form.savePassword === false ? undefined : form.password,
|
||||
managedSourceId: finalManagedSourceId,
|
||||
};
|
||||
cleaned = normalizePrimaryTelnetState(cleaned);
|
||||
if (
|
||||
onImportKey &&
|
||||
pendingReferenceKeyPath &&
|
||||
cleaned.identityFilePaths?.includes(pendingReferenceKeyPath)
|
||||
) {
|
||||
const fileName = pendingReferenceKeyPath.split('/').pop() || pendingReferenceKeyPath;
|
||||
const key = onImportKey({
|
||||
source: 'reference',
|
||||
filePath: pendingReferenceKeyPath,
|
||||
label: fileName,
|
||||
privateKey: '',
|
||||
category: 'key',
|
||||
});
|
||||
cleaned = {
|
||||
...cleaned,
|
||||
identityFileId: key.id,
|
||||
identityFilePaths: [pendingReferenceKeyPath],
|
||||
authMethod: "key",
|
||||
};
|
||||
}
|
||||
const preserveLegacyTheme = initialData?.theme != null && cleaned.themeOverride !== false;
|
||||
const preserveLegacyFontFamily = initialData?.fontFamily != null && cleaned.fontFamilyOverride !== false;
|
||||
const preserveLegacyFontSize = initialData?.fontSize != null && cleaned.fontSizeOverride !== false;
|
||||
@@ -408,6 +535,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
} else if (preserveLegacyFontSize && cleaned.fontSize == null) {
|
||||
cleaned.fontSize = initialData?.fontSize;
|
||||
}
|
||||
|
||||
if ((cleaned.protocol && cleaned.protocol !== "ssh") || cleaned.moshEnabled) {
|
||||
delete cleaned.x11Forwarding;
|
||||
}
|
||||
onSave(cleaned);
|
||||
};
|
||||
|
||||
@@ -499,6 +630,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
identityFileId: undefined,
|
||||
identityFilePaths: undefined,
|
||||
}));
|
||||
setPendingReferenceKeyPath(null);
|
||||
setSelectedCredentialType(null);
|
||||
setCredentialPopoverOpen(false);
|
||||
setIdentitySuggestionsOpen(false);
|
||||
@@ -532,7 +664,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
return (
|
||||
<ProxyPanel
|
||||
proxyConfig={form.proxyConfig}
|
||||
proxyProfiles={proxyProfiles}
|
||||
selectedProxyProfileId={form.proxyProfileId}
|
||||
onUpdateProxy={updateProxyConfig}
|
||||
onSelectProxyProfile={selectProxyProfile}
|
||||
onClearProxy={clearProxyConfig}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
@@ -632,7 +767,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
...(form.protocols || []),
|
||||
{
|
||||
protocol: "telnet" as const,
|
||||
port: form.telnetPort || 23,
|
||||
port: effectiveTelnetPort,
|
||||
enabled: true,
|
||||
theme: themeId,
|
||||
},
|
||||
@@ -1028,6 +1163,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onClick={() => {
|
||||
const paths = form.identityFilePaths?.filter((_, i) => i !== idx) || [];
|
||||
update("identityFilePaths", paths.length > 0 ? paths : undefined);
|
||||
if (keyPath === pendingReferenceKeyPath) {
|
||||
setPendingReferenceKeyPath(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
@@ -1056,6 +1194,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onClick={() => {
|
||||
update("identityFileId", undefined);
|
||||
update("authMethod", "password");
|
||||
setPendingReferenceKeyPath(null);
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
>
|
||||
@@ -1150,6 +1289,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
update("identityFileId", val);
|
||||
update("authMethod", "key");
|
||||
update("identityFilePaths", undefined);
|
||||
setPendingReferenceKeyPath(null);
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
placeholder={t("hostDetails.keys.search")}
|
||||
@@ -1186,6 +1326,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
update("identityFileId", val);
|
||||
update("authMethod", "certificate");
|
||||
update("identityFilePaths", undefined);
|
||||
setPendingReferenceKeyPath(null);
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
placeholder={t("hostDetails.certs.search")}
|
||||
@@ -1221,11 +1362,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newKeyFilePath.trim()) {
|
||||
e.preventDefault();
|
||||
const paths = [...(form.identityFilePaths || []), newKeyFilePath.trim()];
|
||||
update("identityFilePaths", paths);
|
||||
update("identityFileId", undefined);
|
||||
update("authMethod", "key");
|
||||
setNewKeyFilePath("");
|
||||
addLocalKeyFilePath(newKeyFilePath);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -1243,10 +1380,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
[{ name: "All Files", extensions: ["*"] }]
|
||||
);
|
||||
if (filePath) {
|
||||
const paths = [...(form.identityFilePaths || []), filePath];
|
||||
update("identityFilePaths", paths);
|
||||
update("identityFileId", undefined);
|
||||
update("authMethod", "key");
|
||||
addLocalKeyFilePath(filePath);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -1551,11 +1685,15 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
enabled={!!form.moshEnabled}
|
||||
onToggle={() => {
|
||||
const enabling = !form.moshEnabled;
|
||||
if (enabling && form.deviceType === 'network') {
|
||||
// Network device mode is incompatible with Mosh — clear it
|
||||
setForm(prev => ({ ...prev, moshEnabled: true, deviceType: undefined }));
|
||||
if (enabling) {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
moshEnabled: true,
|
||||
deviceType: prev.deviceType === 'network' ? undefined : prev.deviceType,
|
||||
x11Forwarding: undefined,
|
||||
}));
|
||||
} else {
|
||||
update("moshEnabled", enabling);
|
||||
update("moshEnabled", false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -1590,6 +1728,24 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* X11 Forwarding */}
|
||||
{(!form.protocol || form.protocol === "ssh") && !form.moshEnabled && (
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<TerminalSquare size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.section.x11Forwarding")}</p>
|
||||
</div>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.x11Forwarding")}
|
||||
enabled={!!form.x11Forwarding}
|
||||
onToggle={() => update("x11Forwarding", !form.x11Forwarding)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.x11Forwarding.desc")}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Network Device Mode — only for SSH hosts without Mosh (serial already uses raw mode) */}
|
||||
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && (
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
@@ -1651,6 +1807,72 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Per-host keepalive override */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<HeartPulse size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.section.keepalive")}</p>
|
||||
</div>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.keepalive.override")}
|
||||
enabled={!!form.keepaliveOverride}
|
||||
onToggle={() => {
|
||||
const next = !form.keepaliveOverride;
|
||||
update("keepaliveOverride", next);
|
||||
// Seed sensible per-host defaults the first time the user
|
||||
// turns the override on so the inputs aren't empty.
|
||||
if (next) {
|
||||
if (form.keepaliveInterval == null) update("keepaliveInterval", 0);
|
||||
if (form.keepaliveCountMax == null) update("keepaliveCountMax", 3);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground break-words">
|
||||
{t("hostDetails.keepalive.desc")}
|
||||
</p>
|
||||
{form.keepaliveOverride && (
|
||||
<div className="space-y-2 pt-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">{t("hostDetails.keepalive.interval")}</p>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={3600}
|
||||
className="h-8 w-24 rounded-md border border-input bg-background px-2 text-xs"
|
||||
value={form.keepaliveInterval ?? 0}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
if (!Number.isFinite(v)) return;
|
||||
if (v < 0 || v > 3600) return;
|
||||
update("keepaliveInterval", v);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">{t("hostDetails.keepalive.countMax")}</p>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
className="h-8 w-24 rounded-md border border-input bg-background px-2 text-xs"
|
||||
value={form.keepaliveCountMax ?? 3}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
if (!Number.isFinite(v)) return;
|
||||
if (v < 1 || v > 100) return;
|
||||
update("keepaliveCountMax", v);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{(form.keepaliveInterval ?? 0) === 0 && (
|
||||
<p className="text-xs text-muted-foreground break-words pl-1">
|
||||
{t("hostDetails.keepalive.disabledHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -1732,35 +1954,40 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
<Globe size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.proxy")}</p>
|
||||
</div>
|
||||
{form.proxyConfig?.host ? (
|
||||
<button
|
||||
className="w-full min-w-0 grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer overflow-hidden"
|
||||
onClick={() => setActiveSubPanel("proxy")}
|
||||
>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{form.proxyConfig.type?.toUpperCase()}
|
||||
</Badge>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||
{form.proxyConfig.host}:{form.proxyConfig.port}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" className="max-w-xs break-all">
|
||||
{form.proxyConfig.type?.toUpperCase()} {form.proxyConfig.host}:{form.proxyConfig.port}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<X
|
||||
size={14}
|
||||
className="text-muted-foreground hover:text-destructive flex-shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
clearProxyConfig();
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{form.proxyConfig?.host || form.proxyProfileId ? (
|
||||
<div className="w-full min-w-0 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer overflow-hidden"
|
||||
onClick={() => setActiveSubPanel("proxy")}
|
||||
>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{proxySummaryType}
|
||||
</Badge>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||
{proxySummaryLabel}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" className="max-w-xs break-all">
|
||||
{proxySummaryTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-muted-foreground hover:text-destructive shrink-0"
|
||||
aria-label={t("hostDetails.proxyPanel.remove")}
|
||||
onClick={clearProxyConfig}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1843,42 +2070,46 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
{form.telnetEnabled || form.protocol === "telnet" ? (
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 bg-secondary/70 border border-border/70 rounded-md px-2 py-1">
|
||||
<div className="flex-1 min-w-0 h-10 flex items-center gap-2 bg-secondary/70 border border-border/70 rounded-md px-3">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.telnetOn")}</span>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.telnetPort || 23}
|
||||
onChange={(e) => update("telnetPort", Number(e.target.value))}
|
||||
className="h-8 w-16 text-center"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.port")}</span>
|
||||
<div className="ml-auto w-1/2 min-w-0 flex items-center gap-2 justify-end">
|
||||
<Input
|
||||
type="number"
|
||||
value={effectiveTelnetPort}
|
||||
onChange={(e) => update("telnetPort", parseOptionalPortInput(e.target.value))}
|
||||
className="h-8 flex-1 min-w-0 text-center"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.port")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => update("telnetEnabled", false)}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
{form.protocol !== "telnet" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => update("telnetEnabled", false)}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Telnet Credentials */}
|
||||
<p className="text-xs font-semibold">{t("hostDetails.telnet.credentials")}</p>
|
||||
<Input
|
||||
placeholder={t("hostDetails.telnet.username")}
|
||||
value={form.telnetUsername || form.username || ""}
|
||||
onChange={(e) =>
|
||||
update("telnetUsername" as keyof Host, e.target.value)
|
||||
}
|
||||
<Input
|
||||
placeholder={t("hostDetails.telnet.username")}
|
||||
value={effectiveTelnetUsername}
|
||||
onChange={(e) =>
|
||||
update("telnetUsername" as keyof Host, e.target.value)
|
||||
}
|
||||
className="h-10"
|
||||
/>
|
||||
<Input
|
||||
placeholder={t("hostDetails.telnet.password")}
|
||||
type="password"
|
||||
value={form.telnetPassword || form.password || ""}
|
||||
onChange={(e) =>
|
||||
update("telnetPassword" as keyof Host, e.target.value)
|
||||
placeholder={t("hostDetails.telnet.password")}
|
||||
type="password"
|
||||
value={effectiveTelnetPassword}
|
||||
onChange={(e) =>
|
||||
update("telnetPassword" as keyof Host, e.target.value)
|
||||
}
|
||||
className="h-10"
|
||||
/>
|
||||
@@ -1927,7 +2158,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
className="w-full h-10 justify-start gap-2 border border-dashed border-border/60"
|
||||
onClick={() => {
|
||||
update("telnetEnabled", true);
|
||||
update("telnetPort", 23);
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
|
||||
58
components/HostTreeView.test.tsx
Normal file
58
components/HostTreeView.test.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { GroupConfig, Host } from "../types.ts";
|
||||
import { getHostTreeDisplayDetails } from "./HostTreeView.tsx";
|
||||
|
||||
const baseHost: Host = {
|
||||
id: "host-1",
|
||||
label: "Router",
|
||||
hostname: "router.example.com",
|
||||
username: "ssh-user",
|
||||
port: 2222,
|
||||
protocol: "telnet",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
test("HostTreeView display details include inherited telnet defaults", () => {
|
||||
const host: Host = {
|
||||
...baseHost,
|
||||
group: "network",
|
||||
username: "ssh-user",
|
||||
port: 2222,
|
||||
telnetUsername: undefined,
|
||||
telnetPort: undefined,
|
||||
};
|
||||
const groupConfigs: GroupConfig[] = [{
|
||||
path: "network",
|
||||
telnetUsername: "group-telnet-user",
|
||||
telnetPort: 2325,
|
||||
}];
|
||||
|
||||
assert.deepEqual(getHostTreeDisplayDetails(host, groupConfigs), {
|
||||
protocol: "telnet",
|
||||
username: "group-telnet-user",
|
||||
port: 2325,
|
||||
});
|
||||
});
|
||||
|
||||
test("HostTreeView display details keep explicit cleared telnet username", () => {
|
||||
const host: Host = {
|
||||
...baseHost,
|
||||
group: "network",
|
||||
telnetUsername: "",
|
||||
};
|
||||
const groupConfigs: GroupConfig[] = [{
|
||||
path: "network",
|
||||
telnetUsername: "group-telnet-user",
|
||||
telnetPort: 2325,
|
||||
}];
|
||||
|
||||
assert.deepEqual(getHostTreeDisplayDetails(host, groupConfigs), {
|
||||
protocol: "telnet",
|
||||
username: "",
|
||||
port: 2325,
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,11 @@ import { CheckSquare, ChevronRight, Edit2, FileSymlink, Folder, FolderOpen, Moni
|
||||
import React, { useMemo } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useTreeExpandedState } from '../application/state/useTreeExpandedState';
|
||||
import { sanitizeHost } from '../domain/host';
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from '../domain/groupConfig';
|
||||
import { resolveTelnetPort, resolveTelnetUsername, sanitizeHost } from '../domain/host';
|
||||
import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys';
|
||||
import { cn } from '../lib/utils';
|
||||
import { GroupNode, Host } from '../types';
|
||||
import { GroupConfig, GroupNode, Host } from '../types';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
@@ -38,6 +39,7 @@ interface HostTreeViewProps {
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
getDropTargetClasses?: (target: string) => string;
|
||||
setDragOverDropTarget?: (target: string | null) => void;
|
||||
groupConfigs?: GroupConfig[];
|
||||
}
|
||||
|
||||
interface TreeNodeProps {
|
||||
@@ -65,6 +67,7 @@ interface TreeNodeProps {
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
getDropTargetClasses?: (target: string) => string;
|
||||
setDragOverDropTarget?: (target: string | null) => void;
|
||||
groupConfigs: GroupConfig[];
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +96,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
toggleHostSelection,
|
||||
getDropTargetClasses,
|
||||
setDragOverDropTarget,
|
||||
groupConfigs,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isExpanded = expandedPaths.has(node.path);
|
||||
@@ -255,13 +259,14 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
getDropTargetClasses={getDropTargetClasses}
|
||||
setDragOverDropTarget={setDragOverDropTarget}
|
||||
/>
|
||||
))}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
getDropTargetClasses={getDropTargetClasses}
|
||||
setDragOverDropTarget={setDragOverDropTarget}
|
||||
groupConfigs={groupConfigs}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Hosts in this group */}
|
||||
{sortedHosts.map((host) => (
|
||||
@@ -276,11 +281,12 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
/>
|
||||
))}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
groupConfigs={groupConfigs}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
@@ -300,8 +306,28 @@ interface HostTreeItemProps {
|
||||
isMultiSelectMode?: boolean;
|
||||
selectedHostIds?: Set<string>;
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
groupConfigs: GroupConfig[];
|
||||
}
|
||||
|
||||
export const getHostTreeDisplayDetails = (
|
||||
host: Host,
|
||||
groupConfigs: GroupConfig[] = [],
|
||||
) => {
|
||||
const displayHost = host.group
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs))
|
||||
: host;
|
||||
const isTelnet = displayHost.protocol === 'telnet';
|
||||
return {
|
||||
protocol: displayHost.protocol,
|
||||
username: isTelnet
|
||||
? (resolveTelnetUsername(displayHost) || '')
|
||||
: (displayHost.username?.trim() || ''),
|
||||
port: isTelnet
|
||||
? resolveTelnetPort(displayHost)
|
||||
: (displayHost.port ?? 22),
|
||||
};
|
||||
};
|
||||
|
||||
const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
host,
|
||||
depth,
|
||||
@@ -315,18 +341,19 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
toggleHostSelection,
|
||||
groupConfigs,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const paddingLeft = `${depth * 20 + 12}px`;
|
||||
const safeHost = sanitizeHost(host);
|
||||
const tags = host.tags || [];
|
||||
const isTelnet = host.protocol === 'telnet';
|
||||
const displayUsername = isTelnet
|
||||
? (host.telnetUsername?.trim() || host.username?.trim() || '')
|
||||
: (host.username?.trim() || '');
|
||||
const displayPort = isTelnet
|
||||
? (host.telnetPort ?? host.port ?? 23)
|
||||
: (host.port ?? 22);
|
||||
const displayDetails = useMemo(
|
||||
() => getHostTreeDisplayDetails(host, groupConfigs),
|
||||
[groupConfigs, host],
|
||||
);
|
||||
const displayProtocol = displayDetails.protocol;
|
||||
const displayUsername = displayDetails.username;
|
||||
const displayPort = displayDetails.port;
|
||||
const isSelected = isMultiSelectMode && selectedHostIds?.has(host.id);
|
||||
|
||||
return (
|
||||
@@ -371,11 +398,11 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{host.protocol && host.protocol !== 'ssh' && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-primary/10 text-primary rounded">
|
||||
{host.protocol.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
{displayProtocol && displayProtocol !== 'ssh' && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-primary/10 text-primary rounded">
|
||||
{displayProtocol.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
{tags.length > 0 && (
|
||||
<span className="text-xs opacity-60">
|
||||
{tags.slice(0, 2).join(', ')}
|
||||
@@ -445,6 +472,7 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
toggleHostSelection,
|
||||
getDropTargetClasses,
|
||||
setDragOverDropTarget,
|
||||
groupConfigs = [],
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -568,9 +596,10 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
getDropTargetClasses={getDropTargetClasses}
|
||||
setDragOverDropTarget={setDragOverDropTarget}
|
||||
/>
|
||||
getDropTargetClasses={getDropTargetClasses}
|
||||
setDragOverDropTarget={setDragOverDropTarget}
|
||||
groupConfigs={groupConfigs}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Ungrouped hosts at root level */}
|
||||
@@ -586,9 +615,10 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
/>
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
groupConfigs={groupConfigs}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Empty state */}
|
||||
|
||||
@@ -3,6 +3,9 @@ import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Edit2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileKey,
|
||||
Info,
|
||||
Key,
|
||||
LayoutGrid,
|
||||
@@ -18,11 +21,12 @@ import {
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import { resolveHostAuth } from "../domain/sshAuth";
|
||||
import { sanitizeCredentialValue } from "../domain/credentials";
|
||||
import { resolveBridgeKeyAuth, resolveHostAuth } from "../domain/sshAuth";
|
||||
import { STORAGE_KEY_VAULT_KEYS_VIEW_MODE } from "../infrastructure/config/storageKeys";
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host, Identity, KeyType, SSHKey } from "../types";
|
||||
import { Host, Identity, KeyType, ProxyProfile, SSHKey } from "../types";
|
||||
import { ManagedSource } from "../domain/models";
|
||||
import { useKeychainBackend } from "../application/state/useKeychainBackend";
|
||||
import SelectHostPanel from "./SelectHostPanel";
|
||||
@@ -68,6 +72,7 @@ interface KeychainManagerProps {
|
||||
keys: SSHKey[];
|
||||
identities?: Identity[];
|
||||
hosts?: Host[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
customGroups?: string[];
|
||||
managedSources?: ManagedSource[];
|
||||
onSave: (key: SSHKey) => void;
|
||||
@@ -84,6 +89,7 @@ const KeychainManager: React.FC<KeychainManagerProps> = ({
|
||||
keys,
|
||||
identities = [],
|
||||
hosts = [],
|
||||
proxyProfiles = [],
|
||||
customGroups = [],
|
||||
managedSources = [],
|
||||
onSave,
|
||||
@@ -173,7 +179,7 @@ echo $3 >> "$FILE"`);
|
||||
switch (activeFilter) {
|
||||
case "key":
|
||||
result = result.filter(
|
||||
(k) => k.source === "generated" || k.source === "imported",
|
||||
(k) => k.source === "generated" || k.source === "imported" || k.source === "reference",
|
||||
);
|
||||
break;
|
||||
case "certificate":
|
||||
@@ -520,7 +526,7 @@ echo $3 >> "$FILE"`);
|
||||
)}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-3 bg-secondary/80 backdrop-blur border-b border-border/50 shrink-0">
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-3 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm border-b border-border/50 shrink-0">
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* KEY button with split interaction: left=switch view, right=dropdown */}
|
||||
@@ -1027,16 +1033,26 @@ echo $3 >> "$FILE"`);
|
||||
keys,
|
||||
identities,
|
||||
});
|
||||
const exportKeyAuth = resolveBridgeKeyAuth({
|
||||
key: exportAuth.key,
|
||||
fallbackIdentityFilePaths: exportAuth.authMethod === "password" || exportAuth.keyId
|
||||
? undefined
|
||||
: exportHost.identityFilePaths,
|
||||
passphrase: exportAuth.passphrase,
|
||||
});
|
||||
const exportPassword = sanitizeCredentialValue(exportAuth.password);
|
||||
|
||||
// Need either password or a usable key to run remote command.
|
||||
if (!exportAuth.password && !exportAuth.key?.privateKey) {
|
||||
if (
|
||||
!exportPassword &&
|
||||
!exportKeyAuth.privateKey &&
|
||||
!exportKeyAuth.identityFilePaths?.length
|
||||
) {
|
||||
throw new Error(
|
||||
t("keychain.export.missingCredentials"),
|
||||
);
|
||||
}
|
||||
|
||||
const hostPrivateKey = exportAuth.key?.privateKey;
|
||||
|
||||
// Escape the public key for shell (single quotes, escape existing quotes)
|
||||
const escapedPublicKey = panel.key.publicKey.replace(
|
||||
/'/g,
|
||||
@@ -1057,8 +1073,14 @@ echo $3 >> "$FILE"`);
|
||||
hostname: exportHost.hostname,
|
||||
username: exportAuth.username,
|
||||
port: exportHost.port || 22,
|
||||
password: exportAuth.password,
|
||||
privateKey: hostPrivateKey,
|
||||
password: exportPassword,
|
||||
privateKey: exportKeyAuth.privateKey,
|
||||
certificate: exportAuth.key?.certificate,
|
||||
publicKey: exportAuth.key?.publicKey,
|
||||
keyId: exportAuth.keyId,
|
||||
keySource: exportAuth.key?.source,
|
||||
passphrase: exportKeyAuth.passphrase,
|
||||
identityFilePaths: exportKeyAuth.identityFilePaths,
|
||||
command,
|
||||
timeout: 30000,
|
||||
enableKeyboardInteractive: true,
|
||||
@@ -1138,71 +1160,134 @@ echo $3 >> "$FILE"`);
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-destructive">
|
||||
{t("keychain.edit.privateKeyRequired")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={draftKey.privateKey || ""}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, privateKey: e.target.value })
|
||||
}
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
className="min-h-[180px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.edit.publicKey")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={draftKey.publicKey || ""}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, publicKey: e.target.value })
|
||||
}
|
||||
placeholder="ssh-ed25519 AAAA..."
|
||||
className="min-h-[80px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.edit.certificate")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={draftKey.certificate || ""}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, certificate: e.target.value })
|
||||
}
|
||||
placeholder={t("keychain.edit.certificatePlaceholder")}
|
||||
className="min-h-[60px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Key Export section */}
|
||||
<div className="pt-4 mt-4 border-t border-border/60">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-sm font-medium">
|
||||
{t("keychain.edit.keyExport")}
|
||||
</span>
|
||||
<div className="h-4 w-4 rounded-full bg-muted flex items-center justify-center">
|
||||
<Info size={10} className="text-muted-foreground" />
|
||||
{/* Reference key: show file path read-only */}
|
||||
{draftKey.source === 'reference' && draftKey.filePath && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.edit.filePath")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
|
||||
<FileKey size={14} className="text-primary shrink-0" />
|
||||
<span className="text-xs font-mono truncate" title={draftKey.filePath}>
|
||||
{draftKey.filePath}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full h-11"
|
||||
onClick={() => openKeyExport(panel.key)}
|
||||
>
|
||||
{t("keychain.edit.exportToHost")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Managed key: show private key editor */}
|
||||
{draftKey.source !== 'reference' && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-destructive">
|
||||
{t("keychain.edit.privateKeyRequired")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={draftKey.privateKey || ""}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, privateKey: e.target.value })
|
||||
}
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
className="min-h-[180px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{draftKey.source !== 'reference' && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.edit.publicKey")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={draftKey.publicKey || ""}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, publicKey: e.target.value })
|
||||
}
|
||||
placeholder="ssh-ed25519 AAAA..."
|
||||
className="min-h-[80px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{draftKey.source !== 'reference' && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.edit.certificate")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={draftKey.certificate || ""}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, certificate: e.target.value })
|
||||
}
|
||||
placeholder={t("keychain.edit.certificatePlaceholder")}
|
||||
className="min-h-[60px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Passphrase section */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('terminal.auth.passphrase')}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassphrase ? 'text' : 'password'}
|
||||
value={draftKey.passphrase || ''}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, passphrase: e.target.value })
|
||||
}
|
||||
placeholder={t('keychain.generate.passphrasePlaceholder')}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8"
|
||||
onClick={() => setShowPassphrase(!showPassphrase)}
|
||||
>
|
||||
{showPassphrase ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="editSavePassphrase"
|
||||
checked={draftKey.savePassphrase || false}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, savePassphrase: e.target.checked })
|
||||
}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<Label htmlFor="editSavePassphrase" className="text-sm font-normal cursor-pointer">
|
||||
{t('keychain.generate.savePassphrase')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Export section - only for managed keys */}
|
||||
{draftKey.source !== 'reference' && (
|
||||
<div className="pt-4 mt-4 border-t border-border/60">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-sm font-medium">
|
||||
{t("keychain.edit.keyExport")}
|
||||
</span>
|
||||
<div className="h-4 w-4 rounded-full bg-muted flex items-center justify-center">
|
||||
<Info size={10} className="text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full h-11"
|
||||
onClick={() => openKeyExport(panel.key)}
|
||||
>
|
||||
{t("keychain.edit.exportToHost")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save button */}
|
||||
<Button
|
||||
className="w-full h-11 mt-4"
|
||||
disabled={
|
||||
!draftKey.label?.trim() || !draftKey.privateKey?.trim()
|
||||
!draftKey.label?.trim() ||
|
||||
(draftKey.source !== 'reference' && !draftKey.privateKey?.trim())
|
||||
}
|
||||
onClick={() => {
|
||||
if (draftKey.id) {
|
||||
@@ -1234,6 +1319,7 @@ echo $3 >> "$FILE"`);
|
||||
onBack={() => setShowHostSelector(false)}
|
||||
onContinue={() => setShowHostSelector(false)}
|
||||
availableKeys={keys}
|
||||
proxyProfiles={proxyProfiles}
|
||||
managedSources={managedSources}
|
||||
onSaveHost={onSaveHost}
|
||||
onCreateGroup={onCreateGroup}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { Host } from '../types';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export interface HostKeyInfo {
|
||||
hostname: string;
|
||||
port: number;
|
||||
keyType: string; // ssh-rsa, ssh-ed25519, ecdsa-sha2-nistp256, etc.
|
||||
fingerprint: string; // SHA256 fingerprint
|
||||
publicKey?: string; // Full public key
|
||||
}
|
||||
|
||||
interface KnownHostConfirmDialogProps {
|
||||
host: Host;
|
||||
hostKeyInfo: HostKeyInfo;
|
||||
onClose: () => void;
|
||||
onContinue: () => void; // Continue without adding to known hosts
|
||||
onAddAndContinue: () => void; // Add to known hosts and continue
|
||||
}
|
||||
|
||||
const KnownHostConfirmDialog: React.FC<KnownHostConfirmDialogProps> = ({
|
||||
host,
|
||||
hostKeyInfo,
|
||||
onClose,
|
||||
onContinue,
|
||||
onAddAndContinue,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 max-w-2xl mx-auto">
|
||||
{/* Header with host info */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-12 w-12" />
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">{host.label}</h2>
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
SSH {host.hostname}:{host.port || 22}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="ml-4">
|
||||
Show logs
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Progress indicator */}
|
||||
<div className="flex items-center gap-3 w-full max-w-md mb-8">
|
||||
<div className="h-8 w-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center">
|
||||
<div className="h-2 w-2 rounded-full bg-primary-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 h-0.5 bg-primary" />
|
||||
<div className="h-8 w-8 rounded-full bg-primary/20 border-2 border-primary text-primary flex items-center justify-center">
|
||||
<ShieldCheck size={14} />
|
||||
</div>
|
||||
<div className="flex-1 h-0.5 bg-muted" />
|
||||
<div className="h-8 w-8 rounded-full bg-muted text-muted-foreground flex items-center justify-center text-xs font-mono">
|
||||
{'>_'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warning message */}
|
||||
<div className="text-center mb-6">
|
||||
<h3 className="text-lg font-semibold text-amber-500 mb-2">
|
||||
Are you sure you want to connect?
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The authenticity of <span className="font-mono font-medium text-foreground">{hostKeyInfo.hostname}</span> can not be established.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Fingerprint info */}
|
||||
<div className="w-full max-w-md space-y-3 mb-8">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">{hostKeyInfo.keyType} fingerprint is SHA256:</span>
|
||||
</div>
|
||||
<div className="bg-secondary/80 rounded-lg p-3 border border-border/60">
|
||||
<code className="text-sm font-mono text-foreground break-all">
|
||||
{hostKeyInfo.fingerprint}
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Do you want to add it to the list of known hosts?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="min-w-[100px]"
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-w-[100px]"
|
||||
onClick={onContinue}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
<Button
|
||||
className="min-w-[140px]"
|
||||
onClick={onAddAndContinue}
|
||||
>
|
||||
Add and continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KnownHostConfirmDialog;
|
||||
@@ -84,7 +84,7 @@ const parseKnownHostsFile = (content: string): KnownHost[] => {
|
||||
hostname,
|
||||
port,
|
||||
keyType,
|
||||
publicKey: publicKey.slice(0, 64) + "...",
|
||||
publicKey: `${keyType} ${publicKey}`,
|
||||
discoveredAt: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
@@ -455,7 +455,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-3 border-b border-border/50 bg-secondary/80 backdrop-blur">
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-3 border-b border-border/50 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm">
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface PassphraseRequest {
|
||||
|
||||
interface PassphraseModalProps {
|
||||
request: PassphraseRequest | null;
|
||||
onSubmit: (requestId: string, passphrase: string) => void;
|
||||
onSubmit: (requestId: string, passphrase: string, remember: boolean) => void;
|
||||
onCancel: (requestId: string) => void;
|
||||
onSkip?: (requestId: string) => void;
|
||||
}
|
||||
@@ -40,6 +40,7 @@ export const PassphraseModal: React.FC<PassphraseModalProps> = ({
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [showPassphrase, setShowPassphrase] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [rememberPassphrase, setRememberPassphrase] = useState(true);
|
||||
|
||||
// Reset state when request changes
|
||||
useEffect(() => {
|
||||
@@ -47,14 +48,15 @@ export const PassphraseModal: React.FC<PassphraseModalProps> = ({
|
||||
setPassphrase("");
|
||||
setShowPassphrase(false);
|
||||
setIsSubmitting(false);
|
||||
setRememberPassphrase(true);
|
||||
}
|
||||
}, [request]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!request || isSubmitting || !passphrase) return;
|
||||
setIsSubmitting(true);
|
||||
onSubmit(request.requestId, passphrase);
|
||||
}, [request, passphrase, onSubmit, isSubmitting]);
|
||||
onSubmit(request.requestId, passphrase, rememberPassphrase);
|
||||
}, [request, passphrase, onSubmit, isSubmitting, rememberPassphrase]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (!request) return;
|
||||
@@ -82,15 +84,15 @@ export const PassphraseModal: React.FC<PassphraseModalProps> = ({
|
||||
|
||||
return (
|
||||
<Dialog open={!!request} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="sm:max-w-[425px]" hideCloseButton>
|
||||
<DialogContent className="sm:max-w-[500px]" hideCloseButton>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<KeyRound className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<DialogTitle>{t("passphrase.title")}</DialogTitle>
|
||||
<DialogDescription className="mt-1">
|
||||
<DialogDescription className="mt-1 break-words">
|
||||
{request.hostname
|
||||
? t("passphrase.descWithHost", { keyName: keyDisplayName, hostname: request.hostname })
|
||||
: t("passphrase.desc", { keyName: keyDisplayName })}
|
||||
@@ -125,9 +127,21 @@ export const PassphraseModal: React.FC<PassphraseModalProps> = ({
|
||||
{showPassphrase ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("passphrase.keyPath")}: <code className="text-xs">{request.keyPath}</code>
|
||||
<p className="text-xs text-muted-foreground break-all">
|
||||
{t("passphrase.keyPath")}: <code className="text-xs break-all">{request.keyPath}</code>
|
||||
</p>
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none mt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberPassphrase}
|
||||
onChange={(e) => setRememberPassphrase(e.target.checked)}
|
||||
disabled={isSubmitting}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("passphrase.remember")}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Shuffle,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
||||
import {
|
||||
@@ -19,9 +19,11 @@ import {
|
||||
ManagedSource,
|
||||
PortForwardingRule,
|
||||
PortForwardingType,
|
||||
ProxyProfile,
|
||||
SSHKey,
|
||||
} from "../domain/models";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
|
||||
import { cn } from "../lib/utils";
|
||||
import SelectHostPanel from "./SelectHostPanel";
|
||||
import {
|
||||
@@ -69,9 +71,11 @@ interface PortForwardingProps {
|
||||
customGroups: string[];
|
||||
managedSources?: ManagedSource[];
|
||||
groupConfigs?: GroupConfig[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
onNewHost?: () => void;
|
||||
onSaveHost?: (host: Host) => void;
|
||||
onCreateGroup?: (groupPath: string) => void;
|
||||
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
|
||||
}
|
||||
|
||||
const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
@@ -81,9 +85,11 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
customGroups: _customGroups,
|
||||
managedSources = [],
|
||||
groupConfigs = [],
|
||||
proxyProfiles = [],
|
||||
onNewHost: _onNewHost,
|
||||
onSaveHost,
|
||||
onCreateGroup: _onCreateGroup,
|
||||
terminalSettings,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
@@ -113,6 +119,20 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
const [pendingOperations, setPendingOperations] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const proxyProfileIdSet = useMemo(
|
||||
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
[proxyProfiles],
|
||||
);
|
||||
|
||||
const resolveEffectiveHost = useCallback(
|
||||
(host: Host): Host => {
|
||||
const withGroupDefaults = host.group
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
},
|
||||
[groupConfigs, proxyProfileIdSet, proxyProfiles],
|
||||
);
|
||||
|
||||
// Start a port forwarding tunnel
|
||||
const handleStartTunnel = useCallback(
|
||||
@@ -127,9 +147,8 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const _host = _rawHost.group
|
||||
? applyGroupDefaults(_rawHost, resolveGroupDefaults(_rawHost.group, groupConfigs))
|
||||
: _rawHost;
|
||||
const _host = resolveEffectiveHost(_rawHost);
|
||||
const effectiveHosts = hosts.map((host) => resolveEffectiveHost(host));
|
||||
|
||||
setPendingOperations((prev) => new Set([...prev, rule.id]));
|
||||
let errorShown = false;
|
||||
@@ -138,7 +157,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
const result = await startTunnel(
|
||||
rule,
|
||||
_host,
|
||||
hosts,
|
||||
effectiveHosts,
|
||||
keys,
|
||||
identities,
|
||||
(status, error) => {
|
||||
@@ -152,6 +171,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
}
|
||||
},
|
||||
rule.autoStart, // Enable reconnect for auto-start rules
|
||||
terminalSettings,
|
||||
);
|
||||
// Show error from result only if not already shown
|
||||
if (!result.success && result.error && !errorShown) {
|
||||
@@ -169,7 +189,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
});
|
||||
}
|
||||
},
|
||||
[hosts, identities, keys, groupConfigs, setRuleStatus, startTunnel, t],
|
||||
[hosts, identities, keys, resolveEffectiveHost, setRuleStatus, startTunnel, t, terminalSettings],
|
||||
);
|
||||
|
||||
// Stop a port forwarding tunnel
|
||||
@@ -567,7 +587,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
)}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-3 bg-secondary/80 backdrop-blur border-b border-border/50 relative z-20">
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-3 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm border-b border-border/50 relative z-20">
|
||||
<Dropdown open={showNewMenu} onOpenChange={setShowNewMenu}>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
@@ -853,6 +873,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
onContinue={() => setShowHostSelector(false)}
|
||||
availableKeys={keys}
|
||||
identities={identities}
|
||||
proxyProfiles={proxyProfiles}
|
||||
managedSources={managedSources}
|
||||
onSaveHost={onSaveHost}
|
||||
onCreateGroup={_onCreateGroup}
|
||||
|
||||
80
components/ProxyPanel.test.tsx
Normal file
80
components/ProxyPanel.test.tsx
Normal 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=""/);
|
||||
});
|
||||
85
components/ProxyProfilesManager.test.tsx
Normal file
85
components/ProxyProfilesManager.test.tsx
Normal 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);
|
||||
});
|
||||
538
components/ProxyProfilesManager.tsx
Normal file
538
components/ProxyProfilesManager.tsx
Normal 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;
|
||||
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* ScriptsSidePanel - Lightweight scripts browser for the terminal side panel
|
||||
*
|
||||
* Shows snippets organized by package hierarchy with breadcrumb navigation.
|
||||
* Clicking a snippet executes it in the focused terminal session.
|
||||
* Shows snippets organized by package hierarchy as a single tree view.
|
||||
* Packages expand / collapse via a chevron; clicking a snippet executes it
|
||||
* in the focused terminal session. Typing in the search box flattens to a
|
||||
* list of matching snippets regardless of package nesting.
|
||||
*/
|
||||
|
||||
import { ChevronRight, Edit2, Package, Plus, Search, Trash2, Zap } from 'lucide-react';
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { ChevronRight, Edit2, FileCode, Package, Plus, Search, Trash2, Zap } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Snippet } from '../types';
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
} from './ui/context-menu';
|
||||
import { Input } from './ui/input';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||
|
||||
interface ScriptsSidePanelProps {
|
||||
snippets: Snippet[];
|
||||
@@ -26,6 +29,33 @@ interface ScriptsSidePanelProps {
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
type TreeRow =
|
||||
| {
|
||||
type: 'package';
|
||||
id: string;
|
||||
path: string;
|
||||
name: string;
|
||||
depth: number;
|
||||
count: number;
|
||||
hasChildren: boolean;
|
||||
isExpanded: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'snippet';
|
||||
id: string;
|
||||
depth: number;
|
||||
snippet: Snippet;
|
||||
packagePath: string;
|
||||
};
|
||||
|
||||
const pkgDisplayName = (path: string) => {
|
||||
const clean = path.startsWith('/') ? path.slice(1) : path;
|
||||
const last = clean.split('/').filter(Boolean).pop() ?? clean;
|
||||
// Preserve the leading slash on absolute root packages so they stay
|
||||
// distinguishable from relative ones (matches the previous breadcrumb UI).
|
||||
return path.startsWith('/') && !clean.includes('/') ? `/${last}` : last;
|
||||
};
|
||||
|
||||
const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
snippets,
|
||||
packages,
|
||||
@@ -33,97 +63,151 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
isVisible = true,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [selectedPackage, setSelectedPackage] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
|
||||
|
||||
const displayedPackages = useMemo(() => {
|
||||
if (!selectedPackage) {
|
||||
const absolutePaths = packages.filter(p => p.startsWith('/'));
|
||||
const relativePaths = packages.filter(p => !p.startsWith('/'));
|
||||
// Normalize the package list + derive ancestor packages implied by each path
|
||||
// (e.g. package "a/b/c" implies roots "a" and "a/b" even when not listed).
|
||||
const normalizedPackages = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
const addWithAncestors = (raw: string) => {
|
||||
const path = raw.trim();
|
||||
if (!path) return;
|
||||
const isAbs = path.startsWith('/');
|
||||
const body = isAbs ? path.slice(1) : path;
|
||||
const parts = body.split('/').filter(Boolean);
|
||||
for (let i = 1; i <= parts.length; i++) {
|
||||
const sub = parts.slice(0, i).join('/');
|
||||
set.add(isAbs ? `/${sub}` : sub);
|
||||
}
|
||||
};
|
||||
packages.forEach(addWithAncestors);
|
||||
// A snippet may reference a package path that's not in `packages` yet.
|
||||
snippets.forEach((s) => {
|
||||
if (s.package) addWithAncestors(s.package);
|
||||
});
|
||||
return set;
|
||||
}, [packages, snippets]);
|
||||
|
||||
const results: { name: string; path: string; count: number }[] = [];
|
||||
// Track every package we've ever observed so we can tell "new" from
|
||||
// "previously-seen-but-user-collapsed". Without this, any unrelated refresh
|
||||
// that reduced prev.size (because the user collapsed a row) would
|
||||
// incorrectly trip a bulk re-expand.
|
||||
const seenPackagesRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const relativeRoots = relativePaths
|
||||
.map((p) => p.split('/')[0])
|
||||
.filter((name): name is string => Boolean(name) && name.length > 0);
|
||||
// Default: auto-expand packages the first time they appear, so the user sees
|
||||
// everything without drilling in. After that, respect the user's collapse
|
||||
// choices across unrelated refreshes.
|
||||
useEffect(() => {
|
||||
const seen = seenPackagesRef.current;
|
||||
const newlySeen: string[] = [];
|
||||
normalizedPackages.forEach((p) => {
|
||||
if (!seen.has(p)) {
|
||||
seen.add(p);
|
||||
newlySeen.push(p);
|
||||
}
|
||||
});
|
||||
if (newlySeen.length === 0) return;
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
newlySeen.forEach((p) => next.add(p));
|
||||
return next;
|
||||
});
|
||||
}, [normalizedPackages]);
|
||||
|
||||
Array.from(new Set(relativeRoots)).forEach((name: string) => {
|
||||
const path: string = name;
|
||||
const count = snippets.filter((s) => {
|
||||
const pkg = s.package || '';
|
||||
return pkg === path || pkg.startsWith(path + '/');
|
||||
}).length;
|
||||
results.push({ name, path, count });
|
||||
});
|
||||
const togglePackage = useCallback((path: string) => {
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const absoluteRoots = absolutePaths
|
||||
.map((p) => {
|
||||
const cleanPath = p.substring(1);
|
||||
return cleanPath.split('/')[0];
|
||||
// When search is active, flatten everything (no tree, no packages).
|
||||
const searchMatches = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return null;
|
||||
return snippets.filter(
|
||||
(s) =>
|
||||
s.label.toLowerCase().includes(q) ||
|
||||
s.command.toLowerCase().includes(q),
|
||||
);
|
||||
}, [snippets, search]);
|
||||
|
||||
const rows = useMemo<TreeRow[]>(() => {
|
||||
if (searchMatches !== null) return [];
|
||||
|
||||
const out: TreeRow[] = [];
|
||||
const paths: string[] = [];
|
||||
normalizedPackages.forEach((p) => paths.push(p));
|
||||
|
||||
const childPackagesOf = (parent: string | null): string[] => {
|
||||
const prefix = parent === null ? '' : parent + '/';
|
||||
return paths
|
||||
.filter((p) => {
|
||||
if (parent === null) {
|
||||
// Root-level: no "/" inside the body
|
||||
const body = p.startsWith('/') ? p.slice(1) : p;
|
||||
return !body.includes('/');
|
||||
}
|
||||
if (!p.startsWith(prefix)) return false;
|
||||
const rest = p.slice(prefix.length);
|
||||
return rest.length > 0 && !rest.includes('/');
|
||||
})
|
||||
.filter((name): name is string => Boolean(name) && name.length > 0);
|
||||
.sort((a, b) => pkgDisplayName(a).localeCompare(pkgDisplayName(b)));
|
||||
};
|
||||
|
||||
Array.from(new Set(absoluteRoots)).forEach((name: string) => {
|
||||
const path: string = `/${name}`;
|
||||
const displayName: string = `/${name}`;
|
||||
const count = snippets.filter((s) => {
|
||||
const pkg = s.package || '';
|
||||
return pkg === path || pkg.startsWith(path + '/');
|
||||
}).length;
|
||||
results.push({ name: displayName, path, count });
|
||||
const snippetsIn = (pkg: string | null): Snippet[] =>
|
||||
snippets
|
||||
.filter((s) => (s.package || '') === (pkg ?? ''))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
const countDescendants = (pkg: string): number =>
|
||||
snippets.filter((s) => {
|
||||
const sp = s.package || '';
|
||||
return sp === pkg || sp.startsWith(pkg + '/');
|
||||
}).length;
|
||||
|
||||
const walk = (pkg: string, depth: number) => {
|
||||
const children = childPackagesOf(pkg);
|
||||
const localSnippets = snippetsIn(pkg);
|
||||
const hasChildren = children.length > 0 || localSnippets.length > 0;
|
||||
const isExpanded = expandedPaths.has(pkg);
|
||||
|
||||
out.push({
|
||||
type: 'package',
|
||||
id: pkg,
|
||||
path: pkg,
|
||||
name: pkgDisplayName(pkg),
|
||||
depth,
|
||||
count: countDescendants(pkg),
|
||||
hasChildren,
|
||||
isExpanded,
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
const prefix = selectedPackage + '/';
|
||||
const children = packages
|
||||
.filter((p) => p.startsWith(prefix))
|
||||
.map((p) => p.replace(prefix, '').split('/')[0])
|
||||
.filter((name): name is string => Boolean(name) && name.length > 0);
|
||||
return Array.from(new Set(children)).map((name) => {
|
||||
const path = `${selectedPackage}/${name}`;
|
||||
const count = snippets.filter((s) => {
|
||||
const pkg = s.package || '';
|
||||
return pkg === path || pkg.startsWith(path + '/');
|
||||
}).length;
|
||||
return { name, path, count };
|
||||
});
|
||||
}, [packages, selectedPackage, snippets]);
|
||||
|
||||
const displayedSnippets = useMemo(() => {
|
||||
let result = snippets.filter((s) => (s.package || '') === (selectedPackage || ''));
|
||||
if (search.trim()) {
|
||||
const s = search.toLowerCase();
|
||||
result = result.filter(sn =>
|
||||
sn.label.toLowerCase().includes(s) ||
|
||||
sn.command.toLowerCase().includes(s)
|
||||
if (!isExpanded) return;
|
||||
children.forEach((c) => walk(c, depth + 1));
|
||||
localSnippets.forEach((s) =>
|
||||
out.push({ type: 'snippet', id: s.id, depth: depth + 1, snippet: s, packagePath: pkg }),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [snippets, selectedPackage, search]);
|
||||
};
|
||||
|
||||
// Also filter packages by search when at root level
|
||||
const filteredPackages = useMemo(() => {
|
||||
if (!search.trim()) return displayedPackages;
|
||||
const s = search.toLowerCase();
|
||||
return displayedPackages.filter(pkg => pkg.name.toLowerCase().includes(s));
|
||||
}, [displayedPackages, search]);
|
||||
// Orphan / uncategorized snippets first (package === '')
|
||||
snippetsIn(null).forEach((s) =>
|
||||
out.push({ type: 'snippet', id: s.id, depth: 0, snippet: s, packagePath: '' }),
|
||||
);
|
||||
childPackagesOf(null).forEach((root) => walk(root, 0));
|
||||
|
||||
const breadcrumb = useMemo(() => {
|
||||
if (!selectedPackage) return [];
|
||||
const isAbsolute = selectedPackage.startsWith('/');
|
||||
const parts = selectedPackage.split('/').filter(Boolean);
|
||||
return parts.map((name, idx) => {
|
||||
const pathSegments = parts.slice(0, idx + 1);
|
||||
const path = isAbsolute ? `/${pathSegments.join('/')}` : pathSegments.join('/');
|
||||
return { name, path };
|
||||
});
|
||||
}, [selectedPackage]);
|
||||
return out;
|
||||
}, [normalizedPackages, snippets, expandedPaths, searchMatches]);
|
||||
|
||||
const handleSnippetClick = useCallback((command: string, noAutoRun?: boolean) => {
|
||||
onSnippetClick(command, noAutoRun);
|
||||
}, [onSnippetClick]);
|
||||
const handleSnippetClick = useCallback(
|
||||
(command: string, noAutoRun?: boolean) => {
|
||||
onSnippetClick(command, noAutoRun);
|
||||
},
|
||||
[onSnippetClick],
|
||||
);
|
||||
|
||||
const handleAddSnippet = useCallback(() => {
|
||||
// Let the App shell listen and navigate to the Snippets section with
|
||||
@@ -149,6 +233,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
const hasAnyContent = snippets.length > 0 || packages.length > 0;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div
|
||||
className="h-full flex flex-col bg-background overflow-hidden"
|
||||
data-section="snippets-panel"
|
||||
@@ -175,30 +260,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="shrink-0 flex items-center gap-1 px-3 py-1.5 text-[11px] border-b border-border/30 min-h-[28px]">
|
||||
<button
|
||||
className={cn(
|
||||
"hover:text-primary transition-colors truncate",
|
||||
!selectedPackage ? "text-foreground font-medium" : "text-muted-foreground"
|
||||
)}
|
||||
onClick={() => setSelectedPackage(null)}
|
||||
>
|
||||
{t('terminal.toolbar.library')}
|
||||
</button>
|
||||
{breadcrumb.map((b) => (
|
||||
<React.Fragment key={b.path}>
|
||||
<ChevronRight size={10} className="text-muted-foreground shrink-0" />
|
||||
<button
|
||||
className="text-muted-foreground hover:text-primary transition-colors truncate"
|
||||
onClick={() => setSelectedPackage(b.path)}
|
||||
>
|
||||
{b.name}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="py-1">
|
||||
@@ -209,55 +270,47 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Packages */}
|
||||
{filteredPackages.map((pkg) => (
|
||||
<button
|
||||
key={pkg.path}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-accent/50 transition-colors"
|
||||
onClick={() => { setSelectedPackage(pkg.path); setSearch(''); }}
|
||||
>
|
||||
<div className="w-6 h-6 rounded-md bg-primary/10 text-primary flex items-center justify-center shrink-0">
|
||||
<Package size={12} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium truncate">{pkg.name}</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{t('snippets.package.count', { count: pkg.count })}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={12} className="text-muted-foreground shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
{/* Search flat list */}
|
||||
{searchMatches !== null && searchMatches.length > 0 &&
|
||||
searchMatches.map((s) => (
|
||||
<SnippetRow
|
||||
key={s.id}
|
||||
snippet={s}
|
||||
depth={0}
|
||||
subtitle={s.package || t('terminal.toolbar.library')}
|
||||
onClick={() => handleSnippetClick(s.command, s.noAutoRun)}
|
||||
onEdit={() => handleEditSnippet(s)}
|
||||
onDelete={() => handleDeleteSnippet(s.id)}
|
||||
editLabel={t('action.edit')}
|
||||
deleteLabel={t('action.delete')}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Snippets */}
|
||||
{displayedSnippets.map((s) => (
|
||||
<ContextMenu key={s.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<button
|
||||
onClick={() => handleSnippetClick(s.command, s.noAutoRun)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-accent/50 transition-colors flex flex-col gap-0.5"
|
||||
>
|
||||
<span className="text-xs font-medium truncate">{s.label}</span>
|
||||
<span className="text-muted-foreground truncate font-mono text-[10px] max-w-full">
|
||||
{s.command}
|
||||
</span>
|
||||
</button>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => handleEditSnippet(s)}>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => handleDeleteSnippet(s.id)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
{/* Tree */}
|
||||
{searchMatches === null &&
|
||||
rows.map((row) =>
|
||||
row.type === 'package' ? (
|
||||
<PackageRow
|
||||
key={`pkg:${row.id}`}
|
||||
row={row}
|
||||
countLabel={t('snippets.package.count', { count: row.count })}
|
||||
onToggle={() => togglePackage(row.path)}
|
||||
/>
|
||||
) : (
|
||||
<SnippetRow
|
||||
key={`snip:${row.id}`}
|
||||
snippet={row.snippet}
|
||||
depth={row.depth}
|
||||
onClick={() => handleSnippetClick(row.snippet.command, row.snippet.noAutoRun)}
|
||||
onEdit={() => handleEditSnippet(row.snippet)}
|
||||
onDelete={() => handleDeleteSnippet(row.snippet.id)}
|
||||
editLabel={t('action.edit')}
|
||||
deleteLabel={t('action.delete')}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
{hasAnyContent && displayedSnippets.length === 0 && filteredPackages.length === 0 && search.trim() && (
|
||||
{hasAnyContent && searchMatches !== null && searchMatches.length === 0 && (
|
||||
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
|
||||
{t('common.noResultsFound')}
|
||||
</div>
|
||||
@@ -265,8 +318,100 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
interface PackageRowProps {
|
||||
row: Extract<TreeRow, { type: 'package' }>;
|
||||
countLabel: string;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
const PackageRow: React.FC<PackageRowProps> = ({ row, countLabel, onToggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center gap-1.5 pr-3 py-1.5 text-left hover:bg-accent/50 transition-colors"
|
||||
style={{ paddingLeft: 8 + row.depth * 14 }}
|
||||
>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className={cn(
|
||||
'shrink-0 text-muted-foreground transition-transform',
|
||||
row.isExpanded && 'rotate-90',
|
||||
!row.hasChildren && 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
<Package size={12} className="shrink-0 text-primary/80" />
|
||||
<span className="flex-1 min-w-0 truncate text-xs font-medium">{row.name}</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">{countLabel}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
interface SnippetRowProps {
|
||||
snippet: Snippet;
|
||||
depth: number;
|
||||
subtitle?: string;
|
||||
onClick: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
editLabel: string;
|
||||
deleteLabel: string;
|
||||
}
|
||||
|
||||
const SnippetRow: React.FC<SnippetRowProps> = ({
|
||||
snippet,
|
||||
depth,
|
||||
subtitle,
|
||||
onClick,
|
||||
onEdit,
|
||||
onDelete,
|
||||
editLabel,
|
||||
deleteLabel,
|
||||
}) => (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="w-full flex items-center gap-1.5 pr-3 py-1.5 text-left hover:bg-accent/50 transition-colors overflow-hidden"
|
||||
style={{ paddingLeft: 8 + depth * 14 }}
|
||||
>
|
||||
{/* Hidden chevron column mirrors PackageRow's layout so the
|
||||
snippet icon lines up exactly with the package icon above. */}
|
||||
<ChevronRight size={12} className="shrink-0 opacity-0" aria-hidden />
|
||||
<FileCode size={12} className="shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 min-w-0 truncate text-xs font-medium">{snippet.label}</span>
|
||||
{subtitle && (
|
||||
<span className="shrink-0 max-w-[40%] truncate text-[10px] text-muted-foreground">
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" align="start" className="max-w-[480px]">
|
||||
<div className="font-medium text-xs mb-1 break-all">{snippet.label}</div>
|
||||
<pre className="font-mono text-[11px] whitespace-pre-wrap break-all leading-snug opacity-90">
|
||||
{snippet.command}
|
||||
</pre>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={onEdit}>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {editLabel}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={onDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {deleteLabel}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
export const ScriptsSidePanel = memo(ScriptsSidePanelInner);
|
||||
ScriptsSidePanel.displayName = 'ScriptsSidePanel';
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
72
components/SftpPaneFileList.test.tsx
Normal file
72
components/SftpPaneFileList.test.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { SftpFileEntry } from "../types.ts";
|
||||
import {
|
||||
getSftpListUploadFilesTargetPath,
|
||||
getSftpTreeUploadFilesTargetPath,
|
||||
getSftpUploadFilesLabelKey,
|
||||
getSftpUploadFolderLabelKey,
|
||||
shouldShowSftpUploadFolderMenu,
|
||||
shouldShowSftpUploadFilesMenu,
|
||||
} from "./sftp/sftpUploadMenu.ts";
|
||||
|
||||
const baseEntry: SftpFileEntry = {
|
||||
name: "notes.txt",
|
||||
type: "file",
|
||||
size: 1,
|
||||
sizeFormatted: "1 B",
|
||||
lastModified: 1,
|
||||
lastModifiedFormatted: "now",
|
||||
};
|
||||
|
||||
test("upload file menu is shown only for remote panes with a picker upload handler", () => {
|
||||
assert.equal(shouldShowSftpUploadFilesMenu({ isLocal: false, hasFileListUpload: true }), true);
|
||||
assert.equal(shouldShowSftpUploadFilesMenu({ isLocal: true, hasFileListUpload: true }), false);
|
||||
assert.equal(shouldShowSftpUploadFilesMenu({ isLocal: false, hasFileListUpload: false }), false);
|
||||
});
|
||||
|
||||
test("upload folder menu is shown only for remote panes with a folder upload handler", () => {
|
||||
assert.equal(shouldShowSftpUploadFolderMenu({ isLocal: false, hasFolderUpload: true }), true);
|
||||
assert.equal(shouldShowSftpUploadFolderMenu({ isLocal: true, hasFolderUpload: true }), false);
|
||||
assert.equal(shouldShowSftpUploadFolderMenu({ isLocal: false, hasFolderUpload: false }), false);
|
||||
});
|
||||
|
||||
test("directory row upload targets that directory without using its name in the label", () => {
|
||||
const directoryEntry: SftpFileEntry = {
|
||||
...baseEntry,
|
||||
name: "a-very-long-folder-name-that-should-not-expand-the-context-menu",
|
||||
type: "directory",
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
getSftpListUploadFilesTargetPath(directoryEntry, "/home/app"),
|
||||
"/home/app/a-very-long-folder-name-that-should-not-expand-the-context-menu",
|
||||
);
|
||||
assert.equal(getSftpUploadFilesLabelKey(directoryEntry), "sftp.context.uploadFilesHere");
|
||||
assert.equal(getSftpUploadFolderLabelKey(directoryEntry), "sftp.context.uploadFolderHere");
|
||||
});
|
||||
|
||||
test("file row upload targets the current directory", () => {
|
||||
assert.equal(getSftpListUploadFilesTargetPath(baseEntry, "/home/app"), undefined);
|
||||
assert.equal(getSftpUploadFilesLabelKey(baseEntry), "sftp.context.uploadFiles");
|
||||
assert.equal(getSftpUploadFolderLabelKey(baseEntry), "sftp.context.uploadFolder");
|
||||
});
|
||||
|
||||
test("tree directory row upload targets that directory", () => {
|
||||
const directoryEntry: SftpFileEntry = {
|
||||
...baseEntry,
|
||||
name: "logs",
|
||||
type: "directory",
|
||||
};
|
||||
|
||||
assert.equal(getSftpTreeUploadFilesTargetPath(directoryEntry, "/var/logs"), "/var/logs");
|
||||
assert.equal(getSftpUploadFilesLabelKey(directoryEntry), "sftp.context.uploadFilesHere");
|
||||
assert.equal(getSftpUploadFolderLabelKey(directoryEntry), "sftp.context.uploadFolderHere");
|
||||
});
|
||||
|
||||
test("tree file row upload targets the file parent directory", () => {
|
||||
assert.equal(getSftpTreeUploadFilesTargetPath(baseEntry, "/var/logs/app.log"), "/var/logs");
|
||||
assert.equal(getSftpUploadFilesLabelKey(baseEntry), "sftp.context.uploadFiles");
|
||||
assert.equal(getSftpUploadFolderLabelKey(baseEntry), "sftp.context.uploadFolder");
|
||||
});
|
||||
@@ -14,6 +14,9 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "
|
||||
import { formatHostPort } from "../domain/host";
|
||||
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";
|
||||
@@ -38,6 +41,7 @@ import { KeyBinding, HotkeyScheme } from "../domain/models";
|
||||
|
||||
interface SftpSidePanelProps {
|
||||
hosts: Host[];
|
||||
writableHosts?: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
@@ -45,6 +49,7 @@ interface SftpSidePanelProps {
|
||||
/** The host to connect to (follows focused terminal) */
|
||||
activeHost: Host | null;
|
||||
initialLocation?: { hostId: string; path: string } | null;
|
||||
onInitialLocationApplied?: (location: { hostId: string; path: string }) => void;
|
||||
showWorkspaceHostHeader?: boolean;
|
||||
isVisible?: boolean;
|
||||
renderOverlays?: boolean;
|
||||
@@ -65,16 +70,20 @@ interface SftpSidePanelProps {
|
||||
editorWordWrap: boolean;
|
||||
setEditorWordWrap: (value: boolean) => void;
|
||||
onGetTerminalCwd?: () => Promise<string | null>;
|
||||
onRequestTerminalFocus?: () => void;
|
||||
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
|
||||
}
|
||||
|
||||
const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
hosts,
|
||||
writableHosts,
|
||||
keys,
|
||||
identities,
|
||||
updateHosts,
|
||||
sftpDefaultViewMode,
|
||||
activeHost,
|
||||
initialLocation,
|
||||
onInitialLocationApplied,
|
||||
showWorkspaceHostHeader = false,
|
||||
isVisible = true,
|
||||
renderOverlays = true,
|
||||
@@ -89,8 +98,11 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
onGetTerminalCwd,
|
||||
onRequestTerminalFocus,
|
||||
terminalSettings,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const hostWriteSource = writableHosts ?? hosts;
|
||||
|
||||
const fileWatchHandlers = useMemo(() => ({
|
||||
onFileWatchSynced: (payload: { remotePath: string }) => {
|
||||
@@ -109,7 +121,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
useCompressedUpload: sftpUseCompressedUpload,
|
||||
defaultShowHiddenFiles: sftpShowHiddenFiles,
|
||||
autoConnectLocalOnMount: false,
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
|
||||
terminalSettings,
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings]);
|
||||
|
||||
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
|
||||
const {
|
||||
@@ -125,6 +138,47 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
const sftpRef = useRef(sftp);
|
||||
sftpRef.current = sftp;
|
||||
|
||||
// Register this instance's writeTextFileByConnection with the editor bridge
|
||||
// so editor tabs promoted from SFTP files opened in a terminal side panel
|
||||
// can still route saves through this useSftpState.
|
||||
//
|
||||
// Intentionally no deps — go through sftpRef so SFTP state churn (transfers,
|
||||
// tab switches, listings) doesn't make this unregister+reregister on every
|
||||
// re-render.
|
||||
useEffect(() => {
|
||||
return registerEditorSftpWriterScoped((connectionId, expectedHostId, filePath, content, encoding) =>
|
||||
sftpRef.current.writeTextFileByConnection(connectionId, expectedHostId, filePath, content, encoding),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// When this side panel unmounts (its hosting terminal tab was closed) we
|
||||
// force-close any editor tabs bound to connections this panel owned — the
|
||||
// save channel is gone with the SFTP session and there's no way to recover
|
||||
// it. Dirty state is dropped intentionally; the user closed the terminal
|
||||
// knowing the file was open.
|
||||
//
|
||||
// Collect every connection id across all left/right tabs — the panel can
|
||||
// host multiple SFTP tabs per side, and an editor tab promoted from an
|
||||
// inactive-pane tab would otherwise be stranded by the unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const s = sftpRef.current;
|
||||
if (!s) return;
|
||||
const owned = new Set<string>();
|
||||
for (const tab of s.leftTabs?.tabs ?? []) {
|
||||
const id = tab.connection?.id;
|
||||
if (id) owned.add(id);
|
||||
}
|
||||
for (const tab of s.rightTabs?.tabs ?? []) {
|
||||
const id = tab.connection?.id;
|
||||
if (id) owned.add(id);
|
||||
}
|
||||
if (owned.size === 0) return;
|
||||
const closed = editorTabStore.forceCloseBySessions([...owned]);
|
||||
closed.forEach(releaseEditorTabSaveCoordinator);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const behaviorRef = useRef(sftpDoubleClickBehavior);
|
||||
behaviorRef.current = sftpDoubleClickBehavior;
|
||||
|
||||
@@ -224,6 +278,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
handleSaveTextFile,
|
||||
onPromoteToTab,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
} = useSftpViewPaneCallbacks({
|
||||
@@ -422,16 +477,18 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
const locationKey = `${connectedKeyRef.current}:${initialLocation.path}`;
|
||||
if (lastAppliedInitialLocationKeyRef.current === locationKey) return;
|
||||
|
||||
lastAppliedInitialLocationKeyRef.current = locationKey;
|
||||
onInitialLocationApplied?.(initialLocation);
|
||||
|
||||
if (connection.currentPath === initialLocation.path) {
|
||||
lastAppliedInitialLocationKeyRef.current = locationKey;
|
||||
return;
|
||||
}
|
||||
|
||||
lastAppliedInitialLocationKeyRef.current = locationKey;
|
||||
sftpRef.current.navigateTo("left", initialLocation.path);
|
||||
}, [
|
||||
activeHost,
|
||||
initialLocation,
|
||||
onInitialLocationApplied,
|
||||
sftp.leftPane,
|
||||
]);
|
||||
|
||||
@@ -571,6 +628,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
return (
|
||||
<SftpContextProvider
|
||||
hosts={hosts}
|
||||
writableHosts={hostWriteSource}
|
||||
updateHosts={updateHosts}
|
||||
draggedFiles={draggedFiles}
|
||||
dragCallbacks={dragCallbacks}
|
||||
@@ -679,6 +737,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
setFileOpenerTarget={setFileOpenerTarget}
|
||||
handleFileOpenerSelect={handleFileOpenerSelect}
|
||||
handleSelectSystemApp={handleSelectSystemApp}
|
||||
onPromoteToTab={onPromoteToTab}
|
||||
onRequestTerminalFocus={onRequestTerminalFocus}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
@@ -688,6 +748,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
|
||||
const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps): boolean =>
|
||||
prev.hosts === next.hosts &&
|
||||
prev.writableHosts === next.writableHosts &&
|
||||
prev.keys === next.keys &&
|
||||
prev.identities === next.identities &&
|
||||
prev.updateHosts === next.updateHosts &&
|
||||
@@ -707,8 +768,13 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
|
||||
prev.editorWordWrap === next.editorWordWrap &&
|
||||
prev.setEditorWordWrap === next.setEditorWordWrap &&
|
||||
prev.onGetTerminalCwd === next.onGetTerminalCwd &&
|
||||
prev.onRequestTerminalFocus === next.onRequestTerminalFocus &&
|
||||
prev.initialLocation?.hostId === next.initialLocation?.hostId &&
|
||||
prev.initialLocation?.path === next.initialLocation?.path;
|
||||
prev.initialLocation?.path === next.initialLocation?.path &&
|
||||
// Only the keepalive fields of terminalSettings affect SFTP connection
|
||||
// resolution today; compare them directly rather than the whole object.
|
||||
prev.terminalSettings?.keepaliveInterval === next.terminalSettings?.keepaliveInterval &&
|
||||
prev.terminalSettings?.keepaliveCountMax === next.terminalSettings?.keepaliveCountMax;
|
||||
|
||||
export const SftpSidePanel = memo(SftpSidePanelInner, sidePanelAreEqual);
|
||||
SftpSidePanel.displayName = "SftpSidePanel";
|
||||
|
||||
138
components/SftpTransferItem.test.tsx
Normal file
138
components/SftpTransferItem.test.tsx
Normal 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"/);
|
||||
});
|
||||
@@ -14,7 +14,7 @@
|
||||
* - components/sftp/SftpHostPicker.tsx - Host selection dialog
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useIsSftpActive } from "../application/state/activeTabStore";
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
@@ -24,9 +24,11 @@ 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";
|
||||
|
||||
// Import extracted components
|
||||
@@ -53,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";
|
||||
@@ -63,6 +66,7 @@ interface SftpViewProps {
|
||||
keyBindings: KeyBinding[];
|
||||
editorWordWrap: boolean;
|
||||
setEditorWordWrap: (enabled: boolean) => void;
|
||||
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
|
||||
}
|
||||
|
||||
const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
@@ -70,6 +74,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
keys,
|
||||
identities,
|
||||
groupConfigs = [],
|
||||
proxyProfiles = [],
|
||||
updateHosts,
|
||||
sftpDefaultViewMode,
|
||||
sftpDoubleClickBehavior,
|
||||
@@ -80,6 +85,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
keyBindings,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
terminalSettings,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isActive = useIsSftpActive();
|
||||
@@ -105,17 +111,19 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
...fileWatchHandlers,
|
||||
useCompressedUpload: sftpUseCompressedUpload,
|
||||
defaultShowHiddenFiles: sftpShowHiddenFiles,
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
|
||||
terminalSettings,
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings]);
|
||||
|
||||
// Pre-resolve group defaults so SFTP connections inherit group config
|
||||
const effectiveHosts = useMemo(() =>
|
||||
hosts.map(h => {
|
||||
if (!h.group) return h;
|
||||
const defaults = resolveGroupDefaults(h.group, groupConfigs);
|
||||
return applyGroupDefaults(h, defaults);
|
||||
}),
|
||||
[hosts, groupConfigs],
|
||||
);
|
||||
const effectiveHosts = useMemo(() => {
|
||||
const validProxyProfileIds = new Set(proxyProfiles.map((profile) => profile.id));
|
||||
return hosts.map(h => {
|
||||
const withGroupDefaults = h.group
|
||||
? applyGroupDefaults(h, resolveGroupDefaults(h.group, groupConfigs, { validProxyProfileIds }), { validProxyProfileIds })
|
||||
: applyGroupDefaults(h, {}, { validProxyProfileIds });
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
});
|
||||
}, [hosts, groupConfigs, proxyProfiles]);
|
||||
|
||||
const sftp = useSftpState(effectiveHosts, keys, identities, sftpOptions);
|
||||
|
||||
@@ -135,6 +143,23 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
const sftpRef = useRef(sftp);
|
||||
sftpRef.current = sftp;
|
||||
|
||||
// Register this useSftpState's writeTextFileByConnection with the bridge so
|
||||
// the editor tab's save path can reach the active SFTP session. The bridge
|
||||
// supports multiple simultaneous writers (SftpSidePanel inside terminals
|
||||
// also registers its own instance) and dispatches by trying each until one
|
||||
// owns the target connectionId.
|
||||
//
|
||||
// Intentionally no deps: `sftp` identity churns on every SFTP state change
|
||||
// (transfers, pane updates, tab switches), which would make this effect
|
||||
// unregister+reregister constantly. Route through sftpRef so the closure
|
||||
// always reads the latest writeTextFileByConnection; that method is stable
|
||||
// across sftp re-renders (it's a methodsRef-backed dispatcher).
|
||||
useEffect(() => {
|
||||
return registerEditorSftpWriterScoped((connectionId, expectedHostId, filePath, content, encoding) =>
|
||||
sftpRef.current.writeTextFileByConnection(connectionId, expectedHostId, filePath, content, encoding),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Store behavior setting in ref for stable callbacks
|
||||
const behaviorRef = useRef(sftpDoubleClickBehavior);
|
||||
behaviorRef.current = sftpDoubleClickBehavior;
|
||||
@@ -219,6 +244,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
handleSaveTextFile,
|
||||
onPromoteToTab,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
} = useSftpViewPaneCallbacks({
|
||||
@@ -304,7 +330,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
|
||||
return (
|
||||
<SftpContextProvider
|
||||
hosts={hosts}
|
||||
hosts={effectiveHosts}
|
||||
writableHosts={hosts}
|
||||
updateHosts={updateHosts}
|
||||
draggedFiles={draggedFiles}
|
||||
dragCallbacks={dragCallbacks}
|
||||
@@ -443,7 +470,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
</div>
|
||||
|
||||
<SftpOverlays
|
||||
hosts={hosts}
|
||||
hosts={effectiveHosts}
|
||||
sftp={sftp}
|
||||
visibleTransfers={visibleTransfers}
|
||||
showHostPickerLeft={showHostPickerLeft}
|
||||
@@ -475,6 +502,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
setFileOpenerTarget={setFileOpenerTarget}
|
||||
handleFileOpenerSelect={handleFileOpenerSelect}
|
||||
handleSelectSystemApp={handleSelectSystemApp}
|
||||
onPromoteToTab={onPromoteToTab}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
@@ -487,6 +515,7 @@ const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
|
||||
prev.keys === next.keys &&
|
||||
prev.identities === next.identities &&
|
||||
prev.groupConfigs === next.groupConfigs &&
|
||||
prev.proxyProfiles === next.proxyProfiles &&
|
||||
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
|
||||
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
|
||||
prev.sftpAutoSync === next.sftpAutoSync &&
|
||||
@@ -495,7 +524,12 @@ const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
|
||||
prev.hotkeyScheme === next.hotkeyScheme &&
|
||||
prev.keyBindings === next.keyBindings &&
|
||||
prev.editorWordWrap === next.editorWordWrap &&
|
||||
prev.setEditorWordWrap === next.setEditorWordWrap;
|
||||
prev.setEditorWordWrap === next.setEditorWordWrap &&
|
||||
// Only the keepalive fields of terminalSettings affect SFTP connection
|
||||
// resolution today; compare them directly rather than the whole object
|
||||
// so unrelated terminal-setting changes don't tear the panel down.
|
||||
prev.terminalSettings?.keepaliveInterval === next.terminalSettings?.keepaliveInterval &&
|
||||
prev.terminalSettings?.keepaliveCountMax === next.terminalSettings?.keepaliveCountMax;
|
||||
|
||||
export const SftpView = memo(SftpViewInner, sftpViewAreEqual);
|
||||
SftpView.displayName = "SftpView";
|
||||
|
||||
@@ -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}
|
||||
@@ -983,7 +986,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="h-full min-h-0 flex relative">
|
||||
<div className="flex-1 flex flex-col min-h-0 min-w-0 overflow-hidden">
|
||||
<header className="border-b border-border/50 bg-secondary/80 backdrop-blur">
|
||||
<header className="border-b border-border/50 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm">
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-3">
|
||||
{/* Search box */}
|
||||
<div className="relative w-64">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FitAddon } from "@xterm/addon-fit";
|
||||
import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Cpu, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
|
||||
import { Cpu, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
@@ -26,21 +26,24 @@ import {
|
||||
shouldScrollOnTerminalInput,
|
||||
} from "../domain/terminalScroll";
|
||||
import {
|
||||
applyCustomAccentToTerminalTheme,
|
||||
resolveHostTerminalThemeId,
|
||||
} from "../domain/terminalAppearance";
|
||||
import { classifyDistroId } from "../domain/host";
|
||||
import { resolveHostAuth } from "../domain/sshAuth";
|
||||
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog";
|
||||
// SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer
|
||||
import { Button } from "./ui/button";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
|
||||
import { toast } from "./ui/toast";
|
||||
import { useAvailableFonts } from "../application/state/fontStore";
|
||||
import { composeFontFamilyStack, type SupportedPlatform } from "../infrastructure/config/cjkFonts";
|
||||
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
|
||||
import { useCustomThemes } from "../application/state/customThemeStore";
|
||||
|
||||
import { TerminalConnectionDialog } from "./terminal/TerminalConnectionDialog";
|
||||
import { HostKeyInfo } from "./terminal/TerminalHostKeyVerification";
|
||||
import { createKnownHostFromHostKeyInfo, toHostKeyInfo } from "./terminal/hostKeyVerification";
|
||||
import { TerminalToolbar } from "./terminal/TerminalToolbar";
|
||||
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
|
||||
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
|
||||
@@ -49,6 +52,7 @@ import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
|
||||
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
|
||||
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
|
||||
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
|
||||
import { applyUserCursorPreference } from "./terminal/runtime/cursorPreference";
|
||||
import { shouldPreserveTerminalFocusOnMouseDown } from "./terminal/toolbarFocus";
|
||||
import { preserveTerminalViewportInScrollback } from "./terminal/clearTerminalViewport";
|
||||
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
|
||||
@@ -126,6 +130,8 @@ interface TerminalProps {
|
||||
fontSize: number;
|
||||
terminalTheme: TerminalTheme;
|
||||
followAppTerminalTheme?: boolean;
|
||||
accentMode?: "theme" | "custom";
|
||||
customAccent?: string;
|
||||
terminalSettings?: TerminalSettings;
|
||||
sessionId: string;
|
||||
startupCommand?: string;
|
||||
@@ -184,6 +190,29 @@ function formatNetSpeed(bytesPerSec: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
type XTermWithPrivateRenderService = XTerm & {
|
||||
_core?: {
|
||||
_renderService?: {
|
||||
_renderRows?: (start: number, end: number) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function forceSyncRenderAfterResize(term: XTerm): void {
|
||||
const renderService = (term as XTermWithPrivateRenderService)._core?._renderService;
|
||||
const renderRows = renderService?._renderRows;
|
||||
if (typeof renderRows !== "function") return;
|
||||
|
||||
const endRow = term.rows - 1;
|
||||
if (endRow < 0) return;
|
||||
|
||||
try {
|
||||
renderRows.call(renderService, 0, endRow);
|
||||
} catch (err) {
|
||||
logger.warn("Sync render after resize failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
host,
|
||||
keys,
|
||||
@@ -191,7 +220,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
snippets,
|
||||
chainHosts = [],
|
||||
themePreviewId,
|
||||
knownHosts: _knownHosts = [],
|
||||
knownHosts = [],
|
||||
isVisible,
|
||||
inWorkspace,
|
||||
isResizing,
|
||||
@@ -201,6 +230,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
fontSize,
|
||||
terminalTheme,
|
||||
followAppTerminalTheme = false,
|
||||
accentMode = "theme",
|
||||
customAccent = "",
|
||||
terminalSettings,
|
||||
sessionId,
|
||||
startupCommand,
|
||||
@@ -374,6 +405,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
});
|
||||
const terminalEncodingRef = useRef(terminalEncoding);
|
||||
terminalEncodingRef.current = terminalEncoding;
|
||||
// True only after the user actively picks an encoding from the toolbar.
|
||||
// onSessionAttached uses this to decide whether to override the backend's
|
||||
// initial charset for telnet/serial reconnects — on a first attach we
|
||||
// must not overwrite arbitrary host.charset values (latin1/shift_jis/...)
|
||||
// that the UI's two-value state can't represent.
|
||||
const userPickedEncodingRef = useRef(false);
|
||||
|
||||
const terminalSearch = useTerminalSearch({ searchAddonRef, termRef });
|
||||
const {
|
||||
@@ -589,8 +626,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
pendingAuthRef,
|
||||
termRef,
|
||||
onUpdateHost,
|
||||
onStartSsh: (term) => {
|
||||
sessionStartersRef.current?.startSSH(term);
|
||||
onStartSession: (term) => {
|
||||
const starters = sessionStartersRef.current;
|
||||
if (!starters) return;
|
||||
if (host.moshEnabled) {
|
||||
starters.startMosh(term);
|
||||
return;
|
||||
}
|
||||
starters.startSSH(term);
|
||||
},
|
||||
setStatus: (next) => setStatus(next),
|
||||
setProgressLogs,
|
||||
@@ -598,6 +641,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const [needsHostKeyVerification, setNeedsHostKeyVerification] = useState(false);
|
||||
const [pendingHostKeyInfo, setPendingHostKeyInfo] = useState<HostKeyInfo | null>(null);
|
||||
const [pendingHostKeyRequestId, setPendingHostKeyRequestId] = useState<string | null>(null);
|
||||
const pendingConnectionRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// OSC-52 clipboard read prompt
|
||||
@@ -621,6 +665,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const dispose = terminalBackend.onHostKeyVerification?.((request) => {
|
||||
if (request.sessionId !== sessionId) return;
|
||||
|
||||
setPendingHostKeyRequestId(request.requestId);
|
||||
setPendingHostKeyInfo(toHostKeyInfo(request));
|
||||
setNeedsHostKeyVerification(true);
|
||||
setError(null);
|
||||
setProgressLogs((prev) => [
|
||||
...prev,
|
||||
request.status === 'changed'
|
||||
? `Host key changed for ${request.hostname}. Waiting for confirmation...`
|
||||
: `Host key verification required for ${request.hostname}.`,
|
||||
]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
dispose?.();
|
||||
};
|
||||
}, [sessionId, terminalBackend]);
|
||||
|
||||
const handleTopOverlayMouseDownCapture = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
if (!shouldPreserveTerminalFocusOnMouseDown(e.target)) return;
|
||||
@@ -645,25 +710,40 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
? host.fontFamily
|
||||
: fontFamilyId;
|
||||
const resolvedFontId = hostFontId || "menlo";
|
||||
return (availableFonts.find((f) => f.id === resolvedFontId) || availableFonts[0]).family;
|
||||
}, [availableFonts, fontFamilyId, hasFontFamilyOverride, host.fontFamily]);
|
||||
const selectedFont = availableFonts.find((f) => f.id === resolvedFontId) || availableFonts[0];
|
||||
const platform: SupportedPlatform =
|
||||
typeof navigator !== "undefined" && /Mac/i.test(navigator.platform)
|
||||
? "darwin"
|
||||
: typeof navigator !== "undefined" && /Win/i.test(navigator.platform)
|
||||
? "win32"
|
||||
: "linux";
|
||||
return composeFontFamilyStack({
|
||||
primaryFamily: selectedFont.family,
|
||||
userFallback: terminalSettings?.fallbackFont ?? "",
|
||||
latinFontId: resolvedFontId,
|
||||
platform,
|
||||
});
|
||||
}, [availableFonts, fontFamilyId, hasFontFamilyOverride, host.fontFamily, terminalSettings?.fallbackFont]);
|
||||
|
||||
const effectiveTheme = useMemo(() => {
|
||||
// When "Follow Application Theme" is on and there's no active
|
||||
// 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;
|
||||
@@ -711,6 +791,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
host,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts,
|
||||
resolvedChainHosts,
|
||||
sessionId,
|
||||
startupCommand,
|
||||
@@ -740,10 +821,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setChainProgress,
|
||||
t,
|
||||
onSessionAttached: (id: string) => {
|
||||
// Sync terminal encoding to SSH backend before first data arrives
|
||||
const isSSH = host.protocol !== 'local' && host.protocol !== 'serial' && host.protocol !== 'telnet' && host.protocol !== 'mosh' && !host.moshEnabled && !host.id?.startsWith('local-') && !host.id?.startsWith('serial-') && host.hostname !== 'localhost';
|
||||
// SSH: always sync. Its backend starts in utf-8 regardless of
|
||||
// host.charset, so the push is what keeps the UI state aligned
|
||||
// across reconnects — including localhost SSH targets, hence
|
||||
// hostname isn't in the gate.
|
||||
const isLocal = host.protocol === 'local' || host.id?.startsWith('local-');
|
||||
const isSerial = host.protocol === 'serial' || host.id?.startsWith('serial-');
|
||||
const isTelnet = host.protocol === 'telnet';
|
||||
const isMosh = host.protocol === 'mosh' || host.moshEnabled;
|
||||
const isSSH = !isLocal && !isSerial && !isTelnet && !isMosh;
|
||||
if (isSSH) {
|
||||
setSessionEncoding(id, terminalEncodingRef.current);
|
||||
return;
|
||||
}
|
||||
// Telnet / serial: the backend already applied host.charset
|
||||
// (including arbitrary iconv labels like latin1 / shift_jis that
|
||||
// the UI's two-value state can't represent) through start*Session
|
||||
// options, so don't clobber it on first attach. Only re-sync once
|
||||
// the user has explicitly picked from the toolbar menu — that's
|
||||
// the signal they want the UI choice to win on reconnect.
|
||||
if ((isTelnet || isSerial) && userPickedEncodingRef.current) {
|
||||
setSessionEncoding(id, terminalEncodingRef.current);
|
||||
}
|
||||
},
|
||||
onSessionExit,
|
||||
@@ -959,8 +1057,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const runFit = () => {
|
||||
try {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
|
||||
const dimensions = fitAddon.proposeDimensions();
|
||||
if (!dimensions || Number.isNaN(dimensions.cols) || Number.isNaN(dimensions.rows)) return;
|
||||
|
||||
lastFittedSizeRef.current = { width, height };
|
||||
fitAddon.fit();
|
||||
// addon-fit 0.11 clears the renderer before resizing, which can show
|
||||
// as a one-frame WebGL blink during layout changes. Resize directly
|
||||
// using the proposed dimensions to preserve the existing behavior
|
||||
// without forcing a blank intermediate frame.
|
||||
if (term.cols !== dimensions.cols || term.rows !== dimensions.rows) {
|
||||
term.resize(dimensions.cols, dimensions.rows);
|
||||
forceSyncRenderAfterResize(term);
|
||||
}
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => {
|
||||
autocompleteRepositionRef.current?.();
|
||||
@@ -1002,8 +1113,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current.options.fontFamily = resolvedFontFamily;
|
||||
|
||||
if (terminalSettings) {
|
||||
termRef.current.options.cursorStyle = terminalSettings.cursorShape;
|
||||
termRef.current.options.cursorBlink = terminalSettings.cursorBlink;
|
||||
applyUserCursorPreference(termRef.current, terminalSettings);
|
||||
termRef.current.options.scrollback = terminalSettings.scrollback === 0 ? 999999 : terminalSettings.scrollback;
|
||||
termRef.current.options.fontWeight = effectiveFontWeight as
|
||||
| 100
|
||||
@@ -1284,10 +1394,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (!el) return;
|
||||
|
||||
const handleContextMenuCapture = (e: MouseEvent) => {
|
||||
if (mouseTrackingRef.current) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
if (!mouseTrackingRef.current) return;
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
// stopImmediatePropagation blocks the event from reaching React's
|
||||
// bubble-phase root listener, so the onContextMenu handler in
|
||||
// TerminalContextMenu (which dispatches paste / select-word) never
|
||||
// fires inside a mouse-tracking TUI. Without dispatching the user's
|
||||
// chosen action here, right-click paste silently stops working in
|
||||
// opencode, tmux with `mouse on`, vim with `set mouse=a`, etc. (#941).
|
||||
// Middle-click still works because its auxclick listener lives in
|
||||
// createXTermRuntime and isn't gated by mouseTracking.
|
||||
const behavior = terminalSettingsRef.current?.rightClickBehavior;
|
||||
if (behavior === 'paste') {
|
||||
void terminalContextActionsRef.current?.onPaste?.();
|
||||
} else if (behavior === 'select-word') {
|
||||
terminalContextActionsRef.current?.onSelectWord?.();
|
||||
}
|
||||
// 'context-menu' is intentionally not handled — Radix opens the
|
||||
// menu via its own pointerdown listener, which our capture handler
|
||||
// does not intercept.
|
||||
};
|
||||
|
||||
const handleMouseUpCapture = (e: MouseEvent) => {
|
||||
@@ -1384,9 +1511,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
disableBracketedPasteRef,
|
||||
scrollOnPasteRef,
|
||||
});
|
||||
// Kept fresh on every render so the mouseTracking capture handler at
|
||||
// handleContextMenuCapture (which is bound once per sessionId) can
|
||||
// still invoke the latest paste / select-word callbacks without
|
||||
// re-binding on every action identity change. See #941.
|
||||
const terminalContextActionsRef = useRef(terminalContextActions);
|
||||
terminalContextActionsRef.current = terminalContextActions;
|
||||
|
||||
const handleSetTerminalEncoding = (encoding: 'utf-8' | 'gb18030') => {
|
||||
setTerminalEncoding(encoding);
|
||||
userPickedEncodingRef.current = true;
|
||||
if (sessionRef.current) {
|
||||
setSessionEncoding(sessionRef.current, encoding);
|
||||
}
|
||||
@@ -1419,12 +1553,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
};
|
||||
|
||||
const handleCancelConnect = () => {
|
||||
if (pendingHostKeyRequestId) {
|
||||
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
|
||||
}
|
||||
retryTokenRef.current = null;
|
||||
setIsCancelling(true);
|
||||
auth.setNeedsAuth(false);
|
||||
auth.setAuthRetryMessage(null);
|
||||
setNeedsHostKeyVerification(false);
|
||||
setPendingHostKeyInfo(null);
|
||||
setPendingHostKeyRequestId(null);
|
||||
setError("Connection cancelled");
|
||||
setProgressLogs((prev) => [...prev, "Cancelled by user."]);
|
||||
cleanupSession();
|
||||
@@ -1446,29 +1584,29 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const handleHostKeyClose = () => {
|
||||
setNeedsHostKeyVerification(false);
|
||||
setPendingHostKeyInfo(null);
|
||||
setPendingHostKeyRequestId(null);
|
||||
handleCancelConnect();
|
||||
};
|
||||
|
||||
const handleHostKeyContinue = () => {
|
||||
if (pendingHostKeyRequestId) {
|
||||
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, true, false);
|
||||
}
|
||||
setNeedsHostKeyVerification(false);
|
||||
if (pendingConnectionRef.current) {
|
||||
pendingConnectionRef.current();
|
||||
pendingConnectionRef.current = null;
|
||||
}
|
||||
setPendingHostKeyInfo(null);
|
||||
setPendingHostKeyRequestId(null);
|
||||
};
|
||||
|
||||
const handleHostKeyAddAndContinue = () => {
|
||||
if (pendingHostKeyInfo && onAddKnownHost) {
|
||||
const newKnownHost: KnownHost = {
|
||||
id: `kh-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
hostname: pendingHostKeyInfo.hostname,
|
||||
port: pendingHostKeyInfo.port || host.port || 22,
|
||||
keyType: pendingHostKeyInfo.keyType,
|
||||
publicKey: pendingHostKeyInfo.fingerprint,
|
||||
discoveredAt: Date.now(),
|
||||
};
|
||||
onAddKnownHost(newKnownHost);
|
||||
onAddKnownHost(createKnownHostFromHostKeyInfo(pendingHostKeyInfo, host));
|
||||
}
|
||||
if (pendingHostKeyRequestId) {
|
||||
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, true, true);
|
||||
}
|
||||
setNeedsHostKeyVerification(false);
|
||||
if (pendingConnectionRef.current) {
|
||||
@@ -1476,6 +1614,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
pendingConnectionRef.current = null;
|
||||
}
|
||||
setPendingHostKeyInfo(null);
|
||||
setPendingHostKeyRequestId(null);
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
@@ -1546,7 +1685,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
};
|
||||
|
||||
const shouldShowConnectionDialog = status !== "connected"
|
||||
&& !needsHostKeyVerification
|
||||
&& !((isLocalConnection || isSerialConnection) && status === "connecting")
|
||||
&& !(status === "disconnected" && isDisconnectedDialogDismissed);
|
||||
|
||||
@@ -1665,8 +1803,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
|
||||
@@ -1740,6 +1878,23 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
statusDotTone,
|
||||
)}
|
||||
/>
|
||||
{host.protocol !== "local" && host.hostname && host.hostname !== "localhost" && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-0.5 p-0.5 rounded hover:bg-[color:var(--terminal-toolbar-btn-hover)] transition-colors opacity-60 hover:opacity-100 flex-shrink-0"
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(host.hostname).then(() => {
|
||||
toast.success(t("terminal.statusbar.copyHostname.toast", { hostname: host.hostname }));
|
||||
}).catch(() => {
|
||||
toast.error(t("terminal.statusbar.copyHostname.error"));
|
||||
});
|
||||
}}
|
||||
title={t("terminal.statusbar.copyHostname.tooltip", { hostname: host.hostname })}
|
||||
aria-label={t("terminal.statusbar.copyHostname.label")}
|
||||
>
|
||||
<Copy size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Server Stats Display */}
|
||||
{terminalSettings?.showServerStats && status === 'connected' && serverStats.lastUpdated && (
|
||||
@@ -2140,18 +2295,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
{needsHostKeyVerification && pendingHostKeyInfo && (
|
||||
<div className="absolute inset-0 z-30 bg-background">
|
||||
<KnownHostConfirmDialog
|
||||
host={host}
|
||||
hostKeyInfo={pendingHostKeyInfo}
|
||||
onClose={handleHostKeyClose}
|
||||
onContinue={handleHostKeyContinue}
|
||||
onAddAndContinue={handleHostKeyAddAndContinue}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OSC-52 clipboard read prompt */}
|
||||
{osc52ReadPromptVisible && (
|
||||
<div
|
||||
@@ -2188,6 +2331,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
_setShowLogs={setShowLogs}
|
||||
keys={keys}
|
||||
onDismissDisconnected={handleDismissDisconnectedDialog}
|
||||
hostKeyVerification={needsHostKeyVerification && pendingHostKeyInfo ? {
|
||||
hostKeyInfo: pendingHostKeyInfo,
|
||||
onClose: handleHostKeyClose,
|
||||
onContinue: handleHostKeyContinue,
|
||||
onAddAndContinue: handleHostKeyAddAndContinue,
|
||||
} : undefined}
|
||||
authProps={{
|
||||
authMethod: auth.authMethod,
|
||||
setAuthMethod: auth.setAuthMethod,
|
||||
|
||||
98
components/TerminalLayer.memo.test.tsx
Normal file
98
components/TerminalLayer.memo.test.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { terminalLayerAreEqual } from "./terminalLayerMemo.ts";
|
||||
|
||||
const baseProps = {
|
||||
hosts: [],
|
||||
groupConfigs: [],
|
||||
proxyProfiles: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
snippetPackages: [],
|
||||
sessions: [],
|
||||
workspaces: [],
|
||||
knownHosts: [],
|
||||
draggingSessionId: null,
|
||||
terminalTheme: {},
|
||||
accentMode: "theme",
|
||||
customAccent: null,
|
||||
terminalSettings: {},
|
||||
fontSize: 14,
|
||||
hotkeyScheme: "default",
|
||||
keyBindings: [],
|
||||
sftpDefaultViewMode: "list",
|
||||
sftpDoubleClickBehavior: "open",
|
||||
sftpAutoSync: false,
|
||||
sftpShowHiddenFiles: false,
|
||||
sftpUseCompressedUpload: false,
|
||||
sftpAutoOpenSidebar: false,
|
||||
editorWordWrap: false,
|
||||
setEditorWordWrap: () => {},
|
||||
onHotkeyAction: () => {},
|
||||
onUpdateHost: () => {},
|
||||
onAddKnownHost: () => {},
|
||||
onToggleWorkspaceViewMode: () => {},
|
||||
onSetWorkspaceFocusedSession: () => {},
|
||||
onSplitSession: () => {},
|
||||
toggleScriptsSidePanelRef: { current: null },
|
||||
};
|
||||
|
||||
test("TerminalLayer re-renders when group configs change", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, groupConfigs: [{ path: "prod", proxyProfileId: "proxy-1" }] } as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer re-renders when known hosts change", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{
|
||||
...baseProps,
|
||||
knownHosts: [{
|
||||
id: "kh-1",
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
fingerprint: "fingerprint",
|
||||
discoveredAt: 1,
|
||||
}],
|
||||
} as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer re-renders when the known host save handler changes", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, onAddKnownHost: () => {} } as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer re-renders when proxy profiles change", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{
|
||||
...baseProps,
|
||||
proxyProfiles: [{
|
||||
id: "proxy-1",
|
||||
label: "Office Proxy",
|
||||
config: { type: "http", host: "proxy.example.com", port: 3128 },
|
||||
createdAt: 1,
|
||||
}],
|
||||
} as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
resolveHostTerminalFontSize,
|
||||
resolveHostTerminalFontWeight,
|
||||
resolveHostTerminalThemeId,
|
||||
applyCustomAccentToTerminalTheme,
|
||||
} from '../domain/terminalAppearance';
|
||||
import { cn, normalizeLineEndings } from '../lib/utils';
|
||||
import { detectLocalOs } from '../lib/localShell';
|
||||
@@ -35,14 +36,16 @@ 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';
|
||||
import { ScriptsSidePanel } from './ScriptsSidePanel';
|
||||
import { ThemeSidePanel } from './terminal/ThemeSidePanel';
|
||||
import { focusTerminalSessionInput } from './terminal/focusTerminalSession';
|
||||
import { AIChatSidePanel } from './AIChatSidePanel';
|
||||
import { useAIState } from '../application/state/useAIState';
|
||||
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
|
||||
@@ -53,6 +56,8 @@ import { Input } from './ui/input';
|
||||
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';
|
||||
|
||||
@@ -383,6 +388,7 @@ AIChatPanelsHost.displayName = 'AIChatPanelsHost';
|
||||
interface TerminalLayerProps {
|
||||
hosts: Host[];
|
||||
groupConfigs: GroupConfig[];
|
||||
proxyProfiles: ProxyProfile[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
snippets: Snippet[];
|
||||
@@ -393,6 +399,8 @@ interface TerminalLayerProps {
|
||||
draggingSessionId: string | null;
|
||||
terminalTheme: TerminalTheme;
|
||||
followAppTerminalTheme?: boolean;
|
||||
accentMode?: 'theme' | 'custom';
|
||||
customAccent?: string;
|
||||
terminalSettings?: TerminalSettings;
|
||||
terminalFontFamilyId: string;
|
||||
fontSize?: number;
|
||||
@@ -436,12 +444,14 @@ interface TerminalLayerProps {
|
||||
sessionLogsDir?: string;
|
||||
sessionLogsFormat?: string;
|
||||
closeSidePanelRef?: React.MutableRefObject<(() => void) | null>;
|
||||
toggleScriptsSidePanelRef?: React.MutableRefObject<(() => void) | null>;
|
||||
activeSidePanelTabRef?: React.MutableRefObject<string | null>;
|
||||
}
|
||||
|
||||
const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
hosts,
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
keys,
|
||||
identities,
|
||||
snippets,
|
||||
@@ -452,6 +462,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
draggingSessionId,
|
||||
terminalTheme,
|
||||
followAppTerminalTheme = false,
|
||||
accentMode = 'theme',
|
||||
customAccent = '',
|
||||
terminalSettings,
|
||||
terminalFontFamilyId,
|
||||
fontSize = 14,
|
||||
@@ -492,6 +504,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
closeSidePanelRef,
|
||||
toggleScriptsSidePanelRef,
|
||||
activeSidePanelTabRef,
|
||||
}) => {
|
||||
// Subscribe to activeTabId from external store
|
||||
@@ -793,6 +806,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSftpInitialLocationApplied = useCallback((tabId: string, location: { hostId: string; path: string }) => {
|
||||
setSftpInitialLocationForTab(prev => {
|
||||
const current = prev.get(tabId);
|
||||
if (!current || current.hostId !== location.hostId || current.path !== location.path) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.delete(tabId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Focus-mode workspace sidebar resize handler. The sidebar is always
|
||||
// anchored to the left of the workspace area, so a rightward drag grows it.
|
||||
const handleFocusSidebarResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
@@ -858,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(() => {
|
||||
@@ -867,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;
|
||||
@@ -911,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) {
|
||||
@@ -924,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>();
|
||||
@@ -1261,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.
|
||||
@@ -1294,9 +1343,26 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
[sidePanelOpenTabs],
|
||||
);
|
||||
|
||||
const getActiveTerminalSessionId = useCallback((): string | null => {
|
||||
if (!activeWorkspace) return activeSession?.id ?? null;
|
||||
|
||||
const workspaceSessionIdSet = new Set(collectSessionIds(activeWorkspace.root));
|
||||
const focusedSessionId = activeWorkspace.focusedSessionId;
|
||||
if (focusedSessionId && workspaceSessionIdSet.has(focusedSessionId) && sessions.some((session) => session.id === focusedSessionId)) {
|
||||
return focusedSessionId;
|
||||
}
|
||||
|
||||
return sessions.find((session) => workspaceSessionIdSet.has(session.id))?.id ?? null;
|
||||
}, [activeWorkspace, activeSession?.id, sessions]);
|
||||
|
||||
const syncWorkspaceFocusIfNeeded = useCallback((sessionId: string | null) => {
|
||||
if (!activeWorkspace || !sessionId || activeWorkspace.focusedSessionId === sessionId) return;
|
||||
onSetWorkspaceFocusedSession?.(activeWorkspace.id, sessionId);
|
||||
}, [activeWorkspace, onSetWorkspaceFocusedSession]);
|
||||
|
||||
// Get the focused terminal's current working directory
|
||||
const getTerminalCwd = useCallback(async (): Promise<string | null> => {
|
||||
const sessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id;
|
||||
const sessionId = getActiveTerminalSessionId();
|
||||
if (!sessionId) return null;
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionId);
|
||||
@@ -1304,27 +1370,23 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [activeWorkspace?.focusedSessionId, activeSession?.id, terminalBackend]);
|
||||
}, [getActiveTerminalSessionId, terminalBackend]);
|
||||
|
||||
const refocusTerminalSession = useCallback((sessionId?: string | null) => {
|
||||
if (!sessionId) return;
|
||||
|
||||
const focusTarget = () => {
|
||||
const pane = document.querySelector(`[data-session-id="${sessionId}"]`);
|
||||
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
|
||||
textarea?.focus();
|
||||
};
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
focusTarget();
|
||||
setTimeout(focusTarget, 50);
|
||||
});
|
||||
focusTerminalSessionInput(sessionId);
|
||||
}, []);
|
||||
|
||||
const refocusActiveTerminalSession = useCallback(() => {
|
||||
const sessionId = getActiveTerminalSessionId();
|
||||
syncWorkspaceFocusIfNeeded(sessionId);
|
||||
refocusTerminalSession(sessionId);
|
||||
}, [getActiveTerminalSessionId, refocusTerminalSession, syncWorkspaceFocusIfNeeded]);
|
||||
|
||||
// Close the entire side panel for the current tab
|
||||
const handleCloseSidePanel = useCallback(() => {
|
||||
if (!activeTabId) return;
|
||||
const sessionIdToRefocus = activeWorkspace?.focusedSessionId ?? activeSession?.id;
|
||||
const sessionIdToRefocus = getActiveTerminalSessionId();
|
||||
syncWorkspaceFocusIfNeeded(sessionIdToRefocus);
|
||||
setSidePanelOpenTabs(prev => {
|
||||
const next = new Map(prev);
|
||||
next.delete(activeTabId);
|
||||
@@ -1348,7 +1410,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
return next;
|
||||
});
|
||||
refocusTerminalSession(sessionIdToRefocus);
|
||||
}, [activeTabId, activeWorkspace?.focusedSessionId, activeSession?.id, refocusTerminalSession]);
|
||||
}, [activeTabId, getActiveTerminalSessionId, refocusTerminalSession, syncWorkspaceFocusIfNeeded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!closeSidePanelRef) return;
|
||||
@@ -1403,6 +1465,34 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleSwitchSidePanelTab('scripts');
|
||||
}, [handleSwitchSidePanelTab]);
|
||||
|
||||
const handleToggleScriptsSidePanel = useCallback(() => {
|
||||
const tabId = activeTabIdRef.current;
|
||||
if (!tabId) return;
|
||||
|
||||
const intent = resolveScriptsSidePanelShortcutIntent(
|
||||
sidePanelOpenTabsRef.current.get(tabId) ?? null,
|
||||
);
|
||||
|
||||
if (intent.kind === 'closeTerminalSidePanel') {
|
||||
handleCloseSidePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
setSidePanelOpenTabs(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(tabId, 'scripts');
|
||||
return next;
|
||||
});
|
||||
}, [handleCloseSidePanel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toggleScriptsSidePanelRef) return;
|
||||
toggleScriptsSidePanelRef.current = handleToggleScriptsSidePanel;
|
||||
return () => {
|
||||
toggleScriptsSidePanelRef.current = null;
|
||||
};
|
||||
}, [toggleScriptsSidePanelRef, handleToggleScriptsSidePanel]);
|
||||
|
||||
// Open theme side panel (called from Terminal toolbar)
|
||||
const handleOpenTheme = useCallback(() => {
|
||||
handleSwitchSidePanelTab('theme');
|
||||
@@ -1523,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);
|
||||
@@ -1568,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 () => {
|
||||
@@ -1832,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
|
||||
@@ -2144,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)',
|
||||
@@ -2166,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)',
|
||||
@@ -2183,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)',
|
||||
@@ -2200,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)',
|
||||
@@ -2217,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)',
|
||||
@@ -2260,7 +2366,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
return (
|
||||
<SftpSidePanel
|
||||
key={tabId}
|
||||
hosts={hosts}
|
||||
hosts={effectiveHosts}
|
||||
writableHosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
updateHosts={updateHosts}
|
||||
@@ -2271,6 +2378,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
? (sftpInitialLocationForTab.get(tabId) ?? null)
|
||||
: null
|
||||
}
|
||||
onInitialLocationApplied={(location) => handleSftpInitialLocationApplied(tabId, location)}
|
||||
showWorkspaceHostHeader={isVisibleSftpPanel && !!activeWorkspace}
|
||||
isVisible={isVisibleSftpPanel}
|
||||
renderOverlays={isVisibleSftpPanel}
|
||||
@@ -2285,6 +2393,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
onGetTerminalCwd={getTerminalCwd}
|
||||
onRequestTerminalFocus={refocusActiveTerminalSession}
|
||||
terminalSettings={terminalSettings}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -2466,6 +2576,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
fontSize={fontSize}
|
||||
terminalTheme={terminalTheme}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
accentMode={accentMode}
|
||||
customAccent={customAccent}
|
||||
terminalSettings={terminalSettings}
|
||||
sessionId={session.id}
|
||||
startupCommand={session.startupCommand}
|
||||
@@ -2571,37 +2683,5 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Only re-render when data props change - activeTabId/isVisible are now managed internally via store subscription
|
||||
const terminalLayerAreEqual = (prev: TerminalLayerProps, next: TerminalLayerProps): boolean => {
|
||||
return (
|
||||
prev.hosts === next.hosts &&
|
||||
prev.keys === next.keys &&
|
||||
prev.snippets === next.snippets &&
|
||||
prev.snippetPackages === next.snippetPackages &&
|
||||
prev.sessions === next.sessions &&
|
||||
prev.workspaces === next.workspaces &&
|
||||
prev.draggingSessionId === next.draggingSessionId &&
|
||||
prev.terminalTheme === next.terminalTheme &&
|
||||
prev.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.identities === next.identities
|
||||
);
|
||||
};
|
||||
|
||||
export const TerminalLayer = memo(TerminalLayerInner, terminalLayerAreEqual);
|
||||
TerminalLayer.displayName = 'TerminalLayer';
|
||||
|
||||
45
components/TextEditorModal.test.tsx
Normal file
45
components/TextEditorModal.test.tsx
Normal 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);
|
||||
});
|
||||
@@ -1,31 +1,63 @@
|
||||
/**
|
||||
* TextEditorModal - Modal for editing text files in SFTP with syntax highlighting
|
||||
* TextEditorModal - Dialog shell for editing text files in SFTP.
|
||||
* Delegates all editor chrome to TextEditorPane.
|
||||
*/
|
||||
import {
|
||||
CloudUpload,
|
||||
Loader2,
|
||||
Search,
|
||||
WrapText,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import Editor, { type OnMount, loader, useMonaco } from '@monaco-editor/react';
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
// Configure Monaco to use local files instead of CDN
|
||||
const monacoBasePath = import.meta.env.DEV
|
||||
? './node_modules/monaco-editor/min/vs'
|
||||
: `${import.meta.env.BASE_URL}monaco/vs`;
|
||||
loader.config({ paths: { vs: monacoBasePath } });
|
||||
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useClipboardBackend } from '../application/state/useClipboardBackend';
|
||||
import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../domain/models';
|
||||
import { getLanguageId, getLanguageName, getSupportedLanguages } from '../lib/sftpFileUtils';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
import { Combobox } from './ui/combobox';
|
||||
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 of promotion. */
|
||||
baselineContent: string;
|
||||
/** The current (possibly-dirty) editor content. */
|
||||
content: string;
|
||||
/** The current language ID selected by the user (may differ from file-detected default). */
|
||||
languageId: string;
|
||||
/** The current word-wrap state (carried over so the tab opens with the same setting). */
|
||||
wordWrap: boolean;
|
||||
/** The latest Monaco view state (scroll position, cursor, etc.) — may be null before first edit. */
|
||||
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;
|
||||
@@ -37,128 +69,10 @@ interface TextEditorModalProps {
|
||||
onToggleWordWrap: () => void;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
/** If provided, a maximize button is shown in the Pane header. */
|
||||
onPromoteToTab?: (snapshot: TextEditorModalSnapshot) => void;
|
||||
}
|
||||
|
||||
// Map our language IDs to Monaco language IDs
|
||||
const languageIdToMonaco = (langId: string): string => {
|
||||
const mapping: Record<string, string> = {
|
||||
'javascript': 'javascript',
|
||||
'typescript': 'typescript',
|
||||
'python': 'python',
|
||||
'shell': 'shell',
|
||||
'batch': 'bat',
|
||||
'powershell': 'powershell',
|
||||
'c': 'c',
|
||||
'cpp': 'cpp',
|
||||
'java': 'java',
|
||||
'kotlin': 'kotlin',
|
||||
'go': 'go',
|
||||
'rust': 'rust',
|
||||
'ruby': 'ruby',
|
||||
'php': 'php',
|
||||
'perl': 'perl',
|
||||
'lua': 'lua',
|
||||
'r': 'r',
|
||||
'swift': 'swift',
|
||||
'dart': 'dart',
|
||||
'csharp': 'csharp',
|
||||
'fsharp': 'fsharp',
|
||||
'vb': 'vb',
|
||||
'html': 'html',
|
||||
'css': 'css',
|
||||
'scss': 'scss',
|
||||
'sass': 'sass',
|
||||
'less': 'less',
|
||||
'json': 'json',
|
||||
'jsonc': 'json',
|
||||
'json5': 'json',
|
||||
'xml': 'xml',
|
||||
'yaml': 'yaml',
|
||||
'toml': 'ini',
|
||||
'ini': 'ini',
|
||||
'sql': 'sql',
|
||||
'graphql': 'graphql',
|
||||
'markdown': 'markdown',
|
||||
'plaintext': 'plaintext',
|
||||
'vue': 'html',
|
||||
'svelte': 'html',
|
||||
'dockerfile': 'dockerfile',
|
||||
'makefile': 'makefile',
|
||||
'diff': 'diff',
|
||||
};
|
||||
return mapping[langId] || 'plaintext';
|
||||
};
|
||||
|
||||
// Convert HSL string "h s% l%" to hex color
|
||||
const hslToHex = (hslString: string): string => {
|
||||
const parts = hslString.trim().split(/\s+/);
|
||||
if (parts.length < 3) return '#1e1e1e';
|
||||
const h = parseFloat(parts[0]) / 360;
|
||||
const s = parseFloat(parts[1].replace('%', '')) / 100;
|
||||
const l = parseFloat(parts[2].replace('%', '')) / 100;
|
||||
|
||||
const hue2rgb = (p: number, q: number, t: number) => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
|
||||
let r: number, g: number, b: number;
|
||||
if (s === 0) {
|
||||
r = g = b = l;
|
||||
} else {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r = hue2rgb(p, q, h + 1 / 3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1 / 3);
|
||||
}
|
||||
|
||||
const toHex = (x: number) => {
|
||||
const hex = Math.round(x * 255).toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
};
|
||||
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
};
|
||||
|
||||
// Read a CSS custom-property and convert from HSL to hex
|
||||
const getCssColor = (varName: string, fallback: string): string => {
|
||||
const value = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(varName)
|
||||
.trim();
|
||||
return value ? hslToHex(value) : fallback;
|
||||
};
|
||||
|
||||
interface EditorColors {
|
||||
bg: string;
|
||||
fg: string;
|
||||
primary: string;
|
||||
card: string;
|
||||
mutedFg: string;
|
||||
border: string;
|
||||
}
|
||||
|
||||
/** Read all UI CSS variables that matter for the Monaco theme. */
|
||||
const getEditorColors = (isDark: boolean): EditorColors => ({
|
||||
bg: getCssColor('--background', isDark ? '#1e1e1e' : '#ffffff'),
|
||||
fg: getCssColor('--foreground', isDark ? '#d4d4d4' : '#1e1e1e'),
|
||||
primary: getCssColor('--primary', isDark ? '#569cd6' : '#0078d4'),
|
||||
card: getCssColor('--card', isDark ? '#252526' : '#f3f3f3'),
|
||||
mutedFg: getCssColor('--muted-foreground', isDark ? '#858585' : '#858585'),
|
||||
border: getCssColor('--border', isDark ? '#3c3c3c' : '#d4d4d4'),
|
||||
});
|
||||
|
||||
/** Build a fingerprint string so we can detect immersive-mode color changes cheaply. */
|
||||
const getThemeSignal = (): string => {
|
||||
const root = document.documentElement;
|
||||
return root.dataset.immersiveTheme
|
||||
?? getComputedStyle(root).getPropertyValue('--background').trim();
|
||||
};
|
||||
|
||||
export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
@@ -169,406 +83,179 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
onToggleWordWrap,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
onPromoteToTab,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { readClipboardText: readClipboardTextFromBridge } = useClipboardBackend();
|
||||
const monaco = useMonaco();
|
||||
|
||||
const [content, setContent] = useState(initialContent);
|
||||
const [baselineContent, setBaselineContent] = useState(initialContent);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [languageId, setLanguageId] = useState(() => getLanguageId(fileName));
|
||||
const editorRef = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||
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);
|
||||
|
||||
// Ref to store the latest save function to avoid stale closure in keyboard shortcut
|
||||
const handleSaveRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
const handlePasteRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
const readClipboardTextRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
|
||||
// Latest view state captured from Pane's onContentChange — used by handlePromote
|
||||
const viewStateRef = useRef<Monaco.editor.ICodeEditorViewState | null>(null);
|
||||
|
||||
// Track theme from document.documentElement class (syncs with app theme)
|
||||
const [isDarkTheme, setIsDarkTheme] = useState(() =>
|
||||
document.documentElement.classList.contains('dark')
|
||||
);
|
||||
// Derived: whether the current content differs from the clean baseline
|
||||
const hasChanges = content !== baselineContent;
|
||||
|
||||
// Track a signal that changes whenever immersive-mode or base theme colors change
|
||||
const [themeSignal, setThemeSignal] = useState(() => getThemeSignal());
|
||||
|
||||
// Custom theme name
|
||||
const customThemeName = isDarkTheme ? 'netcatty-dark' : 'netcatty-light';
|
||||
|
||||
// Define and update custom Monaco themes — syncs with immersive-mode / base UI colors
|
||||
useEffect(() => {
|
||||
if (!monaco) return;
|
||||
|
||||
const colors = getEditorColors(isDarkTheme);
|
||||
|
||||
const themeColors: Record<string, string> = {
|
||||
'editor.background': colors.bg,
|
||||
'editor.foreground': colors.fg,
|
||||
'editorCursor.foreground': colors.primary,
|
||||
'editor.selectionBackground': colors.primary + '40',
|
||||
'editor.inactiveSelectionBackground': colors.primary + '25',
|
||||
'editorLineNumber.foreground': colors.mutedFg,
|
||||
'editorLineNumber.activeForeground': colors.fg,
|
||||
'editor.lineHighlightBackground': colors.fg + '08',
|
||||
'editorWidget.background': colors.card,
|
||||
'editorWidget.foreground': colors.fg,
|
||||
'editorWidget.border': colors.border,
|
||||
'input.background': colors.card,
|
||||
'input.foreground': colors.fg,
|
||||
'input.border': colors.border,
|
||||
};
|
||||
|
||||
monaco.editor.defineTheme('netcatty-dark', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: themeColors,
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
monaco.editor.defineTheme('netcatty-light', {
|
||||
base: 'vs',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: themeColors,
|
||||
});
|
||||
|
||||
monaco.editor.setTheme(customThemeName);
|
||||
}, [monaco, isDarkTheme, themeSignal, customThemeName]);
|
||||
|
||||
// Listen for theme changes via MutationObserver on <html> class, style, and immersive data attr
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const updateTheme = () => {
|
||||
setIsDarkTheme(root.classList.contains('dark'));
|
||||
setThemeSignal(getThemeSignal());
|
||||
};
|
||||
const observer = new MutationObserver(updateTheme);
|
||||
observer.observe(root, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style', 'data-immersive-theme'],
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
onSaveRef.current = onSave;
|
||||
}, [onSave]);
|
||||
|
||||
// Reset content when file changes
|
||||
useEffect(() => {
|
||||
tRef.current = t;
|
||||
}, [t]);
|
||||
|
||||
// Reset all state when a new file is opened
|
||||
useEffect(() => {
|
||||
saveCoordinatorRef.current?.reset();
|
||||
setContent(initialContent);
|
||||
setHasChanges(false);
|
||||
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]);
|
||||
|
||||
// Track changes
|
||||
useEffect(() => {
|
||||
setHasChanges(content !== initialContent);
|
||||
}, [content, initialContent]);
|
||||
|
||||
const closeTabBinding = useMemo(
|
||||
() => keyBindings.find((binding) => binding.action === 'closeTab'),
|
||||
[keyBindings],
|
||||
);
|
||||
const saveContent = useCallback(async (contentToSave = contentRef.current): Promise<boolean> => {
|
||||
return saveCoordinatorRef.current?.save(contentToSave) ?? false;
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(content);
|
||||
setHasChanges(false);
|
||||
toast.success(t('sftp.editor.saved'), 'SFTP');
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t('sftp.editor.saveFailed'),
|
||||
'SFTP'
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [content, onSave, saving, t]);
|
||||
|
||||
// Keep the ref updated with the latest handleSave function
|
||||
useEffect(() => {
|
||||
handleSaveRef.current = handleSave;
|
||||
}, [handleSave]);
|
||||
|
||||
const readClipboardText = useCallback(async (): Promise<string | null> => {
|
||||
try {
|
||||
if (navigator.clipboard?.readText) {
|
||||
return await navigator.clipboard.readText();
|
||||
}
|
||||
} catch {
|
||||
// Fall through to Electron bridge
|
||||
}
|
||||
|
||||
try {
|
||||
return await readClipboardTextFromBridge();
|
||||
} catch {
|
||||
// Both clipboard APIs unavailable; signal failure so caller can fall back.
|
||||
return null;
|
||||
}
|
||||
}, [readClipboardTextFromBridge]);
|
||||
|
||||
useEffect(() => {
|
||||
readClipboardTextRef.current = readClipboardText;
|
||||
}, [readClipboardText]);
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
const text = await readClipboardText();
|
||||
if (text === null) {
|
||||
// Clipboard read unavailable; fall back to Monaco's native paste.
|
||||
editor.trigger('keyboard', 'editor.action.clipboardPasteAction', null);
|
||||
return;
|
||||
}
|
||||
if (!text) return;
|
||||
|
||||
const selections = editor.getSelections();
|
||||
if (!selections || selections.length === 0) return;
|
||||
|
||||
// Match Monaco's default multicursorPaste:'spread' behavior:
|
||||
// distribute one line per cursor when line count equals cursor count.
|
||||
const lines = text.split(/\r\n|\n/);
|
||||
const distribute = selections.length > 1 && lines.length === selections.length;
|
||||
|
||||
editor.executeEdits(
|
||||
'netcatty-paste',
|
||||
selections.map((selection, i) => ({
|
||||
range: selection,
|
||||
text: distribute ? lines[i] : text,
|
||||
forceMoveMarkers: true,
|
||||
})),
|
||||
);
|
||||
editor.focus();
|
||||
}, [readClipboardText]);
|
||||
|
||||
useEffect(() => {
|
||||
handlePasteRef.current = handlePaste;
|
||||
}, [handlePaste]);
|
||||
await saveContent();
|
||||
}, [saveContent]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (hasChanges) {
|
||||
const confirmed = confirm(t('sftp.editor.unsavedChanges'));
|
||||
if (!confirmed) return;
|
||||
}
|
||||
onClose();
|
||||
}, [hasChanges, onClose, t]);
|
||||
if (closePromptRef.current) return;
|
||||
|
||||
const handleEditorChange = useCallback((value: string | undefined) => {
|
||||
setContent(value || '');
|
||||
}, []);
|
||||
|
||||
const handleEditorMount: OnMount = useCallback((editor, monaco) => {
|
||||
editorRef.current = editor;
|
||||
|
||||
// Add save shortcut - use ref to avoid stale closure
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
handleSaveRef.current();
|
||||
});
|
||||
|
||||
// Add find shortcut (Ctrl+F / Cmd+F)
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, () => {
|
||||
// Trigger Monaco's built-in find widget
|
||||
editor.trigger('keyboard', 'actions.find', null);
|
||||
});
|
||||
|
||||
// Fallback paste path for Electron environments where Monaco paste can fail.
|
||||
// Skip custom paste when focus is inside the find/replace widget so that
|
||||
// its input fields receive the pasted text via default browser behavior.
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => {
|
||||
const active = document.activeElement;
|
||||
if (active?.closest('.find-widget')) {
|
||||
// Read clipboard and insert into the find/replace input field.
|
||||
void (async () => {
|
||||
try {
|
||||
const text = await readClipboardTextRef.current();
|
||||
if (!text) return;
|
||||
// Monaco find widget inputs are <textarea> elements inside .monaco-inputbox
|
||||
if (active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement) {
|
||||
const start = active.selectionStart ?? active.value.length;
|
||||
const end = active.selectionEnd ?? active.value.length;
|
||||
active.focus();
|
||||
active.setSelectionRange(start, end);
|
||||
document.execCommand('insertText', false, text);
|
||||
}
|
||||
} catch {
|
||||
// Ignore – paste simply won't work
|
||||
}
|
||||
})();
|
||||
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;
|
||||
}
|
||||
}
|
||||
void handlePasteRef.current();
|
||||
onClose();
|
||||
scheduleWindowInputFocus();
|
||||
})().finally(() => {
|
||||
closePromptRef.current = null;
|
||||
});
|
||||
|
||||
editor.focus();
|
||||
}, []);
|
||||
closePromptRef.current = closeTask;
|
||||
}, [fileName, onClose, saveContent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
contentRef.current = content;
|
||||
}, [content]);
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
editorRef.current?.focus();
|
||||
});
|
||||
useEffect(() => {
|
||||
baselineContentRef.current = baselineContent;
|
||||
}, [baselineContent]);
|
||||
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
useEffect(() => {
|
||||
savingRef.current = saving;
|
||||
}, [saving]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
closePromptRef.current = null;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleDialogKeyDownCapture = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (hotkeyScheme === 'disabled' || !closeTabBinding) return;
|
||||
useEffect(() => {
|
||||
if (open) scheduleWindowInputFocus();
|
||||
}, [open]);
|
||||
|
||||
const isMac = hotkeyScheme === 'mac';
|
||||
const keyStr = isMac ? closeTabBinding.mac : closeTabBinding.pc;
|
||||
if (!matchesKeyBinding(e.nativeEvent, keyStr, isMac)) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopPropagation();
|
||||
handleClose();
|
||||
}, [closeTabBinding, handleClose, hotkeyScheme]);
|
||||
|
||||
// Trigger search dialog
|
||||
const handleSearch = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.trigger('keyboard', 'actions.find', null);
|
||||
editorRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const supportedLanguages = useMemo(() => getSupportedLanguages(), []);
|
||||
const monacoLanguage = useMemo(() => languageIdToMonaco(languageId), [languageId]);
|
||||
const languageOptions = useMemo(
|
||||
() => supportedLanguages.map((lang) => ({ value: lang.id, label: lang.name })),
|
||||
[supportedLanguages],
|
||||
const handleContentChange = useCallback(
|
||||
(nextContent: string, viewState: Monaco.editor.ICodeEditorViewState | null) => {
|
||||
setContent(nextContent);
|
||||
contentRef.current = nextContent;
|
||||
viewStateRef.current = viewState;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleLanguageChange = useCallback((nextValue: string) => {
|
||||
setLanguageId(nextValue || 'plaintext');
|
||||
}, []);
|
||||
const handlePromote = useCallback(() => {
|
||||
if (!onPromoteToTab) return;
|
||||
const snapshot = createTextEditorModalSnapshot({
|
||||
fileName,
|
||||
getBaselineContent: () => baselineContentRef.current,
|
||||
getContent: () => contentRef.current,
|
||||
languageId,
|
||||
wordWrap: editorWordWrap,
|
||||
getViewState: () => viewStateRef.current,
|
||||
isSaving: () => savingRef.current,
|
||||
});
|
||||
if (snapshot) onPromoteToTab(snapshot);
|
||||
}, [onPromoteToTab, fileName, languageId, editorWordWrap]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<DialogContent
|
||||
className="max-w-5xl h-[85vh] flex flex-col p-0 gap-0"
|
||||
hideCloseButton
|
||||
data-hotkey-close-tab="true"
|
||||
onKeyDownCapture={handleDialogKeyDownCapture}
|
||||
>
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<DialogTitle className="text-sm font-semibold truncate">
|
||||
{fileName}
|
||||
{hasChanges && <span className="text-primary ml-1">*</span>}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{/* Search button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleSearch}
|
||||
title={t('common.search')}
|
||||
>
|
||||
<Search size={14} />
|
||||
</Button>
|
||||
|
||||
{/* Word wrap toggle */}
|
||||
<Button
|
||||
variant={editorWordWrap ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onToggleWordWrap}
|
||||
title={t('sftp.editor.wordWrap')}
|
||||
>
|
||||
<WrapText size={14} />
|
||||
</Button>
|
||||
|
||||
{/* Language selector */}
|
||||
<Combobox
|
||||
options={languageOptions}
|
||||
value={languageId}
|
||||
onValueChange={handleLanguageChange}
|
||||
placeholder={t('sftp.editor.syntaxHighlight')}
|
||||
triggerClassName="h-7 max-w-[180px] min-w-[120px] text-xs"
|
||||
/>
|
||||
|
||||
{/* Save button */}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !hasChanges}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 size={14} className="mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<CloudUpload size={14} className="mr-1.5" />
|
||||
)}
|
||||
{saving ? t('sftp.editor.saving') : t('sftp.editor.save')}
|
||||
</Button>
|
||||
|
||||
{/* Close button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Monaco Editor */}
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<Editor
|
||||
height="100%"
|
||||
language={monacoLanguage}
|
||||
value={content}
|
||||
onChange={handleEditorChange}
|
||||
onMount={handleEditorMount}
|
||||
theme={customThemeName}
|
||||
loading={
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background">
|
||||
<Loader2 size={32} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
}
|
||||
options={{
|
||||
// Prefer native context menu in Electron so right-click Paste uses OS clipboard path.
|
||||
contextmenu: false,
|
||||
minimap: { enabled: true },
|
||||
fontSize: 14,
|
||||
lineNumbers: 'on',
|
||||
roundedSelection: false,
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
wordWrap: editorWordWrap ? 'on' : 'off',
|
||||
folding: true,
|
||||
renderWhitespace: 'selection',
|
||||
bracketPairColorization: { enabled: true },
|
||||
find: {
|
||||
addExtraSpaceOnTop: false,
|
||||
autoFindInSelection: 'never',
|
||||
seedSearchStringFromSelection: 'selection',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-2 border-t border-border/60 flex items-center justify-between text-xs text-muted-foreground bg-muted/30 flex-shrink-0">
|
||||
<span>
|
||||
{getLanguageName(languageId)}
|
||||
</span>
|
||||
<span>
|
||||
{content.split('\n').length} lines • {content.length} characters
|
||||
</span>
|
||||
</div>
|
||||
{/* Radix requires a DialogTitle inside every DialogContent for a11y.
|
||||
The Pane's own header already shows the filename visually, so we
|
||||
mirror it here inside an sr-only DialogTitle for screen readers. */}
|
||||
<DialogTitle className="sr-only">{fileName}</DialogTitle>
|
||||
<TextEditorPane
|
||||
chrome="modal"
|
||||
fileName={`${fileName}${hasChanges ? ' *' : ''}`}
|
||||
content={content}
|
||||
languageId={languageId}
|
||||
wordWrap={editorWordWrap}
|
||||
saving={saving}
|
||||
saveError={saveError}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
onContentChange={handleContentChange}
|
||||
onLanguageChange={setLanguageId}
|
||||
onToggleWordWrap={onToggleWordWrap}
|
||||
onSave={handleSave}
|
||||
onRequestClose={handleClose}
|
||||
onPromoteToTab={onPromoteToTab ? handlePromote : undefined}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Bell, Copy, FileText, Folder, FolderLock, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, Sparkles, Square, Sun, TerminalSquare, Usb, X } from 'lucide-react';
|
||||
import { Bell, Copy, FileCode, FileText, Folder, FolderLock, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, Settings, Sparkles, Square, Sun, TerminalSquare, Usb, X } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { activeTabStore, useActiveTabId } from '../application/state/activeTabStore';
|
||||
import { activeTabStore, fromEditorTabId, isEditorTabId, useActiveTabId } from '../application/state/activeTabStore';
|
||||
import type { EditorTab } from '../application/state/editorTabStore';
|
||||
import { buildWorkspaceActivityMap } from '../application/state/sessionActivity';
|
||||
import { useSessionActivityMap } from '../application/state/sessionActivityStore';
|
||||
import { LogView } from '../application/state/useSessionState';
|
||||
@@ -19,6 +20,9 @@ import { SyncStatusButton } from './SyncStatusButton';
|
||||
const dragRegionStyle = { WebkitAppRegion: 'drag' } as React.CSSProperties;
|
||||
const dragRegionNoSelect = { WebkitAppRegion: 'drag', userSelect: 'none' } as React.CSSProperties;
|
||||
|
||||
// File extensions that render the code-file icon instead of the plain text icon.
|
||||
const CODE_EXTENSIONS_RE = /\.(js|jsx|ts|tsx|py|rb|go|rs|c|cpp|cs|java|php|sh|bash|zsh|fish|lua|r|scala|swift|kt|html|css|scss|less|json|yaml|yml|toml|xml|sql|graphql|gql|md|mdx|conf|ini|env|tf|hcl|dockerfile)$/i;
|
||||
|
||||
interface TopTabsProps {
|
||||
theme: 'dark' | 'light';
|
||||
followAppTerminalTheme?: boolean;
|
||||
@@ -46,6 +50,9 @@ interface TopTabsProps {
|
||||
onEndSessionDrag: () => void;
|
||||
onReorderTabs: (draggedId: string, targetId: string, position: 'before' | 'after') => void;
|
||||
showSftpTab: boolean;
|
||||
editorTabs: readonly EditorTab[];
|
||||
onRequestCloseEditorTab: (editorTabId: string) => void;
|
||||
hostById: Map<string, Host>;
|
||||
}
|
||||
|
||||
// Detect local OS for local terminal tab icons
|
||||
@@ -255,6 +262,9 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onEndSessionDrag,
|
||||
onReorderTabs,
|
||||
showSftpTab,
|
||||
editorTabs,
|
||||
onRequestCloseEditorTab,
|
||||
hostById,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
// Subscribe to activeTabId from external store
|
||||
@@ -477,9 +487,30 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
return styles;
|
||||
}, [dropIndicator, isDraggingForReorder, orderedTabs]);
|
||||
|
||||
// Pre-compute editor tab map for O(1) access
|
||||
const editorTabMap = useMemo(() => {
|
||||
const map = new Map<string, EditorTab>();
|
||||
for (const t of editorTabs) map.set(t.id, t);
|
||||
return map;
|
||||
}, [editorTabs]);
|
||||
|
||||
// fileName → count, for the rename-disambiguation suffix in the render loop.
|
||||
// Memoed so we don't do a per-tab O(n) filter on every render (was O(n²)).
|
||||
const editorTabFileNameCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const t of editorTabs) counts.set(t.fileName, (counts.get(t.fileName) ?? 0) + 1);
|
||||
return counts;
|
||||
}, [editorTabs]);
|
||||
|
||||
// Build ordered tab items using pre-computed maps for O(1) lookups
|
||||
const orderedTabItems = useMemo(() => {
|
||||
return orderedTabs.map((tabId) => {
|
||||
if (isEditorTabId(tabId)) {
|
||||
const editorId = fromEditorTabId(tabId);
|
||||
const editorTab = editorTabMap.get(editorId);
|
||||
if (!editorTab) return null;
|
||||
return { type: 'editor' as const, id: tabId, editorTab };
|
||||
}
|
||||
const session = orphanSessionMap.get(tabId);
|
||||
const workspace = workspaceMap.get(tabId);
|
||||
const logView = logViewMap.get(tabId);
|
||||
@@ -494,7 +525,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
}, [orderedTabs, orphanSessionMap, workspaceMap, logViewMap, workspacePaneCounts]);
|
||||
}, [orderedTabs, editorTabMap, orphanSessionMap, workspaceMap, logViewMap, workspacePaneCounts]);
|
||||
|
||||
// Bulk-close menu items shared by session and workspace context menus.
|
||||
// Anchor is the tab the user right-clicked on (matches VSCode/JetBrains UX).
|
||||
@@ -532,6 +563,77 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
return orderedTabItems.map((item) => {
|
||||
if (!item) return null;
|
||||
|
||||
if (item.type === 'editor') {
|
||||
const { editorTab } = item;
|
||||
const tabId = item.id;
|
||||
const isActive = activeTabId === tabId;
|
||||
const host = hostById.get(editorTab.hostId);
|
||||
const dirty = editorTab.content !== editorTab.baselineContent;
|
||||
const tooltip = `${host?.label ?? editorTab.hostId}@${host?.hostname ?? ''}:${editorTab.remotePath}`;
|
||||
// Disambiguate duplicate filenames using the memoed counts map.
|
||||
const suffix = (editorTabFileNameCounts.get(editorTab.fileName) ?? 0) > 1
|
||||
? ` · ${editorTab.remotePath.split('/').slice(-2, -1)[0] || '/'}`
|
||||
: '';
|
||||
const FileIcon = CODE_EXTENSIONS_RE.test(editorTab.fileName) ? FileCode : FileText;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tabId}
|
||||
data-tab-id={tabId}
|
||||
data-tab-type="editor"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={() => onSelectTab(tabId)}
|
||||
title={tooltip}
|
||||
className={cn(
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<FileIcon
|
||||
size={14}
|
||||
className="shrink-0"
|
||||
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
/>
|
||||
<span className="truncate flex items-center gap-0.5">
|
||||
{dirty && <span className="text-primary mr-0.5">●</span>}
|
||||
{editorTab.fileName}
|
||||
{suffix && <span className="text-muted-foreground ml-1">{suffix}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRequestCloseEditorTab(editorTab.id);
|
||||
}}
|
||||
className="p-1 rounded-full hover:bg-destructive/10 hover:text-destructive transition-colors"
|
||||
aria-label="Close editor tab"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === 'session') {
|
||||
const session = item.session;
|
||||
const hasActivity = !!sessionActivityMap[session.id];
|
||||
@@ -980,6 +1082,17 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Settings gear button - sits to the left of WindowControls on win/linux, at the right edge on mac */}
|
||||
<div className="self-stretch flex items-stretch">
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="h-full w-10 flex items-center justify-center transition-all duration-150 app-no-drag"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
title="Open Settings"
|
||||
>
|
||||
<Settings size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Custom window controls for Windows/Linux */}
|
||||
{!isMacClient && <div className="self-stretch flex items-stretch"><WindowControls /></div>}
|
||||
{/* Small drag shim to the right edge (macOS only – on Windows the close button should touch the edge) */}
|
||||
|
||||
@@ -11,6 +11,8 @@ import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { useTrayPanelBackend } from "../application/state/useTrayPanelBackend";
|
||||
import { useActiveTabId } from "../application/state/activeTabStore";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
|
||||
import type { Host } from "../domain/models";
|
||||
import { X, Maximize2, ChevronRight, ChevronDown, Power } from "lucide-react";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
|
||||
@@ -105,7 +107,11 @@ const WorkspaceGroup: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
const TrayPanelContent: React.FC = () => {
|
||||
interface TrayPanelContentProps {
|
||||
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
|
||||
}
|
||||
|
||||
const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings }) => {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
hideTrayPanel,
|
||||
@@ -117,10 +123,14 @@ const TrayPanelContent: React.FC = () => {
|
||||
onTrayPanelMenuData,
|
||||
} = useTrayPanelBackend();
|
||||
|
||||
const { hosts, keys, identities, groupConfigs } = useVaultState();
|
||||
const { hosts, keys, identities, proxyProfiles, groupConfigs } = useVaultState();
|
||||
useSessionState();
|
||||
const { rules: portForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
|
||||
const activeTabId = useActiveTabId();
|
||||
const proxyProfileIdSet = useMemo(
|
||||
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
[proxyProfiles],
|
||||
);
|
||||
|
||||
const [traySessions, setTraySessions] = useState<TraySession[]>([]);
|
||||
|
||||
@@ -202,7 +212,7 @@ const TrayPanelContent: React.FC = () => {
|
||||
}, [quitApp]);
|
||||
|
||||
return (
|
||||
<div id="tray-panel-root" className="w-full h-full bg-background/95 backdrop-blur border border-border/60 rounded-lg shadow-lg overflow-hidden flex flex-col">
|
||||
<div id="tray-panel-root" className="w-full h-full bg-background/95 supports-[backdrop-filter]:backdrop-blur-sm border border-border/60 rounded-lg shadow-lg overflow-hidden flex flex-col">
|
||||
<div className="px-3 py-2 border-b border-border/60 flex items-center justify-between app-no-drag">
|
||||
<div className="flex items-center gap-2">
|
||||
<AppLogo className="w-5 h-5" />
|
||||
@@ -335,12 +345,16 @@ const TrayPanelContent: React.FC = () => {
|
||||
if (isActive) {
|
||||
void stopTunnel(rule.id);
|
||||
} else {
|
||||
const host = rawHost.group
|
||||
? applyGroupDefaults(rawHost, resolveGroupDefaults(rawHost.group, groupConfigs))
|
||||
: rawHost;
|
||||
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
|
||||
const resolveEffectiveHost = (host: Host) => {
|
||||
const withGroupDefaults = host.group
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
};
|
||||
const host = resolveEffectiveHost(rawHost);
|
||||
void startTunnel(rule, host, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart);
|
||||
}, rule.autoStart, terminalSettings);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
@@ -401,7 +415,7 @@ const TrayPanel: React.FC = () => {
|
||||
const settings = useSettingsState();
|
||||
return (
|
||||
<I18nProvider locale={settings.uiLanguage}>
|
||||
<TrayPanelContent />
|
||||
<TrayPanelContent terminalSettings={settings.terminalSettings} />
|
||||
</I18nProvider>
|
||||
);
|
||||
};
|
||||
|
||||
72
components/VaultView.memo.test.tsx
Normal file
72
components/VaultView.memo.test.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { vaultViewAreEqual } from "./VaultView.tsx";
|
||||
|
||||
test("VaultView re-renders when an external section navigation request changes", () => {
|
||||
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, navigateToSection: "snippets" } as never,
|
||||
),
|
||||
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,
|
||||
);
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
FileSymlink,
|
||||
FolderPlus,
|
||||
FolderTree,
|
||||
Globe,
|
||||
Key,
|
||||
LayoutGrid,
|
||||
List,
|
||||
@@ -35,8 +36,17 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
||||
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
|
||||
import { sanitizeCredentialValue } from "../domain/credentials";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { getEffectiveHostDistro, sanitizeHost, upsertHostById } from "../domain/host";
|
||||
import {
|
||||
getEffectiveHostDistro,
|
||||
resolveTelnetPassword,
|
||||
resolveTelnetPort,
|
||||
resolveTelnetUsername,
|
||||
sanitizeHost,
|
||||
upsertHostById,
|
||||
} from "../domain/host";
|
||||
import { upsertKnownHost } from "../domain/knownHosts";
|
||||
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
|
||||
import type { VaultImportFormat } from "../domain/vaultImport";
|
||||
import {
|
||||
@@ -55,6 +65,7 @@ import {
|
||||
Identity,
|
||||
KnownHost,
|
||||
ManagedSource,
|
||||
ProxyProfile,
|
||||
SerialConfig,
|
||||
SSHKey,
|
||||
ShellHistoryEntry,
|
||||
@@ -69,6 +80,7 @@ import { HostTreeView } from "./HostTreeView";
|
||||
import KeychainManager from "./KeychainManager";
|
||||
import KnownHostsManager from "./KnownHostsManager";
|
||||
import PortForwarding from "./PortForwardingNew";
|
||||
import ProxyProfilesManager from "./ProxyProfilesManager";
|
||||
import QuickConnectWizard from "./QuickConnectWizard";
|
||||
import { isQuickConnectInput, parseQuickConnectInputWithWarnings } from "../domain/quickConnect";
|
||||
import SerialConnectModal from "./SerialConnectModal";
|
||||
@@ -104,7 +116,7 @@ import { HotkeyScheme, KeyBinding } from "../domain/models";
|
||||
const LazyProtocolSelectDialog = lazy(() => import("./ProtocolSelectDialog"));
|
||||
const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager"));
|
||||
|
||||
export type VaultSection = "hosts" | "keys" | "snippets" | "port" | "knownhosts" | "logs";
|
||||
export type VaultSection = "hosts" | "keys" | "proxies" | "snippets" | "port" | "knownhosts" | "logs";
|
||||
|
||||
type DropTarget =
|
||||
| { kind: "root" }
|
||||
@@ -115,6 +127,7 @@ interface VaultViewProps {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
proxyProfiles: ProxyProfile[];
|
||||
snippets: Snippet[];
|
||||
snippetPackages: string[];
|
||||
customGroups: string[];
|
||||
@@ -135,7 +148,9 @@ interface VaultViewProps {
|
||||
onConnect: (host: Host) => void;
|
||||
onUpdateHosts: (hosts: Host[]) => void;
|
||||
onUpdateKeys: (keys: SSHKey[]) => void;
|
||||
onImportOrReuseKey: (draft: Partial<SSHKey>) => SSHKey;
|
||||
onUpdateIdentities: (identities: Identity[]) => void;
|
||||
onUpdateProxyProfiles: (profiles: ProxyProfile[]) => void;
|
||||
onUpdateSnippets: (snippets: Snippet[]) => void;
|
||||
onUpdateSnippetPackages: (pkgs: string[]) => void;
|
||||
onUpdateCustomGroups: (groups: string[]) => void;
|
||||
@@ -157,12 +172,14 @@ interface VaultViewProps {
|
||||
// Optional: navigate to a specific section on mount or when changed
|
||||
navigateToSection?: VaultSection | null;
|
||||
onNavigateToSectionHandled?: () => void;
|
||||
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
|
||||
}
|
||||
|
||||
const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
snippets,
|
||||
snippetPackages,
|
||||
customGroups,
|
||||
@@ -183,7 +200,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
onConnect,
|
||||
onUpdateHosts,
|
||||
onUpdateKeys,
|
||||
onImportOrReuseKey,
|
||||
onUpdateIdentities,
|
||||
onUpdateProxyProfiles,
|
||||
onUpdateSnippets,
|
||||
onUpdateSnippetPackages,
|
||||
onUpdateCustomGroups,
|
||||
@@ -204,6 +223,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
navigateToSection,
|
||||
onNavigateToSectionHandled,
|
||||
terminalSettings,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
@@ -296,6 +316,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
if (!group) return undefined;
|
||||
return resolveGroupDefaults(group, groupConfigs);
|
||||
}, [editingHost, newHostGroupPath, selectedGroupPath, groupConfigs]);
|
||||
const proxyProfileIdSet = useMemo(
|
||||
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
[proxyProfiles],
|
||||
);
|
||||
// Quick connect state
|
||||
const [quickConnectTarget, setQuickConnectTarget] = useState<{
|
||||
hostname: string;
|
||||
@@ -343,8 +367,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
// Check if host has multiple protocols enabled (using effective/resolved host)
|
||||
const hasMultipleProtocols = useCallback((host: Host) => {
|
||||
const effective = host.group
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs))
|
||||
: host;
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
let count = 0;
|
||||
// SSH is always available as base protocol (unless explicitly set to something else)
|
||||
if (effective.protocol === "ssh" || !effective.protocol) count++;
|
||||
@@ -355,7 +379,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
// If protocol is explicitly telnet (not ssh), count it
|
||||
if (effective.protocol === "telnet" && !effective.telnetEnabled) count++;
|
||||
return count > 1;
|
||||
}, [groupConfigs]);
|
||||
}, [groupConfigs, proxyProfileIdSet]);
|
||||
|
||||
// Handle host connect with protocol selection
|
||||
const handleHostConnect = useCallback(
|
||||
@@ -363,14 +387,14 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
if (hasMultipleProtocols(host)) {
|
||||
// Pass effective host to protocol dialog so it shows correct ports/protocols
|
||||
const effective = host.group
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs))
|
||||
: host;
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
setProtocolSelectHost(effective);
|
||||
} else {
|
||||
onConnect(host);
|
||||
}
|
||||
},
|
||||
[hasMultipleProtocols, onConnect, groupConfigs],
|
||||
[hasMultipleProtocols, onConnect, groupConfigs, proxyProfileIdSet],
|
||||
);
|
||||
|
||||
// Handle protocol selection
|
||||
@@ -475,16 +499,14 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const handleCopyCredentials = useCallback((host: Host) => {
|
||||
// Apply group defaults so inherited credentials are included
|
||||
const effective = host.group
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs))
|
||||
: host;
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
// Only use telnet-specific port and credentials when protocol is explicitly telnet
|
||||
// Don't treat telnetEnabled as primary - that's just an optional protocol
|
||||
const isTelnet = effective.protocol === "telnet";
|
||||
|
||||
const defaultPort = isTelnet ? 23 : 22;
|
||||
const effectivePort = isTelnet
|
||||
? (effective.telnetPort ?? effective.port ?? 23)
|
||||
: (effective.port ?? 22);
|
||||
const effectivePort = isTelnet ? resolveTelnetPort(effective) : (effective.port ?? 22);
|
||||
|
||||
// Bracket IPv6 addresses when appending non-default port
|
||||
let address: string;
|
||||
@@ -503,12 +525,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
: undefined;
|
||||
|
||||
const username = isTelnet
|
||||
? (effective.telnetUsername?.trim() || effective.username?.trim())
|
||||
? resolveTelnetUsername(effective)
|
||||
: (identity?.username?.trim() || effective.username?.trim());
|
||||
|
||||
const password = isTelnet
|
||||
? (effective.telnetPassword || effective.password)
|
||||
const rawPassword = isTelnet
|
||||
? resolveTelnetPassword(effective)
|
||||
: (identity?.password || effective.password);
|
||||
const password = sanitizeCredentialValue(rawPassword);
|
||||
|
||||
if (!password) {
|
||||
toast.warning(t('vault.hosts.copyCredentials.toast.noPassword'));
|
||||
@@ -519,7 +542,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
toast.success(t('vault.hosts.copyCredentials.toast.success'));
|
||||
});
|
||||
}, [identities, groupConfigs, t]);
|
||||
}, [identities, groupConfigs, proxyProfileIdSet, t]);
|
||||
|
||||
const [lastPinnedId, setLastPinnedId] = useState<string | null>(null);
|
||||
const toggleHostPinned = useCallback((hostId: string) => {
|
||||
@@ -1179,7 +1202,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
|
||||
// Stable callbacks that read from refs
|
||||
const handleSaveKnownHost = useCallback((kh: KnownHost) => {
|
||||
onUpdateKnownHostsRef.current([...knownHostsRef.current, kh]);
|
||||
onUpdateKnownHostsRef.current(upsertKnownHost(knownHostsRef.current, kh));
|
||||
}, []);
|
||||
|
||||
const handleUpdateKnownHost = useCallback((kh: KnownHost) => {
|
||||
@@ -1669,6 +1692,26 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.keychain")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
variant={currentSection === "proxies" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
|
||||
currentSection === "proxies" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentSection("proxies");
|
||||
}}
|
||||
>
|
||||
<Globe size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.proxies")}
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.proxies")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
@@ -2494,11 +2537,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
getDropTargetClasses={(path) =>
|
||||
getDropTargetClasses({ kind: "group", path })
|
||||
}
|
||||
setDragOverDropTarget={setGroupDragOverDropTarget}
|
||||
/>
|
||||
getDropTargetClasses={(path) =>
|
||||
getDropTargetClasses({ kind: "group", path })
|
||||
}
|
||||
setDragOverDropTarget={setGroupDragOverDropTarget}
|
||||
groupConfigs={groupConfigs}
|
||||
/>
|
||||
) : sortMode === "group" && groupedDisplayHosts ? (
|
||||
<div className="space-y-6">
|
||||
{groupedDisplayHosts.map((group) => (
|
||||
@@ -2826,6 +2870,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
}
|
||||
onRunSnippet={onRunSnippet}
|
||||
availableKeys={keys}
|
||||
proxyProfiles={proxyProfiles}
|
||||
managedSources={managedSources}
|
||||
onSaveHost={(host) => onUpdateHosts([...hosts, host])}
|
||||
onCreateGroup={(groupPath) =>
|
||||
@@ -2840,6 +2885,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
hosts={hosts}
|
||||
proxyProfiles={proxyProfiles}
|
||||
customGroups={customGroups}
|
||||
managedSources={managedSources}
|
||||
onSave={(k) => onUpdateKeys([...keys, k])}
|
||||
@@ -2877,11 +2923,22 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{currentSection === "proxies" && (
|
||||
<ProxyProfilesManager
|
||||
proxyProfiles={proxyProfiles}
|
||||
hosts={hosts}
|
||||
groupConfigs={groupConfigs}
|
||||
onUpdateProxyProfiles={onUpdateProxyProfiles}
|
||||
onUpdateHosts={onUpdateHosts}
|
||||
onUpdateGroupConfigs={onUpdateGroupConfigs}
|
||||
/>
|
||||
)}
|
||||
{currentSection === "port" && (
|
||||
<PortForwarding
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
proxyProfiles={proxyProfiles}
|
||||
customGroups={customGroups}
|
||||
managedSources={managedSources}
|
||||
groupConfigs={groupConfigs}
|
||||
@@ -2891,6 +2948,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
Array.from(new Set([...customGroups, groupPath])),
|
||||
)
|
||||
}
|
||||
terminalSettings={terminalSettings}
|
||||
/>
|
||||
)}
|
||||
{/* Always render KnownHostsManager but hide with CSS to prevent unmounting */}
|
||||
@@ -2924,6 +2982,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
config={groupConfigs.find(c => c.path === editingGroupPath)}
|
||||
availableKeys={keys}
|
||||
identities={identities}
|
||||
proxyProfiles={proxyProfiles}
|
||||
allHosts={hosts}
|
||||
groups={allGroupPaths}
|
||||
terminalThemeId={terminalThemeId}
|
||||
@@ -2944,6 +3003,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
initialData={editingHost}
|
||||
availableKeys={keys}
|
||||
identities={identities}
|
||||
proxyProfiles={proxyProfiles}
|
||||
groups={allGroupPaths}
|
||||
managedSources={managedSources}
|
||||
allTags={allTags}
|
||||
@@ -2953,6 +3013,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
terminalFontSize={terminalFontSize}
|
||||
groupDefaults={editingHostGroupDefaults}
|
||||
groupConfigs={groupConfigs}
|
||||
onImportKey={onImportOrReuseKey}
|
||||
onSave={(host) => {
|
||||
onUpdateHosts(upsertHostById(hosts, host));
|
||||
setIsHostPanelOpen(false);
|
||||
@@ -3199,7 +3260,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
};
|
||||
|
||||
// Only re-render when data props change - isActive is now managed internally via store subscription
|
||||
const vaultViewAreEqual = (
|
||||
export const vaultViewAreEqual = (
|
||||
prev: VaultViewProps,
|
||||
next: VaultViewProps,
|
||||
): boolean => {
|
||||
@@ -3207,6 +3268,7 @@ 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 &&
|
||||
@@ -3217,7 +3279,14 @@ const vaultViewAreEqual = (
|
||||
prev.managedSources === next.managedSources &&
|
||||
prev.groupConfigs === next.groupConfigs &&
|
||||
prev.terminalThemeId === next.terminalThemeId &&
|
||||
prev.terminalFontSize === next.terminalFontSize;
|
||||
prev.terminalFontSize === next.terminalFontSize &&
|
||||
prev.navigateToSection === next.navigateToSection &&
|
||||
// Only the keepalive fields of terminalSettings are forwarded to
|
||||
// PortForwarding inside the vault, so compare them directly. Other
|
||||
// terminal settings (fonts, themes, etc.) don't affect this subtree
|
||||
// and we don't want to re-render for them.
|
||||
prev.terminalSettings?.keepaliveInterval === next.terminalSettings?.keepaliveInterval &&
|
||||
prev.terminalSettings?.keepaliveCountMax === next.terminalSettings?.keepaliveCountMax;
|
||||
|
||||
return isEqual;
|
||||
};
|
||||
|
||||
@@ -9,27 +9,37 @@ export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: 'user' | 'assistant' | 'system' | 'tool';
|
||||
};
|
||||
|
||||
// Public CSS hooks for user customization (Settings → Appearance → Custom CSS):
|
||||
// .ai-chat-message[data-role="user"] — outer row, user-authored
|
||||
// .ai-chat-message[data-role="assistant"] — outer row, assistant reply
|
||||
// .ai-chat-message-content[data-role=...] — inner bubble / content area
|
||||
// These attributes are part of the UI's stable contract; do not rename
|
||||
// without updating Custom CSS docs.
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex w-full max-w-[95%] flex-col gap-1.5',
|
||||
'ai-chat-message group flex w-full max-w-[95%] flex-col gap-1.5',
|
||||
from === 'user' ? 'is-user ml-auto' : 'is-assistant',
|
||||
className,
|
||||
)}
|
||||
data-role={from}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from?: 'user' | 'assistant' | 'system' | 'tool';
|
||||
};
|
||||
|
||||
export const MessageContent = ({ children, className, ...props }: MessageContentProps) => (
|
||||
export const MessageContent = ({ children, className, from, ...props }: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'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',
|
||||
'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-[7px]',
|
||||
'group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground/90',
|
||||
className,
|
||||
)}
|
||||
data-role={from}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -217,7 +217,7 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
<DropdownContent
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="w-[288px] overflow-hidden rounded-2xl border border-border/50 bg-popover p-0 text-foreground shadow-lg supports-[backdrop-filter]:backdrop-blur-xl"
|
||||
className="w-[288px] overflow-hidden rounded-2xl border border-border/50 bg-popover p-0 text-foreground shadow-lg supports-[backdrop-filter]:backdrop-blur-sm"
|
||||
>
|
||||
{BUILTIN_AGENTS.map((agent) => (
|
||||
<AgentMenuRow
|
||||
|
||||
@@ -512,6 +512,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
{showSlashSkillPicker && filteredUserSkills.length > 0 && inputPanelPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div className="fixed inset-0 z-[999] cursor-default" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Insert user skill"
|
||||
@@ -578,6 +579,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
{showAttachMenu && menuPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div className="fixed inset-0 z-[999] cursor-default" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="menu"
|
||||
className="fixed z-[1000] min-w-[170px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
@@ -658,10 +660,11 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
{hasModelPicker && <ChevronDown size={9} className="text-muted-foreground/50" />}
|
||||
</button>
|
||||
{showModelPicker && hasModelPicker && menuPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="listbox"
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div className="fixed inset-0 z-[999] cursor-default" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Select model"
|
||||
className="fixed z-[1000] w-max min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom, maxWidth: MODEL_PICKER_MAX_WIDTH }}
|
||||
@@ -770,6 +773,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
{showPermPicker && menuPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div className="fixed inset-0 z-[999] cursor-default" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Permission mode"
|
||||
|
||||
@@ -196,7 +196,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
|
||||
return (
|
||||
<Message key={message.id} from={message.role}>
|
||||
<MessageContent>
|
||||
<MessageContent from={message.role}>
|
||||
{/* Thinking block */}
|
||||
{!isUser && message.thinking && (
|
||||
<ThinkingBlock
|
||||
@@ -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>
|
||||
|
||||
@@ -49,7 +49,7 @@ const ConversationExport: React.FC<ConversationExportProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={className ?? 'h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground'}
|
||||
className={className ?? 'h-7 w-7 rounded-md text-muted-foreground/70 hover:bg-accent/60 hover:text-foreground'}
|
||||
disabled={!hasMessages}
|
||||
title={t('ai.chat.exportConversation')}
|
||||
>
|
||||
@@ -59,18 +59,18 @@ const ConversationExport: React.FC<ConversationExportProps> = ({
|
||||
<DropdownContent
|
||||
align="end"
|
||||
sideOffset={6}
|
||||
className="w-40 rounded-xl border border-border/45 bg-[#111111]/98 p-1.5 text-foreground shadow-[0_20px_48px_rgba(0,0,0,0.48)] supports-[backdrop-filter]:bg-[#111111]/92 supports-[backdrop-filter]:backdrop-blur-xl"
|
||||
className="w-40 rounded-xl border border-border/60 bg-popover p-1.5 text-popover-foreground shadow-lg supports-[backdrop-filter]:bg-popover/95 supports-[backdrop-filter]:backdrop-blur-sm"
|
||||
>
|
||||
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground/48">
|
||||
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground/70">
|
||||
{t('ai.chat.exportAs')}
|
||||
</div>
|
||||
{EXPORT_OPTIONS.map(({ format, labelKey, icon: Icon }) => (
|
||||
<button
|
||||
key={format}
|
||||
onClick={() => handleExport(format)}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 text-[13px] rounded-lg transition-colors cursor-pointer hover:bg-white/[0.04]"
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 text-[13px] rounded-lg transition-colors cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Icon size={13} className="shrink-0 text-muted-foreground/70" />
|
||||
<Icon size={13} className="shrink-0 text-muted-foreground" />
|
||||
<span>{t(labelKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
35
components/ai/agentSendEligibility.test.ts
Normal file
35
components/ai/agentSendEligibility.test.ts
Normal 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);
|
||||
});
|
||||
15
components/ai/agentSendEligibility.ts
Normal file
15
components/ai/agentSendEligibility.ts
Normal 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));
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
119
components/ai/managedAgentState.test.ts
Normal file
119
components/ai/managedAgentState.test.ts
Normal 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');
|
||||
});
|
||||
37
components/editor/TextEditorPane.test.tsx
Normal file
37
components/editor/TextEditorPane.test.tsx
Normal 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=""/);
|
||||
});
|
||||
613
components/editor/TextEditorPane.tsx
Normal file
613
components/editor/TextEditorPane.tsx
Normal file
@@ -0,0 +1,613 @@
|
||||
/**
|
||||
* TextEditorPane — pure Monaco editor body + toolbar.
|
||||
* Extracted from TextEditorModal.tsx. Contains no Dialog shell.
|
||||
* Parents (modal or tab) own content state, saving state, and toast calls.
|
||||
*/
|
||||
import {
|
||||
CloudUpload,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
Search,
|
||||
WrapText,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import Editor, { type OnMount, loader, useMonaco } from '@monaco-editor/react';
|
||||
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 viteEnv = import.meta.env ?? { BASE_URL: "/" };
|
||||
const monacoBasePath = viteEnv.DEV
|
||||
? './node_modules/monaco-editor/min/vs'
|
||||
: `${viteEnv.BASE_URL}monaco/vs`;
|
||||
loader.config({ paths: { vs: monacoBasePath } });
|
||||
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { useClipboardBackend } from '../../application/state/useClipboardBackend';
|
||||
import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../../domain/models';
|
||||
import { getLanguageName, getSupportedLanguages } from '../../lib/sftpFileUtils';
|
||||
import { Button } from '../ui/button';
|
||||
import { Combobox } from '../ui/combobox';
|
||||
|
||||
// Map our language IDs to Monaco language IDs
|
||||
const languageIdToMonaco = (langId: string): string => {
|
||||
const mapping: Record<string, string> = {
|
||||
'javascript': 'javascript',
|
||||
'typescript': 'typescript',
|
||||
'python': 'python',
|
||||
'shell': 'shell',
|
||||
'batch': 'bat',
|
||||
'powershell': 'powershell',
|
||||
'c': 'c',
|
||||
'cpp': 'cpp',
|
||||
'java': 'java',
|
||||
'kotlin': 'kotlin',
|
||||
'go': 'go',
|
||||
'rust': 'rust',
|
||||
'ruby': 'ruby',
|
||||
'php': 'php',
|
||||
'perl': 'perl',
|
||||
'lua': 'lua',
|
||||
'r': 'r',
|
||||
'swift': 'swift',
|
||||
'dart': 'dart',
|
||||
'csharp': 'csharp',
|
||||
'fsharp': 'fsharp',
|
||||
'vb': 'vb',
|
||||
'html': 'html',
|
||||
'css': 'css',
|
||||
'scss': 'scss',
|
||||
'sass': 'sass',
|
||||
'less': 'less',
|
||||
'json': 'json',
|
||||
'jsonc': 'json',
|
||||
'json5': 'json',
|
||||
'xml': 'xml',
|
||||
'yaml': 'yaml',
|
||||
'toml': 'ini',
|
||||
'ini': 'ini',
|
||||
'sql': 'sql',
|
||||
'graphql': 'graphql',
|
||||
'markdown': 'markdown',
|
||||
'plaintext': 'plaintext',
|
||||
'vue': 'html',
|
||||
'svelte': 'html',
|
||||
'dockerfile': 'dockerfile',
|
||||
'makefile': 'makefile',
|
||||
'diff': 'diff',
|
||||
};
|
||||
return mapping[langId] || 'plaintext';
|
||||
};
|
||||
|
||||
// Convert HSL string "h s% l%" to hex color
|
||||
const hslToHex = (hslString: string): string => {
|
||||
const parts = hslString.trim().split(/\s+/);
|
||||
if (parts.length < 3) return '#1e1e1e';
|
||||
const h = parseFloat(parts[0]) / 360;
|
||||
const s = parseFloat(parts[1].replace('%', '')) / 100;
|
||||
const l = parseFloat(parts[2].replace('%', '')) / 100;
|
||||
|
||||
const hue2rgb = (p: number, q: number, t: number) => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
|
||||
let r: number, g: number, b: number;
|
||||
if (s === 0) {
|
||||
r = g = b = l;
|
||||
} else {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r = hue2rgb(p, q, h + 1 / 3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1 / 3);
|
||||
}
|
||||
|
||||
const toHex = (x: number) => {
|
||||
const hex = Math.round(x * 255).toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
};
|
||||
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
};
|
||||
|
||||
// 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();
|
||||
return value ? hslToHex(value) : fallback;
|
||||
};
|
||||
|
||||
interface EditorColors {
|
||||
bg: string;
|
||||
fg: string;
|
||||
primary: string;
|
||||
card: string;
|
||||
mutedFg: string;
|
||||
border: string;
|
||||
}
|
||||
|
||||
/** Read all UI CSS variables that matter for the Monaco theme. */
|
||||
const getEditorColors = (isDark: boolean): EditorColors => ({
|
||||
bg: getCssColor('--background', isDark ? '#1e1e1e' : '#ffffff'),
|
||||
fg: getCssColor('--foreground', isDark ? '#d4d4d4' : '#1e1e1e'),
|
||||
primary: getCssColor('--primary', isDark ? '#569cd6' : '#0078d4'),
|
||||
card: getCssColor('--card', isDark ? '#252526' : '#f3f3f3'),
|
||||
mutedFg: getCssColor('--muted-foreground', isDark ? '#858585' : '#858585'),
|
||||
border: getCssColor('--border', isDark ? '#3c3c3c' : '#d4d4d4'),
|
||||
});
|
||||
|
||||
/** 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();
|
||||
};
|
||||
|
||||
export interface TextEditorPaneProps {
|
||||
fileName: string;
|
||||
content: string;
|
||||
languageId: string;
|
||||
wordWrap: boolean;
|
||||
saving: boolean;
|
||||
saveError: string | null;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
/** Layout mode — affects header chrome (modal shows close+maximize; tab-form only shows content controls since tab has its own close). */
|
||||
chrome: 'modal' | 'tab';
|
||||
/** Optional secondary label shown next to the filename in muted text — used by the tab form to display `host:remotePath`. */
|
||||
subtitle?: string;
|
||||
onContentChange: (content: string, viewState: Monaco.editor.ICodeEditorViewState | null) => void;
|
||||
onLanguageChange: (nextLanguageId: string) => void;
|
||||
onToggleWordWrap: () => void;
|
||||
onSave: () => void;
|
||||
onRequestClose?: () => void; // modal only
|
||||
onPromoteToTab?: () => void; // modal only — omit to hide the maximize button
|
||||
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,
|
||||
languageId,
|
||||
wordWrap,
|
||||
saving,
|
||||
saveError,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
chrome,
|
||||
subtitle,
|
||||
onContentChange,
|
||||
onLanguageChange,
|
||||
onToggleWordWrap,
|
||||
onSave,
|
||||
onRequestClose,
|
||||
onPromoteToTab,
|
||||
initialViewState,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { readClipboardText: readClipboardTextFromBridge } = useClipboardBackend();
|
||||
const monaco = useMonaco();
|
||||
const editorRef = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||
|
||||
// Ref to store the latest save function to avoid stale closure in keyboard shortcut
|
||||
const handleSaveRef = useRef<() => void>(() => {});
|
||||
const handleCloseRef = useRef<(() => void) | null>(null);
|
||||
const handlePasteRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
const readClipboardTextRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
|
||||
|
||||
// Track theme from document.documentElement class (syncs with app theme)
|
||||
const [isDarkTheme, setIsDarkTheme] = useState(() =>
|
||||
typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
||||
);
|
||||
|
||||
// Track a signal that changes whenever immersive-mode or base theme colors change
|
||||
const [themeSignal, setThemeSignal] = useState(() => getThemeSignal());
|
||||
|
||||
// Custom theme name
|
||||
const customThemeName = isDarkTheme ? 'netcatty-dark' : 'netcatty-light';
|
||||
|
||||
// Define and update custom Monaco themes — syncs with immersive-mode / base UI colors
|
||||
useEffect(() => {
|
||||
if (!monaco) return;
|
||||
|
||||
const colors = getEditorColors(isDarkTheme);
|
||||
|
||||
const themeColors: Record<string, string> = {
|
||||
'editor.background': colors.bg,
|
||||
'editor.foreground': colors.fg,
|
||||
'editorCursor.foreground': colors.primary,
|
||||
'editor.selectionBackground': colors.primary + '40',
|
||||
'editor.inactiveSelectionBackground': colors.primary + '25',
|
||||
'editorLineNumber.foreground': colors.mutedFg,
|
||||
'editorLineNumber.activeForeground': colors.fg,
|
||||
'editor.lineHighlightBackground': colors.fg + '08',
|
||||
'editorWidget.background': colors.card,
|
||||
'editorWidget.foreground': colors.fg,
|
||||
'editorWidget.border': colors.border,
|
||||
'input.background': colors.card,
|
||||
'input.foreground': colors.fg,
|
||||
'input.border': colors.border,
|
||||
};
|
||||
|
||||
monaco.editor.defineTheme('netcatty-dark', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: themeColors,
|
||||
});
|
||||
|
||||
monaco.editor.defineTheme('netcatty-light', {
|
||||
base: 'vs',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: themeColors,
|
||||
});
|
||||
|
||||
monaco.editor.setTheme(customThemeName);
|
||||
}, [monaco, isDarkTheme, themeSignal, customThemeName]);
|
||||
|
||||
// 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'));
|
||||
setThemeSignal(getThemeSignal());
|
||||
};
|
||||
const observer = new MutationObserver(updateTheme);
|
||||
observer.observe(root, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style', 'data-immersive-theme'],
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const closeTabBinding = useMemo(
|
||||
() => keyBindings.find((binding) => binding.action === 'closeTab'),
|
||||
[keyBindings],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (saving) return;
|
||||
onSave();
|
||||
}, [saving, onSave]);
|
||||
|
||||
// Keep the ref updated with the latest handleSave function
|
||||
useEffect(() => {
|
||||
handleSaveRef.current = handleSave;
|
||||
}, [handleSave]);
|
||||
|
||||
// Keep the close ref fresh so the Monaco Cmd/Ctrl+W command invokes the
|
||||
// latest onRequestClose handler without re-binding the Monaco command.
|
||||
useEffect(() => {
|
||||
handleCloseRef.current = onRequestClose ?? null;
|
||||
}, [onRequestClose]);
|
||||
|
||||
const readClipboardText = useCallback(async (): Promise<string | null> => {
|
||||
try {
|
||||
if (navigator.clipboard?.readText) {
|
||||
return await navigator.clipboard.readText();
|
||||
}
|
||||
} catch {
|
||||
// Fall through to Electron bridge
|
||||
}
|
||||
|
||||
try {
|
||||
return await readClipboardTextFromBridge();
|
||||
} catch {
|
||||
// Both clipboard APIs unavailable; signal failure so caller can fall back.
|
||||
return null;
|
||||
}
|
||||
}, [readClipboardTextFromBridge]);
|
||||
|
||||
useEffect(() => {
|
||||
readClipboardTextRef.current = readClipboardText;
|
||||
}, [readClipboardText]);
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
if (saving) return;
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
const text = await readClipboardText();
|
||||
if (text === null) {
|
||||
// Clipboard read unavailable; fall back to Monaco's native paste.
|
||||
editor.trigger('keyboard', 'editor.action.clipboardPasteAction', null);
|
||||
return;
|
||||
}
|
||||
if (!text) return;
|
||||
|
||||
const selections = editor.getSelections();
|
||||
if (!selections || selections.length === 0) return;
|
||||
|
||||
// Match Monaco's default multicursorPaste:'spread' behavior:
|
||||
// distribute one line per cursor when line count equals cursor count.
|
||||
const lines = text.split(/\r\n|\n/);
|
||||
const distribute = selections.length > 1 && lines.length === selections.length;
|
||||
|
||||
editor.executeEdits(
|
||||
'netcatty-paste',
|
||||
selections.map((selection, i) => ({
|
||||
range: selection,
|
||||
text: distribute ? lines[i] : text,
|
||||
forceMoveMarkers: true,
|
||||
})),
|
||||
);
|
||||
editor.focus();
|
||||
}, [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, saving]);
|
||||
|
||||
const handleEditorMount: OnMount = useCallback((editor, monaco) => {
|
||||
editorRef.current = editor;
|
||||
|
||||
if (initialViewState) editor.restoreViewState(initialViewState);
|
||||
|
||||
// Add save shortcut - use ref to avoid stale closure
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
handleSaveRef.current();
|
||||
});
|
||||
|
||||
// Close-tab shortcut inside Monaco. The capture-phase keydown on the
|
||||
// Pane's root div also tries to handle this, but Monaco's internal
|
||||
// key-event dispatcher fires first for focused editor keystrokes, so
|
||||
// registering the command here is the reliable path.
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyW, () => {
|
||||
handleCloseRef.current?.();
|
||||
});
|
||||
|
||||
// Add find shortcut (Ctrl+F / Cmd+F)
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, () => {
|
||||
// Trigger Monaco's built-in find widget
|
||||
editor.trigger('keyboard', 'actions.find', null);
|
||||
});
|
||||
|
||||
// Fallback paste path for Electron environments where Monaco paste can fail.
|
||||
// Skip custom paste when focus is inside the find/replace widget so that
|
||||
// its input fields receive the pasted text via default browser behavior.
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => {
|
||||
const active = document.activeElement;
|
||||
if (active?.closest('.find-widget')) {
|
||||
// Read clipboard and insert into the find/replace input field.
|
||||
void (async () => {
|
||||
try {
|
||||
const text = await readClipboardTextRef.current();
|
||||
if (!text) return;
|
||||
// Monaco find widget inputs are <textarea> elements inside .monaco-inputbox
|
||||
if (active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement) {
|
||||
const start = active.selectionStart ?? active.value.length;
|
||||
const end = active.selectionEnd ?? active.value.length;
|
||||
active.focus();
|
||||
active.setSelectionRange(start, end);
|
||||
document.execCommand('insertText', false, text);
|
||||
}
|
||||
} catch {
|
||||
// Ignore – paste simply won't work
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
void handlePasteRef.current();
|
||||
});
|
||||
|
||||
editor.focus();
|
||||
}, [initialViewState]);
|
||||
|
||||
// Capture-phase close-tab hotkey handler. Runs in both modal and tab chrome
|
||||
// so Cmd/Ctrl+W works even when focus is inside Monaco (which otherwise
|
||||
// swallows the event). Requires an `onRequestClose` prop from the parent.
|
||||
const handleDialogKeyDownCapture = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (hotkeyScheme === 'disabled' || !closeTabBinding || !onRequestClose) return;
|
||||
|
||||
const isMac = hotkeyScheme === 'mac';
|
||||
const keyStr = isMac ? closeTabBinding.mac : closeTabBinding.pc;
|
||||
if (!matchesKeyBinding(e.nativeEvent, keyStr, isMac)) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopPropagation();
|
||||
onRequestClose();
|
||||
}, [closeTabBinding, hotkeyScheme, onRequestClose]);
|
||||
|
||||
// Trigger search dialog
|
||||
const handleSearch = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.trigger('keyboard', 'actions.find', null);
|
||||
editorRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const supportedLanguages = useMemo(() => getSupportedLanguages(), []);
|
||||
const monacoLanguage = useMemo(() => languageIdToMonaco(languageId), [languageId]);
|
||||
const languageOptions = useMemo(
|
||||
() => supportedLanguages.map((lang) => ({ value: lang.id, label: lang.name })),
|
||||
[supportedLanguages],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full flex flex-col"
|
||||
onKeyDownCapture={handleDialogKeyDownCapture}
|
||||
data-hotkey-close-tab={chrome === 'modal' ? 'true' : undefined}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-border/60 flex-shrink-0">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-baseline gap-2 flex-1 min-w-0">
|
||||
<span className="text-sm font-semibold truncate flex-shrink-0">
|
||||
{fileName}
|
||||
</span>
|
||||
{subtitle && (
|
||||
<span className="text-xs text-muted-foreground truncate" title={subtitle}>
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
{saveError && <span className="text-xs text-destructive truncate">{saveError}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{/* Search button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleSearch}
|
||||
title={t('common.search')}
|
||||
>
|
||||
<Search size={14} />
|
||||
</Button>
|
||||
|
||||
{/* Word wrap toggle */}
|
||||
<Button
|
||||
variant={wordWrap ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onToggleWordWrap}
|
||||
title={t('sftp.editor.wordWrap')}
|
||||
>
|
||||
<WrapText size={14} />
|
||||
</Button>
|
||||
|
||||
{/* Language selector */}
|
||||
<Combobox
|
||||
options={languageOptions}
|
||||
value={languageId}
|
||||
onValueChange={(v) => onLanguageChange(v || 'plaintext')}
|
||||
placeholder={t('sftp.editor.syntaxHighlight')}
|
||||
triggerClassName="h-7 max-w-[180px] min-w-[120px] text-xs"
|
||||
/>
|
||||
|
||||
{/* Save button */}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 size={14} className="mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<CloudUpload size={14} className="mr-1.5" />
|
||||
)}
|
||||
{saving ? t('sftp.editor.saving') : t('sftp.editor.save')}
|
||||
</Button>
|
||||
|
||||
{/* Maximize button — modal chrome only, when onPromoteToTab is provided */}
|
||||
{chrome === 'modal' && onPromoteToTab && (
|
||||
<TextEditorPromoteButton
|
||||
saving={saving}
|
||||
onPromoteToTab={onPromoteToTab}
|
||||
title={t('sftp.editor.maximize')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Close button — modal chrome only */}
|
||||
{chrome === 'modal' && onRequestClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onRequestClose}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Monaco Editor */}
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<Editor
|
||||
height="100%"
|
||||
language={monacoLanguage}
|
||||
value={content}
|
||||
onChange={handleEditorChange}
|
||||
onMount={handleEditorMount}
|
||||
theme={customThemeName}
|
||||
loading={
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background">
|
||||
<Loader2 size={32} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
}
|
||||
options={{
|
||||
// Prefer native context menu in Electron so right-click Paste uses OS clipboard path.
|
||||
contextmenu: false,
|
||||
minimap: { enabled: true },
|
||||
fontSize: 14,
|
||||
lineNumbers: 'on',
|
||||
roundedSelection: false,
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
wordWrap: wordWrap ? 'on' : 'off',
|
||||
readOnly: isTextEditorReadOnly({ saving }),
|
||||
domReadOnly: isTextEditorReadOnly({ saving }),
|
||||
folding: true,
|
||||
renderWhitespace: 'selection',
|
||||
bracketPairColorization: { enabled: true },
|
||||
find: {
|
||||
addExtraSpaceOnTop: false,
|
||||
autoFindInSelection: 'never',
|
||||
seedSearchStringFromSelection: 'selection',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-2 border-t border-border/60 flex items-center justify-between text-xs text-muted-foreground bg-muted/30 flex-shrink-0">
|
||||
<span>
|
||||
{getLanguageName(languageId)}
|
||||
</span>
|
||||
<span>
|
||||
{content.split('\n').length} lines • {content.length} characters
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextEditorPane;
|
||||
118
components/editor/TextEditorTabView.tsx
Normal file
118
components/editor/TextEditorTabView.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* TextEditorTabView — thin wrapper that binds an editorTab entry to TextEditorPane.
|
||||
*
|
||||
* Each tab has its own instance (keyed by tabId), so Monaco is never torn down
|
||||
* on tab-switch — we just toggle CSS visibility via the `isVisible` prop.
|
||||
*/
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
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';
|
||||
import { toast } from '../ui/toast';
|
||||
import { TextEditorPane } from './TextEditorPane';
|
||||
|
||||
export interface TextEditorTabViewProps {
|
||||
tabId: EditorTabId;
|
||||
/** When false the view is hidden via display:none so the Monaco instance persists. */
|
||||
isVisible: boolean;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
/** Host lookup for building the `host:remotePath` subtitle next to the filename. */
|
||||
hostById: Map<string, Host>;
|
||||
/** Routed into Monaco's Cmd/Ctrl+W command so closing the editor tab works
|
||||
* even when focus is inside the editor (Monaco otherwise swallows the event). */
|
||||
onRequestClose: (tabId: EditorTabId) => void;
|
||||
}
|
||||
|
||||
export const TextEditorTabView: React.FC<TextEditorTabViewProps> = ({
|
||||
tabId,
|
||||
isVisible,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
hostById,
|
||||
onRequestClose,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const tab = useEditorTab(tabId);
|
||||
|
||||
const handleContentChange = useCallback(
|
||||
(content: string, viewState: Monaco.editor.ICodeEditorViewState | null) => {
|
||||
editorTabStore.updateContent(tabId, content, viewState);
|
||||
},
|
||||
[tabId],
|
||||
);
|
||||
|
||||
const handleLanguageChange = useCallback(
|
||||
(lang: string) => {
|
||||
editorTabStore.setLanguage(tabId, lang);
|
||||
},
|
||||
[tabId],
|
||||
);
|
||||
|
||||
const handleToggleWordWrap = useCallback(() => {
|
||||
const current = editorTabStore.getTab(tabId);
|
||||
if (!current) return;
|
||||
editorTabStore.setWordWrap(tabId, !current.wordWrap);
|
||||
}, [tabId]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const ok = await saveEditorTab(tabId);
|
||||
if (ok) {
|
||||
toast.success(t('sftp.editor.saved'), 'SFTP');
|
||||
} else {
|
||||
const msg = editorTabStore.getTab(tabId)?.saveError ?? t('sftp.editor.saveFailed');
|
||||
toast.error(msg, 'SFTP');
|
||||
}
|
||||
}, [tabId, t]);
|
||||
|
||||
// Tab has been closed — render nothing (parent should remove this instance,
|
||||
// but guard here in case of a transient render before unmount).
|
||||
if (!tab) return null;
|
||||
|
||||
const isDirty = tab.content !== tab.baselineContent;
|
||||
// Subtitle shown next to the filename in the Pane header, e.g.
|
||||
// "Rainyun-114.66.26.174:/root/hello-server.go". Falls back to hostId when
|
||||
// we don't have a Host record (session may have been removed).
|
||||
const host = hostById.get(tab.hostId);
|
||||
const hostLabel = host?.label ?? tab.hostId;
|
||||
const subtitle = `${hostLabel}:${tab.remotePath}`;
|
||||
|
||||
return (
|
||||
// Sibling tab panels (VaultView, SftpView, TerminalLayerMount, LogView)
|
||||
// all fill their flex-1 parent via `absolute inset-0`. Match that here so
|
||||
// an inactive editor tab doesn't collapse to zero height in normal flow,
|
||||
// and an active one fills the viewport instead of stacking beneath others.
|
||||
// z-index high enough to stay above the TerminalLayer's inner `z-10` panels
|
||||
// (TerminalLayer root is visibility:hidden when editor tabs are active, but
|
||||
// its children's stacking contexts can still overlap without an explicit z.)
|
||||
<div
|
||||
style={{ display: isVisible ? undefined : 'none', zIndex: 20 }}
|
||||
className="absolute inset-0 min-h-0 flex flex-col bg-background"
|
||||
>
|
||||
<TextEditorPane
|
||||
chrome="tab"
|
||||
fileName={`${tab.fileName}${isDirty ? ' *' : ''}`}
|
||||
subtitle={subtitle}
|
||||
onRequestClose={() => onRequestClose(tabId)}
|
||||
content={tab.content}
|
||||
languageId={tab.languageId}
|
||||
wordWrap={tab.wordWrap}
|
||||
saving={tab.savingState === 'saving'}
|
||||
saveError={tab.saveError}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
onContentChange={handleContentChange}
|
||||
onLanguageChange={handleLanguageChange}
|
||||
onToggleWordWrap={handleToggleWordWrap}
|
||||
onSave={handleSave}
|
||||
initialViewState={tab.viewState}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextEditorTabView;
|
||||
104
components/editor/UnsavedChangesDialog.tsx
Normal file
104
components/editor/UnsavedChangesDialog.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog";
|
||||
|
||||
export type UnsavedChoice = "save" | "discard" | "cancel";
|
||||
|
||||
interface Pending {
|
||||
fileName: string;
|
||||
resolve: (choice: UnsavedChoice) => void;
|
||||
}
|
||||
|
||||
interface UnsavedChangesAPI {
|
||||
prompt: (fileName: string) => Promise<UnsavedChoice>;
|
||||
}
|
||||
|
||||
export const UnsavedChangesProvider: React.FC<{
|
||||
children: (api: UnsavedChangesAPI) => React.ReactNode;
|
||||
}> = ({ children }) => {
|
||||
const { t } = useI18n();
|
||||
const [pending, setPending] = useState<Pending | null>(null);
|
||||
const pendingRef = useRef<Pending | null>(null);
|
||||
pendingRef.current = pending;
|
||||
|
||||
const prompt = useCallback(
|
||||
(fileName: string) =>
|
||||
new Promise<UnsavedChoice>((resolve) => {
|
||||
// Re-entrance: if a prior prompt is still pending, cancel it so its caller
|
||||
// doesn't hang forever waiting for a resolve that now belongs to a new prompt.
|
||||
const prior = pendingRef.current;
|
||||
if (prior) prior.resolve("cancel");
|
||||
setPending({ fileName, resolve });
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// Register the prompt function as the module-level singleton so it can be
|
||||
// called from outside the React tree (e.g. useSftpViewPaneActions).
|
||||
useEffect(() => {
|
||||
promptSingleton = prompt;
|
||||
return () => { promptSingleton = null; };
|
||||
}, [prompt]);
|
||||
|
||||
// On unmount, resolve any in-flight prompt as "cancel" so awaiting callers don't leak.
|
||||
useEffect(() => () => {
|
||||
const prior = pendingRef.current;
|
||||
if (prior) {
|
||||
prior.resolve("cancel");
|
||||
pendingRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resolveWith = useCallback((choice: UnsavedChoice) => {
|
||||
if (!pending) return;
|
||||
pending.resolve(choice);
|
||||
setPending(null);
|
||||
}, [pending]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children({ prompt })}
|
||||
<Dialog open={!!pending} onOpenChange={(o) => { if (!o) resolveWith("cancel"); }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("sftp.editor.unsavedTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("sftp.editor.unsavedMessage", { fileName: pending?.fileName ?? "" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" onClick={() => resolveWith("cancel")}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => resolveWith("discard")}>
|
||||
{t("sftp.editor.discardChanges")}
|
||||
</Button>
|
||||
<Button variant="default" onClick={() => resolveWith("save")}>
|
||||
{t("sftp.editor.saveAndClose")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module-level singleton — lets non-React code call the dialog without
|
||||
// prop-drilling. Registered/unregistered by UnsavedChangesProvider above.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let promptSingleton: ((fileName: string) => Promise<UnsavedChoice>) | null = null;
|
||||
|
||||
export const promptUnsavedChanges = (fileName: string): Promise<UnsavedChoice> => {
|
||||
if (!promptSingleton) return Promise.resolve("cancel");
|
||||
return promptSingleton(fileName);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -61,20 +61,18 @@ export const IdentityCard: React.FC<IdentityCardProps> = ({
|
||||
{summary}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{viewMode === 'list' && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -69,20 +69,18 @@ export const KeyCard: React.FC<KeyCardProps> = ({
|
||||
Type {getKeyTypeDisplay(keyItem, isMac)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{viewMode === 'list' && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
153
components/settings/TerminalCjkFontSelect.tsx
Normal file
153
components/settings/TerminalCjkFontSelect.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import React, { useMemo, useSyncExternalStore } from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import {
|
||||
getFontAvailabilityVersion,
|
||||
isFontInstalled,
|
||||
subscribeFontAvailability,
|
||||
} from '../../lib/fontAvailability';
|
||||
|
||||
const AUTO_SENTINEL = '__auto__';
|
||||
|
||||
interface CjkFontOption {
|
||||
value: string;
|
||||
/** i18n key looked up via t(). Use '' for the Auto sentinel. */
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
// Only true monospace CJK fonts. Proportional CJK fonts (PingFang SC,
|
||||
// Microsoft YaHei UI, Hiragino Sans GB) render at non-2x widths and
|
||||
// break terminal grid alignment — they are deliberately excluded here
|
||||
// even though they are the OS defaults.
|
||||
const OPTIONS: CjkFontOption[] = [
|
||||
{ value: '', labelKey: 'settings.terminal.font.cjk.option.auto' },
|
||||
{ value: 'Sarasa Mono SC', labelKey: 'settings.terminal.font.cjk.option.sarasaSC' },
|
||||
{ value: 'Sarasa Mono TC', labelKey: 'settings.terminal.font.cjk.option.sarasaTC' },
|
||||
{ value: 'Maple Mono CN', labelKey: 'settings.terminal.font.cjk.option.mapleCN' },
|
||||
{ value: 'Source Han Mono SC', labelKey: 'settings.terminal.font.cjk.option.sourceHan' },
|
||||
{ value: 'Noto Sans Mono CJK SC', labelKey: 'settings.terminal.font.cjk.option.notoCJK' },
|
||||
{ value: 'LXGW WenKai Mono', labelKey: 'settings.terminal.font.cjk.option.lxgwWenkai' },
|
||||
{ value: 'SimSun', labelKey: 'settings.terminal.font.cjk.option.simSun' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const TerminalCjkFontSelect: React.FC<Props> = ({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
disabled,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const matchedOption = OPTIONS.find((o) => o.value === value);
|
||||
const radixValue = value === '' ? AUTO_SENTINEL : (matchedOption?.value ?? value);
|
||||
const triggerLabel = matchedOption
|
||||
? t(matchedOption.labelKey)
|
||||
: value
|
||||
? t('settings.terminal.font.cjk.option.legacy', { font: value })
|
||||
: value;
|
||||
|
||||
// Subscribe to font availability so the filter re-evaluates after the
|
||||
// Local Font Access API populates the authoritative install set
|
||||
// asynchronously (otherwise the dropdown would show stale availability
|
||||
// until the user manually changed `value`).
|
||||
const availabilityVersion = useSyncExternalStore(
|
||||
subscribeFontAvailability,
|
||||
getFontAvailabilityVersion,
|
||||
getFontAvailabilityVersion,
|
||||
);
|
||||
|
||||
// "Auto" is always present; concrete fonts only appear when installed;
|
||||
// the currently-selected value (if any) is also always shown so users
|
||||
// can see and clear their setting even on a machine without the font.
|
||||
// Legacy selections (e.g. "PingFang SC" saved before we dropped
|
||||
// proportional fonts) are appended as a synthetic option with a
|
||||
// "not recommended" label so the user can see them and re-pick.
|
||||
const visibleOptions = useMemo(() => {
|
||||
// The version is read here only so eslint-react-hooks sees it
|
||||
// used; in practice we depend on it to invalidate this memo when
|
||||
// setSystemFamilies bumps it (isFontInstalled below reads module
|
||||
// state, so we need an explicit signal).
|
||||
void availabilityVersion;
|
||||
const filtered: Array<{ value: string; label: string }> = OPTIONS.filter(
|
||||
(opt) =>
|
||||
opt.value === '' ||
|
||||
opt.value === value ||
|
||||
isFontInstalled(opt.value),
|
||||
).map((opt) => ({ value: opt.value, label: t(opt.labelKey) }));
|
||||
if (value && !OPTIONS.some((o) => o.value === value)) {
|
||||
filtered.push({
|
||||
value,
|
||||
label: t('settings.terminal.font.cjk.option.legacy', { font: value }),
|
||||
});
|
||||
}
|
||||
return filtered;
|
||||
}, [value, availabilityVersion, t]);
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Root
|
||||
value={radixValue}
|
||||
onValueChange={(next) => onChange(next === AUTO_SENTINEL ? '' : next)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectPrimitive.Trigger
|
||||
className={cn(
|
||||
'flex h-9 items-center justify-between rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<SelectPrimitive.Value>
|
||||
<span style={{ fontFamily: value ? `"${value}", monospace` : undefined }}>
|
||||
{triggerLabel}
|
||||
</span>
|
||||
</SelectPrimitive.Value>
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
className="z-[200000] max-h-80 min-w-[14rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1"
|
||||
position="popper"
|
||||
sideOffset={4}
|
||||
>
|
||||
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
<SelectPrimitive.Viewport className="p-1 h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]">
|
||||
{visibleOptions.map((opt) => (
|
||||
<SelectPrimitive.Item
|
||||
key={opt.value || AUTO_SENTINEL}
|
||||
value={opt.value || AUTO_SENTINEL}
|
||||
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>
|
||||
<span style={{ fontFamily: opt.value ? `"${opt.value}", monospace` : undefined }}>
|
||||
{opt.label}
|
||||
</span>
|
||||
</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
</SelectPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default TerminalCjkFontSelect;
|
||||
@@ -1,7 +1,14 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo, useSyncExternalStore } from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import {
|
||||
extractPrimaryFamily,
|
||||
getFontAvailabilityVersion,
|
||||
hasAuthoritativeData,
|
||||
isFontInstalled,
|
||||
subscribeFontAvailability,
|
||||
} from '../../lib/fontAvailability';
|
||||
import type { TerminalFont } from '../../infrastructure/config/fonts';
|
||||
|
||||
interface TerminalFontSelectProps {
|
||||
@@ -21,6 +28,37 @@ export const TerminalFontSelect: React.FC<TerminalFontSelectProps> = ({
|
||||
}) => {
|
||||
const selectedFont = fonts.find(f => f.id === value);
|
||||
|
||||
// Subscribe to font availability so the filter re-evaluates after the
|
||||
// Local Font Access API populates the authoritative install set
|
||||
// asynchronously, even if the `fonts` prop ref hasn't changed.
|
||||
const availabilityVersion = useSyncExternalStore(
|
||||
subscribeFontAvailability,
|
||||
getFontAvailabilityVersion,
|
||||
getFontAvailabilityVersion,
|
||||
);
|
||||
|
||||
// Hide fonts that aren't actually rendered on this machine so users
|
||||
// don't pick a font and then see no visible change. The currently
|
||||
// selected font is always shown so the user can read their setting.
|
||||
//
|
||||
// When the Local Font Access API has populated authoritative data,
|
||||
// trust it: an empty or near-empty result means the user really has
|
||||
// few monospace fonts (Layer 3 still gives at least one option via
|
||||
// bundled Sarasa Mono SC). When canvas-only fallback is in play,
|
||||
// we keep a safety net at length>=1 to avoid an empty dropdown if
|
||||
// detection misfires.
|
||||
const visibleFonts = useMemo(() => {
|
||||
// Referenced so eslint-react-hooks sees the dep used; the real
|
||||
// purpose is to invalidate this memo when setSystemFamilies bumps
|
||||
// the version (isFontInstalled reads module state).
|
||||
void availabilityVersion;
|
||||
const filtered = fonts.filter(
|
||||
(f) => f.id === value || isFontInstalled(extractPrimaryFamily(f.family)),
|
||||
);
|
||||
if (hasAuthoritativeData()) return filtered;
|
||||
return filtered.length >= 1 ? filtered : fonts;
|
||||
}, [fonts, value, availabilityVersion]);
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Root value={value} onValueChange={onChange} disabled={disabled}>
|
||||
<SelectPrimitive.Trigger
|
||||
@@ -48,7 +86,7 @@ export const TerminalFontSelect: React.FC<TerminalFontSelectProps> = ({
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
<SelectPrimitive.Viewport className="p-1 h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]">
|
||||
{fonts.map((font) => (
|
||||
{visibleFonts.map((font) => (
|
||||
<SelectPrimitive.Item
|
||||
key={font.id}
|
||||
value={font.id}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Label } from "../../ui/label";
|
||||
import { SectionHeader, Select, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
|
||||
import { ThemeSelectModal } from "../ThemeSelectModal";
|
||||
import { TerminalFontSelect } from "../TerminalFontSelect";
|
||||
import { TerminalCjkFontSelect } from "../TerminalCjkFontSelect";
|
||||
import { CustomThemeModal } from "../../terminal/CustomThemeModal";
|
||||
import type { TerminalTheme } from "../../../domain/models";
|
||||
|
||||
@@ -615,6 +616,17 @@ export default function SettingsTerminalTab(props: {
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.font.cjk")}
|
||||
description={t("settings.terminal.font.cjk.desc")}
|
||||
>
|
||||
<TerminalCjkFontSelect
|
||||
value={terminalSettings.fallbackFont ?? ""}
|
||||
onChange={(next) => updateTerminalSetting("fallbackFont", next)}
|
||||
className="w-48"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.font.size")}
|
||||
description={t("settings.terminal.font.size.desc")}
|
||||
@@ -1034,6 +1046,35 @@ export default function SettingsTerminalTab(props: {
|
||||
className="w-24"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.terminal.connection.keepaliveCountMax")}
|
||||
description={t("settings.terminal.connection.keepaliveCountMax.desc")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={terminalSettings.keepaliveCountMax}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value) || 1;
|
||||
if (val >= 1 && val <= 100) {
|
||||
updateTerminalSetting("keepaliveCountMax", val);
|
||||
}
|
||||
}}
|
||||
className="w-24"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.terminal.connection.x11Display")}
|
||||
description={t("settings.terminal.connection.x11Display.desc")}
|
||||
>
|
||||
<Input
|
||||
value={terminalSettings.x11Display}
|
||||
onChange={(e) => updateTerminalSetting("x11Display", e.target.value)}
|
||||
placeholder={t("settings.terminal.connection.x11Display.placeholder")}
|
||||
className="w-48"
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.serverStats")} />
|
||||
|
||||
72
components/settings/tabs/ai/managedAgentState.ts
Normal file
72
components/settings/tabs/ai/managedAgentState.ts
Normal 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") ?? "",
|
||||
};
|
||||
}
|
||||
@@ -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')}
|
||||
|
||||
@@ -20,7 +20,10 @@ export interface SftpTransferSource {
|
||||
// Types for the context
|
||||
export interface SftpPaneCallbacks {
|
||||
onConnect: (host: Host | "local") => void;
|
||||
onDisconnect: () => void;
|
||||
/** Resolves true if disconnect completed, false if the user canceled the
|
||||
* dirty-editor prompt. Callers that follow up with a replacement connect
|
||||
* must gate on the result. */
|
||||
onDisconnect: () => Promise<boolean>;
|
||||
onPrepareSelection: () => void;
|
||||
onNavigateTo: (path: string) => void;
|
||||
onNavigateUp: () => void;
|
||||
@@ -49,8 +52,13 @@ export interface SftpPaneCallbacks {
|
||||
onOpenFile?: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||
onOpenFileWith?: (entry: SftpFileEntry, fullPath?: string) => void; // Always show opener dialog
|
||||
onDownloadFile?: (entry: SftpFileEntry, fullPath?: string) => void; // Download to local filesystem
|
||||
onDownloadFiles?: (entries: SftpFileEntry[]) => void; // Batch download — picks one target directory for remote panes
|
||||
// External file upload (supports folders via DataTransfer)
|
||||
onUploadExternalFiles?: (dataTransfer: DataTransfer, targetPath?: string) => Promise<void>;
|
||||
// External file upload from <input type="file" multiple> picker (FileList).
|
||||
onUploadExternalFileList?: (fileList: FileList, targetPath?: string) => Promise<void>;
|
||||
// External folder upload from native directory picker.
|
||||
onUploadExternalFolder?: (targetPath?: string) => Promise<void>;
|
||||
onListDirectory: (path: string) => Promise<SftpFileEntry[]>;
|
||||
}
|
||||
|
||||
@@ -104,6 +112,8 @@ export const useIsPaneActive = (side: "left" | "right", paneId: string): boolean
|
||||
export interface SftpContextValue {
|
||||
// Hosts list for connection picker
|
||||
hosts: Host[];
|
||||
// Raw hosts list for bookmark persistence and other host writes.
|
||||
writableHosts: Host[];
|
||||
// Host updater for bookmark persistence
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
|
||||
@@ -155,6 +165,12 @@ export const useSftpHosts = () => {
|
||||
return context.hosts;
|
||||
};
|
||||
|
||||
// Hook to get raw hosts for writeback
|
||||
export const useSftpWritableHosts = () => {
|
||||
const context = useSftpContext();
|
||||
return context.writableHosts;
|
||||
};
|
||||
|
||||
// Hook to get host updater
|
||||
export const useSftpUpdateHosts = () => {
|
||||
const context = useSftpContext();
|
||||
@@ -163,6 +179,7 @@ export const useSftpUpdateHosts = () => {
|
||||
|
||||
interface SftpContextProviderProps {
|
||||
hosts: Host[];
|
||||
writableHosts?: Host[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
|
||||
dragCallbacks: SftpDragCallbacks;
|
||||
@@ -173,6 +190,7 @@ interface SftpContextProviderProps {
|
||||
|
||||
export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
|
||||
hosts,
|
||||
writableHosts,
|
||||
updateHosts,
|
||||
draggedFiles,
|
||||
dragCallbacks,
|
||||
@@ -184,11 +202,12 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
|
||||
const value = useMemo<SftpContextValue>(
|
||||
() => ({
|
||||
hosts,
|
||||
writableHosts: writableHosts ?? hosts,
|
||||
updateHosts,
|
||||
leftCallbacks,
|
||||
rightCallbacks,
|
||||
}),
|
||||
[hosts, updateHosts, leftCallbacks, rightCallbacks],
|
||||
[hosts, writableHosts, updateHosts, leftCallbacks, rightCallbacks],
|
||||
);
|
||||
|
||||
// Memoize drag context separately so only drag consumers re-render on drag state changes
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { useSftpState } from "../../application/state/useSftpState";
|
||||
import type { HotkeyScheme, KeyBinding } from "../../domain/models";
|
||||
import FileOpenerDialog from "../FileOpenerDialog";
|
||||
import TextEditorModal from "../TextEditorModal";
|
||||
import type { TextEditorModalSnapshot } from "../TextEditorModal";
|
||||
import { SftpConflictDialog, SftpHostPicker, SftpPermissionsDialog } from "./index";
|
||||
import { SftpTransferQueue } from "./SftpTransferQueue";
|
||||
|
||||
@@ -44,6 +45,8 @@ interface SftpOverlaysProps {
|
||||
setFileOpenerTarget: (target: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null) => void;
|
||||
handleFileOpenerSelect: (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => void;
|
||||
handleSelectSystemApp: (systemApp: { path: string; name: string }) => void;
|
||||
onPromoteToTab?: (snapshot: TextEditorModalSnapshot) => void;
|
||||
onRequestTerminalFocus?: () => void;
|
||||
}
|
||||
|
||||
export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
@@ -80,6 +83,8 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
setFileOpenerTarget,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
onPromoteToTab,
|
||||
onRequestTerminalFocus,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
@@ -138,6 +143,7 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
setShowTextEditor(false);
|
||||
setTextEditorTarget(null);
|
||||
setTextEditorContent("");
|
||||
onRequestTerminalFocus?.();
|
||||
}}
|
||||
fileName={textEditorTarget?.file.name || ""}
|
||||
initialContent={textEditorContent}
|
||||
@@ -146,6 +152,7 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
onToggleWordWrap={() => setEditorWordWrap(!editorWordWrap)}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
onPromoteToTab={onPromoteToTab}
|
||||
/>
|
||||
|
||||
{/* File Opener Dialog */}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user