Compare commits
153 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
0108390d4f | ||
|
|
e992d51fa6 | ||
|
|
7c55381f39 | ||
|
|
d582baaf53 | ||
|
|
8c1657f1ba | ||
|
|
999ad916e3 | ||
|
|
8ca09b1616 | ||
|
|
70b05bfaaf | ||
|
|
e6ab69b516 | ||
|
|
c6d4d3ec16 | ||
|
|
487b7adf3e | ||
|
|
309996bf3c | ||
|
|
071c95ab5c | ||
|
|
ec99875dec | ||
|
|
51a6b7efaa | ||
|
|
30f5346035 | ||
|
|
e0302e5f34 | ||
|
|
0425841032 | ||
|
|
156550f7eb | ||
|
|
a1648adf12 | ||
|
|
8182bd6b3c | ||
|
|
484ac5f463 | ||
|
|
98e3a6b952 | ||
|
|
f6f3147afb | ||
|
|
54b26511a1 | ||
|
|
8ef91e1266 | ||
|
|
b2689f96a4 | ||
|
|
1b23bdcf15 | ||
|
|
2e63848e0e | ||
|
|
3a748aa1aa | ||
|
|
4574f1e2b2 | ||
|
|
081b167172 | ||
|
|
a818a7004f | ||
|
|
5bc5a6c8b2 | ||
|
|
6c8a39d269 | ||
|
|
db69d5ac39 | ||
|
|
ee400f424b | ||
|
|
ba93e2fa35 | ||
|
|
591b240d12 | ||
|
|
880812f48d | ||
|
|
445ce92dbc | ||
|
|
7f582bb355 | ||
|
|
59f9a1443b | ||
|
|
bcb56d8229 | ||
|
|
1ca2cd8ec2 | ||
|
|
717d8b718a | ||
|
|
363f03a92d | ||
|
|
c5d15a14c9 | ||
|
|
75dc3dd72b | ||
|
|
110e050d20 | ||
|
|
ebcfe49ed6 | ||
|
|
bc8ac08b9a | ||
|
|
309fbdbe7a | ||
|
|
11f831d820 | ||
|
|
806fb6cf29 | ||
|
|
cc2702b825 | ||
|
|
af2589e60b | ||
|
|
971c8a4d8b | ||
|
|
59364e0c75 | ||
|
|
ac83c4c27d | ||
|
|
aa10f962ea | ||
|
|
1f3e531d7b | ||
|
|
50b20eaa05 | ||
|
|
3ab42bf588 | ||
|
|
84423a0096 | ||
|
|
58bc08a045 |
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/
|
||||
|
||||
591
App.tsx
591
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,22 +10,35 @@ 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 { 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 { materializeHostProxyProfile } from './domain/proxyProfiles';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { resolveHostTerminalThemeId } from './domain/terminalAppearance';
|
||||
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from './domain/terminalAppearance';
|
||||
import { collectSessionIds } from './domain/workspace';
|
||||
import { resolveCloseIntent } from './application/state/resolveCloseIntent';
|
||||
import { resolveSnippetsShortcutIntent } from './application/state/resolveSnippetsShortcutIntent';
|
||||
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from './application/state/customThemeStore';
|
||||
import { applySyncPayload } from './application/syncPayload';
|
||||
import type { SyncPayload } from './domain/sync';
|
||||
import { applySyncPayload, buildLocalVaultPayload, hasMeaningfulSyncData } from './application/syncPayload';
|
||||
import {
|
||||
applyProtectedSyncPayload,
|
||||
ensureVersionChangeBackup,
|
||||
} from './application/localVaultBackups';
|
||||
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
|
||||
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
|
||||
import { localStorageAdapter } from './infrastructure/persistence/localStorageAdapter';
|
||||
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
|
||||
import { STORAGE_KEY_DEBUG_HOTKEYS } from './infrastructure/config/storageKeys';
|
||||
import {
|
||||
STORAGE_KEY_DEBUG_HOTKEYS,
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
} from './infrastructure/config/storageKeys';
|
||||
import { getEffectiveKnownHosts } from './infrastructure/syncHelpers';
|
||||
import { TopTabs } from './components/TopTabs';
|
||||
import { Button } from './components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './components/ui/dialog';
|
||||
@@ -34,6 +47,7 @@ import { Label } from './components/ui/label';
|
||||
import { ToastProvider, toast } from './components/ui/toast';
|
||||
import { VaultView, VaultSection } from './components/VaultView';
|
||||
import { QuickAddSnippetDialog } from './components/QuickAddSnippetDialog';
|
||||
import { AddToWorkspaceDialog } from './components/workspace/AddToWorkspaceDialog';
|
||||
import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
|
||||
import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal';
|
||||
import { cn } from './lib/utils';
|
||||
@@ -43,6 +57,9 @@ import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalSession, Termi
|
||||
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();
|
||||
@@ -168,6 +185,15 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
const [isQuickSwitcherOpen, setIsQuickSwitcherOpen] = useState(false);
|
||||
const [isCreateWorkspaceOpen, setIsCreateWorkspaceOpen] = useState(false);
|
||||
// Combined state for the AddToWorkspaceDialog. null = closed; mode
|
||||
// determines whether picking targets appends them to an existing
|
||||
// workspace (focus sidebar "+") or spins up a brand-new workspace
|
||||
// tab (QuickSwitcher's New Workspace button).
|
||||
const [addToWorkspaceDialog, setAddToWorkspaceDialog] = useState<
|
||||
| { mode: 'append'; workspaceId: string }
|
||||
| { mode: 'create' }
|
||||
| null
|
||||
>(null);
|
||||
const [quickSearch, setQuickSearch] = useState('');
|
||||
// Protocol selection dialog state for QuickSwitcher
|
||||
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(null);
|
||||
@@ -182,6 +208,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
theme,
|
||||
setTheme,
|
||||
resolvedTheme,
|
||||
accentMode,
|
||||
customAccent,
|
||||
terminalThemeId,
|
||||
setTerminalThemeId,
|
||||
followAppTerminalTheme,
|
||||
@@ -222,9 +250,11 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, [workspaceFocusStyle]);
|
||||
|
||||
const {
|
||||
isInitialized: isVaultInitialized,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
@@ -235,6 +265,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
updateIdentities,
|
||||
updateProxyProfiles,
|
||||
updateSnippets,
|
||||
updateSnippetPackages,
|
||||
updateCustomGroups,
|
||||
@@ -281,6 +312,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendLocalTerminalToWorkspace,
|
||||
createWorkspaceFromTargets,
|
||||
updateSplitSizes,
|
||||
splitSession,
|
||||
toggleWorkspaceViewMode,
|
||||
@@ -306,6 +340,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// ---------------------------------------------------------------------------
|
||||
const activeTabId = useActiveTabId();
|
||||
const customThemes = useCustomThemes();
|
||||
const editorTabs = useEditorTabs();
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings.showSftpTab && activeTabId === 'sftp') {
|
||||
@@ -336,14 +371,19 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
if (activeTabId === 'vault' || activeTabId === 'sftp') return null;
|
||||
|
||||
const resolveTheme = (s: TerminalSession): TerminalTheme => {
|
||||
let baseTheme: TerminalTheme;
|
||||
// When "Follow Application Theme" is on, the UI-matched terminal
|
||||
// theme overrides everything — including per-host theme overrides.
|
||||
// This ensures all terminals match the app chrome regardless of
|
||||
// individual host settings.
|
||||
if (followAppTerminalTheme) return currentTerminalTheme;
|
||||
const host = hostById.get(s.hostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
return themeById.get(themeId) || currentTerminalTheme;
|
||||
if (followAppTerminalTheme) {
|
||||
baseTheme = currentTerminalTheme;
|
||||
} else {
|
||||
const host = hostById.get(s.hostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
baseTheme = themeById.get(themeId) || currentTerminalTheme;
|
||||
}
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
};
|
||||
|
||||
// Workspace
|
||||
@@ -373,7 +413,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const session = sessionById.get(activeTabId);
|
||||
if (!session) return null;
|
||||
return resolveTheme(session);
|
||||
}, [activeTabId, currentTerminalTheme, followAppTerminalTheme, hostById, sessionById, themeById, workspaceById]);
|
||||
}, [accentMode, activeTabId, currentTerminalTheme, customAccent, followAppTerminalTheme, hostById, sessionById, themeById, workspaceById]);
|
||||
|
||||
useImmersiveMode({
|
||||
activeTabId,
|
||||
@@ -395,25 +435,145 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
[portForwardingRules],
|
||||
);
|
||||
|
||||
const buildCurrentSyncPayload = useCallback(() => {
|
||||
let effectivePortForwardingRules = portForwardingRulesForSync;
|
||||
if (effectivePortForwardingRules.length === 0) {
|
||||
const stored = localStorageAdapter.read<typeof portForwardingRulesForSync>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (stored && Array.isArray(stored) && stored.length > 0) {
|
||||
effectivePortForwardingRules = stored.map((rule) => ({
|
||||
...rule,
|
||||
status: 'inactive' as const,
|
||||
error: undefined,
|
||||
lastUsedAt: undefined,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return buildLocalVaultPayload(
|
||||
{
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
knownHosts: getEffectiveKnownHosts(knownHosts),
|
||||
groupConfigs,
|
||||
},
|
||||
effectivePortForwardingRules,
|
||||
);
|
||||
}, [
|
||||
customGroups,
|
||||
groupConfigs,
|
||||
hosts,
|
||||
identities,
|
||||
keys,
|
||||
proxyProfiles,
|
||||
knownHosts,
|
||||
portForwardingRulesForSync,
|
||||
snippetPackages,
|
||||
snippets,
|
||||
]);
|
||||
|
||||
const [startupSyncSafetyReady, setStartupSyncSafetyReady] = useState(false);
|
||||
// buildCurrentSyncPayload's identity changes each time the vault
|
||||
// settles. The retry effect below watches the underlying data arrays
|
||||
// for hydration progress, and uses the ref to always read the latest
|
||||
// builder without pulling buildCurrentSyncPayload itself into deps
|
||||
// (its identity churns on unrelated state updates too).
|
||||
const buildCurrentSyncPayloadRef = useRef(buildCurrentSyncPayload);
|
||||
useEffect(() => {
|
||||
buildCurrentSyncPayloadRef.current = buildCurrentSyncPayload;
|
||||
}, [buildCurrentSyncPayload]);
|
||||
|
||||
const versionBackupAttemptedRef = useRef(false);
|
||||
// Two-stage gate: once the vault has initialized we open the auto-sync
|
||||
// gate immediately — the hook's own hasMeaningfulSyncData guard and
|
||||
// the cross-window restore barrier prevent an empty-but-not-yet-
|
||||
// hydrated snapshot from overwriting cloud data. The version-change
|
||||
// backup itself is best-effort and retries below as vault data arrives.
|
||||
useEffect(() => {
|
||||
if (isVaultInitialized && !startupSyncSafetyReady) {
|
||||
setStartupSyncSafetyReady(true);
|
||||
}
|
||||
}, [isVaultInitialized, startupSyncSafetyReady]);
|
||||
|
||||
// Retry the version-change backup as hosts/keys/snippets become
|
||||
// available. ensureVersionChangeBackup refuses to advance the stored
|
||||
// version stamp when the observed payload is empty, so running this
|
||||
// effect repeatedly is safe and eventually latches once the vault has
|
||||
// hydrated enough to be backed up (or the user genuinely stays empty,
|
||||
// in which case the effect continues to no-op).
|
||||
useEffect(() => {
|
||||
if (!isVaultInitialized || versionBackupAttemptedRef.current) return;
|
||||
const payload = buildCurrentSyncPayloadRef.current();
|
||||
if (!hasMeaningfulSyncData(payload)) return;
|
||||
versionBackupAttemptedRef.current = true;
|
||||
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const info = await netcattyBridge.get()?.getAppInfo?.();
|
||||
await ensureVersionChangeBackup(payload, info?.version ?? null);
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
// Reset the latch so a later data change (or the next mount)
|
||||
// can retry. ensureVersionChangeBackup already leaves the
|
||||
// version stamp untouched on failure, so retrying is safe.
|
||||
versionBackupAttemptedRef.current = false;
|
||||
}
|
||||
console.error('[App] Failed to create version-change backup:', error);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [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
|
||||
// on unrelated App-level state changes (which would churn the debounced
|
||||
// auto-sync useEffect dep chain).
|
||||
const handleApplySyncPayload = useCallback(
|
||||
(payload: SyncPayload) =>
|
||||
applyProtectedSyncPayload({
|
||||
buildPreApplyPayload: () => buildCurrentSyncPayload(),
|
||||
applyPayload: () =>
|
||||
applySyncPayload(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied: settings.rehydrateAllFromStorage,
|
||||
}),
|
||||
translateProtectiveBackupFailure: (message) =>
|
||||
t('cloudSync.localBackups.protectiveBackupFailed', { message }),
|
||||
}),
|
||||
[
|
||||
buildCurrentSyncPayload,
|
||||
importDataFromString,
|
||||
importPortForwardingRules,
|
||||
settings.rehydrateAllFromStorage,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
// Auto-sync hook for cloud sync
|
||||
const { syncNow: handleSyncNow, emptyVaultConflict, resolveEmptyVaultConflict } = useAutoSync({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
portForwardingRules: portForwardingRulesForSync,
|
||||
knownHosts,
|
||||
groupConfigs,
|
||||
settingsVersion: settings.settingsVersion,
|
||||
onApplyPayload: (payload) => {
|
||||
applySyncPayload(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied: settings.rehydrateAllFromStorage,
|
||||
});
|
||||
},
|
||||
startupReady: startupSyncSafetyReady,
|
||||
onApplyPayload: handleApplySyncPayload,
|
||||
});
|
||||
|
||||
const { clearAndRemoveSource, clearAndRemoveSources, unmanageSource } = useManagedSourceSync({
|
||||
@@ -451,7 +611,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
if (start) {
|
||||
const effectiveHost = resolveEffectiveHost(host);
|
||||
void startTunnel(rule, effectiveHost, hosts, keys, identities, (status, error) => {
|
||||
void startTunnel(rule, effectiveHost, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart);
|
||||
return;
|
||||
@@ -559,7 +719,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
if (binding.category === 'sftp') {
|
||||
continue;
|
||||
}
|
||||
const terminalActions = ['copy', 'paste', 'selectAll', 'clearBuffer', 'searchTerminal'];
|
||||
const terminalActions = ['copy', 'paste', 'pasteSelection', 'selectAll', 'clearBuffer', 'searchTerminal'];
|
||||
if (terminalActions.includes(binding.action)) {
|
||||
if (isTerminalElement) {
|
||||
return;
|
||||
@@ -654,9 +814,11 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
// Auto-start port forwarding rules on app launch
|
||||
usePortForwardingAutoStart({
|
||||
isVaultInitialized,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
groupConfigs,
|
||||
});
|
||||
|
||||
@@ -727,6 +889,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();
|
||||
@@ -864,6 +1056,15 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const addConnectionLogRef = useRef(addConnectionLog);
|
||||
addConnectionLogRef.current = addConnectionLog;
|
||||
|
||||
const closeSidePanelRef = useRef<(() => void) | null>(null);
|
||||
const toggleScriptsSidePanelRef = useRef<(() => void) | null>(null);
|
||||
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);
|
||||
const matchedShell = discoveredShells.find(s => s.id === terminalSettings.localShell);
|
||||
@@ -897,15 +1098,97 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
return hotkeyScheme === 'mac' ? closeTabBinding.mac : closeTabBinding.pc;
|
||||
}, [hotkeyScheme, keyBindings]);
|
||||
|
||||
const confirmIfBusyLocalTerminal = useCallback(
|
||||
async (sessionIds: string[]): Promise<boolean> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
const localIds = sessionIds.filter((id) => {
|
||||
const s = sessions.find((x) => x.id === id);
|
||||
return s?.protocol === 'local';
|
||||
});
|
||||
const busyCommands: string[] = [];
|
||||
for (const id of localIds) {
|
||||
const children = (await bridge?.ptyGetChildProcesses?.(id)) ?? [];
|
||||
if (children.length > 0) {
|
||||
busyCommands.push(children[0].command);
|
||||
}
|
||||
}
|
||||
if (busyCommands.length === 0) return true;
|
||||
|
||||
const primary = busyCommands[0];
|
||||
const extraCount = busyCommands.length - 1;
|
||||
const message =
|
||||
extraCount > 0
|
||||
? t('confirm.closeBusyTerminal.messageWithMore', {
|
||||
command: primary,
|
||||
count: extraCount,
|
||||
})
|
||||
: t('confirm.closeBusyTerminal.message', { command: primary });
|
||||
|
||||
const ok = await bridge?.confirmCloseBusy?.({
|
||||
command: primary,
|
||||
title: t('confirm.closeBusyTerminal.title'),
|
||||
message,
|
||||
cancelLabel: t('confirm.closeBusyTerminal.cancel'),
|
||||
closeLabel: t('confirm.closeBusyTerminal.close'),
|
||||
});
|
||||
return ok === true;
|
||||
},
|
||||
[sessions, t],
|
||||
);
|
||||
|
||||
const closeTabsInFlightRef = useRef(false);
|
||||
|
||||
// Close many tabs at once with a single batched busy-shell confirmation.
|
||||
// Used by the "Close all / Close others / Close to the right" context-menu
|
||||
// actions on tabs (#748).
|
||||
const closeTabsBatch = useCallback(
|
||||
async (targetIds: string[]) => {
|
||||
if (targetIds.length === 0) return;
|
||||
if (closeTabsInFlightRef.current) return;
|
||||
|
||||
// Expand workspace ids into their constituent session ids so the busy
|
||||
// probe sees every local shell that's about to be killed.
|
||||
const sessionIdsToProbe: string[] = [];
|
||||
for (const tabId of targetIds) {
|
||||
const ws = workspaces.find((w) => w.id === tabId);
|
||||
if (ws) {
|
||||
for (const s of sessions) {
|
||||
if (s.workspaceId === tabId) sessionIdsToProbe.push(s.id);
|
||||
}
|
||||
} else if (sessions.find((s) => s.id === tabId)) {
|
||||
sessionIdsToProbe.push(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
closeTabsInFlightRef.current = true;
|
||||
try {
|
||||
const ok = await confirmIfBusyLocalTerminal(sessionIdsToProbe);
|
||||
if (!ok) return;
|
||||
for (const tabId of targetIds) {
|
||||
if (workspaces.find((w) => w.id === tabId)) {
|
||||
closeWorkspace(tabId);
|
||||
} else if (sessions.find((s) => s.id === tabId)) {
|
||||
closeSession(tabId);
|
||||
} else if (logViews.find((lv) => lv.id === tabId)) {
|
||||
closeLogView(tabId);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
closeTabsInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
[workspaces, sessions, logViews, confirmIfBusyLocalTerminal, closeWorkspace, closeSession, closeLogView],
|
||||
);
|
||||
|
||||
// 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)
|
||||
@@ -941,18 +1224,59 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
case 'closeTab': {
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
if (currentId !== 'vault' && currentId !== 'sftp') {
|
||||
// Find if it's a session or workspace
|
||||
const session = sessions.find(s => s.id === currentId);
|
||||
if (session) {
|
||||
closeSession(currentId);
|
||||
} else {
|
||||
const workspace = workspaces.find(w => w.id === currentId);
|
||||
if (workspace) {
|
||||
closeWorkspace(currentId);
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
const focusIsInsideTerminal = !!document.activeElement?.closest('[data-session-id]');
|
||||
const activeSidePanel = activeSidePanelTabRef.current;
|
||||
|
||||
const intent = resolveCloseIntent({
|
||||
activeTabId: currentId,
|
||||
workspace: workspace ? { id: workspace.id, focusedSessionId: workspace.focusedSessionId } : null,
|
||||
sessionForTab: session,
|
||||
activeSidePanelTab: activeSidePanel,
|
||||
focusIsInsideTerminal,
|
||||
});
|
||||
|
||||
closeTabInFlightRef.current = true;
|
||||
(async () => {
|
||||
try {
|
||||
switch (intent.kind) {
|
||||
case 'closeTerminal':
|
||||
case 'closeSingleTab': {
|
||||
const ok = await confirmIfBusyLocalTerminal([intent.sessionId]);
|
||||
if (ok) closeSession(intent.sessionId);
|
||||
return;
|
||||
}
|
||||
case 'closeSidePanel': {
|
||||
closeSidePanelRef.current?.();
|
||||
return;
|
||||
}
|
||||
case 'closeWorkspace': {
|
||||
const ids = sessions.filter((s) => s.workspaceId === intent.workspaceId).map((s) => s.id);
|
||||
const ok = await confirmIfBusyLocalTerminal(ids);
|
||||
if (ok) closeWorkspace(intent.workspaceId);
|
||||
return;
|
||||
}
|
||||
case 'noop':
|
||||
default:
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
closeTabInFlightRef.current = false;
|
||||
}
|
||||
})();
|
||||
|
||||
break;
|
||||
}
|
||||
case 'newTab':
|
||||
@@ -983,15 +1307,35 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
case 'commandPalette':
|
||||
setIsQuickSwitcherOpen(true);
|
||||
break;
|
||||
case 'newWorkspace':
|
||||
// Dedicated shortcut to launch the AddToWorkspaceDialog in
|
||||
// create mode — same entry as QuickSwitcher's "New Workspace"
|
||||
// button, but without having to open QS first.
|
||||
setAddToWorkspaceDialog({ mode: 'create' });
|
||||
break;
|
||||
case 'portForwarding':
|
||||
// Navigate to vault and open port forwarding section
|
||||
setActiveTabId('vault');
|
||||
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
|
||||
@@ -1065,7 +1409,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings.showSftpTab]);
|
||||
}, [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) => {
|
||||
@@ -1165,11 +1509,21 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
});
|
||||
}, [addConnectionLog, createLocalTerminal, terminalSettings.localShell, discoveredShells]);
|
||||
|
||||
const proxyProfileIdSet = useMemo(
|
||||
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
[proxyProfiles],
|
||||
);
|
||||
|
||||
const resolveEffectiveHost = useCallback((host: Host): Host => {
|
||||
if (!host.group) return host;
|
||||
const groupDefaults = resolveGroupDefaults(host.group, groupConfigs);
|
||||
return applyGroupDefaults(host, groupDefaults);
|
||||
}, [groupConfigs]);
|
||||
const withGroupDefaults = host.group
|
||||
? applyGroupDefaults(
|
||||
host,
|
||||
resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }),
|
||||
{ validProxyProfileIds: proxyProfileIdSet },
|
||||
)
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
}, [groupConfigs, proxyProfileIdSet, proxyProfiles]);
|
||||
|
||||
// Wrapper to connect to host with logging
|
||||
const handleConnectToHost = useCallback((host: Host) => {
|
||||
@@ -1374,6 +1728,19 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
};
|
||||
}, [handleOpenSettings, t]);
|
||||
|
||||
// Delete-from-sidepanel plumbing: ScriptsSidePanel's right-click menu
|
||||
// dispatches `netcatty:snippets:delete` with the snippet id. Handled here
|
||||
// (rather than in QuickAddSnippetDialog) because delete needs no UI.
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const id = (e as CustomEvent<{ id?: string }>).detail?.id;
|
||||
if (!id) return;
|
||||
updateSnippets(snippets.filter((s) => s.id !== id));
|
||||
};
|
||||
window.addEventListener('netcatty:snippets:delete', handler);
|
||||
return () => window.removeEventListener('netcatty:snippets:delete', handler);
|
||||
}, [snippets, updateSnippets]);
|
||||
|
||||
const handleEndSessionDrag = useCallback(() => {
|
||||
setDraggingSessionId(null);
|
||||
}, [setDraggingSessionId]);
|
||||
@@ -1406,7 +1773,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}
|
||||
@@ -1416,7 +1835,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
orphanSessions={orphanSessions}
|
||||
workspaces={workspaces}
|
||||
logViews={logViews}
|
||||
orderedTabs={orderedTabs}
|
||||
orderedTabs={orderedTabsWithEditors}
|
||||
draggingSessionId={draggingSessionId}
|
||||
isMacClient={isMacClient}
|
||||
onCloseSession={closeSession}
|
||||
@@ -1425,6 +1844,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onRenameWorkspace={startWorkspaceRename}
|
||||
onCloseWorkspace={closeWorkspace}
|
||||
onCloseLogView={closeLogView}
|
||||
onCloseTabsBatch={closeTabsBatch}
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onToggleTheme={handleToggleTheme}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
@@ -1434,6 +1854,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">
|
||||
@@ -1442,6 +1865,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
proxyProfiles={proxyProfiles}
|
||||
snippets={snippets}
|
||||
snippetPackages={snippetPackages}
|
||||
customGroups={customGroups}
|
||||
@@ -1465,6 +1889,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onUpdateHosts={updateHosts}
|
||||
onUpdateKeys={updateKeys}
|
||||
onUpdateIdentities={updateIdentities}
|
||||
onUpdateProxyProfiles={updateProxyProfiles}
|
||||
onUpdateSnippets={updateSnippets}
|
||||
onUpdateSnippetPackages={updateSnippetPackages}
|
||||
onUpdateCustomGroups={updateCustomGroups}
|
||||
@@ -1490,6 +1915,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
proxyProfiles={proxyProfiles}
|
||||
groupConfigs={groupConfigs}
|
||||
updateHosts={updateHosts}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
@@ -1506,6 +1932,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
<TerminalLayerMount
|
||||
hosts={hosts}
|
||||
groupConfigs={groupConfigs}
|
||||
proxyProfiles={proxyProfiles}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
snippets={snippets}
|
||||
@@ -1516,6 +1943,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
draggingSessionId={draggingSessionId}
|
||||
terminalTheme={currentTerminalTheme}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
accentMode={accentMode}
|
||||
customAccent={customAccent}
|
||||
terminalSettings={terminalSettings}
|
||||
terminalFontFamilyId={terminalFontFamilyId}
|
||||
fontSize={terminalFontSize}
|
||||
@@ -1537,6 +1966,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onTerminalDataCapture={handleTerminalDataCapture}
|
||||
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
||||
onAddSessionToWorkspace={addSessionToWorkspace}
|
||||
onRequestAddToWorkspace={(workspaceId) =>
|
||||
setAddToWorkspaceDialog({ mode: 'append', workspaceId })
|
||||
}
|
||||
onUpdateSplitSizes={updateSplitSizes}
|
||||
onSetDraggingSessionId={setDraggingSessionId}
|
||||
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
|
||||
@@ -1556,6 +1988,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sessionLogsEnabled={sessionLogsEnabled}
|
||||
sessionLogsDir={sessionLogsDir}
|
||||
sessionLogsFormat={sessionLogsFormat}
|
||||
closeSidePanelRef={closeSidePanelRef}
|
||||
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
|
||||
activeSidePanelTabRef={activeSidePanelTabRef}
|
||||
/>
|
||||
|
||||
{/* Log Views - readonly terminal replays */}
|
||||
@@ -1573,19 +2008,80 @@ 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 snippet" dialog, triggered by the
|
||||
netcatty:snippets:add window event (from ScriptsSidePanel "+"). */}
|
||||
{/* Global "quick add / edit snippet" dialog, triggered by the
|
||||
netcatty:snippets:add and :edit window events (from ScriptsSidePanel
|
||||
"+" button and right-click menu). Delete is handled by a sibling
|
||||
useEffect above — it does not need a dialog. */}
|
||||
<QuickAddSnippetDialog
|
||||
snippets={snippets}
|
||||
packages={snippetPackages}
|
||||
onCreateSnippet={(snippet) => updateSnippets([...snippets, snippet])}
|
||||
onUpdateSnippet={(snippet) =>
|
||||
updateSnippets(snippets.map((s) => (s.id === snippet.id ? snippet : s)))
|
||||
}
|
||||
onCreatePackage={(pkg) =>
|
||||
updateSnippetPackages(Array.from(new Set([...snippetPackages, pkg])))
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Root-mounted AddToWorkspaceDialog — triggered by the focus-mode
|
||||
"+" button (mode='append') or QuickSwitcher's "New Workspace"
|
||||
button (mode='create'). Single instance so dialog state and
|
||||
styling stay consistent across entry points. */}
|
||||
{addToWorkspaceDialog && (
|
||||
<AddToWorkspaceDialog
|
||||
open
|
||||
onOpenChange={(open) => { if (!open) setAddToWorkspaceDialog(null); }}
|
||||
// Filter serial hosts only in append mode — appendHostToWorkspace
|
||||
// has no serial code path. Create mode goes through
|
||||
// createWorkspaceFromTargets, which builds a SerialConfig-backed
|
||||
// session for serial hosts, so those should remain pickable.
|
||||
hosts={addToWorkspaceDialog.mode === 'append'
|
||||
? hosts.filter((h) => h.protocol !== 'serial')
|
||||
: hosts}
|
||||
workspaceTitle={
|
||||
addToWorkspaceDialog.mode === 'append'
|
||||
? workspaces.find((w) => w.id === addToWorkspaceDialog.workspaceId)?.title
|
||||
: 'New Workspace'
|
||||
}
|
||||
onAdd={(targets) => {
|
||||
if (addToWorkspaceDialog.mode === 'append') {
|
||||
// Match the workspace root's current split direction so
|
||||
// the new panes peer the existing siblings instead of
|
||||
// wrapping the whole tree into one side of a fresh split
|
||||
// (which would happen if we always passed the helper's
|
||||
// default 'vertical').
|
||||
const ws = workspaces.find((w) => w.id === addToWorkspaceDialog.workspaceId);
|
||||
const rootDir = ws && ws.root.type === 'split' ? ws.root.direction : 'vertical';
|
||||
for (const target of targets) {
|
||||
if (target.kind === 'local') {
|
||||
appendLocalTerminalToWorkspace(addToWorkspaceDialog.workspaceId, undefined, rootDir);
|
||||
} else {
|
||||
appendHostToWorkspace(addToWorkspaceDialog.workspaceId, target.host, rootDir);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
createWorkspaceFromTargets(targets);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isQuickSwitcherOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<LazyQuickSwitcher
|
||||
@@ -1609,7 +2105,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}}
|
||||
onCreateWorkspace={() => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setIsCreateWorkspaceOpen(true);
|
||||
setQuickSearch('');
|
||||
setAddToWorkspaceDialog({ mode: 'create' });
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
@@ -1741,6 +2238,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
hosts: emptyVaultConflict.hostCount,
|
||||
keys: emptyVaultConflict.keyCount,
|
||||
snippets: emptyVaultConflict.snippetCount,
|
||||
proxyProfiles: emptyVaultConflict.proxyProfileCount,
|
||||
})}</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1770,6 +2268,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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -56,6 +56,11 @@ const en: Messages = {
|
||||
'confirm.deleteHost': 'Delete Host "{name}"?',
|
||||
'confirm.deleteIdentity': 'Delete Identity "{name}"?',
|
||||
'confirm.removeProvider': 'Remove provider "{name}"?',
|
||||
'confirm.closeBusyTerminal.title': 'Confirm close',
|
||||
'confirm.closeBusyTerminal.message': 'Process "{command}" is still running and will be terminated.',
|
||||
'confirm.closeBusyTerminal.messageWithMore': 'Process "{command}" and {count} other running process(es) will be terminated.',
|
||||
'confirm.closeBusyTerminal.cancel': 'Cancel',
|
||||
'confirm.closeBusyTerminal.close': 'Close',
|
||||
'dialog.createWorkspace.title': 'Create Workspace',
|
||||
'dialog.renameWorkspace.title': 'Rename workspace',
|
||||
'dialog.renameSession.title': 'Rename session',
|
||||
@@ -301,6 +306,12 @@ const en: Messages = {
|
||||
'settings.terminal.behavior.bracketedPaste': 'Bracketed paste mode',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'Wrap pasted text with escape sequences so the shell can distinguish paste from typed input. Disable if you see ^[[200~ artifacts.',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` wipes scrollback',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'Make `clear` also wipe the scrollback buffer (POSIX default). Disable to keep history visible after `clear`.',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput': 'Keep selection while typing',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput.desc':
|
||||
'Don\'t clear mouse-selected text when typing — useful for selecting a path then pasting it after a command prefix like `sz `.',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 clipboard',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'Allow remote programs (tmux, vim, etc.) to access the local clipboard via OSC-52 escape sequences.',
|
||||
@@ -364,6 +375,9 @@ const en: Messages = {
|
||||
'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.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).',
|
||||
@@ -404,6 +418,7 @@ const en: Messages = {
|
||||
'settings.shortcuts.resetAll': 'Reset All',
|
||||
'settings.shortcuts.recording': 'Press keys...',
|
||||
'settings.shortcuts.none': 'None',
|
||||
'settings.shortcuts.setDisabled': 'Set to disabled',
|
||||
'settings.shortcuts.category.tabs': 'Tabs',
|
||||
'settings.shortcuts.category.terminal': 'Terminal',
|
||||
'settings.shortcuts.category.navigation': 'Navigation',
|
||||
@@ -443,10 +458,15 @@ const en: Messages = {
|
||||
'sync.toast.completedMessage': 'Sync completed successfully',
|
||||
'sync.toast.errorTitle': 'Sync Error',
|
||||
'sync.autoSync.failedTitle': 'Sync failed',
|
||||
'sync.autoSync.inspectFailedTitle': 'Sync paused',
|
||||
'sync.autoSync.inspectFailedMessage': 'Could not reach the cloud to check for changes. Auto-sync will retry when data changes or the app is restarted.',
|
||||
'sync.autoSync.syncedTitle': 'Synced from cloud',
|
||||
'sync.autoSync.syncedMessage': 'Your data has been updated from the cloud.',
|
||||
'sync.autoSync.noProvider': 'No cloud provider connected. Open Settings → Sync & Cloud to connect one.',
|
||||
'sync.autoSync.alreadySyncing': 'Sync is already in progress.',
|
||||
'sync.autoSync.restoreInProgress': 'A vault restore is in progress in another window. Please wait for it to finish.',
|
||||
'sync.autoSync.interruptedApplyTitle': 'Sync paused — previous restore interrupted',
|
||||
'sync.autoSync.interruptedApplyMessage': 'A previous restore did not finish cleanly, so the local vault may be inconsistent. Open Settings → Sync & Cloud → Restore and apply a protective backup before auto-sync resumes.',
|
||||
'sync.autoSync.vaultLocked': 'Vault is locked. Open Settings → Sync & Cloud to unlock.',
|
||||
'sync.autoSync.conflictDetected': 'Sync conflict detected. Open Settings → Sync & Cloud to resolve.',
|
||||
'sync.autoSync.syncFailed': 'Sync failed',
|
||||
@@ -461,7 +481,32 @@ 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',
|
||||
'sync.blocked.reason.bulkShrink': 'Would delete {lost} of {baseCount} {entityType} from cloud ({percent}% reduction).',
|
||||
'sync.blocked.reason.largeShrink': 'Would delete {lost} {entityType} from cloud.',
|
||||
'sync.blocked.detail': 'This is usually caused by a degraded local state (keychain failure, partial data load). Restore from a local backup, or force-push if you truly meant to remove these entries.',
|
||||
'sync.blocked.restoreButton': 'Restore from local backup',
|
||||
'sync.blocked.forcePushButton': 'Force push anyway',
|
||||
|
||||
'sync.forcePush.title': 'Confirm force push',
|
||||
'sync.forcePush.body': 'You are about to remove {lost} {entityType} from the cloud. This cannot be undone. Proceed?',
|
||||
'sync.forcePush.confirm': 'Yes, push anyway',
|
||||
'sync.forcePush.cancel': 'Cancel',
|
||||
|
||||
'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',
|
||||
'sync.entityType.knownHosts': 'known-host entries',
|
||||
'sync.entityType.portForwardingRules': 'port-forwarding rules',
|
||||
'sync.entityType.groupConfigs': 'group configs',
|
||||
|
||||
'sync.credentialsUnavailable': 'This device cannot decrypt some saved credentials. Re-enter credentials locally before syncing.',
|
||||
'time.never': 'Never',
|
||||
'time.justNow': 'Just now',
|
||||
@@ -470,11 +515,28 @@ const en: Messages = {
|
||||
// Vault navigation
|
||||
'vault.nav.hosts': 'Hosts',
|
||||
'vault.nav.keychain': 'Keychain',
|
||||
'vault.nav.proxies': 'Proxies',
|
||||
'vault.nav.portForwarding': 'Port Forwarding',
|
||||
'vault.nav.snippets': 'Snippets',
|
||||
'vault.nav.knownHosts': 'Known Hosts',
|
||||
'vault.nav.logs': 'Logs',
|
||||
|
||||
'proxyProfiles.action.add': 'Add Proxy',
|
||||
'proxyProfiles.search.placeholder': 'Search proxies…',
|
||||
'proxyProfiles.section.proxies': 'Proxies',
|
||||
'proxyProfiles.count.items': '{count} items',
|
||||
'proxyProfiles.empty.title': 'No Proxies',
|
||||
'proxyProfiles.empty.desc': 'Create reusable HTTP or SOCKS5 proxies and select them from host details.',
|
||||
'proxyProfiles.usage': '{count} linked',
|
||||
'proxyProfiles.copyName': '{name} Copy',
|
||||
'proxyProfiles.panel.newTitle': 'New Proxy',
|
||||
'proxyProfiles.field.name': 'Proxy name',
|
||||
'proxyProfiles.error.required': 'Name, host, and port are required.',
|
||||
'proxyProfiles.error.port': 'Port must be between 1 and 65535.',
|
||||
'proxyProfiles.viewMode': 'Proxy view mode',
|
||||
'proxyProfiles.delete.title': 'Delete proxy?',
|
||||
'proxyProfiles.delete.desc': 'Deleting "{name}" will unlink it from {count} host or group settings.',
|
||||
|
||||
'vault.groups.title': 'Groups',
|
||||
'vault.groups.total': '{count} total',
|
||||
'vault.groups.hostsCount': '{count} Hosts',
|
||||
@@ -734,6 +796,9 @@ const en: Messages = {
|
||||
'sftp.transfers.collapseChildren': 'Hide files',
|
||||
'sftp.transfers.expandChildList': 'Show detail',
|
||||
'sftp.transfers.collapseChildList': 'Hide',
|
||||
'sftp.transfers.retryAction': 'Retry',
|
||||
'sftp.transfers.dismissAction': 'Dismiss',
|
||||
'sftp.transfers.resizeNameColumn': 'Resize file name column',
|
||||
'sftp.transfers.dragToResize': 'Drag to resize',
|
||||
'sftp.goUp': 'Go up',
|
||||
'sftp.goToTerminalCwd': 'Go to terminal directory',
|
||||
@@ -800,8 +865,11 @@ const en: Messages = {
|
||||
'sftp.conflict.size': 'Size:',
|
||||
'sftp.conflict.modified': 'Modified:',
|
||||
'sftp.conflict.applyToAll': 'Apply this action to all {count} remaining conflicts',
|
||||
'sftp.conflict.action.stop': 'Stop',
|
||||
'sftp.conflict.action.skip': 'Skip',
|
||||
'sftp.conflict.action.keepBoth': 'Keep Both',
|
||||
'sftp.conflict.action.duplicate': 'Duplicate',
|
||||
'sftp.conflict.action.merge': 'Merge',
|
||||
'sftp.conflict.action.replace': 'Replace',
|
||||
|
||||
// SFTP Upload Phases
|
||||
@@ -1036,6 +1104,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.',
|
||||
@@ -1061,6 +1132,12 @@ const en: Messages = {
|
||||
'hostDetails.proxyPanel.passwordPlaceholder': 'Password',
|
||||
'hostDetails.proxyPanel.identities': 'Identities',
|
||||
'hostDetails.proxyPanel.remove': 'Remove Proxy',
|
||||
'hostDetails.proxyPanel.savedProxy': 'Saved proxy',
|
||||
'hostDetails.proxyPanel.selectSaved': 'Select saved proxy',
|
||||
'hostDetails.proxyPanel.customProxy': 'Custom proxy',
|
||||
'hostDetails.proxyPanel.missing': 'Missing',
|
||||
'hostDetails.proxyPanel.missingSaved': 'Missing saved proxy',
|
||||
'hostDetails.proxyPanel.error.required': 'Proxy host and port are required.',
|
||||
'hostDetails.envVars': 'Environment Variables',
|
||||
'hostDetails.envVars.add': 'Add Environment Variable',
|
||||
'hostDetails.envVars.title': 'Environment Variables',
|
||||
@@ -1152,6 +1229,7 @@ const en: Messages = {
|
||||
'terminal.toolbar.openSftp': 'Open SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': 'Available after connect',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': 'More actions',
|
||||
'terminal.toolbar.scripts': 'Scripts',
|
||||
'terminal.toolbar.library': 'Library',
|
||||
'terminal.toolbar.noSnippets': 'No snippets available',
|
||||
@@ -1212,6 +1290,7 @@ const en: Messages = {
|
||||
'terminal.search.nextMatch': 'Next match (Enter)',
|
||||
'terminal.menu.copy': 'Copy',
|
||||
'terminal.menu.paste': 'Paste',
|
||||
'terminal.menu.pasteSelection': 'Paste Selection',
|
||||
'terminal.menu.selectAll': 'Select All',
|
||||
'terminal.menu.splitHorizontal': 'Split Horizontal',
|
||||
'terminal.menu.splitVertical': 'Split Vertical',
|
||||
@@ -1390,6 +1469,31 @@ const en: Messages = {
|
||||
'cloudSync.history.download': 'Download',
|
||||
'cloudSync.history.resolved': 'Resolved',
|
||||
'cloudSync.history.error': 'Error',
|
||||
'cloudSync.localBackups.title': 'Local Backup History',
|
||||
'cloudSync.localBackups.desc': 'Netcatty keeps local restore points before app version changes and before vault restores.',
|
||||
'cloudSync.localBackups.retentionTitle': 'Backup Retention',
|
||||
'cloudSync.localBackups.retentionDesc': 'Choose how many local backups Netcatty should keep.',
|
||||
'cloudSync.localBackups.maxCount': 'Max backups',
|
||||
'cloudSync.localBackups.maxSaved': 'Saved backup retention: {count}',
|
||||
'cloudSync.localBackups.maxInvalid': 'Please enter a number between 1 and 100.',
|
||||
'cloudSync.localBackups.empty': 'No local backups yet.',
|
||||
'cloudSync.localBackups.reason.appVersionChange': 'Before app version change',
|
||||
'cloudSync.localBackups.reason.beforeRestore': 'Before restore',
|
||||
'cloudSync.localBackups.versionChange': '{from} -> {to}',
|
||||
'cloudSync.localBackups.counts': '{hosts} hosts, {keys} keys, {snippets} snippets',
|
||||
'cloudSync.localBackups.restore': 'Restore',
|
||||
'cloudSync.localBackups.restoreSuccess': 'Local backup restored.',
|
||||
'cloudSync.localBackups.restoreFailedTitle': 'Restore failed',
|
||||
'cloudSync.localBackups.restoreMissing': 'Backup not found.',
|
||||
'cloudSync.localBackups.protectiveBackupFailed': 'Safety backup could not be created, so the restore was aborted to protect your current data. Resolve the underlying issue (e.g. keychain access) and try again. Details: {message}',
|
||||
'cloudSync.localBackups.restoreConfirmTitle': 'Restore this backup?',
|
||||
'cloudSync.localBackups.restoreConfirmDesc': 'Your current hosts, keys, snippets and settings will be replaced with the contents of this backup. A protective snapshot of your current data is taken automatically first.',
|
||||
'cloudSync.localBackups.restoreConfirmButton': 'Restore',
|
||||
'cloudSync.localBackups.restoreConfirmCancel': 'Cancel',
|
||||
'cloudSync.localBackups.unavailableTitle': 'Local backups unavailable',
|
||||
'cloudSync.localBackups.unavailableDesc': 'This platform does not expose a secure keychain to Netcatty, so local backups cannot be written safely. Install Netcatty on a system with a supported keychain to enable the local backup history.',
|
||||
'cloudSync.localBackups.lockedTitle': 'Master key required',
|
||||
'cloudSync.localBackups.lockedDesc': 'Set up or unlock your master key before restoring a backup, so restored credentials remain encrypted.',
|
||||
'cloudSync.revisionHistory.viewButton': 'History',
|
||||
'cloudSync.revisionHistory.title': 'Vault Version History',
|
||||
'cloudSync.revisionHistory.description': 'Browse and restore previous versions of your vault from the Gist revision history.',
|
||||
@@ -1450,6 +1554,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.',
|
||||
@@ -1573,6 +1678,9 @@ const en: Messages = {
|
||||
'tabs.logPrefix': 'Log:',
|
||||
'tabs.logLocal': 'Local',
|
||||
'tabs.copyTab': 'Copy Tab',
|
||||
'tabs.closeOthers': 'Close Others',
|
||||
'tabs.closeToRight': 'Close Tabs to the Right',
|
||||
'tabs.closeAll': 'Close All',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
'keychain.edit.keyLabelPlaceholder': 'Key label',
|
||||
'keychain.edit.privateKeyRequired': 'Private key *',
|
||||
@@ -1612,6 +1720,8 @@ const en: Messages = {
|
||||
'snippets.breadcrumb.separator': '›',
|
||||
'snippets.empty.title': 'Create snippet',
|
||||
'snippets.empty.desc': 'Save your most used commands as snippets to reuse them in one click.',
|
||||
'snippets.search.noResults.title': 'No matches',
|
||||
'snippets.search.noResults.desc': 'No snippets or packages match "{query}". Try a different search term or clear the search to browse.',
|
||||
'snippets.section.packages': 'Packages',
|
||||
'snippets.section.snippets': 'Snippets',
|
||||
'snippets.package.count': '{count} snippet(s)',
|
||||
@@ -1707,6 +1817,12 @@ const en: Messages = {
|
||||
|
||||
// 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',
|
||||
@@ -1764,7 +1880,6 @@ const en: Messages = {
|
||||
'ai.codex.logout': 'Logout',
|
||||
'ai.codex.connectChatGPT': 'Connect ChatGPT',
|
||||
'ai.codex.refreshStatus': 'Refresh Status',
|
||||
'ai.codex.apiKeyHint': 'Detected an enabled OpenAI-compatible provider API key. Codex ACP can use it without ChatGPT login.',
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
@@ -1797,6 +1912,17 @@ const en: Messages = {
|
||||
'ai.toolAccess.description': 'Choose how external ACP agents access Netcatty sessions. MCP exposes the built-in server, while Skills + CLI points agents to the local Netcatty skill and CLI commands.',
|
||||
'ai.toolAccess.mode.mcp': 'MCP',
|
||||
'ai.toolAccess.mode.skills': 'Skills + CLI',
|
||||
'ai.userSkills.title': 'User Skills',
|
||||
'ai.userSkills.description': 'Open the Netcatty skills folder to add your own skill directories. Netcatty scans these skills automatically and injects only lightweight indexes unless a skill clearly matches the current request.',
|
||||
'ai.userSkills.openFolder': 'Open Skills Folder',
|
||||
'ai.userSkills.reload': 'Reload Skills',
|
||||
'ai.userSkills.location': 'Location',
|
||||
'ai.userSkills.loading': 'Scanning user skills...',
|
||||
'ai.userSkills.summary': '{ready} ready, {warnings} warnings',
|
||||
'ai.userSkills.empty': 'No user skills found yet. Open the folder to add skill directories with a SKILL.md file.',
|
||||
'ai.userSkills.unavailable': 'User skills are unavailable in this environment.',
|
||||
'ai.userSkills.status.ready': 'Ready',
|
||||
'ai.userSkills.status.warning': 'Warning',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
|
||||
@@ -1851,6 +1977,7 @@ const en: Messages = {
|
||||
'ai.chat.menuFiles': 'Files',
|
||||
'ai.chat.menuImage': 'Image',
|
||||
'ai.chat.menuMentionHost': 'Mention Host',
|
||||
'ai.chat.menuUserSkills': 'User Skills',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
|
||||
|
||||
@@ -43,6 +43,11 @@ const zhCN: Messages = {
|
||||
'confirm.deleteHost': '删除主机 "{name}"?',
|
||||
'confirm.deleteIdentity': '删除身份 "{name}"?',
|
||||
'confirm.removeProvider': '移除提供商 "{name}"?',
|
||||
'confirm.closeBusyTerminal.title': '确认关闭',
|
||||
'confirm.closeBusyTerminal.message': '进程 "{command}" 仍在运行,关闭后会被终止。',
|
||||
'confirm.closeBusyTerminal.messageWithMore': '进程 "{command}" 及其他 {count} 个正在运行的进程将被终止。',
|
||||
'confirm.closeBusyTerminal.cancel': '取消',
|
||||
'confirm.closeBusyTerminal.close': '关闭',
|
||||
'dialog.renameWorkspace.title': '重命名工作区',
|
||||
'dialog.renameSession.title': '重命名会话',
|
||||
'field.name': '名称',
|
||||
@@ -262,10 +267,15 @@ const zhCN: Messages = {
|
||||
'sync.toast.completedMessage': '同步完成',
|
||||
'sync.toast.errorTitle': '同步错误',
|
||||
'sync.autoSync.failedTitle': '同步失败',
|
||||
'sync.autoSync.inspectFailedTitle': '同步已暂停',
|
||||
'sync.autoSync.inspectFailedMessage': '无法访问云端以检查变更。数据改动或下次启动时会自动重试。',
|
||||
'sync.autoSync.syncedTitle': '已从云端同步',
|
||||
'sync.autoSync.syncedMessage': '你的数据已从云端更新。',
|
||||
'sync.autoSync.noProvider': '未连接云同步 provider。请打开 设置 → Sync & Cloud 进行连接。',
|
||||
'sync.autoSync.alreadySyncing': '同步正在进行中。',
|
||||
'sync.autoSync.restoreInProgress': '另一个窗口中的本地备份恢复正在进行中,请等待其完成。',
|
||||
'sync.autoSync.interruptedApplyTitle': '同步已暂停 — 上次恢复未完成',
|
||||
'sync.autoSync.interruptedApplyMessage': '上次本地恢复过程未正常结束,本地数据可能处于半应用状态。请打开「设置 → Sync & Cloud → 恢复」,从保护性备份中恢复后再让自动同步继续。',
|
||||
'sync.autoSync.vaultLocked': 'Vault 处于锁定状态。请打开 设置 → Sync & Cloud 解锁。',
|
||||
'sync.autoSync.conflictDetected': '检测到同步冲突。请打开 设置 → Sync & Cloud 处理。',
|
||||
'sync.autoSync.syncFailed': '同步失败',
|
||||
@@ -280,7 +290,32 @@ 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': '同步已暂停',
|
||||
'sync.blocked.reason.bulkShrink': '即将从云端删除 {baseCount} 条 {entityType} 中的 {lost} 条(缩减 {percent}%)。',
|
||||
'sync.blocked.reason.largeShrink': '即将从云端删除 {lost} 条 {entityType}。',
|
||||
'sync.blocked.detail': '通常是本地状态异常(钥匙串故障、数据加载不全)导致。请从本地备份恢复,如果确实要删这些条目请使用强制推送。',
|
||||
'sync.blocked.restoreButton': '从本地备份恢复',
|
||||
'sync.blocked.forcePushButton': '强制推送',
|
||||
|
||||
'sync.forcePush.title': '确认强制推送',
|
||||
'sync.forcePush.body': '你将从云端移除 {lost} 条 {entityType},此操作不可撤销。继续?',
|
||||
'sync.forcePush.confirm': '确认推送',
|
||||
'sync.forcePush.cancel': '取消',
|
||||
|
||||
'sync.entityType.hosts': '主机',
|
||||
'sync.entityType.keys': '密钥',
|
||||
'sync.entityType.identities': '身份',
|
||||
'sync.entityType.proxyProfiles': '代理配置',
|
||||
'sync.entityType.snippets': '代码片段',
|
||||
'sync.entityType.customGroups': '分组',
|
||||
'sync.entityType.snippetPackages': '片段包',
|
||||
'sync.entityType.knownHosts': '主机密钥记录',
|
||||
'sync.entityType.portForwardingRules': '端口转发规则',
|
||||
'sync.entityType.groupConfigs': '分组配置',
|
||||
|
||||
'sync.credentialsUnavailable': '当前设备无法解密部分已保存凭据。请先在本地重新输入凭据后再同步。',
|
||||
'time.never': '从未',
|
||||
'time.justNow': '刚刚',
|
||||
@@ -289,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} 台主机',
|
||||
@@ -528,6 +580,9 @@ const zhCN: Messages = {
|
||||
'sftp.transfers.collapseChildren': '收起文件',
|
||||
'sftp.transfers.expandChildList': '展开详情',
|
||||
'sftp.transfers.collapseChildList': '收起',
|
||||
'sftp.transfers.retryAction': '重试',
|
||||
'sftp.transfers.dismissAction': '移除',
|
||||
'sftp.transfers.resizeNameColumn': '调整文件名列宽',
|
||||
'sftp.transfers.dragToResize': '拖拽调整高度',
|
||||
'sftp.goUp': '上一级',
|
||||
'sftp.goToTerminalCwd': '定位到终端当前目录',
|
||||
@@ -678,6 +733,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。',
|
||||
@@ -765,6 +823,7 @@ const zhCN: Messages = {
|
||||
'terminal.toolbar.openSftp': '打开 SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': '连接后可用',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': '更多操作',
|
||||
'terminal.toolbar.scripts': '脚本',
|
||||
'terminal.toolbar.library': '库',
|
||||
'terminal.toolbar.noSnippets': '暂无代码片段',
|
||||
@@ -825,6 +884,7 @@ const zhCN: Messages = {
|
||||
'terminal.search.nextMatch': '下一个匹配 (Enter)',
|
||||
'terminal.menu.copy': '复制',
|
||||
'terminal.menu.paste': '粘贴',
|
||||
'terminal.menu.pasteSelection': '粘贴选中文本',
|
||||
'terminal.menu.selectAll': '全选',
|
||||
'terminal.menu.splitHorizontal': '水平分屏',
|
||||
'terminal.menu.splitVertical': '垂直分屏',
|
||||
@@ -1003,6 +1063,31 @@ const zhCN: Messages = {
|
||||
'cloudSync.history.download': '下载',
|
||||
'cloudSync.history.resolved': '已解决',
|
||||
'cloudSync.history.error': '错误',
|
||||
'cloudSync.localBackups.title': '本地备份历史',
|
||||
'cloudSync.localBackups.desc': 'Netcatty 会在版本变化前,以及恢复主机库前,自动留下一份本地恢复点。',
|
||||
'cloudSync.localBackups.retentionTitle': '备份保留数量',
|
||||
'cloudSync.localBackups.retentionDesc': '设置 Netcatty 最多保留多少份本地备份。',
|
||||
'cloudSync.localBackups.maxCount': '最多保留',
|
||||
'cloudSync.localBackups.maxSaved': '已保存保留数量:{count}',
|
||||
'cloudSync.localBackups.maxInvalid': '请输入 1 到 100 之间的数字。',
|
||||
'cloudSync.localBackups.empty': '还没有本地备份。',
|
||||
'cloudSync.localBackups.reason.appVersionChange': '版本变化前',
|
||||
'cloudSync.localBackups.reason.beforeRestore': '恢复前',
|
||||
'cloudSync.localBackups.versionChange': '{from} -> {to}',
|
||||
'cloudSync.localBackups.counts': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段',
|
||||
'cloudSync.localBackups.restore': '恢复',
|
||||
'cloudSync.localBackups.restoreSuccess': '已恢复本地备份。',
|
||||
'cloudSync.localBackups.restoreFailedTitle': '恢复失败',
|
||||
'cloudSync.localBackups.restoreMissing': '找不到这份备份。',
|
||||
'cloudSync.localBackups.protectiveBackupFailed': '无法创建保护性备份,已中止恢复以避免覆盖当前数据。请先解决底层问题(例如钥匙串访问)后重试。详情:{message}',
|
||||
'cloudSync.localBackups.restoreConfirmTitle': '确认恢复此备份?',
|
||||
'cloudSync.localBackups.restoreConfirmDesc': '当前的主机、密钥、代码片段与设置将被替换为此备份中的内容。系统会先自动创建一个保护性快照,便于撤销。',
|
||||
'cloudSync.localBackups.restoreConfirmButton': '恢复',
|
||||
'cloudSync.localBackups.restoreConfirmCancel': '取消',
|
||||
'cloudSync.localBackups.unavailableTitle': '无法使用本地备份',
|
||||
'cloudSync.localBackups.unavailableDesc': '当前平台未提供受支持的安全密钥库,Netcatty 无法安全地写入本地备份。请在支持系统钥匙串的环境中运行,或改用云同步保留恢复点。',
|
||||
'cloudSync.localBackups.lockedTitle': '需要主密钥',
|
||||
'cloudSync.localBackups.lockedDesc': '请先配置或解锁主密钥再恢复备份,以确保恢复后的凭据仍保持加密。',
|
||||
'cloudSync.revisionHistory.viewButton': '历史版本',
|
||||
'cloudSync.revisionHistory.title': '主机库版本历史',
|
||||
'cloudSync.revisionHistory.description': '浏览并恢复 Gist 修订历史中的旧版主机库数据。',
|
||||
@@ -1062,6 +1147,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 超时,请检查网络或代理设置。',
|
||||
@@ -1149,8 +1235,11 @@ const zhCN: Messages = {
|
||||
'sftp.conflict.size': '大小:',
|
||||
'sftp.conflict.modified': '修改时间:',
|
||||
'sftp.conflict.applyToAll': '将此操作应用到剩余的 {count} 个冲突',
|
||||
'sftp.conflict.action.stop': '停止',
|
||||
'sftp.conflict.action.skip': '跳过',
|
||||
'sftp.conflict.action.keepBoth': '保留两者',
|
||||
'sftp.conflict.action.duplicate': '创建副本',
|
||||
'sftp.conflict.action.merge': '合并',
|
||||
'sftp.conflict.action.replace': '替换',
|
||||
|
||||
// SFTP Upload Phases
|
||||
@@ -1329,6 +1418,12 @@ const zhCN: Messages = {
|
||||
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` 同时清空回滚历史',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'`clear` 命令同时清空回滚历史(POSIX 默认行为)。关闭则保留历史。',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput': '输入时保留选区',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput.desc':
|
||||
'键盘输入时不清除鼠标选中的文本,方便选中路径后输入 `sz ` 之类命令再粘贴。',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪贴板',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'允许远程程序(tmux、vim 等)通过 OSC-52 转义序列访问本地剪贴板。',
|
||||
@@ -1388,6 +1483,9 @@ const zhCN: Messages = {
|
||||
'settings.terminal.section.connection': '连接',
|
||||
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': '向服务器发送 SSH 级别保活数据包的频率(秒)。设为 0 表示禁用。',
|
||||
'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 服务器)。',
|
||||
@@ -1421,6 +1519,7 @@ const zhCN: Messages = {
|
||||
'settings.shortcuts.resetAll': '全部重置',
|
||||
'settings.shortcuts.recording': '请按键...',
|
||||
'settings.shortcuts.none': '无',
|
||||
'settings.shortcuts.setDisabled': '设为禁用',
|
||||
'settings.shortcuts.category.tabs': '标签页',
|
||||
'settings.shortcuts.category.terminal': '终端',
|
||||
'settings.shortcuts.category.navigation': '导航',
|
||||
@@ -1445,6 +1544,7 @@ const zhCN: Messages = {
|
||||
'settings.shortcuts.binding.port-forwarding': '打开端口转发',
|
||||
'settings.shortcuts.binding.command-palette': '打开命令面板',
|
||||
'settings.shortcuts.binding.quick-switch': '快速切换',
|
||||
'settings.shortcuts.binding.new-workspace': '新建工作区',
|
||||
'settings.shortcuts.binding.snippets': '打开代码片段',
|
||||
'settings.shortcuts.binding.broadcast': '切换广播模式',
|
||||
'settings.shortcuts.binding.sftp-copy': '复制文件',
|
||||
@@ -1457,13 +1557,19 @@ const zhCN: Messages = {
|
||||
'settings.shortcuts.binding.sftp-new-folder': '新建文件夹',
|
||||
|
||||
// Host Details (sub-panels)
|
||||
'hostDetails.proxyPanel.title': 'Proxy',
|
||||
'hostDetails.proxyPanel.hostPlaceholder': 'Proxy host',
|
||||
'hostDetails.proxyPanel.credentials': 'Credentials',
|
||||
'hostDetails.proxyPanel.usernamePlaceholder': 'Username',
|
||||
'hostDetails.proxyPanel.passwordPlaceholder': 'Password',
|
||||
'hostDetails.proxyPanel.identities': 'Identities',
|
||||
'hostDetails.proxyPanel.remove': '移除 Proxy',
|
||||
'hostDetails.proxyPanel.title': '通过 HTTP/SOCKS5 代理',
|
||||
'hostDetails.proxyPanel.hostPlaceholder': '代理主机',
|
||||
'hostDetails.proxyPanel.credentials': '凭据',
|
||||
'hostDetails.proxyPanel.usernamePlaceholder': '用户名',
|
||||
'hostDetails.proxyPanel.passwordPlaceholder': '密码',
|
||||
'hostDetails.proxyPanel.identities': '身份',
|
||||
'hostDetails.proxyPanel.remove': '移除代理',
|
||||
'hostDetails.proxyPanel.savedProxy': '已保存代理',
|
||||
'hostDetails.proxyPanel.selectSaved': '选择已保存代理',
|
||||
'hostDetails.proxyPanel.customProxy': '自定义代理',
|
||||
'hostDetails.proxyPanel.missing': '缺失',
|
||||
'hostDetails.proxyPanel.missingSaved': '保存的代理不存在',
|
||||
'hostDetails.proxyPanel.error.required': '代理主机和端口不能为空。',
|
||||
'hostDetails.envVars.title': '环境变量',
|
||||
'hostDetails.envVars.desc': '为 {host} 设置环境变量。',
|
||||
'hostDetails.envVars.note': '部分 SSH 服务器默认只允许以 LC_ 和 LANG_ 为前缀的变量。',
|
||||
@@ -1581,6 +1687,9 @@ const zhCN: Messages = {
|
||||
'tabs.logPrefix': '日志:',
|
||||
'tabs.logLocal': '本地',
|
||||
'tabs.copyTab': '复制标签页',
|
||||
'tabs.closeOthers': '关闭其他标签',
|
||||
'tabs.closeToRight': '关闭右侧标签',
|
||||
'tabs.closeAll': '关闭所有标签',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
'keychain.edit.keyLabelPlaceholder': '密钥 Label',
|
||||
'keychain.edit.privateKeyRequired': '私钥 *',
|
||||
@@ -1620,6 +1729,8 @@ const zhCN: Messages = {
|
||||
'snippets.breadcrumb.separator': '›',
|
||||
'snippets.empty.title': '创建代码片段',
|
||||
'snippets.empty.desc': '将常用命令保存为代码片段,一键复用。',
|
||||
'snippets.search.noResults.title': '无匹配结果',
|
||||
'snippets.search.noResults.desc': '没有代码片段或代码包与"{query}"匹配。换一个关键字,或清除搜索进行浏览。',
|
||||
'snippets.section.packages': '代码包',
|
||||
'snippets.section.snippets': '代码片段',
|
||||
'snippets.package.count': '{count} 个代码片段',
|
||||
@@ -1715,6 +1826,12 @@ const zhCN: Messages = {
|
||||
|
||||
// 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 设置',
|
||||
@@ -1772,7 +1889,6 @@ const zhCN: Messages = {
|
||||
'ai.codex.logout': '退出登录',
|
||||
'ai.codex.connectChatGPT': '连接 ChatGPT',
|
||||
'ai.codex.refreshStatus': '刷新状态',
|
||||
'ai.codex.apiKeyHint': '检测到已启用的兼容 OpenAI 的 API Key。Codex ACP 也可以不走 ChatGPT 登录直接使用它。',
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
@@ -1805,6 +1921,17 @@ const zhCN: Messages = {
|
||||
'ai.toolAccess.description': '选择外部 ACP Agent 访问 Netcatty 会话的方式。MCP 会暴露内置服务器,Skills + CLI 会引导 Agent 读取本地 Skill 并调用 Netcatty CLI。',
|
||||
'ai.toolAccess.mode.mcp': 'MCP',
|
||||
'ai.toolAccess.mode.skills': 'Skills + CLI',
|
||||
'ai.userSkills.title': '用户 Skills',
|
||||
'ai.userSkills.description': '打开 Netcatty 的 Skills 文件夹以添加你自己的技能目录。Netcatty 会自动扫描这些 skills,默认只注入轻量索引,只有在请求明显命中某个 skill 时才展开正文。',
|
||||
'ai.userSkills.openFolder': '打开 Skills 文件夹',
|
||||
'ai.userSkills.reload': '重新加载 Skills',
|
||||
'ai.userSkills.location': '位置',
|
||||
'ai.userSkills.loading': '正在扫描用户 skills...',
|
||||
'ai.userSkills.summary': '已就绪 {ready} 个,警告 {warnings} 个',
|
||||
'ai.userSkills.empty': '暂未发现用户 skills。打开文件夹后可添加包含 SKILL.md 的技能目录。',
|
||||
'ai.userSkills.unavailable': '当前环境不支持用户 skills。',
|
||||
'ai.userSkills.status.ready': '正常',
|
||||
'ai.userSkills.status.warning': '警告',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
|
||||
@@ -1859,6 +1986,7 @@ const zhCN: Messages = {
|
||||
'ai.chat.menuFiles': '文件',
|
||||
'ai.chat.menuImage': '图片',
|
||||
'ai.chat.menuMentionHost': '提及主机',
|
||||
'ai.chat.menuUserSkills': '用户 Skills',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
|
||||
|
||||
495
application/localVaultBackups.ts
Normal file
495
application/localVaultBackups.ts
Normal file
@@ -0,0 +1,495 @@
|
||||
import type { SyncPayload } from '../domain/sync';
|
||||
import {
|
||||
STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION,
|
||||
STORAGE_KEY_LOCAL_VAULT_BACKUP_MAX_COUNT,
|
||||
STORAGE_KEY_VAULT_APPLY_IN_PROGRESS,
|
||||
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
||||
import { getCloudSyncManager } from '../infrastructure/services/CloudSyncManager';
|
||||
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
|
||||
import { hasMeaningfulSyncData } from './syncPayload';
|
||||
|
||||
/**
|
||||
* Snapshot the current sync data version (the integer that increments
|
||||
* on each successful cloud sync). Returns undefined when the value is
|
||||
* 0 (never synced) or unavailable, so the UI can fall back to timestamp.
|
||||
*/
|
||||
function captureCurrentSyncDataVersion(): number | undefined {
|
||||
try {
|
||||
const state = getCloudSyncManager().getState();
|
||||
const v = state.localVersion;
|
||||
return typeof v === 'number' && v > 0 ? v : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export type LocalVaultBackupReason = 'app_version_change' | 'before_restore';
|
||||
|
||||
export interface LocalVaultBackupPreview {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
reason: LocalVaultBackupReason;
|
||||
/** Sync-data version at the time the snapshot was taken (the integer
|
||||
* that the CloudSyncManager increments on each successful cloud sync).
|
||||
* Undefined when the user had never synced yet, or for legacy backups
|
||||
* persisted before this field was added. */
|
||||
syncDataVersion?: number;
|
||||
/** App version transition fields, only for `app_version_change` records.
|
||||
* Kept for backward compatibility with already-persisted backups. */
|
||||
sourceAppVersion?: string;
|
||||
targetAppVersion?: string;
|
||||
fingerprint: string;
|
||||
preview: {
|
||||
hostCount: number;
|
||||
keyCount: number;
|
||||
snippetCount: number;
|
||||
identityCount: number;
|
||||
portForwardingRuleCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LocalVaultBackupDetails {
|
||||
backup: LocalVaultBackupPreview;
|
||||
payload: SyncPayload;
|
||||
}
|
||||
|
||||
export const DEFAULT_LOCAL_VAULT_BACKUP_MAX_COUNT = 20;
|
||||
export const MIN_LOCAL_VAULT_BACKUP_MAX_COUNT = 1;
|
||||
export const MAX_LOCAL_VAULT_BACKUP_MAX_COUNT = 100;
|
||||
|
||||
export const sanitizeLocalVaultBackupMaxCount = (value: number): number => {
|
||||
if (!Number.isFinite(value)) return DEFAULT_LOCAL_VAULT_BACKUP_MAX_COUNT;
|
||||
return Math.max(
|
||||
MIN_LOCAL_VAULT_BACKUP_MAX_COUNT,
|
||||
Math.min(MAX_LOCAL_VAULT_BACKUP_MAX_COUNT, Math.round(value)),
|
||||
);
|
||||
};
|
||||
|
||||
export const getLocalVaultBackupMaxCount = (): number => {
|
||||
const stored = localStorageAdapter.readNumber(STORAGE_KEY_LOCAL_VAULT_BACKUP_MAX_COUNT);
|
||||
return sanitizeLocalVaultBackupMaxCount(
|
||||
stored ?? DEFAULT_LOCAL_VAULT_BACKUP_MAX_COUNT,
|
||||
);
|
||||
};
|
||||
|
||||
export const setLocalVaultBackupMaxCount = (value: number): number => {
|
||||
const sanitized = sanitizeLocalVaultBackupMaxCount(value);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_LOCAL_VAULT_BACKUP_MAX_COUNT, sanitized);
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
export async function trimLocalVaultBackups(maxCount = getLocalVaultBackupMaxCount()): Promise<void> {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.trimVaultBackups?.({ maxCount });
|
||||
}
|
||||
|
||||
export async function getLocalVaultBackupCapabilities(): Promise<{
|
||||
encryptionAvailable: boolean;
|
||||
}> {
|
||||
const bridge = netcattyBridge.get();
|
||||
const caps = await bridge?.getVaultBackupCapabilities?.();
|
||||
// Conservatively treat a missing bridge (non-Electron environments, early
|
||||
// boot) as unavailable so callers fall back to the locked-down UI path
|
||||
// instead of assuming capabilities they can't verify.
|
||||
return { encryptionAvailable: Boolean(caps?.encryptionAvailable) };
|
||||
}
|
||||
|
||||
export async function listLocalVaultBackups(): Promise<LocalVaultBackupPreview[]> {
|
||||
const bridge = netcattyBridge.get();
|
||||
const entries = await bridge?.listVaultBackups?.();
|
||||
return Array.isArray(entries) ? entries : [];
|
||||
}
|
||||
|
||||
export async function readLocalVaultBackup(id: string): Promise<LocalVaultBackupDetails | null> {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.readVaultBackup) return null;
|
||||
return bridge.readVaultBackup({ id });
|
||||
}
|
||||
|
||||
export async function openLocalVaultBackupDir(): Promise<void> {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.openVaultBackupDir?.();
|
||||
}
|
||||
|
||||
export async function createLocalVaultBackup(
|
||||
payload: SyncPayload,
|
||||
options: {
|
||||
reason: LocalVaultBackupReason;
|
||||
syncDataVersion?: number;
|
||||
sourceAppVersion?: string;
|
||||
targetAppVersion?: string;
|
||||
maxCount?: number;
|
||||
},
|
||||
): Promise<LocalVaultBackupPreview | null> {
|
||||
// Intentional: an empty-vault backup has nothing to restore from, so we
|
||||
// early-return instead of writing a zero-entry record. Callers that rely
|
||||
// on a backup (protective-before-restore, version-change on first run)
|
||||
// must treat `null` as "no safety net this time" and continue — blocking
|
||||
// the user's flow on a missing backup would be worse than allowing the
|
||||
// apply to proceed without one.
|
||||
if (!hasMeaningfulSyncData(payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.createVaultBackup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await bridge.createVaultBackup({
|
||||
payload,
|
||||
reason: options.reason,
|
||||
// Default to the live cloud-sync version so every new backup carries
|
||||
// it even when the caller didn't pass one explicitly. Bridge sanitizer
|
||||
// drops invalid values (non-positive / non-finite), so this is safe.
|
||||
syncDataVersion: options.syncDataVersion ?? captureCurrentSyncDataVersion(),
|
||||
sourceAppVersion: options.sourceAppVersion,
|
||||
targetAppVersion: options.targetAppVersion,
|
||||
maxCount: options.maxCount ?? getLocalVaultBackupMaxCount(),
|
||||
});
|
||||
return result?.backup ?? null;
|
||||
} catch (error) {
|
||||
// The main-process bridge refuses to write backups when safeStorage is
|
||||
// unavailable (VAULT_BACKUP_ENCRYPTION_UNAVAILABLE) because SyncPayload
|
||||
// carries plaintext credentials that must never touch disk unencrypted.
|
||||
// Callers (startup version-change, protective-before-restore) intentionally
|
||||
// continue without a backup rather than blocking the user's flow, so we
|
||||
// log and return null here.
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn('[localVaultBackups] Backup skipped:', message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a caller requires a protective backup and the backup
|
||||
* couldn't be written — safeStorage unavailable, bridge missing,
|
||||
* main-process rejection, disk error.
|
||||
*
|
||||
* Callers should surface this as a user-visible abort rather than
|
||||
* proceeding with the destructive apply. Separate from "nothing to
|
||||
* back up" (empty vault) which is returned as `null`.
|
||||
*/
|
||||
export class ProtectiveBackupUnavailableError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ProtectiveBackupUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a protective local backup before a destructive apply (restore
|
||||
* from backup list, restore from Gist revision, cloud download applied
|
||||
* over meaningful local state).
|
||||
*
|
||||
* Returns `null` when there is nothing meaningful to back up — in that
|
||||
* case the caller can safely proceed with the apply, because there is
|
||||
* no local data to lose.
|
||||
*
|
||||
* Throws `ProtectiveBackupUnavailableError` when pre-apply state IS
|
||||
* meaningful but the backup attempt failed. Callers MUST abort the
|
||||
* destructive apply in that case and surface the error to the user,
|
||||
* otherwise we regress the exact safety contract the backup system
|
||||
* was added to enforce (the `console.error`-and-proceed pattern that
|
||||
* previously swallowed safeStorage/keychain failures and continued).
|
||||
*/
|
||||
export async function createRequiredProtectiveLocalVaultBackup(
|
||||
payload: SyncPayload,
|
||||
): Promise<LocalVaultBackupPreview | null> {
|
||||
if (!hasMeaningfulSyncData(payload)) {
|
||||
// Nothing to protect — an empty-vault backup would produce a
|
||||
// useless record, not a safety net.
|
||||
return null;
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.createVaultBackup) {
|
||||
throw new ProtectiveBackupUnavailableError(
|
||||
'Vault backup bridge is not available in this environment.',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await bridge.createVaultBackup({
|
||||
payload,
|
||||
reason: 'before_restore',
|
||||
maxCount: getLocalVaultBackupMaxCount(),
|
||||
});
|
||||
return result?.backup ?? null;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new ProtectiveBackupUnavailableError(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How long each heartbeat extends the cross-window restore barrier.
|
||||
* Short enough that an abandoned lock (crashed window, hung task)
|
||||
* clears itself quickly without user intervention. The heartbeat
|
||||
* interval below refreshes the deadline as long as the caller's task
|
||||
* is still running, so large vaults or slow keychain unlocks cannot
|
||||
* expose a mid-apply window to concurrent auto-sync even when the
|
||||
* total apply time exceeds this value.
|
||||
*/
|
||||
const RESTORE_BARRIER_HOLD_MS = 60_000;
|
||||
|
||||
/**
|
||||
* How often the heartbeat refreshes the barrier. Picked to ensure at
|
||||
* least two refreshes land before the current deadline would expire,
|
||||
* so a single missed tick (event-loop stall, GC pause) cannot drop
|
||||
* the barrier prematurely.
|
||||
*/
|
||||
const RESTORE_BARRIER_HEARTBEAT_MS = Math.max(1_000, Math.floor(RESTORE_BARRIER_HOLD_MS / 3));
|
||||
|
||||
/**
|
||||
* Run `task` while holding a cross-window "restore in progress" barrier.
|
||||
*
|
||||
* The barrier is a localStorage key readable by every window of the same
|
||||
* origin. useAutoSync reads it on each auto-sync and on each data-change
|
||||
* debounce tick, refusing to push while the deadline is still in the
|
||||
* future. We write a time-bounded deadline (rather than a boolean) so a
|
||||
* crashed window can never leave sync permanently wedged.
|
||||
*
|
||||
* While the task runs, a heartbeat timer re-writes the deadline so a
|
||||
* slow apply (large vault, slow keychain) keeps the barrier held rather
|
||||
* than exposing a post-deadline window to concurrent auto-sync. The
|
||||
* heartbeat is cleared and the barrier is released in a finally block
|
||||
* so success, throw, and unexpected early-return all converge on the
|
||||
* same cleanup.
|
||||
*/
|
||||
export async function withRestoreBarrier<T>(
|
||||
task: () => Promise<T>,
|
||||
holdMs: number = RESTORE_BARRIER_HOLD_MS,
|
||||
): Promise<T> {
|
||||
const writeDeadline = () => {
|
||||
try {
|
||||
localStorageAdapter.writeNumber(
|
||||
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
|
||||
Date.now() + holdMs,
|
||||
);
|
||||
} catch (error) {
|
||||
// If we can't write the barrier we still proceed — the UI-side
|
||||
// `isSyncBusy` guard and same-window debounce cancellation are a
|
||||
// secondary defense. Better to complete the restore than refuse on
|
||||
// a broken localStorage.
|
||||
console.warn('[localVaultBackups] Failed to set restore barrier:', error);
|
||||
}
|
||||
};
|
||||
|
||||
writeDeadline();
|
||||
const heartbeat = setInterval(
|
||||
writeDeadline,
|
||||
Math.max(1_000, Math.min(holdMs / 3, RESTORE_BARRIER_HEARTBEAT_MS)),
|
||||
);
|
||||
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
clearInterval(heartbeat);
|
||||
try {
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL, 0);
|
||||
} catch {
|
||||
/* ignore — the deadline will expire naturally */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of the apply-in-progress sentinel record. Persisted as JSON in
|
||||
* `STORAGE_KEY_VAULT_APPLY_IN_PROGRESS` so the next session can
|
||||
* distinguish "the last apply completed cleanly" from "the last apply
|
||||
* crashed mid-way and the local vault is a partial mix of states."
|
||||
*/
|
||||
export interface VaultApplyInProgressRecord {
|
||||
startedAt: number;
|
||||
protectiveBackupId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the persisted apply-in-progress record if a previous apply
|
||||
* was interrupted before clearing it. Callers (notably auto-sync) use
|
||||
* this to refuse to push a partial-apply local state over an intact
|
||||
* cloud copy. See `applyProtectedSyncPayload` for the write side.
|
||||
*
|
||||
* `null` here means "no interrupted apply detected" — either nothing
|
||||
* was ever applied, or the last apply finished cleanly.
|
||||
*/
|
||||
export function readInterruptedVaultApply(): VaultApplyInProgressRecord | null {
|
||||
try {
|
||||
const raw = localStorageAdapter.readString(STORAGE_KEY_VAULT_APPLY_IN_PROGRESS);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const startedAt = typeof parsed.startedAt === 'number' ? parsed.startedAt : 0;
|
||||
const protectiveBackupId =
|
||||
typeof parsed.protectiveBackupId === 'string' ? parsed.protectiveBackupId : null;
|
||||
if (!startedAt) return null;
|
||||
return { startedAt, protectiveBackupId };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the apply-in-progress sentinel. The normal completion path
|
||||
* inside `applyProtectedSyncPayload` clears it automatically; this
|
||||
* export exists so the user's explicit recovery action ("I've restored
|
||||
* from a backup, resume sync") can acknowledge the interrupted state
|
||||
* from the UI without re-running an apply.
|
||||
*/
|
||||
export function clearInterruptedVaultApply(): void {
|
||||
try {
|
||||
localStorageAdapter.remove(STORAGE_KEY_VAULT_APPLY_IN_PROGRESS);
|
||||
} catch {
|
||||
/* ignore — next clean apply will overwrite */
|
||||
}
|
||||
}
|
||||
|
||||
function writeApplyInProgressSentinel(record: VaultApplyInProgressRecord): void {
|
||||
try {
|
||||
localStorageAdapter.writeString(
|
||||
STORAGE_KEY_VAULT_APPLY_IN_PROGRESS,
|
||||
JSON.stringify(record),
|
||||
);
|
||||
} catch (error) {
|
||||
// Sentinel write is best-effort: a failure here means a later crash
|
||||
// won't be detected, but does NOT compromise the apply itself.
|
||||
// Log so a systematic storage outage is diagnosable.
|
||||
console.warn('[localVaultBackups] Failed to set apply-in-progress sentinel:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared "apply a remote-sourced payload safely" helper.
|
||||
*
|
||||
* Holds the cross-window restore barrier, snapshots the pre-apply vault
|
||||
* into a protective backup, persists an apply-in-progress sentinel, and
|
||||
* only then runs the supplied `applyPayload` callback. Every destructive
|
||||
* apply path (startup merge, conflict resolution, empty-vault restore,
|
||||
* manual Gist-revision restore) must go through this so the protections
|
||||
* can't drift out of sync between the main window and the settings
|
||||
* window.
|
||||
*
|
||||
* The sentinel closes the partial-apply-then-crash window: `applyPayload`
|
||||
* writes to several localStorage keys non-atomically (hosts, keys, port-
|
||||
* forwarding rules, settings). A crash mid-sequence leaves the vault in
|
||||
* a state that is neither pre-apply nor post-apply, and the next
|
||||
* auto-sync would otherwise push that partial state over an intact cloud
|
||||
* copy. The sentinel flags "local may be inconsistent" for the next
|
||||
* session; `readInterruptedVaultApply` exposes that to callers that
|
||||
* enforce "don't auto-push a half-applied vault."
|
||||
*
|
||||
* `buildPreApplyPayload` is invoked *before* the apply to snapshot the
|
||||
* current vault. Callers pass their own React-closure builder (hosts,
|
||||
* keys, port-forwarding rules) because the caller owns that state.
|
||||
*
|
||||
* `translateProtectiveBackupFailure` converts the
|
||||
* `ProtectiveBackupUnavailableError` into a user-visible message in the
|
||||
* caller's locale. It runs only on the thrown-and-caught path.
|
||||
*/
|
||||
export function applyProtectedSyncPayload(options: {
|
||||
buildPreApplyPayload: () => SyncPayload;
|
||||
applyPayload: () => void | Promise<void>;
|
||||
translateProtectiveBackupFailure: (message: string) => string;
|
||||
}): Promise<void> {
|
||||
const { buildPreApplyPayload, applyPayload, translateProtectiveBackupFailure } = options;
|
||||
return withRestoreBarrier(async () => {
|
||||
const pre = buildPreApplyPayload();
|
||||
let protectiveBackupId: string | null = null;
|
||||
try {
|
||||
const backup = await createRequiredProtectiveLocalVaultBackup(pre);
|
||||
protectiveBackupId = backup?.id ?? null;
|
||||
} catch (error) {
|
||||
// Destructive apply without a working safety net is exactly the
|
||||
// overwrite-without-recovery regression this module was added to
|
||||
// prevent. Surface the failure to the caller; every call site
|
||||
// currently aborts the apply and shows a user-visible error.
|
||||
if (error instanceof ProtectiveBackupUnavailableError) {
|
||||
throw new Error(translateProtectiveBackupFailure(error.message));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Mark the apply as in-progress. If the renderer crashes between
|
||||
// the first localStorage write inside `applyPayload` and the
|
||||
// successful completion below, the next session will observe this
|
||||
// sentinel and refuse to auto-sync the partial state.
|
||||
writeApplyInProgressSentinel({
|
||||
startedAt: Date.now(),
|
||||
protectiveBackupId,
|
||||
});
|
||||
|
||||
// Only clear the sentinel on successful completion. A throw from
|
||||
// `applyPayload` deliberately leaves the sentinel set: the partial
|
||||
// write is still on disk, and the next session must observe the
|
||||
// flag so auto-sync refuses to push the half-applied state.
|
||||
await applyPayload();
|
||||
clearInterruptedVaultApply();
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureVersionChangeBackup(
|
||||
payload: SyncPayload,
|
||||
currentAppVersion: string | null | undefined,
|
||||
): Promise<{ created: boolean; backup: LocalVaultBackupPreview | null }> {
|
||||
const normalizedVersion = currentAppVersion?.trim() || '';
|
||||
if (!normalizedVersion) {
|
||||
return { created: false, backup: null };
|
||||
}
|
||||
|
||||
const previousVersion =
|
||||
localStorageAdapter.readString(STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION)?.trim() || '';
|
||||
|
||||
if (!previousVersion) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION, normalizedVersion);
|
||||
return { created: false, backup: null };
|
||||
}
|
||||
|
||||
if (previousVersion === normalizedVersion) {
|
||||
return { created: false, backup: null };
|
||||
}
|
||||
|
||||
let backup: LocalVaultBackupPreview | null = null;
|
||||
const payloadIsMeaningful = hasMeaningfulSyncData(payload);
|
||||
if (payloadIsMeaningful) {
|
||||
backup = await createLocalVaultBackup(payload, {
|
||||
reason: 'app_version_change',
|
||||
sourceAppVersion: previousVersion,
|
||||
targetAppVersion: normalizedVersion,
|
||||
});
|
||||
}
|
||||
|
||||
// Only advance the stored version stamp when we actually wrote a
|
||||
// backup. Two failure modes we must NOT collapse into "advance":
|
||||
//
|
||||
// 1. Meaningful payload + backup failed (transient keychain lock,
|
||||
// disk error) — leaving the stamp unchanged means the next
|
||||
// launch retries, instead of turning a transient error into a
|
||||
// permanent "the version-change backup never happened" hole.
|
||||
//
|
||||
// 2. Non-meaningful payload at the moment we checked — on startup
|
||||
// the async vault rehydrate may not have finished yet, so
|
||||
// `hasMeaningfulSyncData` can return false transiently even
|
||||
// though the user has real data. Advancing in that window would
|
||||
// burn the one-shot upgrade opportunity; holding keeps the
|
||||
// retry available on the next launch when rehydrate has
|
||||
// completed (or when the user genuinely starts from empty and
|
||||
// the next migration-boundary arrives).
|
||||
//
|
||||
// Trade-off: a user who truly starts empty and never adds data will
|
||||
// hit this branch on every launch until they do. That's cheap (a
|
||||
// single meaningful-data check) and strictly safer than silently
|
||||
// skipping the first real upgrade backup.
|
||||
const shouldAdvanceVersion = payloadIsMeaningful && backup !== null;
|
||||
if (shouldAdvanceVersion) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION, normalizedVersion);
|
||||
}
|
||||
|
||||
return {
|
||||
created: Boolean(backup),
|
||||
backup,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
349
application/state/aiDraftState.test.ts
Normal file
349
application/state/aiDraftState.test.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
activateDraftView,
|
||||
bumpDraftMutationVersionState,
|
||||
bumpDraftUploadGenerationState,
|
||||
clearScopeDraftState,
|
||||
createEmptyDraft,
|
||||
ensureDraftForScopeState,
|
||||
getDraftMutationVersionState,
|
||||
getDraftUploadGenerationState,
|
||||
pruneTerminalScopeState,
|
||||
pruneTerminalTransientState,
|
||||
resolvePanelView,
|
||||
selectDraftForAgentSwitch,
|
||||
setDraftView,
|
||||
setSessionView,
|
||||
updateDraftForScope,
|
||||
} from "./aiDraftState.ts";
|
||||
|
||||
test("createEmptyDraft seeds selected agent and empty inputs", () => {
|
||||
const draft = createEmptyDraft("agent-alpha");
|
||||
|
||||
assert.equal(draft.agentId, "agent-alpha");
|
||||
assert.equal(draft.text, "");
|
||||
assert.deepEqual(draft.attachments, []);
|
||||
assert.deepEqual(draft.selectedUserSkillSlugs, []);
|
||||
assert.equal(typeof draft.updatedAt, "number");
|
||||
});
|
||||
|
||||
test("resolvePanelView defaults to draft when no explicit view exists", () => {
|
||||
assert.deepEqual(resolvePanelView({}, "terminal:123"), { mode: "draft" });
|
||||
});
|
||||
|
||||
test("setDraftView records draft mode", () => {
|
||||
assert.deepEqual(setDraftView({}, "terminal:123"), {
|
||||
"terminal:123": { mode: "draft" },
|
||||
});
|
||||
});
|
||||
|
||||
test("activateDraftView clears the terminal scope's active session owner", () => {
|
||||
const activeSessionIdMap = {
|
||||
"terminal:123": "session-123",
|
||||
"workspace:abc": "session-workspace",
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"terminal:123": { mode: "session", sessionId: "session-123" },
|
||||
"workspace:abc": { mode: "session", sessionId: "session-workspace" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = activateDraftView(
|
||||
activeSessionIdMap,
|
||||
panelViewByScope,
|
||||
"terminal:123",
|
||||
);
|
||||
|
||||
assert.deepEqual(next.activeSessionIdMap, {
|
||||
"workspace:abc": "session-workspace",
|
||||
});
|
||||
assert.deepEqual(next.panelViewByScope, {
|
||||
"terminal:123": { mode: "draft" },
|
||||
"workspace:abc": panelViewByScope["workspace:abc"],
|
||||
});
|
||||
});
|
||||
|
||||
test("activateDraftView is a no-op when the scope already has explicit draft view", () => {
|
||||
const activeSessionIdMap = {
|
||||
"workspace:abc": "session-workspace",
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"terminal:123": { mode: "draft" },
|
||||
"workspace:abc": { mode: "session", sessionId: "session-workspace" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = activateDraftView(
|
||||
activeSessionIdMap,
|
||||
panelViewByScope,
|
||||
"terminal:123",
|
||||
);
|
||||
|
||||
assert.equal(next.activeSessionIdMap, activeSessionIdMap);
|
||||
assert.equal(next.panelViewByScope, panelViewByScope);
|
||||
});
|
||||
|
||||
test("setSessionView records target session id", () => {
|
||||
assert.deepEqual(setSessionView({}, "workspace:abc", "session-123"), {
|
||||
"workspace:abc": { mode: "session", sessionId: "session-123" },
|
||||
});
|
||||
});
|
||||
|
||||
test("clearScopeDraftState removes both the draft and current panel view", () => {
|
||||
const draftsByScope = {
|
||||
"terminal:1": createEmptyDraft("agent-alpha"),
|
||||
"workspace:2": createEmptyDraft("agent-beta"),
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"terminal:1": { mode: "session", sessionId: "session-123" },
|
||||
"workspace:2": { mode: "draft" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = clearScopeDraftState(draftsByScope, panelViewByScope, "terminal:1");
|
||||
|
||||
assert.deepEqual(next.draftsByScope, {
|
||||
"workspace:2": draftsByScope["workspace:2"],
|
||||
});
|
||||
assert.deepEqual(next.panelViewByScope, {
|
||||
"workspace:2": panelViewByScope["workspace:2"],
|
||||
});
|
||||
});
|
||||
|
||||
test("clearScopeDraftState is a no-op when the scope is already cleared", () => {
|
||||
const draftsByScope = {
|
||||
"workspace:2": createEmptyDraft("agent-beta"),
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"workspace:2": { mode: "draft" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = clearScopeDraftState(draftsByScope, panelViewByScope, "terminal:closed");
|
||||
|
||||
assert.equal(next.draftsByScope, draftsByScope);
|
||||
assert.equal(next.panelViewByScope, panelViewByScope);
|
||||
});
|
||||
|
||||
test("updateDraftForScope creates a draft on first write and keeps other scopes untouched", () => {
|
||||
const draftsByScope = {
|
||||
"workspace:2": createEmptyDraft("agent-beta"),
|
||||
};
|
||||
|
||||
const next = updateDraftForScope(
|
||||
draftsByScope,
|
||||
"terminal:1",
|
||||
"agent-alpha",
|
||||
(draft) => ({
|
||||
...draft,
|
||||
text: "hello world",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(next["terminal:1"].agentId, "agent-alpha");
|
||||
assert.equal(next["terminal:1"].text, "hello world");
|
||||
assert.equal(next["workspace:2"], draftsByScope["workspace:2"]);
|
||||
});
|
||||
|
||||
test("ensureDraftForScopeState adds the missing scope without dropping siblings", () => {
|
||||
const draftsByScope = {
|
||||
"workspace:2": createEmptyDraft("agent-beta"),
|
||||
};
|
||||
|
||||
const next = ensureDraftForScopeState(
|
||||
draftsByScope,
|
||||
"terminal:1",
|
||||
"agent-alpha",
|
||||
);
|
||||
|
||||
assert.equal(next["terminal:1"].agentId, "agent-alpha");
|
||||
assert.equal(next["terminal:1"].text, "");
|
||||
assert.equal(next["workspace:2"], draftsByScope["workspace:2"]);
|
||||
});
|
||||
|
||||
test("ensureDraftForScopeState returns the original ref when the scope already exists", () => {
|
||||
const draftsByScope = {
|
||||
"terminal:1": createEmptyDraft("agent-alpha"),
|
||||
};
|
||||
|
||||
const next = ensureDraftForScopeState(
|
||||
draftsByScope,
|
||||
"terminal:1",
|
||||
"agent-beta",
|
||||
);
|
||||
|
||||
assert.equal(next, draftsByScope);
|
||||
});
|
||||
|
||||
test("selectDraftForAgentSwitch preserves hidden draft content when leaving a populated chat session", () => {
|
||||
const currentDraft = {
|
||||
...createEmptyDraft("agent-alpha"),
|
||||
text: "keep me only if I was already drafting",
|
||||
attachments: [{ id: "file-1", filename: "note.txt", dataUrl: "", base64Data: "", mediaType: "text/plain" }],
|
||||
selectedUserSkillSlugs: ["skill-a"],
|
||||
};
|
||||
|
||||
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", true);
|
||||
|
||||
assert.equal(next.agentId, "agent-beta");
|
||||
assert.equal(next.text, "keep me only if I was already drafting");
|
||||
assert.deepEqual(next.attachments, currentDraft.attachments);
|
||||
assert.deepEqual(next.selectedUserSkillSlugs, ["skill-a"]);
|
||||
});
|
||||
|
||||
test("selectDraftForAgentSwitch resets to an empty draft when leaving a populated chat session without pending draft content", () => {
|
||||
const currentDraft = createEmptyDraft("agent-alpha");
|
||||
|
||||
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", true);
|
||||
|
||||
assert.equal(next.agentId, "agent-beta");
|
||||
assert.equal(next.text, "");
|
||||
assert.deepEqual(next.attachments, []);
|
||||
assert.deepEqual(next.selectedUserSkillSlugs, []);
|
||||
});
|
||||
|
||||
test("selectDraftForAgentSwitch preserves an existing draft while only changing agent", () => {
|
||||
const currentDraft = {
|
||||
...createEmptyDraft("agent-alpha"),
|
||||
text: "unfinished prompt",
|
||||
selectedUserSkillSlugs: ["skill-a"],
|
||||
};
|
||||
|
||||
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", false);
|
||||
|
||||
assert.equal(next.agentId, "agent-beta");
|
||||
assert.equal(next.text, "unfinished prompt");
|
||||
assert.deepEqual(next.selectedUserSkillSlugs, ["skill-a"]);
|
||||
});
|
||||
|
||||
test("draft mutation version increments on every mutation for the same scope", () => {
|
||||
const scopeKey = "terminal:1";
|
||||
const initialVersion = getDraftMutationVersionState({}, scopeKey);
|
||||
const nextVersions = bumpDraftMutationVersionState({}, scopeKey);
|
||||
const finalVersions = bumpDraftMutationVersionState(nextVersions, scopeKey);
|
||||
|
||||
assert.equal(initialVersion, 0);
|
||||
assert.equal(getDraftMutationVersionState(nextVersions, scopeKey), 1);
|
||||
assert.equal(getDraftMutationVersionState(finalVersions, scopeKey), 2);
|
||||
});
|
||||
|
||||
test("draft upload generation only increments when the draft lifecycle rolls over", () => {
|
||||
const scopeKey = "terminal:1";
|
||||
const initialGeneration = getDraftUploadGenerationState({}, scopeKey);
|
||||
const nextGenerations = bumpDraftUploadGenerationState({}, scopeKey);
|
||||
const finalGenerations = bumpDraftUploadGenerationState(nextGenerations, scopeKey);
|
||||
|
||||
assert.equal(initialGeneration, 0);
|
||||
assert.equal(getDraftUploadGenerationState(nextGenerations, scopeKey), 1);
|
||||
assert.equal(getDraftUploadGenerationState(finalGenerations, scopeKey), 2);
|
||||
});
|
||||
|
||||
test("pruneTerminalScopeState removes closed terminal drafts and views only", () => {
|
||||
const draftsByScope = {
|
||||
"terminal:closed": createEmptyDraft("agent-alpha"),
|
||||
"terminal:open": createEmptyDraft("agent-beta"),
|
||||
"workspace:keep": createEmptyDraft("agent-gamma"),
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"terminal:closed": { mode: "draft" },
|
||||
"terminal:open": { mode: "session", sessionId: "session-open" },
|
||||
"workspace:keep": { mode: "session", sessionId: "session-workspace" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = pruneTerminalScopeState(
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
new Set(["open"]),
|
||||
);
|
||||
|
||||
assert.deepEqual(next.draftsByScope, {
|
||||
"terminal:open": draftsByScope["terminal:open"],
|
||||
"workspace:keep": draftsByScope["workspace:keep"],
|
||||
});
|
||||
assert.deepEqual(next.panelViewByScope, {
|
||||
"terminal:open": panelViewByScope["terminal:open"],
|
||||
"workspace:keep": panelViewByScope["workspace:keep"],
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneTerminalScopeState returns original refs when nothing is pruned", () => {
|
||||
const draftsByScope = {
|
||||
"terminal:open": createEmptyDraft("agent-alpha"),
|
||||
"workspace:keep": createEmptyDraft("agent-beta"),
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"terminal:open": { mode: "draft" },
|
||||
"workspace:keep": { mode: "session", sessionId: "session-1" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = pruneTerminalScopeState(
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
new Set(["open"]),
|
||||
);
|
||||
|
||||
assert.equal(next.draftsByScope, draftsByScope);
|
||||
assert.equal(next.panelViewByScope, panelViewByScope);
|
||||
});
|
||||
|
||||
test("pruneTerminalTransientState clears closed terminal active session, draft, and view state only", () => {
|
||||
const activeSessionIdMap = {
|
||||
"terminal:closed": "session-closed",
|
||||
"terminal:open": "session-open",
|
||||
"workspace:keep": "session-workspace",
|
||||
};
|
||||
const draftsByScope = {
|
||||
"terminal:closed": createEmptyDraft("agent-alpha"),
|
||||
"terminal:open": createEmptyDraft("agent-beta"),
|
||||
"workspace:keep": createEmptyDraft("agent-gamma"),
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"terminal:closed": { mode: "draft" },
|
||||
"terminal:open": { mode: "session", sessionId: "session-open" },
|
||||
"workspace:keep": { mode: "session", sessionId: "session-workspace" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = pruneTerminalTransientState(
|
||||
activeSessionIdMap,
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
new Set(["open"]),
|
||||
);
|
||||
|
||||
assert.deepEqual(next.activeSessionIdMap, {
|
||||
"terminal:open": "session-open",
|
||||
"workspace:keep": "session-workspace",
|
||||
});
|
||||
assert.deepEqual(next.draftsByScope, {
|
||||
"terminal:open": draftsByScope["terminal:open"],
|
||||
"workspace:keep": draftsByScope["workspace:keep"],
|
||||
});
|
||||
assert.deepEqual(next.panelViewByScope, {
|
||||
"terminal:open": panelViewByScope["terminal:open"],
|
||||
"workspace:keep": panelViewByScope["workspace:keep"],
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneTerminalTransientState returns original refs when no terminal scopes close", () => {
|
||||
const activeSessionIdMap = {
|
||||
"terminal:open": "session-open",
|
||||
"workspace:keep": "session-workspace",
|
||||
};
|
||||
const draftsByScope = {
|
||||
"terminal:open": createEmptyDraft("agent-alpha"),
|
||||
"workspace:keep": createEmptyDraft("agent-beta"),
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"terminal:open": { mode: "draft" },
|
||||
"workspace:keep": { mode: "session", sessionId: "session-workspace" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = pruneTerminalTransientState(
|
||||
activeSessionIdMap,
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
new Set(["open"]),
|
||||
);
|
||||
|
||||
assert.equal(next.activeSessionIdMap, activeSessionIdMap);
|
||||
assert.equal(next.draftsByScope, draftsByScope);
|
||||
assert.equal(next.panelViewByScope, panelViewByScope);
|
||||
});
|
||||
282
application/state/aiDraftState.ts
Normal file
282
application/state/aiDraftState.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import type {
|
||||
AIDraft,
|
||||
AIPanelView,
|
||||
} from '../../infrastructure/ai/types';
|
||||
|
||||
type DraftsByScope = Partial<Record<string, AIDraft>>;
|
||||
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
|
||||
type ActiveSessionIdMap = Record<string, string | null>;
|
||||
type DraftMutationVersionByScope = Record<string, number>;
|
||||
type DraftUploadGenerationByScope = Record<string, number>;
|
||||
|
||||
const DEFAULT_PANEL_VIEW: AIPanelView = { mode: 'draft' };
|
||||
|
||||
export function createEmptyDraft(agentId: string): AIDraft {
|
||||
return {
|
||||
text: '',
|
||||
agentId,
|
||||
attachments: [],
|
||||
selectedUserSkillSlugs: [],
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function getDraftMutationVersionState(
|
||||
versionsByScope: DraftMutationVersionByScope,
|
||||
scopeKey: string,
|
||||
): number {
|
||||
return versionsByScope[scopeKey] ?? 0;
|
||||
}
|
||||
|
||||
export function bumpDraftMutationVersionState(
|
||||
versionsByScope: DraftMutationVersionByScope,
|
||||
scopeKey: string,
|
||||
): DraftMutationVersionByScope {
|
||||
return {
|
||||
...versionsByScope,
|
||||
[scopeKey]: getDraftMutationVersionState(versionsByScope, scopeKey) + 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDraftUploadGenerationState(
|
||||
generationsByScope: DraftUploadGenerationByScope,
|
||||
scopeKey: string,
|
||||
): number {
|
||||
return generationsByScope[scopeKey] ?? 0;
|
||||
}
|
||||
|
||||
export function bumpDraftUploadGenerationState(
|
||||
generationsByScope: DraftUploadGenerationByScope,
|
||||
scopeKey: string,
|
||||
): DraftUploadGenerationByScope {
|
||||
return {
|
||||
...generationsByScope,
|
||||
[scopeKey]: getDraftUploadGenerationState(generationsByScope, scopeKey) + 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePanelView(
|
||||
panelViewByScope: PanelViewByScope,
|
||||
scopeKey: string,
|
||||
): AIPanelView {
|
||||
return panelViewByScope[scopeKey] ?? DEFAULT_PANEL_VIEW;
|
||||
}
|
||||
|
||||
export function setDraftView(
|
||||
panelViewByScope: PanelViewByScope,
|
||||
scopeKey: string,
|
||||
): PanelViewByScope {
|
||||
const currentPanelView = panelViewByScope[scopeKey];
|
||||
if (currentPanelView?.mode === 'draft') {
|
||||
return panelViewByScope;
|
||||
}
|
||||
|
||||
return {
|
||||
...panelViewByScope,
|
||||
[scopeKey]: DEFAULT_PANEL_VIEW,
|
||||
};
|
||||
}
|
||||
|
||||
export function activateDraftView(
|
||||
activeSessionIdMap: ActiveSessionIdMap,
|
||||
panelViewByScope: PanelViewByScope,
|
||||
scopeKey: string,
|
||||
): {
|
||||
activeSessionIdMap: ActiveSessionIdMap;
|
||||
panelViewByScope: PanelViewByScope;
|
||||
} {
|
||||
const nextPanelViewByScope = setDraftView(panelViewByScope, scopeKey);
|
||||
const hasActiveSession = activeSessionIdMap[scopeKey] != null;
|
||||
|
||||
if (!hasActiveSession) {
|
||||
return {
|
||||
activeSessionIdMap,
|
||||
panelViewByScope: nextPanelViewByScope,
|
||||
};
|
||||
}
|
||||
|
||||
const nextActiveSessionIdMap = { ...activeSessionIdMap };
|
||||
delete nextActiveSessionIdMap[scopeKey];
|
||||
|
||||
return {
|
||||
activeSessionIdMap: nextActiveSessionIdMap,
|
||||
panelViewByScope: nextPanelViewByScope,
|
||||
};
|
||||
}
|
||||
|
||||
export function setSessionView(
|
||||
panelViewByScope: PanelViewByScope,
|
||||
scopeKey: string,
|
||||
sessionId: string,
|
||||
): PanelViewByScope {
|
||||
return {
|
||||
...panelViewByScope,
|
||||
[scopeKey]: { mode: 'session', sessionId },
|
||||
};
|
||||
}
|
||||
|
||||
export function updateDraftForScope(
|
||||
draftsByScope: DraftsByScope,
|
||||
scopeKey: string,
|
||||
fallbackAgentId: string,
|
||||
updater: (draft: AIDraft) => AIDraft,
|
||||
): DraftsByScope {
|
||||
const currentDraft = draftsByScope[scopeKey] ?? createEmptyDraft(fallbackAgentId);
|
||||
const nextDraft = updater(currentDraft);
|
||||
|
||||
return {
|
||||
...draftsByScope,
|
||||
[scopeKey]: nextDraft,
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureDraftForScopeState(
|
||||
draftsByScope: DraftsByScope,
|
||||
scopeKey: string,
|
||||
agentId: string,
|
||||
): DraftsByScope {
|
||||
if (draftsByScope[scopeKey]) {
|
||||
return draftsByScope;
|
||||
}
|
||||
|
||||
return {
|
||||
...draftsByScope,
|
||||
[scopeKey]: createEmptyDraft(agentId),
|
||||
};
|
||||
}
|
||||
|
||||
export function selectDraftForAgentSwitch(
|
||||
currentDraft: AIDraft | null | undefined,
|
||||
agentId: string,
|
||||
startFresh: boolean,
|
||||
): AIDraft {
|
||||
const hasPendingDraftContent = Boolean(
|
||||
currentDraft
|
||||
&& (
|
||||
currentDraft.text.length > 0
|
||||
|| currentDraft.attachments.length > 0
|
||||
|| currentDraft.selectedUserSkillSlugs.length > 0
|
||||
),
|
||||
);
|
||||
|
||||
if (startFresh && !hasPendingDraftContent) {
|
||||
return createEmptyDraft(agentId);
|
||||
}
|
||||
|
||||
const baseDraft = currentDraft ?? createEmptyDraft(agentId);
|
||||
return {
|
||||
...baseDraft,
|
||||
agentId,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearScopeDraftState(
|
||||
draftsByScope: DraftsByScope,
|
||||
panelViewByScope: PanelViewByScope,
|
||||
scopeKey: string,
|
||||
): {
|
||||
draftsByScope: DraftsByScope;
|
||||
panelViewByScope: PanelViewByScope;
|
||||
} {
|
||||
const hasDraft = Object.prototype.hasOwnProperty.call(draftsByScope, scopeKey);
|
||||
const hasPanelView = Object.prototype.hasOwnProperty.call(panelViewByScope, scopeKey);
|
||||
|
||||
if (!hasDraft && !hasPanelView) {
|
||||
return {
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
draftsByScope: hasDraft
|
||||
? (() => {
|
||||
const nextDrafts = { ...draftsByScope };
|
||||
delete nextDrafts[scopeKey];
|
||||
return nextDrafts;
|
||||
})()
|
||||
: draftsByScope,
|
||||
panelViewByScope: hasPanelView
|
||||
? (() => {
|
||||
const nextPanelViews = { ...panelViewByScope };
|
||||
delete nextPanelViews[scopeKey];
|
||||
return nextPanelViews;
|
||||
})()
|
||||
: panelViewByScope,
|
||||
};
|
||||
}
|
||||
|
||||
function isClosedTerminalScope(scopeKey: string, activeTerminalTargetIds: Set<string>) {
|
||||
if (!scopeKey.startsWith('terminal:')) return false;
|
||||
|
||||
const targetId = scopeKey.slice('terminal:'.length);
|
||||
if (!targetId) return false;
|
||||
|
||||
return !activeTerminalTargetIds.has(targetId);
|
||||
}
|
||||
|
||||
export function pruneTerminalScopeState(
|
||||
draftsByScope: DraftsByScope,
|
||||
panelViewByScope: PanelViewByScope,
|
||||
activeTerminalTargetIds: Set<string>,
|
||||
): {
|
||||
draftsByScope: DraftsByScope;
|
||||
panelViewByScope: PanelViewByScope;
|
||||
} {
|
||||
const nextDraftsByScope = { ...draftsByScope };
|
||||
const nextPanelViewByScope = { ...panelViewByScope };
|
||||
let draftsChanged = false;
|
||||
let panelViewsChanged = false;
|
||||
|
||||
for (const scopeKey of Object.keys(nextDraftsByScope)) {
|
||||
if (!isClosedTerminalScope(scopeKey, activeTerminalTargetIds)) continue;
|
||||
delete nextDraftsByScope[scopeKey];
|
||||
draftsChanged = true;
|
||||
}
|
||||
|
||||
for (const scopeKey of Object.keys(nextPanelViewByScope)) {
|
||||
if (!isClosedTerminalScope(scopeKey, activeTerminalTargetIds)) continue;
|
||||
delete nextPanelViewByScope[scopeKey];
|
||||
panelViewsChanged = true;
|
||||
}
|
||||
|
||||
return {
|
||||
draftsByScope: draftsChanged ? nextDraftsByScope : draftsByScope,
|
||||
panelViewByScope: panelViewsChanged ? nextPanelViewByScope : panelViewByScope,
|
||||
};
|
||||
}
|
||||
|
||||
export function pruneTerminalTransientState(
|
||||
activeSessionIdMap: ActiveSessionIdMap,
|
||||
draftsByScope: DraftsByScope,
|
||||
panelViewByScope: PanelViewByScope,
|
||||
activeTerminalTargetIds: Set<string>,
|
||||
): {
|
||||
activeSessionIdMap: ActiveSessionIdMap;
|
||||
draftsByScope: DraftsByScope;
|
||||
panelViewByScope: PanelViewByScope;
|
||||
} {
|
||||
let activeSessionMapChanged = false;
|
||||
const nextActiveSessionIdMap: ActiveSessionIdMap = {};
|
||||
|
||||
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
|
||||
if (isClosedTerminalScope(scopeKey, activeTerminalTargetIds)) {
|
||||
activeSessionMapChanged = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
nextActiveSessionIdMap[scopeKey] = sessionId;
|
||||
}
|
||||
|
||||
const nextTerminalScopeState = pruneTerminalScopeState(
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
activeTerminalTargetIds,
|
||||
);
|
||||
|
||||
return {
|
||||
activeSessionIdMap: activeSessionMapChanged ? nextActiveSessionIdMap : activeSessionIdMap,
|
||||
draftsByScope: nextTerminalScopeState.draftsByScope,
|
||||
panelViewByScope: nextTerminalScopeState.panelViewByScope,
|
||||
};
|
||||
}
|
||||
160
application/state/aiScopeCleanup.test.ts
Normal file
160
application/state/aiScopeCleanup.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type {
|
||||
AIPanelView,
|
||||
AISession,
|
||||
} from "../../infrastructure/ai/types.ts";
|
||||
import { createEmptyDraft } from "./aiDraftState.ts";
|
||||
import {
|
||||
pruneInactiveScopedSessions,
|
||||
pruneInactiveScopedTransientState,
|
||||
} from "./aiScopeCleanup.ts";
|
||||
|
||||
function createSession(id: string, scope: AISession["scope"], externalSessionId?: string): AISession {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
agentId: "catty",
|
||||
scope,
|
||||
messages: [],
|
||||
externalSessionId,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
}
|
||||
|
||||
test("pruneInactiveScopedTransientState removes closed workspace and terminal scope state", () => {
|
||||
const activeSessionIdMap = {
|
||||
"terminal:open-terminal": "session-open",
|
||||
"terminal:closed-terminal": "session-closed-terminal",
|
||||
"workspace:open-workspace": "session-open-workspace",
|
||||
"workspace:closed-workspace": "session-closed-workspace",
|
||||
};
|
||||
const draftsByScope = {
|
||||
"terminal:open-terminal": createEmptyDraft("catty"),
|
||||
"terminal:closed-terminal": createEmptyDraft("catty"),
|
||||
"workspace:open-workspace": createEmptyDraft("catty"),
|
||||
"workspace:closed-workspace": createEmptyDraft("catty"),
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"terminal:open-terminal": { mode: "draft" },
|
||||
"terminal:closed-terminal": { mode: "session", sessionId: "session-closed-terminal" },
|
||||
"workspace:open-workspace": { mode: "draft" },
|
||||
"workspace:closed-workspace": { mode: "session", sessionId: "session-closed-workspace" },
|
||||
} satisfies Record<string, AIPanelView>;
|
||||
|
||||
const next = pruneInactiveScopedTransientState(
|
||||
activeSessionIdMap,
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
new Set(["open-terminal", "open-workspace"]),
|
||||
);
|
||||
|
||||
assert.deepEqual(next.activeSessionIdMap, {
|
||||
"terminal:open-terminal": "session-open",
|
||||
"workspace:open-workspace": "session-open-workspace",
|
||||
});
|
||||
assert.deepEqual(next.draftsByScope, {
|
||||
"terminal:open-terminal": draftsByScope["terminal:open-terminal"],
|
||||
"workspace:open-workspace": draftsByScope["workspace:open-workspace"],
|
||||
});
|
||||
assert.deepEqual(next.panelViewByScope, {
|
||||
"terminal:open-terminal": panelViewByScope["terminal:open-terminal"],
|
||||
"workspace:open-workspace": panelViewByScope["workspace:open-workspace"],
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneInactiveScopedSessions preserves restorable terminal ACP ids across reconnects", () => {
|
||||
const sessions = [
|
||||
createSession("terminal-restorable", {
|
||||
type: "terminal",
|
||||
targetId: "closed-restorable",
|
||||
hostIds: ["host-1"],
|
||||
}, "ext-1"),
|
||||
createSession("terminal-local", {
|
||||
type: "terminal",
|
||||
targetId: "closed-local",
|
||||
hostIds: ["local-shell"],
|
||||
}, "ext-2"),
|
||||
createSession("workspace-closed", {
|
||||
type: "workspace",
|
||||
targetId: "closed-workspace",
|
||||
}, "ext-3"),
|
||||
createSession("terminal-open", {
|
||||
type: "terminal",
|
||||
targetId: "open-terminal",
|
||||
hostIds: ["host-2"],
|
||||
}, "ext-4"),
|
||||
];
|
||||
|
||||
const next = pruneInactiveScopedSessions(
|
||||
sessions,
|
||||
new Set(["open-terminal"]),
|
||||
);
|
||||
|
||||
assert.deepEqual(next.orphanedSessionIds, [
|
||||
"terminal-restorable",
|
||||
"terminal-local",
|
||||
"workspace-closed",
|
||||
]);
|
||||
assert.deepEqual(next.sessions, [
|
||||
sessions[0],
|
||||
sessions[3],
|
||||
]);
|
||||
});
|
||||
|
||||
test("pruneInactiveScopedSessions preserves original sessions when orphaned restorable chats are already detached", () => {
|
||||
const sessions = [
|
||||
createSession("terminal-restorable", {
|
||||
type: "terminal",
|
||||
targetId: "closed-restorable",
|
||||
hostIds: ["host-1"],
|
||||
}),
|
||||
createSession("terminal-open", {
|
||||
type: "terminal",
|
||||
targetId: "open-terminal",
|
||||
hostIds: ["host-2"],
|
||||
}, "ext-4"),
|
||||
];
|
||||
|
||||
const next = pruneInactiveScopedSessions(
|
||||
sessions,
|
||||
new Set(["open-terminal"]),
|
||||
);
|
||||
|
||||
assert.deepEqual(next.orphanedSessionIds, ["terminal-restorable"]);
|
||||
assert.equal(next.sessions, sessions);
|
||||
});
|
||||
|
||||
test("pruneInactiveScopedSessions treats sessions displayed elsewhere as in-use, not orphaned", () => {
|
||||
// terminal-restorable's original scope (terminal-closed-A) is gone, but
|
||||
// the user resumed it into terminal-open-B from history. The session's
|
||||
// externalSessionId must be preserved and it must not appear in the
|
||||
// orphaned list, otherwise the active chat loses ACP continuity.
|
||||
const resumedElsewhere = createSession("terminal-restorable", {
|
||||
type: "terminal",
|
||||
targetId: "terminal-closed-A",
|
||||
hostIds: ["host-1"],
|
||||
}, "ext-resumed");
|
||||
|
||||
const trulyOrphaned = createSession("terminal-stale", {
|
||||
type: "terminal",
|
||||
targetId: "terminal-closed-C",
|
||||
hostIds: ["host-2"],
|
||||
}, "ext-stale");
|
||||
|
||||
const sessions = [resumedElsewhere, trulyOrphaned];
|
||||
|
||||
const next = pruneInactiveScopedSessions(
|
||||
sessions,
|
||||
new Set(["terminal-open-B"]),
|
||||
new Set(["terminal-restorable"]),
|
||||
);
|
||||
|
||||
// Only the one not being displayed anywhere should show up as orphaned.
|
||||
assert.deepEqual(next.orphanedSessionIds, ["terminal-stale"]);
|
||||
// The resumed session must retain its externalSessionId.
|
||||
const resumedNext = next.sessions.find((s) => s.id === "terminal-restorable");
|
||||
assert.equal(resumedNext?.externalSessionId, "ext-resumed");
|
||||
});
|
||||
145
application/state/aiScopeCleanup.ts
Normal file
145
application/state/aiScopeCleanup.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type {
|
||||
AIDraft,
|
||||
AIPanelView,
|
||||
AISession,
|
||||
} from "../../infrastructure/ai/types";
|
||||
|
||||
type DraftsByScope = Partial<Record<string, AIDraft>>;
|
||||
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
|
||||
type ActiveSessionIdMap = Record<string, string | null>;
|
||||
|
||||
function isInactiveScopedTarget(
|
||||
scopeKey: string,
|
||||
activeTargetIds: Set<string>,
|
||||
): boolean {
|
||||
const separatorIndex = scopeKey.indexOf(":");
|
||||
if (separatorIndex === -1) return false;
|
||||
|
||||
const scopeType = scopeKey.slice(0, separatorIndex);
|
||||
if (scopeType !== "terminal" && scopeType !== "workspace") return false;
|
||||
|
||||
const targetId = scopeKey.slice(separatorIndex + 1);
|
||||
if (!targetId) return false;
|
||||
|
||||
return !activeTargetIds.has(targetId);
|
||||
}
|
||||
|
||||
export function pruneInactiveScopedState(
|
||||
draftsByScope: DraftsByScope,
|
||||
panelViewByScope: PanelViewByScope,
|
||||
activeTargetIds: Set<string>,
|
||||
): {
|
||||
draftsByScope: DraftsByScope;
|
||||
panelViewByScope: PanelViewByScope;
|
||||
} {
|
||||
const nextDraftsByScope = { ...draftsByScope };
|
||||
const nextPanelViewByScope = { ...panelViewByScope };
|
||||
let draftsChanged = false;
|
||||
let panelViewsChanged = false;
|
||||
|
||||
for (const scopeKey of Object.keys(nextDraftsByScope)) {
|
||||
if (!isInactiveScopedTarget(scopeKey, activeTargetIds)) continue;
|
||||
delete nextDraftsByScope[scopeKey];
|
||||
draftsChanged = true;
|
||||
}
|
||||
|
||||
for (const scopeKey of Object.keys(nextPanelViewByScope)) {
|
||||
if (!isInactiveScopedTarget(scopeKey, activeTargetIds)) continue;
|
||||
delete nextPanelViewByScope[scopeKey];
|
||||
panelViewsChanged = true;
|
||||
}
|
||||
|
||||
return {
|
||||
draftsByScope: draftsChanged ? nextDraftsByScope : draftsByScope,
|
||||
panelViewByScope: panelViewsChanged ? nextPanelViewByScope : panelViewByScope,
|
||||
};
|
||||
}
|
||||
|
||||
export function pruneInactiveScopedTransientState(
|
||||
activeSessionIdMap: ActiveSessionIdMap,
|
||||
draftsByScope: DraftsByScope,
|
||||
panelViewByScope: PanelViewByScope,
|
||||
activeTargetIds: Set<string>,
|
||||
): {
|
||||
activeSessionIdMap: ActiveSessionIdMap;
|
||||
draftsByScope: DraftsByScope;
|
||||
panelViewByScope: PanelViewByScope;
|
||||
} {
|
||||
let activeSessionMapChanged = false;
|
||||
const nextActiveSessionIdMap: ActiveSessionIdMap = {};
|
||||
|
||||
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
|
||||
if (isInactiveScopedTarget(scopeKey, activeTargetIds)) {
|
||||
activeSessionMapChanged = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
nextActiveSessionIdMap[scopeKey] = sessionId;
|
||||
}
|
||||
|
||||
const nextScopedState = pruneInactiveScopedState(
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
activeTargetIds,
|
||||
);
|
||||
|
||||
return {
|
||||
activeSessionIdMap: activeSessionMapChanged ? nextActiveSessionIdMap : activeSessionIdMap,
|
||||
draftsByScope: nextScopedState.draftsByScope,
|
||||
panelViewByScope: nextScopedState.panelViewByScope,
|
||||
};
|
||||
}
|
||||
|
||||
function isRestorableTerminalSession(session: AISession): boolean {
|
||||
return session.scope.type === "terminal"
|
||||
&& !!session.scope.hostIds?.length
|
||||
&& session.scope.hostIds.some((id) => !id.startsWith("local-") && !id.startsWith("serial-"));
|
||||
}
|
||||
|
||||
export function pruneInactiveScopedSessions(
|
||||
sessions: AISession[],
|
||||
activeTargetIds: Set<string>,
|
||||
/**
|
||||
* Session ids currently displayed by any live scope. A session whose
|
||||
* `scope.targetId` is inactive but whose id is still in use somewhere
|
||||
* (e.g. resumed from history into a different terminal) must not be
|
||||
* treated as orphaned — deleting it outright would break the chat the
|
||||
* user is actively continuing.
|
||||
*/
|
||||
activeSessionIds: Set<string> = new Set(),
|
||||
): {
|
||||
sessions: AISession[];
|
||||
orphanedSessionIds: string[];
|
||||
} {
|
||||
const orphanedSessionIds = sessions
|
||||
.filter((session) => session.scope.targetId && !activeTargetIds.has(session.scope.targetId))
|
||||
.filter((session) => !activeSessionIds.has(session.id))
|
||||
.map((session) => session.id);
|
||||
|
||||
if (orphanedSessionIds.length === 0) {
|
||||
return {
|
||||
sessions,
|
||||
orphanedSessionIds,
|
||||
};
|
||||
}
|
||||
|
||||
const orphanedSessionIdSet = new Set(orphanedSessionIds);
|
||||
let sessionsChanged = false;
|
||||
|
||||
const nextSessions = sessions.flatMap((session) => {
|
||||
if (!orphanedSessionIdSet.has(session.id)) {
|
||||
return [session];
|
||||
}
|
||||
|
||||
if (!isRestorableTerminalSession(session)) {
|
||||
sessionsChanged = true;
|
||||
return [];
|
||||
}
|
||||
return [session];
|
||||
});
|
||||
|
||||
return {
|
||||
sessions: sessionsChanged ? nextSessions : sessions,
|
||||
orphanedSessionIds,
|
||||
};
|
||||
}
|
||||
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);
|
||||
};
|
||||
110
application/state/resolveCloseIntent.test.ts
Normal file
110
application/state/resolveCloseIntent.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveCloseIntent } from "./resolveCloseIntent.ts";
|
||||
|
||||
const baseWorkspace = {
|
||||
id: "w1",
|
||||
focusedSessionId: "s1",
|
||||
};
|
||||
|
||||
const baseSession = { id: "s1" };
|
||||
|
||||
test("non-workspace tab → closeSingleTab with session id", () => {
|
||||
const result = resolveCloseIntent({
|
||||
activeTabId: "s1",
|
||||
workspace: null,
|
||||
sessionForTab: baseSession,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: true,
|
||||
});
|
||||
assert.deepEqual(result, { kind: "closeSingleTab", sessionId: "s1" });
|
||||
});
|
||||
|
||||
test("non-workspace session tab + sidebar open → closeSidePanel (sidebar beats session close)", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "s1",
|
||||
workspace: null,
|
||||
sessionForTab: { id: "s1" },
|
||||
activeSidePanelTab: "ai",
|
||||
focusIsInsideTerminal: true, // focus IS in terminal, but sidebar wins
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
});
|
||||
|
||||
test("vault/sftp tab → noop", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "vault",
|
||||
workspace: null,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "noop" });
|
||||
});
|
||||
|
||||
test("workspace + focus in terminal + sidebar open → closeSidePanel wins (sidebar beats focus)", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: "ai",
|
||||
focusIsInsideTerminal: true,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
});
|
||||
|
||||
test("workspace + focus NOT in terminal + sidebar open → closeSidePanel", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: "sftp",
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
});
|
||||
|
||||
test("workspace + sidebar closed + focus in terminal → closeTerminal", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: true,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeTerminal", sessionId: "s1" });
|
||||
});
|
||||
|
||||
test("workspace + sidebar closed + focus NOT in terminal → closeWorkspace", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
|
||||
});
|
||||
|
||||
test("workspace with no focused session + sidebar closed → closeWorkspace", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: { id: "w1", focusedSessionId: undefined },
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: true, // even if flag true, no focused id → cannot closeTerminal
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
|
||||
});
|
||||
|
||||
test("workspace with no focused session + sidebar open → closeSidePanel", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: { id: "w1", focusedSessionId: undefined },
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: "ai",
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
});
|
||||
43
application/state/resolveCloseIntent.ts
Normal file
43
application/state/resolveCloseIntent.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export type CloseIntent =
|
||||
| { kind: 'closeTerminal'; sessionId: string }
|
||||
| { kind: 'closeSidePanel' }
|
||||
| { kind: 'closeWorkspace'; workspaceId: string }
|
||||
| { kind: 'closeSingleTab'; sessionId: string }
|
||||
| { kind: 'noop' };
|
||||
|
||||
export interface ResolveCloseInput {
|
||||
activeTabId: string | null;
|
||||
workspace: { id: string; focusedSessionId?: string } | null;
|
||||
sessionForTab: { id: string } | null;
|
||||
activeSidePanelTab: string | null;
|
||||
focusIsInsideTerminal: boolean;
|
||||
}
|
||||
|
||||
export function resolveCloseIntent(input: ResolveCloseInput): CloseIntent {
|
||||
const { activeTabId, workspace, sessionForTab, activeSidePanelTab, focusIsInsideTerminal } = input;
|
||||
|
||||
if (!activeTabId) return { kind: 'noop' };
|
||||
|
||||
// Sidebar always wins — applies to any tab type (workspace, single-session, etc.).
|
||||
// Modals take priority over this but are intercepted upstream in App.tsx before the
|
||||
// hotkey reaches resolveCloseIntent.
|
||||
if (activeSidePanelTab !== null) {
|
||||
return { kind: 'closeSidePanel' };
|
||||
}
|
||||
|
||||
if (sessionForTab && !workspace) {
|
||||
return { kind: 'closeSingleTab', sessionId: sessionForTab.id };
|
||||
}
|
||||
|
||||
if (!workspace) {
|
||||
// e.g. 'vault', 'sftp', or any non-closable pinned tab
|
||||
return { kind: 'noop' };
|
||||
}
|
||||
|
||||
const focusedSessionId = workspace.focusedSessionId;
|
||||
if (focusedSessionId && focusIsInsideTerminal) {
|
||||
return { kind: 'closeTerminal', sessionId: focusedSessionId };
|
||||
}
|
||||
|
||||
return { kind: 'closeWorkspace', workspaceId: workspace.id };
|
||||
}
|
||||
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' };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import React, { useCallback, useEffect, useRef } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import type { Host, Identity, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
|
||||
|
||||
@@ -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";
|
||||
@@ -20,6 +20,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 +36,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,
|
||||
@@ -55,6 +63,8 @@ interface SftpExternalOperationsResult {
|
||||
) => Promise<UploadResult[]>;
|
||||
cancelExternalUpload: () => Promise<void>;
|
||||
selectApplication: () => Promise<{ path: string; name: string } | null>;
|
||||
uploadConflicts: FileConflict[];
|
||||
resolveUploadConflict: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => void;
|
||||
}
|
||||
|
||||
export const useSftpExternalOperations = (
|
||||
@@ -62,6 +72,7 @@ export const useSftpExternalOperations = (
|
||||
): SftpExternalOperationsResult => {
|
||||
const {
|
||||
getActivePane,
|
||||
getPaneByConnectionId,
|
||||
refresh,
|
||||
sftpSessionsRef,
|
||||
connectionCacheKeyMapRef,
|
||||
@@ -79,6 +90,11 @@ export const useSftpExternalOperations = (
|
||||
// Track active file watches so the side panel can block host-switching.
|
||||
// Reset to 0 when the SFTP session disconnects (handled in SftpSidePanel).
|
||||
const activeFileWatchCountRef = useRef(0);
|
||||
const [uploadConflicts, setUploadConflicts] = useState<FileConflict[]>([]);
|
||||
const uploadConflictResolversRef = useRef(new Map<string, {
|
||||
resolve: (action: FileConflictAction) => void;
|
||||
setDefault: (action: FileConflictAction) => void;
|
||||
}>());
|
||||
|
||||
const readTextFile = useCallback(
|
||||
async (side: "left" | "right", filePath: string): Promise<string> => {
|
||||
@@ -173,6 +189,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 +503,99 @@ export const useSftpExternalOperations = (
|
||||
};
|
||||
}, [addExternalUpload, updateExternalUpload, dismissExternalUpload]);
|
||||
|
||||
const resolveUploadConflict = useCallback((conflictId: string, action: FileConflictAction, applyToAll = false) => {
|
||||
const conflict = uploadConflicts.find((item) => item.transferId === conflictId);
|
||||
setUploadConflicts((prev) => prev.filter((item) => item.transferId !== conflictId));
|
||||
const resolver = uploadConflictResolversRef.current.get(conflictId);
|
||||
if (!resolver) return;
|
||||
uploadConflictResolversRef.current.delete(conflictId);
|
||||
if (conflict && applyToAll) {
|
||||
resolver.setDefault(action);
|
||||
}
|
||||
resolver.resolve(action);
|
||||
}, [uploadConflicts]);
|
||||
|
||||
const cancelPendingUploadConflicts = useCallback(() => {
|
||||
const resolvers = Array.from(uploadConflictResolversRef.current.values());
|
||||
if (resolvers.length === 0) return;
|
||||
|
||||
uploadConflictResolversRef.current.clear();
|
||||
setUploadConflicts([]);
|
||||
for (const resolver of resolvers) {
|
||||
resolver.resolve("stop");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createUploadConflictResolver = useCallback(() => {
|
||||
const conflictDefaults = new Map<string, FileConflictAction>();
|
||||
|
||||
return async (conflict: {
|
||||
fileName: string;
|
||||
targetPath: string;
|
||||
isDirectory: boolean;
|
||||
existingType?: 'file' | 'directory' | 'symlink';
|
||||
existingSize: number;
|
||||
newSize: number;
|
||||
existingModified: number;
|
||||
newModified: number;
|
||||
applyToAllCount: number;
|
||||
}): Promise<FileConflictAction> => {
|
||||
const conflictType = conflict.isDirectory ? "directory" : "file";
|
||||
const defaultAction = conflictDefaults.get(conflictType);
|
||||
if (defaultAction) return defaultAction;
|
||||
|
||||
const conflictId = `upload-conflict-${crypto.randomUUID()}`;
|
||||
const fileConflict: FileConflict = {
|
||||
transferId: conflictId,
|
||||
fileName: conflict.fileName,
|
||||
sourcePath: "local",
|
||||
targetPath: conflict.targetPath,
|
||||
isDirectory: conflict.isDirectory,
|
||||
existingType: conflict.existingType,
|
||||
applyToAllCount: conflict.applyToAllCount,
|
||||
existingSize: conflict.existingSize,
|
||||
newSize: conflict.newSize,
|
||||
existingModified: conflict.existingModified,
|
||||
newModified: conflict.newModified,
|
||||
};
|
||||
|
||||
setUploadConflicts((prev) => [...prev, fileConflict]);
|
||||
return new Promise<FileConflictAction>((resolve) => {
|
||||
uploadConflictResolversRef.current.set(conflictId, {
|
||||
resolve,
|
||||
setDefault: (action) => {
|
||||
conflictDefaults.set(conflictType, action);
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Create upload bridge that wraps netcattyBridge
|
||||
const createUploadBridge = useMemo((): UploadBridge => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return {
|
||||
writeLocalFile: bridge?.writeLocalFile,
|
||||
mkdirLocal: bridge?.mkdirLocal,
|
||||
statLocal: bridge?.statLocal,
|
||||
deleteLocalFile: bridge?.deleteLocalFile,
|
||||
mkdirSftp: async (sftpId: string, path: string) => {
|
||||
const b = netcattyBridge.get();
|
||||
if (b?.mkdirSftp) {
|
||||
await b.mkdirSftp(sftpId, path);
|
||||
}
|
||||
},
|
||||
statSftp: async (sftpId: string, path: string) => {
|
||||
const b = netcattyBridge.get();
|
||||
if (!b?.statSftp) return null;
|
||||
return b.statSftp(sftpId, path);
|
||||
},
|
||||
deleteSftp: async (sftpId: string, path: string) => {
|
||||
const b = netcattyBridge.get();
|
||||
if (b?.deleteSftp) {
|
||||
await b.deleteSftp(sftpId, path);
|
||||
}
|
||||
},
|
||||
writeSftpBinary: bridge?.writeSftpBinary,
|
||||
// Wrap writeSftpBinaryWithProgress to adapt UploadBridge interface to NetcattyBridge interface
|
||||
// UploadBridge: (sftpId, path, data, taskId, onProgress, onComplete, onError)
|
||||
@@ -552,6 +684,7 @@ export const useSftpExternalOperations = (
|
||||
joinPath,
|
||||
callbacks,
|
||||
useCompressedUpload,
|
||||
resolveConflict: createUploadConflictResolver(),
|
||||
},
|
||||
controller
|
||||
);
|
||||
@@ -580,6 +713,7 @@ export const useSftpExternalOperations = (
|
||||
sftpSessionsRef,
|
||||
createUploadCallbacks,
|
||||
createUploadBridge,
|
||||
createUploadConflictResolver,
|
||||
useCompressedUpload,
|
||||
],
|
||||
);
|
||||
@@ -636,6 +770,7 @@ export const useSftpExternalOperations = (
|
||||
joinPath,
|
||||
callbacks,
|
||||
useCompressedUpload,
|
||||
resolveConflict: createUploadConflictResolver(),
|
||||
},
|
||||
controller,
|
||||
);
|
||||
@@ -663,6 +798,7 @@ export const useSftpExternalOperations = (
|
||||
connectionCacheKeyMapRef,
|
||||
createUploadCallbacks,
|
||||
createUploadBridge,
|
||||
createUploadConflictResolver,
|
||||
getActivePane,
|
||||
refresh,
|
||||
sftpSessionsRef,
|
||||
@@ -672,11 +808,14 @@ export const useSftpExternalOperations = (
|
||||
|
||||
const cancelExternalUpload = useCallback(async () => {
|
||||
const controller = uploadControllerRef.current;
|
||||
let cancelPromise: Promise<void> | undefined;
|
||||
if (controller) {
|
||||
logger.info("[SFTP] Cancelling external upload");
|
||||
await controller.cancel();
|
||||
cancelPromise = controller.cancel();
|
||||
}
|
||||
}, []);
|
||||
cancelPendingUploadConflicts();
|
||||
await cancelPromise;
|
||||
}, [cancelPendingUploadConflicts]);
|
||||
|
||||
const selectApplication = useCallback(
|
||||
async (): Promise<{ path: string; name: string } | null> => {
|
||||
@@ -693,11 +832,14 @@ export const useSftpExternalOperations = (
|
||||
readTextFile,
|
||||
readBinaryFile,
|
||||
writeTextFile,
|
||||
writeTextFileByConnection,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
uploadExternalEntries,
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
activeFileWatchCountRef,
|
||||
uploadConflicts,
|
||||
resolveUploadConflict,
|
||||
};
|
||||
};
|
||||
|
||||
53
application/state/sftp/useSftpHostCredentials.test.ts
Normal file
53
application/state/sftp/useSftpHostCredentials.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { buildSftpHostCredentials } from "./useSftpHostCredentials.ts";
|
||||
import type { Host } from "../../../domain/models.ts";
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials rejects missing jump hosts", () => {
|
||||
assert.throws(
|
||||
() => buildSftpHostCredentials({
|
||||
host: host({ hostChain: { hostIds: ["missing-jump"] } }),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
}),
|
||||
/Jump host "missing-jump" is missing/,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials rejects missing saved proxy profiles", () => {
|
||||
assert.throws(
|
||||
() => buildSftpHostCredentials({
|
||||
host: host({ proxyProfileId: "missing-proxy" }),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
}),
|
||||
/Saved proxy for host "Host" is missing/,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials rejects missing saved proxy profiles on jump hosts", () => {
|
||||
const jumpHost = host({ id: "jump-1", label: "Jump", proxyProfileId: "missing-proxy" });
|
||||
|
||||
assert.throws(
|
||||
() => buildSftpHostCredentials({
|
||||
host: host({ hostChain: { hostIds: ["jump-1"] } }),
|
||||
hosts: [jumpHost],
|
||||
keys: [],
|
||||
identities: [],
|
||||
}),
|
||||
/Saved proxy for jump host "Jump" is missing/,
|
||||
);
|
||||
});
|
||||
@@ -9,94 +9,111 @@ interface UseSftpHostCredentialsParams {
|
||||
identities: Identity[];
|
||||
}
|
||||
|
||||
export const buildSftpHostCredentials = ({
|
||||
host,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
}: UseSftpHostCredentialsParams & { host: Host }): NetcattySSHOptions => {
|
||||
if (host.proxyProfileId && !host.proxyConfig) {
|
||||
throw new Error(`Saved proxy for host "${host.label || host.hostname}" is missing. Open host settings and select a valid proxy.`);
|
||||
}
|
||||
|
||||
const resolved = resolveHostAuth({ host, keys, identities });
|
||||
const key = resolved.key || null;
|
||||
|
||||
const proxyConfig = host.proxyConfig
|
||||
? {
|
||||
type: host.proxyConfig.type,
|
||||
host: host.proxyConfig.host,
|
||||
port: host.proxyConfig.port,
|
||||
username: host.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(host.proxyConfig.password),
|
||||
}
|
||||
: undefined;
|
||||
let jumpHosts: NetcattyJumpHost[] | undefined;
|
||||
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
|
||||
jumpHosts = host.hostChain.hostIds.map((hostId) => {
|
||||
const jumpHost = hosts.find((candidate) => candidate.id === hostId);
|
||||
if (!jumpHost) {
|
||||
throw new Error(`Jump host "${hostId}" is missing. Open host settings and repair the jump host chain.`);
|
||||
}
|
||||
if (jumpHost.proxyProfileId && !jumpHost.proxyConfig) {
|
||||
throw new Error(`Saved proxy for jump host "${jumpHost.label || jumpHost.hostname}" is missing. Open host settings and select a valid proxy.`);
|
||||
}
|
||||
return jumpHost;
|
||||
}).map((jumpHost, index) => {
|
||||
const jumpAuth = resolveHostAuth({
|
||||
host: jumpHost,
|
||||
keys,
|
||||
identities,
|
||||
});
|
||||
const jumpKey = jumpAuth.key;
|
||||
const hasConfiguredJumpProxyEndpoint =
|
||||
index === 0 &&
|
||||
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
|
||||
if (
|
||||
hasConfiguredJumpProxyEndpoint &&
|
||||
jumpHost.proxyConfig?.username &&
|
||||
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig.password) &&
|
||||
!sanitizeCredentialValue(jumpHost.proxyConfig.password)
|
||||
) {
|
||||
throw new Error(`Proxy credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter the proxy password.`);
|
||||
}
|
||||
return {
|
||||
hostname: jumpHost.hostname,
|
||||
port: jumpHost.port || 22,
|
||||
username: jumpAuth.username || "root",
|
||||
password: jumpAuth.password,
|
||||
privateKey: jumpKey?.privateKey,
|
||||
certificate: jumpKey?.certificate,
|
||||
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
|
||||
publicKey: jumpKey?.publicKey,
|
||||
keyId: jumpAuth.keyId,
|
||||
keySource: jumpKey?.source,
|
||||
label: jumpHost.label,
|
||||
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
|
||||
? {
|
||||
type: jumpHost.proxyConfig.type,
|
||||
host: jumpHost.proxyConfig.host,
|
||||
port: jumpHost.proxyConfig.port,
|
||||
username: jumpHost.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
|
||||
}
|
||||
: undefined,
|
||||
identityFilePaths: jumpHost.identityFilePaths,
|
||||
};
|
||||
});
|
||||
}
|
||||
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts?.[0]?.proxy;
|
||||
if (usesTargetProxyForFirstHop && host.proxyConfig?.username && isEncryptedCredentialPlaceholder(host.proxyConfig.password) && !proxyConfig?.password) {
|
||||
throw new Error("Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.");
|
||||
}
|
||||
|
||||
return {
|
||||
hostname: host.hostname,
|
||||
username: resolved.username,
|
||||
port: host.port || 22,
|
||||
password: resolved.password,
|
||||
privateKey: key?.privateKey,
|
||||
certificate: key?.certificate,
|
||||
passphrase: resolved.passphrase || key?.passphrase,
|
||||
publicKey: key?.publicKey,
|
||||
keyId: resolved.keyId,
|
||||
keySource: key?.source,
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
sudo: host.sftpSudo,
|
||||
identityFilePaths: host.identityFilePaths,
|
||||
};
|
||||
};
|
||||
|
||||
export const useSftpHostCredentials = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
}: UseSftpHostCredentialsParams) =>
|
||||
useCallback(
|
||||
(host: Host): NetcattySSHOptions => {
|
||||
const resolved = resolveHostAuth({ host, keys, identities });
|
||||
const key = resolved.key || null;
|
||||
|
||||
const proxyConfig = host.proxyConfig
|
||||
? {
|
||||
type: host.proxyConfig.type,
|
||||
host: host.proxyConfig.host,
|
||||
port: host.proxyConfig.port,
|
||||
username: host.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(host.proxyConfig.password),
|
||||
}
|
||||
: undefined;
|
||||
let jumpHosts: NetcattyJumpHost[] | undefined;
|
||||
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
|
||||
jumpHosts = host.hostChain.hostIds
|
||||
.map((hostId) => hosts.find((h) => h.id === hostId))
|
||||
.filter((h): h is Host => !!h)
|
||||
.map((jumpHost, index) => {
|
||||
const jumpAuth = resolveHostAuth({
|
||||
host: jumpHost,
|
||||
keys,
|
||||
identities,
|
||||
});
|
||||
const jumpKey = jumpAuth.key;
|
||||
const hasConfiguredJumpProxyEndpoint =
|
||||
index === 0 &&
|
||||
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
|
||||
if (
|
||||
hasConfiguredJumpProxyEndpoint &&
|
||||
jumpHost.proxyConfig?.username &&
|
||||
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig.password) &&
|
||||
!sanitizeCredentialValue(jumpHost.proxyConfig.password)
|
||||
) {
|
||||
throw new Error(`Proxy credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter the proxy password.`);
|
||||
}
|
||||
return {
|
||||
hostname: jumpHost.hostname,
|
||||
port: jumpHost.port || 22,
|
||||
username: jumpAuth.username || "root",
|
||||
password: jumpAuth.password,
|
||||
privateKey: jumpKey?.privateKey,
|
||||
certificate: jumpKey?.certificate,
|
||||
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
|
||||
publicKey: jumpKey?.publicKey,
|
||||
keyId: jumpAuth.keyId,
|
||||
keySource: jumpKey?.source,
|
||||
label: jumpHost.label,
|
||||
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
|
||||
? {
|
||||
type: jumpHost.proxyConfig.type,
|
||||
host: jumpHost.proxyConfig.host,
|
||||
port: jumpHost.proxyConfig.port,
|
||||
username: jumpHost.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
|
||||
}
|
||||
: undefined,
|
||||
identityFilePaths: jumpHost.identityFilePaths,
|
||||
};
|
||||
});
|
||||
}
|
||||
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts?.[0]?.proxy;
|
||||
if (usesTargetProxyForFirstHop && host.proxyConfig?.username && isEncryptedCredentialPlaceholder(host.proxyConfig.password) && !proxyConfig?.password) {
|
||||
throw new Error("Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.");
|
||||
}
|
||||
|
||||
return {
|
||||
hostname: host.hostname,
|
||||
username: resolved.username,
|
||||
port: host.port || 22,
|
||||
password: resolved.password,
|
||||
privateKey: key?.privateKey,
|
||||
certificate: key?.certificate,
|
||||
passphrase: resolved.passphrase || key?.passphrase,
|
||||
publicKey: key?.publicKey,
|
||||
keyId: resolved.keyId,
|
||||
keySource: key?.source,
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
sudo: host.sftpSudo,
|
||||
identityFilePaths: host.identityFilePaths,
|
||||
};
|
||||
},
|
||||
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities }),
|
||||
[hosts, identities, keys],
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import React, { useCallback, useRef } from "react";
|
||||
import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
44
application/state/uploadService.test.ts
Normal file
44
application/state/uploadService.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { uploadFromDataTransfer } from "../../lib/uploadService.ts";
|
||||
|
||||
function createDataTransfer(files: File[]): DataTransfer {
|
||||
return {
|
||||
items: { length: 0 },
|
||||
files,
|
||||
} as unknown as DataTransfer;
|
||||
}
|
||||
|
||||
test("clears the scanning placeholder when every dropped file is skipped by conflict resolution", async () => {
|
||||
const events: string[] = [];
|
||||
const file = new File(["local"], "conflict.txt", { lastModified: 1234 });
|
||||
|
||||
const results = await uploadFromDataTransfer(
|
||||
createDataTransfer([file]),
|
||||
{
|
||||
targetPath: "/target",
|
||||
sftpId: null,
|
||||
isLocal: true,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
statLocal: async () => ({ type: "file", size: 10, lastModified: 1000 }),
|
||||
writeLocalFile: async () => {
|
||||
throw new Error("skipped conflicts should not upload");
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
callbacks: {
|
||||
onScanningStart: () => events.push("scan:start"),
|
||||
onScanningEnd: () => events.push("scan:end"),
|
||||
onTaskCreated: () => events.push("task:create"),
|
||||
},
|
||||
resolveConflict: async () => "skip",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(results, [
|
||||
{ fileName: "conflict.txt", success: false, cancelled: true },
|
||||
]);
|
||||
assert.deepEqual(events, ["scan:start", "scan:end"]);
|
||||
});
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type {
|
||||
AIDraft,
|
||||
AIPanelView,
|
||||
AISession,
|
||||
AIPermissionMode,
|
||||
AIToolIntegrationMode,
|
||||
@@ -29,6 +31,21 @@ import type {
|
||||
WebSearchConfig,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
|
||||
import {
|
||||
activateDraftView,
|
||||
bumpDraftMutationVersionState,
|
||||
bumpDraftUploadGenerationState,
|
||||
clearScopeDraftState,
|
||||
ensureDraftForScopeState,
|
||||
getDraftUploadGenerationState,
|
||||
setSessionView,
|
||||
updateDraftForScope,
|
||||
} from './aiDraftState';
|
||||
import {
|
||||
pruneInactiveScopedSessions,
|
||||
pruneInactiveScopedTransientState,
|
||||
} from './aiScopeCleanup';
|
||||
import { convertFilesToUploads } from './useFileUpload';
|
||||
|
||||
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
|
||||
interface AIBridge {
|
||||
@@ -45,6 +62,11 @@ function getAIBridge() {
|
||||
}
|
||||
|
||||
const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
|
||||
const AI_STATE_CHANGED_DRAFTS_BY_SCOPE = 'netcatty:ai-drafts-by-scope';
|
||||
const AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE = 'netcatty:ai-panel-view-by-scope';
|
||||
|
||||
type DraftsByScope = Partial<Record<string, AIDraft>>;
|
||||
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
|
||||
|
||||
function emitAIStateChanged(key: string) {
|
||||
window.dispatchEvent(new CustomEvent<{ key: string }>(AI_STATE_CHANGED_EVENT, { detail: { key } }));
|
||||
@@ -72,53 +94,41 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
const currentSessions = latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
const orphanedSessionIds = currentSessions
|
||||
.filter((session) => session.scope.targetId && !activeTargetIds.has(session.scope.targetId))
|
||||
.map((session) => session.id);
|
||||
|
||||
if (orphanedSessionIds.length > 0) {
|
||||
const orphanedSessionIdSet = new Set(orphanedSessionIds);
|
||||
|
||||
// Determine which sessions can be restored via host-based matching
|
||||
const preservedIds = new Set<string>();
|
||||
for (const session of currentSessions) {
|
||||
if (!orphanedSessionIdSet.has(session.id)) continue;
|
||||
// Only preserve remote terminal sessions with real hostIds
|
||||
const isRestorable = session.scope.type === 'terminal'
|
||||
&& session.scope.hostIds?.length
|
||||
&& session.scope.hostIds.some((id) => !id.startsWith('local-') && !id.startsWith('serial-'));
|
||||
if (isRestorable) {
|
||||
preservedIds.add(session.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup ACP sessions for all orphans (both deleted and preserved).
|
||||
// Preserved sessions will get a new externalSessionId on next use,
|
||||
// so cleaning the old one is safe and prevents subprocess leaks.
|
||||
cleanupAcpSessions(orphanedSessionIds);
|
||||
|
||||
const nextSessions = currentSessions
|
||||
.filter((session) => !orphanedSessionIdSet.has(session.id) || preservedIds.has(session.id))
|
||||
.map((session) => {
|
||||
if (!preservedIds.has(session.id) || !session.externalSessionId) {
|
||||
return session;
|
||||
}
|
||||
// Drop transient ACP session handles so the next turn starts cleanly.
|
||||
return { ...session, externalSessionId: undefined };
|
||||
});
|
||||
|
||||
const sessionsChanged = nextSessions.length !== currentSessions.length
|
||||
|| nextSessions.some((session, index) => session !== currentSessions[index]);
|
||||
if (sessionsChanged) {
|
||||
setLatestAISessionsSnapshot(nextSessions);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(nextSessions));
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
}
|
||||
}
|
||||
|
||||
const activeSessionIdMap = latestAIActiveSessionMapSnapshot
|
||||
// Sessions shown by a still-live scope must be protected from cleanup
|
||||
// even when their own `scope.targetId` points at a closed terminal —
|
||||
// history can be resumed into a different terminal and we must not
|
||||
// delete it outright while it's actively being used.
|
||||
const preCleanupActiveSessionMap = latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {};
|
||||
const activeSessionIds = new Set<string>();
|
||||
for (const [scopeKey, sessionId] of Object.entries(preCleanupActiveSessionMap)) {
|
||||
if (!sessionId) continue;
|
||||
if (!isScopeKeyActive(scopeKey, activeTargetIds)) continue;
|
||||
activeSessionIds.add(sessionId);
|
||||
}
|
||||
|
||||
const nextSessionCleanup = pruneInactiveScopedSessions(
|
||||
currentSessions,
|
||||
activeTargetIds,
|
||||
activeSessionIds,
|
||||
);
|
||||
|
||||
if (nextSessionCleanup.orphanedSessionIds.length > 0) {
|
||||
cleanupAcpSessions(nextSessionCleanup.orphanedSessionIds);
|
||||
}
|
||||
|
||||
if (nextSessionCleanup.sessions !== currentSessions) {
|
||||
setLatestAISessionsSnapshot(nextSessionCleanup.sessions);
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
pruneSessionsForStorage(nextSessionCleanup.sessions),
|
||||
);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
}
|
||||
|
||||
const activeSessionIdMap = preCleanupActiveSessionMap;
|
||||
let activeSessionMapChanged = false;
|
||||
const nextActiveSessionIdMap = { ...activeSessionIdMap };
|
||||
|
||||
@@ -133,6 +143,46 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
|
||||
const currentActiveSessionIdMap = activeSessionMapChanged
|
||||
? nextActiveSessionIdMap
|
||||
: activeSessionIdMap;
|
||||
const currentDraftsByScope = latestAIDraftsByScopeSnapshot ?? {};
|
||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
|
||||
const prunedScopedTransientState = pruneInactiveScopedTransientState(
|
||||
currentActiveSessionIdMap,
|
||||
currentDraftsByScope,
|
||||
currentPanelViewByScope,
|
||||
activeTargetIds,
|
||||
);
|
||||
|
||||
if (prunedScopedTransientState.activeSessionIdMap !== currentActiveSessionIdMap) {
|
||||
setLatestAIActiveSessionMapSnapshot(prunedScopedTransientState.activeSessionIdMap);
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
prunedScopedTransientState.activeSessionIdMap,
|
||||
);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
|
||||
if (prunedScopedTransientState.draftsByScope !== currentDraftsByScope) {
|
||||
for (const scopeKey of Object.keys(currentDraftsByScope)) {
|
||||
if (scopeKey in prunedScopedTransientState.draftsByScope) continue;
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
bumpDraftUploadGeneration(scopeKey);
|
||||
}
|
||||
setLatestAIDraftsByScopeSnapshot(prunedScopedTransientState.draftsByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
}
|
||||
|
||||
if (prunedScopedTransientState.panelViewByScope !== currentPanelViewByScope) {
|
||||
for (const scopeKey of Object.keys(currentPanelViewByScope)) {
|
||||
if (scopeKey in prunedScopedTransientState.panelViewByScope) continue;
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
}
|
||||
setLatestAIPanelViewByScopeSnapshot(prunedScopedTransientState.panelViewByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -163,6 +213,10 @@ function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
|
||||
|
||||
let latestAISessionsSnapshot: AISession[] | null = null;
|
||||
let latestAIActiveSessionMapSnapshot: Record<string, string | null> | null = null;
|
||||
let latestAIDraftsByScopeSnapshot: DraftsByScope | null = null;
|
||||
let latestAIPanelViewByScopeSnapshot: PanelViewByScope | null = null;
|
||||
let latestAIDraftMutationVersionByScopeSnapshot: Record<string, number> = {};
|
||||
let latestAIDraftUploadGenerationByScopeSnapshot: Record<string, number> = {};
|
||||
|
||||
function setLatestAISessionsSnapshot(sessions: AISession[]) {
|
||||
latestAISessionsSnapshot = sessions;
|
||||
@@ -172,17 +226,33 @@ function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<string,
|
||||
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
|
||||
}
|
||||
|
||||
function buildScopeKey(scope: AISessionScope) {
|
||||
return `${scope.type}:${scope.targetId ?? ''}`;
|
||||
function setLatestAIDraftsByScopeSnapshot(draftsByScope: DraftsByScope) {
|
||||
latestAIDraftsByScopeSnapshot = draftsByScope;
|
||||
}
|
||||
|
||||
function areHostIdsEqual(left?: string[], right?: string[]) {
|
||||
const leftIds = left ?? [];
|
||||
const rightIds = right ?? [];
|
||||
if (leftIds.length !== rightIds.length) return false;
|
||||
function setLatestAIPanelViewByScopeSnapshot(panelViewByScope: PanelViewByScope) {
|
||||
latestAIPanelViewByScopeSnapshot = panelViewByScope;
|
||||
}
|
||||
|
||||
const rightSet = new Set(rightIds);
|
||||
return leftIds.every((hostId) => rightSet.has(hostId));
|
||||
function bumpDraftMutationVersion(scopeKey: string) {
|
||||
latestAIDraftMutationVersionByScopeSnapshot = bumpDraftMutationVersionState(
|
||||
latestAIDraftMutationVersionByScopeSnapshot,
|
||||
scopeKey,
|
||||
);
|
||||
}
|
||||
|
||||
function getDraftUploadGeneration(scopeKey: string) {
|
||||
return getDraftUploadGenerationState(
|
||||
latestAIDraftUploadGenerationByScopeSnapshot,
|
||||
scopeKey,
|
||||
);
|
||||
}
|
||||
|
||||
function bumpDraftUploadGeneration(scopeKey: string) {
|
||||
latestAIDraftUploadGenerationByScopeSnapshot = bumpDraftUploadGenerationState(
|
||||
latestAIDraftUploadGenerationByScopeSnapshot,
|
||||
scopeKey,
|
||||
);
|
||||
}
|
||||
|
||||
export function useAIState() {
|
||||
@@ -243,6 +313,14 @@ export function useAIState() {
|
||||
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>(() =>
|
||||
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {}
|
||||
);
|
||||
// Per-scope draft/view state is intentionally memory-only so a relaunch
|
||||
// does not restore stale composer input or panel intent against new history.
|
||||
const [draftsByScope, setDraftsByScopeRaw] = useState<DraftsByScope>(() =>
|
||||
latestAIDraftsByScopeSnapshot ?? {}
|
||||
);
|
||||
const [panelViewByScope, setPanelViewByScopeRaw] = useState<PanelViewByScope>(() =>
|
||||
latestAIPanelViewByScopeSnapshot ?? {}
|
||||
);
|
||||
|
||||
// Per-agent model selection: remembers last selected model per agent
|
||||
const [agentModelMap, setAgentModelMapRaw] = useState<Record<string, string>>(() =>
|
||||
@@ -262,6 +340,14 @@ export function useAIState() {
|
||||
setLatestAIActiveSessionMapSnapshot(activeSessionIdMap);
|
||||
}, [activeSessionIdMap]);
|
||||
|
||||
useEffect(() => {
|
||||
setLatestAIDraftsByScopeSnapshot(draftsByScope);
|
||||
}, [draftsByScope]);
|
||||
|
||||
useEffect(() => {
|
||||
setLatestAIPanelViewByScopeSnapshot(panelViewByScope);
|
||||
}, [panelViewByScope]);
|
||||
|
||||
useEffect(() => {
|
||||
const validSessionIds = new Set(sessions.map((session) => session.id));
|
||||
let changed = false;
|
||||
@@ -284,13 +370,39 @@ export function useAIState() {
|
||||
}, [sessions, activeSessionIdMap]);
|
||||
|
||||
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
|
||||
let nextActiveSessionIdMap: Record<string, string | null> | null = null;
|
||||
|
||||
setActiveSessionIdMapRaw(prev => {
|
||||
if (prev[scopeKey] === id) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const next = { ...prev, [scopeKey]: id };
|
||||
setLatestAIActiveSessionMapSnapshot(next);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
nextActiveSessionIdMap = next;
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!nextActiveSessionIdMap) return;
|
||||
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}, []);
|
||||
|
||||
const setPanelViewByScope = useCallback((value: PanelViewByScope | ((prev: PanelViewByScope) => PanelViewByScope)) => {
|
||||
let nextPanelViewByScope: PanelViewByScope | null = null;
|
||||
|
||||
setPanelViewByScopeRaw((prev) => {
|
||||
const next = typeof value === 'function' ? value(prev) : value;
|
||||
if (next === prev) return prev;
|
||||
nextPanelViewByScope = next;
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!nextPanelViewByScope) return;
|
||||
|
||||
setLatestAIPanelViewByScopeSnapshot(nextPanelViewByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
}, []);
|
||||
|
||||
const setAgentModel = useCallback((agentId: string, modelId: string) => {
|
||||
@@ -522,6 +634,12 @@ export function useAIState() {
|
||||
?? {},
|
||||
);
|
||||
return;
|
||||
case AI_STATE_CHANGED_DRAFTS_BY_SCOPE:
|
||||
setDraftsByScopeRaw(latestAIDraftsByScopeSnapshot ?? {});
|
||||
return;
|
||||
case AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE:
|
||||
setPanelViewByScopeRaw(latestAIPanelViewByScopeSnapshot ?? {});
|
||||
return;
|
||||
default:
|
||||
handleStorage({ key } as StorageEvent);
|
||||
}
|
||||
@@ -686,61 +804,6 @@ export function useAIState() {
|
||||
});
|
||||
}, [debouncedPersistSessions]);
|
||||
|
||||
const retargetSessionScope = useCallback((sessionId: string, scope: AISessionScope) => {
|
||||
const currentSession = sessionsRef.current.find((session) => session.id === sessionId);
|
||||
if (!currentSession) return;
|
||||
|
||||
const currentScope = currentSession.scope;
|
||||
const scopeChanged =
|
||||
currentScope.type !== scope.type
|
||||
|| currentScope.targetId !== scope.targetId
|
||||
|| !areHostIdsEqual(currentScope.hostIds, scope.hostIds);
|
||||
|
||||
const nextScopeKey = buildScopeKey(scope);
|
||||
const currentScopeKey = buildScopeKey(currentScope);
|
||||
|
||||
if (scopeChanged) {
|
||||
setSessionsRaw((prev) => {
|
||||
let changed = false;
|
||||
const next = prev.map((session) => {
|
||||
if (session.id !== sessionId) return session;
|
||||
changed = true;
|
||||
// Clear stale ACP handle — retarget may run before orphan cleanup
|
||||
return { ...session, scope, externalSessionId: undefined };
|
||||
});
|
||||
|
||||
if (!changed) return prev;
|
||||
|
||||
sessionsRef.current = next;
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
setActiveSessionIdMapRaw((prev) => {
|
||||
let changed = false;
|
||||
const next = { ...prev };
|
||||
|
||||
if (currentScopeKey !== nextScopeKey && next[currentScopeKey] === sessionId) {
|
||||
delete next[currentScopeKey];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (next[nextScopeKey] !== sessionId) {
|
||||
next[nextScopeKey] = sessionId;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) return prev;
|
||||
|
||||
setLatestAIActiveSessionMapSnapshot(next);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
return next;
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
// Maximum messages per session to prevent unbounded memory growth
|
||||
const MAX_MESSAGES_PER_SESSION = 500;
|
||||
|
||||
@@ -808,14 +871,193 @@ export function useAIState() {
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
const ensureDraftForScope = useCallback((scopeKey: string, agentId: string): void => {
|
||||
let nextDraftsByScope: DraftsByScope | null = null;
|
||||
|
||||
setDraftsByScopeRaw((prev) => {
|
||||
const next = ensureDraftForScopeState(prev, scopeKey, agentId);
|
||||
if (next === prev) return prev;
|
||||
nextDraftsByScope = next;
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!nextDraftsByScope) return;
|
||||
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
setLatestAIDraftsByScopeSnapshot(nextDraftsByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
}, []);
|
||||
|
||||
const updateDraft = useCallback((
|
||||
scopeKey: string,
|
||||
fallbackAgentId: string,
|
||||
updater: (draft: AIDraft) => AIDraft,
|
||||
): void => {
|
||||
setDraftsByScopeRaw((prev) => {
|
||||
const next = updateDraftForScope(
|
||||
prev,
|
||||
scopeKey,
|
||||
fallbackAgentId,
|
||||
(draft) => {
|
||||
return {
|
||||
...updater(draft),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
},
|
||||
);
|
||||
setLatestAIDraftsByScopeSnapshot(next);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
return next;
|
||||
});
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
}, []);
|
||||
|
||||
const updateDraftIfPresent = useCallback((
|
||||
scopeKey: string,
|
||||
updater: (draft: AIDraft) => AIDraft,
|
||||
): void => {
|
||||
let updated = false;
|
||||
|
||||
setDraftsByScopeRaw((prev) => {
|
||||
const currentDraft = prev[scopeKey];
|
||||
if (!currentDraft) return prev;
|
||||
|
||||
const nextDraft = {
|
||||
...updater(currentDraft),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const next = {
|
||||
...prev,
|
||||
[scopeKey]: nextDraft,
|
||||
};
|
||||
updated = true;
|
||||
setLatestAIDraftsByScopeSnapshot(next);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (updated) {
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const showDraftView = useCallback((scopeKey: string) => {
|
||||
const currentPanelViewByScope = panelViewByScope;
|
||||
let nextActiveSessionIdMap: Record<string, string | null> | null = null;
|
||||
let nextPanelViewByScope: PanelViewByScope | null = null;
|
||||
let activeSessionMapChanged = false;
|
||||
let panelViewChanged = false;
|
||||
|
||||
setActiveSessionIdMapRaw((prevActiveSessionIdMap) => {
|
||||
const next = activateDraftView(
|
||||
prevActiveSessionIdMap,
|
||||
currentPanelViewByScope,
|
||||
scopeKey,
|
||||
);
|
||||
activeSessionMapChanged = next.activeSessionIdMap !== prevActiveSessionIdMap;
|
||||
panelViewChanged = next.panelViewByScope !== currentPanelViewByScope;
|
||||
nextActiveSessionIdMap = next.activeSessionIdMap;
|
||||
nextPanelViewByScope = next.panelViewByScope;
|
||||
return activeSessionMapChanged ? next.activeSessionIdMap : prevActiveSessionIdMap;
|
||||
});
|
||||
|
||||
if (activeSessionMapChanged && nextActiveSessionIdMap) {
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
|
||||
if (panelViewChanged && nextPanelViewByScope) {
|
||||
setLatestAIPanelViewByScopeSnapshot(nextPanelViewByScope);
|
||||
setPanelViewByScopeRaw(nextPanelViewByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
}
|
||||
}, [panelViewByScope]);
|
||||
|
||||
const showSessionView = useCallback((scopeKey: string, sessionId: string) => {
|
||||
setPanelViewByScope((prev) => setSessionView(prev, scopeKey, sessionId));
|
||||
}, [setPanelViewByScope]);
|
||||
|
||||
const clearDraftForScope = useCallback((scopeKey: string) => {
|
||||
const currentPanelViewByScope = panelViewByScope;
|
||||
let nextDraftsByScope: DraftsByScope | null = null;
|
||||
let nextPanelViewByScope: PanelViewByScope | null = null;
|
||||
let draftsChanged = false;
|
||||
let panelViewChanged = false;
|
||||
|
||||
setDraftsByScopeRaw((prevDraftsByScope) => {
|
||||
const next = clearScopeDraftState(
|
||||
prevDraftsByScope,
|
||||
currentPanelViewByScope,
|
||||
scopeKey,
|
||||
);
|
||||
draftsChanged = next.draftsByScope !== prevDraftsByScope;
|
||||
panelViewChanged = next.panelViewByScope !== currentPanelViewByScope;
|
||||
nextDraftsByScope = next.draftsByScope;
|
||||
nextPanelViewByScope = next.panelViewByScope;
|
||||
return draftsChanged ? next.draftsByScope : prevDraftsByScope;
|
||||
});
|
||||
|
||||
if (!draftsChanged && !panelViewChanged) return;
|
||||
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
bumpDraftUploadGeneration(scopeKey);
|
||||
|
||||
if (draftsChanged && nextDraftsByScope) {
|
||||
setLatestAIDraftsByScopeSnapshot(nextDraftsByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
}
|
||||
|
||||
if (panelViewChanged && nextPanelViewByScope) {
|
||||
setLatestAIPanelViewByScopeSnapshot(nextPanelViewByScope);
|
||||
setPanelViewByScopeRaw(nextPanelViewByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
}
|
||||
}, [panelViewByScope]);
|
||||
|
||||
const addDraftFiles = useCallback(async (
|
||||
scopeKey: string,
|
||||
fallbackAgentId: string,
|
||||
inputFiles: File[],
|
||||
) => {
|
||||
ensureDraftForScope(scopeKey, fallbackAgentId);
|
||||
const initialUploadGeneration = getDraftUploadGeneration(scopeKey);
|
||||
const uploads = await convertFilesToUploads(inputFiles);
|
||||
if (uploads.length === 0) return;
|
||||
|
||||
if (getDraftUploadGeneration(scopeKey) !== initialUploadGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateDraftIfPresent(scopeKey, (draft) => ({
|
||||
...draft,
|
||||
attachments: [...draft.attachments, ...uploads],
|
||||
}));
|
||||
}, [ensureDraftForScope, updateDraftIfPresent]);
|
||||
|
||||
const removeDraftFile = useCallback((scopeKey: string, fallbackAgentId: string, fileId: string) => {
|
||||
updateDraft(scopeKey, fallbackAgentId, (draft) => ({
|
||||
...draft,
|
||||
attachments: draft.attachments.filter((file) => file.id !== fileId),
|
||||
}));
|
||||
}, [updateDraft]);
|
||||
|
||||
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
|
||||
cleanupOrphanedAISessions(activeTargetIds);
|
||||
setSessionsRaw(latestAISessionsSnapshot ?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? []);
|
||||
|
||||
const nextSessions =
|
||||
latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
sessionsRef.current = nextSessions;
|
||||
setSessionsRaw(nextSessions);
|
||||
setActiveSessionIdMapRaw(
|
||||
latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {},
|
||||
);
|
||||
setDraftsByScopeRaw(latestAIDraftsByScopeSnapshot ?? {});
|
||||
setPanelViewByScopeRaw(latestAIPanelViewByScopeSnapshot ?? {});
|
||||
}, []);
|
||||
|
||||
// ── Provider CRUD helpers ──
|
||||
@@ -889,13 +1131,21 @@ export function useAIState() {
|
||||
// Sessions (per-scope active session)
|
||||
sessions,
|
||||
activeSessionIdMap,
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
setActiveSessionId,
|
||||
ensureDraftForScope,
|
||||
updateDraft,
|
||||
showDraftView,
|
||||
showSessionView,
|
||||
clearDraftForScope,
|
||||
addDraftFiles,
|
||||
removeDraftFile,
|
||||
createSession,
|
||||
deleteSession,
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
updateSessionExternalSessionId,
|
||||
retargetSessionScope,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
|
||||
@@ -16,60 +16,71 @@ import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { collectSyncableSettings } from '../syncPayload';
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from '../../infrastructure/config/storageKeys';
|
||||
import { collectSyncableSettings, hasMeaningfulCloudSyncData } from '../syncPayload';
|
||||
import { readInterruptedVaultApply } from '../localVaultBackups';
|
||||
import {
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
|
||||
import { notify } from '../notification';
|
||||
|
||||
/**
|
||||
* Check whether a sync payload has any meaningful user data. Covers all
|
||||
* synced entity arrays so that edge cases (e.g. user has 0 hosts but 1
|
||||
* port forwarding rule) are not mistakenly treated as "empty".
|
||||
*/
|
||||
function isPayloadEffectivelyEmpty(payload: SyncPayload): boolean {
|
||||
// Check all synced entity arrays.
|
||||
const hasEntities =
|
||||
(payload.hosts?.length ?? 0) > 0 ||
|
||||
(payload.keys?.length ?? 0) > 0 ||
|
||||
(payload.snippets?.length ?? 0) > 0 ||
|
||||
(payload.identities?.length ?? 0) > 0 ||
|
||||
(payload.customGroups?.length ?? 0) > 0 ||
|
||||
(payload.snippetPackages?.length ?? 0) > 0 ||
|
||||
(payload.portForwardingRules?.length ?? 0) > 0 ||
|
||||
(payload.knownHosts?.length ?? 0) > 0 ||
|
||||
(payload.groupConfigs?.length ?? 0) > 0;
|
||||
if (hasEntities) return false;
|
||||
// Also consider settings: if any key has a defined value, the user has
|
||||
// customized something worth preserving.
|
||||
if (payload.settings && Object.values(payload.settings).some((v) => v !== undefined)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
interface AutoSyncConfig {
|
||||
// Data to sync
|
||||
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;
|
||||
startupReady?: boolean;
|
||||
|
||||
// Callbacks
|
||||
onApplyPayload: (payload: SyncPayload) => void;
|
||||
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
}
|
||||
|
||||
// Get manager singleton for direct state access
|
||||
const manager = getCloudSyncManager();
|
||||
const AUTO_SYNC_PROVIDER_ORDER: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
|
||||
|
||||
// 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
|
||||
// must not push concurrently. The writer (`withRestoreBarrier`) heartbeats
|
||||
// the deadline to keep it alive; a crashed window naturally expires within
|
||||
// ~RESTORE_BARRIER_HOLD_MS. We still defend against two degenerate cases:
|
||||
// (1) a stale deadline sitting in the past — harmless but pollutes debug
|
||||
// state, so we opportunistically clear it; (2) a deadline absurdly far
|
||||
// in the future (clock skew between windows, pathological holdMs, or a
|
||||
// tampered value) — would otherwise lock auto-sync indefinitely, so we
|
||||
// clear it and treat the barrier as inactive.
|
||||
const RESTORE_BARRIER_SANITY_MAX_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const isRestoreInProgress = (): boolean => {
|
||||
const raw = localStorageAdapter.readNumber(STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL);
|
||||
if (typeof raw !== 'number' || raw <= 0) return false;
|
||||
const now = Date.now();
|
||||
if (raw <= now) {
|
||||
// Deadline is in the past — either a clean finish that failed to
|
||||
// overwrite the key, or a crashed heartbeat. Clear so subsequent
|
||||
// reads are cheap and the key doesn't linger forever.
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL, 0);
|
||||
return false;
|
||||
}
|
||||
if (raw - now > RESTORE_BARRIER_SANITY_MAX_MS) {
|
||||
console.warn(
|
||||
'[useAutoSync] Restore barrier deadline is absurdly far in the future; treating as corrupt and clearing.',
|
||||
{ deadline: raw, now },
|
||||
);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL, 0);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
type SyncTrigger = 'auto' | 'manual';
|
||||
|
||||
interface SyncNowOptions {
|
||||
@@ -100,6 +111,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
remotePayload: SyncPayload;
|
||||
hostCount: number;
|
||||
keyCount: number;
|
||||
proxyProfileCount: number;
|
||||
snippetCount: number;
|
||||
} | null>(null);
|
||||
const emptyVaultResolveRef = useRef<((action: 'restore' | 'keep-empty') => void) | null>(null);
|
||||
@@ -128,28 +140,26 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveKnownHosts = getEffectiveKnownHosts(config.knownHosts);
|
||||
|
||||
return {
|
||||
hosts: config.hosts,
|
||||
keys: config.keys,
|
||||
identities: config.identities,
|
||||
proxyProfiles: config.proxyProfiles,
|
||||
snippets: config.snippets,
|
||||
customGroups: config.customGroups,
|
||||
snippetPackages: config.snippetPackages,
|
||||
portForwardingRules: effectivePFRules,
|
||||
knownHosts: effectiveKnownHosts,
|
||||
groupConfigs: config.groupConfigs,
|
||||
};
|
||||
}, [
|
||||
config.hosts,
|
||||
config.keys,
|
||||
config.identities,
|
||||
config.proxyProfiles,
|
||||
config.snippets,
|
||||
config.customGroups,
|
||||
config.snippetPackages,
|
||||
config.portForwardingRules,
|
||||
config.knownHosts,
|
||||
config.groupConfigs,
|
||||
]);
|
||||
|
||||
@@ -190,6 +200,50 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
throw new Error(t('sync.autoSync.alreadySyncing'));
|
||||
}
|
||||
|
||||
// Cross-window guard: another window may be in the middle of
|
||||
// applying a local vault restore. If we push right now we'd upload
|
||||
// the pre-restore snapshot (the main window's React state hasn't
|
||||
// observed the localStorage writes yet), clobbering the just-
|
||||
// restored cloud copy. Skip silently on auto triggers and fail
|
||||
// loudly on manual ones so the user understands why their click
|
||||
// did nothing.
|
||||
//
|
||||
// Pairs with `withRestoreBarrier` in application/localVaultBackups.ts
|
||||
// (the writer) and with the matching early-return in the
|
||||
// debounced-sync effect below (the other reader, which prevents
|
||||
// scheduling a push while the barrier is held).
|
||||
if (isRestoreInProgress()) {
|
||||
if (trigger === 'auto') {
|
||||
console.info('[AutoSync] Skipping: a vault restore is in progress in another window.');
|
||||
return;
|
||||
}
|
||||
throw new Error(t('sync.autoSync.restoreInProgress'));
|
||||
}
|
||||
|
||||
// Refuse to auto-push when a previous apply crashed mid-way and
|
||||
// left the vault in a partial state. `applyProtectedSyncPayload`
|
||||
// sets a sentinel before its non-atomic localStorage writes and
|
||||
// clears it on successful completion; the sentinel's presence
|
||||
// here means the renderer crashed between a first write and the
|
||||
// clean-up, so the in-memory payload is a mix of pre-apply and
|
||||
// post-apply entries. Pushing that would silently overwrite an
|
||||
// intact cloud copy with corrupted data.
|
||||
//
|
||||
// Manual triggers surface a user-visible error that points the
|
||||
// user at the Restore UI; auto triggers return quietly (the
|
||||
// next startup toast below flags the state).
|
||||
const interruptedApply = readInterruptedVaultApply();
|
||||
if (interruptedApply) {
|
||||
if (trigger === 'auto') {
|
||||
console.warn(
|
||||
'[AutoSync] Skipping: previous apply was interrupted — refusing to push partial state.',
|
||||
interruptedApply,
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error(t('sync.autoSync.interruptedApplyMessage'));
|
||||
}
|
||||
|
||||
// If another window unlocked, reuse the in-memory session password from main process.
|
||||
if (state.securityState !== 'UNLOCKED') {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -216,14 +270,23 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
throw new Error(t('sync.credentialsUnavailable'));
|
||||
}
|
||||
|
||||
// Prevent pushing an empty vault to cloud. This is almost always
|
||||
// Refuse to push an empty vault to cloud. This is almost always
|
||||
// a sign that the local state was lost (update, import failure,
|
||||
// storage corruption) rather than a deliberate "delete everything".
|
||||
// We only block auto-sync — manual trigger from Settings can still
|
||||
// push if the user explicitly wants to.
|
||||
if (isPayloadEffectivelyEmpty(payload) && trigger === 'auto') {
|
||||
console.warn('[AutoSync] Blocked: refusing to auto-sync an empty vault to cloud');
|
||||
return;
|
||||
// Both auto and manual triggers are blocked; the user can still
|
||||
// use Force Push from the SyncBlocked banner if they genuinely
|
||||
// want to wipe the cloud.
|
||||
//
|
||||
// This pairs with the inspect-failure "fail open" behavior in
|
||||
// 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 (!hasMeaningfulCloudSyncData(payload)) {
|
||||
if (trigger === 'auto') {
|
||||
console.warn('[AutoSync] Blocked: refusing to auto-sync an empty vault to cloud');
|
||||
return;
|
||||
}
|
||||
throw new Error(t('sync.autoSync.emptyVaultManual'));
|
||||
}
|
||||
|
||||
const results = await sync.syncNow(payload);
|
||||
@@ -232,7 +295,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// state gets updated even when some providers failed
|
||||
for (const result of results.values()) {
|
||||
if (result.mergedPayload) {
|
||||
onApplyPayload(result.mergedPayload);
|
||||
await Promise.resolve(onApplyPayload(result.mergedPayload));
|
||||
skipNextSyncRef.current = true;
|
||||
break; // All providers share the same merged payload
|
||||
}
|
||||
@@ -248,6 +311,18 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
|
||||
lastSyncedDataRef.current = dataHash;
|
||||
|
||||
// Successful sync implies a successful per-provider
|
||||
// `checkProviderConflict` (which inspects remote) — equivalent
|
||||
// to a successful startup reconciliation from the auto-sync
|
||||
// gate's point of view. Opening the gate here is the escape
|
||||
// hatch when a network outage exhausted the startup retry
|
||||
// timer: a user-triggered manual sync (or any first successful
|
||||
// auto sync that somehow ran anyway) resumes auto-sync for the
|
||||
// rest of the session. Without this, a degraded-startup session
|
||||
// would require the user to manually sync after every edit.
|
||||
hasCheckedRemoteRef.current = true;
|
||||
remoteCheckDoneRef.current = true;
|
||||
} catch (error) {
|
||||
if (trigger === 'manual') {
|
||||
throw error;
|
||||
@@ -261,81 +336,233 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
isSyncRunningRef.current = false;
|
||||
}
|
||||
}, [sync, buildPayload, getDataHash, onApplyPayload, t]);
|
||||
|
||||
|
||||
// One-shot toast per mount when a previous apply was interrupted, so the
|
||||
// user understands why auto-sync is silently paused and where to go to
|
||||
// recover. `applyProtectedSyncPayload` clears the sentinel on a clean
|
||||
// apply, so this only fires once per genuine crash and naturally stops
|
||||
// after the user completes a recovery.
|
||||
const interruptedApplyNotifiedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (interruptedApplyNotifiedRef.current) return;
|
||||
if (!sync.isUnlocked) return;
|
||||
const interrupted = readInterruptedVaultApply();
|
||||
if (!interrupted) return;
|
||||
interruptedApplyNotifiedRef.current = true;
|
||||
notify.error(
|
||||
t('sync.autoSync.interruptedApplyMessage'),
|
||||
t('sync.autoSync.interruptedApplyTitle'),
|
||||
);
|
||||
}, [sync.isUnlocked, t]);
|
||||
|
||||
// Stabilize the fields `checkRemoteVersion` reads from `config`.
|
||||
// AutoSyncConfig is a fresh object literal on every App render, so a
|
||||
// naive `config` dep would rebuild `checkRemoteVersion`'s identity on
|
||||
// every unrelated state change — re-firing the retry effect with
|
||||
// `attempt=0` and spawning overlapping in-flight inspections. The
|
||||
// refs below let `checkRemoteVersion` read the latest callback and
|
||||
// readiness flag without pulling the object identity into deps.
|
||||
const onApplyPayloadRef = useRef(config.onApplyPayload);
|
||||
useEffect(() => {
|
||||
onApplyPayloadRef.current = config.onApplyPayload;
|
||||
}, [config.onApplyPayload]);
|
||||
const startupReadyRef = useRef(config.startupReady);
|
||||
useEffect(() => {
|
||||
startupReadyRef.current = config.startupReady;
|
||||
}, [config.startupReady]);
|
||||
// `buildPayload` closes over live React state so its identity flips
|
||||
// on every vault edit; route it through a ref so `checkRemoteVersion`
|
||||
// can read the latest builder without churning its memo identity.
|
||||
const buildPayloadRef = useRef(buildPayload);
|
||||
useEffect(() => {
|
||||
buildPayloadRef.current = buildPayload;
|
||||
}, [buildPayload]);
|
||||
|
||||
// Serialize `checkRemoteVersion` invocations. Overlapping runs would
|
||||
// race on `commitRemoteInspection` + `onApplyPayload`: two merges
|
||||
// could both write-then-clear the apply-in-progress sentinel around
|
||||
// interleaved applies, and both could push post-merge snapshots to
|
||||
// remote. The cross-window `withRestoreBarrier` protects other
|
||||
// windows but does NOT serialize same-window re-entry, so this
|
||||
// in-flight guard closes that gap at the top of the call.
|
||||
const checkRemoteInFlightRef = useRef(false);
|
||||
|
||||
// Check remote version and pull if newer (on startup)
|
||||
const checkRemoteVersion = useCallback(async () => {
|
||||
if (checkRemoteInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
const state = manager.getState();
|
||||
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
|
||||
const unlocked = state.securityState === 'UNLOCKED';
|
||||
|
||||
if (!hasProvider || !unlocked || hasCheckedRemoteRef.current) {
|
||||
|
||||
if (!hasProvider || !unlocked || hasCheckedRemoteRef.current || startupReadyRef.current === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasCheckedRemoteRef.current = true;
|
||||
|
||||
// Find connected provider
|
||||
|
||||
// Find connected provider BEFORE acquiring the in-flight lock so the
|
||||
// "nothing to check" early return doesn't leak the lock and wedge
|
||||
// the retry timer. Any path that takes the lock MUST reach the
|
||||
// finally-release below.
|
||||
const connectedProvider = AUTO_SYNC_PROVIDER_ORDER.find((provider) =>
|
||||
isProviderReadyForSync(state.providers[provider]),
|
||||
) ?? null;
|
||||
|
||||
if (!connectedProvider) return;
|
||||
|
||||
|
||||
if (!connectedProvider) {
|
||||
// Nothing to check — mark as done so the auto-sync gate opens.
|
||||
remoteCheckDoneRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
checkRemoteInFlightRef.current = true;
|
||||
|
||||
// Track whether the startup path completed in a state where the anchor/base
|
||||
// are consistent with the local vault. Only then should we latch
|
||||
// hasCheckedRemoteRef so that transient failures are retryable.
|
||||
let startupConsistent = false;
|
||||
try {
|
||||
// Load base BEFORE downloading (downloadFromProvider overwrites the base)
|
||||
// Load base BEFORE observing the remote payload (commitRemoteInspection overwrites the base).
|
||||
const base = await manager.loadSyncBase(connectedProvider);
|
||||
const remotePayload = await sync.downloadFromProvider(connectedProvider);
|
||||
const inspection = await manager.inspectProviderRemote(connectedProvider);
|
||||
|
||||
if (remotePayload && remotePayload.syncedAt > state.localUpdatedAt) {
|
||||
const localPayload = buildPayload();
|
||||
const localIsEmpty = isPayloadEffectivelyEmpty(localPayload);
|
||||
const remoteHasData = !isPayloadEffectivelyEmpty(remotePayload);
|
||||
if (!inspection.payload || !inspection.remoteChanged || !inspection.remoteFile) {
|
||||
// Remote unchanged (or empty) — no local mutation needed; anchor/base
|
||||
// are already in sync with remote from a previous run.
|
||||
startupConsistent = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// If local vault is empty but cloud has data, this almost certainly
|
||||
// means the user's data was lost (update, storage corruption, etc.).
|
||||
// Pause and ask the user what to do instead of silently merging.
|
||||
if (localIsEmpty && remoteHasData) {
|
||||
const userAction = await new Promise<'restore' | 'keep-empty'>((resolve) => {
|
||||
emptyVaultResolveRef.current = resolve;
|
||||
setEmptyVaultConflict({
|
||||
remotePayload,
|
||||
hostCount: remotePayload.hosts?.length ?? 0,
|
||||
keyCount: remotePayload.keys?.length ?? 0,
|
||||
snippetCount: remotePayload.snippets?.length ?? 0,
|
||||
});
|
||||
const remoteFile = inspection.remoteFile;
|
||||
const remotePayload = inspection.payload;
|
||||
const localPayload = buildPayloadRef.current();
|
||||
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.).
|
||||
// Pause and ask the user what to do instead of silently merging.
|
||||
if (localIsEmpty && remoteHasData) {
|
||||
const userAction = await new Promise<'restore' | 'keep-empty'>((resolve) => {
|
||||
emptyVaultResolveRef.current = resolve;
|
||||
setEmptyVaultConflict({
|
||||
remotePayload,
|
||||
hostCount: remotePayload.hosts?.length ?? 0,
|
||||
keyCount: remotePayload.keys?.length ?? 0,
|
||||
proxyProfileCount: remotePayload.proxyProfiles?.length ?? 0,
|
||||
snippetCount: remotePayload.snippets?.length ?? 0,
|
||||
});
|
||||
setEmptyVaultConflict(null);
|
||||
emptyVaultResolveRef.current = null;
|
||||
});
|
||||
setEmptyVaultConflict(null);
|
||||
emptyVaultResolveRef.current = null;
|
||||
|
||||
if (userAction === 'restore') {
|
||||
config.onApplyPayload(remotePayload);
|
||||
skipNextSyncRef.current = true;
|
||||
notify.success(t('sync.autoSync.restoredMessage'), t('sync.autoSync.restoredTitle'));
|
||||
} else {
|
||||
// User chose to keep the empty vault. Don't apply remote data.
|
||||
// The next auto-sync will eventually push the empty state if
|
||||
// the user makes another edit.
|
||||
notify.info(t('sync.autoSync.keptLocalMessage'), t('sync.autoSync.keptLocalTitle'));
|
||||
}
|
||||
return;
|
||||
if (userAction === 'restore') {
|
||||
// Apply remote FIRST; only commit anchor/base after the UI-side
|
||||
// state has accepted the remote payload, otherwise a failure
|
||||
// between commit and apply would leave the anchor pointing at
|
||||
// remote while local is still empty — the exact overwrite window
|
||||
// we're trying to close.
|
||||
await Promise.resolve(onApplyPayloadRef.current(remotePayload));
|
||||
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload);
|
||||
skipNextSyncRef.current = true;
|
||||
startupConsistent = true;
|
||||
notify.success(t('sync.autoSync.restoredMessage'), t('sync.autoSync.restoredTitle'));
|
||||
} else {
|
||||
// User chose to keep the empty vault. Deliberately do NOT advance
|
||||
// the anchor or base — the next sync must still treat remote as
|
||||
// "unseen" so the empty-vault-push guard (`hasMeaningfulSyncData`)
|
||||
// keeps protecting the cloud copy. startupConsistent stays false
|
||||
// so hasCheckedRemoteRef is not latched and the next startup will
|
||||
// re-prompt if the user still has not added anything.
|
||||
notify.info(t('sync.autoSync.keptLocalMessage'), t('sync.autoSync.keptLocalTitle'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
|
||||
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
|
||||
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
|
||||
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
|
||||
|
||||
config.onApplyPayload(mergeResult.payload);
|
||||
// Prevent the data-change effect from immediately re-uploading the
|
||||
// merged payload — the merge already incorporated both sides. The
|
||||
// next deliberate edit by the user will trigger a normal sync.
|
||||
skipNextSyncRef.current = true;
|
||||
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
|
||||
// Apply merged payload to local state BEFORE committing. If the apply
|
||||
// throws, the next startup will re-run the merge with fresh data.
|
||||
await Promise.resolve(onApplyPayloadRef.current(mergeResult.payload));
|
||||
// Base is the last-agreed remote snapshot; `commitRemoteInspection`
|
||||
// stores remotePayload as the base so the next diff is computed
|
||||
// against what the cloud actually has, not against the merged
|
||||
// local-only state.
|
||||
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload);
|
||||
startupConsistent = true;
|
||||
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
|
||||
|
||||
// If the three-way merge introduced any local-only additions that the
|
||||
// remote does not yet have, we MUST round-trip those to the cloud.
|
||||
// Previously this branch stopped after applying merge locally, so the
|
||||
// merged-in additions lived only on the device that ran the merge
|
||||
// until the user's next edit.
|
||||
//
|
||||
// We push the merged payload *directly* through the manager rather
|
||||
// than going through the React-state-driven `syncNow`. syncNow
|
||||
// rebuilds the payload from hooks state, which may not yet reflect
|
||||
// the onApplyPayload we awaited above (React commit phase is async
|
||||
// relative to the awaited promise resolution). Passing mergeResult
|
||||
// in explicitly removes the race entirely and avoids a setTimeout(0)
|
||||
// that only approximated the correct ordering.
|
||||
if (mergeResult.payload) {
|
||||
try {
|
||||
const roundTripResults = await manager.syncAllProviders(mergeResult.payload);
|
||||
const wasShrinkBlocked = Array.from(roundTripResults.values()).some(
|
||||
(r) => r.shrinkBlocked === true,
|
||||
);
|
||||
if (wasShrinkBlocked) {
|
||||
// The merged payload is already applied locally and is the source of truth
|
||||
// for THIS device. The blocking only prevents pushing it to cloud, which
|
||||
// is acceptable here — the next user-edit-triggered sync will re-check
|
||||
// (and the user can also force-push from the Settings banner if they
|
||||
// navigate there). Reset syncState so we don't leave the manager wedged
|
||||
// in BLOCKED with no banner visible.
|
||||
console.warn('[AutoSync] Post-merge round-trip was shrink-blocked; merged data applied locally, reset syncState to IDLE for next attempt.');
|
||||
manager.clearShrinkBlockedState();
|
||||
}
|
||||
// Suppress the debounced follow-up tick that otherwise fires
|
||||
// once React commits the applied state, since we've just
|
||||
// already pushed that exact payload upstream.
|
||||
skipNextSyncRef.current = true;
|
||||
} catch (error) {
|
||||
// Non-fatal: the next user edit will drive another sync cycle.
|
||||
console.warn('[AutoSync] Post-merge round-trip push failed:', error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AutoSync] Failed to check remote version:', error);
|
||||
// Surface a degraded-sync hint to the user rather than silently
|
||||
// opening the auto-sync gate. Auto-sync will still retry on next
|
||||
// data change (see finally block), but without this toast the user
|
||||
// has no visible signal that startup reconciliation failed.
|
||||
notify.error(
|
||||
t('sync.autoSync.inspectFailedMessage'),
|
||||
t('sync.autoSync.inspectFailedTitle'),
|
||||
);
|
||||
// Leave hasCheckedRemoteRef=false so the next startup (or the next
|
||||
// provider/unlock transition) can retry.
|
||||
} finally {
|
||||
remoteCheckDoneRef.current = true;
|
||||
if (startupConsistent) {
|
||||
hasCheckedRemoteRef.current = true;
|
||||
// Only open the auto-sync gate when the inspect actually
|
||||
// validated the remote state. Leaving the gate closed on
|
||||
// inspect failure is intentional: an edit made during a
|
||||
// degraded startup must not race ahead and push a partially-
|
||||
// hydrated vault over an intact remote. The retry effect
|
||||
// below re-fires checkRemoteVersion on the next provider/
|
||||
// unlock/startupReady transition, and a manual sync from
|
||||
// Settings remains available as an escape hatch.
|
||||
remoteCheckDoneRef.current = true;
|
||||
}
|
||||
checkRemoteInFlightRef.current = false;
|
||||
}
|
||||
}, [sync, config, buildPayload, t]);
|
||||
// Intentionally minimal deps: `buildPayload`, `config.onApplyPayload`,
|
||||
// and `config.startupReady` are read through refs above so their
|
||||
// identity flips (every vault edit produces a fresh `buildPayload`
|
||||
// and a fresh AutoSyncConfig literal) cannot re-memoize this
|
||||
// callback and restart the retry-timer's exponential backoff.
|
||||
}, [t]);
|
||||
|
||||
// Debounced auto-sync when data changes
|
||||
useEffect(() => {
|
||||
@@ -379,6 +606,23 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
if (sync.isSyncing || isSyncRunningRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hold off on scheduling a new push while another window is applying
|
||||
// a restore — the restore is about to land via localStorage and the
|
||||
// debounce-fired syncNow would otherwise race it. The next data-
|
||||
// change tick after the restore barrier clears will re-enter here.
|
||||
if (isRestoreInProgress()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't even schedule a push while the apply-in-progress sentinel
|
||||
// is held. The syncNow path re-checks and refuses too, but dropping
|
||||
// the debounced schedule here avoids spinning a 3-second timer for
|
||||
// every keystroke while the user is in the Restore UI working
|
||||
// through recovery.
|
||||
if (readInterruptedVaultApply()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing timeout
|
||||
if (syncTimeoutRef.current) {
|
||||
@@ -397,17 +641,65 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
};
|
||||
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow, config.settingsVersion, bookmarksVersion]);
|
||||
|
||||
// Check remote version on startup/unlock
|
||||
// Check remote version on startup/unlock, then retry with backoff
|
||||
// while the inspect keeps failing. Without the timer-based retry,
|
||||
// a failure that doesn't coincide with a dep change would wedge the
|
||||
// auto-sync gate closed until the user restarts or manually triggers
|
||||
// sync from Settings — the 30s/60s/90s cadence below lets a short
|
||||
// outage (network blip, provider rate-limit) self-heal.
|
||||
useEffect(() => {
|
||||
if (sync.hasAnyConnectedProvider && sync.isUnlocked && !hasCheckedRemoteRef.current) {
|
||||
// Delay check to ensure everything is loaded
|
||||
const timer = setTimeout(() => {
|
||||
checkRemoteVersion();
|
||||
}, 1000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
if (
|
||||
!sync.hasAnyConnectedProvider ||
|
||||
!sync.isUnlocked ||
|
||||
hasCheckedRemoteRef.current ||
|
||||
config.startupReady === false
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, checkRemoteVersion]);
|
||||
|
||||
let cancelled = false;
|
||||
let attempt = 0;
|
||||
let timerId: NodeJS.Timeout | null = null;
|
||||
|
||||
const tick = () => {
|
||||
if (cancelled) return;
|
||||
void (async () => {
|
||||
await checkRemoteVersion();
|
||||
if (cancelled || hasCheckedRemoteRef.current) return;
|
||||
// Cap retries at ~5 minutes total (30s + 60s + 120s + 240s). A
|
||||
// persistent failure beyond that is almost certainly a
|
||||
// misconfiguration that needs user action rather than more
|
||||
// auto-retries.
|
||||
//
|
||||
// When retries exhaust we deliberately leave the auto-sync gate
|
||||
// CLOSED. Opening it here would allow a partially-lost local
|
||||
// vault to silently clobber an unchanged remote: anchor still
|
||||
// matches, `checkProviderConflict` sees no remote change,
|
||||
// `hasMeaningfulSyncData` doesn't flag non-empty-but-partial
|
||||
// local, and the empty-vault prompt never fires.
|
||||
//
|
||||
// Escape hatch: a successful manual sync from Settings opens
|
||||
// the gate via `syncNow`'s success path. That path runs the
|
||||
// same per-provider inspect we use here, so a successful
|
||||
// manual sync is equivalent to a successful startup inspect
|
||||
// from the gate's point of view — the user's explicit click
|
||||
// authorizes both the push and the subsequent auto-sync
|
||||
// resumption. Until then, auto-sync stays paused and the
|
||||
// "sync paused" toast is the user's signal to act.
|
||||
if (attempt >= 4) return;
|
||||
const delayMs = Math.min(240_000, 30_000 * 2 ** attempt);
|
||||
attempt += 1;
|
||||
timerId = setTimeout(tick, delayMs);
|
||||
})();
|
||||
};
|
||||
|
||||
tick();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timerId) clearTimeout(timerId);
|
||||
};
|
||||
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady, checkRemoteVersion]);
|
||||
|
||||
// Reset check flags when provider disconnects
|
||||
useEffect(() => {
|
||||
@@ -416,6 +708,25 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
remoteCheckDoneRef.current = false;
|
||||
}
|
||||
}, [sync.hasAnyConnectedProvider]);
|
||||
|
||||
// On unmount, release any pending empty-vault confirmation. Without
|
||||
// this, an unmount mid-dialog (window close, workspace switch) leaves
|
||||
// the resolver promise dangling forever and the `checkRemoteVersion`
|
||||
// finally block never sets remoteCheckDoneRef — in practice React
|
||||
// tears down the hook first, but leaking the resolve callback and
|
||||
// referenced remotePayload keeps them pinned by the awaiter until
|
||||
// the next reload. Resolving with 'keep-empty' is the safe default:
|
||||
// it mirrors the "don't touch remote" choice and leaves the version
|
||||
// stamp untouched so the next mount re-prompts.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const resolve = emptyVaultResolveRef.current;
|
||||
if (resolve) {
|
||||
emptyVaultResolveRef.current = null;
|
||||
resolve('keep-empty');
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resolveEmptyVaultConflict = useCallback((action: 'restore' | 'keep-empty') => {
|
||||
// Guard: resolve only once (prevents double-click from entering an
|
||||
|
||||
@@ -26,7 +26,9 @@ import {
|
||||
import {
|
||||
getCloudSyncManager,
|
||||
type SyncManagerState,
|
||||
type SyncEventCallback,
|
||||
} from '../../infrastructure/services/CloudSyncManager';
|
||||
import type { ShrinkFinding } from '../../domain/syncGuards';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import type { DeviceFlowState } from '../../infrastructure/services/adapters/GitHubAdapter';
|
||||
|
||||
@@ -51,11 +53,12 @@ export interface CloudSyncHook {
|
||||
remoteVersion: number;
|
||||
remoteUpdatedAt: number;
|
||||
syncHistory: SyncHistoryEntry[];
|
||||
pendingBrowserAuthProvider: 'google' | 'onedrive' | null;
|
||||
|
||||
// Computed
|
||||
hasAnyConnectedProvider: boolean;
|
||||
connectedProviderCount: number;
|
||||
overallSyncStatus: 'none' | 'synced' | 'syncing' | 'error' | 'conflict';
|
||||
overallSyncStatus: 'none' | 'synced' | 'syncing' | 'error' | 'conflict' | 'blocked';
|
||||
|
||||
// Master Key Actions
|
||||
setupMasterKey: (password: string, confirmPassword: string) => Promise<void>;
|
||||
@@ -70,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>;
|
||||
@@ -86,8 +91,8 @@ export interface CloudSyncHook {
|
||||
resetProviderStatus: (provider: CloudProvider) => void;
|
||||
|
||||
// Sync Actions
|
||||
syncNow: (payload: SyncPayload) => Promise<Map<CloudProvider, SyncResult>>;
|
||||
syncToProvider: (provider: CloudProvider, payload: SyncPayload) => Promise<SyncResult>;
|
||||
syncNow: (payload: SyncPayload, opts?: { overrideShrink?: boolean }) => Promise<Map<CloudProvider, SyncResult>>;
|
||||
syncToProvider: (provider: CloudProvider, payload: SyncPayload, opts?: { overrideShrink?: boolean }) => Promise<SyncResult>;
|
||||
downloadFromProvider: (provider: CloudProvider) => Promise<SyncPayload | null>;
|
||||
resolveConflict: (resolution: ConflictResolution) => Promise<SyncPayload | null>;
|
||||
|
||||
@@ -116,8 +121,55 @@ export interface CloudSyncHook {
|
||||
formatLastSync: (timestamp?: number) => string;
|
||||
getProviderDotColor: (provider: CloudProvider) => string;
|
||||
refresh: () => void;
|
||||
|
||||
// Event subscription (for non-state events like SYNC_BLOCKED_SHRINK)
|
||||
subscribeToEvents: (callback: SyncEventCallback) => () => void;
|
||||
|
||||
// Shrink-block state query (for banner hydration on mount)
|
||||
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
|
||||
// ============================================================================
|
||||
@@ -138,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.
|
||||
@@ -190,7 +251,8 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
).length;
|
||||
}, [state.providers]);
|
||||
|
||||
const overallSyncStatus = useMemo((): 'none' | 'synced' | 'syncing' | 'error' | 'conflict' => {
|
||||
const overallSyncStatus = useMemo((): 'none' | 'synced' | 'syncing' | 'error' | 'conflict' | 'blocked' => {
|
||||
if (state.syncState === 'BLOCKED') return 'blocked';
|
||||
if (state.syncState === 'CONFLICT') return 'conflict';
|
||||
if (state.syncState === 'ERROR') return 'error';
|
||||
if (state.syncState === 'SYNCING') return 'syncing';
|
||||
@@ -253,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,
|
||||
@@ -379,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 ==========
|
||||
|
||||
@@ -422,14 +661,14 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
throw new Error('Vault is locked');
|
||||
}, []);
|
||||
|
||||
const syncNowWithUnlock = useCallback(async (payload: SyncPayload) => {
|
||||
const syncNowWithUnlock = useCallback(async (payload: SyncPayload, opts?: { overrideShrink?: boolean }) => {
|
||||
await ensureUnlocked();
|
||||
return await manager.syncAllProviders(payload);
|
||||
return await manager.syncAllProviders(payload, opts);
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const syncToProviderWithUnlock = useCallback(async (provider: CloudProvider, payload: SyncPayload) => {
|
||||
const syncToProviderWithUnlock = useCallback(async (provider: CloudProvider, payload: SyncPayload, opts?: { overrideShrink?: boolean }) => {
|
||||
await ensureUnlocked();
|
||||
return await manager.syncToProvider(provider, payload);
|
||||
return await manager.syncToProvider(provider, payload, opts);
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const downloadFromProviderWithUnlock = useCallback(async (provider: CloudProvider) => {
|
||||
@@ -437,6 +676,16 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
return await manager.downloadFromProvider(provider);
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const subscribeToEvents = useCallback(
|
||||
(callback: SyncEventCallback) => manager.subscribe(callback),
|
||||
[],
|
||||
);
|
||||
|
||||
const getShrinkBlockedFinding = useCallback(
|
||||
() => manager.getShrinkBlockedFinding(),
|
||||
[],
|
||||
);
|
||||
|
||||
const resolveConflictWithUnlock = useCallback(async (resolution: ConflictResolution) => {
|
||||
await ensureUnlocked();
|
||||
return await manager.resolveConflict(resolution);
|
||||
@@ -459,6 +708,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
remoteVersion: state.remoteVersion,
|
||||
remoteUpdatedAt: state.remoteUpdatedAt,
|
||||
syncHistory: state.syncHistory,
|
||||
pendingBrowserAuthProvider: pendingBrowserAuth?.provider ?? null,
|
||||
|
||||
// Computed
|
||||
hasAnyConnectedProvider,
|
||||
@@ -505,6 +755,12 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
formatLastSync,
|
||||
getProviderDotColor,
|
||||
refresh,
|
||||
|
||||
// Event subscription
|
||||
subscribeToEvents,
|
||||
|
||||
// Shrink-block state query
|
||||
getShrinkBlockedFinding,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
/**
|
||||
* useFileUpload - Handle file paste/drop with base64 conversion
|
||||
* File upload conversion helpers for AI draft attachments.
|
||||
*
|
||||
* Supports images, PDFs, and other document types.
|
||||
* Ported from 1code's use-agents-file-upload.ts
|
||||
*/
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { UploadedFile } from '../../infrastructure/ai/types';
|
||||
import { getPathForFile } from '../../lib/sftpFileUtils';
|
||||
|
||||
export interface UploadedFile {
|
||||
id: string;
|
||||
filename: string;
|
||||
dataUrl: string; // data:...;base64,... for preview
|
||||
base64Data: string; // raw base64 for API
|
||||
mediaType: string; // MIME type e.g. "image/png", "application/pdf"
|
||||
filePath?: string; // original filesystem path (Electron only)
|
||||
}
|
||||
export type { UploadedFile } from '../../infrastructure/ai/types';
|
||||
|
||||
/** Reject only known binary blobs that AI models can't process */
|
||||
const REJECTED_MIME_PREFIXES = ['video/', 'audio/'];
|
||||
@@ -38,42 +31,32 @@ async function fileToDataUrl(file: File): Promise<{ dataUrl: string; base64: str
|
||||
});
|
||||
}
|
||||
|
||||
export function useFileUpload() {
|
||||
const [files, setFiles] = useState<UploadedFile[]>([]);
|
||||
export async function convertFilesToUploads(inputFiles: File[]): Promise<UploadedFile[]> {
|
||||
const supported = inputFiles.filter(isSupportedFile);
|
||||
if (supported.length === 0) return [];
|
||||
|
||||
const addFiles = useCallback(async (inputFiles: File[]) => {
|
||||
const supported = inputFiles.filter(isSupportedFile);
|
||||
if (supported.length === 0) return;
|
||||
|
||||
const newFiles: UploadedFile[] = await Promise.all(
|
||||
supported.map(async (file) => {
|
||||
const id = crypto.randomUUID();
|
||||
const filename = file.name || `file-${Date.now()}`;
|
||||
const mediaType = file.type || 'application/octet-stream';
|
||||
let dataUrl = '';
|
||||
let base64Data = '';
|
||||
try {
|
||||
const result = await fileToDataUrl(file);
|
||||
dataUrl = result.dataUrl;
|
||||
base64Data = result.base64;
|
||||
} catch (err) {
|
||||
console.error('[useFileUpload] Failed to convert:', err);
|
||||
}
|
||||
const uploads: Array<UploadedFile | null> = await Promise.all(
|
||||
supported.map(async (file) => {
|
||||
const id = crypto.randomUUID();
|
||||
const filename = file.name || `file-${Date.now()}`;
|
||||
const mediaType = file.type || 'application/octet-stream';
|
||||
try {
|
||||
const result = await fileToDataUrl(file);
|
||||
const filePath = getPathForFile(file);
|
||||
return { id, filename, dataUrl, base64Data, mediaType, filePath };
|
||||
}),
|
||||
);
|
||||
return {
|
||||
id,
|
||||
filename,
|
||||
dataUrl: result.dataUrl,
|
||||
base64Data: result.base64,
|
||||
mediaType,
|
||||
filePath,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[useFileUpload] Failed to convert:', err);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
setFiles((prev) => [...prev, ...newFiles]);
|
||||
}, []);
|
||||
|
||||
const removeFile = useCallback((id: string) => {
|
||||
setFiles((prev) => prev.filter((f) => f.id !== id));
|
||||
}, []);
|
||||
|
||||
const clearFiles = useCallback(() => {
|
||||
setFiles([]);
|
||||
}, []);
|
||||
|
||||
return { files, addFiles, removeFile, clearFiles };
|
||||
return uploads.filter((upload): upload is UploadedFile => upload !== null);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ interface HotkeyActions {
|
||||
openHosts: () => void;
|
||||
openSftp: () => void;
|
||||
quickSwitch: () => void;
|
||||
newWorkspace: () => void;
|
||||
commandPalette: () => void;
|
||||
portForwarding: () => void;
|
||||
snippets: () => void;
|
||||
@@ -61,6 +62,7 @@ export const getAppLevelActions = (): Set<string> => {
|
||||
'openHosts',
|
||||
'openSftp',
|
||||
'quickSwitch',
|
||||
'newWorkspace',
|
||||
'commandPalette',
|
||||
'portForwarding',
|
||||
'snippets',
|
||||
@@ -77,6 +79,7 @@ export const getTerminalPassthroughActions = (): Set<string> => {
|
||||
return new Set([
|
||||
'copy',
|
||||
'paste',
|
||||
'pasteSelection',
|
||||
'selectAll',
|
||||
'clearBuffer',
|
||||
'searchTerminal',
|
||||
@@ -167,6 +170,9 @@ export const useGlobalHotkeys = ({
|
||||
case 'quickSwitch':
|
||||
currentActions.quickSwitch?.();
|
||||
break;
|
||||
case 'newWorkspace':
|
||||
currentActions.newWorkspace?.();
|
||||
break;
|
||||
case 'commandPalette':
|
||||
currentActions.commandPalette?.();
|
||||
break;
|
||||
|
||||
95
application/state/useLocalVaultBackups.ts
Normal file
95
application/state/useLocalVaultBackups.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
type LocalVaultBackupPreview,
|
||||
getLocalVaultBackupCapabilities,
|
||||
getLocalVaultBackupMaxCount,
|
||||
listLocalVaultBackups,
|
||||
openLocalVaultBackupDir,
|
||||
readLocalVaultBackup,
|
||||
setLocalVaultBackupMaxCount,
|
||||
trimLocalVaultBackups,
|
||||
} from '../localVaultBackups';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function useLocalVaultBackups() {
|
||||
const [backups, setBackups] = useState<LocalVaultBackupPreview[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [maxBackups, setMaxBackupsState] = useState(() => getLocalVaultBackupMaxCount());
|
||||
// `null` while we're still asking the main process. The UI should treat
|
||||
// `null` as "unknown, don't render restore controls yet" so we never expose
|
||||
// a destructive action that might later be disabled.
|
||||
const [encryptionAvailable, setEncryptionAvailable] = useState<boolean | null>(null);
|
||||
|
||||
const refreshBackups = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const next = await listLocalVaultBackups();
|
||||
setBackups(next);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const caps = await getLocalVaultBackupCapabilities();
|
||||
if (!cancelled) {
|
||||
setEncryptionAvailable(caps.encryptionAvailable);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setEncryptionAvailable(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
void refreshBackups();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [refreshBackups]);
|
||||
|
||||
// Cross-window live refresh: the main process broadcasts when any
|
||||
// renderer's createBackup or trimBackups actually mutated the on-disk
|
||||
// set. Without this subscription, a protective backup written by the
|
||||
// main window wouldn't show up in the Settings window's list until
|
||||
// the user manually navigated away and back, silently under-reporting
|
||||
// the most recent recovery points.
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
const subscribe = bridge?.onVaultBackupsChanged;
|
||||
if (typeof subscribe !== 'function') return undefined;
|
||||
const unsubscribe = subscribe(() => {
|
||||
void refreshBackups();
|
||||
});
|
||||
return () => {
|
||||
try { unsubscribe?.(); } catch { /* ignore */ }
|
||||
};
|
||||
}, [refreshBackups]);
|
||||
|
||||
const updateMaxBackups = useCallback(async (value: number) => {
|
||||
const sanitized = setLocalVaultBackupMaxCount(value);
|
||||
setMaxBackupsState(sanitized);
|
||||
await trimLocalVaultBackups(sanitized);
|
||||
await refreshBackups();
|
||||
return sanitized;
|
||||
}, [refreshBackups]);
|
||||
|
||||
const openBackupDirectory = useCallback(async () => {
|
||||
await openLocalVaultBackupDir();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
backups,
|
||||
isLoading,
|
||||
maxBackups,
|
||||
encryptionAvailable,
|
||||
refreshBackups,
|
||||
readBackup: readLocalVaultBackup,
|
||||
setMaxBackups: updateMaxBackups,
|
||||
openBackupDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
export default useLocalVaultBackups;
|
||||
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,26 +18,97 @@ import {
|
||||
import { logger } from "../../lib/logger";
|
||||
|
||||
export interface UsePortForwardingAutoStartOptions {
|
||||
isVaultInitialized: boolean;
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
proxyProfiles: ProxyProfile[];
|
||||
groupConfigs: GroupConfig[];
|
||||
}
|
||||
|
||||
const AUTO_START_PROXY_NOT_READY_ERROR = "Proxy or jump host configuration is not ready";
|
||||
const AUTO_START_AUTH_NOT_READY_ERROR = "Host authentication configuration is not ready";
|
||||
|
||||
export const isAutoStartProxyReady = (
|
||||
host: Host,
|
||||
allHosts: Host[],
|
||||
proxyProfiles: ProxyProfile[],
|
||||
groupConfigs: GroupConfig[],
|
||||
seen = new Set<string>(),
|
||||
): boolean => {
|
||||
if (!host || seen.has(host.id)) return true;
|
||||
seen.add(host.id);
|
||||
|
||||
const validProxyProfileIds: ReadonlySet<string> = new Set(proxyProfiles.map((profile) => profile.id));
|
||||
const rawGroupDefaults = host.group
|
||||
? resolveGroupDefaults(host.group, groupConfigs)
|
||||
: {};
|
||||
const groupDefaults = host.group
|
||||
? resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds })
|
||||
: {};
|
||||
const missingHostProxyProfile = Boolean(
|
||||
host.proxyProfileId && !validProxyProfileIds.has(host.proxyProfileId),
|
||||
);
|
||||
const missingGroupProxyProfile = Boolean(
|
||||
!host.proxyConfig &&
|
||||
!host.proxyProfileId &&
|
||||
rawGroupDefaults.proxyProfileId &&
|
||||
!validProxyProfileIds.has(rawGroupDefaults.proxyProfileId),
|
||||
);
|
||||
const effectiveHost = applyGroupDefaults(host, groupDefaults, { validProxyProfileIds });
|
||||
const hasProxyReplacement = Boolean(
|
||||
effectiveHost.proxyConfig ||
|
||||
(effectiveHost.proxyProfileId && validProxyProfileIds.has(effectiveHost.proxyProfileId)),
|
||||
);
|
||||
|
||||
if ((missingHostProxyProfile || missingGroupProxyProfile) && !hasProxyReplacement) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const chainIds = effectiveHost.hostChain?.hostIds || [];
|
||||
for (const chainId of chainIds) {
|
||||
const chainHost = allHosts.find((candidate) => candidate.id === chainId);
|
||||
if (!chainHost) return false;
|
||||
if (!isAutoStartProxyReady(chainHost, allHosts, proxyProfiles, groupConfigs, seen)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getAutoStartRuleBlockReason = (
|
||||
rule: PortForwardingRule,
|
||||
hosts: Host[],
|
||||
proxyProfiles: ProxyProfile[],
|
||||
groupConfigs: GroupConfig[],
|
||||
isHostAuthReady: (host: Host) => boolean,
|
||||
): string | undefined => {
|
||||
if (!rule.hostId) return "Rule host is not configured";
|
||||
const host = hosts.find((candidate) => candidate.id === rule.hostId);
|
||||
if (!host) return "Host not found";
|
||||
if (!isHostAuthReady(host)) return AUTO_START_AUTH_NOT_READY_ERROR;
|
||||
if (!isAutoStartProxyReady(host, hosts, proxyProfiles, groupConfigs)) {
|
||||
return AUTO_START_PROXY_NOT_READY_ERROR;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Auto-starts port forwarding rules that have autoStart enabled.
|
||||
* This hook should be called at the App level to run on app launch.
|
||||
*/
|
||||
export const usePortForwardingAutoStart = ({
|
||||
isVaultInitialized,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
groupConfigs,
|
||||
}: UsePortForwardingAutoStartOptions): void => {
|
||||
const autoStartExecutedRef = useRef(false);
|
||||
const hostsRef = useRef<Host[]>(hosts);
|
||||
const keysRef = useRef<SSHKey[]>(keys);
|
||||
const identitiesRef = useRef<Identity[]>(identities);
|
||||
const proxyProfilesRef = useRef<ProxyProfile[]>(proxyProfiles);
|
||||
const groupConfigsRef = useRef<GroupConfig[]>(groupConfigs);
|
||||
|
||||
const isHostAuthReady = useCallback((host: Host, seen = new Set<string>()): boolean => {
|
||||
@@ -77,16 +149,53 @@ export const usePortForwardingAutoStart = ({
|
||||
identitiesRef.current = identities;
|
||||
}, [identities]);
|
||||
|
||||
useEffect(() => {
|
||||
proxyProfilesRef.current = proxyProfiles;
|
||||
}, [proxyProfiles]);
|
||||
|
||||
useEffect(() => {
|
||||
groupConfigsRef.current = groupConfigs;
|
||||
}, [groupConfigs]);
|
||||
|
||||
const resolveEffectiveHost = useCallback((host: Host): Host => {
|
||||
if (!host.group) return host;
|
||||
const defaults = resolveGroupDefaults(host.group, groupConfigsRef.current);
|
||||
return applyGroupDefaults(host, defaults);
|
||||
const validProxyProfileIds: ReadonlySet<string> = new Set(proxyProfilesRef.current.map((profile) => profile.id));
|
||||
const withGroupDefaults = host.group
|
||||
? applyGroupDefaults(
|
||||
host,
|
||||
resolveGroupDefaults(host.group, groupConfigsRef.current, { validProxyProfileIds }),
|
||||
{ validProxyProfileIds },
|
||||
)
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds });
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfilesRef.current);
|
||||
}, []);
|
||||
|
||||
const resolveEffectiveHosts = useCallback(
|
||||
(items: Host[]): Host[] => items.map((host) => resolveEffectiveHost(host)),
|
||||
[resolveEffectiveHost],
|
||||
);
|
||||
|
||||
const updateStoredRuleStatus = useCallback(
|
||||
(ruleId: string, status: PortForwardingRule["status"], error?: string) => {
|
||||
const currentRules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
) ?? [];
|
||||
|
||||
const updatedRules = currentRules.map((rule) =>
|
||||
rule.id === ruleId
|
||||
? {
|
||||
...rule,
|
||||
status,
|
||||
error,
|
||||
lastUsedAt: status === "active" ? Date.now() : rule.lastUsedAt,
|
||||
}
|
||||
: rule,
|
||||
);
|
||||
|
||||
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, updatedRules);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Set up the reconnect callback
|
||||
useEffect(() => {
|
||||
const handleReconnect = async (
|
||||
@@ -99,40 +208,49 @@ export const usePortForwardingAutoStart = ({
|
||||
) ?? [];
|
||||
|
||||
const rule = rules.find((r) => r.id === ruleId);
|
||||
if (!rule || !rule.hostId) {
|
||||
return { success: false, error: "Rule or host not found" };
|
||||
if (!rule) {
|
||||
const error = "Rule not found";
|
||||
onStatusChange("error", error);
|
||||
return { success: false, error };
|
||||
}
|
||||
if (!rule.hostId) {
|
||||
const error = "Rule host is not configured";
|
||||
onStatusChange("error", error);
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
const rawHost = hostsRef.current.find((h) => h.id === rule.hostId);
|
||||
if (!rawHost) {
|
||||
return { success: false, error: "Host not found" };
|
||||
const error = "Host not found";
|
||||
onStatusChange("error", error);
|
||||
return { success: false, error };
|
||||
}
|
||||
const blockReason = getAutoStartRuleBlockReason(
|
||||
rule,
|
||||
hostsRef.current,
|
||||
proxyProfilesRef.current,
|
||||
groupConfigsRef.current,
|
||||
(host) => isHostAuthReady(host),
|
||||
);
|
||||
if (blockReason) {
|
||||
onStatusChange("error", blockReason);
|
||||
return { success: false, error: blockReason };
|
||||
}
|
||||
|
||||
const host = resolveEffectiveHost(rawHost);
|
||||
return startPortForward(rule, host, hostsRef.current, keysRef.current, identitiesRef.current, onStatusChange, true);
|
||||
return startPortForward(rule, host, resolveEffectiveHosts(hostsRef.current), keysRef.current, identitiesRef.current, onStatusChange, true);
|
||||
};
|
||||
|
||||
setReconnectCallback(handleReconnect);
|
||||
return () => {
|
||||
setReconnectCallback(null);
|
||||
};
|
||||
}, [resolveEffectiveHost]);
|
||||
}, [isHostAuthReady, resolveEffectiveHost, resolveEffectiveHosts]);
|
||||
|
||||
// Auto-start rules on app launch
|
||||
useEffect(() => {
|
||||
if (autoStartExecutedRef.current) return;
|
||||
if (hosts.length === 0) return;
|
||||
|
||||
const storedRules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
) ?? [];
|
||||
const pendingAutoStartRules = storedRules.filter((rule) => rule.autoStart && rule.hostId);
|
||||
if (pendingAutoStartRules.some((rule) => {
|
||||
const host = hosts.find((candidate) => candidate.id === rule.hostId);
|
||||
return !host || !isHostAuthReady(host);
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
if (!isVaultInitialized) return;
|
||||
|
||||
// Mark as executed immediately to prevent duplicate runs
|
||||
// (React StrictMode or dependency changes could cause re-runs)
|
||||
@@ -149,7 +267,7 @@ export const usePortForwardingAutoStart = ({
|
||||
|
||||
// Only start rules that are not already active
|
||||
const autoStartRules = rules.filter((r) => {
|
||||
if (!r.autoStart || !r.hostId) return false;
|
||||
if (!r.autoStart) return false;
|
||||
// Check if there's an active connection for this rule
|
||||
const conn = getActiveConnection(r.id);
|
||||
// Only start if not already connecting or active
|
||||
@@ -162,39 +280,45 @@ export const usePortForwardingAutoStart = ({
|
||||
// Start each auto-start rule
|
||||
for (const rule of autoStartRules) {
|
||||
const rawHost = hosts.find((h) => h.id === rule.hostId);
|
||||
if (rawHost) {
|
||||
const host = resolveEffectiveHost(rawHost);
|
||||
void startPortForward(
|
||||
rule,
|
||||
host,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
(status, error) => {
|
||||
// Update the rule status in storage
|
||||
const currentRules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
) ?? [];
|
||||
|
||||
const updatedRules = currentRules.map((r) =>
|
||||
r.id === rule.id
|
||||
? {
|
||||
...r,
|
||||
status,
|
||||
error,
|
||||
lastUsedAt: status === "active" ? Date.now() : r.lastUsedAt,
|
||||
}
|
||||
: r,
|
||||
);
|
||||
|
||||
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, updatedRules);
|
||||
},
|
||||
true, // Enable reconnect for auto-start rules
|
||||
);
|
||||
const blockReason = getAutoStartRuleBlockReason(
|
||||
rule,
|
||||
hosts,
|
||||
proxyProfiles,
|
||||
groupConfigs,
|
||||
(host) => isHostAuthReady(host),
|
||||
);
|
||||
if (blockReason) {
|
||||
updateStoredRuleStatus(rule.id, "error", blockReason);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!rawHost) continue;
|
||||
const host = resolveEffectiveHost(rawHost);
|
||||
void startPortForward(
|
||||
rule,
|
||||
host,
|
||||
resolveEffectiveHosts(hosts),
|
||||
keys,
|
||||
identities,
|
||||
(status, error) => {
|
||||
updateStoredRuleStatus(rule.id, status, error);
|
||||
},
|
||||
true, // Enable reconnect for auto-start rules
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
void runAutoStart();
|
||||
}, [hosts, identities, isHostAuthReady, keys, resolveEffectiveHost]);
|
||||
}, [
|
||||
groupConfigs,
|
||||
hosts,
|
||||
identities,
|
||||
isHostAuthReady,
|
||||
isVaultInitialized,
|
||||
keys,
|
||||
proxyProfiles,
|
||||
resolveEffectiveHost,
|
||||
resolveEffectiveHosts,
|
||||
updateStoredRuleStatus,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MouseEvent,useCallback,useMemo,useState } from 'react';
|
||||
import { MouseEvent,useCallback,useMemo,useRef,useState } from 'react';
|
||||
import { ConnectionLog,Host,SerialConfig,Snippet,TerminalSession,Workspace,WorkspaceViewMode } from '../../domain/models';
|
||||
import {
|
||||
appendPaneToWorkspaceRoot,
|
||||
collectSessionIds,
|
||||
createWorkspaceFromSessions as createWorkspaceEntity,
|
||||
createWorkspaceFromSessionIds,
|
||||
@@ -24,6 +25,12 @@ export interface LogView {
|
||||
export const useSessionState = () => {
|
||||
const [sessions, setSessions] = useState<TerminalSession[]>([]);
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||
// Latest workspaces snapshot for synchronous existence checks outside
|
||||
// setWorkspaces updaters — React doesn't guarantee updaters run
|
||||
// synchronously, so relying on a flag flipped inside them to decide
|
||||
// whether to also call setSessions is racy and can leave orphan panes.
|
||||
const workspacesRef = useRef(workspaces);
|
||||
workspacesRef.current = workspaces;
|
||||
// activeTabId is now managed by external store - components subscribe directly
|
||||
const setActiveTabId = activeTabStore.setActiveTabId;
|
||||
const [draggingSessionId, setDraggingSessionId] = useState<string | null>(null);
|
||||
@@ -141,19 +148,48 @@ export const useSessionState = () => {
|
||||
setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s));
|
||||
}, []);
|
||||
|
||||
const closeWorkspace = useCallback((workspaceId: string) => {
|
||||
setWorkspaces(prevWorkspaces => {
|
||||
const remainingWorkspaces = prevWorkspaces.filter(w => w.id !== workspaceId);
|
||||
|
||||
setSessions(prevSessions => prevSessions.filter(s => s.workspaceId !== workspaceId));
|
||||
|
||||
const currentActiveTabId = activeTabStore.getActiveTabId();
|
||||
if (currentActiveTabId === workspaceId) {
|
||||
if (remainingWorkspaces.length > 0) {
|
||||
setActiveTabId(remainingWorkspaces[remainingWorkspaces.length - 1].id);
|
||||
} else {
|
||||
setActiveTabId('vault');
|
||||
}
|
||||
}
|
||||
|
||||
return remainingWorkspaces;
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const closeSession = useCallback((sessionId: string, e?: MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
|
||||
|
||||
// Pre-compute outside the setSessions updater so we don't depend on React
|
||||
// having run the updater by the time we queue the microtask. React 18+ does
|
||||
// not guarantee updater execution timing under concurrent scheduling.
|
||||
const sessionBeingClosed = sessions.find(s => s.id === sessionId);
|
||||
const workspaceIdToMaybeClose =
|
||||
sessionBeingClosed?.workspaceId &&
|
||||
sessions.every(s => s.id === sessionId || s.workspaceId !== sessionBeingClosed.workspaceId)
|
||||
? sessionBeingClosed.workspaceId
|
||||
: undefined;
|
||||
|
||||
setSessions(prevSessions => {
|
||||
const targetSession = prevSessions.find(s => s.id === sessionId);
|
||||
const wsId = targetSession?.workspaceId;
|
||||
|
||||
|
||||
setWorkspaces(prevWorkspaces => {
|
||||
let removedWorkspaceId: string | null = null;
|
||||
let nextWorkspaces = prevWorkspaces;
|
||||
let dissolvedWorkspaceId: string | null = null;
|
||||
let lastRemainingSessionId: string | null = null;
|
||||
|
||||
|
||||
if (wsId) {
|
||||
nextWorkspaces = prevWorkspaces
|
||||
.map(ws => {
|
||||
@@ -163,7 +199,7 @@ export const useSessionState = () => {
|
||||
removedWorkspaceId = ws.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Check if only 1 session remains - dissolve workspace
|
||||
const remainingSessionIds = collectSessionIds(pruned);
|
||||
if (remainingSessionIds.length === 1) {
|
||||
@@ -171,12 +207,12 @@ export const useSessionState = () => {
|
||||
lastRemainingSessionId = remainingSessionIds[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return { ...ws, root: pruned };
|
||||
})
|
||||
.filter((ws): ws is Workspace => Boolean(ws));
|
||||
}
|
||||
|
||||
|
||||
const remainingSessions = prevSessions.filter(s => s.id !== sessionId);
|
||||
const fallbackWorkspace = nextWorkspaces[nextWorkspaces.length - 1];
|
||||
const fallbackSolo = remainingSessions.filter(s => !s.workspaceId).slice(-1)[0];
|
||||
@@ -198,10 +234,10 @@ export const useSessionState = () => {
|
||||
} else if (wsId && currentActiveTabId === wsId && !nextWorkspaces.find(w => w.id === wsId)) {
|
||||
setActiveTabId(getFallback());
|
||||
}
|
||||
|
||||
|
||||
return nextWorkspaces;
|
||||
});
|
||||
|
||||
|
||||
// Check if we need to dissolve a workspace (convert remaining session to orphan)
|
||||
if (targetSession?.workspaceId) {
|
||||
const ws = workspaces.find(w => w.id === targetSession.workspaceId);
|
||||
@@ -218,29 +254,14 @@ export const useSessionState = () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return prevSessions.filter(s => s.id !== sessionId);
|
||||
});
|
||||
}, [workspaces, setActiveTabId]);
|
||||
|
||||
const closeWorkspace = useCallback((workspaceId: string) => {
|
||||
setWorkspaces(prevWorkspaces => {
|
||||
const remainingWorkspaces = prevWorkspaces.filter(w => w.id !== workspaceId);
|
||||
|
||||
setSessions(prevSessions => prevSessions.filter(s => s.workspaceId !== workspaceId));
|
||||
|
||||
const currentActiveTabId = activeTabStore.getActiveTabId();
|
||||
if (currentActiveTabId === workspaceId) {
|
||||
if (remainingWorkspaces.length > 0) {
|
||||
setActiveTabId(remainingWorkspaces[remainingWorkspaces.length - 1].id);
|
||||
} else {
|
||||
setActiveTabId('vault');
|
||||
}
|
||||
}
|
||||
|
||||
return remainingWorkspaces;
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
return prevSessions.filter(s => s.id !== sessionId);
|
||||
});
|
||||
|
||||
if (workspaceIdToMaybeClose) {
|
||||
queueMicrotask(() => closeWorkspace(workspaceIdToMaybeClose!));
|
||||
}
|
||||
}, [sessions, workspaces, setActiveTabId, closeWorkspace]);
|
||||
|
||||
const startSessionRename = useCallback((sessionId: string) => {
|
||||
setSessions(prevSessions => {
|
||||
@@ -369,6 +390,89 @@ export const useSessionState = () => {
|
||||
setActiveTabId(workspace.id);
|
||||
}, [setActiveTabId]);
|
||||
|
||||
// Like createWorkspaceWithHosts but supports mixed targets — each
|
||||
// entry is either an SSH host or a local terminal. Used by the
|
||||
// "New Workspace" flow in QuickSwitcher.
|
||||
type WorkspaceTarget =
|
||||
| { kind: 'local'; shellType?: TerminalSession['shellType']; shell?: string; shellArgs?: string[]; shellName?: string; shellIcon?: string }
|
||||
| { kind: 'host'; host: Host };
|
||||
|
||||
const createWorkspaceFromTargets = useCallback((targets: WorkspaceTarget[], name: string = 'Workspace'): string | null => {
|
||||
if (targets.length === 0) return null;
|
||||
|
||||
const newSessions: TerminalSession[] = targets.map((target) => {
|
||||
if (target.kind === 'local') {
|
||||
const sessionId = crypto.randomUUID();
|
||||
return {
|
||||
id: sessionId,
|
||||
hostId: `local-${sessionId}`,
|
||||
hostLabel: target.shellName || 'Local Terminal',
|
||||
hostname: 'localhost',
|
||||
username: 'local',
|
||||
status: 'connecting',
|
||||
protocol: 'local',
|
||||
shellType: target.shellType,
|
||||
localShell: target.shell,
|
||||
localShellArgs: target.shellArgs,
|
||||
localShellName: target.shellName,
|
||||
localShellIcon: target.shellIcon,
|
||||
};
|
||||
}
|
||||
const host = target.host;
|
||||
if (host.protocol === 'serial') {
|
||||
const serialConfig: SerialConfig = host.serialConfig || {
|
||||
path: host.hostname,
|
||||
baudRate: host.port || 115200,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: 'none',
|
||||
flowControl: 'none',
|
||||
localEcho: false,
|
||||
lineMode: false,
|
||||
};
|
||||
const portName = serialConfig.path.split('/').pop() || serialConfig.path;
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
hostId: host.id,
|
||||
hostLabel: host.label || `Serial: ${portName}`,
|
||||
hostname: serialConfig.path,
|
||||
username: '',
|
||||
status: 'connecting',
|
||||
protocol: 'serial',
|
||||
serialConfig,
|
||||
charset: host.charset,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: host.username,
|
||||
status: 'connecting',
|
||||
protocol: host.protocol,
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
charset: host.charset,
|
||||
};
|
||||
});
|
||||
|
||||
const sessionIds = newSessions.map((s) => s.id);
|
||||
// Default to focus-mode (sidebar layout) regardless of target
|
||||
// count — matches the intent behind the QuickSwitcher "New
|
||||
// Workspace" flow, which the user expects to land in focus view.
|
||||
const workspace = createWorkspaceFromSessionIds(sessionIds, {
|
||||
title: name,
|
||||
viewMode: 'focus',
|
||||
});
|
||||
const sessionsWithWorkspace = newSessions.map((s) => ({ ...s, workspaceId: workspace.id }));
|
||||
|
||||
setSessions((prev) => [...prev, ...sessionsWithWorkspace]);
|
||||
setWorkspaces((prev) => [...prev, workspace]);
|
||||
setActiveTabId(workspace.id);
|
||||
return workspace.id;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const createWorkspaceFromSessions = useCallback((
|
||||
baseSessionId: string,
|
||||
joiningSessionId: string,
|
||||
@@ -420,6 +524,118 @@ export const useSessionState = () => {
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
|
||||
// Add a host into an existing workspace by creating a new session for
|
||||
// that host and appending it as the last pane at the workspace root.
|
||||
// Sibling sizes are rebalanced equally by appendPaneToWorkspaceRoot.
|
||||
// Unlike addSessionToWorkspace (which takes a pre-created orphan
|
||||
// session and a SplitHint), this is atomic — the new session is born
|
||||
// already bound to the target workspace and focused.
|
||||
const appendHostToWorkspace = useCallback((
|
||||
workspaceId: string,
|
||||
host: Host,
|
||||
direction: SplitDirection = 'vertical',
|
||||
): string | null => {
|
||||
// Serial hosts use a different session constructor; they currently
|
||||
// only enter workspaces via createSerialSession + drag, so reject
|
||||
// them here to avoid a partially-constructed session.
|
||||
if (host.protocol === 'serial') return null;
|
||||
|
||||
// Cheap early-exit using the ref when the workspace is clearly
|
||||
// absent. The authoritative check lives inside the setWorkspaces
|
||||
// updater below so we also cover the concurrent-close race.
|
||||
if (!workspacesRef.current.some(w => w.id === workspaceId)) return null;
|
||||
|
||||
const newSessionId = crypto.randomUUID();
|
||||
const newSession: TerminalSession = {
|
||||
id: newSessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: host.username,
|
||||
status: 'connecting',
|
||||
protocol: host.protocol,
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
charset: host.charset,
|
||||
workspaceId,
|
||||
};
|
||||
|
||||
// Nest setSessions + setActiveTabId inside the setWorkspaces updater
|
||||
// so we only commit the session when the workspace update actually
|
||||
// matched — otherwise a concurrent closeWorkspace between the ref
|
||||
// check and the updater firing would leave an orphan session with a
|
||||
// workspaceId pointing at nothing, and active tab would jump to a
|
||||
// closed id. The inner setSessions is idempotent (id dedupe) so
|
||||
// StrictMode's dev-time double-invoke does not duplicate the row.
|
||||
setWorkspaces(prev => {
|
||||
const target = prev.find(w => w.id === workspaceId);
|
||||
if (!target) return prev;
|
||||
setSessions(s => s.some(x => x.id === newSessionId) ? s : [...s, newSession]);
|
||||
setActiveTabId(workspaceId);
|
||||
return prev.map(ws => {
|
||||
if (ws.id !== workspaceId) return ws;
|
||||
return {
|
||||
...ws,
|
||||
root: appendPaneToWorkspaceRoot(ws.root, newSessionId, direction),
|
||||
focusedSessionId: newSessionId,
|
||||
};
|
||||
});
|
||||
});
|
||||
return newSessionId;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
// Atomic "append a local terminal pane" — mirror of appendHostToWorkspace
|
||||
// but constructs a local-protocol session instead of an SSH one.
|
||||
const appendLocalTerminalToWorkspace = useCallback((
|
||||
workspaceId: string,
|
||||
options?: {
|
||||
shellType?: TerminalSession['shellType'];
|
||||
shell?: string;
|
||||
shellArgs?: string[];
|
||||
shellName?: string;
|
||||
shellIcon?: string;
|
||||
},
|
||||
direction: SplitDirection = 'vertical',
|
||||
): string | null => {
|
||||
// Same pattern as appendHostToWorkspace — ref guard + authoritative
|
||||
// inside-updater match to cover concurrent closeWorkspace.
|
||||
if (!workspacesRef.current.some(w => w.id === workspaceId)) return null;
|
||||
|
||||
const newSessionId = crypto.randomUUID();
|
||||
const localHostId = `local-${newSessionId}`;
|
||||
const newSession: TerminalSession = {
|
||||
id: newSessionId,
|
||||
hostId: localHostId,
|
||||
hostLabel: options?.shellName || 'Local Terminal',
|
||||
hostname: 'localhost',
|
||||
username: 'local',
|
||||
status: 'connecting',
|
||||
protocol: 'local',
|
||||
shellType: options?.shellType,
|
||||
localShell: options?.shell,
|
||||
localShellArgs: options?.shellArgs,
|
||||
localShellName: options?.shellName,
|
||||
localShellIcon: options?.shellIcon,
|
||||
workspaceId,
|
||||
};
|
||||
|
||||
setWorkspaces(prev => {
|
||||
const target = prev.find(w => w.id === workspaceId);
|
||||
if (!target) return prev;
|
||||
setSessions(s => s.some(x => x.id === newSessionId) ? s : [...s, newSession]);
|
||||
setActiveTabId(workspaceId);
|
||||
return prev.map(ws => {
|
||||
if (ws.id !== workspaceId) return ws;
|
||||
return {
|
||||
...ws,
|
||||
root: appendPaneToWorkspaceRoot(ws.root, newSessionId, direction),
|
||||
focusedSessionId: newSessionId,
|
||||
};
|
||||
});
|
||||
});
|
||||
return newSessionId;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const updateSplitSizes = useCallback((workspaceId: string, splitId: string, sizes: number[]) => {
|
||||
setWorkspaces(prev => prev.map(ws => {
|
||||
if (ws.id !== workspaceId) return ws;
|
||||
@@ -654,16 +870,22 @@ export const useSessionState = () => {
|
||||
const copySession = useCallback((sessionId: string, options?: {
|
||||
localShellType?: TerminalSession['shellType'];
|
||||
}) => {
|
||||
// Pre-allocate the new id outside the updater so StrictMode's
|
||||
// double-invocation of the functional updater doesn't mint two ids.
|
||||
const newSessionId = crypto.randomUUID();
|
||||
|
||||
setSessions(prevSessions => {
|
||||
const session = prevSessions.find(s => s.id === sessionId);
|
||||
// Source may have been closed between the user's action and this
|
||||
// update running; in that case skip entirely — do NOT switch the
|
||||
// active tab or insert into tabOrder, which would leave dangling ids.
|
||||
if (!session) return prevSessions;
|
||||
const nextShellType = session.protocol === 'local'
|
||||
? options?.localShellType
|
||||
: session.shellType;
|
||||
|
||||
// Create a new session with the same connection info
|
||||
const newSession: TerminalSession = {
|
||||
id: crypto.randomUUID(),
|
||||
id: newSessionId,
|
||||
hostId: session.hostId,
|
||||
hostLabel: session.hostLabel,
|
||||
hostname: session.hostname,
|
||||
@@ -681,10 +903,40 @@ export const useSessionState = () => {
|
||||
localShellIcon: session.localShellIcon,
|
||||
};
|
||||
|
||||
setActiveTabId(newSession.id);
|
||||
// Schedule the activeTab + tabOrder updates only when creation
|
||||
// actually happens. These nested setStates are idempotent, so
|
||||
// StrictMode's double-invocation is harmless.
|
||||
setActiveTabId(newSessionId);
|
||||
setTabOrder(prevTabOrder => {
|
||||
// Fast path: source is already tracked in tabOrder — splice directly.
|
||||
const directIdx = prevTabOrder.indexOf(sessionId);
|
||||
if (directIdx !== -1) {
|
||||
const next = [...prevTabOrder];
|
||||
next.splice(directIdx + 1, 0, newSessionId);
|
||||
return next;
|
||||
}
|
||||
// Fallback: source is only in the derived tab collections. Rebuild the
|
||||
// effective order (same pattern as reorderTabs) to locate its position.
|
||||
const allTabIds = [
|
||||
...orphanSessions.map(s => s.id),
|
||||
...workspaces.map(w => w.id),
|
||||
...logViews.map(lv => lv.id),
|
||||
];
|
||||
const allTabIdSet = new Set(allTabIds);
|
||||
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
|
||||
const orderedIdSet = new Set(orderedIds);
|
||||
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
|
||||
const currentOrder = [...orderedIds, ...newIds];
|
||||
const sourceIdx = currentOrder.indexOf(sessionId);
|
||||
if (sourceIdx === -1) return [...prevTabOrder, newSessionId];
|
||||
const next = [...currentOrder];
|
||||
next.splice(sourceIdx + 1, 0, newSessionId);
|
||||
return next;
|
||||
});
|
||||
|
||||
return [...prevSessions, newSession];
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
}, [orphanSessions, workspaces, logViews, setActiveTabId]);
|
||||
|
||||
// Toggle broadcast mode for a workspace
|
||||
const toggleBroadcast = useCallback((workspaceId: string) => {
|
||||
@@ -788,8 +1040,11 @@ export const useSessionState = () => {
|
||||
closeWorkspace,
|
||||
updateSessionStatus,
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromTargets,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendLocalTerminalToWorkspace,
|
||||
updateSplitSizes,
|
||||
splitSession,
|
||||
toggleWorkspaceViewMode,
|
||||
|
||||
@@ -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,7 +40,16 @@ 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 { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
@@ -124,6 +133,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 +186,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);
|
||||
@@ -231,8 +250,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 +349,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 +384,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 {
|
||||
@@ -456,11 +524,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 +561,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;
|
||||
@@ -616,14 +684,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 +720,7 @@ export const useSettingsState = () => {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
}, [mergeIncomingTerminalSettings, syncAppearanceFromStorage, syncCustomCssFromStorage]);
|
||||
}, [applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings, syncAppearanceFromStorage, syncCustomCssFromStorage]);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -752,11 +815,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
|
||||
@@ -908,7 +969,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 +1017,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 +1243,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 +1265,7 @@ export const useSettingsState = () => {
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
const currentTerminalTheme = useMemo(() => {
|
||||
let baseTheme: TerminalTheme;
|
||||
// When "Follow Application Theme" is enabled, pick the terminal theme
|
||||
// whose background matches the active UI theme preset.
|
||||
if (followAppTerminalTheme) {
|
||||
@@ -1218,13 +1273,17 @@ export const useSettingsState = () => {
|
||||
const mapped = getTerminalThemeForUiTheme(activeUiThemeId);
|
||||
if (mapped) {
|
||||
const found = TERMINAL_THEMES.find(t => t.id === mapped);
|
||||
if (found) return found;
|
||||
if (found) {
|
||||
baseTheme = found;
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
}
|
||||
}
|
||||
}
|
||||
return TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|
||||
baseTheme = TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|
||||
|| customThemes.find(t => t.id === terminalThemeId)
|
||||
|| TERMINAL_THEMES[0];
|
||||
}, [terminalThemeId, customThemes, followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId]);
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
}, [terminalThemeId, customThemes, followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
|
||||
|
||||
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
|
||||
key: K,
|
||||
|
||||
@@ -271,7 +271,7 @@ export const useSftpState = (
|
||||
|
||||
const {
|
||||
transfers,
|
||||
conflicts,
|
||||
conflicts: transferConflicts,
|
||||
activeTransfersCount,
|
||||
startTransfer,
|
||||
downloadToLocal,
|
||||
@@ -282,7 +282,7 @@ export const useSftpState = (
|
||||
retryTransfer,
|
||||
clearCompletedTransfers,
|
||||
dismissTransfer,
|
||||
resolveConflict,
|
||||
resolveConflict: resolveTransferConflict,
|
||||
} = useSftpTransfers({
|
||||
getActivePane,
|
||||
getPaneByConnectionId,
|
||||
@@ -301,14 +301,18 @@ export const useSftpState = (
|
||||
readTextFile,
|
||||
readBinaryFile,
|
||||
writeTextFile,
|
||||
writeTextFileByConnection,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
uploadExternalEntries,
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
activeFileWatchCountRef,
|
||||
uploadConflicts,
|
||||
resolveUploadConflict,
|
||||
} = useSftpExternalOperations({
|
||||
getActivePane,
|
||||
getPaneByConnectionId,
|
||||
refresh,
|
||||
sftpSessionsRef,
|
||||
connectionCacheKeyMapRef,
|
||||
@@ -320,6 +324,21 @@ export const useSftpState = (
|
||||
dismissExternalUpload: dismissTransfer,
|
||||
});
|
||||
|
||||
const conflicts = useMemo(
|
||||
() => [...transferConflicts, ...uploadConflicts],
|
||||
[transferConflicts, uploadConflicts],
|
||||
);
|
||||
const resolveAnyConflict = useCallback(
|
||||
(...args: Parameters<typeof resolveTransferConflict>) => {
|
||||
const [conflictId] = args;
|
||||
if (uploadConflicts.some((conflict) => conflict.transferId === conflictId)) {
|
||||
return resolveUploadConflict(...args);
|
||||
}
|
||||
return resolveTransferConflict(...args);
|
||||
},
|
||||
[resolveTransferConflict, resolveUploadConflict, uploadConflicts],
|
||||
);
|
||||
|
||||
// Store methods in a ref to create stable wrapper functions
|
||||
// This prevents callback reference changes from causing re-renders in consumers
|
||||
const methodsRef = useRef({
|
||||
@@ -359,6 +378,7 @@ export const useSftpState = (
|
||||
readTextFile,
|
||||
readBinaryFile,
|
||||
writeTextFile,
|
||||
writeTextFileByConnection,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
uploadExternalEntries,
|
||||
@@ -372,7 +392,7 @@ export const useSftpState = (
|
||||
retryTransfer,
|
||||
clearCompletedTransfers,
|
||||
dismissTransfer,
|
||||
resolveConflict,
|
||||
resolveConflict: resolveAnyConflict,
|
||||
getSftpIdForConnection,
|
||||
reportSessionError: handleSessionError,
|
||||
});
|
||||
@@ -413,6 +433,7 @@ export const useSftpState = (
|
||||
readTextFile,
|
||||
readBinaryFile,
|
||||
writeTextFile,
|
||||
writeTextFileByConnection,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
uploadExternalEntries,
|
||||
@@ -426,7 +447,7 @@ export const useSftpState = (
|
||||
retryTransfer,
|
||||
clearCompletedTransfers,
|
||||
dismissTransfer,
|
||||
resolveConflict,
|
||||
resolveConflict: resolveAnyConflict,
|
||||
getSftpIdForConnection,
|
||||
reportSessionError: handleSessionError,
|
||||
};
|
||||
@@ -476,6 +497,8 @@ 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),
|
||||
uploadExternalEntries: (...args: Parameters<typeof uploadExternalEntries>) =>
|
||||
@@ -490,7 +513,7 @@ export const useSftpState = (
|
||||
retryTransfer: (...args: Parameters<typeof retryTransfer>) => methodsRef.current.retryTransfer(...args),
|
||||
clearCompletedTransfers: () => methodsRef.current.clearCompletedTransfers(),
|
||||
dismissTransfer: (...args: Parameters<typeof dismissTransfer>) => methodsRef.current.dismissTransfer(...args),
|
||||
resolveConflict: (...args: Parameters<typeof resolveConflict>) => methodsRef.current.resolveConflict(...args),
|
||||
resolveConflict: (...args: Parameters<typeof resolveAnyConflict>) => methodsRef.current.resolveConflict(...args),
|
||||
getSftpIdForConnection: (...args: Parameters<typeof getSftpIdForConnection>) => methodsRef.current.getSftpIdForConnection(...args),
|
||||
reportSessionError: (...args: Parameters<typeof handleSessionError>) => methodsRef.current.reportSessionError(...args),
|
||||
activeFileWatchCountRef,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
KeyCategory,
|
||||
KnownHost,
|
||||
ManagedSource,
|
||||
ProxyProfile,
|
||||
ShellHistoryEntry,
|
||||
Snippet,
|
||||
SSHKey,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
STORAGE_KEY_KNOWN_HOSTS,
|
||||
STORAGE_KEY_LEGACY_KEYS,
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
STORAGE_KEY_PROXY_PROFILES,
|
||||
STORAGE_KEY_SHELL_HISTORY,
|
||||
STORAGE_KEY_SNIPPET_PACKAGES,
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
@@ -36,16 +38,19 @@ import {
|
||||
decryptHosts,
|
||||
decryptIdentities,
|
||||
decryptKeys,
|
||||
decryptProxyProfiles,
|
||||
encryptGroupConfigs,
|
||||
encryptHosts,
|
||||
encryptIdentities,
|
||||
encryptKeys,
|
||||
encryptProxyProfiles,
|
||||
} from "../../infrastructure/persistence/secureFieldAdapter";
|
||||
|
||||
type ExportableVaultData = {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities?: Identity[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
snippets: Snippet[];
|
||||
customGroups: string[];
|
||||
snippetPackages?: string[];
|
||||
@@ -102,9 +107,11 @@ const safeParse = <T,>(value: string | null): T | null => {
|
||||
};
|
||||
|
||||
export const useVaultState = () => {
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
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[]>([]);
|
||||
@@ -120,6 +127,7 @@ export const useVaultState = () => {
|
||||
const hostsWriteVersion = useRef(0);
|
||||
const keysWriteVersion = useRef(0);
|
||||
const identitiesWriteVersion = useRef(0);
|
||||
const proxyProfilesWriteVersion = useRef(0);
|
||||
const groupConfigsWriteVersion = useRef(0);
|
||||
|
||||
// Read-sequence counters for cross-window storage events. Each incoming
|
||||
@@ -129,13 +137,14 @@ export const useVaultState = () => {
|
||||
const hostsReadSeq = useRef(0);
|
||||
const keysReadSeq = useRef(0);
|
||||
const identitiesReadSeq = useRef(0);
|
||||
const proxyProfilesReadSeq = useRef(0);
|
||||
const groupConfigsReadSeq = useRef(0);
|
||||
|
||||
const updateHosts = useCallback((data: Host[]) => {
|
||||
const cleaned = data.map(sanitizeHost);
|
||||
setHosts(cleaned);
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
encryptHosts(cleaned).then((enc) => {
|
||||
return encryptHosts(cleaned).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
@@ -144,7 +153,7 @@ export const useVaultState = () => {
|
||||
const updateKeys = useCallback((data: SSHKey[]) => {
|
||||
setKeys(data);
|
||||
const ver = ++keysWriteVersion.current;
|
||||
encryptKeys(data).then((enc) => {
|
||||
return encryptKeys(data).then((enc) => {
|
||||
if (ver === keysWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
|
||||
});
|
||||
@@ -153,12 +162,21 @@ export const useVaultState = () => {
|
||||
const updateIdentities = useCallback((data: Identity[]) => {
|
||||
setIdentities(data);
|
||||
const ver = ++identitiesWriteVersion.current;
|
||||
encryptIdentities(data).then((enc) => {
|
||||
return encryptIdentities(data).then((enc) => {
|
||||
if (ver === identitiesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateProxyProfiles = useCallback((data: ProxyProfile[]) => {
|
||||
setProxyProfiles(data);
|
||||
const ver = ++proxyProfilesWriteVersion.current;
|
||||
return encryptProxyProfiles(data).then((enc) => {
|
||||
if (ver === proxyProfilesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_PROXY_PROFILES, enc);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateSnippets = useCallback((data: Snippet[]) => {
|
||||
setSnippets(data);
|
||||
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, data);
|
||||
@@ -187,7 +205,7 @@ export const useVaultState = () => {
|
||||
const updateGroupConfigs = useCallback((data: GroupConfig[]) => {
|
||||
setGroupConfigs(data);
|
||||
const ver = ++groupConfigsWriteVersion.current;
|
||||
encryptGroupConfigs(data).then((enc) => {
|
||||
return encryptGroupConfigs(data).then((enc) => {
|
||||
if (ver === groupConfigsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
|
||||
});
|
||||
@@ -197,6 +215,7 @@ export const useVaultState = () => {
|
||||
updateHosts([]);
|
||||
updateKeys([]);
|
||||
updateIdentities([]);
|
||||
updateProxyProfiles([]);
|
||||
updateSnippets([]);
|
||||
updateSnippetPackages([]);
|
||||
updateCustomGroups([]);
|
||||
@@ -208,6 +227,7 @@ export const useVaultState = () => {
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
updateIdentities,
|
||||
updateProxyProfiles,
|
||||
updateSnippets,
|
||||
updateSnippetPackages,
|
||||
updateCustomGroups,
|
||||
@@ -339,129 +359,147 @@ export const useVaultState = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
|
||||
try {
|
||||
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
|
||||
|
||||
if (savedHosts) {
|
||||
// Capture version before the async gap so that any write occurring
|
||||
// during decryption (storage event, user edit) advances the counter
|
||||
// and causes this stale result to be discarded.
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
const decrypted = await decryptHosts(savedHosts);
|
||||
if (ver === hostsWriteVersion.current) {
|
||||
const sanitized = decrypted.map(sanitizeHost);
|
||||
setHosts(sanitized);
|
||||
encryptHosts(sanitized).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
if (savedHosts) {
|
||||
// Capture version before the async gap so that any write occurring
|
||||
// during decryption (storage event, user edit) advances the counter
|
||||
// and causes this stale result to be discarded.
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
const decrypted = await decryptHosts(savedHosts);
|
||||
if (ver === hostsWriteVersion.current) {
|
||||
const sanitized = decrypted.map(sanitizeHost);
|
||||
setHosts(sanitized);
|
||||
encryptHosts(sanitized).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
updateHosts(INITIAL_HOSTS);
|
||||
}
|
||||
} else {
|
||||
updateHosts(INITIAL_HOSTS);
|
||||
}
|
||||
|
||||
// Read keys fresh here (not before the hosts await) so we don't apply
|
||||
// a stale snapshot if keys were updated during host decryption.
|
||||
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
|
||||
// Read keys fresh here (not before the hosts await) so we don't apply
|
||||
// a stale snapshot if keys were updated during host decryption.
|
||||
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
|
||||
|
||||
// Migrate old keys to new format with source/category fields
|
||||
if (savedKeysRaw?.length) {
|
||||
const migratedKeys: SSHKey[] = [];
|
||||
const legacyKeys: LegacyKeyRecord[] = [];
|
||||
// Migrate old keys to new format with source/category fields
|
||||
if (savedKeysRaw?.length) {
|
||||
const migratedKeys: SSHKey[] = [];
|
||||
const legacyKeys: LegacyKeyRecord[] = [];
|
||||
|
||||
for (const entry of savedKeysRaw) {
|
||||
const record =
|
||||
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
|
||||
if (!record) continue;
|
||||
for (const entry of savedKeysRaw) {
|
||||
const record =
|
||||
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
|
||||
if (!record) continue;
|
||||
|
||||
if (isLegacyUnsupportedKey(record)) {
|
||||
legacyKeys.push(record);
|
||||
continue;
|
||||
if (isLegacyUnsupportedKey(record)) {
|
||||
legacyKeys.push(record);
|
||||
continue;
|
||||
}
|
||||
|
||||
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
|
||||
}
|
||||
|
||||
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
|
||||
// Decrypt sensitive fields (passphrase, privateKey)
|
||||
const keyVer = ++keysWriteVersion.current;
|
||||
const decryptedKeys = await decryptKeys(migratedKeys);
|
||||
if (keyVer === keysWriteVersion.current) {
|
||||
setKeys(decryptedKeys);
|
||||
encryptKeys(decryptedKeys).then((enc) => {
|
||||
if (keyVer === keysWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
|
||||
});
|
||||
}
|
||||
if (legacyKeys.length) {
|
||||
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt sensitive fields (passphrase, privateKey)
|
||||
const keyVer = ++keysWriteVersion.current;
|
||||
const decryptedKeys = await decryptKeys(migratedKeys);
|
||||
if (keyVer === keysWriteVersion.current) {
|
||||
setKeys(decryptedKeys);
|
||||
encryptKeys(decryptedKeys).then((enc) => {
|
||||
if (keyVer === keysWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
|
||||
});
|
||||
// Read identities fresh here (not before the hosts/keys awaits) so we
|
||||
// don't apply a stale snapshot if identities were updated during prior decryption.
|
||||
const savedIdentities =
|
||||
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
|
||||
if (savedIdentities) {
|
||||
const idVer = ++identitiesWriteVersion.current;
|
||||
const decryptedIds = await decryptIdentities(savedIdentities);
|
||||
if (idVer === identitiesWriteVersion.current) {
|
||||
setIdentities(decryptedIds);
|
||||
encryptIdentities(decryptedIds).then((enc) => {
|
||||
if (idVer === identitiesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (legacyKeys.length) {
|
||||
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
|
||||
|
||||
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 identities fresh here (not before the hosts/keys awaits) so we
|
||||
// don't apply a stale snapshot if identities were updated during prior decryption.
|
||||
const savedIdentities =
|
||||
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
|
||||
if (savedIdentities) {
|
||||
const idVer = ++identitiesWriteVersion.current;
|
||||
const decryptedIds = await decryptIdentities(savedIdentities);
|
||||
if (idVer === identitiesWriteVersion.current) {
|
||||
setIdentities(decryptedIds);
|
||||
encryptIdentities(decryptedIds).then((enc) => {
|
||||
if (idVer === identitiesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Read remaining non-encrypted data fresh after all async gaps above
|
||||
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
|
||||
const savedSnippets =
|
||||
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
|
||||
const savedSnippetPackages = localStorageAdapter.read<string[]>(
|
||||
STORAGE_KEY_SNIPPET_PACKAGES,
|
||||
);
|
||||
|
||||
if (savedSnippets) setSnippets(savedSnippets);
|
||||
else updateSnippets(INITIAL_SNIPPETS);
|
||||
|
||||
if (savedGroups) setCustomGroups(savedGroups);
|
||||
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
|
||||
|
||||
// Load known hosts
|
||||
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
|
||||
STORAGE_KEY_KNOWN_HOSTS,
|
||||
);
|
||||
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
|
||||
|
||||
// Load shell history
|
||||
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
|
||||
STORAGE_KEY_SHELL_HISTORY,
|
||||
);
|
||||
if (savedShellHistory) setShellHistory(savedShellHistory);
|
||||
|
||||
// Load connection logs
|
||||
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
);
|
||||
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
|
||||
|
||||
// Load managed sources
|
||||
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
);
|
||||
if (savedManagedSources) setManagedSources(savedManagedSources);
|
||||
|
||||
// Load group configs
|
||||
const savedGroupConfigs = localStorageAdapter.read<GroupConfig[]>(STORAGE_KEY_GROUP_CONFIGS);
|
||||
if (savedGroupConfigs) {
|
||||
const gcVer = ++groupConfigsWriteVersion.current;
|
||||
const decryptedGC = await decryptGroupConfigs(savedGroupConfigs);
|
||||
if (gcVer === groupConfigsWriteVersion.current) {
|
||||
setGroupConfigs(decryptedGC);
|
||||
encryptGroupConfigs(decryptedGC).then((enc) => {
|
||||
if (gcVer === groupConfigsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
|
||||
});
|
||||
// Read remaining non-encrypted data fresh after all async gaps above
|
||||
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
|
||||
const savedSnippets =
|
||||
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
|
||||
const savedSnippetPackages = localStorageAdapter.read<string[]>(
|
||||
STORAGE_KEY_SNIPPET_PACKAGES,
|
||||
);
|
||||
|
||||
if (savedSnippets) setSnippets(savedSnippets);
|
||||
else updateSnippets(INITIAL_SNIPPETS);
|
||||
|
||||
if (savedGroups) setCustomGroups(savedGroups);
|
||||
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
|
||||
|
||||
// Load known hosts
|
||||
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
|
||||
STORAGE_KEY_KNOWN_HOSTS,
|
||||
);
|
||||
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
|
||||
|
||||
// Load shell history
|
||||
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
|
||||
STORAGE_KEY_SHELL_HISTORY,
|
||||
);
|
||||
if (savedShellHistory) setShellHistory(savedShellHistory);
|
||||
|
||||
// Load connection logs
|
||||
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
);
|
||||
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
|
||||
|
||||
// Load managed sources
|
||||
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
);
|
||||
if (savedManagedSources) setManagedSources(savedManagedSources);
|
||||
|
||||
// Load group configs
|
||||
const savedGroupConfigs = localStorageAdapter.read<GroupConfig[]>(STORAGE_KEY_GROUP_CONFIGS);
|
||||
if (savedGroupConfigs) {
|
||||
const gcVer = ++groupConfigsWriteVersion.current;
|
||||
const decryptedGC = await decryptGroupConfigs(savedGroupConfigs);
|
||||
if (gcVer === groupConfigsWriteVersion.current) {
|
||||
setGroupConfigs(decryptedGC);
|
||||
encryptGroupConfigs(decryptedGC).then((enc) => {
|
||||
if (gcVer === groupConfigsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsInitialized(true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -523,6 +561,18 @@ export const useVaultState = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_PROXY_PROFILES) {
|
||||
const next = safeParse<ProxyProfile[]>(event.newValue) ?? [];
|
||||
++proxyProfilesWriteVersion.current;
|
||||
const seq = ++proxyProfilesReadSeq.current;
|
||||
const writeAtStart = proxyProfilesWriteVersion.current;
|
||||
decryptProxyProfiles(next).then((dec) => {
|
||||
if (seq === proxyProfilesReadSeq.current && writeAtStart === proxyProfilesWriteVersion.current)
|
||||
setProxyProfiles(dec);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_SNIPPETS) {
|
||||
const next = safeParse<Snippet[]>(event.newValue) ?? [];
|
||||
setSnippets(next);
|
||||
@@ -616,30 +666,35 @@ export const useVaultState = () => {
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
knownHosts,
|
||||
groupConfigs,
|
||||
}),
|
||||
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts, groupConfigs],
|
||||
[hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, knownHosts, groupConfigs],
|
||||
);
|
||||
|
||||
const importData = useCallback(
|
||||
(payload: Partial<ExportableVaultData>) => {
|
||||
if (payload.hosts) updateHosts(payload.hosts);
|
||||
if (payload.keys) updateKeys(payload.keys);
|
||||
if (payload.identities) updateIdentities(payload.identities);
|
||||
(payload: Partial<ExportableVaultData>): Promise<void> => {
|
||||
const encryptedWrites: Promise<void>[] = [];
|
||||
if (payload.hosts) encryptedWrites.push(updateHosts(payload.hosts));
|
||||
if (payload.keys) encryptedWrites.push(updateKeys(payload.keys));
|
||||
if (payload.identities) encryptedWrites.push(updateIdentities(payload.identities));
|
||||
if (Array.isArray(payload.proxyProfiles)) encryptedWrites.push(updateProxyProfiles(payload.proxyProfiles));
|
||||
if (payload.snippets) updateSnippets(payload.snippets);
|
||||
if (payload.customGroups) updateCustomGroups(payload.customGroups);
|
||||
if (payload.snippetPackages) updateSnippetPackages(payload.snippetPackages);
|
||||
if (payload.knownHosts) updateKnownHosts(payload.knownHosts);
|
||||
if (Array.isArray(payload.groupConfigs)) updateGroupConfigs(payload.groupConfigs);
|
||||
if (Array.isArray(payload.groupConfigs)) encryptedWrites.push(updateGroupConfigs(payload.groupConfigs));
|
||||
return Promise.all(encryptedWrites).then(() => undefined);
|
||||
},
|
||||
[
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
updateIdentities,
|
||||
updateProxyProfiles,
|
||||
updateSnippets,
|
||||
updateCustomGroups,
|
||||
updateSnippetPackages,
|
||||
@@ -649,17 +704,19 @@ export const useVaultState = () => {
|
||||
);
|
||||
|
||||
const importDataFromString = useCallback(
|
||||
(jsonString: string) => {
|
||||
(jsonString: string): Promise<void> => {
|
||||
const data = JSON.parse(jsonString);
|
||||
importData(data);
|
||||
return importData(data);
|
||||
},
|
||||
[importData],
|
||||
);
|
||||
|
||||
return {
|
||||
isInitialized,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
@@ -671,6 +728,7 @@ export const useVaultState = () => {
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
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);
|
||||
});
|
||||
};
|
||||
256
application/syncPayload.test.ts
Normal file
256
application/syncPayload.test.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { SyncPayload } from "../domain/sync.ts";
|
||||
import type { KnownHost } from "../domain/models.ts";
|
||||
import type { SyncableVaultData } from "./syncPayload.ts";
|
||||
|
||||
type LocalStorageMock = {
|
||||
clear(): void;
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
};
|
||||
|
||||
function installLocalStorage(): LocalStorageMock {
|
||||
const store = new Map<string, string>();
|
||||
const localStorage: LocalStorageMock = {
|
||||
clear() {
|
||||
store.clear();
|
||||
},
|
||||
getItem(key: string) {
|
||||
return store.has(key) ? store.get(key)! : null;
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store.set(key, String(value));
|
||||
},
|
||||
removeItem(key: string) {
|
||||
store.delete(key);
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: localStorage,
|
||||
configurable: true,
|
||||
});
|
||||
return localStorage;
|
||||
}
|
||||
|
||||
const localStorage = installLocalStorage();
|
||||
const {
|
||||
applyLocalVaultPayload,
|
||||
applySyncPayload,
|
||||
buildLocalVaultPayload,
|
||||
buildSyncPayload,
|
||||
hasMeaningfulCloudSyncData,
|
||||
} = await import("./syncPayload.ts");
|
||||
|
||||
const knownHost = (id = "kh-1"): KnownHost => ({
|
||||
id,
|
||||
hostname: `${id}.example.com`,
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
publicKey: `SHA256:${id}`,
|
||||
discoveredAt: 1,
|
||||
});
|
||||
|
||||
const vault = (knownHosts: KnownHost[] = [knownHost()]): SyncableVaultData => ({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
snippetPackages: [],
|
||||
knownHosts,
|
||||
groupConfigs: [],
|
||||
});
|
||||
|
||||
test.beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
test("buildSyncPayload treats known hosts as local-only data", () => {
|
||||
const payload = buildSyncPayload(vault([knownHost("kh-cloud")]));
|
||||
|
||||
assert.equal("knownHosts" in payload, false);
|
||||
});
|
||||
|
||||
test("buildSyncPayload includes reusable proxy profiles", () => {
|
||||
const proxyProfiles = [
|
||||
{
|
||||
id: "proxy-1",
|
||||
label: "Office Proxy",
|
||||
config: { type: "socks5", host: "proxy.example.com", port: 1080 },
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const payload = buildSyncPayload({
|
||||
...vault(),
|
||||
proxyProfiles,
|
||||
} as SyncableVaultData & { proxyProfiles: typeof proxyProfiles });
|
||||
|
||||
assert.deepEqual(payload.proxyProfiles, proxyProfiles);
|
||||
});
|
||||
|
||||
test("hasMeaningfulCloudSyncData ignores legacy cloud known hosts", () => {
|
||||
assert.equal(
|
||||
hasMeaningfulCloudSyncData({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
knownHosts: [knownHost("kh-only")],
|
||||
syncedAt: 1,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildLocalVaultPayload preserves known hosts for local backups", () => {
|
||||
const payload = buildLocalVaultPayload(vault([knownHost("kh-local")]));
|
||||
|
||||
assert.deepEqual(payload.knownHosts, [knownHost("kh-local")]);
|
||||
});
|
||||
|
||||
test("applySyncPayload ignores legacy cloud known hosts", async () => {
|
||||
let imported: Record<string, unknown> | null = null;
|
||||
const proxyProfiles = [
|
||||
{
|
||||
id: "proxy-1",
|
||||
label: "Office Proxy",
|
||||
config: { type: "socks5", host: "proxy.example.com", port: 1080 },
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
];
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
knownHosts: [knownHost("kh-legacy")],
|
||||
proxyProfiles,
|
||||
syncedAt: 1,
|
||||
} as SyncPayload & { proxyProfiles: typeof proxyProfiles };
|
||||
|
||||
await applySyncPayload(payload, {
|
||||
importVaultData: (json) => {
|
||||
imported = JSON.parse(json);
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(imported);
|
||||
assert.equal("knownHosts" in imported, false);
|
||||
assert.deepEqual(imported.proxyProfiles, proxyProfiles);
|
||||
});
|
||||
|
||||
test("applySyncPayload keeps missing proxy references visible to connection guards", async () => {
|
||||
let imported: Record<string, unknown> | null = null;
|
||||
const payload: SyncPayload = {
|
||||
hosts: [{
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
proxyProfileId: "missing-proxy",
|
||||
}],
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
groupConfigs: [{ path: "prod", proxyProfileId: "missing-proxy" }],
|
||||
syncedAt: 1,
|
||||
};
|
||||
|
||||
await applySyncPayload(payload, {
|
||||
importVaultData: (json) => {
|
||||
imported = JSON.parse(json);
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(imported);
|
||||
assert.equal((imported.hosts as SyncPayload["hosts"])[0]?.proxyProfileId, "missing-proxy");
|
||||
assert.equal((imported.groupConfigs as SyncPayload["groupConfigs"])?.[0]?.proxyProfileId, "missing-proxy");
|
||||
});
|
||||
|
||||
test("applySyncPayload preserves host proxy references when group configs are absent", async () => {
|
||||
let imported: Record<string, unknown> | null = null;
|
||||
const payload: SyncPayload = {
|
||||
hosts: [{
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
proxyProfileId: "missing-proxy",
|
||||
}],
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
};
|
||||
|
||||
await applySyncPayload(payload, {
|
||||
importVaultData: (json) => {
|
||||
imported = JSON.parse(json);
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(imported);
|
||||
assert.equal((imported.hosts as SyncPayload["hosts"])[0]?.proxyProfileId, "missing-proxy");
|
||||
assert.equal("groupConfigs" in imported, false);
|
||||
});
|
||||
|
||||
test("applySyncPayload waits for async vault imports", async () => {
|
||||
let finished = false;
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
};
|
||||
|
||||
const promise = applySyncPayload(payload, {
|
||||
importVaultData: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
finished = true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(finished, false);
|
||||
await promise;
|
||||
assert.equal(finished, true);
|
||||
});
|
||||
|
||||
test("applyLocalVaultPayload restores known hosts from local backups", async () => {
|
||||
let imported: Record<string, unknown> | null = null;
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
knownHosts: [knownHost("kh-backup")],
|
||||
syncedAt: 1,
|
||||
};
|
||||
|
||||
await applyLocalVaultPayload(payload, {
|
||||
importVaultData: (json) => {
|
||||
imported = JSON.parse(json);
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(imported);
|
||||
assert.deepEqual(imported.knownHosts, [knownHost("kh-backup")]);
|
||||
});
|
||||
@@ -13,11 +13,17 @@ 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 { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
||||
import { rehydrateGlobalBookmarks } from '../components/sftp/hooks/useGlobalSftpBookmarks';
|
||||
import {
|
||||
@@ -51,22 +57,73 @@ import {
|
||||
// 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[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the payload contains any meaningful user data worth
|
||||
* protecting or syncing.
|
||||
*/
|
||||
export function hasMeaningfulSyncData(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.knownHosts?.length ?? 0) > 0 ||
|
||||
(payload.groupConfigs?.length ?? 0) > 0;
|
||||
|
||||
if (hasEntities) return true;
|
||||
|
||||
return Boolean(
|
||||
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
@@ -85,7 +142,8 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'smoothScrolling',
|
||||
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'disableBracketedPaste', 'osc52Clipboard',
|
||||
'keepaliveInterval', 'disableBracketedPaste', 'clearWipesScrollback',
|
||||
'preserveSelectionOnInput', 'osc52Clipboard',
|
||||
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
|
||||
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
|
||||
] as const;
|
||||
@@ -147,9 +205,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
|
||||
@@ -226,7 +283,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
|
||||
@@ -274,10 +341,10 @@ export function buildSyncPayload(
|
||||
hosts: vault.hosts,
|
||||
keys: vault.keys,
|
||||
identities: vault.identities,
|
||||
proxyProfiles: vault.proxyProfiles,
|
||||
snippets: vault.snippets,
|
||||
customGroups: vault.customGroups,
|
||||
snippetPackages: vault.snippetPackages,
|
||||
knownHosts: vault.knownHosts,
|
||||
groupConfigs: vault.groupConfigs,
|
||||
portForwardingRules,
|
||||
settings: collectSyncableSettings(),
|
||||
@@ -285,52 +352,77 @@ export function buildSyncPayload(
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a local backup/restore payload, including local-only trust records. */
|
||||
export function buildLocalVaultPayload(
|
||||
vault: SyncableVaultData,
|
||||
portForwardingRules?: PortForwardingRule[],
|
||||
): SyncPayload {
|
||||
return {
|
||||
...buildSyncPayload(vault, portForwardingRules),
|
||||
knownHosts: vault.knownHosts,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a downloaded `SyncPayload` to local state via the provided importers.
|
||||
*
|
||||
* This ensures both vault data and port-forwarding rules are imported
|
||||
* consistently across windows.
|
||||
*/
|
||||
export function applySyncPayload(
|
||||
function applyPayload(
|
||||
payload: SyncPayload,
|
||||
importers: SyncPayloadImporters,
|
||||
): void {
|
||||
// Build the vault import object. knownHosts is only included when the
|
||||
// payload explicitly carries the field (even if it's []). Legacy cloud
|
||||
// snapshots may omit it entirely — in that case we leave the local
|
||||
// known-hosts list untouched rather than destructively wiping it.
|
||||
options: { includeLocalOnlyData: boolean },
|
||||
): Promise<void> {
|
||||
// Build the vault import object. Cloud sync intentionally ignores
|
||||
// local-only trust records even if legacy cloud snapshots still carry them.
|
||||
const vaultImport: Record<string, unknown> = {
|
||||
hosts: payload.hosts,
|
||||
keys: payload.keys,
|
||||
identities: payload.identities,
|
||||
proxyProfiles: payload.proxyProfiles,
|
||||
snippets: payload.snippets,
|
||||
customGroups: payload.customGroups,
|
||||
};
|
||||
if (payload.snippetPackages !== undefined) {
|
||||
vaultImport.snippetPackages = payload.snippetPackages;
|
||||
}
|
||||
if (payload.knownHosts !== undefined) {
|
||||
if (options.includeLocalOnlyData && payload.knownHosts !== undefined) {
|
||||
vaultImport.knownHosts = payload.knownHosts;
|
||||
}
|
||||
if (Array.isArray(payload.groupConfigs)) {
|
||||
vaultImport.groupConfigs = payload.groupConfigs;
|
||||
}
|
||||
|
||||
importers.importVaultData(JSON.stringify(vaultImport));
|
||||
return Promise.resolve(importers.importVaultData(JSON.stringify(vaultImport))).then(() => {
|
||||
// Only import port-forwarding rules when the payload explicitly carries
|
||||
// them. Absent field = "payload was created before this feature existed",
|
||||
// so local rules are preserved. Explicitly present [] = "remote has no
|
||||
// rules, clear local state".
|
||||
if (payload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
|
||||
importers.importPortForwardingRules(payload.portForwardingRules);
|
||||
}
|
||||
|
||||
// Only import port-forwarding rules when the payload explicitly carries
|
||||
// them. Absent field = "payload was created before this feature existed",
|
||||
// so local rules are preserved. Explicitly present [] = "remote has no
|
||||
// rules, clear local state".
|
||||
if (payload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
|
||||
importers.importPortForwardingRules(payload.portForwardingRules);
|
||||
}
|
||||
|
||||
// Apply synced settings
|
||||
if (payload.settings) {
|
||||
applySyncableSettings(payload.settings);
|
||||
// Rehydrate in-memory bookmark snapshot after localStorage was updated
|
||||
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalBookmarks();
|
||||
importers.onSettingsApplied?.();
|
||||
}
|
||||
// Apply synced settings
|
||||
if (payload.settings) {
|
||||
applySyncableSettings(payload.settings);
|
||||
// Rehydrate in-memory bookmark snapshot after localStorage was updated
|
||||
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalBookmarks();
|
||||
importers.onSettingsApplied?.();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function applySyncPayload(
|
||||
payload: SyncPayload,
|
||||
importers: SyncPayloadImporters,
|
||||
): Promise<void> {
|
||||
return applyPayload(payload, importers, { includeLocalOnlyData: false });
|
||||
}
|
||||
|
||||
export function applyLocalVaultPayload(
|
||||
payload: SyncPayload,
|
||||
importers: SyncPayloadImporters,
|
||||
): Promise<void> {
|
||||
return applyPayload(payload, importers, { includeLocalOnlyData: true });
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,38 +1,58 @@
|
||||
import React from 'react';
|
||||
|
||||
interface AppLogoProps {
|
||||
className?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* App logo component that dynamically uses the accent color (--primary CSS variable).
|
||||
* The original logo.svg file remains unchanged; this component renders an inline SVG
|
||||
* with colors bound to the current theme's accent color.
|
||||
*/
|
||||
export const AppLogo: React.FC<AppLogoProps> = ({ className }) => (
|
||||
<svg viewBox="0 0 64 64" className={className}>
|
||||
{/* Main background - uses accent color */}
|
||||
<rect x="4" y="4" width="56" height="56" rx="12" fill="hsl(var(--primary))" />
|
||||
{/* Terminal window */}
|
||||
<rect x="14" y="17" width="36" height="24" rx="4" fill="white" />
|
||||
{/* Title bar - light accent tint */}
|
||||
<rect x="14" y="17" width="36" height="5" rx="4" fill="hsl(var(--primary) / 0.15)" />
|
||||
{/* Window buttons */}
|
||||
<circle cx="18" cy="19.5" r="1" fill="hsl(var(--primary))" />
|
||||
<circle cx="22" cy="19.5" r="1" fill="hsl(var(--primary))" opacity="0.7" />
|
||||
<circle cx="26" cy="19.5" r="1" fill="hsl(var(--primary))" opacity="0.5" />
|
||||
{/* Terminal prompt arrow */}
|
||||
<path d="M20 32 L24 30 L20 28" stroke="hsl(var(--primary))" fill="none" strokeWidth="1.6" />
|
||||
{/* Cursor line */}
|
||||
<path d="M28 34 H34" stroke="hsl(var(--primary))" strokeWidth="1.6" />
|
||||
{/* Cat ears */}
|
||||
<path d="M24 17 L26 12 L28 17Z" fill="white" />
|
||||
<path d="M36 17 L38 12 L40 17Z" fill="white" />
|
||||
{/* Cat tail */}
|
||||
<path d="M40 37 C44 40,46 42,46 46 C46 49,44 51,41 51" stroke="white" fill="none" strokeWidth="3.2" />
|
||||
{/* Connector/plug */}
|
||||
<rect x="38" y="48" width="6" height="5" rx="1" fill="white" stroke="hsl(var(--primary))" />
|
||||
</svg>
|
||||
<svg
|
||||
viewBox="0 0 1024 1024"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="1024"
|
||||
height="1024"
|
||||
rx="192"
|
||||
ry="192"
|
||||
fill="hsl(var(--primary))"
|
||||
/>
|
||||
<g transform="translate(85.64 85.64) scale(0.68)">
|
||||
<g><path style={{opacity:1}} fill="#f9f9f9" d="M 618.5,240.5 C 647.925,240.677 677.258,242.344 706.5,245.5C 753.323,252.113 798.49,265.113 842,284.5C 870.064,257.538 902.23,236.704 938.5,222C 966.969,211.263 988.469,219.096 1003,245.5C 1011.08,263.079 1016.75,281.412 1020,300.5C 1022.13,320.204 1024.29,339.871 1026.5,359.5C 1026.17,379.674 1026.5,399.674 1027.5,419.5C 1072.74,473.648 1102.74,535.314 1117.5,604.5C 1117.29,607.495 1117.96,610.162 1119.5,612.5C 1126.08,656.83 1126.08,701.163 1119.5,745.5C 1118.23,747.905 1117.57,750.572 1117.5,753.5C 1107.38,802.706 1088.05,847.872 1059.5,889C 1053.04,888.572 1046.71,887.405 1040.5,885.5C 1036.79,883.864 1032.79,883.198 1028.5,883.5C 1011.79,881.938 995.122,882.271 978.5,884.5C 975.572,884.565 972.905,885.232 970.5,886.5C 928.686,895.489 896.519,918.156 874,954.5C 864.791,970.962 859.958,988.628 859.5,1007.5C 793.269,1029.39 725.269,1041.72 655.5,1044.5C 633.833,1044.5 612.167,1044.5 590.5,1044.5C 524.821,1041.8 460.821,1029.63 398.5,1008C 396.254,996.177 393.421,984.344 390,972.5C 387.524,964.881 384.024,957.881 379.5,951.5C 363.815,925.334 341.815,906.667 313.5,895.5C 297.343,888.573 280.343,884.406 262.5,883C 248.055,882.038 233.722,882.538 219.5,884.5C 216.572,884.565 213.905,885.232 211.5,886.5C 211.167,886.5 210.833,886.5 210.5,886.5C 207.848,886.41 205.515,887.076 203.5,888.5C 200.823,889.614 198.156,889.614 195.5,888.5C 149.432,819.968 128.098,744.301 131.5,661.5C 131.502,654.48 131.835,647.48 132.5,640.5C 133.461,638.735 133.795,636.735 133.5,634.5C 135.136,630.79 135.802,626.79 135.5,622.5C 137.764,609.333 140.431,596.333 143.5,583.5C 144.924,581.485 145.59,579.152 145.5,576.5C 156.228,537.714 172.395,501.381 194,467.5C 204.685,451.452 215.852,435.786 227.5,420.5C 228.042,388.62 229.375,356.62 231.5,324.5C 234.549,300.253 240.382,276.586 249,253.5C 253.868,241.906 261.035,232.073 270.5,224C 279.336,218.042 289.002,216.042 299.5,218C 314.655,220.607 328.988,225.607 342.5,233C 368.29,247.23 391.957,264.396 413.5,284.5C 478.68,255.797 547.014,241.13 618.5,240.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#1f2657" d="M 706.5,245.5 C 677.258,242.344 647.925,240.677 618.5,240.5C 649.662,238.284 680.995,239.784 712.5,245C 710.527,245.495 708.527,245.662 706.5,245.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#18214c" d="M 231.5,324.5 C 229.375,356.62 228.042,388.62 227.5,420.5C 226.104,392.965 226.604,365.298 229,337.5C 229.17,331.677 230.003,327.344 231.5,324.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#0c1943" d="M 1026.5,359.5 C 1027.92,371.971 1028.59,384.637 1028.5,397.5C 1028.5,405.008 1028.17,412.341 1027.5,419.5C 1026.5,399.674 1026.17,379.674 1026.5,359.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#505c83" d="M 817.5,544.5 C 815.162,546.04 812.495,546.706 809.5,546.5C 811.905,545.232 814.572,544.565 817.5,544.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#919ab0" d="M 445.5,545.5 C 448.152,545.41 450.485,546.076 452.5,547.5C 449.848,547.59 447.515,546.924 445.5,545.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#022551" d="M 445.5,545.5 C 447.515,546.924 449.848,547.59 452.5,547.5C 479.103,555.885 499.269,572.218 513,596.5C 515.435,607.525 511.268,614.191 500.5,616.5C 497.302,616.378 494.302,615.545 491.5,614C 485.302,604.13 477.969,595.13 469.5,587C 459.207,579.735 447.873,574.902 435.5,572.5C 415.88,568.656 398.213,573.156 382.5,586C 380.905,585.383 379.572,585.716 378.5,587C 378.957,587.414 379.291,587.914 379.5,588.5C 376.839,591.423 374.005,593.423 371,594.5C 369.606,600.126 366.772,603.96 362.5,606C 363.517,607.049 363.684,608.216 363,609.5C 355.276,616.472 347.943,616.139 341,608.5C 339.805,603.4 340.638,598.733 343.5,594.5C 344.086,594.709 344.586,595.043 345,595.5C 344.718,590.888 346.551,587.055 350.5,584C 351.515,582.627 351.515,581.46 350.5,580.5C 375.329,550.884 406.995,539.218 445.5,545.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#032551" d="M 817.5,544.5 C 862.791,541.392 895.958,559.726 917,599.5C 917.138,612.028 910.971,617.528 898.5,616C 897.167,615.333 895.833,614.667 894.5,614C 884.255,595.245 869.255,582.078 849.5,574.5C 843.812,571.54 837.645,570.207 831,570.5C 822.066,570.919 813.233,572.086 804.5,574C 798.217,577.721 792.05,581.554 786,585.5C 785.667,585.167 785.333,584.833 785,584.5C 782.92,587.065 781.087,589.732 779.5,592.5C 774.384,597.792 770.218,603.792 767,610.5C 759.55,618.016 751.883,618.349 744,611.5C 742.878,609.593 742.045,607.593 741.5,605.5C 741.508,602.455 741.841,599.455 742.5,596.5C 757.037,569.397 779.371,552.73 809.5,546.5C 812.495,546.706 815.162,546.04 817.5,544.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#0c1a4d" d="M 849.5,574.5 C 822.908,568.314 799.574,574.314 779.5,592.5C 781.087,589.732 782.92,587.065 785,584.5C 785.333,584.833 785.667,585.167 786,585.5C 792.05,581.554 798.217,577.721 804.5,574C 813.233,572.086 822.066,570.919 831,570.5C 837.645,570.207 843.812,571.54 849.5,574.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#98a2bf" d="M 423.5,572.5 C 419.684,573.482 415.684,574.149 411.5,574.5C 415.183,572.75 419.183,572.083 423.5,572.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#9ea6be" d="M 145.5,576.5 C 145.59,579.152 144.924,581.485 143.5,583.5C 143.41,580.848 144.076,578.515 145.5,576.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#132152" d="M 435.5,572.5 C 431.5,572.5 427.5,572.5 423.5,572.5C 419.183,572.083 415.183,572.75 411.5,574.5C 389.242,579.57 372.909,592.403 362.5,613C 356.408,617.241 350.075,617.574 343.5,614C 337.996,608.137 337.163,601.637 341,594.5C 343.929,589.631 347.096,584.965 350.5,580.5C 351.515,581.46 351.515,582.627 350.5,584C 346.551,587.055 344.718,590.888 345,595.5C 344.586,595.043 344.086,594.709 343.5,594.5C 340.638,598.733 339.805,603.4 341,608.5C 347.943,616.139 355.276,616.472 363,609.5C 363.684,608.216 363.517,607.049 362.5,606C 366.772,603.96 369.606,600.126 371,594.5C 374.005,593.423 376.839,591.423 379.5,588.5C 379.291,587.914 378.957,587.414 378.5,587C 379.572,585.716 380.905,585.383 382.5,586C 398.213,573.156 415.88,568.656 435.5,572.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#6c7794" d="M 742.5,596.5 C 741.841,599.455 741.508,602.455 741.5,605.5C 740.848,604.551 740.514,603.385 740.5,602C 740.393,599.779 741.06,597.946 742.5,596.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#6f7b97" d="M 1117.5,604.5 C 1118.77,606.905 1119.43,609.572 1119.5,612.5C 1117.96,610.162 1117.29,607.495 1117.5,604.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#a8aec5" d="M 135.5,622.5 C 135.802,626.79 135.136,630.79 133.5,634.5C 133.717,630.295 134.383,626.295 135.5,622.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#677393" d="M 653.5,662.5 C 634.473,662.218 615.473,662.551 596.5,663.5C 597.263,662.732 598.263,662.232 599.5,662C 617.671,661.171 635.671,661.338 653.5,662.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#032551" d="M 653.5,662.5 C 664.536,665.228 669.036,672.228 667,683.5C 665.861,687.112 664.194,690.446 662,693.5C 656.35,700.317 650.184,706.65 643.5,712.5C 643.058,737.755 654.725,754.922 678.5,764C 709.272,768.521 729.105,756.021 738,726.5C 747.413,717.842 755.746,718.842 763,729.5C 759.409,758.463 743.909,778.297 716.5,789C 713.111,789.776 709.778,790.609 706.5,791.5C 697.533,792.383 688.533,792.716 679.5,792.5C 657.328,788.994 639.828,777.994 627,759.5C 607.084,786.202 580.584,797.035 547.5,792C 516.901,784.235 497.901,765.068 490.5,734.5C 493.257,721.955 500.59,718.121 512.5,723C 517.164,727.124 519.998,732.291 521,738.5C 533.515,761.003 552.348,769.17 577.5,763C 599.78,754.048 610.947,737.548 611,713.5C 604.698,706.197 598.032,699.197 591,692.5C 586.824,686.46 585.491,679.794 587,672.5C 589.072,668.26 592.238,665.26 596.5,663.5C 615.473,662.551 634.473,662.218 653.5,662.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#01103f" d="M 132.5,640.5 C 131.835,647.48 131.502,654.48 131.5,661.5C 130.669,675.994 130.169,690.661 130,705.5C 128.188,682.722 128.854,660.055 132,637.5C 132.483,638.448 132.649,639.448 132.5,640.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#7c869d" d="M 1119.5,745.5 C 1119.71,748.495 1119.04,751.162 1117.5,753.5C 1117.57,750.572 1118.23,747.905 1119.5,745.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#7581a0" d="M 706.5,791.5 C 705.737,792.268 704.737,792.768 703.5,793C 695.323,793.823 687.323,793.656 679.5,792.5C 688.533,792.716 697.533,792.383 706.5,791.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#a7aec3" d="M 1028.5,883.5 C 1032.79,883.198 1036.79,883.864 1040.5,885.5C 1036.29,885.283 1032.29,884.617 1028.5,883.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#f9f9f9" d="M 233.5,904.5 C 242.833,904.5 252.167,904.5 261.5,904.5C 263.833,904.5 266.167,904.5 268.5,904.5C 304.989,908.827 334.489,925.494 357,954.5C 374.323,977.781 379.323,1003.45 372,1031.5C 365.153,1050.01 351.986,1060.85 332.5,1064C 324.173,1064.5 315.84,1064.67 307.5,1064.5C 307.947,1050.43 307.447,1036.43 306,1022.5C 296.93,1011.58 288.263,1011.91 280,1023.5C 279.833,1038.51 279.333,1053.51 278.5,1068.5C 271.841,1075.83 263.508,1080 253.5,1081C 248.845,1081.5 244.179,1081.67 239.5,1081.5C 237.485,1080.08 235.152,1079.41 232.5,1079.5C 225.481,1077.32 219.315,1073.66 214,1068.5C 213.667,1053.5 213.333,1038.5 213,1023.5C 208.464,1016.16 201.964,1013.66 193.5,1016C 190.333,1017.83 187.833,1020.33 186,1023.5C 185.5,1037.83 185.333,1052.16 185.5,1066.5C 160.376,1072.2 140.21,1064.86 125,1044.5C 120.792,1037.38 118.292,1029.71 117.5,1021.5C 117.482,1013.15 117.815,1004.82 118.5,996.5C 129.171,955.493 154.504,927.826 194.5,913.5C 200.166,912.61 205.5,910.943 210.5,908.5C 211.568,907.566 212.901,907.232 214.5,907.5C 221.111,907.453 227.444,906.453 233.5,904.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#f8f8f9" d="M 1133.5,985.5 C 1133.41,988.152 1134.08,990.485 1135.5,992.5C 1136.26,1002.48 1136.59,1012.48 1136.5,1022.5C 1133.68,1047.82 1119.68,1062.66 1094.5,1067C 1086.48,1067.61 1078.48,1067.44 1070.5,1066.5C 1070.67,1052.83 1070.5,1039.16 1070,1025.5C 1066.12,1016.96 1059.62,1013.79 1050.5,1016C 1047.33,1017.83 1044.83,1020.33 1043,1023.5C 1042.67,1038.17 1042.33,1052.83 1042,1067.5C 1035.97,1075.1 1028.14,1079.43 1018.5,1080.5C 1013.2,1081.27 1007.87,1081.61 1002.5,1081.5C 991.789,1080.39 982.955,1075.73 976,1067.5C 975.667,1052.83 975.333,1038.17 975,1023.5C 971.569,1017.53 966.402,1014.87 959.5,1015.5C 953.942,1016.72 950.275,1020.06 948.5,1025.5C 947.505,1037.99 947.171,1050.66 947.5,1063.5C 946.209,1063.26 945.209,1063.6 944.5,1064.5C 903.542,1067.19 882.208,1048.02 880.5,1007C 880.658,1002.81 880.991,998.641 881.5,994.5C 883.277,991.495 884.277,988.162 884.5,984.5C 894.73,953.43 914.73,930.93 944.5,917C 978.246,903.385 1012.91,900.718 1048.5,909C 1082.5,918.575 1108.67,938.409 1127,968.5C 1129.86,973.928 1132.03,979.595 1133.5,985.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#adb2c9" d="M 233.5,904.5 C 227.444,906.453 221.111,907.453 214.5,907.5C 220.536,905.419 226.869,904.419 233.5,904.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#bec4d7" d="M 210.5,908.5 C 205.5,910.943 200.166,912.61 194.5,913.5C 199.5,911.057 204.834,909.39 210.5,908.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#9ba0b8" d="M 884.5,984.5 C 884.277,988.162 883.277,991.495 881.5,994.5C 881.723,990.838 882.723,987.505 884.5,984.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#9aa5bc" d="M 1133.5,985.5 C 1134.92,987.515 1135.59,989.848 1135.5,992.5C 1134.08,990.485 1133.41,988.152 1133.5,985.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#adb1c6" d="M 118.5,996.5 C 117.815,1004.82 117.482,1013.15 117.5,1021.5C 116.835,1018.69 116.502,1015.69 116.5,1012.5C 116.429,1006.93 117.096,1001.6 118.5,996.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#c9d0dc" d="M 1135.5,992.5 C 1136.96,998.434 1137.63,1004.6 1137.5,1011C 1137.5,1015.02 1137.17,1018.85 1136.5,1022.5C 1136.59,1012.48 1136.26,1002.48 1135.5,992.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#b5bfcb" d="M 948.5,1025.5 C 948.5,1038.5 948.5,1051.5 948.5,1064.5C 947.167,1064.5 945.833,1064.5 944.5,1064.5C 945.209,1063.6 946.209,1063.26 947.5,1063.5C 947.171,1050.66 947.505,1037.99 948.5,1025.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#8193aa" d="M 232.5,1079.5 C 235.152,1079.41 237.485,1080.08 239.5,1081.5C 236.848,1081.59 234.515,1080.92 232.5,1079.5 Z"/></g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default AppLogo;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - Sync status and conflict resolution
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Download,
|
||||
Database,
|
||||
ExternalLink,
|
||||
FolderOpen,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Github,
|
||||
@@ -32,11 +33,19 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useCloudSync } from '../application/state/useCloudSync';
|
||||
import { useLocalVaultBackups } from '../application/state/useLocalVaultBackups';
|
||||
import {
|
||||
MAX_LOCAL_VAULT_BACKUP_MAX_COUNT,
|
||||
MIN_LOCAL_VAULT_BACKUP_MAX_COUNT,
|
||||
withRestoreBarrier,
|
||||
} from '../application/localVaultBackups';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type ConflictInfo, type SyncPayload, type WebDAVAuthType, type WebDAVConfig, type S3Config } from '../domain/sync';
|
||||
import { isProviderReadyForSync, type CloudProvider, type ConflictInfo, type SyncPayload, type SyncResult, type WebDAVAuthType, type WebDAVConfig, type S3Config } from '../domain/sync';
|
||||
import type { ShrinkFinding } from '../domain/syncGuards';
|
||||
import { SyncBlockedBanner } from './sync/SyncBlockedBanner';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
@@ -424,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')}
|
||||
@@ -433,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} />}
|
||||
@@ -628,13 +637,426 @@ const ConflictModal: React.FC<ConflictModalProps> = ({
|
||||
|
||||
interface SyncDashboardProps {
|
||||
onBuildPayload: () => SyncPayload;
|
||||
onApplyPayload: (payload: SyncPayload) => void;
|
||||
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
onApplyLocalPayload?: (payload: SyncPayload) => void | Promise<void>;
|
||||
onClearLocalData?: () => void;
|
||||
}
|
||||
|
||||
interface LocalBackupsPanelProps {
|
||||
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
/**
|
||||
* When true, the panel hides the Restore button entirely — e.g. while the
|
||||
* master key has not been configured yet, a restore would land credentials
|
||||
* on disk in plaintext (I3). Listing is still allowed so users can see that
|
||||
* their history exists.
|
||||
*/
|
||||
restoreDisabledReason?: 'no-master-key' | null;
|
||||
}
|
||||
|
||||
const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
onApplyPayload,
|
||||
restoreDisabledReason = null,
|
||||
}) => {
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const {
|
||||
backups,
|
||||
isLoading,
|
||||
maxBackups,
|
||||
encryptionAvailable,
|
||||
refreshBackups,
|
||||
readBackup,
|
||||
setMaxBackups,
|
||||
openBackupDirectory,
|
||||
} = useLocalVaultBackups();
|
||||
const [maxBackupsInput, setMaxBackupsInput] = useState(String(maxBackups));
|
||||
const [isSavingMaxBackups, setIsSavingMaxBackups] = useState(false);
|
||||
const [restoringBackupId, setRestoringBackupId] = useState<string | null>(null);
|
||||
// Backup chosen in the list but not yet confirmed. A two-step flow keeps
|
||||
// users from wiping their vault with a single accidental click (I2).
|
||||
const [pendingRestoreBackup, setPendingRestoreBackup] = useState<
|
||||
(typeof backups)[number] | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMaxBackupsInput(String(maxBackups));
|
||||
}, [maxBackups]);
|
||||
|
||||
const formatTimestamp = (timestamp: number) =>
|
||||
new Date(timestamp).toLocaleString(resolvedLocale || undefined);
|
||||
|
||||
const getReasonLabel = (reason: 'app_version_change' | 'before_restore') =>
|
||||
reason === 'app_version_change'
|
||||
? t('cloudSync.localBackups.reason.appVersionChange')
|
||||
: t('cloudSync.localBackups.reason.beforeRestore');
|
||||
|
||||
const handleSaveMaxBackups = async () => {
|
||||
// Validate BEFORE calling setMaxBackups, which hands off to the
|
||||
// renderer's `sanitizeLocalVaultBackupMaxCount` clamp. Two failure
|
||||
// modes must be surfaced rather than silently clamped, because
|
||||
// both produce a misleading "saved" toast:
|
||||
//
|
||||
// 1. Empty / non-numeric input — `Number("")` coerces to 0 and
|
||||
// sanitize clamps to the default (20). A user who meant to
|
||||
// clear the field then re-type would see their retention
|
||||
// silently reset to 20 with a success message.
|
||||
//
|
||||
// 2. Out-of-range input (e.g. 500) — sanitize clamps to 100 and
|
||||
// still reports success, but the visible error string says
|
||||
// "between 1 and 100", so the user has no idea their value
|
||||
// was changed. Reject explicitly instead.
|
||||
//
|
||||
// The 1..MAX range check mirrors the main-process `sanitizeMaxCount`
|
||||
// in vaultBackupBridge.cjs so renderer and bridge agree.
|
||||
const parsed = Number(maxBackupsInput);
|
||||
const inRange =
|
||||
Number.isFinite(parsed) &&
|
||||
parsed >= MIN_LOCAL_VAULT_BACKUP_MAX_COUNT &&
|
||||
parsed <= MAX_LOCAL_VAULT_BACKUP_MAX_COUNT;
|
||||
if (!inRange || maxBackupsInput.trim() === '') {
|
||||
toast.error(
|
||||
t('cloudSync.localBackups.maxInvalid'),
|
||||
t('sync.toast.errorTitle'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setIsSavingMaxBackups(true);
|
||||
try {
|
||||
const next = await setMaxBackups(parsed);
|
||||
setMaxBackupsInput(String(next));
|
||||
toast.success(t('cloudSync.localBackups.maxSaved', { count: String(next) }));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('sync.toast.errorTitle'),
|
||||
);
|
||||
} finally {
|
||||
setIsSavingMaxBackups(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenBackupDirectory = async () => {
|
||||
try {
|
||||
await openBackupDirectory();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('sync.toast.errorTitle'),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const performRestore = async (backupId: string) => {
|
||||
setRestoringBackupId(backupId);
|
||||
try {
|
||||
// Hold the cross-window restore barrier around both the load
|
||||
// and the apply so another window's auto-sync cannot push a
|
||||
// pre-restore snapshot concurrently. See `withRestoreBarrier`
|
||||
// in application/localVaultBackups.ts for the read-side in
|
||||
// useAutoSync.
|
||||
//
|
||||
// In-memory React state refresh is implicit: `onApplyPayload`
|
||||
// (supplied by the hosting screen) routes through
|
||||
// `applySyncPayload` → `importDataFromString` → store writes
|
||||
// → the hook-store listeners in `useVaultState` /
|
||||
// `useCustomThemes` / etc. We do NOT explicitly re-pull host
|
||||
// lists here because a future refactor that decouples those
|
||||
// stores from the apply path would silently break the UI
|
||||
// refresh in a way that's only visible after a manual
|
||||
// restart. Any change to that chain must either preserve
|
||||
// store-listener notification OR add an explicit
|
||||
// `rehydrateAllFromStorage` call here — do not assume
|
||||
// restore is "just" a payload swap.
|
||||
await withRestoreBarrier(async () => {
|
||||
const detail = await readBackup(backupId);
|
||||
if (!detail) {
|
||||
throw new Error(t('cloudSync.localBackups.restoreMissing'));
|
||||
}
|
||||
await Promise.resolve(onApplyPayload(detail.payload));
|
||||
});
|
||||
await refreshBackups();
|
||||
toast.success(t('cloudSync.localBackups.restoreSuccess'));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('cloudSync.localBackups.restoreFailedTitle'),
|
||||
);
|
||||
} finally {
|
||||
setRestoringBackupId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const restoreAllowed = restoreDisabledReason === null;
|
||||
// While encryptionAvailable is still `null` we're mid-probe — render the
|
||||
// restore button as disabled so the user never sees a path they can't
|
||||
// actually take (I1 surface). Once resolved, `false` hides the panel body
|
||||
// via the unavailable banner below.
|
||||
const encryptionResolved = encryptionAvailable !== null;
|
||||
const encryptionUsable = encryptionAvailable === true;
|
||||
|
||||
// safeStorage probe finished and returned "not available" → disable the
|
||||
// panel entirely; the main process refuses to write in this state (I1).
|
||||
if (encryptionResolved && !encryptionUsable) {
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle size={16} />
|
||||
<span className="text-sm font-medium">
|
||||
{t('cloudSync.localBackups.unavailableTitle')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('cloudSync.localBackups.unavailableDesc')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="max-w-lg">
|
||||
<div className="text-sm font-medium">{t('cloudSync.localBackups.retentionTitle')}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t('cloudSync.localBackups.retentionDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 md:min-w-[260px] md:shrink-0">
|
||||
<div className="flex items-end gap-2 md:justify-end">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={maxBackupsInput}
|
||||
onChange={(e) => setMaxBackupsInput(e.target.value)}
|
||||
className="w-28"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleSaveMaxBackups()}
|
||||
disabled={isSavingMaxBackups}
|
||||
className="gap-2"
|
||||
>
|
||||
{isSavingMaxBackups && <Loader2 size={14} className="animate-spin" />}
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!restoreAllowed && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400 mb-1">
|
||||
<AlertTriangle size={14} />
|
||||
<span className="font-medium">
|
||||
{t('cloudSync.localBackups.lockedTitle')}
|
||||
</span>
|
||||
</div>
|
||||
{t('cloudSync.localBackups.lockedDesc')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border bg-card p-4 space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('cloudSync.localBackups.title')}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t('cloudSync.localBackups.desc')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void refreshBackups()}
|
||||
disabled={isLoading}
|
||||
className="gap-1"
|
||||
>
|
||||
<RefreshCw size={14} className={cn(isLoading && 'animate-spin')} />
|
||||
{t('settings.system.refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void handleOpenBackupDirectory()}
|
||||
className="gap-1"
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
{t('settings.system.openFolder')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{backups.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/60 p-4 text-sm text-muted-foreground">
|
||||
{t('cloudSync.localBackups.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{backups.map((backup) => (
|
||||
<div
|
||||
key={backup.id}
|
||||
className="flex items-center gap-3 rounded-lg border border-border/60 p-3"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">
|
||||
{backup.syncDataVersion
|
||||
? `v${backup.syncDataVersion}`
|
||||
: formatTimestamp(backup.createdAt)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 flex items-center gap-1 flex-wrap">
|
||||
<span>{getReasonLabel(backup.reason)}</span>
|
||||
{backup.syncDataVersion && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{formatTimestamp(backup.createdAt)}</span>
|
||||
</>
|
||||
)}
|
||||
{backup.sourceAppVersion && backup.targetAppVersion && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>
|
||||
{t('cloudSync.localBackups.versionChange', {
|
||||
from: backup.sourceAppVersion,
|
||||
to: backup.targetAppVersion,
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t('cloudSync.localBackups.counts', {
|
||||
hosts: String(backup.preview.hostCount),
|
||||
keys: String(backup.preview.keyCount),
|
||||
snippets: String(backup.preview.snippetCount),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{restoreAllowed && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setPendingRestoreBackup(backup)}
|
||||
// Disable every row while ANY restore is in
|
||||
// flight. Each restore runs a full
|
||||
// `applyProtectedSyncPayload` — multiple
|
||||
// localStorage writes + the apply-in-progress
|
||||
// sentinel. `withRestoreBarrier` serializes
|
||||
// across windows but does NOT serialize
|
||||
// same-window re-entry, so two overlapping
|
||||
// clicks here would interleave destructive
|
||||
// writes and the second run's sentinel-clear
|
||||
// could mask a still-partial first apply.
|
||||
disabled={restoringBackupId !== null}
|
||||
className="gap-2"
|
||||
>
|
||||
{restoringBackupId === backup.id ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Download size={14} />
|
||||
)}
|
||||
{t('cloudSync.localBackups.restore')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Restore confirmation dialog (I2). Keeps the destructive action
|
||||
gated behind an explicit second click, mirroring the clear-local
|
||||
dialog elsewhere in this screen. */}
|
||||
<Dialog
|
||||
open={pendingRestoreBackup !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingRestoreBackup(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[440px] z-[70]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle size={20} />
|
||||
{t('cloudSync.localBackups.restoreConfirmTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('cloudSync.localBackups.restoreConfirmDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{pendingRestoreBackup && (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs space-y-1">
|
||||
<div className="font-medium">
|
||||
{pendingRestoreBackup.syncDataVersion
|
||||
? `v${pendingRestoreBackup.syncDataVersion}`
|
||||
: formatTimestamp(pendingRestoreBackup.createdAt)}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center gap-1 flex-wrap">
|
||||
<span>{getReasonLabel(pendingRestoreBackup.reason)}</span>
|
||||
{pendingRestoreBackup.syncDataVersion && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{formatTimestamp(pendingRestoreBackup.createdAt)}</span>
|
||||
</>
|
||||
)}
|
||||
{pendingRestoreBackup.sourceAppVersion && pendingRestoreBackup.targetAppVersion && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>
|
||||
{t('cloudSync.localBackups.versionChange', {
|
||||
from: pendingRestoreBackup.sourceAppVersion,
|
||||
to: pendingRestoreBackup.targetAppVersion,
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{t('cloudSync.localBackups.counts', {
|
||||
hosts: String(pendingRestoreBackup.preview.hostCount),
|
||||
keys: String(pendingRestoreBackup.preview.keyCount),
|
||||
snippets: String(pendingRestoreBackup.preview.snippetCount),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPendingRestoreBackup(null)}
|
||||
disabled={restoringBackupId !== null}
|
||||
>
|
||||
{t('cloudSync.localBackups.restoreConfirmCancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
const target = pendingRestoreBackup;
|
||||
if (!target) return;
|
||||
setPendingRestoreBackup(null);
|
||||
await performRestore(target.id);
|
||||
}}
|
||||
disabled={restoringBackupId !== null}
|
||||
className="gap-2"
|
||||
>
|
||||
{restoringBackupId !== null ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Download size={14} />
|
||||
)}
|
||||
{t('cloudSync.localBackups.restoreConfirmButton')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
onBuildPayload,
|
||||
onApplyPayload,
|
||||
onApplyLocalPayload,
|
||||
onClearLocalData,
|
||||
}) => {
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
@@ -701,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;
|
||||
@@ -715,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);
|
||||
@@ -732,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);
|
||||
@@ -780,6 +1241,17 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// Clear local data dialog
|
||||
const [showClearLocalDialog, setShowClearLocalDialog] = useState(false);
|
||||
|
||||
// Sync-blocked banner (Task 7) + force-push confirmation modal (Task 8)
|
||||
const [blockedFinding, setBlockedFinding] = useState<Extract<ShrinkFinding, { suspicious: true }> | null>(null);
|
||||
const [showForcePushConfirm, setShowForcePushConfirm] = useState(false);
|
||||
|
||||
// Ref for scrolling to LocalBackupsPanel when the banner's Restore button is clicked
|
||||
const localBackupsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Active tab state — lets the banner's "Restore" button switch to the
|
||||
// local-backups tab without a separate DOM query.
|
||||
const [activeTab, setActiveTab] = useState<'providers' | 'status'>('providers');
|
||||
|
||||
const ensureSyncablePayload = useCallback(
|
||||
(payload: SyncPayload): boolean => {
|
||||
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
|
||||
@@ -798,6 +1270,35 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
}
|
||||
}, [sync.currentConflict]);
|
||||
|
||||
// Subscribe to sync events to show/clear the blocked-shrink banner.
|
||||
// Destructure the stable useCallback reference so the effect runs once on
|
||||
// mount rather than re-subscribing on every render when `sync` object ref changes.
|
||||
const { subscribeToEvents, getShrinkBlockedFinding } = sync;
|
||||
|
||||
// Hydrate from current manager state in case a shrink-block happened
|
||||
// before this component mounted (e.g., auto-sync ran while the user
|
||||
// was on a different tab). Without this, the banner only shows
|
||||
// blocks that occur after Settings is open.
|
||||
useEffect(() => {
|
||||
const existing = getShrinkBlockedFinding();
|
||||
if (existing) {
|
||||
setBlockedFinding(existing);
|
||||
}
|
||||
}, [getShrinkBlockedFinding]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = subscribeToEvents((event) => {
|
||||
if (event.type === 'SYNC_BLOCKED_SHRINK') {
|
||||
if (event.finding.suspicious) {
|
||||
setBlockedFinding(event.finding);
|
||||
}
|
||||
} else if (event.type === 'SYNC_BLOCKED_CLEARED') {
|
||||
setBlockedFinding(null);
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
}, [subscribeToEvents]);
|
||||
|
||||
// If we have a master key but we're still locked (e.g. older installs),
|
||||
// prompt once and persist the password via safeStorage.
|
||||
useEffect(() => {
|
||||
@@ -815,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);
|
||||
@@ -827,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');
|
||||
@@ -894,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 || '');
|
||||
@@ -1012,7 +1541,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
if (result.success) {
|
||||
// Apply merged data if a three-way merge happened
|
||||
if (result.mergedPayload && onApplyPayload) {
|
||||
onApplyPayload(result.mergedPayload);
|
||||
await Promise.resolve(onApplyPayload(result.mergedPayload));
|
||||
}
|
||||
toast.success(t('cloudSync.sync.success', { provider }));
|
||||
} else if (result.conflictDetected) {
|
||||
@@ -1030,13 +1559,49 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
try {
|
||||
const payload = await sync.resolveConflict(resolution);
|
||||
if (payload && resolution === 'USE_REMOTE') {
|
||||
onApplyPayload(payload);
|
||||
// USE_REMOTE applies cloud data over local — same data-loss
|
||||
// shape as a local backup restore, so gate auto-sync in
|
||||
// every other window the same way.
|
||||
await withRestoreBarrier(async () => {
|
||||
await Promise.resolve(onApplyPayload(payload));
|
||||
});
|
||||
toast.success(t('cloudSync.resolve.downloaded'));
|
||||
} else if (resolution === 'USE_LOCAL') {
|
||||
// Re-sync with local data
|
||||
// Re-sync with local data. Hold the same cross-window
|
||||
// restore barrier that USE_REMOTE uses: without it, a
|
||||
// concurrent auto-sync tick in another window can slip
|
||||
// between our conflict resolution and the upload,
|
||||
// producing a second upload path with stale state that
|
||||
// races against this push. USE_LOCAL doesn't mutate the
|
||||
// renderer's in-memory state (no onApplyPayload call), so
|
||||
// the barrier is belt-and-suspenders against the other
|
||||
// window's push, not ours.
|
||||
const localPayload = onBuildPayload();
|
||||
if (!ensureSyncablePayload(localPayload)) return;
|
||||
await sync.syncNow(localPayload);
|
||||
|
||||
let results: Map<CloudProvider, SyncResult> | null = null;
|
||||
await withRestoreBarrier(async () => {
|
||||
results = await sync.syncNow(localPayload, { overrideShrink: true });
|
||||
});
|
||||
|
||||
if (results) {
|
||||
// Apply any merged payload BEFORE closing the modal so local state
|
||||
// reflects what's now on cloud (in case remote changed during the merge).
|
||||
for (const result of (results as Map<CloudProvider, SyncResult>).values()) {
|
||||
if (result.mergedPayload) {
|
||||
await Promise.resolve(onApplyPayload(result.mergedPayload));
|
||||
break;
|
||||
}
|
||||
}
|
||||
const allOk = Array.from((results as Map<CloudProvider, SyncResult>).values()).every((r) => r.success);
|
||||
if (!allOk) {
|
||||
const firstError = Array.from((results as Map<CloudProvider, SyncResult>).values())
|
||||
.find((r) => !r.success)?.error
|
||||
?? t('common.unknownError');
|
||||
toast.error(firstError, t('cloudSync.resolve.failedTitle'));
|
||||
return; // KEEP the modal open so user can retry / pick USE_REMOTE
|
||||
}
|
||||
}
|
||||
toast.success(t('cloudSync.resolve.uploaded'));
|
||||
}
|
||||
setShowConflictModal(false);
|
||||
@@ -1094,9 +1659,14 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreRevision = () => {
|
||||
const handleRestoreRevision = async () => {
|
||||
if (!historyPreview) return;
|
||||
onApplyPayload(historyPreview.payload);
|
||||
// Gist revision restore is a destructive "replace local with cloud
|
||||
// snapshot" op — same shape as a local backup restore, same
|
||||
// cross-window race to block.
|
||||
await withRestoreBarrier(async () => {
|
||||
await Promise.resolve(onApplyPayload(historyPreview.payload));
|
||||
});
|
||||
toast.success(t('cloudSync.revisionHistory.restored'));
|
||||
setShowHistoryModal(false);
|
||||
setHistoryPreview(null);
|
||||
@@ -1142,7 +1712,20 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="providers" className="space-y-4">
|
||||
{blockedFinding && (
|
||||
<SyncBlockedBanner
|
||||
finding={blockedFinding}
|
||||
onRestore={() => {
|
||||
setActiveTab('status');
|
||||
requestAnimationFrame(() => {
|
||||
localBackupsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
}}
|
||||
onForcePush={() => setShowForcePushConfirm(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'providers' | 'status')} className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="providers">{t('cloudSync.providers.title')}</TabsTrigger>
|
||||
<TabsTrigger value="status">{t('cloudSync.status.title')}</TabsTrigger>
|
||||
@@ -1159,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')}
|
||||
@@ -1179,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')}
|
||||
@@ -1196,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')}
|
||||
@@ -1217,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')}
|
||||
@@ -1234,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')}
|
||||
@@ -1327,6 +1916,12 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={localBackupsRef}>
|
||||
<LocalBackupsPanel
|
||||
onApplyPayload={onApplyLocalPayload ?? onApplyPayload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Clear Local Data */}
|
||||
<div className="p-4 rounded-lg border border-destructive/30 bg-destructive/5">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -1356,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();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1945,6 +2540,69 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Force-push confirmation modal (Task 8) */}
|
||||
{showForcePushConfirm && blockedFinding && (
|
||||
<Dialog open onOpenChange={(open) => !open && setShowForcePushConfirm(false)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('sync.forcePush.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm">
|
||||
{t('sync.forcePush.body', {
|
||||
lost: blockedFinding.lost,
|
||||
entityType: t(`sync.entityType.${blockedFinding.entityType}`),
|
||||
})}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowForcePushConfirm(false)}>
|
||||
{t('sync.forcePush.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
const localPayload = onBuildPayload();
|
||||
if (!ensureSyncablePayload(localPayload)) {
|
||||
setShowForcePushConfirm(false);
|
||||
return;
|
||||
}
|
||||
setShowForcePushConfirm(false);
|
||||
try {
|
||||
const results = await sync.syncNow(localPayload, { overrideShrink: true });
|
||||
|
||||
// Apply any merged payload BEFORE clearing the banner. If a merge happened
|
||||
// during force-push (remote changed), the merged result is what the cloud
|
||||
// now has — applying it to local state prevents the next sync from
|
||||
// re-deleting the remote additions we just merged in.
|
||||
for (const result of results.values()) {
|
||||
if (result.mergedPayload) {
|
||||
await Promise.resolve(onApplyPayload(result.mergedPayload));
|
||||
break; // All providers share the same merged payload
|
||||
}
|
||||
}
|
||||
|
||||
const allOk = Array.from(results.values()).every((r) => r.success);
|
||||
if (allOk) {
|
||||
setBlockedFinding(null);
|
||||
} else {
|
||||
// Surface the failure but KEEP the banner so the user can retry or
|
||||
// restore. Find the first error string to display.
|
||||
const firstError = Array.from(results.values())
|
||||
.find((r) => !r.success)
|
||||
?.error ?? t('sync.toast.errorTitle');
|
||||
toast.error(firstError, t('sync.toast.errorTitle'));
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(String(err), t('sync.toast.errorTitle'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('sync.forcePush.confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1955,7 +2613,8 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
|
||||
interface CloudSyncSettingsProps {
|
||||
onBuildPayload: () => SyncPayload;
|
||||
onApplyPayload: (payload: SyncPayload) => void;
|
||||
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
onApplyLocalPayload?: (payload: SyncPayload) => void | Promise<void>;
|
||||
onClearLocalData?: () => void;
|
||||
}
|
||||
|
||||
@@ -1965,7 +2624,19 @@ export const CloudSyncSettings: React.FC<CloudSyncSettingsProps> = (props) => {
|
||||
// Simplified UX: once a master key is configured, we auto-unlock via safeStorage
|
||||
// so users don't have to manage a separate LOCKED screen.
|
||||
if (securityState === 'NO_KEY') {
|
||||
return <GatekeeperScreen onSetupComplete={() => { }} />;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<GatekeeperScreen onSetupComplete={() => { }} />
|
||||
{/* The master key is not configured yet. Expose the backup
|
||||
history for diagnostic purposes but refuse restores: the
|
||||
vault encryption layer can't re-protect the restored
|
||||
credentials until the user finishes master-key setup (I3). */}
|
||||
<LocalBackupsPanel
|
||||
onApplyPayload={props.onApplyPayload}
|
||||
restoreDisabledReason="no-master-key"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <SyncDashboard {...props} />;
|
||||
|
||||
@@ -67,27 +67,27 @@ export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-md flex flex-col max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('dialog.createWorkspace.title', 'Create Workspace')}</DialogTitle>
|
||||
<DialogTitle>{t('dialog.createWorkspace.title', { defaultValue: 'Create Workspace' })}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2 flex-1 flex flex-col min-h-0">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="workspace-name">{t('field.name', 'Name')}</Label>
|
||||
<Label htmlFor="workspace-name">{t('field.name', { defaultValue: 'Name' })}</Label>
|
||||
<Input
|
||||
id="workspace-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('placeholder.workspaceName', 'Workspace Name')}
|
||||
placeholder={t('placeholder.workspaceName', { defaultValue: 'Workspace Name' })}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 flex-1 flex flex-col min-h-0">
|
||||
<Label>{t('field.selectHosts', 'Select Hosts')}</Label>
|
||||
<Label>{t('field.selectHosts', { defaultValue: 'Select Hosts' })}</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t('placeholder.searchHosts', 'Search hosts...')}
|
||||
placeholder={t('placeholder.searchHosts', { defaultValue: 'Search hosts...' })}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
@@ -99,7 +99,7 @@ export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
|
||||
<div className="p-2 space-y-1">
|
||||
{filteredHosts.length === 0 ? (
|
||||
<div className="text-center py-4 text-sm text-muted-foreground">
|
||||
{t('common.noResults', 'No hosts found')}
|
||||
{t('common.noResults', { defaultValue: 'No hosts found' })}
|
||||
</div>
|
||||
) : (
|
||||
filteredHosts.map(host => {
|
||||
@@ -126,15 +126,15 @@ export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground text-right">
|
||||
{selectedHostIds.size} {t('common.selected', 'selected')}
|
||||
{selectedHostIds.size} {t('common.selected', { defaultValue: 'selected' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
|
||||
<Button variant="ghost" onClick={onClose}>{t('common.cancel', { defaultValue: 'Cancel' })}</Button>
|
||||
<Button onClick={handleCreate} disabled={!name.trim() || selectedHostIds.size === 0}>
|
||||
{t('common.create', 'Create')}
|
||||
{t('common.create', { defaultValue: 'Create' })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -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>
|
||||
|
||||
51
components/HostDetailsPanel.proxyProfile.test.tsx
Normal file
51
components/HostDetailsPanel.proxyProfile.test.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import type { Host } from "../types.ts";
|
||||
import HostDetailsPanel from "./HostDetailsPanel.tsx";
|
||||
|
||||
const hostWithMissingProxyProfile: Host = {
|
||||
id: "host-1",
|
||||
label: "DB",
|
||||
hostname: "db.example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
port: 22,
|
||||
protocol: "ssh",
|
||||
authMethod: "password",
|
||||
proxyProfileId: "missing-proxy",
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const renderHostDetails = () =>
|
||||
renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData: hostWithMissingProxyProfile,
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: [],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
test("HostDetailsPanel shows a missing saved proxy without undefined fields", () => {
|
||||
const markup = renderHostDetails();
|
||||
|
||||
assert.match(markup, /Missing saved proxy/);
|
||||
assert.doesNotMatch(markup, /undefined:undefined/);
|
||||
});
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
LINUX_DISTRO_OPTIONS,
|
||||
NETWORK_DEVICE_OPTIONS,
|
||||
} from "../domain/host";
|
||||
import { isCompleteProxyConfig, normalizeManualProxyConfig } from "../domain/proxyProfiles";
|
||||
import { customThemeStore } from "../application/state/customThemeStore";
|
||||
import {
|
||||
clearHostFontSizeOverride,
|
||||
@@ -48,7 +49,7 @@ import {
|
||||
} from "../domain/terminalAppearance";
|
||||
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts";
|
||||
import { cn } from "../lib/utils";
|
||||
import { EnvVar, GroupConfig, Host, Identity, ManagedSource, ProxyConfig, SSHKey } from "../types";
|
||||
import { EnvVar, GroupConfig, Host, Identity, ManagedSource, ProxyConfig, ProxyProfile, SSHKey } from "../types";
|
||||
import { DISTRO_COLORS, DISTRO_LOGOS } from "./DistroAvatar";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import ThemeSelectPanel from "./ThemeSelectPanel";
|
||||
@@ -69,6 +70,7 @@ import { Textarea } from "./ui/textarea";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
import { toast } from "./ui/toast";
|
||||
|
||||
// Import host-details sub-panels
|
||||
import {
|
||||
@@ -97,6 +99,7 @@ interface HostDetailsPanelProps {
|
||||
initialData?: Host | null;
|
||||
availableKeys: SSHKey[];
|
||||
identities: Identity[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
groups: string[];
|
||||
managedSources?: ManagedSource[];
|
||||
allTags?: string[]; // All available tags for autocomplete
|
||||
@@ -117,6 +120,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
initialData,
|
||||
availableKeys,
|
||||
identities,
|
||||
proxyProfiles = [],
|
||||
groups,
|
||||
managedSources = [],
|
||||
allTags = [],
|
||||
@@ -260,6 +264,24 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
);
|
||||
|
||||
const effectiveFormDistro = getEffectiveHostDistro(form);
|
||||
const selectedProxyProfile = useMemo(
|
||||
() => proxyProfiles.find((profile) => profile.id === form.proxyProfileId),
|
||||
[form.proxyProfileId, proxyProfiles],
|
||||
);
|
||||
const hasMissingProxyProfile = Boolean(form.proxyProfileId && !selectedProxyProfile);
|
||||
const proxySummaryType = hasMissingProxyProfile
|
||||
? t("hostDetails.proxyPanel.missing")
|
||||
: (selectedProxyProfile?.config.type || form.proxyConfig?.type || "http").toUpperCase();
|
||||
const proxySummaryLabel = hasMissingProxyProfile
|
||||
? t("hostDetails.proxyPanel.missingSaved")
|
||||
: selectedProxyProfile
|
||||
? selectedProxyProfile.label
|
||||
: `${form.proxyConfig?.host}:${form.proxyConfig?.port}`;
|
||||
const proxySummaryTooltip = hasMissingProxyProfile
|
||||
? t("hostDetails.proxyPanel.missingSaved")
|
||||
: selectedProxyProfile
|
||||
? `${selectedProxyProfile.label} - ${selectedProxyProfile.config.host}:${selectedProxyProfile.config.port}`
|
||||
: `${form.proxyConfig?.type?.toUpperCase()} ${form.proxyConfig?.host}:${form.proxyConfig?.port}`;
|
||||
|
||||
const handleDistroModeChange = useCallback((mode: "auto" | "manual") => {
|
||||
setForm((prev) => ({
|
||||
@@ -274,27 +296,38 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
|
||||
const updateProxyConfig = useCallback(
|
||||
(field: keyof ProxyConfig, value: string | number) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
proxyConfig: {
|
||||
type: prev.proxyConfig?.type || "http",
|
||||
host: prev.proxyConfig?.host || "",
|
||||
port: prev.proxyConfig?.port || 8080,
|
||||
...prev.proxyConfig,
|
||||
[field]: value,
|
||||
},
|
||||
}));
|
||||
setForm((prev) => {
|
||||
const { proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
return {
|
||||
...rest,
|
||||
proxyConfig: {
|
||||
type: prev.proxyConfig?.type || "http",
|
||||
host: prev.proxyConfig?.host || "",
|
||||
port: prev.proxyConfig?.port || 8080,
|
||||
...prev.proxyConfig,
|
||||
[field]: value,
|
||||
},
|
||||
} as Host;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clearProxyConfig = useCallback(() => {
|
||||
setForm((prev) => {
|
||||
const { proxyConfig: _proxyConfig, ...rest } = prev;
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
return rest as Host;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectProxyProfile = useCallback((profileId: string | undefined) => {
|
||||
setForm((prev) => {
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
if (!profileId) return rest as Host;
|
||||
return { ...rest, proxyProfileId: profileId } as Host;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addHostToChain = (hostId: string) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
@@ -342,6 +375,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!form.hostname) return;
|
||||
const normalizedProxyConfig = normalizeManualProxyConfig(form.proxyConfig);
|
||||
if (normalizedProxyConfig && !isCompleteProxyConfig(normalizedProxyConfig)) {
|
||||
toast.error(
|
||||
normalizedProxyConfig.host ? t("proxyProfiles.error.port") : t("hostDetails.proxyPanel.error.required"),
|
||||
);
|
||||
setActiveSubPanel("proxy");
|
||||
return;
|
||||
}
|
||||
if (hasMissingProxyProfile) {
|
||||
toast.error(t("hostDetails.proxyPanel.missingSaved"));
|
||||
setActiveSubPanel("proxy");
|
||||
return;
|
||||
}
|
||||
// If label is empty, use hostname as label
|
||||
let finalLabel = form.label?.trim() || form.hostname;
|
||||
const finalGroup = groupInputValue.trim() || form.group || "";
|
||||
@@ -377,8 +423,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
finalManagedSourceId = undefined;
|
||||
}
|
||||
|
||||
const { proxyConfig: _draftProxyConfig, ...formWithoutProxyDraft } = form;
|
||||
const cleaned: Host = {
|
||||
...form,
|
||||
...formWithoutProxyDraft,
|
||||
...(normalizedProxyConfig && { proxyConfig: normalizedProxyConfig }),
|
||||
label: finalLabel,
|
||||
group: finalGroup,
|
||||
tags: form.tags || [],
|
||||
@@ -408,6 +456,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);
|
||||
};
|
||||
|
||||
@@ -532,7 +584,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
return (
|
||||
<ProxyPanel
|
||||
proxyConfig={form.proxyConfig}
|
||||
proxyProfiles={proxyProfiles}
|
||||
selectedProxyProfileId={form.proxyProfileId}
|
||||
onUpdateProxy={updateProxyConfig}
|
||||
onSelectProxyProfile={selectProxyProfile}
|
||||
onClearProxy={clearProxyConfig}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
@@ -1551,11 +1606,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 +1649,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">
|
||||
@@ -1732,35 +1809,40 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
<Globe size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.proxy")}</p>
|
||||
</div>
|
||||
{form.proxyConfig?.host ? (
|
||||
<button
|
||||
className="w-full min-w-0 grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer overflow-hidden"
|
||||
onClick={() => setActiveSubPanel("proxy")}
|
||||
>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{form.proxyConfig.type?.toUpperCase()}
|
||||
</Badge>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||
{form.proxyConfig.host}:{form.proxyConfig.port}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" className="max-w-xs break-all">
|
||||
{form.proxyConfig.type?.toUpperCase()} {form.proxyConfig.host}:{form.proxyConfig.port}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<X
|
||||
size={14}
|
||||
className="text-muted-foreground hover:text-destructive flex-shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
clearProxyConfig();
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{form.proxyConfig?.host || form.proxyProfileId ? (
|
||||
<div className="w-full min-w-0 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer overflow-hidden"
|
||||
onClick={() => setActiveSubPanel("proxy")}
|
||||
>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{proxySummaryType}
|
||||
</Badge>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||
{proxySummaryLabel}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" className="max-w-xs break-all">
|
||||
{proxySummaryTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-muted-foreground hover:text-destructive shrink-0"
|
||||
aria-label={t("hostDetails.proxyPanel.remove")}
|
||||
onClick={clearProxyConfig}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -22,7 +22,7 @@ import { resolveHostAuth } from "../domain/sshAuth";
|
||||
import { STORAGE_KEY_VAULT_KEYS_VIEW_MODE } from "../infrastructure/config/storageKeys";
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host, Identity, KeyType, SSHKey } from "../types";
|
||||
import { Host, Identity, KeyType, ProxyProfile, SSHKey } from "../types";
|
||||
import { ManagedSource } from "../domain/models";
|
||||
import { useKeychainBackend } from "../application/state/useKeychainBackend";
|
||||
import SelectHostPanel from "./SelectHostPanel";
|
||||
@@ -68,6 +68,7 @@ interface KeychainManagerProps {
|
||||
keys: SSHKey[];
|
||||
identities?: Identity[];
|
||||
hosts?: Host[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
customGroups?: string[];
|
||||
managedSources?: ManagedSource[];
|
||||
onSave: (key: SSHKey) => void;
|
||||
@@ -84,6 +85,7 @@ const KeychainManager: React.FC<KeychainManagerProps> = ({
|
||||
keys,
|
||||
identities = [],
|
||||
hosts = [],
|
||||
proxyProfiles = [],
|
||||
customGroups = [],
|
||||
managedSources = [],
|
||||
onSave,
|
||||
@@ -520,7 +522,7 @@ echo $3 >> "$FILE"`);
|
||||
)}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-3 bg-secondary/60 border-b border-border/70 px-3 py-1.5 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 */}
|
||||
@@ -528,16 +530,15 @@ echo $3 >> "$FILE"`);
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center rounded-md transition-colors",
|
||||
activeFilter === "key" ? "bg-primary/15" : "hover:bg-accent",
|
||||
activeFilter === "key"
|
||||
? "bg-foreground/10 text-foreground hover:bg-foreground/15"
|
||||
: "bg-foreground/5 text-foreground hover:bg-foreground/10",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 px-3 gap-2 rounded-r-none hover:bg-transparent",
|
||||
activeFilter === "key" && "text-primary",
|
||||
)}
|
||||
className="h-10 px-3 gap-2 rounded-r-none hover:bg-transparent text-inherit"
|
||||
onClick={() => setActiveFilter("key")}
|
||||
>
|
||||
<Key size={14} />
|
||||
@@ -547,10 +548,7 @@ echo $3 >> "$FILE"`);
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 px-1.5 rounded-l-none hover:bg-transparent",
|
||||
activeFilter === "key" && "text-primary",
|
||||
)}
|
||||
className="h-10 px-1.5 rounded-l-none hover:bg-transparent text-inherit"
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
</Button>
|
||||
@@ -589,33 +587,24 @@ echo $3 >> "$FILE"`);
|
||||
className={cn(
|
||||
"flex items-center rounded-md transition-colors",
|
||||
activeFilter === "certificate"
|
||||
? "bg-primary/15"
|
||||
: "hover:bg-accent",
|
||||
? "bg-foreground/10 text-foreground hover:bg-foreground/15"
|
||||
: "bg-foreground/5 text-foreground hover:bg-foreground/10",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 px-3 gap-2 rounded-r-none hover:bg-transparent",
|
||||
activeFilter === "certificate" && "text-primary",
|
||||
)}
|
||||
className="h-10 px-3 gap-2 rounded-r-none hover:bg-transparent text-inherit"
|
||||
onClick={() => setActiveFilter("certificate")}
|
||||
>
|
||||
<BadgeCheck size={14} />
|
||||
{t("keychain.filter.certificate")}
|
||||
<span className="text-[10px] px-1.5 rounded-full bg-muted text-muted-foreground">
|
||||
{keys.filter((k) => k.certificate).length}
|
||||
</span>
|
||||
</Button>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 px-1.5 rounded-l-none hover:bg-transparent",
|
||||
activeFilter === "certificate" && "text-primary",
|
||||
)}
|
||||
className="h-10 px-1.5 rounded-l-none hover:bg-transparent text-inherit"
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
</Button>
|
||||
@@ -645,7 +634,7 @@ echo $3 >> "$FILE"`);
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("common.searchPlaceholder")}
|
||||
className="h-9 pl-8 w-full"
|
||||
className="h-10 pl-9 w-full bg-secondary border-border/60 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -654,7 +643,7 @@ echo $3 >> "$FILE"`);
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 flex-shrink-0"
|
||||
className="h-10 w-10 flex-shrink-0"
|
||||
>
|
||||
{viewMode === "grid" ? (
|
||||
<LayoutGrid size={16} />
|
||||
@@ -1247,6 +1236,7 @@ echo $3 >> "$FILE"`);
|
||||
onBack={() => setShowHostSelector(false)}
|
||||
onContinue={() => setShowHostSelector(false)}
|
||||
availableKeys={keys}
|
||||
proxyProfiles={proxyProfiles}
|
||||
managedSources={managedSources}
|
||||
onSaveHost={onSaveHost}
|
||||
onCreateGroup={onCreateGroup}
|
||||
|
||||
@@ -455,7 +455,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-border/50 bg-secondary/50">
|
||||
<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
|
||||
@@ -464,7 +464,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
/>
|
||||
<Input
|
||||
placeholder={t("knownHosts.search.placeholder")}
|
||||
className="pl-9 h-9 bg-background border-border/60 text-sm"
|
||||
className="pl-9 h-10 bg-secondary border-border/60 text-sm"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
@@ -474,7 +474,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
{/* View Mode Toggle */}
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9">
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10">
|
||||
{viewMode === "grid" ? (
|
||||
<LayoutGrid size={16} />
|
||||
) : (
|
||||
@@ -505,15 +505,14 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
<SortDropdown
|
||||
value={sortMode}
|
||||
onChange={setSortMode}
|
||||
className="h-9 w-9"
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-px h-5 bg-border/50" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 px-3 text-xs"
|
||||
variant="secondary"
|
||||
className="h-10 px-3 bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
|
||||
onClick={() => handleScanSystem()}
|
||||
disabled={isScanning}
|
||||
>
|
||||
@@ -532,8 +531,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-9 px-3 text-xs"
|
||||
className="h-10 px-3 bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
|
||||
onClick={openFilePicker}
|
||||
>
|
||||
<Import size={14} className="mr-2" />
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Shuffle,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
||||
import {
|
||||
@@ -19,9 +19,11 @@ import {
|
||||
ManagedSource,
|
||||
PortForwardingRule,
|
||||
PortForwardingType,
|
||||
ProxyProfile,
|
||||
SSHKey,
|
||||
} from "../domain/models";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
|
||||
import { cn } from "../lib/utils";
|
||||
import SelectHostPanel from "./SelectHostPanel";
|
||||
import {
|
||||
@@ -69,6 +71,7 @@ interface PortForwardingProps {
|
||||
customGroups: string[];
|
||||
managedSources?: ManagedSource[];
|
||||
groupConfigs?: GroupConfig[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
onNewHost?: () => void;
|
||||
onSaveHost?: (host: Host) => void;
|
||||
onCreateGroup?: (groupPath: string) => void;
|
||||
@@ -81,6 +84,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
customGroups: _customGroups,
|
||||
managedSources = [],
|
||||
groupConfigs = [],
|
||||
proxyProfiles = [],
|
||||
onNewHost: _onNewHost,
|
||||
onSaveHost,
|
||||
onCreateGroup: _onCreateGroup,
|
||||
@@ -113,6 +117,20 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
const [pendingOperations, setPendingOperations] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const proxyProfileIdSet = useMemo(
|
||||
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
[proxyProfiles],
|
||||
);
|
||||
|
||||
const resolveEffectiveHost = useCallback(
|
||||
(host: Host): Host => {
|
||||
const withGroupDefaults = host.group
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
},
|
||||
[groupConfigs, proxyProfileIdSet, proxyProfiles],
|
||||
);
|
||||
|
||||
// Start a port forwarding tunnel
|
||||
const handleStartTunnel = useCallback(
|
||||
@@ -127,9 +145,8 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const _host = _rawHost.group
|
||||
? applyGroupDefaults(_rawHost, resolveGroupDefaults(_rawHost.group, groupConfigs))
|
||||
: _rawHost;
|
||||
const _host = resolveEffectiveHost(_rawHost);
|
||||
const effectiveHosts = hosts.map((host) => resolveEffectiveHost(host));
|
||||
|
||||
setPendingOperations((prev) => new Set([...prev, rule.id]));
|
||||
let errorShown = false;
|
||||
@@ -138,7 +155,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
const result = await startTunnel(
|
||||
rule,
|
||||
_host,
|
||||
hosts,
|
||||
effectiveHosts,
|
||||
keys,
|
||||
identities,
|
||||
(status, error) => {
|
||||
@@ -169,7 +186,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
});
|
||||
}
|
||||
},
|
||||
[hosts, identities, keys, groupConfigs, setRuleStatus, startTunnel, t],
|
||||
[hosts, identities, keys, resolveEffectiveHost, setRuleStatus, startTunnel, t],
|
||||
);
|
||||
|
||||
// Stop a port forwarding tunnel
|
||||
@@ -567,10 +584,13 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
)}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div className="h-14 px-4 flex items-center gap-3 bg-secondary/60 border-b border-border/60 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 variant="secondary" className="h-9 px-3 gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-10 px-3 gap-2 bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
|
||||
>
|
||||
<Zap size={14} />
|
||||
{t("pf.action.newForwarding")}
|
||||
<ChevronDown
|
||||
@@ -618,7 +638,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
/>
|
||||
<Input
|
||||
placeholder={t("common.searchPlaceholder")}
|
||||
className="h-9 pl-8 w-44"
|
||||
className="h-10 pl-9 w-44 bg-secondary border-border/60 text-sm"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
@@ -627,7 +647,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
{/* View mode toggle */}
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9">
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10">
|
||||
{viewMode === "grid" ? (
|
||||
<LayoutGrid size={16} />
|
||||
) : (
|
||||
@@ -664,7 +684,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
<SortDropdown
|
||||
value={sortMode}
|
||||
onChange={setSortMode}
|
||||
className="h-9 w-9"
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -850,6 +870,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;
|
||||
@@ -30,6 +30,7 @@ export interface QuickAddSnippetDialogProps {
|
||||
snippets: Snippet[];
|
||||
packages: string[];
|
||||
onCreateSnippet: (snippet: Snippet) => void;
|
||||
onUpdateSnippet?: (snippet: Snippet) => void;
|
||||
onCreatePackage?: (packagePath: string) => void;
|
||||
}
|
||||
|
||||
@@ -37,6 +38,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
snippets,
|
||||
packages,
|
||||
onCreateSnippet,
|
||||
onUpdateSnippet,
|
||||
onCreatePackage,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -44,6 +46,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
const [label, setLabel] = useState('');
|
||||
const [command, setCommand] = useState('');
|
||||
const [packagePath, setPackagePath] = useState('');
|
||||
const [editing, setEditing] = useState<Snippet | null>(null);
|
||||
const labelInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Listen for the global "add snippet" request dispatched by the
|
||||
@@ -51,6 +54,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
// every open so stale input from a previous cancel does not leak.
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setEditing(null);
|
||||
setLabel('');
|
||||
setCommand('');
|
||||
setPackagePath('');
|
||||
@@ -60,6 +64,23 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
return () => window.removeEventListener('netcatty:snippets:add', handler);
|
||||
}, []);
|
||||
|
||||
// Sibling event for editing an existing snippet from the ScriptsSidePanel
|
||||
// context menu. Prefills the form and flips the dialog into update mode.
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ snippet?: Snippet }>).detail;
|
||||
const snippet = detail?.snippet;
|
||||
if (!snippet) return;
|
||||
setEditing(snippet);
|
||||
setLabel(snippet.label ?? '');
|
||||
setCommand(snippet.command ?? '');
|
||||
setPackagePath(snippet.package ?? '');
|
||||
setOpen(true);
|
||||
};
|
||||
window.addEventListener('netcatty:snippets:edit', handler);
|
||||
return () => window.removeEventListener('netcatty:snippets:edit', handler);
|
||||
}, []);
|
||||
|
||||
// Auto-focus the label input once the dialog renders, so the user can
|
||||
// start typing immediately after clicking the + button.
|
||||
useEffect(() => {
|
||||
@@ -92,16 +113,27 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
if (trimmedPackage && !packages.includes(trimmedPackage)) {
|
||||
onCreatePackage?.(trimmedPackage);
|
||||
}
|
||||
onCreateSnippet({
|
||||
id: crypto.randomUUID(),
|
||||
label: label.trim(),
|
||||
command, // preserve whitespace in multi-line commands
|
||||
tags: [],
|
||||
package: trimmedPackage || '',
|
||||
targets: [],
|
||||
});
|
||||
if (editing && onUpdateSnippet) {
|
||||
// Preserve tags/targets/shortkey/noAutoRun etc. that this lightweight
|
||||
// dialog does not expose — only the three quick-edit fields change.
|
||||
onUpdateSnippet({
|
||||
...editing,
|
||||
label: label.trim(),
|
||||
command,
|
||||
package: trimmedPackage || '',
|
||||
});
|
||||
} else {
|
||||
onCreateSnippet({
|
||||
id: crypto.randomUUID(),
|
||||
label: label.trim(),
|
||||
command, // preserve whitespace in multi-line commands
|
||||
tags: [],
|
||||
package: trimmedPackage || '',
|
||||
targets: [],
|
||||
});
|
||||
}
|
||||
setOpen(false);
|
||||
}, [canSave, packagePath, packages, onCreatePackage, onCreateSnippet, label, command]);
|
||||
}, [canSave, packagePath, packages, onCreatePackage, onCreateSnippet, onUpdateSnippet, editing, label, command]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
@@ -118,7 +150,9 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-md" onKeyDown={handleKeyDown}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('snippets.panel.newTitle')}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{t(editing ? 'snippets.panel.editTitle' : 'snippets.panel.newTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('snippets.empty.desc')}
|
||||
</DialogDescription>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {
|
||||
Folder,
|
||||
LayoutGrid,
|
||||
Search,
|
||||
FolderLock,
|
||||
LayoutGrid,
|
||||
Plus,
|
||||
Search,
|
||||
Terminal,
|
||||
TerminalSquare,
|
||||
} from "lucide-react";
|
||||
@@ -68,7 +69,7 @@ interface QuickSwitcherProps {
|
||||
onSelectTab: (tabId: string) => void;
|
||||
onClose: () => void;
|
||||
onCreateLocalTerminal?: (shell?: { command: string; args?: string[]; name?: string; icon?: string }) => void;
|
||||
// onCreateWorkspace removed - feature not currently used
|
||||
onCreateWorkspace?: () => void;
|
||||
keyBindings?: KeyBinding[];
|
||||
showSftpTab: boolean;
|
||||
}
|
||||
@@ -84,6 +85,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
onSelectTab,
|
||||
onClose,
|
||||
onCreateLocalTerminal,
|
||||
onCreateWorkspace,
|
||||
keyBindings,
|
||||
showSftpTab,
|
||||
}) => {
|
||||
@@ -280,7 +282,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
<ScrollArea className="flex-1 h-full">
|
||||
{/* Categorized view: Hosts/Tabs/Quick connect */}
|
||||
<div>
|
||||
{/* Jump To hint */}
|
||||
{/* Jump To hint + New Workspace action */}
|
||||
<div className="px-4 py-2 flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{t("qs.jumpTo")}</span>
|
||||
{quickSwitchKey && (
|
||||
@@ -288,6 +290,20 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
{quickSwitchKey.replace(/ \+ /g, '+')}
|
||||
</kbd>
|
||||
)}
|
||||
{onCreateWorkspace && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onCreateWorkspace();
|
||||
onClose();
|
||||
}}
|
||||
className="ml-auto inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground border border-border rounded px-1.5 py-0.5 transition-colors hover:bg-muted/50"
|
||||
title="New Workspace"
|
||||
>
|
||||
<Plus size={11} />
|
||||
<span>New Workspace</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hosts section */}
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
/**
|
||||
* 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, Package, Plus, Search, 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';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} 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[];
|
||||
@@ -20,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,
|
||||
@@ -27,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
|
||||
@@ -126,11 +216,24 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
window.dispatchEvent(new CustomEvent('netcatty:snippets:add'));
|
||||
}, []);
|
||||
|
||||
const handleEditSnippet = useCallback((snippet: Snippet) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('netcatty:snippets:edit', { detail: { snippet } }),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleDeleteSnippet = useCallback((id: string) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('netcatty:snippets:delete', { detail: { id } }),
|
||||
);
|
||||
}, []);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
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"
|
||||
@@ -157,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">
|
||||
@@ -191,41 +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) => (
|
||||
<button
|
||||
key={s.id}
|
||||
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>
|
||||
))}
|
||||
{/* 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>
|
||||
@@ -233,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}
|
||||
|
||||
@@ -152,7 +152,14 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
|
||||
<div className="flex items-center gap-4">
|
||||
<AppLogo className="w-16 h-16" />
|
||||
<div>
|
||||
<div className="text-3xl font-semibold leading-none">{appInfo.name}</div>
|
||||
{/* Match the Vault sidebar wordmark so the Netcatty brand
|
||||
reads consistently across surfaces — same italic heavy
|
||||
cut, just scaled up for the Settings hero area and
|
||||
using the branded mixed-case "Netcatty" instead of
|
||||
the lowercase electron app name. */}
|
||||
<div className="text-3xl font-black italic tracking-tight leading-none text-foreground">
|
||||
Netcatty
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{appInfo.version ? appInfo.version : " "}
|
||||
|
||||
@@ -41,7 +41,7 @@ class AITabErrorBoundary extends React.Component<
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
return (this.props as { children: React.ReactNode }).children;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,19 @@ interface SftpSidePanelProps {
|
||||
editorWordWrap: boolean;
|
||||
setEditorWordWrap: (value: boolean) => void;
|
||||
onGetTerminalCwd?: () => Promise<string | null>;
|
||||
onRequestTerminalFocus?: () => void;
|
||||
}
|
||||
|
||||
const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
hosts,
|
||||
writableHosts,
|
||||
keys,
|
||||
identities,
|
||||
updateHosts,
|
||||
sftpDefaultViewMode,
|
||||
activeHost,
|
||||
initialLocation,
|
||||
onInitialLocationApplied,
|
||||
showWorkspaceHostHeader = false,
|
||||
isVisible = true,
|
||||
renderOverlays = true,
|
||||
@@ -89,8 +97,10 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
onGetTerminalCwd,
|
||||
onRequestTerminalFocus,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const hostWriteSource = writableHosts ?? hosts;
|
||||
|
||||
const fileWatchHandlers = useMemo(() => ({
|
||||
onFileWatchSynced: (payload: { remotePath: string }) => {
|
||||
@@ -125,6 +135,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 +275,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
handleSaveTextFile,
|
||||
onPromoteToTab,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
} = useSftpViewPaneCallbacks({
|
||||
@@ -422,16 +474,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 +625,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
return (
|
||||
<SftpContextProvider
|
||||
hosts={hosts}
|
||||
writableHosts={hostWriteSource}
|
||||
updateHosts={updateHosts}
|
||||
draggedFiles={draggedFiles}
|
||||
dragCallbacks={dragCallbacks}
|
||||
@@ -679,6 +734,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
setFileOpenerTarget={setFileOpenerTarget}
|
||||
handleFileOpenerSelect={handleFileOpenerSelect}
|
||||
handleSelectSystemApp={handleSelectSystemApp}
|
||||
onPromoteToTab={onPromoteToTab}
|
||||
onRequestTerminalFocus={onRequestTerminalFocus}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
@@ -688,6 +745,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
|
||||
const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps): boolean =>
|
||||
prev.hosts === next.hosts &&
|
||||
prev.writableHosts === next.writableHosts &&
|
||||
prev.keys === next.keys &&
|
||||
prev.identities === next.identities &&
|
||||
prev.updateHosts === next.updateHosts &&
|
||||
@@ -707,6 +765,7 @@ 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;
|
||||
|
||||
|
||||
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";
|
||||
@@ -70,6 +73,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
keys,
|
||||
identities,
|
||||
groupConfigs = [],
|
||||
proxyProfiles = [],
|
||||
updateHosts,
|
||||
sftpDefaultViewMode,
|
||||
sftpDoubleClickBehavior,
|
||||
@@ -108,14 +112,15 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
|
||||
|
||||
// Pre-resolve group defaults so SFTP connections inherit group config
|
||||
const effectiveHosts = useMemo(() =>
|
||||
hosts.map(h => {
|
||||
if (!h.group) return h;
|
||||
const defaults = resolveGroupDefaults(h.group, groupConfigs);
|
||||
return applyGroupDefaults(h, defaults);
|
||||
}),
|
||||
[hosts, groupConfigs],
|
||||
);
|
||||
const effectiveHosts = useMemo(() => {
|
||||
const validProxyProfileIds = new Set(proxyProfiles.map((profile) => profile.id));
|
||||
return hosts.map(h => {
|
||||
const withGroupDefaults = h.group
|
||||
? applyGroupDefaults(h, resolveGroupDefaults(h.group, groupConfigs, { validProxyProfileIds }), { validProxyProfileIds })
|
||||
: applyGroupDefaults(h, {}, { validProxyProfileIds });
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
});
|
||||
}, [hosts, groupConfigs, proxyProfiles]);
|
||||
|
||||
const sftp = useSftpState(effectiveHosts, keys, identities, sftpOptions);
|
||||
|
||||
@@ -135,6 +140,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 +241,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
handleSaveTextFile,
|
||||
onPromoteToTab,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
} = useSftpViewPaneCallbacks({
|
||||
@@ -304,7 +327,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
|
||||
return (
|
||||
<SftpContextProvider
|
||||
hosts={hosts}
|
||||
hosts={effectiveHosts}
|
||||
writableHosts={hosts}
|
||||
updateHosts={updateHosts}
|
||||
draggedFiles={draggedFiles}
|
||||
dragCallbacks={dragCallbacks}
|
||||
@@ -443,7 +467,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
</div>
|
||||
|
||||
<SftpOverlays
|
||||
hosts={hosts}
|
||||
hosts={effectiveHosts}
|
||||
sftp={sftp}
|
||||
visibleTransfers={visibleTransfers}
|
||||
showHostPickerLeft={showHostPickerLeft}
|
||||
@@ -475,6 +499,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
setFileOpenerTarget={setFileOpenerTarget}
|
||||
handleFileOpenerSelect={handleFileOpenerSelect}
|
||||
handleSelectSystemApp={handleSelectSystemApp}
|
||||
onPromoteToTab={onPromoteToTab}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
@@ -487,6 +512,7 @@ const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
|
||||
prev.keys === next.keys &&
|
||||
prev.identities === next.identities &&
|
||||
prev.groupConfigs === next.groupConfigs &&
|
||||
prev.proxyProfiles === next.proxyProfiles &&
|
||||
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
|
||||
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
|
||||
prev.sftpAutoSync === next.sftpAutoSync &&
|
||||
|
||||
@@ -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,
|
||||
@@ -402,9 +404,15 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
}, [packages, selectedPackage, snippets]);
|
||||
|
||||
const displayedSnippets = useMemo(() => {
|
||||
let result = snippets.filter((s) => (s.package || '') === (selectedPackage || ''));
|
||||
// Apply search filter
|
||||
if (search.trim()) {
|
||||
// Search spans all packages (#777): when the user types in the search
|
||||
// box we drop the current-package scoping so cross-package matches are
|
||||
// reachable without navigating into each one. Otherwise the user is
|
||||
// browsing and we keep the package scope.
|
||||
const hasSearch = search.trim().length > 0;
|
||||
let result = hasSearch
|
||||
? snippets
|
||||
: snippets.filter((s) => (s.package || '') === (selectedPackage || ''));
|
||||
if (hasSearch) {
|
||||
const s = search.toLowerCase();
|
||||
result = result.filter(sn =>
|
||||
sn.label.toLowerCase().includes(s) ||
|
||||
@@ -717,6 +725,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
onBack={handleTargetPickerBack}
|
||||
onContinue={handleTargetPickerBack}
|
||||
availableKeys={availableKeys}
|
||||
proxyProfiles={proxyProfiles}
|
||||
managedSources={managedSources}
|
||||
onSaveHost={onSaveHost}
|
||||
onCreateGroup={onCreateGroup}
|
||||
@@ -734,16 +743,35 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
title={editingSnippet.id ? t('snippets.panel.editTitle') : t('snippets.panel.newTitle')}
|
||||
layout="inline"
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleSubmit}
|
||||
disabled={!editingSnippet.label || !editingSnippet.command}
|
||||
aria-label={t('common.save')}
|
||||
>
|
||||
<Check size={16} />
|
||||
</Button>
|
||||
<>
|
||||
{editingSnippet.id && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
const id = editingSnippet.id;
|
||||
if (!id) return;
|
||||
onDelete(id);
|
||||
handleClosePanel();
|
||||
}}
|
||||
aria-label={t('common.delete')}
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleSubmit}
|
||||
disabled={!editingSnippet.label || !editingSnippet.command}
|
||||
aria-label={t('common.save')}
|
||||
>
|
||||
<Check size={16} />
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AsidePanelContent>
|
||||
@@ -958,8 +986,8 @@ 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">
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-2">
|
||||
<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">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
@@ -980,7 +1008,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
}}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-10 gap-2"
|
||||
className="h-10 gap-2 bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
|
||||
>
|
||||
<FolderPlus size={14} className="mr-1" /> {t('snippets.action.newPackage')}
|
||||
</Button>
|
||||
@@ -1049,7 +1077,10 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
)}
|
||||
|
||||
<div className="flex-1 space-y-3 overflow-y-auto px-4 pb-4">
|
||||
{displayedPackages.length > 0 && (
|
||||
{/* Hide the sub-package grid while searching (#777) — search spans
|
||||
all packages, so showing the package tiles alongside a flat
|
||||
cross-package snippet list is noisy. */}
|
||||
{displayedPackages.length > 0 && !search.trim() && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">{t('snippets.section.packages')}</h3>
|
||||
@@ -1196,6 +1227,29 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search-with-no-results feedback (#777 codex follow-up). Package
|
||||
tiles are already hidden during search, so the only visible
|
||||
surface is the flat snippet list — if that's empty the content
|
||||
area would be blank without this fallback. The gate intentionally
|
||||
excludes the fully-empty workspace (snippets.length === 0 AND
|
||||
displayedPackages.length === 0), which the global "Create
|
||||
snippet" empty state renders instead — avoids stacking two
|
||||
empty states. Package-only workspaces (no snippets yet) still
|
||||
get this feedback when searching. */}
|
||||
{search.trim() && displayedSnippets.length === 0 && (snippets.length > 0 || displayedPackages.length > 0) && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<div className="h-14 w-14 rounded-2xl bg-secondary/80 flex items-center justify-center mb-3">
|
||||
<Search size={24} className="opacity-60" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-foreground mb-1">
|
||||
{t('snippets.search.noResults.title')}
|
||||
</h3>
|
||||
<p className="text-xs text-center max-w-sm">
|
||||
{t('snippets.search.noResults.desc', { query: search.trim() })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -136,7 +136,13 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
// Determine overall status for the button indicator
|
||||
const getOverallStatus = (): StatusIndicatorProps['status'] => {
|
||||
if (sync.overallSyncStatus === 'syncing') return 'syncing';
|
||||
if (sync.overallSyncStatus === 'error' || sync.overallSyncStatus === 'conflict') return 'error';
|
||||
if (
|
||||
sync.overallSyncStatus === 'error' ||
|
||||
sync.overallSyncStatus === 'conflict' ||
|
||||
sync.overallSyncStatus === 'blocked'
|
||||
) {
|
||||
return 'error';
|
||||
}
|
||||
if (sync.overallSyncStatus === 'synced') return 'synced';
|
||||
return 'none';
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
shouldScrollOnTerminalInput,
|
||||
} from "../domain/terminalScroll";
|
||||
import {
|
||||
applyCustomAccentToTerminalTheme,
|
||||
resolveHostTerminalThemeId,
|
||||
} from "../domain/terminalAppearance";
|
||||
import { classifyDistroId } from "../domain/host";
|
||||
@@ -49,6 +50,8 @@ 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";
|
||||
import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch";
|
||||
@@ -125,6 +128,8 @@ interface TerminalProps {
|
||||
fontSize: number;
|
||||
terminalTheme: TerminalTheme;
|
||||
followAppTerminalTheme?: boolean;
|
||||
accentMode?: "theme" | "custom";
|
||||
customAccent?: string;
|
||||
terminalSettings?: TerminalSettings;
|
||||
sessionId: string;
|
||||
startupCommand?: string;
|
||||
@@ -183,6 +188,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,
|
||||
@@ -200,6 +228,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
fontSize,
|
||||
terminalTheme,
|
||||
followAppTerminalTheme = false,
|
||||
accentMode = "theme",
|
||||
customAccent = "",
|
||||
terminalSettings,
|
||||
sessionId,
|
||||
startupCommand,
|
||||
@@ -373,6 +403,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 {
|
||||
@@ -588,8 +624,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
pendingAuthRef,
|
||||
termRef,
|
||||
onUpdateHost,
|
||||
onStartSsh: (term) => {
|
||||
sessionStartersRef.current?.startSSH(term);
|
||||
onStartSession: (term) => {
|
||||
const starters = sessionStartersRef.current;
|
||||
if (!starters) return;
|
||||
if (host.moshEnabled) {
|
||||
starters.startMosh(term);
|
||||
return;
|
||||
}
|
||||
starters.startSSH(term);
|
||||
},
|
||||
setStatus: (next) => setStatus(next),
|
||||
setProgressLogs,
|
||||
@@ -620,6 +662,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleTopOverlayMouseDownCapture = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
if (!shouldPreserveTerminalFocusOnMouseDown(e.target)) return;
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
// Subscribe to custom theme changes so editing triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
const hasFontSizeOverride = host.fontSizeOverride === true || (host.fontSizeOverride === undefined && host.fontSize != null);
|
||||
@@ -645,18 +693,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// When "Follow Application Theme" is on and there's no active
|
||||
// preview, skip per-host overrides — all terminals should use the
|
||||
// UI-matched theme passed via terminalTheme prop.
|
||||
if (followAppTerminalTheme && !themePreviewId) return terminalTheme;
|
||||
if (followAppTerminalTheme && !themePreviewId) {
|
||||
return applyCustomAccentToTerminalTheme(terminalTheme, accentMode, customAccent);
|
||||
}
|
||||
const themeId = themePreviewId ?? resolveHostTerminalThemeId(
|
||||
{ theme: host.theme, themeOverride: host.themeOverride } as Pick<Host, 'theme' | 'themeOverride'>,
|
||||
terminalTheme.id,
|
||||
);
|
||||
let baseTheme = terminalTheme;
|
||||
if (themeId) {
|
||||
const hostTheme = TERMINAL_THEMES.find((t) => t.id === themeId)
|
||||
|| customThemes.find((t) => t.id === themeId);
|
||||
if (hostTheme) return hostTheme;
|
||||
if (hostTheme) baseTheme = hostTheme;
|
||||
}
|
||||
return terminalTheme;
|
||||
}, [customThemes, followAppTerminalTheme, host.theme, host.themeOverride, terminalTheme, themePreviewId]);
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
}, [accentMode, customAccent, customThemes, followAppTerminalTheme, host.theme, host.themeOverride, terminalTheme, themePreviewId]);
|
||||
|
||||
const resolvedChainHosts =
|
||||
chainHosts;
|
||||
@@ -733,10 +784,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,
|
||||
@@ -793,6 +861,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// Autocomplete integration
|
||||
onAutocompleteKeyEvent: (e: KeyboardEvent) => autocompleteKeyEventRef.current?.(e) ?? true,
|
||||
onAutocompleteInput: (data: string) => autocompleteInputRef.current?.(data),
|
||||
isRestoringSelectionRef,
|
||||
});
|
||||
|
||||
xtermRuntimeRef.current = runtime;
|
||||
@@ -951,8 +1020,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?.();
|
||||
@@ -994,8 +1076,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
|
||||
@@ -1230,7 +1311,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const hasText = !!selection && selection.length > 0;
|
||||
setHasSelection(hasText);
|
||||
|
||||
if (hasText && terminalSettings?.copyOnSelect) {
|
||||
if (hasText && terminalSettings?.copyOnSelect && !isRestoringSelectionRef.current) {
|
||||
navigator.clipboard.writeText(selection).catch((err) => {
|
||||
logger.warn("Copy on select failed:", err);
|
||||
});
|
||||
@@ -1321,6 +1402,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const disableBracketedPasteRef = useRef(terminalSettings?.disableBracketedPaste ?? false);
|
||||
disableBracketedPasteRef.current = terminalSettings?.disableBracketedPaste ?? false;
|
||||
|
||||
// True only while createXTermRuntime is programmatically restoring the
|
||||
// selection right after a keystroke (preserveSelectionOnInput). Lets
|
||||
// copy-on-select skip a redundant clipboard write that would otherwise
|
||||
// clobber whatever the user copied elsewhere in the meantime.
|
||||
const isRestoringSelectionRef = useRef(false);
|
||||
|
||||
const scrollOnPasteRef = useRef(terminalSettings?.scrollOnPaste ?? true);
|
||||
scrollOnPasteRef.current = terminalSettings?.scrollOnPaste ?? true;
|
||||
|
||||
@@ -1373,6 +1460,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const handleSetTerminalEncoding = (encoding: 'utf-8' | 'gb18030') => {
|
||||
setTerminalEncoding(encoding);
|
||||
userPickedEncodingRef.current = true;
|
||||
if (sessionRef.current) {
|
||||
setSessionEncoding(sessionRef.current, encoding);
|
||||
}
|
||||
@@ -1651,8 +1739,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
['--terminal-ui-border' as never]: `var(--terminal-preview-border, color-mix(in srgb, ${effectiveTheme.colors.foreground} 8%, ${effectiveTheme.colors.background} 92%))`,
|
||||
['--terminal-ui-toolbar-btn' as never]: `var(--terminal-preview-toolbar-btn, color-mix(in srgb, ${effectiveTheme.colors.background} 88%, ${effectiveTheme.colors.foreground} 12%))`,
|
||||
['--terminal-ui-toolbar-btn-hover' as never]: `var(--terminal-preview-toolbar-btn-hover, color-mix(in srgb, ${effectiveTheme.colors.background} 78%, ${effectiveTheme.colors.foreground} 22%))`,
|
||||
['--terminal-ui-toolbar-btn-active' as never]: `var(--terminal-preview-toolbar-btn-active, color-mix(in srgb, ${effectiveTheme.colors.background} 68%, ${effectiveTheme.colors.foreground} 32%))`,
|
||||
}), [effectiveTheme.colors.background, effectiveTheme.colors.foreground]);
|
||||
['--terminal-ui-toolbar-btn-active' as never]: `var(--terminal-preview-toolbar-btn-active, color-mix(in srgb, ${effectiveTheme.colors.cursor} 78%, ${effectiveTheme.colors.background} 22%))`,
|
||||
}), [effectiveTheme.colors.background, effectiveTheme.colors.cursor, effectiveTheme.colors.foreground]);
|
||||
|
||||
return (
|
||||
<TerminalContextMenu
|
||||
@@ -1663,6 +1751,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isAlternateScreen={hasMouseTracking}
|
||||
onCopy={terminalContextActions.onCopy}
|
||||
onPaste={terminalContextActions.onPaste}
|
||||
onPasteSelection={terminalContextActions.onPasteSelection}
|
||||
onSelectAll={terminalContextActions.onSelectAll}
|
||||
onClear={terminalContextActions.onClear}
|
||||
onSelectWord={terminalContextActions.onSelectWord}
|
||||
@@ -1705,6 +1794,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
<div className="absolute left-0 right-0 top-0 z-20 pointer-events-none">
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0"
|
||||
onMouseDownCapture={handleTopOverlayMouseDownCapture}
|
||||
style={{
|
||||
backgroundColor: 'var(--terminal-ui-bg)',
|
||||
color: 'var(--terminal-ui-fg)',
|
||||
|
||||
66
components/TerminalLayer.memo.test.tsx
Normal file
66
components/TerminalLayer.memo.test.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { terminalLayerAreEqual } from "./terminalLayerMemo.ts";
|
||||
|
||||
const baseProps = {
|
||||
hosts: [],
|
||||
groupConfigs: [],
|
||||
proxyProfiles: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
snippetPackages: [],
|
||||
sessions: [],
|
||||
workspaces: [],
|
||||
draggingSessionId: null,
|
||||
terminalTheme: {},
|
||||
accentMode: "theme",
|
||||
customAccent: null,
|
||||
terminalSettings: {},
|
||||
fontSize: 14,
|
||||
hotkeyScheme: "default",
|
||||
keyBindings: [],
|
||||
sftpDefaultViewMode: "list",
|
||||
sftpDoubleClickBehavior: "open",
|
||||
sftpAutoSync: false,
|
||||
sftpShowHiddenFiles: false,
|
||||
sftpUseCompressedUpload: false,
|
||||
sftpAutoOpenSidebar: false,
|
||||
editorWordWrap: false,
|
||||
setEditorWordWrap: () => {},
|
||||
onHotkeyAction: () => {},
|
||||
onUpdateHost: () => {},
|
||||
onToggleWorkspaceViewMode: () => {},
|
||||
onSetWorkspaceFocusedSession: () => {},
|
||||
onSplitSession: () => {},
|
||||
toggleScriptsSidePanelRef: { current: null },
|
||||
};
|
||||
|
||||
test("TerminalLayer re-renders when group configs change", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, groupConfigs: [{ path: "prod", proxyProfileId: "proxy-1" }] } as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer re-renders when proxy profiles change", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{
|
||||
...baseProps,
|
||||
proxyProfiles: [{
|
||||
id: "proxy-1",
|
||||
label: "Office Proxy",
|
||||
config: { type: "http", host: "proxy.example.com", port: 3128 },
|
||||
createdAt: 1,
|
||||
}],
|
||||
} as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Circle, FolderTree, LayoutGrid, MessageSquare, PanelLeft, PanelRight, Palette, Server, X, Zap } from 'lucide-react';
|
||||
import { Circle, Columns2, FolderTree, MessageSquare, PanelLeft, PanelRight, Palette, Plus, Search, Server, X, Zap } from 'lucide-react';
|
||||
import React, { createContext, memo, startTransition, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useActiveTabId } from '../application/state/activeTabStore';
|
||||
import {
|
||||
@@ -24,29 +24,40 @@ import {
|
||||
resolveHostTerminalFontSize,
|
||||
resolveHostTerminalFontWeight,
|
||||
resolveHostTerminalThemeId,
|
||||
applyCustomAccentToTerminalTheme,
|
||||
} from '../domain/terminalAppearance';
|
||||
import { cn, normalizeLineEndings } from '../lib/utils';
|
||||
import { detectLocalOs } from '../lib/localShell';
|
||||
import { useStoredString } from '../application/state/useStoredString';
|
||||
import { useStoredNumber } from '../application/state/useStoredNumber';
|
||||
import { STORAGE_KEY_SIDE_PANEL_WIDTH } from '../infrastructure/config/storageKeys';
|
||||
import {
|
||||
STORAGE_KEY_SIDE_PANEL_WIDTH,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_SIDEBAR_WIDTH,
|
||||
} 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 { cleanupOrphanedAISessions, useAIState } from '../application/state/useAIState';
|
||||
import { useAIState } from '../application/state/useAIState';
|
||||
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
|
||||
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from '../application/state/customThemeStore';
|
||||
import { Button } from './ui/button';
|
||||
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';
|
||||
|
||||
@@ -259,6 +270,10 @@ interface AIChatPanelsHostProps {
|
||||
}) => ExecutorContext;
|
||||
}
|
||||
|
||||
interface AIStateMaintenanceHostProps {
|
||||
validAIScopeTargetIds: Set<string>;
|
||||
}
|
||||
|
||||
const AIStateProviderInner: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const aiState = useAIState();
|
||||
return (
|
||||
@@ -271,6 +286,27 @@ const AIStateProviderInner: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
const AIStateProvider = memo(AIStateProviderInner);
|
||||
AIStateProvider.displayName = 'AIStateProvider';
|
||||
|
||||
const AIStateMaintenanceHostInner: React.FC<AIStateMaintenanceHostProps> = ({
|
||||
validAIScopeTargetIds,
|
||||
}) => {
|
||||
const aiState = useContext(AIStateContext);
|
||||
|
||||
if (!aiState) {
|
||||
throw new Error('AIStateMaintenanceHost must be rendered inside AIStateProvider');
|
||||
}
|
||||
|
||||
const { cleanupOrphanedSessions } = aiState;
|
||||
|
||||
useEffect(() => {
|
||||
cleanupOrphanedSessions(validAIScopeTargetIds);
|
||||
}, [cleanupOrphanedSessions, validAIScopeTargetIds]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const AIStateMaintenanceHost = memo(AIStateMaintenanceHostInner);
|
||||
AIStateMaintenanceHost.displayName = 'AIStateMaintenanceHost';
|
||||
|
||||
const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
mountedTabIds,
|
||||
activeTabId,
|
||||
@@ -300,12 +336,20 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
<AIChatSidePanel
|
||||
sessions={aiState.sessions}
|
||||
activeSessionIdMap={aiState.activeSessionIdMap}
|
||||
draftsByScope={aiState.draftsByScope}
|
||||
panelViewByScope={aiState.panelViewByScope}
|
||||
setActiveSessionId={aiState.setActiveSessionId}
|
||||
ensureDraftForScope={aiState.ensureDraftForScope}
|
||||
updateDraft={aiState.updateDraft}
|
||||
showDraftView={aiState.showDraftView}
|
||||
showSessionView={aiState.showSessionView}
|
||||
clearDraftForScope={aiState.clearDraftForScope}
|
||||
addDraftFiles={aiState.addDraftFiles}
|
||||
removeDraftFile={aiState.removeDraftFile}
|
||||
createSession={aiState.createSession}
|
||||
deleteSession={aiState.deleteSession}
|
||||
updateSessionTitle={aiState.updateSessionTitle}
|
||||
updateSessionExternalSessionId={aiState.updateSessionExternalSessionId}
|
||||
retargetSessionScope={aiState.retargetSessionScope}
|
||||
addMessageToSession={aiState.addMessageToSession}
|
||||
updateLastMessage={aiState.updateLastMessage}
|
||||
updateMessageById={aiState.updateMessageById}
|
||||
@@ -344,6 +388,7 @@ AIChatPanelsHost.displayName = 'AIChatPanelsHost';
|
||||
interface TerminalLayerProps {
|
||||
hosts: Host[];
|
||||
groupConfigs: GroupConfig[];
|
||||
proxyProfiles: ProxyProfile[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
snippets: Snippet[];
|
||||
@@ -354,6 +399,8 @@ interface TerminalLayerProps {
|
||||
draggingSessionId: string | null;
|
||||
terminalTheme: TerminalTheme;
|
||||
followAppTerminalTheme?: boolean;
|
||||
accentMode?: 'theme' | 'custom';
|
||||
customAccent?: string;
|
||||
terminalSettings?: TerminalSettings;
|
||||
terminalFontFamilyId: string;
|
||||
fontSize?: number;
|
||||
@@ -373,6 +420,7 @@ interface TerminalLayerProps {
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
||||
onCreateWorkspaceFromSessions: (baseSessionId: string, joiningSessionId: string, hint: Exclude<SplitHint, null>) => void;
|
||||
onAddSessionToWorkspace: (workspaceId: string, sessionId: string, hint: Exclude<SplitHint, null>) => void;
|
||||
onRequestAddToWorkspace?: (workspaceId: string) => void;
|
||||
onUpdateSplitSizes: (workspaceId: string, splitId: string, sizes: number[]) => void;
|
||||
onSetDraggingSessionId: (id: string | null) => void;
|
||||
onToggleWorkspaceViewMode?: (workspaceId: string) => void;
|
||||
@@ -395,11 +443,15 @@ interface TerminalLayerProps {
|
||||
sessionLogsEnabled?: boolean;
|
||||
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,
|
||||
@@ -410,6 +462,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
draggingSessionId,
|
||||
terminalTheme,
|
||||
followAppTerminalTheme = false,
|
||||
accentMode = 'theme',
|
||||
customAccent = '',
|
||||
terminalSettings,
|
||||
terminalFontFamilyId,
|
||||
fontSize = 14,
|
||||
@@ -429,6 +483,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onTerminalDataCapture,
|
||||
onCreateWorkspaceFromSessions,
|
||||
onAddSessionToWorkspace,
|
||||
onRequestAddToWorkspace,
|
||||
onUpdateSplitSizes,
|
||||
onSetDraggingSessionId,
|
||||
onToggleWorkspaceViewMode,
|
||||
@@ -448,6 +503,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
closeSidePanelRef,
|
||||
toggleScriptsSidePanelRef,
|
||||
activeSidePanelTabRef,
|
||||
}) => {
|
||||
// Subscribe to activeTabId from external store
|
||||
const activeTabId = useActiveTabId();
|
||||
@@ -562,6 +620,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const workspaceInnerRef = useRef<HTMLDivElement>(null);
|
||||
const workspaceOverlayRef = useRef<HTMLDivElement>(null);
|
||||
const [dropHint, setDropHint] = useState<SplitHint>(null);
|
||||
// Focus-mode sidebar: client-side filter for the terminal list.
|
||||
const [focusSidebarSearch, setFocusSidebarSearch] = useState('');
|
||||
const [themePreview, setThemePreview] = useState<{ targetSessionId: string | null; themeId: string | null }>({
|
||||
targetSessionId: null,
|
||||
themeId: null,
|
||||
@@ -616,6 +676,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const [sidePanelWidth, setSidePanelWidth, persistSidePanelWidth] = useStoredNumber(
|
||||
STORAGE_KEY_SIDE_PANEL_WIDTH, 420, { min: 280, max: 800 },
|
||||
);
|
||||
const [focusSidebarWidth, setFocusSidebarWidth, persistFocusSidebarWidth] = useStoredNumber(
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_SIDEBAR_WIDTH, 224, { min: 160, max: 480 },
|
||||
);
|
||||
const [sidePanelPosition, setSidePanelPosition] = useStoredString<'left' | 'right'>(
|
||||
'netcatty_side_panel_position',
|
||||
'left',
|
||||
@@ -628,6 +691,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
// Whether side panel is open for the currently active tab and which sub-panel
|
||||
const isSidePanelOpenForCurrentTab = activeTabId ? sidePanelOpenTabs.has(activeTabId) : false;
|
||||
const activeSidePanelTab = activeTabId ? sidePanelOpenTabs.get(activeTabId) ?? null : null;
|
||||
if (activeSidePanelTabRef) {
|
||||
activeSidePanelTabRef.current = activeSidePanelTab;
|
||||
}
|
||||
|
||||
// Legacy compatibility helpers for SFTP-specific logic
|
||||
const isSftpOpenForCurrentTab = activeSidePanelTab === 'sftp';
|
||||
@@ -740,6 +806,47 @@ 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) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startWidth = focusSidebarWidth;
|
||||
|
||||
let lastWidth = startWidth;
|
||||
let rafId: number | null = null;
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
const delta = ev.clientX - startX;
|
||||
lastWidth = Math.max(160, Math.min(480, startWidth + delta));
|
||||
if (rafId !== null) return;
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null;
|
||||
setFocusSidebarWidth(lastWidth);
|
||||
});
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
setFocusSidebarWidth(lastWidth);
|
||||
persistFocusSidebarWidth(lastWidth);
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
}, [focusSidebarWidth, setFocusSidebarWidth, persistFocusSidebarWidth]);
|
||||
|
||||
// Side panel resize handler
|
||||
const handleSidePanelResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -776,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(() => {
|
||||
@@ -785,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;
|
||||
@@ -829,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) {
|
||||
@@ -842,17 +968,20 @@ 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 validTerminalTabIds = useMemo(() => {
|
||||
const validAIScopeTargetIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const session of sessions) ids.add(session.id);
|
||||
for (const workspace of workspaces) ids.add(workspace.id);
|
||||
@@ -940,16 +1069,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}, [workspaces]);
|
||||
|
||||
useEffect(() => {
|
||||
setSidePanelOpenTabs(prev => filterTabsMap(prev, validTerminalTabIds));
|
||||
setSftpHostForTab(prev => filterTabsMap(prev, validTerminalTabIds));
|
||||
setSftpInitialLocationForTab(prev => filterTabsMap(prev, validTerminalTabIds));
|
||||
setSftpPendingUploadsForTab(prev => filterTabsMap(prev, validTerminalTabIds));
|
||||
setSidePanelOpenTabs(prev => filterTabsMap(prev, validAIScopeTargetIds));
|
||||
setSftpHostForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
|
||||
setSftpInitialLocationForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
|
||||
setSftpPendingUploadsForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
|
||||
sessionActivityStore.prune(validSessionActivityIds);
|
||||
}, [validSessionActivityIds, validTerminalTabIds]);
|
||||
|
||||
useEffect(() => {
|
||||
cleanupOrphanedAISessions(validTerminalTabIds);
|
||||
}, [validTerminalTabIds]);
|
||||
}, [validSessionActivityIds, validAIScopeTargetIds]);
|
||||
|
||||
const computeWorkspaceRects = useCallback((workspace?: Workspace, size?: { width: number; height: number }): Record<string, WorkspaceRect> => {
|
||||
if (!workspace) return {} as Record<string, WorkspaceRect>;
|
||||
@@ -1183,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.
|
||||
@@ -1216,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);
|
||||
@@ -1226,11 +1370,23 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [activeWorkspace?.focusedSessionId, activeSession?.id, terminalBackend]);
|
||||
}, [getActiveTerminalSessionId, terminalBackend]);
|
||||
|
||||
const refocusTerminalSession = useCallback((sessionId?: string | null) => {
|
||||
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 = getActiveTerminalSessionId();
|
||||
syncWorkspaceFocusIfNeeded(sessionIdToRefocus);
|
||||
setSidePanelOpenTabs(prev => {
|
||||
const next = new Map(prev);
|
||||
next.delete(activeTabId);
|
||||
@@ -1253,7 +1409,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
next.delete(activeTabId);
|
||||
return next;
|
||||
});
|
||||
}, [activeTabId]);
|
||||
refocusTerminalSession(sessionIdToRefocus);
|
||||
}, [activeTabId, getActiveTerminalSessionId, refocusTerminalSession, syncWorkspaceFocusIfNeeded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!closeSidePanelRef) return;
|
||||
closeSidePanelRef.current = handleCloseSidePanel;
|
||||
return () => {
|
||||
closeSidePanelRef.current = null;
|
||||
};
|
||||
}, [closeSidePanelRef, handleCloseSidePanel]);
|
||||
|
||||
// Switch side panel to a specific tab (or toggle if already on that tab)
|
||||
const handleSwitchSidePanelTab = useCallback((tab: SidePanelTab) => {
|
||||
@@ -1300,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');
|
||||
@@ -1420,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);
|
||||
@@ -1465,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 () => {
|
||||
@@ -1646,8 +1841,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
// recomputing scope resolution from scratch on every tab switch.
|
||||
const aiContextsByTabId = useMemo(() => {
|
||||
const localOs = detectLocalOs(navigator.userAgent || navigator.platform);
|
||||
const sessionById = new Map(sessions.map((session) => [session.id, session]));
|
||||
const workspaceById = new Map(workspaces.map((workspace) => [workspace.id, workspace]));
|
||||
const sessionById = new Map<string, TerminalSession>(sessions.map((session) => [session.id, session]));
|
||||
const workspaceById = new Map<string, Workspace>(workspaces.map((workspace) => [workspace.id, workspace]));
|
||||
const tabIds = new Set<string>(mountedAiTabIds);
|
||||
if (activeTabId) tabIds.add(activeTabId);
|
||||
|
||||
@@ -1729,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
|
||||
@@ -1847,31 +2043,97 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const renderFocusModeSidebar = () => {
|
||||
if (!activeWorkspace || !isFocusMode) return null;
|
||||
|
||||
// Use terminal-theme colors for every surface in here so the sidebar
|
||||
// stays readable when the app theme and terminal theme diverge
|
||||
// (e.g. followAppTerminalTheme=off, light app + dark terminal).
|
||||
// Tailwind's bg-foreground/* / text-foreground classes bind to app
|
||||
// theme vars, so we derive row colors from the terminal theme
|
||||
// directly with color-mix.
|
||||
const termBg = resolvedPreviewTheme.colors.background;
|
||||
const termFg = resolvedPreviewTheme.colors.foreground;
|
||||
const selectedBg = `color-mix(in srgb, ${termFg} 10%, transparent)`;
|
||||
const selectedHoverBg = `color-mix(in srgb, ${termFg} 15%, transparent)`;
|
||||
const unselectedHoverBg = `color-mix(in srgb, ${termFg} 10%, transparent)`;
|
||||
const unselectedFg = `color-mix(in srgb, ${termFg} 75%, ${termBg} 25%)`;
|
||||
const mutedFg = `color-mix(in srgb, ${termFg} 55%, ${termBg} 45%)`;
|
||||
const separator = `color-mix(in srgb, ${termFg} 10%, ${termBg} 90%)`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-56 flex-shrink-0 bg-secondary/50 border-r border-border/50 flex flex-col"
|
||||
className="flex-shrink-0 flex flex-col relative"
|
||||
style={{
|
||||
width: focusSidebarWidth,
|
||||
// Paint the sidebar with the terminal's theme background so it
|
||||
// reads as one continuous surface with the focused terminal
|
||||
// (instead of a distinct tinted panel sitting next to it).
|
||||
backgroundColor: termBg,
|
||||
color: termFg,
|
||||
borderRight: `1px solid ${separator}`,
|
||||
}}
|
||||
data-section="terminal-workspace-sidebar"
|
||||
>
|
||||
{/* Header with view toggle */}
|
||||
<div className="h-10 flex items-center justify-between px-3 border-b border-border/50">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Terminals · {workspaceSessions.length}
|
||||
</span>
|
||||
{/* Resize handle sitting on the right edge of the sidebar. */}
|
||||
<div
|
||||
className="absolute top-0 right-[-3px] h-full w-2 cursor-ew-resize z-30"
|
||||
onMouseDown={handleFocusSidebarResizeStart}
|
||||
/>
|
||||
{/* Header — search box + actions (matches Vault-sidebar search
|
||||
style but skinned to the terminal theme so it blends with the
|
||||
sidebar's bg). */}
|
||||
<div
|
||||
className="h-11 flex items-center gap-1.5 px-2"
|
||||
style={{ borderBottom: `1px solid ${separator}` }}
|
||||
>
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-1 top-1/2 -translate-y-1/2 pointer-events-none"
|
||||
style={{ color: mutedFg }}
|
||||
/>
|
||||
<Input
|
||||
value={focusSidebarSearch}
|
||||
onChange={(e) => setFocusSidebarSearch(e.target.value)}
|
||||
placeholder="Search terminals..."
|
||||
className="h-7 pl-6 pr-0 text-xs bg-transparent border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
style={{ color: termFg }}
|
||||
/>
|
||||
</div>
|
||||
{onRequestAddToWorkspace && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 flex-shrink-0 hover:text-inherit"
|
||||
style={{ color: mutedFg }}
|
||||
onClick={() => onRequestAddToWorkspace(activeWorkspace.id)}
|
||||
title="Add Terminal"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
className="h-7 w-7 p-0 flex-shrink-0 hover:text-inherit"
|
||||
style={{ color: mutedFg }}
|
||||
onClick={() => onToggleWorkspaceViewMode?.(activeWorkspace.id)}
|
||||
title="Switch to Split View"
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
<Columns2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Session list */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 space-y-1">
|
||||
{workspaceSessions.map(session => {
|
||||
{workspaceSessions.filter((session) => {
|
||||
const term = focusSidebarSearch.trim().toLowerCase();
|
||||
if (!term) return true;
|
||||
return (
|
||||
session.hostLabel?.toLowerCase().includes(term)
|
||||
|| session.hostname?.toLowerCase().includes(term)
|
||||
|| session.username?.toLowerCase().includes(term)
|
||||
);
|
||||
}).map(session => {
|
||||
const host = sessionHostsMap.get(session.id);
|
||||
const isSelected = session.id === focusedSessionId;
|
||||
const statusColor = session.status === 'connected'
|
||||
@@ -1880,35 +2142,49 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
? 'text-amber-500'
|
||||
: 'text-red-500';
|
||||
|
||||
const restBg = isSelected ? selectedBg : 'transparent';
|
||||
const hoverBg = isSelected ? selectedHoverBg : unselectedHoverBg;
|
||||
const rowFg = isSelected ? termFg : unselectedFg;
|
||||
|
||||
return (
|
||||
<div
|
||||
<RippleButton
|
||||
key={session.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer transition-colors",
|
||||
isSelected
|
||||
? "bg-primary/15 border border-primary/30"
|
||||
: "hover:bg-secondary/80 border border-transparent"
|
||||
)}
|
||||
variant="ghost"
|
||||
// Row colors are terminal-theme derived (see renderFocusModeSidebar
|
||||
// top). `hover:text-inherit` pins text against ghost variant's
|
||||
// hover:text-accent-foreground default; hover bg is swapped
|
||||
// via inline style so we stay on terminal-theme alpha rather
|
||||
// than Tailwind's app-theme foreground color.
|
||||
className="w-full h-auto justify-start gap-2 px-2 py-1.5 font-normal hover:text-inherit"
|
||||
style={{ backgroundColor: restBg, color: rowFg }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = hoverBg;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = restBg;
|
||||
}}
|
||||
onClick={() => onSetWorkspaceFocusedSession?.(activeWorkspace.id, session.id)}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="relative flex-shrink-0">
|
||||
{host ? (
|
||||
<DistroAvatar host={host} fallback={session.hostLabel} size="sm" />
|
||||
) : (
|
||||
<Server size={16} className="text-muted-foreground" />
|
||||
<Server size={16} style={{ color: mutedFg }} />
|
||||
)}
|
||||
<Circle
|
||||
size={6}
|
||||
className={cn("absolute -bottom-0.5 -right-0.5 fill-current", statusColor)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium truncate">{session.hostLabel}</div>
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<div className={cn("text-xs truncate", isSelected ? "font-semibold" : "font-medium")}>
|
||||
{session.hostLabel}
|
||||
</div>
|
||||
<div className="text-[10px] truncate" style={{ color: mutedFg }}>
|
||||
{session.username}@{session.hostname}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RippleButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -1919,6 +2195,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
return (
|
||||
<AIStateProvider>
|
||||
<AIStateMaintenanceHost validAIScopeTargetIds={validAIScopeTargetIds} />
|
||||
<div
|
||||
ref={workspaceOuterRef}
|
||||
className="absolute inset-0 bg-background flex flex-col"
|
||||
@@ -1929,14 +2206,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
zIndex: isTerminalLayerVisible ? 10 : 0,
|
||||
}}
|
||||
>
|
||||
<div className={cn("flex-1 flex min-h-0 relative", sidePanelPosition === 'right' && "flex-row-reverse")}>
|
||||
{/* Side panel with tab header + content (SFTP / Scripts / Theme) */}
|
||||
<div className="flex-1 flex min-h-0 relative">
|
||||
{/* Side panel with tab header + content (SFTP / Scripts / Theme).
|
||||
Uses `order-last` instead of flex-row-reverse on the parent so the
|
||||
workspace focus-mode sidebar and terminal area below stay in source
|
||||
order (sidebar on the left) regardless of the side panel's side. */}
|
||||
{(isSidePanelOpenForCurrentTab || mountedSftpTabIds.length > 0 || mountedAiTabIds.length > 0) && (
|
||||
<>
|
||||
<div
|
||||
style={{ width: isSidePanelOpenForCurrentTab ? sidePanelWidth : 0 }}
|
||||
className={cn(
|
||||
"flex-shrink-0 h-full relative z-20",
|
||||
sidePanelPosition === 'right' && "order-last",
|
||||
)}
|
||||
>
|
||||
{isSidePanelOpenForCurrentTab && (
|
||||
@@ -1956,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)',
|
||||
@@ -1973,8 +2255,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
data-tab-id="sftp"
|
||||
data-tab-type="sidepanel"
|
||||
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)',
|
||||
@@ -1987,8 +2275,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
data-tab-id="scripts"
|
||||
data-tab-type="sidepanel"
|
||||
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)',
|
||||
@@ -2001,8 +2295,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
data-tab-id="theme"
|
||||
data-tab-type="sidepanel"
|
||||
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)',
|
||||
@@ -2015,8 +2315,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
data-tab-id="ai"
|
||||
data-tab-type="sidepanel"
|
||||
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)',
|
||||
@@ -2060,7 +2366,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
return (
|
||||
<SftpSidePanel
|
||||
key={tabId}
|
||||
hosts={hosts}
|
||||
hosts={effectiveHosts}
|
||||
writableHosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
updateHosts={updateHosts}
|
||||
@@ -2071,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}
|
||||
@@ -2085,6 +2393,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
onGetTerminalCwd={getTerminalCwd}
|
||||
onRequestTerminalFocus={refocusActiveTerminalSession}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -2145,6 +2454,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
{/* Focus mode sidebar */}
|
||||
{isFocusMode && renderFocusModeSidebar()}
|
||||
|
||||
|
||||
<div ref={workspaceInnerRef} className="overflow-hidden relative flex-1">
|
||||
{draggingSessionId && !isFocusMode && (
|
||||
<div
|
||||
@@ -2265,6 +2575,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
fontSize={fontSize}
|
||||
terminalTheme={terminalTheme}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
accentMode={accentMode}
|
||||
customAccent={customAccent}
|
||||
terminalSettings={terminalSettings}
|
||||
sessionId={session.id}
|
||||
startupCommand={session.startupCommand}
|
||||
@@ -2359,14 +2671,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onSend={handleComposeSend}
|
||||
onClose={() => {
|
||||
setIsComposeBarOpen(false);
|
||||
// Refocus the terminal pane (matching solo-session behavior)
|
||||
if (focusedSessionId) {
|
||||
requestAnimationFrame(() => {
|
||||
const pane = document.querySelector(`[data-session-id="${focusedSessionId}"]`);
|
||||
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
|
||||
textarea?.focus();
|
||||
});
|
||||
}
|
||||
refocusTerminalSession(focusedSessionId);
|
||||
}}
|
||||
isBroadcastEnabled={isBroadcastEnabled?.(activeWorkspace.id)}
|
||||
themeColors={composeBarThemeColors}
|
||||
@@ -2377,37 +2682,5 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Only re-render when data props change - activeTabId/isVisible are now managed internally via store subscription
|
||||
const terminalLayerAreEqual = (prev: TerminalLayerProps, next: TerminalLayerProps): boolean => {
|
||||
return (
|
||||
prev.hosts === next.hosts &&
|
||||
prev.keys === next.keys &&
|
||||
prev.snippets === next.snippets &&
|
||||
prev.snippetPackages === next.snippetPackages &&
|
||||
prev.sessions === next.sessions &&
|
||||
prev.workspaces === next.workspaces &&
|
||||
prev.draggingSessionId === next.draggingSessionId &&
|
||||
prev.terminalTheme === next.terminalTheme &&
|
||||
prev.terminalSettings === next.terminalSettings &&
|
||||
prev.fontSize === next.fontSize &&
|
||||
prev.hotkeyScheme === next.hotkeyScheme &&
|
||||
prev.keyBindings === next.keyBindings &&
|
||||
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
|
||||
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
|
||||
prev.sftpAutoSync === next.sftpAutoSync &&
|
||||
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
|
||||
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
|
||||
prev.sftpAutoOpenSidebar === next.sftpAutoOpenSidebar &&
|
||||
prev.editorWordWrap === next.editorWordWrap &&
|
||||
prev.setEditorWordWrap === next.setEditorWordWrap &&
|
||||
prev.onHotkeyAction === next.onHotkeyAction &&
|
||||
prev.onUpdateHost === next.onUpdateHost &&
|
||||
prev.onToggleWorkspaceViewMode === next.onToggleWorkspaceViewMode &&
|
||||
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
|
||||
prev.onSplitSession === next.onSplitSession &&
|
||||
prev.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, 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';
|
||||
@@ -12,13 +13,16 @@ import { Host, TerminalSession, Workspace } from '../types';
|
||||
import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
|
||||
import { getShellIconPath, isMonochromeShellIcon } from '../lib/useDiscoveredShells';
|
||||
import { Button } from './ui/button';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { SyncStatusButton } from './SyncStatusButton';
|
||||
|
||||
// Helper styles for Electron drag regions (use type assertion to include non-standard WebkitAppRegion)
|
||||
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;
|
||||
@@ -36,6 +40,7 @@ interface TopTabsProps {
|
||||
onRenameWorkspace: (workspaceId: string) => void;
|
||||
onCloseWorkspace: (workspaceId: string) => void;
|
||||
onCloseLogView: (logViewId: string) => void;
|
||||
onCloseTabsBatch: (targetIds: string[]) => void;
|
||||
onOpenQuickSwitcher: () => void;
|
||||
onToggleTheme: () => void;
|
||||
onOpenSettings: () => void;
|
||||
@@ -45,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
|
||||
@@ -244,6 +252,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onRenameWorkspace,
|
||||
onCloseWorkspace,
|
||||
onCloseLogView,
|
||||
onCloseTabsBatch,
|
||||
onOpenQuickSwitcher,
|
||||
onToggleTheme,
|
||||
onOpenSettings,
|
||||
@@ -253,6 +262,9 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onEndSessionDrag,
|
||||
onReorderTabs,
|
||||
showSftpTab,
|
||||
editorTabs,
|
||||
onRequestCloseEditorTab,
|
||||
hostById,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
// Subscribe to activeTabId from external store
|
||||
@@ -304,11 +316,23 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
updateScrollState();
|
||||
const container = tabsContainerRef.current;
|
||||
if (container) {
|
||||
// Translate vertical wheel to horizontal scroll so users can reach
|
||||
// off-screen tabs with a standard mouse wheel. Trackpad gestures that
|
||||
// already carry horizontal delta are left alone so native two-finger
|
||||
// swiping still works.
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (e.deltaY !== 0 && e.deltaX === 0) {
|
||||
e.preventDefault();
|
||||
container.scrollLeft += e.deltaY;
|
||||
}
|
||||
};
|
||||
container.addEventListener('scroll', updateScrollState);
|
||||
container.addEventListener('wheel', handleWheel, { passive: false });
|
||||
const resizeObserver = new ResizeObserver(updateScrollState);
|
||||
resizeObserver.observe(container);
|
||||
return () => {
|
||||
container.removeEventListener('scroll', updateScrollState);
|
||||
container.removeEventListener('wheel', handleWheel);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}
|
||||
@@ -463,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);
|
||||
@@ -480,13 +525,115 @@ 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).
|
||||
const renderBulkCloseItems = (anchorId: string) => {
|
||||
const anchorIdx = orderedTabs.indexOf(anchorId);
|
||||
const othersIds = orderedTabs.filter((id) => id !== anchorId);
|
||||
const rightIds = anchorIdx >= 0 ? orderedTabs.slice(anchorIdx + 1) : [];
|
||||
return (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
disabled={othersIds.length === 0}
|
||||
onClick={() => onCloseTabsBatch(othersIds)}
|
||||
>
|
||||
{t('tabs.closeOthers')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
disabled={rightIds.length === 0}
|
||||
onClick={() => onCloseTabsBatch(rightIds)}
|
||||
>
|
||||
{t('tabs.closeToRight')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onCloseTabsBatch(orderedTabs)}
|
||||
>
|
||||
{t('tabs.closeAll')}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Render the tabs
|
||||
const renderOrderedTabs = () => {
|
||||
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];
|
||||
@@ -500,6 +647,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
data-tab-id={session.id}
|
||||
data-tab-type="session"
|
||||
data-state={activeTabId === session.id ? 'active' : 'inactive'}
|
||||
onClick={() => onSelectTab(session.id)}
|
||||
draggable
|
||||
onDragStart={(e) => handleTabDragStart(e, session.id)}
|
||||
@@ -508,7 +657,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onDragLeave={handleTabDragLeave}
|
||||
onDrop={(e) => handleTabDrop(e, session.id)}
|
||||
className={cn(
|
||||
"relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"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",
|
||||
"transition-transform duration-150",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
|
||||
)}
|
||||
@@ -534,13 +683,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Active tab top accent line */}
|
||||
{activeTabId === session.id && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
|
||||
/>
|
||||
)}
|
||||
{/* Drop indicator line - before */}
|
||||
{showDropIndicatorBefore && isDraggingForReorder && (
|
||||
<div
|
||||
@@ -579,6 +721,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<ContextMenuItem className="text-destructive" onClick={() => onCloseSession(session.id)}>
|
||||
{t('common.close')}
|
||||
</ContextMenuItem>
|
||||
{renderBulkCloseItems(session.id)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
@@ -599,6 +742,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
data-tab-id={workspace.id}
|
||||
data-tab-type="workspace"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={() => onSelectTab(workspace.id)}
|
||||
draggable
|
||||
onDragStart={(e) => handleTabDragStart(e, workspace.id)}
|
||||
@@ -607,7 +752,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onDragLeave={handleTabDragLeave}
|
||||
onDrop={(e) => handleTabDrop(e, workspace.id)}
|
||||
className={cn(
|
||||
"relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-transform duration-150",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
|
||||
)}
|
||||
@@ -633,13 +778,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Active tab top accent line */}
|
||||
{isActive && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
|
||||
/>
|
||||
)}
|
||||
{/* Drop indicator line - before */}
|
||||
{showDropIndicatorBefore && isDraggingForReorder && (
|
||||
<div
|
||||
@@ -683,6 +821,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<ContextMenuItem className="text-destructive" onClick={() => onCloseWorkspace(workspace.id)}>
|
||||
{t('common.close')}
|
||||
</ContextMenuItem>
|
||||
{renderBulkCloseItems(workspace.id)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
@@ -697,9 +836,11 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<div
|
||||
key={logView.id}
|
||||
data-tab-id={logView.id}
|
||||
data-tab-type="logView"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={() => onSelectTab(logView.id)}
|
||||
className={cn(
|
||||
"relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"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
|
||||
@@ -722,13 +863,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Active tab top accent line */}
|
||||
{isActive && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<FileText
|
||||
size={14}
|
||||
@@ -787,9 +921,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
{/* Fixed left tabs: Vaults and SFTP */}
|
||||
<div className="flex items-end gap-0 flex-shrink-0 app-drag">
|
||||
<div
|
||||
data-tab-id="vault"
|
||||
data-tab-type="root"
|
||||
data-state={isVaultActive ? 'active' : 'inactive'}
|
||||
onClick={() => onSelectTab('vault')}
|
||||
className={cn(
|
||||
"relative h-7 px-3 rounded text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
"netcatty-tab relative h-7 px-3 rounded text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isVaultActive
|
||||
@@ -816,9 +953,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
</div>
|
||||
{showSftpTab && (
|
||||
<div
|
||||
data-tab-id="sftp"
|
||||
data-tab-type="root"
|
||||
data-state={isSftpActive ? 'active' : 'inactive'}
|
||||
onClick={() => onSelectTab('sftp')}
|
||||
className={cn(
|
||||
"relative h-7 px-3 rounded-none text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
"netcatty-tab relative h-7 px-3 rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isSftpActive
|
||||
@@ -841,12 +981,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isSftpActive && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
|
||||
/>
|
||||
)}
|
||||
<Folder size={14} /> SFTP
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,8 @@ import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { useTrayPanelBackend } from "../application/state/useTrayPanelBackend";
|
||||
import { useActiveTabId } from "../application/state/activeTabStore";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
|
||||
import type { Host } from "../domain/models";
|
||||
import { X, Maximize2, ChevronRight, ChevronDown, Power } from "lucide-react";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
|
||||
@@ -117,10 +119,14 @@ const TrayPanelContent: React.FC = () => {
|
||||
onTrayPanelMenuData,
|
||||
} = useTrayPanelBackend();
|
||||
|
||||
const { hosts, keys, identities, groupConfigs } = useVaultState();
|
||||
const { hosts, keys, identities, proxyProfiles, groupConfigs } = useVaultState();
|
||||
useSessionState();
|
||||
const { rules: portForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
|
||||
const activeTabId = useActiveTabId();
|
||||
const proxyProfileIdSet = useMemo(
|
||||
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
[proxyProfiles],
|
||||
);
|
||||
|
||||
const [traySessions, setTraySessions] = useState<TraySession[]>([]);
|
||||
|
||||
@@ -202,7 +208,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,10 +341,14 @@ const TrayPanelContent: React.FC = () => {
|
||||
if (isActive) {
|
||||
void stopTunnel(rule.id);
|
||||
} else {
|
||||
const host = rawHost.group
|
||||
? applyGroupDefaults(rawHost, resolveGroupDefaults(rawHost.group, groupConfigs))
|
||||
: rawHost;
|
||||
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
|
||||
const resolveEffectiveHost = (host: Host) => {
|
||||
const withGroupDefaults = host.group
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
};
|
||||
const host = resolveEffectiveHost(rawHost);
|
||||
void startTunnel(rule, host, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -36,7 +37,7 @@ import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
||||
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { getEffectiveHostDistro, sanitizeHost } from "../domain/host";
|
||||
import { getEffectiveHostDistro, sanitizeHost, upsertHostById } from "../domain/host";
|
||||
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
|
||||
import type { VaultImportFormat } from "../domain/vaultImport";
|
||||
import {
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
Identity,
|
||||
KnownHost,
|
||||
ManagedSource,
|
||||
ProxyProfile,
|
||||
SerialConfig,
|
||||
SSHKey,
|
||||
ShellHistoryEntry,
|
||||
@@ -69,6 +71,7 @@ import { HostTreeView } from "./HostTreeView";
|
||||
import KeychainManager from "./KeychainManager";
|
||||
import KnownHostsManager from "./KnownHostsManager";
|
||||
import PortForwarding from "./PortForwardingNew";
|
||||
import ProxyProfilesManager from "./ProxyProfilesManager";
|
||||
import QuickConnectWizard from "./QuickConnectWizard";
|
||||
import { isQuickConnectInput, parseQuickConnectInputWithWarnings } from "../domain/quickConnect";
|
||||
import SerialConnectModal from "./SerialConnectModal";
|
||||
@@ -76,6 +79,7 @@ import SerialHostDetailsPanel from "./SerialHostDetailsPanel";
|
||||
import SnippetsManager from "./SnippetsManager";
|
||||
import { ImportVaultDialog, ImportOptions } from "./vault/ImportVaultDialog";
|
||||
import { Button } from "./ui/button";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -103,7 +107,7 @@ import { HotkeyScheme, KeyBinding } from "../domain/models";
|
||||
const LazyProtocolSelectDialog = lazy(() => import("./ProtocolSelectDialog"));
|
||||
const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager"));
|
||||
|
||||
export type VaultSection = "hosts" | "keys" | "snippets" | "port" | "knownhosts" | "logs";
|
||||
export type VaultSection = "hosts" | "keys" | "proxies" | "snippets" | "port" | "knownhosts" | "logs";
|
||||
|
||||
type DropTarget =
|
||||
| { kind: "root" }
|
||||
@@ -114,6 +118,7 @@ interface VaultViewProps {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
proxyProfiles: ProxyProfile[];
|
||||
snippets: Snippet[];
|
||||
snippetPackages: string[];
|
||||
customGroups: string[];
|
||||
@@ -135,6 +140,7 @@ interface VaultViewProps {
|
||||
onUpdateHosts: (hosts: Host[]) => void;
|
||||
onUpdateKeys: (keys: SSHKey[]) => void;
|
||||
onUpdateIdentities: (identities: Identity[]) => void;
|
||||
onUpdateProxyProfiles: (profiles: ProxyProfile[]) => void;
|
||||
onUpdateSnippets: (snippets: Snippet[]) => void;
|
||||
onUpdateSnippetPackages: (pkgs: string[]) => void;
|
||||
onUpdateCustomGroups: (groups: string[]) => void;
|
||||
@@ -162,6 +168,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
snippets,
|
||||
snippetPackages,
|
||||
customGroups,
|
||||
@@ -183,6 +190,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
onUpdateHosts,
|
||||
onUpdateKeys,
|
||||
onUpdateIdentities,
|
||||
onUpdateProxyProfiles,
|
||||
onUpdateSnippets,
|
||||
onUpdateSnippetPackages,
|
||||
onUpdateCustomGroups,
|
||||
@@ -295,6 +303,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
if (!group) return undefined;
|
||||
return resolveGroupDefaults(group, groupConfigs);
|
||||
}, [editingHost, newHostGroupPath, selectedGroupPath, groupConfigs]);
|
||||
const proxyProfileIdSet = useMemo(
|
||||
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
[proxyProfiles],
|
||||
);
|
||||
// Quick connect state
|
||||
const [quickConnectTarget, setQuickConnectTarget] = useState<{
|
||||
hostname: string;
|
||||
@@ -342,8 +354,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
// Check if host has multiple protocols enabled (using effective/resolved host)
|
||||
const hasMultipleProtocols = useCallback((host: Host) => {
|
||||
const effective = host.group
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs))
|
||||
: host;
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
let count = 0;
|
||||
// SSH is always available as base protocol (unless explicitly set to something else)
|
||||
if (effective.protocol === "ssh" || !effective.protocol) count++;
|
||||
@@ -354,7 +366,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
// If protocol is explicitly telnet (not ssh), count it
|
||||
if (effective.protocol === "telnet" && !effective.telnetEnabled) count++;
|
||||
return count > 1;
|
||||
}, [groupConfigs]);
|
||||
}, [groupConfigs, proxyProfileIdSet]);
|
||||
|
||||
// Handle host connect with protocol selection
|
||||
const handleHostConnect = useCallback(
|
||||
@@ -362,14 +374,14 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
if (hasMultipleProtocols(host)) {
|
||||
// Pass effective host to protocol dialog so it shows correct ports/protocols
|
||||
const effective = host.group
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs))
|
||||
: host;
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
setProtocolSelectHost(effective);
|
||||
} else {
|
||||
onConnect(host);
|
||||
}
|
||||
},
|
||||
[hasMultipleProtocols, onConnect, groupConfigs],
|
||||
[hasMultipleProtocols, onConnect, groupConfigs, proxyProfileIdSet],
|
||||
);
|
||||
|
||||
// Handle protocol selection
|
||||
@@ -474,8 +486,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const handleCopyCredentials = useCallback((host: Host) => {
|
||||
// Apply group defaults so inherited credentials are included
|
||||
const effective = host.group
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs))
|
||||
: host;
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
// Only use telnet-specific port and credentials when protocol is explicitly telnet
|
||||
// Don't treat telnetEnabled as primary - that's just an optional protocol
|
||||
const isTelnet = effective.protocol === "telnet";
|
||||
@@ -518,7 +530,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
toast.success(t('vault.hosts.copyCredentials.toast.success'));
|
||||
});
|
||||
}, [identities, groupConfigs, t]);
|
||||
}, [identities, groupConfigs, proxyProfileIdSet, t]);
|
||||
|
||||
const [lastPinnedId, setLastPinnedId] = useState<string | null>(null);
|
||||
const toggleHostPinned = useCallback((hostId: string) => {
|
||||
@@ -867,23 +879,30 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
|
||||
const displayedHosts = useMemo(() => {
|
||||
let filtered = hosts;
|
||||
if (selectedGroupPath) {
|
||||
// Match hosts whose group equals the selected path
|
||||
// For "General" group, also match hosts with empty/undefined group
|
||||
filtered = filtered.filter((h) => {
|
||||
const hostGroup = h.group || "";
|
||||
if (selectedGroupPath === "General") {
|
||||
return hostGroup === "" || hostGroup === "General";
|
||||
}
|
||||
return hostGroup === selectedGroupPath;
|
||||
});
|
||||
} else if (showOnlyUngroupedHostsInRoot) {
|
||||
filtered = filtered.filter((h) => {
|
||||
const hostGroup = (h.group || "").trim();
|
||||
return hostGroup === "";
|
||||
});
|
||||
// Search spans all groups (#777): when the user types in the search box
|
||||
// we skip group/ungrouped-root scoping, so a matching host in another
|
||||
// group is still reachable without having to navigate into it first.
|
||||
// The tree view already uses this shape — see `treeViewHosts` below.
|
||||
const hasSearch = search.trim().length > 0;
|
||||
if (!hasSearch) {
|
||||
if (selectedGroupPath) {
|
||||
// Match hosts whose group equals the selected path
|
||||
// For "General" group, also match hosts with empty/undefined group
|
||||
filtered = filtered.filter((h) => {
|
||||
const hostGroup = h.group || "";
|
||||
if (selectedGroupPath === "General") {
|
||||
return hostGroup === "" || hostGroup === "General";
|
||||
}
|
||||
return hostGroup === selectedGroupPath;
|
||||
});
|
||||
} else if (showOnlyUngroupedHostsInRoot) {
|
||||
filtered = filtered.filter((h) => {
|
||||
const hostGroup = (h.group || "").trim();
|
||||
return hostGroup === "";
|
||||
});
|
||||
}
|
||||
}
|
||||
if (search.trim()) {
|
||||
if (hasSearch) {
|
||||
const s = search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(h) =>
|
||||
@@ -1590,24 +1609,26 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-secondary/80 border-r border-border/60 flex flex-col transition-all duration-200",
|
||||
"bg-secondary border-r border-border/60 flex flex-col transition-all duration-200",
|
||||
sidebarCollapsed ? "w-14" : "w-52"
|
||||
)}
|
||||
data-section="vault-sidebar"
|
||||
>
|
||||
<div className={cn(
|
||||
"py-4 flex items-center",
|
||||
"pt-5 pb-6 flex items-center",
|
||||
sidebarCollapsed ? "px-2 justify-center" : "px-4"
|
||||
)}>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity"
|
||||
className="flex items-center gap-2.5 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<AppLogo className="h-10 w-10 rounded-xl flex-shrink-0" />
|
||||
<AppLogo className="h-8 w-8 flex-shrink-0" />
|
||||
{!sidebarCollapsed && (
|
||||
<p className="text-sm font-bold text-foreground">Netcatty</p>
|
||||
<p className="text-xl font-black italic tracking-tight text-foreground leading-none">
|
||||
Netcatty
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@@ -1620,7 +1641,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<div className={cn("space-y-1", sidebarCollapsed ? "px-1.5" : "px-3")}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
<RippleButton
|
||||
variant={currentSection === "hosts" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
@@ -1635,13 +1656,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
<LayoutGrid size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.hosts")}
|
||||
</Button>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.hosts")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
<RippleButton
|
||||
variant={currentSection === "keys" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
@@ -1655,13 +1676,33 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
<Key size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.keychain")}
|
||||
</Button>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.keychain")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
<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
|
||||
variant={currentSection === "port" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
@@ -1673,13 +1714,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
<Plug size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.portForwarding")}
|
||||
</Button>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.portForwarding")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
<RippleButton
|
||||
variant={currentSection === "snippets" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
@@ -1693,13 +1734,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
<FileCode size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.snippets")}
|
||||
</Button>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.snippets")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
<RippleButton
|
||||
variant={currentSection === "knownhosts" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
@@ -1711,13 +1752,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
<BookMarked size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.knownHosts")}
|
||||
</Button>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.knownHosts")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
<RippleButton
|
||||
variant={currentSection === "logs" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
@@ -1729,7 +1770,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
<Activity size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.logs")}
|
||||
</Button>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.logs")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
@@ -1967,6 +2008,52 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{isMultiSelectMode && isHostsSectionActive && (
|
||||
<div className="px-4 py-1.5 bg-background border-b border-border/40 flex items-center gap-2">
|
||||
<span className="flex items-center h-7 text-xs text-muted-foreground leading-none">
|
||||
{t("vault.hosts.selected", { count: selectedHostIds.size })}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => {
|
||||
const allIds = new Set(displayedHosts.map(h => h.id));
|
||||
setSelectedHostIds(allIds);
|
||||
}}
|
||||
>
|
||||
{t("vault.hosts.selectAll")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={clearHostSelection}
|
||||
>
|
||||
{t("vault.hosts.deselectAll")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={selectedHostIds.size === 0}
|
||||
onClick={deleteSelectedHosts}
|
||||
>
|
||||
<Trash2 size={12} className="mr-1" />
|
||||
{t("vault.hosts.deleteSelected", { count: selectedHostIds.size })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={clearHostSelection}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keep hosts mounted so switching sections does not reset scroll or remount the list. */}
|
||||
<div
|
||||
className={cn(
|
||||
@@ -2401,49 +2488,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMultiSelectMode && (
|
||||
<div className="flex items-center gap-2 p-2 bg-secondary/60 rounded-lg border border-border/40">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("vault.hosts.selected", { count: selectedHostIds.size })}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const allIds = new Set(displayedHosts.map(h => h.id));
|
||||
setSelectedHostIds(allIds);
|
||||
}}
|
||||
>
|
||||
{t("vault.hosts.selectAll")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearHostSelection}
|
||||
>
|
||||
{t("vault.hosts.deselectAll")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={selectedHostIds.size === 0}
|
||||
onClick={deleteSelectedHosts}
|
||||
>
|
||||
<Trash2 size={14} className="mr-1" />
|
||||
{t("vault.hosts.deleteSelected", { count: selectedHostIds.size })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={clearHostSelection}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === "tree" ? (
|
||||
<HostTreeView
|
||||
groupTree={treeViewGroupTree}
|
||||
@@ -2813,6 +2857,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
}
|
||||
onRunSnippet={onRunSnippet}
|
||||
availableKeys={keys}
|
||||
proxyProfiles={proxyProfiles}
|
||||
managedSources={managedSources}
|
||||
onSaveHost={(host) => onUpdateHosts([...hosts, host])}
|
||||
onCreateGroup={(groupPath) =>
|
||||
@@ -2827,6 +2872,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
hosts={hosts}
|
||||
proxyProfiles={proxyProfiles}
|
||||
customGroups={customGroups}
|
||||
managedSources={managedSources}
|
||||
onSave={(k) => onUpdateKeys([...keys, k])}
|
||||
@@ -2864,11 +2910,22 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{currentSection === "proxies" && (
|
||||
<ProxyProfilesManager
|
||||
proxyProfiles={proxyProfiles}
|
||||
hosts={hosts}
|
||||
groupConfigs={groupConfigs}
|
||||
onUpdateProxyProfiles={onUpdateProxyProfiles}
|
||||
onUpdateHosts={onUpdateHosts}
|
||||
onUpdateGroupConfigs={onUpdateGroupConfigs}
|
||||
/>
|
||||
)}
|
||||
{currentSection === "port" && (
|
||||
<PortForwarding
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
proxyProfiles={proxyProfiles}
|
||||
customGroups={customGroups}
|
||||
managedSources={managedSources}
|
||||
groupConfigs={groupConfigs}
|
||||
@@ -2911,6 +2968,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
config={groupConfigs.find(c => c.path === editingGroupPath)}
|
||||
availableKeys={keys}
|
||||
identities={identities}
|
||||
proxyProfiles={proxyProfiles}
|
||||
allHosts={hosts}
|
||||
groups={allGroupPaths}
|
||||
terminalThemeId={terminalThemeId}
|
||||
@@ -2931,6 +2989,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
initialData={editingHost}
|
||||
availableKeys={keys}
|
||||
identities={identities}
|
||||
proxyProfiles={proxyProfiles}
|
||||
groups={allGroupPaths}
|
||||
managedSources={managedSources}
|
||||
allTags={allTags}
|
||||
@@ -2941,13 +3000,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
groupDefaults={editingHostGroupDefaults}
|
||||
groupConfigs={groupConfigs}
|
||||
onSave={(host) => {
|
||||
// Check if host already exists in the list (for updates vs. new/duplicate)
|
||||
const hostExists = hosts.some((h) => h.id === host.id);
|
||||
onUpdateHosts(
|
||||
hostExists
|
||||
? hosts.map((h) => (h.id === host.id ? host : h))
|
||||
: [...hosts, host],
|
||||
);
|
||||
onUpdateHosts(upsertHostById(hosts, host));
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
setNewHostGroupPath(null);
|
||||
@@ -2973,15 +3026,15 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
allTags={allTags}
|
||||
groups={allGroupPaths}
|
||||
onSave={(host) => {
|
||||
onUpdateHosts(
|
||||
hosts.map((h) => (h.id === host.id ? host : h)),
|
||||
);
|
||||
onUpdateHosts(upsertHostById(hosts, host));
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
setNewHostGroupPath(null);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
setNewHostGroupPath(null);
|
||||
}}
|
||||
layout="inline"
|
||||
/>
|
||||
@@ -3192,7 +3245,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 => {
|
||||
@@ -3200,6 +3253,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 &&
|
||||
@@ -3210,7 +3264,8 @@ 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;
|
||||
|
||||
return isEqual;
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn('relative flex-1 overflow-y-hidden', className)}
|
||||
className={cn('relative flex-1 overflow-x-hidden overflow-y-hidden', className)}
|
||||
initial="instant"
|
||||
resize="smooth"
|
||||
role="log"
|
||||
@@ -20,7 +20,7 @@ export type ConversationContentProps = ComponentProps<typeof StickToBottom.Conte
|
||||
|
||||
export const ConversationContent = ({ className, ...props }: ConversationContentProps) => (
|
||||
<StickToBottom.Content
|
||||
className={cn('flex flex-col gap-4 p-4', className)}
|
||||
className={cn('flex min-w-0 max-w-full flex-col gap-4 overflow-x-hidden p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
@@ -62,7 +72,7 @@ export const MessageResponse = memo(
|
||||
// Style the rendered markdown
|
||||
// Code: base styles (code-block overrides are in index.css)
|
||||
'[&_code]:text-[12px] [&_code]:font-mono',
|
||||
'[&_p_code]:px-[0.4em] [&_p_code]:py-[0.15em] [&_p_code]:rounded [&_p_code]:bg-foreground/[0.06] [&_p_code]:text-[85%]',
|
||||
'[&_p_code]:px-[0.4em] [&_p_code]:py-[0.15em] [&_p_code]:rounded [&_p_code]:bg-foreground/[0.06] [&_p_code]:text-[85%] [&_p_code]:whitespace-normal [&_p_code]:[overflow-wrap:anywhere]',
|
||||
'[&_p]:my-1.5',
|
||||
'[&_ul]:my-1.5 [&_ul]:pl-4 [&_ul]:list-disc',
|
||||
'[&_ol]:my-1.5 [&_ol]:pl-4 [&_ol]:list-decimal',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Check, ChevronDown, ChevronRight, CheckCircle2, Loader2, ShieldAlert, X, XCircle, Slash } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useRef, useState, type HTMLAttributes } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
@@ -40,6 +41,7 @@ function formatToolResult(result: unknown): string {
|
||||
|
||||
export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
|
||||
name: string;
|
||||
className?: string;
|
||||
args?: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
isError?: boolean;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,12 +6,11 @@
|
||||
* and a bottom toolbar with muted controls + subtle send button.
|
||||
*/
|
||||
|
||||
import { AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, Plus, ShieldCheck, X, Zap } from 'lucide-react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, Package, Plus, ShieldCheck, X, Zap } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { FormEvent } from 'react';
|
||||
import type { UploadedFile } from '../../application/state/useFileUpload';
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputFooter,
|
||||
@@ -21,7 +20,8 @@ import {
|
||||
} from '../ai-elements/prompt-input';
|
||||
import type { PromptInputStatus } from '../ai-elements/prompt-input';
|
||||
import { formatThinkingLabel } from '../../infrastructure/ai/types';
|
||||
import type { AgentModelPreset, AIPermissionMode } from '../../infrastructure/ai/types';
|
||||
import type { AgentModelPreset, AIPermissionMode, UploadedFile } from '../../infrastructure/ai/types';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
|
||||
// Keep in sync with the popover's Tailwind max-width below.
|
||||
const MODEL_PICKER_MAX_WIDTH = 360;
|
||||
@@ -51,6 +51,14 @@ interface ChatInputProps {
|
||||
onRemoveFile?: (id: string) => void;
|
||||
/** Available hosts for @ mention */
|
||||
hosts?: Array<{ sessionId: string; hostname: string; label: string; connected: boolean }>;
|
||||
/** User skills currently selected for the next send */
|
||||
selectedUserSkills?: Array<{ id: string; slug: string; name: string; description: string }>;
|
||||
/** Available user skills for /skill-slug insertion */
|
||||
userSkills?: Array<{ id: string; slug: string; name: string; description: string }>;
|
||||
/** Callback to add a selected user skill */
|
||||
onAddUserSkill?: (slug: string) => void;
|
||||
/** Callback to remove a selected user skill */
|
||||
onRemoveUserSkill?: (slug: string) => void;
|
||||
/** Permission mode (only shown for Catty Agent) */
|
||||
permissionMode?: AIPermissionMode;
|
||||
/** Callback when user changes permission mode */
|
||||
@@ -75,38 +83,76 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
onAddFiles,
|
||||
onRemoveFile,
|
||||
hosts = [],
|
||||
selectedUserSkills = [],
|
||||
userSkills = [],
|
||||
onAddUserSkill,
|
||||
onRemoveUserSkill,
|
||||
permissionMode,
|
||||
onPermissionModeChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// Consolidate menu state into a single discriminated union to prevent multiple menus open simultaneously
|
||||
type ActiveMenu = 'model' | 'attach' | 'atMention' | 'perm' | null;
|
||||
type ActiveMenu = 'model' | 'attach' | 'atMention' | 'slashSkill' | 'perm' | null;
|
||||
const [activeMenu, setActiveMenu] = useState<ActiveMenu>(null);
|
||||
const [menuPos, setMenuPos] = useState<{ left: number; bottom: number } | null>(null);
|
||||
const [inputPanelPos, setInputPanelPos] = useState<{ left: number; bottom: number; width: number } | null>(null);
|
||||
const [hoveredModelId, setHoveredModelId] = useState<string | null>(null);
|
||||
const [showHostSubmenu, setShowHostSubmenu] = useState(false);
|
||||
const [slashQuery, setSlashQuery] = useState('');
|
||||
const [slashRange, setSlashRange] = useState<{ start: number; end: number } | null>(null);
|
||||
// Active highlight index for @ mention / slash skill keyboard navigation
|
||||
const [activeMenuIndex, setActiveMenuIndex] = useState(0);
|
||||
|
||||
// Derived booleans for readability
|
||||
const showModelPicker = activeMenu === 'model';
|
||||
const showAttachMenu = activeMenu === 'attach';
|
||||
const showAtMention = activeMenu === 'atMention';
|
||||
const showSlashSkillPicker = activeMenu === 'slashSkill';
|
||||
const showPermPicker = activeMenu === 'perm';
|
||||
|
||||
const closeAllMenus = useCallback(() => {
|
||||
setActiveMenu(null);
|
||||
setMenuPos(null);
|
||||
setInputPanelPos(null);
|
||||
setHoveredModelId(null);
|
||||
setShowHostSubmenu(false);
|
||||
setSlashQuery('');
|
||||
setSlashRange(null);
|
||||
}, []);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const inputShellRef = useRef<HTMLDivElement>(null);
|
||||
const modelBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const permBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const attachBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const findSlashTrigger = useCallback((text: string, caretPosition: number) => {
|
||||
const beforeCaret = text.slice(0, caretPosition);
|
||||
const match = /(^|\s)\/([a-z0-9-]*)$/i.exec(beforeCaret);
|
||||
if (!match) return null;
|
||||
const start = beforeCaret.length - match[0].length + match[1].length;
|
||||
return {
|
||||
start,
|
||||
end: beforeCaret.length,
|
||||
query: String(match[2] || '').toLowerCase(),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getInputPanelMenuPos = useCallback(() => {
|
||||
const rect = inputShellRef.current?.getBoundingClientRect();
|
||||
if (!rect) return null;
|
||||
const horizontalMargin = 12;
|
||||
const safeRight = window.innerWidth - horizontalMargin;
|
||||
const width = Math.min(rect.width, safeRight - rect.left);
|
||||
return {
|
||||
left: rect.left,
|
||||
bottom: window.innerHeight - rect.top + 8,
|
||||
width,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleInputChange = useCallback((newValue: string) => {
|
||||
onChange(newValue);
|
||||
const caretPosition = textareaRef.current?.selectionStart ?? newValue.length;
|
||||
// Detect if user just typed @
|
||||
if (
|
||||
hosts.length > 0 &&
|
||||
@@ -114,16 +160,28 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
newValue.endsWith('@')
|
||||
) {
|
||||
// Position the popover near the textarea
|
||||
const el = textareaRef.current;
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
setMenuPos({ left: rect.left + 12, bottom: window.innerHeight - rect.top + 4 });
|
||||
}
|
||||
const pos = getInputPanelMenuPos();
|
||||
if (pos) setInputPanelPos(pos);
|
||||
setActiveMenu('atMention');
|
||||
} else if (showAtMention && !newValue.includes('@')) {
|
||||
setActiveMenu(null);
|
||||
return;
|
||||
}
|
||||
}, [onChange, value, hosts.length, showAtMention]);
|
||||
|
||||
const slashTrigger = findSlashTrigger(newValue, caretPosition);
|
||||
if (userSkills.length > 0 && slashTrigger) {
|
||||
const pos = getInputPanelMenuPos();
|
||||
if (pos) setInputPanelPos(pos);
|
||||
setSlashQuery(slashTrigger.query);
|
||||
setSlashRange({ start: slashTrigger.start, end: slashTrigger.end });
|
||||
setActiveMenu('slashSkill');
|
||||
return;
|
||||
}
|
||||
|
||||
if (showAtMention && !newValue.includes('@')) {
|
||||
setActiveMenu(null);
|
||||
} else if (showSlashSkillPicker) {
|
||||
closeAllMenus();
|
||||
}
|
||||
}, [onChange, value, hosts.length, showAtMention, findSlashTrigger, userSkills.length, showSlashSkillPicker, closeAllMenus, getInputPanelMenuPos]);
|
||||
|
||||
const handleSelectAtMention = useCallback((host: { label: string; hostname: string }) => {
|
||||
// Replace the trailing @ with @hostname
|
||||
@@ -136,10 +194,117 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
closeAllMenus();
|
||||
}, [value, onChange, closeAllMenus]);
|
||||
|
||||
const openInputPanelMenu = useCallback((menu: 'atMention' | 'slashSkill') => {
|
||||
const pos = getInputPanelMenuPos();
|
||||
if (!pos) return;
|
||||
setInputPanelPos(pos);
|
||||
if (menu === 'slashSkill') {
|
||||
setSlashQuery('');
|
||||
setSlashRange(null);
|
||||
}
|
||||
setActiveMenu(menu);
|
||||
}, [getInputPanelMenuPos]);
|
||||
|
||||
const filteredUserSkills = useMemo(() => userSkills.filter((skill) => {
|
||||
if (!slashQuery) return true;
|
||||
const lowerQuery = slashQuery.toLowerCase();
|
||||
return skill.slug.toLowerCase().startsWith(lowerQuery) || skill.name.toLowerCase().includes(lowerQuery);
|
||||
}), [userSkills, slashQuery]);
|
||||
|
||||
const removeSlashQueryFromInput = useCallback(() => {
|
||||
if (!slashRange) return value;
|
||||
const before = value.slice(0, slashRange.start);
|
||||
const after = value.slice(slashRange.end);
|
||||
if (/\s$/.test(before) && /^\s/.test(after)) {
|
||||
return `${before}${after.slice(1)}`;
|
||||
}
|
||||
return `${before}${after}`;
|
||||
}, [slashRange, value]);
|
||||
|
||||
const insertUserSkillToken = useCallback((skill: { slug: string }) => {
|
||||
onAddUserSkill?.(skill.slug);
|
||||
if (slashRange) {
|
||||
onChange(removeSlashQueryFromInput());
|
||||
}
|
||||
closeAllMenus();
|
||||
}, [closeAllMenus, onAddUserSkill, onChange, removeSlashQueryFromInput, slashRange]);
|
||||
|
||||
// Reset active highlight when a menu opens or when the *identity* of the
|
||||
// visible items changes. Watching only `.length` misses cases where the
|
||||
// filter produces a different set with the same count (e.g. user types
|
||||
// another character into the slash query) — Enter would then commit an
|
||||
// unexpected item. Derive a stable key from the visible ids instead.
|
||||
const atMentionKey = useMemo(
|
||||
() => hosts.map((h) => h.sessionId).join('|'),
|
||||
[hosts],
|
||||
);
|
||||
const slashSkillKey = useMemo(
|
||||
() => filteredUserSkills.map((s) => s.id).join('|'),
|
||||
[filteredUserSkills],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (showAtMention) setActiveMenuIndex(0);
|
||||
}, [showAtMention, atMentionKey]);
|
||||
useEffect(() => {
|
||||
if (showSlashSkillPicker) setActiveMenuIndex(0);
|
||||
}, [showSlashSkillPicker, slashSkillKey]);
|
||||
|
||||
const handleTextareaKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
// @ mention popover keyboard navigation
|
||||
if (showAtMention && hosts.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveMenuIndex((i) => (i + 1) % hosts.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveMenuIndex((i) => (i - 1 + hosts.length) % hosts.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const host = hosts[Math.min(activeMenuIndex, hosts.length - 1)];
|
||||
if (host) handleSelectAtMention(host);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeAllMenus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// / skill popover keyboard navigation
|
||||
if (showSlashSkillPicker && filteredUserSkills.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveMenuIndex((i) => (i + 1) % filteredUserSkills.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveMenuIndex((i) => (i - 1 + filteredUserSkills.length) % filteredUserSkills.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const skill = filteredUserSkills[Math.min(activeMenuIndex, filteredUserSkills.length - 1)];
|
||||
if (skill) insertUserSkillToken(skill);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeAllMenus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [showAtMention, hosts, showSlashSkillPicker, filteredUserSkills, activeMenuIndex, handleSelectAtMention, insertUserSkillToken, closeAllMenus]);
|
||||
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
||||
const pastedFiles = Array.from(e.clipboardData.items)
|
||||
.map((item) => item.getAsFile())
|
||||
.filter(Boolean) as File[];
|
||||
.map((item: DataTransferItem) => item.getAsFile())
|
||||
.filter((f): f is File => !!f);
|
||||
if (pastedFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
onAddFiles?.(pastedFiles);
|
||||
@@ -195,11 +360,14 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
const hasModelPicker = modelPresets.length > 0 && onModelSelect;
|
||||
const chipClassName =
|
||||
'inline-flex h-6 items-center gap-1 rounded-full px-1.5 text-[10.5px] text-foreground/72';
|
||||
const selectedSkillChipClassName =
|
||||
'inline-flex h-7 items-center gap-1.5 rounded-full border border-primary/18 bg-primary/8 pl-2.5 pr-1.5 text-[11px] font-medium text-foreground/86 shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]';
|
||||
const iconButtonClassName =
|
||||
'h-6 w-6 rounded-full bg-transparent text-foreground/62 hover:bg-muted/24 hover:text-foreground';
|
||||
|
||||
return (
|
||||
<div className="shrink-0 px-4 pb-4">
|
||||
<div ref={inputShellRef} className="relative">
|
||||
<PromptInput onSubmit={handleSubmit}>
|
||||
{/* File attachment chips */}
|
||||
{files.length > 0 && (
|
||||
@@ -243,13 +411,44 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
|
||||
{/* Textarea with expand toggle */}
|
||||
<div className="relative" onPaste={handlePaste} onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}>
|
||||
{selectedUserSkills.length > 0 && (
|
||||
<div className="px-3 pt-3 pb-1.5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedUserSkills.map((skill) => (
|
||||
<div
|
||||
key={skill.id}
|
||||
className={selectedSkillChipClassName}
|
||||
title={skill.description || skill.name || skill.slug}
|
||||
>
|
||||
<Package size={11} className="text-primary/72 shrink-0" />
|
||||
<span className="truncate max-w-[180px]">
|
||||
{skill.name && skill.name !== skill.slug ? skill.name : `/${skill.slug}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveUserSkill?.(skill.slug)}
|
||||
className="inline-flex h-4.5 w-4.5 items-center justify-center rounded-full text-foreground/42 hover:bg-primary/10 hover:text-foreground/72 transition-colors cursor-pointer"
|
||||
aria-label={`Remove skill ${skill.name || skill.slug}`}
|
||||
>
|
||||
<X size={9} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<PromptInputTextarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onKeyDown={handleTextareaKeyDown}
|
||||
placeholder={placeholder || defaultPlaceholder}
|
||||
disabled={disabled}
|
||||
className={expanded ? 'max-h-[220px]' : undefined}
|
||||
className={[
|
||||
selectedUserSkills.length > 0 ? 'pt-1.5' : undefined,
|
||||
expanded ? 'max-h-[220px]' : undefined,
|
||||
].filter(Boolean).join(' ')}
|
||||
maxLength={100000}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -262,31 +461,94 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
</div>
|
||||
|
||||
{/* @ mention popover */}
|
||||
{showAtMention && hosts.length > 0 && menuPos && createPortal(
|
||||
{showAtMention && hosts.length > 0 && inputPanelPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Mention host"
|
||||
className="fixed z-[1000] min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom }}
|
||||
aria-activedescendant={hosts[activeMenuIndex] ? `at-mention-${hosts[activeMenuIndex].sessionId}` : undefined}
|
||||
className="fixed z-[1000] overflow-hidden rounded-lg border border-border/50 bg-popover shadow-lg"
|
||||
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: 'auto', minWidth: Math.min(200, inputPanelPos.width), maxWidth: inputPanelPos.width }}
|
||||
>
|
||||
<div className="px-3 py-1 text-[10px] text-muted-foreground/40 tracking-wide">{t('ai.chat.menuHosts')}</div>
|
||||
{hosts.map(host => (
|
||||
<button
|
||||
key={host.sessionId}
|
||||
type="button"
|
||||
role="option"
|
||||
onClick={() => handleSelectAtMention(host)}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
|
||||
<span className="text-foreground/85 truncate">{host.label || host.hostname}</span>
|
||||
{host.label && host.hostname !== host.label && (
|
||||
<span className="text-[10px] text-muted-foreground/40">{host.hostname}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<ScrollArea className="max-h-[280px]">
|
||||
<div className="p-1">
|
||||
{hosts.map((host, idx) => {
|
||||
const isActive = idx === activeMenuIndex;
|
||||
const showHostnameLine = host.label
|
||||
&& host.hostname !== host.label
|
||||
&& !host.label.includes(host.hostname);
|
||||
return (
|
||||
<button
|
||||
id={`at-mention-${host.sessionId}`}
|
||||
key={host.sessionId}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onMouseEnter={() => setActiveMenuIndex(idx)}
|
||||
onClick={() => handleSelectAtMention(host)}
|
||||
className={`w-full rounded-md px-2 py-1 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[12px] text-foreground/90">
|
||||
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
|
||||
<span className="truncate">{host.label || host.hostname}</span>
|
||||
</div>
|
||||
{showHostnameLine ? (
|
||||
<div className="pl-3.5 text-[10px] text-muted-foreground/60 truncate">
|
||||
{host.hostname}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{/* / skill popover */}
|
||||
{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"
|
||||
aria-activedescendant={filteredUserSkills[activeMenuIndex] ? `slash-skill-${filteredUserSkills[activeMenuIndex].id}` : undefined}
|
||||
className="fixed z-[1000] overflow-hidden rounded-lg border border-border/50 bg-popover shadow-lg"
|
||||
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: 'auto', minWidth: Math.min(200, inputPanelPos.width), maxWidth: inputPanelPos.width }}
|
||||
>
|
||||
<ScrollArea className="max-h-[280px]">
|
||||
<div className="p-1">
|
||||
{filteredUserSkills.map((skill, idx) => {
|
||||
const isActive = idx === activeMenuIndex;
|
||||
return (
|
||||
<button
|
||||
id={`slash-skill-${skill.id}`}
|
||||
key={skill.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onMouseEnter={() => setActiveMenuIndex(idx)}
|
||||
onClick={() => insertUserSkillToken(skill)}
|
||||
className={`w-full rounded-md px-2 py-1 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[12px]">
|
||||
<Package size={12} className="text-muted-foreground/55 shrink-0" />
|
||||
<span className="text-foreground/90">/{skill.slug}</span>
|
||||
</div>
|
||||
{skill.description ? (
|
||||
<div className="pl-5 text-[10px] leading-4.5 text-muted-foreground/62 line-clamp-2">
|
||||
{skill.description}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
@@ -317,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"
|
||||
@@ -341,48 +604,30 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
<ImageIcon size={13} className="text-muted-foreground/60" />
|
||||
<span className="text-foreground/85">{t('ai.chat.menuImage')}</span>
|
||||
</button>
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setShowHostSubmenu(true)}
|
||||
onMouseLeave={() => setShowHostSubmenu(false)}
|
||||
onFocus={() => setShowHostSubmenu(true)}
|
||||
onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setShowHostSubmenu(false); }}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label="Mention host"
|
||||
onClick={() => openInputPanelMenu('atMention')}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<AtSign size={13} className="text-muted-foreground/60" />
|
||||
<span className="flex-1 text-foreground/85">{t('ai.chat.menuMentionHost')}</span>
|
||||
{hosts.length > 0 && <ChevronRight size={10} className="text-muted-foreground/50" />}
|
||||
</button>
|
||||
{userSkills.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label="Mention host"
|
||||
aria-expanded={showHostSubmenu && hosts.length > 0}
|
||||
aria-label="Insert user skill"
|
||||
onClick={() => openInputPanelMenu('slashSkill')}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<AtSign size={13} className="text-muted-foreground/60" />
|
||||
<span className="flex-1 text-foreground/85">{t('ai.chat.menuMentionHost')}</span>
|
||||
{hosts.length > 0 && <ChevronRight size={10} className="text-muted-foreground/50" />}
|
||||
<Package size={13} className="text-muted-foreground/60" />
|
||||
<span className="flex-1 text-foreground/85">{t('ai.chat.menuUserSkills')}</span>
|
||||
<ChevronRight size={10} className="text-muted-foreground/50" />
|
||||
</button>
|
||||
{showHostSubmenu && hosts.length > 0 && (
|
||||
<div role="menu" className="absolute left-full top-0 ml-1 min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1 z-[1001]">
|
||||
{hosts.map(host => (
|
||||
<button
|
||||
key={host.sessionId}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
const mention = `@${host.label || host.hostname} `;
|
||||
onChange(value + mention);
|
||||
closeAllMenus();
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
|
||||
<span className="text-foreground/85 truncate">{host.label || host.hostname}</span>
|
||||
{host.label && host.hostname !== host.label && (
|
||||
<span className="text-[10px] text-muted-foreground/40">{host.hostname}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
@@ -415,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 }}
|
||||
@@ -527,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"
|
||||
@@ -579,6 +826,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
</div>
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -177,13 +177,14 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
return (
|
||||
<React.Fragment key={message.id}>
|
||||
{message.toolResults?.map((tr) => (
|
||||
<ToolCall
|
||||
key={tr.toolCallId}
|
||||
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
|
||||
args={toolCallArgs.get(tr.toolCallId)}
|
||||
result={tr.content}
|
||||
isError={tr.isError}
|
||||
/>
|
||||
<div key={tr.toolCallId}>
|
||||
<ToolCall
|
||||
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
|
||||
args={toolCallArgs.get(tr.toolCallId)}
|
||||
result={tr.content}
|
||||
isError={tr.isError}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
@@ -195,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
|
||||
@@ -232,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>
|
||||
@@ -255,15 +256,16 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
? 'denied' as const
|
||||
: undefined;
|
||||
return (
|
||||
<ToolCall
|
||||
key={tc.id}
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isInterrupted={!isPending}
|
||||
approvalStatus={approvalStatus}
|
||||
onApprove={() => handleApprove(tc.id)}
|
||||
onReject={() => handleReject(tc.id)}
|
||||
/>
|
||||
<div key={tc.id}>
|
||||
<ToolCall
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isInterrupted={!isPending}
|
||||
approvalStatus={approvalStatus}
|
||||
onApprove={() => handleApprove(tc.id)}
|
||||
onReject={() => handleReject(tc.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -308,34 +310,35 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
? 'denied' as const
|
||||
: undefined;
|
||||
return (
|
||||
<ToolCall
|
||||
key={tc.id}
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isLoading={isStreaming && lastAssistantMessage.executionStatus === 'running' && !isPending}
|
||||
approvalStatus={approvalStatus}
|
||||
onApprove={() => handleApprove(tc.id)}
|
||||
onReject={() => handleReject(tc.id)}
|
||||
/>
|
||||
<div key={tc.id}>
|
||||
<ToolCall
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isLoading={isStreaming && lastAssistantMessage.executionStatus === 'running' && !isPending}
|
||||
approvalStatus={approvalStatus}
|
||||
onApprove={() => handleApprove(tc.id)}
|
||||
onReject={() => handleReject(tc.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Standalone MCP/ACP approval requests (not tied to SDK tool calls) */}
|
||||
{Array.from(pendingApprovals.entries())
|
||||
.filter((entry) => entry[0].startsWith('mcp_approval_') && (!activeSessionId || entry[1].chatSessionId === activeSessionId))
|
||||
.map((entry) => {
|
||||
const [id, req] = entry;
|
||||
.filter(([id, req]) => id.startsWith('mcp_approval_') && (!activeSessionId || req.chatSessionId === activeSessionId))
|
||||
.map(([id, req]) => {
|
||||
return (
|
||||
<ToolCall
|
||||
key={id}
|
||||
name={req.toolName}
|
||||
args={req.args}
|
||||
isLoading={false}
|
||||
isInterrupted={false}
|
||||
approvalStatus={'pending'}
|
||||
onApprove={() => handleApprove(id)}
|
||||
onReject={() => handleReject(id)}
|
||||
/>
|
||||
<div key={id}>
|
||||
<ToolCall
|
||||
name={req.toolName}
|
||||
args={req.args}
|
||||
isLoading={false}
|
||||
isInterrupted={false}
|
||||
approvalStatus={'pending'}
|
||||
onApprove={() => handleApprove(id)}
|
||||
onReject={() => handleReject(id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Streaming indicator — only when no content and no thinking yet */}
|
||||
|
||||
@@ -59,7 +59,7 @@ 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/45 bg-[#111111]/98 p-1.5 text-foreground shadow-[0_20px_48px_rgba(0,0,0,0.48)] supports-[backdrop-filter]:bg-[#111111]/92 supports-[backdrop-filter]:backdrop-blur-sm"
|
||||
>
|
||||
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground/48">
|
||||
{t('ai.chat.exportAs')}
|
||||
|
||||
662
components/ai/acpHistory.test.ts
Normal file
662
components/ai/acpHistory.test.ts
Normal file
@@ -0,0 +1,662 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
|
||||
import {
|
||||
buildAcpHistoryMessages,
|
||||
buildAcpHistoryMessagesForBridge,
|
||||
} from "./acpHistory.ts";
|
||||
|
||||
function message(
|
||||
id: string,
|
||||
role: ChatMessage["role"],
|
||||
content: string,
|
||||
extra: Partial<ChatMessage> = {},
|
||||
): ChatMessage {
|
||||
return {
|
||||
id,
|
||||
role,
|
||||
content,
|
||||
timestamp: 1,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
test("buildAcpHistoryMessages compacts older ACP context and keeps only recent raw turns", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "我希望最小改动,不要添加很多 test"),
|
||||
message("a1", "assistant", "已按最小改动处理"),
|
||||
message("u2", "user", "MCP 不允许使用,Windows 上不要假设 pwsh.exe"),
|
||||
message("a2", "assistant", "PR #738 已创建,commit 4181a2c"),
|
||||
message("u3", "user", "帮我上网查查优化方案,每轮都带历史太慢了"),
|
||||
message("a3", "assistant", "建议 ACP history compaction"),
|
||||
message("tool1", "tool", "", {
|
||||
toolResults: [
|
||||
{
|
||||
toolCallId: "search",
|
||||
content: `error: ${"large output ".repeat(500)}`,
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
message("u4", "user", "好的"),
|
||||
message("a4", "assistant", "准备实现"),
|
||||
message("u5", "user", "继续"),
|
||||
message("a5", "assistant", "继续处理"),
|
||||
message("u6", "user", "现在提交"),
|
||||
message("a6", "assistant", "还没提交"),
|
||||
];
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Compact prior Netcatty UI context/);
|
||||
assert.match(result[0].content, /最小改动/);
|
||||
assert.match(result[0].content, /pwsh\.exe/);
|
||||
assert.match(result[0].content, /PR #738/);
|
||||
assert.ok(result[0].content.length <= 3000);
|
||||
|
||||
assert.ok(result.length <= 7);
|
||||
assert.deepEqual(
|
||||
result.slice(1).map((entry) => entry.content),
|
||||
["好的", "准备实现", "继续", "继续处理", "现在提交", "还没提交"],
|
||||
);
|
||||
assert.ok(result.every((entry) => entry.content.length <= 3000));
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessagesForBridge keeps fallback history available for stale ACP session recovery", () => {
|
||||
const messages = [message("u1", "user", "继续处理这个历史压缩问题")];
|
||||
|
||||
assert.equal(buildAcpHistoryMessagesForBridge([], "acp-session-1"), undefined);
|
||||
assert.deepEqual(
|
||||
buildAcpHistoryMessagesForBridge(messages, "acp-session-1"),
|
||||
buildAcpHistoryMessages(messages),
|
||||
);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves older substantive user instructions outside the recent raw window", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "Keep this incremental and do not refactor unrelated files."),
|
||||
message("a1", "assistant", "Understood."),
|
||||
];
|
||||
|
||||
for (let index = 2; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Keep this incremental and do not refactor unrelated files\./);
|
||||
assert.deepEqual(
|
||||
result.slice(-6).map((entry) => entry.content),
|
||||
[
|
||||
"filler user message 11",
|
||||
"filler assistant message 11",
|
||||
"filler user message 12",
|
||||
"filler assistant message 12",
|
||||
"filler user message 13",
|
||||
"filler assistant message 13",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves short important user constraints outside the recent raw window", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "不要提交"),
|
||||
message("a1", "assistant", "收到"),
|
||||
];
|
||||
|
||||
for (let index = 2; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /不要提交/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages does not treat pr inside ordinary words as important", () => {
|
||||
// Original intent: `\bpr\b` in IMPORTANT_PATTERNS must NOT match 'pr'
|
||||
// inside ordinary English words like 'approach' / 'improve' / 'prepare'.
|
||||
// Those words land at priority=1 (kept only as space allows) while the
|
||||
// 不要提交 line lands at priority=2 (always preferred). The check below
|
||||
// doesn't assert that the ordinary words are absent from the compact
|
||||
// section — they may legitimately survive when budget allows; that's
|
||||
// intentional after we stopped blanket-dropping short user messages.
|
||||
// What we DO verify: the priority-2 line is selected, which is only
|
||||
// possible if the IMPORTANT_PATTERNS regex correctly distinguishes it
|
||||
// from the surrounding short ordinary-word turns.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "不要提交"),
|
||||
message("a1", "assistant", "收到"),
|
||||
message("u2", "user", "approach"),
|
||||
message("a2", "assistant", "ack"),
|
||||
message("u3", "user", "improve"),
|
||||
message("a3", "assistant", "ack"),
|
||||
message("u4", "user", "prepare"),
|
||||
message("a4", "assistant", "ack"),
|
||||
];
|
||||
|
||||
for (let index = 5; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /不要提交/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages prioritizes later durable instructions over older filler prompts", () => {
|
||||
const messages: ChatMessage[] = [];
|
||||
|
||||
for (let index = 1; index <= 12; index += 1) {
|
||||
messages.push(
|
||||
message(
|
||||
`u${index}`,
|
||||
"user",
|
||||
`Please continue with implementation step ${index} and keep momentum by following the current plan carefully.`,
|
||||
),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
messages.push(
|
||||
message("u13", "user", "Keep the existing layout and copy wording unchanged."),
|
||||
message("a13", "assistant", "Understood."),
|
||||
);
|
||||
|
||||
for (let index = 14; index <= 18; index += 1) {
|
||||
messages.push(
|
||||
message(
|
||||
`u${index}`,
|
||||
"user",
|
||||
`Please continue with implementation step ${index} and keep momentum by following the current plan carefully.`,
|
||||
),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Keep the existing layout and copy wording unchanged\./);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves older substantive assistant context that later user prompts can reference", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "Please propose a migration plan for the sidebar state."),
|
||||
message(
|
||||
"a1",
|
||||
"assistant",
|
||||
"Plan: 1. Introduce a dedicated hook for the panel stack. 2. Move the derived view state into that hook. 3. Keep the existing UI copy and layout. 4. Add a regression test around back navigation.",
|
||||
),
|
||||
];
|
||||
|
||||
for (let index = 2; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
messages.push(message("u14", "user", "Apply step 2 of your plan now."));
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Move the derived view state into that hook\./);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves short non-trivial user constraints that miss the IMPORTANT regex", () => {
|
||||
// Regression: short load-bearing instructions like "Use ssh2" / "中文输出"
|
||||
// would previously be dropped by a blanket length<10 heuristic, even
|
||||
// though they don't match any TRIVIAL pattern.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "Use ssh2"),
|
||||
message("a1", "assistant", "Got it."),
|
||||
message("u2", "user", "中文输出"),
|
||||
message("a2", "assistant", "明白"),
|
||||
];
|
||||
|
||||
// Push enough later turns so u1/u2 fall outside the recent raw window
|
||||
// and have to survive via the durable-user compaction path.
|
||||
for (let index = 3; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Use ssh2/);
|
||||
assert.match(result[0].content, /中文输出/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages still drops one-word filler user messages", () => {
|
||||
// Sanity: removing the length<10 heuristic must not cause "ok" / "继续" /
|
||||
// "thanks" filler to leak into the compact section.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "ok"),
|
||||
message("a1", "assistant", "ack"),
|
||||
message("u2", "user", "继续"),
|
||||
message("a2", "assistant", "继续处理"),
|
||||
];
|
||||
|
||||
for (let index = 3; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
// u1 / u2 fall outside the recent raw window. The compact context, if it
|
||||
// exists, must not surface these trivial turns as durable user requests.
|
||||
if (result.length > 0 && result[0].role === "user") {
|
||||
assert.doesNotMatch(result[0].content, /User request: ok\b/);
|
||||
assert.doesNotMatch(result[0].content, /User request: 继续/);
|
||||
}
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves recent tool results verbatim (up to the raw budget) for follow-up references", () => {
|
||||
// Regression: tool results used to only reach fallback replay via the
|
||||
// 500-char compact summary. If the user's last interaction produced a
|
||||
// large tool output (cat/rg/fetched file), any "use that output"-style
|
||||
// follow-up lost the actual bytes. Now tool messages flow through the
|
||||
// recent raw window at MAX_RAW_MESSAGE_CHARS (2000).
|
||||
const bigToolOutput = "DATA ".repeat(300); // ~1500 chars — bigger than summary cap but smaller than raw cap
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "cat /etc/hosts"),
|
||||
message("a1", "assistant", "", {
|
||||
toolCalls: [{ id: "call1", name: "terminal", arguments: { cmd: "cat /etc/hosts" } }],
|
||||
}),
|
||||
message("tool1", "tool", "", {
|
||||
toolResults: [
|
||||
{ toolCallId: "call1", content: bigToolOutput, isError: false },
|
||||
],
|
||||
}),
|
||||
message("u2", "user", "use that output"),
|
||||
];
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Raw-window tool result carries both the [from ...] provenance label
|
||||
// and the actual bytes (not just the 500-char compact summary).
|
||||
assert.match(flat, /Tool result \[from terminal.*?cat \/etc\/hosts.*?\] \(call1\): DATA DATA DATA/);
|
||||
// Confirm we kept enough bytes to exceed the compact-summary cap.
|
||||
const toolResultIdx = flat.indexOf("Tool result [from terminal");
|
||||
assert.ok(toolResultIdx >= 0, "tool result line must appear in raw window");
|
||||
const toolResultChunk = flat.slice(toolResultIdx);
|
||||
assert.ok(
|
||||
toolResultChunk.length > 600,
|
||||
`expected tool result chunk to exceed compact cap (~500 chars), got ${toolResultChunk.length}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages inlines tool_call name+args so tool_result is interpretable without the preceding assistant turn", () => {
|
||||
// Regression: if the raw window starts mid-tool-interaction, the
|
||||
// preceding assistant tool_call message may be outside the 6-item
|
||||
// slice. Without the call's name/args inline on the result line, the
|
||||
// AI sees opaque bytes and "use that output" becomes ambiguous.
|
||||
const messages: ChatMessage[] = [
|
||||
// Early filler to push the tool_call off the raw window
|
||||
message("u0", "user", "prior chatter"),
|
||||
message("a0", "assistant", "prior reply"),
|
||||
message("u1", "user", "cat /etc/hosts"),
|
||||
message("a1", "assistant", "", {
|
||||
toolCalls: [
|
||||
{ id: "call1", name: "terminal_exec", arguments: { command: "cat /etc/hosts" } },
|
||||
],
|
||||
}),
|
||||
message("tool1", "tool", "", {
|
||||
toolResults: [
|
||||
{ toolCallId: "call1", content: "127.0.0.1 localhost", isError: false },
|
||||
],
|
||||
}),
|
||||
message("u2", "user", "use that output"),
|
||||
message("a2", "assistant", "acknowledged"),
|
||||
message("u3", "user", "now do the same for /etc/resolv.conf"),
|
||||
];
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// The tool_result line must carry the originating tool_call's name and
|
||||
// args, so even if a1 was pushed out of the raw window, the result is
|
||||
// self-describing.
|
||||
assert.match(flat, /Tool result \[from terminal_exec/);
|
||||
assert.match(flat, /cat \/etc\/hosts/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages bounds the durable-candidate scan to avoid O(N) work per send on long chats", () => {
|
||||
// Regression target: codex review flagged that the compaction path
|
||||
// scanned messages.entries() over the full transcript. Build a very
|
||||
// long chat (>> MAX_DURABLE_SCAN_TURNS user turns) and verify that
|
||||
// only messages within the recent user-turn window contribute
|
||||
// durable candidates.
|
||||
const messages: ChatMessage[] = [];
|
||||
// An ancient high-priority constraint that MUST be aged out.
|
||||
messages.push(message("old-important", "user", "不要提交 old-marker-xyz"));
|
||||
messages.push(message("old-ack", "assistant", "收到"));
|
||||
|
||||
// 300 filler turns between the ancient constraint and the window —
|
||||
// well past MAX_DURABLE_SCAN_TURNS (100).
|
||||
for (let i = 0; i < 300; i += 1) {
|
||||
messages.push(
|
||||
message(`u${i}`, "user", `filler user message ${i}`),
|
||||
message(`a${i}`, "assistant", `filler assistant message ${i}`),
|
||||
);
|
||||
}
|
||||
|
||||
// A recent constraint that should survive.
|
||||
messages.push(message("recent-important", "user", "不要提交 recent-marker-abc"));
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
messages.push(
|
||||
message(`t${i}`, "user", `tail user message ${i}`),
|
||||
message(`ta${i}`, "assistant", `tail assistant message ${i}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Recent priority-2 constraint is kept.
|
||||
assert.match(flat, /recent-marker-abc/);
|
||||
// Ancient one past the scan window is dropped — proof the bound holds.
|
||||
assert.doesNotMatch(flat, /old-marker-xyz/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves an early constraint in a tool-heavy chat where message count balloons past the raw-count limit", () => {
|
||||
// Regression: the previous bound was MAX_DURABLE_SCAN_MESSAGES=200 on
|
||||
// the raw message array. In a tool-heavy chat, each user turn can
|
||||
// expand to 5+ messages (user + assistant w/ toolCalls + N tool
|
||||
// results + follow-up assistant), so 200 messages might be only
|
||||
// ~40 user turns. An instruction like "不要提交" from turn 5 would
|
||||
// fall out of the scan before the turn count justified aging it out.
|
||||
//
|
||||
// Now the bound is MAX_DURABLE_SCAN_TURNS=100 user turns. Build a
|
||||
// chat with only 30 user turns but many messages per turn — the
|
||||
// early constraint must still survive.
|
||||
const messages: ChatMessage[] = [];
|
||||
messages.push(message("early-important", "user", "不要提交 EARLY_CONSTRAINT_MARKER"));
|
||||
messages.push(message("early-ack", "assistant", "收到"));
|
||||
|
||||
// 35 additional turns, each with 6 messages (bloats the total
|
||||
// message count to >200 without exceeding 100 user turns).
|
||||
for (let turn = 1; turn < 36; turn += 1) {
|
||||
messages.push(message(`u${turn}`, "user", `turn ${turn} request`));
|
||||
messages.push(message(`a${turn}-plan`, "assistant", "let me check", {
|
||||
toolCalls: [
|
||||
{ id: `c${turn}a`, name: "terminal_exec", arguments: { cmd: "echo a" } },
|
||||
{ id: `c${turn}b`, name: "terminal_exec", arguments: { cmd: "echo b" } },
|
||||
{ id: `c${turn}c`, name: "terminal_exec", arguments: { cmd: "echo c" } },
|
||||
],
|
||||
}));
|
||||
messages.push(message(`t${turn}a`, "tool", "", {
|
||||
toolResults: [{ toolCallId: `c${turn}a`, content: `result a of turn ${turn}`, isError: false }],
|
||||
}));
|
||||
messages.push(message(`t${turn}b`, "tool", "", {
|
||||
toolResults: [{ toolCallId: `c${turn}b`, content: `result b of turn ${turn}`, isError: false }],
|
||||
}));
|
||||
messages.push(message(`t${turn}c`, "tool", "", {
|
||||
toolResults: [{ toolCallId: `c${turn}c`, content: `result c of turn ${turn}`, isError: false }],
|
||||
}));
|
||||
messages.push(message(`a${turn}-done`, "assistant", `turn ${turn} done`));
|
||||
}
|
||||
|
||||
// Sanity: the message count is over 200 even though user turns are 30.
|
||||
assert.ok(messages.length > 200, `setup: expected > 200 messages, got ${messages.length}`);
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Under the old raw-count bound, the early constraint would age out;
|
||||
// under the turn-based bound it survives.
|
||||
assert.match(flat, /EARLY_CONSTRAINT_MARKER/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves short non-trivial assistant decisions that miss the keyword heuristic", () => {
|
||||
// Regression: isSubstantiveAssistantMessage previously required length
|
||||
// >= 40 OR a small English keyword match OR a numbered list. Short
|
||||
// load-bearing replies like "Use ssh2" / "rebase instead" / "中文输出"
|
||||
// satisfied none of those and were silently dropped. After a stale-
|
||||
// session recovery, "do what you suggested earlier" would then replay
|
||||
// only the user's question without the assistant's actual decision.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "which client should I use"),
|
||||
message("a1", "assistant", "Use ssh2"),
|
||||
message("u2", "user", "output language?"),
|
||||
message("a2", "assistant", "中文输出"),
|
||||
message("u3", "user", "merge or rebase?"),
|
||||
message("a3", "assistant", "rebase instead"),
|
||||
];
|
||||
|
||||
// Pad so u1..a3 fall outside the recent raw window (last 6 items) and
|
||||
// must flow through the durable-assistant compact pass.
|
||||
for (let index = 4; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
assert.match(flat, /Use ssh2/);
|
||||
assert.match(flat, /中文输出/);
|
||||
assert.match(flat, /rebase instead/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages still drops trivial assistant filler like 'ack' / 'ok' / '明白'", () => {
|
||||
// Sanity: removing the length/keyword gate must not let assistant
|
||||
// filler leak into the compact durable-assistant section.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "prompt 1"),
|
||||
message("a1", "assistant", "ack"),
|
||||
message("u2", "user", "prompt 2"),
|
||||
message("a2", "assistant", "明白"),
|
||||
message("u3", "user", "prompt 3"),
|
||||
message("a3", "assistant", "got it"),
|
||||
];
|
||||
|
||||
for (let index = 4; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `more filler ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
assert.doesNotMatch(flat, /Assistant context: ack\b/);
|
||||
assert.doesNotMatch(flat, /Assistant context: got it\b/);
|
||||
assert.doesNotMatch(flat, /Assistant context: 明白/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages inlines tool_call context on OLDER summarized tool results", () => {
|
||||
// Regression: the raw-window fix covered the last 6 items, but once
|
||||
// a tool result fell into the compact section (summarizeToolMessage
|
||||
// path) the `[from <name>(<args>)]` provenance label was absent.
|
||||
// With multiple older tool outputs, all surfacing as identical
|
||||
// `Tool result (callN): ...`, follow-ups like "use the resolv.conf
|
||||
// output" have no way to map to the right call.
|
||||
const messages: ChatMessage[] = [
|
||||
// Two distinct tool interactions, both pushed well outside the
|
||||
// recent raw window by later turns.
|
||||
message("u1", "user", "show hosts"),
|
||||
message("a1", "assistant", "", {
|
||||
toolCalls: [{ id: "call-hosts", name: "terminal_exec", arguments: { command: "cat /etc/hosts" } }],
|
||||
}),
|
||||
message("tool1", "tool", "", {
|
||||
toolResults: [{ toolCallId: "call-hosts", content: "127.0.0.1 localhost", isError: false }],
|
||||
}),
|
||||
message("u2", "user", "show resolv.conf"),
|
||||
message("a2", "assistant", "", {
|
||||
toolCalls: [{ id: "call-resolv", name: "terminal_exec", arguments: { command: "cat /etc/resolv.conf" } }],
|
||||
}),
|
||||
message("tool2", "tool", "", {
|
||||
toolResults: [{ toolCallId: "call-resolv", content: "nameserver 8.8.8.8", isError: false }],
|
||||
}),
|
||||
// Important user text so summarizeMessage picks these up via the
|
||||
// important-text branch; tool results themselves are always
|
||||
// summarized regardless of IMPORTANT_PATTERNS.
|
||||
message("u3", "user", "fallback plan"),
|
||||
];
|
||||
|
||||
// Filler to push the early tool results out of the 6-item raw window
|
||||
// and into the compact summary section (scanned = last 20).
|
||||
for (let index = 4; index <= 10; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Both older tool results must now carry provenance labels so a
|
||||
// follow-up can disambiguate them.
|
||||
assert.match(flat, /Tool result \[from terminal_exec.*?cat \/etc\/hosts/);
|
||||
assert.match(flat, /Tool result \[from terminal_exec.*?cat \/etc\/resolv\.conf/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages does not duplicate recent raw turns into the compact summary section", () => {
|
||||
// Regression: the scanned loop (last 20) overlaps with recentRaw (last 6).
|
||||
// Without skipping raw-window items, the same last-6 turns would be
|
||||
// summarized in the compact section AND appended verbatim in the raw
|
||||
// section — doubling the budget cost of important user turns / large
|
||||
// tool output and crowding out older durable context.
|
||||
//
|
||||
// Setup: enough filler upfront that u3 ends up OUTSIDE the raw window
|
||||
// (so it can be asserted absent from raw), then a distinctive "raw
|
||||
// only" marker that should appear only in the last-6 raw slice.
|
||||
const messages: ChatMessage[] = [];
|
||||
for (let index = 1; index <= 6; index += 1) {
|
||||
messages.push(
|
||||
message(`uf${index}`, "user", `filler user ${index}`),
|
||||
message(`af${index}`, "assistant", `filler assistant ${index}`),
|
||||
);
|
||||
}
|
||||
// These are the last 4 user/assistant messages — guaranteed to be in
|
||||
// the last-6 raw slice. The IMPORTANT markers below would ordinarily
|
||||
// also get summarized into the compact section, duplicating the cost.
|
||||
messages.push(
|
||||
message("u-rec1", "user", "commit now IMPORTANT_RAW_MARKER please"),
|
||||
message("a-rec1", "assistant", "", {
|
||||
toolCalls: [{ id: "c1", name: "git", arguments: { op: "commit" } }],
|
||||
}),
|
||||
message("tool-rec", "tool", "", {
|
||||
toolResults: [{ toolCallId: "c1", content: "committed abc123 RAW_TOOL_MARKER", isError: false }],
|
||||
}),
|
||||
message("u-rec2", "user", "now push"),
|
||||
);
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
const compact = result.find((m) => m.content.includes("[Compact prior Netcatty UI context]"));
|
||||
assert.ok(compact, "expected a compact context message");
|
||||
|
||||
// Both markers belong to messages inside the raw window — they must
|
||||
// not be summarized into compact (which would double-bill them).
|
||||
assert.doesNotMatch(compact.content, /IMPORTANT_RAW_MARKER/);
|
||||
assert.doesNotMatch(compact.content, /RAW_TOOL_MARKER/);
|
||||
|
||||
// Raw section still carries them verbatim.
|
||||
const raw = result.filter((m) => !m.content.includes("[Compact prior Netcatty UI context]"));
|
||||
const rawFlat = raw.map((m) => m.content).join("\n");
|
||||
assert.match(rawFlat, /IMPORTANT_RAW_MARKER/);
|
||||
assert.match(rawFlat, /RAW_TOOL_MARKER/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages resolves tool_call provenance correctly when tool ids are reused across turns", () => {
|
||||
// Regression: keying toolCallIndex by raw toolCall.id alone let a later
|
||||
// assistant tool_call with the same id overwrite the older one. An
|
||||
// older tool_result in the replay history would then be annotated
|
||||
// with the wrong command (e.g. a /etc/hosts result labeled as
|
||||
// /etc/resolv.conf). Now each tool_result is indexed by its own
|
||||
// messageId + toolCallId and resolved to the most recent preceding
|
||||
// call with that id.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "show hosts"),
|
||||
message("a1", "assistant", "", {
|
||||
toolCalls: [{ id: "call1", name: "terminal_exec", arguments: { command: "cat /etc/hosts" } }],
|
||||
}),
|
||||
message("tool-hosts", "tool", "", {
|
||||
toolResults: [{ toolCallId: "call1", content: "127.0.0.1 localhost HOSTS_BYTES", isError: false }],
|
||||
}),
|
||||
// A later assistant turn reuses the id "call1" for a different call.
|
||||
message("u2", "user", "show resolv"),
|
||||
message("a2", "assistant", "", {
|
||||
toolCalls: [{ id: "call1", name: "terminal_exec", arguments: { command: "cat /etc/resolv.conf" } }],
|
||||
}),
|
||||
message("tool-resolv", "tool", "", {
|
||||
toolResults: [{ toolCallId: "call1", content: "nameserver 8.8.8.8 RESOLV_BYTES", isError: false }],
|
||||
}),
|
||||
message("u3", "user", "ok"),
|
||||
];
|
||||
|
||||
// Pad so the first interaction lands in the compact summary pass.
|
||||
for (let index = 4; index <= 10; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Each tool_result must be annotated with ITS OWN preceding call's
|
||||
// args — not whichever assistant tool_call happened to win the
|
||||
// last-write on the shared id.
|
||||
//
|
||||
// Extract the two Tool-result lines and match each to its expected
|
||||
// args. Use non-greedy .*? — the args JSON can contain parentheses.
|
||||
const hostsMatch = flat.match(/Tool result \[from [^\]]*?cat \/etc\/hosts[^\]]*?\][^\n]*HOSTS_BYTES/);
|
||||
const resolvMatch = flat.match(/Tool result \[from [^\]]*?cat \/etc\/resolv\.conf[^\]]*?\][^\n]*RESOLV_BYTES/);
|
||||
|
||||
assert.ok(hostsMatch, "hosts result must still be labeled with cat /etc/hosts despite later id reuse");
|
||||
assert.ok(resolvMatch, "resolv result must be labeled with cat /etc/resolv.conf");
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves assistant-only compact context", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "ok"),
|
||||
message(
|
||||
"a1",
|
||||
"assistant",
|
||||
"Plan: 1. Move parser setup into a dedicated hook. 2. Keep storage schema unchanged. 3. Add a regression test.",
|
||||
),
|
||||
];
|
||||
|
||||
for (let index = 2; index <= 7; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", index % 2 === 0 ? "ok" : "continue"),
|
||||
message(`a${index}`, "assistant", "ack"),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Move parser setup into a dedicated hook\./);
|
||||
});
|
||||
438
components/ai/acpHistory.ts
Normal file
438
components/ai/acpHistory.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
|
||||
|
||||
type AcpHistoryMessage = { role: "user" | "assistant"; content: string };
|
||||
type RawHistoryMessage = AcpHistoryMessage & { sourceId: string };
|
||||
type DurableUserLine = {
|
||||
line: string;
|
||||
messageIndex: number;
|
||||
priority: number;
|
||||
};
|
||||
|
||||
const MAX_RECENT_RAW_MESSAGES = 6;
|
||||
const MAX_MESSAGES_TO_SCAN = 20;
|
||||
// Bound the scan by user turns, not raw message count: a tool-heavy ACP
|
||||
// chat can produce 5+ messages per logical turn (user + assistant +
|
||||
// several tool_results + follow-up assistant), so a plain
|
||||
// message-count cap ages out early constraints much sooner than intended.
|
||||
const MAX_DURABLE_SCAN_TURNS = 100;
|
||||
const MAX_COMPACT_CONTEXT_CHARS = 3000;
|
||||
const MAX_RAW_MESSAGE_CHARS = 2000;
|
||||
const MAX_TOOL_SUMMARY_CHARS = 500;
|
||||
const MAX_DURABLE_USER_CONTEXT_CHARS = 1400;
|
||||
const MAX_DURABLE_ASSISTANT_CONTEXT_CHARS = 900;
|
||||
const MAX_RECENT_SUMMARY_CONTEXT_CHARS = 1200;
|
||||
const MAX_DURABLE_USER_MESSAGE_CHARS = 280;
|
||||
const MAX_DURABLE_ASSISTANT_MESSAGE_CHARS = 360;
|
||||
const MAX_TOOL_CALL_LABEL_CHARS = 200;
|
||||
|
||||
type ToolCallInfo = { name: string; arguments: unknown };
|
||||
|
||||
const IMPORTANT_PATTERNS = [
|
||||
/不要|别|不能|不允许|必须|希望|只|最小|先|暂时|fallback|pwsh|powershell|cmd\.exe|windows|mcp|skills|cli|commit|\bpr\b|打包|内存|历史|压缩|慢/i,
|
||||
/error|failed|failure|exit code|exception|cannot|unable|timeout|crash|fallback|commit|pull request|PR #\d+/i,
|
||||
];
|
||||
const DURABLE_CONSTRAINT_PATTERNS = [
|
||||
/\bdo not\b|\bdon't\b|\bkeep\b|\bpreserve\b|\bavoid\b|\bonly\b|\bunchanged\b|\blocal only\b|\bwithout\b|\bleave\b/i,
|
||||
/不要|别|保留|保持|维持|不改|别改|不要改|仅限本地/i,
|
||||
];
|
||||
const TRIVIAL_USER_MESSAGE_PATTERNS = [
|
||||
/^(ok|okay|yes|no|thanks|thank you|continue|继续|好的|收到|行|嗯|好|继续处理|继续吧|开始吧)[.!? ]*$/i,
|
||||
];
|
||||
const TRIVIAL_ASSISTANT_MESSAGE_PATTERNS = [
|
||||
/^(ok|okay|understood|got it|working|proceeding|ready|ack(?: \d+)?|收到|明白|继续处理|准备实现|开始处理|处理中)[.!? ]*$/i,
|
||||
];
|
||||
|
||||
function truncateText(value: string, maxChars: number): string {
|
||||
if (value.length <= maxChars) return value;
|
||||
return `${value.slice(0, Math.max(0, maxChars - 24)).trimEnd()}\n[truncated]`;
|
||||
}
|
||||
|
||||
function normalizeWhitespace(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function isImportantText(value: string): boolean {
|
||||
return IMPORTANT_PATTERNS.some((pattern) => pattern.test(value));
|
||||
}
|
||||
|
||||
function isDurableConstraintText(value: string): boolean {
|
||||
return DURABLE_CONSTRAINT_PATTERNS.some((pattern) => pattern.test(value));
|
||||
}
|
||||
|
||||
function isTrivialUserMessage(value: string): boolean {
|
||||
const normalized = normalizeWhitespace(value);
|
||||
if (isImportantText(normalized) || isDurableConstraintText(normalized)) return false;
|
||||
// Don't blanket-drop short messages — short user turns are often
|
||||
// load-bearing constraints ("Use ssh2", "中文输出", "no logs", "more
|
||||
// verbose") that the IMPORTANT/DURABLE regexes can't realistically
|
||||
// enumerate. The trivial-phrase regex already catches actual filler
|
||||
// ("ok", "yes", "thanks", "继续").
|
||||
return TRIVIAL_USER_MESSAGE_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
|
||||
function getDurableUserPriority(value: string): number {
|
||||
const normalized = normalizeWhitespace(value);
|
||||
if (isImportantText(normalized) || isDurableConstraintText(normalized)) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function isSubstantiveAssistantMessage(value: string): boolean {
|
||||
const normalized = normalizeWhitespace(value);
|
||||
if (!normalized) return false;
|
||||
// Mirror the user-side loosening: don't blanket-drop short assistant
|
||||
// messages just because they're under 40 chars or don't match the small
|
||||
// English keyword list. Short but load-bearing decisions ("Use ssh2",
|
||||
// "rebase instead", "中文输出") aren't realistically enumerable and
|
||||
// they're the exact things a later "do what you suggested" references.
|
||||
// TRIVIAL_ASSISTANT_MESSAGE_PATTERNS still catches the actual filler
|
||||
// ("ok", "ack", "got it", "明白").
|
||||
return !TRIVIAL_ASSISTANT_MESSAGE_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
|
||||
function getDurableAssistantPriority(value: string): number {
|
||||
const normalized = normalizeWhitespace(value);
|
||||
if (isImportantText(normalized)) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function appendUniqueLine(
|
||||
target: string[],
|
||||
seen: Set<string>,
|
||||
line: string,
|
||||
maxSectionChars: number,
|
||||
sectionCharsRef: { value: number },
|
||||
): void {
|
||||
const normalized = normalizeWhitespace(line);
|
||||
if (!normalized || seen.has(normalized)) return;
|
||||
const nextChars = sectionCharsRef.value + normalized.length;
|
||||
if (nextChars > maxSectionChars) return;
|
||||
seen.add(normalized);
|
||||
target.push(normalized);
|
||||
sectionCharsRef.value = nextChars;
|
||||
}
|
||||
|
||||
function summarizeToolMessage(
|
||||
message: ChatMessage,
|
||||
toolCallIndex: Map<string, ToolCallInfo>,
|
||||
): string[] {
|
||||
if (!message.toolResults?.length) return [];
|
||||
return message.toolResults.map((result) => {
|
||||
const prefix = result.isError ? "Tool error" : "Tool result";
|
||||
const content = normalizeWhitespace(result.content || "");
|
||||
// Same provenance problem as the raw-window path: once a tool result
|
||||
// lands in the compact section (older than the 6-item raw window),
|
||||
// its paired assistant tool_call is almost always gone. Without the
|
||||
// call label, multiple older results collapse into indistinguishable
|
||||
// "Tool result (callN): ..." lines and follow-ups like "use the
|
||||
// resolv.conf output" can't be resolved. Inline the name+args here
|
||||
// the same way toRawHistoryMessage does.
|
||||
const callInfo = lookupToolCallInfo(toolCallIndex, message.id, result.toolCallId);
|
||||
const callLabel = callInfo
|
||||
? ` [from ${callInfo.name}(${truncateText(JSON.stringify(callInfo.arguments ?? {}), MAX_TOOL_CALL_LABEL_CHARS)})]`
|
||||
: "";
|
||||
return `${prefix}${callLabel} (${result.toolCallId}): ${truncateText(content, MAX_TOOL_SUMMARY_CHARS)}`;
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeMessage(
|
||||
message: ChatMessage,
|
||||
toolCallIndex: Map<string, ToolCallInfo>,
|
||||
): string[] {
|
||||
if (message.role === "system") return [];
|
||||
if (message.role === "tool") return summarizeToolMessage(message, toolCallIndex);
|
||||
|
||||
const lines: string[] = [];
|
||||
if (message.content && isImportantText(message.content)) {
|
||||
const label = message.role === "user" ? "User" : "Assistant";
|
||||
lines.push(`${label}: ${truncateText(normalizeWhitespace(message.content), MAX_TOOL_SUMMARY_CHARS)}`);
|
||||
}
|
||||
|
||||
if (message.role === "assistant" && message.toolCalls?.length) {
|
||||
for (const toolCall of message.toolCalls) {
|
||||
const args = JSON.stringify(toolCall.arguments ?? {});
|
||||
const summary = `Tool call: ${toolCall.name}(${truncateText(args, 220)})`;
|
||||
if (isImportantText(summary)) lines.push(summary);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function summarizeDurableUserMessage(message: ChatMessage): string | null {
|
||||
if (message.role !== "user" || !message.content) return null;
|
||||
if (isTrivialUserMessage(message.content)) return null;
|
||||
return `User request: ${truncateText(normalizeWhitespace(message.content), MAX_DURABLE_USER_MESSAGE_CHARS)}`;
|
||||
}
|
||||
|
||||
function summarizeDurableAssistantMessage(message: ChatMessage): string | null {
|
||||
if (message.role !== "assistant" || !message.content) return null;
|
||||
if (!isSubstantiveAssistantMessage(message.content)) return null;
|
||||
return `Assistant context: ${truncateText(normalizeWhitespace(message.content), MAX_DURABLE_ASSISTANT_MESSAGE_CHARS)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a per-tool-result provenance index. Keys are
|
||||
* `${toolResultMessageId}:${toolCallId}` rather than the bare toolCall.id
|
||||
* so that provider-reused ids (e.g. "call1" across unrelated turns) don't
|
||||
* cause later calls to overwrite older ones in the lookup — each
|
||||
* tool_result resolves to the most recent assistant tool_call that
|
||||
* preceded it with matching id, which preserves historical correctness
|
||||
* when rebuilding older compact summaries.
|
||||
*/
|
||||
function buildToolCallIndex(messages: ChatMessage[]): Map<string, ToolCallInfo> {
|
||||
const provenance = new Map<string, ToolCallInfo>();
|
||||
// Rolling map of the latest tool_call seen (by id) up to the current
|
||||
// point in the message stream.
|
||||
const latestByCallId = new Map<string, ToolCallInfo>();
|
||||
for (const message of messages) {
|
||||
if (message.role === "assistant" && message.toolCalls?.length) {
|
||||
for (const toolCall of message.toolCalls) {
|
||||
if (!toolCall.id) continue;
|
||||
latestByCallId.set(toolCall.id, { name: toolCall.name, arguments: toolCall.arguments });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (message.role === "tool" && message.toolResults?.length) {
|
||||
for (const result of message.toolResults) {
|
||||
const info = latestByCallId.get(result.toolCallId);
|
||||
if (info) {
|
||||
provenance.set(`${message.id}:${result.toolCallId}`, info);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return provenance;
|
||||
}
|
||||
|
||||
function lookupToolCallInfo(
|
||||
index: Map<string, ToolCallInfo>,
|
||||
toolMessageId: string,
|
||||
toolCallId: string,
|
||||
): ToolCallInfo | undefined {
|
||||
return index.get(`${toolMessageId}:${toolCallId}`);
|
||||
}
|
||||
|
||||
function toRawHistoryMessage(
|
||||
message: ChatMessage,
|
||||
toolCallIndex: Map<string, ToolCallInfo>,
|
||||
): RawHistoryMessage[] {
|
||||
if (message.role === "user") {
|
||||
return message.content
|
||||
? [{ sourceId: message.id, role: "user", content: truncateText(message.content, MAX_RAW_MESSAGE_CHARS) }]
|
||||
: [];
|
||||
}
|
||||
|
||||
if (message.role === "assistant") {
|
||||
const parts: string[] = [];
|
||||
if (message.content) parts.push(message.content);
|
||||
if (message.toolCalls?.length) {
|
||||
parts.push(...message.toolCalls.map((tc) => `Tool call: ${tc.name}(${JSON.stringify(tc.arguments ?? {})})`));
|
||||
}
|
||||
return parts.length
|
||||
? [{ sourceId: message.id, role: "assistant", content: truncateText(parts.join("\n\n"), MAX_RAW_MESSAGE_CHARS) }]
|
||||
: [];
|
||||
}
|
||||
|
||||
if (message.role === "tool" && message.toolResults?.length) {
|
||||
// Keep tool output in the recent raw window (up to MAX_RAW_MESSAGE_CHARS
|
||||
// per message, ~2000). Without this, follow-up turns after stale-session
|
||||
// recovery would only see the 500-char compact summary in
|
||||
// summarizeToolMessage, losing the actual bytes the user might reference
|
||||
// ("use that output", "what did cat show?"). ACP only supports user/
|
||||
// assistant roles, so we flatten to "assistant" — the tool results were
|
||||
// produced during the assistant's turn.
|
||||
//
|
||||
// Inline the originating tool_call's name+args. Tool calls and their
|
||||
// results live in separate messages; if the last six raw items start
|
||||
// in the middle of a tool interaction, the preceding assistant tool
|
||||
// call can be outside the window. Without the call label the result
|
||||
// is opaque bytes and "use that output" becomes ambiguous.
|
||||
const parts = message.toolResults.map((result) => {
|
||||
const prefix = result.isError ? "Tool error" : "Tool result";
|
||||
const callInfo = lookupToolCallInfo(toolCallIndex, message.id, result.toolCallId);
|
||||
const callLabel = callInfo
|
||||
? ` [from ${callInfo.name}(${truncateText(JSON.stringify(callInfo.arguments ?? {}), MAX_TOOL_CALL_LABEL_CHARS)})]`
|
||||
: "";
|
||||
return `${prefix}${callLabel} (${result.toolCallId}): ${result.content || ""}`;
|
||||
});
|
||||
return [{
|
||||
sourceId: message.id,
|
||||
role: "assistant",
|
||||
content: truncateText(parts.join("\n\n"), MAX_RAW_MESSAGE_CHARS),
|
||||
}];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildCompactContext(
|
||||
messages: ChatMessage[],
|
||||
durableScanStart: number,
|
||||
recentRawSourceIds: Set<string>,
|
||||
toolCallIndex: Map<string, ToolCallInfo>,
|
||||
): AcpHistoryMessage[] {
|
||||
const scanned = messages.slice(-MAX_MESSAGES_TO_SCAN);
|
||||
const summaryLines: string[] = [];
|
||||
const durableUserCandidates: DurableUserLine[] = [];
|
||||
const selectedDurableUserLines: DurableUserLine[] = [];
|
||||
const durableAssistantCandidates: DurableUserLine[] = [];
|
||||
const selectedDurableAssistantLines: DurableUserLine[] = [];
|
||||
const seen = new Set<string>();
|
||||
const durableChars = { value: 0 };
|
||||
const durableAssistantChars = { value: 0 };
|
||||
const summaryChars = { value: 0 };
|
||||
|
||||
for (let messageIndex = durableScanStart; messageIndex < messages.length; messageIndex += 1) {
|
||||
const message = messages[messageIndex];
|
||||
if (recentRawSourceIds.has(message.id)) continue;
|
||||
const durableUserLine = summarizeDurableUserMessage(message);
|
||||
if (durableUserLine) {
|
||||
durableUserCandidates.push({
|
||||
line: durableUserLine,
|
||||
messageIndex,
|
||||
priority: getDurableUserPriority(message.content || ""),
|
||||
});
|
||||
}
|
||||
const durableAssistantLine = summarizeDurableAssistantMessage(message);
|
||||
if (durableAssistantLine) {
|
||||
durableAssistantCandidates.push({
|
||||
line: durableAssistantLine,
|
||||
messageIndex,
|
||||
priority: getDurableAssistantPriority(message.content || ""),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
durableUserCandidates
|
||||
.sort((left, right) => right.priority - left.priority || right.messageIndex - left.messageIndex)
|
||||
.forEach((candidate) => {
|
||||
const normalized = normalizeWhitespace(candidate.line);
|
||||
if (!normalized || seen.has(normalized)) return;
|
||||
const nextChars = durableChars.value + normalized.length;
|
||||
if (nextChars > MAX_DURABLE_USER_CONTEXT_CHARS) return;
|
||||
seen.add(normalized);
|
||||
selectedDurableUserLines.push(candidate);
|
||||
durableChars.value = nextChars;
|
||||
});
|
||||
|
||||
durableAssistantCandidates
|
||||
.sort((left, right) => right.priority - left.priority || right.messageIndex - left.messageIndex)
|
||||
.forEach((candidate) => {
|
||||
const normalized = normalizeWhitespace(candidate.line);
|
||||
if (!normalized || seen.has(normalized)) return;
|
||||
const nextChars = durableAssistantChars.value + normalized.length;
|
||||
if (nextChars > MAX_DURABLE_ASSISTANT_CONTEXT_CHARS) return;
|
||||
seen.add(normalized);
|
||||
selectedDurableAssistantLines.push(candidate);
|
||||
durableAssistantChars.value = nextChars;
|
||||
});
|
||||
|
||||
const durableUserLines = selectedDurableUserLines
|
||||
.sort((left, right) => left.messageIndex - right.messageIndex)
|
||||
.map((candidate) => candidate.line);
|
||||
const durableAssistantLines = selectedDurableAssistantLines
|
||||
.sort((left, right) => left.messageIndex - right.messageIndex)
|
||||
.map((candidate) => candidate.line);
|
||||
|
||||
for (const line of [...durableUserLines, ...durableAssistantLines]) {
|
||||
seen.add(normalizeWhitespace(line));
|
||||
}
|
||||
|
||||
// Skip messages that are already appended verbatim in the raw window —
|
||||
// otherwise the same last-6 turns get summarized here AND re-sent as
|
||||
// raw, doubling the budget cost of important user turns / large tool
|
||||
// output and crowding out older durable context the replay is meant
|
||||
// to preserve. Matches the recentRawSourceIds skip in the durable pass.
|
||||
for (const message of scanned) {
|
||||
if (recentRawSourceIds.has(message.id)) continue;
|
||||
for (const line of summarizeMessage(message, toolCallIndex)) {
|
||||
appendUniqueLine(summaryLines, seen, line, MAX_RECENT_SUMMARY_CONTEXT_CHARS, summaryChars);
|
||||
}
|
||||
}
|
||||
|
||||
if (!durableUserLines.length && !durableAssistantLines.length && !summaryLines.length) return [];
|
||||
|
||||
const contentLines = [
|
||||
"[Compact prior Netcatty UI context]",
|
||||
"The external ACP agent may already have its own persisted session context. Use this compact Netcatty UI context only as fallback/background, and prefer the current user request when there is any conflict.",
|
||||
];
|
||||
if (durableUserLines.length) {
|
||||
contentLines.push("Earlier user requests that may still apply:");
|
||||
contentLines.push(...durableUserLines.map((line) => `- ${line}`));
|
||||
}
|
||||
if (durableAssistantLines.length) {
|
||||
contentLines.push("Earlier assistant context that may still matter:");
|
||||
contentLines.push(...durableAssistantLines.map((line) => `- ${line}`));
|
||||
}
|
||||
if (summaryLines.length) {
|
||||
contentLines.push("Recent noteworthy context:");
|
||||
contentLines.push(...summaryLines.map((line) => `- ${line}`));
|
||||
}
|
||||
|
||||
return [{
|
||||
role: "user",
|
||||
content: truncateText(
|
||||
contentLines.join("\n"),
|
||||
MAX_COMPACT_CONTEXT_CHARS,
|
||||
),
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the index of the first message to include in the scan window,
|
||||
* bounded by MAX_DURABLE_SCAN_TURNS user turns (not raw message count).
|
||||
* Walking backwards stops at the target turn count, so the cost is
|
||||
* bounded even when the transcript is huge.
|
||||
*/
|
||||
function computeDurableScanStart(messages: ChatMessage[]): number {
|
||||
let userTurns = 0;
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
if (messages[i].role === "user") {
|
||||
userTurns += 1;
|
||||
if (userTurns >= MAX_DURABLE_SCAN_TURNS) return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function buildAcpHistoryMessages(messages: ChatMessage[]): AcpHistoryMessage[] {
|
||||
// Compute the scan start once, then do all subsequent work over the
|
||||
// already-sliced tail. This avoids O(N) walks over the whole transcript
|
||||
// on every send — previously buildToolCallIndex + the flatMap-to-take-
|
||||
// last-6 raw history both traversed every message in the chat.
|
||||
const durableScanStart = computeDurableScanStart(messages);
|
||||
const scannedTail = messages.slice(durableScanStart);
|
||||
|
||||
// The tool-call provenance index only needs entries for tool_results
|
||||
// that might appear in our output. Building from the scanned tail is
|
||||
// correct for any tool_result whose paired assistant tool_call is
|
||||
// also within the window, which covers >99% of realistic patterns
|
||||
// (tool_calls and tool_results are always adjacent or near-adjacent).
|
||||
// If an ancient tool_call's result stays within the window while the
|
||||
// call itself is outside, that single result loses its [from X(Y)]
|
||||
// label — an acceptable trade for eliminating the per-send O(N) walk.
|
||||
const toolCallIndex = buildToolCallIndex(scannedTail);
|
||||
|
||||
const rawHistory = scannedTail
|
||||
.flatMap((message) => toRawHistoryMessage(message, toolCallIndex))
|
||||
.slice(-MAX_RECENT_RAW_MESSAGES);
|
||||
const compactContext = buildCompactContext(
|
||||
messages,
|
||||
durableScanStart,
|
||||
new Set(rawHistory.map((message) => message.sourceId)),
|
||||
toolCallIndex,
|
||||
);
|
||||
const recentRaw = rawHistory.map(({ role, content }) => ({ role, content }));
|
||||
|
||||
return [...compactContext, ...recentRaw];
|
||||
}
|
||||
|
||||
export function buildAcpHistoryMessagesForBridge(
|
||||
messages: ChatMessage[],
|
||||
_existingSessionId?: string | null,
|
||||
): AcpHistoryMessage[] | undefined {
|
||||
// The main process bridge only consumes this payload during stale-session
|
||||
// fallback replay, so keep it available even when a session id exists.
|
||||
const historyMessages = buildAcpHistoryMessages(messages);
|
||||
return historyMessages.length ? historyMessages : undefined;
|
||||
}
|
||||
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));
|
||||
}
|
||||
177
components/ai/aiPanelViewState.test.ts
Normal file
177
components/ai/aiPanelViewState.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type {
|
||||
AIPanelView,
|
||||
AISession,
|
||||
} from "../../infrastructure/ai/types.ts";
|
||||
import {
|
||||
applyDraftEntrySelection,
|
||||
applyHistorySessionSelection,
|
||||
normalizePanelView,
|
||||
resolveDisplayedPanelView,
|
||||
resolveDisplayedSession,
|
||||
} from "./aiPanelViewState.ts";
|
||||
|
||||
function createSession(id: string): AISession {
|
||||
return {
|
||||
id,
|
||||
title: `Session ${id}`,
|
||||
messages: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
agentId: "catty",
|
||||
scope: {
|
||||
type: "terminal",
|
||||
targetId: "terminal-1",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("draft view never falls back to most recent history", () => {
|
||||
const panelView: AIPanelView = { mode: "draft" };
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.equal(resolveDisplayedSession(panelView, sessions), null);
|
||||
});
|
||||
|
||||
test("session view returns the selected session", () => {
|
||||
const selectedSession = createSession("session-2");
|
||||
const panelView: AIPanelView = { mode: "session", sessionId: selectedSession.id };
|
||||
const sessions = [createSession("session-1"), selectedSession];
|
||||
|
||||
assert.equal(resolveDisplayedSession(panelView, sessions), selectedSession);
|
||||
});
|
||||
|
||||
test("missing session target resolves to null instead of history fallback", () => {
|
||||
const panelView: AIPanelView = { mode: "session", sessionId: "missing-session" };
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.equal(resolveDisplayedSession(panelView, sessions), null);
|
||||
});
|
||||
|
||||
test("missing session target normalizes back to draft view", () => {
|
||||
const panelView: AIPanelView = { mode: "session", sessionId: "missing-session" };
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(normalizePanelView(panelView, sessions), { mode: "draft" });
|
||||
});
|
||||
|
||||
test("missing explicit panel view resumes the most recent matching history when no draft exists", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, false, sessions, undefined, "workspace"),
|
||||
{ mode: "session", sessionId: "session-2" },
|
||||
);
|
||||
});
|
||||
|
||||
test("missing explicit panel view restores the persisted active session instead of the newest", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, false, sessions, "session-1", "workspace"),
|
||||
{ mode: "session", sessionId: "session-1" },
|
||||
);
|
||||
});
|
||||
|
||||
test("persisted session id that no longer exists in history falls back to newest", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, false, sessions, "deleted-session", "workspace"),
|
||||
{ mode: "session", sessionId: "session-2" },
|
||||
);
|
||||
});
|
||||
|
||||
test("null persisted session id falls back to newest history entry", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, false, sessions, null, "workspace"),
|
||||
{ mode: "session", sessionId: "session-2" },
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal scope without explicit view always starts from draft even when history exists", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, false, sessions, "session-1", "terminal"),
|
||||
{ mode: "draft" },
|
||||
);
|
||||
});
|
||||
|
||||
test("missing explicit panel view prefers the draft when unsent input exists", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, true, sessions),
|
||||
{ mode: "draft" },
|
||||
);
|
||||
});
|
||||
|
||||
test("draft state is used when there is no implicit history to resume", () => {
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, true, []),
|
||||
{ mode: "draft" },
|
||||
);
|
||||
});
|
||||
|
||||
test("history selection switches to the chosen session without touching draft state", () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
applyHistorySessionSelection("session-2", {
|
||||
showSessionView: (sessionId) => {
|
||||
calls.push(`view:${sessionId}`);
|
||||
},
|
||||
setActiveSessionId: (sessionId) => {
|
||||
calls.push(`active:${sessionId}`);
|
||||
},
|
||||
closeHistory: () => {
|
||||
calls.push("close-history");
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"view:session-2",
|
||||
"active:session-2",
|
||||
"close-history",
|
||||
]);
|
||||
});
|
||||
|
||||
test("draft entry ensures a draft exists before switching the panel to draft mode", () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
applyDraftEntrySelection({
|
||||
ensureDraft: () => {
|
||||
calls.push("ensure-draft");
|
||||
},
|
||||
showDraftView: () => {
|
||||
calls.push("show-draft");
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"ensure-draft",
|
||||
"show-draft",
|
||||
]);
|
||||
});
|
||||
|
||||
test("draft entry can preserve the current session view while ensuring draft state", () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
applyDraftEntrySelection({
|
||||
ensureDraft: () => {
|
||||
calls.push("ensure-draft");
|
||||
},
|
||||
showDraftView: () => {
|
||||
calls.push("show-draft");
|
||||
},
|
||||
preserveSessionView: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"ensure-draft",
|
||||
]);
|
||||
});
|
||||
94
components/ai/aiPanelViewState.ts
Normal file
94
components/ai/aiPanelViewState.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type {
|
||||
AIPanelView,
|
||||
AISession,
|
||||
} from "../../infrastructure/ai/types.ts";
|
||||
|
||||
const DEFAULT_PANEL_VIEW: AIPanelView = { mode: "draft" };
|
||||
|
||||
interface HistorySessionSelectionActions {
|
||||
showSessionView: (sessionId: string) => void;
|
||||
setActiveSessionId: (sessionId: string) => void;
|
||||
closeHistory?: () => void;
|
||||
}
|
||||
|
||||
interface DraftEntrySelectionActions {
|
||||
ensureDraft: () => void;
|
||||
showDraftView: () => void;
|
||||
preserveSessionView?: boolean;
|
||||
}
|
||||
|
||||
export function resolveDisplayedPanelView(
|
||||
panelView: AIPanelView | undefined,
|
||||
hasDraft: boolean,
|
||||
sessions: AISession[],
|
||||
persistedSessionId?: string | null,
|
||||
scopeType: "terminal" | "workspace" = "workspace",
|
||||
): AIPanelView {
|
||||
if (panelView) {
|
||||
return normalizePanelView(panelView, sessions);
|
||||
}
|
||||
|
||||
if (hasDraft) {
|
||||
return DEFAULT_PANEL_VIEW;
|
||||
}
|
||||
|
||||
// New terminal sessions should always start from a blank draft. History is
|
||||
// still available in the drawer, but never auto-resumed into a fresh SSH tab.
|
||||
if (scopeType === "terminal") {
|
||||
return DEFAULT_PANEL_VIEW;
|
||||
}
|
||||
|
||||
// Honour the persisted active-session selection (survives cold mount)
|
||||
// before falling back to the newest history entry.
|
||||
if (persistedSessionId && sessions.some((s) => s.id === persistedSessionId)) {
|
||||
return { mode: "session", sessionId: persistedSessionId };
|
||||
}
|
||||
|
||||
if (sessions[0]) {
|
||||
return { mode: "session", sessionId: sessions[0].id };
|
||||
}
|
||||
|
||||
return DEFAULT_PANEL_VIEW;
|
||||
}
|
||||
|
||||
export function normalizePanelView(
|
||||
panelView: AIPanelView,
|
||||
sessions: AISession[],
|
||||
): AIPanelView {
|
||||
if (panelView.mode !== "session") {
|
||||
return panelView;
|
||||
}
|
||||
|
||||
return sessions.some((session) => session.id === panelView.sessionId)
|
||||
? panelView
|
||||
: DEFAULT_PANEL_VIEW;
|
||||
}
|
||||
|
||||
export function resolveDisplayedSession(
|
||||
panelView: AIPanelView,
|
||||
sessions: AISession[],
|
||||
): AISession | null {
|
||||
if (panelView.mode !== "session") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sessions.find((session) => session.id === panelView.sessionId) ?? null;
|
||||
}
|
||||
|
||||
export function applyHistorySessionSelection(
|
||||
sessionId: string,
|
||||
actions: HistorySessionSelectionActions,
|
||||
): void {
|
||||
actions.showSessionView(sessionId);
|
||||
actions.setActiveSessionId(sessionId);
|
||||
actions.closeHistory?.();
|
||||
}
|
||||
|
||||
export function applyDraftEntrySelection(
|
||||
actions: DraftEntrySelectionActions,
|
||||
): void {
|
||||
actions.ensureDraft();
|
||||
if (!actions.preserveSessionView) {
|
||||
actions.showDraftView();
|
||||
}
|
||||
}
|
||||
18
components/ai/draftSendGate.test.ts
Normal file
18
components/ai/draftSendGate.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
endDraftSend,
|
||||
tryBeginDraftSend,
|
||||
} from "./draftSendGate.ts";
|
||||
|
||||
test("draft send gate allows only one in-flight draft send at a time", () => {
|
||||
const gate = { current: false };
|
||||
|
||||
assert.equal(tryBeginDraftSend(gate), true);
|
||||
assert.equal(tryBeginDraftSend(gate), false);
|
||||
|
||||
endDraftSend(gate);
|
||||
|
||||
assert.equal(tryBeginDraftSend(gate), true);
|
||||
});
|
||||
12
components/ai/draftSendGate.ts
Normal file
12
components/ai/draftSendGate.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function tryBeginDraftSend(gate: { current: boolean }): boolean {
|
||||
if (gate.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
gate.current = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function endDraftSend(gate: { current: boolean }): void {
|
||||
gate.current = false;
|
||||
}
|
||||
@@ -30,8 +30,19 @@ import { createCattyTools } from '../../../infrastructure/ai/sdk/tools';
|
||||
import type { NetcattyBridge, ExecutorContext } from '../../../infrastructure/ai/cattyAgent/executor';
|
||||
import { runExternalAgentTurn } from '../../../infrastructure/ai/externalAgentAdapter';
|
||||
import { runAcpAgentTurn } from '../../../infrastructure/ai/acpAgentAdapter';
|
||||
import { findManagedAgentProvider, matchesManagedAgentConfig } from '../../../infrastructure/ai/managedAgents';
|
||||
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)
|
||||
@@ -42,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. */
|
||||
@@ -57,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. */
|
||||
@@ -106,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). */
|
||||
@@ -120,8 +143,19 @@ 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;
|
||||
skills?: Array<{
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: 'ready' | 'warning';
|
||||
}>;
|
||||
}>;
|
||||
aiUserSkillsBuildContext?: (prompt: string, selectedSkillSlugs?: string[]) => Promise<{ ok: boolean; context?: string; error?: string }>;
|
||||
[key: string]: ((...args: unknown[]) => unknown) | undefined;
|
||||
}
|
||||
|
||||
@@ -143,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
|
||||
@@ -156,6 +207,45 @@ function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
const USER_SKILLS_CONTEXT_TIMEOUT_MS = 500;
|
||||
|
||||
interface UserSkillsContextResult {
|
||||
ok: boolean;
|
||||
context?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function buildExplicitUserSkillsFallback(selectedUserSkillSlugs?: string[]): string {
|
||||
if (!selectedUserSkillSlugs?.length) return '';
|
||||
return `The user explicitly selected these Netcatty user skills for this request: ${selectedUserSkillSlugs.map((slug) => `/${slug}`).join(', ')}. Honor those selections even if their expanded skill content is unavailable.`;
|
||||
}
|
||||
|
||||
async function resolveUserSkillsContext(
|
||||
bridge: PanelBridge | undefined,
|
||||
prompt: string,
|
||||
selectedUserSkillSlugs?: string[],
|
||||
): Promise<string> {
|
||||
if (!bridge?.aiUserSkillsBuildContext) {
|
||||
return buildExplicitUserSkillsFallback(selectedUserSkillSlugs);
|
||||
}
|
||||
|
||||
const buildContextPromise: Promise<UserSkillsContextResult> = bridge
|
||||
.aiUserSkillsBuildContext(prompt, selectedUserSkillSlugs)
|
||||
.catch(() => ({ ok: false, context: '' }));
|
||||
|
||||
const hasExplicitSelections = (selectedUserSkillSlugs?.length ?? 0) > 0;
|
||||
const result = hasExplicitSelections
|
||||
? await buildContextPromise
|
||||
: await Promise.race([
|
||||
buildContextPromise,
|
||||
new Promise<UserSkillsContextResult>((resolve) =>
|
||||
setTimeout(() => resolve({ ok: false, context: '' }), USER_SKILLS_CONTEXT_TIMEOUT_MS),
|
||||
),
|
||||
]);
|
||||
|
||||
return result.context || buildExplicitUserSkillsFallback(selectedUserSkillSlugs);
|
||||
}
|
||||
|
||||
const sharedStreamingSessionIds = new Set<string>();
|
||||
const sharedAbortControllers = new Map<string, AbortController>();
|
||||
const streamingSubscribers = new Set<() => void>();
|
||||
@@ -202,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: (
|
||||
@@ -240,6 +331,7 @@ export interface SendToCattyContext {
|
||||
webSearchConfig?: WebSearchConfig | null;
|
||||
getExecutorContext?: () => ExecutorContext;
|
||||
autoTitleSession: (sessionId: string, text: string) => void;
|
||||
selectedUserSkillSlugs?: string[];
|
||||
}
|
||||
|
||||
/** Context values needed by sendToExternalAgent that change frequently. */
|
||||
@@ -252,6 +344,7 @@ export interface SendToExternalContext {
|
||||
providers: ProviderConfig[];
|
||||
selectedAgentModel?: string;
|
||||
toolIntegrationMode: AIToolIntegrationMode;
|
||||
selectedUserSkillSlugs?: string[];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -304,14 +397,13 @@ export function useAIChatStreaming({
|
||||
err: unknown,
|
||||
) => {
|
||||
if (abortSignal.aborted) return;
|
||||
let errorStr: string;
|
||||
if (err instanceof Error) errorStr = err.message;
|
||||
else if (typeof err === 'object' && err !== null && 'message' in err) errorStr = String((err as { message: unknown }).message);
|
||||
else if (typeof err === 'string') errorStr = err;
|
||||
else { try { errorStr = JSON.stringify(err) ?? 'Unknown error'; } catch { errorStr = 'Unknown error'; } }
|
||||
// Log the full unsanitized error for debugging
|
||||
console.error('[AIChatSidePanel] Stream error (full):', errorStr);
|
||||
const errorInfo = classifyError(errorStr);
|
||||
console.error('[AIChatSidePanel] Stream error (full):', err);
|
||||
// Pass the raw error to classifyError so it can inspect structured
|
||||
// fields (statusCode, responseBody) from APICallError and friends;
|
||||
// string-coercing here would strip the metadata we need to detect
|
||||
// 413 / HTML-error-page / parse-failure scenarios.
|
||||
const errorInfo = classifyError(err);
|
||||
updateLastMessage(sessionId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
@@ -339,6 +431,7 @@ export function useAIChatStreaming({
|
||||
signal: AbortSignal,
|
||||
currentAssistantMsgId: string,
|
||||
advancedParams?: ProviderAdvancedParams,
|
||||
continuationContext?: CattyProviderContinuationContext,
|
||||
): Promise<void> => {
|
||||
const result = streamText({
|
||||
model,
|
||||
@@ -347,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 }),
|
||||
@@ -362,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) {
|
||||
@@ -405,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) {
|
||||
@@ -419,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;
|
||||
}
|
||||
@@ -453,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,
|
||||
@@ -463,6 +605,13 @@ export function useAIChatStreaming({
|
||||
executionStatus: 'running',
|
||||
statusText: undefined,
|
||||
}));
|
||||
if (providerOptions) {
|
||||
updateAssistantContinuation(messageId, {
|
||||
toolCallProviderOptionsById: {
|
||||
[typedChunk.toolCallId]: providerOptions,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'tool-result': {
|
||||
@@ -509,11 +658,10 @@ export function useAIChatStreaming({
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
errorInfo: classifyError(
|
||||
typedChunk.error instanceof Error ? typedChunk.error.message
|
||||
: typeof typedChunk.error === 'string' ? typedChunk.error
|
||||
: (() => { try { return JSON.stringify(typedChunk.error) ?? 'Unknown error'; } catch { return 'Unknown error'; } })(),
|
||||
),
|
||||
// Pass the raw error so classifyError can detect 413 / HTML /
|
||||
// schema-parse scenarios via structured fields (statusCode,
|
||||
// responseBody) instead of lossy string conversion.
|
||||
errorInfo: classifyError(typedChunk.error),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
break;
|
||||
@@ -543,6 +691,11 @@ export function useAIChatStreaming({
|
||||
context: SendToExternalContext,
|
||||
) => {
|
||||
const bridge = getNetcattyBridge();
|
||||
const userSkillsContext = await resolveUserSkillsContext(
|
||||
bridge,
|
||||
trimmed,
|
||||
context.selectedUserSkillSlugs,
|
||||
);
|
||||
|
||||
if (agentConfig.acpCommand && bridge) {
|
||||
const requestId = `acp_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
@@ -552,21 +705,6 @@ export function useAIChatStreaming({
|
||||
await bridge.aiMcpUpdateSessions(context.terminalSessions, sessionId);
|
||||
}
|
||||
|
||||
// Pass only the provider ID — the main process resolves and decrypts the API key itself,
|
||||
// avoiding plaintext key transit across the IPC boundary.
|
||||
// Resolve the correct provider based on agent type:
|
||||
// - Claude agent → anthropic provider (prefer over generic custom)
|
||||
// - Codex agent → openai provider (fallback to openai-compatible custom)
|
||||
const agentProviderId = (() => {
|
||||
if (matchesManagedAgentConfig(agentConfig, 'claude')) {
|
||||
return findManagedAgentProvider(context.providers, 'claude')?.id;
|
||||
}
|
||||
if (matchesManagedAgentConfig(agentConfig, 'codex')) {
|
||||
return findManagedAgentProvider(context.providers, 'codex')?.id;
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
// Mutable flag: set after tool-result, cleared when new assistant msg is created
|
||||
let needsNewAssistantMsg = false;
|
||||
const maybeCreateAssistantMsg = () => {
|
||||
@@ -648,19 +786,23 @@ export function useAIChatStreaming({
|
||||
onDone: () => {},
|
||||
},
|
||||
abortController.signal,
|
||||
agentProviderId,
|
||||
// Managed ACP agents (codex, claude) must resolve auth from their own
|
||||
// CLI config/login state, so we deliberately pass no providerId here.
|
||||
// See issue #705 for Codex; same reasoning for Claude.
|
||||
undefined,
|
||||
context.selectedAgentModel,
|
||||
context.existingSessionId,
|
||||
context.historyMessages,
|
||||
attachedImages.length > 0 ? attachedImages : undefined,
|
||||
context.toolIntegrationMode,
|
||||
context.defaultTargetSession,
|
||||
userSkillsContext,
|
||||
);
|
||||
} else {
|
||||
// Fallback: spawn as raw process
|
||||
await runExternalAgentTurn(
|
||||
agentConfig,
|
||||
trimmed,
|
||||
userSkillsContext ? `${userSkillsContext}\n\nUser request:\n${trimmed}` : trimmed,
|
||||
{
|
||||
onTextDelta: (text: string) => {
|
||||
updateLastMessage(sessionId, msg => ({ ...msg, content: msg.content + text }));
|
||||
@@ -694,6 +836,11 @@ export function useAIChatStreaming({
|
||||
attachments?: ChatMessageAttachment[],
|
||||
) => {
|
||||
const bridge = getNetcattyBridge();
|
||||
const userSkillsContext = await resolveUserSkillsContext(
|
||||
bridge,
|
||||
trimmed,
|
||||
context.selectedUserSkillSlugs,
|
||||
);
|
||||
const getExecutorContext = context.getExecutorContext ?? (() => ({
|
||||
sessions: context.terminalSessions,
|
||||
workspaceId: context.scopeType === 'workspace' ? context.scopeTargetId : undefined,
|
||||
@@ -721,6 +868,7 @@ export function useAIChatStreaming({
|
||||
})),
|
||||
permissionMode: context.globalPermissionMode,
|
||||
webSearchEnabled: isWebSearchReady(context.webSearchConfig),
|
||||
userSkillsContext,
|
||||
});
|
||||
|
||||
// Guard: activeProvider must exist for Catty agent path
|
||||
@@ -729,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
|
||||
@@ -769,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;
|
||||
@@ -788,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({
|
||||
@@ -824,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) {
|
||||
@@ -841,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');
|
||||
});
|
||||
15
components/ai/sessionHistoryLayout.test.ts
Normal file
15
components/ai/sessionHistoryLayout.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
SESSION_HISTORY_ROW_CLASSNAMES,
|
||||
} from "./sessionHistoryLayout.ts";
|
||||
|
||||
test("session history row keeps metadata pinned to the end while title truncates", () => {
|
||||
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.row, /\bgrid\b/);
|
||||
assert.ok(SESSION_HISTORY_ROW_CLASSNAMES.row.includes('grid-cols-[minmax(0,1fr)_auto]'));
|
||||
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.title, /\btruncate\b/);
|
||||
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.title, /\bmin-w-0\b/);
|
||||
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.meta, /\bjustify-self-end\b/);
|
||||
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.meta, /\bshrink-0\b/);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user