mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 08:31:00 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
86e2336370 | ||
|
|
6f7d18ffba | ||
|
|
6cb68bf6fc | ||
|
|
bccc085029 | ||
|
|
2d95c71b85 | ||
|
|
effafd9c2e | ||
|
|
aebdcedda9 | ||
|
|
74b05c83d0 | ||
|
|
94e359f6d7 | ||
|
|
101ff24a1f | ||
|
|
9fe1fc7aa4 | ||
|
|
1dbf46e32f | ||
|
|
6e3401f9dc | ||
|
|
6944b77ec7 | ||
|
|
3f2e33f6e0 | ||
|
|
2b9846d1d2 | ||
|
|
b6a7f40175 | ||
|
|
aa4b7f46e5 | ||
|
|
c79d51858a | ||
|
|
e430a42dbe | ||
|
|
5dec129088 | ||
|
|
f299271b1b | ||
|
|
49787215d2 | ||
|
|
5e34538c55 | ||
|
|
862c3f6fb5 | ||
|
|
2edbeae02c | ||
|
|
02ada0c61d |
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
|
||||
+188
-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 pacman packaging dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y libarchive-tools
|
||||
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
|
||||
@@ -453,6 +618,9 @@ jobs:
|
||||
- name: Verify packaged deb artifact
|
||||
run: bash scripts/verify-linux-deb-artifact.sh amd64
|
||||
|
||||
- name: Verify packaged rpm artifact
|
||||
run: bash scripts/verify-linux-rpm-artifact.sh x86_64
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -566,6 +734,9 @@ jobs:
|
||||
- name: Verify packaged deb artifact
|
||||
run: bash scripts/verify-linux-deb-artifact.sh arm64
|
||||
|
||||
- name: Verify packaged rpm artifact
|
||||
run: bash scripts/verify-linux-rpm-artifact.sh aarch64
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
@@ -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,11 +74,14 @@ 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';
|
||||
import { LogViewWrapper, SftpViewMount, TerminalLayerMount, VaultViewContainer } from './application/app/AppMounts';
|
||||
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleToggleThemeImpl, handleRootContextMenuImpl } from './application/app/AppHandlers';
|
||||
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleToggleThemeImpl, handleRootContextMenuImpl } from './application/app/AppHandlers';
|
||||
|
||||
// Initialize fonts eagerly at app startup
|
||||
initializeFonts();
|
||||
@@ -109,11 +118,14 @@ 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');
|
||||
|
||||
const {
|
||||
@@ -171,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,
|
||||
@@ -337,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],
|
||||
@@ -374,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) => ({
|
||||
@@ -557,6 +594,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const _handleTrayJumpToSession = useEffectEvent((sessionId: string) => { return handleTrayJumpToSessionImpl(() => ({ sessionId, sessions, setActiveTabId, setWorkspaceFocusedSession }), sessionId); });
|
||||
const _handleTrayTogglePortForward = useEffectEvent((ruleId: string, start: boolean) => { return handleTrayTogglePortForwardImpl(() => ({ hosts, identities, keys, knownHosts: effectiveKnownHosts, portForwardingRules, resolveEffectiveHost, ruleId, start, startTunnel, stopTunnel, t, terminalSettings, toast, undefined }), ruleId, start); });
|
||||
const _handleTrayPanelConnect = useEffectEvent((hostId: string) => { return handleTrayPanelConnectImpl(() => ({ addConnectionLog, connectToHost, hostId, hosts, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef, t, toast }), hostId); });
|
||||
const _handleTrayPanelConnectRequest = useEffectEvent((hostId: string) => { return handleTrayPanelConnectRequestImpl(() => ({ connectNow: _handleTrayPanelConnect, hostId, isVaultInitialized, queueConnect: (queuedHostId: string) => setPendingTrayPanelConnectHostIds((prev) => [...prev, queuedHostId]) }), hostId); });
|
||||
const _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleGlobalHotkeyKeyDownImpl(() => ({ HOTKEY_DEBUG, closeTabKeyStr, e, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding }), e); });
|
||||
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleEscapeKeyDownImpl(() => ({ e, isQuickSwitcherOpen, setIsQuickSwitcherOpen }), e); });
|
||||
|
||||
@@ -589,7 +627,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
_handleTrayJumpToSession(sessionId);
|
||||
});
|
||||
const unsubscribeConnect = bridge.onTrayPanelConnectToHost((hostId) => {
|
||||
_handleTrayPanelConnect(hostId);
|
||||
_handleTrayPanelConnectRequest(hostId);
|
||||
});
|
||||
return () => {
|
||||
unsubscribeJump?.();
|
||||
@@ -597,6 +635,15 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
};
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVaultInitialized || pendingTrayPanelConnectHostIds.length === 0) return;
|
||||
flushQueuedTrayPanelConnectHostsImpl(() => ({
|
||||
connectNow: _handleTrayPanelConnect,
|
||||
pendingHostIds: pendingTrayPanelConnectHostIds,
|
||||
setPendingHostIds: setPendingTrayPanelConnectHostIds,
|
||||
}));
|
||||
}, [isVaultInitialized, pendingTrayPanelConnectHostIds]);
|
||||
|
||||
// Handle keyboard-interactive submit
|
||||
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, updateHosts }), requestId, responses, savePassword); }, [keyboardInteractiveQueue, sessions, hosts, updateHosts]);
|
||||
|
||||
@@ -893,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);
|
||||
@@ -929,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)),
|
||||
@@ -947,6 +1007,23 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
}, [groupConfigs, proxyProfileIdSet, proxyProfiles]);
|
||||
|
||||
// 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 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,
|
||||
@@ -963,11 +1040,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);
|
||||
@@ -985,6 +1060,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));
|
||||
@@ -1008,6 +1100,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) {
|
||||
@@ -1073,6 +1265,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;
|
||||
@@ -1178,6 +1381,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}
|
||||
@@ -1195,7 +1408,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 }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1223,11 +1436,21 @@ function AppWithProviders() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 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`、
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { handleConnectToHostImpl } from './app/AppHandlers.ts';
|
||||
import {
|
||||
flushQueuedTrayPanelConnectHostsImpl,
|
||||
handleConnectToHostImpl,
|
||||
handleTrayPanelConnectRequestImpl,
|
||||
} from './app/AppHandlers.ts';
|
||||
import type { Host } from '../types';
|
||||
|
||||
const baseHost: Host = {
|
||||
@@ -62,3 +66,53 @@ test('connect serial host handler returns the created terminal tab id', () => {
|
||||
|
||||
assert.equal(result, 'serial-session');
|
||||
});
|
||||
|
||||
test('tray panel connect request queues until the vault is initialized', () => {
|
||||
const queuedHostIds: string[] = [];
|
||||
const connectedHostIds: string[] = [];
|
||||
|
||||
handleTrayPanelConnectRequestImpl(
|
||||
() => ({
|
||||
connectNow: (hostId: string) => connectedHostIds.push(hostId),
|
||||
isVaultInitialized: false,
|
||||
queueConnect: (hostId: string) => queuedHostIds.push(hostId),
|
||||
}),
|
||||
'host-1',
|
||||
);
|
||||
|
||||
assert.deepEqual(queuedHostIds, ['host-1']);
|
||||
assert.deepEqual(connectedHostIds, []);
|
||||
});
|
||||
|
||||
test('tray panel connect request runs immediately after the vault is initialized', () => {
|
||||
const queuedHostIds: string[] = [];
|
||||
const connectedHostIds: string[] = [];
|
||||
|
||||
handleTrayPanelConnectRequestImpl(
|
||||
() => ({
|
||||
connectNow: (hostId: string) => connectedHostIds.push(hostId),
|
||||
isVaultInitialized: true,
|
||||
queueConnect: (hostId: string) => queuedHostIds.push(hostId),
|
||||
}),
|
||||
'host-1',
|
||||
);
|
||||
|
||||
assert.deepEqual(queuedHostIds, []);
|
||||
assert.deepEqual(connectedHostIds, ['host-1']);
|
||||
});
|
||||
|
||||
test('queued tray panel connects flush in order', () => {
|
||||
const connectedHostIds: string[] = [];
|
||||
let pendingHostIds = ['host-1', 'host-2'];
|
||||
|
||||
flushQueuedTrayPanelConnectHostsImpl(() => ({
|
||||
connectNow: (hostId: string) => connectedHostIds.push(hostId),
|
||||
pendingHostIds,
|
||||
setPendingHostIds: (nextHostIds: string[]) => {
|
||||
pendingHostIds = nextHostIds;
|
||||
},
|
||||
}));
|
||||
|
||||
assert.deepEqual(connectedHostIds, ['host-1', 'host-2']);
|
||||
assert.deepEqual(pendingHostIds, []);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export function handleTrayTogglePortForwardImpl(getCtx: AppContextGetter, ruleId
|
||||
|
||||
export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: string) {
|
||||
const { addConnectionLog, connectToHost, hosts, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef, t, toast } = getCtx();
|
||||
{
|
||||
{
|
||||
const host = hosts.find((item) => item.id === hostId);
|
||||
if (!host) {
|
||||
toast.error(t("pf.error.hostNotFound"));
|
||||
@@ -110,6 +110,24 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
|
||||
}
|
||||
}
|
||||
|
||||
export function handleTrayPanelConnectRequestImpl(getCtx: AppContextGetter, hostId: string) {
|
||||
const { connectNow, isVaultInitialized, queueConnect } = getCtx();
|
||||
if (!hostId) return;
|
||||
if (!isVaultInitialized) {
|
||||
queueConnect(hostId);
|
||||
return;
|
||||
}
|
||||
connectNow(hostId);
|
||||
}
|
||||
|
||||
export function flushQueuedTrayPanelConnectHostsImpl(getCtx: AppContextGetter) {
|
||||
const { connectNow, pendingHostIds, setPendingHostIds } = getCtx();
|
||||
if (!Array.isArray(pendingHostIds) || pendingHostIds.length === 0) return;
|
||||
const hostIds = [...pendingHostIds];
|
||||
setPendingHostIds([]);
|
||||
hostIds.forEach((hostId: string) => connectNow(hostId));
|
||||
}
|
||||
|
||||
export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEvent) {
|
||||
const { HOTKEY_DEBUG, closeTabKeyStr, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding } = getCtx();
|
||||
{
|
||||
@@ -727,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;
|
||||
@@ -742,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>
|
||||
|
||||
@@ -113,8 +113,3 @@ export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
|
||||
topTabsChromeThemeVarsApplied = true;
|
||||
}
|
||||
|
||||
export function hasActiveChromeThemeDataset(): boolean {
|
||||
if (typeof document === 'undefined') return false;
|
||||
return Boolean(document.documentElement.dataset.activeChromeTheme);
|
||||
}
|
||||
@@ -131,9 +131,22 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
};
|
||||
});
|
||||
|
||||
const hostsForSystemMenu = hosts
|
||||
.filter((host: any) => typeof host?.id === "string" && host.id.length > 0)
|
||||
.map((host: any) => ({
|
||||
id: host.id,
|
||||
label: host.label,
|
||||
hostname: host.hostname,
|
||||
group: host.group,
|
||||
pinned: host.pinned,
|
||||
lastConnectedAt: host.lastConnectedAt,
|
||||
protocol: host.protocol,
|
||||
}));
|
||||
|
||||
void bridge.updateTrayMenuData({
|
||||
sessions: sessionsForTray,
|
||||
portForwardRules: portForwardingRules,
|
||||
hosts: hostsForSystemMenu,
|
||||
});
|
||||
}, 250);
|
||||
|
||||
@@ -141,7 +154,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [enabled, sessions, portForwardingRules, workspaces]);
|
||||
}, [enabled, hosts, sessions, portForwardingRules, workspaces]);
|
||||
|
||||
// Quit guard: block app exit while any editor tab has unsaved changes.
|
||||
// Main process sends "app:query-dirty-editors"; we respond with the result.
|
||||
|
||||
@@ -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`.',
|
||||
@@ -488,6 +512,8 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.section.connection': 'Connection',
|
||||
'settings.terminal.connection.verifyHostKeys': 'Verify SSH host keys',
|
||||
'settings.terminal.connection.verifyHostKeys.desc': 'Ask before connecting to a new or changed SSH host key. Turn this off only for trusted private networks.',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled': 'Automatically reconnect SSH sessions',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled.desc': 'When turned on, established SSH sessions that drop unexpectedly try to reconnect every 5 seconds until the tab is closed or the connection succeeds.',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': 'How often (in seconds) to send SSH-level keepalive packets. Set to 0 to disable globally — note that individual hosts can override this in their own settings.',
|
||||
'settings.terminal.connection.keepaliveCountMax': 'Max unanswered keepalives',
|
||||
@@ -721,6 +747,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',
|
||||
|
||||
@@ -763,5 +795,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}}',
|
||||
|
||||
};
|
||||
@@ -69,6 +69,11 @@ export const enScriptsMessages = {
|
||||
'scripts.recording.helpTip4': 'For vim, menus, or other interactive flows, record the main commands first, then refine the script manually.',
|
||||
'scripts.dialog.title': 'Script',
|
||||
'scripts.dialog.ok': 'OK',
|
||||
'scripts.dialog.required': 'Required',
|
||||
'scripts.dialog.numberInvalid': 'Enter a valid number',
|
||||
'scripts.dialog.numberMin': 'Must be at least {min}',
|
||||
'scripts.dialog.numberMax': 'Must be at most {max}',
|
||||
'scripts.dialog.numberStep': 'Must use increments of {step}',
|
||||
'scripts.dialog.waitForTimeoutTitle': 'Wait timed out',
|
||||
'scripts.dialog.retry': 'Retry',
|
||||
'scripts.dialog.skip': 'Skip',
|
||||
|
||||
@@ -6,6 +6,7 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.noSession': 'No active terminal session.',
|
||||
'systemManager.notConnected': 'Connect to a host to manage processes and services.',
|
||||
'systemManager.empty': 'No data available.',
|
||||
'systemManager.tabs.overview': 'Overview',
|
||||
'systemManager.tabs.processes': 'Processes',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
@@ -21,8 +22,35 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': 'Failed to load containers',
|
||||
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
|
||||
'systemManager.errors.loadDockerImages': 'Failed to load images',
|
||||
'systemManager.errors.loadOverview': 'Failed to load system overview',
|
||||
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
|
||||
|
||||
'systemManager.overview.empty': 'No system overview data yet.',
|
||||
'systemManager.overview.loading': 'Loading system overview…',
|
||||
'systemManager.overview.memory': 'Memory',
|
||||
'systemManager.overview.disk': 'Disk',
|
||||
'systemManager.overview.network': 'Network',
|
||||
'systemManager.overview.rx': 'RX',
|
||||
'systemManager.overview.tx': 'TX',
|
||||
'systemManager.overview.cores': '{{count}} cores',
|
||||
'systemManager.overview.load': 'Load',
|
||||
'systemManager.overview.uptime': 'Uptime',
|
||||
'systemManager.overview.duration.daysHours': '{{days}}d {{hours}}h',
|
||||
'systemManager.overview.duration.hoursMinutes': '{{hours}}h {{minutes}}m',
|
||||
'systemManager.overview.duration.minutes': '{{minutes}}m',
|
||||
'systemManager.overview.system': 'System',
|
||||
'systemManager.overview.kernel': 'Kernel',
|
||||
'systemManager.overview.swap': 'Swap',
|
||||
'systemManager.overview.latency': 'SSH network latency',
|
||||
'systemManager.overview.cpuCores': 'CPU cores',
|
||||
'systemManager.overview.disks': 'Disks',
|
||||
'systemManager.overview.interfaces': 'Network interfaces',
|
||||
'systemManager.overview.topProcesses': 'Top memory processes',
|
||||
'systemManager.overview.noData': 'No data',
|
||||
'systemManager.overview.noDisks': 'No disk data',
|
||||
'systemManager.overview.noInterfaces': 'No interface data',
|
||||
'systemManager.overview.noTopProcesses': 'No process data',
|
||||
|
||||
'systemManager.processes.search': 'Search processes…',
|
||||
'systemManager.processes.command': 'Command',
|
||||
'systemManager.processes.user': 'User',
|
||||
|
||||
@@ -88,7 +88,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',
|
||||
@@ -155,6 +155,8 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.auth.passphrase.placeholder': 'Optional passphrase for the selected private key',
|
||||
'terminal.auth.certificate': 'Certificate',
|
||||
'terminal.auth.selectKey': 'Select Key',
|
||||
'terminal.auth.retryMessage': 'Authentication failed. Please check your credentials and try again.',
|
||||
'terminal.auth.retryLog': 'Authentication failed. Please try again.',
|
||||
'terminal.auth.noKeysHint': 'No keys available. Add keys in Keychain.',
|
||||
'terminal.auth.continueSave': 'Continue & Save',
|
||||
'terminal.auth.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Please re-enter and save them again.',
|
||||
@@ -166,6 +168,9 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.progress.disconnected': 'Disconnected',
|
||||
'terminal.progress.cancelling': 'Cancelling...',
|
||||
'terminal.progress.startOver': 'Start over',
|
||||
'terminal.progress.enterReconnectHint': 'Press Enter to reconnect',
|
||||
'terminal.progress.autoReconnectScheduled': 'Connection lost. Reconnecting in {seconds}s (attempt {attempt}).',
|
||||
'terminal.progress.autoReconnectAttempt': 'Auto reconnect attempt {attempt}...',
|
||||
'terminal.connection.dismissDisconnectedDialog': 'Dismiss disconnected notice',
|
||||
'terminal.connection.chainOf': 'Chain {current} of {total}',
|
||||
'terminal.connection.showLogs': 'Show logs',
|
||||
@@ -296,6 +301,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',
|
||||
|
||||
@@ -279,6 +279,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 +385,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',
|
||||
|
||||
@@ -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`.',
|
||||
@@ -725,11 +749,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}}',
|
||||
|
||||
};
|
||||
@@ -6,6 +6,7 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.noSession': 'Нет активного терминального сеанса.',
|
||||
'systemManager.notConnected': 'Подключитесь к хосту для управления процессами и сервисами.',
|
||||
'systemManager.empty': 'Нет данных.',
|
||||
'systemManager.tabs.overview': 'Обзор',
|
||||
'systemManager.tabs.processes': 'Процессы',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
@@ -21,8 +22,35 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': 'Не удалось загрузить контейнеры',
|
||||
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
|
||||
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
|
||||
'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы',
|
||||
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
|
||||
|
||||
'systemManager.overview.empty': 'Данных обзора пока нет.',
|
||||
'systemManager.overview.loading': 'Загрузка обзора системы…',
|
||||
'systemManager.overview.memory': 'Память',
|
||||
'systemManager.overview.disk': 'Диск',
|
||||
'systemManager.overview.network': 'Сеть',
|
||||
'systemManager.overview.rx': 'RX',
|
||||
'systemManager.overview.tx': 'TX',
|
||||
'systemManager.overview.cores': '{{count}} ядер',
|
||||
'systemManager.overview.load': 'Нагрузка',
|
||||
'systemManager.overview.uptime': 'Время работы',
|
||||
'systemManager.overview.duration.daysHours': '{{days}} д {{hours}} ч',
|
||||
'systemManager.overview.duration.hoursMinutes': '{{hours}} ч {{minutes}} мин',
|
||||
'systemManager.overview.duration.minutes': '{{minutes}} мин',
|
||||
'systemManager.overview.system': 'Система',
|
||||
'systemManager.overview.kernel': 'Ядро',
|
||||
'systemManager.overview.swap': 'Swap',
|
||||
'systemManager.overview.latency': 'Сетевая задержка SSH',
|
||||
'systemManager.overview.cpuCores': 'Ядра CPU',
|
||||
'systemManager.overview.disks': 'Диски',
|
||||
'systemManager.overview.interfaces': 'Сетевые интерфейсы',
|
||||
'systemManager.overview.topProcesses': 'Процессы по памяти',
|
||||
'systemManager.overview.noData': 'Нет данных',
|
||||
'systemManager.overview.noDisks': 'Нет данных дисков',
|
||||
'systemManager.overview.noInterfaces': 'Нет данных интерфейсов',
|
||||
'systemManager.overview.noTopProcesses': 'Нет данных процессов',
|
||||
|
||||
'systemManager.processes.search': 'Поиск процессов…',
|
||||
'systemManager.processes.command': 'Команда',
|
||||
'systemManager.processes.user': 'Пользователь',
|
||||
|
||||
@@ -109,6 +109,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': 'Перетащите для вставки путей',
|
||||
@@ -175,6 +176,8 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.auth.passphrase.placeholder': 'Необязательная парольная фраза для выбранного приватного ключа',
|
||||
'terminal.auth.certificate': 'Сертификат',
|
||||
'terminal.auth.selectKey': 'Выбрать ключ',
|
||||
'terminal.auth.retryMessage': 'Ошибка аутентификации. Проверьте учётные данные и повторите попытку.',
|
||||
'terminal.auth.retryLog': 'Ошибка аутентификации. Повторите попытку.',
|
||||
'terminal.auth.noKeysHint': 'Нет доступных ключей. Добавьте ключи в связке ключей.',
|
||||
'terminal.auth.continueSave': 'Продолжить и сохранить',
|
||||
'terminal.auth.credentialsUnavailable': 'Сохранённые учётные данные не могут быть расшифрованы на этом устройстве. Пожалуйста, введите и сохраните их заново.',
|
||||
@@ -186,6 +189,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.progress.disconnected': 'Отключено',
|
||||
'terminal.progress.cancelling': 'Отмена...',
|
||||
'terminal.progress.startOver': 'Начать заново',
|
||||
'terminal.progress.enterReconnectHint': 'Нажмите Enter, чтобы подключиться снова',
|
||||
'terminal.connection.dismissDisconnectedDialog': 'Закрыть уведомление об отключении',
|
||||
'terminal.connection.chainOf': 'Цепочка {current} из {total}',
|
||||
'terminal.connection.showLogs': 'Показать журналы',
|
||||
@@ -313,6 +317,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',
|
||||
|
||||
@@ -314,6 +314,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 +420,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': 'Автосинхронизация с удалённым сервером',
|
||||
|
||||
@@ -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',
|
||||
@@ -711,6 +735,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': '所有者',
|
||||
|
||||
@@ -69,6 +69,11 @@ export const zhCNScriptsMessages = {
|
||||
'scripts.recording.helpTip4': 'vim、交互式菜单等复杂操作,建议先录主干命令,再在编辑器里手动补充。',
|
||||
'scripts.dialog.title': '脚本',
|
||||
'scripts.dialog.ok': '确定',
|
||||
'scripts.dialog.required': '必填',
|
||||
'scripts.dialog.numberInvalid': '请输入有效数字',
|
||||
'scripts.dialog.numberMin': '不能小于 {min}',
|
||||
'scripts.dialog.numberMax': '不能大于 {max}',
|
||||
'scripts.dialog.numberStep': '步进必须为 {step} 的倍数',
|
||||
'scripts.dialog.waitForTimeoutTitle': '等待超时',
|
||||
'scripts.dialog.retry': '重试',
|
||||
'scripts.dialog.skip': '跳过',
|
||||
|
||||
@@ -6,6 +6,7 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.noSession': '没有活动的终端会话。',
|
||||
'systemManager.notConnected': '请先连接到主机以管理进程与服务。',
|
||||
'systemManager.empty': '暂无数据。',
|
||||
'systemManager.tabs.overview': '概览',
|
||||
'systemManager.tabs.processes': '进程',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
@@ -21,8 +22,35 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': '加载容器列表失败',
|
||||
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
|
||||
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
|
||||
'systemManager.errors.loadOverview': '加载系统概览失败',
|
||||
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
|
||||
|
||||
'systemManager.overview.empty': '暂无系统概览数据。',
|
||||
'systemManager.overview.loading': '正在加载系统概览…',
|
||||
'systemManager.overview.memory': '内存',
|
||||
'systemManager.overview.disk': '磁盘',
|
||||
'systemManager.overview.network': '网络',
|
||||
'systemManager.overview.rx': '下行',
|
||||
'systemManager.overview.tx': '上行',
|
||||
'systemManager.overview.cores': '{{count}} 核',
|
||||
'systemManager.overview.load': '负载',
|
||||
'systemManager.overview.uptime': '运行时间',
|
||||
'systemManager.overview.duration.daysHours': '{{days}}天 {{hours}}小时',
|
||||
'systemManager.overview.duration.hoursMinutes': '{{hours}}小时 {{minutes}}分钟',
|
||||
'systemManager.overview.duration.minutes': '{{minutes}}分钟',
|
||||
'systemManager.overview.system': '系统',
|
||||
'systemManager.overview.kernel': '内核',
|
||||
'systemManager.overview.swap': '交换区',
|
||||
'systemManager.overview.latency': 'SSH 网络延迟',
|
||||
'systemManager.overview.cpuCores': 'CPU 核心',
|
||||
'systemManager.overview.disks': '磁盘',
|
||||
'systemManager.overview.interfaces': '网络接口',
|
||||
'systemManager.overview.topProcesses': '内存占用最高进程',
|
||||
'systemManager.overview.noData': '暂无数据',
|
||||
'systemManager.overview.noDisks': '暂无磁盘数据',
|
||||
'systemManager.overview.noInterfaces': '暂无网卡数据',
|
||||
'systemManager.overview.noTopProcesses': '暂无进程数据',
|
||||
|
||||
'systemManager.processes.search': '搜索进程…',
|
||||
'systemManager.processes.command': '命令',
|
||||
'systemManager.processes.user': '用户',
|
||||
|
||||
@@ -20,6 +20,9 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'terminal.osc7Setup.sent': '目录追踪配置已发送到终端',
|
||||
'terminal.toolbar.timestampsEnable': '显示时间戳',
|
||||
'terminal.toolbar.timestampsDisable': '隐藏时间戳',
|
||||
'terminal.progress.enterReconnectHint': '按 Enter 重新连接',
|
||||
'terminal.progress.autoReconnectScheduled': '连接已断开,将在 {seconds} 秒后自动重连(第 {attempt} 次)。',
|
||||
'terminal.progress.autoReconnectAttempt': '正在自动重连(第 {attempt} 次)...',
|
||||
'terminal.connection.protocol.et': 'EternalTerminal',
|
||||
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支持 Netcatty 的代理设置。请改用 SSH,或移除该主机的代理。',
|
||||
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支持一个跳板机。',
|
||||
@@ -101,9 +104,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': '自动同步到远程',
|
||||
@@ -247,9 +253,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 默认行为)。关闭则保留历史。',
|
||||
@@ -332,6 +347,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.localShell.startDir.notFound': '目录不存在',
|
||||
'settings.terminal.localShell.startDir.isFile': '路径是文件,不是目录',
|
||||
'settings.terminal.section.connection': '连接',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled': 'SSH 断线后自动重连',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled.desc': '打开后,已连上的 SSH 会话意外断开时会每 5 秒尝试重新连接,直到关闭标签页或连接恢复。',
|
||||
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': '向服务器发送 SSH 保活数据包的频率(秒)。设为 0 表示全局禁用——单个主机可在自己的设置里覆盖此值。',
|
||||
'settings.terminal.connection.keepaliveCountMax': '最大无响应保活次数',
|
||||
|
||||
@@ -382,7 +382,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': '拖放以插入路径',
|
||||
@@ -437,6 +437,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.auth.passphrase.placeholder': '可选:所选私钥的密码短语',
|
||||
'terminal.auth.certificate': '证书',
|
||||
'terminal.auth.selectKey': '选择密钥',
|
||||
'terminal.auth.retryMessage': '身份验证失败。请检查凭据后重试。',
|
||||
'terminal.auth.retryLog': '身份验证失败,请重试。',
|
||||
'terminal.auth.noKeysHint': '暂无密钥,请先在钥匙串中添加。',
|
||||
'terminal.auth.continueSave': '继续并保存',
|
||||
'terminal.auth.credentialsUnavailable': '当前设备无法解密已保存凭据,请重新输入并再次保存。',
|
||||
@@ -575,6 +577,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',
|
||||
@@ -711,6 +735,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': '所有者',
|
||||
|
||||
@@ -69,6 +69,11 @@ export const zhTWScriptsMessages = {
|
||||
'scripts.recording.helpTip4': 'vim、互動式選單等複雜操作,建議先錄主幹指令,再在編輯器裡手動補充。',
|
||||
'scripts.dialog.title': '指令碼',
|
||||
'scripts.dialog.ok': '確定',
|
||||
'scripts.dialog.required': '必填',
|
||||
'scripts.dialog.numberInvalid': '請輸入有效數字',
|
||||
'scripts.dialog.numberMin': '不能小於 {min}',
|
||||
'scripts.dialog.numberMax': '不能大於 {max}',
|
||||
'scripts.dialog.numberStep': '步進必須為 {step} 的倍數',
|
||||
'scripts.dialog.waitForTimeoutTitle': '等待逾時',
|
||||
'scripts.dialog.retry': '重試',
|
||||
'scripts.dialog.skip': '跳過',
|
||||
|
||||
@@ -6,6 +6,7 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.noSession': '沒有使用中的終端工作階段。',
|
||||
'systemManager.notConnected': '請先連線到主機以管理程序與服務。',
|
||||
'systemManager.empty': '暫無資料。',
|
||||
'systemManager.tabs.overview': '概覽',
|
||||
'systemManager.tabs.processes': '程序',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
@@ -21,8 +22,35 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': '載入容器列表失敗',
|
||||
'systemManager.errors.loadDockerStats': '載入容器效能資料失敗',
|
||||
'systemManager.errors.loadDockerImages': '載入映像列表失敗',
|
||||
'systemManager.errors.loadOverview': '載入系統概覽失敗',
|
||||
'systemManager.errors.sshChannelUnavailable': '伺服器拒絕開啟新的執行通道。請稍後重試,或重新連線目前主機。',
|
||||
|
||||
'systemManager.overview.empty': '暫無系統概覽資料。',
|
||||
'systemManager.overview.loading': '正在載入系統概覽…',
|
||||
'systemManager.overview.memory': '記憶體',
|
||||
'systemManager.overview.disk': '磁碟',
|
||||
'systemManager.overview.network': '網路',
|
||||
'systemManager.overview.rx': '下行',
|
||||
'systemManager.overview.tx': '上行',
|
||||
'systemManager.overview.cores': '{{count}} 核心',
|
||||
'systemManager.overview.load': '負載',
|
||||
'systemManager.overview.uptime': '執行時間',
|
||||
'systemManager.overview.duration.daysHours': '{{days}}天 {{hours}}小時',
|
||||
'systemManager.overview.duration.hoursMinutes': '{{hours}}小時 {{minutes}}分鐘',
|
||||
'systemManager.overview.duration.minutes': '{{minutes}}分鐘',
|
||||
'systemManager.overview.system': '系統',
|
||||
'systemManager.overview.kernel': '核心',
|
||||
'systemManager.overview.swap': '交換區',
|
||||
'systemManager.overview.latency': 'SSH 網路延遲',
|
||||
'systemManager.overview.cpuCores': 'CPU 核心',
|
||||
'systemManager.overview.disks': '磁碟',
|
||||
'systemManager.overview.interfaces': '網路介面',
|
||||
'systemManager.overview.topProcesses': '記憶體占用最高程序',
|
||||
'systemManager.overview.noData': '暫無資料',
|
||||
'systemManager.overview.noDisks': '暫無磁碟資料',
|
||||
'systemManager.overview.noInterfaces': '暫無網卡資料',
|
||||
'systemManager.overview.noTopProcesses': '暫無程序資料',
|
||||
|
||||
'systemManager.processes.search': '搜尋程序…',
|
||||
'systemManager.processes.command': '指令',
|
||||
'systemManager.processes.user': '使用者',
|
||||
|
||||
@@ -20,6 +20,9 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'terminal.osc7Setup.sent': '目錄追蹤設定已傳送到終端',
|
||||
'terminal.toolbar.timestampsEnable': '顯示時間戳',
|
||||
'terminal.toolbar.timestampsDisable': '隱藏時間戳',
|
||||
'terminal.progress.enterReconnectHint': '按 Enter 重新連線',
|
||||
'terminal.progress.autoReconnectScheduled': '連線已斷開,將在 {seconds} 秒後自動重連(第 {attempt} 次)。',
|
||||
'terminal.progress.autoReconnectAttempt': '正在自動重連(第 {attempt} 次)...',
|
||||
'terminal.connection.protocol.et': 'EternalTerminal',
|
||||
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支援 Netcatty 的代理設定。請改用 SSH,或移除該主機的代理。',
|
||||
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支援一個跳板機。',
|
||||
@@ -101,9 +104,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': '自動同步到遠端',
|
||||
@@ -247,9 +253,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 預設行為)。關閉則保留歷史。',
|
||||
@@ -332,6 +347,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.localShell.startDir.notFound': '目錄不存在',
|
||||
'settings.terminal.localShell.startDir.isFile': '路徑是檔案,不是目錄',
|
||||
'settings.terminal.section.connection': '連線',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled': 'SSH 斷線後自動重連',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled.desc': '開啟後,已連上的 SSH 工作階段意外斷開時會每 5 秒嘗試重新連線,直到關閉標籤頁或連線恢復。',
|
||||
'settings.terminal.connection.keepaliveInterval': '工作階段保持間隔',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': '向伺服器傳送 SSH 保活資料包的頻率(秒)。設為 0 表示全域停用——單個主機可在自己的設定裡覆蓋此值。',
|
||||
'settings.terminal.connection.keepaliveCountMax': '最大無響應保活次數',
|
||||
|
||||
@@ -382,7 +382,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': '拖放以插入路徑',
|
||||
@@ -437,6 +437,8 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.auth.passphrase.placeholder': '可選:所選私鑰的密碼短語',
|
||||
'terminal.auth.certificate': '憑證',
|
||||
'terminal.auth.selectKey': '選擇金鑰',
|
||||
'terminal.auth.retryMessage': '驗證失敗。請檢查憑證後再試一次。',
|
||||
'terminal.auth.retryLog': '驗證失敗,請再試一次。',
|
||||
'terminal.auth.noKeysHint': '暫無金鑰,請先在鑰匙串中新增。',
|
||||
'terminal.auth.continueSave': '繼續並儲存',
|
||||
'terminal.auth.credentialsUnavailable': '目前裝置無法解密已儲存憑證,請重新輸入並再次儲存。',
|
||||
@@ -575,6 +577,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 設定',
|
||||
|
||||
@@ -67,11 +67,6 @@ export const useActiveTabId = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Hook to get setter - never causes re-render
|
||||
export const useSetActiveTabId = () => {
|
||||
return activeTabStore.setActiveTabId;
|
||||
};
|
||||
|
||||
// Check if a specific tab is active - only re-renders when this specific tab's active state changes
|
||||
export const useIsTabActive = (tabId: string) => {
|
||||
const getSnapshot = useCallback(() => activeTabStore.getActiveTabId() === tabId, [tabId]);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -142,14 +142,6 @@ export const customThemeStore = new CustomThemeStore();
|
||||
|
||||
// ============== Hooks ==============
|
||||
|
||||
/** Get all themes (built-in + custom) */
|
||||
export const useAllThemes = (): TerminalTheme[] => {
|
||||
return useSyncExternalStore(
|
||||
customThemeStore.subscribe,
|
||||
customThemeStore.getAllThemes
|
||||
);
|
||||
};
|
||||
|
||||
/** Get custom themes only */
|
||||
export const useCustomThemes = (): TerminalTheme[] => {
|
||||
return useSyncExternalStore(
|
||||
@@ -158,12 +150,6 @@ export const useCustomThemes = (): TerminalTheme[] => {
|
||||
);
|
||||
};
|
||||
|
||||
/** Get theme by ID (built-in or custom) with fallback */
|
||||
export const useThemeById = (id: string): TerminalTheme => {
|
||||
const allThemes = useAllThemes();
|
||||
return allThemes.find(t => t.id === id) || TERMINAL_THEMES[0];
|
||||
};
|
||||
|
||||
/** Theme mutation actions */
|
||||
export const useCustomThemeActions = () => {
|
||||
const addTheme = useCallback((theme: TerminalTheme) => {
|
||||
|
||||
@@ -134,24 +134,6 @@ export const useAvailableFonts = (): TerminalFont[] => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get font loading state
|
||||
*/
|
||||
export const useFontsLoading = (): boolean => {
|
||||
return useSyncExternalStore(
|
||||
fontStore.subscribe,
|
||||
fontStore.getIsLoading
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get font by ID with fallback - useful for components that need a specific font
|
||||
*/
|
||||
export const useFontById = (fontId: string): TerminalFont => {
|
||||
const fonts = useAvailableFonts();
|
||||
return fonts.find(f => f.id === fontId) || fonts[0] || TERMINAL_FONTS[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize fonts eagerly (call at app startup)
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveCurrentTerminalTheme } from "./settingsTerminalTheme";
|
||||
|
||||
const commonArgs = {
|
||||
terminalThemeId: "netcatty-dark",
|
||||
customThemes: [],
|
||||
resolvedTheme: "dark" as const,
|
||||
lightUiThemeId: "snow",
|
||||
darkUiThemeId: "github",
|
||||
accentMode: "theme" as const,
|
||||
customAccent: "",
|
||||
};
|
||||
|
||||
test("follow-app terminal theme uses pending pick before resolvedTheme catches up", () => {
|
||||
const theme = resolveCurrentTerminalTheme({
|
||||
...commonArgs,
|
||||
followAppTerminalTheme: true,
|
||||
pendingFollowAppTerminalThemeId: "system-flexoki-light",
|
||||
resolvedTheme: "dark",
|
||||
lightUiThemeId: "flexoki",
|
||||
darkUiThemeId: "github",
|
||||
terminalThemeDarkId: "dracula",
|
||||
terminalThemeLightId: "solarized-light",
|
||||
});
|
||||
|
||||
assert.equal(theme.id, "system-flexoki-light");
|
||||
});
|
||||
|
||||
test("follow-app terminal theme ignores manual per-mode terminal picks", () => {
|
||||
const theme = resolveCurrentTerminalTheme({
|
||||
...commonArgs,
|
||||
followAppTerminalTheme: true,
|
||||
terminalThemeDarkId: "dracula",
|
||||
terminalThemeLightId: "solarized-light",
|
||||
});
|
||||
|
||||
assert.equal(theme.id, "system-github-dark");
|
||||
});
|
||||
|
||||
test("manual terminal theme uses the active per-mode pick", () => {
|
||||
const theme = resolveCurrentTerminalTheme({
|
||||
...commonArgs,
|
||||
followAppTerminalTheme: false,
|
||||
terminalThemeDarkId: "dracula",
|
||||
terminalThemeLightId: "solarized-light",
|
||||
});
|
||||
|
||||
assert.equal(theme.id, "dracula");
|
||||
});
|
||||
|
||||
test("manual terminal theme preserves the saved global theme until a per-mode pick exists", () => {
|
||||
const theme = resolveCurrentTerminalTheme({
|
||||
...commonArgs,
|
||||
terminalThemeId: "dracula",
|
||||
followAppTerminalTheme: false,
|
||||
terminalThemeDarkId: "auto",
|
||||
terminalThemeLightId: "auto",
|
||||
});
|
||||
|
||||
assert.equal(theme.id, "dracula");
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { TerminalTheme } from '../../domain/models';
|
||||
import { TERMINAL_THEMES, getBuiltinTerminalThemeById } from '../../infrastructure/config/terminalThemes';
|
||||
import {
|
||||
applyCustomAccentToTerminalTheme,
|
||||
resolveFollowAppTerminalThemeId,
|
||||
resolveManualTerminalThemeId,
|
||||
} from '../../domain/terminalAppearance';
|
||||
|
||||
interface ResolveCurrentTerminalThemeParams {
|
||||
terminalThemeId: string;
|
||||
terminalThemeDarkId: string;
|
||||
terminalThemeLightId: string;
|
||||
customThemes: TerminalTheme[];
|
||||
followAppTerminalTheme: boolean;
|
||||
pendingFollowAppTerminalThemeId?: string | null;
|
||||
resolvedTheme: 'light' | 'dark';
|
||||
lightUiThemeId: string;
|
||||
darkUiThemeId: string;
|
||||
accentMode: 'theme' | 'custom';
|
||||
customAccent: string;
|
||||
}
|
||||
|
||||
export function resolveCurrentTerminalTheme({
|
||||
terminalThemeId,
|
||||
terminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
customThemes,
|
||||
followAppTerminalTheme,
|
||||
pendingFollowAppTerminalThemeId = null,
|
||||
resolvedTheme,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
accentMode,
|
||||
customAccent,
|
||||
}: ResolveCurrentTerminalThemeParams): TerminalTheme {
|
||||
if (followAppTerminalTheme) {
|
||||
const followedId = resolveFollowAppTerminalThemeId(pendingFollowAppTerminalThemeId, {
|
||||
resolvedTheme,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
fallbackThemeId: terminalThemeId,
|
||||
});
|
||||
const followed = getBuiltinTerminalThemeById(followedId)
|
||||
|| customThemes.find(t => t.id === followedId);
|
||||
if (followed) {
|
||||
return applyCustomAccentToTerminalTheme(followed, accentMode, customAccent);
|
||||
}
|
||||
}
|
||||
const manualThemeId = resolveManualTerminalThemeId({
|
||||
resolvedTheme,
|
||||
terminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
fallbackThemeId: terminalThemeId,
|
||||
});
|
||||
const baseTheme = getBuiltinTerminalThemeById(manualThemeId)
|
||||
|| customThemes.find(t => t.id === manualThemeId)
|
||||
|| getBuiltinTerminalThemeById(terminalThemeId)
|
||||
|| customThemes.find(t => t.id === terminalThemeId)
|
||||
|| TERMINAL_THEMES[0];
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
}
|
||||
@@ -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,22 @@ 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 rejects missing jump hosts", () => {
|
||||
assert.throws(
|
||||
() => buildSftpHostCredentials({
|
||||
|
||||
@@ -30,6 +30,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,
|
||||
|
||||
@@ -43,10 +43,15 @@ interface UseSftpPaneActionsParams {
|
||||
dirCacheTtlMs: number;
|
||||
}
|
||||
|
||||
export type SftpNavigateResult = "reached" | "failed" | "aborted";
|
||||
export type SftpNavigateResult = "reached" | "failed" | "aborted" | "superseded";
|
||||
export type SftpNavigateOptions = {
|
||||
force?: boolean;
|
||||
tabId?: string;
|
||||
shouldApply?: () => boolean;
|
||||
};
|
||||
|
||||
interface UseSftpPaneActionsResult {
|
||||
navigateTo: (side: "left" | "right", path: string, options?: { force?: boolean; tabId?: string }) => Promise<SftpNavigateResult>;
|
||||
navigateTo: (side: "left" | "right", path: string, options?: SftpNavigateOptions) => Promise<SftpNavigateResult>;
|
||||
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
|
||||
navigateUp: (side: "left" | "right") => Promise<void>;
|
||||
openEntry: (side: "left" | "right", entry: SftpFileEntry) => Promise<void>;
|
||||
@@ -161,7 +166,7 @@ export const useSftpPaneActions = ({
|
||||
async (
|
||||
side: "left" | "right",
|
||||
path: string,
|
||||
options?: { force?: boolean; tabId?: string },
|
||||
options?: SftpNavigateOptions,
|
||||
): Promise<SftpNavigateResult> => {
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
// When tabId is specified, target that specific tab instead of the active one.
|
||||
@@ -176,6 +181,10 @@ export const useSftpPaneActions = ({
|
||||
return "aborted";
|
||||
}
|
||||
|
||||
if (options?.shouldApply?.() === false) {
|
||||
return "aborted";
|
||||
}
|
||||
|
||||
const connectionId = pane.connection.id;
|
||||
const requestId = ++navSeqRef.current[side];
|
||||
const cacheKey = makeCacheKey(connectionId, path, pane.filenameEncoding);
|
||||
@@ -244,6 +253,31 @@ export const useSftpPaneActions = ({
|
||||
const previousFiles = confirmed.files;
|
||||
const previousSelection = confirmed.selectedFiles;
|
||||
const previousFilter = confirmed.filter;
|
||||
const previousError = pane.error;
|
||||
const isTargetRequestCurrent = () => tabNavSeqRef.current.get(targetTabId) === requestId;
|
||||
const getTargetPane = () => {
|
||||
const currentSideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
return currentSideTabs.tabs.find((t) => t.id === targetTabId) ?? null;
|
||||
};
|
||||
const shouldApplyNavigationState = () => (
|
||||
getTargetPane()?.connection?.id === connectionId
|
||||
&& (options?.shouldApply?.() ?? true)
|
||||
);
|
||||
const restoreConfirmedStateIfCurrent = () => {
|
||||
if (!isTargetRequestCurrent()) return;
|
||||
updateTab(side, targetTabId, (prev) => {
|
||||
if (prev.connection?.id !== connectionId || !isTargetRequestCurrent()) return prev;
|
||||
return {
|
||||
...prev,
|
||||
connection: { ...prev.connection, currentPath: previousPath },
|
||||
files: previousFiles,
|
||||
selectedFiles: previousSelection,
|
||||
filter: previousFilter,
|
||||
error: previousError,
|
||||
loading: false,
|
||||
};
|
||||
});
|
||||
};
|
||||
tabNavSeqRef.current.set(targetTabId, requestId);
|
||||
// Keep existing files visible during loading — the loading overlay
|
||||
// (pointer-events-none) prevents interaction. This avoids blanking a tab
|
||||
@@ -267,6 +301,13 @@ export const useSftpPaneActions = ({
|
||||
} else {
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
if (!isTargetRequestCurrent()) {
|
||||
return "superseded";
|
||||
}
|
||||
if (!shouldApplyNavigationState()) {
|
||||
restoreConfirmedStateIfCurrent();
|
||||
return "aborted";
|
||||
}
|
||||
clearCacheForConnection(pane.connection.id);
|
||||
// For background tabs (explicit tabId), update that tab directly
|
||||
// instead of handleSessionError which targets the active tab.
|
||||
@@ -286,6 +327,13 @@ export const useSftpPaneActions = ({
|
||||
files = await listRemoteFiles(sftpId, path, pane.filenameEncoding);
|
||||
} catch (err) {
|
||||
if (isSessionError(err)) {
|
||||
if (!isTargetRequestCurrent()) {
|
||||
return "superseded";
|
||||
}
|
||||
if (!shouldApplyNavigationState()) {
|
||||
restoreConfirmedStateIfCurrent();
|
||||
return "aborted";
|
||||
}
|
||||
sftpSessionsRef.current.delete(pane.connection.id);
|
||||
clearCacheForConnection(pane.connection.id);
|
||||
if (options?.tabId) {
|
||||
@@ -306,14 +354,18 @@ export const useSftpPaneActions = ({
|
||||
if (navSeqRef.current[side] !== requestId) {
|
||||
// Side-level sequence was bumped by another tab's navigation or
|
||||
// a connect/disconnect. Check if THIS tab's request is still current.
|
||||
if (tabNavSeqRef.current.get(targetTabId) !== requestId) {
|
||||
if (!isTargetRequestCurrent()) {
|
||||
// This tab also has a newer navigation — drop completely.
|
||||
return "aborted";
|
||||
return "superseded";
|
||||
}
|
||||
// Side was superseded by another tab, but this tab's request is
|
||||
// still current. The fetched files are valid — fall through to
|
||||
// apply them instead of restoring previousPath.
|
||||
}
|
||||
if (!shouldApplyNavigationState()) {
|
||||
restoreConfirmedStateIfCurrent();
|
||||
return "aborted";
|
||||
}
|
||||
|
||||
dirCacheRef.current.set(cacheKey, {
|
||||
files,
|
||||
@@ -349,12 +401,16 @@ export const useSftpPaneActions = ({
|
||||
return "reached";
|
||||
} catch (err) {
|
||||
if (navSeqRef.current[side] !== requestId) {
|
||||
if (tabNavSeqRef.current.get(targetTabId) !== requestId) {
|
||||
return "aborted";
|
||||
if (!isTargetRequestCurrent()) {
|
||||
return "superseded";
|
||||
}
|
||||
// Side superseded by another tab, but this tab's request is
|
||||
// current — fall through to show the error on this tab.
|
||||
}
|
||||
if (!shouldApplyNavigationState()) {
|
||||
restoreConfirmedStateIfCurrent();
|
||||
return "aborted";
|
||||
}
|
||||
let navigationFailed = false;
|
||||
updateTab(side, targetTabId, (prev) => {
|
||||
if (prev.connection?.id !== connectionId) {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
TERMINAL_AUTO_RECONNECT_DELAY_MS,
|
||||
canAttemptTerminalAutoReconnect,
|
||||
isTerminalAutoReconnectEnabled,
|
||||
shouldAutoReconnectAfterExit,
|
||||
shouldContinueAutoReconnectAfterFailure,
|
||||
} from "./terminalAutoReconnect";
|
||||
|
||||
const sshHost = {
|
||||
protocol: "ssh" as const,
|
||||
hostname: "example.com",
|
||||
};
|
||||
|
||||
test("terminal auto reconnect uses a five second retry delay", () => {
|
||||
assert.equal(TERMINAL_AUTO_RECONNECT_DELAY_MS, 5000);
|
||||
});
|
||||
|
||||
test("terminal auto reconnect is disabled unless the setting is explicitly true", () => {
|
||||
assert.equal(isTerminalAutoReconnectEnabled(undefined), false);
|
||||
assert.equal(isTerminalAutoReconnectEnabled({ sshAutoReconnectEnabled: true }), true);
|
||||
assert.equal(isTerminalAutoReconnectEnabled({ sshAutoReconnectEnabled: false }), false);
|
||||
});
|
||||
|
||||
test("unexpected SSH exits reconnect only after the tab has connected before", () => {
|
||||
assert.equal(
|
||||
shouldAutoReconnectAfterExit({
|
||||
evt: { reason: "closed" },
|
||||
host: sshHost,
|
||||
terminalSettings: { sshAutoReconnectEnabled: true },
|
||||
hasEverConnected: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldAutoReconnectAfterExit({
|
||||
evt: { reason: "closed" },
|
||||
host: sshHost,
|
||||
terminalSettings: { sshAutoReconnectEnabled: true },
|
||||
hasEverConnected: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("normal shell exits do not auto reconnect", () => {
|
||||
assert.equal(
|
||||
shouldAutoReconnectAfterExit({
|
||||
evt: { reason: "exited", exitCode: 0 },
|
||||
host: sshHost,
|
||||
terminalSettings: { sshAutoReconnectEnabled: true },
|
||||
hasEverConnected: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("auto reconnect ignores protocols that already have different lifecycle semantics", () => {
|
||||
const variants = [
|
||||
{ protocol: "local" as const, hostname: "localhost" },
|
||||
{ protocol: "serial" as const, hostname: "/dev/tty.usbserial" },
|
||||
{ protocol: "telnet" as const, hostname: "router.local" },
|
||||
{ protocol: "ssh" as const, hostname: "example.com", moshEnabled: true },
|
||||
{ protocol: "ssh" as const, hostname: "example.com", etEnabled: true },
|
||||
];
|
||||
|
||||
for (const host of variants) {
|
||||
assert.equal(
|
||||
shouldAutoReconnectAfterExit({
|
||||
evt: { reason: "error", error: "connection reset" },
|
||||
host,
|
||||
terminalSettings: { sshAutoReconnectEnabled: true },
|
||||
hasEverConnected: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("an active auto reconnect loop continues after failed retry attempts", () => {
|
||||
assert.equal(
|
||||
shouldContinueAutoReconnectAfterFailure({
|
||||
host: sshHost,
|
||||
terminalSettings: { sshAutoReconnectEnabled: true },
|
||||
loopActive: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldContinueAutoReconnectAfterFailure({
|
||||
host: sshHost,
|
||||
terminalSettings: { sshAutoReconnectEnabled: false },
|
||||
loopActive: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal auto reconnect can start from live or fully hibernated runtimes", () => {
|
||||
assert.equal(
|
||||
canAttemptTerminalAutoReconnect({
|
||||
hasTerminalRuntime: true,
|
||||
isHibernated: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
canAttemptTerminalAutoReconnect({
|
||||
hasTerminalRuntime: false,
|
||||
isHibernated: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
canAttemptTerminalAutoReconnect({
|
||||
hasTerminalRuntime: false,
|
||||
isHibernated: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { TerminalSettings } from "../../domain/models";
|
||||
import type { TerminalSessionExitEvent } from "./resolveTerminalSessionExitIntent";
|
||||
|
||||
export const TERMINAL_AUTO_RECONNECT_DELAY_MS = 5000;
|
||||
|
||||
type AutoReconnectHost = {
|
||||
protocol?: "ssh" | "telnet" | "local" | "serial" | "mosh" | "et";
|
||||
hostname?: string;
|
||||
moshEnabled?: boolean;
|
||||
etEnabled?: boolean;
|
||||
};
|
||||
|
||||
type AutoReconnectSettings = Pick<TerminalSettings, "sshAutoReconnectEnabled"> | undefined | null;
|
||||
|
||||
export function isTerminalAutoReconnectEnabled(settings: AutoReconnectSettings): boolean {
|
||||
return settings?.sshAutoReconnectEnabled === true;
|
||||
}
|
||||
|
||||
export function isAutoReconnectableSshHost(host: AutoReconnectHost): boolean {
|
||||
const protocol = host.protocol ?? "ssh";
|
||||
return (
|
||||
protocol === "ssh" &&
|
||||
host.hostname !== "localhost" &&
|
||||
host.moshEnabled !== true &&
|
||||
host.etEnabled !== true
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldAutoReconnectAfterExit({
|
||||
evt,
|
||||
host,
|
||||
terminalSettings,
|
||||
hasEverConnected,
|
||||
}: {
|
||||
evt: TerminalSessionExitEvent;
|
||||
host: AutoReconnectHost;
|
||||
terminalSettings?: AutoReconnectSettings;
|
||||
hasEverConnected: boolean;
|
||||
}): boolean {
|
||||
if (!hasEverConnected) return false;
|
||||
if (!isTerminalAutoReconnectEnabled(terminalSettings)) return false;
|
||||
if (!isAutoReconnectableSshHost(host)) return false;
|
||||
return evt.reason !== "exited";
|
||||
}
|
||||
|
||||
export function shouldContinueAutoReconnectAfterFailure({
|
||||
host,
|
||||
terminalSettings,
|
||||
loopActive,
|
||||
}: {
|
||||
host: AutoReconnectHost;
|
||||
terminalSettings?: AutoReconnectSettings;
|
||||
loopActive: boolean;
|
||||
}): boolean {
|
||||
return loopActive && isTerminalAutoReconnectEnabled(terminalSettings) && isAutoReconnectableSshHost(host);
|
||||
}
|
||||
|
||||
export function canAttemptTerminalAutoReconnect({
|
||||
hasTerminalRuntime,
|
||||
isHibernated,
|
||||
}: {
|
||||
hasTerminalRuntime: boolean;
|
||||
isHibernated: boolean;
|
||||
}): boolean {
|
||||
return hasTerminalRuntime || isHibernated;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -162,16 +162,6 @@ export const useAvailableUIFonts = (): UIFont[] => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get UI font loading state
|
||||
*/
|
||||
export const useUIFontsLoading = (): boolean => {
|
||||
return useSyncExternalStore(
|
||||
uiFontStore.subscribe,
|
||||
uiFontStore.getIsLoading
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get UI font loaded state
|
||||
*/
|
||||
@@ -182,14 +172,6 @@ export const useUIFontsLoaded = (): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get UI font by ID with fallback
|
||||
*/
|
||||
export const useUIFontById = (fontId: string): UIFont => {
|
||||
const fonts = useAvailableUIFonts();
|
||||
return fonts.find(f => f.id === fontId) || fonts[0] || UI_FONTS[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a font ID is valid
|
||||
*/
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
};
|
||||
};
|
||||
@@ -168,6 +168,11 @@ export const useTerminalBackend = () => {
|
||||
return bridge?.onWindowFullScreenChanged?.(cb);
|
||||
}, []);
|
||||
|
||||
const onWindowShown = useCallback((cb: () => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onWindowShown?.(cb);
|
||||
}, []);
|
||||
|
||||
const onHostKeyVerification = useCallback((cb: Parameters<NonNullable<NetcattyBridge["onHostKeyVerification"]>>[0]) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onHostKeyVerification?.(cb);
|
||||
@@ -370,6 +375,7 @@ export const useTerminalBackend = () => {
|
||||
onChainProgress,
|
||||
onConnectionReuseFallback,
|
||||
onWindowFullScreenChanged,
|
||||
onWindowShown,
|
||||
onHostKeyVerification,
|
||||
respondHostKeyVerification,
|
||||
openExternal,
|
||||
@@ -422,6 +428,7 @@ export const useTerminalBackend = () => {
|
||||
onChainProgress,
|
||||
onConnectionReuseFallback,
|
||||
onWindowFullScreenChanged,
|
||||
onWindowShown,
|
||||
onHostKeyVerification,
|
||||
respondHostKeyVerification,
|
||||
openExternal,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { Host, TerminalTheme } from '../../domain/models';
|
||||
import type { TerminalTheme } from '../../domain/models';
|
||||
import {
|
||||
idleThemeUserIntent,
|
||||
pickingThemeUserIntent,
|
||||
@@ -113,7 +113,3 @@ export function useTerminalAppearanceInjection(
|
||||
injectTerminalAppearanceVars(appearance.theme, { includeChromeSurfaces });
|
||||
}, [appearance.theme.id, appearance.theme, includeChromeSurfaces]);
|
||||
}
|
||||
|
||||
export function buildHostScope(host: Host | null, isEphemeral: boolean): TerminalAppearanceHostScope {
|
||||
return { host, isEphemeral };
|
||||
}
|
||||
@@ -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,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' };
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import * as storageKeys from "../../infrastructure/config/storageKeys.ts";
|
||||
import { getRememberedYmodemSendDefaultPath, rememberYmodemSendFilePath } from "./ymodemFileMemory.ts";
|
||||
|
||||
type TestStore = {
|
||||
readString: (key: string) => string | null;
|
||||
writeString: (key: string, value: string) => boolean;
|
||||
};
|
||||
|
||||
const createStore = (initial: Record<string, string> = {}): {
|
||||
store: TestStore;
|
||||
values: Map<string, string>;
|
||||
} => {
|
||||
const values = new Map(Object.entries(initial));
|
||||
return {
|
||||
store: {
|
||||
readString: (key: string) => values.get(key) ?? null,
|
||||
writeString: (key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
values,
|
||||
};
|
||||
};
|
||||
|
||||
test("uses the remembered YMODEM send directory as the file picker default path", () => {
|
||||
assert.equal(
|
||||
storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR,
|
||||
"netcatty_terminal_ymodem_send_dir_v1",
|
||||
);
|
||||
|
||||
const { store } = createStore({
|
||||
[storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR]: "/firmware/releases",
|
||||
});
|
||||
|
||||
assert.equal(getRememberedYmodemSendDefaultPath(store), "/firmware/releases");
|
||||
});
|
||||
|
||||
test("preserves valid remembered directory whitespace", () => {
|
||||
const { store } = createStore({
|
||||
[storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR]: "/firmware/releases ",
|
||||
});
|
||||
|
||||
assert.equal(getRememberedYmodemSendDefaultPath(store), "/firmware/releases ");
|
||||
});
|
||||
|
||||
test("remembers the directory containing a selected YMODEM send file", () => {
|
||||
const { store, values } = createStore();
|
||||
|
||||
assert.equal(rememberYmodemSendFilePath("/firmware/releases/device.bin", store), true);
|
||||
assert.equal(
|
||||
values.get(storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR),
|
||||
"/firmware/releases",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps POSIX backslashes as part of the selected file name", () => {
|
||||
const { store, values } = createStore();
|
||||
|
||||
assert.equal(rememberYmodemSendFilePath("/firmware/releases\\device.bin", store), true);
|
||||
assert.equal(
|
||||
values.get(storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR),
|
||||
"/firmware",
|
||||
);
|
||||
});
|
||||
|
||||
test("remembers Windows-style YMODEM send directories", () => {
|
||||
const { store, values } = createStore();
|
||||
|
||||
assert.equal(rememberYmodemSendFilePath("C:\\Users\\catty\\device.bin", store), true);
|
||||
assert.equal(
|
||||
values.get(storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR),
|
||||
"C:\\Users\\catty",
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores blank remembered paths and selected file names without a parent directory", () => {
|
||||
const { store, values } = createStore({
|
||||
[storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR]: " ",
|
||||
});
|
||||
|
||||
assert.equal(getRememberedYmodemSendDefaultPath(store), undefined);
|
||||
assert.equal(rememberYmodemSendFilePath("device.bin", store), false);
|
||||
assert.equal(values.get(storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR), " ");
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR } from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
type YmodemFileMemoryStore = {
|
||||
readString: (key: string) => string | null;
|
||||
writeString: (key: string, value: string) => boolean;
|
||||
};
|
||||
|
||||
const getParentDirectory = (filePath: string): string | null => {
|
||||
const lastForwardSlash = filePath.lastIndexOf("/");
|
||||
const isWindowsPath = /^[A-Za-z]:[\\/]/.test(filePath) || filePath.startsWith("\\\\");
|
||||
const lastSeparator = isWindowsPath
|
||||
? Math.max(lastForwardSlash, filePath.lastIndexOf("\\"))
|
||||
: lastForwardSlash;
|
||||
|
||||
if (lastSeparator < 0) return null;
|
||||
if (lastSeparator === 0) return filePath.slice(0, 1);
|
||||
if (lastSeparator === 2 && filePath[1] === ":") return filePath.slice(0, 3);
|
||||
|
||||
return filePath.slice(0, lastSeparator);
|
||||
};
|
||||
|
||||
export const getRememberedYmodemSendDefaultPath = (
|
||||
store: YmodemFileMemoryStore = localStorageAdapter,
|
||||
): string | undefined => {
|
||||
const rememberedPath = store.readString(STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR);
|
||||
if (!rememberedPath || rememberedPath.trim() === "") return undefined;
|
||||
return rememberedPath;
|
||||
};
|
||||
|
||||
export const rememberYmodemSendFilePath = (
|
||||
filePath: string,
|
||||
store: YmodemFileMemoryStore = localStorageAdapter,
|
||||
): boolean => {
|
||||
const directory = getParentDirectory(filePath);
|
||||
if (!directory) return false;
|
||||
return store.writeString(STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR, directory);
|
||||
};
|
||||
@@ -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,
|
||||
@@ -983,6 +987,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,7 +200,8 @@ 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',
|
||||
|
||||
@@ -108,22 +108,6 @@ export function modelPresetsContainId(presets: AgentModelPreset[], modelId: stri
|
||||
return presets.some((preset) => modelPresetMatchesId(preset, modelId));
|
||||
}
|
||||
|
||||
export function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
|
||||
if (!agent) return false;
|
||||
const tokens = [
|
||||
agent.id,
|
||||
agent.name,
|
||||
agent.icon,
|
||||
agent.command,
|
||||
getExternalAgentSdkBackend(agent),
|
||||
]
|
||||
.filter((value): value is string => typeof value === 'string' && value.length > 0)
|
||||
// Split on both separators so Windows command paths (e.g. "...\\copilot.exe")
|
||||
// reduce to their basename rather than staying as the full path.
|
||||
.map((value) => value.split(/[\\/]/).pop()?.toLowerCase() ?? value.toLowerCase());
|
||||
return tokens.some((token) => token.includes('copilot'));
|
||||
}
|
||||
|
||||
export function shouldLoadSdkRuntimeModels(agent?: ExternalAgentConfig): boolean {
|
||||
const sdkBackend = getExternalAgentSdkBackend(agent);
|
||||
return sdkBackend === 'claude'
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -188,7 +188,8 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
return true;
|
||||
case "auth":
|
||||
if (authMethod === "password") {
|
||||
return password.trim().length > 0;
|
||||
// Whitespace-only passwords are valid SSH secrets (issue #2036).
|
||||
return password.length > 0;
|
||||
}
|
||||
return !!selectedKeyId;
|
||||
}
|
||||
@@ -434,7 +435,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();
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import { KeyBinding, HotkeyScheme } from "../domain/models";
|
||||
import {
|
||||
mergeLatestFollowTerminalCwdHostSetting,
|
||||
resolveHostFollowTerminalCwd,
|
||||
shouldApplyFollowTerminalCwdSyncResult,
|
||||
shouldClearBlockedFollowOnReach,
|
||||
shouldFollowTerminalCwdNavigate,
|
||||
type SftpFollowTerminalCwdBlock,
|
||||
@@ -52,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[];
|
||||
@@ -99,6 +103,7 @@ interface SftpSidePanelProps {
|
||||
const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
hosts,
|
||||
writableHosts,
|
||||
sessions = [],
|
||||
keys,
|
||||
identities,
|
||||
knownHosts = [],
|
||||
@@ -132,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 }) => {
|
||||
@@ -479,6 +490,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
<SftpSidePanelInteractiveBody
|
||||
hosts={hosts}
|
||||
hostWriteSource={hostWriteSource}
|
||||
connectedHosts={connectedHosts}
|
||||
updateHosts={updateHosts}
|
||||
sftp={sftp}
|
||||
sftpRef={sftpRef}
|
||||
@@ -521,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>>;
|
||||
@@ -560,6 +573,7 @@ type SftpSidePanelInteractiveBodyProps = {
|
||||
const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps> = ({
|
||||
hosts,
|
||||
hostWriteSource,
|
||||
connectedHosts,
|
||||
updateHosts,
|
||||
sftp,
|
||||
sftpRef,
|
||||
@@ -778,25 +792,32 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0;
|
||||
|
||||
const blockedFollowRef = useRef<SftpFollowTerminalCwdBlock | null>(null);
|
||||
const handledFollowRef = useRef<SftpFollowTerminalCwdBlock | null>(null);
|
||||
const followSyncGenerationRef = useRef(0);
|
||||
const effectiveFollowTerminalCwdRef = useRef(effectiveFollowTerminalCwd);
|
||||
const canFollowTerminalCwdRef = useRef(canFollowTerminalCwd);
|
||||
const activeTerminalCwdRef = useRef(activeTerminalCwd);
|
||||
const connectionId = sftp.leftPane.connection?.id ?? null;
|
||||
const connectionIdRef = useRef(connectionId);
|
||||
const connectionPath = sftp.leftPane.connection?.currentPath ?? null;
|
||||
effectiveFollowTerminalCwdRef.current = effectiveFollowTerminalCwd;
|
||||
canFollowTerminalCwdRef.current = canFollowTerminalCwd;
|
||||
const connectionId = sftp.leftPane.connection?.id ?? null;
|
||||
const connectionPath = sftp.leftPane.connection?.currentPath ?? null;
|
||||
activeTerminalCwdRef.current = activeTerminalCwd;
|
||||
connectionIdRef.current = connectionId;
|
||||
|
||||
const invalidateInFlightFollowSync = useCallback(() => {
|
||||
followSyncGenerationRef.current += 1;
|
||||
blockedFollowRef.current = null;
|
||||
handledFollowRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
blockedFollowRef.current = null;
|
||||
invalidateInFlightFollowSync();
|
||||
}, [
|
||||
activeTerminalCwd,
|
||||
followTerminalCwdHost?.id,
|
||||
connectionId,
|
||||
invalidateInFlightFollowSync,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -805,15 +826,17 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
}, [effectiveFollowTerminalCwd, invalidateInFlightFollowSync]);
|
||||
|
||||
useEffect(() => {
|
||||
const blockedFollow = blockedFollowRef.current;
|
||||
if (
|
||||
shouldClearBlockedFollowOnReach(
|
||||
blockedFollowRef.current,
|
||||
blockedFollow,
|
||||
connectionId,
|
||||
connectionPath,
|
||||
sftp.leftPane.loading,
|
||||
)
|
||||
) {
|
||||
blockedFollowRef.current = null;
|
||||
handledFollowRef.current = blockedFollow;
|
||||
}
|
||||
}, [connectionId, connectionPath, sftp.leftPane.loading]);
|
||||
|
||||
@@ -824,6 +847,10 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
const navigateResult = await sftpRef.current.navigateTo("left", cwd);
|
||||
if (navigateResult === "reached") {
|
||||
blockedFollowRef.current = null;
|
||||
const connection = sftpRef.current.leftPane.connection;
|
||||
if (connection?.id) {
|
||||
handledFollowRef.current = { connectionId: connection.id, terminalCwd: cwd };
|
||||
}
|
||||
}
|
||||
}, [onGetTerminalCwd, sftpRef]);
|
||||
|
||||
@@ -834,16 +861,18 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
|
||||
const syncGeneration = followSyncGenerationRef.current;
|
||||
|
||||
const usesLiveTerminalCwd = Boolean(activeTerminalCwd);
|
||||
let terminalCwd = activeTerminalCwd;
|
||||
if (!terminalCwd) {
|
||||
terminalCwd = await onGetTerminalCwd({ preferFreshBackend: true });
|
||||
}
|
||||
if (!terminalCwd) return;
|
||||
if (
|
||||
syncGeneration !== followSyncGenerationRef.current
|
||||
|| !effectiveFollowTerminalCwdRef.current
|
||||
|| !canFollowTerminalCwdRef.current
|
||||
) {
|
||||
if (!shouldApplyFollowTerminalCwdSyncResult({
|
||||
syncGeneration,
|
||||
currentGeneration: followSyncGenerationRef.current,
|
||||
followEnabled: effectiveFollowTerminalCwdRef.current,
|
||||
canFollow: canFollowTerminalCwdRef.current,
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -857,16 +886,43 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
hasActiveWork,
|
||||
isConnected: Boolean(connection && !connection.isLocal && connection.status === "connected"),
|
||||
blockedFollow: blockedFollowRef.current,
|
||||
handledFollow: handledFollowRef.current,
|
||||
})) {
|
||||
if (
|
||||
connection?.id
|
||||
&& !connection.isLocal
|
||||
&& connection.status === "connected"
|
||||
&& connection.currentPath === terminalCwd
|
||||
) {
|
||||
handledFollowRef.current = { connectionId: connection.id, terminalCwd };
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const navigateResult = await sftpRef.current.navigateTo("left", terminalCwd);
|
||||
if (
|
||||
syncGeneration !== followSyncGenerationRef.current
|
||||
|| !effectiveFollowTerminalCwdRef.current
|
||||
|| !canFollowTerminalCwdRef.current
|
||||
) {
|
||||
const expectedConnectionId = connection?.id ?? null;
|
||||
const shouldApplyCurrentFollowSync = () => (
|
||||
shouldApplyFollowTerminalCwdSyncResult({
|
||||
syncGeneration,
|
||||
currentGeneration: followSyncGenerationRef.current,
|
||||
followEnabled: effectiveFollowTerminalCwdRef.current,
|
||||
canFollow: canFollowTerminalCwdRef.current,
|
||||
expectedConnectionId,
|
||||
liveConnectionId: connectionIdRef.current,
|
||||
paneConnectionId: sftpRef.current.leftPane.connection?.id ?? null,
|
||||
expectedTerminalCwd: terminalCwd,
|
||||
liveTerminalCwd: activeTerminalCwdRef.current,
|
||||
requireLiveTerminalCwd: usesLiveTerminalCwd,
|
||||
})
|
||||
);
|
||||
const navigateResult = await sftpRef.current.navigateTo("left", terminalCwd, {
|
||||
shouldApply: shouldApplyCurrentFollowSync,
|
||||
});
|
||||
if (!shouldApplyFollowTerminalCwdSyncResult({
|
||||
syncGeneration,
|
||||
currentGeneration: followSyncGenerationRef.current,
|
||||
followEnabled: effectiveFollowTerminalCwdRef.current,
|
||||
canFollow: canFollowTerminalCwdRef.current,
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -877,8 +933,11 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
|
||||
if (navigateResult === "failed" && currentConnection.id) {
|
||||
blockedFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
|
||||
} else if (navigateResult === "superseded" && currentConnection.id) {
|
||||
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
|
||||
} else if (navigateResult === "reached") {
|
||||
blockedFollowRef.current = null;
|
||||
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
|
||||
}
|
||||
}, [
|
||||
activeTerminalCwd,
|
||||
@@ -892,9 +951,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
|
||||
const handleToggleFollowTerminalCwd = useCallback(() => {
|
||||
const nextEnabled = !effectiveFollowTerminalCwd;
|
||||
if (!nextEnabled) {
|
||||
invalidateInFlightFollowSync();
|
||||
}
|
||||
invalidateInFlightFollowSync();
|
||||
if (followTerminalCwdHost?.id) {
|
||||
setPendingFollowOverride({ hostId: followTerminalCwdHost.id, value: nextEnabled });
|
||||
}
|
||||
@@ -910,7 +967,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
effectiveFollowTerminalCwd,
|
||||
hasActiveWork,
|
||||
isVisible,
|
||||
sftp.leftPane.connection?.currentPath,
|
||||
connectionId,
|
||||
sftp.leftPane.connection?.status,
|
||||
sftp.leftPane.connection?.isLocal,
|
||||
syncFollowToTerminalCwd,
|
||||
@@ -1007,6 +1064,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
return (
|
||||
<SftpContextProvider
|
||||
hosts={hosts}
|
||||
connectedHosts={connectedHosts}
|
||||
writableHosts={hostWriteSource}
|
||||
updateHosts={updateHosts}
|
||||
draggedFiles={draggedFiles}
|
||||
@@ -1094,6 +1152,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
{renderOverlays && (
|
||||
<SftpOverlays
|
||||
hosts={hosts}
|
||||
connectedHosts={connectedHosts}
|
||||
sftp={sftp}
|
||||
visibleTransfers={visibleTransfers}
|
||||
showTransferQueue={false}
|
||||
@@ -1142,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')}
|
||||
>
|
||||
|
||||
+346
-52
@@ -45,6 +45,12 @@ import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
|
||||
import { supportsZmodemTerminalDragDrop } from "../lib/zmodemDragDrop";
|
||||
import { resolveHostAuth } from "../domain/sshAuth";
|
||||
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
import {
|
||||
TERMINAL_AUTO_RECONNECT_DELAY_MS,
|
||||
canAttemptTerminalAutoReconnect,
|
||||
shouldAutoReconnectAfterExit,
|
||||
shouldContinueAutoReconnectAfterFailure,
|
||||
} from "../application/state/terminalAutoReconnect";
|
||||
import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
||||
import { readOptionalStoredStringValue, useStoredString } from "../application/state/useStoredString";
|
||||
import { useSessionLogBackend } from "../application/state/useSessionLogBackend";
|
||||
@@ -126,6 +132,7 @@ import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextAc
|
||||
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
|
||||
import { useTerminalDragDrop } from "./terminal/hooks/useTerminalDragDrop";
|
||||
import { useTerminalFilePaste } from "./terminal/hooks/useTerminalFilePaste";
|
||||
import { getRememberedYmodemSendDefaultPath, rememberYmodemSendFilePath } from "../application/state/ymodemFileMemory";
|
||||
import { TerminalAutocomplete } from "./terminal/TerminalAutocomplete";
|
||||
import { resolveTerminalAutocompleteSettings } from "./terminal/autocomplete/terminalAutocompleteSettings";
|
||||
import { buildOsc7SetupExecCommand, runOsc7SetupAction, shouldOfferOsc7SetupAction } from "./terminal/osc7Setup";
|
||||
@@ -148,6 +155,7 @@ import {
|
||||
flushTerminalSessionFlowAck,
|
||||
} from "./terminal/runtime/terminalFlowAckBuffer";
|
||||
import { releaseTerminalFlowBeforeHibernate } from "./terminal/runtime/terminalSessionAttachment";
|
||||
import { flushPendingTerminalWritesBeforeHibernate } from "./terminal/runtime/terminalUnfocusedRepaint";
|
||||
import {
|
||||
isTerminalFileTransferActive,
|
||||
resolveHibernateKeepRendererCount,
|
||||
@@ -180,6 +188,8 @@ import {
|
||||
} from "./terminal/terminalHelpers";
|
||||
import { terminalPropsAreEqual } from "./terminal/terminalMemo";
|
||||
|
||||
const HIBERNATE_RETRY_AFTER_DRAIN_MS = 250;
|
||||
|
||||
const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
host,
|
||||
keys,
|
||||
@@ -351,6 +361,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);
|
||||
@@ -363,6 +375,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// chained xterm.write callbacks verify the token before proceeding so a
|
||||
// cancelled retry can't fire a startNewSession after the fact.
|
||||
const retryTokenRef = useRef<symbol | null>(null);
|
||||
const autoReconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const autoReconnectLoopActiveRef = useRef(false);
|
||||
const autoReconnectAttemptRef = useRef(0);
|
||||
const startReconnectRef = useRef<((mode: "manual" | "auto") => void) | null>(null);
|
||||
const wakeHibernatedRuntimeForReconnectRef = useRef<(() => Promise<boolean>) | null>(null);
|
||||
const terminalDataCapturedRef = useRef(false);
|
||||
const connectionLogBufferRef = useRef(createConnectionLogBuffer(MAX_CONNECTION_LOG_DATA_CHARS));
|
||||
const terminalLogSanitizerRef = useRef(createReplaySafeTerminalLogSanitizer());
|
||||
@@ -377,12 +394,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);
|
||||
@@ -560,6 +582,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const [status, setStatus] = useState<TerminalSession["status"]>(() => (
|
||||
getInitialTerminalStatus()
|
||||
));
|
||||
const hasEverConnectedRef = useRef(status === "connected");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const lastToastedErrorRef = useRef<string | null>(null);
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
@@ -636,6 +659,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
handleFindPrevious,
|
||||
handleCloseSearch,
|
||||
} = terminalSearch;
|
||||
isSearchOpenRef.current = isSearchOpen;
|
||||
|
||||
const prepareProgrammaticSudoInput = useCallback((data: string): string => {
|
||||
if (
|
||||
@@ -915,7 +939,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}).then((result) => {
|
||||
handleOsc7SetupOpenChange(false);
|
||||
if (result.success) {
|
||||
toast.success(t("terminal.osc7Setup.configured"));
|
||||
toast.success(result.sentToTerminal
|
||||
? t("terminal.osc7Setup.sent")
|
||||
: t("terminal.osc7Setup.configured"));
|
||||
return;
|
||||
}
|
||||
toast.error(result.error || t("terminal.osc7Setup.failed"));
|
||||
@@ -999,14 +1025,74 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const resolvedChainHosts =
|
||||
chainHosts;
|
||||
|
||||
const updateStatus = (next: TerminalSession["status"]) => {
|
||||
const clearAutoReconnect = useCallback((options?: { stopLoop?: boolean }) => {
|
||||
if (autoReconnectTimerRef.current) {
|
||||
clearTimeout(autoReconnectTimerRef.current);
|
||||
autoReconnectTimerRef.current = null;
|
||||
}
|
||||
if (options?.stopLoop !== false) {
|
||||
autoReconnectLoopActiveRef.current = false;
|
||||
autoReconnectAttemptRef.current = 0;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateStatus = useCallback((next: TerminalSession["status"]) => {
|
||||
setStatus(next);
|
||||
hasConnectedRef.current = next === "connected";
|
||||
if (next === "connected") {
|
||||
hasEverConnectedRef.current = true;
|
||||
clearAutoReconnect();
|
||||
}
|
||||
onStatusChange?.(sessionId, next);
|
||||
};
|
||||
}, [clearAutoReconnect, onStatusChange, sessionId]);
|
||||
const updateStatusRef = useRef(updateStatus);
|
||||
updateStatusRef.current = updateStatus;
|
||||
|
||||
const scheduleAutoReconnect = useCallback((trigger?: { evt?: Parameters<typeof shouldAutoReconnectAfterExit>[0]["evt"] }) => {
|
||||
const shouldSchedule = trigger?.evt
|
||||
? shouldAutoReconnectAfterExit({
|
||||
evt: trigger.evt,
|
||||
host,
|
||||
terminalSettings,
|
||||
hasEverConnected: hasEverConnectedRef.current,
|
||||
})
|
||||
: shouldContinueAutoReconnectAfterFailure({
|
||||
host,
|
||||
terminalSettings,
|
||||
loopActive: autoReconnectLoopActiveRef.current,
|
||||
});
|
||||
|
||||
if (!shouldSchedule || !canAttemptTerminalAutoReconnect({
|
||||
hasTerminalRuntime: Boolean(termRef.current),
|
||||
isHibernated: hibernatedRef.current,
|
||||
})) {
|
||||
return false;
|
||||
}
|
||||
|
||||
autoReconnectLoopActiveRef.current = true;
|
||||
if (autoReconnectTimerRef.current) {
|
||||
return true;
|
||||
}
|
||||
|
||||
autoReconnectAttemptRef.current += 1;
|
||||
const attempt = autoReconnectAttemptRef.current;
|
||||
const seconds = Math.round(TERMINAL_AUTO_RECONNECT_DELAY_MS / 1000);
|
||||
const scheduledMessage = t("terminal.progress.autoReconnectScheduled", { seconds, attempt });
|
||||
|
||||
setError(null);
|
||||
setShowLogs(true);
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
setProgressLogs((prev) => [...prev, scheduledMessage]);
|
||||
updateStatus("connecting");
|
||||
|
||||
autoReconnectTimerRef.current = setTimeout(() => {
|
||||
autoReconnectTimerRef.current = null;
|
||||
startReconnectRef.current?.("auto");
|
||||
}, TERMINAL_AUTO_RECONNECT_DELAY_MS);
|
||||
|
||||
return true;
|
||||
}, [host, t, terminalSettings, updateStatus]);
|
||||
|
||||
const prepareRestoredReconnect = useCallback(() => {
|
||||
if (restoreState !== "restored-disconnected") {
|
||||
suppressHostStartupCommandRef.current = false;
|
||||
@@ -1092,6 +1178,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
hasRuntimeRef.current = false;
|
||||
};
|
||||
|
||||
const clearHibernateRuntimeState = useCallback(() => {
|
||||
hibernatedRef.current = false;
|
||||
softHiddenRef.current = false;
|
||||
hibernateSnapshotRef.current = "";
|
||||
hibernateViewportSnapshotRef.current = "";
|
||||
hibernateScrollbackSnapshotRef.current = "";
|
||||
hibernateContextSnapshotRef.current = "";
|
||||
hibernateContextViewportSnapshotRef.current = "";
|
||||
hibernateContextScrollbackSnapshotRef.current = "";
|
||||
hibernatePendingBufferRef.current = "";
|
||||
hibernateAlternateScreenRef.current = false;
|
||||
terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
}, [sessionId]);
|
||||
|
||||
const forceCloseHibernatedSession = useCallback(() => {
|
||||
if (!terminalDataCapturedRef.current) {
|
||||
const hibernatedData = hibernateSnapshotRef.current + hibernatePendingBufferRef.current;
|
||||
@@ -1117,18 +1217,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
}
|
||||
sessionRef.current = null;
|
||||
hibernatedRef.current = false;
|
||||
softHiddenRef.current = false;
|
||||
hibernateSnapshotRef.current = "";
|
||||
hibernateViewportSnapshotRef.current = "";
|
||||
hibernateScrollbackSnapshotRef.current = "";
|
||||
hibernateContextSnapshotRef.current = "";
|
||||
hibernateContextViewportSnapshotRef.current = "";
|
||||
hibernateContextScrollbackSnapshotRef.current = "";
|
||||
hibernatePendingBufferRef.current = "";
|
||||
hibernateAlternateScreenRef.current = false;
|
||||
terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
}, [handleTerminalDataCaptureOnce, sessionId, terminalBackend]);
|
||||
clearHibernateRuntimeState();
|
||||
}, [clearHibernateRuntimeState, handleTerminalDataCaptureOnce, sessionId, terminalBackend]);
|
||||
|
||||
const beginHibernatedSessionListeners = useCallback((backendId: string) => {
|
||||
disposeDataRef.current?.();
|
||||
@@ -1162,8 +1252,35 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
exitMessage,
|
||||
);
|
||||
onSessionExit?.(sessionId, evt);
|
||||
scheduleAutoReconnect({ evt });
|
||||
});
|
||||
}, [onSessionExit, sessionId, terminalBackend]);
|
||||
}, [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: {
|
||||
@@ -1185,30 +1302,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?.();
|
||||
@@ -1223,13 +1379,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;
|
||||
@@ -1242,11 +1402,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;
|
||||
}
|
||||
|
||||
@@ -1264,7 +1420,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
|
||||
void fullHibernateRuntime();
|
||||
}, [fullHibernateRuntime, hideRuntimeOnly, sessionId, terminalSettings]);
|
||||
}, [fullHibernateRuntime, hideRuntimeOnly, sessionId, shouldSkipHibernateForActiveAlternateScreen, terminalSettings]);
|
||||
|
||||
const terminalRuntimeRefs = useMemo<TerminalRuntimeRefs>(() => ({
|
||||
xtermRuntimeRef,
|
||||
@@ -1282,6 +1438,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
retryTokenRef.current = null;
|
||||
restoreCwdIntentRef.current = null;
|
||||
suppressHostStartupCommandRef.current = false;
|
||||
clearHibernateRetry();
|
||||
clearAutoReconnect();
|
||||
cleanupSession();
|
||||
disposeRuntimeOnly();
|
||||
};
|
||||
@@ -1304,7 +1462,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
terminalBackend,
|
||||
serialConfig,
|
||||
telnetLocalEchoRef,
|
||||
isVisibleRef,
|
||||
isVisibleRef: isRendererActiveRef,
|
||||
isBootActiveRef,
|
||||
pendingOutputScrollRef,
|
||||
sessionRef,
|
||||
@@ -1371,6 +1529,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onSessionExit: (closedSessionId, evt) => {
|
||||
clearTerminalCwd();
|
||||
onSessionExit?.(closedSessionId, evt);
|
||||
scheduleAutoReconnect({ evt });
|
||||
},
|
||||
onTerminalDataCapture: handleTerminalDataCaptureOnce,
|
||||
onTerminalOutput: onTerminalOutput
|
||||
@@ -1400,12 +1559,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
sessionStartersRef.current = sessionStarters;
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'disconnected') {
|
||||
if (status === 'disconnected' && !autoReconnectLoopActiveRef.current) {
|
||||
connectScriptsConsumedRef.current = false;
|
||||
connectScriptsCompletedIdsRef.current = new Set();
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "disconnected") return;
|
||||
scheduleAutoReconnect();
|
||||
}, [scheduleAutoReconnect, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoReconnectLoopActiveRef.current) return;
|
||||
if (shouldContinueAutoReconnectAfterFailure({ host, terminalSettings, loopActive: true })) return;
|
||||
const hadPendingReconnect = autoReconnectTimerRef.current !== null;
|
||||
clearAutoReconnect();
|
||||
if (hadPendingReconnect) {
|
||||
updateStatus("disconnected");
|
||||
}
|
||||
}, [clearAutoReconnect, host, terminalSettings, updateStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
pendingScriptRunIdRef.current = null;
|
||||
pendingScriptHandledRef.current = null;
|
||||
@@ -1559,6 +1733,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;
|
||||
@@ -1626,7 +1815,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;
|
||||
}
|
||||
@@ -1672,6 +1861,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).
|
||||
@@ -1893,12 +2089,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
const defaultPath = getRememberedYmodemSendDefaultPath();
|
||||
const filePath = await selectFile(
|
||||
t("terminal.ymodem.selectFile"),
|
||||
undefined,
|
||||
defaultPath,
|
||||
[{ name: t("terminal.ymodem.allFiles"), extensions: ["*"] }],
|
||||
);
|
||||
if (!filePath) return;
|
||||
rememberYmodemSendFilePath(filePath);
|
||||
|
||||
const fileName = filePath.split(/[\\/]/).pop() || filePath;
|
||||
toast.info(t("terminal.ymodem.started", { fileName }));
|
||||
@@ -1960,6 +2158,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (pendingHostKeyRequestId) {
|
||||
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
|
||||
}
|
||||
clearAutoReconnect();
|
||||
retryTokenRef.current = null;
|
||||
restoreCwdIntentRef.current = null;
|
||||
setIsCancelling(true);
|
||||
@@ -1979,9 +2178,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const handleDismissDisconnectedDialog = () => {
|
||||
setIsDisconnectedDialogDismissed(true);
|
||||
queueMicrotask(() => termRef.current?.focus());
|
||||
};
|
||||
|
||||
const handleCloseDisconnectedSession = () => {
|
||||
clearAutoReconnect();
|
||||
retryTokenRef.current = null;
|
||||
restoreCwdIntentRef.current = null;
|
||||
onCloseSession?.(sessionId);
|
||||
@@ -2023,9 +2224,30 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setPendingHostKeyRequestId(null);
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
const startReconnect = (mode: "manual" | "auto" = "manual") => {
|
||||
if (!termRef.current && mode === "auto" && hibernatedRef.current) {
|
||||
const wakeForReconnect = wakeHibernatedRuntimeForReconnectRef.current;
|
||||
if (!wakeForReconnect) {
|
||||
updateStatus("disconnected");
|
||||
return;
|
||||
}
|
||||
void wakeForReconnect().then((woke) => {
|
||||
if (woke) {
|
||||
startReconnectRef.current?.("auto");
|
||||
return;
|
||||
}
|
||||
updateStatus("disconnected");
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!termRef.current) return;
|
||||
prepareRestoredReconnect();
|
||||
if (mode === "manual") {
|
||||
clearAutoReconnect();
|
||||
prepareRestoredReconnect();
|
||||
} else {
|
||||
restoreCwdIntentRef.current = null;
|
||||
suppressHostStartupCommandRef.current = true;
|
||||
}
|
||||
cleanupSession();
|
||||
const term = termRef.current;
|
||||
// Claim a fresh retry token. If the user cancels / closes / unmounts /
|
||||
@@ -2039,12 +2261,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isBootActiveRef.current = true;
|
||||
auth.resetForRetry();
|
||||
terminalDataCapturedRef.current = false;
|
||||
hasRunStartupCommandRef.current = false;
|
||||
if (mode === "manual") {
|
||||
hasRunStartupCommandRef.current = false;
|
||||
}
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
setConnectionReuseFellBack(false);
|
||||
updateStatus("connecting");
|
||||
setError(null);
|
||||
setProgressLogs(["Retrying secure channel..."]);
|
||||
setProgressLogs((prev) => (
|
||||
mode === "auto"
|
||||
? [...prev, t("terminal.progress.autoReconnectAttempt", { attempt: autoReconnectAttemptRef.current })]
|
||||
: ["Retrying secure channel..."]
|
||||
));
|
||||
setShowLogs(true);
|
||||
|
||||
const startNewSession = () => {
|
||||
@@ -2094,6 +2322,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
);
|
||||
});
|
||||
};
|
||||
startReconnectRef.current = startReconnect;
|
||||
|
||||
const handleRetry = () => {
|
||||
startReconnect("manual");
|
||||
};
|
||||
|
||||
const shouldShowConnectionDialog = shouldShowTerminalConnectionDialog({
|
||||
status,
|
||||
@@ -2239,7 +2472,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
|
||||
@@ -2286,6 +2528,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isSessionLogging,
|
||||
isWorkspaceComposeBarOpen,
|
||||
onCloseSession,
|
||||
onDetach,
|
||||
onOpenScripts,
|
||||
onOpenHistory,
|
||||
onOpenTheme,
|
||||
@@ -2378,15 +2621,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,
|
||||
@@ -2457,6 +2718,43 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
});
|
||||
}, [sessionId, terminalBackend, terminalRuntimeRefs, resizeSession, terminalSettings]);
|
||||
|
||||
wakeHibernatedRuntimeForReconnectRef.current = async () => {
|
||||
if (!hibernatedRef.current) {
|
||||
return Boolean(termRef.current);
|
||||
}
|
||||
|
||||
const getPayload = (): TerminalHibernateWakePayload => ({
|
||||
snapshot: hibernateSnapshotRef.current,
|
||||
viewportSnapshot: hibernateViewportSnapshotRef.current || hibernateSnapshotRef.current,
|
||||
scrollbackSnapshot: hibernateScrollbackSnapshotRef.current,
|
||||
pendingBuffer: hibernatePendingBufferRef.current,
|
||||
alternateScreen: hibernateAlternateScreenRef.current,
|
||||
});
|
||||
|
||||
logger.info("[Terminal] Waking hibernated runtime for auto reconnect", {
|
||||
sessionId,
|
||||
snapshotChars: hibernateSnapshotRef.current.length,
|
||||
viewportChars: hibernateViewportSnapshotRef.current.length,
|
||||
scrollbackChars: hibernateScrollbackSnapshotRef.current.length,
|
||||
pendingChars: hibernatePendingBufferRef.current.length,
|
||||
});
|
||||
|
||||
const accepted = await Promise.resolve(wakeFromHibernateRuntime(getPayload, { sessionConnected: false }));
|
||||
if (accepted === false || !termRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
clearHibernateRuntimeState();
|
||||
return true;
|
||||
};
|
||||
|
||||
const hibernateFileTransferActive = isTerminalFileTransferActive({
|
||||
zmodemActive: zmodem.active,
|
||||
ymodemInProgress,
|
||||
isDraggingOver,
|
||||
});
|
||||
hibernateFileTransferActiveRef.current = hibernateFileTransferActive;
|
||||
|
||||
useTerminalHibernateEffect({
|
||||
sessionId,
|
||||
isVisible,
|
||||
@@ -2466,11 +2764,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,
|
||||
@@ -2491,7 +2785,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
|
||||
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
|
||||
<ScriptExecutionOverlay
|
||||
run={activeScriptRun}
|
||||
onPause={() => { void pauseScriptRun(activeScriptRun.runId); }}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -32,12 +32,15 @@ const {
|
||||
} = await import("../application/state/terminalDragData.ts");
|
||||
const {
|
||||
activateLogViewTab,
|
||||
createSessionTopTabDoubleClickHandler,
|
||||
formatSessionTopTabLabel,
|
||||
formatSessionTopTabTooltip,
|
||||
stopCloseButtonDoubleClickPropagation,
|
||||
} = await import("./top-tabs/TopTabItems.tsx");
|
||||
const { activeTabStore } = await import("../application/state/activeTabStore.ts");
|
||||
const indexCss = readFileSync(new URL("../index.css", import.meta.url), "utf8");
|
||||
const topTabsSource = readFileSync(new URL("./TopTabs.tsx", import.meta.url), "utf8");
|
||||
const topTabItemsSource = readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8");
|
||||
const terminalViewSource = readFileSync(new URL("./terminal/TerminalView.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("host tree tab gutter fills the remaining sidebar width", () => {
|
||||
@@ -108,14 +111,39 @@ test("quick switcher plus button exposes a custom CSS hook", () => {
|
||||
});
|
||||
|
||||
test("SessionTabIcon checks custom host icon appearance before distro logos", () => {
|
||||
const source = readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /resolveHostIconAppearance\(host\)/);
|
||||
assert.match(topTabItemsSource, /resolveHostIconAppearance\(host\)/);
|
||||
assert.ok(
|
||||
source.indexOf("resolveHostIconAppearance(host)") < source.indexOf("getEffectiveHostDistro(host)"),
|
||||
topTabItemsSource.indexOf("resolveHostIconAppearance(host)") < topTabItemsSource.indexOf("getEffectiveHostDistro(host)"),
|
||||
"custom host icon should be checked before distro fallback",
|
||||
);
|
||||
});
|
||||
|
||||
test("session top tabs copy the session on double click through the existing copy handler", () => {
|
||||
const copiedSessionIds: string[] = [];
|
||||
const handleDoubleClick = createSessionTopTabDoubleClickHandler(
|
||||
(sessionId) => copiedSessionIds.push(sessionId),
|
||||
"session-1",
|
||||
);
|
||||
|
||||
handleDoubleClick({} as Parameters<typeof handleDoubleClick>[0]);
|
||||
|
||||
assert.deepEqual(copiedSessionIds, ["session-1"]);
|
||||
assert.match(topTabItemsSource, /onDoubleClick=\{handleDoubleClick\}/);
|
||||
});
|
||||
|
||||
test("session close button double click stays on the close button", () => {
|
||||
let stopPropagationCount = 0;
|
||||
|
||||
stopCloseButtonDoubleClickPropagation({
|
||||
stopPropagation: () => {
|
||||
stopPropagationCount += 1;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(stopPropagationCount, 1);
|
||||
assert.match(topTabItemsSource, /onDoubleClick=\{stopCloseButtonDoubleClickPropagation\}/);
|
||||
});
|
||||
|
||||
test("session top tab label only shows the host label for ssh sessions", () => {
|
||||
assert.equal(
|
||||
formatSessionTopTabLabel({
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -559,7 +559,13 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
|
||||
{/* Standalone MCP/SDK approval requests (not tied to SDK tool calls) */}
|
||||
{!hideToolCalls && Array.from(pendingApprovals.entries())
|
||||
.filter(([id, req]) => id.startsWith('mcp_approval_') && (!activeSessionId || req.chatSessionId === activeSessionId))
|
||||
.filter(([id, req]) => {
|
||||
if (!id.startsWith('mcp_approval_')) return false;
|
||||
// External MCP approvals render in ExternalMcpApprovalsHost so they
|
||||
// remain visible even when the Catty AI panel is closed.
|
||||
if (req.chatSessionId === '__external_mcp__') return false;
|
||||
return !activeSessionId || req.chatSessionId === activeSessionId;
|
||||
})
|
||||
.map(([id, req]) => {
|
||||
return (
|
||||
<React.Profiler key={id} {...getAIPanelProfilerProps('AIChatPanel.ToolCall.Approval')}>
|
||||
|
||||
Loaded 100 of 490 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user