mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 22:40:09 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a54075ac1 | ||
|
|
1bb6eb9011 | ||
|
|
b73b73a7ee | ||
|
|
7febde4c4d | ||
|
|
3f00d9f3b0 | ||
|
|
ae7171431d | ||
|
|
03f47a51d0 | ||
|
|
26d113b8bf | ||
|
|
788d525a7d | ||
|
|
47623c7324 | ||
|
|
bafb52e283 | ||
|
|
1480693ebd | ||
|
|
3634841f9f | ||
|
|
6f9aa31650 | ||
|
|
9a2d258770 | ||
|
|
65fb48fd44 | ||
|
|
43fcfecc3a | ||
|
|
5e9fb24c12 | ||
|
|
54ed15b2f8 | ||
|
|
d1b8d97c0c | ||
|
|
857dc789fc | ||
|
|
b58a78021d | ||
|
|
25986252fd | ||
|
|
e3510f8a50 | ||
|
|
5d6b6b9769 | ||
|
|
158d5c144a | ||
|
|
783c45801b | ||
|
|
29c8b67958 | ||
|
|
aa2cb79c9e | ||
|
|
ec257558c1 | ||
|
|
efa3d768cd | ||
|
|
3569cb9a87 | ||
|
|
f6de13b4aa | ||
|
|
69d8eac51c | ||
|
|
8f970a43b8 | ||
|
|
c462e4f835 | ||
|
|
ebd5f0fc44 | ||
|
|
40eb7b99dd | ||
|
|
c23dc5dd88 | ||
|
|
a47df43707 | ||
|
|
55bc4559d9 | ||
|
|
f5f93cf8f5 | ||
|
|
48342ae059 | ||
|
|
0046291376 | ||
|
|
7cb722a5c5 | ||
|
|
e6b4256d5d | ||
|
|
219f98deb5 | ||
|
|
ca10c6ee92 | ||
|
|
08cc1607f9 | ||
|
|
e979f243e0 | ||
|
|
a1e77dab8e | ||
|
|
a03864da41 | ||
|
|
80ad3f73cc | ||
|
|
0550bd36d5 | ||
|
|
86390568fb | ||
|
|
c3a364b519 | ||
|
|
468f196a9d | ||
|
|
60017871fc | ||
|
|
e578ecab4c | ||
|
|
a5bbe2ac2f | ||
|
|
c11502c31f | ||
|
|
af6b7645b4 | ||
|
|
daaac5f3e4 | ||
|
|
eb3c49cdbe | ||
|
|
f382677b48 | ||
|
|
2fbc5f87c0 | ||
|
|
64e2140b44 | ||
|
|
54ceb2b85e | ||
|
|
4233aef01a | ||
|
|
f42c9dbfec | ||
|
|
c0abf0c3d5 | ||
|
|
f61690ab05 | ||
|
|
535b28a43e | ||
|
|
c096a64d7a | ||
|
|
4631b1e037 | ||
|
|
400a4829b9 | ||
|
|
8b104a6813 | ||
|
|
ffa2289db8 | ||
|
|
4b3bdfb033 | ||
|
|
a810ec4fd4 | ||
|
|
04981b2996 | ||
|
|
2cabefce99 | ||
|
|
8abbd36baf | ||
|
|
327ef62f90 | ||
|
|
1520040b6e | ||
|
|
094743a3f8 | ||
|
|
b17bc15c48 | ||
|
|
56216dbf47 | ||
|
|
83e65471b3 | ||
|
|
09cd907b85 | ||
|
|
33f37b5f53 | ||
|
|
ebcc82a2f4 | ||
|
|
32221dce0b | ||
|
|
fad16446cd | ||
|
|
a627ec56c9 | ||
|
|
1a426c4d07 | ||
|
|
1e93f91c62 | ||
|
|
76442e3240 | ||
|
|
01e7edc006 | ||
|
|
46c4be135f | ||
|
|
15d654f785 | ||
|
|
2b13673512 | ||
|
|
3ea5f295d2 | ||
|
|
897f7c901f | ||
|
|
3085bfdfce | ||
|
|
8d781af4f0 | ||
|
|
9f2669645b | ||
|
|
70b5ab2bd6 | ||
|
|
753d6b7aa4 | ||
|
|
a4b5204c74 | ||
|
|
64ff394847 | ||
|
|
ae52ef461e | ||
|
|
b6cdd602f1 | ||
|
|
4735e9ace0 | ||
|
|
ca0c59ff0c | ||
|
|
2f3ba0e581 | ||
|
|
cf34c14f3a | ||
|
|
f696163674 | ||
|
|
b18c3e8c4f | ||
|
|
e658d008dc | ||
|
|
a82b006428 | ||
|
|
78554e6d37 | ||
|
|
0c7f69dc8f | ||
|
|
45a38237ad | ||
|
|
74eeecb190 | ||
|
|
584ccf9482 | ||
|
|
21350d64db | ||
|
|
04810ae15e | ||
|
|
729958ea90 | ||
|
|
c89e3d9574 | ||
|
|
fa445a8466 | ||
|
|
b2d4883006 | ||
|
|
9848107592 | ||
|
|
9a45005406 | ||
|
|
e06b47625f | ||
|
|
609c664eed | ||
|
|
37a136aeb5 | ||
|
|
1ce4797bdb | ||
|
|
d8aacbe4e9 | ||
|
|
9c66e7f309 | ||
|
|
8020d45808 | ||
|
|
48157f56a8 | ||
|
|
dbe3e693e7 | ||
|
|
fc6a686ad9 | ||
|
|
da1e9547ec | ||
|
|
f615fc4b89 | ||
|
|
5c0e5f5538 | ||
|
|
c0d90178ab | ||
|
|
f291ba5ebf | ||
|
|
5e16c72c03 | ||
|
|
c02848bb08 | ||
|
|
f67a29ecd7 | ||
|
|
793f86129c | ||
|
|
9811e1ad24 | ||
|
|
1840fa53f8 | ||
|
|
416ea21424 | ||
|
|
82af11f26e | ||
|
|
b04e8f58bf | ||
|
|
54e4d8dfbb | ||
|
|
45d7046b16 | ||
|
|
ae8babcf5a | ||
|
|
9b2b8be2b8 | ||
|
|
b6dec0dba3 | ||
|
|
5ba34b647e | ||
|
|
73bc62948f | ||
|
|
524db08a8e | ||
|
|
7d8f46d00d | ||
|
|
387866cb71 | ||
|
|
4d72c80189 | ||
|
|
317c05f9f4 | ||
|
|
a6141ebb33 | ||
|
|
8ccf46c8f8 | ||
|
|
07c7a0fae5 | ||
|
|
a90a681cf0 | ||
|
|
26a923c498 | ||
|
|
b688fc815b | ||
|
|
93421abdc4 | ||
|
|
a5e13009f9 | ||
|
|
626b5a2c04 | ||
|
|
26a550c596 | ||
|
|
6dd816e346 | ||
|
|
3ff093f576 | ||
|
|
f18be6e8a9 | ||
|
|
882b37c6e6 | ||
|
|
52f92330ad | ||
|
|
1428e9754c | ||
|
|
c41d11989b | ||
|
|
17b33c192e | ||
|
|
9837f4fed9 | ||
|
|
30104338d2 | ||
|
|
86d4897a37 | ||
|
|
4180a770e5 | ||
|
|
ef70cad34d | ||
|
|
2b7f4b47d7 | ||
|
|
885ab7b379 | ||
|
|
90e7149517 | ||
|
|
fd2d34f780 | ||
|
|
317d5647e0 | ||
|
|
f1d2585279 | ||
|
|
a558c16485 | ||
|
|
b52d474819 | ||
|
|
18f00ec1af | ||
|
|
b6f548283b | ||
|
|
b9e8a45509 | ||
|
|
3b3145358d | ||
|
|
45c67d0a3c | ||
|
|
ea74a4cbf8 | ||
|
|
f4fb0a5717 | ||
|
|
606478435e | ||
|
|
181c1d637e | ||
|
|
d00a966984 | ||
|
|
cd4eb06cb1 | ||
|
|
d60645365a | ||
|
|
bfe8baf39a | ||
|
|
749021ea85 | ||
|
|
aba07c3e6f | ||
|
|
4e2bd5f783 | ||
|
|
b18f2bc447 | ||
|
|
69c735a908 | ||
|
|
bb74a84eff | ||
|
|
49604ba432 | ||
|
|
72c133cc57 | ||
|
|
cef4c2ee30 | ||
|
|
816e494b27 | ||
|
|
33df2cad52 | ||
|
|
e27a626b8d | ||
|
|
75977a7e15 | ||
|
|
1d91c8fbf4 | ||
|
|
1eb877e2aa | ||
|
|
205f266441 | ||
|
|
6469336ba9 | ||
|
|
c9b9a4f5d1 | ||
|
|
f08ca1c08a | ||
|
|
c6baebd514 | ||
|
|
34c3c5beca | ||
|
|
34eb3cd8fc | ||
|
|
3d939480c9 | ||
|
|
6400ce1e75 | ||
|
|
6cda26fad7 | ||
|
|
3deeb74ca7 | ||
|
|
476e441057 | ||
|
|
965c1c581a | ||
|
|
99e1a8d6d8 | ||
|
|
0558dd6f6d |
No files matched your search
@@ -1,234 +0,0 @@
|
||||
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 runtime bundle.
|
||||
# Do not compile this in CI: rebuilding the upstream Cygwin client here
|
||||
# previously regressed Windows startup. Ship the SHA256-pinned Netcatty
|
||||
# bundle with DLLs + terminfo, plus the FluentTerminal standalone fallback,
|
||||
# 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 bundle (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 Netcatty runtime bundle, with the FluentTerminal standalone `mosh-client.exe` kept as a fallback asset.'
|
||||
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
|
||||
+182
-17
@@ -354,11 +354,17 @@ jobs:
|
||||
release/*.blockmap
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Linux x64 — pin to ubuntu-22.04 for broader glibc compatibility.
|
||||
# ubuntu-latest (24.04) links native modules against glibc 2.39 which
|
||||
# can cause dlopen failures on some distros. 22.04 uses glibc 2.35,
|
||||
# compatible with most current Linux distributions including Arch.
|
||||
# See #264.
|
||||
# Dedicated job for Linux x64 — builds inside AlmaLinux 8 (GLIBC 2.28)
|
||||
# so native modules (node-pty, serialport) stay compatible with RHEL 8 /
|
||||
# CentOS 8 / UOS / Deepin. Ubuntu 22.04 links against GLIBC 2.32–2.35,
|
||||
# which crashes on those older hosts (see #2062).
|
||||
#
|
||||
# We previously used debian:buster for the same glibc floor, but Buster's
|
||||
# g++ 8 cannot compile Electron 42 natives (`-std=gnu++20`). AlmaLinux 8
|
||||
# keeps glibc 2.28 while gcc-toolset-13 provides a real C++20 toolchain.
|
||||
# Link natives with -static-libstdc++ so the result does not require a
|
||||
# newer system libstdc++ on RHEL 8 derivatives.
|
||||
# Key: GLIBC < 2.34 also avoids the libpthread-merge symbol requirement.
|
||||
build-linux-x64:
|
||||
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }}
|
||||
needs: [dedupe, resolve-mosh, resolve-et]
|
||||
@@ -366,12 +372,16 @@ jobs:
|
||||
always()
|
||||
&& needs.dedupe.result == 'success'
|
||||
&& needs.dedupe.outputs.skip_heavy_ci != 'true'
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: almalinux:8
|
||||
env:
|
||||
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
|
||||
ET_BIN_RELEASE: ${{ needs.resolve-et.outputs.et_bin_release }}
|
||||
npm_config_arch: x64
|
||||
npm_config_target_arch: x64
|
||||
# Keep rebuilt natives portable to stock RHEL 8 libstdc++.
|
||||
LDFLAGS: "-static-libstdc++ -static-libgcc"
|
||||
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 }}
|
||||
@@ -399,20 +409,159 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install build dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
dnf -y install epel-release
|
||||
dnf -y install \
|
||||
curl ca-certificates \
|
||||
gcc-toolset-13-gcc gcc-toolset-13-gcc-c++ make \
|
||||
python3.11 python3.11-pip \
|
||||
git file rpm-build binutils cpio elfutils \
|
||||
libarchive gzip bzip2 xz zstd tar which findutils \
|
||||
dpkg fakeroot \
|
||||
fuse fuse-libs gtk3 nss libXScrnSaver libXtst alsa-lib \
|
||||
at-spi2-atk libdrm mesa-libgbm libX11 libX11-xcb libxcb \
|
||||
mesa-libGL pango cairo
|
||||
# gcc-toolset-13: C++20 for Electron 42 natives; still links glibc 2.28.
|
||||
GCC_TOOLSET_ROOT="/opt/rh/gcc-toolset-13/root"
|
||||
echo "PATH=${GCC_TOOLSET_ROOT}/usr/bin:${PATH}" >> "${GITHUB_ENV}"
|
||||
echo "CC=${GCC_TOOLSET_ROOT}/usr/bin/gcc" >> "${GITHUB_ENV}"
|
||||
echo "CXX=${GCC_TOOLSET_ROOT}/usr/bin/g++" >> "${GITHUB_ENV}"
|
||||
echo "DEVTOOLSET_ROOTPATH=${GCC_TOOLSET_ROOT}" >> "${GITHUB_ENV}"
|
||||
export PATH="${GCC_TOOLSET_ROOT}/usr/bin:${PATH}"
|
||||
gcc --version
|
||||
g++ --version
|
||||
# AlmaLinux 8's libarchive package is often library-only in minimal
|
||||
# images (no /usr/bin/bsdtar). electron-builder pacman packaging needs
|
||||
# bsdtar; pull a Debian tools package that runs on glibc 2.28.
|
||||
# Prefer Buster (published as +deb10u1 on archive.debian.org).
|
||||
if [[ ! -x /usr/bin/bsdtar ]]; then
|
||||
echo "bsdtar missing; installing CLI from Debian libarchive-tools"
|
||||
mkdir -p /tmp/bsdtar-deb
|
||||
BSDTAR_DEB_URLS=(
|
||||
"http://archive.debian.org/debian/pool/main/liba/libarchive/libarchive-tools_3.3.3-4+deb10u1_amd64.deb"
|
||||
"http://archive.debian.org/debian/pool/main/liba/libarchive/libarchive-tools_3.2.2-2+deb9u2_amd64.deb"
|
||||
)
|
||||
BSDTAR_DEB_OK=0
|
||||
for url in "${BSDTAR_DEB_URLS[@]}"; do
|
||||
echo "trying ${url}"
|
||||
if curl -fsSL -o /tmp/libarchive-tools.deb "${url}"; then
|
||||
BSDTAR_DEB_OK=1
|
||||
break
|
||||
fi
|
||||
echo "download failed for ${url}"
|
||||
done
|
||||
if [[ "${BSDTAR_DEB_OK}" -ne 1 ]]; then
|
||||
echo "::error::Could not download Debian libarchive-tools for bsdtar"
|
||||
exit 1
|
||||
fi
|
||||
dpkg-deb -x /tmp/libarchive-tools.deb /tmp/bsdtar-deb
|
||||
install -m 0755 /tmp/bsdtar-deb/usr/bin/bsdtar /usr/local/bin/bsdtar-bin
|
||||
# Prefer system libarchive.so if present; fall back to the deb's.
|
||||
if [[ ! -e /usr/lib64/libarchive.so.13 && -d /tmp/bsdtar-deb/usr/lib/x86_64-linux-gnu ]]; then
|
||||
mkdir -p /usr/local/lib/bsdtar-libs
|
||||
cp -a /tmp/bsdtar-deb/usr/lib/x86_64-linux-gnu/libarchive.so* /usr/local/lib/bsdtar-libs/ 2>/dev/null || true
|
||||
fi
|
||||
printf '%s\n' \
|
||||
'#!/bin/bash' \
|
||||
'unset LD_LIBRARY_PATH' \
|
||||
'if [[ -d /usr/local/lib/bsdtar-libs ]]; then' \
|
||||
' export LD_LIBRARY_PATH=/usr/local/lib/bsdtar-libs' \
|
||||
'fi' \
|
||||
'exec /usr/local/bin/bsdtar-bin "$@"' \
|
||||
> /usr/local/bin/bsdtar
|
||||
chmod +x /usr/local/bin/bsdtar
|
||||
fi
|
||||
# Some fpm paths still look for xzmt even when payload compression
|
||||
# is gzip; provide a multi-thread xz shim.
|
||||
printf '%s\n' '#!/bin/sh' 'exec /usr/bin/xz -T0 "$@"' > /usr/local/bin/xzmt
|
||||
chmod +x /usr/local/bin/xzmt
|
||||
# electron-builder's portable fpm injects its own LD_LIBRARY_PATH.
|
||||
# System tools (rpmbuild, bsdtar, ...) then exit 127 under that path.
|
||||
# Wrap every packaging helper so child processes see a clean env.
|
||||
# Use printf (not a bare heredoc): unindented heredoc body breaks GHA YAML.
|
||||
wrap_clean_env() {
|
||||
local name="$1"
|
||||
local real=""
|
||||
if [[ -x "/usr/bin/${name}" ]]; then
|
||||
real="/usr/bin/${name}"
|
||||
elif [[ -x "/usr/local/bin/${name}-bin" ]]; then
|
||||
real="/usr/local/bin/${name}-bin"
|
||||
elif command -v "${name}" >/dev/null 2>&1; then
|
||||
real="$(command -v "${name}")"
|
||||
else
|
||||
return 0
|
||||
fi
|
||||
# Don't wrap our own wrapper recursively.
|
||||
if [[ "${real}" == /usr/local/bin/* && "${name}" != "bsdtar" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ "${name}" == "bsdtar" && -x /usr/local/bin/bsdtar-bin ]]; then
|
||||
real="/usr/local/bin/bsdtar-bin"
|
||||
fi
|
||||
printf '%s\n' \
|
||||
'#!/bin/bash' \
|
||||
'unset LD_LIBRARY_PATH' \
|
||||
"exec ${real} \"\$@\"" \
|
||||
> "/usr/local/bin/${name}"
|
||||
chmod +x "/usr/local/bin/${name}"
|
||||
}
|
||||
for cmd in rpmbuild bsdtar bsdcpio tar gzip xz strip cpio; do
|
||||
wrap_clean_env "${cmd}"
|
||||
done
|
||||
# Re-apply bsdtar wrapper with optional lib path for the deb-extracted binary.
|
||||
if [[ -x /usr/local/bin/bsdtar-bin ]]; then
|
||||
printf '%s\n' \
|
||||
'#!/bin/bash' \
|
||||
'unset LD_LIBRARY_PATH' \
|
||||
'if [[ -d /usr/local/lib/bsdtar-libs ]]; then' \
|
||||
' export LD_LIBRARY_PATH=/usr/local/lib/bsdtar-libs' \
|
||||
'fi' \
|
||||
'exec /usr/local/bin/bsdtar-bin "$@"' \
|
||||
> /usr/local/bin/bsdtar
|
||||
chmod +x /usr/local/bin/bsdtar
|
||||
fi
|
||||
# Fail early with diagnostics if packaging tools are missing.
|
||||
for tool in rpmbuild bsdtar strip gzip xz xzmt; do
|
||||
if ! command -v "${tool}" >/dev/null 2>&1; then
|
||||
echo "::error::Required packaging tool not found: ${tool}"
|
||||
ls -la /usr/bin/bsd* /usr/local/bin/bsd* 2>/dev/null || true
|
||||
rpm -ql libarchive 2>/dev/null | head -n 40 || true
|
||||
exit 1
|
||||
fi
|
||||
echo "found ${tool}: $(command -v "${tool}")"
|
||||
done
|
||||
rpmbuild --version
|
||||
bsdtar --version | head -n 1
|
||||
# Official Node 22 linux-x64 tarball (same major as other package jobs).
|
||||
NODE_VERSION="$(curl -fsSL https://nodejs.org/dist/latest-v22.x/SHASUMS256.txt \
|
||||
| awk '/node-v[0-9.]+-linux-x64\.tar\.xz$/{print $2; exit}' \
|
||||
| sed -E 's/^node-(v[0-9.]+)-linux-x64\.tar\.xz$/\1/')"
|
||||
if [[ -z "${NODE_VERSION}" ]]; then
|
||||
echo "::error::Could not resolve latest Node 22 linux-x64 tarball version."
|
||||
exit 1
|
||||
fi
|
||||
echo "Installing Node ${NODE_VERSION} from nodejs.org"
|
||||
curl -fsSL "https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.xz" \
|
||||
| tar -xJ -C /usr/local --strip-components=1
|
||||
node -v
|
||||
npm -v
|
||||
# node-gyp 12 needs Python >=3.8; AlmaLinux 8 default is older.
|
||||
alternatives --set python3 /usr/bin/python3.11 || ln -sfn /usr/bin/python3.11 /usr/local/bin/python3
|
||||
python3 --version
|
||||
echo "PYTHON=/usr/bin/python3.11" >> "${GITHUB_ENV}"
|
||||
echo "npm_config_python=/usr/bin/python3.11" >> "${GITHUB_ENV}"
|
||||
|
||||
- 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: Install Linux packaging dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y libarchive-tools rpm
|
||||
run: |
|
||||
python3 --version
|
||||
g++ --version
|
||||
npm ci
|
||||
|
||||
- name: Set version
|
||||
shell: bash
|
||||
@@ -445,7 +594,23 @@ jobs:
|
||||
env:
|
||||
npm_config_arch: x64
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
run: npm run pack:linux-x64
|
||||
# Natives are already rebuilt. Prefer system packaging tools so
|
||||
# rpmbuild brp scripts do not pick up a toolset-only binary, and so
|
||||
# /usr/local/bin/rpmbuild (clean-env wrapper) wins over /usr/bin.
|
||||
PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
run: |
|
||||
set -euo pipefail
|
||||
command -v node
|
||||
command -v rpmbuild
|
||||
command -v bsdtar
|
||||
command -v gzip
|
||||
command -v xzmt
|
||||
for cmd in rpmbuild bsdtar; do
|
||||
if ! head -n 2 "$(command -v "${cmd}")" | grep -q 'unset LD_LIBRARY_PATH'; then
|
||||
echo "::warning::${cmd} on PATH is not the clean-env wrapper; packaging may fail with exit 127"
|
||||
fi
|
||||
done
|
||||
npm run pack:linux-x64
|
||||
|
||||
- name: Verify packaged node-pty Linux runtime
|
||||
run: bash scripts/ensure-node-pty-linux.sh verify x64
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: spam-comments
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
delete-spam:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout filter rules
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Delete suspected malware spam
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const path = require('node:path');
|
||||
const { detectSpamComment } = require(
|
||||
path.join(process.env.GITHUB_WORKSPACE, 'scripts/spam-comment-filter.cjs')
|
||||
);
|
||||
|
||||
const comment = context.payload.comment;
|
||||
const result = detectSpamComment({
|
||||
body: comment.body || '',
|
||||
authorAssociation: comment.author_association,
|
||||
userType: comment.user && comment.user.type,
|
||||
});
|
||||
|
||||
core.info(JSON.stringify(result, null, 2));
|
||||
|
||||
if (!result.spam) {
|
||||
core.info('Comment did not match the zip/dangerous-file spam filter.');
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: comment.id,
|
||||
});
|
||||
|
||||
core.notice(
|
||||
`Deleted comment with zip/dangerous attachment by @${comment.user.login} on #${context.payload.issue.number}: ${result.reasons.join('; ')}`
|
||||
);
|
||||
+6
-8
@@ -42,6 +42,9 @@ coverage
|
||||
# Codex
|
||||
/.codex/
|
||||
|
||||
# OMO / local agent run-continuation state
|
||||
/.omo/
|
||||
|
||||
# Qoder
|
||||
.qoder
|
||||
|
||||
@@ -74,16 +77,11 @@ 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.
|
||||
# Bundled MoshCatty mosh-client binaries fetched at pack time by
|
||||
# scripts/fetch-mosh-binaries.cjs. resources/mosh/README.md is committed;
|
||||
# pure single-binary artifacts from binaricat/MoshCatty are never committed.
|
||||
/resources/mosh/*/mosh-client
|
||||
/resources/mosh/*/mosh-client.exe
|
||||
/resources/mosh/*/mosh-client-*-dlls/
|
||||
/resources/mosh/*/*.dll
|
||||
/resources/mosh/*/terminfo/
|
||||
|
||||
# Bundled EternalTerminal `et` client binaries fetched at pack time by
|
||||
# scripts/fetch-et-binaries.cjs. resources/et/README.md is committed; the
|
||||
|
||||
@@ -25,7 +25,10 @@ import { matchesKeyBinding } from './domain/models';
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from './domain/groupConfig';
|
||||
import { upsertKnownHost } from './domain/knownHosts';
|
||||
import { materializeHostProxyProfile } from './domain/proxyProfiles';
|
||||
import { buildSshDeepLinkConnectionHost, buildSshDeepLinkHostDraft, findSshDeepLinkHost, parseSshDeepLink } from './domain/sshDeepLink';
|
||||
import { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSshDeepLinkEphemeralHostFromSaved, buildSshDeepLinkHostDraft, findSshDeepLinkHost, parseSshDeepLink } from './domain/sshDeepLink';
|
||||
import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from './domain/telnetDeepLink';
|
||||
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from './domain/jmsDeepLink';
|
||||
import { applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from './domain/ephemeralHosts';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
|
||||
import {
|
||||
@@ -50,6 +53,8 @@ import {
|
||||
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
|
||||
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
|
||||
import { localStorageAdapter } from './infrastructure/persistence/localStorageAdapter';
|
||||
import { syncExternalMcpStartupState } from './application/state/useExternalMcpToggleState';
|
||||
import { useExternalMcpSessionSync } from './application/state/useExternalMcpSessionSync';
|
||||
import {
|
||||
STORAGE_KEY_DEBUG_HOTKEYS,
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
@@ -57,6 +62,7 @@ import {
|
||||
import { getEffectiveKnownHosts } from './infrastructure/syncHelpers';
|
||||
import { ToastProvider, toast } from './components/ui/toast';
|
||||
import { TooltipProvider } from './components/ui/tooltip';
|
||||
import { ConfirmDialog } from './components/ui/confirm-dialog';
|
||||
import { PortForwardHostKeyDialog } from './components/port-forwarding';
|
||||
import { VaultSection } from './components/VaultView';
|
||||
import { KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
|
||||
@@ -68,6 +74,9 @@ import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalS
|
||||
import { resolveSnippetCommand } from './components/SnippetExecutionProvider';
|
||||
import { isScriptSnippet } from './domain/snippetScript.ts';
|
||||
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
|
||||
import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost';
|
||||
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
|
||||
import { setupMcpApprovalBridge } from './infrastructure/ai/shared/approvalGate';
|
||||
import { AppActiveTabChrome } from './application/app/AppActiveTabChrome';
|
||||
import { AppView } from './application/app/AppView';
|
||||
import { useAppStartupEffects } from './application/app/useAppStartupEffects';
|
||||
@@ -109,10 +118,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Navigation state for VaultView sections
|
||||
const [navigateToSection, setNavigateToSection] = useState<VaultSection | null>(null);
|
||||
const [deepLinkHostDraft, setDeepLinkHostDraft] = useState<Host | null>(null);
|
||||
const [ephemeralHosts, setEphemeralHosts] = useState<Host[]>([]);
|
||||
// Keyboard-interactive authentication queue (2FA/MFA) - queue-based to handle multiple concurrent sessions
|
||||
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
|
||||
// Passphrase request queue for encrypted SSH keys
|
||||
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
|
||||
const [deleteHostConfirm, setDeleteHostConfirm] = useState<{ hostId: string; name: string } | null>(null);
|
||||
const [pendingNewWindowSession, setPendingNewWindowSession] = useState<OpenSessionInNewWindowPayload | null>(null);
|
||||
const [pendingTrayPanelConnectHostIds, setPendingTrayPanelConnectHostIds] = useState<string[]>([]);
|
||||
const isPeerSessionWindow = typeof window !== 'undefined' && window.location.hash.startsWith('#/session-window');
|
||||
@@ -172,6 +183,16 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
}, [workspaceFocusStyle]);
|
||||
|
||||
// External MCP: only persistent+enabled restores at app startup.
|
||||
// Keep this on the App mount path (not Settings) so temporary mode is not
|
||||
// accidentally disabled when the AI settings page remounts.
|
||||
// Skip peer session windows — they also mount App and must not clear a
|
||||
// temporary-mode runtime that the main window already started.
|
||||
useEffect(() => {
|
||||
if (isPeerSessionWindow) return;
|
||||
syncExternalMcpStartupState(netcattyBridge.get());
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
const {
|
||||
isInitialized: isVaultInitialized,
|
||||
hosts,
|
||||
@@ -338,6 +359,14 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
() => new Map(hosts.map((host) => [host.id, host])),
|
||||
[hosts],
|
||||
);
|
||||
const terminalHosts = useMemo(
|
||||
() => (ephemeralHosts.length > 0 ? [...hosts, ...ephemeralHosts] : hosts),
|
||||
[hosts, ephemeralHosts],
|
||||
);
|
||||
const ephemeralHostIds = useMemo(
|
||||
() => new Set(ephemeralHosts.map((host) => host.id)),
|
||||
[ephemeralHosts],
|
||||
);
|
||||
const sessionById = useMemo(
|
||||
() => new Map(sessions.map((session) => [session.id, session])),
|
||||
[sessions],
|
||||
@@ -375,6 +404,13 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Get port forwarding rules and import function
|
||||
const { rules: portForwardingRules, importRules: importPortForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
|
||||
|
||||
// App-level External MCP session sync (before TerminalLayer lazy-mount).
|
||||
useExternalMcpSessionSync({
|
||||
sessions,
|
||||
hosts: terminalHosts,
|
||||
portForwardingRules,
|
||||
});
|
||||
|
||||
const portForwardingRulesForSync = useMemo(
|
||||
() =>
|
||||
portForwardingRules.map((rule) => ({
|
||||
@@ -904,10 +940,14 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
const handleDeleteHost = useCallback((hostId: string) => {
|
||||
const target = hosts.find(h => h.id === hostId);
|
||||
const confirmed = window.confirm(t('confirm.deleteHost', { name: target?.label || hostId }));
|
||||
if (!confirmed) return;
|
||||
updateHosts(hosts.filter(h => h.id !== hostId));
|
||||
}, [hosts, updateHosts, t]);
|
||||
setDeleteHostConfirm({ hostId, name: target?.label || hostId });
|
||||
}, [hosts]);
|
||||
|
||||
const handleConfirmDeleteHost = useCallback(() => {
|
||||
if (!deleteHostConfirm) return;
|
||||
updateHosts(hosts.filter(h => h.id !== deleteHostConfirm.hostId));
|
||||
setDeleteHostConfirm(null);
|
||||
}, [deleteHostConfirm, hosts, updateHosts]);
|
||||
|
||||
const handleAddKnownHost = useCallback((kh: KnownHost) => {
|
||||
const nextKnownHosts = upsertKnownHost(knownHostsRef.current, kh);
|
||||
@@ -940,7 +980,16 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, []);
|
||||
|
||||
// Wrapper to create local terminal with logging
|
||||
const handleCreateLocalTerminal = useCallback((shell?: { command: string; args?: string[]; name?: string; icon?: string }) => { return handleCreateLocalTerminalImpl(() => ({ addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, shell, systemInfoRef, terminalSettings, undefined }), shell); }, [addConnectionLog, createLocalTerminal, terminalSettings, discoveredShells]);
|
||||
const handleCreateLocalTerminal = useCallback((
|
||||
shell?: { command: string; args?: string[]; name?: string; icon?: string },
|
||||
options?: { localStartDir?: string },
|
||||
) => {
|
||||
return handleCreateLocalTerminalImpl(
|
||||
() => ({ addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, shell, systemInfoRef, terminalSettings, undefined }),
|
||||
shell,
|
||||
options,
|
||||
);
|
||||
}, [addConnectionLog, createLocalTerminal, terminalSettings, discoveredShells]);
|
||||
|
||||
const proxyProfileIdSet = useMemo(
|
||||
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
@@ -958,6 +1007,32 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
}, [groupConfigs, proxyProfileIdSet, proxyProfiles]);
|
||||
|
||||
// Wrapper to connect to host with logging
|
||||
const handleConnectToHost = useCallback((host: Host) => {
|
||||
if (host.ephemeral) {
|
||||
setEphemeralHosts((previous) => {
|
||||
const existingIndex = previous.findIndex((candidate) => candidate.id === host.id);
|
||||
if (existingIndex < 0) return [...previous, host];
|
||||
return previous.map((candidate, index) => index === existingIndex ? host : candidate);
|
||||
});
|
||||
}
|
||||
return handleConnectToHostImpl(() => ({ addConnectionLog, connectToHost, host, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef }), host);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys]);
|
||||
|
||||
const openHostForVaultAgent = useCallback((hostId: string) => {
|
||||
const host = hosts.find((item) => item.id === hostId);
|
||||
if (!host) {
|
||||
return { ok: false as const, error: `Host "${hostId}" was not found.` };
|
||||
}
|
||||
const sessionId = handleConnectToHost(host);
|
||||
if (!sessionId) {
|
||||
return { ok: false as const, error: `Failed to open host "${hostId}".` };
|
||||
}
|
||||
// Surface the main window for external MCP / CLI open requests.
|
||||
void netcattyBridge.get()?.openMainWindow?.();
|
||||
return { ok: true as const, sessionId, host };
|
||||
}, [handleConnectToHost, hosts]);
|
||||
|
||||
useVaultAgentBridge({
|
||||
hosts,
|
||||
snippets,
|
||||
@@ -974,11 +1049,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
updateNotes,
|
||||
startTunnel,
|
||||
stopTunnel,
|
||||
openHost: openHostForVaultAgent,
|
||||
});
|
||||
|
||||
// Wrapper to connect to host with logging
|
||||
const handleConnectToHost = useCallback((host: Host) => { return handleConnectToHostImpl(() => ({ addConnectionLog, connectToHost, host, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef }), host); }, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys]);
|
||||
|
||||
const _handleSshDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
const rawUrl = payload?.url || '';
|
||||
const target = parseSshDeepLink(rawUrl);
|
||||
@@ -996,6 +1069,23 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
};
|
||||
});
|
||||
const matchedEffectiveHost = findSshDeepLinkHost(effectiveHosts, target);
|
||||
|
||||
if (target.password) {
|
||||
// One-time-password link: connect ephemerally with exactly the URL
|
||||
// credentials. A uniquely matched saved host still contributes its
|
||||
// non-credential settings (proxy, jump chain, charset, ...). Build
|
||||
// from the group-resolved effective host so the builder can clear
|
||||
// `group` and block group credential inheritance from later
|
||||
// effective-host resolution.
|
||||
const draftOptions = { id: crypto.randomUUID(), now: Date.now() };
|
||||
const ephemeralHost = matchedEffectiveHost
|
||||
? buildSshDeepLinkEphemeralHostFromSaved(matchedEffectiveHost, target, draftOptions)
|
||||
: buildSshDeepLinkEphemeralHost(target, draftOptions);
|
||||
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
|
||||
handleConnectToHost(ephemeralHost);
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchedEffectiveHost) {
|
||||
const originalHost = hosts.find((host) => host.id === matchedEffectiveHost.id) ?? matchedEffectiveHost;
|
||||
handleConnectToHost(buildSshDeepLinkConnectionHost(originalHost));
|
||||
@@ -1019,6 +1109,106 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
});
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
const _handleTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
const rawUrl = payload?.url || '';
|
||||
const target = parseTelnetDeepLink(rawUrl);
|
||||
if (!target) {
|
||||
toast.warning(t('deepLink.telnet.invalid'));
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveHosts = hosts.map((host) =>
|
||||
materializeTelnetDeepLinkMatchHost(resolveEffectiveHost(host), identities),
|
||||
);
|
||||
const matchedEffectiveHost = findTelnetDeepLinkHost(effectiveHosts, target, {
|
||||
ignoreTargetUsername: Boolean(target.password),
|
||||
});
|
||||
|
||||
if (matchedEffectiveHost) {
|
||||
if (target.password) {
|
||||
const ephemeralHost = buildTelnetDeepLinkEphemeralHostFromSaved(matchedEffectiveHost, target, {
|
||||
id: crypto.randomUUID(),
|
||||
now: Date.now(),
|
||||
});
|
||||
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
|
||||
handleConnectToHost(ephemeralHost);
|
||||
return;
|
||||
}
|
||||
handleConnectToHost(buildTelnetDeepLinkConnectionHost(matchedEffectiveHost));
|
||||
return;
|
||||
}
|
||||
|
||||
const ephemeralHost = buildTelnetDeepLinkOpenHost(effectiveHosts, target, {
|
||||
id: crypto.randomUUID(),
|
||||
now: Date.now(),
|
||||
});
|
||||
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
|
||||
handleConnectToHost(ephemeralHost);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isPeerSessionWindow) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onTelnetDeepLink) return;
|
||||
return bridge.onTelnetDeepLink((payload) => {
|
||||
_handleTelnetDeepLink(payload);
|
||||
});
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
const _handleJmsDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
const rawUrl = payload?.url || '';
|
||||
const target = parseJmsDeepLink(rawUrl);
|
||||
if (!target) {
|
||||
toast.warning(t('deepLink.jms.invalid'));
|
||||
return;
|
||||
}
|
||||
if (!isSupportedJmsProtocol(target.protocol)) {
|
||||
toast.warning(t('deepLink.jms.unsupported', { protocol: target.protocol }));
|
||||
return;
|
||||
}
|
||||
const ephemeralHost = buildJmsDeepLinkEphemeralHost(target, {
|
||||
id: crypto.randomUUID(),
|
||||
now: Date.now(),
|
||||
});
|
||||
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
|
||||
handleConnectToHost(ephemeralHost);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isPeerSessionWindow) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onJmsDeepLink) return;
|
||||
return bridge.onJmsDeepLink((payload) => {
|
||||
_handleJmsDeepLink(payload);
|
||||
});
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
useEffect(() => {
|
||||
setEphemeralHosts((prev) => {
|
||||
if (prev.length === 0) return prev;
|
||||
const referencedHostIds = new Set(
|
||||
sessions.map((session) => session.hostId).filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
const next = prev.filter((host) => referencedHostIds.has(host.id));
|
||||
return next.length === prev.length ? prev : next;
|
||||
});
|
||||
}, [sessions]);
|
||||
|
||||
const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
|
||||
const localStartDir = typeof payload?.path === 'string' ? payload.path : '';
|
||||
if (!localStartDir.trim()) return;
|
||||
handleCreateLocalTerminal(undefined, { localStartDir });
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isPeerSessionWindow) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onOpenTerminalPath) return;
|
||||
return bridge.onOpenTerminalPath((payload) => {
|
||||
_handleOpenTerminalPath(payload);
|
||||
});
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
const handleOpenHostFromVaultNote = useCallback((host: Host, source?: { noteId?: string }) => {
|
||||
const tabId = handleConnectToHost(host);
|
||||
if (source?.noteId && typeof tabId === 'string' && tabId) {
|
||||
@@ -1084,6 +1274,17 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
)));
|
||||
}, [hosts, updateHosts]);
|
||||
|
||||
// Terminal-layer host updates may include ephemeral deep-link hosts; keep
|
||||
// those in memory only and never let them reach the persisted vault.
|
||||
const updateTerminalHosts = useCallback((nextHosts: Host[]) => {
|
||||
const { vaultHosts, ephemeralHosts: updatedEphemeralHosts } =
|
||||
splitHostsUpdateByEphemeral(nextHosts, ephemeralHostIds);
|
||||
if (updatedEphemeralHosts.length > 0) {
|
||||
setEphemeralHosts((prev) => applyEphemeralHostsUpdate(prev, updatedEphemeralHosts));
|
||||
}
|
||||
updateHosts(vaultHosts);
|
||||
}, [ephemeralHostIds, updateHosts]);
|
||||
|
||||
// Wrapper to create serial session with logging
|
||||
const handleConnectSerial = useCallback((config: SerialConfig, options?: { charset?: string }) => {
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
@@ -1189,6 +1390,16 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
return (
|
||||
<>
|
||||
<PortForwardHostKeyDialog onAddKnownHost={handleAddKnownHost} />
|
||||
<ConfirmDialog
|
||||
open={deleteHostConfirm !== null}
|
||||
title={deleteHostConfirm ? t('confirm.deleteHost', { name: deleteHostConfirm.name }) : ''}
|
||||
confirmLabel={t('action.delete')}
|
||||
destructive
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteHostConfirm(null);
|
||||
}}
|
||||
onConfirm={handleConfirmDeleteHost}
|
||||
/>
|
||||
<AppActiveTabChrome
|
||||
showSftpTab={settings.showSftpTab}
|
||||
setActiveTabId={setActiveTabId}
|
||||
@@ -1206,7 +1417,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
resolveSessionAppearance={themeRuntime.resolveFocusedAppearance}
|
||||
t={t}
|
||||
/>
|
||||
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, clearThemeIntent: themeRuntime.clearIntent, settleManualThemeIntent: themeRuntime.settleManualIntent, pickTerminalTheme: themeRuntime.pickTheme, resolveSessionAppearance: themeRuntime.resolveFocusedAppearance, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost, handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleOpenHostFromVaultNote, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
|
||||
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, clearThemeIntent: themeRuntime.clearIntent, settleManualThemeIntent: themeRuntime.settleManualIntent, pickTerminalTheme: themeRuntime.pickTheme, resolveSessionAppearance: themeRuntime.resolveFocusedAppearance, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost, handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleOpenHostFromVaultNote, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1219,26 +1430,40 @@ function AppWithProviders() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let splashRemovalTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
// Hide splash screen with a fade-out animation
|
||||
const splash = document.getElementById('splash');
|
||||
if (splash) {
|
||||
splash.classList.add('fade-out');
|
||||
// Remove from DOM after animation completes
|
||||
setTimeout(() => splash.remove(), 200);
|
||||
splashRemovalTimer = setTimeout(() => splash.remove(), 200);
|
||||
}
|
||||
// Notify main process that renderer is ready
|
||||
netcattyBridge.get()?.rendererReady?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return () => {
|
||||
if (splashRemovalTimer !== undefined) clearTimeout(splashRemovalTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Keep MCP/SDK approval IPC alive for the whole app lifetime — including the
|
||||
// ~5s before TerminalLayer lazy-mounts, and when External MCP is used without
|
||||
// opening the Catty AI panel.
|
||||
useEffect(() => {
|
||||
return setupMcpApprovalBridge();
|
||||
}, []);
|
||||
|
||||
useExternalMcpGrantPersister();
|
||||
|
||||
return (
|
||||
<I18nProvider locale={settings.uiLanguage}>
|
||||
<ToastProvider>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<ScriptAutomationRoot />
|
||||
<ExternalMcpApprovalsHost />
|
||||
<App settings={settings} />
|
||||
</TooltipProvider>
|
||||
</ToastProvider>
|
||||
|
||||
+28
-25
@@ -8,37 +8,42 @@
|
||||
> 后端 + UI 重新落到上游重构后的目录结构上,并让它启动**捆绑的** `et`。
|
||||
>
|
||||
> 旧实现参考:`git show 67e81616`(共 7 个 ET 提交,见 `feat/eternal-terminal`)。
|
||||
> Mosh 模板参考:`resources/mosh/README.md`、`scripts/*mosh*`、
|
||||
> `electron/bridges/terminalBridge/moshSession.cjs`、`.github/workflows/build-mosh-binaries.yml`。
|
||||
> Mosh 模板参考(**仅 MoshCatty 纯二进制路径**):`resources/mosh/README.md`、
|
||||
> `scripts/fetch-mosh-binaries.cjs`、`scripts/resolve-mosh-bin-release.cjs`、
|
||||
> `scripts/mosh-extra-resources.cjs`、`electron/bridges/terminalBridge/moshSession.cjs`。
|
||||
> 客户端本体在独立仓库 [binaricat/MoshCatty](https://github.com/binaricat/MoshCatty)
|
||||
> (`moshcatty-*` releases);Netcatty 内已无 Cygwin 构建流水线 / FluentTerminal 回退。
|
||||
|
||||
## 关键设计差异(ET vs Mosh)
|
||||
|
||||
- **协议**:Mosh 需要 Node 重写 Perl 包装器(SSH bootstrap + 抓 `MOSH CONNECT` +
|
||||
换 PTY)。**ET 不需要** —— `et` 客户端自己完成 SSH 引导 + 协议握手,我们只要
|
||||
把 `et` 当作普通 PTY 进程 `pty.spawn` 即可。所以**没有** `etHandshake.cjs`。
|
||||
- **协议**:Mosh 需要 Node 做 SSH bootstrap + 抓 `MOSH CONNECT` + 换 PTY
|
||||
(`moshHandshake` + `moshSession`)。**ET 不需要** —— `et` 客户端自己完成 SSH
|
||||
引导 + 协议握手,我们只要把 `et` 当作普通 PTY 进程 `pty.spawn` 即可。所以**没有**
|
||||
`etHandshake.cjs`。
|
||||
- **凭证注入**:Mosh 自己驱动 ssh、直接往 PTY 里敲密码;ET 内部驱动 ssh,需用
|
||||
**SSH_ASKPASS + 临时 ~/.ssh 环境**把保存的密码/密钥/跳板/算法喂给 et 内部的 ssh
|
||||
(旧实现 `prepareEtSshEnvironment` 已完整实现,直接搬运)。
|
||||
- **terminfo**:`et` 是纯传输客户端、本地不渲染终端,**无需** 捆绑 terminfo
|
||||
(Mosh 因静态 ncurses 才需要)。打包目录里只放 `et[.exe]`(+ Windows DLL)。
|
||||
- **构建系统**:Mosh 用 autotools;**ET 用 CMake + Ninja + vcpkg**
|
||||
- **纯二进制**:MoshCatty 与理想 ET 打包都是「每平台一个客户端文件」。Mosh 侧已
|
||||
无 terminfo / Cygwin DLL 袋;`et` 同样本地不渲染终端。Windows 若动态链 CRT
|
||||
才考虑可选 DLL 目录,否则只放 `et[.exe]`。
|
||||
- **构建系统**:Mosh 客户端在 **MoshCatty** 仓库用 Rust 构建并发布;Netcatty 只
|
||||
`fetch`。**ET** 用 CMake + Ninja + vcpkg
|
||||
(`cmake -DDISABLE_TELEMETRY=ON -GNinja -DCMAKE_BUILD_TYPE=RelWithDebInfo`),
|
||||
产物是单个 `et`(Windows `et.exe`)。
|
||||
产物是单个 `et`(Windows `et.exe`),由 `scripts/build-et/` + `build-et-binaries.yml` 发布。
|
||||
|
||||
## 命名约定(镜像 Mosh)
|
||||
## 命名约定(镜像 Mosh / MoshCatty)
|
||||
|
||||
| Mosh | ET |
|
||||
| Mosh (MoshCatty) | ET |
|
||||
|------|----|
|
||||
| `resources/mosh/<plat-arch>/mosh-client[.exe]` | `resources/et/<plat-arch>/et[.exe]` |
|
||||
| 打包后 `<Resources>/mosh/mosh-client` | 打包后 `<Resources>/et/et` |
|
||||
| `scripts/build-mosh/` | `scripts/build-et/` |
|
||||
| 上游构建:`binaricat/MoshCatty` CI releases | `scripts/build-et/` + `build-et-binaries.yml` |
|
||||
| `scripts/fetch-mosh-binaries.cjs` | `scripts/fetch-et-binaries.cjs` |
|
||||
| `scripts/resolve-mosh-bin-release.cjs` | `scripts/resolve-et-bin-release.cjs` |
|
||||
| `scripts/mosh-extra-resources.cjs` | `scripts/et-extra-resources.cjs` |
|
||||
| env `MOSH_BIN_RELEASE` / 仓库 `Netcatty-mosh-bin` / tag `mosh-bin-*` | env `ET_BIN_RELEASE` / 仓库 `Netcatty-et-bin` / tag `et-bin-*` |
|
||||
| env `MOSH_BIN_RELEASE` / 仓库 `MoshCatty` / tag `moshcatty-*` | env `ET_BIN_RELEASE` / 仓库 `Netcatty-et-bin` / tag `et-bin-*` |
|
||||
| `npm run fetch:mosh[:dev]` | `npm run fetch:et[:dev]` |
|
||||
| `bundledMoshClient()` / `resolveBareMoshClient()` | `bundledEtClient()` / `resolveBareEtClient()` |
|
||||
| `.github/workflows/build-mosh-binaries.yml` | `.github/workflows/build-et-binaries.yml` |
|
||||
|
||||
---
|
||||
|
||||
@@ -52,7 +57,7 @@
|
||||
`/resources/et/*/et-win32-*-dlls/`。保留 `resources/et/README.md`。
|
||||
- [x] **1.3** `scripts/build-et/build-linux.sh` —— manylinux2014 + vcpkg 静态三元组
|
||||
构建 `et`(x64/arm64),产物 `et-linux-<arch>.tar.gz`(+.sha256),内含单个 `et`。
|
||||
校验非系统动态库(同 mosh 的 ldd 白名单)。
|
||||
校验非系统动态库(ldd 白名单)。
|
||||
- [x] **1.4** `scripts/build-et/build-macos.sh` —— arm64 + x86_64 分别构建后 `lipo`
|
||||
成 universal,`MACOSX_DEPLOYMENT_TARGET=11.0`,产物 `et-darwin-universal.tar.gz`。
|
||||
- [x] **1.5** `scripts/build-et/build-windows.ps1`(或 `.sh`)—— MSVC + vcpkg
|
||||
@@ -60,13 +65,12 @@
|
||||
则随附 DLL 目录 `et-win32-x64-dlls/`,否则纯静态无 DLL)。
|
||||
- [x] **1.6** `scripts/et-extra-resources.cjs` —— 镜像 `mosh-extra-resources.cjs`:
|
||||
按平台/arch 仅当 `resources/et/<plat-arch>/et[.exe]` 存在时才产出 extraResources
|
||||
指令(`to: "et/"`);Windows 额外处理可选 DLL 目录。**去掉 terminfo 分支**。
|
||||
指令(`to: "et/"`);Windows 额外处理可选 DLL 目录。纯客户端文件为主。
|
||||
- [x] **1.7** `scripts/resolve-et-bin-release.cjs` —— 镜像 `resolve-mosh-bin-release.cjs`:
|
||||
`TAG_RE=/^et-bin-.../`,默认仓库 `Netcatty-et-bin`,env `ET_BIN_RELEASE` 优先。
|
||||
- [x] **1.8** `scripts/fetch-et-binaries.cjs` —— 镜像 `fetch-mosh-binaries.cjs`:
|
||||
`TARGETS` 四项(linux-x64/arm64、darwin-universal、win32-x64),全部 tar.gz;
|
||||
SHA256SUMS 校验;解包到 `resources/et/<plat-arch>/`。**Windows 用自建产物**
|
||||
(ET 官方有 Windows 构建,无需 FluentTerminal 那种 fallback)。去掉 terminfo 校验。
|
||||
SHA256SUMS 校验;解包到 `resources/et/<plat-arch>/`。**Windows 用自建产物**。
|
||||
- [x] **1.9** 单元测试:`scripts/fetch-et-binaries.test.cjs`、
|
||||
`scripts/resolve-et-bin-release.test.cjs`、`scripts/et-extra-resources.test.cjs`
|
||||
(镜像对应 mosh 测试,改名/改路径)。
|
||||
@@ -77,13 +81,12 @@
|
||||
`scripts/*.test.cjs`(确认即可)。
|
||||
- [x] **1.11** `electron-builder.config.cjs`:引入 `etExtraResources`,在 darwin/win32/
|
||||
linux 三处把 `etExtraResources(plat)` 合并进 `extraResources`(与 mosh 数组拼接)。
|
||||
- [x] **1.12** `.github/workflows/build-et-binaries.yml` —— 镜像
|
||||
`build-mosh-binaries.yml`:四个构建 job + 一个 `release` job(dispatch 且
|
||||
`release_tag` 非空时发布到 `Netcatty-et-bin`,附 `SHA256SUMS`)。`paths` 过滤
|
||||
指向 `scripts/build-et/**`、`scripts/fetch-et-binaries.cjs`、`scripts/et-extra-resources.cjs`。
|
||||
env 用 `ET_REF`(默认 ET release tag,如 `et-v6.2.x`)。
|
||||
- [x] **1.12** `.github/workflows/build-et-binaries.yml` —— 四个构建 job + 一个
|
||||
`release` job(dispatch 且 `release_tag` 非空时发布到 `Netcatty-et-bin`,附
|
||||
`SHA256SUMS`)。`paths` 过滤指向 `scripts/build-et/**`、`scripts/fetch-et-binaries.cjs`、
|
||||
`scripts/et-extra-resources.cjs`。env 用 `ET_REF`(默认 ET release tag,如 `et-v6.2.x`)。
|
||||
> 注:实际二进制由用户手动 `workflow_dispatch` 触发产出;本地/CI 未设
|
||||
> `ET_BIN_RELEASE` 时 fetch 步骤安静跳过(同 mosh)。
|
||||
> `ET_BIN_RELEASE` 时 fetch 步骤安静跳过(同 mosh 的 `MOSH_BIN_RELEASE`)。
|
||||
|
||||
## Phase 2 — 运行时定位捆绑客户端
|
||||
|
||||
@@ -100,7 +103,7 @@
|
||||
`cleanupSessionExternalAuthArtifacts`、`execOnEtSession`、`startEtSession`。
|
||||
**改动点**:`etCmd` 由 `findExecutable('et')` 改为 `resolveBareEtClient()`
|
||||
(取捆绑二进制);找不到时抛错(同 mosh:提示跑 `npm run fetch:et:dev`)。
|
||||
Windows 若有 DLL 目录,复用 `prependEnvPath` 思路把 DLL 目录加进 PATH。
|
||||
Windows 若有动态链接 DLL 目录,可把该目录加进 PATH(MoshCatty 路径已无此需求)。
|
||||
- [x] **3.2** `terminalBridge.cjs` 接线 `createEtSessionApi(ctx)`(镜像 moshSessionApi
|
||||
的 ctx),传入 `bundledEtClient`、`tempDirBridge`、`execFile/execFileSync` 等;
|
||||
解构出 `startEtSession`、`execOnEtSession`、`cleanupStaleEtTempDirs`、
|
||||
|
||||
@@ -46,6 +46,42 @@ test("copySessionToNewWindowWithCurrentShellImpl asks Electron to open a peer wi
|
||||
});
|
||||
});
|
||||
|
||||
test("copySessionToNewWindowWithCurrentShellImpl preserves local start directory in the source session", async () => {
|
||||
const openedPayloads: unknown[] = [];
|
||||
const localSession = sourceSession({
|
||||
hostLabel: "Local Terminal",
|
||||
hostname: "localhost",
|
||||
protocol: "local",
|
||||
localStartDir: "/Users/alice/project with spaces ",
|
||||
});
|
||||
|
||||
await copySessionToNewWindowWithCurrentShellImpl(
|
||||
() => ({
|
||||
classifyLocalShellType: () => "zsh",
|
||||
discoveredShells: [],
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
openSessionInNewWindow: async (payload: unknown) => {
|
||||
openedPayloads.push(payload);
|
||||
return { success: true };
|
||||
},
|
||||
}),
|
||||
},
|
||||
resolveShellSetting: () => ({ command: "/bin/zsh" }),
|
||||
sessions: [localSession],
|
||||
terminalSettings: { localShell: "system-default" },
|
||||
}),
|
||||
"session-1",
|
||||
);
|
||||
|
||||
assert.equal(openedPayloads.length, 1);
|
||||
assert.deepEqual(openedPayloads[0], {
|
||||
title: "Local Terminal",
|
||||
sourceSession: localSession,
|
||||
localShellType: "zsh",
|
||||
});
|
||||
});
|
||||
|
||||
test("copySessionToNewWindowWithCurrentShellImpl does nothing when the source session is gone", async () => {
|
||||
let called = false;
|
||||
|
||||
|
||||
@@ -745,7 +745,11 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
}
|
||||
}
|
||||
|
||||
export function handleCreateLocalTerminalImpl(getCtx: AppContextGetter, shell?: { command: string; args?: string[]; name?: string; icon?: string }) {
|
||||
export function handleCreateLocalTerminalImpl(
|
||||
getCtx: AppContextGetter,
|
||||
shell?: { command: string; args?: string[]; name?: string; icon?: string },
|
||||
options?: { localStartDir?: string },
|
||||
) {
|
||||
const { addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, systemInfoRef, terminalSettings } = getCtx();
|
||||
{
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
@@ -760,6 +764,7 @@ export function handleCreateLocalTerminalImpl(getCtx: AppContextGetter, shell?:
|
||||
shellArgs: resolved?.args,
|
||||
shellName,
|
||||
shellIcon,
|
||||
localStartDir: options?.localStartDir,
|
||||
});
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
|
||||
@@ -95,7 +95,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
|
||||
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
|
||||
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal,
|
||||
hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
||||
resolveEmptyVaultConflict, resolvedTheme, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
@@ -294,13 +294,15 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
</VaultViewContainer>
|
||||
|
||||
<SftpViewMount
|
||||
hosts={hosts}
|
||||
hosts={terminalHosts}
|
||||
writableHosts={hosts}
|
||||
sessions={sessions}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
knownHosts={effectiveKnownHosts}
|
||||
proxyProfiles={proxyProfiles}
|
||||
groupConfigs={groupConfigs}
|
||||
updateHosts={updateHosts}
|
||||
updateHosts={updateTerminalHosts}
|
||||
onAddKnownHost={handleAddKnownHost}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
@@ -315,7 +317,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
/>
|
||||
|
||||
<TerminalLayerMount
|
||||
hosts={hosts}
|
||||
hosts={terminalHosts}
|
||||
portForwardingRules={portForwardingRules}
|
||||
customGroups={customGroups}
|
||||
groupConfigs={groupConfigs}
|
||||
@@ -390,7 +392,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
onToggleBroadcast={toggleBroadcast}
|
||||
updateHosts={updateHosts}
|
||||
updateHosts={updateTerminalHosts}
|
||||
updateSnippets={updateSnippets}
|
||||
updateSnippetPackages={updateSnippetPackages}
|
||||
updateNotes={updateNotes}
|
||||
@@ -558,6 +560,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
setQuickSearch('');
|
||||
}}
|
||||
keyBindings={keyBindings}
|
||||
terminalSettings={terminalSettings}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
|
||||
@@ -185,6 +185,71 @@ export const enAiMessages: Messages = {
|
||||
'ai.toolAccess.description': 'Choose how external 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',
|
||||
|
||||
// External MCP (productized catalog MCP for Codex / Claude Code / Cursor)
|
||||
'ai.externalMcp.title': 'External MCP',
|
||||
'ai.externalMcp.description': 'Expose Netcatty as an MCP server for external clients such as Codex, Claude Code, Cursor, and Grok. Uses the same catalog tools as in-app agents (terminal, SFTP, Vault, port forwarding). Keep Netcatty running while clients are connected.',
|
||||
'ai.externalMcp.sessionsExposed': 'Sessions in scope: {count}',
|
||||
'ai.externalMcp.mode': 'Availability mode',
|
||||
'ai.externalMcp.mode.temporary': 'Temporary',
|
||||
'ai.externalMcp.mode.persistent': 'Always on',
|
||||
'ai.externalMcp.mode.description': 'Temporary mode auto-disables after idle timeout. Always-on restores External MCP when Netcatty starts.',
|
||||
'ai.externalMcp.idleTimeout': 'Idle timeout',
|
||||
'ai.externalMcp.idleTimeout.description': 'In temporary mode, disable External MCP after this many minutes with no MCP operations.',
|
||||
'ai.externalMcp.idleTimeout.minutes': 'min',
|
||||
'ai.externalMcp.usage.title': 'How to use',
|
||||
'ai.externalMcp.usage.keepRunning': '1. Turn on External MCP and keep Netcatty running.',
|
||||
'ai.externalMcp.usage.localhost': '2. Clients connect via the local launcher (127.0.0.1 only). Discovery is removed when you disable the switch.',
|
||||
'ai.externalMcp.usage.permissions': '3. Write operations follow Settings → AI → Safety (observer / confirm / auto) and the command blocklist.',
|
||||
'ai.externalMcp.usage.capabilities': '4. Full catalog tools are available: terminal, SFTP, Vault, and port forwarding. Secrets (passwords / private keys) are never returned.',
|
||||
'ai.externalMcp.help.ariaLabel': 'External MCP help',
|
||||
'ai.externalMcp.security': 'Security',
|
||||
'ai.externalMcp.security.description': 'Listens on 127.0.0.1 with a rotating token, reuses AI Permission Mode for writes, and removes discovery when disabled. No OAuth — this is a local desktop bridge.',
|
||||
'ai.externalMcp.permissionMode': 'Current permission mode: {mode}',
|
||||
'ai.externalMcp.permissionMode.label': 'Write permission mode',
|
||||
'ai.externalMcp.permissionMode.hint': 'Same setting as Settings → AI → Safety. Auto runs Netcatty write tools without Netcatty approval prompts; Confirm asks each time. External clients (Codex / Claude / Grok) may still show their own tool approval UI.',
|
||||
'ai.externalMcp.permissionMode.unknown': 'Unknown',
|
||||
'ai.externalMcp.discovery': 'Discovery',
|
||||
'ai.externalMcp.launcher': 'Launcher',
|
||||
'ai.externalMcp.unavailable': 'Unavailable',
|
||||
'ai.externalMcp.bridgeUnavailable': 'External MCP bridge unavailable',
|
||||
'ai.externalMcp.copy': 'Copy',
|
||||
'ai.externalMcp.copied': 'Copied',
|
||||
'ai.externalMcp.copyFailed': 'Copy failed. Try copying manually.',
|
||||
'ai.externalMcp.refresh': 'Refresh',
|
||||
'ai.externalMcp.clientConfiguration': 'Client configuration',
|
||||
'ai.externalMcp.clientConfiguration.description': 'Pick a client to one-click install, or copy CLI / config snippets.',
|
||||
'ai.externalMcp.client.codex': 'Codex',
|
||||
'ai.externalMcp.client.claude': 'Claude Code',
|
||||
'ai.externalMcp.client.grok': 'Grok',
|
||||
'ai.externalMcp.client.cursor': 'Cursor',
|
||||
'ai.externalMcp.cliCommand': 'CLI command',
|
||||
'ai.externalMcp.configSnippet': 'Config snippet',
|
||||
'ai.externalMcp.addToCodex': 'Add to Codex',
|
||||
'ai.externalMcp.addToClaude': 'Add to Claude Code',
|
||||
'ai.externalMcp.addToGrok': 'Add to Grok',
|
||||
'ai.externalMcp.codexAdded': 'Codex MCP entry added. Restart Codex or open a new Codex session.',
|
||||
'ai.externalMcp.claudeAdded': 'Claude Code MCP entry added. Restart Claude Code or open a new Claude Code session.',
|
||||
'ai.externalMcp.grokAdded': 'Grok MCP entry added. Restart Grok or open a new Grok session.',
|
||||
'ai.externalMcp.installCodex': 'Install Codex separately, then click Refresh.',
|
||||
'ai.externalMcp.installClaude': 'Install Claude Code separately, then click Refresh.',
|
||||
'ai.externalMcp.installGrok': 'Install the Grok CLI separately, then click Refresh.',
|
||||
'ai.externalMcp.conflict.description': 'A netcatty-external entry already exists and points elsewhere. Remove or edit it manually.',
|
||||
'ai.externalMcp.enableForLauncher': 'Enable External MCP to get a usable launcher path.',
|
||||
'ai.externalMcp.cursor.title': 'Cursor / other clients',
|
||||
'ai.externalMcp.cursor.description': 'Merge this into your MCP config (for example ~/.cursor/mcp.json). Do not replace the whole file if you already have other servers.',
|
||||
'ai.externalMcp.status.unavailable': 'Unavailable',
|
||||
'ai.externalMcp.status.disabled': 'Disabled',
|
||||
'ai.externalMcp.status.running': 'Running',
|
||||
'ai.externalMcp.status.starting': 'Starting',
|
||||
'ai.externalMcp.status.error': 'Error',
|
||||
'ai.externalMcp.status.configured': 'Configured',
|
||||
'ai.externalMcp.status.notConfigured': 'Not configured',
|
||||
'ai.externalMcp.status.checking': 'Checking',
|
||||
'ai.externalMcp.status.codexNotFound': 'Codex not found',
|
||||
'ai.externalMcp.status.claudeNotFound': 'Claude Code not found',
|
||||
'ai.externalMcp.status.grokNotFound': 'Grok not found',
|
||||
'ai.externalMcp.status.conflict': 'Conflict',
|
||||
'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',
|
||||
|
||||
@@ -106,6 +106,21 @@ export const enCoreMessages: Messages = {
|
||||
'settings.system.credentials.unavailableHint': 'Credentials encrypted on another user profile or machine cannot be decrypted here. Re-enter and save credentials on this device.',
|
||||
'settings.system.credentials.portabilityHint': 'Cloud Sync is portable because it uses your master key encryption. Local safeStorage encryption is device/user scoped.',
|
||||
|
||||
// Settings > System > Network Proxy
|
||||
'settings.system.networkProxy.title': 'Network Proxy',
|
||||
'settings.system.networkProxy.description': 'HTTP(S) proxy for cloud sync and AI providers. This is separate from SSH ProxyJump / ProxyCommand profiles.',
|
||||
'settings.system.networkProxy.mode': 'Proxy mode',
|
||||
'settings.system.networkProxy.mode.system': 'Use system proxy',
|
||||
'settings.system.networkProxy.mode.direct': 'Direct (no proxy)',
|
||||
'settings.system.networkProxy.mode.custom': 'Custom proxy',
|
||||
'settings.system.networkProxy.url': 'Proxy URL',
|
||||
'settings.system.networkProxy.url.placeholder': 'http://127.0.0.1:7890',
|
||||
'settings.system.networkProxy.url.desc': 'Supports http://, https://, and socks5:// host:port URLs. Proxy credentials in the URL are not supported.',
|
||||
'settings.system.networkProxy.bypass': 'Bypass list',
|
||||
'settings.system.networkProxy.bypass.placeholder': '<local>,localhost,127.0.0.1',
|
||||
'settings.system.networkProxy.bypass.desc': 'Comma-separated hosts that skip the proxy. Use <local> for loopback and LAN names.',
|
||||
'settings.system.networkProxy.hint': 'Affects Google Drive / OneDrive / GitHub sync login and AI provider requests. Does not change SSH connections.',
|
||||
|
||||
// Settings > System > Crash Logs
|
||||
'settings.system.crashLogs.title': 'Crash Logs',
|
||||
'settings.system.crashLogs.description': 'View error logs from the main process to help diagnose unexpected behavior.',
|
||||
@@ -397,9 +412,18 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.behavior.middleClick.menu': 'Show menu',
|
||||
'settings.terminal.behavior.middleClick.paste': 'Paste',
|
||||
'settings.terminal.behavior.middleClick.disabled': 'Do nothing',
|
||||
'settings.terminal.behavior.wordSeparators': 'Word separators',
|
||||
'settings.terminal.behavior.wordSeparators.desc':
|
||||
'Characters treated as word boundaries for double-click selection. Spaces count too; add =, comma, or : for strings like uid=name.',
|
||||
'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.shiftEnterNewline': 'Shift+Enter text',
|
||||
'settings.terminal.behavior.shiftEnterNewline.desc':
|
||||
'Send configured text instead of normal Enter when pressing Shift+Enter in the terminal.',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': 'Text to send',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'Use \\n for newline, \\t for tab, and \\\\ for backslash.',
|
||||
'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`.',
|
||||
@@ -498,6 +522,8 @@ export const enCoreMessages: Messages = {
|
||||
'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.hostInfoBar.show': 'Show host information bar',
|
||||
'settings.terminal.hostInfoBar.show.desc': 'Show the host address and server information above the terminal. Action buttons remain available when hidden.',
|
||||
'settings.terminal.section.systemManager': 'System Manager',
|
||||
'settings.terminal.systemManager.processRefreshInterval': 'Process list refresh',
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'How often to refresh the process list in the system manager side panel.',
|
||||
@@ -723,6 +749,12 @@ export const enCoreMessages: Messages = {
|
||||
'vault.groups.errors.invalidChars': "Group name cannot include '/' or '\\\\'.",
|
||||
'vault.groups.errors.duplicatePath': 'A group with this name already exists at this location.',
|
||||
|
||||
'vault.deleteConfirm.title': 'Delete "{name}"?',
|
||||
'vault.deleteConfirm.desc': 'This action cannot be undone.',
|
||||
'vault.deleteConfirm.packageDesc': 'This will remove the script package. Scripts inside it will stay saved and move out of the package.',
|
||||
'vault.deleteConfirm.noteGroupDesc': 'This will remove the folder. Notes inside it will stay saved and move out of the folder.',
|
||||
'vault.deleteConfirm.portForwardingDesc': 'This will delete the port forwarding rule.',
|
||||
|
||||
'vault.managedSource.unmanage': 'Unmanage',
|
||||
'vault.managedSource.unmanageSuccess': 'Successfully unmanaged group',
|
||||
|
||||
@@ -765,5 +797,8 @@ export const enCoreMessages: Messages = {
|
||||
'vault.hosts.empty.title': 'Set up your hosts',
|
||||
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
|
||||
'deepLink.ssh.invalid': 'Invalid ssh:// link',
|
||||
'deepLink.telnet.invalid': 'Invalid telnet:// link',
|
||||
'deepLink.jms.invalid': 'Invalid JumpServer link',
|
||||
'deepLink.jms.unsupported': 'Unsupported JumpServer protocol: {{protocol}}',
|
||||
|
||||
};
|
||||
@@ -41,7 +41,7 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.overview.system': 'System',
|
||||
'systemManager.overview.kernel': 'Kernel',
|
||||
'systemManager.overview.swap': 'Swap',
|
||||
'systemManager.overview.latency': 'Latency',
|
||||
'systemManager.overview.latency': 'SSH network latency',
|
||||
'systemManager.overview.cpuCores': 'CPU cores',
|
||||
'systemManager.overview.disks': 'Disks',
|
||||
'systemManager.overview.interfaces': 'Network interfaces',
|
||||
|
||||
@@ -55,6 +55,8 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.toolbar.focus': 'Focus',
|
||||
'terminal.toolbar.focusMode': 'Focus Mode',
|
||||
'terminal.toolbar.detach': 'Detach to standalone tab',
|
||||
'terminal.toolbar.dragPane': 'Drag terminal pane',
|
||||
'terminal.toolbar.showActions': 'Show terminal actions',
|
||||
'terminal.toolbar.encoding': 'Terminal Encoding',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
@@ -88,7 +90,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.serverStats.disk': 'Disk Usage (Root)',
|
||||
'terminal.serverStats.diskDetails': 'Mounted Disks',
|
||||
'terminal.serverStats.network': 'Network Speed',
|
||||
'terminal.serverStats.latency': 'Latency',
|
||||
'terminal.serverStats.latency': 'SSH network latency',
|
||||
'terminal.serverStats.networkDetails': 'Network Interfaces',
|
||||
'terminal.serverStats.noData': 'No data available',
|
||||
'terminal.dragDrop.localTitle': 'Drop to Insert Paths',
|
||||
@@ -301,6 +303,7 @@ export const enTerminalMessages: Messages = {
|
||||
'cloudSync.s3.sessionToken': 'Session Token (optional)',
|
||||
'cloudSync.s3.prefix': 'Key Prefix (optional)',
|
||||
'cloudSync.s3.forcePathStyle': 'Force path-style URLs (for MinIO/R2, etc.)',
|
||||
'cloudSync.s3.allowInsecure': 'Allow insecure connection (ignore certificate errors)',
|
||||
'cloudSync.s3.showSecret': 'Show secrets',
|
||||
'cloudSync.s3.validation.required': 'Endpoint, region, bucket, access key, and secret are required.',
|
||||
'cloudSync.smb.title': 'SMB Settings',
|
||||
|
||||
@@ -50,6 +50,14 @@ export const enVaultMessages: Messages = {
|
||||
'vault.import.toast.summary':
|
||||
'Imported {count} hosts (skipped {skipped}, duplicates {duplicates}).',
|
||||
'vault.import.toast.firstIssue': 'First issue: {issue}',
|
||||
'vault.import.mobaxterm.chooseEncoding': 'Choose how to read your MobaXterm file.',
|
||||
'vault.import.mobaxterm.encodingQuestion': 'Which text encoding does this file use?',
|
||||
'vault.import.mobaxterm.auto': 'Auto Detect',
|
||||
'vault.import.mobaxterm.autoDesc': 'Recommended. Uses UTF-8 when valid, otherwise Chinese Windows encoding.',
|
||||
'vault.import.mobaxterm.utf8': 'UTF-8',
|
||||
'vault.import.mobaxterm.utf8Desc': 'Use for files saved or converted as UTF-8.',
|
||||
'vault.import.mobaxterm.gb18030': 'Chinese Windows',
|
||||
'vault.import.mobaxterm.gb18030Desc': 'Use for legacy files exported on Chinese Windows.',
|
||||
'vault.import.sshConfig.chooseMode': 'Choose how to import your SSH config file.',
|
||||
'vault.import.sshConfig.modeQuestion': 'How would you like to import?',
|
||||
'vault.import.sshConfig.importOnly': 'Import Only',
|
||||
@@ -166,6 +174,7 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.columns.modified': 'Modified',
|
||||
'sftp.columns.size': 'Size',
|
||||
'sftp.columns.kind': 'Kind',
|
||||
'sftp.columns.configure': 'Choose visible columns',
|
||||
'sftp.columns.actions': 'Actions',
|
||||
'sftp.emptyDirectory': 'Empty directory',
|
||||
'sftp.nav.up': 'Go up',
|
||||
@@ -186,6 +195,7 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.moveTo.pathNotFound': 'Directory not found or inaccessible',
|
||||
'sftp.context.download': 'Download',
|
||||
'sftp.context.copyToOtherPane': 'Copy to other pane',
|
||||
'sftp.copyToOtherPane.unavailable': 'Open the two-pane SFTP view and connect the destination pane before copying files.',
|
||||
'sftp.copyCurrentPath': 'Copy current path',
|
||||
'sftp.copyCurrentPath.success': 'Current path copied',
|
||||
'sftp.copyCurrentPath.error': 'Could not copy current path',
|
||||
@@ -279,6 +289,9 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.picker.local.title': 'Local filesystem',
|
||||
'sftp.picker.local.desc': 'Browse local files',
|
||||
'sftp.picker.local.badge': 'Local',
|
||||
'sftp.picker.connected.section': 'Connected',
|
||||
'sftp.picker.connected.badge': 'Live',
|
||||
'sftp.picker.connected.connecting': 'Connecting',
|
||||
'sftp.picker.noMatch': 'No matching hosts',
|
||||
'sftp.permissions.title': 'Edit Permissions',
|
||||
'sftp.permissions.owner': 'Owner',
|
||||
@@ -382,9 +395,12 @@ export const enVaultMessages: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.transfer': 'Transfer to other pane',
|
||||
'settings.sftp.doubleClickBehavior.openDesc': 'Open the file in the default application',
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': 'Transfer the file to the other pane\'s active host',
|
||||
'settings.sshDeepLink.title': 'SSH links',
|
||||
'settings.sshDeepLink.enable': 'Open ssh:// links with Netcatty',
|
||||
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// links from browsers and other apps.',
|
||||
'settings.sshDeepLink.title': 'SSH and Telnet links',
|
||||
'settings.sshDeepLink.enable': 'Open ssh:// and telnet:// links with Netcatty',
|
||||
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// and telnet:// links from browsers and other apps.',
|
||||
'settings.jmsDeepLink.title': 'JumpServer Links',
|
||||
'settings.jmsDeepLink.enable': 'Handle jms:// links',
|
||||
'settings.jmsDeepLink.enableDesc': 'Register Netcatty as the handler for JumpServer jms:// client links. May conflict with the official JumpServer client.',
|
||||
|
||||
// Settings > SFTP Auto Sync
|
||||
'settings.sftp.autoSync': 'Auto-sync to remote',
|
||||
@@ -472,6 +488,17 @@ export const enVaultMessages: Messages = {
|
||||
'quickConnect.knownHost.addAndContinue': 'Add and continue',
|
||||
'quickConnect.addKey': 'Add key',
|
||||
'quickConnect.warning.unparsedOptions': 'Some SSH arguments were ignored: {options}',
|
||||
'quickConnect.identity.label': 'Credential preset',
|
||||
'quickConnect.identity.placeholder': 'Choose a saved identity',
|
||||
'quickConnect.identity.empty': 'No saved identities found',
|
||||
'quickConnect.identity.hint': 'Choose an identity to fill in its username and authentication automatically.',
|
||||
'quickConnect.identity.selectedHint': 'Ready to connect as {username}.',
|
||||
'quickConnect.identity.method.password': 'Password',
|
||||
'quickConnect.identity.method.key': 'SSH key',
|
||||
'quickConnect.identity.method.certificate': 'Certificate',
|
||||
'quickConnect.identity.unavailable': 'This identity is missing usable credentials on this device.',
|
||||
'quickConnect.identity.useCustom': 'Use custom credentials',
|
||||
'quickConnect.identity.connect': 'Connect with preset',
|
||||
|
||||
// Terminal
|
||||
'terminal.connectionErrorTitle': 'Connection Error',
|
||||
@@ -618,6 +645,15 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
|
||||
'hostDetails.credential.browseKeyFile': 'Browse...',
|
||||
'hostDetails.credential.missing': 'Credential not found',
|
||||
'hostDetails.section.systemSshAgent': 'System SSH Agent',
|
||||
'hostDetails.systemSshAgent': 'Use System SSH Agent to Sign In',
|
||||
'hostDetails.systemSshAgent.desc': 'Use keys already unlocked by macOS, Linux, Windows, 1Password, or another compatible SSH agent',
|
||||
'hostDetails.systemSshAgent.socket': 'Agent Socket',
|
||||
'hostDetails.systemSshAgent.socket.desc': 'Leave empty to use SSH_AUTH_SOCK',
|
||||
'hostDetails.systemSshAgent.identitiesOnly': 'Only Use Configured Keys',
|
||||
'hostDetails.systemSshAgent.identitiesOnly.desc': 'Do not try unrelated keys currently loaded in the agent',
|
||||
'hostDetails.systemSshAgent.notRunning': 'System SSH agent is unavailable',
|
||||
'hostDetails.systemSshAgent.notRunningHint': 'Start or unlock your SSH agent, or provide its socket path above.',
|
||||
'hostDetails.keys.search': 'Search keys...',
|
||||
'hostDetails.keys.empty': 'No keys available',
|
||||
'hostDetails.certs.search': 'Search certificates...',
|
||||
@@ -659,6 +695,11 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.keepalive.interval': 'Interval (seconds)',
|
||||
'hostDetails.keepalive.countMax': 'Max unanswered keepalives',
|
||||
'hostDetails.keepalive.disabledHint': 'Interval = 0 disables keepalive for this host. The session will rely on TCP-level timeouts to detect a dead connection.',
|
||||
'hostDetails.section.sshTimeouts': 'SSH Connection Timeouts',
|
||||
'hostDetails.sshTimeouts.tcpConnect': 'TCP timeout',
|
||||
'hostDetails.sshTimeouts.tcpConnect.desc': 'Seconds to wait for the network connection to this host.',
|
||||
'hostDetails.sshTimeouts.authReady': 'SSH auth timeout',
|
||||
'hostDetails.sshTimeouts.authReady.desc': 'Seconds to wait after connecting for SSH negotiation and authentication to finish.',
|
||||
'hostDetails.backspaceBehavior': 'Backspace Behavior',
|
||||
'hostDetails.backspaceBehavior.default': 'Default',
|
||||
'hostDetails.jumpHosts': 'Proxy via Hosts',
|
||||
|
||||
@@ -169,6 +169,70 @@ export const ruAiMessages: Messages = {
|
||||
'ai.toolAccess.description': 'Выберите, как внешние агенты получают доступ к сессиям Netcatty. MCP предоставляет встроенный сервер, а Skills + CLI указывает агентам на локальный skill Netcatty и команды CLI.',
|
||||
'ai.toolAccess.mode.mcp': 'MCP',
|
||||
'ai.toolAccess.mode.skills': 'Skills + CLI',
|
||||
|
||||
'ai.externalMcp.title': 'Внешний MCP',
|
||||
'ai.externalMcp.description': 'Откройте Netcatty как MCP-сервер для Codex, Claude Code, Cursor и Grok. Набор инструментов тот же, что у встроенных агентов. Держите Netcatty запущенным, пока клиенты подключены.',
|
||||
'ai.externalMcp.sessionsExposed': 'Сессий в области: {count}',
|
||||
'ai.externalMcp.mode': 'Режим доступности',
|
||||
'ai.externalMcp.mode.temporary': 'Временный',
|
||||
'ai.externalMcp.mode.persistent': 'Постоянный',
|
||||
'ai.externalMcp.mode.description': 'Временный режим отключается после простоя. Постоянный восстанавливает External MCP при запуске Netcatty.',
|
||||
'ai.externalMcp.idleTimeout': 'Таймаут простоя',
|
||||
'ai.externalMcp.idleTimeout.description': 'В временном режиме отключить External MCP после указанного числа минут без MCP-операций.',
|
||||
'ai.externalMcp.idleTimeout.minutes': 'мин',
|
||||
'ai.externalMcp.usage.title': 'Как пользоваться',
|
||||
'ai.externalMcp.usage.keepRunning': '1. Включите External MCP и держите Netcatty запущенным.',
|
||||
'ai.externalMcp.usage.localhost': '2. Клиенты подключаются через локальный launcher (только 127.0.0.1). Discovery удаляется при отключении.',
|
||||
'ai.externalMcp.usage.permissions': '3. Запись следует Settings → AI → Safety и списку блокировки команд.',
|
||||
'ai.externalMcp.usage.capabilities': '4. Доступен полный catalog: терминал, SFTP, Vault и проброс портов. Секреты не возвращаются.',
|
||||
'ai.externalMcp.help.ariaLabel': 'Справка External MCP',
|
||||
'ai.externalMcp.security': 'Безопасность',
|
||||
'ai.externalMcp.security.description': 'Слушает только 127.0.0.1 с ротацией token, использует AI Permission Mode для записи и удаляет discovery при отключении.',
|
||||
'ai.externalMcp.permissionMode': 'Текущий режим разрешений: {mode}',
|
||||
'ai.externalMcp.permissionMode.label': 'Режим разрешений на запись',
|
||||
'ai.externalMcp.permissionMode.hint': 'Тот же параметр, что Settings → AI → Safety. Auto выполняет write-инструменты Netcatty без запросов; Confirm спрашивает каждый раз. Внешние клиенты (Codex / Claude / Grok) могут показывать своё подтверждение инструментов.',
|
||||
'ai.externalMcp.permissionMode.unknown': 'Неизвестно',
|
||||
'ai.externalMcp.discovery': 'Discovery',
|
||||
'ai.externalMcp.launcher': 'Launcher',
|
||||
'ai.externalMcp.unavailable': 'Недоступно',
|
||||
'ai.externalMcp.bridgeUnavailable': 'Мост External MCP недоступен',
|
||||
'ai.externalMcp.copy': 'Копировать',
|
||||
'ai.externalMcp.copied': 'Скопировано',
|
||||
'ai.externalMcp.copyFailed': 'Не удалось скопировать. Скопируйте вручную.',
|
||||
'ai.externalMcp.refresh': 'Обновить',
|
||||
'ai.externalMcp.clientConfiguration': 'Настройка клиента',
|
||||
'ai.externalMcp.clientConfiguration.description': 'Выберите клиент для установки в один клик или скопируйте CLI / фрагмент конфига.',
|
||||
'ai.externalMcp.client.codex': 'Codex',
|
||||
'ai.externalMcp.client.claude': 'Claude Code',
|
||||
'ai.externalMcp.client.grok': 'Grok',
|
||||
'ai.externalMcp.client.cursor': 'Cursor',
|
||||
'ai.externalMcp.cliCommand': 'CLI-команда',
|
||||
'ai.externalMcp.configSnippet': 'Фрагмент конфига',
|
||||
'ai.externalMcp.addToCodex': 'Добавить в Codex',
|
||||
'ai.externalMcp.addToClaude': 'Добавить в Claude Code',
|
||||
'ai.externalMcp.addToGrok': 'Добавить в Grok',
|
||||
'ai.externalMcp.codexAdded': 'Запись MCP для Codex добавлена. Перезапустите Codex или откройте новую сессию.',
|
||||
'ai.externalMcp.claudeAdded': 'Запись MCP для Claude Code добавлена. Перезапустите Claude Code или откройте новую сессию.',
|
||||
'ai.externalMcp.grokAdded': 'Запись MCP для Grok добавлена. Перезапустите Grok или откройте новую сессию.',
|
||||
'ai.externalMcp.installCodex': 'Сначала установите Codex, затем нажмите Обновить.',
|
||||
'ai.externalMcp.installClaude': 'Сначала установите Claude Code, затем нажмите Обновить.',
|
||||
'ai.externalMcp.installGrok': 'Сначала установите Grok CLI, затем нажмите Обновить.',
|
||||
'ai.externalMcp.conflict.description': 'Запись netcatty-external уже существует и указывает в другое место. Удалите или измените её вручную.',
|
||||
'ai.externalMcp.enableForLauncher': 'Включите External MCP, чтобы получить путь launcher.',
|
||||
'ai.externalMcp.cursor.title': 'Cursor / другие клиенты',
|
||||
'ai.externalMcp.cursor.description': 'Объедините с MCP-конфигом (например ~/.cursor/mcp.json). Не заменяйте весь файл, если там уже есть другие серверы.',
|
||||
'ai.externalMcp.status.unavailable': 'Недоступно',
|
||||
'ai.externalMcp.status.disabled': 'Выключено',
|
||||
'ai.externalMcp.status.running': 'Работает',
|
||||
'ai.externalMcp.status.starting': 'Запуск',
|
||||
'ai.externalMcp.status.error': 'Ошибка',
|
||||
'ai.externalMcp.status.configured': 'Настроено',
|
||||
'ai.externalMcp.status.notConfigured': 'Не настроено',
|
||||
'ai.externalMcp.status.checking': 'Проверка',
|
||||
'ai.externalMcp.status.codexNotFound': 'Codex не найден',
|
||||
'ai.externalMcp.status.claudeNotFound': 'Claude Code не найден',
|
||||
'ai.externalMcp.status.grokNotFound': 'Grok не найден',
|
||||
'ai.externalMcp.status.conflict': 'Конфликт',
|
||||
'ai.userSkills.title': 'Пользовательские skills',
|
||||
'ai.userSkills.description': 'Откройте папку skills Netcatty, чтобы добавить свои каталоги skills. Netcatty автоматически сканирует их и добавляет только лёгкие индексы, если skill явно не соответствует текущему запросу.',
|
||||
'ai.userSkills.openFolder': 'Открыть папку skills',
|
||||
|
||||
@@ -106,6 +106,21 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.system.credentials.unavailableHint': 'Учётные данные, зашифрованные в другом профиле пользователя или на другой машине, здесь расшифровать нельзя. Повторно введите и сохраните их на этом устройстве.',
|
||||
'settings.system.credentials.portabilityHint': 'Облачная синхронизация переносима, потому что использует шифрование вашим мастер-ключом. Локальное шифрование safeStorage привязано к устройству и пользователю.',
|
||||
|
||||
// Settings > System > Network Proxy
|
||||
'settings.system.networkProxy.title': 'Сетевой прокси',
|
||||
'settings.system.networkProxy.description': 'HTTP(S)-прокси для облачной синхронизации и AI-провайдеров. Не связан с SSH ProxyJump / ProxyCommand.',
|
||||
'settings.system.networkProxy.mode': 'Режим прокси',
|
||||
'settings.system.networkProxy.mode.system': 'Системный прокси',
|
||||
'settings.system.networkProxy.mode.direct': 'Напрямую (без прокси)',
|
||||
'settings.system.networkProxy.mode.custom': 'Свой прокси',
|
||||
'settings.system.networkProxy.url': 'URL прокси',
|
||||
'settings.system.networkProxy.url.placeholder': 'http://127.0.0.1:7890',
|
||||
'settings.system.networkProxy.url.desc': 'Поддерживаются URL вида http://, https:// и socks5:// host:port. Учётные данные в URL прокси не поддерживаются.',
|
||||
'settings.system.networkProxy.bypass': 'Список исключений',
|
||||
'settings.system.networkProxy.bypass.placeholder': '<local>,localhost,127.0.0.1',
|
||||
'settings.system.networkProxy.bypass.desc': 'Хосты через запятую, для которых прокси не используется. <local> — localhost и локальные имена.',
|
||||
'settings.system.networkProxy.hint': 'Влияет на вход в Google Drive / OneDrive / GitHub sync и запросы к AI-провайдерам. SSH-подключения не меняет.',
|
||||
|
||||
// Settings > System > Crash Logs
|
||||
'settings.system.crashLogs.title': 'Журналы сбоев',
|
||||
'settings.system.crashLogs.description': 'Просмотр журналов ошибок основного процесса для диагностики неожиданного поведения.',
|
||||
@@ -374,9 +389,18 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.behavior.middleClick.menu': 'Показать меню',
|
||||
'settings.terminal.behavior.middleClick.paste': 'Вставить',
|
||||
'settings.terminal.behavior.middleClick.disabled': 'Ничего не делать',
|
||||
'settings.terminal.behavior.wordSeparators': 'Разделители слов',
|
||||
'settings.terminal.behavior.wordSeparators.desc':
|
||||
'Символы, которые считаются границами слова при выделении двойным щелчком. Пробел тоже учитывается; добавьте =, запятую или : для строк вроде uid=name.',
|
||||
'settings.terminal.behavior.bracketedPaste': 'Режим bracketed paste',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'Оборачивать вставляемый текст escape-последовательностями, чтобы оболочка отличала вставку от обычного ввода. Отключите, если видите артефакты вида ^[[200~.',
|
||||
'settings.terminal.behavior.shiftEnterNewline': 'Текст для Shift+Enter',
|
||||
'settings.terminal.behavior.shiftEnterNewline.desc':
|
||||
'Отправлять настроенный текст вместо обычного Enter при нажатии Shift+Enter в терминале.',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': 'Отправляемый текст',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'Используйте \\n для перевода строки, \\t для табуляции и \\\\ для обратной косой черты.',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` очищает буфер прокрутки',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'Команда `clear` также будет очищать буфер прокрутки (поведение POSIX по умолчанию). Отключите, чтобы история оставалась видимой после `clear`.',
|
||||
@@ -471,6 +495,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.connection.x11Display.desc': 'Необязательный адрес локального дисплея для перенаправления X11. Оставьте пустым, чтобы использовать системное значение по умолчанию.',
|
||||
'settings.terminal.connection.x11Display.placeholder': 'Авто (:0 или DISPLAY)',
|
||||
'settings.terminal.section.serverStats': 'Статистика сервера (Linux)',
|
||||
'settings.terminal.hostInfoBar.show': 'Показывать сведения о хосте',
|
||||
'settings.terminal.hostInfoBar.show.desc': 'Показывать адрес хоста и сведения о сервере над терминалом. Кнопки действий остаются доступными после скрытия.',
|
||||
'settings.terminal.section.systemManager': 'Системный менеджер',
|
||||
'settings.terminal.systemManager.processRefreshInterval': 'Обновление списка процессов',
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'Как часто обновлять список процессов в боковой панели системного менеджера.',
|
||||
@@ -725,11 +751,20 @@ export const ruCoreMessages: Messages = {
|
||||
'vault.groups.errors.invalidChars': "Имя группы не может содержать '/' или '\\\\'.",
|
||||
'vault.groups.errors.duplicatePath': 'Группа с таким именем уже существует в этом расположении.',
|
||||
|
||||
'vault.deleteConfirm.title': 'Удалить "{name}"?',
|
||||
'vault.deleteConfirm.desc': 'Это действие нельзя отменить.',
|
||||
'vault.deleteConfirm.packageDesc': 'Пакет скриптов будет удалён. Скрипты внутри него сохранятся и будут вынесены из пакета.',
|
||||
'vault.deleteConfirm.noteGroupDesc': 'Папка будет удалена. Заметки внутри неё сохранятся и будут вынесены из папки.',
|
||||
'vault.deleteConfirm.portForwardingDesc': 'Правило проброса портов будет удалено.',
|
||||
|
||||
'vault.managedSource.unmanage': 'Снять управление',
|
||||
'vault.managedSource.unmanageSuccess': 'Управление группой успешно снято',
|
||||
|
||||
'vault.hosts.header.entries': 'Записей: {count}',
|
||||
'vault.hosts.header.live': 'Активных: {count}',
|
||||
'deepLink.ssh.invalid': 'Недопустимая ссылка ssh://',
|
||||
'deepLink.telnet.invalid': 'Недопустимая ссылка telnet://',
|
||||
'deepLink.jms.invalid': 'Недопустимая ссылка JumpServer',
|
||||
'deepLink.jms.unsupported': 'Неподдерживаемый протокол JumpServer: {{protocol}}',
|
||||
|
||||
};
|
||||
@@ -41,7 +41,7 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.overview.system': 'Система',
|
||||
'systemManager.overview.kernel': 'Ядро',
|
||||
'systemManager.overview.swap': 'Swap',
|
||||
'systemManager.overview.latency': 'Задержка',
|
||||
'systemManager.overview.latency': 'Сетевая задержка SSH',
|
||||
'systemManager.overview.cpuCores': 'Ядра CPU',
|
||||
'systemManager.overview.disks': 'Диски',
|
||||
'systemManager.overview.interfaces': 'Сетевые интерфейсы',
|
||||
|
||||
@@ -76,6 +76,8 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.toolbar.focus': 'Фокус',
|
||||
'terminal.toolbar.focusMode': 'Режим фокуса',
|
||||
'terminal.toolbar.detach': 'Открепить в отдельную вкладку',
|
||||
'terminal.toolbar.dragPane': 'Перетащить панель терминала',
|
||||
'terminal.toolbar.showActions': 'Показать действия терминала',
|
||||
'terminal.toolbar.encoding': 'Кодировка терминала',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
@@ -109,6 +111,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.serverStats.disk': 'Использование диска (корень)',
|
||||
'terminal.serverStats.diskDetails': 'Смонтированные диски',
|
||||
'terminal.serverStats.network': 'Скорость сети',
|
||||
'terminal.serverStats.latency': 'Сетевая задержка SSH',
|
||||
'terminal.serverStats.networkDetails': 'Сетевые интерфейсы',
|
||||
'terminal.serverStats.noData': 'Данные недоступны',
|
||||
'terminal.dragDrop.localTitle': 'Перетащите для вставки путей',
|
||||
@@ -316,6 +319,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'cloudSync.s3.sessionToken': 'Токен сессии (необязательно)',
|
||||
'cloudSync.s3.prefix': 'Префикс ключа (необязательно)',
|
||||
'cloudSync.s3.forcePathStyle': 'Принудительно использовать path-style URL (для MinIO/R2 и т. д.)',
|
||||
'cloudSync.s3.allowInsecure': 'Разрешить небезопасное соединение (игнорировать ошибки сертификата)',
|
||||
'cloudSync.s3.showSecret': 'Показать секреты',
|
||||
'cloudSync.s3.validation.required': 'Endpoint, регион, бакет, access key и secret обязательны.',
|
||||
'cloudSync.smb.title': 'Настройки SMB',
|
||||
|
||||
@@ -85,6 +85,14 @@ export const ruVaultMessages: Messages = {
|
||||
'vault.import.toast.summary':
|
||||
'Импортировано {count} хостов (пропущено {skipped}, дубликатов {duplicates}).',
|
||||
'vault.import.toast.firstIssue': 'Первая проблема: {issue}',
|
||||
'vault.import.mobaxterm.chooseEncoding': 'Выберите способ чтения файла MobaXterm.',
|
||||
'vault.import.mobaxterm.encodingQuestion': 'Какая кодировка используется в этом файле?',
|
||||
'vault.import.mobaxterm.auto': 'Определить автоматически',
|
||||
'vault.import.mobaxterm.autoDesc': 'Рекомендуется. UTF-8, если он корректен, иначе китайская кодировка Windows.',
|
||||
'vault.import.mobaxterm.utf8': 'UTF-8',
|
||||
'vault.import.mobaxterm.utf8Desc': 'Для файлов, сохранённых или преобразованных в UTF-8.',
|
||||
'vault.import.mobaxterm.gb18030': 'Китайская Windows',
|
||||
'vault.import.mobaxterm.gb18030Desc': 'Для старых файлов, экспортированных в китайской Windows.',
|
||||
'vault.import.sshConfig.chooseMode': 'Выберите, как импортировать ваш файл SSH-конфига.',
|
||||
'vault.import.sshConfig.modeQuestion': 'Как вы хотите выполнить импорт?',
|
||||
'vault.import.sshConfig.importOnly': 'Только импорт',
|
||||
@@ -201,6 +209,7 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.columns.modified': 'Изменён',
|
||||
'sftp.columns.size': 'Размер',
|
||||
'sftp.columns.kind': 'Тип',
|
||||
'sftp.columns.configure': 'Выбрать видимые столбцы',
|
||||
'sftp.columns.actions': 'Действия',
|
||||
'sftp.emptyDirectory': 'Пустой каталог',
|
||||
'sftp.nav.up': 'Наверх',
|
||||
@@ -221,6 +230,7 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.moveTo.pathNotFound': 'Каталог не найден или недоступен',
|
||||
'sftp.context.download': 'Скачать',
|
||||
'sftp.context.copyToOtherPane': 'Копировать в другую панель',
|
||||
'sftp.copyToOtherPane.unavailable': 'Откройте двухпанельный режим SFTP и подключите панель назначения перед копированием файлов.',
|
||||
'sftp.copyCurrentPath': 'Копировать текущий путь',
|
||||
'sftp.copyCurrentPath.success': 'Текущий путь скопирован',
|
||||
'sftp.copyCurrentPath.error': 'Не удалось скопировать текущий путь',
|
||||
@@ -314,6 +324,9 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.picker.local.title': 'Локальная файловая система',
|
||||
'sftp.picker.local.desc': 'Просмотр локальных файлов',
|
||||
'sftp.picker.local.badge': 'Локально',
|
||||
'sftp.picker.connected.section': 'Подключённые',
|
||||
'sftp.picker.connected.badge': 'Онлайн',
|
||||
'sftp.picker.connected.connecting': 'Подключение',
|
||||
'sftp.picker.noMatch': 'Подходящие хосты не найдены',
|
||||
'sftp.permissions.title': 'Изменить права доступа',
|
||||
'sftp.permissions.owner': 'Владелец',
|
||||
@@ -417,9 +430,12 @@ export const ruVaultMessages: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.transfer': 'Передать в другую панель',
|
||||
'settings.sftp.doubleClickBehavior.openDesc': 'Открыть файл в приложении по умолчанию',
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': 'Передать файл на активный хост другой панели',
|
||||
'settings.sshDeepLink.title': 'SSH-ссылки',
|
||||
'settings.sshDeepLink.enable': 'Открывать ssh:// ссылки в Netcatty',
|
||||
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// ссылки из браузеров и других приложений.',
|
||||
'settings.sshDeepLink.title': 'SSH- и Telnet-ссылки',
|
||||
'settings.sshDeepLink.enable': 'Открывать ssh:// и telnet:// ссылки в Netcatty',
|
||||
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// и telnet:// ссылки из браузеров и других приложений.',
|
||||
'settings.jmsDeepLink.title': 'Ссылки JumpServer',
|
||||
'settings.jmsDeepLink.enable': 'Обрабатывать ссылки jms://',
|
||||
'settings.jmsDeepLink.enableDesc': 'Зарегистрировать Netcatty как обработчик клиентских ссылок JumpServer jms://. Может конфликтовать с официальным клиентом JumpServer.',
|
||||
|
||||
// Settings > SFTP Auto Sync
|
||||
'settings.sftp.autoSync': 'Автосинхронизация с удалённым сервером',
|
||||
@@ -507,6 +523,17 @@ export const ruVaultMessages: Messages = {
|
||||
'quickConnect.knownHost.addAndContinue': 'Добавить и продолжить',
|
||||
'quickConnect.addKey': 'Добавить ключ',
|
||||
'quickConnect.warning.unparsedOptions': 'Некоторые аргументы SSH были проигнорированы: {options}',
|
||||
'quickConnect.identity.label': 'Набор учётных данных',
|
||||
'quickConnect.identity.placeholder': 'Выберите сохранённый профиль',
|
||||
'quickConnect.identity.empty': 'Сохранённые профили не найдены',
|
||||
'quickConnect.identity.hint': 'Выберите профиль, чтобы автоматически подставить имя пользователя и способ входа.',
|
||||
'quickConnect.identity.selectedHint': 'Готово к подключению как {username}.',
|
||||
'quickConnect.identity.method.password': 'Пароль',
|
||||
'quickConnect.identity.method.key': 'Ключ SSH',
|
||||
'quickConnect.identity.method.certificate': 'Сертификат',
|
||||
'quickConnect.identity.unavailable': 'Для этого профиля нет доступных учётных данных на устройстве.',
|
||||
'quickConnect.identity.useCustom': 'Использовать другие данные',
|
||||
'quickConnect.identity.connect': 'Подключиться с профилем',
|
||||
|
||||
// Terminal
|
||||
'terminal.connectionErrorTitle': 'Ошибка подключения',
|
||||
@@ -650,6 +677,15 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
|
||||
'hostDetails.credential.browseKeyFile': 'Обзор...',
|
||||
'hostDetails.credential.missing': 'Учётные данные не найдены',
|
||||
'hostDetails.section.systemSshAgent': 'Системный SSH-агент',
|
||||
'hostDetails.systemSshAgent': 'Использовать системный SSH-агент для входа',
|
||||
'hostDetails.systemSshAgent.desc': 'Использовать ключи, разблокированные macOS, Linux, Windows, 1Password или другим совместимым агентом',
|
||||
'hostDetails.systemSshAgent.socket': 'Сокет агента',
|
||||
'hostDetails.systemSshAgent.socket.desc': 'Оставьте пустым, чтобы использовать SSH_AUTH_SOCK',
|
||||
'hostDetails.systemSshAgent.identitiesOnly': 'Только настроенные ключи',
|
||||
'hostDetails.systemSshAgent.identitiesOnly.desc': 'Не пробовать другие ключи, загруженные в агент',
|
||||
'hostDetails.systemSshAgent.notRunning': 'Системный SSH-агент недоступен',
|
||||
'hostDetails.systemSshAgent.notRunningHint': 'Запустите или разблокируйте SSH-агент либо укажите путь к его сокету выше.',
|
||||
'hostDetails.keys.search': 'Поиск ключей...',
|
||||
'hostDetails.keys.empty': 'Нет доступных ключей',
|
||||
'hostDetails.certs.search': 'Поиск сертификатов...',
|
||||
@@ -691,6 +727,11 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.keepalive.interval': 'Интервал (секунды)',
|
||||
'hostDetails.keepalive.countMax': 'Макс. число пропущенных keepalive',
|
||||
'hostDetails.keepalive.disabledHint': 'Интервал = 0 отключает keepalive для этого хоста. Для определения разорванного соединения сессия будет полагаться на TCP-таймауты.',
|
||||
'hostDetails.section.sshTimeouts': 'Тайм-ауты SSH-подключения',
|
||||
'hostDetails.sshTimeouts.tcpConnect': 'Тайм-аут TCP',
|
||||
'hostDetails.sshTimeouts.tcpConnect.desc': 'Сколько секунд ждать сетевого подключения к этому хосту.',
|
||||
'hostDetails.sshTimeouts.authReady': 'Тайм-аут SSH-входа',
|
||||
'hostDetails.sshTimeouts.authReady.desc': 'Сколько секунд ждать согласования SSH и завершения аутентификации после подключения.',
|
||||
'hostDetails.backspaceBehavior': 'Поведение Backspace',
|
||||
'hostDetails.backspaceBehavior.default': 'По умолчанию',
|
||||
'hostDetails.jumpHosts': 'Прокси через хосты',
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { enSystemManagerMessages } from "./en/systemManager";
|
||||
import { enTerminalMessages } from "./en/terminal";
|
||||
import { ruSystemManagerMessages } from "./ru/systemManager";
|
||||
import { ruTerminalMessages } from "./ru/terminal";
|
||||
import { zhCnSystemManagerMessages } from "./zh-CN/systemManager";
|
||||
import { zhCNVaultMessages } from "./zh-CN/vault";
|
||||
import { zhTwSystemManagerMessages } from "./zh-TW/systemManager";
|
||||
import { zhTWVaultMessages } from "./zh-TW/vault";
|
||||
|
||||
test("SSH network latency is explicit in every locale and UI surface", () => {
|
||||
const labels = [
|
||||
[enTerminalMessages, enSystemManagerMessages, "SSH network latency"],
|
||||
[ruTerminalMessages, ruSystemManagerMessages, "Сетевая задержка SSH"],
|
||||
[zhCNVaultMessages, zhCnSystemManagerMessages, "SSH 网络延迟"],
|
||||
[zhTWVaultMessages, zhTwSystemManagerMessages, "SSH 網路延遲"],
|
||||
] as const;
|
||||
|
||||
for (const [terminalMessages, systemManagerMessages, expected] of labels) {
|
||||
assert.equal(terminalMessages["terminal.serverStats.latency"], expected);
|
||||
assert.equal(systemManagerMessages["systemManager.overview.latency"], expected);
|
||||
}
|
||||
});
|
||||
@@ -36,6 +36,29 @@ test("localized settings include workspace focus indicator labels", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include network proxy labels", () => {
|
||||
const keys = [
|
||||
"settings.system.networkProxy.title",
|
||||
"settings.system.networkProxy.description",
|
||||
"settings.system.networkProxy.mode",
|
||||
"settings.system.networkProxy.mode.system",
|
||||
"settings.system.networkProxy.mode.direct",
|
||||
"settings.system.networkProxy.mode.custom",
|
||||
"settings.system.networkProxy.url",
|
||||
"settings.system.networkProxy.url.placeholder",
|
||||
"settings.system.networkProxy.url.desc",
|
||||
"settings.system.networkProxy.bypass",
|
||||
"settings.system.networkProxy.bypass.placeholder",
|
||||
"settings.system.networkProxy.bypass.desc",
|
||||
"settings.system.networkProxy.hint",
|
||||
];
|
||||
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing network proxy labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include terminal font weight option labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.font.weight.thin",
|
||||
|
||||
@@ -185,6 +185,71 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.toolAccess.description': '选择外部 Agent 访问 Netcatty 会话的方式。MCP 会暴露内置服务器,Skills + CLI 会引导 Agent 读取本地 Skill 并调用 Netcatty CLI。',
|
||||
'ai.toolAccess.mode.mcp': 'MCP',
|
||||
'ai.toolAccess.mode.skills': 'Skills + CLI',
|
||||
|
||||
// External MCP
|
||||
'ai.externalMcp.title': '对外 MCP',
|
||||
'ai.externalMcp.description': '把 Netcatty 作为 MCP 服务器暴露给 Codex、Claude Code、Cursor、Grok 等外部客户端。工具面与应用内 Agent 相同(终端、SFTP、Vault、端口转发)。客户端连接期间请保持 Netcatty 运行。',
|
||||
'ai.externalMcp.sessionsExposed': '作用域内会话:{count}',
|
||||
'ai.externalMcp.mode': '可用模式',
|
||||
'ai.externalMcp.mode.temporary': '临时',
|
||||
'ai.externalMcp.mode.persistent': '常开',
|
||||
'ai.externalMcp.mode.description': '临时模式会在空闲超时后自动关闭;常开模式会在 Netcatty 启动时恢复对外 MCP。',
|
||||
'ai.externalMcp.idleTimeout': '空闲超时',
|
||||
'ai.externalMcp.idleTimeout.description': '临时模式下,若超过该分钟数没有 MCP 操作,将自动关闭对外 MCP。',
|
||||
'ai.externalMcp.idleTimeout.minutes': '分钟',
|
||||
'ai.externalMcp.usage.title': '使用说明',
|
||||
'ai.externalMcp.usage.keepRunning': '1. 打开对外 MCP 开关,并保持 Netcatty 运行。',
|
||||
'ai.externalMcp.usage.localhost': '2. 客户端通过本地 launcher 连接(仅 127.0.0.1)。关闭开关后会删除 discovery。',
|
||||
'ai.externalMcp.usage.permissions': '3. 写操作遵循「设置 → AI → 安全」(观察 / 确认 / 自动)以及命令黑名单。',
|
||||
'ai.externalMcp.usage.capabilities': '4. 完整 catalog 工具可用:终端、SFTP、Vault、端口转发。密码 / 私钥不会返回。',
|
||||
'ai.externalMcp.help.ariaLabel': '对外 MCP 使用说明',
|
||||
'ai.externalMcp.security': '安全',
|
||||
'ai.externalMcp.security.description': '仅监听 127.0.0.1,使用轮换 token,写操作复用 AI 权限模式,关闭时删除 discovery。不做 OAuth——这是本机桌面桥接。',
|
||||
'ai.externalMcp.permissionMode': '当前权限模式:{mode}',
|
||||
'ai.externalMcp.permissionMode.label': '写操作权限模式',
|
||||
'ai.externalMcp.permissionMode.hint': '与「设置 → AI → 安全」为同一项。自动:Netcatty 写操作不再弹本应用审批;确认:每次询问。外部客户端(Codex / Claude / Grok)仍可能有自己的工具审批界面。',
|
||||
'ai.externalMcp.permissionMode.unknown': '未知',
|
||||
'ai.externalMcp.discovery': 'Discovery',
|
||||
'ai.externalMcp.launcher': 'Launcher',
|
||||
'ai.externalMcp.unavailable': '不可用',
|
||||
'ai.externalMcp.bridgeUnavailable': '对外 MCP 桥接不可用',
|
||||
'ai.externalMcp.copy': '复制',
|
||||
'ai.externalMcp.copied': '已复制',
|
||||
'ai.externalMcp.copyFailed': '复制失败,请手动复制。',
|
||||
'ai.externalMcp.refresh': '刷新',
|
||||
'ai.externalMcp.clientConfiguration': '客户端配置',
|
||||
'ai.externalMcp.clientConfiguration.description': '选择客户端一键安装,或复制 CLI / 配置片段。',
|
||||
'ai.externalMcp.client.codex': 'Codex',
|
||||
'ai.externalMcp.client.claude': 'Claude Code',
|
||||
'ai.externalMcp.client.grok': 'Grok',
|
||||
'ai.externalMcp.client.cursor': 'Cursor',
|
||||
'ai.externalMcp.cliCommand': 'CLI 命令',
|
||||
'ai.externalMcp.configSnippet': '配置片段',
|
||||
'ai.externalMcp.addToCodex': '添加到 Codex',
|
||||
'ai.externalMcp.addToClaude': '添加到 Claude Code',
|
||||
'ai.externalMcp.addToGrok': '添加到 Grok',
|
||||
'ai.externalMcp.codexAdded': '已添加 Codex MCP 条目。请重启 Codex 或打开新会话。',
|
||||
'ai.externalMcp.claudeAdded': '已添加 Claude Code MCP 条目。请重启 Claude Code 或打开新会话。',
|
||||
'ai.externalMcp.grokAdded': '已添加 Grok MCP 条目。请重启 Grok 或打开新会话。',
|
||||
'ai.externalMcp.installCodex': '请先单独安装 Codex,然后点击刷新。',
|
||||
'ai.externalMcp.installClaude': '请先单独安装 Claude Code,然后点击刷新。',
|
||||
'ai.externalMcp.installGrok': '请先单独安装 Grok CLI,然后点击刷新。',
|
||||
'ai.externalMcp.conflict.description': '已存在指向其他位置的 netcatty-external 条目,请手动删除或修改。',
|
||||
'ai.externalMcp.enableForLauncher': '请先启用对外 MCP,以获取可用的 launcher 路径。',
|
||||
'ai.externalMcp.cursor.title': 'Cursor / 其他客户端',
|
||||
'ai.externalMcp.cursor.description': '合并到 MCP 配置文件(例如 ~/.cursor/mcp.json)。若已有其他服务器,请勿整文件覆盖。',
|
||||
'ai.externalMcp.status.unavailable': '不可用',
|
||||
'ai.externalMcp.status.disabled': '已关闭',
|
||||
'ai.externalMcp.status.running': '运行中',
|
||||
'ai.externalMcp.status.starting': '启动中',
|
||||
'ai.externalMcp.status.error': '错误',
|
||||
'ai.externalMcp.status.configured': '已配置',
|
||||
'ai.externalMcp.status.notConfigured': '未配置',
|
||||
'ai.externalMcp.status.checking': '检查中',
|
||||
'ai.externalMcp.status.codexNotFound': '未找到 Codex',
|
||||
'ai.externalMcp.status.claudeNotFound': '未找到 Claude Code',
|
||||
'ai.externalMcp.status.grokNotFound': '未找到 Grok',
|
||||
'ai.externalMcp.status.conflict': '冲突',
|
||||
'ai.userSkills.title': '用户 Skills',
|
||||
'ai.userSkills.description': '打开 Netcatty 的 Skills 文件夹以添加你自己的技能目录。Netcatty 会自动扫描这些 skills,默认只注入轻量索引,只有在请求明显命中某个 skill 时才展开正文。',
|
||||
'ai.userSkills.openFolder': '打开 Skills 文件夹',
|
||||
|
||||
@@ -104,6 +104,21 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.system.credentials.unavailableHint': '在其他用户或机器上加密的凭据无法在此处解密。请在当前设备重新输入并保存凭据。',
|
||||
'settings.system.credentials.portabilityHint': '云同步可跨设备,因为使用主密钥加密;本地 safeStorage 加密仅绑定当前系统用户/设备。',
|
||||
|
||||
// Settings > System > Network Proxy
|
||||
'settings.system.networkProxy.title': '网络代理',
|
||||
'settings.system.networkProxy.description': '用于云同步与 AI 服务商的 HTTP(S) 代理。与 SSH 的 ProxyJump / ProxyCommand 配置无关。',
|
||||
'settings.system.networkProxy.mode': '代理模式',
|
||||
'settings.system.networkProxy.mode.system': '系统代理',
|
||||
'settings.system.networkProxy.mode.direct': '直连(不使用代理)',
|
||||
'settings.system.networkProxy.mode.custom': '自定义代理',
|
||||
'settings.system.networkProxy.url': '代理地址',
|
||||
'settings.system.networkProxy.url.placeholder': 'http://127.0.0.1:7890',
|
||||
'settings.system.networkProxy.url.desc': '支持 http://、https://、socks5:// 的 host:port 地址。代理 URL 中不支持账号密码。',
|
||||
'settings.system.networkProxy.bypass': '绕过列表',
|
||||
'settings.system.networkProxy.bypass.placeholder': '<local>,localhost,127.0.0.1',
|
||||
'settings.system.networkProxy.bypass.desc': '逗号分隔、不走代理的主机。可用 <local> 表示本机与局域网名称。',
|
||||
'settings.system.networkProxy.hint': '影响 Google Drive / OneDrive / GitHub 同步登录与 AI 服务商请求,不会改变 SSH 连接。',
|
||||
|
||||
// Settings > System > Crash Logs
|
||||
'settings.system.crashLogs.title': '崩溃日志',
|
||||
'settings.system.crashLogs.description': '查看主进程错误日志,帮助诊断异常行为。',
|
||||
@@ -450,6 +465,12 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.groups.errors.invalidChars': "分组名称不能包含 '/' 或 '\\\\'.",
|
||||
'vault.groups.errors.duplicatePath': '该位置已存在同名分组。',
|
||||
|
||||
'vault.deleteConfirm.title': '删除“{name}”?',
|
||||
'vault.deleteConfirm.desc': '此操作不可撤销。',
|
||||
'vault.deleteConfirm.packageDesc': '这会删除脚本包,包内脚本会保留,并移出该脚本包。',
|
||||
'vault.deleteConfirm.noteGroupDesc': '这会删除文件夹,文件夹内笔记会保留,并移出该文件夹。',
|
||||
'vault.deleteConfirm.portForwardingDesc': '这会删除该端口转发规则。',
|
||||
|
||||
'vault.managedSource.unmanage': '取消托管',
|
||||
'vault.managedSource.unmanageSuccess': '已取消托管分组',
|
||||
|
||||
@@ -492,6 +513,9 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.hosts.empty.title': '设置你的主机',
|
||||
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
|
||||
'deepLink.ssh.invalid': '无效的 ssh:// 链接',
|
||||
'deepLink.telnet.invalid': '无效的 telnet:// 链接',
|
||||
'deepLink.jms.invalid': '无效的 JumpServer 链接',
|
||||
'deepLink.jms.unsupported': '不支持的 JumpServer 协议:{{protocol}}',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': '添加数据到你的 Vault',
|
||||
@@ -506,6 +530,14 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.import.toast.noNewHosts': '从 {format} 没有导入到新的主机。',
|
||||
'vault.import.toast.summary': '已导入 {count} 个主机(跳过 {skipped},重复 {duplicates})。',
|
||||
'vault.import.toast.firstIssue': '首个问题:{issue}',
|
||||
'vault.import.mobaxterm.chooseEncoding': '选择如何读取 MobaXterm 文件。',
|
||||
'vault.import.mobaxterm.encodingQuestion': '这个文件使用哪种文字编码?',
|
||||
'vault.import.mobaxterm.auto': '自动检测',
|
||||
'vault.import.mobaxterm.autoDesc': '推荐。能按 UTF-8 读取时使用 UTF-8,否则使用中文 Windows 编码。',
|
||||
'vault.import.mobaxterm.utf8': 'UTF-8',
|
||||
'vault.import.mobaxterm.utf8Desc': '适用于已保存或转换为 UTF-8 的文件。',
|
||||
'vault.import.mobaxterm.gb18030': '中文 Windows',
|
||||
'vault.import.mobaxterm.gb18030Desc': '适用于中文 Windows 上导出的旧版文件。',
|
||||
'vault.import.sshConfig.chooseMode': '选择如何导入你的 SSH config 文件。',
|
||||
'vault.import.sshConfig.modeQuestion': '你希望如何导入?',
|
||||
'vault.import.sshConfig.importOnly': '仅导入',
|
||||
@@ -595,6 +627,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.bookmark.addGlobalTooltip': '保存为全局收藏(所有主机共享)',
|
||||
'sftp.bookmark.empty': '暂无收藏路径',
|
||||
'sftp.columns.name': '名称',
|
||||
'sftp.columns.configure': '选择显示的列',
|
||||
'sftp.columns.modified': '修改时间',
|
||||
'sftp.columns.size': '大小',
|
||||
'sftp.columns.kind': '类型',
|
||||
@@ -618,6 +651,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.moveTo.pathNotFound': '目录不存在或无法访问',
|
||||
'sftp.context.download': '下载',
|
||||
'sftp.context.copyToOtherPane': '复制到另一侧',
|
||||
'sftp.copyToOtherPane.unavailable': '请打开双栏 SFTP 文件管理,并连接目标侧后再复制文件。',
|
||||
'sftp.copyCurrentPath': '复制当前路径',
|
||||
'sftp.copyCurrentPath.success': '已复制当前路径',
|
||||
'sftp.copyCurrentPath.error': '无法复制当前路径',
|
||||
@@ -711,6 +745,9 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.picker.local.title': '本地文件系统',
|
||||
'sftp.picker.local.desc': '浏览本地文件',
|
||||
'sftp.picker.local.badge': '本地',
|
||||
'sftp.picker.connected.section': '已连接',
|
||||
'sftp.picker.connected.badge': '已连接',
|
||||
'sftp.picker.connected.connecting': '连接中',
|
||||
'sftp.picker.noMatch': '没有匹配的主机',
|
||||
'sftp.permissions.title': '编辑权限',
|
||||
'sftp.permissions.owner': '所有者',
|
||||
|
||||
@@ -41,7 +41,7 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.overview.system': '系统',
|
||||
'systemManager.overview.kernel': '内核',
|
||||
'systemManager.overview.swap': '交换区',
|
||||
'systemManager.overview.latency': '延迟',
|
||||
'systemManager.overview.latency': 'SSH 网络延迟',
|
||||
'systemManager.overview.cpuCores': 'CPU 核心',
|
||||
'systemManager.overview.disks': '磁盘',
|
||||
'systemManager.overview.interfaces': '网络接口',
|
||||
|
||||
@@ -8,6 +8,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'terminal.clipboardImageUpload.failed': '无法上传剪贴板图片',
|
||||
'terminal.toolbar.configureOsc7': '配置目录追踪',
|
||||
'terminal.toolbar.detach': '移出到独立标签',
|
||||
'terminal.toolbar.dragPane': '拖动终端窗格',
|
||||
'terminal.toolbar.showActions': '显示终端操作',
|
||||
'terminal.menu.detach': '从工作区移出',
|
||||
'terminal.osc7Setup.title': '配置目录追踪',
|
||||
'terminal.osc7Setup.desc': 'Netcatty 会为当前远端用户添加 OSC 7 提示符配置。这样在 sudo 或 su 之后,SFTP 也能继续跟随终端目录。',
|
||||
@@ -104,9 +106,12 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.transfer': '传输到另一侧',
|
||||
'settings.sftp.doubleClickBehavior.openDesc': '使用默认应用程序打开文件',
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': '将文件传输到另一窗格的活动主机',
|
||||
'settings.sshDeepLink.title': 'SSH 链接',
|
||||
'settings.sshDeepLink.enable': '用 Netcatty 打开 ssh:// 链接',
|
||||
'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh:// 链接。',
|
||||
'settings.sshDeepLink.title': 'SSH 和 Telnet 链接',
|
||||
'settings.sshDeepLink.enable': '用 Netcatty 打开 ssh:// 和 telnet:// 链接',
|
||||
'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh://、telnet:// 链接。',
|
||||
'settings.jmsDeepLink.title': 'JumpServer 链接',
|
||||
'settings.jmsDeepLink.enable': '处理 jms:// 链接',
|
||||
'settings.jmsDeepLink.enableDesc': '将 Netcatty 注册为 JumpServer jms:// 客户端链接的处理程序,可能与官方 JumpServer 客户端冲突。',
|
||||
|
||||
// Settings > SFTP Auto Sync
|
||||
'settings.sftp.autoSync': '自动同步到远程',
|
||||
@@ -250,9 +255,18 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.middleClick.menu': '显示菜单',
|
||||
'settings.terminal.behavior.middleClick.paste': '粘贴',
|
||||
'settings.terminal.behavior.middleClick.disabled': '无动作',
|
||||
'settings.terminal.behavior.wordSeparators': '单词分隔符',
|
||||
'settings.terminal.behavior.wordSeparators.desc':
|
||||
'双击选择文本时作为边界的字符。空格也算;可添加 =、逗号或 :,方便选择 uid=name 里的部分内容。',
|
||||
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
|
||||
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter 发送文本',
|
||||
'settings.terminal.behavior.shiftEnterNewline.desc':
|
||||
'在终端中按 Shift+Enter 时发送配置的文本,而不是普通回车。',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': '发送内容',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'使用 \\n 表示换行,\\t 表示 Tab,\\\\ 表示反斜杠。',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` 同时清空回滚历史',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'`clear` 命令同时清空回滚历史(POSIX 默认行为)。关闭则保留历史。',
|
||||
@@ -345,6 +359,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.connection.x11Display.desc': '可选的本机 X11 显示地址。留空则使用系统默认值。',
|
||||
'settings.terminal.connection.x11Display.placeholder': '自动(:0 或 DISPLAY)',
|
||||
'settings.terminal.section.serverStats': '服务器状态(Linux)',
|
||||
'settings.terminal.hostInfoBar.show': '显示主机信息栏',
|
||||
'settings.terminal.hostInfoBar.show.desc': '在终端上方显示主机地址和服务器信息。隐藏后仍可使用操作按钮。',
|
||||
'settings.terminal.serverStats.show': '显示服务器状态',
|
||||
'settings.terminal.serverStats.show.desc': '在终端状态栏显示 CPU、内存和磁盘使用情况(仅限 Linux 服务器)。',
|
||||
'settings.terminal.serverStats.refreshInterval': '刷新间隔',
|
||||
|
||||
@@ -50,6 +50,17 @@ export const zhCNVaultMessages: Messages = {
|
||||
'quickConnect.knownHost.addAndContinue': '加入并继续',
|
||||
'quickConnect.addKey': '添加 key',
|
||||
'quickConnect.warning.unparsedOptions': '部分 SSH 参数已被忽略: {options}',
|
||||
'quickConnect.identity.label': '凭据预设',
|
||||
'quickConnect.identity.placeholder': '选择已保存的身份',
|
||||
'quickConnect.identity.empty': '没有可用的身份',
|
||||
'quickConnect.identity.hint': '选择身份后会自动带入用户名和认证信息。',
|
||||
'quickConnect.identity.selectedHint': '将以 {username} 连接。',
|
||||
'quickConnect.identity.method.password': '密码',
|
||||
'quickConnect.identity.method.key': 'SSH 密钥',
|
||||
'quickConnect.identity.method.certificate': '证书',
|
||||
'quickConnect.identity.unavailable': '此身份在当前设备上缺少可用凭据。',
|
||||
'quickConnect.identity.useCustom': '改用自定义凭据',
|
||||
'quickConnect.identity.connect': '使用预设连接',
|
||||
|
||||
// Protocol select dialog
|
||||
'protocolSelect.chooseProtocol': '选择协议',
|
||||
@@ -192,6 +203,15 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
|
||||
'hostDetails.credential.browseKeyFile': '浏览…',
|
||||
'hostDetails.credential.missing': '凭据不存在',
|
||||
'hostDetails.section.systemSshAgent': '系统 SSH 密钥',
|
||||
'hostDetails.systemSshAgent': '使用系统 SSH 密钥登录',
|
||||
'hostDetails.systemSshAgent.desc': '使用已由 macOS、Linux、Windows、1Password 或其他兼容服务解锁的密钥',
|
||||
'hostDetails.systemSshAgent.socket': '代理地址',
|
||||
'hostDetails.systemSshAgent.socket.desc': '留空时使用 SSH_AUTH_SOCK',
|
||||
'hostDetails.systemSshAgent.identitiesOnly': '仅使用已配置的密钥',
|
||||
'hostDetails.systemSshAgent.identitiesOnly.desc': '不尝试代理中其他无关密钥',
|
||||
'hostDetails.systemSshAgent.notRunning': '系统 SSH 密钥服务不可用',
|
||||
'hostDetails.systemSshAgent.notRunningHint': '请启动或解锁密钥服务,或者在上方填写它的地址。',
|
||||
'hostDetails.keys.search': '搜索密钥…',
|
||||
'hostDetails.keys.empty': '暂无密钥',
|
||||
'hostDetails.certs.search': '搜索证书…',
|
||||
@@ -233,6 +253,11 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.keepalive.interval': '间隔(秒)',
|
||||
'hostDetails.keepalive.countMax': '最大无响应保活次数',
|
||||
'hostDetails.keepalive.disabledHint': '间隔为 0 时该主机不发送保活包,仅依赖 TCP 层超时检测断连。',
|
||||
'hostDetails.section.sshTimeouts': 'SSH 连接超时',
|
||||
'hostDetails.sshTimeouts.tcpConnect': 'TCP 连接超时(秒)',
|
||||
'hostDetails.sshTimeouts.tcpConnect.desc': '等待与该主机建立网络连接的最长时间。',
|
||||
'hostDetails.sshTimeouts.authReady': '认证超时(秒)',
|
||||
'hostDetails.sshTimeouts.authReady.desc': '网络连接建立后,等待 SSH 协商和认证完成的最长时间。',
|
||||
'hostDetails.backspaceBehavior': 'Backspace 行为',
|
||||
'hostDetails.backspaceBehavior.default': '默认',
|
||||
'hostDetails.jumpHosts': '通过主机代理',
|
||||
@@ -382,7 +407,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.serverStats.disk': '磁盘使用(根分区)',
|
||||
'terminal.serverStats.diskDetails': '已挂载磁盘',
|
||||
'terminal.serverStats.network': '网络速度',
|
||||
'terminal.serverStats.latency': '延迟',
|
||||
'terminal.serverStats.latency': 'SSH 网络延迟',
|
||||
'terminal.serverStats.networkDetails': '网络接口',
|
||||
'terminal.serverStats.noData': '暂无数据',
|
||||
'terminal.dragDrop.localTitle': '拖放以插入路径',
|
||||
@@ -577,6 +602,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'cloudSync.s3.sessionToken': 'Session Token(可选)',
|
||||
'cloudSync.s3.prefix': 'Key 前缀(可选)',
|
||||
'cloudSync.s3.forcePathStyle': '强制使用 path-style URL(适用于 MinIO/R2 等)',
|
||||
'cloudSync.s3.allowInsecure': '允许不安全的连接(忽略证书错误)',
|
||||
'cloudSync.s3.showSecret': '显示密钥',
|
||||
'cloudSync.s3.validation.required': '端点、Region、Bucket、Access Key 与 Secret 必填。',
|
||||
'cloudSync.smb.title': 'SMB 设置',
|
||||
|
||||
@@ -185,6 +185,70 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.toolAccess.description': '選擇外部 Agent 存取 Netcatty 工作階段的方式。MCP 會公開內建伺服器,Skills + CLI 會引導 Agent 讀取本機 Skill 並呼叫 Netcatty CLI。',
|
||||
'ai.toolAccess.mode.mcp': 'MCP',
|
||||
'ai.toolAccess.mode.skills': 'Skills + CLI',
|
||||
|
||||
'ai.externalMcp.title': '對外 MCP',
|
||||
'ai.externalMcp.description': '將 Netcatty 作為 MCP 伺服器暴露給 Codex、Claude Code、Cursor、Grok 等外部用戶端。工具面與應用內 Agent 相同。用戶端連線期間請保持 Netcatty 執行。',
|
||||
'ai.externalMcp.sessionsExposed': '作用域內工作階段:{count}',
|
||||
'ai.externalMcp.mode': '可用模式',
|
||||
'ai.externalMcp.mode.temporary': '暫時',
|
||||
'ai.externalMcp.mode.persistent': '常開',
|
||||
'ai.externalMcp.mode.description': '暫時模式會在閒置逾時後自動關閉;常開模式會在 Netcatty 啟動時還原對外 MCP。',
|
||||
'ai.externalMcp.idleTimeout': '閒置逾時',
|
||||
'ai.externalMcp.idleTimeout.description': '暫時模式下,若超過該分鐘數沒有 MCP 操作,將自動關閉對外 MCP。',
|
||||
'ai.externalMcp.idleTimeout.minutes': '分鐘',
|
||||
'ai.externalMcp.usage.title': '使用說明',
|
||||
'ai.externalMcp.usage.keepRunning': '1. 開啟對外 MCP 開關,並保持 Netcatty 執行。',
|
||||
'ai.externalMcp.usage.localhost': '2. 用戶端透過本機 launcher 連線(僅 127.0.0.1)。關閉開關後會刪除 discovery。',
|
||||
'ai.externalMcp.usage.permissions': '3. 寫入操作遵循「設定 → AI → 安全」以及命令黑名單。',
|
||||
'ai.externalMcp.usage.capabilities': '4. 完整 catalog 工具可用:終端機、SFTP、Vault、連接埠轉發。密碼 / 私鑰不會回傳。',
|
||||
'ai.externalMcp.help.ariaLabel': '對外 MCP 使用說明',
|
||||
'ai.externalMcp.security': '安全',
|
||||
'ai.externalMcp.security.description': '僅監聽 127.0.0.1,使用輪替 token,寫入操作重用 AI 權限模式,關閉時刪除 discovery。',
|
||||
'ai.externalMcp.permissionMode': '目前權限模式:{mode}',
|
||||
'ai.externalMcp.permissionMode.label': '寫入權限模式',
|
||||
'ai.externalMcp.permissionMode.hint': '與「設定 → AI → 安全」為同一項。自動:Netcatty 寫入操作不再彈本應用審批;確認:每次詢問。外部用戶端(Codex / Claude / Grok)仍可能有自己的工具審批介面。',
|
||||
'ai.externalMcp.permissionMode.unknown': '未知',
|
||||
'ai.externalMcp.discovery': 'Discovery',
|
||||
'ai.externalMcp.launcher': 'Launcher',
|
||||
'ai.externalMcp.unavailable': '無法使用',
|
||||
'ai.externalMcp.bridgeUnavailable': '對外 MCP 橋接無法使用',
|
||||
'ai.externalMcp.copy': '複製',
|
||||
'ai.externalMcp.copied': '已複製',
|
||||
'ai.externalMcp.copyFailed': '複製失敗,請手動複製。',
|
||||
'ai.externalMcp.refresh': '重新整理',
|
||||
'ai.externalMcp.clientConfiguration': '用戶端設定',
|
||||
'ai.externalMcp.clientConfiguration.description': '選擇用戶端一鍵安裝,或複製 CLI / 設定片段。',
|
||||
'ai.externalMcp.client.codex': 'Codex',
|
||||
'ai.externalMcp.client.claude': 'Claude Code',
|
||||
'ai.externalMcp.client.grok': 'Grok',
|
||||
'ai.externalMcp.client.cursor': 'Cursor',
|
||||
'ai.externalMcp.cliCommand': 'CLI 命令',
|
||||
'ai.externalMcp.configSnippet': '設定片段',
|
||||
'ai.externalMcp.addToCodex': '新增到 Codex',
|
||||
'ai.externalMcp.addToClaude': '新增到 Claude Code',
|
||||
'ai.externalMcp.addToGrok': '新增到 Grok',
|
||||
'ai.externalMcp.codexAdded': '已新增 Codex MCP 項目。請重新啟動 Codex 或開啟新工作階段。',
|
||||
'ai.externalMcp.claudeAdded': '已新增 Claude Code MCP 項目。請重新啟動 Claude Code 或開啟新工作階段。',
|
||||
'ai.externalMcp.grokAdded': '已新增 Grok MCP 項目。請重新啟動 Grok 或開啟新工作階段。',
|
||||
'ai.externalMcp.installCodex': '請先另外安裝 Codex,然後按重新整理。',
|
||||
'ai.externalMcp.installClaude': '請先另外安裝 Claude Code,然後按重新整理。',
|
||||
'ai.externalMcp.installGrok': '請先另外安裝 Grok CLI,然後按重新整理。',
|
||||
'ai.externalMcp.conflict.description': '已存在指向其他位置的 netcatty-external 項目,請手動刪除或修改。',
|
||||
'ai.externalMcp.enableForLauncher': '請先啟用對外 MCP,以取得可用的 launcher 路徑。',
|
||||
'ai.externalMcp.cursor.title': 'Cursor / 其他用戶端',
|
||||
'ai.externalMcp.cursor.description': '合併到 MCP 設定檔(例如 ~/.cursor/mcp.json)。若已有其他伺服器,請勿整檔覆蓋。',
|
||||
'ai.externalMcp.status.unavailable': '無法使用',
|
||||
'ai.externalMcp.status.disabled': '已關閉',
|
||||
'ai.externalMcp.status.running': '執行中',
|
||||
'ai.externalMcp.status.starting': '啟動中',
|
||||
'ai.externalMcp.status.error': '錯誤',
|
||||
'ai.externalMcp.status.configured': '已設定',
|
||||
'ai.externalMcp.status.notConfigured': '未設定',
|
||||
'ai.externalMcp.status.checking': '檢查中',
|
||||
'ai.externalMcp.status.codexNotFound': '找不到 Codex',
|
||||
'ai.externalMcp.status.claudeNotFound': '找不到 Claude Code',
|
||||
'ai.externalMcp.status.grokNotFound': '找不到 Grok',
|
||||
'ai.externalMcp.status.conflict': '衝突',
|
||||
'ai.userSkills.title': '使用者 Skills',
|
||||
'ai.userSkills.description': '開啟 Netcatty 的 Skills 資料夾以新增你自己的技能目錄。Netcatty 會自動掃描這些 skills,預設只注入輕量索引,只有在請求明顯命中某個 skill 時才展開正文。',
|
||||
'ai.userSkills.openFolder': '開啟 Skills 資料夾',
|
||||
|
||||
@@ -104,6 +104,21 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.system.credentials.unavailableHint': '在其他使用者或機器上加密的憑證無法在此處解密。請在目前裝置重新輸入並儲存憑證。',
|
||||
'settings.system.credentials.portabilityHint': '雲同步可跨裝置,因為使用主金鑰加密;本機 safeStorage 加密僅綁定目前系統使用者/裝置。',
|
||||
|
||||
// Settings > System > Network Proxy
|
||||
'settings.system.networkProxy.title': '網路代理',
|
||||
'settings.system.networkProxy.description': '用於雲端同步與 AI 服務商的 HTTP(S) 代理。與 SSH 的 ProxyJump / ProxyCommand 設定無關。',
|
||||
'settings.system.networkProxy.mode': '代理模式',
|
||||
'settings.system.networkProxy.mode.system': '系統代理',
|
||||
'settings.system.networkProxy.mode.direct': '直連(不使用代理)',
|
||||
'settings.system.networkProxy.mode.custom': '自訂代理',
|
||||
'settings.system.networkProxy.url': '代理位址',
|
||||
'settings.system.networkProxy.url.placeholder': 'http://127.0.0.1:7890',
|
||||
'settings.system.networkProxy.url.desc': '支援 http://、https://、socks5:// 的 host:port 位址。代理 URL 中不支援帳號密碼。',
|
||||
'settings.system.networkProxy.bypass': '略過清單',
|
||||
'settings.system.networkProxy.bypass.placeholder': '<local>,localhost,127.0.0.1',
|
||||
'settings.system.networkProxy.bypass.desc': '逗號分隔、不走代理的主機。可用 <local> 表示本機與區域網路名稱。',
|
||||
'settings.system.networkProxy.hint': '影響 Google Drive / OneDrive / GitHub 同步登入與 AI 服務商請求,不會改變 SSH 連線。',
|
||||
|
||||
// Settings > System > Crash Logs
|
||||
'settings.system.crashLogs.title': '崩潰日誌',
|
||||
'settings.system.crashLogs.description': '檢視主程序錯誤日誌,協助診斷異常行為。',
|
||||
@@ -450,6 +465,12 @@ export const zhTWCoreMessages: Messages = {
|
||||
'vault.groups.errors.invalidChars': "分組名稱不能包含 '/' 或 '\\\\'.",
|
||||
'vault.groups.errors.duplicatePath': '該位置已存在同名分組。',
|
||||
|
||||
'vault.deleteConfirm.title': '刪除「{name}」?',
|
||||
'vault.deleteConfirm.desc': '此操作不可復原。',
|
||||
'vault.deleteConfirm.packageDesc': '這會刪除程式碼包,包內程式碼片段會保留,並移出該程式碼包。',
|
||||
'vault.deleteConfirm.noteGroupDesc': '這會刪除資料夾,資料夾內筆記會保留,並移出該資料夾。',
|
||||
'vault.deleteConfirm.portForwardingDesc': '這會刪除此埠轉發規則。',
|
||||
|
||||
'vault.managedSource.unmanage': '取消託管',
|
||||
'vault.managedSource.unmanageSuccess': '已取消託管分組',
|
||||
|
||||
@@ -492,6 +513,9 @@ export const zhTWCoreMessages: Messages = {
|
||||
'vault.hosts.empty.title': '設定你的主機',
|
||||
'vault.hosts.empty.desc': '儲存主機以快速連線到你的伺服器、虛擬機器和容器。',
|
||||
'deepLink.ssh.invalid': '無效的 ssh:// 連結',
|
||||
'deepLink.telnet.invalid': '無效的 telnet:// 連結',
|
||||
'deepLink.jms.invalid': '無效的 JumpServer 連結',
|
||||
'deepLink.jms.unsupported': '不支援的 JumpServer 協定:{{protocol}}',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': '新增資料到你的 Vault',
|
||||
@@ -506,6 +530,14 @@ export const zhTWCoreMessages: Messages = {
|
||||
'vault.import.toast.noNewHosts': '從 {format} 沒有匯入到新的主機。',
|
||||
'vault.import.toast.summary': '已匯入 {count} 個主機(跳過 {skipped},重複 {duplicates})。',
|
||||
'vault.import.toast.firstIssue': '首個問題:{issue}',
|
||||
'vault.import.mobaxterm.chooseEncoding': '選擇如何讀取 MobaXterm 檔案。',
|
||||
'vault.import.mobaxterm.encodingQuestion': '這個檔案使用哪種文字編碼?',
|
||||
'vault.import.mobaxterm.auto': '自動偵測',
|
||||
'vault.import.mobaxterm.autoDesc': '建議。能以 UTF-8 讀取時使用 UTF-8,否則使用中文 Windows 編碼。',
|
||||
'vault.import.mobaxterm.utf8': 'UTF-8',
|
||||
'vault.import.mobaxterm.utf8Desc': '適用於已儲存或轉換為 UTF-8 的檔案。',
|
||||
'vault.import.mobaxterm.gb18030': '中文 Windows',
|
||||
'vault.import.mobaxterm.gb18030Desc': '適用於中文 Windows 上匯出的舊版檔案。',
|
||||
'vault.import.sshConfig.chooseMode': '選擇如何匯入你的 SSH config 檔案。',
|
||||
'vault.import.sshConfig.modeQuestion': '你希望如何匯入?',
|
||||
'vault.import.sshConfig.importOnly': '僅匯入',
|
||||
@@ -595,6 +627,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.bookmark.addGlobalTooltip': '儲存為全域收藏(所有主機共享)',
|
||||
'sftp.bookmark.empty': '暫無收藏路徑',
|
||||
'sftp.columns.name': '名稱',
|
||||
'sftp.columns.configure': '選擇顯示的欄位',
|
||||
'sftp.columns.modified': '修改時間',
|
||||
'sftp.columns.size': '大小',
|
||||
'sftp.columns.kind': '型別',
|
||||
@@ -618,6 +651,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.moveTo.pathNotFound': '目錄不存在或無法存取',
|
||||
'sftp.context.download': '下載',
|
||||
'sftp.context.copyToOtherPane': '複製到另一側',
|
||||
'sftp.copyToOtherPane.unavailable': '請開啟雙欄 SFTP 檔案管理,並連線目標側後再複製檔案。',
|
||||
'sftp.copyCurrentPath': '複製目前路徑',
|
||||
'sftp.copyCurrentPath.success': '已複製目前路徑',
|
||||
'sftp.copyCurrentPath.error': '無法複製目前路徑',
|
||||
@@ -711,6 +745,9 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.picker.local.title': '本機檔案系統',
|
||||
'sftp.picker.local.desc': '瀏覽本機檔案',
|
||||
'sftp.picker.local.badge': '本機',
|
||||
'sftp.picker.connected.section': '已連線',
|
||||
'sftp.picker.connected.badge': '已連線',
|
||||
'sftp.picker.connected.connecting': '連線中',
|
||||
'sftp.picker.noMatch': '沒有相符的主機',
|
||||
'sftp.permissions.title': '編輯權限',
|
||||
'sftp.permissions.owner': '所有者',
|
||||
|
||||
@@ -41,7 +41,7 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.overview.system': '系統',
|
||||
'systemManager.overview.kernel': '核心',
|
||||
'systemManager.overview.swap': '交換區',
|
||||
'systemManager.overview.latency': '延遲',
|
||||
'systemManager.overview.latency': 'SSH 網路延遲',
|
||||
'systemManager.overview.cpuCores': 'CPU 核心',
|
||||
'systemManager.overview.disks': '磁碟',
|
||||
'systemManager.overview.interfaces': '網路介面',
|
||||
|
||||
@@ -8,6 +8,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'terminal.clipboardImageUpload.failed': '無法上傳剪貼簿圖片',
|
||||
'terminal.toolbar.configureOsc7': '設定目錄追蹤',
|
||||
'terminal.toolbar.detach': '移出到獨立標籤',
|
||||
'terminal.toolbar.dragPane': '拖動終端窗格',
|
||||
'terminal.toolbar.showActions': '顯示終端操作',
|
||||
'terminal.menu.detach': '從工作區移出',
|
||||
'terminal.osc7Setup.title': '設定目錄追蹤',
|
||||
'terminal.osc7Setup.desc': 'Netcatty 會為目前遠端使用者新增 OSC 7 提示字元設定。這樣在 sudo 或 su 之後,SFTP 也能繼續跟隨終端目錄。',
|
||||
@@ -104,9 +106,12 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.transfer': '傳輸到另一側',
|
||||
'settings.sftp.doubleClickBehavior.openDesc': '使用預設應用程式開啟檔案',
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': '將檔案傳輸到另一窗格的使用中主機',
|
||||
'settings.sshDeepLink.title': 'SSH 連結',
|
||||
'settings.sshDeepLink.enable': '用 Netcatty 開啟 ssh:// 連結',
|
||||
'settings.sshDeepLink.enableDesc': '允許 Netcatty 接管來自瀏覽器和其他應用程式的 ssh:// 連結。',
|
||||
'settings.sshDeepLink.title': 'SSH 和 Telnet 連結',
|
||||
'settings.sshDeepLink.enable': '用 Netcatty 開啟 ssh:// 和 telnet:// 連結',
|
||||
'settings.sshDeepLink.enableDesc': '允許 Netcatty 接管來自瀏覽器和其他應用程式的 ssh://、telnet:// 連結。',
|
||||
'settings.jmsDeepLink.title': 'JumpServer 連結',
|
||||
'settings.jmsDeepLink.enable': '處理 jms:// 連結',
|
||||
'settings.jmsDeepLink.enableDesc': '將 Netcatty 註冊為 JumpServer jms:// 用戶端連結的處理程式,可能與官方 JumpServer 用戶端衝突。',
|
||||
|
||||
// Settings > SFTP Auto Sync
|
||||
'settings.sftp.autoSync': '自動同步到遠端',
|
||||
@@ -250,9 +255,18 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.middleClick.menu': '顯示選單',
|
||||
'settings.terminal.behavior.middleClick.paste': '貼上',
|
||||
'settings.terminal.behavior.middleClick.disabled': '無動作',
|
||||
'settings.terminal.behavior.wordSeparators': '單字分隔符',
|
||||
'settings.terminal.behavior.wordSeparators.desc':
|
||||
'雙擊選取文字時作為邊界的字元。空格也算;可加入 =、逗號或 :,方便選取 uid=name 裡的部分內容。',
|
||||
'settings.terminal.behavior.bracketedPaste': '括號貼上模式',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'貼上文字時使用轉義序列包裹,以便終端區分貼上和鍵入。如果出現 ^[[200~ 字樣請關閉此選項。',
|
||||
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter 傳送文字',
|
||||
'settings.terminal.behavior.shiftEnterNewline.desc':
|
||||
'在終端中按 Shift+Enter 時傳送設定的文字,而不是普通 Enter。',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': '傳送內容',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'使用 \\n 表示換行,\\t 表示 Tab,\\\\ 表示反斜線。',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` 同時清空捲動緩衝',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'`clear` 指令同時清空捲動緩衝(POSIX 預設行為)。關閉則保留歷史。',
|
||||
@@ -345,6 +359,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.connection.x11Display.desc': '可選的本機 X11 顯示地址。留空則使用系統預設值。',
|
||||
'settings.terminal.connection.x11Display.placeholder': '自動(:0 或 DISPLAY)',
|
||||
'settings.terminal.section.serverStats': '伺服器狀態(Linux)',
|
||||
'settings.terminal.hostInfoBar.show': '顯示主機資訊列',
|
||||
'settings.terminal.hostInfoBar.show.desc': '在終端上方顯示主機地址和伺服器資訊。隱藏後仍可使用操作按鈕。',
|
||||
'settings.terminal.serverStats.show': '顯示伺服器狀態',
|
||||
'settings.terminal.serverStats.show.desc': '在終端狀態列顯示 CPU、記憶體和磁碟使用情況(僅限 Linux 伺服器)。',
|
||||
'settings.terminal.serverStats.refreshInterval': '重新整理間隔',
|
||||
|
||||
@@ -50,6 +50,17 @@ export const zhTWVaultMessages: Messages = {
|
||||
'quickConnect.knownHost.addAndContinue': '加入並繼續',
|
||||
'quickConnect.addKey': '新增 key',
|
||||
'quickConnect.warning.unparsedOptions': '部分 SSH 引數已被忽略: {options}',
|
||||
'quickConnect.identity.label': '憑據預設',
|
||||
'quickConnect.identity.placeholder': '選擇已儲存的身分',
|
||||
'quickConnect.identity.empty': '沒有可用的身分',
|
||||
'quickConnect.identity.hint': '選擇身分後會自動帶入使用者名稱和驗證資訊。',
|
||||
'quickConnect.identity.selectedHint': '將以 {username} 連線。',
|
||||
'quickConnect.identity.method.password': '密碼',
|
||||
'quickConnect.identity.method.key': 'SSH 金鑰',
|
||||
'quickConnect.identity.method.certificate': '憑證',
|
||||
'quickConnect.identity.unavailable': '此身分在目前裝置上缺少可用憑據。',
|
||||
'quickConnect.identity.useCustom': '改用自訂憑據',
|
||||
'quickConnect.identity.connect': '使用預設連線',
|
||||
|
||||
// Protocol select dialog
|
||||
'protocolSelect.chooseProtocol': '選擇協定',
|
||||
@@ -192,6 +203,15 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
|
||||
'hostDetails.credential.browseKeyFile': '瀏覽…',
|
||||
'hostDetails.credential.missing': '憑證不存在',
|
||||
'hostDetails.section.systemSshAgent': '系統 SSH 金鑰',
|
||||
'hostDetails.systemSshAgent': '使用系統 SSH 金鑰登入',
|
||||
'hostDetails.systemSshAgent.desc': '使用已由 macOS、Linux、Windows、1Password 或其他相容服務解鎖的金鑰',
|
||||
'hostDetails.systemSshAgent.socket': '代理位址',
|
||||
'hostDetails.systemSshAgent.socket.desc': '留空時使用 SSH_AUTH_SOCK',
|
||||
'hostDetails.systemSshAgent.identitiesOnly': '僅使用已設定的金鑰',
|
||||
'hostDetails.systemSshAgent.identitiesOnly.desc': '不嘗試代理中其他無關金鑰',
|
||||
'hostDetails.systemSshAgent.notRunning': '系統 SSH 金鑰服務無法使用',
|
||||
'hostDetails.systemSshAgent.notRunningHint': '請啟動或解鎖金鑰服務,或在上方填寫其位址。',
|
||||
'hostDetails.keys.search': '搜尋金鑰…',
|
||||
'hostDetails.keys.empty': '暫無金鑰',
|
||||
'hostDetails.certs.search': '搜尋憑證…',
|
||||
@@ -233,6 +253,11 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.keepalive.interval': '間隔(秒)',
|
||||
'hostDetails.keepalive.countMax': '最大無響應保活次數',
|
||||
'hostDetails.keepalive.disabledHint': '間隔為 0 時該主機不傳送保活包,僅依賴 TCP 層超時偵測斷連。',
|
||||
'hostDetails.section.sshTimeouts': 'SSH 連線逾時',
|
||||
'hostDetails.sshTimeouts.tcpConnect': 'TCP 連線逾時(秒)',
|
||||
'hostDetails.sshTimeouts.tcpConnect.desc': '等待與該主機建立網路連線的最長時間。',
|
||||
'hostDetails.sshTimeouts.authReady': '驗證逾時(秒)',
|
||||
'hostDetails.sshTimeouts.authReady.desc': '網路連線建立後,等待 SSH 交握和驗證完成的最長時間。',
|
||||
'hostDetails.backspaceBehavior': 'Backspace 行為',
|
||||
'hostDetails.backspaceBehavior.default': '預設',
|
||||
'hostDetails.jumpHosts': '透過主機代理',
|
||||
@@ -382,7 +407,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.serverStats.disk': '磁碟使用(根分割槽)',
|
||||
'terminal.serverStats.diskDetails': '已掛載磁碟',
|
||||
'terminal.serverStats.network': '網路速度',
|
||||
'terminal.serverStats.latency': '延遲',
|
||||
'terminal.serverStats.latency': 'SSH 網路延遲',
|
||||
'terminal.serverStats.networkDetails': '網路介面',
|
||||
'terminal.serverStats.noData': '暫無資料',
|
||||
'terminal.dragDrop.localTitle': '拖放以插入路徑',
|
||||
@@ -577,6 +602,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'cloudSync.s3.sessionToken': 'Session Token(可選)',
|
||||
'cloudSync.s3.prefix': 'Key 字首(可選)',
|
||||
'cloudSync.s3.forcePathStyle': '強制使用 path-style URL(適用於 MinIO/R2 等)',
|
||||
'cloudSync.s3.allowInsecure': '允許不安全的連線(忽略憑證錯誤)',
|
||||
'cloudSync.s3.showSecret': '顯示金鑰',
|
||||
'cloudSync.s3.validation.required': '端點、Region、Bucket、Access Key 與 Secret 必填。',
|
||||
'cloudSync.smb.title': 'SMB 設定',
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { idleThemeUserIntent, resolveGlobalTerminalAppearance } from "../../domain/terminalAppearanceRuntime.ts";
|
||||
import {
|
||||
hasPersistedAppearanceChanged,
|
||||
resolveAppearanceStorageEvent,
|
||||
resolveAppearanceSyncState,
|
||||
resolveIncomingAppearanceValue,
|
||||
type AppearanceState,
|
||||
type AppearanceRenderSnapshot,
|
||||
type StoredAppearanceValues,
|
||||
} from "./appearanceSync.ts";
|
||||
import {
|
||||
STORAGE_KEY_ACCENT_MODE,
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
} from "../../infrastructure/config/storageKeys.ts";
|
||||
|
||||
const systemLight: AppearanceRenderSnapshot = {
|
||||
theme: "system",
|
||||
resolvedTheme: "light",
|
||||
lightUiThemeId: "snow",
|
||||
darkUiThemeId: "midnight",
|
||||
accentMode: "theme",
|
||||
customAccent: "208 100% 50%",
|
||||
};
|
||||
|
||||
test("an OS color event cannot persist a stale System choice over a newer Dark choice", () => {
|
||||
const systemDark = { ...systemLight, resolvedTheme: "dark" as const };
|
||||
let storedTheme: "light" | "dark" | "system" = "dark";
|
||||
|
||||
if (hasPersistedAppearanceChanged(systemLight, systemDark)) {
|
||||
storedTheme = systemDark.theme;
|
||||
}
|
||||
|
||||
assert.equal(storedTheme, "dark");
|
||||
});
|
||||
|
||||
test("an explicit theme choice remains a persisted appearance change", () => {
|
||||
assert.equal(
|
||||
hasPersistedAppearanceChanged(systemLight, {
|
||||
...systemLight,
|
||||
theme: "dark",
|
||||
resolvedTheme: "dark",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("an appearance IPC value wins over stale local storage for the changed key", () => {
|
||||
const incoming = { key: STORAGE_KEY_THEME, value: "dark" };
|
||||
|
||||
assert.equal(
|
||||
resolveIncomingAppearanceValue(
|
||||
incoming,
|
||||
STORAGE_KEY_THEME,
|
||||
"system",
|
||||
"system",
|
||||
(value): value is "light" | "dark" | "system" => (
|
||||
value === "light" || value === "dark" || value === "system"
|
||||
),
|
||||
),
|
||||
"dark",
|
||||
);
|
||||
// Non-matching keyed IPC must keep the current in-memory value, not storage.
|
||||
assert.equal(
|
||||
resolveIncomingAppearanceValue(
|
||||
incoming,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
"stale-from-storage",
|
||||
"midnight",
|
||||
(value): value is string => typeof value === "string",
|
||||
),
|
||||
"midnight",
|
||||
);
|
||||
// Full rehydrate (no incoming) still prefers storage.
|
||||
assert.equal(
|
||||
resolveIncomingAppearanceValue(
|
||||
undefined,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
"from-storage",
|
||||
"midnight",
|
||||
(value): value is string => typeof value === "string",
|
||||
),
|
||||
"from-storage",
|
||||
);
|
||||
});
|
||||
|
||||
test("a non-theme appearance IPC value wins over stale local storage for that key", () => {
|
||||
const current: AppearanceState = {
|
||||
theme: "dark",
|
||||
lightUiThemeId: "snow",
|
||||
darkUiThemeId: "midnight",
|
||||
accentMode: "theme",
|
||||
customAccent: "208 100% 50%",
|
||||
};
|
||||
// Storage still lags for every non-theme field (the race the review covers).
|
||||
const staleStored: StoredAppearanceValues = {
|
||||
theme: "dark",
|
||||
lightUiThemeId: "snow",
|
||||
darkUiThemeId: "midnight",
|
||||
accentMode: "theme",
|
||||
customAccent: "208 100% 50%",
|
||||
};
|
||||
|
||||
// Picking a follow-app terminal theme updates darkUiThemeId; only that key is
|
||||
// announced on the IPC payload, so the reducer must prefer the payload.
|
||||
const darkUiSelection = {
|
||||
key: STORAGE_KEY_UI_THEME_DARK,
|
||||
value: "github",
|
||||
};
|
||||
const nextDarkUi = resolveAppearanceSyncState(current, {
|
||||
...staleStored,
|
||||
darkUiThemeId: "midnight",
|
||||
}, darkUiSelection);
|
||||
assert.equal(nextDarkUi.darkUiThemeId, "github");
|
||||
assert.equal(nextDarkUi.theme, "dark");
|
||||
assert.equal(nextDarkUi.lightUiThemeId, "snow");
|
||||
|
||||
const lightUiSelection = {
|
||||
key: STORAGE_KEY_UI_THEME_LIGHT,
|
||||
value: "flexoki",
|
||||
};
|
||||
const nextLightUi = resolveAppearanceSyncState(current, {
|
||||
...staleStored,
|
||||
lightUiThemeId: "snow",
|
||||
}, lightUiSelection);
|
||||
assert.equal(nextLightUi.lightUiThemeId, "flexoki");
|
||||
|
||||
const accentModeSelection = {
|
||||
key: STORAGE_KEY_ACCENT_MODE,
|
||||
value: "custom",
|
||||
};
|
||||
const nextAccentMode = resolveAppearanceSyncState(current, {
|
||||
...staleStored,
|
||||
accentMode: "theme",
|
||||
}, accentModeSelection);
|
||||
assert.equal(nextAccentMode.accentMode, "custom");
|
||||
|
||||
const customAccentSelection = {
|
||||
key: STORAGE_KEY_COLOR,
|
||||
value: "221.2 83.2% 53.3%",
|
||||
};
|
||||
const nextCustomAccent = resolveAppearanceSyncState(current, {
|
||||
...staleStored,
|
||||
customAccent: "208 100% 50%",
|
||||
}, customAccentSelection);
|
||||
assert.equal(nextCustomAccent.customAccent, "221.2 83.2% 53.3%");
|
||||
|
||||
// Without an announced key, full rehydrate still reads storage.
|
||||
const noIncoming = resolveAppearanceSyncState(current, {
|
||||
...staleStored,
|
||||
darkUiThemeId: "github",
|
||||
});
|
||||
assert.equal(noIncoming.darkUiThemeId, "github");
|
||||
});
|
||||
|
||||
test("sequential keyed appearance IPC updates compose without stale-storage clobber", () => {
|
||||
// One action changes theme + dark UI theme; the sender emits two keyed notifies.
|
||||
// Storage still holds the pre-change values for the entire sequence.
|
||||
const initial: AppearanceState = {
|
||||
theme: "system",
|
||||
lightUiThemeId: "snow",
|
||||
darkUiThemeId: "midnight",
|
||||
accentMode: "theme",
|
||||
customAccent: "208 100% 50%",
|
||||
};
|
||||
const staleStored: StoredAppearanceValues = { ...initial };
|
||||
|
||||
let next = resolveAppearanceSyncState(initial, staleStored, {
|
||||
key: STORAGE_KEY_THEME,
|
||||
value: "dark",
|
||||
});
|
||||
assert.equal(next.theme, "dark");
|
||||
assert.equal(next.darkUiThemeId, "midnight");
|
||||
|
||||
// Later theme-id message must not revert theme back to the stale stored System.
|
||||
next = resolveAppearanceSyncState(next, staleStored, {
|
||||
key: STORAGE_KEY_UI_THEME_DARK,
|
||||
value: "github",
|
||||
});
|
||||
assert.equal(next.theme, "dark");
|
||||
assert.equal(next.darkUiThemeId, "github");
|
||||
assert.equal(next.lightUiThemeId, "snow");
|
||||
assert.equal(next.accentMode, "theme");
|
||||
});
|
||||
|
||||
test("System on a light OS changes to Dark in every open follow-app terminal", () => {
|
||||
const initialState: AppearanceState = {
|
||||
theme: systemLight.theme,
|
||||
lightUiThemeId: systemLight.lightUiThemeId,
|
||||
darkUiThemeId: systemLight.darkUiThemeId,
|
||||
accentMode: systemLight.accentMode,
|
||||
customAccent: systemLight.customAccent,
|
||||
};
|
||||
const terminalAppearance = (appearance: AppearanceState, resolvedTheme: "light" | "dark") => (
|
||||
resolveGlobalTerminalAppearance({
|
||||
userIntent: idleThemeUserIntent(),
|
||||
settings: {
|
||||
terminalThemeId: "netcatty-dark",
|
||||
terminalThemeDarkId: "auto",
|
||||
terminalThemeLightId: "auto",
|
||||
followAppTerminalTheme: true,
|
||||
resolvedTheme,
|
||||
lightUiThemeId: appearance.lightUiThemeId,
|
||||
darkUiThemeId: appearance.darkUiThemeId,
|
||||
accentMode: appearance.accentMode,
|
||||
customAccent: appearance.customAccent,
|
||||
},
|
||||
customThemes: [],
|
||||
})
|
||||
);
|
||||
const persistRender = (
|
||||
stored: StoredAppearanceValues,
|
||||
previous: AppearanceRenderSnapshot,
|
||||
current: AppearanceRenderSnapshot,
|
||||
): StoredAppearanceValues => {
|
||||
if (!hasPersistedAppearanceChanged(previous, current)) return stored;
|
||||
return {
|
||||
theme: current.theme,
|
||||
lightUiThemeId: current.lightUiThemeId,
|
||||
darkUiThemeId: current.darkUiThemeId,
|
||||
accentMode: current.accentMode,
|
||||
customAccent: current.customAccent,
|
||||
};
|
||||
};
|
||||
|
||||
let stored: StoredAppearanceValues = { ...initialState };
|
||||
let main = { ...initialState };
|
||||
let detachedTerminal = { ...initialState };
|
||||
const initialMainTerminal = terminalAppearance(main, "light");
|
||||
const initialDetachedTerminal = terminalAppearance(detachedTerminal, "light");
|
||||
|
||||
const settingsDark: AppearanceRenderSnapshot = {
|
||||
...systemLight,
|
||||
theme: "dark",
|
||||
resolvedTheme: "dark",
|
||||
};
|
||||
stored = persistRender(stored, systemLight, settingsDark);
|
||||
const darkSelection = { key: STORAGE_KEY_THEME, value: "dark" };
|
||||
|
||||
// A stale peer receives an OS color event before the Dark IPC message. Its
|
||||
// semantic choice is still System, so it must not write System back.
|
||||
stored = persistRender(stored, systemLight, { ...systemLight, resolvedTheme: "dark" });
|
||||
|
||||
// Main windows run the production IPC reducer. Deliberately pass its lagging
|
||||
// System storage read so only the ordered Dark payload can win.
|
||||
main = resolveAppearanceSyncState(main, {
|
||||
...stored,
|
||||
theme: "system",
|
||||
}, darkSelection);
|
||||
|
||||
// Detached terminal and tray renderers run the production storage-event
|
||||
// reducer because they are not direct IPC broadcast targets.
|
||||
const detachedUpdate = resolveAppearanceStorageEvent(
|
||||
detachedTerminal,
|
||||
darkSelection.key,
|
||||
String(stored.theme),
|
||||
);
|
||||
detachedTerminal = detachedUpdate.next;
|
||||
|
||||
const finalMainTerminal = terminalAppearance(main, "dark");
|
||||
const finalDetachedTerminal = terminalAppearance(detachedTerminal, "dark");
|
||||
|
||||
assert.equal(stored.theme, "dark");
|
||||
assert.equal(settingsDark.theme, "dark");
|
||||
assert.equal(main.theme, "dark");
|
||||
assert.equal(detachedUpdate.handled, true);
|
||||
assert.equal(detachedTerminal.theme, "dark");
|
||||
assert.equal(initialMainTerminal.theme.type, "light");
|
||||
assert.equal(initialDetachedTerminal.theme.type, "light");
|
||||
assert.equal(finalMainTerminal.theme.type, "dark");
|
||||
assert.equal(finalDetachedTerminal.theme.type, "dark");
|
||||
assert.notEqual(finalMainTerminal.theme.colors.background, initialMainTerminal.theme.colors.background);
|
||||
assert.notEqual(finalDetachedTerminal.theme.colors.background, initialDetachedTerminal.theme.colors.background);
|
||||
});
|
||||
|
||||
test("the race guards are wired into the real settings paths", () => {
|
||||
const stateSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
|
||||
const ipcSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
|
||||
const storageSource = readFileSync(new URL("./settingsStorageSync.ts", import.meta.url), "utf8");
|
||||
const popupSource = readFileSync(new URL("../../components/TerminalPopupPage.tsx", import.meta.url), "utf8");
|
||||
|
||||
const guardIndex = stateSource.indexOf("hasPersistedAppearanceChanged(");
|
||||
const returnIndex = stateSource.indexOf("if (!persistedAppearanceChanged && persistMountedRef.current)", guardIndex);
|
||||
const writeIndex = stateSource.indexOf("localStorageAdapter.writeString(STORAGE_KEY_THEME", guardIndex);
|
||||
const themeNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_THEME, theme)", writeIndex);
|
||||
const lightNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId)", writeIndex);
|
||||
const darkNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId)", writeIndex);
|
||||
const accentModeNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode)", writeIndex);
|
||||
const colorNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_COLOR, customAccent)", writeIndex);
|
||||
|
||||
assert.ok(guardIndex >= 0, "the settings effect must compare persisted appearance fields");
|
||||
assert.ok(returnIndex > guardIndex && returnIndex < writeIndex, "the stale render must stop before storage is written");
|
||||
assert.ok(themeNotifyIndex > writeIndex, "theme changes must be announced over IPC with the new value");
|
||||
assert.ok(lightNotifyIndex > writeIndex, "light UI theme changes must be announced over IPC with the new value");
|
||||
assert.ok(darkNotifyIndex > writeIndex, "dark UI theme changes must be announced over IPC with the new value");
|
||||
assert.ok(accentModeNotifyIndex > writeIndex, "accent mode changes must be announced over IPC with the new value");
|
||||
assert.ok(colorNotifyIndex > writeIndex, "custom accent changes must be announced over IPC with the new value");
|
||||
// Source still only notifies fields that actually changed (keyed, not a single theme-only broadcast).
|
||||
assert.match(
|
||||
stateSource,
|
||||
/previousAppearance\.theme !== theme[\s\S]*notifySettingsChanged\(STORAGE_KEY_THEME, theme\)/,
|
||||
);
|
||||
assert.match(
|
||||
stateSource,
|
||||
/previousAppearance\.lightUiThemeId !== lightUiThemeId[\s\S]*notifySettingsChanged\(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId\)/,
|
||||
);
|
||||
assert.match(
|
||||
stateSource,
|
||||
/previousAppearance\.darkUiThemeId !== darkUiThemeId[\s\S]*notifySettingsChanged\(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId\)/,
|
||||
);
|
||||
// Sequential keyed IPC must compose via a ref, not a render-stale closure.
|
||||
assert.match(stateSource, /appearanceStateRef\.current = nextAppearance/);
|
||||
assert.match(ipcSource, /syncAppearanceFromStorage\(\{ key, value \}\)/);
|
||||
assert.match(storageSource, /resolveAppearanceStorageEvent\(s, e\.key, e\.newValue\)/);
|
||||
assert.match(popupSource, /terminalTheme=\{settings\.currentTerminalTheme\}/);
|
||||
assert.match(popupSource, /followAppTerminalTheme=\{settings\.followAppTerminalTheme\}/);
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
STORAGE_KEY_ACCENT_MODE,
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import {
|
||||
isValidHslToken,
|
||||
isValidTheme,
|
||||
isValidUiThemeId,
|
||||
} from './settingsStateDefaults';
|
||||
|
||||
export type AppearanceState = {
|
||||
theme: "light" | "dark" | "system";
|
||||
lightUiThemeId: string;
|
||||
darkUiThemeId: string;
|
||||
accentMode: "theme" | "custom";
|
||||
customAccent: string;
|
||||
};
|
||||
|
||||
export type AppearanceRenderSnapshot = {
|
||||
theme: AppearanceState["theme"];
|
||||
resolvedTheme: "light" | "dark";
|
||||
} & Omit<AppearanceState, "theme">;
|
||||
|
||||
export type AppearanceSyncEvent = {
|
||||
key: string;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
export type StoredAppearanceValues = {
|
||||
theme: unknown;
|
||||
lightUiThemeId: unknown;
|
||||
darkUiThemeId: unknown;
|
||||
accentMode: unknown;
|
||||
customAccent: unknown;
|
||||
};
|
||||
|
||||
export type AppearanceStorageEventResolution = {
|
||||
handled: boolean;
|
||||
next: AppearanceState;
|
||||
};
|
||||
|
||||
export function hasPersistedAppearanceChanged(
|
||||
previous: AppearanceRenderSnapshot,
|
||||
current: AppearanceRenderSnapshot,
|
||||
): boolean {
|
||||
return previous.theme !== current.theme
|
||||
|| previous.lightUiThemeId !== current.lightUiThemeId
|
||||
|| previous.darkUiThemeId !== current.darkUiThemeId
|
||||
|| previous.accentMode !== current.accentMode
|
||||
|| previous.customAccent !== current.customAccent;
|
||||
}
|
||||
|
||||
export function resolveIncomingAppearanceValue<T>(
|
||||
incoming: AppearanceSyncEvent | undefined,
|
||||
key: string,
|
||||
storedValue: T,
|
||||
currentValue: T,
|
||||
isValid: (value: unknown) => value is T,
|
||||
): T {
|
||||
if (incoming?.key === key && isValid(incoming.value)) {
|
||||
return incoming.value;
|
||||
}
|
||||
// Keyed IPC updates only trust the announced key. Non-matching fields keep
|
||||
// the in-memory current value so sequential notifies for one multi-field
|
||||
// change cannot clobber each other with a still-stale storage read.
|
||||
// Full rehydrate (no incoming) continues to prefer shared storage.
|
||||
if (incoming) {
|
||||
return currentValue;
|
||||
}
|
||||
return storedValue;
|
||||
}
|
||||
|
||||
export function resolveAppearanceSyncState(
|
||||
current: AppearanceState,
|
||||
stored: StoredAppearanceValues,
|
||||
incoming?: AppearanceSyncEvent,
|
||||
): AppearanceState {
|
||||
const theme = resolveIncomingAppearanceValue(
|
||||
incoming,
|
||||
STORAGE_KEY_THEME,
|
||||
stored.theme,
|
||||
current.theme,
|
||||
isValidTheme,
|
||||
);
|
||||
const lightUiThemeId = resolveIncomingAppearanceValue(
|
||||
incoming,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
stored.lightUiThemeId,
|
||||
current.lightUiThemeId,
|
||||
(value): value is string => typeof value === 'string' && isValidUiThemeId('light', value),
|
||||
);
|
||||
const darkUiThemeId = resolveIncomingAppearanceValue(
|
||||
incoming,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
stored.darkUiThemeId,
|
||||
current.darkUiThemeId,
|
||||
(value): value is string => typeof value === 'string' && isValidUiThemeId('dark', value),
|
||||
);
|
||||
const accentMode = resolveIncomingAppearanceValue(
|
||||
incoming,
|
||||
STORAGE_KEY_ACCENT_MODE,
|
||||
stored.accentMode,
|
||||
current.accentMode,
|
||||
(value): value is AppearanceState['accentMode'] => value === 'theme' || value === 'custom',
|
||||
);
|
||||
const customAccent = resolveIncomingAppearanceValue(
|
||||
incoming,
|
||||
STORAGE_KEY_COLOR,
|
||||
stored.customAccent,
|
||||
current.customAccent,
|
||||
(value): value is string => typeof value === 'string' && isValidHslToken(value),
|
||||
);
|
||||
|
||||
return {
|
||||
theme: isValidTheme(theme) ? theme : current.theme,
|
||||
lightUiThemeId: typeof lightUiThemeId === 'string' && isValidUiThemeId('light', lightUiThemeId)
|
||||
? lightUiThemeId
|
||||
: current.lightUiThemeId,
|
||||
darkUiThemeId: typeof darkUiThemeId === 'string' && isValidUiThemeId('dark', darkUiThemeId)
|
||||
? darkUiThemeId
|
||||
: current.darkUiThemeId,
|
||||
accentMode: accentMode === 'theme' || accentMode === 'custom' ? accentMode : current.accentMode,
|
||||
customAccent: typeof customAccent === 'string' && isValidHslToken(customAccent)
|
||||
? customAccent.trim()
|
||||
: current.customAccent,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAppearanceStorageEvent(
|
||||
current: AppearanceState,
|
||||
key: string | null,
|
||||
newValue: string | null,
|
||||
): AppearanceStorageEventResolution {
|
||||
if (key === STORAGE_KEY_THEME) {
|
||||
return {
|
||||
handled: true,
|
||||
next: newValue && isValidTheme(newValue) ? { ...current, theme: newValue } : current,
|
||||
};
|
||||
}
|
||||
if (key === STORAGE_KEY_UI_THEME_LIGHT) {
|
||||
return {
|
||||
handled: true,
|
||||
next: newValue && isValidUiThemeId('light', newValue)
|
||||
? { ...current, lightUiThemeId: newValue }
|
||||
: current,
|
||||
};
|
||||
}
|
||||
if (key === STORAGE_KEY_UI_THEME_DARK) {
|
||||
return {
|
||||
handled: true,
|
||||
next: newValue && isValidUiThemeId('dark', newValue)
|
||||
? { ...current, darkUiThemeId: newValue }
|
||||
: current,
|
||||
};
|
||||
}
|
||||
if (key === STORAGE_KEY_ACCENT_MODE) {
|
||||
return {
|
||||
handled: true,
|
||||
next: newValue === 'theme' || newValue === 'custom'
|
||||
? { ...current, accentMode: newValue }
|
||||
: current,
|
||||
};
|
||||
}
|
||||
if (key === STORAGE_KEY_COLOR) {
|
||||
return {
|
||||
handled: true,
|
||||
next: newValue && isValidHslToken(newValue)
|
||||
? { ...current, customAccent: newValue.trim() }
|
||||
: current,
|
||||
};
|
||||
}
|
||||
return { handled: false, next: current };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
LOCAL_TERMINAL_HOST_ID,
|
||||
createLocalTerminalSession,
|
||||
} from "./sessionFactories.ts";
|
||||
|
||||
test("createLocalTerminalSession uses a stable hostId across sessions", () => {
|
||||
const a = createLocalTerminalSession("session-a");
|
||||
const b = createLocalTerminalSession("session-b");
|
||||
|
||||
assert.equal(a.hostId, LOCAL_TERMINAL_HOST_ID);
|
||||
assert.equal(b.hostId, LOCAL_TERMINAL_HOST_ID);
|
||||
assert.notEqual(a.id, b.id);
|
||||
assert.equal(a.protocol, "local");
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { Host } from "../../domain/models";
|
||||
import { buildTelnetDeepLinkConnectionHost } from "../../domain/telnetDeepLink";
|
||||
import { createHostTerminalSession } from "./sessionFactories";
|
||||
|
||||
const host = (overrides: Partial<Host>): Host => ({
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
group: "",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
createdAt: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("createHostTerminalSession keeps telnet deep-link default port for ssh hosts with telnet enabled", () => {
|
||||
const connectionHost = buildTelnetDeepLinkConnectionHost(
|
||||
host({
|
||||
protocol: "ssh",
|
||||
telnetEnabled: true,
|
||||
telnetPort: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const session = createHostTerminalSession("session-1", connectionHost);
|
||||
|
||||
assert.equal(session.protocol, "telnet");
|
||||
assert.equal(session.port, 23);
|
||||
});
|
||||
@@ -6,14 +6,24 @@ export interface LocalTerminalOptions {
|
||||
shellArgs?: string[];
|
||||
shellName?: string;
|
||||
shellIcon?: string;
|
||||
localStartDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable hostId for all Local Terminal sessions.
|
||||
*
|
||||
* Autocomplete command history is keyed by hostId. Using `local-${sessionId}`
|
||||
* made every new Local Terminal look like a brand-new host, so history
|
||||
* suggestions never accumulated across opens (issue #2037).
|
||||
*/
|
||||
export const LOCAL_TERMINAL_HOST_ID = "local-terminal";
|
||||
|
||||
export const createLocalTerminalSession = (
|
||||
sessionId: string,
|
||||
options?: LocalTerminalOptions,
|
||||
): TerminalSession => ({
|
||||
id: sessionId,
|
||||
hostId: `local-${sessionId}`,
|
||||
hostId: LOCAL_TERMINAL_HOST_ID,
|
||||
hostLabel: options?.shellName || "Local Terminal",
|
||||
hostname: "localhost",
|
||||
username: "local",
|
||||
@@ -24,6 +34,7 @@ export const createLocalTerminalSession = (
|
||||
localShellArgs: options?.shellArgs,
|
||||
localShellName: options?.shellName,
|
||||
localShellIcon: options?.shellIcon,
|
||||
localStartDir: options?.localStartDir,
|
||||
});
|
||||
|
||||
export const createSerialTerminalSession = (
|
||||
@@ -86,5 +97,7 @@ export const createHostTerminalSession = (
|
||||
moshEnabled: host.moshEnabled,
|
||||
etEnabled: host.etEnabled,
|
||||
charset: host.charset,
|
||||
...(host.ephemeral ? { ephemeralHost: true } : {}),
|
||||
...(host.autoOpenSftpPanel ? { autoOpenSidePanel: "sftp" as const } : {}),
|
||||
};
|
||||
};
|
||||
@@ -2,9 +2,11 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { TerminalSession, Workspace } from "../../domain/models";
|
||||
import {
|
||||
applyCloseSessionToSessions,
|
||||
closeSessionWorkspaceLayoutState,
|
||||
detachSessionFromWorkspaceState,
|
||||
replaceDissolvedWorkspaceTabOrder,
|
||||
resolveActiveTabAfterCloseSession,
|
||||
} from "./sessionWorkspaceDetach";
|
||||
|
||||
const session = (id: string, workspaceId = "ws-1"): TerminalSession => ({
|
||||
@@ -121,3 +123,57 @@ test("closing a workspace session dissolves the workspace when one terminal rema
|
||||
["log-1", "s2", "session-3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("closing one split pane keeps the other terminal as an orphan tab", () => {
|
||||
const layoutResult = closeSessionWorkspaceLayoutState(
|
||||
[workspace(["s1", "s2"])],
|
||||
"ws-1",
|
||||
"s1",
|
||||
);
|
||||
const nextSessions = applyCloseSessionToSessions(
|
||||
[session("s1"), session("s2")],
|
||||
"s1",
|
||||
layoutResult,
|
||||
);
|
||||
|
||||
assert.deepEqual(nextSessions.map((s) => [s.id, s.workspaceId]), [
|
||||
["s2", undefined],
|
||||
]);
|
||||
assert.equal(
|
||||
resolveActiveTabAfterCloseSession({
|
||||
currentActiveTabId: "ws-1",
|
||||
closedSessionId: "s1",
|
||||
workspaceId: "ws-1",
|
||||
layoutResult,
|
||||
remainingSessions: nextSessions,
|
||||
}),
|
||||
"s2",
|
||||
);
|
||||
});
|
||||
|
||||
test("closing the last workspace session does not invent a surviving terminal", () => {
|
||||
const layoutResult = closeSessionWorkspaceLayoutState(
|
||||
[workspace(["s1"])],
|
||||
"ws-1",
|
||||
"s1",
|
||||
);
|
||||
const nextSessions = applyCloseSessionToSessions(
|
||||
[session("s1")],
|
||||
"s1",
|
||||
layoutResult,
|
||||
);
|
||||
|
||||
assert.deepEqual(nextSessions, []);
|
||||
assert.equal(layoutResult.removedWorkspaceId, "ws-1");
|
||||
assert.equal(layoutResult.lastRemainingSessionId, undefined);
|
||||
assert.equal(
|
||||
resolveActiveTabAfterCloseSession({
|
||||
currentActiveTabId: "ws-1",
|
||||
closedSessionId: "s1",
|
||||
workspaceId: "ws-1",
|
||||
layoutResult,
|
||||
remainingSessions: nextSessions,
|
||||
}),
|
||||
"vault",
|
||||
);
|
||||
});
|
||||
@@ -101,6 +101,59 @@ export function closeSessionWorkspaceLayoutState(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a close-session action to the session list using the layout result.
|
||||
* When a 2-pane workspace dissolves to one terminal, that remaining session
|
||||
* must become an orphan tab — not stay bound to a deleted workspaceId.
|
||||
*/
|
||||
export function applyCloseSessionToSessions(
|
||||
sessions: readonly TerminalSession[],
|
||||
sessionId: string,
|
||||
layoutResult: Pick<CloseSessionWorkspaceLayoutResult, "lastRemainingSessionId">,
|
||||
): TerminalSession[] {
|
||||
const remaining = sessions.filter((session) => session.id !== sessionId);
|
||||
const lastRemainingSessionId = layoutResult.lastRemainingSessionId;
|
||||
if (!lastRemainingSessionId) return remaining;
|
||||
|
||||
return remaining.map((session) => (
|
||||
session.id === lastRemainingSessionId
|
||||
? { ...session, workspaceId: undefined }
|
||||
: session
|
||||
));
|
||||
}
|
||||
|
||||
export function resolveActiveTabAfterCloseSession({
|
||||
currentActiveTabId,
|
||||
closedSessionId,
|
||||
workspaceId,
|
||||
layoutResult,
|
||||
remainingSessions,
|
||||
}: {
|
||||
currentActiveTabId: string | null;
|
||||
closedSessionId: string;
|
||||
workspaceId: string | undefined;
|
||||
layoutResult: CloseSessionWorkspaceLayoutResult;
|
||||
remainingSessions: readonly TerminalSession[];
|
||||
}): string | null {
|
||||
const fallbackWorkspace = layoutResult.workspaces[layoutResult.workspaces.length - 1];
|
||||
const fallbackSolo = remainingSessions.filter((session) => !session.workspaceId).slice(-1)[0];
|
||||
const fallback = layoutResult.lastRemainingSessionId
|
||||
?? fallbackWorkspace?.id
|
||||
?? fallbackSolo?.id
|
||||
?? "vault";
|
||||
|
||||
if (
|
||||
currentActiveTabId === closedSessionId
|
||||
|| (layoutResult.dissolvedWorkspaceId && currentActiveTabId === layoutResult.dissolvedWorkspaceId)
|
||||
|| (layoutResult.removedWorkspaceId && currentActiveTabId === layoutResult.removedWorkspaceId)
|
||||
|| (workspaceId && currentActiveTabId === workspaceId && !layoutResult.workspaces.some((ws) => ws.id === workspaceId))
|
||||
) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function detachSessionFromWorkspaceState({
|
||||
sessions,
|
||||
workspaces,
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
|
||||
STORAGE_KEY_JMS_DEEP_LINK_ENABLED,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
@@ -43,19 +44,25 @@ import {
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_APP_ICON_VARIANT,
|
||||
STORAGE_KEY_HTTP_NETWORK_PROXY,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
|
||||
import {
|
||||
areHttpNetworkProxySettingsEqual,
|
||||
normalizeHttpNetworkProxySettings,
|
||||
type HttpNetworkProxySettings,
|
||||
} from '../../domain/httpNetworkProxy';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
clampWindowOpacity,
|
||||
isValidUiFontId,
|
||||
migrateIncomingTerminalFontId,
|
||||
} from './settingsStateDefaults';
|
||||
import { isTerminalSidePanelAutoOpenTab, type TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
|
||||
import type { AppearanceSyncEvent } from './appearanceSync';
|
||||
|
||||
interface UseSettingsIpcSyncParams {
|
||||
enabled?: boolean;
|
||||
syncAppearanceFromStorage: () => void;
|
||||
syncAppearanceFromStorage: (incoming?: AppearanceSyncEvent) => void;
|
||||
syncCustomCssFromStorage: () => void;
|
||||
setUiLanguage: Dispatch<SetStateAction<UILanguage>>;
|
||||
setUiFontFamilyId: Dispatch<SetStateAction<string>>;
|
||||
@@ -73,13 +80,15 @@ interface UseSettingsIpcSyncParams {
|
||||
setSessionLogsTimestampsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSshDeepLinkEnabledState: (enabled: boolean) => void;
|
||||
setJmsDeepLinkEnabledState: (enabled: boolean) => void;
|
||||
setHotkeyScheme: Dispatch<SetStateAction<HotkeyScheme>>;
|
||||
applyIncomingCustomKeyBindings: (incoming: { bindings: CustomKeyBindings; version: number; origin: string }) => void;
|
||||
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
|
||||
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWindowOpacity: Dispatch<SetStateAction<number>>;
|
||||
setWindowOpacity: (raw: unknown) => void;
|
||||
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setHttpNetworkProxy: Dispatch<SetStateAction<HttpNetworkProxySettings>>;
|
||||
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||
@@ -113,6 +122,7 @@ export function useSettingsIpcSync({
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDebugLogsEnabled,
|
||||
setSshDeepLinkEnabledState,
|
||||
setJmsDeepLinkEnabledState,
|
||||
setHotkeyScheme,
|
||||
applyIncomingCustomKeyBindings,
|
||||
setIsHotkeyRecordingState,
|
||||
@@ -120,6 +130,7 @@ export function useSettingsIpcSync({
|
||||
setWindowOpacity,
|
||||
setAppIconVariant,
|
||||
setAutoUpdateEnabled,
|
||||
setHttpNetworkProxy,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
@@ -146,7 +157,7 @@ export function useSettingsIpcSync({
|
||||
key === STORAGE_KEY_ACCENT_MODE ||
|
||||
key === STORAGE_KEY_COLOR
|
||||
) {
|
||||
syncAppearanceFromStorage();
|
||||
syncAppearanceFromStorage({ key, value });
|
||||
return;
|
||||
}
|
||||
if (key === STORAGE_KEY_UI_LANGUAGE && typeof value === 'string') {
|
||||
@@ -218,6 +229,9 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_SSH_DEEP_LINK_ENABLED && typeof value === 'boolean') {
|
||||
setSshDeepLinkEnabledState(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_JMS_DEEP_LINK_ENABLED && typeof value === 'boolean') {
|
||||
setJmsDeepLinkEnabledState(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
|
||||
setHotkeyScheme(value);
|
||||
}
|
||||
@@ -233,9 +247,8 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && typeof value === 'boolean') {
|
||||
setGlobalHotkeyEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_WINDOW_OPACITY && (typeof value === 'number' || typeof value === 'string')) {
|
||||
const nextOpacity = clampWindowOpacity(value);
|
||||
setWindowOpacity((prev) => (prev === nextOpacity ? prev : nextOpacity));
|
||||
if (key === STORAGE_KEY_WINDOW_OPACITY) {
|
||||
setWindowOpacity(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_APP_ICON_VARIANT) {
|
||||
const nextVariant = resolveAppIconVariant(value);
|
||||
@@ -244,6 +257,12 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_AUTO_UPDATE_ENABLED && typeof value === 'boolean') {
|
||||
setAutoUpdateEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_HTTP_NETWORK_PROXY) {
|
||||
const next = normalizeHttpNetworkProxySettings(value);
|
||||
setHttpNetworkProxy((prev) => (
|
||||
areHttpNetworkProxySettingsEqual(prev, next) ? prev : next
|
||||
));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
|
||||
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
@@ -292,6 +311,7 @@ export function useSettingsIpcSync({
|
||||
applyIncomingCustomKeyBindings,
|
||||
mergeIncomingTerminalSettings,
|
||||
setAutoUpdateEnabled,
|
||||
setHttpNetworkProxy,
|
||||
setEditorWordWrapState,
|
||||
setFollowAppTerminalThemeState,
|
||||
setGlobalHotkeyEnabled,
|
||||
@@ -304,6 +324,7 @@ export function useSettingsIpcSync({
|
||||
setSessionLogsFormat,
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDeepLinkEnabledState,
|
||||
setJmsDeepLinkEnabledState,
|
||||
setSshDebugLogsEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
|
||||
@@ -92,6 +92,7 @@ export const DEFAULT_SESSION_LOGS_FORMAT: SessionLogFormat = 'txt';
|
||||
export const DEFAULT_SESSION_LOGS_TIMESTAMPS_ENABLED = false;
|
||||
export const DEFAULT_SSH_DEBUG_LOGS_ENABLED = false;
|
||||
export const DEFAULT_SSH_DEEP_LINK_ENABLED = true;
|
||||
export const DEFAULT_JMS_DEEP_LINK_ENABLED = false;
|
||||
|
||||
export const readStoredString = (key: string): string | null => {
|
||||
const raw = localStorageAdapter.readString(key);
|
||||
|
||||
@@ -3,9 +3,7 @@ import type { CustomKeyBindings, HotkeyScheme, SessionLogFormat, TerminalSetting
|
||||
import { parseCustomKeyBindingsStorageRecord } from '../../domain/customKeyBindings';
|
||||
import { resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import {
|
||||
STORAGE_KEY_ACCENT_MODE,
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_CUSTOM_CSS,
|
||||
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
||||
STORAGE_KEY_EDITOR_WORD_WRAP,
|
||||
@@ -17,6 +15,7 @@ import {
|
||||
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
|
||||
STORAGE_KEY_JMS_DEEP_LINK_ENABLED,
|
||||
STORAGE_KEY_RESTORE_PREVIOUS_SESSION,
|
||||
STORAGE_KEY_RESTORE_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
@@ -42,22 +41,16 @@ import {
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_TERM_THEME_DARK,
|
||||
STORAGE_KEY_TERM_THEME_LIGHT,
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_UI_FONT_FAMILY,
|
||||
STORAGE_KEY_UI_LANGUAGE,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_APP_ICON_VARIANT,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
|
||||
import { resolveAppearanceStorageEvent } from './appearanceSync';
|
||||
import {
|
||||
clampWindowOpacity,
|
||||
isValidHslToken,
|
||||
isValidTheme,
|
||||
isValidUiFontId,
|
||||
isValidUiThemeId,
|
||||
migrateIncomingTerminalFontId,
|
||||
} from './settingsStateDefaults';
|
||||
import { isTerminalSidePanelAutoOpenTab, type TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
|
||||
@@ -101,6 +94,7 @@ interface UseSettingsStorageSyncParams {
|
||||
sessionLogsTimestampsEnabled: boolean;
|
||||
sshDebugLogsEnabled: boolean;
|
||||
sshDeepLinkEnabled: boolean;
|
||||
jmsDeepLinkEnabled: boolean;
|
||||
globalHotkeyEnabled: boolean;
|
||||
autoUpdateEnabled: boolean;
|
||||
windowOpacity: number;
|
||||
@@ -144,8 +138,9 @@ interface UseSettingsStorageSyncParams {
|
||||
setSessionLogsTimestampsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSshDeepLinkEnabledState: (enabled: boolean) => void;
|
||||
setJmsDeepLinkEnabledState: (enabled: boolean) => void;
|
||||
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWindowOpacity: Dispatch<SetStateAction<number>>;
|
||||
setWindowOpacity: (raw: unknown) => void;
|
||||
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||
@@ -162,7 +157,7 @@ export function useSettingsStorageSync({
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
@@ -171,7 +166,7 @@ export function useSettingsStorageSync({
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
}: UseSettingsStorageSyncParams) {
|
||||
@@ -185,7 +180,7 @@ export function useSettingsStorageSync({
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
});
|
||||
settingsSnapshotRef.current = {
|
||||
@@ -195,7 +190,7 @@ export function useSettingsStorageSync({
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
};
|
||||
|
||||
@@ -204,30 +199,14 @@ export function useSettingsStorageSync({
|
||||
if (!enabled) return;
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
const s = settingsSnapshotRef.current;
|
||||
if (e.key === STORAGE_KEY_THEME && e.newValue) {
|
||||
if (isValidTheme(e.newValue) && e.newValue !== s.theme) {
|
||||
setTheme(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_THEME_LIGHT && e.newValue) {
|
||||
if (isValidUiThemeId('light', e.newValue) && e.newValue !== s.lightUiThemeId) {
|
||||
setLightUiThemeId(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_THEME_DARK && e.newValue) {
|
||||
if (isValidUiThemeId('dark', e.newValue) && e.newValue !== s.darkUiThemeId) {
|
||||
setDarkUiThemeId(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_ACCENT_MODE && e.newValue) {
|
||||
if ((e.newValue === 'theme' || e.newValue === 'custom') && e.newValue !== s.accentMode) {
|
||||
setAccentMode(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_COLOR && e.newValue) {
|
||||
if (isValidHslToken(e.newValue) && e.newValue !== s.customAccent) {
|
||||
setCustomAccent(e.newValue.trim());
|
||||
}
|
||||
const appearance = resolveAppearanceStorageEvent(s, e.key, e.newValue);
|
||||
if (appearance.handled) {
|
||||
if (appearance.next.theme !== s.theme) setTheme(appearance.next.theme);
|
||||
if (appearance.next.lightUiThemeId !== s.lightUiThemeId) setLightUiThemeId(appearance.next.lightUiThemeId);
|
||||
if (appearance.next.darkUiThemeId !== s.darkUiThemeId) setDarkUiThemeId(appearance.next.darkUiThemeId);
|
||||
if (appearance.next.accentMode !== s.accentMode) setAccentMode(appearance.next.accentMode);
|
||||
if (appearance.next.customAccent !== s.customAccent) setCustomAccent(appearance.next.customAccent);
|
||||
return;
|
||||
}
|
||||
if (e.key === STORAGE_KEY_CUSTOM_CSS && e.newValue !== null) {
|
||||
if (e.newValue !== s.customCSS) {
|
||||
@@ -365,6 +344,12 @@ export function useSettingsStorageSync({
|
||||
setSshDeepLinkEnabledState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_JMS_DEEP_LINK_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.jmsDeepLinkEnabled) {
|
||||
setJmsDeepLinkEnabledState(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP compressed upload setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
|
||||
@@ -465,10 +450,7 @@ export function useSettingsStorageSync({
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_WINDOW_OPACITY && e.newValue !== null) {
|
||||
const newValue = clampWindowOpacity(e.newValue);
|
||||
if (newValue !== s.windowOpacity) {
|
||||
setWindowOpacity(newValue);
|
||||
}
|
||||
setWindowOpacity(e.newValue);
|
||||
}
|
||||
if (e.key === STORAGE_KEY_APP_ICON_VARIANT && e.newValue !== null) {
|
||||
const newValue = resolveAppIconVariant(e.newValue);
|
||||
@@ -514,6 +496,7 @@ export function useSettingsStorageSync({
|
||||
setSessionLogsFormat,
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDeepLinkEnabledState,
|
||||
setJmsDeepLinkEnabledState,
|
||||
setSshDebugLogsEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
|
||||
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
|
||||
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
|
||||
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
|
||||
import { useSftpHostCredentials } from "./useSftpHostCredentials";
|
||||
import { buildSftpReuseCredentials, useSftpHostCredentials } from "./useSftpHostCredentials";
|
||||
import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
|
||||
import { resolveRemoteSftpStartState } from "./sftpConnectStartPath";
|
||||
|
||||
@@ -421,7 +421,6 @@ export const useSftpConnections = ({
|
||||
}
|
||||
|
||||
try {
|
||||
const credentials = getHostCredentials(host);
|
||||
const openSftp = bridge?.openSftp;
|
||||
if (!openSftp) throw new Error("SFTP bridge unavailable");
|
||||
|
||||
@@ -436,47 +435,69 @@ export const useSftpConnections = ({
|
||||
);
|
||||
};
|
||||
|
||||
const hasKey = !!credentials.privateKey || !!credentials.identityFilePaths?.length;
|
||||
const hasPassword = !!credentials.password;
|
||||
|
||||
let credentials: NetcattySSHOptions | null = null;
|
||||
let sftpId: string | undefined;
|
||||
if (hasKey) {
|
||||
|
||||
// Live Connected rows: try reuse with endpoint-only options first so
|
||||
// missing/undecryptable vault credentials do not block an already-
|
||||
// authenticated terminal session. Fall back to full credentials if
|
||||
// reuse fails (bridge may also fall through to a fresh connect).
|
||||
if (options?.sourceSessionId && !host.sftpSudo) {
|
||||
const reuseCredentials = buildSftpReuseCredentials(host, options.sourceSessionId);
|
||||
try {
|
||||
const keyFirstCredentials = {
|
||||
sftpId = await openSftp({
|
||||
sessionId: sftpSessionId,
|
||||
...credentials,
|
||||
sourceSessionId: options?.sourceSessionId,
|
||||
};
|
||||
if (!credentials.sudo) {
|
||||
keyFirstCredentials.password = undefined;
|
||||
}
|
||||
sftpId = await openSftp(keyFirstCredentials);
|
||||
} catch (err) {
|
||||
if (hasPassword && isAuthError(err)) {
|
||||
sftpId = await openSftp({
|
||||
...reuseCredentials,
|
||||
});
|
||||
credentials = reuseCredentials;
|
||||
} catch {
|
||||
sftpId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sftpId) {
|
||||
credentials = getHostCredentials(host);
|
||||
const hasKey = !!credentials.privateKey || !!credentials.identityFilePaths?.length;
|
||||
const hasPassword = !!credentials.password;
|
||||
|
||||
if (hasKey) {
|
||||
try {
|
||||
const keyFirstCredentials = {
|
||||
sessionId: sftpSessionId,
|
||||
...credentials,
|
||||
sourceSessionId: options?.sourceSessionId,
|
||||
privateKey: undefined,
|
||||
certificate: undefined,
|
||||
publicKey: undefined,
|
||||
keyId: undefined,
|
||||
keySource: undefined,
|
||||
identityFilePaths: undefined,
|
||||
});
|
||||
} else {
|
||||
throw err;
|
||||
};
|
||||
if (!credentials.sudo) {
|
||||
keyFirstCredentials.password = undefined;
|
||||
}
|
||||
sftpId = await openSftp(keyFirstCredentials);
|
||||
} catch (err) {
|
||||
if (hasPassword && isAuthError(err)) {
|
||||
sftpId = await openSftp({
|
||||
sessionId: sftpSessionId,
|
||||
...credentials,
|
||||
sourceSessionId: options?.sourceSessionId,
|
||||
privateKey: undefined,
|
||||
certificate: undefined,
|
||||
publicKey: undefined,
|
||||
keyId: undefined,
|
||||
keySource: undefined,
|
||||
identityFilePaths: undefined,
|
||||
});
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sftpId = await openSftp({
|
||||
sessionId: sftpSessionId,
|
||||
...credentials,
|
||||
sourceSessionId: options?.sourceSessionId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
sftpId = await openSftp({
|
||||
sessionId: sftpSessionId,
|
||||
...credentials,
|
||||
sourceSessionId: options?.sourceSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
if (!sftpId) throw new Error("Failed to open SFTP session");
|
||||
if (!sftpId || !credentials) throw new Error("Failed to open SFTP session");
|
||||
|
||||
sftpSessionsRef.current.set(connectionId, sftpId);
|
||||
if (!isTargetConnectionCurrent()) {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { buildSftpHostCredentials } from "./useSftpHostCredentials.ts";
|
||||
import {
|
||||
buildSftpHostCredentials,
|
||||
buildSftpReuseCredentials,
|
||||
} from "./useSftpHostCredentials.ts";
|
||||
import type { Host, Identity, KnownHost, SSHKey } from "../../../domain/models.ts";
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
@@ -14,6 +17,91 @@ const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("buildSftpReuseCredentials only needs the live endpoint and sourceSessionId", () => {
|
||||
const credentials = buildSftpReuseCredentials(
|
||||
host({ hostname: "live.example.com", username: "alice", port: 2222 }),
|
||||
"session-live",
|
||||
);
|
||||
|
||||
assert.deepEqual(credentials, {
|
||||
hostname: "live.example.com",
|
||||
username: "alice",
|
||||
port: 2222,
|
||||
sourceSessionId: "session-live",
|
||||
reuseOnly: true,
|
||||
sudo: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials forwards system agent settings for target and jump hosts", () => {
|
||||
const jump = host({
|
||||
id: "jump-1",
|
||||
hostname: "jump.example.com",
|
||||
useSshAgent: true,
|
||||
identityFilePaths: ["~/.ssh/jump"],
|
||||
identitiesOnly: true,
|
||||
});
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({
|
||||
hostChain: { hostIds: ["jump-1"] },
|
||||
useSshAgent: true,
|
||||
identityAgent: "$SSH_AUTH_SOCK",
|
||||
identityFilePaths: ["~/.ssh/aws_root"],
|
||||
identitiesOnly: true,
|
||||
addKeysToAgent: "yes",
|
||||
useKeychain: true,
|
||||
}),
|
||||
hosts: [jump],
|
||||
keys: [],
|
||||
identities: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
{
|
||||
useSshAgent: credentials.useSshAgent,
|
||||
identityAgent: credentials.identityAgent,
|
||||
identityFilePaths: credentials.identityFilePaths,
|
||||
identitiesOnly: credentials.identitiesOnly,
|
||||
addKeysToAgent: credentials.addKeysToAgent,
|
||||
useKeychain: credentials.useKeychain,
|
||||
},
|
||||
{
|
||||
useSshAgent: true,
|
||||
identityAgent: "$SSH_AUTH_SOCK",
|
||||
identityFilePaths: ["~/.ssh/aws_root"],
|
||||
identitiesOnly: true,
|
||||
addKeysToAgent: "yes",
|
||||
useKeychain: true,
|
||||
},
|
||||
);
|
||||
assert.equal(credentials.jumpHosts?.[0]?.useSshAgent, true);
|
||||
assert.deepEqual(credentials.jumpHosts?.[0]?.identityFilePaths, ["~/.ssh/jump"]);
|
||||
assert.equal(credentials.jumpHosts?.[0]?.identitiesOnly, true);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials forwards target and jump-host timeouts", () => {
|
||||
const jumpHost = host({
|
||||
id: "jump-1",
|
||||
sshTcpConnectTimeoutSeconds: 75,
|
||||
sshAuthReadyTimeoutSeconds: 360,
|
||||
});
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({
|
||||
hostChain: { hostIds: ["jump-1"] },
|
||||
sshTcpConnectTimeoutSeconds: 45,
|
||||
sshAuthReadyTimeoutSeconds: 300,
|
||||
}),
|
||||
hosts: [jumpHost],
|
||||
keys: [],
|
||||
identities: [],
|
||||
});
|
||||
|
||||
assert.equal(credentials.sshTcpConnectTimeoutMs, 45_000);
|
||||
assert.equal(credentials.sshAuthReadyTimeoutMs, 300_000);
|
||||
assert.equal(credentials.jumpHosts?.[0]?.sshTcpConnectTimeoutMs, 75_000);
|
||||
assert.equal(credentials.jumpHosts?.[0]?.sshAuthReadyTimeoutMs, 360_000);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials rejects missing jump hosts", () => {
|
||||
assert.throws(
|
||||
() => buildSftpHostCredentials({
|
||||
@@ -495,3 +583,47 @@ test("buildSftpHostCredentials does not use stale local key paths when a selecte
|
||||
/Saved credentials cannot be decrypted/,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials uses the system agent when a synced key cannot be decrypted", () => {
|
||||
const key: SSHKey = {
|
||||
id: "key-1",
|
||||
label: "Synced key",
|
||||
type: "ED25519",
|
||||
publicKey: "ssh-ed25519 AAAASELECTED",
|
||||
privateKey: "enc:v1:djEwAAAA",
|
||||
source: "imported",
|
||||
category: "key",
|
||||
created: 1,
|
||||
};
|
||||
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({
|
||||
authMethod: "key",
|
||||
identityFileId: "key-1",
|
||||
useSshAgent: true,
|
||||
}),
|
||||
hosts: [],
|
||||
keys: [key],
|
||||
identities: [],
|
||||
});
|
||||
|
||||
assert.equal(credentials.useSshAgent, true);
|
||||
assert.deepEqual(credentials.agentPublicKeys, ["ssh-ed25519 AAAASELECTED"]);
|
||||
assert.equal(credentials.privateKey, undefined);
|
||||
});
|
||||
|
||||
test("imported IdentityFile paths remain usable when agent login is disabled", () => {
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({
|
||||
identityFilePaths: ["~/.ssh/id_work"],
|
||||
useSshAgent: false,
|
||||
identityAgent: "none",
|
||||
}),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
});
|
||||
|
||||
assert.equal(credentials.useSshAgent, false);
|
||||
assert.deepEqual(credentials.identityFilePaths, ["~/.ssh/id_work"]);
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from "../../../domain/models";
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
|
||||
import { resolveBridgeKeyAuth, resolveHostAuth } from "../../../domain/sshAuth";
|
||||
import { resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from "../../../domain/sshAuth";
|
||||
import { resolveHostKeepalive } from "../../../domain/host";
|
||||
import { resolveHostSshConnectionTimeouts } from "../../../domain/sshConnectionTimeouts";
|
||||
import {
|
||||
findIncompleteProxyIdentityId,
|
||||
findMissingProxyIdentityId,
|
||||
@@ -30,6 +31,23 @@ interface UseSftpHostCredentialsParams {
|
||||
terminalSettings?: Pick<TerminalSettings, 'verifyHostKeys' | 'keepaliveInterval' | 'keepaliveCountMax'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal options for reusing a live terminal SSH connection for SFTP.
|
||||
* Must not require vault password/key material — the source session is already
|
||||
* authenticated. Endpoint fields must match the session for findReusableSession.
|
||||
*/
|
||||
export const buildSftpReuseCredentials = (
|
||||
host: Pick<Host, "hostname" | "username" | "port">,
|
||||
sourceSessionId: string,
|
||||
): NetcattySSHOptions => ({
|
||||
hostname: host.hostname,
|
||||
username: host.username || "root",
|
||||
port: host.port || 22,
|
||||
sourceSessionId,
|
||||
reuseOnly: true,
|
||||
sudo: false,
|
||||
});
|
||||
|
||||
export const buildSftpHostCredentials = ({
|
||||
host,
|
||||
hosts,
|
||||
@@ -82,12 +100,15 @@ export const buildSftpHostCredentials = ({
|
||||
const jumpPassword = sanitizeCredentialValue(jumpAuth.password);
|
||||
const jumpKeyAuth = resolveBridgeKeyAuth({
|
||||
key: jumpKey,
|
||||
fallbackIdentityFilePaths: jumpAuth.authMethod === "password" || jumpAuth.keyId
|
||||
fallbackIdentityFilePaths: (!jumpHost.useSshAgent && jumpAuth.authMethod === "password") || jumpAuth.keyId
|
||||
? undefined
|
||||
: jumpHost.identityFilePaths,
|
||||
passphrase: jumpAuth.passphrase,
|
||||
});
|
||||
const hasJumpKeyMaterial = Boolean(jumpKeyAuth.privateKey || jumpKeyAuth.identityFilePaths?.length);
|
||||
const jumpAgentAuth = resolveBridgeSshAgentAuth(jumpHost, jumpKey);
|
||||
const hasJumpKeyMaterial = Boolean(
|
||||
jumpAgentAuth.useSshAgent || jumpKeyAuth.privateKey || jumpKeyAuth.identityFilePaths?.length,
|
||||
);
|
||||
const hasConfiguredJumpProxyEndpoint =
|
||||
index === 0 &&
|
||||
hasUsableProxyConfig(jumpHost.proxyConfig);
|
||||
@@ -108,6 +129,7 @@ export const buildSftpHostCredentials = ({
|
||||
throw new Error(`Saved credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter them.`);
|
||||
}
|
||||
const hopKeepalive = resolveHostKeepalive(jumpHost, globalTerminalSettings);
|
||||
const hopConnectionTimeouts = resolveHostSshConnectionTimeouts(jumpHost);
|
||||
return {
|
||||
hostname: jumpHost.hostname,
|
||||
port: jumpHost.port || 22,
|
||||
@@ -124,8 +146,11 @@ export const buildSftpHostCredentials = ({
|
||||
? resolveProxyConfigAuth(jumpHost.proxyConfig, identities)
|
||||
: undefined,
|
||||
identityFilePaths: jumpKeyAuth.identityFilePaths,
|
||||
...jumpAgentAuth,
|
||||
keepaliveInterval: hopKeepalive.interval,
|
||||
keepaliveCountMax: hopKeepalive.countMax,
|
||||
sshTcpConnectTimeoutMs: hopConnectionTimeouts.tcpConnectTimeoutSeconds * 1000,
|
||||
sshAuthReadyTimeoutMs: hopConnectionTimeouts.authReadyTimeoutSeconds * 1000,
|
||||
verifyHostKeys: globalTerminalSettings.verifyHostKeys,
|
||||
legacyAlgorithms: jumpHost.legacyAlgorithms,
|
||||
skipEcdsaHostKey: jumpHost.skipEcdsaHostKey,
|
||||
@@ -140,13 +165,16 @@ export const buildSftpHostCredentials = ({
|
||||
|
||||
const keyAuth = resolveBridgeKeyAuth({
|
||||
key,
|
||||
fallbackIdentityFilePaths: resolved.authMethod === "password" || resolved.keyId
|
||||
fallbackIdentityFilePaths: (!host.useSshAgent && resolved.authMethod === "password") || resolved.keyId
|
||||
? undefined
|
||||
: host.identityFilePaths,
|
||||
passphrase: resolved.passphrase,
|
||||
});
|
||||
const targetAgentAuth = resolveBridgeSshAgentAuth(host, key);
|
||||
const password = sanitizeCredentialValue(resolved.password);
|
||||
const hasKeyMaterial = Boolean(keyAuth.privateKey || keyAuth.identityFilePaths?.length);
|
||||
const hasKeyMaterial = Boolean(
|
||||
targetAgentAuth.useSshAgent || keyAuth.privateKey || keyAuth.identityFilePaths?.length,
|
||||
);
|
||||
const hasUnreadableCredential =
|
||||
isEncryptedCredentialPlaceholder(resolved.password) ||
|
||||
isEncryptedCredentialPlaceholder(key?.privateKey) ||
|
||||
@@ -159,6 +187,7 @@ export const buildSftpHostCredentials = ({
|
||||
}
|
||||
|
||||
const targetKeepalive = resolveHostKeepalive(host, globalTerminalSettings);
|
||||
const targetConnectionTimeouts = resolveHostSshConnectionTimeouts(host);
|
||||
return {
|
||||
hostname: host.hostname,
|
||||
username: resolved.username,
|
||||
@@ -174,8 +203,11 @@ export const buildSftpHostCredentials = ({
|
||||
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
sudo: host.sftpSudo,
|
||||
identityFilePaths: keyAuth.identityFilePaths,
|
||||
...targetAgentAuth,
|
||||
keepaliveInterval: targetKeepalive.interval,
|
||||
keepaliveCountMax: targetKeepalive.countMax,
|
||||
sshTcpConnectTimeoutMs: targetConnectionTimeouts.tcpConnectTimeoutSeconds * 1000,
|
||||
sshAuthReadyTimeoutMs: targetConnectionTimeouts.authReadyTimeoutSeconds * 1000,
|
||||
knownHosts,
|
||||
verifyHostKeys: globalTerminalSettings.verifyHostKeys,
|
||||
// Algorithm settings — must reach the SFTP bridge or hosts that need
|
||||
|
||||
@@ -6,19 +6,34 @@ import {
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_APP_ICON_VARIANT,
|
||||
STORAGE_KEY_HTTP_NETWORK_PROXY,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
|
||||
import {
|
||||
normalizeHttpNetworkProxySettings,
|
||||
type HttpNetworkProxySettings,
|
||||
} from '../../domain/httpNetworkProxy';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
parseWindowOpacityRecord,
|
||||
serializeWindowOpacityRecord,
|
||||
shouldApplyWindowOpacityRecord,
|
||||
shouldBroadcastWindowOpacityChange,
|
||||
type WindowOpacityMutationSource,
|
||||
type WindowOpacityRecord,
|
||||
} from './windowOpacitySync';
|
||||
|
||||
interface UseSystemSettingsEffectsParams {
|
||||
enabled?: boolean;
|
||||
toggleWindowHotkey: string;
|
||||
globalHotkeyEnabled: boolean;
|
||||
closeToTray: boolean;
|
||||
windowOpacity: number;
|
||||
windowOpacityRecord: WindowOpacityRecord;
|
||||
windowOpacityMutationSourceRef: MutableRefObject<WindowOpacityMutationSource>;
|
||||
appIconVariant: AppIconVariant;
|
||||
autoUpdateEnabled: boolean;
|
||||
httpNetworkProxy: HttpNetworkProxySettings;
|
||||
persistMountedRef: MutableRefObject<boolean>;
|
||||
setHotkeyRegistrationError: (error: string | null) => void;
|
||||
setAutoUpdateEnabled: (enabled: boolean | ((prev: boolean) => boolean)) => void;
|
||||
@@ -31,9 +46,11 @@ export function useSystemSettingsEffects({
|
||||
toggleWindowHotkey,
|
||||
globalHotkeyEnabled,
|
||||
closeToTray,
|
||||
windowOpacity,
|
||||
windowOpacityRecord,
|
||||
windowOpacityMutationSourceRef,
|
||||
appIconVariant,
|
||||
autoUpdateEnabled,
|
||||
httpNetworkProxy,
|
||||
persistMountedRef,
|
||||
setHotkeyRegistrationError,
|
||||
setAutoUpdateEnabled,
|
||||
@@ -106,17 +123,57 @@ export function useSystemSettingsEffects({
|
||||
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
|
||||
}, [enabled, closeToTray, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
// Persist and apply app-level HTTP(S) network proxy (cloud sync / AI)
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const normalized = normalizeHttpNetworkProxySettings(httpNetworkProxy);
|
||||
localStorageAdapter.write(STORAGE_KEY_HTTP_NETWORK_PROXY, normalized);
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.setHttpNetworkProxy) {
|
||||
// Apply to main process; empty custom is treated as system there.
|
||||
// Persist draft custom+empty so the URL field remains visible.
|
||||
bridge.setHttpNetworkProxy(normalized).catch((err) => {
|
||||
console.warn('[NetworkProxy] Failed to apply HTTP network proxy:', err);
|
||||
});
|
||||
}
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_HTTP_NETWORK_PROXY, normalized);
|
||||
}, [enabled, httpNetworkProxy, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
// Persist and sync window opacity
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.setWindowOpacity?.(windowOpacity).catch((err) => {
|
||||
bridge?.setWindowOpacity?.(windowOpacityRecord.opacity).catch((err) => {
|
||||
console.warn('[WindowOpacity] Failed to apply window opacity:', err);
|
||||
});
|
||||
localStorageAdapter.writeString(STORAGE_KEY_WINDOW_OPACITY, String(windowOpacity));
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_WINDOW_OPACITY, windowOpacity);
|
||||
}, [enabled, windowOpacity, notifySettingsChanged, persistMountedRef]);
|
||||
// Never let a stale effect overwrite a newer revision already on disk.
|
||||
const stored = parseWindowOpacityRecord(
|
||||
localStorageAdapter.readString(STORAGE_KEY_WINDOW_OPACITY),
|
||||
);
|
||||
if (
|
||||
shouldApplyWindowOpacityRecord(stored, windowOpacityRecord)
|
||||
|| stored.version === windowOpacityRecord.version
|
||||
) {
|
||||
localStorageAdapter.writeString(
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
serializeWindowOpacityRecord(windowOpacityRecord),
|
||||
);
|
||||
}
|
||||
const decision = shouldBroadcastWindowOpacityChange(
|
||||
windowOpacityMutationSourceRef.current,
|
||||
persistMountedRef.current,
|
||||
);
|
||||
windowOpacityMutationSourceRef.current = decision.nextSource;
|
||||
if (!decision.shouldBroadcast) return;
|
||||
notifySettingsChanged(STORAGE_KEY_WINDOW_OPACITY, windowOpacityRecord);
|
||||
}, [
|
||||
enabled,
|
||||
windowOpacityRecord,
|
||||
windowOpacityMutationSourceRef,
|
||||
notifySettingsChanged,
|
||||
persistMountedRef,
|
||||
]);
|
||||
|
||||
// Persist and sync app icon variant
|
||||
useEffect(() => {
|
||||
|
||||
@@ -49,6 +49,21 @@ test("split session clones reuse only connected SSH sources", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("session clones preserve the ephemeral-host marker", () => {
|
||||
assert.equal(
|
||||
createSplitTerminalSessionClone(session({ ephemeralHost: true }), { id: "split-1" }).ephemeralHost,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
createCopiedTerminalSessionClone(session({ ephemeralHost: true }), { id: "copy-1" }).ephemeralHost,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
createSplitTerminalSessionClone(session(), { id: "split-2" }).ephemeralHost,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("copy session clones reuse SSH sources and preserve serial config", () => {
|
||||
const copied = createCopiedTerminalSessionClone(
|
||||
session({
|
||||
@@ -60,3 +75,19 @@ test("copy session clones reuse SSH sources and preserve serial config", () => {
|
||||
assert.equal(copied.reuseConnectionFromSessionId, "session-1");
|
||||
assert.deepEqual(copied.serialConfig, { path: "/dev/tty.usbserial", baudRate: 115200 });
|
||||
});
|
||||
|
||||
test("split and copy session clones preserve local start directory", () => {
|
||||
const source = session({
|
||||
protocol: "local",
|
||||
localStartDir: "/Users/alice/project with spaces ",
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
createSplitTerminalSessionClone(source, { id: "split-local" }).localStartDir,
|
||||
"/Users/alice/project with spaces ",
|
||||
);
|
||||
assert.equal(
|
||||
createCopiedTerminalSessionClone(source, { id: "copy-local" }).localStartDir,
|
||||
"/Users/alice/project with spaces ",
|
||||
);
|
||||
});
|
||||
@@ -43,8 +43,10 @@ function createTerminalSessionClone(
|
||||
localShellArgs: session.localShellArgs,
|
||||
localShellName: session.localShellName,
|
||||
localShellIcon: session.localShellIcon,
|
||||
localStartDir: session.localStartDir,
|
||||
fontSize: session.fontSize,
|
||||
fontSizeOverride: session.fontSizeOverride,
|
||||
...(session.ephemeralHost ? { ephemeralHost: true } : {}),
|
||||
reuseConnectionFromSessionId: canReuseTerminalConnection(session) ? session.id : undefined,
|
||||
};
|
||||
|
||||
|
||||
@@ -33,12 +33,16 @@ export const useApplicationBackend = () => {
|
||||
return info ?? null;
|
||||
}, []);
|
||||
|
||||
const checkSshAgent = useCallback(async (): Promise<SshAgentStatus | null> => {
|
||||
const checkSshAgent = useCallback(async (options?: {
|
||||
identityAgent?: string;
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
}): Promise<SshAgentStatus | null> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
const status = await bridge?.checkSshAgent?.();
|
||||
const status = await bridge?.checkSshAgent?.(options);
|
||||
return status ?? null;
|
||||
}, []);
|
||||
|
||||
return { openExternal, getApplicationInfo, checkSshAgent };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { buildAITerminalSessionInfo } from '../../components/terminalLayer/TerminalLayerSupport';
|
||||
import { detectLocalOs } from '../../lib/localShell';
|
||||
import type { Host, PortForwardingRule, TerminalSession } from '../../types';
|
||||
import { STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } from '../../infrastructure/config/storageKeys';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { AI_STATE_CHANGED_EVENT } from './aiStateEvents';
|
||||
import { readExternalMcpStoredEnabled } from './useExternalMcpToggleState';
|
||||
|
||||
const EXTERNAL_MCP_CHAT_SESSION_ID = '__external_mcp__';
|
||||
|
||||
type UseExternalMcpSessionSyncOptions = {
|
||||
sessions: TerminalSession[];
|
||||
sessionHostsMap?: Map<string, Host>;
|
||||
hosts: Host[];
|
||||
portForwardingRules: PortForwardingRule[];
|
||||
};
|
||||
|
||||
function isMainAppWindow(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const hash = window.location.hash || '';
|
||||
// Peer session windows and settings-only windows must not own the
|
||||
// app-wide External MCP scope — they only see a partial session set.
|
||||
if (hash.startsWith('#/session-window')) return false;
|
||||
if (hash.startsWith('#/settings')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the reserved External MCP scope aligned with every live terminal
|
||||
* session, independent of whether the Catty AI side panel / TerminalLayer
|
||||
* has mounted yet.
|
||||
*/
|
||||
export function useExternalMcpSessionSync({
|
||||
sessions,
|
||||
sessionHostsMap,
|
||||
hosts,
|
||||
portForwardingRules,
|
||||
}: UseExternalMcpSessionSyncOptions) {
|
||||
const [enabledTick, setEnabledTick] = useState(0);
|
||||
const enabled = useMemo(() => {
|
||||
void enabledTick;
|
||||
return readExternalMcpStoredEnabled();
|
||||
}, [enabledTick]);
|
||||
const syncGenerationRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const bump = () => setEnabledTick((value) => value + 1);
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) return;
|
||||
bump();
|
||||
};
|
||||
const handleLocalStateChanged = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ key?: string }>).detail;
|
||||
if (detail?.key && detail.key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) return;
|
||||
bump();
|
||||
};
|
||||
window.addEventListener('storage', handleStorage);
|
||||
window.addEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorage);
|
||||
window.removeEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const payload = useMemo(() => {
|
||||
const localOs = detectLocalOs(navigator.userAgent || navigator.platform);
|
||||
const hostById = new Map(hosts.map((host) => [host.id, host]));
|
||||
return sessions.map((session) => {
|
||||
const host = sessionHostsMap?.get(session.id)
|
||||
|| (session.hostId ? hostById.get(session.hostId) : undefined);
|
||||
return buildAITerminalSessionInfo(session, host, localOs, {
|
||||
allHosts: hosts,
|
||||
portForwardingRules,
|
||||
});
|
||||
});
|
||||
}, [sessions, sessionHostsMap, hosts, portForwardingRules]);
|
||||
|
||||
const lastSentSerializedRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMainAppWindow()) return;
|
||||
if (!enabled) {
|
||||
syncGenerationRef.current += 1;
|
||||
lastSentSerializedRef.current = '';
|
||||
return;
|
||||
}
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.aiMcpUpdateSessions) return;
|
||||
|
||||
const serialized = JSON.stringify(payload);
|
||||
if (serialized === lastSentSerializedRef.current) return;
|
||||
|
||||
const generation = ++syncGenerationRef.current;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void Promise.resolve(bridge.aiMcpUpdateSessions?.(payload, EXTERNAL_MCP_CHAT_SESSION_ID))
|
||||
.then((result) => {
|
||||
if (generation !== syncGenerationRef.current) return;
|
||||
if (result && typeof result === 'object' && 'ok' in result && (result as { ok?: boolean }).ok === false) {
|
||||
return;
|
||||
}
|
||||
lastSentSerializedRef.current = serialized;
|
||||
})
|
||||
.catch(() => {
|
||||
// Leave the ref unset so the next effect can retry.
|
||||
});
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [enabled, payload]);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import {
|
||||
createExternalMcpStartupSyncPlan,
|
||||
normalizeExternalMcpIdleTimeoutMinutes,
|
||||
normalizeExternalMcpMode,
|
||||
shouldStartExternalMcpOnStartup,
|
||||
} from './useExternalMcpToggleState.ts';
|
||||
|
||||
describe('useExternalMcpToggleState helpers', () => {
|
||||
it('normalizes mode and idle timeout', () => {
|
||||
assert.equal(normalizeExternalMcpMode('persistent'), 'persistent');
|
||||
assert.equal(normalizeExternalMcpMode('other'), 'temporary');
|
||||
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(null), 10);
|
||||
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(0), 1);
|
||||
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(99999), 24 * 60);
|
||||
});
|
||||
|
||||
it('only starts on launch for persistent+enabled', () => {
|
||||
assert.equal(shouldStartExternalMcpOnStartup({ enabled: true, mode: 'persistent' }), true);
|
||||
assert.equal(shouldStartExternalMcpOnStartup({ enabled: true, mode: 'temporary' }), false);
|
||||
assert.equal(shouldStartExternalMcpOnStartup({ enabled: false, mode: 'persistent' }), false);
|
||||
});
|
||||
|
||||
it('startup sync plan clears temporary stored enabled', () => {
|
||||
const plan = createExternalMcpStartupSyncPlan({
|
||||
enabled: true,
|
||||
mode: 'temporary',
|
||||
idleTimeoutMinutes: 15,
|
||||
});
|
||||
assert.equal(plan.runtimeEnabled, false);
|
||||
assert.equal(plan.storedEnabled, false);
|
||||
assert.equal(plan.shouldPersistStoredEnabled, true);
|
||||
assert.equal(plan.config.idleTimeoutMinutes, 15);
|
||||
});
|
||||
|
||||
it('startup sync plan keeps persistent enabled', () => {
|
||||
const plan = createExternalMcpStartupSyncPlan({
|
||||
enabled: true,
|
||||
mode: 'persistent',
|
||||
idleTimeoutMinutes: 20,
|
||||
});
|
||||
assert.equal(plan.runtimeEnabled, true);
|
||||
assert.equal(plan.storedEnabled, true);
|
||||
assert.equal(plan.shouldPersistStoredEnabled, false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_MODE,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
|
||||
export type ExternalMcpMode = 'temporary' | 'persistent';
|
||||
|
||||
const DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 10;
|
||||
const MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 1;
|
||||
const MAX_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 24 * 60;
|
||||
|
||||
type ExternalMcpConfig = {
|
||||
mode: ExternalMcpMode;
|
||||
idleTimeoutMinutes: number;
|
||||
};
|
||||
|
||||
type ExternalMcpBridge = {
|
||||
externalMcpSetConfig?: (config: ExternalMcpConfig) => Promise<unknown> | unknown;
|
||||
externalMcpSetEnabled?: (enabled: boolean) => Promise<unknown> | unknown;
|
||||
externalMcpGetStatus?: () => Promise<{ ok?: boolean; enabled?: boolean } | undefined>;
|
||||
};
|
||||
|
||||
export type ExternalMcpStartupSyncPlan = {
|
||||
config: ExternalMcpConfig;
|
||||
runtimeEnabled: boolean;
|
||||
storedEnabled: boolean;
|
||||
shouldPersistStoredEnabled: boolean;
|
||||
};
|
||||
|
||||
export function normalizeExternalMcpMode(value: string | null): ExternalMcpMode {
|
||||
return value === 'persistent' ? 'persistent' : 'temporary';
|
||||
}
|
||||
|
||||
export function normalizeExternalMcpIdleTimeoutMinutes(value: number | null): number {
|
||||
if (!Number.isFinite(value)) return DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES;
|
||||
return Math.min(
|
||||
MAX_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
|
||||
Math.max(
|
||||
MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
|
||||
Math.round(value ?? DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function readExternalMcpStoredEnabled(): boolean {
|
||||
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) ?? false;
|
||||
}
|
||||
|
||||
export function readExternalMcpMode(): ExternalMcpMode {
|
||||
return normalizeExternalMcpMode(localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE));
|
||||
}
|
||||
|
||||
export function readExternalMcpIdleTimeoutMinutes(): number {
|
||||
return normalizeExternalMcpIdleTimeoutMinutes(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES),
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldStartExternalMcpOnStartup({
|
||||
enabled,
|
||||
mode,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
mode: ExternalMcpMode;
|
||||
}): boolean {
|
||||
return mode === 'persistent' && enabled;
|
||||
}
|
||||
|
||||
export function readExternalMcpStartupEnabled(): boolean {
|
||||
return shouldStartExternalMcpOnStartup({
|
||||
enabled: readExternalMcpStoredEnabled(),
|
||||
mode: readExternalMcpMode(),
|
||||
});
|
||||
}
|
||||
|
||||
export function createExternalMcpStartupSyncPlan({
|
||||
enabled,
|
||||
mode,
|
||||
idleTimeoutMinutes,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
mode: ExternalMcpMode;
|
||||
idleTimeoutMinutes: number;
|
||||
}): ExternalMcpStartupSyncPlan {
|
||||
const runtimeEnabled = shouldStartExternalMcpOnStartup({ enabled, mode });
|
||||
const storedEnabled = runtimeEnabled;
|
||||
return {
|
||||
config: {
|
||||
mode,
|
||||
idleTimeoutMinutes,
|
||||
},
|
||||
runtimeEnabled,
|
||||
storedEnabled,
|
||||
shouldPersistStoredEnabled: storedEnabled !== enabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function readExternalMcpStartupSyncPlan(): ExternalMcpStartupSyncPlan {
|
||||
return createExternalMcpStartupSyncPlan({
|
||||
enabled: readExternalMcpStoredEnabled(),
|
||||
mode: readExternalMcpMode(),
|
||||
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
|
||||
});
|
||||
}
|
||||
|
||||
export function syncExternalMcpConfig(bridge: ExternalMcpBridge | undefined = netcattyBridge.get()): void {
|
||||
void bridge?.externalMcpSetConfig?.({
|
||||
mode: readExternalMcpMode(),
|
||||
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* App-startup reconcile: only persistent+enabled starts the runtime.
|
||||
* Temporary mode never auto-starts, and we clear a stale stored enabled flag
|
||||
* so Settings remounts cannot accidentally re-enable temporary mode.
|
||||
*/
|
||||
export function syncExternalMcpStartupState(
|
||||
bridge: ExternalMcpBridge | undefined = netcattyBridge.get(),
|
||||
): ExternalMcpStartupSyncPlan {
|
||||
const plan = readExternalMcpStartupSyncPlan();
|
||||
void bridge?.externalMcpSetConfig?.(plan.config);
|
||||
if (plan.shouldPersistStoredEnabled) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, plan.storedEnabled);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
|
||||
}
|
||||
void bridge?.externalMcpSetEnabled?.(plan.runtimeEnabled);
|
||||
return plan;
|
||||
}
|
||||
|
||||
export function useExternalMcpToggleState() {
|
||||
// UI mirrors the stored switch. Startup reconcile (App mount, main window only)
|
||||
// decides whether temporary mode should clear/persist and start the runtime.
|
||||
const [enabled, setEnabledRaw] = useState<boolean>(() => readExternalMcpStoredEnabled());
|
||||
|
||||
const persistEnabled = useCallback((nextEnabled: boolean) => {
|
||||
setEnabledRaw(nextEnabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, nextEnabled);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
|
||||
}, []);
|
||||
|
||||
const setEnabled = useCallback((nextEnabled: boolean) => {
|
||||
persistEnabled(nextEnabled);
|
||||
void netcattyBridge.get()?.externalMcpSetEnabled?.(nextEnabled);
|
||||
}, [persistEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const syncFromStorage = () => {
|
||||
const nextEnabled = readExternalMcpStoredEnabled();
|
||||
setEnabledRaw(nextEnabled);
|
||||
};
|
||||
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) return;
|
||||
syncFromStorage();
|
||||
};
|
||||
const handleLocalStateChanged = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) return;
|
||||
syncFromStorage();
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorage);
|
||||
window.addEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorage);
|
||||
window.removeEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const syncRuntimeStatus = async () => {
|
||||
try {
|
||||
const status = await netcattyBridge.get()?.externalMcpGetStatus?.();
|
||||
if (status?.ok && !status.enabled) {
|
||||
persistEnabled(false);
|
||||
}
|
||||
} catch {
|
||||
// Keep the user's stored switch state during transient bridge errors.
|
||||
}
|
||||
};
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
void syncRuntimeStatus();
|
||||
}, 30000);
|
||||
void syncRuntimeStatus();
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [enabled, persistEnabled]);
|
||||
|
||||
return { enabled, setEnabled };
|
||||
}
|
||||
@@ -7,24 +7,7 @@ export const useKeychainBackend = () => {
|
||||
return bridge?.generateKeyPair?.(options);
|
||||
}, []);
|
||||
|
||||
const execCommand = useCallback(async (options: {
|
||||
hostname: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
command: string;
|
||||
timeout?: number;
|
||||
enableKeyboardInteractive?: boolean;
|
||||
sessionId?: string;
|
||||
// Algorithm settings — let the keychain "export public key" flow honor
|
||||
// the same per-host SSH algorithm config the terminal uses, so a host
|
||||
// that needs the ECDSA skip / legacy mode / advanced overrides works
|
||||
// here too.
|
||||
legacyAlgorithms?: boolean;
|
||||
skipEcdsaHostKey?: boolean;
|
||||
algorithmOverrides?: import("../../domain/models").HostAlgorithmOverrides;
|
||||
}) => {
|
||||
const execCommand = useCallback(async (options: Parameters<NetcattyBridge["execCommand"]>[0]) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.execCommand) throw new Error("execCommand unavailable");
|
||||
return bridge.execCommand(options);
|
||||
@@ -32,4 +15,3 @@ export const useKeychainBackend = () => {
|
||||
|
||||
return { generateKeyPair, execCommand };
|
||||
};
|
||||
|
||||
@@ -43,6 +43,7 @@ test("main-window input focus recovery ignores generic focus and visible visibil
|
||||
let cancelFocusCalls = 0;
|
||||
let hiddenCalls = 0;
|
||||
let shownHandler: Listener | null = null;
|
||||
let focusRequestedHandler: Listener | null = null;
|
||||
let willHideHandler: Listener | null = null;
|
||||
|
||||
const documentTarget = createEventTargetStub();
|
||||
@@ -63,6 +64,10 @@ test("main-window input focus recovery ignores generic focus and visible visibil
|
||||
shownHandler = cb;
|
||||
return () => { shownHandler = null; };
|
||||
},
|
||||
onWindowFocusRequested(cb: Listener) {
|
||||
focusRequestedHandler = cb;
|
||||
return () => { focusRequestedHandler = null; };
|
||||
},
|
||||
onWindowWillHide(cb: Listener) {
|
||||
willHideHandler = cb;
|
||||
return () => { willHideHandler = null; };
|
||||
@@ -85,6 +90,10 @@ test("main-window input focus recovery ignores generic focus and visible visibil
|
||||
|
||||
shownHandler?.();
|
||||
|
||||
assert.equal(scheduleFocusCalls, 0);
|
||||
|
||||
focusRequestedHandler?.();
|
||||
|
||||
assert.equal(scheduleFocusCalls, 1);
|
||||
|
||||
willHideHandler?.();
|
||||
@@ -94,6 +103,10 @@ test("main-window input focus recovery ignores generic focus and visible visibil
|
||||
|
||||
shownHandler?.();
|
||||
|
||||
assert.equal(scheduleFocusCalls, 1);
|
||||
|
||||
focusRequestedHandler?.();
|
||||
|
||||
assert.equal(scheduleFocusCalls, 2);
|
||||
|
||||
visibilityState = "hidden";
|
||||
@@ -105,11 +118,11 @@ test("main-window input focus recovery ignores generic focus and visible visibil
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("main-window input focus recovery retries an explicit show when visibility catches up", () => {
|
||||
test("main-window input focus recovery retries an explicit foreground request when visibility catches up", () => {
|
||||
let visibilityState: DocumentVisibilityState = "hidden";
|
||||
let scheduleFocusCalls = 0;
|
||||
let hiddenCalls = 0;
|
||||
let shownHandler: Listener | null = null;
|
||||
let focusRequestedHandler: Listener | null = null;
|
||||
|
||||
const documentTarget = createEventTargetStub();
|
||||
|
||||
@@ -123,9 +136,9 @@ test("main-window input focus recovery retries an explicit show when visibility
|
||||
},
|
||||
},
|
||||
bridge: {
|
||||
onWindowShown(cb: Listener) {
|
||||
shownHandler = cb;
|
||||
return () => { shownHandler = null; };
|
||||
onWindowFocusRequested(cb: Listener) {
|
||||
focusRequestedHandler = cb;
|
||||
return () => { focusRequestedHandler = null; };
|
||||
},
|
||||
},
|
||||
scheduleFocus: () => {
|
||||
@@ -135,7 +148,7 @@ test("main-window input focus recovery retries an explicit show when visibility
|
||||
},
|
||||
);
|
||||
|
||||
shownHandler?.();
|
||||
focusRequestedHandler?.();
|
||||
|
||||
assert.equal(scheduleFocusCalls, 0);
|
||||
|
||||
@@ -152,12 +165,13 @@ test("main-window input focus recovery retries an explicit show when visibility
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("useMainWindowInputFocusRecovery restores input focus only after explicit window-shown IPC", () => {
|
||||
test("useMainWindowInputFocusRecovery restores input focus only after explicit foreground IPC", () => {
|
||||
const source = readProjectFile("application/state/useMainWindowInputFocusRecovery.ts");
|
||||
|
||||
assert.match(source, /visibilityState !== "visible"/);
|
||||
assert.match(source, /scheduleFocus\(\)/);
|
||||
assert.match(source, /onWindowShown\?\.\(\(\) => \{\s*pendingExplicitShowRecovery = true;\s*recoverFocus\(\);\s*\}\)/);
|
||||
assert.match(source, /onWindowFocusRequested\?\.\(\(\) => \{\s*pendingExplicitFocusRecovery = true;\s*recoverFocus\(\);\s*\}\)/);
|
||||
assert.doesNotMatch(source, /onWindowShown\?\.\(\(\) => \{[\s\S]*recoverFocus\(\)/);
|
||||
assert.doesNotMatch(source, /window\.addEventListener\("focus"/);
|
||||
assert.doesNotMatch(source, /window\.removeEventListener\("focus"/);
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ type MainWindowInputFocusRecoveryWindow = {
|
||||
|
||||
type MainWindowInputFocusRecoveryBridge = {
|
||||
onWindowShown?: (callback: Listener) => Listener;
|
||||
onWindowFocusRequested?: (callback: Listener) => Listener;
|
||||
onWindowWillHide?: (callback: Listener) => Listener;
|
||||
};
|
||||
|
||||
@@ -53,7 +54,7 @@ export function startMainWindowInputFocusRecovery(
|
||||
} = dependencies;
|
||||
|
||||
let pendingFocusRecovery: ScheduledWindowInputFocus | null = null;
|
||||
let pendingExplicitShowRecovery = false;
|
||||
let pendingExplicitFocusRecovery = false;
|
||||
|
||||
const cancelPendingFocusRecovery = () => {
|
||||
pendingFocusRecovery?.cancel();
|
||||
@@ -62,14 +63,14 @@ export function startMainWindowInputFocusRecovery(
|
||||
|
||||
const recoverFocus = (): boolean => {
|
||||
if (documentRef.visibilityState !== "visible") return false;
|
||||
pendingExplicitShowRecovery = false;
|
||||
pendingExplicitFocusRecovery = false;
|
||||
cancelPendingFocusRecovery();
|
||||
pendingFocusRecovery = scheduleFocus();
|
||||
return true;
|
||||
};
|
||||
|
||||
const dismissTransientUi = () => {
|
||||
pendingExplicitShowRecovery = false;
|
||||
pendingExplicitFocusRecovery = false;
|
||||
cancelPendingFocusRecovery();
|
||||
onPageHidden?.();
|
||||
};
|
||||
@@ -79,15 +80,15 @@ export function startMainWindowInputFocusRecovery(
|
||||
dismissTransientUi();
|
||||
return;
|
||||
}
|
||||
if (pendingExplicitShowRecovery) {
|
||||
if (pendingExplicitFocusRecovery) {
|
||||
recoverFocus();
|
||||
}
|
||||
};
|
||||
|
||||
documentRef.addEventListener("visibilitychange", onVisibilityChange);
|
||||
|
||||
const unsubscribeShown = bridge?.onWindowShown?.(() => {
|
||||
pendingExplicitShowRecovery = true;
|
||||
const unsubscribeFocusRequested = bridge?.onWindowFocusRequested?.(() => {
|
||||
pendingExplicitFocusRecovery = true;
|
||||
recoverFocus();
|
||||
});
|
||||
const unsubscribeWillHide = bridge?.onWindowWillHide?.(() => {
|
||||
@@ -95,16 +96,16 @@ export function startMainWindowInputFocusRecovery(
|
||||
});
|
||||
|
||||
return () => {
|
||||
pendingExplicitShowRecovery = false;
|
||||
pendingExplicitFocusRecovery = false;
|
||||
cancelPendingFocusRecovery();
|
||||
documentRef.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
unsubscribeShown?.();
|
||||
unsubscribeFocusRequested?.();
|
||||
unsubscribeWillHide?.();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover OS/renderer input focus when the main process explicitly shows the
|
||||
* Recover OS/renderer input focus when the main process explicitly foregrounds the
|
||||
* main window (#760, #1714, #1722).
|
||||
*/
|
||||
export function useMainWindowInputFocusRecovery(
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { Host } from "../../domain/models.ts";
|
||||
import { haveSameManagedSshAgentFields } from "./useManagedSourceSync.ts";
|
||||
|
||||
const host: Host = {
|
||||
id: "host-1",
|
||||
label: "Managed host",
|
||||
hostname: "managed.example.com",
|
||||
username: "root",
|
||||
port: 22,
|
||||
protocol: "ssh",
|
||||
os: "linux",
|
||||
tags: [],
|
||||
};
|
||||
|
||||
test("managed SSH source comparison tracks every agent setting", () => {
|
||||
const changedFields: Array<keyof Host> = [
|
||||
"useSshAgent",
|
||||
"identityAgent",
|
||||
"identitiesOnly",
|
||||
"addKeysToAgent",
|
||||
"useKeychain",
|
||||
];
|
||||
|
||||
for (const field of changedFields) {
|
||||
const changed = {
|
||||
...host,
|
||||
[field]: field === "identityAgent" || field === "addKeysToAgent"
|
||||
? "changed"
|
||||
: true,
|
||||
};
|
||||
assert.equal(haveSameManagedSshAgentFields(host, changed), false, field);
|
||||
}
|
||||
|
||||
assert.equal(haveSameManagedSshAgentFields(host, { ...host }), true);
|
||||
});
|
||||
@@ -15,6 +15,14 @@ export interface UseManagedSourceSyncOptions {
|
||||
onUpdateManagedSources: (sources: ManagedSource[]) => void;
|
||||
}
|
||||
|
||||
export const haveSameManagedSshAgentFields = (previous: Host, current: Host): boolean => (
|
||||
previous.useSshAgent === current.useSshAgent
|
||||
&& previous.identityAgent === current.identityAgent
|
||||
&& previous.identitiesOnly === current.identitiesOnly
|
||||
&& previous.addKeysToAgent === current.addKeysToAgent
|
||||
&& previous.useKeychain === current.useKeychain
|
||||
);
|
||||
|
||||
export const useManagedSourceSync = ({
|
||||
hosts,
|
||||
managedSources,
|
||||
@@ -285,6 +293,7 @@ export const useManagedSourceSync = ({
|
||||
prev.label !== curr.label ||
|
||||
prev.group !== curr.group ||
|
||||
prev.protocol !== curr.protocol ||
|
||||
!haveSameManagedSshAgentFields(prev, curr) ||
|
||||
chainChanged;
|
||||
if (hasChanged) {
|
||||
sourceChanged = true;
|
||||
|
||||
@@ -11,7 +11,6 @@ createWorkspaceFromSessionIds,
|
||||
FocusDirection,
|
||||
getNextFocusSessionId,
|
||||
insertPaneIntoWorkspace,
|
||||
pruneWorkspaceNode,
|
||||
reorderWorkspaceFocusSessionOrder,
|
||||
SplitDirection,
|
||||
SplitHint,
|
||||
@@ -21,9 +20,11 @@ import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } fr
|
||||
import { buildOrderedWorkTabIds, reorderWorkTabIds } from '../app/workTabSurface';
|
||||
import { activeTabStore } from './activeTabStore';
|
||||
import {
|
||||
applyCloseSessionToSessions,
|
||||
closeSessionWorkspaceLayoutState,
|
||||
detachSessionFromWorkspaceState,
|
||||
replaceDissolvedWorkspaceTabOrder,
|
||||
resolveActiveTabAfterCloseSession,
|
||||
} from './sessionWorkspaceDetach';
|
||||
import {
|
||||
createCopiedTerminalSessionClone,
|
||||
@@ -390,85 +391,46 @@ export const useSessionState = ({
|
||||
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;
|
||||
// Compute layout from the latest refs so dissolve/remove cannot desync from
|
||||
// a stale workspaces/sessions render snapshot. Empty workspaces are already
|
||||
// removed by closeSessionWorkspaceLayoutState — do NOT follow up with
|
||||
// closeWorkspace(), which would race and delete remaining terminals that
|
||||
// still briefly carry the old workspaceId.
|
||||
const targetSession = sessionsRef.current.find((session) => session.id === sessionId);
|
||||
const wsId = targetSession?.workspaceId;
|
||||
const layoutResult = closeSessionWorkspaceLayoutState(
|
||||
workspacesRef.current,
|
||||
wsId,
|
||||
sessionId,
|
||||
);
|
||||
const nextSessions = applyCloseSessionToSessions(
|
||||
sessionsRef.current,
|
||||
sessionId,
|
||||
layoutResult,
|
||||
);
|
||||
|
||||
setSessions(prevSessions => {
|
||||
const targetSession = prevSessions.find(s => s.id === sessionId);
|
||||
const wsId = targetSession?.workspaceId;
|
||||
setWorkspaces(layoutResult.workspaces);
|
||||
setSessions(nextSessions);
|
||||
|
||||
setWorkspaces(prevWorkspaces => {
|
||||
const {
|
||||
workspaces: nextWorkspaces,
|
||||
removedWorkspaceId,
|
||||
dissolvedWorkspaceId,
|
||||
lastRemainingSessionId,
|
||||
} = closeSessionWorkspaceLayoutState(prevWorkspaces, wsId, sessionId);
|
||||
|
||||
const remainingSessions = prevSessions.filter(s => s.id !== sessionId);
|
||||
const fallbackWorkspace = nextWorkspaces[nextWorkspaces.length - 1];
|
||||
const fallbackSolo = remainingSessions.filter(s => !s.workspaceId).slice(-1)[0];
|
||||
|
||||
const currentActiveTabId = activeTabStore.getActiveTabId();
|
||||
const getFallback = () => {
|
||||
if (lastRemainingSessionId) return lastRemainingSessionId;
|
||||
if (fallbackWorkspace) return fallbackWorkspace.id;
|
||||
if (fallbackSolo) return fallbackSolo.id;
|
||||
return 'vault';
|
||||
};
|
||||
|
||||
if (dissolvedWorkspaceId && lastRemainingSessionId) {
|
||||
setTabOrder(prevTabOrder => replaceDissolvedWorkspaceTabOrder(
|
||||
prevTabOrder,
|
||||
dissolvedWorkspaceId,
|
||||
[lastRemainingSessionId],
|
||||
));
|
||||
}
|
||||
|
||||
if (dissolvedWorkspaceId && currentActiveTabId === dissolvedWorkspaceId) {
|
||||
setActiveTabId(getFallback());
|
||||
} else if (currentActiveTabId === sessionId) {
|
||||
setActiveTabId(getFallback());
|
||||
} else if (removedWorkspaceId && currentActiveTabId === removedWorkspaceId) {
|
||||
setActiveTabId(getFallback());
|
||||
} 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);
|
||||
if (ws) {
|
||||
const pruned = pruneWorkspaceNode(ws.root, sessionId);
|
||||
if (pruned) {
|
||||
const remainingSessionIds = collectSessionIds(pruned);
|
||||
if (remainingSessionIds.length === 1) {
|
||||
// Dissolve: remove workspaceId from the remaining session
|
||||
return prevSessions
|
||||
.filter(s => s.id !== sessionId)
|
||||
.map(s => remainingSessionIds.includes(s.id) ? { ...s, workspaceId: undefined } : s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return prevSessions.filter(s => s.id !== sessionId);
|
||||
});
|
||||
|
||||
if (workspaceIdToMaybeClose) {
|
||||
queueMicrotask(() => closeWorkspace(workspaceIdToMaybeClose!));
|
||||
if (layoutResult.dissolvedWorkspaceId && layoutResult.lastRemainingSessionId) {
|
||||
setTabOrder((prevTabOrder) => replaceDissolvedWorkspaceTabOrder(
|
||||
prevTabOrder,
|
||||
layoutResult.dissolvedWorkspaceId,
|
||||
[layoutResult.lastRemainingSessionId!],
|
||||
));
|
||||
}
|
||||
}, [sessions, workspaces, setActiveTabId, closeWorkspace]);
|
||||
|
||||
const nextActiveTabId = resolveActiveTabAfterCloseSession({
|
||||
currentActiveTabId: activeTabStore.getActiveTabId(),
|
||||
closedSessionId: sessionId,
|
||||
workspaceId: wsId,
|
||||
layoutResult,
|
||||
remainingSessions: nextSessions,
|
||||
});
|
||||
if (nextActiveTabId) {
|
||||
setActiveTabId(nextActiveTabId);
|
||||
}
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const startSessionRename = useCallback((sessionId: string) => {
|
||||
setSessions(prevSessions => {
|
||||
@@ -630,20 +592,13 @@ export const useSessionState = ({
|
||||
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',
|
||||
return createLocalTerminalSession(sessionId, {
|
||||
shellType: target.shellType,
|
||||
localShell: target.shell,
|
||||
localShellArgs: target.shellArgs,
|
||||
localShellName: target.shellName,
|
||||
localShellIcon: target.shellIcon,
|
||||
};
|
||||
shell: target.shell,
|
||||
shellArgs: target.shellArgs,
|
||||
shellName: target.shellName,
|
||||
shellIcon: target.shellIcon,
|
||||
});
|
||||
}
|
||||
const host = target.host;
|
||||
if (host.protocol === 'serial') {
|
||||
@@ -708,23 +663,38 @@ export const useSessionState = ({
|
||||
) => {
|
||||
if (!hint || baseSessionId === joiningSessionId) return;
|
||||
const newWorkspace = createWorkspaceEntity(baseSessionId, joiningSessionId, hint);
|
||||
|
||||
setSessions(prevSessions => {
|
||||
const base = prevSessions.find(s => s.id === baseSessionId);
|
||||
const joining = prevSessions.find(s => s.id === joiningSessionId);
|
||||
|
||||
setSessions((prevSessions) => {
|
||||
const base = prevSessions.find((s) => s.id === baseSessionId);
|
||||
const joining = prevSessions.find((s) => s.id === joiningSessionId);
|
||||
if (!base || !joining || base.workspaceId || joining.workspaceId) return prevSessions;
|
||||
|
||||
setWorkspaces(prev => addWorkspaceIfMissing(prev, newWorkspace));
|
||||
setWorkspaces((prev) => addWorkspaceIfMissing(prev, newWorkspace));
|
||||
// Collapse the two session tab slots into the workspace tab so later
|
||||
// detach/close can replace `ws-*` with session ids again.
|
||||
setTabOrder((prevTabOrder) => {
|
||||
const withoutSessions = prevTabOrder.filter(
|
||||
(id) => id !== baseSessionId && id !== joiningSessionId,
|
||||
);
|
||||
if (withoutSessions.includes(newWorkspace.id)) return withoutSessions;
|
||||
const indexes = [baseSessionId, joiningSessionId]
|
||||
.map((id) => prevTabOrder.indexOf(id))
|
||||
.filter((index) => index >= 0);
|
||||
const insertAt = indexes.length > 0 ? Math.min(...indexes) : withoutSessions.length;
|
||||
const next = [...withoutSessions];
|
||||
next.splice(Math.min(insertAt, next.length), 0, newWorkspace.id);
|
||||
return next;
|
||||
});
|
||||
setActiveTabId(newWorkspace.id);
|
||||
|
||||
return prevSessions.map(s => {
|
||||
|
||||
return prevSessions.map((s) => {
|
||||
if (s.id === baseSessionId || s.id === joiningSessionId) {
|
||||
return { ...s, workspaceId: newWorkspace.id };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const addSessionToWorkspace = useCallback((
|
||||
workspaceId: string,
|
||||
@@ -821,20 +791,14 @@ export const useSessionState = ({
|
||||
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,
|
||||
...createLocalTerminalSession(newSessionId, {
|
||||
shellType: options?.shellType,
|
||||
shell: options?.shell,
|
||||
shellArgs: options?.shellArgs,
|
||||
shellName: options?.shellName,
|
||||
shellIcon: options?.shellIcon,
|
||||
}),
|
||||
workspaceId,
|
||||
};
|
||||
|
||||
@@ -1152,40 +1116,41 @@ export const useSessionState = ({
|
||||
additionalTabIds?: readonly string[];
|
||||
},
|
||||
) => {
|
||||
setSessions(prevSessions => {
|
||||
const result = detachSessionFromWorkspaceState({
|
||||
sessions: prevSessions,
|
||||
workspaces: workspacesRef.current,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
if (!result.changed) return prevSessions;
|
||||
setWorkspaces(result.workspaces);
|
||||
setTabOrder(prevTabOrder => {
|
||||
const replacedOrder = replaceDissolvedWorkspaceTabOrder(
|
||||
prevTabOrder,
|
||||
result.dissolvedWorkspaceId,
|
||||
result.replacementTabIds,
|
||||
);
|
||||
if (!tabInsertionTarget) return replacedOrder;
|
||||
|
||||
const allTabIds = [
|
||||
...result.sessions.filter(s => !s.workspaceId).map(s => s.id),
|
||||
...result.workspaces.map(w => w.id),
|
||||
...logViews.map(lv => lv.id),
|
||||
...(tabInsertionTarget.additionalTabIds ?? []),
|
||||
];
|
||||
return reorderWorkTabIds(
|
||||
replacedOrder,
|
||||
allTabIds,
|
||||
sessionId,
|
||||
tabInsertionTarget.tabId,
|
||||
tabInsertionTarget.position,
|
||||
);
|
||||
});
|
||||
if (result.activeTabId) setActiveTabId(result.activeTabId);
|
||||
return result.sessions;
|
||||
// Detach from latest refs so continuous-render / memoized panes cannot
|
||||
// act on a stale workspace snapshot and drop the terminal instead of
|
||||
// restoring it as a standalone tab.
|
||||
const result = detachSessionFromWorkspaceState({
|
||||
sessions: sessionsRef.current,
|
||||
workspaces: workspacesRef.current,
|
||||
sessionId,
|
||||
});
|
||||
if (!result.changed) return;
|
||||
|
||||
setWorkspaces(result.workspaces);
|
||||
setSessions(result.sessions);
|
||||
setTabOrder((prevTabOrder) => {
|
||||
const replacedOrder = replaceDissolvedWorkspaceTabOrder(
|
||||
prevTabOrder,
|
||||
result.dissolvedWorkspaceId,
|
||||
result.replacementTabIds,
|
||||
);
|
||||
if (!tabInsertionTarget) return replacedOrder;
|
||||
|
||||
const allTabIds = [
|
||||
...result.sessions.filter((s) => !s.workspaceId).map((s) => s.id),
|
||||
...result.workspaces.map((w) => w.id),
|
||||
...logViews.map((lv) => lv.id),
|
||||
...(tabInsertionTarget.additionalTabIds ?? []),
|
||||
];
|
||||
return reorderWorkTabIds(
|
||||
replacedOrder,
|
||||
allTabIds,
|
||||
sessionId,
|
||||
tabInsertionTarget.tabId,
|
||||
tabInsertionTarget.position,
|
||||
);
|
||||
});
|
||||
if (result.activeTabId) setActiveTabId(result.activeTabId);
|
||||
}, [logViews, setActiveTabId]);
|
||||
|
||||
const reorderTabs = useCallback((
|
||||
|
||||
@@ -2,6 +2,12 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, typ
|
||||
|
||||
import { runThemeTransition, type ThemeTransitionMode } from './themeTransition';
|
||||
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
|
||||
import {
|
||||
DEFAULT_HTTP_NETWORK_PROXY,
|
||||
areHttpNetworkProxySettingsEqual,
|
||||
normalizeHttpNetworkProxySettings,
|
||||
type HttpNetworkProxySettings,
|
||||
} from '../../domain/httpNetworkProxy';
|
||||
import {
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_SYNC,
|
||||
@@ -39,8 +45,10 @@ import {
|
||||
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
|
||||
STORAGE_KEY_JMS_DEEP_LINK_ENABLED,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_HTTP_NETWORK_PROXY,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_APP_ICON_VARIANT,
|
||||
@@ -98,6 +106,7 @@ import {
|
||||
DEFAULT_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
|
||||
DEFAULT_SSH_DEEP_LINK_ENABLED,
|
||||
DEFAULT_JMS_DEEP_LINK_ENABLED,
|
||||
DEFAULT_TERMINAL_THEME,
|
||||
DEFAULT_THEME,
|
||||
DEFAULT_WINDOW_OPACITY,
|
||||
@@ -130,6 +139,18 @@ import {
|
||||
isTerminalSidePanelAutoOpenTab,
|
||||
type TerminalSidePanelAutoOpenTab,
|
||||
} from '../../domain/terminalSidePanelAutoOpen';
|
||||
import {
|
||||
parseWindowOpacityRecord,
|
||||
shouldApplyWindowOpacityRecord,
|
||||
type WindowOpacityMutationSource,
|
||||
type WindowOpacityRecord,
|
||||
} from './windowOpacitySync';
|
||||
import {
|
||||
hasPersistedAppearanceChanged,
|
||||
resolveAppearanceSyncState,
|
||||
type AppearanceRenderSnapshot,
|
||||
type AppearanceSyncEvent,
|
||||
} from './appearanceSync';
|
||||
|
||||
export const useSettingsState = (options: { enableSettingsSync?: boolean; enableSystemEffects?: boolean } = {}) => {
|
||||
const enableSettingsSync = options.enableSettingsSync !== false;
|
||||
@@ -322,6 +343,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SSH_DEEP_LINK_ENABLED);
|
||||
return stored ?? DEFAULT_SSH_DEEP_LINK_ENABLED;
|
||||
});
|
||||
const [jmsDeepLinkEnabled, setJmsDeepLinkEnabledState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED);
|
||||
return stored ?? DEFAULT_JMS_DEEP_LINK_ENABLED;
|
||||
});
|
||||
|
||||
// Global Toggle Window Settings (Quake Mode)
|
||||
const [toggleWindowHotkey, setToggleWindowHotkey] = useState<string>(() => {
|
||||
@@ -337,6 +362,21 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
if (stored === null) return true;
|
||||
return stored === 'true';
|
||||
});
|
||||
const [httpNetworkProxy, setHttpNetworkProxyState] = useState<HttpNetworkProxySettings>(() => {
|
||||
const stored = localStorageAdapter.read<unknown>(STORAGE_KEY_HTTP_NETWORK_PROXY);
|
||||
return normalizeHttpNetworkProxySettings(stored ?? DEFAULT_HTTP_NETWORK_PROXY);
|
||||
});
|
||||
const setHttpNetworkProxy = useCallback((nextValue: SetStateAction<HttpNetworkProxySettings>) => {
|
||||
setHttpNetworkProxyState((prev) => {
|
||||
const candidate = typeof nextValue === 'function'
|
||||
? (nextValue as (prevState: HttpNetworkProxySettings) => HttpNetworkProxySettings)(prev)
|
||||
: nextValue;
|
||||
// Preserve the previous object when values are unchanged so cross-window
|
||||
// settings:changed IPC does not rebroadcast forever via useSystemSettingsEffects.
|
||||
const next = normalizeHttpNetworkProxySettings(candidate);
|
||||
return areHttpNetworkProxySettingsEqual(prev, next) ? prev : next;
|
||||
});
|
||||
}, []);
|
||||
const [autoUpdateEnabled, setAutoUpdateEnabled] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_AUTO_UPDATE_ENABLED);
|
||||
if (stored === null) return true; // Default to enabled
|
||||
@@ -348,17 +388,34 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
if (stored === null) return true; // Default to enabled
|
||||
return stored === 'true';
|
||||
});
|
||||
const [windowOpacity, setWindowOpacityState] = useState<number>(() => {
|
||||
const [windowOpacityRecord, setWindowOpacityRecord] = useState<WindowOpacityRecord>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_WINDOW_OPACITY);
|
||||
if (stored === null) return DEFAULT_WINDOW_OPACITY;
|
||||
return clampWindowOpacity(stored);
|
||||
if (stored === null) return { opacity: DEFAULT_WINDOW_OPACITY, version: 0 };
|
||||
return parseWindowOpacityRecord(stored);
|
||||
});
|
||||
const windowOpacity = windowOpacityRecord.opacity;
|
||||
const windowOpacityMutationSourceRef = useRef<WindowOpacityMutationSource>('local');
|
||||
const setWindowOpacity = useCallback((nextValue: SetStateAction<number>) => {
|
||||
setWindowOpacityState((prev) => {
|
||||
windowOpacityMutationSourceRef.current = 'local';
|
||||
setWindowOpacityRecord((prev) => {
|
||||
const candidate = typeof nextValue === 'function'
|
||||
? (nextValue as (prevState: number) => number)(prev)
|
||||
? (nextValue as (prevState: number) => number)(prev.opacity)
|
||||
: nextValue;
|
||||
return clampWindowOpacity(candidate);
|
||||
const nextOpacity = clampWindowOpacity(candidate);
|
||||
if (nextOpacity === prev.opacity) return prev;
|
||||
return { opacity: nextOpacity, version: prev.version + 1 };
|
||||
});
|
||||
}, []);
|
||||
const applyIncomingWindowOpacity = useCallback((raw: unknown) => {
|
||||
const incoming = parseWindowOpacityRecord(raw);
|
||||
// Version gate so stale IPC/storage echoes cannot clobber a newer local
|
||||
// drag revision (see #2018).
|
||||
setWindowOpacityRecord((prev) => {
|
||||
if (!shouldApplyWindowOpacityRecord(prev, incoming)) {
|
||||
return prev;
|
||||
}
|
||||
windowOpacityMutationSourceRef.current = 'incoming';
|
||||
return incoming;
|
||||
});
|
||||
}, []);
|
||||
const [appIconVariant, setAppIconVariantState] = useState<AppIconVariant>(() => {
|
||||
@@ -383,11 +440,31 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const sshDeepLinkMutationSourceRef = useRef<'local' | 'incoming'>('local');
|
||||
const sshDeepLinkEnabledRef = useRef(sshDeepLinkEnabled);
|
||||
const sshDeepLinkSetRequestIdRef = useRef(0);
|
||||
const jmsDeepLinkMutationSourceRef = useRef<'local' | 'incoming'>('local');
|
||||
const jmsDeepLinkEnabledRef = useRef(jmsDeepLinkEnabled);
|
||||
const jmsDeepLinkSetRequestIdRef = useRef(0);
|
||||
|
||||
// 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.
|
||||
const persistMountedRef = useRef(false);
|
||||
const appearanceTransitionModeRef = useRef<ThemeTransitionMode>('view');
|
||||
const previousAppearanceRenderRef = useRef<AppearanceRenderSnapshot | null>(null);
|
||||
// Latest appearance kept in a ref so sequential keyed IPC updates (one multi-field
|
||||
// change -> multiple notifies) can compose without waiting for a React re-render.
|
||||
const appearanceStateRef = useRef({
|
||||
theme,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
accentMode,
|
||||
customAccent,
|
||||
});
|
||||
appearanceStateRef.current = {
|
||||
theme,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
accentMode,
|
||||
customAccent,
|
||||
};
|
||||
|
||||
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
|
||||
setTerminalSettingsState((prev) => {
|
||||
@@ -476,6 +553,15 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
});
|
||||
}, []);
|
||||
|
||||
const applyIncomingJmsDeepLinkEnabled = useCallback((enabled: boolean) => {
|
||||
jmsDeepLinkSetRequestIdRef.current += 1;
|
||||
setJmsDeepLinkEnabledState((prev) => {
|
||||
if (prev === enabled) return prev;
|
||||
jmsDeepLinkMutationSourceRef.current = 'incoming';
|
||||
return enabled;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Helper to notify other windows about settings changes via IPC
|
||||
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
|
||||
if (!enableSettingsSync) return;
|
||||
@@ -504,29 +590,40 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
notifySettingsChanged(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, style);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const syncAppearanceFromStorage = useCallback(() => {
|
||||
const storedTheme = readStoredString(STORAGE_KEY_THEME);
|
||||
const nextTheme = storedTheme && isValidTheme(storedTheme) ? storedTheme : theme;
|
||||
const storedLightId = readStoredString(STORAGE_KEY_UI_THEME_LIGHT);
|
||||
const nextLightId = storedLightId && isValidUiThemeId('light', storedLightId) ? storedLightId : lightUiThemeId;
|
||||
const storedDarkId = readStoredString(STORAGE_KEY_UI_THEME_DARK);
|
||||
const nextDarkId = storedDarkId && isValidUiThemeId('dark', storedDarkId) ? storedDarkId : darkUiThemeId;
|
||||
const storedAccentMode = readStoredString(STORAGE_KEY_ACCENT_MODE);
|
||||
const nextAccentMode = storedAccentMode === 'theme' || storedAccentMode === 'custom' ? storedAccentMode : accentMode;
|
||||
const storedAccent = readStoredString(STORAGE_KEY_COLOR);
|
||||
const nextAccent = storedAccent && isValidHslToken(storedAccent) ? storedAccent.trim() : customAccent;
|
||||
const syncAppearanceFromStorage = useCallback((incoming?: AppearanceSyncEvent) => {
|
||||
const current = appearanceStateRef.current;
|
||||
const nextAppearance = resolveAppearanceSyncState(
|
||||
current,
|
||||
{
|
||||
theme: readStoredString(STORAGE_KEY_THEME),
|
||||
lightUiThemeId: readStoredString(STORAGE_KEY_UI_THEME_LIGHT),
|
||||
darkUiThemeId: readStoredString(STORAGE_KEY_UI_THEME_DARK),
|
||||
accentMode: readStoredString(STORAGE_KEY_ACCENT_MODE),
|
||||
customAccent: readStoredString(STORAGE_KEY_COLOR),
|
||||
},
|
||||
incoming,
|
||||
);
|
||||
const {
|
||||
theme: nextTheme,
|
||||
lightUiThemeId: nextLightId,
|
||||
darkUiThemeId: nextDarkId,
|
||||
accentMode: nextAccentMode,
|
||||
customAccent: nextAccent,
|
||||
} = nextAppearance;
|
||||
|
||||
// Fix 2: Skip expensive DOM operations if nothing actually changed
|
||||
if (
|
||||
nextTheme === theme &&
|
||||
nextLightId === lightUiThemeId &&
|
||||
nextDarkId === darkUiThemeId &&
|
||||
nextAccentMode === accentMode &&
|
||||
nextAccent === customAccent
|
||||
nextTheme === current.theme &&
|
||||
nextLightId === current.lightUiThemeId &&
|
||||
nextDarkId === current.darkUiThemeId &&
|
||||
nextAccentMode === current.accentMode &&
|
||||
nextAccent === current.customAccent
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Publish synchronously so a later keyed IPC in the same turn composes on top.
|
||||
appearanceStateRef.current = nextAppearance;
|
||||
setTheme(nextTheme);
|
||||
setLightUiThemeId(nextLightId);
|
||||
setDarkUiThemeId(nextDarkId);
|
||||
@@ -538,7 +635,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
runThemeTransition(() => {
|
||||
applyThemeTokens(nextTheme, effective, tokens, nextAccentMode, nextAccent);
|
||||
});
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
|
||||
}, []);
|
||||
|
||||
const syncCustomCssFromStorage = useCallback(() => {
|
||||
const storedCss = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_CSS) || '';
|
||||
@@ -598,6 +695,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
}
|
||||
const storedSshDeepLinkEnabled = localStorageAdapter.readBoolean(STORAGE_KEY_SSH_DEEP_LINK_ENABLED);
|
||||
applyIncomingSshDeepLinkEnabled(storedSshDeepLinkEnabled ?? DEFAULT_SSH_DEEP_LINK_ENABLED);
|
||||
const storedJmsDeepLinkEnabled = localStorageAdapter.readBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED);
|
||||
applyIncomingJmsDeepLinkEnabled(storedJmsDeepLinkEnabled ?? DEFAULT_JMS_DEEP_LINK_ENABLED);
|
||||
|
||||
// SFTP
|
||||
const storedDblClick = readStoredString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
|
||||
@@ -645,11 +744,32 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
|
||||
if (storedFocusStyle === 'dim' || storedFocusStyle === 'border') setWorkspaceFocusStyleState(storedFocusStyle);
|
||||
|
||||
// App-level HTTP(S) network proxy
|
||||
setHttpNetworkProxyState(
|
||||
normalizeHttpNetworkProxySettings(
|
||||
localStorageAdapter.read(STORAGE_KEY_HTTP_NETWORK_PROXY) ?? DEFAULT_HTTP_NETWORK_PROXY,
|
||||
),
|
||||
);
|
||||
|
||||
// Custom terminal themes
|
||||
customThemeStore.loadFromStorage();
|
||||
}, [applyIncomingCustomKeyBindings, applyIncomingSshDeepLinkEnabled, syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
|
||||
}, [applyIncomingCustomKeyBindings, applyIncomingJmsDeepLinkEnabled, applyIncomingSshDeepLinkEnabled, syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const appearanceRender: AppearanceRenderSnapshot = {
|
||||
theme,
|
||||
resolvedTheme,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
accentMode,
|
||||
customAccent,
|
||||
};
|
||||
// Capture previous snapshot before overwrite so we can notify only the
|
||||
// appearance fields that actually changed on this render.
|
||||
const previousAppearance = previousAppearanceRenderRef.current;
|
||||
const persistedAppearanceChanged = previousAppearance === null
|
||||
|| hasPersistedAppearanceChanged(previousAppearance, appearanceRender);
|
||||
previousAppearanceRenderRef.current = appearanceRender;
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
const apply = () => applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
|
||||
const transitionMode = appearanceTransitionModeRef.current;
|
||||
@@ -659,6 +779,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
} else {
|
||||
apply();
|
||||
}
|
||||
if (!persistedAppearanceChanged && persistMountedRef.current) return;
|
||||
localStorageAdapter.writeString(STORAGE_KEY_THEME, theme);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
|
||||
@@ -666,9 +787,25 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
localStorageAdapter.writeString(STORAGE_KEY_COLOR, customAccent);
|
||||
// Fix 1: Skip IPC broadcast on initial mount (values already match localStorage)
|
||||
if (!persistMountedRef.current) return;
|
||||
// Fix 3: Send a single IPC instead of 5 — the receiver calls syncAppearanceFromStorage()
|
||||
// which re-reads ALL appearance values from localStorage.
|
||||
notifySettingsChanged(STORAGE_KEY_THEME, theme);
|
||||
// Emit a keyed IPC notification for each changed appearance field so the
|
||||
// receiver can apply the payload value even if shared storage is still stale.
|
||||
// resolveAppearanceSyncState only trusts the announced key's value; other
|
||||
// fields fall back to storage, so every changed field must be announced.
|
||||
if (!previousAppearance || previousAppearance.theme !== theme) {
|
||||
notifySettingsChanged(STORAGE_KEY_THEME, theme);
|
||||
}
|
||||
if (!previousAppearance || previousAppearance.lightUiThemeId !== lightUiThemeId) {
|
||||
notifySettingsChanged(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
|
||||
}
|
||||
if (!previousAppearance || previousAppearance.darkUiThemeId !== darkUiThemeId) {
|
||||
notifySettingsChanged(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
|
||||
}
|
||||
if (!previousAppearance || previousAppearance.accentMode !== accentMode) {
|
||||
notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode);
|
||||
}
|
||||
if (!previousAppearance || previousAppearance.customAccent !== customAccent) {
|
||||
notifySettingsChanged(STORAGE_KEY_COLOR, customAccent);
|
||||
}
|
||||
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, notifySettingsChanged]);
|
||||
|
||||
// Listen for OS color scheme changes to keep systemPreference in sync
|
||||
@@ -724,13 +861,15 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDebugLogsEnabled,
|
||||
setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled,
|
||||
setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled,
|
||||
setHotkeyScheme,
|
||||
applyIncomingCustomKeyBindings,
|
||||
setIsHotkeyRecordingState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setWindowOpacity: applyIncomingWindowOpacity,
|
||||
setAppIconVariant,
|
||||
setAutoUpdateEnabled,
|
||||
setHttpNetworkProxy,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
@@ -770,7 +909,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
@@ -779,8 +918,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
});
|
||||
|
||||
@@ -1087,14 +1226,76 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
notifySettingsChanged(STORAGE_KEY_SSH_DEEP_LINK_ENABLED, sshDeepLinkEnabled);
|
||||
}, [sshDeepLinkEnabled, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
jmsDeepLinkEnabledRef.current = jmsDeepLinkEnabled;
|
||||
}, [jmsDeepLinkEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const requestIdAtStart = jmsDeepLinkSetRequestIdRef.current;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getJmsDeepLinkEnabled) return;
|
||||
void bridge.getJmsDeepLinkEnabled().then((enabled) => {
|
||||
if (cancelled || typeof enabled !== 'boolean') return;
|
||||
if (jmsDeepLinkSetRequestIdRef.current !== requestIdAtStart) return;
|
||||
jmsDeepLinkMutationSourceRef.current = 'incoming';
|
||||
setJmsDeepLinkEnabledState((prev) => (prev === enabled ? prev : enabled));
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED, enabled);
|
||||
}).catch(() => {
|
||||
// The renderer can still use its cached setting when the bridge is unavailable.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setJmsDeepLinkEnabled = useCallback((enabled: boolean) => {
|
||||
const previous = jmsDeepLinkEnabledRef.current;
|
||||
const requestId = jmsDeepLinkSetRequestIdRef.current + 1;
|
||||
jmsDeepLinkSetRequestIdRef.current = requestId;
|
||||
jmsDeepLinkMutationSourceRef.current = 'local';
|
||||
setJmsDeepLinkEnabledState(enabled);
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.setJmsDeepLinkEnabled) return;
|
||||
void bridge.setJmsDeepLinkEnabled(enabled).then((result) => {
|
||||
if (jmsDeepLinkSetRequestIdRef.current !== requestId) return;
|
||||
const success = typeof result === 'object' ? result.success : result;
|
||||
if (success !== false) return;
|
||||
const finalEnabled = typeof result === 'object' && typeof result.enabled === 'boolean'
|
||||
? result.enabled
|
||||
: previous;
|
||||
jmsDeepLinkMutationSourceRef.current = 'incoming';
|
||||
setJmsDeepLinkEnabledState(finalEnabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED, finalEnabled);
|
||||
}).catch(() => {
|
||||
if (jmsDeepLinkSetRequestIdRef.current !== requestId) return;
|
||||
jmsDeepLinkMutationSourceRef.current = 'incoming';
|
||||
setJmsDeepLinkEnabledState(previous);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED, previous);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED, jmsDeepLinkEnabled);
|
||||
if (jmsDeepLinkMutationSourceRef.current === 'incoming') {
|
||||
jmsDeepLinkMutationSourceRef.current = 'local';
|
||||
return;
|
||||
}
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_JMS_DEEP_LINK_ENABLED, jmsDeepLinkEnabled);
|
||||
}, [jmsDeepLinkEnabled, notifySettingsChanged]);
|
||||
|
||||
useSystemSettingsEffects({
|
||||
enabled: enableSystemEffects,
|
||||
toggleWindowHotkey,
|
||||
globalHotkeyEnabled,
|
||||
closeToTray,
|
||||
windowOpacity,
|
||||
windowOpacityRecord,
|
||||
windowOpacityMutationSourceRef,
|
||||
appIconVariant,
|
||||
autoUpdateEnabled,
|
||||
httpNetworkProxy,
|
||||
persistMountedRef,
|
||||
setHotkeyRegistrationError,
|
||||
setAutoUpdateEnabled,
|
||||
@@ -1278,11 +1479,15 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setSshDebugLogsEnabled,
|
||||
sshDeepLinkEnabled,
|
||||
setSshDeepLinkEnabled,
|
||||
jmsDeepLinkEnabled,
|
||||
setJmsDeepLinkEnabled,
|
||||
// Global Toggle Window (Quake Mode)
|
||||
toggleWindowHotkey,
|
||||
setToggleWindowHotkey,
|
||||
closeToTray,
|
||||
setCloseToTray,
|
||||
httpNetworkProxy,
|
||||
setHttpNetworkProxy,
|
||||
autoUpdateEnabled,
|
||||
setAutoUpdateEnabled,
|
||||
hotkeyRegistrationError,
|
||||
@@ -1305,7 +1510,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
|
||||
]),
|
||||
};
|
||||
};
|
||||
@@ -23,6 +23,7 @@ export interface UseVaultAgentBridgeInput {
|
||||
updateNotes: (notes: VaultNote[]) => void;
|
||||
startTunnel: VaultAgentApiDeps['startTunnel'];
|
||||
stopTunnel: VaultAgentApiDeps['stopTunnel'];
|
||||
openHost?: VaultAgentApiDeps['openHost'];
|
||||
}
|
||||
|
||||
type VaultAgentSnapshot = {
|
||||
@@ -107,6 +108,9 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
|
||||
},
|
||||
startTunnel: current.startTunnel,
|
||||
stopTunnel: current.stopTunnel,
|
||||
openHost: current.openHost
|
||||
? (hostId) => current.openHost!(hostId)
|
||||
: undefined,
|
||||
});
|
||||
});
|
||||
return setupVaultAgentBridge();
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { importVaultHostsFromText } from "../../domain/vaultImport.ts";
|
||||
import { readVaultImportFile } from "./vaultImportFile.ts";
|
||||
|
||||
const sessionValue = "#109#0%10.0.0.1%22%root";
|
||||
|
||||
test("MobaXterm import decodes legacy GB18030 Chinese text", async () => {
|
||||
const prefix = new TextEncoder().encode("[Bookmarks]\nSubRep=\nImgNum=42\n");
|
||||
const suffix = new TextEncoder().encode(`=${sessionValue}`);
|
||||
const encodedLabel = new Uint8Array([
|
||||
0xd6, 0xd0, 0xce, 0xc4, 0xb7, 0xfe, 0xce, 0xf1, 0xc6, 0xf7,
|
||||
]);
|
||||
const bytes = new Uint8Array(prefix.length + encodedLabel.length + suffix.length);
|
||||
bytes.set(prefix);
|
||||
bytes.set(encodedLabel, prefix.length);
|
||||
bytes.set(suffix, prefix.length + encodedLabel.length);
|
||||
|
||||
const text = await readVaultImportFile(
|
||||
"mobaxterm",
|
||||
new File([bytes], "MobaXterm.ini", { type: "text/plain" }),
|
||||
);
|
||||
const result = importVaultHostsFromText("mobaxterm", text);
|
||||
|
||||
assert.equal(result.hosts[0]?.label, "中文服务器");
|
||||
});
|
||||
|
||||
test("MobaXterm import keeps unmarked UTF-8 Chinese text", async () => {
|
||||
const text = `[Bookmarks]\nSubRep=\nImgNum=42\n北京上海=${sessionValue}`;
|
||||
const decoded = await readVaultImportFile(
|
||||
"mobaxterm",
|
||||
new File([text], "MobaXterm.ini", { type: "text/plain" }),
|
||||
);
|
||||
const result = importVaultHostsFromText("mobaxterm", decoded);
|
||||
|
||||
assert.equal(result.hosts[0]?.label, "北京上海");
|
||||
});
|
||||
|
||||
test("MobaXterm import keeps valid UTF-8 labels when GB18030 would produce Chinese", async () => {
|
||||
const text = `[Bookmarks]\nSubRep=\nImgNum=42\n¡prod=${sessionValue}`;
|
||||
const decoded = await readVaultImportFile(
|
||||
"mobaxterm",
|
||||
new File([text], "MobaXterm.ini", { type: "text/plain" }),
|
||||
);
|
||||
const result = importVaultHostsFromText("mobaxterm", decoded);
|
||||
|
||||
assert.equal(result.hosts[0]?.label, "¡prod");
|
||||
});
|
||||
|
||||
test("MobaXterm import can force GB18030 for ambiguous legacy Chinese text", async () => {
|
||||
const prefix = new TextEncoder().encode("[Bookmarks]\nSubRep=\nImgNum=42\n");
|
||||
const suffix = new TextEncoder().encode(`prod=${sessionValue}`);
|
||||
const bytes = new Uint8Array(prefix.length + 2 + suffix.length);
|
||||
bytes.set(prefix);
|
||||
bytes.set([0xc2, 0xa1], prefix.length);
|
||||
bytes.set(suffix, prefix.length + 2);
|
||||
|
||||
const decoded = await readVaultImportFile(
|
||||
"mobaxterm",
|
||||
new File([bytes], "MobaXterm.ini", { type: "text/plain" }),
|
||||
"gb18030",
|
||||
);
|
||||
const result = importVaultHostsFromText("mobaxterm", decoded);
|
||||
|
||||
assert.equal(result.hosts[0]?.label, "隆prod");
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { VaultImportFormat } from "../../domain/vaultImport";
|
||||
import { readTextFile } from "../../lib/readTextFile";
|
||||
|
||||
export type VaultImportFileEncoding = "auto" | "utf-8" | "gb18030";
|
||||
|
||||
export const readVaultImportFile = (
|
||||
format: VaultImportFormat,
|
||||
file: File,
|
||||
encoding: VaultImportFileEncoding = "auto",
|
||||
): Promise<string> => {
|
||||
if (format !== "mobaxterm") return readTextFile(file);
|
||||
if (encoding !== "auto") return readTextFile(file, { encoding });
|
||||
return readTextFile(file, { fallbackEncoding: "gb18030" });
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
parseWindowOpacityRecord,
|
||||
serializeWindowOpacityRecord,
|
||||
shouldApplyWindowOpacityRecord,
|
||||
shouldBroadcastWindowOpacityChange,
|
||||
type WindowOpacityMutationSource,
|
||||
type WindowOpacityRecord,
|
||||
} from './windowOpacitySync.ts';
|
||||
|
||||
/**
|
||||
* Minimal model of the settings ↔ main opacity sync loop that caused #2018.
|
||||
* Models both IPC rebroadcast and stale localStorage overwrites.
|
||||
*/
|
||||
function simulateOpacityDrag(options: {
|
||||
shouldBroadcast: (
|
||||
source: WindowOpacityMutationSource,
|
||||
persistMounted: boolean,
|
||||
) => { shouldBroadcast: boolean; nextSource: WindowOpacityMutationSource };
|
||||
shouldApply: (current: WindowOpacityRecord, incoming: WindowOpacityRecord) => boolean;
|
||||
versioned: boolean;
|
||||
}): { settingsValues: number[]; mainValues: number[]; storageWrites: string[] } {
|
||||
let settings: WindowOpacityRecord = { opacity: 1, version: 0 };
|
||||
let main: WindowOpacityRecord = { opacity: 1, version: 0 };
|
||||
let settingsSource: WindowOpacityMutationSource = 'local';
|
||||
let mainSource: WindowOpacityMutationSource = 'local';
|
||||
let storage = serializeWindowOpacityRecord(settings);
|
||||
const settingsValues: number[] = [];
|
||||
const mainValues: number[] = [];
|
||||
const storageWrites: string[] = [];
|
||||
|
||||
const pendingIpc: Array<{ to: 'settings' | 'main'; record: WindowOpacityRecord }> = [];
|
||||
|
||||
const writeStorage = (record: WindowOpacityRecord) => {
|
||||
const next = options.versioned
|
||||
? serializeWindowOpacityRecord(record)
|
||||
: String(record.opacity);
|
||||
if (next === storage) return;
|
||||
storage = next;
|
||||
storageWrites.push(next);
|
||||
// Cross-window StorageEvent: deliver to the other window.
|
||||
};
|
||||
|
||||
const applyLocal = (window: 'settings' | 'main', opacity: number) => {
|
||||
const bump = (prev: WindowOpacityRecord): WindowOpacityRecord => (
|
||||
options.versioned
|
||||
? { opacity, version: prev.version + 1 }
|
||||
: { opacity, version: 0 }
|
||||
);
|
||||
|
||||
if (window === 'settings') {
|
||||
settingsSource = 'local';
|
||||
settings = bump(settings);
|
||||
settingsValues.push(settings.opacity);
|
||||
writeStorage(settings);
|
||||
const decision = options.shouldBroadcast(settingsSource, true);
|
||||
settingsSource = decision.nextSource;
|
||||
if (decision.shouldBroadcast) pendingIpc.push({ to: 'main', record: { ...settings } });
|
||||
return;
|
||||
}
|
||||
|
||||
mainSource = 'local';
|
||||
main = bump(main);
|
||||
mainValues.push(main.opacity);
|
||||
writeStorage(main);
|
||||
const decision = options.shouldBroadcast(mainSource, true);
|
||||
mainSource = decision.nextSource;
|
||||
if (decision.shouldBroadcast) pendingIpc.push({ to: 'settings', record: { ...main } });
|
||||
};
|
||||
|
||||
const applyIncoming = (window: 'settings' | 'main', record: WindowOpacityRecord) => {
|
||||
if (window === 'settings') {
|
||||
if (!options.shouldApply(settings, record)) return;
|
||||
settingsSource = 'incoming';
|
||||
settings = { ...record };
|
||||
settingsValues.push(settings.opacity);
|
||||
writeStorage(settings);
|
||||
const decision = options.shouldBroadcast(settingsSource, true);
|
||||
settingsSource = decision.nextSource;
|
||||
if (decision.shouldBroadcast) pendingIpc.push({ to: 'main', record: { ...settings } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.shouldApply(main, record)) return;
|
||||
mainSource = 'incoming';
|
||||
main = { ...record };
|
||||
mainValues.push(main.opacity);
|
||||
writeStorage(main);
|
||||
const decision = options.shouldBroadcast(mainSource, true);
|
||||
mainSource = decision.nextSource;
|
||||
if (decision.shouldBroadcast) pendingIpc.push({ to: 'settings', record: { ...main } });
|
||||
};
|
||||
|
||||
const flushIpc = () => {
|
||||
while (pendingIpc.length > 0) {
|
||||
const next = pendingIpc.shift()!;
|
||||
applyIncoming(next.to, next.record);
|
||||
if (settingsValues.length > 40) break;
|
||||
}
|
||||
};
|
||||
|
||||
// Replay storage writes onto the peer after each local burst, matching
|
||||
// Electron's cross-window StorageEvent delivery.
|
||||
const deliverStorageTo = (window: 'settings' | 'main') => {
|
||||
const record = options.versioned
|
||||
? parseWindowOpacityRecord(JSON.parse(storage))
|
||||
: parseWindowOpacityRecord(storage);
|
||||
applyIncoming(window, record);
|
||||
};
|
||||
|
||||
applyLocal('settings', 0.5);
|
||||
deliverStorageTo('main');
|
||||
applyLocal('settings', 0.65);
|
||||
// Delayed IPC for the older 0.5 update arrives after 0.65 was already local.
|
||||
const delayed = pendingIpc.shift();
|
||||
flushIpc();
|
||||
if (delayed) applyIncoming(delayed.to, delayed.record);
|
||||
deliverStorageTo('settings');
|
||||
flushIpc();
|
||||
|
||||
return { settingsValues, mainValues, storageWrites };
|
||||
}
|
||||
|
||||
test('parseWindowOpacityRecord accepts legacy plain numbers and versioned JSON', () => {
|
||||
assert.deepEqual(parseWindowOpacityRecord('0.85'), { opacity: 0.85, version: 0 });
|
||||
assert.deepEqual(parseWindowOpacityRecord(0.7), { opacity: 0.7, version: 0 });
|
||||
assert.deepEqual(
|
||||
parseWindowOpacityRecord({ opacity: 0.5, version: 3 }),
|
||||
{ opacity: 0.5, version: 3 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseWindowOpacityRecord('{"opacity":0.55,"version":9}'),
|
||||
{ opacity: 0.55, version: 9 },
|
||||
);
|
||||
assert.equal(parseWindowOpacityRecord('bad').opacity, 1);
|
||||
});
|
||||
|
||||
test('shouldApplyWindowOpacityRecord ignores stale revisions', () => {
|
||||
const current = { opacity: 0.65, version: 2 };
|
||||
assert.equal(shouldApplyWindowOpacityRecord(current, { opacity: 0.5, version: 1 }), false);
|
||||
assert.equal(shouldApplyWindowOpacityRecord(current, { opacity: 0.7, version: 3 }), true);
|
||||
assert.equal(shouldApplyWindowOpacityRecord(current, { opacity: 0.65, version: 2 }), false);
|
||||
});
|
||||
|
||||
test('shouldBroadcastWindowOpacityChange suppresses incoming rebroadcasts', () => {
|
||||
assert.deepEqual(
|
||||
shouldBroadcastWindowOpacityChange('incoming', true),
|
||||
{ shouldBroadcast: false, nextSource: 'local' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
shouldBroadcastWindowOpacityChange('local', true),
|
||||
{ shouldBroadcast: true, nextSource: 'local' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
shouldBroadcastWindowOpacityChange('local', false),
|
||||
{ shouldBroadcast: false, nextSource: 'local' },
|
||||
);
|
||||
});
|
||||
|
||||
test('legacy unversioned always-broadcast opacity sync oscillates during a fast drag', () => {
|
||||
const alwaysBroadcast = (
|
||||
_source: WindowOpacityMutationSource,
|
||||
persistMounted: boolean,
|
||||
) => ({
|
||||
shouldBroadcast: persistMounted,
|
||||
nextSource: 'local' as const,
|
||||
});
|
||||
const alwaysApply = () => true;
|
||||
|
||||
const { settingsValues } = simulateOpacityDrag({
|
||||
shouldBroadcast: alwaysBroadcast,
|
||||
shouldApply: alwaysApply,
|
||||
versioned: false,
|
||||
});
|
||||
const unique = new Set(settingsValues);
|
||||
assert.ok(
|
||||
unique.has(0.5) && unique.has(0.65) && settingsValues.length > 2,
|
||||
`expected oscillation between 0.5 and 0.65, got ${settingsValues.join(',')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('versioned opacity sync ignores stale peer echoes during a fast drag', () => {
|
||||
const { settingsValues, mainValues } = simulateOpacityDrag({
|
||||
shouldBroadcast: shouldBroadcastWindowOpacityChange,
|
||||
shouldApply: shouldApplyWindowOpacityRecord,
|
||||
versioned: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(settingsValues, [0.5, 0.65]);
|
||||
assert.ok(mainValues.includes(0.65));
|
||||
assert.equal(mainValues.includes(0.5) && mainValues[mainValues.length - 1] === 0.5, false);
|
||||
});
|
||||
|
||||
test('serializeWindowOpacityRecord round-trips through parse', () => {
|
||||
const raw = serializeWindowOpacityRecord({ opacity: 0.55, version: 9 });
|
||||
assert.deepEqual(parseWindowOpacityRecord(JSON.parse(raw)), { opacity: 0.55, version: 9 });
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
DEFAULT_WINDOW_OPACITY,
|
||||
clampWindowOpacity,
|
||||
} from './settingsStateDefaults';
|
||||
|
||||
export { DEFAULT_WINDOW_OPACITY, clampWindowOpacity };
|
||||
|
||||
export type WindowOpacityMutationSource = 'local' | 'incoming';
|
||||
|
||||
export type WindowOpacityRecord = {
|
||||
opacity: number;
|
||||
version: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse persisted / IPC window-opacity payloads.
|
||||
* Accepts legacy plain numbers ("0.85") and versioned records.
|
||||
*/
|
||||
export function parseWindowOpacityRecord(raw: unknown): WindowOpacityRecord {
|
||||
if (typeof raw === 'number' || typeof raw === 'string') {
|
||||
const trimmed = typeof raw === 'string' ? raw.trim() : raw;
|
||||
// Prefer JSON object payloads written by serializeWindowOpacityRecord.
|
||||
if (typeof trimmed === 'string' && trimmed.startsWith('{')) {
|
||||
try {
|
||||
return parseWindowOpacityRecord(JSON.parse(trimmed));
|
||||
} catch {
|
||||
// fall through to Number()
|
||||
}
|
||||
}
|
||||
const asNumber = Number(trimmed);
|
||||
if (Number.isFinite(asNumber)) {
|
||||
return { opacity: clampWindowOpacity(asNumber), version: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
if (raw && typeof raw === 'object') {
|
||||
const record = raw as { opacity?: unknown; version?: unknown };
|
||||
const opacity = clampWindowOpacity(record.opacity);
|
||||
const version = Number(record.version);
|
||||
return {
|
||||
opacity,
|
||||
version: Number.isFinite(version) && version > 0 ? Math.floor(version) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
return { opacity: DEFAULT_WINDOW_OPACITY, version: 0 };
|
||||
}
|
||||
|
||||
export function serializeWindowOpacityRecord(record: WindowOpacityRecord): string {
|
||||
return JSON.stringify({
|
||||
opacity: clampWindowOpacity(record.opacity),
|
||||
version: Math.max(0, Math.floor(record.version) || 0),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Incoming peer updates must not clobber a newer local/drag revision.
|
||||
* Equal versions are treated as already-applied (no state thrash).
|
||||
*/
|
||||
export function shouldApplyWindowOpacityRecord(
|
||||
current: WindowOpacityRecord,
|
||||
incoming: WindowOpacityRecord,
|
||||
): boolean {
|
||||
if (incoming.version > current.version) return true;
|
||||
if (incoming.version < current.version) return false;
|
||||
// Same version: only apply when the opacity itself differs and both are
|
||||
// legacy/unversioned (version 0), so first-load plain strings still sync.
|
||||
if (incoming.version === 0 && current.version === 0) {
|
||||
return incoming.opacity !== current.opacity;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a window-opacity state change should be rebroadcast to peer
|
||||
* windows. Incoming IPC/storage updates must not notify again — otherwise a
|
||||
* fast slider drag in the settings window ping-pongs with the main window and
|
||||
* the controlled range input oscillates (see #2018).
|
||||
*/
|
||||
export function shouldBroadcastWindowOpacityChange(
|
||||
mutationSource: WindowOpacityMutationSource,
|
||||
persistMounted: boolean,
|
||||
): { shouldBroadcast: boolean; nextSource: WindowOpacityMutationSource } {
|
||||
if (mutationSource === 'incoming') {
|
||||
return { shouldBroadcast: false, nextSource: 'local' };
|
||||
}
|
||||
return { shouldBroadcast: persistMounted, nextSource: 'local' };
|
||||
}
|
||||
@@ -683,6 +683,8 @@ test("buildSyncPayload includes syncable terminal options from settings", () =>
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_SETTINGS, JSON.stringify({
|
||||
terminalEmulationType: "vt100",
|
||||
altAsMeta: true,
|
||||
shiftEnterNewlineEnabled: false,
|
||||
shiftEnterNewlineText: " \\\\\\n",
|
||||
middleClickBehavior: "context-menu",
|
||||
fontSmoothing: false,
|
||||
showServerStats: false,
|
||||
@@ -697,6 +699,8 @@ test("buildSyncPayload includes syncable terminal options from settings", () =>
|
||||
assert.deepEqual(payload.settings?.terminalSettings, {
|
||||
terminalEmulationType: "vt100",
|
||||
altAsMeta: true,
|
||||
shiftEnterNewlineEnabled: false,
|
||||
shiftEnterNewlineText: " \\\\\\n",
|
||||
middleClickBehavior: "context-menu",
|
||||
fontSmoothing: false,
|
||||
showServerStats: false,
|
||||
@@ -951,6 +955,44 @@ test("buildSyncPayload includes fallbackFont when present in TERM_SETTINGS", ()
|
||||
assert.equal(termSettings.fallbackFont, "PingFang SC");
|
||||
});
|
||||
|
||||
test("buildSyncPayload includes the terminal host information bar preference", () => {
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ showHostInfoBar: false }),
|
||||
);
|
||||
|
||||
const payload = buildSyncPayload(vault());
|
||||
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
|
||||
assert.equal(termSettings.showHostInfoBar, false);
|
||||
});
|
||||
|
||||
test("applySyncPayload restores the terminal host information bar preference", async () => {
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ showHostInfoBar: true, scrollback: 5000 }),
|
||||
);
|
||||
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
settings: { terminalSettings: { showHostInfoBar: false } },
|
||||
};
|
||||
|
||||
await applySyncPayload(payload, {
|
||||
importVaultData: () => {},
|
||||
});
|
||||
|
||||
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
|
||||
assert.ok(raw, "TERM_SETTINGS should be written");
|
||||
const parsed = JSON.parse(raw!);
|
||||
assert.equal(parsed.showHostInfoBar, false);
|
||||
assert.equal(parsed.scrollback, 5000);
|
||||
});
|
||||
|
||||
test("buildSyncPayload omits fallbackFont when TERM_SETTINGS does not set it", () => {
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
@@ -983,6 +1025,39 @@ test("applySyncPayload writes incoming fallbackFont into local TERM_SETTINGS", a
|
||||
assert.equal(parsed.fallbackFont, "Sarasa Mono SC");
|
||||
});
|
||||
|
||||
test("applySyncPayload writes incoming Shift+Enter terminal settings", async () => {
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ scrollback: 5000 }),
|
||||
);
|
||||
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
settings: {
|
||||
terminalSettings: {
|
||||
shiftEnterNewlineEnabled: false,
|
||||
shiftEnterNewlineText: " \\\\\\n",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await applySyncPayload(payload, {
|
||||
importVaultData: () => {},
|
||||
});
|
||||
|
||||
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
|
||||
assert.ok(raw, "TERM_SETTINGS should be written");
|
||||
const parsed = JSON.parse(raw!);
|
||||
assert.equal(parsed.scrollback, 5000);
|
||||
assert.equal(parsed.shiftEnterNewlineEnabled, false);
|
||||
assert.equal(parsed.shiftEnterNewlineText, " \\\\\\n");
|
||||
});
|
||||
|
||||
test("applySyncPayload lets legacy middle-click paste update the new middle-click behavior", async () => {
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
|
||||
@@ -200,12 +200,14 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'scrollback', 'drawBoldInBrightColors', 'terminalEmulationType',
|
||||
'fontLigatures', 'fontSmoothing', 'fontWeight', 'fontWeightBold', 'fallbackFont',
|
||||
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
|
||||
'altAsMeta', 'optionArrowWordJump', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'altAsMeta', 'optionArrowWordJump', 'shiftEnterNewlineEnabled', 'shiftEnterNewlineText',
|
||||
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'smoothScrolling',
|
||||
'rightClickBehavior', 'middleClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'dynamicTabTitleMode', 'showServerStats',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'dynamicTabTitleMode',
|
||||
'showHostInfoBar', 'showServerStats',
|
||||
'serverStatsRefreshInterval',
|
||||
'systemManagerProcessRefreshInterval', 'systemManagerTmuxRefreshInterval',
|
||||
'systemManagerDockerListRefreshInterval', 'systemManagerDockerStatsRefreshInterval',
|
||||
|
||||
@@ -218,6 +218,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
const [s3SessionToken, setS3SessionToken] = useState('');
|
||||
const [s3Prefix, setS3Prefix] = useState('');
|
||||
const [s3ForcePathStyle, setS3ForcePathStyle] = useState(true);
|
||||
const [s3AllowInsecure, setS3AllowInsecure] = useState(false);
|
||||
const [showS3Secret, setShowS3Secret] = useState(false);
|
||||
const [s3Error, setS3Error] = useState<string | null>(null);
|
||||
const [s3ErrorDetail, setS3ErrorDetail] = useState<string | null>(null);
|
||||
@@ -417,6 +418,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
setS3SessionToken(config?.sessionToken || '');
|
||||
setS3Prefix(config?.prefix || '');
|
||||
setS3ForcePathStyle(config?.forcePathStyle ?? true);
|
||||
setS3AllowInsecure(config?.allowInsecure || false);
|
||||
setShowS3Secret(false);
|
||||
setS3Error(null);
|
||||
setS3ErrorDetail(null);
|
||||
@@ -489,6 +491,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
sessionToken: s3SessionToken.trim() ? s3SessionToken.trim() : undefined,
|
||||
prefix: s3Prefix.trim() ? s3Prefix.trim() : undefined,
|
||||
forcePathStyle: s3ForcePathStyle,
|
||||
allowInsecure: s3AllowInsecure ? true : undefined,
|
||||
};
|
||||
|
||||
setIsSavingS3(true);
|
||||
@@ -508,6 +511,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
region: s3Region.trim(),
|
||||
bucket: s3Bucket.trim(),
|
||||
forcePathStyle: s3ForcePathStyle,
|
||||
allowInsecure: s3AllowInsecure,
|
||||
}),
|
||||
);
|
||||
toast.error(message, t('cloudSync.connect.s3.failedTitle'));
|
||||
@@ -808,6 +812,8 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
setS3Prefix={setS3Prefix}
|
||||
s3ForcePathStyle={s3ForcePathStyle}
|
||||
setS3ForcePathStyle={setS3ForcePathStyle}
|
||||
s3AllowInsecure={s3AllowInsecure}
|
||||
setS3AllowInsecure={setS3AllowInsecure}
|
||||
showS3Secret={showS3Secret}
|
||||
setShowS3Secret={setShowS3Secret}
|
||||
s3Error={s3Error}
|
||||
|
||||
@@ -1,10 +1,112 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { hasGroupTelnetFields } from "./GroupDetailsPanel.tsx";
|
||||
import {
|
||||
hasGroupSshFields,
|
||||
hasGroupTelnetFields,
|
||||
includeMissingIdentityOption,
|
||||
resolveGroupFormIdentityId,
|
||||
selectGroupSshIdentity,
|
||||
selectGroupTelnetIdentity,
|
||||
} from "./GroupDetailsPanel.tsx";
|
||||
|
||||
test("GroupDetailsPanel treats an empty SSH identity as an explicit setting", () => {
|
||||
assert.equal(hasGroupSshFields({ identityId: "" }), true);
|
||||
assert.equal(hasGroupSshFields({}), false);
|
||||
});
|
||||
|
||||
test("GroupDetailsPanel treats cleared telnet credentials as explicit settings", () => {
|
||||
assert.equal(hasGroupTelnetFields({ telnetUsername: "" }), true);
|
||||
assert.equal(hasGroupTelnetFields({ telnetPassword: "" }), true);
|
||||
assert.equal(hasGroupTelnetFields({ telnetIdentityId: "identity-1" }), true);
|
||||
assert.equal(hasGroupTelnetFields({}), false);
|
||||
});
|
||||
|
||||
test("GroupDetailsPanel replaces manual SSH credentials with a reusable identity", () => {
|
||||
const result = selectGroupSshIdentity(
|
||||
{
|
||||
username: "manual",
|
||||
password: "secret",
|
||||
identityFileId: "key-1",
|
||||
identityFilePaths: ["~/.ssh/id_ed25519"],
|
||||
},
|
||||
{
|
||||
id: "identity-1",
|
||||
label: "Shared admin",
|
||||
username: "admin",
|
||||
authMethod: "password",
|
||||
created: 1,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.identityId, "identity-1");
|
||||
assert.equal(result.username, "admin");
|
||||
assert.equal(result.password, undefined);
|
||||
assert.equal(result.identityFileId, undefined);
|
||||
assert.equal(result.identityFilePaths, undefined);
|
||||
});
|
||||
|
||||
test("GroupDetailsPanel replaces manual Telnet credentials with a reusable identity", () => {
|
||||
const result = selectGroupTelnetIdentity(
|
||||
{ telnetUsername: "manual", telnetPassword: "secret" },
|
||||
"identity-1",
|
||||
);
|
||||
|
||||
assert.equal(result.telnetIdentityId, "identity-1");
|
||||
assert.equal(result.telnetUsername, undefined);
|
||||
assert.equal(result.telnetPassword, undefined);
|
||||
});
|
||||
|
||||
test("GroupDetailsPanel explicitly clears inherited identities for manual credentials", () => {
|
||||
const ssh = selectGroupSshIdentity({}, undefined, "", "parent-ssh-identity");
|
||||
const telnet = selectGroupTelnetIdentity({}, "", "parent-telnet-identity");
|
||||
|
||||
assert.equal(ssh.identityId, "");
|
||||
assert.equal(telnet.telnetIdentityId, "");
|
||||
});
|
||||
|
||||
test("GroupDetailsPanel clears key authentication before switching to a manual password", () => {
|
||||
const selected = selectGroupSshIdentity(
|
||||
{},
|
||||
{
|
||||
id: "key-identity",
|
||||
label: "Key identity",
|
||||
username: "admin",
|
||||
authMethod: "key",
|
||||
keyId: "key-1",
|
||||
created: 1,
|
||||
},
|
||||
);
|
||||
const cleared = selectGroupSshIdentity(selected, undefined);
|
||||
const manual = { ...cleared, username: "operator", password: "secret" };
|
||||
|
||||
assert.equal(manual.identityId, undefined);
|
||||
assert.equal(manual.authMethod, undefined);
|
||||
assert.equal(manual.username, "operator");
|
||||
assert.equal(manual.password, "secret");
|
||||
});
|
||||
|
||||
test("GroupDetailsPanel keeps a deleted identity visible so it can be cleared", () => {
|
||||
const options = includeMissingIdentityOption([], "deleted-identity", "Identity not found");
|
||||
|
||||
assert.deepEqual(options, [{ value: "deleted-identity", label: "Identity not found" }]);
|
||||
});
|
||||
|
||||
test("GroupDetailsPanel shows saved child manual credentials instead of a parent identity", () => {
|
||||
assert.equal(
|
||||
resolveGroupFormIdentityId(
|
||||
{ username: "child-user", password: "child-password" },
|
||||
"parent-ssh-identity",
|
||||
"ssh",
|
||||
),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
resolveGroupFormIdentityId(
|
||||
{ telnetUsername: "child-user", telnetPassword: "child-password" },
|
||||
"parent-telnet-identity",
|
||||
"telnet",
|
||||
),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -14,7 +14,12 @@ import {
|
||||
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 {
|
||||
hasManualGroupSshCredentials,
|
||||
hasManualGroupTelnetCredentials,
|
||||
resolveGroupDefaults,
|
||||
resolveGroupTerminalThemeId,
|
||||
} from "../domain/groupConfig";
|
||||
import {
|
||||
formatProxyConfigEndpoint,
|
||||
formatProxyConfigType,
|
||||
@@ -54,6 +59,7 @@ import { useAvailableFonts } from "../application/state/fontStore";
|
||||
import { toast } from "./ui/toast";
|
||||
import { GroupSshSettingsSection } from "./GroupSshSettingsSection";
|
||||
import { prepareProxyConfigForSave } from "./HostDetailsPanel.helpers";
|
||||
import { TerminalEncodingSelect } from "./TerminalEncodingSelect";
|
||||
|
||||
type SubPanel = "none" | "proxy" | "chain" | "env-vars" | "theme-select";
|
||||
|
||||
@@ -85,10 +91,81 @@ type GroupDetailsPanelPropsWithResize = GroupDetailsPanelProps & AsidePanelResiz
|
||||
|
||||
export const hasGroupTelnetFields = (c: Partial<GroupConfig>): boolean =>
|
||||
c.telnetPort !== undefined ||
|
||||
c.telnetIdentityId !== undefined ||
|
||||
c.telnetUsername !== undefined ||
|
||||
c.telnetPassword !== undefined ||
|
||||
c.telnetEnabled === true;
|
||||
|
||||
export const hasGroupSshFields = (c: Partial<GroupConfig>): boolean =>
|
||||
c.protocol === 'ssh' ||
|
||||
c.port !== undefined || !!c.username || !!c.password || !!c.identityFileId ||
|
||||
c.deviceType !== undefined ||
|
||||
c.agentForwarding !== undefined || c.authMethod !== undefined || c.identityId !== undefined ||
|
||||
!!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.startupCommandRunMode !== undefined || c.legacyAlgorithms !== undefined || c.skipEcdsaHostKey !== undefined || c.algorithms !== undefined || c.backspaceBehavior !== undefined ||
|
||||
Boolean(c.environmentVariables && c.environmentVariables.length > 0) ||
|
||||
c.moshEnabled !== undefined || !!c.moshServerPath ||
|
||||
c.etEnabled !== undefined || c.etPort !== undefined ||
|
||||
Boolean(c.identityFilePaths && c.identityFilePaths.length > 0);
|
||||
|
||||
export const selectGroupSshIdentity = (
|
||||
form: Partial<GroupConfig>,
|
||||
identity: Identity | undefined,
|
||||
identityId = identity?.id || "",
|
||||
inheritedIdentityId?: string,
|
||||
): Partial<GroupConfig> => {
|
||||
if (!identityId) {
|
||||
return {
|
||||
...form,
|
||||
identityId: inheritedIdentityId ? "" : undefined,
|
||||
username: undefined,
|
||||
authMethod: undefined,
|
||||
};
|
||||
}
|
||||
if (!identity) return { ...form, identityId };
|
||||
return {
|
||||
...form,
|
||||
identityId,
|
||||
username: identity.username,
|
||||
authMethod: identity.authMethod,
|
||||
password: undefined,
|
||||
savePassword: undefined,
|
||||
identityFileId: undefined,
|
||||
identityFilePaths: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const selectGroupTelnetIdentity = (
|
||||
form: Partial<GroupConfig>,
|
||||
identityId: string,
|
||||
inheritedIdentityId?: string,
|
||||
): Partial<GroupConfig> => ({
|
||||
...form,
|
||||
telnetIdentityId: identityId || (inheritedIdentityId ? "" : undefined),
|
||||
...(identityId ? { telnetUsername: undefined, telnetPassword: undefined } : {}),
|
||||
});
|
||||
|
||||
export const includeMissingIdentityOption = (
|
||||
options: Array<{ value: string; label: string; sublabel?: string }>,
|
||||
identityId: string | undefined,
|
||||
missingLabel: string,
|
||||
): Array<{ value: string; label: string; sublabel?: string }> => {
|
||||
if (!identityId || options.some((option) => option.value === identityId)) return options;
|
||||
return [{ value: identityId, label: missingLabel }, ...options];
|
||||
};
|
||||
|
||||
export const resolveGroupFormIdentityId = (
|
||||
form: Partial<GroupConfig>,
|
||||
inheritedIdentityId: string | undefined,
|
||||
protocol: "ssh" | "telnet",
|
||||
): string | undefined => {
|
||||
const formIdentityId = protocol === "ssh" ? form.identityId : form.telnetIdentityId;
|
||||
if (formIdentityId !== undefined) return formIdentityId;
|
||||
const hasManualCredentials = protocol === "ssh"
|
||||
? hasManualGroupSshCredentials(form)
|
||||
: hasManualGroupTelnetCredentials(form);
|
||||
return hasManualCredentials ? undefined : inheritedIdentityId;
|
||||
};
|
||||
|
||||
const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
groupPath,
|
||||
config,
|
||||
@@ -130,18 +207,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
|
||||
// Protocol sections enabled state
|
||||
const hasSshFields = (c: Partial<GroupConfig>) =>
|
||||
c.protocol === 'ssh' ||
|
||||
c.port !== undefined || !!c.username || !!c.password || !!c.identityFileId ||
|
||||
c.deviceType !== undefined ||
|
||||
c.agentForwarding !== undefined || c.authMethod !== undefined || !!c.identityId ||
|
||||
!!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.startupCommandRunMode !== undefined || c.legacyAlgorithms !== undefined || c.skipEcdsaHostKey !== undefined || c.algorithms !== undefined || c.backspaceBehavior !== undefined ||
|
||||
(c.environmentVariables && c.environmentVariables.length > 0) ||
|
||||
c.moshEnabled !== undefined || !!c.moshServerPath ||
|
||||
c.etEnabled !== undefined || c.etPort !== undefined ||
|
||||
(c.identityFilePaths && c.identityFilePaths.length > 0);
|
||||
|
||||
const [sshEnabled, setSshEnabled] = useState(() => hasSshFields(config || {}));
|
||||
const [sshEnabled, setSshEnabled] = useState(() => hasGroupSshFields(config || {}));
|
||||
const [telnetEnabled, setTelnetEnabled] = useState(() => hasGroupTelnetFields(config || {}));
|
||||
|
||||
// Sub-panel state
|
||||
@@ -220,6 +286,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
const next = { ...prev };
|
||||
delete next.telnetEnabled;
|
||||
delete next.telnetPort;
|
||||
delete next.telnetIdentityId;
|
||||
delete next.telnetUsername;
|
||||
delete next.telnetPassword;
|
||||
return next;
|
||||
@@ -320,6 +387,65 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
};
|
||||
}, [availableKeys]);
|
||||
|
||||
const identityOptions = useMemo(
|
||||
() => identities.map((identity) => ({
|
||||
value: identity.id,
|
||||
label: identity.label,
|
||||
sublabel: identity.username,
|
||||
})),
|
||||
[identities],
|
||||
);
|
||||
|
||||
const inheritedConnectionDefaults = useMemo(() => {
|
||||
if (!parentGroup || groupConfigs.length === 0) return {};
|
||||
return resolveGroupDefaults(parentGroup, groupConfigs);
|
||||
}, [groupConfigs, parentGroup]);
|
||||
const effectiveSshIdentityId = resolveGroupFormIdentityId(
|
||||
form,
|
||||
inheritedConnectionDefaults.identityId,
|
||||
"ssh",
|
||||
);
|
||||
const effectiveTelnetIdentityId = resolveGroupFormIdentityId(
|
||||
form,
|
||||
inheritedConnectionDefaults.telnetIdentityId,
|
||||
"telnet",
|
||||
);
|
||||
const sshIdentityOptions = useMemo(
|
||||
() => includeMissingIdentityOption(
|
||||
identityOptions,
|
||||
effectiveSshIdentityId,
|
||||
t("hostDetails.identity.missing"),
|
||||
),
|
||||
[effectiveSshIdentityId, identityOptions, t],
|
||||
);
|
||||
const telnetIdentityOptions = useMemo(
|
||||
() => includeMissingIdentityOption(
|
||||
identityOptions,
|
||||
effectiveTelnetIdentityId,
|
||||
t("hostDetails.identity.missing"),
|
||||
),
|
||||
[effectiveTelnetIdentityId, identityOptions, t],
|
||||
);
|
||||
|
||||
const updateSshIdentity = useCallback((identityId: string) => {
|
||||
setForm((prev) => selectGroupSshIdentity(
|
||||
prev,
|
||||
identities.find((item) => item.id === identityId),
|
||||
identityId,
|
||||
inheritedConnectionDefaults.identityId,
|
||||
));
|
||||
setSelectedCredentialType(null);
|
||||
setCredentialPopoverOpen(false);
|
||||
}, [identities, inheritedConnectionDefaults.identityId]);
|
||||
|
||||
const updateTelnetIdentity = useCallback((identityId: string) => {
|
||||
setForm((prev) => selectGroupTelnetIdentity(
|
||||
prev,
|
||||
identityId,
|
||||
inheritedConnectionDefaults.telnetIdentityId,
|
||||
));
|
||||
}, [inheritedConnectionDefaults.telnetIdentityId]);
|
||||
|
||||
// Parent group options — exclude self and children
|
||||
const parentGroupOptions = useMemo(() => {
|
||||
const selfPath = groupPath;
|
||||
@@ -453,6 +579,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
...(telnetEnabled && {
|
||||
telnetEnabled: true,
|
||||
...(form.telnetPort !== undefined && { telnetPort: form.telnetPort }),
|
||||
...(form.telnetIdentityId !== undefined && { telnetIdentityId: form.telnetIdentityId }),
|
||||
...(form.telnetUsername !== undefined && { telnetUsername: form.telnetUsername }),
|
||||
...(form.telnetPassword !== undefined && { telnetPassword: form.telnetPassword }),
|
||||
}),
|
||||
@@ -630,6 +757,10 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
showPassword={showPassword}
|
||||
setShowPassword={setShowPassword}
|
||||
availableKeys={availableKeys}
|
||||
identities={identities}
|
||||
identityOptions={sshIdentityOptions}
|
||||
updateSshIdentity={updateSshIdentity}
|
||||
effectiveSshIdentityId={effectiveSshIdentityId}
|
||||
setSelectedCredentialType={setSelectedCredentialType}
|
||||
selectedCredentialType={selectedCredentialType}
|
||||
credentialPopoverOpen={credentialPopoverOpen}
|
||||
@@ -715,28 +846,40 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
placeholder={t("hostDetails.username.placeholder")}
|
||||
value={form.telnetUsername || ""}
|
||||
onChange={(e) => update("telnetUsername", e.target.value)}
|
||||
className="h-10"
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={t("hostDetails.password.placeholder")}
|
||||
type={showTelnetPassword ? "text" : "password"}
|
||||
value={form.telnetPassword || ""}
|
||||
onChange={(e) => update("telnetPassword", e.target.value)}
|
||||
className="h-10 pr-10"
|
||||
{(identities.length > 0 || effectiveTelnetIdentityId) && (
|
||||
<Combobox
|
||||
options={telnetIdentityOptions}
|
||||
value={effectiveTelnetIdentityId || ""}
|
||||
onValueChange={updateTelnetIdentity}
|
||||
placeholder={t("hostDetails.identity.suggestions")}
|
||||
emptyText={t("common.noResultsFound")}
|
||||
className="w-full"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTelnetPassword(!showTelnetPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showTelnetPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!effectiveTelnetIdentityId && (<>
|
||||
<Input
|
||||
placeholder={t("hostDetails.username.placeholder")}
|
||||
value={form.telnetUsername || ""}
|
||||
onChange={(e) => update("telnetUsername", e.target.value)}
|
||||
className="h-10"
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={t("hostDetails.password.placeholder")}
|
||||
type={showTelnetPassword ? "text" : "password"}
|
||||
value={form.telnetPassword || ""}
|
||||
onChange={(e) => update("telnetPassword", e.target.value)}
|
||||
className="h-10 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTelnetPassword(!showTelnetPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showTelnetPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</>)}
|
||||
</HostDetailsSection>
|
||||
)}
|
||||
|
||||
@@ -746,11 +889,10 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
icon={<Globe size={14} className="text-muted-foreground" />}
|
||||
title={t("vault.groups.details.advanced")}
|
||||
>
|
||||
<Input
|
||||
placeholder="UTF-8"
|
||||
value={form.charset || ""}
|
||||
onChange={(e) => update("charset", e.target.value || undefined)}
|
||||
className="h-10"
|
||||
<TerminalEncodingSelect
|
||||
value={form.charset}
|
||||
inheritedValue={inheritedConnectionDefaults.charset}
|
||||
onValueChange={(value) => update("charset", value)}
|
||||
/>
|
||||
</HostDetailsSection>
|
||||
|
||||
|
||||
@@ -34,6 +34,10 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
|
||||
showPassword,
|
||||
setShowPassword,
|
||||
availableKeys,
|
||||
identities,
|
||||
identityOptions,
|
||||
updateSshIdentity,
|
||||
effectiveSshIdentityId,
|
||||
setSelectedCredentialType,
|
||||
selectedCredentialType,
|
||||
credentialPopoverOpen,
|
||||
@@ -100,32 +104,45 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
placeholder={t("hostDetails.username.placeholder")}
|
||||
value={form.username || ""}
|
||||
onChange={(e) => update("username", e.target.value || undefined)}
|
||||
className="h-10"
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={t("hostDetails.password.placeholder")}
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={form.password || ""}
|
||||
onChange={(e) => update("password", e.target.value || undefined)}
|
||||
className="h-10 pr-10"
|
||||
{(identities.length > 0 || effectiveSshIdentityId) && (
|
||||
<Combobox
|
||||
options={identityOptions}
|
||||
value={effectiveSshIdentityId || ""}
|
||||
onValueChange={updateSshIdentity}
|
||||
placeholder={t("hostDetails.identity.suggestions")}
|
||||
emptyText={t("common.noResultsFound")}
|
||||
className="w-full"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!effectiveSshIdentityId && (<>
|
||||
<Input
|
||||
placeholder={t("hostDetails.username.placeholder")}
|
||||
value={form.username || ""}
|
||||
onChange={(e) => update("username", e.target.value || undefined)}
|
||||
className="h-10"
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={t("hostDetails.password.placeholder")}
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={form.password || ""}
|
||||
onChange={(e) => update("password", e.target.value || undefined)}
|
||||
className="h-10 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</>)}
|
||||
|
||||
{/* Selected credential display */}
|
||||
{form.identityFileId && (
|
||||
{!effectiveSshIdentityId && form.identityFileId && (
|
||||
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
|
||||
{form.authMethod === "certificate" ? (
|
||||
<Shield size={14} className="text-primary" />
|
||||
@@ -151,7 +168,7 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
|
||||
)}
|
||||
|
||||
{/* Local key file paths display */}
|
||||
{!form.identityFileId && form.identityFilePaths && form.identityFilePaths.length > 0 && (
|
||||
{!effectiveSshIdentityId && !form.identityFileId && form.identityFilePaths && form.identityFilePaths.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{form.identityFilePaths.map((keyPath, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 h-8 px-2 rounded-md bg-secondary/50 border border-border/60" style={{ maxWidth: '100%' }}>
|
||||
@@ -175,7 +192,7 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
|
||||
)}
|
||||
|
||||
{/* Credential type selection with inline popover - hidden when credential is selected */}
|
||||
{!form.identityFileId &&
|
||||
{!effectiveSshIdentityId && !form.identityFileId &&
|
||||
!selectedCredentialType && (
|
||||
<Popover
|
||||
open={credentialPopoverOpen}
|
||||
@@ -244,7 +261,7 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
|
||||
|
||||
{/* Key selection combobox - appears after selecting "Key" type */}
|
||||
{selectedCredentialType === "key" &&
|
||||
!form.identityFileId && (
|
||||
!effectiveSshIdentityId && !form.identityFileId && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Combobox
|
||||
options={keysByCategory.key.map((k) => ({
|
||||
@@ -277,7 +294,7 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
|
||||
|
||||
{/* Certificate selection combobox - appears after selecting "Certificate" type */}
|
||||
{selectedCredentialType === "certificate" &&
|
||||
!form.identityFileId && (
|
||||
!effectiveSshIdentityId && !form.identityFileId && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Combobox
|
||||
options={keysByCategory.certificate.map((k) => ({
|
||||
@@ -312,7 +329,7 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
|
||||
)}
|
||||
|
||||
{/* Local key file path input - appears after selecting "Local Key File" type */}
|
||||
{!form.identityFileId && selectedCredentialType === "localKeyFile" && (
|
||||
{!effectiveSshIdentityId && !form.identityFileId && selectedCredentialType === "localKeyFile" && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1 w-full">
|
||||
<input
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = readFileSync(new URL('./HostDetailsAdvancedSections.tsx', import.meta.url), 'utf8');
|
||||
|
||||
test('advanced host settings expose per-host SSH connection timeouts', () => {
|
||||
assert.match(source, /hostDetails\.section\.sshTimeouts/);
|
||||
assert.match(source, /value=\{form\.sshTcpConnectTimeoutSeconds \?\?/);
|
||||
assert.match(source, /update\("sshTcpConnectTimeoutSeconds", value\)/);
|
||||
assert.match(source, /value=\{form\.sshAuthReadyTimeoutSeconds \?\?/);
|
||||
assert.match(source, /update\("sshAuthReadyTimeoutSeconds", value\)/);
|
||||
assert.equal(source.match(/!Number\.isFinite\(value\)/g)?.length, 2);
|
||||
});
|
||||
|
||||
test('editing enabled SSH agent controls persists the enabled state', () => {
|
||||
assert.match(source, /enabled=\{form\.useSshAgent === true\}/);
|
||||
assert.match(source, /useSshAgent: true,\s*identityAgent:/);
|
||||
assert.match(source, /useSshAgent: true,\s*identitiesOnly:/);
|
||||
});
|
||||
|
||||
test('enabling SSH agent login clears an imported none sentinel', () => {
|
||||
assert.match(source, /enabling && isSshAgentNoneValue\(previous\.identityAgent\)/);
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from "react";
|
||||
import { AlertTriangle, ChevronDown, ChevronUp, Forward, Globe, HeartPulse, Link2, Palette, Plus, Router, ShieldAlert, TerminalSquare, Wifi, X, Variable } from "lucide-react";
|
||||
import { AlertTriangle, ChevronDown, ChevronUp, Forward, Globe, HeartPulse, KeyRound, Link2, Palette, Plus, Router, ShieldAlert, TerminalSquare, Timer, Wifi, X, Variable } from "lucide-react";
|
||||
import { customThemeStore } from "../application/state/customThemeStore";
|
||||
import { clearHostFontSizeOverride, clearHostThemeOverride } from "../domain/terminalAppearance";
|
||||
import { isSshAgentNoneValue } from "../domain/sshAgentSettings";
|
||||
import { MAX_FONT_SIZE, MIN_FONT_SIZE } from "../infrastructure/config/fonts";
|
||||
import { AlgorithmOverridesPanel } from "./host-details/AlgorithmOverridesPanel";
|
||||
import { Badge } from "./ui/badge";
|
||||
@@ -13,6 +14,11 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
|
||||
import { Switch } from "./ui/switch";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
|
||||
import {
|
||||
DEFAULT_SSH_AUTH_READY_TIMEOUT_SECONDS,
|
||||
DEFAULT_SSH_TCP_CONNECT_TIMEOUT_SECONDS,
|
||||
MAX_SSH_CONNECTION_TIMEOUT_SECONDS,
|
||||
} from "../domain/sshConnectionTimeouts";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type HostDetailsAdvancedSectionsProps = Record<string, any>;
|
||||
@@ -229,6 +235,70 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* System SSH Agent login */}
|
||||
<HostDetailsSection
|
||||
icon={<KeyRound size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.systemSshAgent")}
|
||||
>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.systemSshAgent")}
|
||||
hint={t("hostDetails.systemSshAgent.desc")}
|
||||
enabled={form.useSshAgent === true}
|
||||
onToggle={() => setForm((previous: typeof form) => {
|
||||
const enabling = previous.useSshAgent !== true;
|
||||
return {
|
||||
...previous,
|
||||
useSshAgent: enabling,
|
||||
identityAgent: enabling && isSshAgentNoneValue(previous.identityAgent)
|
||||
? undefined
|
||||
: previous.identityAgent,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
{form.useSshAgent === true && (
|
||||
<>
|
||||
<HostDetailsSettingRow
|
||||
label={t("hostDetails.systemSshAgent.socket")}
|
||||
hint={t("hostDetails.systemSshAgent.socket.desc")}
|
||||
>
|
||||
<Input
|
||||
className="w-44 font-mono text-xs"
|
||||
placeholder="$SSH_AUTH_SOCK"
|
||||
value={form.identityAgent ?? ""}
|
||||
onChange={(event) => setForm((previous: typeof form) => ({
|
||||
...previous,
|
||||
useSshAgent: true,
|
||||
identityAgent: event.target.value || undefined,
|
||||
}))}
|
||||
/>
|
||||
</HostDetailsSettingRow>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.systemSshAgent.identitiesOnly")}
|
||||
hint={t("hostDetails.systemSshAgent.identitiesOnly.desc")}
|
||||
enabled={!!form.identitiesOnly}
|
||||
onToggle={() => setForm((previous: typeof form) => ({
|
||||
...previous,
|
||||
useSshAgent: true,
|
||||
identitiesOnly: !previous.identitiesOnly,
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{form.useSshAgent === true && sshAgentStatus && !sshAgentStatus.running && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 font-medium">
|
||||
{t("hostDetails.systemSshAgent.notRunning")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.systemSshAgent.notRunningHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* Agent Forwarding */}
|
||||
<HostDetailsSection
|
||||
icon={<Forward size={14} className="text-muted-foreground" />}
|
||||
@@ -456,6 +526,48 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
|
||||
<HostDetailsSection
|
||||
icon={<Timer size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.sshTimeouts")}
|
||||
>
|
||||
<HostDetailsSettingRow
|
||||
label={t("hostDetails.sshTimeouts.tcpConnect")}
|
||||
hint={t("hostDetails.sshTimeouts.tcpConnect.desc")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_SSH_CONNECTION_TIMEOUT_SECONDS}
|
||||
className="h-8 w-20 text-xs"
|
||||
aria-label={t("hostDetails.sshTimeouts.tcpConnect")}
|
||||
value={form.sshTcpConnectTimeoutSeconds ?? DEFAULT_SSH_TCP_CONNECT_TIMEOUT_SECONDS}
|
||||
onChange={(e) => {
|
||||
const value = Number.parseInt(e.target.value, 10);
|
||||
if (!Number.isFinite(value) || value < 1 || value > MAX_SSH_CONNECTION_TIMEOUT_SECONDS) return;
|
||||
update("sshTcpConnectTimeoutSeconds", value);
|
||||
}}
|
||||
/>
|
||||
</HostDetailsSettingRow>
|
||||
<HostDetailsSettingRow
|
||||
label={t("hostDetails.sshTimeouts.authReady")}
|
||||
hint={t("hostDetails.sshTimeouts.authReady.desc")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_SSH_CONNECTION_TIMEOUT_SECONDS}
|
||||
className="h-8 w-20 text-xs"
|
||||
aria-label={t("hostDetails.sshTimeouts.authReady")}
|
||||
value={form.sshAuthReadyTimeoutSeconds ?? DEFAULT_SSH_AUTH_READY_TIMEOUT_SECONDS}
|
||||
onChange={(e) => {
|
||||
const value = Number.parseInt(e.target.value, 10);
|
||||
if (!Number.isFinite(value) || value < 1 || value > MAX_SSH_CONNECTION_TIMEOUT_SECONDS) return;
|
||||
update("sshAuthReadyTimeoutSeconds", value);
|
||||
}}
|
||||
/>
|
||||
</HostDetailsSettingRow>
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}
|
||||
<HostDetailsSection
|
||||
icon={<Link2 size={14} className="text-muted-foreground" />}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = readFileSync(new URL('./HostDetailsPanel.tsx', import.meta.url), 'utf8');
|
||||
|
||||
test('SSH agent availability ignores stale async responses', () => {
|
||||
assert.match(source, /let cancelled = false/);
|
||||
assert.match(source, /if \(!cancelled\) setSshAgentStatus\(status\)/);
|
||||
assert.match(source, /return \(\) => \{\s*cancelled = true/);
|
||||
});
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
prepareProxyConfigForSave,
|
||||
} from "./HostDetailsPanel.helpers";
|
||||
export { parseOptionalPortInput } from "./HostDetailsPanel.helpers";
|
||||
import { TerminalEncodingSelect } from "./TerminalEncodingSelect";
|
||||
import { Button } from "./ui/button";
|
||||
import { Combobox, ComboboxOption, MultiCombobox } from "./ui/combobox";
|
||||
import { Input } from "./ui/input";
|
||||
@@ -187,12 +188,23 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (form.agentForwarding) {
|
||||
checkSshAgent().then(setSshAgentStatus);
|
||||
let cancelled = false;
|
||||
if (form.agentForwarding || form.useSshAgent === true) {
|
||||
void checkSshAgent({
|
||||
identityAgent: form.useSshAgent === true ? form.identityAgent : undefined,
|
||||
hostname: form.hostname,
|
||||
port: form.port,
|
||||
username: form.username,
|
||||
}).then((status) => {
|
||||
if (!cancelled) setSshAgentStatus(status);
|
||||
});
|
||||
} else {
|
||||
setSshAgentStatus(null);
|
||||
}
|
||||
}, [form.agentForwarding, checkSshAgent]);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [form.agentForwarding, form.useSshAgent, form.identityAgent, form.hostname, form.port, form.username, checkSshAgent]);
|
||||
|
||||
const [groupInputValue, setGroupInputValue] = useState(form.group || "");
|
||||
|
||||
@@ -1094,11 +1106,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
<Input
|
||||
placeholder={groupDefaults?.charset || t("hostDetails.charset.placeholder")}
|
||||
value={form.charset || "UTF-8"}
|
||||
onChange={(e) => update("charset", e.target.value)}
|
||||
className="h-10"
|
||||
<TerminalEncodingSelect
|
||||
value={form.charset}
|
||||
inheritedValue={effectiveGroupDefaults?.charset}
|
||||
onValueChange={(value) => update("charset", value)}
|
||||
/>
|
||||
|
||||
<button
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import type { GroupConfig, Host } from "../types.ts";
|
||||
import { getHostTreeDisplayDetails } from "./HostTreeView.tsx";
|
||||
import type { GroupConfig, GroupNode, Host } from "../types.ts";
|
||||
import { getHostTreeDisplayDetails, HostTreeView } from "./HostTreeView.tsx";
|
||||
|
||||
const baseHost: Host = {
|
||||
id: "host-1",
|
||||
@@ -16,6 +18,22 @@ const baseHost: Host = {
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const installLocalStorageMock = () => {
|
||||
const store = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
test("HostTreeView display details include inherited telnet defaults", () => {
|
||||
const host: Host = {
|
||||
...baseHost,
|
||||
@@ -56,3 +74,79 @@ test("HostTreeView display details keep explicit cleared telnet username", () =>
|
||||
port: 2325,
|
||||
});
|
||||
});
|
||||
|
||||
test("HostTreeView renders the host edit action beside the host label", () => {
|
||||
installLocalStorageMock();
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<HostTreeView
|
||||
groupTree={[]}
|
||||
hosts={[{
|
||||
...baseHost,
|
||||
notes: "Maintenance notes",
|
||||
tags: ["edge"],
|
||||
}]}
|
||||
onConnect={() => undefined}
|
||||
onEditHost={() => undefined}
|
||||
onDuplicateHost={() => undefined}
|
||||
onDeleteHost={() => undefined}
|
||||
onCopyCredentials={() => undefined}
|
||||
onNewGroup={() => undefined}
|
||||
onRenameGroup={() => undefined}
|
||||
onEditGroup={() => undefined}
|
||||
onDeleteGroup={() => undefined}
|
||||
moveHostToGroup={() => undefined}
|
||||
moveGroup={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
const labelIndex = markup.indexOf("Router");
|
||||
const editButtonIndex = markup.indexOf('data-host-tree-host-edit-button="host-1"');
|
||||
const notesIndex = markup.indexOf('aria-label="Host notes"', labelIndex);
|
||||
const protocolIndex = markup.indexOf("TELNET", labelIndex);
|
||||
|
||||
assert.ok(labelIndex >= 0);
|
||||
assert.ok(editButtonIndex > labelIndex);
|
||||
assert.ok(notesIndex > editButtonIndex);
|
||||
assert.ok(protocolIndex > notesIndex);
|
||||
});
|
||||
|
||||
test("HostTreeView renders the group edit action beside the group label", () => {
|
||||
installLocalStorageMock();
|
||||
|
||||
const groupNode: GroupNode = {
|
||||
name: "Production",
|
||||
path: "production",
|
||||
children: {},
|
||||
hosts: [],
|
||||
totalHostCount: 2,
|
||||
};
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<HostTreeView
|
||||
groupTree={[groupNode]}
|
||||
hosts={[]}
|
||||
expandedPaths={new Set<string>()}
|
||||
onTogglePath={() => undefined}
|
||||
onConnect={() => undefined}
|
||||
onEditHost={() => undefined}
|
||||
onDuplicateHost={() => undefined}
|
||||
onDeleteHost={() => undefined}
|
||||
onCopyCredentials={() => undefined}
|
||||
onNewGroup={() => undefined}
|
||||
onRenameGroup={() => undefined}
|
||||
onEditGroup={() => undefined}
|
||||
onDeleteGroup={() => undefined}
|
||||
moveHostToGroup={() => undefined}
|
||||
moveGroup={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
const labelIndex = markup.indexOf("Production");
|
||||
const editButtonIndex = markup.indexOf('data-host-tree-group-edit-button="production"');
|
||||
const countIndex = markup.indexOf(">2<", editButtonIndex);
|
||||
|
||||
assert.ok(labelIndex >= 0);
|
||||
assert.ok(editButtonIndex > labelIndex);
|
||||
assert.ok(countIndex > editButtonIndex);
|
||||
});
|
||||
+18
-14
@@ -272,15 +272,17 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
Managed
|
||||
</span>
|
||||
)}
|
||||
actions={(
|
||||
labelActions={(
|
||||
<button
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md opacity-0 transition-colors hover:bg-secondary/80 group-hover:opacity-100"
|
||||
aria-label={`Edit ${node.name}`}
|
||||
data-host-tree-group-edit-button={node.path}
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded opacity-0 transition-colors hover:bg-secondary/80 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditGroup(node.path);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={13} />
|
||||
<Edit2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
@@ -463,6 +465,17 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
<div className="min-w-0 flex-1 leading-tight">
|
||||
<div className="flex items-center gap-1.5 truncate font-medium leading-4">
|
||||
<span className="truncate">{host.label}</span>
|
||||
<button
|
||||
aria-label={`Edit ${host.label}`}
|
||||
data-host-tree-host-edit-button={host.id}
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded transition-colors opacity-0 hover:bg-secondary/80 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditHost(host);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={12} />
|
||||
</button>
|
||||
<HostNotesIndicator notes={host.notes} />
|
||||
</div>
|
||||
<div className="truncate text-[11px] leading-4 text-muted-foreground">
|
||||
@@ -470,7 +483,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
actions={(
|
||||
actions={(displayProtocol && displayProtocol !== 'ssh') || tags.length > 0 ? (
|
||||
<div className="ml-2 flex shrink-0 items-center gap-1.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{displayProtocol && displayProtocol !== 'ssh' && (
|
||||
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] leading-none text-primary">
|
||||
@@ -483,17 +496,8 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
{tags.length > 2 && '...'}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md transition-colors hover:bg-secondary/80"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditHost(host);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
) : undefined}
|
||||
/>
|
||||
</ContextMenuTrigger>
|
||||
<HostTreeHostContextMenuContent
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = readFileSync(new URL('./KeychainExportPanel.tsx', import.meta.url), 'utf8');
|
||||
|
||||
test('key export forwards the selected host SSH connection timeouts', () => {
|
||||
assert.match(source, /resolveHostSshConnectionTimeouts\(effectiveExportHost\)/);
|
||||
assert.match(source, /sshTcpConnectTimeoutMs: connectionTimeouts\.tcpConnectTimeoutSeconds \* 1000/);
|
||||
assert.match(source, /sshAuthReadyTimeoutMs: connectionTimeouts\.authReadyTimeoutSeconds \* 1000/);
|
||||
});
|
||||
|
||||
test('key export preserves imported identity files when the system SSH agent is enabled', () => {
|
||||
assert.match(
|
||||
source,
|
||||
/fallbackIdentityFilePaths: \(!effectiveExportHost\.useSshAgent && exportAuth\.authMethod === "password"\) \|\| exportAuth\.keyId/,
|
||||
);
|
||||
});
|
||||
|
||||
test('key export passes the selected vault key to agent filtering', () => {
|
||||
assert.match(source, /resolveBridgeSshAgentAuth\(\s*effectiveExportHost,\s*exportAuth\.key,\s*\)/);
|
||||
});
|
||||
@@ -2,7 +2,8 @@ import React from "react";
|
||||
import { ChevronRight, Info } from "lucide-react";
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from "../domain/groupConfig";
|
||||
import { sanitizeCredentialValue } from "../domain/credentials";
|
||||
import { resolveBridgeKeyAuth, resolveHostAuth } from "../domain/sshAuth";
|
||||
import { hasBridgeSshCredentials, resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from "../domain/sshAuth";
|
||||
import { resolveHostSshConnectionTimeouts } from "../domain/sshConnectionTimeouts";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
|
||||
@@ -172,26 +173,40 @@ export const KeychainExportPanel: React.FC<KeychainExportPanelProps> = ({
|
||||
setIsExporting(true);
|
||||
|
||||
try {
|
||||
// Apply group defaults before resolving authentication
|
||||
// and connection settings for this one-off SSH command.
|
||||
const effectiveExportHost = exportHost.group
|
||||
? applyGroupDefaults(
|
||||
exportHost,
|
||||
resolveGroupDefaults(exportHost.group, groupConfigs),
|
||||
)
|
||||
: applyGroupDefaults(exportHost, {});
|
||||
const connectionTimeouts = resolveHostSshConnectionTimeouts(effectiveExportHost);
|
||||
const exportAuth = resolveHostAuth({
|
||||
host: exportHost,
|
||||
host: effectiveExportHost,
|
||||
keys,
|
||||
identities,
|
||||
});
|
||||
const exportKeyAuth = resolveBridgeKeyAuth({
|
||||
key: exportAuth.key,
|
||||
fallbackIdentityFilePaths: exportAuth.authMethod === "password" || exportAuth.keyId
|
||||
fallbackIdentityFilePaths: (!effectiveExportHost.useSshAgent && exportAuth.authMethod === "password") || exportAuth.keyId
|
||||
? undefined
|
||||
: exportHost.identityFilePaths,
|
||||
: effectiveExportHost.identityFilePaths,
|
||||
passphrase: exportAuth.passphrase,
|
||||
});
|
||||
const exportPassword = sanitizeCredentialValue(exportAuth.password);
|
||||
const exportAgentAuth = resolveBridgeSshAgentAuth(
|
||||
effectiveExportHost,
|
||||
exportAuth.key,
|
||||
);
|
||||
|
||||
// Need either password or a usable key to run remote command.
|
||||
if (
|
||||
!exportPassword &&
|
||||
!exportKeyAuth.privateKey &&
|
||||
!exportKeyAuth.identityFilePaths?.length
|
||||
) {
|
||||
if (!hasBridgeSshCredentials({
|
||||
password: exportPassword,
|
||||
privateKey: exportKeyAuth.privateKey,
|
||||
identityFilePaths: exportKeyAuth.identityFilePaths,
|
||||
useSshAgent: exportAgentAuth.useSshAgent,
|
||||
})) {
|
||||
throw new Error(
|
||||
t("keychain.export.missingCredentials"),
|
||||
);
|
||||
@@ -212,17 +227,6 @@ export const KeychainExportPanel: React.FC<KeychainExportPanelProps> = ({
|
||||
// Execute the script directly - SSH exec handles multiline commands
|
||||
const command = scriptWithVars;
|
||||
|
||||
// Resolve the effective host (applying group
|
||||
// defaults), so algorithm settings inherited from
|
||||
// the group reach the bridge — the host object on
|
||||
// its own only carries explicitly set fields.
|
||||
const effectiveExportHost = exportHost.group
|
||||
? applyGroupDefaults(
|
||||
exportHost,
|
||||
resolveGroupDefaults(exportHost.group, groupConfigs),
|
||||
)
|
||||
: applyGroupDefaults(exportHost, {});
|
||||
|
||||
// Execute via SSH
|
||||
const result = await execCommand({
|
||||
hostname: effectiveExportHost.hostname,
|
||||
@@ -236,6 +240,7 @@ export const KeychainExportPanel: React.FC<KeychainExportPanelProps> = ({
|
||||
keySource: exportAuth.key?.source,
|
||||
passphrase: exportKeyAuth.passphrase,
|
||||
identityFilePaths: exportKeyAuth.identityFilePaths,
|
||||
...exportAgentAuth,
|
||||
// Carry the effective host's algorithm settings
|
||||
// (host value falling back to its group default)
|
||||
// so the one-off SSH exec honors them just like
|
||||
@@ -245,6 +250,8 @@ export const KeychainExportPanel: React.FC<KeychainExportPanelProps> = ({
|
||||
algorithmOverrides: effectiveExportHost.algorithms,
|
||||
command,
|
||||
timeout: 30000,
|
||||
sshTcpConnectTimeoutMs: connectionTimeouts.tcpConnectTimeoutSeconds * 1000,
|
||||
sshAuthReadyTimeoutMs: connectionTimeouts.authReadyTimeoutSeconds * 1000,
|
||||
enableKeyboardInteractive: true,
|
||||
sessionId: `export-key:${effectiveExportHost.id}:${panel.key.id}`,
|
||||
});
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
vaultHeaderIconButtonClass,
|
||||
vaultSectionTitleClass,
|
||||
} from "./vault/VaultPageHeader";
|
||||
import { VaultDeleteConfirmDialog } from "./vault/VaultDeleteConfirmDialog";
|
||||
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
|
||||
|
||||
// Import utilities and components from keychain module
|
||||
@@ -115,6 +116,11 @@ const KeychainManager: React.FC<KeychainManagerProps> = ({
|
||||
const { generateKeyPair, execCommand } = useKeychainBackend();
|
||||
const [activeFilter, setActiveFilter] = useState<FilterTab>("key");
|
||||
const [search, setSearch] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState<{
|
||||
type: "key" | "identity";
|
||||
id: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
const [viewMode, setViewMode] = useStoredViewMode(
|
||||
STORAGE_KEY_VAULT_KEYS_VIEW_MODE,
|
||||
"grid",
|
||||
@@ -468,24 +474,49 @@ echo $3 >> "$FILE"`);
|
||||
|
||||
// Handle delete
|
||||
const handleDelete = useCallback(
|
||||
async (id: string) => {
|
||||
onDelete(id);
|
||||
if (panel.type === "view" && panel.key.id === id) {
|
||||
closePanel();
|
||||
}
|
||||
(id: string) => {
|
||||
const key = keys.find((item) => item.id === id);
|
||||
setDeleteTarget({
|
||||
type: "key",
|
||||
id,
|
||||
name: key?.label || t("keychain.panel.keyDetails"),
|
||||
});
|
||||
},
|
||||
[onDelete, panel, closePanel],
|
||||
[keys, t],
|
||||
);
|
||||
|
||||
// Handle delete identity
|
||||
const _handleDeleteIdentity = useCallback(
|
||||
(id: string) => {
|
||||
onDeleteIdentity?.(id);
|
||||
if (panel.type === "identity" && panel.identity?.id === id) {
|
||||
closePanel();
|
||||
}
|
||||
const identity = identities.find((item) => item.id === id);
|
||||
setDeleteTarget({
|
||||
type: "identity",
|
||||
id,
|
||||
name: identity?.label || t("keychain.panel.editIdentity"),
|
||||
});
|
||||
},
|
||||
[onDeleteIdentity, panel, closePanel],
|
||||
[identities, t],
|
||||
);
|
||||
|
||||
const confirmDeleteTarget = useCallback(
|
||||
() => {
|
||||
if (!deleteTarget) return;
|
||||
|
||||
if (deleteTarget.type === "key") {
|
||||
onDelete(deleteTarget.id);
|
||||
if (panel.type === "view" && panel.key.id === deleteTarget.id) {
|
||||
closePanel();
|
||||
}
|
||||
} else {
|
||||
onDeleteIdentity?.(deleteTarget.id);
|
||||
if (panel.type === "identity" && panel.identity?.id === deleteTarget.id) {
|
||||
closePanel();
|
||||
}
|
||||
}
|
||||
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
[closePanel, deleteTarget, onDelete, onDeleteIdentity, panel],
|
||||
);
|
||||
|
||||
// Get icon for key source
|
||||
@@ -837,15 +868,7 @@ echo $3 >> "$FILE"`);
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
const ok = window.confirm(
|
||||
t("confirm.deleteIdentity", {
|
||||
name: identity.label || "",
|
||||
}),
|
||||
);
|
||||
if (!ok) return;
|
||||
_handleDeleteIdentity(identity.id);
|
||||
}}
|
||||
onClick={() => _handleDeleteIdentity(identity.id)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />{" "}
|
||||
{t("action.delete")}
|
||||
@@ -876,13 +899,7 @@ echo $3 >> "$FILE"`);
|
||||
variant="destructive"
|
||||
icon={<Trash2 size={14} />}
|
||||
onClick={() => {
|
||||
const ok = window.confirm(
|
||||
t("confirm.deleteIdentity", {
|
||||
name: panel.identity?.label || "",
|
||||
}),
|
||||
);
|
||||
if (!ok || !panel.identity) return;
|
||||
_handleDeleteIdentity(panel.identity.id);
|
||||
if (panel.identity) _handleDeleteIdentity(panel.identity.id);
|
||||
}}
|
||||
>
|
||||
{t("common.delete")}
|
||||
@@ -1031,6 +1048,18 @@ echo $3 >> "$FILE"`);
|
||||
)}
|
||||
</AsidePanel>
|
||||
)}
|
||||
|
||||
<VaultDeleteConfirmDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
title={t("vault.deleteConfirm.title", {
|
||||
name: deleteTarget?.name ?? "",
|
||||
})}
|
||||
description={t("vault.deleteConfirm.desc")}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteTarget(null);
|
||||
}}
|
||||
onConfirm={confirmDeleteTarget}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
vaultHeaderIconButtonClass,
|
||||
vaultHeaderSecondaryButtonClass,
|
||||
} from "./vault/VaultPageHeader";
|
||||
import { VaultDeleteConfirmDialog } from "./vault/VaultDeleteConfirmDialog";
|
||||
import { VaultEntityIcon, vaultPrimaryIconClass } from "./vault/VaultEntityIcon";
|
||||
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
|
||||
|
||||
@@ -294,6 +295,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
const [search, setSearch] = useState("");
|
||||
const deferredSearch = useDeferredValue(search);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useStoredViewMode(
|
||||
STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE,
|
||||
"grid",
|
||||
@@ -471,9 +473,23 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
// Memoized handlers to prevent re-renders
|
||||
const handleDelete = useCallback(
|
||||
(id: string) => {
|
||||
onDelete(id);
|
||||
setDeleteTargetId(id);
|
||||
},
|
||||
[onDelete],
|
||||
[],
|
||||
);
|
||||
|
||||
const deleteTarget = useMemo(
|
||||
() => knownHosts.find((knownHost) => knownHost.id === deleteTargetId) ?? null,
|
||||
[deleteTargetId, knownHosts],
|
||||
);
|
||||
|
||||
const confirmDelete = useCallback(
|
||||
() => {
|
||||
if (!deleteTargetId) return;
|
||||
onDelete(deleteTargetId);
|
||||
setDeleteTargetId(null);
|
||||
},
|
||||
[deleteTargetId, onDelete],
|
||||
);
|
||||
|
||||
const handleConvertToHost = useCallback(
|
||||
@@ -670,6 +686,17 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<VaultDeleteConfirmDialog
|
||||
open={Boolean(deleteTargetId)}
|
||||
title={t("vault.deleteConfirm.title", {
|
||||
name: deleteTarget?.hostname ?? "",
|
||||
})}
|
||||
description={t("vault.deleteConfirm.desc")}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteTargetId(null);
|
||||
}}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+13
-12
@@ -155,7 +155,7 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
}
|
||||
|
||||
// Fit terminal
|
||||
setTimeout(() => {
|
||||
const initialFitTimer = setTimeout(() => {
|
||||
try {
|
||||
fitAddon.fit();
|
||||
} catch {
|
||||
@@ -186,6 +186,7 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
clearTimeout(initialFitTimer);
|
||||
term.dispose();
|
||||
termRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
@@ -205,17 +206,17 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
|
||||
// Update font size instantly without recreating terminal
|
||||
useEffect(() => {
|
||||
if (termRef.current && isReady) {
|
||||
termRef.current.options.fontSize = currentFontSize;
|
||||
// Refit after font size change
|
||||
setTimeout(() => {
|
||||
try {
|
||||
fitAddonRef.current?.fit();
|
||||
} catch {
|
||||
// Ignore fit errors
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
if (!termRef.current || !isReady) return;
|
||||
termRef.current.options.fontSize = currentFontSize;
|
||||
// Refit after font size change
|
||||
const refitTimer = setTimeout(() => {
|
||||
try {
|
||||
fitAddonRef.current?.fit();
|
||||
} catch {
|
||||
// Ignore fit errors
|
||||
}
|
||||
}, 10);
|
||||
return () => clearTimeout(refitTimer);
|
||||
}, [currentFontSize, isReady]);
|
||||
|
||||
// Handle resize
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { DEFAULT_REMEMBER_PASSPHRASE } from "./PassphraseModal.tsx";
|
||||
|
||||
test("passphrase remember checkbox defaults to unchecked for security", () => {
|
||||
assert.equal(DEFAULT_REMEMBER_PASSPHRASE, false);
|
||||
});
|
||||
@@ -23,6 +23,9 @@ export interface PassphraseRequest {
|
||||
hostname?: string;
|
||||
}
|
||||
|
||||
/** Opt-in only: do not default to remembering key passphrases (#2024). */
|
||||
export const DEFAULT_REMEMBER_PASSPHRASE = false;
|
||||
|
||||
interface PassphraseModalProps {
|
||||
request: PassphraseRequest | null;
|
||||
onSubmit: (requestId: string, passphrase: string, remember: boolean) => void;
|
||||
@@ -40,7 +43,7 @@ export const PassphraseModal: React.FC<PassphraseModalProps> = ({
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [showPassphrase, setShowPassphrase] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [rememberPassphrase, setRememberPassphrase] = useState(true);
|
||||
const [rememberPassphrase, setRememberPassphrase] = useState(DEFAULT_REMEMBER_PASSPHRASE);
|
||||
|
||||
// Reset state when request changes
|
||||
useEffect(() => {
|
||||
@@ -48,7 +51,7 @@ export const PassphraseModal: React.FC<PassphraseModalProps> = ({
|
||||
setPassphrase("");
|
||||
setShowPassphrase(false);
|
||||
setIsSubmitting(false);
|
||||
setRememberPassphrase(true);
|
||||
setRememberPassphrase(DEFAULT_REMEMBER_PASSPHRASE);
|
||||
}
|
||||
}, [request]);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Globe,
|
||||
@@ -32,14 +31,6 @@ import {
|
||||
AsidePanelFooter,
|
||||
} from "./ui/aside-panel";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
||||
import { SortDropdown } from "./ui/sort-dropdown";
|
||||
import { toast } from "./ui/toast";
|
||||
@@ -50,6 +41,7 @@ import {
|
||||
vaultHeaderSecondaryButtonClass,
|
||||
vaultSectionTitleClass,
|
||||
} from "./vault/VaultPageHeader";
|
||||
import { VaultDeleteConfirmDialog } from "./vault/VaultDeleteConfirmDialog";
|
||||
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
|
||||
|
||||
// Import components and utilities from port-forwarding module
|
||||
@@ -436,33 +428,24 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
setSelectedRuleId(null);
|
||||
}, [setSelectedRuleId]);
|
||||
|
||||
// Handle delete with confirmation for active tunnels
|
||||
// Handle delete with confirmation
|
||||
const handleDeleteRule = useCallback(
|
||||
(rule: PortForwardingRule) => {
|
||||
// If tunnel is active or connecting, show confirmation dialog
|
||||
if (rule.status === "active" || rule.status === "connecting") {
|
||||
setRuleToDelete(rule);
|
||||
setShowDeleteConfirm(true);
|
||||
} else {
|
||||
// If inactive, delete directly
|
||||
if (editingRule?.id === rule.id) {
|
||||
closeEditPanel();
|
||||
}
|
||||
deleteRule(rule.id);
|
||||
}
|
||||
setRuleToDelete(rule);
|
||||
setShowDeleteConfirm(true);
|
||||
},
|
||||
[editingRule, deleteRule, closeEditPanel],
|
||||
[],
|
||||
);
|
||||
|
||||
// Confirm delete of active tunnel: stop first, then delete
|
||||
const confirmDeleteActiveRule = useCallback(async () => {
|
||||
// Confirm delete; active tunnels are stopped first.
|
||||
const confirmDeleteRule = useCallback(async () => {
|
||||
if (!ruleToDelete) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
// Stop the tunnel first
|
||||
await stopTunnel(ruleToDelete.id);
|
||||
// Then delete the rule
|
||||
if (ruleToDelete.status === "active" || ruleToDelete.status === "connecting") {
|
||||
await stopTunnel(ruleToDelete.id);
|
||||
}
|
||||
if (editingRule?.id === ruleToDelete.id) {
|
||||
closeEditPanel();
|
||||
}
|
||||
@@ -474,6 +457,8 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
}
|
||||
}, [ruleToDelete, stopTunnel, deleteRule, editingRule, closeEditPanel]);
|
||||
|
||||
const deleteTargetIsActive = ruleToDelete?.status === "active" || ruleToDelete?.status === "connecting";
|
||||
|
||||
// Handle wizard navigation
|
||||
// Flow for local: type -> local-config -> destination -> host-selection
|
||||
// Flow for remote: type -> remote-host-selection (select host first) -> remote-config (port on remote) -> destination -> label
|
||||
@@ -923,44 +908,26 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Active Tunnel Confirmation Dialog */}
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={(open) => {
|
||||
if (!isDeleting) {
|
||||
<VaultDeleteConfirmDialog
|
||||
open={showDeleteConfirm}
|
||||
title={t("vault.deleteConfirm.title", {
|
||||
name: ruleToDelete?.label ?? "",
|
||||
})}
|
||||
description={
|
||||
deleteTargetIsActive
|
||||
? t("pf.deleteActive.desc", { label: ruleToDelete?.label ?? "" })
|
||||
: t("vault.deleteConfirm.portForwardingDesc")
|
||||
}
|
||||
confirmLabel={deleteTargetIsActive ? t("pf.deleteActive.confirm") : undefined}
|
||||
disabled={isDeleting}
|
||||
onOpenChange={(open) => {
|
||||
setShowDeleteConfirm(open);
|
||||
if (!open) setRuleToDelete(null);
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle size={20} />
|
||||
{t("pf.deleteActive.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("pf.deleteActive.desc", { label: ruleToDelete?.label ?? "" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowDeleteConfirm(false);
|
||||
setRuleToDelete(null);
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmDeleteActiveRule}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{t("pf.deleteActive.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}}
|
||||
onConfirm={() => {
|
||||
void confirmDeleteRule();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider";
|
||||
import QuickConnectWizard from "./QuickConnectWizard";
|
||||
|
||||
test("QuickConnectWizard offers ET without obsolete Mosh path or log controls", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<QuickConnectWizard
|
||||
open
|
||||
target={{ hostname: "example.com" }}
|
||||
keys={[]}
|
||||
identities={[]}
|
||||
onConnect={() => undefined}
|
||||
onClose={() => undefined}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.match(markup, /Eternal Terminal/);
|
||||
assert.doesNotMatch(markup, /mosh --server/);
|
||||
assert.doesNotMatch(markup, /Show logs|Hide logs/);
|
||||
});
|
||||
|
||||
test("quick connect shows saved identities as credential presets", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<QuickConnectWizard
|
||||
open
|
||||
target={{ hostname: "192.0.2.10" }}
|
||||
keys={[]}
|
||||
identities={[{
|
||||
id: "identity-root",
|
||||
label: "Root devices",
|
||||
username: "root",
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
created: 1,
|
||||
}]}
|
||||
onConnect={() => undefined}
|
||||
onClose={() => undefined}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.match(markup, /Credential preset/);
|
||||
assert.match(markup, /Choose a saved identity/);
|
||||
});
|
||||
@@ -14,16 +14,22 @@ import React, { useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import type { QuickConnectTarget } from "../domain/quickConnect";
|
||||
import { formatHostPort } from "../domain/host";
|
||||
import {
|
||||
buildQuickConnectHost,
|
||||
getQuickConnectDefaultPort,
|
||||
isQuickConnectIdentityUsable,
|
||||
type QuickConnectAuthMethod,
|
||||
type QuickConnectProtocol,
|
||||
} from "../domain/quickConnectHost";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host, SSHKey } from "../types";
|
||||
import { Host, Identity, SSHKey } from "../types";
|
||||
import { Button } from "./ui/button";
|
||||
import { Combobox } from "./ui/combobox";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
|
||||
// Protocol types supported for quick connect
|
||||
type Protocol = "ssh" | "mosh" | "telnet";
|
||||
|
||||
// Wizard steps
|
||||
type WizardStep = "protocol" | "username" | "knownhost" | "auth";
|
||||
|
||||
@@ -31,6 +37,7 @@ interface QuickConnectWizardProps {
|
||||
open: boolean;
|
||||
target: QuickConnectTarget;
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
warnings?: string[];
|
||||
onConnect: (host: Host) => void;
|
||||
onSaveHost?: (host: Host) => void;
|
||||
@@ -42,6 +49,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
open,
|
||||
target,
|
||||
keys,
|
||||
identities,
|
||||
warnings,
|
||||
onConnect,
|
||||
onSaveHost,
|
||||
@@ -51,11 +59,9 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
const { t } = useI18n();
|
||||
// Wizard state
|
||||
const [step, setStep] = useState<WizardStep>("protocol");
|
||||
const [protocol, setProtocol] = useState<Protocol>("ssh");
|
||||
const [protocol, setProtocol] = useState<QuickConnectProtocol>("ssh");
|
||||
const [username, setUsername] = useState(target.username || "");
|
||||
const [port, setPort] = useState<number>(target.port || 22);
|
||||
const [moshServerPath, setMoshServerPath] = useState("");
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
|
||||
// Known host verification state
|
||||
const [knownHostInfo, setKnownHostInfo] = useState<{
|
||||
@@ -64,11 +70,16 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
} | null>(null);
|
||||
|
||||
// Auth state
|
||||
const [authMethod, setAuthMethod] = useState<"password" | "key">("password");
|
||||
const [authMethod, setAuthMethod] = useState<QuickConnectAuthMethod>("password");
|
||||
const [password, setPassword] = useState("");
|
||||
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(null);
|
||||
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(null);
|
||||
const [saveOptionsOpen, setSaveOptionsOpen] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [saveCredentials] = useState(true);
|
||||
const selectedIdentity = useMemo(
|
||||
() => identities.find((identity) => identity.id === selectedIdentityId),
|
||||
[identities, selectedIdentityId],
|
||||
);
|
||||
|
||||
// Reset state when target changes
|
||||
React.useEffect(() => {
|
||||
@@ -79,38 +90,82 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
setPort(target.port || 22);
|
||||
setPassword("");
|
||||
setSelectedKeyId(null);
|
||||
setSelectedIdentityId(null);
|
||||
setSaveOptionsOpen(false);
|
||||
setShowPassword(false);
|
||||
setKnownHostInfo(null);
|
||||
}
|
||||
}, [open, target]);
|
||||
|
||||
// Get default port for protocol
|
||||
const getDefaultPort = (proto: Protocol) => {
|
||||
switch (proto) {
|
||||
case "ssh":
|
||||
return 22;
|
||||
case "mosh":
|
||||
return 22;
|
||||
case "telnet":
|
||||
return 23;
|
||||
default:
|
||||
return 22;
|
||||
const canUseSelectedIdentity = useMemo(
|
||||
() => isQuickConnectIdentityUsable(selectedIdentity, keys, protocol),
|
||||
[keys, protocol, selectedIdentity],
|
||||
);
|
||||
const identityOptions = useMemo(
|
||||
() => identities.map((identity) => ({
|
||||
value: identity.id,
|
||||
label: identity.label,
|
||||
sublabel: identity.username,
|
||||
icon: <User size={14} />,
|
||||
})),
|
||||
[identities],
|
||||
);
|
||||
|
||||
const detachSelectedIdentity = () => {
|
||||
setSelectedIdentityId(null);
|
||||
setAuthMethod("password");
|
||||
setPassword("");
|
||||
setSelectedKeyId(null);
|
||||
};
|
||||
|
||||
const clearSelectedIdentity = () => {
|
||||
detachSelectedIdentity();
|
||||
setUsername(target.username || "");
|
||||
};
|
||||
|
||||
const handleIdentitySelect = (identityId: string) => {
|
||||
if (!identityId) {
|
||||
clearSelectedIdentity();
|
||||
return;
|
||||
}
|
||||
const identity = identities.find((candidate) => candidate.id === identityId);
|
||||
if (!identity) return;
|
||||
setSelectedIdentityId(identity.id);
|
||||
setUsername(identity.username);
|
||||
setAuthMethod(identity.authMethod);
|
||||
setPassword("");
|
||||
setSelectedKeyId(identity.keyId || null);
|
||||
};
|
||||
|
||||
// Handle protocol selection
|
||||
const handleProtocolSelect = (proto: Protocol) => {
|
||||
const handleProtocolSelect = (proto: QuickConnectProtocol) => {
|
||||
setProtocol(proto);
|
||||
// Update port to default for protocol if unchanged
|
||||
if (port === getDefaultPort(protocol)) {
|
||||
setPort(getDefaultPort(proto));
|
||||
if (proto === "telnet") {
|
||||
if (selectedIdentityId) clearSelectedIdentity();
|
||||
setAuthMethod("password");
|
||||
}
|
||||
if (port === getQuickConnectDefaultPort(protocol)) {
|
||||
setPort(getQuickConnectDefaultPort(proto));
|
||||
}
|
||||
};
|
||||
|
||||
const handleProtocolPortChange = (
|
||||
proto: QuickConnectProtocol,
|
||||
rawValue: string,
|
||||
fallbackPort: number,
|
||||
) => {
|
||||
handleProtocolSelect(proto);
|
||||
setPort(parseInt(rawValue) || fallbackPort);
|
||||
};
|
||||
|
||||
// Navigate to next step
|
||||
const handleContinue = () => {
|
||||
switch (step) {
|
||||
case "protocol":
|
||||
if (canUseSelectedIdentity) {
|
||||
handleConnect();
|
||||
break;
|
||||
}
|
||||
// Always go to username step to let user confirm/edit username
|
||||
setStep("username");
|
||||
break;
|
||||
@@ -143,33 +198,25 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
};
|
||||
|
||||
// Create host and connect
|
||||
const handleConnect = () => {
|
||||
const handleConnect = (save = false) => {
|
||||
const effectiveUsername = username || target.username || "root";
|
||||
const effectivePort = port || getDefaultPort(protocol);
|
||||
|
||||
const tempHost: Host = {
|
||||
id: `quick-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
label: `${target.hostname}`,
|
||||
hostname: target.hostname,
|
||||
const effectivePort = port || getQuickConnectDefaultPort(protocol);
|
||||
const now = Date.now();
|
||||
const tempHost = buildQuickConnectHost({
|
||||
id: `quick-${now}-${Math.random().toString(36).slice(2, 11)}`,
|
||||
createdAt: now,
|
||||
target,
|
||||
protocol,
|
||||
port: effectivePort,
|
||||
username: effectiveUsername,
|
||||
group: "",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
protocol: protocol === "mosh" ? "ssh" : protocol,
|
||||
authMethod: authMethod,
|
||||
password: authMethod === "password" ? password : undefined,
|
||||
identityFileId:
|
||||
authMethod === "key" ? selectedKeyId || undefined : undefined,
|
||||
moshEnabled: protocol === "mosh",
|
||||
// Set telnet-specific fields when using telnet protocol
|
||||
telnetEnabled: protocol === "telnet",
|
||||
telnetPort: protocol === "telnet" ? effectivePort : undefined,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
authMethod,
|
||||
password,
|
||||
selectedKeyId,
|
||||
selectedIdentityId: selectedIdentity?.id,
|
||||
save,
|
||||
});
|
||||
|
||||
// Save host if requested
|
||||
if (saveCredentials && onSaveHost) {
|
||||
if (save && onSaveHost) {
|
||||
onSaveHost(tempHost);
|
||||
}
|
||||
|
||||
@@ -187,12 +234,24 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
case "knownhost":
|
||||
return true;
|
||||
case "auth":
|
||||
if (selectedIdentity && protocol !== "telnet") return canUseSelectedIdentity;
|
||||
if (authMethod === "password") {
|
||||
return password.trim().length > 0;
|
||||
// Whitespace-only passwords are valid SSH secrets (issue #2036).
|
||||
return password.length > 0;
|
||||
}
|
||||
return !!selectedKeyId;
|
||||
return Boolean(selectedKeyId && keys.some((key) => key.id === selectedKeyId));
|
||||
}
|
||||
}, [step, username, authMethod, password, selectedKeyId]);
|
||||
}, [
|
||||
step,
|
||||
username,
|
||||
authMethod,
|
||||
password,
|
||||
selectedKeyId,
|
||||
selectedIdentity,
|
||||
canUseSelectedIdentity,
|
||||
keys,
|
||||
protocol,
|
||||
]);
|
||||
|
||||
// Render protocol selection step
|
||||
const renderProtocolStep = () => (
|
||||
@@ -232,10 +291,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
<Input
|
||||
type="number"
|
||||
value={protocol === "ssh" ? port : 22}
|
||||
onChange={(e) => {
|
||||
setPort(parseInt(e.target.value) || 22);
|
||||
setProtocol("ssh");
|
||||
}}
|
||||
onChange={(e) => handleProtocolPortChange("ssh", e.target.value, 22)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-16 h-7 text-xs text-center"
|
||||
min={1}
|
||||
@@ -277,22 +333,53 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
<Input
|
||||
type="number"
|
||||
value={protocol === "mosh" ? port : 22}
|
||||
onChange={(e) => {
|
||||
setPort(parseInt(e.target.value) || 22);
|
||||
setProtocol("mosh");
|
||||
}}
|
||||
onChange={(e) => handleProtocolPortChange("mosh", e.target.value, 22)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-16 h-7 text-xs text-center"
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Eternal Terminal */}
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-4 py-3 rounded-xl border-2 transition-all text-left",
|
||||
protocol === "et"
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border/60 hover:border-border hover:bg-secondary/50",
|
||||
)}
|
||||
onClick={() => handleProtocolSelect("et")}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"h-10 w-10 rounded-lg flex items-center justify-center",
|
||||
protocol === "et"
|
||||
? "bg-primary/20 text-primary"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Globe size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">Eternal Terminal</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
et {target.hostname}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{t("protocolSelect.port")}</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={moshServerPath}
|
||||
onChange={(e) => setMoshServerPath(e.target.value)}
|
||||
type="number"
|
||||
value={protocol === "et" ? port : 22}
|
||||
onChange={(e) => handleProtocolPortChange("et", e.target.value, 22)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
placeholder="mosh --server=/path/server host"
|
||||
className="w-40 h-7 text-xs"
|
||||
className="w-16 h-7 text-xs text-center"
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
@@ -330,10 +417,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
<Input
|
||||
type="number"
|
||||
value={protocol === "telnet" ? port : 23}
|
||||
onChange={(e) => {
|
||||
setPort(parseInt(e.target.value) || 23);
|
||||
setProtocol("telnet");
|
||||
}}
|
||||
onChange={(e) => handleProtocolPortChange("telnet", e.target.value, 23)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-16 h-7 text-xs text-center"
|
||||
min={1}
|
||||
@@ -342,6 +426,25 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{protocol !== "telnet" && identities.length > 0 && (
|
||||
<div className="space-y-2 pt-1">
|
||||
<Label>{t("quickConnect.identity.label")}</Label>
|
||||
<Combobox
|
||||
options={identityOptions}
|
||||
value={selectedIdentityId || undefined}
|
||||
onValueChange={handleIdentitySelect}
|
||||
placeholder={t("quickConnect.identity.placeholder")}
|
||||
emptyText={t("quickConnect.identity.empty")}
|
||||
icon={<User size={14} />}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedIdentity
|
||||
? t("quickConnect.identity.selectedHint", { username: selectedIdentity.username })
|
||||
: t("quickConnect.identity.hint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -353,7 +456,10 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
<Input
|
||||
id="quick-username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onChange={(e) => {
|
||||
if (selectedIdentityId) detachSelectedIdentity();
|
||||
setUsername(e.target.value);
|
||||
}}
|
||||
placeholder={t("terminal.auth.username.placeholder")}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
@@ -392,6 +498,30 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
// Render auth step
|
||||
const renderAuthStep = () => (
|
||||
<div className="space-y-4">
|
||||
{selectedIdentity ? (
|
||||
<div className="rounded-xl border border-primary/40 bg-primary/5 p-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-lg bg-primary/15 text-primary flex items-center justify-center">
|
||||
<User size={16} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium truncate">{selectedIdentity.label}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{selectedIdentity.username} · {t(`quickConnect.identity.method.${selectedIdentity.authMethod}`)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!canUseSelectedIdentity && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("quickConnect.identity.unavailable")}
|
||||
</p>
|
||||
)}
|
||||
<Button variant="outline" size="sm" className="w-full" onClick={detachSelectedIdentity}>
|
||||
{t("quickConnect.identity.useCustom")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Auth method tabs */}
|
||||
<div className="flex gap-1 p-1 bg-secondary/80 rounded-lg border border-border/60">
|
||||
<button
|
||||
@@ -401,7 +531,10 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-secondary",
|
||||
)}
|
||||
onClick={() => setAuthMethod("password")}
|
||||
onClick={() => {
|
||||
setSelectedIdentityId(null);
|
||||
setAuthMethod("password");
|
||||
}}
|
||||
>
|
||||
<Lock size={14} />
|
||||
{t("terminal.auth.password")}
|
||||
@@ -409,11 +542,14 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md transition-all",
|
||||
authMethod === "key"
|
||||
authMethod !== "password"
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-secondary",
|
||||
)}
|
||||
onClick={() => setAuthMethod("key")}
|
||||
onClick={() => {
|
||||
setSelectedIdentityId(null);
|
||||
setAuthMethod("key");
|
||||
}}
|
||||
>
|
||||
<Key size={14} />
|
||||
{t("terminal.auth.sshKey")}
|
||||
@@ -434,7 +570,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
className="pr-10"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && password.trim()) {
|
||||
if (e.key === "Enter" && password.length > 0) {
|
||||
handleConnect();
|
||||
}
|
||||
}}
|
||||
@@ -471,7 +607,10 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border/50 hover:bg-secondary/50",
|
||||
)}
|
||||
onClick={() => setSelectedKeyId(key.id)}
|
||||
onClick={() => {
|
||||
setSelectedIdentityId(null);
|
||||
setSelectedKeyId(key.id);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -508,6 +647,8 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -620,16 +761,6 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{(step === "auth" || (step === "knownhost" && !knownHostInfo)) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => setShowLogs(!showLogs)}
|
||||
>
|
||||
{showLogs ? "Hide logs" : "Show logs"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -693,14 +824,43 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
{t("quickConnect.addKey")}
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={!canProceed} onClick={handleConnect}>
|
||||
{t("terminal.auth.continueSave")}
|
||||
<ChevronDown size={14} className="ml-2" />
|
||||
</Button>
|
||||
<Dropdown open={saveOptionsOpen} onOpenChange={setSaveOptionsOpen}>
|
||||
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
|
||||
<Button
|
||||
disabled={!canProceed}
|
||||
onClick={() => handleConnect(false)}
|
||||
className="rounded-r-none bg-transparent hover:bg-white/10 shadow-none"
|
||||
>
|
||||
{t("common.continue")}
|
||||
</Button>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
disabled={!canProceed}
|
||||
aria-label={t("terminal.auth.continueSave")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={saveOptionsOpen}
|
||||
className="px-2 rounded-l-none bg-transparent hover:bg-white/10 border-l border-primary-foreground/20 shadow-none"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
</div>
|
||||
<DropdownContent className="w-44 p-1" align="end" side="top">
|
||||
<button
|
||||
className="w-full px-3 py-2 text-sm text-left hover:bg-secondary rounded-md"
|
||||
onClick={() => handleConnect(true)}
|
||||
disabled={!canProceed}
|
||||
>
|
||||
{t("terminal.auth.continueSave")}
|
||||
</button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={handleContinue} disabled={!canProceed}>
|
||||
{t("common.continue")}
|
||||
{step === "protocol" && canUseSelectedIdentity
|
||||
? t("quickConnect.identity.connect")
|
||||
: t("common.continue")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,10 +9,10 @@ import {
|
||||
} from "lucide-react";
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Host, TerminalSession, Workspace } from "../types";
|
||||
import { Host, TerminalSession, TerminalSettings, Workspace } from "../types";
|
||||
import { KeyBinding } from "../domain/models";
|
||||
import { matchesSearchQuery } from "../lib/searchMatcher";
|
||||
import { useDiscoveredShells, getShellIconPath, isMonochromeShellIcon } from "../lib/useDiscoveredShells";
|
||||
import { buildQuickSwitcherShells, useDiscoveredShells, getShellIconPath, isMonochromeShellIcon } from "../lib/useDiscoveredShells";
|
||||
|
||||
type QuickSwitcherItem = {
|
||||
type: "host" | "tab" | "workspace" | "action" | "shell";
|
||||
@@ -73,6 +73,7 @@ interface QuickSwitcherProps {
|
||||
onCreateWorkspace?: () => void;
|
||||
keyBindings?: KeyBinding[];
|
||||
showSftpTab: boolean;
|
||||
terminalSettings?: Pick<TerminalSettings, "localShell" | "localShellArgs">;
|
||||
}
|
||||
|
||||
const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
@@ -89,19 +90,27 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
onCreateWorkspace,
|
||||
keyBindings,
|
||||
showSftpTab,
|
||||
terminalSettings,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
const quickSwitcherShells = useMemo(() => (
|
||||
buildQuickSwitcherShells(
|
||||
discoveredShells,
|
||||
terminalSettings?.localShell ?? "",
|
||||
terminalSettings?.localShellArgs,
|
||||
)
|
||||
), [discoveredShells, terminalSettings?.localShell, terminalSettings?.localShellArgs]);
|
||||
|
||||
const filteredShells = useMemo(() => {
|
||||
const list = !query.trim()
|
||||
? discoveredShells
|
||||
: discoveredShells.filter(
|
||||
(s) => matchesSearchQuery(query, s.name, s.id)
|
||||
? quickSwitcherShells
|
||||
: quickSwitcherShells.filter(
|
||||
(s) => matchesSearchQuery(query, s.name, s.id, s.command)
|
||||
);
|
||||
// Default shell first
|
||||
return [...list].sort((a, b) => (a.isDefault === b.isDefault ? 0 : a.isDefault ? -1 : 1));
|
||||
}, [discoveredShells, query]);
|
||||
}, [quickSwitcherShells, query]);
|
||||
|
||||
// Get hotkey display strings
|
||||
const getHotkeyLabel = useCallback((actionId: string) => {
|
||||
@@ -270,7 +279,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
}
|
||||
break;
|
||||
case "shell": {
|
||||
const shell = discoveredShells.find(s => s.id === item.id);
|
||||
const shell = quickSwitcherShells.find(s => s.id === item.id);
|
||||
if (shell && onCreateLocalTerminal) {
|
||||
onCreateLocalTerminal({ command: shell.command, args: shell.args, name: shell.name, icon: shell.icon });
|
||||
onClose();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { buildScriptsSidePanelRows } from "./ScriptsSidePanel.tsx";
|
||||
import type { Snippet } from "../types";
|
||||
@@ -12,6 +13,8 @@ const snippet = (overrides: Partial<Snippet>): Snippet => ({
|
||||
order: overrides.order,
|
||||
});
|
||||
|
||||
const source = readFileSync(new URL("./ScriptsSidePanel.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("scripts side panel rows keep manual snippet order inside a package", () => {
|
||||
const rows = buildScriptsSidePanelRows({
|
||||
snippets: [
|
||||
@@ -28,3 +31,7 @@ test("scripts side panel rows keep manual snippet order inside a package", () =>
|
||||
["zulu", "beta", "alpha"],
|
||||
);
|
||||
});
|
||||
|
||||
test("scripts side panel active tabs pair the accent background with its foreground", () => {
|
||||
assert.equal(source.match(/bg-accent text-accent-foreground/g)?.length, 2);
|
||||
});
|
||||
@@ -616,7 +616,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex-1 h-7 rounded-md text-xs',
|
||||
subView === 'library' ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-muted/50',
|
||||
subView === 'library' ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-muted/50',
|
||||
)}
|
||||
onClick={() => setSubView('library')}
|
||||
>
|
||||
@@ -626,7 +626,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex-1 h-7 rounded-md text-xs',
|
||||
subView === 'running' ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-muted/50',
|
||||
subView === 'running' ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-muted/50',
|
||||
)}
|
||||
onClick={() => setSubView('running')}
|
||||
>
|
||||
|
||||
@@ -18,6 +18,9 @@ import { SettingsTabContent } from "./settings/settings-ui";
|
||||
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { LazyLoadBoundary } from "./ui/lazy-load-boundary";
|
||||
import { ExternalMcpApprovalsHost } from "./ai/ExternalMcpApprovalsHost";
|
||||
import { useExternalMcpGrantPersister } from "./ai/useExternalMcpGrantPersister";
|
||||
import { setupMcpApprovalBridge } from "../infrastructure/ai/shared/approvalGate";
|
||||
|
||||
const LazySettingsApplicationTab = lazy(() => import("./SettingsApplicationTab"));
|
||||
const LazySettingsAppearanceTab = lazy(() => import("./settings/tabs/SettingsAppearanceTab"));
|
||||
@@ -306,6 +309,12 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
notifyRendererReady();
|
||||
}, [notifyRendererReady]);
|
||||
|
||||
useEffect(() => {
|
||||
return setupMcpApprovalBridge();
|
||||
}, []);
|
||||
|
||||
useExternalMcpGrantPersister();
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onWindowCommandCloseRequested(() => {
|
||||
void closeSettingsWindow();
|
||||
@@ -327,6 +336,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
}, [closeSettingsWindow]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="settings-window h-screen flex flex-col bg-background text-foreground font-sans">
|
||||
<div className="shrink-0 border-b border-border app-drag">
|
||||
<div className="flex items-center justify-between px-4 pt-3">
|
||||
@@ -548,6 +558,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setSshDebugLogsEnabled={settings.setSshDebugLogsEnabled}
|
||||
sshDeepLinkEnabled={settings.sshDeepLinkEnabled}
|
||||
setSshDeepLinkEnabled={settings.setSshDeepLinkEnabled}
|
||||
jmsDeepLinkEnabled={settings.jmsDeepLinkEnabled}
|
||||
setJmsDeepLinkEnabled={settings.setJmsDeepLinkEnabled}
|
||||
restorePreviousSession={settings.restorePreviousSession}
|
||||
setRestorePreviousSession={settings.setRestorePreviousSession}
|
||||
restoreTerminalCwd={settings.restoreTerminalCwd}
|
||||
@@ -556,6 +568,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setToggleWindowHotkey={settings.setToggleWindowHotkey}
|
||||
closeToTray={settings.closeToTray}
|
||||
setCloseToTray={settings.setCloseToTray}
|
||||
httpNetworkProxy={settings.httpNetworkProxy}
|
||||
setHttpNetworkProxy={settings.setHttpNetworkProxy}
|
||||
hotkeyRegistrationError={settings.hotkeyRegistrationError}
|
||||
globalHotkeyEnabled={settings.globalHotkeyEnabled}
|
||||
setGlobalHotkeyEnabled={settings.setGlobalHotkeyEnabled}
|
||||
@@ -572,6 +586,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
<ExternalMcpApprovalsHost />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -53,10 +53,13 @@ import {
|
||||
shouldResetSftpSidePanelSourceSession,
|
||||
shouldSkipSftpSidePanelAutoConnect,
|
||||
} from "./sftp/sftpSidePanelAutoConnect";
|
||||
import { listSftpConnectedHosts, sftpPickerSessionsEqual } from "../domain/sftpConnectedHosts";
|
||||
import type { TerminalSession } from "../domain/models";
|
||||
|
||||
interface SftpSidePanelProps {
|
||||
hosts: Host[];
|
||||
writableHosts?: Host[];
|
||||
sessions?: TerminalSession[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
knownHosts?: KnownHost[];
|
||||
@@ -100,6 +103,7 @@ interface SftpSidePanelProps {
|
||||
const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
hosts,
|
||||
writableHosts,
|
||||
sessions = [],
|
||||
keys,
|
||||
identities,
|
||||
knownHosts = [],
|
||||
@@ -133,6 +137,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const hostWriteSource = writableHosts ?? hosts;
|
||||
const connectedHosts = useMemo(() => {
|
||||
const hostsById = new Map<string, Host>(
|
||||
hosts.map((host) => [host.id, host]),
|
||||
);
|
||||
return listSftpConnectedHosts(sessions, hostsById);
|
||||
}, [hosts, sessions]);
|
||||
|
||||
const fileWatchHandlers = useMemo(() => ({
|
||||
onFileWatchSynced: (payload: { remotePath: string }) => {
|
||||
@@ -480,6 +490,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
<SftpSidePanelInteractiveBody
|
||||
hosts={hosts}
|
||||
hostWriteSource={hostWriteSource}
|
||||
connectedHosts={connectedHosts}
|
||||
updateHosts={updateHosts}
|
||||
sftp={sftp}
|
||||
sftpRef={sftpRef}
|
||||
@@ -522,6 +533,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
type SftpSidePanelInteractiveBodyProps = {
|
||||
hosts: Host[];
|
||||
hostWriteSource: Host[];
|
||||
connectedHosts: import("../domain/sftpConnectedHosts").SftpConnectedHostEntry[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
sftp: ReturnType<typeof useSftpState>;
|
||||
sftpRef: MutableRefObject<ReturnType<typeof useSftpState>>;
|
||||
@@ -561,6 +573,7 @@ type SftpSidePanelInteractiveBodyProps = {
|
||||
const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps> = ({
|
||||
hosts,
|
||||
hostWriteSource,
|
||||
connectedHosts,
|
||||
updateHosts,
|
||||
sftp,
|
||||
sftpRef,
|
||||
@@ -1051,6 +1064,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
return (
|
||||
<SftpContextProvider
|
||||
hosts={hosts}
|
||||
connectedHosts={connectedHosts}
|
||||
writableHosts={hostWriteSource}
|
||||
updateHosts={updateHosts}
|
||||
draggedFiles={draggedFiles}
|
||||
@@ -1138,6 +1152,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
{renderOverlays && (
|
||||
<SftpOverlays
|
||||
hosts={hosts}
|
||||
connectedHosts={connectedHosts}
|
||||
sftp={sftp}
|
||||
visibleTransfers={visibleTransfers}
|
||||
showTransferQueue={false}
|
||||
@@ -1186,6 +1201,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps): boolean =>
|
||||
prev.hosts === next.hosts &&
|
||||
prev.writableHosts === next.writableHosts &&
|
||||
sftpPickerSessionsEqual(prev.sessions, next.sessions) &&
|
||||
prev.keys === next.keys &&
|
||||
prev.identities === next.identities &&
|
||||
prev.knownHosts === next.knownHosts &&
|
||||
|
||||
@@ -51,3 +51,78 @@ test("SftpView re-renders when host-key verification setting changes", async ()
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("SftpView ignores session title-only updates for memoization", async () => {
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
},
|
||||
});
|
||||
const { sftpViewAreEqual } = await import("./SftpView.tsx");
|
||||
|
||||
const baseProps = {
|
||||
hosts: [],
|
||||
sessions: [{
|
||||
id: "s1",
|
||||
hostId: "h1",
|
||||
hostLabel: "Host",
|
||||
username: "alice",
|
||||
hostname: "h1.example.test",
|
||||
protocol: "ssh",
|
||||
status: "connected",
|
||||
dynamicTitle: "old",
|
||||
}],
|
||||
keys: [],
|
||||
identities: [],
|
||||
knownHosts: [],
|
||||
groupConfigs: [],
|
||||
proxyProfiles: [],
|
||||
updateHosts: () => {},
|
||||
onAddKnownHost: () => {},
|
||||
sftpDefaultViewMode: "list",
|
||||
sftpDoubleClickBehavior: "open",
|
||||
sftpAutoSync: false,
|
||||
sftpShowHiddenFiles: false,
|
||||
sftpUseCompressedUpload: false,
|
||||
hotkeyScheme: {},
|
||||
keyBindings: [],
|
||||
editorWordWrap: false,
|
||||
setEditorWordWrap: () => {},
|
||||
terminalSettings: {
|
||||
verifyHostKeys: true,
|
||||
keepaliveInterval: 30,
|
||||
keepaliveCountMax: 10,
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
sftpViewAreEqual(
|
||||
baseProps as never,
|
||||
{
|
||||
...baseProps,
|
||||
sessions: [{
|
||||
...baseProps.sessions[0],
|
||||
dynamicTitle: "new title from OSC",
|
||||
}],
|
||||
} as never,
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
sftpViewAreEqual(
|
||||
baseProps as never,
|
||||
{
|
||||
...baseProps,
|
||||
sessions: [{
|
||||
...baseProps.sessions[0],
|
||||
status: "connecting",
|
||||
}],
|
||||
} as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
+22
-3
@@ -20,7 +20,8 @@ import { useIsSftpActive } from "../application/state/activeTabStore";
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
import { useSftpBackend } from "../application/state/useSftpBackend";
|
||||
import { getParentPath, isConcreteTransferTargetPath } from "../application/state/sftp/utils";
|
||||
import { HotkeyScheme, KeyBinding } from "../domain/models";
|
||||
import { HotkeyScheme, KeyBinding, TerminalSession } from "../domain/models";
|
||||
import { listSftpConnectedHosts, sftpPickerSessionsEqual } from "../domain/sftpConnectedHosts";
|
||||
import { logger } from "../lib/logger";
|
||||
import { useRenderTracker } from "../lib/useRenderTracker";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -52,6 +53,9 @@ import { keepOnlyActivePaneSelections, keepOnlyPaneSelections } from "./sftp/hoo
|
||||
// Main SftpView component
|
||||
interface SftpViewProps {
|
||||
hosts: Host[];
|
||||
/** Vault-persisted hosts only; used for writes so ephemeral deep-link hosts stay out of vault. */
|
||||
writableHosts?: Host[];
|
||||
sessions?: TerminalSession[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
knownHosts?: KnownHost[];
|
||||
@@ -73,6 +77,8 @@ interface SftpViewProps {
|
||||
|
||||
const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
hosts,
|
||||
writableHosts,
|
||||
sessions = [],
|
||||
keys,
|
||||
identities,
|
||||
knownHosts = [],
|
||||
@@ -129,6 +135,15 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
});
|
||||
}, [hosts, groupConfigs, proxyProfiles]);
|
||||
|
||||
const hostWriteSource = writableHosts ?? hosts;
|
||||
|
||||
const connectedHosts = useMemo(() => {
|
||||
const hostsById = new Map<string, Host>(
|
||||
effectiveHosts.map((host) => [host.id, host]),
|
||||
);
|
||||
return listSftpConnectedHosts(sessions, hostsById);
|
||||
}, [effectiveHosts, sessions]);
|
||||
|
||||
const sftp = useSftpState(effectiveHosts, keys, identities, sftpOptions);
|
||||
|
||||
// Get backend helpers for file downloads and local filesystem writes.
|
||||
@@ -429,7 +444,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
return (
|
||||
<SftpContextProvider
|
||||
hosts={effectiveHosts}
|
||||
writableHosts={hosts}
|
||||
connectedHosts={connectedHosts}
|
||||
writableHosts={hostWriteSource}
|
||||
updateHosts={updateHosts}
|
||||
draggedFiles={draggedFiles}
|
||||
dragCallbacks={dragCallbacks}
|
||||
@@ -439,7 +455,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={cn(
|
||||
"absolute inset-0 min-h-0 flex flex-col",
|
||||
"absolute inset-0 min-h-0 flex flex-col bg-background",
|
||||
isActive ? "z-20" : "",
|
||||
)}
|
||||
style={containerStyle}
|
||||
@@ -571,6 +587,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
|
||||
<SftpOverlays
|
||||
hosts={effectiveHosts}
|
||||
connectedHosts={connectedHosts}
|
||||
sftp={sftp}
|
||||
visibleTransfers={visibleTransfers}
|
||||
canRevealTransferTarget={canRevealTransferTarget}
|
||||
@@ -616,6 +633,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
|
||||
export const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
|
||||
prev.hosts === next.hosts &&
|
||||
prev.writableHosts === next.writableHosts &&
|
||||
sftpPickerSessionsEqual(prev.sessions, next.sessions) &&
|
||||
prev.keys === next.keys &&
|
||||
prev.identities === next.identities &&
|
||||
prev.knownHosts === next.knownHosts &&
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
vaultPrimaryIconClass,
|
||||
vaultSnippetIconClass,
|
||||
} from './vault/VaultEntityIcon';
|
||||
import { VaultDeleteConfirmDialog } from './vault/VaultDeleteConfirmDialog';
|
||||
import {
|
||||
clearVaultDropIndicator as clearSnippetDropIndicator,
|
||||
getVaultDropIntent as getPackageDropIntent,
|
||||
@@ -498,6 +499,11 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
const [draggingPackagePath, setDraggingPackagePath] = useState<string | null>(null);
|
||||
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
|
||||
const [selectedSnippetIds, setSelectedSnippetIds] = useState<Set<string>>(new Set());
|
||||
const [deleteTarget, setDeleteTarget] = useState<{
|
||||
type: 'snippet' | 'package';
|
||||
id: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
const [isSnippetImportDialogOpen, setIsSnippetImportDialogOpen] = useState(false);
|
||||
const [pendingImport, setPendingImport] = useState<PendingSnippetImport | null>(null);
|
||||
const prepareGridLayoutAnimation = useVaultGridLayoutAnimation(listRef);
|
||||
@@ -1189,7 +1195,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
setIsPackageDialogOpen(false);
|
||||
};
|
||||
|
||||
const deletePackage = (path: string) => {
|
||||
const performDeletePackage = (path: string) => {
|
||||
const keep = packages.filter((p) => !(p === path || p.startsWith(path + '/')));
|
||||
|
||||
const updatedSnippets = snippets.map((s) => {
|
||||
@@ -1209,6 +1215,44 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const requestDeletePackage = (path: string) => {
|
||||
setDeleteTarget({
|
||||
type: 'package',
|
||||
id: path,
|
||||
name: path,
|
||||
});
|
||||
};
|
||||
|
||||
const requestDeleteSnippet = (id: string) => {
|
||||
const snippet = snippets.find((item) => item.id === id);
|
||||
setDeleteTarget({
|
||||
type: 'snippet',
|
||||
id,
|
||||
name: snippet?.label || t('snippets.panel.editTitle'),
|
||||
});
|
||||
};
|
||||
|
||||
const confirmDeleteTarget = () => {
|
||||
if (!deleteTarget) return;
|
||||
|
||||
if (deleteTarget.type === 'package') {
|
||||
performDeletePackage(deleteTarget.id);
|
||||
} else {
|
||||
onDelete(deleteTarget.id);
|
||||
setSelectedSnippetIds((prev) => {
|
||||
if (!prev.has(deleteTarget.id)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(deleteTarget.id);
|
||||
return next;
|
||||
});
|
||||
if (editingSnippet.id === deleteTarget.id) {
|
||||
handleClosePanel();
|
||||
}
|
||||
}
|
||||
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
const movePackage = (source: string, target: string | null) => {
|
||||
const name = source.split('/').pop() || '';
|
||||
const isAbsolute = source.startsWith('/');
|
||||
@@ -1581,7 +1625,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
t={t}
|
||||
handleClosePanel={handleClosePanel}
|
||||
editingSnippet={editingSnippet}
|
||||
onDelete={onDelete}
|
||||
onDelete={requestDeleteSnippet}
|
||||
handleSave={handleSave}
|
||||
handleSaveAndRun={handleSaveAndRun}
|
||||
setEditingSnippet={setEditingSnippet}
|
||||
@@ -1869,7 +1913,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
<Download className="mr-2 h-4 w-4" /> {t('snippets.export.package')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => openRenameDialog(pkg.path)}>{t('common.rename')}</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={() => deletePackage(pkg.path)}>{t('action.delete')}</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={() => requestDeletePackage(pkg.path)}>{t('action.delete')}</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
@@ -2007,7 +2051,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
<ContextMenuItem onClick={() => exportSingleSnippet(snippet)}>
|
||||
<Download className="mr-2 h-4 w-4" /> {t('snippets.export.snippet')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={() => onDelete(snippet.id)}>
|
||||
<ContextMenuItem className="text-destructive" onClick={() => requestDeleteSnippet(snippet.id)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
@@ -2071,6 +2115,22 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
<VaultDeleteConfirmDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
title={t('vault.deleteConfirm.title', {
|
||||
name: deleteTarget?.name ?? '',
|
||||
})}
|
||||
description={
|
||||
deleteTarget?.type === 'package'
|
||||
? t('vault.deleteConfirm.packageDesc')
|
||||
: t('vault.deleteConfirm.desc')
|
||||
}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteTarget(null);
|
||||
}}
|
||||
onConfirm={confirmDeleteTarget}
|
||||
/>
|
||||
|
||||
{renderRightPanel()}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -156,7 +156,6 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
const id = editingSnippet.id;
|
||||
if (!id) return;
|
||||
onDelete(id);
|
||||
handleClosePanel();
|
||||
}}
|
||||
aria-label={t('common.delete')}
|
||||
>
|
||||
|
||||
+167
-33
@@ -42,6 +42,7 @@ import {
|
||||
type TerminalContextReader,
|
||||
} from "../domain/terminalContextRead";
|
||||
import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
|
||||
import { resolveHostSshConnectionTimeouts } from "../domain/sshConnectionTimeouts";
|
||||
import { supportsZmodemTerminalDragDrop } from "../lib/zmodemDragDrop";
|
||||
import { resolveHostAuth } from "../domain/sshAuth";
|
||||
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
@@ -71,7 +72,6 @@ import {
|
||||
import { useCustomThemes } from "../application/state/customThemeStore";
|
||||
|
||||
import { TerminalConnectionDialog } from "./terminal/TerminalConnectionDialog";
|
||||
import { SSH_TCP_CONNECT_TIMEOUT_MS } from "./terminal/connectionTimeouts";
|
||||
import { HostKeyInfo } from "./terminal/TerminalHostKeyVerification";
|
||||
import { createKnownHostFromHostKeyInfo, toHostKeyInfo } from "./terminal/hostKeyVerification";
|
||||
import { TerminalToolbar } from "./terminal/TerminalToolbar";
|
||||
@@ -155,6 +155,7 @@ import {
|
||||
flushTerminalSessionFlowAck,
|
||||
} from "./terminal/runtime/terminalFlowAckBuffer";
|
||||
import { releaseTerminalFlowBeforeHibernate } from "./terminal/runtime/terminalSessionAttachment";
|
||||
import { flushPendingTerminalWritesBeforeHibernate } from "./terminal/runtime/terminalUnfocusedRepaint";
|
||||
import {
|
||||
isTerminalFileTransferActive,
|
||||
resolveHibernateKeepRendererCount,
|
||||
@@ -187,6 +188,9 @@ import {
|
||||
} from "./terminal/terminalHelpers";
|
||||
import { terminalPropsAreEqual } from "./terminal/terminalMemo";
|
||||
|
||||
const HIBERNATE_RETRY_AFTER_DRAIN_MS = 250;
|
||||
const EMPTY_CHAIN_HOSTS: Host[] = [];
|
||||
|
||||
const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
host,
|
||||
keys,
|
||||
@@ -195,7 +199,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
snippetPackages = [],
|
||||
compactToolbar = false,
|
||||
lineTimestampsAvailable = true,
|
||||
chainHosts = [],
|
||||
chainHosts = EMPTY_CHAIN_HOSTS,
|
||||
appearanceTheme,
|
||||
knownHosts = [],
|
||||
isVisible,
|
||||
@@ -278,7 +282,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
deferTerminalResizeRef.current = deferTerminalResize;
|
||||
|
||||
// Initial TCP dial timeout. Authentication prompts use their own backend timeout.
|
||||
const CONNECTION_TIMEOUT = SSH_TCP_CONNECT_TIMEOUT_MS;
|
||||
const hostConnectionTimeouts = resolveHostSshConnectionTimeouts(host);
|
||||
const CONNECTION_TIMEOUT = hostConnectionTimeouts.tcpConnectTimeoutSeconds * 1000;
|
||||
const { t } = useI18n();
|
||||
const connectScriptsConsumedRef = useRef(false);
|
||||
const connectScriptsCompletedIdsRef = useRef(new Set<string>());
|
||||
@@ -358,6 +363,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const hibernateContextScrollbackSnapshotRef = useRef("");
|
||||
const hibernatePendingBufferRef = useRef("");
|
||||
const hibernateAlternateScreenRef = useRef(false);
|
||||
const hibernateRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const fullHibernateRuntimeRef = useRef<(() => Promise<boolean>) | null>(null);
|
||||
const wakeInProgressRef = useRef(false);
|
||||
const sessionRef = useRef<string | null>(null);
|
||||
const isBootActiveRef = useRef(false);
|
||||
@@ -389,12 +396,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const terminalSettingsRef = useRef(terminalSettings);
|
||||
terminalSettingsRef.current = terminalSettings;
|
||||
const isSearchOpenRef = useRef(false);
|
||||
const hibernateFileTransferActiveRef = useRef(false);
|
||||
const handleUpdateHostFromTerminal = useCallback((hostUpdate: TerminalHostUpdate) => {
|
||||
onUpdateHost?.(hostUpdate as Host);
|
||||
}, [onUpdateHost]);
|
||||
onTerminalDataCaptureRef.current = onTerminalDataCapture;
|
||||
const isVisibleRef = useRef(isVisible);
|
||||
isVisibleRef.current = isVisible;
|
||||
const isRendererActive = isVisible || !resolveTerminalHibernateEnabled(terminalSettings);
|
||||
const isRendererActiveRef = useRef(isRendererActive);
|
||||
isRendererActiveRef.current = isRendererActive;
|
||||
const pendingOutputScrollRef = useRef(false);
|
||||
const lastFittedSizeRef = useRef<{ width: number; height: number } | null>(null);
|
||||
const fontWeightFixupDoneRef = useRef(false);
|
||||
@@ -599,6 +611,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
currentHop: number;
|
||||
totalHops: number;
|
||||
currentHostLabel: string;
|
||||
connectionPhase: string;
|
||||
} | null>(null);
|
||||
const [isConnectionAwaitingUserInput, setIsConnectionAwaitingUserInput] = useState(false);
|
||||
const [isConnectionPastTcpDial, setIsConnectionPastTcpDial] = useState(false);
|
||||
@@ -649,6 +662,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
handleFindPrevious,
|
||||
handleCloseSearch,
|
||||
} = terminalSearch;
|
||||
isSearchOpenRef.current = isSearchOpen;
|
||||
|
||||
const prepareProgrammaticSudoInput = useCallback((data: string): string => {
|
||||
if (
|
||||
@@ -1245,6 +1259,32 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
});
|
||||
}, [onSessionExit, scheduleAutoReconnect, sessionId, terminalBackend]);
|
||||
|
||||
const clearHibernateRetry = useCallback(() => {
|
||||
if (hibernateRetryTimerRef.current === null) return;
|
||||
clearTimeout(hibernateRetryTimerRef.current);
|
||||
hibernateRetryTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const scheduleHibernateRetry = useCallback(() => {
|
||||
if (hibernateRetryTimerRef.current !== null) return;
|
||||
hibernateRetryTimerRef.current = setTimeout(() => {
|
||||
hibernateRetryTimerRef.current = null;
|
||||
if (
|
||||
isVisibleRef.current
|
||||
|| hibernatedRef.current
|
||||
|| softHiddenRef.current
|
||||
|| !hasRuntimeRef.current
|
||||
|| statusRef.current !== "connected"
|
||||
|| isSearchOpenRef.current
|
||||
|| hibernateFileTransferActiveRef.current
|
||||
|| !resolveTerminalHibernateEnabled(terminalSettingsRef.current)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void fullHibernateRuntimeRef.current?.();
|
||||
}, HIBERNATE_RETRY_AFTER_DRAIN_MS);
|
||||
}, []);
|
||||
|
||||
const applyHibernateSnapshot = useCallback((
|
||||
snapshot: {
|
||||
snapshot: string;
|
||||
@@ -1265,30 +1305,69 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
hibernateAlternateScreenRef.current = snapshot.alternateScreen;
|
||||
}, []);
|
||||
|
||||
const fullHibernateRuntime = useCallback(async () => {
|
||||
if (hibernatedRef.current || softHiddenRef.current || !termRef.current || !serializeAddonRef.current) return;
|
||||
const shouldSkipHibernateForActiveAlternateScreen = useCallback((term: XTerm): boolean => {
|
||||
if (
|
||||
!isTerminalAlternateScreenActive(term)
|
||||
|| !resolveHibernateSkipAltScreen(terminalSettings)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
logger.info("[Terminal] Skipping hibernate: alternate screen active", { sessionId });
|
||||
return true;
|
||||
}, [sessionId, terminalSettings]);
|
||||
|
||||
const fullHibernateRuntime = useCallback(async (): Promise<boolean> => {
|
||||
if (hibernatedRef.current || softHiddenRef.current || !termRef.current || !serializeAddonRef.current) return false;
|
||||
clearHibernateRetry();
|
||||
const backendId = sessionRef.current;
|
||||
if (!backendId) return;
|
||||
if (!backendId) return false;
|
||||
const term = termRef.current;
|
||||
const serializeAddon = serializeAddonRef.current;
|
||||
const canFinishHibernate = () => (
|
||||
!isVisibleRef.current
|
||||
&& !hibernatedRef.current
|
||||
&& !softHiddenRef.current
|
||||
&& hasRuntimeRef.current
|
||||
&& statusRef.current === "connected"
|
||||
&& !isSearchOpenRef.current
|
||||
&& !hibernateFileTransferActiveRef.current
|
||||
&& resolveTerminalHibernateEnabled(terminalSettingsRef.current)
|
||||
&& termRef.current === term
|
||||
&& sessionRef.current === backendId
|
||||
&& serializeAddonRef.current === serializeAddon
|
||||
);
|
||||
|
||||
if (!canFinishHibernate()) return false;
|
||||
|
||||
terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
softHiddenRef.current = false;
|
||||
const flushedBeforeHibernate = await flushPendingTerminalWritesBeforeHibernate(term);
|
||||
if (!flushedBeforeHibernate) {
|
||||
logger.info("[Terminal] Skipping hibernate: terminal output is still draining", { sessionId });
|
||||
scheduleHibernateRetry();
|
||||
return false;
|
||||
}
|
||||
if (!canFinishHibernate()) return false;
|
||||
if (shouldSkipHibernateForActiveAlternateScreen(term)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const snapshot = await serializeTerminalForHibernate(
|
||||
termRef.current,
|
||||
serializeAddonRef.current,
|
||||
{ preferWasm: resolveHibernatePreferWasmSerialize(terminalSettings) },
|
||||
term,
|
||||
serializeAddon,
|
||||
{ preferWasm: resolveHibernatePreferWasmSerialize(terminalSettingsRef.current) },
|
||||
);
|
||||
|
||||
if (!canFinishHibernate()) return false;
|
||||
|
||||
if (snapshot.alternateScreen && snapshot.snapshot.length === 0) {
|
||||
logger.info("[Terminal] Skipping hibernate: alternate screen snapshot unavailable", { sessionId });
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
applyHibernateSnapshot(snapshot);
|
||||
isBootActiveRef.current = false;
|
||||
if (termRef.current) {
|
||||
releaseTerminalFlowBeforeHibernate(terminalBackend, termRef.current, backendId);
|
||||
}
|
||||
releaseTerminalFlowBeforeHibernate(terminalBackend, term, backendId);
|
||||
disposeDataRef.current?.();
|
||||
disposeDataRef.current = null;
|
||||
disposeExitRef.current?.();
|
||||
@@ -1303,13 +1382,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
scrollbackChars: hibernateScrollbackSnapshotRef.current.length,
|
||||
alternateScreen: snapshot.alternateScreen,
|
||||
});
|
||||
return true;
|
||||
}, [
|
||||
applyHibernateSnapshot,
|
||||
beginHibernatedSessionListeners,
|
||||
clearHibernateRetry,
|
||||
scheduleHibernateRetry,
|
||||
sessionId,
|
||||
shouldSkipHibernateForActiveAlternateScreen,
|
||||
terminalBackend,
|
||||
terminalSettings,
|
||||
]);
|
||||
fullHibernateRuntimeRef.current = fullHibernateRuntime;
|
||||
|
||||
const hideRuntimeOnly = useCallback(() => {
|
||||
if (hibernatedRef.current || softHiddenRef.current || !hasRuntimeRef.current) return;
|
||||
@@ -1322,11 +1405,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const hibernateRuntime = useCallback(() => {
|
||||
if (hibernatedRef.current || softHiddenRef.current || !termRef.current) return;
|
||||
|
||||
if (
|
||||
isTerminalAlternateScreenActive(termRef.current)
|
||||
&& resolveHibernateSkipAltScreen(terminalSettings)
|
||||
) {
|
||||
logger.info("[Terminal] Skipping hibernate: alternate screen active", { sessionId });
|
||||
if (shouldSkipHibernateForActiveAlternateScreen(termRef.current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1344,7 +1423,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
|
||||
void fullHibernateRuntime();
|
||||
}, [fullHibernateRuntime, hideRuntimeOnly, sessionId, terminalSettings]);
|
||||
}, [fullHibernateRuntime, hideRuntimeOnly, sessionId, shouldSkipHibernateForActiveAlternateScreen, terminalSettings]);
|
||||
|
||||
const terminalRuntimeRefs = useMemo<TerminalRuntimeRefs>(() => ({
|
||||
xtermRuntimeRef,
|
||||
@@ -1362,6 +1441,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
retryTokenRef.current = null;
|
||||
restoreCwdIntentRef.current = null;
|
||||
suppressHostStartupCommandRef.current = false;
|
||||
clearHibernateRetry();
|
||||
clearAutoReconnect();
|
||||
cleanupSession();
|
||||
disposeRuntimeOnly();
|
||||
@@ -1385,7 +1465,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
terminalBackend,
|
||||
serialConfig,
|
||||
telnetLocalEchoRef,
|
||||
isVisibleRef,
|
||||
isVisibleRef: isRendererActiveRef,
|
||||
isBootActiveRef,
|
||||
pendingOutputScrollRef,
|
||||
sessionRef,
|
||||
@@ -1656,6 +1736,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return registerScreenSnapshotProvider(sessionId, () => {
|
||||
const term = termRef.current;
|
||||
if (!term?.buffer?.active) {
|
||||
// Hibernated terminals: prefer last captured viewport for script sync.
|
||||
const hibernatedViewport =
|
||||
hibernateViewportSnapshotRef.current
|
||||
|| hibernateSnapshotRef.current
|
||||
|| hibernatePendingBufferRef.current;
|
||||
if (hibernatedViewport) {
|
||||
const lines = hibernatedViewport.split("\n");
|
||||
return {
|
||||
rows: Math.max(lines.length, 1),
|
||||
cols: 80,
|
||||
currentRow: Math.max(lines.length - 1, 0),
|
||||
lines,
|
||||
source: "hibernate-viewport",
|
||||
};
|
||||
}
|
||||
return { rows: 24, cols: 80, currentRow: 0, lines: [] };
|
||||
}
|
||||
const buffer = term.buffer.active;
|
||||
@@ -1723,7 +1818,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const safeFit = (options?: { force?: boolean; requireVisible?: boolean; immediate?: boolean; allowHidden?: boolean }) => {
|
||||
const fitAddon = fitAddonRef.current;
|
||||
if (!fitAddon) return;
|
||||
if (!isVisibleRef.current && !options?.allowHidden) {
|
||||
if (!isRendererActiveRef.current && !options?.allowHidden) {
|
||||
lastFittedSizeRef.current = null;
|
||||
return;
|
||||
}
|
||||
@@ -1769,6 +1864,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (term.cols !== dimensions.cols || term.rows !== dimensions.rows) {
|
||||
term.resize(dimensions.cols, dimensions.rows);
|
||||
forceSyncRenderAfterResize(term);
|
||||
} else {
|
||||
// Pixel-only layout changes (opening the SFTP side panel, compose
|
||||
// bar, etc.) shrink the container without changing cols/rows, so
|
||||
// term.onResize — which clears the WebGL atlas — never fires.
|
||||
// Stale atlas glyphs then paint as black squares (#2013).
|
||||
xtermRuntimeRef.current?.clearTextureAtlas();
|
||||
forceSyncRenderAfterResize(term);
|
||||
}
|
||||
|
||||
// Preserve scroll position across resize (superset/Tabby pattern).
|
||||
@@ -2373,7 +2475,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}) ? () => setOsc7SetupOpen(true) : undefined}
|
||||
onUpdateHost={handleUpdateHostFromTerminal}
|
||||
showClose={opts?.showClose}
|
||||
onClose={() => onCloseSession?.(sessionId)}
|
||||
// In a workspace, the toolbar X means "return this pane to its own tab"
|
||||
// (detach), not destroy the session. Context-menu Close still kills it.
|
||||
onClose={() => {
|
||||
if (inWorkspace && onDetach) {
|
||||
onDetach();
|
||||
return;
|
||||
}
|
||||
onCloseSession?.(sessionId);
|
||||
}}
|
||||
closeRestoresTab={Boolean(inWorkspace && onDetach)}
|
||||
isSearchOpen={isSearchOpen}
|
||||
onToggleSearch={handleToggleSearch}
|
||||
showLogButton
|
||||
@@ -2420,6 +2531,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isSessionLogging,
|
||||
isWorkspaceComposeBarOpen,
|
||||
onCloseSession,
|
||||
onDetach,
|
||||
onOpenScripts,
|
||||
onOpenHistory,
|
||||
onOpenTheme,
|
||||
@@ -2512,15 +2624,33 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
safeFitRef.current({ force: true });
|
||||
}, [sessionId]);
|
||||
|
||||
const resumeRendererAfterCancelledHibernateUpgrade = useCallback(() => {
|
||||
if (hibernatedRef.current || softHiddenRef.current || !hasRuntimeRef.current) return;
|
||||
xtermRuntimeRef.current?.ensureWebglRenderer();
|
||||
xtermRuntimeRef.current?.clearTextureAtlas();
|
||||
safeFitRef.current({ force: true });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return terminalHiddenRendererStore.subscribe(() => {
|
||||
if (!terminalHiddenRendererStore.consumeEvictionRequest(sessionId)) return;
|
||||
if (!softHiddenRef.current || hibernatedRef.current) return;
|
||||
softHiddenRef.current = false;
|
||||
terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
void fullHibernateRuntime();
|
||||
// Resume the soft-hidden renderer before the asynchronous full-hibernate
|
||||
// upgrade. If the pane is revealed while the upgrade is draining output
|
||||
// or serializing, it then already has a live renderer instead of waiting
|
||||
// on the upgrade promise to settle.
|
||||
wakeSoftHiddenRuntime();
|
||||
void fullHibernateRuntime().then(
|
||||
(completed) => {
|
||||
if (!completed) resumeRendererAfterCancelledHibernateUpgrade();
|
||||
},
|
||||
(error) => {
|
||||
logger.error("[Terminal] Failed to upgrade soft-hidden runtime to hibernate", { sessionId, error });
|
||||
resumeRendererAfterCancelledHibernateUpgrade();
|
||||
},
|
||||
);
|
||||
});
|
||||
}, [fullHibernateRuntime, sessionId]);
|
||||
}, [fullHibernateRuntime, resumeRendererAfterCancelledHibernateUpgrade, sessionId, wakeSoftHiddenRuntime]);
|
||||
|
||||
const wakeFromHibernateRuntime = useCallback((
|
||||
getPayload: () => TerminalHibernateWakePayload,
|
||||
@@ -2621,6 +2751,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return true;
|
||||
};
|
||||
|
||||
const hibernateFileTransferActive = isTerminalFileTransferActive({
|
||||
zmodemActive: zmodem.active,
|
||||
ymodemInProgress,
|
||||
isDraggingOver,
|
||||
});
|
||||
hibernateFileTransferActiveRef.current = hibernateFileTransferActive;
|
||||
|
||||
useTerminalHibernateEffect({
|
||||
sessionId,
|
||||
isVisible,
|
||||
@@ -2630,11 +2767,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isSearchOpen,
|
||||
hibernateEnabled: resolveTerminalHibernateEnabled(terminalSettings),
|
||||
hibernateDelayMs: resolveTerminalHibernateDelayMs(terminalSettings),
|
||||
fileTransferActive: isTerminalFileTransferActive({
|
||||
zmodemActive: zmodem.active,
|
||||
ymodemInProgress,
|
||||
isDraggingOver,
|
||||
}),
|
||||
fileTransferActive: hibernateFileTransferActive,
|
||||
hibernatedRef,
|
||||
softHiddenRef,
|
||||
hibernatePendingBufferRef,
|
||||
@@ -2651,7 +2784,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onWake: wakeFromHibernateRuntime,
|
||||
});
|
||||
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, prepareRestoredReconnect, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, prepareRestoredReconnect, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -2662,6 +2795,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onResume={() => { void resumeScriptRun(activeScriptRun.runId); }}
|
||||
onStop={() => { void stopScriptRun(activeScriptRun.runId); }}
|
||||
onDismiss={dismissScriptOverlay}
|
||||
compactTopChrome={terminalSettings?.showHostInfoBar === false}
|
||||
/>
|
||||
) : null, selectionOverlayPosition, sessionDisplayName, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />
|
||||
<ScriptSaveRecordingDialog
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
getTerminalEncodingOptions,
|
||||
resolveTerminalEncodingSelectValue,
|
||||
} from "./TerminalEncodingSelect.tsx";
|
||||
|
||||
test("terminal encoding selector advertises the supported encodings", () => {
|
||||
assert.deepEqual(getTerminalEncodingOptions(), ["UTF-8", "GB18030"]);
|
||||
});
|
||||
|
||||
test("terminal encoding selector preserves an existing custom encoding", () => {
|
||||
assert.deepEqual(getTerminalEncodingOptions("Shift_JIS"), ["Shift_JIS", "UTF-8", "GB18030"]);
|
||||
});
|
||||
|
||||
test("terminal encoding selector normalizes supported values to their displayed option", () => {
|
||||
assert.equal(resolveTerminalEncodingSelectValue("utf-8"), "UTF-8");
|
||||
assert.equal(resolveTerminalEncodingSelectValue("gb18030"), "GB18030");
|
||||
assert.equal(resolveTerminalEncodingSelectValue("Shift_JIS"), "Shift_JIS");
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
|
||||
interface TerminalEncodingSelectProps {
|
||||
value?: string;
|
||||
inheritedValue?: string;
|
||||
onValueChange: (value: string | undefined) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SUPPORTED_ENCODINGS = ["UTF-8", "GB18030"] as const;
|
||||
|
||||
export const resolveTerminalEncodingSelectValue = (value?: string): string => {
|
||||
const normalized = value?.trim() || "UTF-8";
|
||||
return SUPPORTED_ENCODINGS.find(
|
||||
(encoding) => encoding.toLowerCase() === normalized.toLowerCase(),
|
||||
) || normalized;
|
||||
};
|
||||
|
||||
export const getTerminalEncodingOptions = (currentValue?: string): string[] => {
|
||||
const normalizedCurrent = currentValue?.trim();
|
||||
if (!normalizedCurrent) return [...SUPPORTED_ENCODINGS];
|
||||
if (SUPPORTED_ENCODINGS.some((encoding) => encoding.toLowerCase() === normalizedCurrent.toLowerCase())) {
|
||||
return [...SUPPORTED_ENCODINGS];
|
||||
}
|
||||
return [normalizedCurrent, ...SUPPORTED_ENCODINGS];
|
||||
};
|
||||
|
||||
export const TerminalEncodingSelect: React.FC<TerminalEncodingSelectProps> = ({
|
||||
value,
|
||||
inheritedValue,
|
||||
onValueChange,
|
||||
className = "h-10 w-full",
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const effectiveValue = resolveTerminalEncodingSelectValue(value || inheritedValue);
|
||||
const options = getTerminalEncodingOptions(effectiveValue);
|
||||
const fallbackValue = "__fallback__";
|
||||
const selectedValue = value ? resolveTerminalEncodingSelectValue(value) : fallbackValue;
|
||||
const fallbackLabel = inheritedValue
|
||||
? `${t("vault.groups.details.inherited")} (${resolveTerminalEncodingSelectValue(inheritedValue)})`
|
||||
: value
|
||||
? `${t("common.reset")} (UTF-8)`
|
||||
: "UTF-8";
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={selectedValue}
|
||||
onValueChange={(nextValue) => onValueChange(nextValue === fallbackValue ? undefined : nextValue)}
|
||||
>
|
||||
<SelectTrigger className={className}>
|
||||
<SelectValue placeholder={t("terminal.toolbar.encoding")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={fallbackValue}>{fallbackLabel}</SelectItem>
|
||||
{options.map((encoding) => (
|
||||
<SelectItem key={encoding} value={encoding}>
|
||||
{encoding}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
@@ -65,13 +65,13 @@ import {
|
||||
import { ThemeSidePanel } from './terminal/ThemeSidePanel';
|
||||
import { focusTerminalSessionInput } from './terminal/focusTerminalSession';
|
||||
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
|
||||
import { resolveTerminalFontSizeUpdateTarget } from './terminalLayer/terminalFontSizeUpdate';
|
||||
import {
|
||||
AUTO_RUN_SNIPPET_LINE_DELAY_MS,
|
||||
shouldDelayAutoRunSnippetInput,
|
||||
type TerminalBroadcastInputOptions,
|
||||
} from './terminal/terminalHelpers';
|
||||
import { Button } from './ui/button';
|
||||
import { setupMcpApprovalBridge } from '../infrastructure/ai/shared/approvalGate';
|
||||
import { resolveScriptsSidePanelShortcutIntent } from '../application/state/resolveSnippetsShortcutIntent';
|
||||
import { resolveSidePanelToggleIntent } from '../application/state/resolveSidePanelToggleIntent';
|
||||
import { resolveAiSidePanelToggleIntent } from '../application/state/resolveAiSidePanelToggleIntent';
|
||||
@@ -417,7 +417,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
if (sidePanelOpenTabsRef.current.has(tabId)) return;
|
||||
|
||||
const autoOpenTarget = resolveTerminalSidePanelAutoOpen({
|
||||
const sessionAutoOpenTarget = session.autoOpenSidePanel === 'sftp' && sftpAvailable ? 'sftp' : null;
|
||||
const autoOpenTarget = sessionAutoOpenTarget ?? resolveTerminalSidePanelAutoOpen({
|
||||
enabled: terminalSidePanelAutoOpenRef.current,
|
||||
selectedTab: terminalSidePanelAutoOpenTabRef.current,
|
||||
sftpAvailable,
|
||||
@@ -903,24 +904,17 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
const handleTerminalFontSizeChange = useCallback((sessionId: string, nextFontSize: number) => {
|
||||
const session = sessionsRef.current.find((candidate) => candidate.id === sessionId);
|
||||
// Workspace panes keep per-session font size so zooming one split does not
|
||||
// change global defaults or sibling panes (even when they share a host).
|
||||
if (session?.workspaceId) {
|
||||
onUpdateSessionFontSize?.(sessionId, nextFontSize);
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionHost = sessionHostsMapRef.current.get(sessionId);
|
||||
if (!sessionHost) return;
|
||||
|
||||
const rawHost = hostMapRef.current.get(sessionHost.id);
|
||||
const usesGlobalFontSize = sessionHost.protocol === 'local' || sessionHost.id?.startsWith('local-') || !rawHost;
|
||||
if (usesGlobalFontSize) {
|
||||
const rawHost = sessionHost ? hostMapRef.current.get(sessionHost.id) : null;
|
||||
const target = resolveTerminalFontSizeUpdateTarget({ session, sessionHost, rawHost });
|
||||
if (target.kind === 'none') return;
|
||||
if (target.kind === 'session') {
|
||||
onUpdateSessionFontSize?.(sessionId, nextFontSize);
|
||||
} else if (target.kind === 'global') {
|
||||
onUpdateTerminalFontSize?.(nextFontSize);
|
||||
return;
|
||||
} else {
|
||||
onUpdateHost({ ...target.host, fontSize: nextFontSize, fontSizeOverride: true });
|
||||
}
|
||||
|
||||
onUpdateHost({ ...rawHost, fontSize: nextFontSize, fontSizeOverride: true });
|
||||
}, [onUpdateHost, onUpdateSessionFontSize, onUpdateTerminalFontSize]);
|
||||
|
||||
const validAIScopeTargetIds = useMemo(() => {
|
||||
@@ -1731,7 +1725,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
setSftpHostForTab,
|
||||
setSftpInitialLocationForTab,
|
||||
setSftpPendingUploadsForTab,
|
||||
setupMcpApprovalBridge,
|
||||
showHostTreeSidebar,
|
||||
sidePanelOpenTabs,
|
||||
sidePanelPosition,
|
||||
|
||||
@@ -63,7 +63,7 @@ export const WindowOpacityButton: React.FC<WindowOpacityButtonProps> = ({
|
||||
type="range"
|
||||
min={50}
|
||||
max={100}
|
||||
step={5}
|
||||
step={1}
|
||||
value={percent}
|
||||
onChange={(e) => setWindowOpacity(Number(e.target.value) / 100)}
|
||||
className="flex-1 accent-primary"
|
||||
|
||||
@@ -187,12 +187,12 @@ export const ToolCall = ({
|
||||
|
||||
// Auto-focus and auto-scroll when approval is pending
|
||||
useEffect(() => {
|
||||
if (isPendingApproval && cardRef.current) {
|
||||
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
// Small delay to let the UI render, then expand and focus
|
||||
setExpanded(true);
|
||||
setTimeout(() => approveBtnRef.current?.focus(), 100);
|
||||
}
|
||||
if (!isPendingApproval || !cardRef.current) return;
|
||||
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
// Small delay to let the UI render, then expand and focus
|
||||
setExpanded(true);
|
||||
const focusTimer = setTimeout(() => approveBtnRef.current?.focus(), 100);
|
||||
return () => clearTimeout(focusTimer);
|
||||
}, [isPendingApproval]);
|
||||
|
||||
// Reset responded state when approvalStatus changes (e.g. new approval)
|
||||
|
||||
Loaded 100 of 471 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user