Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f0d3d8274 | ||
|
|
d8c62a55f5 | ||
|
|
1b08e5ee88 | ||
|
|
de7057183c | ||
|
|
dd910cc53d | ||
|
|
8ccefc821c | ||
|
|
863397fc7d | ||
|
|
6a39ed05a9 | ||
|
|
470d9b5aae | ||
|
|
20694a47dd | ||
|
|
d86c5ed05a | ||
|
|
fdaaaf62d8 | ||
|
|
2ceea46b50 | ||
|
|
5a1d6931a5 | ||
|
|
fb97e242ee | ||
|
|
68040ebdd7 | ||
|
|
cca6dac543 | ||
|
|
d86b720748 | ||
|
|
aa192c66c3 | ||
|
|
7dd25a55bb | ||
|
|
e4e1b54374 | ||
|
|
4dd2465388 | ||
|
|
b6734b9ef9 | ||
|
|
fb443541aa | ||
|
|
7622c43c38 | ||
|
|
a4a5c703b1 | ||
|
|
2063a5ccfe | ||
|
|
1fcf77ef4d | ||
|
|
8296c2c780 | ||
|
|
d1e6857f76 | ||
|
|
eccb9f2cfc | ||
|
|
74d56cdcb8 | ||
|
|
cd04b0b33c | ||
|
|
a29953f831 | ||
|
|
c941038e68 | ||
|
|
b1ab4d7105 | ||
|
|
08e566adb0 | ||
|
|
df25d6c4b0 | ||
|
|
324301e61a | ||
|
|
2c3a8e7fb8 | ||
|
|
bd2642be74 | ||
|
|
23151c9db8 |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
8
.github/scripts/generate-release-note.js
vendored
8
.github/scripts/generate-release-note.js
vendored
@@ -56,8 +56,7 @@ const files = {
|
||||
x64: `Netcatty-${version}-mac-x64.dmg`
|
||||
},
|
||||
win: {
|
||||
x64: `Netcatty-${version}-win-x64.exe`,
|
||||
arm64: `Netcatty-${version}-win-arm64.exe`
|
||||
x64: `Netcatty-${version}-win-x64.exe`
|
||||
},
|
||||
linux: {
|
||||
appimage: {
|
||||
@@ -77,8 +76,7 @@ const files = {
|
||||
|
||||
const badges = {
|
||||
win: {
|
||||
setup_x64: `[](${baseUrl}/${files.win.x64})`,
|
||||
setup_arm64: `[](${baseUrl}/${files.win.arm64})`
|
||||
setup_x64: `[](${baseUrl}/${files.win.x64})`
|
||||
},
|
||||
mac: {
|
||||
apple_silicon: `[](${baseUrl}/${files.mac.arm64})`,
|
||||
@@ -99,7 +97,7 @@ const content = `
|
||||
|
||||
| OS | Download |
|
||||
| :--- | :--- |
|
||||
| **Windows** | ${badges.win.setup_x64} ${badges.win.setup_arm64} |
|
||||
| **Windows** | ${badges.win.setup_x64} |
|
||||
| **macOS** | ${badges.mac.apple_silicon} ${badges.mac.intel} |
|
||||
| **Linux** | ${badges.linux.appimage_x64} ${badges.linux.deb_x64} ${badges.linux.rpm_x64} <br> ${badges.linux.appimage_arm64} ${badges.linux.deb_arm64} ${badges.linux.rpm_arm64} |
|
||||
`;
|
||||
|
||||
262
.github/workflows/build-mosh-binaries.yml
vendored
Normal file
262
.github/workflows/build-mosh-binaries.yml
vendored
Normal file
@@ -0,0 +1,262 @@
|
||||
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
|
||||
# mosh on every push — this workflow is expensive (~30min Cygwin leg).
|
||||
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 — in-CI Cygwin build from upstream mobile-shell/mosh
|
||||
# source. Cygwin's POSIX runtime can't be fully statically linked, so
|
||||
# we accept the dynamic Cygwin DLL deps and bundle them alongside the
|
||||
# exe (cygcheck-discovered, ~10 MB total). The pinned-FluentTerminal
|
||||
# path is preserved as `fetch-windows.sh` for emergency fallback.
|
||||
# ------------------------------------------------------------------
|
||||
build-windows-x64:
|
||||
name: build-windows-x64
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Cygwin
|
||||
uses: cygwin/cygwin-install-action@v5
|
||||
with:
|
||||
add-to-path: false
|
||||
# Keep package signature checks, but avoid the setup.exe hash
|
||||
# fetch path that currently fails on windows-latest runners.
|
||||
check-hash: false
|
||||
packages: >
|
||||
gcc-g++ make autoconf automake libtool perl perl_pods pkg-config git
|
||||
openssl-devel libssl-devel libprotobuf-devel libncurses-devel
|
||||
libncursesw-devel zlib-devel protobuf-compiler
|
||||
- name: Build mosh-client.exe (win32-x64)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$cygwinBin = "C:\cygwin\bin"
|
||||
$workspace = (& "$cygwinBin\cygpath.exe" -u "$env:GITHUB_WORKSPACE").Trim()
|
||||
$scriptPath = Join-Path $env:RUNNER_TEMP "build-mosh-windows.sh"
|
||||
$script = @'
|
||||
set -euo pipefail
|
||||
cd "__WORKSPACE__"
|
||||
export MOSH_REF="${MOSH_REF:?missing MOSH_REF}"
|
||||
export ARCH=x64
|
||||
export OUT_DIR="__WORKSPACE__/out"
|
||||
mkdir -p "$OUT_DIR"
|
||||
bash scripts/build-mosh/build-windows.sh
|
||||
'@
|
||||
$script = $script.Replace("__WORKSPACE__", $workspace).Replace("`r`n", "`n")
|
||||
Set-Content -Path $scriptPath -Value $script -NoNewline -Encoding utf8
|
||||
$scriptPathCygwin = (& "$cygwinBin\cygpath.exe" -u "$scriptPath").Trim()
|
||||
& "$cygwinBin\bash.exe" --login "$scriptPathCygwin"
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mosh-client-win32-x64
|
||||
path: out/
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Windows arm64 — intentionally not built.
|
||||
# Cygwin's arm64 port is still experimental (no stable cygwin1.dll
|
||||
# release for aarch64 as of this commit), so we don't attempt an
|
||||
# arm64 mosh build. arm64 Windows installs fall through to the
|
||||
# legacy `mosh` wrapper path in terminalBridge.startMoshSession.
|
||||
# When upstream Cygwin ships a stable arm64 build, drop the same
|
||||
# cygwin-install-action job below with `platform: arm64`.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Aggregate + optional release to the dedicated binary repository.
|
||||
# ------------------------------------------------------------------
|
||||
release:
|
||||
name: release
|
||||
needs:
|
||||
- build-linux-x64
|
||||
- build-linux-arm64
|
||||
- build-macos-universal
|
||||
- build-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 'Built from `mobile-shell/mosh` upstream ref `%s`.\n\n' "${MOSH_REF}"
|
||||
printf 'Source workflow: %s/%s/actions/runs/%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_RUN_ID}"
|
||||
printf 'Source commit: `%s`\n\n' "${GITHUB_SHA}"
|
||||
printf '%s\n' 'All artifacts are GPL-3.0; see `resources/mosh/README.md` for source provenance.'
|
||||
} > release-notes.md
|
||||
if gh release view "${RELEASE_TAG}" --repo "${RELEASE_REPO}" >/dev/null 2>&1; then
|
||||
gh release edit "${RELEASE_TAG}" \
|
||||
--repo "${RELEASE_REPO}" \
|
||||
--title "${RELEASE_TAG}" \
|
||||
--notes-file release-notes.md
|
||||
gh release upload "${RELEASE_TAG}" release/* \
|
||||
--repo "${RELEASE_REPO}" \
|
||||
--clobber
|
||||
else
|
||||
gh release create "${RELEASE_TAG}" release/* \
|
||||
--repo "${RELEASE_REPO}" \
|
||||
--title "${RELEASE_TAG}" \
|
||||
--notes-file release-notes.md
|
||||
fi
|
||||
310
.github/workflows/build.yml
vendored
310
.github/workflows/build.yml
vendored
@@ -1,5 +1,23 @@
|
||||
name: build-packages
|
||||
|
||||
# Trigger philosophy
|
||||
# - Any push to any branch + any PR -> run the build matrix so CI is
|
||||
# always testable. Same-repo PR runs own package validation; matching
|
||||
# branch push runs become a lightweight mirror only after a current
|
||||
# open PR run for the same commit is visible. If lookup is slow or
|
||||
# unavailable, the push run falls back to the full matrix. Artifacts
|
||||
# upload as workflow artifacts only; *no* GitHub Release is published.
|
||||
# - Tag push matching `v<MAJOR>.<MINOR>.<PATCH>` (with optional
|
||||
# pre-release suffix like `v1.2.3-rc.1`) -> run the matrix and
|
||||
# publish a GitHub Release. Loose tags like `v-test`, `vNEXT`, or
|
||||
# `v1.0` no longer auto-publish.
|
||||
# - Manual `workflow_dispatch` -> run the matrix on the selected ref.
|
||||
# `publish_release` only publishes when the selected ref is also a
|
||||
# strict version tag.
|
||||
#
|
||||
# The release job validates the exact same rule before publishing, so
|
||||
# adding branches/PRs above is safe; accidental tag-like branch names
|
||||
# won't leak a release.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -7,13 +25,179 @@ on:
|
||||
description: "Publish GitHub Release after build"
|
||||
type: boolean
|
||||
default: false
|
||||
mosh_bin_release:
|
||||
description: "Release tag containing bundled mosh-client binaries"
|
||||
type: string
|
||||
default: ""
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
tags:
|
||||
- "v*"
|
||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||
- "v[0-9]+.[0-9]+.[0-9]+-[0-9A-Za-z]*"
|
||||
pull_request:
|
||||
|
||||
# A newer run for the same push branch or PR cancels older in-progress
|
||||
# work. Push and PR events stay in separate groups so deduped push runs
|
||||
# can mirror PR results cleanly instead of leaving cancelled checks on
|
||||
# the PR. Publishing tag runs share a release group across push and
|
||||
# manual dispatch; non-publishing manual tag runs use their own group.
|
||||
concurrency:
|
||||
group: build-packages-${{ github.workflow }}-${{ startsWith(github.ref, 'refs/tags/') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release)) && 'release' || github.event_name }}-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.ref_type }}-${{ github.event.pull_request.head.ref || github.ref_name }}
|
||||
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
env:
|
||||
MOSH_BIN_RELEASE: ${{ github.event.inputs.mosh_bin_release || vars.MOSH_BIN_RELEASE || '' }}
|
||||
BUNDLE_MOSH: ${{ (startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))) || (github.event_name == 'workflow_dispatch' && inputs.mosh_bin_release != '') }}
|
||||
STRICT_VERSION_REF_RE: '^refs/tags/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[A-Za-z][0-9A-Za-z-]*|[0-9A-Za-z][0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[A-Za-z][0-9A-Za-z-]*|[0-9A-Za-z][0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?$'
|
||||
|
||||
jobs:
|
||||
dedupe:
|
||||
name: dedupe push run
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
skip_heavy_ci: ${{ steps.detect.outputs.skip_heavy_ci }}
|
||||
heavy_ci_pr_run_id: ${{ steps.detect.outputs.heavy_ci_pr_run_id }}
|
||||
steps:
|
||||
- name: Detect duplicate heavy CI
|
||||
id: detect
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
REPOSITORY_OWNER: ${{ github.repository_owner }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REF: ${{ github.ref }}
|
||||
HEAD_REF: ${{ github.ref_name }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
skip_heavy_ci=false
|
||||
if [[ "$EVENT_NAME" == "push" && "$REF" == refs/heads/* ]]; then
|
||||
pr_count=0
|
||||
if ! pr_count="$(gh api --method GET "repos/${REPOSITORY}/pulls" \
|
||||
-f state=open \
|
||||
-f "head=${REPOSITORY_OWNER}:${HEAD_REF}" \
|
||||
-F per_page=1 \
|
||||
--jq 'length')"; then
|
||||
echo "::warning::Could not check open PRs; running full push CI."
|
||||
pr_count=0
|
||||
fi
|
||||
|
||||
pr_run_id=""
|
||||
if [[ "$pr_count" != "0" ]]; then
|
||||
cutoff="$(date -u -d '20 minutes ago' +'%Y-%m-%dT%H:%M:%SZ')"
|
||||
for attempt in {1..18}; do
|
||||
if ! pr_run_id="$(gh api --method GET "repos/${REPOSITORY}/actions/workflows/build.yml/runs" \
|
||||
-f event=pull_request \
|
||||
-f "branch=${HEAD_REF}" \
|
||||
-f "head_sha=${HEAD_SHA}" \
|
||||
-F per_page=20 \
|
||||
--jq "[.workflow_runs[] | select(.created_at >= \"${cutoff}\" and .conclusion != \"cancelled\" and .conclusion != \"skipped\")] | sort_by(.created_at, .id) | .[0].id // \"\"")"; then
|
||||
echo "::warning::Could not check PR workflow runs; running full push CI."
|
||||
pr_run_id=""
|
||||
break
|
||||
fi
|
||||
if [[ -n "$pr_run_id" ]]; then
|
||||
skip_heavy_ci=true
|
||||
break
|
||||
fi
|
||||
if [[ "$attempt" == "18" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
fi
|
||||
if [[ -n "$pr_run_id" ]]; then
|
||||
echo "heavy_ci_pr_run_id=${pr_run_id}" >> "$GITHUB_OUTPUT"
|
||||
echo "heavy_ci_pr_run_id=${pr_run_id}"
|
||||
fi
|
||||
fi
|
||||
echo "skip_heavy_ci=${skip_heavy_ci}" >> "$GITHUB_OUTPUT"
|
||||
echo "skip_heavy_ci=${skip_heavy_ci}"
|
||||
|
||||
dedupe-result:
|
||||
name: dedupe result
|
||||
needs: dedupe
|
||||
if: needs.dedupe.outputs.skip_heavy_ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Mirror PR build result
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
PR_RUN_ID: ${{ needs.dedupe.outputs.heavy_ci_pr_run_id }}
|
||||
run: |
|
||||
if [[ -z "$PR_RUN_ID" ]]; then
|
||||
echo "::error::No PR workflow run was selected for dedupe."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for attempt in {1..360}; do
|
||||
if ! result="$(gh run view "$PR_RUN_ID" --repo "$REPOSITORY" --json status,conclusion --jq '.status + "|" + (.conclusion // "")')"; then
|
||||
echo "::warning::Could not read PR workflow run ${PR_RUN_ID}; retrying."
|
||||
sleep 30
|
||||
continue
|
||||
fi
|
||||
status="${result%%|*}"
|
||||
conclusion="${result#*|}"
|
||||
echo "PR run ${PR_RUN_ID}: status=${status} conclusion=${conclusion:-pending}"
|
||||
|
||||
if [[ "$status" == "completed" ]]; then
|
||||
if [[ "$conclusion" == "success" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
echo "::error::PR workflow run ${PR_RUN_ID} completed with conclusion '${conclusion}'."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 30
|
||||
done
|
||||
|
||||
echo "::error::Timed out waiting for PR workflow run ${PR_RUN_ID}."
|
||||
exit 1
|
||||
|
||||
resolve-mosh:
|
||||
name: resolve bundled mosh-client
|
||||
needs: dedupe
|
||||
if: |
|
||||
needs.dedupe.outputs.skip_heavy_ci != 'true'
|
||||
&& (
|
||||
(startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release)))
|
||||
|| (github.event_name == 'workflow_dispatch' && inputs.mosh_bin_release != '')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
mosh_bin_release: ${{ steps.resolve.outputs.mosh_bin_release }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve bundled mosh-client release
|
||||
id: resolve
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
node scripts/resolve-mosh-bin-release.cjs
|
||||
release="$(grep '^MOSH_BIN_RELEASE=' "$GITHUB_ENV" | tail -n 1 | cut -d= -f2-)"
|
||||
if [[ -z "$release" ]]; then
|
||||
echo "::error::MOSH_BIN_RELEASE was not resolved."
|
||||
exit 1
|
||||
fi
|
||||
echo "mosh_bin_release=${release}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
name: build-${{ matrix.name }}
|
||||
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && format('deduped build-{0}', matrix.name) || format('build-{0}', matrix.name) }}
|
||||
needs: [dedupe, resolve-mosh]
|
||||
if: |
|
||||
always()
|
||||
&& needs.dedupe.result == 'success'
|
||||
&& needs.dedupe.outputs.skip_heavy_ci != 'true'
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -24,13 +208,28 @@ jobs:
|
||||
pack_script: pack:mac
|
||||
- name: windows
|
||||
os: windows-latest
|
||||
pack_script: pack:win
|
||||
# The mosh binary workflow currently produces win32-x64 only.
|
||||
# Keep official packages aligned with bundled-mosh coverage
|
||||
# until Cygwin arm64 is stable enough to build win32-arm64.
|
||||
pack_script: pack:win-x64
|
||||
env:
|
||||
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
|
||||
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
|
||||
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
|
||||
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
|
||||
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
|
||||
steps:
|
||||
- name: Validate bundled mosh-client release
|
||||
if: env.BUNDLE_MOSH == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
RESOLVE_MOSH_RESULT: ${{ needs.resolve-mosh.result }}
|
||||
run: |
|
||||
if [[ "$RESOLVE_MOSH_RESULT" != "success" || -z "$MOSH_BIN_RELEASE" ]]; then
|
||||
echo "::error::Bundled mosh-client release was not resolved for this package build."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -46,27 +245,40 @@ jobs:
|
||||
- name: Install cross-platform native binaries
|
||||
shell: bash
|
||||
run: |
|
||||
# npm ci only installs optional deps for the host platform, but
|
||||
# electron-builder produces both arm64 and x64 binaries, so we
|
||||
# need the native codex-acp binary for the other architecture too.
|
||||
# npm ci only installs optional deps for the host platform.
|
||||
# macOS packages still cover both arm64 and x64, so we need
|
||||
# codex-acp for both architectures there.
|
||||
# Platform-specific codex-acp packages declare cpu/os constraints,
|
||||
# so --force is needed to install the non-host-arch binary.
|
||||
CODEX_VER=$(node -e "console.log(require('./node_modules/@zed-industries/codex-acp/package.json').version)")
|
||||
if [[ "${{ matrix.name }}" == "macos" ]]; then
|
||||
npm install "@zed-industries/codex-acp-darwin-x64@${CODEX_VER}" "@zed-industries/codex-acp-darwin-arm64@${CODEX_VER}" --no-save --force
|
||||
elif [[ "${{ matrix.name }}" == "windows" ]]; then
|
||||
npm install "@zed-industries/codex-acp-win32-x64@${CODEX_VER}" "@zed-industries/codex-acp-win32-arm64@${CODEX_VER}" --no-save --force
|
||||
npm install "@zed-industries/codex-acp-win32-x64@${CODEX_VER}" --no-save --force
|
||||
fi
|
||||
|
||||
- name: Fetch bundled mosh-client
|
||||
if: env.BUNDLE_MOSH == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${{ matrix.name }}" == "macos" ]]; then
|
||||
npm run fetch:mosh -- --platform=darwin --arch=universal
|
||||
elif [[ "${{ matrix.name }}" == "windows" ]]; then
|
||||
npm run fetch:mosh -- --platform=win32 --arch=x64
|
||||
fi
|
||||
|
||||
- name: Set version
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
# Tag release: use version from tag
|
||||
# Strict semver matches v<MAJOR>.<MINOR>.<PATCH>[-pre]; loose
|
||||
# tags / branches / PRs fall through to a semver-pre-release
|
||||
# form (`0.0.0-sha-<short-sha>`) so npm pkg / electron-builder
|
||||
# accept it. Non-semver versions (e.g. bare "abc1234") cause
|
||||
# downstream tooling to error or pick weird codepaths.
|
||||
if [[ "$GITHUB_REF" =~ $STRICT_VERSION_REF_RE ]]; then
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
else
|
||||
# workflow_dispatch: use short commit ID
|
||||
VERSION="${GITHUB_SHA:0:7}"
|
||||
VERSION="0.0.0-sha-${GITHUB_SHA:0:7}"
|
||||
fi
|
||||
echo "Setting version to ${VERSION}"
|
||||
npm pkg set version="${VERSION}"
|
||||
@@ -105,9 +317,15 @@ jobs:
|
||||
# compatible with most current Linux distributions including Arch.
|
||||
# See #264.
|
||||
build-linux-x64:
|
||||
name: build-linux-x64
|
||||
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }}
|
||||
needs: [dedupe, resolve-mosh]
|
||||
if: |
|
||||
always()
|
||||
&& needs.dedupe.result == 'success'
|
||||
&& needs.dedupe.outputs.skip_heavy_ci != 'true'
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
|
||||
npm_config_arch: x64
|
||||
npm_config_target_arch: x64
|
||||
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
|
||||
@@ -115,6 +333,17 @@ jobs:
|
||||
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
|
||||
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
|
||||
steps:
|
||||
- name: Validate bundled mosh-client release
|
||||
if: env.BUNDLE_MOSH == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
RESOLVE_MOSH_RESULT: ${{ needs.resolve-mosh.result }}
|
||||
run: |
|
||||
if [[ "$RESOLVE_MOSH_RESULT" != "success" || -z "$MOSH_BIN_RELEASE" ]]; then
|
||||
echo "::error::Bundled mosh-client release was not resolved for this package build."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -130,10 +359,13 @@ jobs:
|
||||
- name: Set version
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
# See matrix job's Set version step for the strict-semver
|
||||
# rationale; identical logic, duplicated because the Linux
|
||||
# legs are standalone jobs.
|
||||
if [[ "$GITHUB_REF" =~ $STRICT_VERSION_REF_RE ]]; then
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
else
|
||||
VERSION="${GITHUB_SHA:0:7}"
|
||||
VERSION="0.0.0-sha-${GITHUB_SHA:0:7}"
|
||||
fi
|
||||
echo "Setting version to ${VERSION}"
|
||||
npm pkg set version="${VERSION}"
|
||||
@@ -143,6 +375,10 @@ jobs:
|
||||
npm_config_arch: x64
|
||||
run: bash scripts/ensure-node-pty-linux.sh prepare x64
|
||||
|
||||
- name: Fetch bundled mosh-client
|
||||
if: env.BUNDLE_MOSH == 'true'
|
||||
run: npm run fetch:mosh -- --platform=linux --arch=x64
|
||||
|
||||
- name: Build package
|
||||
env:
|
||||
npm_config_arch: x64
|
||||
@@ -171,11 +407,17 @@ jobs:
|
||||
# to ensure compatibility with older distros like UOS/Deepin (GLIBC 2.28).
|
||||
# Key: GLIBC < 2.34 avoids the libpthread-merge symbol requirement.
|
||||
build-linux-arm64:
|
||||
name: build-linux-arm64
|
||||
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }}
|
||||
needs: [dedupe, resolve-mosh]
|
||||
if: |
|
||||
always()
|
||||
&& needs.dedupe.result == 'success'
|
||||
&& needs.dedupe.outputs.skip_heavy_ci != 'true'
|
||||
runs-on: ubuntu-24.04-arm
|
||||
container:
|
||||
image: debian:bullseye
|
||||
env:
|
||||
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
|
||||
npm_config_arch: arm64
|
||||
npm_config_target_arch: arm64
|
||||
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
|
||||
@@ -183,6 +425,17 @@ jobs:
|
||||
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
|
||||
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
|
||||
steps:
|
||||
- name: Validate bundled mosh-client release
|
||||
if: env.BUNDLE_MOSH == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
RESOLVE_MOSH_RESULT: ${{ needs.resolve-mosh.result }}
|
||||
run: |
|
||||
if [[ "$RESOLVE_MOSH_RESULT" != "success" || -z "$MOSH_BIN_RELEASE" ]]; then
|
||||
echo "::error::Bundled mosh-client release was not resolved for this package build."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
@@ -201,10 +454,13 @@ jobs:
|
||||
- name: Set version
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
# See matrix job's Set version step for the strict-semver
|
||||
# rationale; identical logic, duplicated because the Linux
|
||||
# legs are standalone jobs.
|
||||
if [[ "$GITHUB_REF" =~ $STRICT_VERSION_REF_RE ]]; then
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
else
|
||||
VERSION="${GITHUB_SHA:0:7}"
|
||||
VERSION="0.0.0-sha-${GITHUB_SHA:0:7}"
|
||||
fi
|
||||
echo "Setting version to ${VERSION}"
|
||||
npm pkg set version="${VERSION}"
|
||||
@@ -214,6 +470,10 @@ jobs:
|
||||
npm_config_arch: arm64
|
||||
run: bash scripts/ensure-node-pty-linux.sh prepare arm64
|
||||
|
||||
- name: Fetch bundled mosh-client
|
||||
if: env.BUNDLE_MOSH == 'true'
|
||||
run: npm run fetch:mosh -- --platform=linux --arch=arm64
|
||||
|
||||
- name: Build package
|
||||
env:
|
||||
npm_config_arch: arm64
|
||||
@@ -242,7 +502,12 @@ jobs:
|
||||
name: release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build, build-linux-x64, build-linux-arm64]
|
||||
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
|
||||
# Only release on a strict v<MAJOR>.<MINOR>.<PATCH>[-pre] tag.
|
||||
# Manual workflow_dispatch can publish only when it is run from one
|
||||
# of those tags. PRs and branch pushes skip this job.
|
||||
if: |
|
||||
startsWith(github.ref, 'refs/tags/v')
|
||||
&& (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
@@ -250,6 +515,14 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Validate release tag
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ ! "$GITHUB_REF" =~ $STRICT_VERSION_REF_RE ]]; then
|
||||
echo "::error::Release tags must be v<MAJOR>.<MINOR>.<PATCH> or v<MAJOR>.<MINOR>.<PATCH>-<prerelease>."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -318,6 +591,7 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
body_path: release_notes.md
|
||||
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||
files: |
|
||||
artifacts/*.dmg
|
||||
artifacts/*.zip
|
||||
|
||||
37
.github/workflows/test.yml
vendored
Normal file
37
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
name: test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
|
||||
concurrency:
|
||||
group: test-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: lint-and-test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -63,3 +63,13 @@ Directory.Build.props
|
||||
Directory.Build.targets
|
||||
build_with_vs.bat
|
||||
build_with_vs2022.bat
|
||||
|
||||
# Bundled mosh-client binaries fetched at pack time by
|
||||
# scripts/fetch-mosh-binaries.cjs. resources/mosh/README.md is
|
||||
# committed; the actual binaries (and on Windows the Cygwin DLL
|
||||
# bundle that ships alongside mosh-client.exe) are pulled from the
|
||||
# dedicated mosh binary repository, never committed.
|
||||
/resources/mosh/*/mosh-client
|
||||
/resources/mosh/*/mosh-client.exe
|
||||
/resources/mosh/*/mosh-client-*-dlls/
|
||||
/resources/mosh/*/*.dll
|
||||
|
||||
90
App.tsx
90
App.tsx
@@ -17,13 +17,14 @@ import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
|
||||
import { matchesKeyBinding } from './domain/models';
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from './domain/groupConfig';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { resolveHostTerminalThemeId } from './domain/terminalAppearance';
|
||||
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from './domain/terminalAppearance';
|
||||
import { collectSessionIds } from './domain/workspace';
|
||||
import { resolveCloseIntent } from './application/state/resolveCloseIntent';
|
||||
import { resolveSnippetsShortcutIntent } from './application/state/resolveSnippetsShortcutIntent';
|
||||
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from './application/state/customThemeStore';
|
||||
import type { SyncPayload } from './domain/sync';
|
||||
import { applySyncPayload, buildSyncPayload, hasMeaningfulSyncData } from './application/syncPayload';
|
||||
import { applySyncPayload, buildLocalVaultPayload, hasMeaningfulSyncData } from './application/syncPayload';
|
||||
import {
|
||||
applyProtectedSyncPayload,
|
||||
ensureVersionChangeBackup,
|
||||
@@ -57,7 +58,7 @@ import type { SftpView as SftpViewComponent } from './components/SftpView';
|
||||
import type { TerminalLayer as TerminalLayerComponent } from './components/TerminalLayer';
|
||||
import { TextEditorTabView } from './components/editor/TextEditorTabView';
|
||||
import { UnsavedChangesProvider } from './components/editor/UnsavedChangesDialog';
|
||||
import { editorSftpWrite } from './application/state/editorSftpBridge';
|
||||
import { releaseEditorTabSaveCoordinator, saveEditorTab } from './application/state/editorTabSave';
|
||||
|
||||
// Initialize fonts eagerly at app startup
|
||||
initializeFonts();
|
||||
@@ -206,6 +207,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
theme,
|
||||
setTheme,
|
||||
resolvedTheme,
|
||||
accentMode,
|
||||
customAccent,
|
||||
terminalThemeId,
|
||||
setTerminalThemeId,
|
||||
followAppTerminalTheme,
|
||||
@@ -365,14 +368,19 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
if (activeTabId === 'vault' || activeTabId === 'sftp') return null;
|
||||
|
||||
const resolveTheme = (s: TerminalSession): TerminalTheme => {
|
||||
let baseTheme: TerminalTheme;
|
||||
// When "Follow Application Theme" is on, the UI-matched terminal
|
||||
// theme overrides everything — including per-host theme overrides.
|
||||
// This ensures all terminals match the app chrome regardless of
|
||||
// individual host settings.
|
||||
if (followAppTerminalTheme) return currentTerminalTheme;
|
||||
const host = hostById.get(s.hostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
return themeById.get(themeId) || currentTerminalTheme;
|
||||
if (followAppTerminalTheme) {
|
||||
baseTheme = currentTerminalTheme;
|
||||
} else {
|
||||
const host = hostById.get(s.hostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
baseTheme = themeById.get(themeId) || currentTerminalTheme;
|
||||
}
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
};
|
||||
|
||||
// Workspace
|
||||
@@ -402,7 +410,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const session = sessionById.get(activeTabId);
|
||||
if (!session) return null;
|
||||
return resolveTheme(session);
|
||||
}, [activeTabId, currentTerminalTheme, followAppTerminalTheme, hostById, sessionById, themeById, workspaceById]);
|
||||
}, [accentMode, activeTabId, currentTerminalTheme, customAccent, followAppTerminalTheme, hostById, sessionById, themeById, workspaceById]);
|
||||
|
||||
useImmersiveMode({
|
||||
activeTabId,
|
||||
@@ -440,7 +448,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
}
|
||||
|
||||
return buildSyncPayload(
|
||||
return buildLocalVaultPayload(
|
||||
{
|
||||
hosts,
|
||||
keys,
|
||||
@@ -556,7 +564,6 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
portForwardingRules: portForwardingRulesForSync,
|
||||
knownHosts,
|
||||
groupConfigs,
|
||||
settingsVersion: settings.settingsVersion,
|
||||
startupReady: startupSyncSafetyReady,
|
||||
@@ -880,9 +887,26 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onCheckDirtyEditors) return;
|
||||
const unsub = bridge.onCheckDirtyEditors(() => {
|
||||
const hasDirty = editorTabStore.getTabs().some((tab) => tab.content !== tab.baselineContent);
|
||||
if (hasDirty) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty);
|
||||
// Always report SOMETHING so the main process doesn't time out for
|
||||
// 5 s on an unhandled exception. If we can't determine the state,
|
||||
// fail open — losing unsaved work is bad, but stranding the user
|
||||
// on a slow quit and then quitting anyway after the timeout is
|
||||
// exactly the same outcome.
|
||||
let hasDirty = false;
|
||||
try {
|
||||
hasDirty = editorTabStore.getTabs().some((tab) => tab.content !== tab.baselineContent);
|
||||
if (hasDirty) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
|
||||
} catch (err) {
|
||||
console.error('[App] dirty-editors check failed:', err);
|
||||
}
|
||||
try {
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty);
|
||||
} catch (err) {
|
||||
// Reporting itself shouldn't throw, but if the IPC bridge is in a
|
||||
// bad state we'd rather log than bubble out of the listener and
|
||||
// disable the quit guard for the rest of the session.
|
||||
console.error('[App] reportDirtyEditorsResult failed:', err);
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
}, [t]);
|
||||
@@ -1025,6 +1049,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
addConnectionLogRef.current = addConnectionLog;
|
||||
|
||||
const closeSidePanelRef = useRef<(() => void) | null>(null);
|
||||
const toggleScriptsSidePanelRef = useRef<(() => void) | null>(null);
|
||||
const activeSidePanelTabRef = useRef<string | null>(null);
|
||||
const closeTabInFlightRef = useRef(false);
|
||||
// Populated by UnsavedChangesProvider render-prop below so that the hotkey
|
||||
@@ -1286,9 +1311,23 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
setNavigateToSection('port');
|
||||
break;
|
||||
case 'snippets':
|
||||
// Navigate to vault and open snippets section
|
||||
setActiveTabId('vault');
|
||||
setNavigateToSection('snippets');
|
||||
{
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
const intent = resolveSnippetsShortcutIntent({
|
||||
activeTabId: currentId,
|
||||
sessionForTab: sessions.find((s) => s.id === currentId) ?? null,
|
||||
workspaceForTab: workspaces.find((w) => w.id === currentId) ?? null,
|
||||
terminalScriptsToggleAvailable: !!toggleScriptsSidePanelRef.current,
|
||||
});
|
||||
|
||||
if (intent.kind === 'toggleTerminalScripts') {
|
||||
toggleScriptsSidePanelRef.current();
|
||||
break;
|
||||
}
|
||||
|
||||
setActiveTabId('vault');
|
||||
setNavigateToSection('snippets');
|
||||
}
|
||||
break;
|
||||
case 'broadcast': {
|
||||
// Toggle broadcast mode for the active workspace
|
||||
@@ -1730,6 +1769,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const closingTabId = toEditorTabId(id);
|
||||
const list = orderedTabsWithEditors;
|
||||
const idx = list.indexOf(closingTabId);
|
||||
releaseEditorTabSaveCoordinator(id);
|
||||
editorTabStore.close(id);
|
||||
if (activeTabStore.getActiveTabId() !== closingTabId) return;
|
||||
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
|
||||
@@ -1752,16 +1792,15 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
return;
|
||||
}
|
||||
if (choice === 'save') {
|
||||
try {
|
||||
editorTabStore.setSavingState(id, 'saving');
|
||||
await editorSftpWrite(tab.sessionId, tab.hostId, tab.remotePath, tab.content);
|
||||
editorTabStore.markSaved(id, tab.content);
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Save failed';
|
||||
editorTabStore.setSavingState(id, 'error', msg);
|
||||
const ok = await saveEditorTab(id);
|
||||
if (!ok) {
|
||||
const msg = editorTabStore.getTab(id)?.saveError ?? 'Save failed';
|
||||
toast.error(msg, 'SFTP');
|
||||
return;
|
||||
}
|
||||
const latest = editorTabStore.getTab(id);
|
||||
if (!latest || latest.content !== latest.baselineContent) return;
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1882,6 +1921,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
draggingSessionId={draggingSessionId}
|
||||
terminalTheme={currentTerminalTheme}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
accentMode={accentMode}
|
||||
customAccent={customAccent}
|
||||
terminalSettings={terminalSettings}
|
||||
terminalFontFamilyId={terminalFontFamilyId}
|
||||
fontSize={terminalFontSize}
|
||||
@@ -1926,6 +1967,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sessionLogsDir={sessionLogsDir}
|
||||
sessionLogsFormat={sessionLogsFormat}
|
||||
closeSidePanelRef={closeSidePanelRef}
|
||||
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
|
||||
activeSidePanelTabRef={activeSidePanelTabRef}
|
||||
/>
|
||||
|
||||
|
||||
45
README.md
45
README.md
@@ -40,7 +40,8 @@
|
||||
|
||||
---
|
||||
|
||||
[](screenshots/main-window-dark.png)
|
||||
<img width="2868" height="1784" alt="netcatty SSH (Window) 2026-04-23 11:19 PM" src="https://github.com/user-attachments/assets/d6df734f-9ebc-452a-8b7d-e8a0fdc9463a" />
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -48,11 +49,6 @@
|
||||
# 🔥 Catty Agent — Your IT Ops AI Partner
|
||||
|
||||
> 🚀 **Boost your IT ops daily work with AI power.** Catty Agent is the built-in AI assistant that understands your servers, executes commands, and handles complex multi-host operations — all through natural conversation.
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/ai-feature.png" alt="Catty Agent Interface" width="800">
|
||||
</p>
|
||||
|
||||
### 🔥 What can Catty Agent do?
|
||||
|
||||
- 🚀 **Natural language server management** — just tell it what you need, no more memorizing commands
|
||||
@@ -68,7 +64,10 @@
|
||||
Ask Catty Agent to check a server's health, and it runs the right commands, analyzes the output, and gives you a clear summary — all in seconds.
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/eecf08f1-80bd-49db-886d-b36e93388865
|
||||
|
||||
https://github.com/user-attachments/assets/f819a1b6-8cba-4910-8017-97dfc080b477
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -78,8 +77,9 @@ https://github.com/user-attachments/assets/eecf08f1-80bd-49db-886d-b36e93388865
|
||||
Watch Catty Agent orchestrate a Docker Swarm cluster across two servers in one conversation. It handles the init, token exchange, and node joining — you just tell it what you want.
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/52fd30b8-9f02-43d4-a3b2-142691e8e3ec
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/282027aa-5c9e-4bb1-b2c3-5eea9df2b203
|
||||
|
||||
|
||||
|
||||
@@ -160,21 +160,27 @@ Video previews (stored in `screenshots/gifs/`), rendered inline on GitHub:
|
||||
### Vault views: grid / list / tree
|
||||
Switch between different Vault views to match your workflow: overview in grid, dense scanning in list, and hierarchical navigation in tree.
|
||||
|
||||
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
|
||||
|
||||
https://github.com/user-attachments/assets/1ff1f3f1-e5ae-40ea-b35a-0e5148c3afeb
|
||||
|
||||
|
||||
|
||||
### Split terminals + session management
|
||||
Work in multiple sessions at once with split panes. Keep related tasks side-by-side and reduce context switching.
|
||||
|
||||
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/9c24b519-4b4b-4910-a22a-590d04c9af31
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
### SFTP: drag & drop + built-in editor
|
||||
Move files with drag & drop, then edit quickly using the built-in editor without leaving the app.
|
||||
|
||||
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
|
||||
|
||||
https://github.com/user-attachments/assets/f3afdb36-399d-4330-b9f3-4678f178f6db
|
||||
|
||||
|
||||
|
||||
@@ -182,7 +188,11 @@ https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
|
||||
### Drag file upload
|
||||
Drop files into the app to kick off uploads without hunting through dialogs.
|
||||
|
||||
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/e1e26f7a-3489-41cc-975e-8dccba56ea85
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -190,7 +200,10 @@ https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
|
||||
### Custom themes
|
||||
Make Netcatty yours: customize themes and UI appearance.
|
||||
|
||||
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/1a6049aa-9a4c-4d52-a13d-0b007a791b00
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -198,7 +211,11 @@ https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
|
||||
### Keyword highlighting
|
||||
Highlight important terminal output so errors, warnings, and key events stand out at a glance.
|
||||
|
||||
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/1a1db7bd-948b-4f3c-97cd-8fd0cbe7cce7
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -375,6 +375,9 @@ const en: Messages = {
|
||||
'settings.terminal.section.connection': 'Connection',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': 'How often (in seconds) to send SSH-level keepalive packets to server. Set to 0 to disable.',
|
||||
'settings.terminal.connection.x11Display': 'X11 display',
|
||||
'settings.terminal.connection.x11Display.desc': 'Optional local display address for X11 forwarding. Leave empty to use the system default.',
|
||||
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 or DISPLAY)',
|
||||
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
|
||||
'settings.terminal.serverStats.show': 'Show Server Stats',
|
||||
'settings.terminal.serverStats.show.desc': 'Display CPU, memory, and disk usage in the terminal statusbar (Linux servers only).',
|
||||
@@ -775,6 +778,9 @@ const en: Messages = {
|
||||
'sftp.transfers.collapseChildren': 'Hide files',
|
||||
'sftp.transfers.expandChildList': 'Show detail',
|
||||
'sftp.transfers.collapseChildList': 'Hide',
|
||||
'sftp.transfers.retryAction': 'Retry',
|
||||
'sftp.transfers.dismissAction': 'Dismiss',
|
||||
'sftp.transfers.resizeNameColumn': 'Resize file name column',
|
||||
'sftp.transfers.dragToResize': 'Drag to resize',
|
||||
'sftp.goUp': 'Go up',
|
||||
'sftp.goToTerminalCwd': 'Go to terminal directory',
|
||||
@@ -841,8 +847,11 @@ const en: Messages = {
|
||||
'sftp.conflict.size': 'Size:',
|
||||
'sftp.conflict.modified': 'Modified:',
|
||||
'sftp.conflict.applyToAll': 'Apply this action to all {count} remaining conflicts',
|
||||
'sftp.conflict.action.stop': 'Stop',
|
||||
'sftp.conflict.action.skip': 'Skip',
|
||||
'sftp.conflict.action.keepBoth': 'Keep Both',
|
||||
'sftp.conflict.action.duplicate': 'Duplicate',
|
||||
'sftp.conflict.action.merge': 'Merge',
|
||||
'sftp.conflict.action.replace': 'Replace',
|
||||
|
||||
// SFTP Upload Phases
|
||||
@@ -1077,6 +1086,9 @@ const en: Messages = {
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not available',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': 'No SSH agent detected. Enable OpenSSH Authentication Agent in Windows Services, or use a compatible agent such as Bitwarden, 1Password, or gpg-agent.',
|
||||
'hostDetails.section.agentForwarding': 'SSH Agent',
|
||||
'hostDetails.x11Forwarding': 'Forward X11 apps',
|
||||
'hostDetails.x11Forwarding.desc': 'Show remote graphical apps on your local desktop when a local X server is running.',
|
||||
'hostDetails.section.x11Forwarding': 'X11 Forwarding',
|
||||
'hostDetails.section.deviceType': 'Device Type',
|
||||
'hostDetails.deviceType': 'Network Device Mode',
|
||||
'hostDetails.deviceType.desc': 'Enable for network equipment (switches, routers, firewalls) connected via SSH. Commands are sent as-is without shell wrapping, compatible with vendor CLIs like Huawei VRP and Cisco IOS.',
|
||||
|
||||
@@ -562,6 +562,9 @@ const zhCN: Messages = {
|
||||
'sftp.transfers.collapseChildren': '收起文件',
|
||||
'sftp.transfers.expandChildList': '展开详情',
|
||||
'sftp.transfers.collapseChildList': '收起',
|
||||
'sftp.transfers.retryAction': '重试',
|
||||
'sftp.transfers.dismissAction': '移除',
|
||||
'sftp.transfers.resizeNameColumn': '调整文件名列宽',
|
||||
'sftp.transfers.dragToResize': '拖拽调整高度',
|
||||
'sftp.goUp': '上一级',
|
||||
'sftp.goToTerminalCwd': '定位到终端当前目录',
|
||||
@@ -712,6 +715,9 @@ const zhCN: Messages = {
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 不可用',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': '未检测到 SSH Agent。请启用 Windows OpenSSH Authentication Agent 服务,或使用兼容的 Agent(如 Bitwarden、1Password、gpg-agent)。',
|
||||
'hostDetails.section.agentForwarding': 'SSH 代理',
|
||||
'hostDetails.x11Forwarding': '转发 X11 图形应用',
|
||||
'hostDetails.x11Forwarding.desc': '本机运行 X 服务时,让远程图形程序显示在本地桌面。',
|
||||
'hostDetails.section.x11Forwarding': 'X11 转发',
|
||||
'hostDetails.section.deviceType': '设备类型',
|
||||
'hostDetails.deviceType': '网络设备模式',
|
||||
'hostDetails.deviceType.desc': '适用于通过 SSH 连接的网络设备(交换机、路由器、防火墙)。命令将原样发送,不进行 Shell 包装,兼容华为 VRP、Cisco IOS 等厂商 CLI。',
|
||||
@@ -1211,8 +1217,11 @@ const zhCN: Messages = {
|
||||
'sftp.conflict.size': '大小:',
|
||||
'sftp.conflict.modified': '修改时间:',
|
||||
'sftp.conflict.applyToAll': '将此操作应用到剩余的 {count} 个冲突',
|
||||
'sftp.conflict.action.stop': '停止',
|
||||
'sftp.conflict.action.skip': '跳过',
|
||||
'sftp.conflict.action.keepBoth': '保留两者',
|
||||
'sftp.conflict.action.duplicate': '创建副本',
|
||||
'sftp.conflict.action.merge': '合并',
|
||||
'sftp.conflict.action.replace': '替换',
|
||||
|
||||
// SFTP Upload Phases
|
||||
@@ -1456,6 +1465,9 @@ const zhCN: Messages = {
|
||||
'settings.terminal.section.connection': '连接',
|
||||
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': '向服务器发送 SSH 级别保活数据包的频率(秒)。设为 0 表示禁用。',
|
||||
'settings.terminal.connection.x11Display': 'X11 显示地址',
|
||||
'settings.terminal.connection.x11Display.desc': '可选的本机 X11 显示地址。留空则使用系统默认值。',
|
||||
'settings.terminal.connection.x11Display.placeholder': '自动(:0 或 DISPLAY)',
|
||||
'settings.terminal.section.serverStats': '服务器状态(Linux)',
|
||||
'settings.terminal.serverStats.show': '显示服务器状态',
|
||||
'settings.terminal.serverStats.show.desc': '在终端状态栏显示 CPU、内存和磁盘使用情况(仅限 Linux 服务器)。',
|
||||
|
||||
88
application/state/editorTabSave.test.ts
Normal file
88
application/state/editorTabSave.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { EditorTabStore, type EditorTab } from "./editorTabStore.ts";
|
||||
import { createEditorTabSaveService } from "./editorTabSave.ts";
|
||||
|
||||
const deferred = <T = void>() => {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
const makeTab = (overrides: Partial<EditorTab> = {}): EditorTab => ({
|
||||
id: "edt_1",
|
||||
kind: "editor",
|
||||
sessionId: "conn_1",
|
||||
hostId: "host_1",
|
||||
remotePath: "/tmp/file.txt",
|
||||
fileName: "file.txt",
|
||||
languageId: "plaintext",
|
||||
content: "v1",
|
||||
baselineContent: "old",
|
||||
wordWrap: false,
|
||||
viewState: null,
|
||||
savingState: "idle",
|
||||
saveError: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("editor tab save service joins duplicate saves for the same content", async () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab());
|
||||
const pending = deferred();
|
||||
const writes: string[] = [];
|
||||
const service = createEditorTabSaveService({
|
||||
store,
|
||||
write: async (_sessionId, _hostId, _remotePath, content) => {
|
||||
writes.push(content);
|
||||
await pending.promise;
|
||||
},
|
||||
});
|
||||
|
||||
const first = service.saveTab("edt_1");
|
||||
const second = service.saveTab("edt_1", "v1");
|
||||
|
||||
assert.deepEqual(writes, ["v1"]);
|
||||
pending.resolve();
|
||||
|
||||
assert.equal(await first, true);
|
||||
assert.equal(await second, true);
|
||||
assert.deepEqual(writes, ["v1"]);
|
||||
assert.equal(store.getTab("edt_1")?.baselineContent, "v1");
|
||||
assert.equal(store.getTab("edt_1")?.savingState, "idle");
|
||||
});
|
||||
|
||||
test("editor tab save service queues newer tab content after an in-flight save", async () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab());
|
||||
const firstSave = deferred();
|
||||
const secondSave = deferred();
|
||||
const writes: string[] = [];
|
||||
const service = createEditorTabSaveService({
|
||||
store,
|
||||
write: async (_sessionId, _hostId, _remotePath, content) => {
|
||||
writes.push(content);
|
||||
await (content === "v1" ? firstSave.promise : secondSave.promise);
|
||||
},
|
||||
});
|
||||
|
||||
const first = service.saveTab("edt_1");
|
||||
store.updateContent("edt_1", "v2", null);
|
||||
const second = service.saveTab("edt_1");
|
||||
|
||||
assert.deepEqual(writes, ["v1"]);
|
||||
firstSave.resolve();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(writes, ["v1", "v2"]);
|
||||
secondSave.resolve();
|
||||
|
||||
assert.equal(await first, true);
|
||||
assert.equal(await second, true);
|
||||
assert.equal(store.getTab("edt_1")?.baselineContent, "v2");
|
||||
assert.equal(store.getTab("edt_1")?.content, "v2");
|
||||
});
|
||||
72
application/state/editorTabSave.ts
Normal file
72
application/state/editorTabSave.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { editorSftpWrite, type EditorSftpWrite } from "./editorSftpBridge";
|
||||
import { editorTabStore, type EditorTabId, type EditorTabStore } from "./editorTabStore";
|
||||
import {
|
||||
createTextEditorSaveCoordinator,
|
||||
type TextEditorSaveCoordinator,
|
||||
} from "./textEditorSaveCoordinator";
|
||||
|
||||
interface EditorTabSaveServiceDeps {
|
||||
store: EditorTabStore;
|
||||
write: EditorSftpWrite;
|
||||
}
|
||||
|
||||
export interface EditorTabSaveService {
|
||||
saveTab(id: EditorTabId, contentOverride?: string): Promise<boolean>;
|
||||
releaseTab(id: EditorTabId): void;
|
||||
}
|
||||
|
||||
const formatSaveError = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : "Save failed";
|
||||
|
||||
export const createEditorTabSaveService = ({
|
||||
store,
|
||||
write,
|
||||
}: EditorTabSaveServiceDeps): EditorTabSaveService => {
|
||||
const coordinators = new Map<EditorTabId, TextEditorSaveCoordinator>();
|
||||
|
||||
const getCoordinator = (id: EditorTabId): TextEditorSaveCoordinator => {
|
||||
const existing = coordinators.get(id);
|
||||
if (existing) return existing;
|
||||
|
||||
const coordinator = createTextEditorSaveCoordinator({
|
||||
onSave: async (content) => {
|
||||
const tab = store.getTab(id);
|
||||
if (!tab) throw new Error("Editor tab closed before save completed");
|
||||
await write(tab.sessionId, tab.hostId, tab.remotePath, content);
|
||||
},
|
||||
onSaveStart: () => {
|
||||
store.setSavingState(id, "saving");
|
||||
},
|
||||
onSaveSuccess: (content) => {
|
||||
store.markSaved(id, content);
|
||||
},
|
||||
onSaveError: (error) => {
|
||||
store.setSavingState(id, "error", formatSaveError(error));
|
||||
},
|
||||
});
|
||||
|
||||
coordinators.set(id, coordinator);
|
||||
return coordinator;
|
||||
};
|
||||
|
||||
return {
|
||||
saveTab: async (id, contentOverride) => {
|
||||
const tab = store.getTab(id);
|
||||
if (!tab) return false;
|
||||
return getCoordinator(id).save(contentOverride ?? tab.content);
|
||||
},
|
||||
releaseTab: (id) => {
|
||||
const coordinator = coordinators.get(id);
|
||||
coordinator?.reset();
|
||||
coordinators.delete(id);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const editorTabSaveService = createEditorTabSaveService({
|
||||
store: editorTabStore,
|
||||
write: editorSftpWrite,
|
||||
});
|
||||
|
||||
export const saveEditorTab = editorTabSaveService.saveTab;
|
||||
export const releaseEditorTabSaveCoordinator = editorTabSaveService.releaseTab;
|
||||
@@ -196,3 +196,24 @@ test("confirmCloseBySession invokes save callback for 'save' choice and only clo
|
||||
assert.equal(ok, true);
|
||||
assert.equal(store.getTab("edt_1"), undefined);
|
||||
});
|
||||
|
||||
test("confirmCloseBySession reports every closed editor tab to cleanup callback", async () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab({ id: "edt_clean" }));
|
||||
store._debugInsert(makeTab({ id: "edt_dirty", remotePath: "/b.txt", fileName: "b.txt", content: "new", baselineContent: "old" }));
|
||||
const closed: string[] = [];
|
||||
|
||||
const ok = await store.confirmCloseBySession(
|
||||
"conn_1",
|
||||
async () => "save",
|
||||
async (id) => {
|
||||
const tab = store.getTab(id)!;
|
||||
store.markSaved(id, tab.content);
|
||||
},
|
||||
(id) => closed.push(id),
|
||||
);
|
||||
|
||||
assert.equal(ok, true);
|
||||
assert.deepEqual(closed, ["edt_clean", "edt_dirty"]);
|
||||
assert.equal(store.getTabs().length, 0);
|
||||
});
|
||||
|
||||
@@ -167,17 +167,23 @@ export class EditorTabStore {
|
||||
sessionId: string,
|
||||
promptChoice: (tab: EditorTab) => Promise<"save" | "discard" | "cancel">,
|
||||
saveTab?: (tabId: EditorTabId) => Promise<void>,
|
||||
onCloseTab?: (tabId: EditorTabId) => void,
|
||||
): Promise<boolean> => {
|
||||
const matching = this.tabs.filter((t) => t.sessionId === sessionId);
|
||||
for (const tab of matching) {
|
||||
const dirty = tab.content !== tab.baselineContent;
|
||||
if (!dirty) {
|
||||
onCloseTab?.(tab.id);
|
||||
this.close(tab.id);
|
||||
continue;
|
||||
}
|
||||
const choice = await promptChoice(tab);
|
||||
if (choice === "cancel") return false;
|
||||
if (choice === "discard") { this.close(tab.id); continue; }
|
||||
if (choice === "discard") {
|
||||
onCloseTab?.(tab.id);
|
||||
this.close(tab.id);
|
||||
continue;
|
||||
}
|
||||
if (choice === "save") {
|
||||
if (!saveTab) throw new Error("saveTab callback required when 'save' choice is possible");
|
||||
try {
|
||||
@@ -186,6 +192,7 @@ export class EditorTabStore {
|
||||
// Save failed — treat like cancel (keep tab open, abort batch so the user sees the error)
|
||||
return false;
|
||||
}
|
||||
onCloseTab?.(tab.id);
|
||||
this.close(tab.id);
|
||||
}
|
||||
}
|
||||
|
||||
64
application/state/resolveSnippetsShortcutIntent.test.ts
Normal file
64
application/state/resolveSnippetsShortcutIntent.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
resolveScriptsSidePanelShortcutIntent,
|
||||
resolveSnippetsShortcutIntent,
|
||||
} from "./resolveSnippetsShortcutIntent.ts";
|
||||
|
||||
test("active single terminal tab toggles the terminal scripts panel", () => {
|
||||
const result = resolveSnippetsShortcutIntent({
|
||||
activeTabId: "s1",
|
||||
sessionForTab: { id: "s1" },
|
||||
workspaceForTab: null,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { kind: "toggleTerminalScripts" });
|
||||
});
|
||||
|
||||
test("active workspace tab toggles the terminal scripts panel", () => {
|
||||
const result = resolveSnippetsShortcutIntent({
|
||||
activeTabId: "w1",
|
||||
sessionForTab: null,
|
||||
workspaceForTab: { id: "w1" },
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { kind: "toggleTerminalScripts" });
|
||||
});
|
||||
|
||||
test("non-terminal tabs navigate to the vault snippets section", () => {
|
||||
for (const activeTabId of ["vault", "sftp", "editor:notes", "log1", null]) {
|
||||
const result = resolveSnippetsShortcutIntent({
|
||||
activeTabId,
|
||||
sessionForTab: null,
|
||||
workspaceForTab: null,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { kind: "openVaultSnippets" });
|
||||
}
|
||||
});
|
||||
|
||||
test("terminal tabs fall back to vault snippets when terminal toggle is unavailable", () => {
|
||||
const result = resolveSnippetsShortcutIntent({
|
||||
activeTabId: "s1",
|
||||
sessionForTab: { id: "s1" },
|
||||
workspaceForTab: null,
|
||||
terminalScriptsToggleAvailable: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { kind: "openVaultSnippets" });
|
||||
});
|
||||
|
||||
test("scripts panel shortcut closes when scripts is already open", () => {
|
||||
const result = resolveScriptsSidePanelShortcutIntent("scripts");
|
||||
|
||||
assert.deepEqual(result, { kind: "closeTerminalSidePanel" });
|
||||
});
|
||||
|
||||
test("scripts panel shortcut opens scripts from closed or other panel states", () => {
|
||||
for (const activePanel of [null, "sftp", "theme", "ai"]) {
|
||||
const result = resolveScriptsSidePanelShortcutIntent(activePanel);
|
||||
|
||||
assert.deepEqual(result, { kind: "openTerminalScripts" });
|
||||
}
|
||||
});
|
||||
42
application/state/resolveSnippetsShortcutIntent.ts
Normal file
42
application/state/resolveSnippetsShortcutIntent.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export type SnippetsShortcutIntent =
|
||||
| { kind: 'toggleTerminalScripts' }
|
||||
| { kind: 'openVaultSnippets' };
|
||||
|
||||
export type ScriptsSidePanelShortcutIntent =
|
||||
| { kind: 'closeTerminalSidePanel' }
|
||||
| { kind: 'openTerminalScripts' };
|
||||
|
||||
export interface ResolveSnippetsShortcutIntentInput {
|
||||
activeTabId: string | null;
|
||||
sessionForTab: { id: string } | null;
|
||||
workspaceForTab: { id: string } | null;
|
||||
terminalScriptsToggleAvailable?: boolean;
|
||||
}
|
||||
|
||||
export function resolveSnippetsShortcutIntent(
|
||||
input: ResolveSnippetsShortcutIntentInput,
|
||||
): SnippetsShortcutIntent {
|
||||
const {
|
||||
activeTabId,
|
||||
sessionForTab,
|
||||
workspaceForTab,
|
||||
terminalScriptsToggleAvailable = true,
|
||||
} = input;
|
||||
if (!activeTabId) return { kind: 'openVaultSnippets' };
|
||||
|
||||
if ((sessionForTab || workspaceForTab) && terminalScriptsToggleAvailable) {
|
||||
return { kind: 'toggleTerminalScripts' };
|
||||
}
|
||||
|
||||
return { kind: 'openVaultSnippets' };
|
||||
}
|
||||
|
||||
export function resolveScriptsSidePanelShortcutIntent(
|
||||
activePanel: string | null,
|
||||
): ScriptsSidePanelShortcutIntent {
|
||||
if (activePanel === 'scripts') {
|
||||
return { kind: 'closeTerminalSidePanel' };
|
||||
}
|
||||
|
||||
return { kind: 'openTerminalScripts' };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useRef, useMemo } from "react";
|
||||
import { TransferTask, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import React, { useCallback, useRef, useMemo, useState } from "react";
|
||||
import { FileConflict, FileConflictAction, TransferTask, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { SftpPane } from "./types";
|
||||
@@ -63,6 +63,8 @@ interface SftpExternalOperationsResult {
|
||||
) => Promise<UploadResult[]>;
|
||||
cancelExternalUpload: () => Promise<void>;
|
||||
selectApplication: () => Promise<{ path: string; name: string } | null>;
|
||||
uploadConflicts: FileConflict[];
|
||||
resolveUploadConflict: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => void;
|
||||
}
|
||||
|
||||
export const useSftpExternalOperations = (
|
||||
@@ -88,6 +90,11 @@ export const useSftpExternalOperations = (
|
||||
// Track active file watches so the side panel can block host-switching.
|
||||
// Reset to 0 when the SFTP session disconnects (handled in SftpSidePanel).
|
||||
const activeFileWatchCountRef = useRef(0);
|
||||
const [uploadConflicts, setUploadConflicts] = useState<FileConflict[]>([]);
|
||||
const uploadConflictResolversRef = useRef(new Map<string, {
|
||||
resolve: (action: FileConflictAction) => void;
|
||||
setDefault: (action: FileConflictAction) => void;
|
||||
}>());
|
||||
|
||||
const readTextFile = useCallback(
|
||||
async (side: "left" | "right", filePath: string): Promise<string> => {
|
||||
@@ -496,18 +503,99 @@ export const useSftpExternalOperations = (
|
||||
};
|
||||
}, [addExternalUpload, updateExternalUpload, dismissExternalUpload]);
|
||||
|
||||
const resolveUploadConflict = useCallback((conflictId: string, action: FileConflictAction, applyToAll = false) => {
|
||||
const conflict = uploadConflicts.find((item) => item.transferId === conflictId);
|
||||
setUploadConflicts((prev) => prev.filter((item) => item.transferId !== conflictId));
|
||||
const resolver = uploadConflictResolversRef.current.get(conflictId);
|
||||
if (!resolver) return;
|
||||
uploadConflictResolversRef.current.delete(conflictId);
|
||||
if (conflict && applyToAll) {
|
||||
resolver.setDefault(action);
|
||||
}
|
||||
resolver.resolve(action);
|
||||
}, [uploadConflicts]);
|
||||
|
||||
const cancelPendingUploadConflicts = useCallback(() => {
|
||||
const resolvers = Array.from(uploadConflictResolversRef.current.values());
|
||||
if (resolvers.length === 0) return;
|
||||
|
||||
uploadConflictResolversRef.current.clear();
|
||||
setUploadConflicts([]);
|
||||
for (const resolver of resolvers) {
|
||||
resolver.resolve("stop");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createUploadConflictResolver = useCallback(() => {
|
||||
const conflictDefaults = new Map<string, FileConflictAction>();
|
||||
|
||||
return async (conflict: {
|
||||
fileName: string;
|
||||
targetPath: string;
|
||||
isDirectory: boolean;
|
||||
existingType?: 'file' | 'directory' | 'symlink';
|
||||
existingSize: number;
|
||||
newSize: number;
|
||||
existingModified: number;
|
||||
newModified: number;
|
||||
applyToAllCount: number;
|
||||
}): Promise<FileConflictAction> => {
|
||||
const conflictType = conflict.isDirectory ? "directory" : "file";
|
||||
const defaultAction = conflictDefaults.get(conflictType);
|
||||
if (defaultAction) return defaultAction;
|
||||
|
||||
const conflictId = `upload-conflict-${crypto.randomUUID()}`;
|
||||
const fileConflict: FileConflict = {
|
||||
transferId: conflictId,
|
||||
fileName: conflict.fileName,
|
||||
sourcePath: "local",
|
||||
targetPath: conflict.targetPath,
|
||||
isDirectory: conflict.isDirectory,
|
||||
existingType: conflict.existingType,
|
||||
applyToAllCount: conflict.applyToAllCount,
|
||||
existingSize: conflict.existingSize,
|
||||
newSize: conflict.newSize,
|
||||
existingModified: conflict.existingModified,
|
||||
newModified: conflict.newModified,
|
||||
};
|
||||
|
||||
setUploadConflicts((prev) => [...prev, fileConflict]);
|
||||
return new Promise<FileConflictAction>((resolve) => {
|
||||
uploadConflictResolversRef.current.set(conflictId, {
|
||||
resolve,
|
||||
setDefault: (action) => {
|
||||
conflictDefaults.set(conflictType, action);
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Create upload bridge that wraps netcattyBridge
|
||||
const createUploadBridge = useMemo((): UploadBridge => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return {
|
||||
writeLocalFile: bridge?.writeLocalFile,
|
||||
mkdirLocal: bridge?.mkdirLocal,
|
||||
statLocal: bridge?.statLocal,
|
||||
deleteLocalFile: bridge?.deleteLocalFile,
|
||||
mkdirSftp: async (sftpId: string, path: string) => {
|
||||
const b = netcattyBridge.get();
|
||||
if (b?.mkdirSftp) {
|
||||
await b.mkdirSftp(sftpId, path);
|
||||
}
|
||||
},
|
||||
statSftp: async (sftpId: string, path: string) => {
|
||||
const b = netcattyBridge.get();
|
||||
if (!b?.statSftp) return null;
|
||||
return b.statSftp(sftpId, path);
|
||||
},
|
||||
deleteSftp: async (sftpId: string, path: string) => {
|
||||
const b = netcattyBridge.get();
|
||||
if (b?.deleteSftp) {
|
||||
await b.deleteSftp(sftpId, path);
|
||||
}
|
||||
},
|
||||
writeSftpBinary: bridge?.writeSftpBinary,
|
||||
// Wrap writeSftpBinaryWithProgress to adapt UploadBridge interface to NetcattyBridge interface
|
||||
// UploadBridge: (sftpId, path, data, taskId, onProgress, onComplete, onError)
|
||||
@@ -596,6 +684,7 @@ export const useSftpExternalOperations = (
|
||||
joinPath,
|
||||
callbacks,
|
||||
useCompressedUpload,
|
||||
resolveConflict: createUploadConflictResolver(),
|
||||
},
|
||||
controller
|
||||
);
|
||||
@@ -624,6 +713,7 @@ export const useSftpExternalOperations = (
|
||||
sftpSessionsRef,
|
||||
createUploadCallbacks,
|
||||
createUploadBridge,
|
||||
createUploadConflictResolver,
|
||||
useCompressedUpload,
|
||||
],
|
||||
);
|
||||
@@ -680,6 +770,7 @@ export const useSftpExternalOperations = (
|
||||
joinPath,
|
||||
callbacks,
|
||||
useCompressedUpload,
|
||||
resolveConflict: createUploadConflictResolver(),
|
||||
},
|
||||
controller,
|
||||
);
|
||||
@@ -707,6 +798,7 @@ export const useSftpExternalOperations = (
|
||||
connectionCacheKeyMapRef,
|
||||
createUploadCallbacks,
|
||||
createUploadBridge,
|
||||
createUploadConflictResolver,
|
||||
getActivePane,
|
||||
refresh,
|
||||
sftpSessionsRef,
|
||||
@@ -716,11 +808,14 @@ export const useSftpExternalOperations = (
|
||||
|
||||
const cancelExternalUpload = useCallback(async () => {
|
||||
const controller = uploadControllerRef.current;
|
||||
let cancelPromise: Promise<void> | undefined;
|
||||
if (controller) {
|
||||
logger.info("[SFTP] Cancelling external upload");
|
||||
await controller.cancel();
|
||||
cancelPromise = controller.cancel();
|
||||
}
|
||||
}, []);
|
||||
cancelPendingUploadConflicts();
|
||||
await cancelPromise;
|
||||
}, [cancelPendingUploadConflicts]);
|
||||
|
||||
const selectApplication = useCallback(
|
||||
async (): Promise<{ path: string; name: string } | null> => {
|
||||
@@ -744,5 +839,7 @@ export const useSftpExternalOperations = (
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
activeFileWatchCountRef,
|
||||
uploadConflicts,
|
||||
resolveUploadConflict,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
FileConflict,
|
||||
FileConflictAction,
|
||||
SftpFileEntry,
|
||||
SftpFilenameEncoding,
|
||||
TransferDirection,
|
||||
@@ -61,7 +62,7 @@ interface UseSftpTransfersResult {
|
||||
retryTransfer: (transferId: string) => Promise<void>;
|
||||
clearCompletedTransfers: () => void;
|
||||
dismissTransfer: (transferId: string) => void;
|
||||
resolveConflict: (conflictId: string, action: "replace" | "skip" | "duplicate") => Promise<void>;
|
||||
resolveConflict: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
interface TransferResult {
|
||||
@@ -96,6 +97,7 @@ export const useSftpTransfers = ({
|
||||
const conflictsRef = useRef(conflicts);
|
||||
conflictsRef.current = conflicts;
|
||||
const completionHandlersRef = useRef<Map<string, (result: TransferResult) => void | Promise<void>>>(new Map());
|
||||
const conflictDefaultsRef = useRef<Map<string, FileConflictAction>>(new Map());
|
||||
|
||||
const clearCancelledTask = useCallback((taskId: string) => {
|
||||
cancelledTasksRef.current.delete(taskId);
|
||||
@@ -122,6 +124,196 @@ export const useSftpTransfers = ({
|
||||
[],
|
||||
);
|
||||
|
||||
const conflictDefaultKey = useCallback(
|
||||
(batchId: string | undefined, isDirectory: boolean) =>
|
||||
`${batchId ?? "global"}:${isDirectory ? "directory" : "file"}`,
|
||||
[],
|
||||
);
|
||||
|
||||
const splitNameForDuplicate = useCallback((fileName: string, isDirectory: boolean) => {
|
||||
if (isDirectory) return { baseName: fileName, ext: "" };
|
||||
const lastDot = fileName.lastIndexOf(".");
|
||||
if (lastDot <= 0) return { baseName: fileName, ext: "" };
|
||||
return {
|
||||
baseName: fileName.slice(0, lastDot),
|
||||
ext: fileName.slice(lastDot),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const statTargetPath = useCallback(
|
||||
async (
|
||||
targetPane: SftpPane,
|
||||
targetSftpId: string | null,
|
||||
targetPath: string,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
): Promise<{ type?: "file" | "directory" | "symlink"; size: number; mtime: number } | null> => {
|
||||
if (!targetPane.connection) return null;
|
||||
|
||||
if (targetPane.connection.isLocal) {
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(targetPath);
|
||||
if (!stat) return null;
|
||||
return {
|
||||
type: stat.type as "file" | "directory" | "symlink" | undefined,
|
||||
size: stat.size,
|
||||
mtime: stat.lastModified || Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
if (!targetSftpId) return null;
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
targetSftpId,
|
||||
targetPath,
|
||||
targetEncoding,
|
||||
);
|
||||
if (!stat) return null;
|
||||
return {
|
||||
type: stat.type as "file" | "directory" | "symlink" | undefined,
|
||||
size: stat.size,
|
||||
mtime: stat.lastModified || Date.now(),
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const getDuplicateTarget = useCallback(
|
||||
async (
|
||||
task: TransferTask,
|
||||
targetPane: SftpPane,
|
||||
targetSftpId: string | null,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
) => {
|
||||
const parentPath = getParentPath(task.targetPath);
|
||||
const { baseName, ext } = splitNameForDuplicate(task.fileName, task.isDirectory);
|
||||
|
||||
for (let index = 1; index < 1000; index++) {
|
||||
const suffix = index === 1 ? " (copy)" : ` (copy ${index})`;
|
||||
const fileName = `${baseName}${suffix}${ext}`;
|
||||
const targetPath = joinPath(parentPath, fileName);
|
||||
try {
|
||||
const existing = await statTargetPath(targetPane, targetSftpId, targetPath, targetEncoding);
|
||||
if (!existing) return { fileName, targetPath };
|
||||
} catch {
|
||||
return { fileName, targetPath };
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackName = `${baseName} (copy ${Date.now()})${ext}`;
|
||||
return { fileName: fallbackName, targetPath: joinPath(parentPath, fallbackName) };
|
||||
},
|
||||
[splitNameForDuplicate, statTargetPath],
|
||||
);
|
||||
|
||||
const completeCancelledTask = useCallback(
|
||||
async (task: TransferTask) => {
|
||||
const completionHandler = completionHandlersRef.current.get(task.id);
|
||||
if (completionHandler) {
|
||||
try {
|
||||
await completionHandler({
|
||||
id: task.id,
|
||||
fileName: task.fileName,
|
||||
originalFileName: task.originalFileName ?? task.fileName,
|
||||
status: "cancelled",
|
||||
});
|
||||
} finally {
|
||||
completionHandlersRef.current.delete(task.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const cancelBackendTransfers = useCallback(async (transferIds: string[]) => {
|
||||
const idsToCancel = new Set<string>();
|
||||
const currentTransfers = transfersRef.current;
|
||||
for (const transferId of transferIds) {
|
||||
idsToCancel.add(transferId);
|
||||
const trackedChildren = activeChildIdsRef.current.get(transferId);
|
||||
if (trackedChildren) {
|
||||
for (const childId of trackedChildren) {
|
||||
idsToCancel.add(childId);
|
||||
cancelledTasksRef.current.add(childId);
|
||||
}
|
||||
}
|
||||
for (const transfer of currentTransfers) {
|
||||
if (
|
||||
transfer.parentTaskId === transferId &&
|
||||
(transfer.status === "transferring" || transfer.status === "pending")
|
||||
) {
|
||||
idsToCancel.add(transfer.id);
|
||||
cancelledTasksRef.current.add(transfer.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cancelTransferAtBackend = netcattyBridge.get()?.cancelTransfer;
|
||||
if (!cancelTransferAtBackend) return;
|
||||
|
||||
await Promise.all(
|
||||
Array.from(idsToCancel).map((id) =>
|
||||
cancelTransferAtBackend(id).catch((err) => {
|
||||
logger.warn("Failed to cancel transfer at backend:", err);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const markBatchStopped = useCallback(
|
||||
async (task: TransferTask) => {
|
||||
const batchId = task.batchId;
|
||||
const affected = transfersRef.current.filter((candidate) =>
|
||||
candidate.id === task.id ||
|
||||
(!!batchId && candidate.batchId === batchId && (candidate.status === "pending" || candidate.status === "transferring")),
|
||||
);
|
||||
|
||||
affected.forEach((candidate) => cancelledTasksRef.current.add(candidate.id));
|
||||
const affectedIds = new Set(affected.map((candidate) => candidate.id));
|
||||
setConflicts((prev) => prev.filter((conflict) => conflict.transferId !== task.id && (!batchId || conflict.batchId !== batchId)));
|
||||
setTransfers((prev) => {
|
||||
for (const candidate of prev) {
|
||||
if (candidate.parentTaskId && affectedIds.has(candidate.parentTaskId)) {
|
||||
cancelledTasksRef.current.add(candidate.id);
|
||||
}
|
||||
}
|
||||
|
||||
return prev
|
||||
.filter((candidate) => !(candidate.parentTaskId && affectedIds.has(candidate.parentTaskId)))
|
||||
.map((candidate) =>
|
||||
affectedIds.has(candidate.id)
|
||||
? { ...candidate, status: "cancelled" as TransferStatus, endTime: Date.now() }
|
||||
: candidate,
|
||||
);
|
||||
});
|
||||
await cancelBackendTransfers(affected.map((candidate) => candidate.id));
|
||||
|
||||
for (const candidate of affected) {
|
||||
await completeCancelledTask(candidate);
|
||||
}
|
||||
},
|
||||
[cancelBackendTransfers, completeCancelledTask],
|
||||
);
|
||||
|
||||
const deleteTargetPath = useCallback(
|
||||
async (
|
||||
task: TransferTask,
|
||||
targetPane: SftpPane,
|
||||
targetSftpId: string | null,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
) => {
|
||||
if (!targetPane.connection) return;
|
||||
if (targetPane.connection.isLocal) {
|
||||
const deleteLocalFile = netcattyBridge.get()?.deleteLocalFile;
|
||||
if (!deleteLocalFile) throw new Error("Local delete unavailable");
|
||||
await deleteLocalFile(task.targetPath);
|
||||
return;
|
||||
}
|
||||
if (!targetSftpId) throw new Error("Target SFTP session not found");
|
||||
const deleteSftp = netcattyBridge.get()?.deleteSftp;
|
||||
if (!deleteSftp) throw new Error("SFTP delete unavailable");
|
||||
await deleteSftp(targetSftpId, task.targetPath, targetEncoding);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const getEntrySize = useCallback((entry: SftpFileEntry): number => {
|
||||
if (typeof entry.size === "string") {
|
||||
const parsed = parseInt(entry.size, 10);
|
||||
@@ -557,6 +749,10 @@ export const useSftpTransfers = ({
|
||||
targetPane: SftpPane,
|
||||
targetSide: "left" | "right",
|
||||
): Promise<TransferStatus> => {
|
||||
if (cancelledTasksRef.current.has(task.id)) {
|
||||
return "cancelled";
|
||||
}
|
||||
|
||||
const updateTask = (updates: Partial<TransferTask>) => {
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => (t.id === task.id ? { ...t, ...updates } : t)),
|
||||
@@ -676,7 +872,7 @@ export const useSftpTransfers = ({
|
||||
|
||||
// Run size discovery and conflict check in parallel
|
||||
const conflictCheckPromise = (async (): Promise<FileConflict | null> => {
|
||||
if (task.skipConflictCheck || task.isDirectory || !targetPane.connection) return null;
|
||||
if (task.skipConflictCheck || !targetPane.connection) return null;
|
||||
|
||||
const sourceStat: { size: number; mtime: number } | null =
|
||||
(task.totalBytes > 0 || task.sourceLastModified)
|
||||
@@ -684,30 +880,26 @@ export const useSftpTransfers = ({
|
||||
: null;
|
||||
|
||||
try {
|
||||
let existingStat: { size: number; mtime: number } | null = null;
|
||||
|
||||
if (targetPane.connection.isLocal) {
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(task.targetPath);
|
||||
if (stat) {
|
||||
existingStat = { size: stat.size, mtime: stat.lastModified || Date.now() };
|
||||
}
|
||||
} else if (targetSftpId) {
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
targetSftpId,
|
||||
task.targetPath,
|
||||
targetEncoding,
|
||||
);
|
||||
if (stat) {
|
||||
existingStat = { size: stat.size, mtime: stat.lastModified || Date.now() };
|
||||
}
|
||||
}
|
||||
const existingStat = await statTargetPath(targetPane, targetSftpId, task.targetPath, targetEncoding);
|
||||
|
||||
if (existingStat) {
|
||||
return {
|
||||
transferId: task.id,
|
||||
batchId: task.batchId,
|
||||
fileName: task.fileName,
|
||||
sourcePath: task.sourcePath,
|
||||
targetPath: task.targetPath,
|
||||
isDirectory: task.isDirectory,
|
||||
existingType: existingStat.type,
|
||||
applyToAllCount: task.batchId
|
||||
? transfersRef.current.filter((candidate) =>
|
||||
candidate.batchId === task.batchId &&
|
||||
candidate.isDirectory === task.isDirectory &&
|
||||
!candidate.parentTaskId &&
|
||||
candidate.status !== "completed" &&
|
||||
candidate.status !== "cancelled",
|
||||
).length
|
||||
: 1,
|
||||
existingSize: existingStat.size,
|
||||
newSize: sourceStat?.size || task.totalBytes || 0,
|
||||
existingModified: existingStat.mtime,
|
||||
@@ -729,6 +921,44 @@ export const useSftpTransfers = ({
|
||||
const conflict = await conflictCheckPromise;
|
||||
|
||||
if (conflict) {
|
||||
const defaultAction = conflictDefaultsRef.current.get(conflictDefaultKey(task.batchId, task.isDirectory));
|
||||
if (defaultAction) {
|
||||
if (defaultAction === "stop") {
|
||||
await markBatchStopped(task);
|
||||
return "cancelled";
|
||||
}
|
||||
|
||||
if (defaultAction === "skip") {
|
||||
cancelledTasksRef.current.add(task.id);
|
||||
updateTask({ status: "cancelled", endTime: Date.now() });
|
||||
await completeCancelledTask(task);
|
||||
return "cancelled";
|
||||
}
|
||||
|
||||
const duplicateTarget = defaultAction === "duplicate"
|
||||
? await getDuplicateTarget(task, targetPane, targetSftpId, targetEncoding)
|
||||
: null;
|
||||
const updatedTask: TransferTask = {
|
||||
...task,
|
||||
...(duplicateTarget
|
||||
? {
|
||||
fileName: duplicateTarget.fileName,
|
||||
targetPath: duplicateTarget.targetPath,
|
||||
}
|
||||
: null),
|
||||
skipConflictCheck: true,
|
||||
replaceExistingTarget: defaultAction === "replace",
|
||||
};
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === task.id
|
||||
? { ...updatedTask, status: "pending" as TransferStatus }
|
||||
: t,
|
||||
),
|
||||
);
|
||||
return processTransfer(updatedTask, sourcePane, targetPane, targetSide);
|
||||
}
|
||||
|
||||
setConflicts((prev) => [...prev, conflict]);
|
||||
updateTask({
|
||||
status: "pending",
|
||||
@@ -741,6 +971,10 @@ export const useSftpTransfers = ({
|
||||
|
||||
let dirPartialFailure = false;
|
||||
|
||||
if (task.replaceExistingTarget) {
|
||||
await deleteTargetPath(task, targetPane, targetSftpId, targetEncoding);
|
||||
}
|
||||
|
||||
// Same-host exec-based paths are only safe for UTF-8 compatible encodings.
|
||||
// "auto" is allowed here — the backend resolves it to the actual encoding
|
||||
// and skips exec if it resolved to non-UTF-8 (e.g. gb18030).
|
||||
@@ -816,6 +1050,10 @@ export const useSftpTransfers = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (cancelledTasksRef.current.has(task.id)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
const finalStatus: TransferStatus = dirPartialFailure ? "failed" : "completed";
|
||||
setTransfers((prev) => {
|
||||
return prev.map((t) => {
|
||||
@@ -940,6 +1178,7 @@ export const useSftpTransfers = ({
|
||||
const sourcePath = options?.sourcePath ?? sourcePane.connection.currentPath;
|
||||
const targetPath = options?.targetPath ?? targetPane.connection.currentPath;
|
||||
const sourceConnectionId = options?.sourceConnectionId ?? sourcePane.connection.id;
|
||||
const batchId = crypto.randomUUID();
|
||||
|
||||
const newTasks: TransferTask[] = [];
|
||||
|
||||
@@ -965,6 +1204,7 @@ export const useSftpTransfers = ({
|
||||
|
||||
newTasks.push({
|
||||
id: crypto.randomUUID(),
|
||||
batchId,
|
||||
fileName: file.name,
|
||||
originalFileName: file.name,
|
||||
sourcePath: joinPath(sourcePath, file.name),
|
||||
@@ -1032,37 +1272,10 @@ export const useSftpTransfers = ({
|
||||
|
||||
setConflicts((prev) => prev.filter((c) => c.transferId !== transferId));
|
||||
|
||||
if (netcattyBridge.get()?.cancelTransfer) {
|
||||
// Cancel parent and all active child streams at the backend.
|
||||
// Use activeChildIdsRef for immediate visibility (not subject to
|
||||
// React state batching delays like transfersRef).
|
||||
const idsToCancel = [transferId];
|
||||
const trackedChildren = activeChildIdsRef.current.get(transferId);
|
||||
if (trackedChildren) {
|
||||
for (const childId of trackedChildren) {
|
||||
idsToCancel.push(childId);
|
||||
cancelledTasksRef.current.add(childId);
|
||||
}
|
||||
}
|
||||
// Also check rendered state as fallback for transfers started
|
||||
// via other paths (e.g. startTransfer/processTransfer)
|
||||
const currentTransfers = transfersRef.current;
|
||||
for (const t of currentTransfers) {
|
||||
if (t.parentTaskId === transferId && (t.status === "transferring" || t.status === "pending") && !idsToCancel.includes(t.id)) {
|
||||
idsToCancel.push(t.id);
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
idsToCancel.map((id) =>
|
||||
netcattyBridge.get()!.cancelTransfer!(id).catch((err) => {
|
||||
logger.warn("Failed to cancel transfer at backend:", err);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
await cancelBackendTransfers([transferId]);
|
||||
|
||||
},
|
||||
[],
|
||||
[cancelBackendTransfers],
|
||||
);
|
||||
|
||||
const retryTransfer = useCallback(
|
||||
@@ -1155,79 +1368,123 @@ export const useSftpTransfers = ({
|
||||
}, []);
|
||||
|
||||
const resolveConflict = useCallback(
|
||||
async (conflictId: string, action: "replace" | "skip" | "duplicate") => {
|
||||
async (conflictId: string, action: FileConflictAction, applyToAll = false) => {
|
||||
const conflict = conflictsRef.current.find((c) => c.transferId === conflictId);
|
||||
if (!conflict) return;
|
||||
|
||||
setConflicts((prev) => prev.filter((c) => c.transferId !== conflictId));
|
||||
|
||||
const task = transfersRef.current.find((t) => t.id === conflictId);
|
||||
if (!task) return;
|
||||
if (!task) {
|
||||
setConflicts((prev) => prev.filter((c) => c.transferId !== conflictId));
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedConflictKey = conflictDefaultKey(task.batchId, task.isDirectory);
|
||||
const affectedConflicts = applyToAll
|
||||
? conflictsRef.current.filter((candidate) =>
|
||||
conflictDefaultKey(candidate.batchId, candidate.isDirectory) === selectedConflictKey,
|
||||
)
|
||||
: [conflict];
|
||||
const affectedConflictIds = new Set(affectedConflicts.map((candidate) => candidate.transferId));
|
||||
const affectedTasks = affectedConflicts
|
||||
.map((candidate) => transfersRef.current.find((transfer) => transfer.id === candidate.transferId))
|
||||
.filter((candidate): candidate is TransferTask => Boolean(candidate));
|
||||
|
||||
if (applyToAll) {
|
||||
conflictDefaultsRef.current.set(selectedConflictKey, action);
|
||||
}
|
||||
|
||||
setConflicts((prev) => prev.filter((c) => !affectedConflictIds.has(c.transferId)));
|
||||
|
||||
if (affectedTasks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "stop") {
|
||||
await markBatchStopped(task);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "skip") {
|
||||
for (const affectedTask of affectedTasks) {
|
||||
cancelledTasksRef.current.add(affectedTask.id);
|
||||
}
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === conflictId
|
||||
? { ...t, status: "cancelled" as TransferStatus }
|
||||
prev.map((t) => affectedConflictIds.has(t.id)
|
||||
? { ...t, status: "cancelled" as TransferStatus, endTime: Date.now() }
|
||||
: t,
|
||||
),
|
||||
);
|
||||
const completionHandler = completionHandlersRef.current.get(conflictId);
|
||||
if (completionHandler) {
|
||||
try {
|
||||
await completionHandler({
|
||||
id: task.id,
|
||||
fileName: task.fileName,
|
||||
originalFileName: task.originalFileName ?? task.fileName,
|
||||
status: "cancelled",
|
||||
});
|
||||
} finally {
|
||||
completionHandlersRef.current.delete(conflictId);
|
||||
}
|
||||
for (const affectedTask of affectedTasks) {
|
||||
await completeCancelledTask(affectedTask);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let updatedTask = { ...task };
|
||||
const updatedTasks: TransferTask[] = [];
|
||||
|
||||
if (action === "duplicate") {
|
||||
const ext = task.fileName.includes(".")
|
||||
? "." + task.fileName.split(".").pop()
|
||||
: "";
|
||||
const baseName = task.fileName.includes(".")
|
||||
? task.fileName.slice(0, task.fileName.lastIndexOf("."))
|
||||
: task.fileName;
|
||||
const newName = `${baseName} (copy)${ext}`;
|
||||
const newTargetPath = joinPath(getParentPath(task.targetPath), newName);
|
||||
updatedTask = {
|
||||
...task,
|
||||
fileName: newName,
|
||||
targetPath: newTargetPath,
|
||||
skipConflictCheck: true,
|
||||
};
|
||||
} else if (action === "replace") {
|
||||
updatedTask = {
|
||||
...task,
|
||||
skipConflictCheck: true,
|
||||
};
|
||||
for (const affectedTask of affectedTasks) {
|
||||
let updatedTask = { ...affectedTask };
|
||||
|
||||
if (action === "duplicate") {
|
||||
const endpoints = resolveTaskEndpoints(affectedTask);
|
||||
if (!endpoints) continue;
|
||||
const targetSftpId = endpoints.targetPane.connection?.isLocal
|
||||
? null
|
||||
: sftpSessionsRef.current.get(endpoints.targetPane.connection!.id) ?? null;
|
||||
const targetEncoding = endpoints.targetPane.connection?.isLocal
|
||||
? "auto"
|
||||
: endpoints.targetPane.filenameEncoding || "auto";
|
||||
const duplicateTarget = await getDuplicateTarget(affectedTask, endpoints.targetPane, targetSftpId, targetEncoding);
|
||||
updatedTask = {
|
||||
...affectedTask,
|
||||
fileName: duplicateTarget.fileName,
|
||||
targetPath: duplicateTarget.targetPath,
|
||||
skipConflictCheck: true,
|
||||
};
|
||||
} else if (action === "replace") {
|
||||
updatedTask = {
|
||||
...affectedTask,
|
||||
skipConflictCheck: true,
|
||||
replaceExistingTarget: true,
|
||||
};
|
||||
} else if (action === "merge") {
|
||||
updatedTask = {
|
||||
...affectedTask,
|
||||
skipConflictCheck: true,
|
||||
replaceExistingTarget: false,
|
||||
};
|
||||
}
|
||||
|
||||
updatedTasks.push(updatedTask);
|
||||
}
|
||||
|
||||
const updatedTaskMap = new Map(updatedTasks.map((updatedTask) => [updatedTask.id, updatedTask]));
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === conflictId
|
||||
prev.map((t) => {
|
||||
const updatedTask = updatedTaskMap.get(t.id);
|
||||
return updatedTask
|
||||
? { ...updatedTask, status: "pending" as TransferStatus }
|
||||
: t,
|
||||
),
|
||||
: t;
|
||||
}),
|
||||
);
|
||||
|
||||
setTimeout(async () => {
|
||||
const endpoints = resolveTaskEndpoints(updatedTask);
|
||||
if (!endpoints) return;
|
||||
await processTransfer(updatedTask, endpoints.sourcePane, endpoints.targetPane, endpoints.targetSide);
|
||||
}, 100);
|
||||
for (const updatedTask of updatedTasks) {
|
||||
setTimeout(async () => {
|
||||
const endpoints = resolveTaskEndpoints(updatedTask);
|
||||
if (!endpoints) return;
|
||||
await processTransfer(updatedTask, endpoints.sourcePane, endpoints.targetPane, endpoints.targetSide);
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline; transfers/conflicts accessed via refs
|
||||
[resolveTaskEndpoints],
|
||||
[
|
||||
completeCancelledTask,
|
||||
conflictDefaultKey,
|
||||
getDuplicateTarget,
|
||||
markBatchStopped,
|
||||
resolveTaskEndpoints,
|
||||
sftpSessionsRef,
|
||||
],
|
||||
);
|
||||
|
||||
const activeTransfersCount = useMemo(() => transfers.filter(
|
||||
|
||||
130
application/state/textEditorSaveCoordinator.test.ts
Normal file
130
application/state/textEditorSaveCoordinator.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createTextEditorSaveCoordinator } from "./textEditorSaveCoordinator.ts";
|
||||
|
||||
const deferred = <T = void>() => {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
test("text editor save coordinator joins duplicate saves already in flight", async () => {
|
||||
const pending = deferred();
|
||||
const saved: string[] = [];
|
||||
const savingStates: boolean[] = [];
|
||||
const coordinator = createTextEditorSaveCoordinator({
|
||||
onSave: async (content) => {
|
||||
saved.push(content);
|
||||
await pending.promise;
|
||||
},
|
||||
onSavingChange: (saving) => savingStates.push(saving),
|
||||
});
|
||||
|
||||
const first = coordinator.save("remote text");
|
||||
const second = coordinator.save("remote text");
|
||||
|
||||
assert.deepEqual(saved, ["remote text"]);
|
||||
pending.resolve();
|
||||
|
||||
assert.equal(await first, true);
|
||||
assert.equal(await second, true);
|
||||
assert.deepEqual(saved, ["remote text"]);
|
||||
assert.deepEqual(savingStates, [true, false]);
|
||||
});
|
||||
|
||||
test("text editor save coordinator saves newer content after an in-flight save finishes", async () => {
|
||||
const firstSave = deferred();
|
||||
const secondSave = deferred();
|
||||
const saved: string[] = [];
|
||||
const coordinator = createTextEditorSaveCoordinator({
|
||||
onSave: async (content) => {
|
||||
saved.push(content);
|
||||
await (content === "v1" ? firstSave.promise : secondSave.promise);
|
||||
},
|
||||
});
|
||||
|
||||
const first = coordinator.save("v1");
|
||||
const second = coordinator.save("v2");
|
||||
|
||||
assert.deepEqual(saved, ["v1"]);
|
||||
firstSave.resolve();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(saved, ["v1", "v2"]);
|
||||
secondSave.resolve();
|
||||
|
||||
assert.equal(await first, true);
|
||||
assert.equal(await second, true);
|
||||
});
|
||||
|
||||
test("text editor save coordinator returns false to duplicate callers when the in-flight save fails", async () => {
|
||||
const pending = deferred();
|
||||
const errors: string[] = [];
|
||||
const coordinator = createTextEditorSaveCoordinator({
|
||||
onSave: async () => {
|
||||
await pending.promise;
|
||||
throw new Error("denied");
|
||||
},
|
||||
onSaveError: (error) => {
|
||||
errors.push(error instanceof Error ? error.message : String(error));
|
||||
},
|
||||
});
|
||||
|
||||
const first = coordinator.save("content");
|
||||
const second = coordinator.save("content");
|
||||
|
||||
pending.resolve();
|
||||
|
||||
assert.equal(await first, false);
|
||||
assert.equal(await second, false);
|
||||
assert.deepEqual(errors, ["denied"]);
|
||||
});
|
||||
|
||||
test("text editor save coordinator reset prevents an old in-flight save from updating the next file", async () => {
|
||||
const pending = deferred();
|
||||
const successes: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const savingStates: boolean[] = [];
|
||||
const coordinator = createTextEditorSaveCoordinator({
|
||||
onSave: async () => {
|
||||
await pending.promise;
|
||||
},
|
||||
onSaveSuccess: (content) => successes.push(content),
|
||||
onSaveError: (error) => errors.push(error instanceof Error ? error.message : String(error)),
|
||||
onSavingChange: (saving) => savingStates.push(saving),
|
||||
});
|
||||
|
||||
const save = coordinator.save("old file");
|
||||
coordinator.reset();
|
||||
pending.resolve();
|
||||
|
||||
assert.equal(await save, false);
|
||||
assert.deepEqual(successes, []);
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(savingStates, [true, false]);
|
||||
});
|
||||
|
||||
test("text editor save coordinator reset cancels queued stale saves", async () => {
|
||||
const firstSave = deferred();
|
||||
const saved: string[] = [];
|
||||
const coordinator = createTextEditorSaveCoordinator({
|
||||
onSave: async (content) => {
|
||||
saved.push(content);
|
||||
await firstSave.promise;
|
||||
},
|
||||
});
|
||||
|
||||
const first = coordinator.save("old v1");
|
||||
const queued = coordinator.save("old v2");
|
||||
coordinator.reset();
|
||||
firstSave.resolve();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(await first, false);
|
||||
assert.equal(await queued, false);
|
||||
assert.deepEqual(saved, ["old v1"]);
|
||||
});
|
||||
90
application/state/textEditorSaveCoordinator.ts
Normal file
90
application/state/textEditorSaveCoordinator.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
export interface TextEditorSaveCoordinator {
|
||||
save(content: string): Promise<boolean>;
|
||||
isSaving(): boolean;
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
export interface TextEditorSaveCoordinatorOptions {
|
||||
onSave: (content: string) => Promise<void>;
|
||||
onSaveStart?: (content: string) => void;
|
||||
onSaveSuccess?: (content: string) => void;
|
||||
onSaveError?: (error: unknown) => void;
|
||||
onSavingChange?: (saving: boolean) => void;
|
||||
}
|
||||
|
||||
interface InFlightSave {
|
||||
content: string;
|
||||
promise: Promise<boolean>;
|
||||
}
|
||||
|
||||
export const createTextEditorSaveCoordinator = (
|
||||
options: TextEditorSaveCoordinatorOptions,
|
||||
): TextEditorSaveCoordinator => {
|
||||
let inFlight: InFlightSave | null = null;
|
||||
let generation = 0;
|
||||
|
||||
const notifySavingChange = () => {
|
||||
options.onSavingChange?.(inFlight !== null);
|
||||
};
|
||||
|
||||
const startSave = (content: string): Promise<boolean> => {
|
||||
const saveGeneration = generation;
|
||||
options.onSaveStart?.(content);
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
await options.onSave(content);
|
||||
if (saveGeneration !== generation) {
|
||||
return false;
|
||||
}
|
||||
if (saveGeneration === generation) {
|
||||
options.onSaveSuccess?.(content);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (saveGeneration !== generation) {
|
||||
return false;
|
||||
}
|
||||
if (saveGeneration === generation) {
|
||||
options.onSaveError?.(error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
const entry = { content, promise };
|
||||
inFlight = entry;
|
||||
notifySavingChange();
|
||||
void promise.finally(() => {
|
||||
if (inFlight === entry) {
|
||||
inFlight = null;
|
||||
notifySavingChange();
|
||||
}
|
||||
});
|
||||
return promise;
|
||||
};
|
||||
|
||||
const save = async (content: string): Promise<boolean> => {
|
||||
const current = inFlight;
|
||||
if (current) {
|
||||
const waitGeneration = generation;
|
||||
const ok = await current.promise;
|
||||
if (waitGeneration !== generation) return false;
|
||||
if (!ok || current.content === content) return ok;
|
||||
return save(content);
|
||||
}
|
||||
return startSave(content);
|
||||
};
|
||||
|
||||
return {
|
||||
save,
|
||||
isSaving: () => inFlight !== null,
|
||||
reset: () => {
|
||||
generation += 1;
|
||||
if (inFlight) {
|
||||
inFlight = null;
|
||||
notifySavingChange();
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
44
application/state/uploadService.test.ts
Normal file
44
application/state/uploadService.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { uploadFromDataTransfer } from "../../lib/uploadService.ts";
|
||||
|
||||
function createDataTransfer(files: File[]): DataTransfer {
|
||||
return {
|
||||
items: { length: 0 },
|
||||
files,
|
||||
} as unknown as DataTransfer;
|
||||
}
|
||||
|
||||
test("clears the scanning placeholder when every dropped file is skipped by conflict resolution", async () => {
|
||||
const events: string[] = [];
|
||||
const file = new File(["local"], "conflict.txt", { lastModified: 1234 });
|
||||
|
||||
const results = await uploadFromDataTransfer(
|
||||
createDataTransfer([file]),
|
||||
{
|
||||
targetPath: "/target",
|
||||
sftpId: null,
|
||||
isLocal: true,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
statLocal: async () => ({ type: "file", size: 10, lastModified: 1000 }),
|
||||
writeLocalFile: async () => {
|
||||
throw new Error("skipped conflicts should not upload");
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
callbacks: {
|
||||
onScanningStart: () => events.push("scan:start"),
|
||||
onScanningEnd: () => events.push("scan:end"),
|
||||
onTaskCreated: () => events.push("task:create"),
|
||||
},
|
||||
resolveConflict: async () => "skip",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(results, [
|
||||
{ fileName: "conflict.txt", success: false, cancelled: true },
|
||||
]);
|
||||
assert.deepEqual(events, ["scan:start", "scan:end"]);
|
||||
});
|
||||
@@ -16,14 +16,13 @@ import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { collectSyncableSettings, hasMeaningfulSyncData } from '../syncPayload';
|
||||
import { collectSyncableSettings, hasMeaningfulCloudSyncData } from '../syncPayload';
|
||||
import { readInterruptedVaultApply } from '../localVaultBackups';
|
||||
import {
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
|
||||
import { notify } from '../notification';
|
||||
|
||||
interface AutoSyncConfig {
|
||||
@@ -35,7 +34,6 @@ interface AutoSyncConfig {
|
||||
customGroups: SyncPayload['customGroups'];
|
||||
snippetPackages?: SyncPayload['snippetPackages'];
|
||||
portForwardingRules?: SyncPayload['portForwardingRules'];
|
||||
knownHosts?: SyncPayload['knownHosts'];
|
||||
groupConfigs?: SyncPayload['groupConfigs'];
|
||||
/** Opaque token that changes whenever a synced setting changes. */
|
||||
settingsVersion?: number;
|
||||
@@ -140,8 +138,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveKnownHosts = getEffectiveKnownHosts(config.knownHosts);
|
||||
|
||||
return {
|
||||
hosts: config.hosts,
|
||||
keys: config.keys,
|
||||
@@ -150,7 +146,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
customGroups: config.customGroups,
|
||||
snippetPackages: config.snippetPackages,
|
||||
portForwardingRules: effectivePFRules,
|
||||
knownHosts: effectiveKnownHosts,
|
||||
groupConfigs: config.groupConfigs,
|
||||
};
|
||||
}, [
|
||||
@@ -161,7 +156,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
config.customGroups,
|
||||
config.snippetPackages,
|
||||
config.portForwardingRules,
|
||||
config.knownHosts,
|
||||
config.groupConfigs,
|
||||
]);
|
||||
|
||||
@@ -283,7 +277,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// checkRemoteVersion below: if inspect transiently errors we still
|
||||
// let auto-sync run, trusting this guard to refuse if local is
|
||||
// truly empty rather than letting an empty state clobber remote.
|
||||
if (!hasMeaningfulSyncData(payload)) {
|
||||
if (!hasMeaningfulCloudSyncData(payload)) {
|
||||
if (trigger === 'auto') {
|
||||
console.warn('[AutoSync] Blocked: refusing to auto-sync an empty vault to cloud');
|
||||
return;
|
||||
@@ -437,8 +431,8 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const remoteFile = inspection.remoteFile;
|
||||
const remotePayload = inspection.payload;
|
||||
const localPayload = buildPayloadRef.current();
|
||||
const localIsEmpty = !hasMeaningfulSyncData(localPayload);
|
||||
const remoteHasData = hasMeaningfulSyncData(remotePayload);
|
||||
const localIsEmpty = !hasMeaningfulCloudSyncData(localPayload);
|
||||
const remoteHasData = hasMeaningfulCloudSyncData(remotePayload);
|
||||
|
||||
// If local vault is empty but cloud has data, this almost certainly
|
||||
// means the user's data was lost (update, storage corruption, etc.).
|
||||
|
||||
@@ -550,7 +550,10 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
// Release the transient "connecting" UI once the browser handoff has
|
||||
// happened. The callback session remains active in the background and
|
||||
// will mark the provider connected when the redirect completes.
|
||||
manager.resetProviderStatus(provider);
|
||||
// Do NOT use resetProviderStatus here — it would restore from the
|
||||
// auth snapshot and delete the adapter we just created, making the
|
||||
// eventual completePKCEAuth call fail with "adapter not initialized".
|
||||
manager.clearConnectingStatus(provider);
|
||||
manager.clearProviderError(provider);
|
||||
void completionPromise;
|
||||
return data.url;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
|
||||
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
|
||||
import { SyncConfig, TerminalTheme, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
|
||||
import {
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_SYNC,
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
shouldApplyIncomingCustomKeyBindingsRecord,
|
||||
updateCustomKeyBinding as updateCustomKeyBindingRecord,
|
||||
} from '../../domain/customKeyBindings';
|
||||
import { getTerminalThemeForUiTheme } from '../../domain/terminalAppearance';
|
||||
import { applyCustomAccentToTerminalTheme, getTerminalThemeForUiTheme } from '../../domain/terminalAppearance';
|
||||
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
|
||||
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
|
||||
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
@@ -1265,6 +1265,7 @@ export const useSettingsState = () => {
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
const currentTerminalTheme = useMemo(() => {
|
||||
let baseTheme: TerminalTheme;
|
||||
// When "Follow Application Theme" is enabled, pick the terminal theme
|
||||
// whose background matches the active UI theme preset.
|
||||
if (followAppTerminalTheme) {
|
||||
@@ -1272,13 +1273,17 @@ export const useSettingsState = () => {
|
||||
const mapped = getTerminalThemeForUiTheme(activeUiThemeId);
|
||||
if (mapped) {
|
||||
const found = TERMINAL_THEMES.find(t => t.id === mapped);
|
||||
if (found) return found;
|
||||
if (found) {
|
||||
baseTheme = found;
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
}
|
||||
}
|
||||
}
|
||||
return TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|
||||
baseTheme = TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|
||||
|| customThemes.find(t => t.id === terminalThemeId)
|
||||
|| TERMINAL_THEMES[0];
|
||||
}, [terminalThemeId, customThemes, followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId]);
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
}, [terminalThemeId, customThemes, followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
|
||||
|
||||
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
|
||||
key: K,
|
||||
|
||||
@@ -271,7 +271,7 @@ export const useSftpState = (
|
||||
|
||||
const {
|
||||
transfers,
|
||||
conflicts,
|
||||
conflicts: transferConflicts,
|
||||
activeTransfersCount,
|
||||
startTransfer,
|
||||
downloadToLocal,
|
||||
@@ -282,7 +282,7 @@ export const useSftpState = (
|
||||
retryTransfer,
|
||||
clearCompletedTransfers,
|
||||
dismissTransfer,
|
||||
resolveConflict,
|
||||
resolveConflict: resolveTransferConflict,
|
||||
} = useSftpTransfers({
|
||||
getActivePane,
|
||||
getPaneByConnectionId,
|
||||
@@ -308,6 +308,8 @@ export const useSftpState = (
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
activeFileWatchCountRef,
|
||||
uploadConflicts,
|
||||
resolveUploadConflict,
|
||||
} = useSftpExternalOperations({
|
||||
getActivePane,
|
||||
getPaneByConnectionId,
|
||||
@@ -322,6 +324,21 @@ export const useSftpState = (
|
||||
dismissExternalUpload: dismissTransfer,
|
||||
});
|
||||
|
||||
const conflicts = useMemo(
|
||||
() => [...transferConflicts, ...uploadConflicts],
|
||||
[transferConflicts, uploadConflicts],
|
||||
);
|
||||
const resolveAnyConflict = useCallback(
|
||||
(...args: Parameters<typeof resolveTransferConflict>) => {
|
||||
const [conflictId] = args;
|
||||
if (uploadConflicts.some((conflict) => conflict.transferId === conflictId)) {
|
||||
return resolveUploadConflict(...args);
|
||||
}
|
||||
return resolveTransferConflict(...args);
|
||||
},
|
||||
[resolveTransferConflict, resolveUploadConflict, uploadConflicts],
|
||||
);
|
||||
|
||||
// Store methods in a ref to create stable wrapper functions
|
||||
// This prevents callback reference changes from causing re-renders in consumers
|
||||
const methodsRef = useRef({
|
||||
@@ -375,7 +392,7 @@ export const useSftpState = (
|
||||
retryTransfer,
|
||||
clearCompletedTransfers,
|
||||
dismissTransfer,
|
||||
resolveConflict,
|
||||
resolveConflict: resolveAnyConflict,
|
||||
getSftpIdForConnection,
|
||||
reportSessionError: handleSessionError,
|
||||
});
|
||||
@@ -430,7 +447,7 @@ export const useSftpState = (
|
||||
retryTransfer,
|
||||
clearCompletedTransfers,
|
||||
dismissTransfer,
|
||||
resolveConflict,
|
||||
resolveConflict: resolveAnyConflict,
|
||||
getSftpIdForConnection,
|
||||
reportSessionError: handleSessionError,
|
||||
};
|
||||
@@ -496,7 +513,7 @@ export const useSftpState = (
|
||||
retryTransfer: (...args: Parameters<typeof retryTransfer>) => methodsRef.current.retryTransfer(...args),
|
||||
clearCompletedTransfers: () => methodsRef.current.clearCompletedTransfers(),
|
||||
dismissTransfer: (...args: Parameters<typeof dismissTransfer>) => methodsRef.current.dismissTransfer(...args),
|
||||
resolveConflict: (...args: Parameters<typeof resolveConflict>) => methodsRef.current.resolveConflict(...args),
|
||||
resolveConflict: (...args: Parameters<typeof resolveAnyConflict>) => methodsRef.current.resolveConflict(...args),
|
||||
getSftpIdForConnection: (...args: Parameters<typeof getSftpIdForConnection>) => methodsRef.current.getSftpIdForConnection(...args),
|
||||
reportSessionError: (...args: Parameters<typeof handleSessionError>) => methodsRef.current.reportSessionError(...args),
|
||||
activeFileWatchCountRef,
|
||||
|
||||
25
application/state/windowInputFocus.ts
Normal file
25
application/state/windowInputFocus.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
|
||||
export const requestWindowInputFocus = (): void => {
|
||||
try {
|
||||
const result = netcattyBridge.get()?.windowFocus?.();
|
||||
void result?.catch?.(() => undefined);
|
||||
} catch {
|
||||
// Browser preview or a disposed Electron bridge.
|
||||
}
|
||||
};
|
||||
|
||||
export const scheduleWindowInputFocus = (): void => {
|
||||
const scheduleFrame: (callback: () => void) => unknown =
|
||||
typeof requestAnimationFrame === "function"
|
||||
? requestAnimationFrame
|
||||
: (callback) => {
|
||||
callback();
|
||||
return undefined;
|
||||
};
|
||||
|
||||
scheduleFrame(() => {
|
||||
requestWindowInputFocus();
|
||||
setTimeout(requestWindowInputFocus, 50);
|
||||
});
|
||||
};
|
||||
139
application/syncPayload.test.ts
Normal file
139
application/syncPayload.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { SyncPayload } from "../domain/sync.ts";
|
||||
import type { KnownHost } from "../domain/models.ts";
|
||||
import type { SyncableVaultData } from "./syncPayload.ts";
|
||||
|
||||
type LocalStorageMock = {
|
||||
clear(): void;
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
};
|
||||
|
||||
function installLocalStorage(): LocalStorageMock {
|
||||
const store = new Map<string, string>();
|
||||
const localStorage: LocalStorageMock = {
|
||||
clear() {
|
||||
store.clear();
|
||||
},
|
||||
getItem(key: string) {
|
||||
return store.has(key) ? store.get(key)! : null;
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store.set(key, String(value));
|
||||
},
|
||||
removeItem(key: string) {
|
||||
store.delete(key);
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: localStorage,
|
||||
configurable: true,
|
||||
});
|
||||
return localStorage;
|
||||
}
|
||||
|
||||
const localStorage = installLocalStorage();
|
||||
const {
|
||||
applyLocalVaultPayload,
|
||||
applySyncPayload,
|
||||
buildLocalVaultPayload,
|
||||
buildSyncPayload,
|
||||
hasMeaningfulCloudSyncData,
|
||||
} = await import("./syncPayload.ts");
|
||||
|
||||
const knownHost = (id = "kh-1"): KnownHost => ({
|
||||
id,
|
||||
hostname: `${id}.example.com`,
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
fingerprint: `SHA256:${id}`,
|
||||
});
|
||||
|
||||
const vault = (knownHosts: KnownHost[] = [knownHost()]): SyncableVaultData => ({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
snippetPackages: [],
|
||||
knownHosts,
|
||||
groupConfigs: [],
|
||||
});
|
||||
|
||||
test.beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
test("buildSyncPayload treats known hosts as local-only data", () => {
|
||||
const payload = buildSyncPayload(vault([knownHost("kh-cloud")]));
|
||||
|
||||
assert.equal("knownHosts" in payload, false);
|
||||
});
|
||||
|
||||
test("hasMeaningfulCloudSyncData ignores legacy cloud known hosts", () => {
|
||||
assert.equal(
|
||||
hasMeaningfulCloudSyncData({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
knownHosts: [knownHost("kh-only")],
|
||||
syncedAt: 1,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildLocalVaultPayload preserves known hosts for local backups", () => {
|
||||
const payload = buildLocalVaultPayload(vault([knownHost("kh-local")]));
|
||||
|
||||
assert.deepEqual(payload.knownHosts, [knownHost("kh-local")]);
|
||||
});
|
||||
|
||||
test("applySyncPayload ignores legacy cloud known hosts", () => {
|
||||
let imported: Record<string, unknown> | null = null;
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
knownHosts: [knownHost("kh-legacy")],
|
||||
syncedAt: 1,
|
||||
};
|
||||
|
||||
applySyncPayload(payload, {
|
||||
importVaultData: (json) => {
|
||||
imported = JSON.parse(json);
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(imported);
|
||||
assert.equal("knownHosts" in imported, false);
|
||||
});
|
||||
|
||||
test("applyLocalVaultPayload restores known hosts from local backups", () => {
|
||||
let imported: Record<string, unknown> | null = null;
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
knownHosts: [knownHost("kh-backup")],
|
||||
syncedAt: 1,
|
||||
};
|
||||
|
||||
applyLocalVaultPayload(payload, {
|
||||
importVaultData: (json) => {
|
||||
imported = JSON.parse(json);
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(imported);
|
||||
assert.deepEqual(imported.knownHosts, [knownHost("kh-backup")]);
|
||||
});
|
||||
@@ -58,7 +58,7 @@ import {
|
||||
|
||||
const CUSTOM_KEY_BINDINGS_SYNC_PAYLOAD_ORIGIN = 'sync-payload';
|
||||
|
||||
/** All vault-owned data that participates in cloud sync. */
|
||||
/** Vault-owned data. Some fields are local-only and excluded from cloud sync. */
|
||||
export interface SyncableVaultData {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
@@ -66,6 +66,7 @@ export interface SyncableVaultData {
|
||||
snippets: Snippet[];
|
||||
customGroups: string[];
|
||||
snippetPackages?: string[];
|
||||
/** Local trust records. Kept in local backups, excluded from cloud sync. */
|
||||
knownHosts: KnownHost[];
|
||||
groupConfigs?: GroupConfig[];
|
||||
}
|
||||
@@ -93,9 +94,31 @@ export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when a payload contains cloud-sync data.
|
||||
* Local-only trust records are intentionally ignored.
|
||||
*/
|
||||
export function hasMeaningfulCloudSyncData(payload: SyncPayload): boolean {
|
||||
const hasEntities =
|
||||
(payload.hosts?.length ?? 0) > 0 ||
|
||||
(payload.keys?.length ?? 0) > 0 ||
|
||||
(payload.snippets?.length ?? 0) > 0 ||
|
||||
(payload.identities?.length ?? 0) > 0 ||
|
||||
(payload.customGroups?.length ?? 0) > 0 ||
|
||||
(payload.snippetPackages?.length ?? 0) > 0 ||
|
||||
(payload.portForwardingRules?.length ?? 0) > 0 ||
|
||||
(payload.groupConfigs?.length ?? 0) > 0;
|
||||
|
||||
if (hasEntities) return true;
|
||||
|
||||
return Boolean(
|
||||
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
/** Callbacks used by `applySyncPayload` to import data into local state. */
|
||||
interface SyncPayloadImporters {
|
||||
/** Import vault data (hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts). */
|
||||
/** Import vault data. Cloud sync excludes local-only known hosts by default. */
|
||||
importVaultData: (jsonString: string) => void;
|
||||
/** Import port-forwarding rules (lives outside the vault hook). */
|
||||
importPortForwardingRules?: (rules: PortForwardingRule[]) => void;
|
||||
@@ -317,7 +340,6 @@ export function buildSyncPayload(
|
||||
snippets: vault.snippets,
|
||||
customGroups: vault.customGroups,
|
||||
snippetPackages: vault.snippetPackages,
|
||||
knownHosts: vault.knownHosts,
|
||||
groupConfigs: vault.groupConfigs,
|
||||
portForwardingRules,
|
||||
settings: collectSyncableSettings(),
|
||||
@@ -325,20 +347,30 @@ export function buildSyncPayload(
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a local backup/restore payload, including local-only trust records. */
|
||||
export function buildLocalVaultPayload(
|
||||
vault: SyncableVaultData,
|
||||
portForwardingRules?: PortForwardingRule[],
|
||||
): SyncPayload {
|
||||
return {
|
||||
...buildSyncPayload(vault, portForwardingRules),
|
||||
knownHosts: vault.knownHosts,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a downloaded `SyncPayload` to local state via the provided importers.
|
||||
*
|
||||
* This ensures both vault data and port-forwarding rules are imported
|
||||
* consistently across windows.
|
||||
*/
|
||||
export function applySyncPayload(
|
||||
function applyPayload(
|
||||
payload: SyncPayload,
|
||||
importers: SyncPayloadImporters,
|
||||
options: { includeLocalOnlyData: boolean },
|
||||
): void {
|
||||
// Build the vault import object. knownHosts is only included when the
|
||||
// payload explicitly carries the field (even if it's []). Legacy cloud
|
||||
// snapshots may omit it entirely — in that case we leave the local
|
||||
// known-hosts list untouched rather than destructively wiping it.
|
||||
// Build the vault import object. Cloud sync intentionally ignores
|
||||
// local-only trust records even if legacy cloud snapshots still carry them.
|
||||
const vaultImport: Record<string, unknown> = {
|
||||
hosts: payload.hosts,
|
||||
keys: payload.keys,
|
||||
@@ -349,7 +381,7 @@ export function applySyncPayload(
|
||||
if (payload.snippetPackages !== undefined) {
|
||||
vaultImport.snippetPackages = payload.snippetPackages;
|
||||
}
|
||||
if (payload.knownHosts !== undefined) {
|
||||
if (options.includeLocalOnlyData && payload.knownHosts !== undefined) {
|
||||
vaultImport.knownHosts = payload.knownHosts;
|
||||
}
|
||||
if (Array.isArray(payload.groupConfigs)) {
|
||||
@@ -374,3 +406,17 @@ export function applySyncPayload(
|
||||
importers.onSettingsApplied?.();
|
||||
}
|
||||
}
|
||||
|
||||
export function applySyncPayload(
|
||||
payload: SyncPayload,
|
||||
importers: SyncPayloadImporters,
|
||||
): void {
|
||||
applyPayload(payload, importers, { includeLocalOnlyData: false });
|
||||
}
|
||||
|
||||
export function applyLocalVaultPayload(
|
||||
payload: SyncPayload,
|
||||
importers: SyncPayloadImporters,
|
||||
): void {
|
||||
applyPayload(payload, importers, { includeLocalOnlyData: true });
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
AIPanelView,
|
||||
AIPermissionMode,
|
||||
AIToolIntegrationMode,
|
||||
AgentModelPreset,
|
||||
AISession,
|
||||
AISessionScope,
|
||||
ChatMessage,
|
||||
@@ -66,10 +67,22 @@ import {
|
||||
type DefaultTargetSessionHint,
|
||||
} from './ai/hooks/useAIChatStreaming';
|
||||
import { buildAcpHistoryMessagesForBridge } from './ai/acpHistory';
|
||||
import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligibility';
|
||||
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
|
||||
import { useConversationExport } from './ai/hooks/useConversationExport';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
|
||||
function modelPresetMatchesId(preset: AgentModelPreset, modelId: string): boolean {
|
||||
if (preset.thinkingLevels?.length) {
|
||||
return preset.thinkingLevels.some((level) => `${preset.id}/${level}` === modelId);
|
||||
}
|
||||
return preset.id === modelId;
|
||||
}
|
||||
|
||||
function modelPresetsContainId(presets: AgentModelPreset[], modelId: string): boolean {
|
||||
return presets.some((preset) => modelPresetMatchesId(preset, modelId));
|
||||
}
|
||||
|
||||
function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
|
||||
if (!agent) return false;
|
||||
const tokens = [
|
||||
@@ -231,7 +244,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
const scopeKey = `${scopeType}:${scopeTargetId ?? ''}`;
|
||||
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, ReturnType<typeof getAgentModelPresets>>>({});
|
||||
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, AgentModelPreset[]>>({});
|
||||
const [userSkillOptions, setUserSkillOptions] = useState<UserSkillOption[]>([]);
|
||||
const { openSettingsWindow } = useWindowControls();
|
||||
const terminalSessionsRef = useRef(terminalSessions);
|
||||
@@ -608,12 +621,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentAgentConfig?.acpCommand) return;
|
||||
// Codex has its own path via aiCodexGetIntegration (reads config.toml).
|
||||
// Everyone else that speaks ACP can be asked for their available models
|
||||
// directly — in particular, Claude Code through claude-agent-acp
|
||||
// advertises the real catalog (including Bedrock/Vertex model ids when
|
||||
// the user configured those) instead of the hardcoded CLAUDE_MODEL_PRESETS.
|
||||
if (!isCopilotExternalAgent && !isClaudeManagedAgent) return;
|
||||
// ACP agents can expose their runtime model catalog during session setup.
|
||||
// Codex also exposes model/reasoning selectors through ACP config options,
|
||||
// which keeps the picker aligned with the user's installed CLI version.
|
||||
if (!isCopilotExternalAgent && !isClaudeManagedAgent && !isCodexManagedAgent) return;
|
||||
|
||||
const bridge = getNetcattyBridge();
|
||||
if (!bridge?.aiAcpListModels) return;
|
||||
@@ -640,13 +651,13 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
});
|
||||
return;
|
||||
}
|
||||
const knownModelIds = new Set(result.models.map((model) => model.id));
|
||||
const runtimePresets = result.models ?? [];
|
||||
setRuntimeAgentModelPresets((prev) => ({
|
||||
...prev,
|
||||
[currentAgentId]: result.models ?? [],
|
||||
[currentAgentId]: runtimePresets,
|
||||
}));
|
||||
const storedModelId = agentModelMapRef.current[currentAgentId];
|
||||
if (result.currentModelId && (!storedModelId || !knownModelIds.has(storedModelId))) {
|
||||
if (result.currentModelId && (!storedModelId || !modelPresetsContainId(runtimePresets, storedModelId))) {
|
||||
setAgentModel(currentAgentId, result.currentModelId);
|
||||
}
|
||||
}).catch((err) => {
|
||||
@@ -658,7 +669,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, isClaudeManagedAgent, setAgentModel]);
|
||||
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, isClaudeManagedAgent, isCodexManagedAgent, setAgentModel]);
|
||||
|
||||
// When Codex is backed by a ~/.codex/config.toml custom provider, the
|
||||
// stock CODEX_MODEL_PRESETS catalog is invalid for that endpoint.
|
||||
@@ -668,7 +679,11 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent;
|
||||
|
||||
const agentModelPresets = useMemo(() => {
|
||||
const runtimePresets = runtimeAgentModelPresets[currentAgentId];
|
||||
if (hasCodexCustomConfig) {
|
||||
if (runtimePresets) {
|
||||
return runtimePresets;
|
||||
}
|
||||
// Config.toml with a pinned model → show just that model.
|
||||
if (codexConfigModel) {
|
||||
return [{ id: codexConfigModel, name: codexConfigModel }];
|
||||
@@ -678,13 +693,13 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
// wouldn't work. Empty list disables the picker.
|
||||
return [];
|
||||
}
|
||||
return runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command);
|
||||
return runtimePresets ?? getAgentModelPresets(currentAgentConfig?.command);
|
||||
}, [currentAgentConfig?.command, currentAgentId, runtimeAgentModelPresets, hasCodexCustomConfig, codexConfigModel]);
|
||||
|
||||
// Per-agent model: recall last selection or use first preset as default
|
||||
const selectedAgentModel = useMemo(() => {
|
||||
const stored = agentModelMap[currentAgentId];
|
||||
if (stored && agentModelPresets.some(p => stored === p.id || stored.startsWith(p.id + '/'))) {
|
||||
if (stored && modelPresetsContainId(agentModelPresets, stored)) {
|
||||
return stored;
|
||||
}
|
||||
// Default to first preset; for models with thinking levels, use the default level
|
||||
@@ -698,6 +713,12 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
return undefined;
|
||||
}, [currentAgentId, agentModelMap, agentModelPresets]);
|
||||
|
||||
const inputAgentId = activeSession?.agentId ?? currentDraft?.agentId ?? currentAgentId;
|
||||
const canSendCurrentAgent = useMemo(
|
||||
() => canSendWithAgent(inputAgentId, externalAgents),
|
||||
[inputAgentId, externalAgents],
|
||||
);
|
||||
|
||||
const handleAgentModelSelect = useCallback((modelId: string) => {
|
||||
setAgentModel(currentAgentId, modelId);
|
||||
}, [currentAgentId, setAgentModel]);
|
||||
@@ -800,6 +821,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
// immediately after the first send path starts; `isStreaming` alone does
|
||||
// not protect the initial draft->session transition.
|
||||
if (!trimmed || isStreaming) return;
|
||||
const sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
|
||||
const agentConfig = sendAgentId !== 'catty' ? findEnabledExternalAgent(externalAgents, sendAgentId) : undefined;
|
||||
if (sendAgentId !== 'catty' && !agentConfig) return;
|
||||
|
||||
const selectedSkillSlugs = draft?.selectedUserSkillSlugs ?? [];
|
||||
const attachments = (draft?.attachments ?? []).map((file) => ({
|
||||
base64Data: file.base64Data,
|
||||
@@ -816,8 +841,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
try {
|
||||
let sessionId = currentSessionView?.id ?? null;
|
||||
let currentSession = currentSessionView ?? null;
|
||||
const sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
|
||||
|
||||
if (isDraftMode) {
|
||||
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
|
||||
const createdSession = createSession(scope, sendAgentId);
|
||||
@@ -857,7 +880,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
setStreamingForScope(sessionId, true);
|
||||
|
||||
// Create assistant message placeholder with a tracked ID
|
||||
const agentConfig = isExternalAgent ? externalAgents.find((agent) => agent.id === sendAgentId) : undefined;
|
||||
const assistantMsgId = generateId();
|
||||
addMessageToSession(sessionId, {
|
||||
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
|
||||
@@ -1088,6 +1110,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
isStreaming={isStreaming}
|
||||
disabled={!canSendCurrentAgent}
|
||||
providerName={providerDisplayName}
|
||||
modelName={modelDisplayName}
|
||||
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}
|
||||
|
||||
@@ -638,6 +638,7 @@ const ConflictModal: React.FC<ConflictModalProps> = ({
|
||||
interface SyncDashboardProps {
|
||||
onBuildPayload: () => SyncPayload;
|
||||
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
onApplyLocalPayload?: (payload: SyncPayload) => void | Promise<void>;
|
||||
onClearLocalData?: () => void;
|
||||
}
|
||||
|
||||
@@ -1055,6 +1056,7 @@ const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
onBuildPayload,
|
||||
onApplyPayload,
|
||||
onApplyLocalPayload,
|
||||
onClearLocalData,
|
||||
}) => {
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
@@ -1916,7 +1918,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
|
||||
<div ref={localBackupsRef}>
|
||||
<LocalBackupsPanel
|
||||
onApplyPayload={onApplyPayload}
|
||||
onApplyPayload={onApplyLocalPayload ?? onApplyPayload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2612,6 +2614,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
interface CloudSyncSettingsProps {
|
||||
onBuildPayload: () => SyncPayload;
|
||||
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
onApplyLocalPayload?: (payload: SyncPayload) => void | Promise<void>;
|
||||
onClearLocalData?: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -408,6 +408,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
} else if (preserveLegacyFontSize && cleaned.fontSize == null) {
|
||||
cleaned.fontSize = initialData?.fontSize;
|
||||
}
|
||||
|
||||
if ((cleaned.protocol && cleaned.protocol !== "ssh") || cleaned.moshEnabled) {
|
||||
delete cleaned.x11Forwarding;
|
||||
}
|
||||
onSave(cleaned);
|
||||
};
|
||||
|
||||
@@ -1551,11 +1555,15 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
enabled={!!form.moshEnabled}
|
||||
onToggle={() => {
|
||||
const enabling = !form.moshEnabled;
|
||||
if (enabling && form.deviceType === 'network') {
|
||||
// Network device mode is incompatible with Mosh — clear it
|
||||
setForm(prev => ({ ...prev, moshEnabled: true, deviceType: undefined }));
|
||||
if (enabling) {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
moshEnabled: true,
|
||||
deviceType: prev.deviceType === 'network' ? undefined : prev.deviceType,
|
||||
x11Forwarding: undefined,
|
||||
}));
|
||||
} else {
|
||||
update("moshEnabled", enabling);
|
||||
update("moshEnabled", false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -1590,6 +1598,24 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* X11 Forwarding */}
|
||||
{(!form.protocol || form.protocol === "ssh") && !form.moshEnabled && (
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<TerminalSquare size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.section.x11Forwarding")}</p>
|
||||
</div>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.x11Forwarding")}
|
||||
enabled={!!form.x11Forwarding}
|
||||
onToggle={() => update("x11Forwarding", !form.x11Forwarding)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.x11Forwarding.desc")}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Network Device Mode — only for SSH hosts without Mosh (serial already uses raw mode) */}
|
||||
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && (
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
|
||||
import { editorTabStore } from "../application/state/editorTabStore";
|
||||
import { releaseEditorTabSaveCoordinator } from "../application/state/editorTabSave";
|
||||
import { useSftpBackend } from "../application/state/useSftpBackend";
|
||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||
import { getParentPath } from "../application/state/sftp/utils";
|
||||
@@ -47,6 +48,7 @@ interface SftpSidePanelProps {
|
||||
/** The host to connect to (follows focused terminal) */
|
||||
activeHost: Host | null;
|
||||
initialLocation?: { hostId: string; path: string } | null;
|
||||
onInitialLocationApplied?: (location: { hostId: string; path: string }) => void;
|
||||
showWorkspaceHostHeader?: boolean;
|
||||
isVisible?: boolean;
|
||||
renderOverlays?: boolean;
|
||||
@@ -67,6 +69,7 @@ interface SftpSidePanelProps {
|
||||
editorWordWrap: boolean;
|
||||
setEditorWordWrap: (value: boolean) => void;
|
||||
onGetTerminalCwd?: () => Promise<string | null>;
|
||||
onRequestTerminalFocus?: () => void;
|
||||
}
|
||||
|
||||
const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
@@ -77,6 +80,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
sftpDefaultViewMode,
|
||||
activeHost,
|
||||
initialLocation,
|
||||
onInitialLocationApplied,
|
||||
showWorkspaceHostHeader = false,
|
||||
isVisible = true,
|
||||
renderOverlays = true,
|
||||
@@ -91,6 +95,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
onGetTerminalCwd,
|
||||
onRequestTerminalFocus,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -163,7 +168,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
if (id) owned.add(id);
|
||||
}
|
||||
if (owned.size === 0) return;
|
||||
editorTabStore.forceCloseBySessions([...owned]);
|
||||
const closed = editorTabStore.forceCloseBySessions([...owned]);
|
||||
closed.forEach(releaseEditorTabSaveCoordinator);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -465,16 +471,18 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
const locationKey = `${connectedKeyRef.current}:${initialLocation.path}`;
|
||||
if (lastAppliedInitialLocationKeyRef.current === locationKey) return;
|
||||
|
||||
lastAppliedInitialLocationKeyRef.current = locationKey;
|
||||
onInitialLocationApplied?.(initialLocation);
|
||||
|
||||
if (connection.currentPath === initialLocation.path) {
|
||||
lastAppliedInitialLocationKeyRef.current = locationKey;
|
||||
return;
|
||||
}
|
||||
|
||||
lastAppliedInitialLocationKeyRef.current = locationKey;
|
||||
sftpRef.current.navigateTo("left", initialLocation.path);
|
||||
}, [
|
||||
activeHost,
|
||||
initialLocation,
|
||||
onInitialLocationApplied,
|
||||
sftp.leftPane,
|
||||
]);
|
||||
|
||||
@@ -723,6 +731,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
handleFileOpenerSelect={handleFileOpenerSelect}
|
||||
handleSelectSystemApp={handleSelectSystemApp}
|
||||
onPromoteToTab={onPromoteToTab}
|
||||
onRequestTerminalFocus={onRequestTerminalFocus}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
@@ -751,6 +760,7 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
|
||||
prev.editorWordWrap === next.editorWordWrap &&
|
||||
prev.setEditorWordWrap === next.setEditorWordWrap &&
|
||||
prev.onGetTerminalCwd === next.onGetTerminalCwd &&
|
||||
prev.onRequestTerminalFocus === next.onRequestTerminalFocus &&
|
||||
prev.initialLocation?.hostId === next.initialLocation?.hostId &&
|
||||
prev.initialLocation?.path === next.initialLocation?.path;
|
||||
|
||||
|
||||
138
components/SftpTransferItem.test.tsx
Normal file
138
components/SftpTransferItem.test.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import type { TransferTask } from "../types.ts";
|
||||
import { SftpTransferItem } from "./sftp/SftpTransferItem.tsx";
|
||||
|
||||
const baseTask: TransferTask = {
|
||||
id: "transfer-1",
|
||||
fileName: "archive.tar.gz",
|
||||
sourcePath: "/local/archive.tar.gz",
|
||||
targetPath: "/remote/archive.tar.gz",
|
||||
sourceConnectionId: "local",
|
||||
targetConnectionId: "remote",
|
||||
direction: "upload",
|
||||
status: "failed",
|
||||
totalBytes: 1024,
|
||||
transferredBytes: 512,
|
||||
speed: 0,
|
||||
error: "Network error",
|
||||
startTime: 1,
|
||||
isDirectory: false,
|
||||
};
|
||||
|
||||
const renderTransferItem = (
|
||||
task: TransferTask,
|
||||
props: Partial<React.ComponentProps<typeof SftpTransferItem>> = {},
|
||||
) =>
|
||||
renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(SftpTransferItem, {
|
||||
task,
|
||||
onCancel: () => {},
|
||||
onRetry: () => {},
|
||||
onDismiss: () => {},
|
||||
...props,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
test("renders failed transfer actions with custom tooltips and readable labels", () => {
|
||||
const markup = renderTransferItem(baseTask);
|
||||
|
||||
assert.match(markup, /aria-label="Retry: archive\.tar\.gz"/);
|
||||
assert.match(markup, /aria-label="Dismiss: archive\.tar\.gz"/);
|
||||
assert.match(markup, /focus-visible:ring-1/);
|
||||
});
|
||||
|
||||
test("renders active transfer cancel action with an item-specific label", () => {
|
||||
const markup = renderTransferItem({
|
||||
...baseTask,
|
||||
status: "transferring",
|
||||
error: undefined,
|
||||
speed: 128,
|
||||
});
|
||||
|
||||
assert.match(markup, /aria-label="Cancel: archive\.tar\.gz"/);
|
||||
});
|
||||
|
||||
test("renders child resize handle as a keyboard-reachable separator", () => {
|
||||
const markup = renderTransferItem(
|
||||
{
|
||||
...baseTask,
|
||||
id: "child-transfer-1",
|
||||
parentTaskId: "transfer-1",
|
||||
status: "transferring",
|
||||
error: undefined,
|
||||
transferredBytes: 256,
|
||||
speed: 128,
|
||||
},
|
||||
{
|
||||
isChild: true,
|
||||
childNameColumnWidth: 260,
|
||||
onResizeNameColumn: () => {},
|
||||
onSetNameColumnWidth: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
assert.match(markup, /role="separator"/);
|
||||
assert.match(markup, /aria-label="Resize file name column"/);
|
||||
assert.match(markup, /aria-orientation="vertical"/);
|
||||
assert.match(markup, /tabindex="0"/);
|
||||
});
|
||||
|
||||
test("can remove duplicate child resize handles from the tab order", () => {
|
||||
const markup = renderTransferItem(
|
||||
{
|
||||
...baseTask,
|
||||
id: "child-transfer-2",
|
||||
parentTaskId: "transfer-1",
|
||||
status: "pending",
|
||||
error: undefined,
|
||||
},
|
||||
{
|
||||
isChild: true,
|
||||
onResizeNameColumn: () => {},
|
||||
onSetNameColumnWidth: () => {},
|
||||
resizeHandleTabIndex: -1,
|
||||
},
|
||||
);
|
||||
|
||||
assert.match(markup, /role="separator"/);
|
||||
assert.match(markup, /tabindex="-1"/);
|
||||
});
|
||||
|
||||
test("keeps reveal target and child toggle as separate buttons", () => {
|
||||
const markup = renderTransferItem(
|
||||
{
|
||||
...baseTask,
|
||||
status: "completed",
|
||||
error: undefined,
|
||||
isDirectory: true,
|
||||
},
|
||||
{
|
||||
canRevealTarget: true,
|
||||
onRevealTarget: () => {},
|
||||
canToggleChildren: true,
|
||||
isExpanded: false,
|
||||
childListId: "children-transfer-1",
|
||||
onToggleChildren: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
const revealStart = markup.indexOf('<button type="button" class="flex min-w-0 flex-1');
|
||||
assert.notEqual(revealStart, -1);
|
||||
|
||||
const revealEnd = markup.indexOf("</button>", revealStart);
|
||||
const toggleStart = markup.indexOf('aria-label="Show detail"');
|
||||
|
||||
assert.notEqual(toggleStart, -1);
|
||||
assert.ok(toggleStart > revealEnd);
|
||||
assert.match(markup, /aria-expanded="false"/);
|
||||
assert.match(markup, /aria-controls="children-transfer-1"/);
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
shouldScrollOnTerminalInput,
|
||||
} from "../domain/terminalScroll";
|
||||
import {
|
||||
applyCustomAccentToTerminalTheme,
|
||||
resolveHostTerminalThemeId,
|
||||
} from "../domain/terminalAppearance";
|
||||
import { classifyDistroId } from "../domain/host";
|
||||
@@ -49,6 +50,7 @@ import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
|
||||
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
|
||||
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
|
||||
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
|
||||
import { applyUserCursorPreference } from "./terminal/runtime/cursorPreference";
|
||||
import { shouldPreserveTerminalFocusOnMouseDown } from "./terminal/toolbarFocus";
|
||||
import { preserveTerminalViewportInScrollback } from "./terminal/clearTerminalViewport";
|
||||
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
|
||||
@@ -126,6 +128,8 @@ interface TerminalProps {
|
||||
fontSize: number;
|
||||
terminalTheme: TerminalTheme;
|
||||
followAppTerminalTheme?: boolean;
|
||||
accentMode?: "theme" | "custom";
|
||||
customAccent?: string;
|
||||
terminalSettings?: TerminalSettings;
|
||||
sessionId: string;
|
||||
startupCommand?: string;
|
||||
@@ -184,6 +188,29 @@ function formatNetSpeed(bytesPerSec: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
type XTermWithPrivateRenderService = XTerm & {
|
||||
_core?: {
|
||||
_renderService?: {
|
||||
_renderRows?: (start: number, end: number) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function forceSyncRenderAfterResize(term: XTerm): void {
|
||||
const renderService = (term as XTermWithPrivateRenderService)._core?._renderService;
|
||||
const renderRows = renderService?._renderRows;
|
||||
if (typeof renderRows !== "function") return;
|
||||
|
||||
const endRow = term.rows - 1;
|
||||
if (endRow < 0) return;
|
||||
|
||||
try {
|
||||
renderRows.call(renderService, 0, endRow);
|
||||
} catch (err) {
|
||||
logger.warn("Sync render after resize failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
host,
|
||||
keys,
|
||||
@@ -201,6 +228,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
fontSize,
|
||||
terminalTheme,
|
||||
followAppTerminalTheme = false,
|
||||
accentMode = "theme",
|
||||
customAccent = "",
|
||||
terminalSettings,
|
||||
sessionId,
|
||||
startupCommand,
|
||||
@@ -658,18 +687,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// When "Follow Application Theme" is on and there's no active
|
||||
// preview, skip per-host overrides — all terminals should use the
|
||||
// UI-matched theme passed via terminalTheme prop.
|
||||
if (followAppTerminalTheme && !themePreviewId) return terminalTheme;
|
||||
if (followAppTerminalTheme && !themePreviewId) {
|
||||
return applyCustomAccentToTerminalTheme(terminalTheme, accentMode, customAccent);
|
||||
}
|
||||
const themeId = themePreviewId ?? resolveHostTerminalThemeId(
|
||||
{ theme: host.theme, themeOverride: host.themeOverride } as Pick<Host, 'theme' | 'themeOverride'>,
|
||||
terminalTheme.id,
|
||||
);
|
||||
let baseTheme = terminalTheme;
|
||||
if (themeId) {
|
||||
const hostTheme = TERMINAL_THEMES.find((t) => t.id === themeId)
|
||||
|| customThemes.find((t) => t.id === themeId);
|
||||
if (hostTheme) return hostTheme;
|
||||
if (hostTheme) baseTheme = hostTheme;
|
||||
}
|
||||
return terminalTheme;
|
||||
}, [customThemes, followAppTerminalTheme, host.theme, host.themeOverride, terminalTheme, themePreviewId]);
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
}, [accentMode, customAccent, customThemes, followAppTerminalTheme, host.theme, host.themeOverride, terminalTheme, themePreviewId]);
|
||||
|
||||
const resolvedChainHosts =
|
||||
chainHosts;
|
||||
@@ -982,8 +1014,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const runFit = () => {
|
||||
try {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
|
||||
const dimensions = fitAddon.proposeDimensions();
|
||||
if (!dimensions || Number.isNaN(dimensions.cols) || Number.isNaN(dimensions.rows)) return;
|
||||
|
||||
lastFittedSizeRef.current = { width, height };
|
||||
fitAddon.fit();
|
||||
// addon-fit 0.11 clears the renderer before resizing, which can show
|
||||
// as a one-frame WebGL blink during layout changes. Resize directly
|
||||
// using the proposed dimensions to preserve the existing behavior
|
||||
// without forcing a blank intermediate frame.
|
||||
if (term.cols !== dimensions.cols || term.rows !== dimensions.rows) {
|
||||
term.resize(dimensions.cols, dimensions.rows);
|
||||
forceSyncRenderAfterResize(term);
|
||||
}
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => {
|
||||
autocompleteRepositionRef.current?.();
|
||||
@@ -1025,8 +1070,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current.options.fontFamily = resolvedFontFamily;
|
||||
|
||||
if (terminalSettings) {
|
||||
termRef.current.options.cursorStyle = terminalSettings.cursorShape;
|
||||
termRef.current.options.cursorBlink = terminalSettings.cursorBlink;
|
||||
applyUserCursorPreference(termRef.current, terminalSettings);
|
||||
termRef.current.options.scrollback = terminalSettings.scrollback === 0 ? 999999 : terminalSettings.scrollback;
|
||||
termRef.current.options.fontWeight = effectiveFontWeight as
|
||||
| 100
|
||||
@@ -1689,8 +1733,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
['--terminal-ui-border' as never]: `var(--terminal-preview-border, color-mix(in srgb, ${effectiveTheme.colors.foreground} 8%, ${effectiveTheme.colors.background} 92%))`,
|
||||
['--terminal-ui-toolbar-btn' as never]: `var(--terminal-preview-toolbar-btn, color-mix(in srgb, ${effectiveTheme.colors.background} 88%, ${effectiveTheme.colors.foreground} 12%))`,
|
||||
['--terminal-ui-toolbar-btn-hover' as never]: `var(--terminal-preview-toolbar-btn-hover, color-mix(in srgb, ${effectiveTheme.colors.background} 78%, ${effectiveTheme.colors.foreground} 22%))`,
|
||||
['--terminal-ui-toolbar-btn-active' as never]: `var(--terminal-preview-toolbar-btn-active, color-mix(in srgb, ${effectiveTheme.colors.background} 68%, ${effectiveTheme.colors.foreground} 32%))`,
|
||||
}), [effectiveTheme.colors.background, effectiveTheme.colors.foreground]);
|
||||
['--terminal-ui-toolbar-btn-active' as never]: `var(--terminal-preview-toolbar-btn-active, color-mix(in srgb, ${effectiveTheme.colors.cursor} 78%, ${effectiveTheme.colors.background} 22%))`,
|
||||
}), [effectiveTheme.colors.background, effectiveTheme.colors.cursor, effectiveTheme.colors.foreground]);
|
||||
|
||||
return (
|
||||
<TerminalContextMenu
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
resolveHostTerminalFontSize,
|
||||
resolveHostTerminalFontWeight,
|
||||
resolveHostTerminalThemeId,
|
||||
applyCustomAccentToTerminalTheme,
|
||||
} from '../domain/terminalAppearance';
|
||||
import { cn, normalizeLineEndings } from '../lib/utils';
|
||||
import { detectLocalOs } from '../lib/localShell';
|
||||
@@ -43,6 +44,7 @@ import Terminal from './Terminal';
|
||||
import { SftpSidePanel } from './SftpSidePanel';
|
||||
import { ScriptsSidePanel } from './ScriptsSidePanel';
|
||||
import { ThemeSidePanel } from './terminal/ThemeSidePanel';
|
||||
import { focusTerminalSessionInput } from './terminal/focusTerminalSession';
|
||||
import { AIChatSidePanel } from './AIChatSidePanel';
|
||||
import { useAIState } from '../application/state/useAIState';
|
||||
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
|
||||
@@ -53,6 +55,7 @@ import { Input } from './ui/input';
|
||||
import { RippleButton } from './ui/ripple';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { setupMcpApprovalBridge } from '../infrastructure/ai/shared/approvalGate';
|
||||
import { resolveScriptsSidePanelShortcutIntent } from '../application/state/resolveSnippetsShortcutIntent';
|
||||
|
||||
type SidePanelTab = 'sftp' | 'scripts' | 'theme' | 'ai';
|
||||
|
||||
@@ -393,6 +396,8 @@ interface TerminalLayerProps {
|
||||
draggingSessionId: string | null;
|
||||
terminalTheme: TerminalTheme;
|
||||
followAppTerminalTheme?: boolean;
|
||||
accentMode?: 'theme' | 'custom';
|
||||
customAccent?: string;
|
||||
terminalSettings?: TerminalSettings;
|
||||
terminalFontFamilyId: string;
|
||||
fontSize?: number;
|
||||
@@ -436,6 +441,7 @@ interface TerminalLayerProps {
|
||||
sessionLogsDir?: string;
|
||||
sessionLogsFormat?: string;
|
||||
closeSidePanelRef?: React.MutableRefObject<(() => void) | null>;
|
||||
toggleScriptsSidePanelRef?: React.MutableRefObject<(() => void) | null>;
|
||||
activeSidePanelTabRef?: React.MutableRefObject<string | null>;
|
||||
}
|
||||
|
||||
@@ -452,6 +458,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
draggingSessionId,
|
||||
terminalTheme,
|
||||
followAppTerminalTheme = false,
|
||||
accentMode = 'theme',
|
||||
customAccent = '',
|
||||
terminalSettings,
|
||||
terminalFontFamilyId,
|
||||
fontSize = 14,
|
||||
@@ -492,6 +500,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
closeSidePanelRef,
|
||||
toggleScriptsSidePanelRef,
|
||||
activeSidePanelTabRef,
|
||||
}) => {
|
||||
// Subscribe to activeTabId from external store
|
||||
@@ -793,6 +802,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSftpInitialLocationApplied = useCallback((tabId: string, location: { hostId: string; path: string }) => {
|
||||
setSftpInitialLocationForTab(prev => {
|
||||
const current = prev.get(tabId);
|
||||
if (!current || current.hostId !== location.hostId || current.path !== location.path) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.delete(tabId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Focus-mode workspace sidebar resize handler. The sidebar is always
|
||||
// anchored to the left of the workspace area, so a rightward drag grows it.
|
||||
const handleFocusSidebarResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
@@ -1294,9 +1315,26 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
[sidePanelOpenTabs],
|
||||
);
|
||||
|
||||
const getActiveTerminalSessionId = useCallback((): string | null => {
|
||||
if (!activeWorkspace) return activeSession?.id ?? null;
|
||||
|
||||
const workspaceSessionIdSet = new Set(collectSessionIds(activeWorkspace.root));
|
||||
const focusedSessionId = activeWorkspace.focusedSessionId;
|
||||
if (focusedSessionId && workspaceSessionIdSet.has(focusedSessionId) && sessions.some((session) => session.id === focusedSessionId)) {
|
||||
return focusedSessionId;
|
||||
}
|
||||
|
||||
return sessions.find((session) => workspaceSessionIdSet.has(session.id))?.id ?? null;
|
||||
}, [activeWorkspace, activeSession?.id, sessions]);
|
||||
|
||||
const syncWorkspaceFocusIfNeeded = useCallback((sessionId: string | null) => {
|
||||
if (!activeWorkspace || !sessionId || activeWorkspace.focusedSessionId === sessionId) return;
|
||||
onSetWorkspaceFocusedSession?.(activeWorkspace.id, sessionId);
|
||||
}, [activeWorkspace, onSetWorkspaceFocusedSession]);
|
||||
|
||||
// Get the focused terminal's current working directory
|
||||
const getTerminalCwd = useCallback(async (): Promise<string | null> => {
|
||||
const sessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id;
|
||||
const sessionId = getActiveTerminalSessionId();
|
||||
if (!sessionId) return null;
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionId);
|
||||
@@ -1304,27 +1342,23 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [activeWorkspace?.focusedSessionId, activeSession?.id, terminalBackend]);
|
||||
}, [getActiveTerminalSessionId, terminalBackend]);
|
||||
|
||||
const refocusTerminalSession = useCallback((sessionId?: string | null) => {
|
||||
if (!sessionId) return;
|
||||
|
||||
const focusTarget = () => {
|
||||
const pane = document.querySelector(`[data-session-id="${sessionId}"]`);
|
||||
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
|
||||
textarea?.focus();
|
||||
};
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
focusTarget();
|
||||
setTimeout(focusTarget, 50);
|
||||
});
|
||||
focusTerminalSessionInput(sessionId);
|
||||
}, []);
|
||||
|
||||
const refocusActiveTerminalSession = useCallback(() => {
|
||||
const sessionId = getActiveTerminalSessionId();
|
||||
syncWorkspaceFocusIfNeeded(sessionId);
|
||||
refocusTerminalSession(sessionId);
|
||||
}, [getActiveTerminalSessionId, refocusTerminalSession, syncWorkspaceFocusIfNeeded]);
|
||||
|
||||
// Close the entire side panel for the current tab
|
||||
const handleCloseSidePanel = useCallback(() => {
|
||||
if (!activeTabId) return;
|
||||
const sessionIdToRefocus = activeWorkspace?.focusedSessionId ?? activeSession?.id;
|
||||
const sessionIdToRefocus = getActiveTerminalSessionId();
|
||||
syncWorkspaceFocusIfNeeded(sessionIdToRefocus);
|
||||
setSidePanelOpenTabs(prev => {
|
||||
const next = new Map(prev);
|
||||
next.delete(activeTabId);
|
||||
@@ -1348,7 +1382,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
return next;
|
||||
});
|
||||
refocusTerminalSession(sessionIdToRefocus);
|
||||
}, [activeTabId, activeWorkspace?.focusedSessionId, activeSession?.id, refocusTerminalSession]);
|
||||
}, [activeTabId, getActiveTerminalSessionId, refocusTerminalSession, syncWorkspaceFocusIfNeeded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!closeSidePanelRef) return;
|
||||
@@ -1403,6 +1437,34 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleSwitchSidePanelTab('scripts');
|
||||
}, [handleSwitchSidePanelTab]);
|
||||
|
||||
const handleToggleScriptsSidePanel = useCallback(() => {
|
||||
const tabId = activeTabIdRef.current;
|
||||
if (!tabId) return;
|
||||
|
||||
const intent = resolveScriptsSidePanelShortcutIntent(
|
||||
sidePanelOpenTabsRef.current.get(tabId) ?? null,
|
||||
);
|
||||
|
||||
if (intent.kind === 'closeTerminalSidePanel') {
|
||||
handleCloseSidePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
setSidePanelOpenTabs(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(tabId, 'scripts');
|
||||
return next;
|
||||
});
|
||||
}, [handleCloseSidePanel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toggleScriptsSidePanelRef) return;
|
||||
toggleScriptsSidePanelRef.current = handleToggleScriptsSidePanel;
|
||||
return () => {
|
||||
toggleScriptsSidePanelRef.current = null;
|
||||
};
|
||||
}, [toggleScriptsSidePanelRef, handleToggleScriptsSidePanel]);
|
||||
|
||||
// Open theme side panel (called from Terminal toolbar)
|
||||
const handleOpenTheme = useCallback(() => {
|
||||
handleSwitchSidePanelTab('theme');
|
||||
@@ -1523,35 +1585,37 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
return;
|
||||
}
|
||||
const pane = document.querySelector<HTMLElement>(`[data-session-id="${sessionId}"]`);
|
||||
const theme = TERMINAL_THEMES.find((entry) => entry.id === themeId)
|
||||
const baseTheme = TERMINAL_THEMES.find((entry) => entry.id === themeId)
|
||||
|| customThemes.find((entry) => entry.id === themeId);
|
||||
if (!pane || !theme) {
|
||||
if (!pane || !baseTheme) {
|
||||
clearTerminalPreviewVars(sessionId);
|
||||
return;
|
||||
}
|
||||
const theme = applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
|
||||
pane.style.setProperty('--terminal-preview-bg', theme.colors.background);
|
||||
pane.style.setProperty('--terminal-preview-fg', theme.colors.foreground);
|
||||
pane.style.setProperty('--terminal-preview-border', `color-mix(in srgb, ${theme.colors.foreground} 8%, ${theme.colors.background} 92%)`);
|
||||
pane.style.setProperty('--terminal-preview-toolbar-btn', `color-mix(in srgb, ${theme.colors.background} 88%, ${theme.colors.foreground} 12%)`);
|
||||
pane.style.setProperty('--terminal-preview-toolbar-btn-hover', `color-mix(in srgb, ${theme.colors.background} 78%, ${theme.colors.foreground} 22%)`);
|
||||
pane.style.setProperty('--terminal-preview-toolbar-btn-active', `color-mix(in srgb, ${theme.colors.background} 68%, ${theme.colors.foreground} 32%)`);
|
||||
}, [customThemes]);
|
||||
pane.style.setProperty('--terminal-preview-toolbar-btn-active', `color-mix(in srgb, ${theme.colors.cursor} 78%, ${theme.colors.background} 22%)`);
|
||||
}, [accentMode, customAccent, customThemes]);
|
||||
const applyTopTabsPreviewVars = useCallback((themeId: string | null) => {
|
||||
if (!themeId || typeof document === 'undefined') {
|
||||
clearTopTabsPreviewVars();
|
||||
return;
|
||||
}
|
||||
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
const theme = TERMINAL_THEMES.find((entry) => entry.id === themeId)
|
||||
const baseTheme = TERMINAL_THEMES.find((entry) => entry.id === themeId)
|
||||
|| customThemes.find((entry) => entry.id === themeId);
|
||||
if (!tabsRoot || !theme) {
|
||||
if (!tabsRoot || !baseTheme) {
|
||||
clearTopTabsPreviewVars();
|
||||
return;
|
||||
}
|
||||
const theme = applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
const bg = hexToHslToken(theme.colors.background);
|
||||
const fg = hexToHslToken(theme.colors.foreground);
|
||||
const accent = fg;
|
||||
const accent = hexToHslToken(theme.colors.cursor);
|
||||
const isDark = theme.type === 'dark';
|
||||
const secondary = adjustLightnessToken(bg, isDark ? 6 : -5);
|
||||
const border = adjustLightnessToken(bg, isDark ? 12 : -10);
|
||||
@@ -1568,8 +1632,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
tabsRoot.style.setProperty('--top-tabs-fg', 'hsl(var(--foreground))');
|
||||
tabsRoot.style.setProperty('--top-tabs-muted', 'hsl(var(--muted-foreground))');
|
||||
tabsRoot.style.setProperty('--top-tabs-active-bg', 'hsl(var(--background))');
|
||||
tabsRoot.style.setProperty('--top-tabs-accent', 'hsl(var(--foreground))');
|
||||
}, [customThemes]);
|
||||
tabsRoot.style.setProperty('--top-tabs-accent', 'hsl(var(--accent))');
|
||||
}, [accentMode, customAccent, customThemes]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -1832,10 +1896,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
const resolvedPreviewTheme = useMemo(() => {
|
||||
const themeId = previewedOrVisibleThemeId;
|
||||
return TERMINAL_THEMES.find((theme) => theme.id === themeId)
|
||||
const baseTheme = TERMINAL_THEMES.find((theme) => theme.id === themeId)
|
||||
|| customThemes.find((theme) => theme.id === themeId)
|
||||
|| terminalTheme;
|
||||
}, [customThemes, previewedOrVisibleThemeId, terminalTheme]);
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
}, [accentMode, customAccent, customThemes, previewedOrVisibleThemeId, terminalTheme]);
|
||||
const sessionLogConfig = useMemo(
|
||||
() =>
|
||||
sessionLogsEnabled && sessionLogsDir
|
||||
@@ -2144,6 +2209,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
style={{
|
||||
['--terminal-sidepanel-bg' as never]: resolvedPreviewTheme.colors.background,
|
||||
['--terminal-sidepanel-fg' as never]: resolvedPreviewTheme.colors.foreground,
|
||||
['--terminal-sidepanel-accent' as never]: resolvedPreviewTheme.colors.cursor,
|
||||
['--terminal-sidepanel-muted' as never]: `color-mix(in srgb, ${resolvedPreviewTheme.colors.foreground} 62%, ${resolvedPreviewTheme.colors.background} 38%)`,
|
||||
['--terminal-sidepanel-border' as never]: `color-mix(in srgb, ${resolvedPreviewTheme.colors.foreground} 12%, ${resolvedPreviewTheme.colors.background} 88%)`,
|
||||
backgroundColor: 'var(--terminal-sidepanel-bg)',
|
||||
@@ -2166,6 +2232,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
data-state={activeSidePanelTab === 'sftp' ? 'active' : 'inactive'}
|
||||
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{
|
||||
backgroundColor: activeSidePanelTab === 'sftp'
|
||||
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
|
||||
: 'transparent',
|
||||
color: activeSidePanelTab === 'sftp'
|
||||
? 'var(--terminal-sidepanel-fg)'
|
||||
: 'var(--terminal-sidepanel-muted)',
|
||||
@@ -2183,6 +2252,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
data-state={activeSidePanelTab === 'scripts' ? 'active' : 'inactive'}
|
||||
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{
|
||||
backgroundColor: activeSidePanelTab === 'scripts'
|
||||
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
|
||||
: 'transparent',
|
||||
color: activeSidePanelTab === 'scripts'
|
||||
? 'var(--terminal-sidepanel-fg)'
|
||||
: 'var(--terminal-sidepanel-muted)',
|
||||
@@ -2200,6 +2272,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
data-state={activeSidePanelTab === 'theme' ? 'active' : 'inactive'}
|
||||
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{
|
||||
backgroundColor: activeSidePanelTab === 'theme'
|
||||
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
|
||||
: 'transparent',
|
||||
color: activeSidePanelTab === 'theme'
|
||||
? 'var(--terminal-sidepanel-fg)'
|
||||
: 'var(--terminal-sidepanel-muted)',
|
||||
@@ -2217,6 +2292,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
data-state={activeSidePanelTab === 'ai' ? 'active' : 'inactive'}
|
||||
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{
|
||||
backgroundColor: activeSidePanelTab === 'ai'
|
||||
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
|
||||
: 'transparent',
|
||||
color: activeSidePanelTab === 'ai'
|
||||
? 'var(--terminal-sidepanel-fg)'
|
||||
: 'var(--terminal-sidepanel-muted)',
|
||||
@@ -2271,6 +2349,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
? (sftpInitialLocationForTab.get(tabId) ?? null)
|
||||
: null
|
||||
}
|
||||
onInitialLocationApplied={(location) => handleSftpInitialLocationApplied(tabId, location)}
|
||||
showWorkspaceHostHeader={isVisibleSftpPanel && !!activeWorkspace}
|
||||
isVisible={isVisibleSftpPanel}
|
||||
renderOverlays={isVisibleSftpPanel}
|
||||
@@ -2285,6 +2364,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
onGetTerminalCwd={getTerminalCwd}
|
||||
onRequestTerminalFocus={refocusActiveTerminalSession}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -2466,6 +2546,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
fontSize={fontSize}
|
||||
terminalTheme={terminalTheme}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
accentMode={accentMode}
|
||||
customAccent={customAccent}
|
||||
terminalSettings={terminalSettings}
|
||||
sessionId={session.id}
|
||||
startupCommand={session.startupCommand}
|
||||
@@ -2582,6 +2664,8 @@ const terminalLayerAreEqual = (prev: TerminalLayerProps, next: TerminalLayerProp
|
||||
prev.workspaces === next.workspaces &&
|
||||
prev.draggingSessionId === next.draggingSessionId &&
|
||||
prev.terminalTheme === next.terminalTheme &&
|
||||
prev.accentMode === next.accentMode &&
|
||||
prev.customAccent === next.customAccent &&
|
||||
prev.terminalSettings === next.terminalSettings &&
|
||||
prev.fontSize === next.fontSize &&
|
||||
prev.hotkeyScheme === next.hotkeyScheme &&
|
||||
@@ -2599,6 +2683,7 @@ const terminalLayerAreEqual = (prev: TerminalLayerProps, next: TerminalLayerProp
|
||||
prev.onToggleWorkspaceViewMode === next.onToggleWorkspaceViewMode &&
|
||||
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
|
||||
prev.onSplitSession === next.onSplitSession &&
|
||||
prev.toggleScriptsSidePanelRef === next.toggleScriptsSidePanelRef &&
|
||||
prev.identities === next.identities
|
||||
);
|
||||
};
|
||||
|
||||
45
components/TextEditorModal.test.tsx
Normal file
45
components/TextEditorModal.test.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createTextEditorModalSnapshot } from "./TextEditorModal.tsx";
|
||||
import { createTextEditorSaveCoordinator } from "../application/state/textEditorSaveCoordinator.ts";
|
||||
|
||||
test("promotion snapshot uses the latest saved baseline after a save", async () => {
|
||||
let baselineContent = "old";
|
||||
let content = "saved";
|
||||
const coordinator = createTextEditorSaveCoordinator({
|
||||
onSave: async () => {},
|
||||
onSaveSuccess: (savedContent) => {
|
||||
baselineContent = savedContent;
|
||||
},
|
||||
});
|
||||
|
||||
await coordinator.save(content);
|
||||
|
||||
const snapshot = createTextEditorModalSnapshot({
|
||||
fileName: "file.txt",
|
||||
getBaselineContent: () => baselineContent,
|
||||
getContent: () => content,
|
||||
languageId: "plaintext",
|
||||
wordWrap: false,
|
||||
getViewState: () => null,
|
||||
isSaving: () => false,
|
||||
});
|
||||
|
||||
assert.equal(snapshot?.baselineContent, "saved");
|
||||
assert.equal(snapshot?.content, "saved");
|
||||
});
|
||||
|
||||
test("promotion snapshot is blocked while saving", () => {
|
||||
const snapshot = createTextEditorModalSnapshot({
|
||||
fileName: "file.txt",
|
||||
getBaselineContent: () => "old",
|
||||
getContent: () => "new",
|
||||
languageId: "plaintext",
|
||||
wordWrap: false,
|
||||
getViewState: () => null,
|
||||
isSaving: () => true,
|
||||
});
|
||||
|
||||
assert.equal(snapshot, null);
|
||||
});
|
||||
@@ -9,14 +9,20 @@ import { getLanguageId } from '../lib/sftpFileUtils';
|
||||
import { Dialog, DialogContent, DialogTitle } from './ui/dialog';
|
||||
import { toast } from './ui/toast';
|
||||
import { TextEditorPane } from './editor/TextEditorPane';
|
||||
import { promptUnsavedChanges } from './editor/UnsavedChangesDialog';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { scheduleWindowInputFocus } from '../application/state/windowInputFocus';
|
||||
import {
|
||||
createTextEditorSaveCoordinator,
|
||||
type TextEditorSaveCoordinator,
|
||||
} from '../application/state/textEditorSaveCoordinator';
|
||||
import type { HotkeyScheme, KeyBinding } from '../domain/models';
|
||||
|
||||
/** Snapshot passed to `onPromoteToTab` when the user clicks the maximize button. */
|
||||
export interface TextEditorModalSnapshot {
|
||||
/** The file name at the time of promotion (modal's fileName prop). */
|
||||
fileName: string;
|
||||
/** The clean baseline content at the time the modal was opened. */
|
||||
/** The clean baseline content at the time of promotion. */
|
||||
baselineContent: string;
|
||||
/** The current (possibly-dirty) editor content. */
|
||||
content: string;
|
||||
@@ -28,6 +34,31 @@ export interface TextEditorModalSnapshot {
|
||||
viewState: Monaco.editor.ICodeEditorViewState | null;
|
||||
}
|
||||
|
||||
export interface TextEditorModalSnapshotSource {
|
||||
fileName: string;
|
||||
getBaselineContent: () => string;
|
||||
getContent: () => string;
|
||||
languageId: string;
|
||||
wordWrap: boolean;
|
||||
getViewState: () => Monaco.editor.ICodeEditorViewState | null;
|
||||
isSaving: () => boolean;
|
||||
}
|
||||
|
||||
export const createTextEditorModalSnapshot = (
|
||||
source: TextEditorModalSnapshotSource,
|
||||
): TextEditorModalSnapshot | null => {
|
||||
if (source.isSaving()) return null;
|
||||
return {
|
||||
fileName: source.fileName,
|
||||
baselineContent: source.getBaselineContent(),
|
||||
content: source.getContent(),
|
||||
languageId: source.languageId,
|
||||
wordWrap: source.wordWrap,
|
||||
viewState: source.getViewState(),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
interface TextEditorModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -57,51 +88,128 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
const { t } = useI18n();
|
||||
|
||||
const [content, setContent] = useState(initialContent);
|
||||
const [baselineContent, setBaselineContent] = useState(initialContent);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [languageId, setLanguageId] = useState(() => getLanguageId(fileName));
|
||||
const contentRef = useRef(initialContent);
|
||||
const baselineContentRef = useRef(initialContent);
|
||||
const savingRef = useRef(false);
|
||||
const closePromptRef = useRef<Promise<void> | null>(null);
|
||||
const onSaveRef = useRef(onSave);
|
||||
const tRef = useRef(t);
|
||||
const saveCoordinatorRef = useRef<TextEditorSaveCoordinator | null>(null);
|
||||
|
||||
// Latest view state captured from Pane's onContentChange — used by handlePromote
|
||||
const viewStateRef = useRef<Monaco.editor.ICodeEditorViewState | null>(null);
|
||||
|
||||
// Derived: whether the current content differs from the clean baseline
|
||||
const hasChanges = content !== initialContent;
|
||||
const hasChanges = content !== baselineContent;
|
||||
|
||||
if (!saveCoordinatorRef.current) {
|
||||
saveCoordinatorRef.current = createTextEditorSaveCoordinator({
|
||||
onSave: (contentToSave) => onSaveRef.current(contentToSave),
|
||||
onSaveStart: () => {
|
||||
setSaveError(null);
|
||||
},
|
||||
onSaveSuccess: (savedContent) => {
|
||||
setBaselineContent(savedContent);
|
||||
baselineContentRef.current = savedContent;
|
||||
toast.success(tRef.current('sftp.editor.saved'), 'SFTP');
|
||||
},
|
||||
onSaveError: (error) => {
|
||||
const msg = error instanceof Error
|
||||
? error.message
|
||||
: tRef.current('sftp.editor.saveFailed');
|
||||
setSaveError(msg);
|
||||
toast.error(msg, 'SFTP');
|
||||
},
|
||||
onSavingChange: (nextSaving) => {
|
||||
savingRef.current = nextSaving;
|
||||
setSaving(nextSaving);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
onSaveRef.current = onSave;
|
||||
}, [onSave]);
|
||||
|
||||
useEffect(() => {
|
||||
tRef.current = t;
|
||||
}, [t]);
|
||||
|
||||
// Reset all state when a new file is opened
|
||||
useEffect(() => {
|
||||
saveCoordinatorRef.current?.reset();
|
||||
setContent(initialContent);
|
||||
setBaselineContent(initialContent);
|
||||
setSaveError(null);
|
||||
setSaving(false);
|
||||
setLanguageId(getLanguageId(fileName));
|
||||
contentRef.current = initialContent;
|
||||
baselineContentRef.current = initialContent;
|
||||
savingRef.current = false;
|
||||
closePromptRef.current = null;
|
||||
viewStateRef.current = null;
|
||||
}, [initialContent, fileName]);
|
||||
|
||||
const saveContent = useCallback(async (contentToSave = contentRef.current): Promise<boolean> => {
|
||||
return saveCoordinatorRef.current?.save(contentToSave) ?? false;
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
await onSave(content);
|
||||
toast.success(t('sftp.editor.saved'), 'SFTP');
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : t('sftp.editor.saveFailed');
|
||||
setSaveError(msg);
|
||||
toast.error(msg, 'SFTP');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [content, onSave, saving, t]);
|
||||
await saveContent();
|
||||
}, [saveContent]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (hasChanges) {
|
||||
const confirmed = confirm(t('sftp.editor.unsavedChanges'));
|
||||
if (!confirmed) return;
|
||||
if (closePromptRef.current) return;
|
||||
|
||||
const closeTask = (async () => {
|
||||
if (contentRef.current !== baselineContentRef.current) {
|
||||
const choice = await promptUnsavedChanges(fileName);
|
||||
if (choice === 'cancel') return;
|
||||
if (choice === 'save') {
|
||||
const saved = await saveContent();
|
||||
if (!saved) return;
|
||||
if (contentRef.current !== baselineContentRef.current) return;
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
scheduleWindowInputFocus();
|
||||
})().finally(() => {
|
||||
closePromptRef.current = null;
|
||||
});
|
||||
|
||||
closePromptRef.current = closeTask;
|
||||
}, [fileName, onClose, saveContent]);
|
||||
|
||||
useEffect(() => {
|
||||
contentRef.current = content;
|
||||
}, [content]);
|
||||
|
||||
useEffect(() => {
|
||||
baselineContentRef.current = baselineContent;
|
||||
}, [baselineContent]);
|
||||
|
||||
useEffect(() => {
|
||||
savingRef.current = saving;
|
||||
}, [saving]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
closePromptRef.current = null;
|
||||
}
|
||||
onClose();
|
||||
}, [hasChanges, onClose, t]);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) scheduleWindowInputFocus();
|
||||
}, [open]);
|
||||
|
||||
const handleContentChange = useCallback(
|
||||
(nextContent: string, viewState: Monaco.editor.ICodeEditorViewState | null) => {
|
||||
setContent(nextContent);
|
||||
contentRef.current = nextContent;
|
||||
viewStateRef.current = viewState;
|
||||
},
|
||||
[],
|
||||
@@ -109,15 +217,17 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
|
||||
const handlePromote = useCallback(() => {
|
||||
if (!onPromoteToTab) return;
|
||||
onPromoteToTab({
|
||||
const snapshot = createTextEditorModalSnapshot({
|
||||
fileName,
|
||||
baselineContent: initialContent,
|
||||
content,
|
||||
getBaselineContent: () => baselineContentRef.current,
|
||||
getContent: () => contentRef.current,
|
||||
languageId,
|
||||
wordWrap: editorWordWrap,
|
||||
viewState: viewStateRef.current,
|
||||
getViewState: () => viewStateRef.current,
|
||||
isSaving: () => savingRef.current,
|
||||
});
|
||||
}, [onPromoteToTab, fileName, initialContent, content, languageId, editorWordWrap]);
|
||||
if (snapshot) onPromoteToTab(snapshot);
|
||||
}, [onPromoteToTab, fileName, languageId, editorWordWrap]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
|
||||
32
components/VaultView.memo.test.tsx
Normal file
32
components/VaultView.memo.test.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { vaultViewAreEqual } from "./VaultView.tsx";
|
||||
|
||||
test("VaultView re-renders when an external section navigation request changes", () => {
|
||||
const baseProps = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
snippetPackages: [],
|
||||
customGroups: [],
|
||||
knownHosts: [],
|
||||
shellHistory: [],
|
||||
connectionLogs: [],
|
||||
sessions: [],
|
||||
managedSources: [],
|
||||
groupConfigs: {},
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
navigateToSection: null,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
vaultViewAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, navigateToSection: "snippets" } as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -3199,7 +3199,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
};
|
||||
|
||||
// Only re-render when data props change - isActive is now managed internally via store subscription
|
||||
const vaultViewAreEqual = (
|
||||
export const vaultViewAreEqual = (
|
||||
prev: VaultViewProps,
|
||||
next: VaultViewProps,
|
||||
): boolean => {
|
||||
@@ -3217,7 +3217,8 @@ const vaultViewAreEqual = (
|
||||
prev.managedSources === next.managedSources &&
|
||||
prev.groupConfigs === next.groupConfigs &&
|
||||
prev.terminalThemeId === next.terminalThemeId &&
|
||||
prev.terminalFontSize === next.terminalFontSize;
|
||||
prev.terminalFontSize === next.terminalFontSize &&
|
||||
prev.navigateToSection === next.navigateToSection;
|
||||
|
||||
return isEqual;
|
||||
};
|
||||
|
||||
@@ -9,27 +9,37 @@ export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: 'user' | 'assistant' | 'system' | 'tool';
|
||||
};
|
||||
|
||||
// Public CSS hooks for user customization (Settings → Appearance → Custom CSS):
|
||||
// .ai-chat-message[data-role="user"] — outer row, user-authored
|
||||
// .ai-chat-message[data-role="assistant"] — outer row, assistant reply
|
||||
// .ai-chat-message-content[data-role=...] — inner bubble / content area
|
||||
// These attributes are part of the UI's stable contract; do not rename
|
||||
// without updating Custom CSS docs.
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex w-full max-w-[95%] flex-col gap-1.5',
|
||||
'ai-chat-message group flex w-full max-w-[95%] flex-col gap-1.5',
|
||||
from === 'user' ? 'is-user ml-auto' : 'is-assistant',
|
||||
className,
|
||||
)}
|
||||
data-role={from}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from?: 'user' | 'assistant' | 'system' | 'tool';
|
||||
};
|
||||
|
||||
export const MessageContent = ({ children, className, ...props }: MessageContentProps) => (
|
||||
export const MessageContent = ({ children, className, from, ...props }: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-fit min-w-0 max-w-full flex-col gap-1.5 text-[13px] leading-relaxed',
|
||||
'group-[.is-user]:ml-auto group-[.is-user]:overflow-hidden group-[.is-user]:rounded-lg group-[.is-user]:border group-[.is-user]:border-border/50 group-[.is-user]:bg-muted/50 group-[.is-user]:px-2.5 group-[.is-user]:py-2',
|
||||
'ai-chat-message-content flex w-fit min-w-0 max-w-full flex-col gap-1.5 text-[13px] leading-relaxed',
|
||||
'group-[.is-user]:ml-auto group-[.is-user]:overflow-hidden group-[.is-user]:rounded-lg group-[.is-user]:border group-[.is-user]:border-border/50 group-[.is-user]:bg-muted/50 group-[.is-user]:px-2.5 group-[.is-user]:py-[7px]',
|
||||
'group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground/90',
|
||||
className,
|
||||
)}
|
||||
data-role={from}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -196,7 +196,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
|
||||
return (
|
||||
<Message key={message.id} from={message.role}>
|
||||
<MessageContent>
|
||||
<MessageContent from={message.role}>
|
||||
{/* Thinking block */}
|
||||
{!isUser && message.thinking && (
|
||||
<ThinkingBlock
|
||||
@@ -233,7 +233,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
|
||||
{message.content && (
|
||||
isUser
|
||||
? <div className="whitespace-pre-wrap break-words text-[13px]">{message.content}</div>
|
||||
? <div className="whitespace-pre-wrap break-words text-[13px] leading-[1.45]">{message.content}</div>
|
||||
: <MessageResponse isAnimating={isThisStreaming}>
|
||||
{message.content}
|
||||
</MessageResponse>
|
||||
|
||||
35
components/ai/agentSendEligibility.test.ts
Normal file
35
components/ai/agentSendEligibility.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { canSendWithAgent, findEnabledExternalAgent } from './agentSendEligibility';
|
||||
import type { ExternalAgentConfig } from '../../infrastructure/ai/types';
|
||||
|
||||
const agents: ExternalAgentConfig[] = [
|
||||
{
|
||||
id: 'enabled-agent',
|
||||
name: 'Enabled Agent',
|
||||
command: '/usr/local/bin/enabled-agent',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'disabled-agent',
|
||||
name: 'Disabled Agent',
|
||||
command: '/usr/local/bin/disabled-agent',
|
||||
enabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
test('canSendWithAgent allows Catty and enabled external agents', () => {
|
||||
assert.equal(canSendWithAgent('catty', agents), true);
|
||||
assert.equal(canSendWithAgent('enabled-agent', agents), true);
|
||||
});
|
||||
|
||||
test('canSendWithAgent blocks missing or disabled external agents', () => {
|
||||
assert.equal(canSendWithAgent('disabled-agent', agents), false);
|
||||
assert.equal(canSendWithAgent('missing-agent', agents), false);
|
||||
});
|
||||
|
||||
test('findEnabledExternalAgent ignores disabled external agents', () => {
|
||||
assert.equal(findEnabledExternalAgent(agents, 'enabled-agent')?.name, 'Enabled Agent');
|
||||
assert.equal(findEnabledExternalAgent(agents, 'disabled-agent'), undefined);
|
||||
});
|
||||
15
components/ai/agentSendEligibility.ts
Normal file
15
components/ai/agentSendEligibility.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { ExternalAgentConfig } from "../../infrastructure/ai/types";
|
||||
|
||||
export function findEnabledExternalAgent(
|
||||
agents: ExternalAgentConfig[],
|
||||
agentId: string,
|
||||
): ExternalAgentConfig | undefined {
|
||||
return agents.find((agent) => agent.id === agentId && agent.enabled);
|
||||
}
|
||||
|
||||
export function canSendWithAgent(
|
||||
agentId: string,
|
||||
agents: ExternalAgentConfig[],
|
||||
): boolean {
|
||||
return agentId === "catty" || Boolean(findEnabledExternalAgent(agents, agentId));
|
||||
}
|
||||
@@ -31,6 +31,18 @@ import type { NetcattyBridge, ExecutorContext } from '../../../infrastructure/ai
|
||||
import { runExternalAgentTurn } from '../../../infrastructure/ai/externalAgentAdapter';
|
||||
import { runAcpAgentTurn } from '../../../infrastructure/ai/acpAgentAdapter';
|
||||
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
|
||||
import {
|
||||
extractProviderContinuationFromRawChunk,
|
||||
getOpenAIChatAssistantFieldsForHistoryMessage,
|
||||
isProviderContinuationForSource,
|
||||
mergeProviderContinuation,
|
||||
normalizeProviderContinuationOptions,
|
||||
withProviderContinuationSource,
|
||||
type OpenAIChatAssistantFields,
|
||||
type ProviderContinuation,
|
||||
type ProviderContinuationOptions,
|
||||
type ProviderContinuationSource,
|
||||
} from '../../../infrastructure/ai/providerContinuation';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Stream chunk type interfaces (Issue #13: replace unsafe casts)
|
||||
@@ -41,12 +53,22 @@ interface TextDeltaChunk {
|
||||
type: 'text' | 'text-delta';
|
||||
text?: string;
|
||||
textDelta?: string;
|
||||
providerMetadata?: unknown;
|
||||
}
|
||||
|
||||
/** Shape of a reasoning chunk from the Vercel AI SDK fullStream. */
|
||||
interface ReasoningChunk {
|
||||
type: 'reasoning' | 'reasoning-start' | 'reasoning-delta';
|
||||
text?: string;
|
||||
textDelta?: string;
|
||||
delta?: string;
|
||||
providerMetadata?: unknown;
|
||||
}
|
||||
|
||||
/** Shape of a raw provider chunk from the Vercel AI SDK fullStream. */
|
||||
interface RawChunk {
|
||||
type: 'raw';
|
||||
rawValue: unknown;
|
||||
}
|
||||
|
||||
/** Shape of a tool-call chunk from the Vercel AI SDK fullStream. */
|
||||
@@ -56,6 +78,7 @@ interface ToolCallChunk {
|
||||
toolName: string;
|
||||
input?: unknown;
|
||||
args?: unknown;
|
||||
providerMetadata?: unknown;
|
||||
}
|
||||
|
||||
/** Shape of a tool-result chunk from the Vercel AI SDK fullStream. */
|
||||
@@ -105,6 +128,7 @@ type StreamChunk =
|
||||
| ToolCallChunk
|
||||
| ToolResultChunk
|
||||
| ErrorChunk
|
||||
| RawChunk
|
||||
| { type: 'reasoning-end' | 'text-start' | 'text-end' | 'start' | 'finish' | 'start-step' | 'finish-step' | 'tool-approval-request' };
|
||||
|
||||
/** Shape of the netcatty bridge exposed on `window` (panel-specific subset). */
|
||||
@@ -119,7 +143,7 @@ export interface PanelBridge extends NetcattyBridge {
|
||||
cwd?: string,
|
||||
providerId?: string,
|
||||
chatSessionId?: string,
|
||||
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string }>; currentModelId?: string | null; error?: string }>;
|
||||
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[] }>; currentModelId?: string | null; error?: string }>;
|
||||
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
|
||||
aiUserSkillsGetStatus?: () => Promise<{
|
||||
ok: boolean;
|
||||
@@ -153,6 +177,23 @@ export interface DefaultTargetSessionHint extends TerminalSessionInfo {
|
||||
source: 'scope-target' | 'only-connected-in-scope';
|
||||
}
|
||||
|
||||
interface CattyProviderContinuationContext {
|
||||
source: ProviderContinuationSource;
|
||||
openAIChatAssistantFields: Array<OpenAIChatAssistantFields | undefined>;
|
||||
}
|
||||
|
||||
type AssistantContentPart =
|
||||
| { type: 'reasoning'; text: string; providerOptions?: ProviderContinuationOptions }
|
||||
| { type: 'text'; text: string; providerOptions?: ProviderContinuationOptions }
|
||||
| { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown; providerOptions?: ProviderContinuationOptions };
|
||||
|
||||
function toAssistantModelContent(parts: AssistantContentPart[]): string | AssistantContentPart[] {
|
||||
if (parts.length === 1 && parts[0].type === 'text' && !parts[0].providerOptions) {
|
||||
return parts[0].text;
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** Typed accessor for the netcatty bridge on the window object. */
|
||||
export function getNetcattyBridge(): PanelBridge | undefined {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -251,6 +292,7 @@ export interface UseAIChatStreamingReturn {
|
||||
signal: AbortSignal,
|
||||
currentAssistantMsgId: string,
|
||||
advancedParams?: ProviderAdvancedParams,
|
||||
continuationContext?: CattyProviderContinuationContext,
|
||||
) => Promise<void>;
|
||||
/** Send a message to the Catty agent (built-in). */
|
||||
sendToCattyAgent: (
|
||||
@@ -389,6 +431,7 @@ export function useAIChatStreaming({
|
||||
signal: AbortSignal,
|
||||
currentAssistantMsgId: string,
|
||||
advancedParams?: ProviderAdvancedParams,
|
||||
continuationContext?: CattyProviderContinuationContext,
|
||||
): Promise<void> => {
|
||||
const result = streamText({
|
||||
model,
|
||||
@@ -397,6 +440,7 @@ export function useAIChatStreaming({
|
||||
tools,
|
||||
stopWhen: stepCountIs(maxIterations),
|
||||
abortSignal: signal,
|
||||
includeRawChunks: true,
|
||||
...(advancedParams?.maxTokens != null && { maxOutputTokens: advancedParams.maxTokens }),
|
||||
...(advancedParams?.temperature != null && { temperature: advancedParams.temperature }),
|
||||
...(advancedParams?.topP != null && { topP: advancedParams.topP }),
|
||||
@@ -412,6 +456,42 @@ export function useAIChatStreaming({
|
||||
// -- Text-delta batching: accumulate deltas and flush periodically --
|
||||
let pendingText = '';
|
||||
let rafId: number | null = null;
|
||||
const ensureAssistantMessage = (): string => {
|
||||
if (lastAddedRole !== 'tool') return activeMsgId;
|
||||
|
||||
const newId = generateId();
|
||||
addMessageToSession(streamSessionId, {
|
||||
id: newId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
activeMsgId = newId;
|
||||
lastAddedRole = 'assistant';
|
||||
return activeMsgId;
|
||||
};
|
||||
|
||||
const updateAssistantContinuation = (
|
||||
messageId: string,
|
||||
continuation: ProviderContinuation | undefined,
|
||||
thinkingText = '',
|
||||
) => {
|
||||
if (!continuation && !thinkingText) return;
|
||||
const sourcedContinuation = withProviderContinuationSource(continuation, continuationContext?.source);
|
||||
updateMessageById(streamSessionId, messageId, msg => {
|
||||
const providerContinuation = mergeProviderContinuation(msg.providerContinuation, sourcedContinuation);
|
||||
return {
|
||||
...msg,
|
||||
...(providerContinuation ? { providerContinuation } : {}),
|
||||
...(thinkingText ? { thinking: (msg.thinking || '') + thinkingText } : {}),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getOpenAIReasoningText = (continuation: ProviderContinuation | undefined): string => {
|
||||
const reasoningContent = continuation?.openAIChatAssistantFields?.reasoning_content;
|
||||
return typeof reasoningContent === 'string' ? reasoningContent : '';
|
||||
};
|
||||
|
||||
const flushText = () => {
|
||||
if (pendingText) {
|
||||
@@ -455,6 +535,11 @@ export function useAIChatStreaming({
|
||||
case 'text-delta': {
|
||||
const typedChunk = chunk as TextDeltaChunk;
|
||||
const text = typedChunk.text ?? typedChunk.textDelta;
|
||||
const providerOptions = normalizeProviderContinuationOptions(typedChunk.providerMetadata);
|
||||
if (providerOptions) {
|
||||
const messageId = ensureAssistantMessage();
|
||||
updateAssistantContinuation(messageId, { textProviderOptions: providerOptions });
|
||||
}
|
||||
if (text) {
|
||||
pendingText += text;
|
||||
if (rafId === null) {
|
||||
@@ -469,25 +554,30 @@ export function useAIChatStreaming({
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
const typedChunk = chunk as ReasoningChunk;
|
||||
const rText = typedChunk.text;
|
||||
if (rText) {
|
||||
if (lastAddedRole === 'tool') {
|
||||
const newId = generateId();
|
||||
addMessageToSession(streamSessionId, {
|
||||
id: newId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
thinking: rText,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
activeMsgId = newId;
|
||||
lastAddedRole = 'assistant';
|
||||
} else {
|
||||
updateMessageById(streamSessionId, activeMsgId, msg => ({
|
||||
...msg,
|
||||
thinking: (msg.thinking || '') + rText,
|
||||
}));
|
||||
}
|
||||
const rText = typedChunk.text ?? typedChunk.textDelta ?? typedChunk.delta ?? '';
|
||||
const providerOptions = normalizeProviderContinuationOptions(typedChunk.providerMetadata);
|
||||
const continuation = rText || providerOptions
|
||||
? {
|
||||
reasoningParts: [{
|
||||
text: rText,
|
||||
...(providerOptions ? { providerOptions } : {}),
|
||||
}],
|
||||
} satisfies ProviderContinuation
|
||||
: undefined;
|
||||
if (continuation || rText) {
|
||||
const messageId = ensureAssistantMessage();
|
||||
updateAssistantContinuation(messageId, continuation, rText);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'raw': {
|
||||
const typedChunk = chunk as RawChunk;
|
||||
const continuation = extractProviderContinuationFromRawChunk(typedChunk.rawValue);
|
||||
if (continuation) {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
const messageId = ensureAssistantMessage();
|
||||
updateAssistantContinuation(messageId, continuation, getOpenAIReasoningText(continuation));
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -503,7 +593,9 @@ export function useAIChatStreaming({
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
const typedChunk = chunk as ToolCallChunk;
|
||||
updateMessageById(streamSessionId, activeMsgId, msg => ({
|
||||
const messageId = ensureAssistantMessage();
|
||||
const providerOptions = normalizeProviderContinuationOptions(typedChunk.providerMetadata);
|
||||
updateMessageById(streamSessionId, messageId, msg => ({
|
||||
...msg,
|
||||
toolCalls: [...(msg.toolCalls || []), {
|
||||
id: typedChunk.toolCallId,
|
||||
@@ -513,6 +605,13 @@ export function useAIChatStreaming({
|
||||
executionStatus: 'running',
|
||||
statusText: undefined,
|
||||
}));
|
||||
if (providerOptions) {
|
||||
updateAssistantContinuation(messageId, {
|
||||
toolCallProviderOptionsById: {
|
||||
[typedChunk.toolCallId]: providerOptions,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'tool-result': {
|
||||
@@ -778,20 +877,15 @@ export function useAIChatStreaming({
|
||||
return;
|
||||
}
|
||||
|
||||
// Create model with placeholder API key — the main process injects the real
|
||||
// decrypted key when the HTTP request is proxied through IPC, so plaintext
|
||||
// keys never transit the renderer ↔ main IPC boundary.
|
||||
let model;
|
||||
try {
|
||||
model = createModelFromConfig({
|
||||
...context.activeProvider,
|
||||
defaultModel: context.activeModelId || context.activeProvider.defaultModel || '',
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[Catty] Model creation failed:', e);
|
||||
reportStreamError(sessionId, abortController.signal, `Model creation failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return;
|
||||
}
|
||||
const activeModelId = context.activeModelId || context.activeProvider.defaultModel || '';
|
||||
const continuationContext: CattyProviderContinuationContext = {
|
||||
source: {
|
||||
providerConfigId: context.activeProvider.id,
|
||||
providerType: context.activeProvider.providerId,
|
||||
modelId: activeModelId,
|
||||
},
|
||||
openAIChatAssistantFields: [],
|
||||
};
|
||||
|
||||
try {
|
||||
// Issue #5: Build SDK messages including tool-call and tool-result messages
|
||||
@@ -818,7 +912,9 @@ export function useAIChatStreaming({
|
||||
};
|
||||
|
||||
const sdkMessages: Array<ModelMessage> = [];
|
||||
let previousHistoryMessageWasToolResult = false;
|
||||
for (const m of allMessages) {
|
||||
const currentMessageFollowsToolResult = previousHistoryMessageWasToolResult;
|
||||
if (m.role === 'user') {
|
||||
// Build multimodal content when attachments are present (fallback to legacy `images` field)
|
||||
const messageAttachments = m.attachments ?? m.images;
|
||||
@@ -837,30 +933,76 @@ export function useAIChatStreaming({
|
||||
sdkMessages.push({ role: 'user', content: m.content });
|
||||
}
|
||||
} else if (m.role === 'assistant') {
|
||||
const activeContinuation = isProviderContinuationForSource(
|
||||
m.providerContinuation,
|
||||
continuationContext.source,
|
||||
)
|
||||
? m.providerContinuation
|
||||
: undefined;
|
||||
const openAIChatAssistantFields = getOpenAIChatAssistantFieldsForHistoryMessage(
|
||||
m,
|
||||
continuationContext.source,
|
||||
);
|
||||
if (m.toolCalls?.length) {
|
||||
// Only include tool calls that have matching results
|
||||
const resolvedCalls = m.toolCalls.filter(tc => resolvedToolCallIds.has(tc.id));
|
||||
const contentParts: Array<
|
||||
{ type: 'text'; text: string } |
|
||||
{ type: 'tool-call'; toolCallId: string; toolName: string; input: unknown }
|
||||
> = [];
|
||||
const contentParts: AssistantContentPart[] = [];
|
||||
if (resolvedCalls.length > 0) {
|
||||
for (const part of activeContinuation?.reasoningParts ?? []) {
|
||||
if (!part.text && !part.providerOptions) continue;
|
||||
contentParts.push({
|
||||
type: 'reasoning' as const,
|
||||
text: part.text,
|
||||
...(part.providerOptions ? { providerOptions: part.providerOptions } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (m.content) {
|
||||
contentParts.push({ type: 'text' as const, text: m.content });
|
||||
contentParts.push({
|
||||
type: 'text' as const,
|
||||
text: m.content,
|
||||
...(activeContinuation?.textProviderOptions ? { providerOptions: activeContinuation.textProviderOptions } : {}),
|
||||
});
|
||||
}
|
||||
for (const tc of resolvedCalls) {
|
||||
const providerOptions = activeContinuation?.toolCallProviderOptionsById?.[tc.id];
|
||||
contentParts.push({
|
||||
type: 'tool-call' as const,
|
||||
toolCallId: tc.id,
|
||||
toolName: tc.name,
|
||||
input: tc.arguments ?? {},
|
||||
...(providerOptions ? { providerOptions } : {}),
|
||||
});
|
||||
}
|
||||
// If all tool calls were orphaned, just include the text content
|
||||
if (contentParts.length > 0) {
|
||||
sdkMessages.push({ role: 'assistant', content: contentParts.length === 1 && contentParts[0].type === 'text' ? (contentParts[0] as { type: 'text'; text: string }).text : contentParts });
|
||||
sdkMessages.push({ role: 'assistant', content: toAssistantModelContent(contentParts) });
|
||||
if (resolvedCalls.length > 0) {
|
||||
continuationContext.openAIChatAssistantFields.push(openAIChatAssistantFields);
|
||||
}
|
||||
}
|
||||
} else if (m.content) {
|
||||
sdkMessages.push({ role: 'assistant', content: m.content });
|
||||
const contentParts: AssistantContentPart[] = [];
|
||||
for (const part of activeContinuation?.reasoningParts ?? []) {
|
||||
if (!part.text && !part.providerOptions) continue;
|
||||
contentParts.push({
|
||||
type: 'reasoning' as const,
|
||||
text: part.text,
|
||||
...(part.providerOptions ? { providerOptions: part.providerOptions } : {}),
|
||||
});
|
||||
}
|
||||
contentParts.push({
|
||||
type: 'text' as const,
|
||||
text: m.content,
|
||||
...(activeContinuation?.textProviderOptions ? { providerOptions: activeContinuation.textProviderOptions } : {}),
|
||||
});
|
||||
sdkMessages.push({
|
||||
role: 'assistant',
|
||||
content: toAssistantModelContent(contentParts),
|
||||
});
|
||||
if (currentMessageFollowsToolResult) {
|
||||
continuationContext.openAIChatAssistantFields.push(openAIChatAssistantFields);
|
||||
}
|
||||
}
|
||||
} else if (m.role === 'tool' && m.toolResults?.length) {
|
||||
sdkMessages.push({
|
||||
@@ -873,6 +1015,7 @@ export function useAIChatStreaming({
|
||||
})),
|
||||
});
|
||||
}
|
||||
previousHistoryMessageWasToolResult = m.role === 'tool' && !!m.toolResults?.length;
|
||||
}
|
||||
// Build the current user message — include attachments as multimodal content
|
||||
if (attachments?.length) {
|
||||
@@ -890,7 +1033,37 @@ export function useAIChatStreaming({
|
||||
sdkMessages.push({ role: 'user', content: trimmed });
|
||||
}
|
||||
|
||||
await processCattyStream(sessionId, model, systemPrompt, tools, sdkMessages, abortController.signal, assistantMsgId, context.activeProvider?.advancedParams);
|
||||
// Create model with placeholder API key — the main process injects the real
|
||||
// decrypted key when the HTTP request is proxied through IPC, so plaintext
|
||||
// keys never transit the renderer ↔ main IPC boundary.
|
||||
let model;
|
||||
try {
|
||||
model = createModelFromConfig(
|
||||
{
|
||||
...context.activeProvider,
|
||||
defaultModel: activeModelId,
|
||||
},
|
||||
{
|
||||
getOpenAIChatAssistantFields: () => continuationContext.openAIChatAssistantFields,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('[Catty] Model creation failed:', e);
|
||||
reportStreamError(sessionId, abortController.signal, `Model creation failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await processCattyStream(
|
||||
sessionId,
|
||||
model,
|
||||
systemPrompt,
|
||||
tools,
|
||||
sdkMessages,
|
||||
abortController.signal,
|
||||
assistantMsgId,
|
||||
context.activeProvider?.advancedParams,
|
||||
continuationContext,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[Catty] streamText error:', err);
|
||||
reportStreamError(sessionId, abortController.signal, err);
|
||||
|
||||
119
components/ai/managedAgentState.test.ts
Normal file
119
components/ai/managedAgentState.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { buildManagedAgentState } from '../settings/tabs/ai/managedAgentState';
|
||||
import type { ExternalAgentConfig } from '../../infrastructure/ai/types';
|
||||
|
||||
test('buildManagedAgentState removes stale managed agents when path detection fails', () => {
|
||||
const agents: ExternalAgentConfig[] = [
|
||||
{
|
||||
id: 'discovered_codex',
|
||||
name: 'Codex CLI',
|
||||
command: '/usr/local/bin/codex',
|
||||
enabled: true,
|
||||
acpCommand: 'codex-acp',
|
||||
acpArgs: [],
|
||||
},
|
||||
{
|
||||
id: 'custom-agent',
|
||||
name: 'Custom Agent',
|
||||
command: '/usr/local/bin/custom-agent',
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
const state = buildManagedAgentState(
|
||||
agents,
|
||||
'discovered_codex',
|
||||
'codex',
|
||||
{ path: '/usr/local/bin/codex', version: null, available: false },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
state.agents.map((agent) => agent.id),
|
||||
['custom-agent'],
|
||||
);
|
||||
assert.equal(state.defaultAgentId, 'catty');
|
||||
});
|
||||
|
||||
test('buildManagedAgentState keeps unrelated defaults when removing stale managed agents', () => {
|
||||
const agents: ExternalAgentConfig[] = [
|
||||
{
|
||||
id: 'discovered_claude',
|
||||
name: 'Claude Code',
|
||||
command: '/usr/local/bin/claude',
|
||||
enabled: true,
|
||||
acpCommand: 'claude-agent-acp',
|
||||
acpArgs: [],
|
||||
},
|
||||
{
|
||||
id: 'custom-agent',
|
||||
name: 'Custom Agent',
|
||||
command: '/usr/local/bin/custom-agent',
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
const state = buildManagedAgentState(
|
||||
agents,
|
||||
'custom-agent',
|
||||
'claude',
|
||||
{ path: '/usr/local/bin/claude', version: null, available: false },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
state.agents.map((agent) => agent.id),
|
||||
['custom-agent'],
|
||||
);
|
||||
assert.equal(state.defaultAgentId, 'custom-agent');
|
||||
});
|
||||
|
||||
test('buildManagedAgentState does not remove user-created matching agents', () => {
|
||||
const agents: ExternalAgentConfig[] = [
|
||||
{
|
||||
id: 'my-claude-wrapper',
|
||||
name: 'My Claude Wrapper',
|
||||
command: '/usr/local/bin/claude',
|
||||
enabled: true,
|
||||
acpCommand: 'claude-agent-acp',
|
||||
acpArgs: [],
|
||||
},
|
||||
];
|
||||
|
||||
const state = buildManagedAgentState(
|
||||
agents,
|
||||
'my-claude-wrapper',
|
||||
'claude',
|
||||
{ path: '/usr/local/bin/claude', version: null, available: false },
|
||||
);
|
||||
|
||||
assert.deepEqual(state.agents, agents);
|
||||
assert.equal(state.defaultAgentId, 'my-claude-wrapper');
|
||||
});
|
||||
|
||||
test('buildManagedAgentState only rewrites settings-managed discovered agents', () => {
|
||||
const agents: ExternalAgentConfig[] = [
|
||||
{
|
||||
id: 'my-codex-wrapper',
|
||||
name: 'My Codex Wrapper',
|
||||
command: '/usr/local/bin/codex',
|
||||
enabled: true,
|
||||
acpCommand: 'codex-acp',
|
||||
acpArgs: [],
|
||||
},
|
||||
];
|
||||
|
||||
const state = buildManagedAgentState(
|
||||
agents,
|
||||
'my-codex-wrapper',
|
||||
'codex',
|
||||
{ path: '/opt/netcatty/codex-acp', version: 'Bundled ACP', available: true },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
state.agents.map((agent) => agent.id),
|
||||
['my-codex-wrapper', 'discovered_codex'],
|
||||
);
|
||||
assert.equal(state.agents[0], agents[0]);
|
||||
assert.equal(state.defaultAgentId, 'my-codex-wrapper');
|
||||
});
|
||||
37
components/editor/TextEditorPane.test.tsx
Normal file
37
components/editor/TextEditorPane.test.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import {
|
||||
canPromoteTextEditor,
|
||||
isTextEditorReadOnly,
|
||||
TextEditorPromoteButton,
|
||||
} from "./TextEditorPane.tsx";
|
||||
|
||||
test("disables promoting a modal editor to a tab while a save is running", () => {
|
||||
assert.equal(canPromoteTextEditor({ saving: true }), false);
|
||||
assert.equal(canPromoteTextEditor({ saving: false }), true);
|
||||
assert.equal(isTextEditorReadOnly({ saving: true }), true);
|
||||
assert.equal(isTextEditorReadOnly({ saving: false }), false);
|
||||
});
|
||||
|
||||
test("renders the promote button disabled while a save is running", () => {
|
||||
const savingMarkup = renderToStaticMarkup(
|
||||
React.createElement(TextEditorPromoteButton, {
|
||||
saving: true,
|
||||
onPromoteToTab: () => {},
|
||||
title: "Maximize",
|
||||
}),
|
||||
);
|
||||
const idleMarkup = renderToStaticMarkup(
|
||||
React.createElement(TextEditorPromoteButton, {
|
||||
saving: false,
|
||||
onPromoteToTab: () => {},
|
||||
title: "Maximize",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.match(savingMarkup, /disabled=""/);
|
||||
assert.doesNotMatch(idleMarkup, /disabled=""/);
|
||||
});
|
||||
@@ -16,9 +16,10 @@ import type * as Monaco from 'monaco-editor';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
// Configure Monaco to use local files instead of CDN
|
||||
const monacoBasePath = import.meta.env.DEV
|
||||
const viteEnv = import.meta.env ?? { BASE_URL: "/" };
|
||||
const monacoBasePath = viteEnv.DEV
|
||||
? './node_modules/monaco-editor/min/vs'
|
||||
: `${import.meta.env.BASE_URL}monaco/vs`;
|
||||
: `${viteEnv.BASE_URL}monaco/vs`;
|
||||
loader.config({ paths: { vs: monacoBasePath } });
|
||||
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
@@ -116,6 +117,9 @@ const hslToHex = (hslString: string): string => {
|
||||
|
||||
// Read a CSS custom-property and convert from HSL to hex
|
||||
const getCssColor = (varName: string, fallback: string): string => {
|
||||
if (typeof document === 'undefined' || typeof getComputedStyle === 'undefined') {
|
||||
return fallback;
|
||||
}
|
||||
const value = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(varName)
|
||||
.trim();
|
||||
@@ -143,6 +147,9 @@ const getEditorColors = (isDark: boolean): EditorColors => ({
|
||||
|
||||
/** Build a fingerprint string so we can detect immersive-mode color changes cheaply. */
|
||||
const getThemeSignal = (): string => {
|
||||
if (typeof document === 'undefined' || typeof getComputedStyle === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
const root = document.documentElement;
|
||||
return root.dataset.immersiveTheme
|
||||
?? getComputedStyle(root).getPropertyValue('--background').trim();
|
||||
@@ -170,6 +177,27 @@ export interface TextEditorPaneProps {
|
||||
initialViewState?: Monaco.editor.ICodeEditorViewState | null;
|
||||
}
|
||||
|
||||
export const isTextEditorReadOnly = ({ saving }: { saving: boolean }): boolean => saving;
|
||||
|
||||
export const canPromoteTextEditor = ({ saving }: { saving: boolean }): boolean => !saving;
|
||||
|
||||
export const TextEditorPromoteButton: React.FC<{
|
||||
saving: boolean;
|
||||
onPromoteToTab: () => void;
|
||||
title: string;
|
||||
}> = ({ saving, onPromoteToTab, title }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onPromoteToTab}
|
||||
disabled={!canPromoteTextEditor({ saving })}
|
||||
title={title}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</Button>
|
||||
);
|
||||
|
||||
export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
|
||||
fileName,
|
||||
content,
|
||||
@@ -202,7 +230,7 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
|
||||
|
||||
// Track theme from document.documentElement class (syncs with app theme)
|
||||
const [isDarkTheme, setIsDarkTheme] = useState(() =>
|
||||
document.documentElement.classList.contains('dark')
|
||||
typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
||||
);
|
||||
|
||||
// Track a signal that changes whenever immersive-mode or base theme colors change
|
||||
@@ -253,6 +281,7 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
|
||||
|
||||
// Listen for theme changes via MutationObserver on <html> class, style, and immersive data attr
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return;
|
||||
const root = document.documentElement;
|
||||
const updateTheme = () => {
|
||||
setIsDarkTheme(root.classList.contains('dark'));
|
||||
@@ -309,6 +338,7 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
|
||||
}, [readClipboardText]);
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
if (saving) return;
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
@@ -337,16 +367,17 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
|
||||
})),
|
||||
);
|
||||
editor.focus();
|
||||
}, [readClipboardText]);
|
||||
}, [readClipboardText, saving]);
|
||||
|
||||
useEffect(() => {
|
||||
handlePasteRef.current = handlePaste;
|
||||
}, [handlePaste]);
|
||||
|
||||
const handleEditorChange = useCallback((value: string | undefined) => {
|
||||
if (saving) return;
|
||||
const editor = editorRef.current;
|
||||
onContentChange(value ?? '', editor ? editor.saveViewState() : null);
|
||||
}, [onContentChange]);
|
||||
}, [onContentChange, saving]);
|
||||
|
||||
const handleEditorMount: OnMount = useCallback((editor, monaco) => {
|
||||
editorRef.current = editor;
|
||||
@@ -504,15 +535,11 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
|
||||
|
||||
{/* Maximize button — modal chrome only, when onPromoteToTab is provided */}
|
||||
{chrome === 'modal' && onPromoteToTab && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onPromoteToTab}
|
||||
<TextEditorPromoteButton
|
||||
saving={saving}
|
||||
onPromoteToTab={onPromoteToTab}
|
||||
title={t('sftp.editor.maximize')}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</Button>
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Close button — modal chrome only */}
|
||||
@@ -556,6 +583,8 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
wordWrap: wordWrap ? 'on' : 'off',
|
||||
readOnly: isTextEditorReadOnly({ saving }),
|
||||
domReadOnly: isTextEditorReadOnly({ saving }),
|
||||
folding: true,
|
||||
renderWhitespace: 'selection',
|
||||
bracketPairColorization: { enabled: true },
|
||||
|
||||
@@ -8,7 +8,7 @@ import type * as Monaco from 'monaco-editor';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { editorSftpWrite } from '../../application/state/editorSftpBridge';
|
||||
import { saveEditorTab } from '../../application/state/editorTabSave';
|
||||
import { editorTabStore, useEditorTab, type EditorTabId } from '../../application/state/editorTabStore';
|
||||
import type { HotkeyScheme, KeyBinding } from '../../domain/models';
|
||||
import type { Host } from '../../types';
|
||||
@@ -60,21 +60,11 @@ export const TextEditorTabView: React.FC<TextEditorTabViewProps> = ({
|
||||
}, [tabId]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
// Read live store state at call time — React state snapshot lags the store
|
||||
// by one microtask, so a keystroke between onChange and this save would
|
||||
// otherwise leave us writing stale content and marking a stale baseline.
|
||||
const current = editorTabStore.getTab(tabId);
|
||||
if (!current) return;
|
||||
if (current.savingState === 'saving') return;
|
||||
|
||||
editorTabStore.setSavingState(tabId, 'saving');
|
||||
try {
|
||||
await editorSftpWrite(current.sessionId, current.hostId, current.remotePath, current.content);
|
||||
editorTabStore.markSaved(tabId, current.content);
|
||||
const ok = await saveEditorTab(tabId);
|
||||
if (ok) {
|
||||
toast.success(t('sftp.editor.saved'), 'SFTP');
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : t('sftp.editor.saveFailed');
|
||||
editorTabStore.setSavingState(tabId, 'error', msg);
|
||||
} else {
|
||||
const msg = editorTabStore.getTab(tabId)?.saveError ?? t('sftp.editor.saveFailed');
|
||||
toast.error(msg, 'SFTP');
|
||||
}
|
||||
}, [tabId, t]);
|
||||
|
||||
@@ -17,11 +17,7 @@ import type {
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
} from "../../../infrastructure/ai/types";
|
||||
import {
|
||||
getManagedAgentStoredPath,
|
||||
matchesManagedAgentConfig,
|
||||
type ManagedAgentKey,
|
||||
} from "../../../infrastructure/ai/managedAgents";
|
||||
import type { ManagedAgentKey } from "../../../infrastructure/ai/managedAgents";
|
||||
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { TabsContent } from "../../ui/tabs";
|
||||
@@ -36,7 +32,6 @@ import type {
|
||||
UserSkillsStatusResult,
|
||||
} from "./ai/types";
|
||||
import {
|
||||
AGENT_DEFAULTS,
|
||||
getBridge,
|
||||
normalizeCodexBridgeError,
|
||||
} from "./ai/types";
|
||||
@@ -48,6 +43,11 @@ import { ClaudeCodeCard } from "./ai/ClaudeCodeCard";
|
||||
import { CopilotCliCard } from "./ai/CopilotCliCard";
|
||||
import { SafetySettings } from "./ai/SafetySettings";
|
||||
import { WebSearchSettings } from "./ai/WebSearchSettings";
|
||||
import {
|
||||
areExternalAgentListsEqual,
|
||||
buildManagedAgentState,
|
||||
getInitialManagedAgentPaths,
|
||||
} from "./ai/managedAgentState";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props
|
||||
@@ -80,54 +80,6 @@ interface SettingsAITabProps {
|
||||
setWebSearchConfig: (config: WebSearchConfig | null) => void;
|
||||
}
|
||||
|
||||
function areExternalAgentListsEqual(
|
||||
left: ExternalAgentConfig[],
|
||||
right: ExternalAgentConfig[],
|
||||
): boolean {
|
||||
if (left.length !== right.length) return false;
|
||||
return left.every((agent, index) => JSON.stringify(agent) === JSON.stringify(right[index]));
|
||||
}
|
||||
|
||||
function buildManagedAgentState(
|
||||
prevAgents: ExternalAgentConfig[],
|
||||
defaultAgentId: string,
|
||||
agentKey: ManagedAgentKey,
|
||||
pathInfo: AgentPathInfo | null,
|
||||
): { agents: ExternalAgentConfig[]; defaultAgentId: string } {
|
||||
const managedId = `discovered_${agentKey}`;
|
||||
const managedAgents = prevAgents.filter((agent) => matchesManagedAgentConfig(agent, agentKey));
|
||||
const otherAgents = prevAgents.filter((agent) => !matchesManagedAgentConfig(agent, agentKey));
|
||||
const storedPath = getManagedAgentStoredPath(prevAgents, agentKey);
|
||||
|
||||
if (!pathInfo?.available || !pathInfo.path) {
|
||||
return {
|
||||
agents: storedPath ? prevAgents : otherAgents,
|
||||
defaultAgentId: storedPath
|
||||
? defaultAgentId
|
||||
: managedAgents.some((agent) => agent.id === defaultAgentId)
|
||||
? "catty"
|
||||
: defaultAgentId,
|
||||
};
|
||||
}
|
||||
|
||||
const existingManaged = managedAgents.find((agent) => agent.id === managedId);
|
||||
const defaults = AGENT_DEFAULTS[agentKey];
|
||||
const nextManagedAgent: ExternalAgentConfig = {
|
||||
...existingManaged,
|
||||
...defaults,
|
||||
id: managedId,
|
||||
command: pathInfo.path,
|
||||
enabled: managedAgents.length === 0 ? true : managedAgents.some((agent) => agent.enabled),
|
||||
};
|
||||
|
||||
return {
|
||||
agents: [...otherAgents, nextManagedAgent],
|
||||
defaultAgentId: managedAgents.some((agent) => agent.id === defaultAgentId)
|
||||
? managedId
|
||||
: defaultAgentId,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Tab Component
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -179,11 +131,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
copilot: string;
|
||||
} | null>(null);
|
||||
if (!initialManagedPathsRef.current) {
|
||||
initialManagedPathsRef.current = {
|
||||
codex: getManagedAgentStoredPath(externalAgents, "codex") ?? "",
|
||||
claude: getManagedAgentStoredPath(externalAgents, "claude") ?? "",
|
||||
copilot: getManagedAgentStoredPath(externalAgents, "copilot") ?? "",
|
||||
};
|
||||
initialManagedPathsRef.current = getInitialManagedAgentPaths(externalAgents);
|
||||
}
|
||||
|
||||
const [copilotPathInfo, setCopilotPathInfo] = useState<AgentPathInfo | null>(null);
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import React, { useCallback } from "react";
|
||||
import type { PortForwardingRule } from "../../../domain/models";
|
||||
import type { SyncPayload } from "../../../domain/sync";
|
||||
import { buildSyncPayload, applySyncPayload } from "../../../application/syncPayload";
|
||||
import {
|
||||
applyLocalVaultPayload,
|
||||
buildLocalVaultPayload,
|
||||
buildSyncPayload,
|
||||
applySyncPayload,
|
||||
} from "../../../application/syncPayload";
|
||||
import { applyProtectedSyncPayload } from "../../../application/localVaultBackups";
|
||||
import type { SyncableVaultData } from "../../../application/syncPayload";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
@@ -29,7 +34,7 @@ export default function SettingsSyncTab(props: {
|
||||
} = props;
|
||||
const { t } = useI18n();
|
||||
|
||||
const onBuildPayload = useCallback((): SyncPayload => {
|
||||
const getEffectivePortForwardingRules = useCallback((): PortForwardingRule[] => {
|
||||
// If hook state is empty but localStorage has data, the async store
|
||||
// initialization hasn't finished yet. Read from localStorage directly
|
||||
// to avoid uploading empty arrays and overwriting the remote snapshot.
|
||||
@@ -51,15 +56,26 @@ export default function SettingsSyncTab(props: {
|
||||
}
|
||||
}
|
||||
|
||||
return effectiveRules;
|
||||
}, [portForwardingRules]);
|
||||
|
||||
const onBuildPayload = useCallback((): SyncPayload => {
|
||||
return buildSyncPayload(vault, getEffectivePortForwardingRules());
|
||||
}, [vault, getEffectivePortForwardingRules]);
|
||||
|
||||
const onBuildLocalPayload = useCallback((): SyncPayload => {
|
||||
const effectiveKnownHosts = getEffectiveKnownHosts(vault.knownHosts);
|
||||
|
||||
return buildSyncPayload({ ...vault, knownHosts: effectiveKnownHosts }, effectiveRules);
|
||||
}, [vault, portForwardingRules]);
|
||||
return buildLocalVaultPayload(
|
||||
{ ...vault, knownHosts: effectiveKnownHosts ?? [] },
|
||||
getEffectivePortForwardingRules(),
|
||||
);
|
||||
}, [vault, getEffectivePortForwardingRules]);
|
||||
|
||||
const onApplyPayload = useCallback(
|
||||
(payload: SyncPayload) =>
|
||||
applyProtectedSyncPayload({
|
||||
buildPreApplyPayload: onBuildPayload,
|
||||
buildPreApplyPayload: onBuildLocalPayload,
|
||||
applyPayload: () =>
|
||||
applySyncPayload(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
@@ -69,7 +85,23 @@ export default function SettingsSyncTab(props: {
|
||||
translateProtectiveBackupFailure: (message) =>
|
||||
t("cloudSync.localBackups.protectiveBackupFailed", { message }),
|
||||
}),
|
||||
[importDataFromString, importPortForwardingRules, onBuildPayload, onSettingsApplied, t],
|
||||
[importDataFromString, importPortForwardingRules, onBuildLocalPayload, onSettingsApplied, t],
|
||||
);
|
||||
|
||||
const onApplyLocalPayload = useCallback(
|
||||
(payload: SyncPayload) =>
|
||||
applyProtectedSyncPayload({
|
||||
buildPreApplyPayload: onBuildLocalPayload,
|
||||
applyPayload: () =>
|
||||
applyLocalVaultPayload(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied,
|
||||
}),
|
||||
translateProtectiveBackupFailure: (message) =>
|
||||
t("cloudSync.localBackups.protectiveBackupFailed", { message }),
|
||||
}),
|
||||
[importDataFromString, importPortForwardingRules, onBuildLocalPayload, onSettingsApplied, t],
|
||||
);
|
||||
|
||||
const clearAllLocalData = useCallback(() => {
|
||||
@@ -82,6 +114,7 @@ export default function SettingsSyncTab(props: {
|
||||
<CloudSyncSettings
|
||||
onBuildPayload={onBuildPayload}
|
||||
onApplyPayload={onApplyPayload}
|
||||
onApplyLocalPayload={onApplyLocalPayload}
|
||||
onClearLocalData={clearAllLocalData}
|
||||
/>
|
||||
</SettingsTabContent>
|
||||
|
||||
@@ -1034,6 +1034,17 @@ export default function SettingsTerminalTab(props: {
|
||||
className="w-24"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.terminal.connection.x11Display")}
|
||||
description={t("settings.terminal.connection.x11Display.desc")}
|
||||
>
|
||||
<Input
|
||||
value={terminalSettings.x11Display}
|
||||
onChange={(e) => updateTerminalSetting("x11Display", e.target.value)}
|
||||
placeholder={t("settings.terminal.connection.x11Display.placeholder")}
|
||||
className="w-48"
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.serverStats")} />
|
||||
|
||||
72
components/settings/tabs/ai/managedAgentState.ts
Normal file
72
components/settings/tabs/ai/managedAgentState.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { ExternalAgentConfig } from "../../../../infrastructure/ai/types";
|
||||
import {
|
||||
type ManagedAgentKey,
|
||||
} from "../../../../infrastructure/ai/managedAgents";
|
||||
import type { AgentPathInfo } from "./types";
|
||||
import { AGENT_DEFAULTS } from "./types";
|
||||
|
||||
function isPathLikeCommand(command: string | undefined): boolean {
|
||||
const normalized = String(command || "").trim();
|
||||
return normalized.includes("/") || normalized.includes("\\");
|
||||
}
|
||||
|
||||
function getAutoManagedAgentStoredPath(
|
||||
agents: ExternalAgentConfig[],
|
||||
agentKey: ManagedAgentKey,
|
||||
): string | null {
|
||||
const managed = agents.find((agent) => agent.id === `discovered_${agentKey}`);
|
||||
return isPathLikeCommand(managed?.command) ? managed?.command ?? null : null;
|
||||
}
|
||||
|
||||
export function areExternalAgentListsEqual(
|
||||
left: ExternalAgentConfig[],
|
||||
right: ExternalAgentConfig[],
|
||||
): boolean {
|
||||
if (left.length !== right.length) return false;
|
||||
return left.every((agent, index) => JSON.stringify(agent) === JSON.stringify(right[index]));
|
||||
}
|
||||
|
||||
export function buildManagedAgentState(
|
||||
prevAgents: ExternalAgentConfig[],
|
||||
defaultAgentId: string,
|
||||
agentKey: ManagedAgentKey,
|
||||
pathInfo: AgentPathInfo | null,
|
||||
): { agents: ExternalAgentConfig[]; defaultAgentId: string } {
|
||||
const managedId = `discovered_${agentKey}`;
|
||||
const managedAgents = prevAgents.filter((agent) => agent.id === managedId);
|
||||
const otherAgents = prevAgents.filter((agent) => agent.id !== managedId);
|
||||
|
||||
if (!pathInfo?.available || !pathInfo.path) {
|
||||
return {
|
||||
agents: otherAgents,
|
||||
defaultAgentId: managedAgents.some((agent) => agent.id === defaultAgentId)
|
||||
? "catty"
|
||||
: defaultAgentId,
|
||||
};
|
||||
}
|
||||
|
||||
const existingManaged = managedAgents.find((agent) => agent.id === managedId);
|
||||
const defaults = AGENT_DEFAULTS[agentKey];
|
||||
const nextManagedAgent: ExternalAgentConfig = {
|
||||
...existingManaged,
|
||||
...defaults,
|
||||
id: managedId,
|
||||
command: pathInfo.path,
|
||||
enabled: managedAgents.length === 0 ? true : managedAgents.some((agent) => agent.enabled),
|
||||
};
|
||||
|
||||
return {
|
||||
agents: [...otherAgents, nextManagedAgent],
|
||||
defaultAgentId: managedAgents.some((agent) => agent.id === defaultAgentId)
|
||||
? managedId
|
||||
: defaultAgentId,
|
||||
};
|
||||
}
|
||||
|
||||
export function getInitialManagedAgentPaths(agents: ExternalAgentConfig[]) {
|
||||
return {
|
||||
codex: getAutoManagedAgentStoredPath(agents, "codex") ?? "",
|
||||
claude: getAutoManagedAgentStoredPath(agents, "claude") ?? "",
|
||||
copilot: getAutoManagedAgentStoredPath(agents, "copilot") ?? "",
|
||||
};
|
||||
}
|
||||
@@ -7,12 +7,16 @@ import React, { memo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Button } from '../ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import type { FileConflictAction } from '../../domain/models';
|
||||
|
||||
interface ConflictItem {
|
||||
transferId: string;
|
||||
fileName: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
isDirectory: boolean;
|
||||
existingType?: 'file' | 'directory' | 'symlink';
|
||||
applyToAllCount?: number;
|
||||
existingSize: number;
|
||||
newSize: number;
|
||||
existingModified: number;
|
||||
@@ -21,7 +25,7 @@ interface ConflictItem {
|
||||
|
||||
interface SftpConflictDialogProps {
|
||||
conflicts: ConflictItem[];
|
||||
onResolve: (conflictId: string, action: 'replace' | 'skip' | 'duplicate') => void;
|
||||
onResolve: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => void;
|
||||
formatFileSize: (size: number) => string;
|
||||
}
|
||||
|
||||
@@ -36,13 +40,14 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
|
||||
return new Date(timestamp).toLocaleString();
|
||||
};
|
||||
|
||||
const handleAction = (action: 'replace' | 'skip' | 'duplicate') => {
|
||||
if (applyToAll) {
|
||||
// Apply to all conflicts
|
||||
conflicts.forEach(c => onResolve(c.transferId, action));
|
||||
} else {
|
||||
onResolve(conflict.transferId, action);
|
||||
}
|
||||
const sameTypeConflictCount = Math.max(
|
||||
conflict.applyToAllCount ?? 1,
|
||||
conflicts.filter((item) => item.isDirectory === conflict.isDirectory).length,
|
||||
);
|
||||
const canMerge = conflict.isDirectory && conflict.existingType === 'directory';
|
||||
|
||||
const handleAction = (action: FileConflictAction) => {
|
||||
onResolve(conflict.transferId, action, applyToAll);
|
||||
setApplyToAll(false);
|
||||
};
|
||||
|
||||
@@ -95,7 +100,7 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{conflicts.length > 1 && (
|
||||
{sameTypeConflictCount > 1 && (
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -103,12 +108,19 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
|
||||
onChange={(e) => setApplyToAll(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
{t('sftp.conflict.applyToAll', { count: conflicts.length })}
|
||||
{t('sftp.conflict.applyToAll', { count: sameTypeConflictCount })}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex gap-2">
|
||||
<DialogFooter className="flex flex-wrap gap-2 sm:justify-end sm:space-x-0">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => handleAction('stop')}
|
||||
className="flex-1"
|
||||
>
|
||||
{t('sftp.conflict.action.stop')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction('skip')}
|
||||
@@ -121,8 +133,18 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
|
||||
onClick={() => handleAction('duplicate')}
|
||||
className="flex-1"
|
||||
>
|
||||
{t('sftp.conflict.action.keepBoth')}
|
||||
{t('sftp.conflict.action.duplicate')}
|
||||
</Button>
|
||||
{conflict.isDirectory && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction('merge')}
|
||||
disabled={!canMerge}
|
||||
className="flex-1"
|
||||
>
|
||||
{t('sftp.conflict.action.merge')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => handleAction('replace')}
|
||||
|
||||
@@ -46,6 +46,7 @@ interface SftpOverlaysProps {
|
||||
handleFileOpenerSelect: (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => void;
|
||||
handleSelectSystemApp: (systemApp: { path: string; name: string }) => void;
|
||||
onPromoteToTab?: (snapshot: TextEditorModalSnapshot) => void;
|
||||
onRequestTerminalFocus?: () => void;
|
||||
}
|
||||
|
||||
export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
@@ -83,6 +84,7 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
onPromoteToTab,
|
||||
onRequestTerminalFocus,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
@@ -141,6 +143,7 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
setShowTextEditor(false);
|
||||
setTextEditorTarget(null);
|
||||
setTextEditorContent("");
|
||||
onRequestTerminalFocus?.();
|
||||
}}
|
||||
fileName={textEditorTarget?.file.name || ""}
|
||||
initialContent={textEditorContent}
|
||||
|
||||
@@ -39,24 +39,39 @@ interface SftpTransferItemProps {
|
||||
isExpanded?: boolean;
|
||||
visibleChildCount?: number;
|
||||
onToggleChildren?: () => void;
|
||||
onSetNameColumnWidth?: (width: number) => void;
|
||||
childNameColumnMinWidth?: number;
|
||||
childNameColumnMaxWidth?: number;
|
||||
childListId?: string;
|
||||
resizeHandleTabIndex?: number;
|
||||
}
|
||||
|
||||
const TruncatedTextWithTooltip: React.FC<{
|
||||
text: string;
|
||||
className?: string;
|
||||
}> = ({ text, className }) => (
|
||||
<TooltipProvider delayDuration={300} skipDelayDuration={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn("truncate", className)}>
|
||||
{text}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start" className="max-w-md break-all">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn("truncate", className)}>
|
||||
{text}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start" className="max-w-md break-all">
|
||||
{text}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const IconButtonWithTooltip: React.FC<{
|
||||
label: string;
|
||||
children: React.ReactElement;
|
||||
}> = ({ label, children }) => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
@@ -73,6 +88,11 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
isExpanded = false,
|
||||
visibleChildCount: _visibleChildCount = 0,
|
||||
onToggleChildren,
|
||||
onSetNameColumnWidth,
|
||||
childNameColumnMinWidth = 160,
|
||||
childNameColumnMaxWidth = 480,
|
||||
childListId,
|
||||
resizeHandleTabIndex = 0,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -184,29 +204,65 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
const showTransferSizeCalculation = task.status === 'transferring' && !hasKnownTotal && !isDirParent;
|
||||
const showFailedError = task.status === 'failed' && !!task.error;
|
||||
const hasFooterContent = showTransferSizeCalculation || showFailedError;
|
||||
const retryActionLabel = t('sftp.transfers.retryAction');
|
||||
const cancelActionLabel = t('common.cancel');
|
||||
const dismissActionLabel = t('sftp.transfers.dismissAction');
|
||||
const resizeNameColumnLabel = t('sftp.transfers.resizeNameColumn');
|
||||
const toggleChildrenLabel = isExpanded ? t('sftp.transfers.collapseChildList') : t('sftp.transfers.expandChildList');
|
||||
const actionButtonClass = "h-6 w-6 focus-visible:ring-1 focus-visible:ring-primary/50";
|
||||
const actionAriaLabel = (label: string) => `${label}: ${task.fileName}`;
|
||||
|
||||
const setNameColumnWidth = (width: number) => {
|
||||
const nextWidth = Math.max(childNameColumnMinWidth, Math.min(childNameColumnMaxWidth, width));
|
||||
onSetNameColumnWidth?.(nextWidth);
|
||||
};
|
||||
|
||||
const handleResizeKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!onSetNameColumnWidth) return;
|
||||
|
||||
const step = event.shiftKey ? 40 : 10;
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
setNameColumnWidth(childNameColumnWidth - step);
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
setNameColumnWidth(childNameColumnWidth + step);
|
||||
} else if (event.key === 'Home') {
|
||||
event.preventDefault();
|
||||
setNameColumnWidth(childNameColumnMinWidth);
|
||||
} else if (event.key === 'End') {
|
||||
event.preventDefault();
|
||||
setNameColumnWidth(childNameColumnMaxWidth);
|
||||
}
|
||||
};
|
||||
|
||||
const actionButtons = (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{task.status === 'failed' && task.retryable !== false && (
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onRetry} title="Retry">
|
||||
<RefreshCw size={12} />
|
||||
</Button>
|
||||
<IconButtonWithTooltip label={retryActionLabel}>
|
||||
<Button variant="ghost" size="icon" className={actionButtonClass} onClick={onRetry} aria-label={actionAriaLabel(retryActionLabel)}>
|
||||
<RefreshCw size={12} />
|
||||
</Button>
|
||||
</IconButtonWithTooltip>
|
||||
)}
|
||||
{(task.status === 'pending' || task.status === 'transferring') && (
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive hover:text-destructive" onClick={onCancel} title="Cancel">
|
||||
<X size={12} />
|
||||
</Button>
|
||||
<IconButtonWithTooltip label={cancelActionLabel}>
|
||||
<Button variant="ghost" size="icon" className={cn(actionButtonClass, "text-destructive hover:text-destructive")} onClick={onCancel} aria-label={actionAriaLabel(cancelActionLabel)}>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</IconButtonWithTooltip>
|
||||
)}
|
||||
{(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') && (
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onDismiss} title="Dismiss">
|
||||
<X size={12} />
|
||||
</Button>
|
||||
<IconButtonWithTooltip label={dismissActionLabel}>
|
||||
<Button variant="ghost" size="icon" className={actionButtonClass} onClick={onDismiss} aria-label={actionAriaLabel(dismissActionLabel)}>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</IconButtonWithTooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isChild) {
|
||||
return (
|
||||
const content = isChild ? (
|
||||
<div
|
||||
className="grid h-7 items-stretch border-t border-border/20 bg-background/20 px-3"
|
||||
style={{
|
||||
@@ -222,13 +278,25 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
className="min-w-0 text-[11px] font-medium text-foreground/90"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex h-full cursor-col-resize items-center justify-center text-muted-foreground/35 hover:text-foreground/70"
|
||||
onMouseDown={onResizeNameColumn}
|
||||
title="Resize file name column"
|
||||
>
|
||||
<GripVertical size={10} />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="flex h-full cursor-col-resize items-center justify-center text-muted-foreground/35 hover:text-foreground/70 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
|
||||
onMouseDown={onResizeNameColumn}
|
||||
onKeyDown={handleResizeKeyDown}
|
||||
role="separator"
|
||||
aria-label={resizeNameColumnLabel}
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin={childNameColumnMinWidth}
|
||||
aria-valuemax={childNameColumnMaxWidth}
|
||||
aria-valuenow={childNameColumnWidth}
|
||||
tabIndex={resizeHandleTabIndex}
|
||||
>
|
||||
<GripVertical size={10} />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{resizeNameColumnLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="min-w-0">
|
||||
{childProgressBar}
|
||||
</div>
|
||||
@@ -236,12 +304,10 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
{actionButtons}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
) : (() => {
|
||||
const showBelowParentProgress = task.status === 'transferring' || task.status === 'pending';
|
||||
|
||||
const showBelowParentProgress = task.status === 'transferring' || task.status === 'pending';
|
||||
|
||||
const titleBlock = (
|
||||
const titleBlock = (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<TruncatedTextWithTooltip
|
||||
text={task.fileName}
|
||||
@@ -255,21 +321,29 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
canRevealTarget ? "text-primary/80" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
{canToggleChildren && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded border border-border/60 bg-secondary/60 px-1.5 py-0.5 text-[10px] text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
onClick={onToggleChildren}
|
||||
title={isExpanded ? t('sftp.transfers.collapseChildList') : t('sftp.transfers.expandChildList')}
|
||||
>
|
||||
{isExpanded ? t('sftp.transfers.collapseChildList') : t('sftp.transfers.expandChildList')}
|
||||
{isExpanded ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
);
|
||||
|
||||
return (
|
||||
const toggleChildrenButton = canToggleChildren ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded border border-border/60 bg-secondary/60 px-1.5 py-0.5 text-[10px] text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
|
||||
onClick={onToggleChildren}
|
||||
aria-label={toggleChildrenLabel}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={childListId}
|
||||
>
|
||||
{toggleChildrenLabel}
|
||||
{isExpanded ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{toggleChildrenLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="border-t border-border/40 bg-background/60 px-3 py-2.5 supports-[backdrop-filter]:backdrop-blur-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex h-5 w-5 items-center justify-center shrink-0 -translate-y-px">
|
||||
@@ -290,6 +364,8 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toggleChildrenButton}
|
||||
|
||||
{progressSummaryText && (
|
||||
<span className="ml-auto shrink-0 whitespace-nowrap text-[10px] text-muted-foreground font-mono">
|
||||
{progressSummaryText}
|
||||
@@ -341,6 +417,13 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})();
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300} skipDelayDuration={100}>
|
||||
{content}
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -362,6 +445,10 @@ const arePropsEqual = (
|
||||
if ((prevProps.canToggleChildren ?? false) !== (nextProps.canToggleChildren ?? false)) return false;
|
||||
if ((prevProps.isExpanded ?? false) !== (nextProps.isExpanded ?? false)) return false;
|
||||
if ((prevProps.visibleChildCount ?? 0) !== (nextProps.visibleChildCount ?? 0)) return false;
|
||||
if ((prevProps.childNameColumnMinWidth ?? 160) !== (nextProps.childNameColumnMinWidth ?? 160)) return false;
|
||||
if ((prevProps.childNameColumnMaxWidth ?? 480) !== (nextProps.childNameColumnMaxWidth ?? 480)) return false;
|
||||
if ((prevProps.childListId ?? '') !== (nextProps.childListId ?? '')) return false;
|
||||
if ((prevProps.resizeHandleTabIndex ?? 0) !== (nextProps.resizeHandleTabIndex ?? 0)) return false;
|
||||
|
||||
if (next.status === 'transferring') {
|
||||
if (next.totalBytes <= 0 && prev.transferredBytes !== next.transferredBytes) return false;
|
||||
|
||||
@@ -29,9 +29,11 @@ const MAX_CHILD_NAME_WIDTH = 480;
|
||||
const CHILD_ROW_HEIGHT = 28;
|
||||
const CHILD_VIRTUALIZE_THRESHOLD = 80;
|
||||
const CHILD_OVERSCAN = 8;
|
||||
const childListIdForTask = (taskId: string) => `sftp-transfer-children-${taskId.replace(/[^A-Za-z0-9_-]/g, "-")}`;
|
||||
|
||||
interface TransferChildListProps {
|
||||
childTasks: TransferTask[];
|
||||
childListId: string;
|
||||
childNameWidth: number;
|
||||
onResizeNameColumn: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement>;
|
||||
@@ -40,10 +42,12 @@ interface TransferChildListProps {
|
||||
onCancel: (taskId: string) => void;
|
||||
onRetry: (taskId: string) => Promise<void>;
|
||||
onDismiss: (taskId: string) => void;
|
||||
onSetNameColumnWidth: (width: number) => void;
|
||||
}
|
||||
|
||||
const TransferChildList: React.FC<TransferChildListProps> = ({
|
||||
childTasks,
|
||||
childListId,
|
||||
childNameWidth,
|
||||
onResizeNameColumn,
|
||||
scrollContainerRef,
|
||||
@@ -52,6 +56,7 @@ const TransferChildList: React.FC<TransferChildListProps> = ({
|
||||
onCancel,
|
||||
onRetry,
|
||||
onDismiss,
|
||||
onSetNameColumnWidth,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [contentTop, setContentTop] = useState(0);
|
||||
@@ -102,6 +107,7 @@ const TransferChildList: React.FC<TransferChildListProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
id={childListId}
|
||||
ref={containerRef}
|
||||
className="border-t border-border/30 bg-background/30"
|
||||
>
|
||||
@@ -121,7 +127,11 @@ const TransferChildList: React.FC<TransferChildListProps> = ({
|
||||
task={child}
|
||||
isChild
|
||||
childNameColumnWidth={childNameWidth}
|
||||
childNameColumnMinWidth={MIN_CHILD_NAME_WIDTH}
|
||||
childNameColumnMaxWidth={MAX_CHILD_NAME_WIDTH}
|
||||
onResizeNameColumn={onResizeNameColumn}
|
||||
onSetNameColumnWidth={onSetNameColumnWidth}
|
||||
resizeHandleTabIndex={visibleIndex === 0 ? 0 : -1}
|
||||
onCancel={() => onCancel(child.id)}
|
||||
onRetry={() => onRetry(child.id)}
|
||||
onDismiss={() => onDismiss(child.id)}
|
||||
@@ -303,6 +313,12 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
|
||||
document.body.style.userSelect = "none";
|
||||
}, [childNameWidth]);
|
||||
|
||||
const handleChildColumnWidthSet = useCallback((width: number) => {
|
||||
const nextWidth = Math.max(MIN_CHILD_NAME_WIDTH, Math.min(MAX_CHILD_NAME_WIDTH, width));
|
||||
setChildNameWidth(nextWidth);
|
||||
persistChildNameWidth(nextWidth);
|
||||
}, [persistChildNameWidth, setChildNameWidth]);
|
||||
|
||||
const toggleExpanded = useCallback((taskId: string) => {
|
||||
setExpandedParents((prev) => ({
|
||||
...prev,
|
||||
@@ -369,6 +385,7 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
|
||||
{topLevelTransfers.map((task) => {
|
||||
const childTasks = childrenByParent.get(task.id) ?? [];
|
||||
const isExpanded = expandedParents[task.id] ?? true;
|
||||
const childListId = childListIdForTask(task.id);
|
||||
|
||||
return (
|
||||
<React.Fragment key={task.id}>
|
||||
@@ -377,6 +394,7 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
|
||||
canToggleChildren={childTasks.length > 0}
|
||||
isExpanded={isExpanded}
|
||||
visibleChildCount={childTasks.length}
|
||||
childListId={childListId}
|
||||
onToggleChildren={() => toggleExpanded(task.id)}
|
||||
onCancel={() => {
|
||||
if (task.sourceConnectionId === "external") {
|
||||
@@ -399,8 +417,10 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
|
||||
{isExpanded && childTasks.length > 0 && (
|
||||
<TransferChildList
|
||||
childTasks={childTasks}
|
||||
childListId={childListId}
|
||||
childNameWidth={childNameWidth}
|
||||
onResizeNameColumn={handleChildColumnResizeStart}
|
||||
onSetNameColumnWidth={handleChildColumnWidthSet}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
scrollTop={scrollTop}
|
||||
viewportHeight={viewportHeight}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { SftpDragCallbacks, SftpTransferSource } from "../SftpContext";
|
||||
import { keepOnlyActivePaneSelections } from "./selectionScope";
|
||||
import { editorTabStore } from "../../../application/state/editorTabStore";
|
||||
import type { EditorTab, EditorTabId } from "../../../application/state/editorTabStore";
|
||||
import { releaseEditorTabSaveCoordinator, saveEditorTab } from "../../../application/state/editorTabSave";
|
||||
import { promptUnsavedChanges } from "../../editor/UnsavedChangesDialog";
|
||||
|
||||
interface UseSftpViewPaneActionsParams {
|
||||
@@ -139,12 +140,18 @@ export const useSftpViewPaneActions = ({
|
||||
if (connectionId) {
|
||||
const choice = (tab: EditorTab) => promptUnsavedChanges(tab.fileName);
|
||||
const saveTab = async (id: EditorTabId) => {
|
||||
const ok = await saveEditorTab(id);
|
||||
const tab = editorTabStore.getTab(id);
|
||||
if (!tab) return;
|
||||
await sftpRef.current.writeTextFileByConnection(tab.sessionId, tab.hostId, tab.remotePath, tab.content);
|
||||
editorTabStore.markSaved(id, tab.content);
|
||||
if (!ok || (tab && tab.content !== tab.baselineContent)) {
|
||||
throw new Error(tab?.saveError ?? "Save failed");
|
||||
}
|
||||
};
|
||||
const ok = await editorTabStore.confirmCloseBySession(connectionId, choice, saveTab);
|
||||
const ok = await editorTabStore.confirmCloseBySession(
|
||||
connectionId,
|
||||
choice,
|
||||
saveTab,
|
||||
releaseEditorTabSaveCoordinator,
|
||||
);
|
||||
if (!ok) return false;
|
||||
}
|
||||
sftpRef.current.disconnect("left");
|
||||
@@ -155,12 +162,18 @@ export const useSftpViewPaneActions = ({
|
||||
if (connectionId) {
|
||||
const choice = (tab: EditorTab) => promptUnsavedChanges(tab.fileName);
|
||||
const saveTab = async (id: EditorTabId) => {
|
||||
const ok = await saveEditorTab(id);
|
||||
const tab = editorTabStore.getTab(id);
|
||||
if (!tab) return;
|
||||
await sftpRef.current.writeTextFileByConnection(tab.sessionId, tab.hostId, tab.remotePath, tab.content);
|
||||
editorTabStore.markSaved(id, tab.content);
|
||||
if (!ok || (tab && tab.content !== tab.baselineContent)) {
|
||||
throw new Error(tab?.saveError ?? "Save failed");
|
||||
}
|
||||
};
|
||||
const ok = await editorTabStore.confirmCloseBySession(connectionId, choice, saveTab);
|
||||
const ok = await editorTabStore.confirmCloseBySession(
|
||||
connectionId,
|
||||
choice,
|
||||
saveTab,
|
||||
releaseEditorTabSaveCoordinator,
|
||||
);
|
||||
if (!ok) return false;
|
||||
}
|
||||
sftpRef.current.disconnect("right");
|
||||
|
||||
@@ -2,6 +2,10 @@ import React, { useCallback, useMemo, useState } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { Host } from "../../../types";
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import { editorTabStore } from "../../../application/state/editorTabStore";
|
||||
import type { EditorTab, EditorTabId } from "../../../application/state/editorTabStore";
|
||||
import { releaseEditorTabSaveCoordinator, saveEditorTab } from "../../../application/state/editorTabSave";
|
||||
import { promptUnsavedChanges } from "../../editor/UnsavedChangesDialog";
|
||||
|
||||
interface UseSftpViewTabsParams {
|
||||
sftp: SftpStateApi;
|
||||
@@ -23,8 +27,8 @@ interface UseSftpViewTabsResult {
|
||||
setHostSearchRight: React.Dispatch<React.SetStateAction<string>>;
|
||||
handleAddTabLeft: () => string;
|
||||
handleAddTabRight: () => string;
|
||||
handleCloseTabLeft: (tabId: string) => void;
|
||||
handleCloseTabRight: (tabId: string) => void;
|
||||
handleCloseTabLeft: (tabId: string) => Promise<void>;
|
||||
handleCloseTabRight: (tabId: string) => Promise<void>;
|
||||
handleSelectTabLeft: (tabId: string) => void;
|
||||
handleSelectTabRight: (tabId: string) => void;
|
||||
handleReorderTabsLeft: (draggedId: string, targetId: string, position: "before" | "after") => void;
|
||||
@@ -53,13 +57,41 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf
|
||||
return tabId;
|
||||
}, [sftpRef]);
|
||||
|
||||
const handleCloseTabLeft = useCallback((tabId: string) => {
|
||||
sftpRef.current.closeTab("left", tabId);
|
||||
}, [sftpRef]);
|
||||
const confirmCloseEditorTabsByConnection = useCallback(async (connectionId: string): Promise<boolean> => {
|
||||
const choice = (tab: EditorTab) => promptUnsavedChanges(tab.fileName);
|
||||
const saveTab = async (id: EditorTabId) => {
|
||||
const ok = await saveEditorTab(id);
|
||||
const tab = editorTabStore.getTab(id);
|
||||
if (!ok || (tab && tab.content !== tab.baselineContent)) {
|
||||
throw new Error(tab?.saveError ?? "Save failed");
|
||||
}
|
||||
};
|
||||
return editorTabStore.confirmCloseBySession(
|
||||
connectionId,
|
||||
choice,
|
||||
saveTab,
|
||||
releaseEditorTabSaveCoordinator,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleCloseTabRight = useCallback((tabId: string) => {
|
||||
sftpRef.current.closeTab("right", tabId);
|
||||
}, [sftpRef]);
|
||||
const handleCloseSftpTab = useCallback(async (side: "left" | "right", tabId: string) => {
|
||||
const sideTabs = side === "left" ? sftpRef.current.leftTabs : sftpRef.current.rightTabs;
|
||||
const pane = sideTabs.tabs.find((tab) => tab.id === tabId);
|
||||
const connectionId = pane?.connection?.id;
|
||||
if (connectionId) {
|
||||
const ok = await confirmCloseEditorTabsByConnection(connectionId);
|
||||
if (!ok) return;
|
||||
}
|
||||
sftpRef.current.closeTab(side, tabId);
|
||||
}, [confirmCloseEditorTabsByConnection, sftpRef]);
|
||||
|
||||
const handleCloseTabLeft = useCallback((tabId: string) => (
|
||||
handleCloseSftpTab("left", tabId)
|
||||
), [handleCloseSftpTab]);
|
||||
|
||||
const handleCloseTabRight = useCallback((tabId: string) => (
|
||||
handleCloseSftpTab("right", tabId)
|
||||
), [handleCloseSftpTab]);
|
||||
|
||||
const handleSelectTabLeft = useCallback((tabId: string) => {
|
||||
sftpRef.current.selectTab("left", tabId);
|
||||
|
||||
@@ -47,3 +47,72 @@ test("still trims prompt decorations out of the detected input", () => {
|
||||
assert.equal(result.prompt.cursorOffset, 2);
|
||||
assert.equal(result.alignedTyped, "do");
|
||||
});
|
||||
|
||||
test("detects oh-my-posh Nerd Font chevron (U+F105) prompt terminator", () => {
|
||||
// Real-world PS1 captured from oh-my-posh themed bash on a server:
|
||||
// "<U+F31B> root@oracle ~ <U+F105> " then user input
|
||||
const term = createFakeTerm(" root@oracle ~ ls", 21);
|
||||
|
||||
const result = getAlignedPrompt(term as never, "ls", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, " root@oracle ~ ");
|
||||
assert.equal(result.prompt.userInput, "ls");
|
||||
});
|
||||
|
||||
test("detects Powerline right-arrow (U+E0B0) prompt terminator", () => {
|
||||
// oh-my-posh agnoster-style: colored block ending with U+E0B0 + space
|
||||
const term = createFakeTerm(" root ~ git", 16);
|
||||
|
||||
const result = getAlignedPrompt(term as never, "git", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.userInput, "git");
|
||||
assert.ok(result.prompt.promptText.endsWith(" "));
|
||||
});
|
||||
|
||||
test("PUA char without trailing space is not a prompt boundary", () => {
|
||||
// A bare PUA glyph mid-token (e.g. paste artifact) should not trigger detection.
|
||||
const term = createFakeTerm("echo foo", 13);
|
||||
|
||||
const result = getAlignedPrompt(term as never, "", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, false);
|
||||
});
|
||||
|
||||
test("keeps typed command intact when command text contains Powerline glyphs", () => {
|
||||
const typedInput = "echo foo";
|
||||
const lineText = `$ ${typedInput}`;
|
||||
const term = createFakeTerm(lineText, lineText.length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, "$ ");
|
||||
assert.equal(result.prompt.userInput, typedInput);
|
||||
assert.equal(result.alignedTyped, typedInput);
|
||||
});
|
||||
|
||||
test("prefers standard prompt terminator over later Powerline glyphs", () => {
|
||||
const lineText = "$ echo foo";
|
||||
const term = createFakeTerm(lineText, lineText.length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, "", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, "$ ");
|
||||
assert.equal(result.prompt.userInput, "echo foo");
|
||||
});
|
||||
|
||||
test("keeps typed command intact for PUA-only prompts when command text contains Powerline glyphs", () => {
|
||||
const typedInput = "echo foo";
|
||||
const lineText = ` root ~ ${typedInput}`;
|
||||
const term = createFakeTerm(lineText, lineText.length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, " root ~ ");
|
||||
assert.equal(result.prompt.userInput, typedInput);
|
||||
assert.equal(result.alignedTyped, typedInput);
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Terminal as TerminalIcon,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { KeyBinding, RightClickBehavior } from '../../domain/models';
|
||||
import {
|
||||
@@ -59,6 +59,17 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isMac = hotkeyScheme === 'mac';
|
||||
// Tracks the .workspace-pane whose context menu is currently open so we can
|
||||
// keep its `:focus-within`-driven opacity stable while focus is in the
|
||||
// menu portal (otherwise the pane dims for the menu's lifetime).
|
||||
const markedPaneRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const handleOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
markedPaneRef.current?.removeAttribute('data-menu-open');
|
||||
markedPaneRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Helper to get shortcut from keyBindings and format for display
|
||||
const getShortcut = (bindingId: string): string => {
|
||||
@@ -91,7 +102,15 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
}
|
||||
|
||||
// Shift+Right-Click or context-menu mode: let Radix open the menu
|
||||
if (e.shiftKey || rightClickBehavior === 'context-menu') return;
|
||||
if (e.shiftKey || rightClickBehavior === 'context-menu') {
|
||||
const pane = (e.target as HTMLElement | null)?.closest<HTMLElement>('.workspace-pane');
|
||||
if (pane) {
|
||||
markedPaneRef.current?.removeAttribute('data-menu-open');
|
||||
pane.setAttribute('data-menu-open', '');
|
||||
markedPaneRef.current = pane;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Paste / select-word: intercept and prevent the context menu
|
||||
e.preventDefault();
|
||||
@@ -107,7 +126,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
// Always use ContextMenu wrapper to maintain consistent React tree structure
|
||||
// This prevents terminal from unmounting when rightClickBehavior changes
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenu onOpenChange={handleOpenChange}>
|
||||
<ContextMenuTrigger
|
||||
asChild
|
||||
onContextMenu={handleRightClick}
|
||||
|
||||
71
components/terminal/TerminalToolbar.test.ts
Normal file
71
components/terminal/TerminalToolbar.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
|
||||
import type { Host } from "../../types.ts";
|
||||
import { TerminalToolbar } from "./TerminalToolbar.tsx";
|
||||
|
||||
const sshHost: Host = {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
};
|
||||
|
||||
const renderToolbar = (
|
||||
host: Host,
|
||||
status: "connecting" | "connected" | "disconnected" = "connected",
|
||||
props: Partial<React.ComponentProps<typeof TerminalToolbar>> = {},
|
||||
) =>
|
||||
renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(TerminalToolbar, {
|
||||
status,
|
||||
host,
|
||||
onOpenSFTP: () => {},
|
||||
onOpenScripts: () => {},
|
||||
onOpenTheme: () => {},
|
||||
...props,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
test("keeps SFTP visible before the terminal overflow menu for SSH sessions", () => {
|
||||
const markup = renderToolbar(sshHost);
|
||||
|
||||
const sftpIndex = markup.indexOf('aria-label="Open SFTP"');
|
||||
const moreIndex = markup.indexOf('aria-label="More actions"');
|
||||
|
||||
assert.notEqual(sftpIndex, -1);
|
||||
assert.notEqual(moreIndex, -1);
|
||||
assert.ok(sftpIndex < moreIndex);
|
||||
});
|
||||
|
||||
test("hides SFTP for local terminal sessions", () => {
|
||||
const markup = renderToolbar({
|
||||
...sshHost,
|
||||
id: "local-1",
|
||||
protocol: "local",
|
||||
});
|
||||
|
||||
assert.equal(markup.includes('aria-label="Open SFTP"'), false);
|
||||
});
|
||||
|
||||
test("uses the terminal active button color for pressed toolbar actions", () => {
|
||||
const markup = renderToolbar(sshHost, "connected", {
|
||||
isSearchOpen: true,
|
||||
onToggleSearch: () => {},
|
||||
});
|
||||
|
||||
assert.match(
|
||||
markup,
|
||||
/aria-label="Search terminal"[^>]*style="background-color:var\(--terminal-toolbar-btn-active\)"/,
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Terminal Toolbar
|
||||
* Displays SFTP, Scripts, Theme, Highlight, Search buttons and close button in terminal status bar
|
||||
* Displays high-frequency terminal actions and close button in the terminal status bar.
|
||||
*/
|
||||
import { Check, ChevronRight, FolderInput, Languages, MoreVertical, X, Zap, Palette, Search, TextCursorInput } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
@@ -71,6 +71,9 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
const hidesSftp = isLocalTerminal || isSerialTerminal;
|
||||
|
||||
const menuItemClass = "w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors";
|
||||
const activeButtonStyle: React.CSSProperties = {
|
||||
backgroundColor: 'var(--terminal-toolbar-btn-active)',
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={500} skipDelayDuration={100} disableHoverableContent>
|
||||
@@ -82,6 +85,26 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
buttonClassName={buttonBase}
|
||||
/>
|
||||
|
||||
{!hidesSftp && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={cn(buttonBase, status !== 'connected' && "opacity-50")}
|
||||
aria-label={status === 'connected' ? t("terminal.toolbar.openSftp") : t("terminal.toolbar.availableAfterConnect")}
|
||||
onClick={onOpenSFTP}
|
||||
disabled={status !== 'connected'}
|
||||
>
|
||||
<FolderInput size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{status === 'connected' ? t("terminal.toolbar.openSftp") : t("terminal.toolbar.availableAfterConnect")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -91,6 +114,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
aria-label={t("terminal.toolbar.composeBar")}
|
||||
aria-pressed={isComposeBarOpen}
|
||||
onClick={onToggleComposeBar}
|
||||
style={isComposeBarOpen ? activeButtonStyle : undefined}
|
||||
>
|
||||
<TextCursorInput size={12} />
|
||||
</Button>
|
||||
@@ -107,6 +131,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
aria-label={t("terminal.toolbar.searchTerminal")}
|
||||
aria-pressed={isSearchOpen}
|
||||
onClick={onToggleSearch}
|
||||
style={isSearchOpen ? activeButtonStyle : undefined}
|
||||
>
|
||||
<Search size={12} />
|
||||
</Button>
|
||||
@@ -114,9 +139,9 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
<TooltipContent>{t("terminal.toolbar.searchTerminal")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Overflow menu — collapses the four opener-style actions
|
||||
(SFTP / Encoding / Scripts / Terminal Settings) behind a
|
||||
single ⋮ trigger so the toolbar doesn't feel crowded.
|
||||
{/* Overflow menu — keeps lower-frequency opener-style actions
|
||||
(Encoding / Scripts / Terminal Settings) behind a single
|
||||
trigger so the toolbar doesn't feel crowded.
|
||||
Highlight / Compose / Search stay visible because they
|
||||
are toggled mid-session, not just once. */}
|
||||
<Popover
|
||||
@@ -154,21 +179,6 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!hidesSftp && (
|
||||
<PopoverClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(menuItemClass, status !== 'connected' && "opacity-50 pointer-events-none")}
|
||||
onClick={onOpenSFTP}
|
||||
disabled={status !== 'connected'}
|
||||
>
|
||||
<FolderInput size={12} className="shrink-0" />
|
||||
<span className="flex-1 text-left truncate">
|
||||
{status === 'connected' ? t("terminal.toolbar.openSftp") : t("terminal.toolbar.availableAfterConnect")}
|
||||
</span>
|
||||
</button>
|
||||
</PopoverClose>
|
||||
)}
|
||||
<PopoverClose asChild>
|
||||
<button type="button" className={menuItemClass} onClick={onOpenScripts}>
|
||||
<Zap size={12} className="shrink-0" />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Context-aware completion engine.
|
||||
* Combines multiple data sources:
|
||||
* 1. Command history (highest priority)
|
||||
* 2. @withfig/autocomplete specs (subcommands, options, args)
|
||||
* 1. Context-aware path completions and @withfig/autocomplete specs
|
||||
* 2. Command history
|
||||
* 3. Fuzzy history matching (fallback)
|
||||
*
|
||||
* Parses the current command line to determine context (command, subcommand,
|
||||
@@ -66,6 +66,11 @@ export interface CompletionContext {
|
||||
isOptionArg: boolean;
|
||||
}
|
||||
|
||||
interface SpecSuggestionResult {
|
||||
suggestions: CompletionSuggestion[];
|
||||
pathArgs?: FigSubcommand["args"];
|
||||
}
|
||||
|
||||
export function shellEscape(name: string): string {
|
||||
if (!name) return name;
|
||||
if (/[\\$'"|!<>;#~` ]/.test(name)) {
|
||||
@@ -170,10 +175,13 @@ export async function getCompletions(
|
||||
if (!input || input.trim().length === 0) return [];
|
||||
|
||||
const ctx = parseCommandLine(input);
|
||||
const specResult: SpecSuggestionResult = ctx.commandName && ctx.wordIndex >= 0
|
||||
? await getSpecSuggestions(ctx)
|
||||
: { suggestions: [] };
|
||||
const suggestions: CompletionSuggestion[] = [];
|
||||
const seenSuggestionTexts = new Set<string>();
|
||||
const pathCheck = ctx.commandName && ctx.wordIndex >= 1
|
||||
? shouldDoPathCompletion(ctx, undefined)
|
||||
? shouldDoPathCompletion(ctx, specResult.pathArgs)
|
||||
: { shouldComplete: false, foldersOnly: false };
|
||||
const preferPathSuggestions = pathCheck.shouldComplete;
|
||||
const resultLimit = preferPathSuggestions ? Math.max(maxResults, 24) : maxResults;
|
||||
@@ -226,21 +234,16 @@ export async function getCompletions(
|
||||
|
||||
const canQueryPaths = options.protocol === "local" || options.sessionId !== undefined;
|
||||
|
||||
const specPromise = ctx.commandName && ctx.wordIndex >= 0
|
||||
? getSpecSuggestions(ctx)
|
||||
: Promise.resolve([]);
|
||||
const pathPromise = canQueryPaths && pathCheck.shouldComplete
|
||||
? getPathSuggestions(ctx, {
|
||||
const pathEntries = canQueryPaths && pathCheck.shouldComplete
|
||||
? await getPathSuggestions(ctx, {
|
||||
sessionId: options.sessionId,
|
||||
protocol: options.protocol,
|
||||
cwd: options.cwd,
|
||||
foldersOnly: pathCheck.foldersOnly,
|
||||
})
|
||||
: Promise.resolve([]);
|
||||
: [];
|
||||
|
||||
const [specSugs, pathEntries] = await Promise.all([specPromise, pathPromise]);
|
||||
|
||||
for (const suggestion of specSugs) {
|
||||
for (const suggestion of specResult.suggestions) {
|
||||
suggestions.push(suggestion);
|
||||
seenSuggestionTexts.add(suggestion.text);
|
||||
}
|
||||
@@ -313,26 +316,26 @@ function normalizeHistoryPathPrefix(token: string): string {
|
||||
/**
|
||||
* Get suggestions from Fig spec + return resolved args (for path detection reuse).
|
||||
*/
|
||||
async function getSpecSuggestions(ctx: CompletionContext): Promise<CompletionSuggestion[]> {
|
||||
async function getSpecSuggestions(ctx: CompletionContext): Promise<SpecSuggestionResult> {
|
||||
const suggestions: CompletionSuggestion[] = [];
|
||||
|
||||
const specAvailable = await hasSpec(ctx.commandName);
|
||||
if (!specAvailable) {
|
||||
if (ctx.wordIndex === 0 && ctx.currentWord.length >= 1) {
|
||||
return await getCommandNameSuggestions(ctx.currentWord);
|
||||
return { suggestions: await getCommandNameSuggestions(ctx.currentWord) };
|
||||
}
|
||||
return [];
|
||||
return { suggestions };
|
||||
}
|
||||
|
||||
const spec = await loadSpec(ctx.commandName);
|
||||
if (!spec) return [];
|
||||
if (!spec) return { suggestions };
|
||||
|
||||
// If we're still typing the command name (partial match, not yet complete)
|
||||
if (ctx.wordIndex === 0) {
|
||||
const typedLower = ctx.currentWord.toLowerCase();
|
||||
const specNames = resolveNames(spec.name);
|
||||
const isExactMatch = specNames.some((n) => n.toLowerCase() === typedLower);
|
||||
if (!isExactMatch) return [];
|
||||
if (!isExactMatch) return { suggestions };
|
||||
|
||||
// Show subcommands as preview (user typed full command but no space yet)
|
||||
if (spec.subcommands) {
|
||||
@@ -348,11 +351,11 @@ async function getSpecSuggestions(ctx: CompletionContext): Promise<CompletionSug
|
||||
if (suggestions.length >= 10) break;
|
||||
}
|
||||
}
|
||||
return suggestions;
|
||||
return { suggestions };
|
||||
}
|
||||
|
||||
// Navigate the spec tree based on typed tokens
|
||||
let resolved = resolveSpecContext(spec, ctx.tokens.slice(1, ctx.wordIndex));
|
||||
const resolved = resolveSpecContext(spec, ctx.tokens.slice(1, ctx.wordIndex));
|
||||
const currentToken = ctx.currentWord;
|
||||
|
||||
// Check if currentToken exactly matches a subcommand — if so, navigate into it
|
||||
@@ -387,7 +390,7 @@ async function getSpecSuggestions(ctx: CompletionContext): Promise<CompletionSug
|
||||
childResolved.options?.length ? childResolved.options : childResolved.fallbackOptions,
|
||||
15,
|
||||
);
|
||||
return suggestions;
|
||||
return { suggestions };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,7 +445,10 @@ async function getSpecSuggestions(ctx: CompletionContext): Promise<CompletionSug
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
return {
|
||||
suggestions,
|
||||
pathArgs: resolved.args,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* Detects whether the user is currently at a shell prompt (vs. inside a running program).
|
||||
* Uses xterm.js buffer analysis to identify common prompt patterns.
|
||||
*
|
||||
* Strategy: scan left-to-right for the FIRST prompt-ending character ($ # % > etc.)
|
||||
* followed by a space. Exclude false positives like $HOME, $PATH, etc.
|
||||
* Strategy: scan prompt-looking boundaries ($ # % >, Powerline/Nerd Font glyphs,
|
||||
* etc.) and choose the most reliable split for prompt text vs. user input.
|
||||
*/
|
||||
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
@@ -62,6 +62,16 @@ function replacePromptUserInput(
|
||||
};
|
||||
}
|
||||
|
||||
function getCursorLinePrefix(term: XTerm): string | null {
|
||||
const buffer = term.buffer.active;
|
||||
const cursorY = buffer.cursorY + buffer.baseY;
|
||||
const line = buffer.getLine(cursorY);
|
||||
|
||||
if (!line) return null;
|
||||
|
||||
return line.translateToString(false).substring(0, Math.max(0, buffer.cursorX));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether the terminal cursor is at a shell prompt and extract the current user input.
|
||||
*/
|
||||
@@ -141,9 +151,23 @@ export function detectPrompt(term: XTerm): PromptDetectionResult {
|
||||
/** Characters that commonly end a shell prompt */
|
||||
const PROMPT_CHARS = new Set(["$", "#", "%", ">", "❯", "❮", "→", "➜", "➤", "⟩", "»", "›"]);
|
||||
|
||||
/**
|
||||
* Whether a character lives in the Unicode Private Use Area (U+E000–U+F8FF).
|
||||
* Powerline separators (U+E0B0..) and Nerd Font icons (U+E200.., U+F000..) all
|
||||
* fall here. A PUA char followed by a space is common in themed prompt
|
||||
* terminators (oh-my-posh, starship, p10k, etc.), but commands can still echo
|
||||
* those glyphs, so PUA boundaries are kept lower priority than standard prompt
|
||||
* characters and reconciled with the typed buffer when available.
|
||||
*/
|
||||
function isPuaChar(ch: string): boolean {
|
||||
if (!ch) return false;
|
||||
const code = ch.charCodeAt(0);
|
||||
return code >= 0xE000 && code <= 0xF8FF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the boundary between prompt and user input.
|
||||
* Scans left-to-right within the first 80 chars for a prompt character followed by space.
|
||||
* Scans left-to-right within the first 200 chars for a prompt character followed by space.
|
||||
* Avoids false positives: $VAR, $(...), ${...} are not prompt endings.
|
||||
* Returns the character index where user input begins, or -1 if no prompt detected.
|
||||
*/
|
||||
@@ -154,15 +178,18 @@ function findPromptBoundary(lineText: string): number {
|
||||
// confused with shell syntax in a prompt position.
|
||||
const lineLen = lineText.trimEnd().length;
|
||||
const scanLimit = Math.min(lineLen, 200);
|
||||
let lastBoundary = -1;
|
||||
let lastStandardBoundary = -1;
|
||||
let lastPuaBoundary = -1;
|
||||
|
||||
// Ambiguous chars (>) only scan first 60% to avoid matching redirections
|
||||
const ambiguousScanLimit = Math.min(scanLimit, Math.max(40, Math.floor(lineLen * 0.6)));
|
||||
|
||||
for (let i = 0; i < scanLimit; i++) {
|
||||
const ch = lineText[i];
|
||||
const isStandard = PROMPT_CHARS.has(ch);
|
||||
const isPua = !isStandard && isPuaChar(ch);
|
||||
|
||||
if (!PROMPT_CHARS.has(ch)) continue;
|
||||
if (!isStandard && !isPua) continue;
|
||||
|
||||
// For ambiguous prompt chars like >, only accept in the first 60% of the line
|
||||
if ((ch === ">" || ch === "›") && i >= ambiguousScanLimit) continue;
|
||||
@@ -222,11 +249,17 @@ function findPromptBoundary(lineText: string): number {
|
||||
}
|
||||
}
|
||||
|
||||
// Record this as a candidate boundary
|
||||
lastBoundary = nextChar === " " ? i + 2 : i + 1;
|
||||
// Record this as a candidate boundary. A standard shell prompt terminator
|
||||
// is more reliable than a later Powerline/Nerd Font glyph in command text.
|
||||
const boundary = nextChar === " " ? i + 2 : i + 1;
|
||||
if (isStandard) {
|
||||
lastStandardBoundary = boundary;
|
||||
} else {
|
||||
lastPuaBoundary = boundary;
|
||||
}
|
||||
}
|
||||
|
||||
return lastBoundary;
|
||||
return lastStandardBoundary >= 0 ? lastStandardBoundary : lastPuaBoundary;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -312,6 +345,21 @@ export function getAlignedPrompt(
|
||||
alignedTyped: typedBuffer,
|
||||
};
|
||||
}
|
||||
const cursorLinePrefix = getCursorLinePrefix(term);
|
||||
if (cursorLinePrefix?.endsWith(typedBuffer)) {
|
||||
const promptText = cursorLinePrefix.slice(0, cursorLinePrefix.length - typedBuffer.length);
|
||||
if (promptText.length > 0) {
|
||||
return {
|
||||
prompt: {
|
||||
isAtPrompt: true,
|
||||
promptText,
|
||||
userInput: typedBuffer,
|
||||
cursorOffset: typedBuffer.length,
|
||||
},
|
||||
alignedTyped: typedBuffer,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { prompt: raw, alignedTyped: null };
|
||||
}
|
||||
|
||||
|
||||
@@ -107,11 +107,6 @@ export function shouldDoPathCompletion(
|
||||
foldersOnly: templates.includes("folders") && !templates.includes("filepaths"),
|
||||
};
|
||||
}
|
||||
// Generators field often indicates path completion (e.g., cd)
|
||||
if (arg.generators) {
|
||||
const foldersOnly = FOLDER_ONLY_COMMANDS.has(ctx.commandName);
|
||||
return { shouldComplete: true, foldersOnly };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
123
components/terminal/completionEngine.test.ts
Normal file
123
components/terminal/completionEngine.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { FigSpec } from "./autocomplete/figSpecLoader.ts";
|
||||
|
||||
type LocalStorageMock = {
|
||||
clear(): void;
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
};
|
||||
|
||||
type MockDirEntry = {
|
||||
name: string;
|
||||
type: "file" | "directory" | "symlink";
|
||||
};
|
||||
|
||||
function installLocalStorage(): LocalStorageMock {
|
||||
const store = new Map<string, string>();
|
||||
const localStorage: LocalStorageMock = {
|
||||
clear() {
|
||||
store.clear();
|
||||
},
|
||||
getItem(key: string) {
|
||||
return store.has(key) ? store.get(key)! : null;
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store.set(key, String(value));
|
||||
},
|
||||
removeItem(key: string) {
|
||||
store.delete(key);
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: localStorage,
|
||||
configurable: true,
|
||||
});
|
||||
return localStorage;
|
||||
}
|
||||
|
||||
const localStorage = installLocalStorage();
|
||||
const storySpec: FigSpec = {
|
||||
name: "story",
|
||||
subcommands: [
|
||||
{
|
||||
name: "open",
|
||||
args: { template: "filepaths" },
|
||||
},
|
||||
{
|
||||
name: "pick",
|
||||
args: { name: "item", generators: {} },
|
||||
},
|
||||
],
|
||||
};
|
||||
const bridgeState: { localEntries: MockDirEntry[] } = {
|
||||
localEntries: [],
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: {
|
||||
netcatty: {
|
||||
listFigSpecs: async () => ["story"],
|
||||
loadFigSpec: async (commandName: string) => commandName === "story" ? storySpec : null,
|
||||
listAutocompleteLocalDir: async (
|
||||
_path: string,
|
||||
foldersOnly: boolean,
|
||||
filterPrefix?: string,
|
||||
limit?: number,
|
||||
) => {
|
||||
const prefix = (filterPrefix ?? "").toLowerCase();
|
||||
const entries = bridgeState.localEntries
|
||||
.filter((entry) => !foldersOnly || entry.type === "directory")
|
||||
.filter((entry) => !prefix || entry.name.toLowerCase().startsWith(prefix))
|
||||
.slice(0, limit ?? bridgeState.localEntries.length);
|
||||
return { success: true, entries };
|
||||
},
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const { getCompletions } = await import("./autocomplete/completionEngine.ts");
|
||||
const { clearHistory, recordCommand } = await import("./autocomplete/commandHistoryStore.ts");
|
||||
|
||||
test.beforeEach(() => {
|
||||
localStorage.clear();
|
||||
clearHistory();
|
||||
bridgeState.localEntries = [{ name: "package.json", type: "file" }];
|
||||
});
|
||||
|
||||
test("getCompletions prioritizes spec-driven path suggestions over history", async () => {
|
||||
recordCommand("story open package-lock.json", "host-1");
|
||||
|
||||
const completions = await getCompletions("story open pa", {
|
||||
hostId: "host-1",
|
||||
protocol: "local",
|
||||
cwd: "/repo",
|
||||
});
|
||||
|
||||
assert.ok(completions.length > 0);
|
||||
assert.equal(completions[0]?.source, "path");
|
||||
assert.equal(completions[0]?.text, "story open package.json");
|
||||
|
||||
const historyIndex = completions.findIndex((entry) =>
|
||||
entry.source === "history" && entry.text === "story open package-lock.json"
|
||||
);
|
||||
assert.ok(historyIndex > 0);
|
||||
});
|
||||
|
||||
test("getCompletions does not treat generator-only spec args as path contexts", async () => {
|
||||
recordCommand("story pick package-choice", "host-1");
|
||||
|
||||
const completions = await getCompletions("story pick pa", {
|
||||
hostId: "host-1",
|
||||
protocol: "local",
|
||||
cwd: "/repo",
|
||||
});
|
||||
|
||||
assert.ok(completions.length > 0);
|
||||
assert.equal(completions[0]?.source, "history");
|
||||
assert.equal(completions[0]?.text, "story pick package-choice");
|
||||
assert.equal(completions.some((entry) => entry.source === "path"), false);
|
||||
});
|
||||
61
components/terminal/focusTerminalSession.test.ts
Normal file
61
components/terminal/focusTerminalSession.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { focusTerminalSessionInput } from "./focusTerminalSession";
|
||||
|
||||
test("focusTerminalSessionInput focuses the xterm helper textarea immediately and after scheduled retries", () => {
|
||||
const focusCalls: string[] = [];
|
||||
const textarea = {
|
||||
focus: () => focusCalls.push("focus"),
|
||||
};
|
||||
const pane = {
|
||||
querySelector: (selector: string) => {
|
||||
assert.equal(selector, "textarea.xterm-helper-textarea");
|
||||
return textarea;
|
||||
},
|
||||
};
|
||||
const queriedSelectors: string[] = [];
|
||||
const doc = {
|
||||
querySelector: (selector: string) => {
|
||||
queriedSelectors.push(selector);
|
||||
return pane;
|
||||
},
|
||||
};
|
||||
const timeouts: number[] = [];
|
||||
|
||||
focusTerminalSessionInput("session-1", {
|
||||
document: doc,
|
||||
requestAnimationFrame: (callback) => {
|
||||
callback();
|
||||
return 1;
|
||||
},
|
||||
setTimeout: (callback, delay) => {
|
||||
timeouts.push(delay);
|
||||
callback();
|
||||
return delay;
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(queriedSelectors, [
|
||||
'[data-session-id="session-1"]',
|
||||
'[data-session-id="session-1"]',
|
||||
]);
|
||||
assert.deepEqual(timeouts, [50]);
|
||||
assert.deepEqual(focusCalls, ["focus", "focus"]);
|
||||
});
|
||||
|
||||
test("focusTerminalSessionInput ignores empty or unavailable targets", () => {
|
||||
assert.doesNotThrow(() => {
|
||||
focusTerminalSessionInput(null, {
|
||||
document: undefined,
|
||||
requestAnimationFrame: (callback) => {
|
||||
callback();
|
||||
return 1;
|
||||
},
|
||||
setTimeout: (callback, delay) => {
|
||||
callback();
|
||||
return delay;
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
57
components/terminal/focusTerminalSession.ts
Normal file
57
components/terminal/focusTerminalSession.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
type QueryTarget = {
|
||||
querySelector: (selector: string) => QueryTarget | FocusableTarget | null;
|
||||
};
|
||||
|
||||
type FocusableTarget = {
|
||||
focus?: () => void;
|
||||
};
|
||||
|
||||
interface FocusTerminalSessionInputOptions {
|
||||
document?: QueryTarget | null;
|
||||
requestAnimationFrame?: (callback: () => void) => unknown;
|
||||
setTimeout?: (callback: () => void, delay: number) => unknown;
|
||||
retryDelays?: readonly number[];
|
||||
}
|
||||
|
||||
const escapeAttributeValue = (value: string): string =>
|
||||
value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
|
||||
export const focusTerminalSessionInput = (
|
||||
sessionId: string | null | undefined,
|
||||
options: FocusTerminalSessionInputOptions = {},
|
||||
): void => {
|
||||
if (!sessionId) return;
|
||||
|
||||
const doc = options.document ?? (typeof document !== "undefined" ? document : null);
|
||||
if (!doc) return;
|
||||
|
||||
const raf = options.requestAnimationFrame
|
||||
?? (typeof requestAnimationFrame !== "undefined"
|
||||
? requestAnimationFrame
|
||||
: (callback: () => void) => {
|
||||
callback();
|
||||
return undefined;
|
||||
});
|
||||
const scheduleTimeout = options.setTimeout
|
||||
?? (typeof setTimeout !== "undefined"
|
||||
? setTimeout
|
||||
: (callback: () => void) => {
|
||||
callback();
|
||||
return undefined;
|
||||
});
|
||||
const retryDelays = options.retryDelays ?? [50];
|
||||
const paneSelector = `[data-session-id="${escapeAttributeValue(sessionId)}"]`;
|
||||
|
||||
const focusTarget = () => {
|
||||
const pane = doc.querySelector(paneSelector) as QueryTarget | null;
|
||||
const textarea = pane?.querySelector("textarea.xterm-helper-textarea") as FocusableTarget | null;
|
||||
textarea?.focus?.();
|
||||
};
|
||||
|
||||
raf(() => {
|
||||
focusTarget();
|
||||
retryDelays.forEach((delay) => {
|
||||
scheduleTimeout(focusTarget, delay);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createTerminalSessionStarters } from "./createTerminalSessionStarters";
|
||||
|
||||
const noop = () => undefined;
|
||||
|
||||
test("startMosh does not pass legacy configured mosh client paths to the backend", async () => {
|
||||
let capturedOptions: Record<string, unknown> | null = null;
|
||||
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
telnetAvailable: () => true,
|
||||
moshAvailable: () => true,
|
||||
localAvailable: () => true,
|
||||
serialAvailable: () => true,
|
||||
execAvailable: () => true,
|
||||
startSSHSession: async () => "ssh-session",
|
||||
startTelnetSession: async () => "telnet-session",
|
||||
startMoshSession: async (options: Record<string, unknown>) => {
|
||||
capturedOptions = options;
|
||||
return "mosh-session";
|
||||
},
|
||||
startLocalSession: async () => "local-session",
|
||||
startSerialSession: async () => "serial-session",
|
||||
execCommand: async () => ({}),
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Example",
|
||||
hostname: "example.test",
|
||||
username: "alice",
|
||||
port: 2200,
|
||||
},
|
||||
keys: [],
|
||||
resolvedChainHosts: [],
|
||||
sessionId: "session-1",
|
||||
terminalSettings: {
|
||||
terminalEmulationType: "xterm-256color",
|
||||
moshClientPath: "/usr/local/bin/mosh-client",
|
||||
},
|
||||
terminalBackend,
|
||||
sessionRef: { current: null },
|
||||
hasConnectedRef: { current: false },
|
||||
hasRunStartupCommandRef: { current: false },
|
||||
disposeDataRef: { current: null },
|
||||
disposeExitRef: { current: null },
|
||||
fitAddonRef: { current: null },
|
||||
serializeAddonRef: { current: null },
|
||||
pendingAuthRef: { current: null },
|
||||
updateStatus: noop,
|
||||
setStatus: noop,
|
||||
setError: noop,
|
||||
setNeedsAuth: noop,
|
||||
setAuthRetryMessage: noop,
|
||||
setAuthPassword: noop,
|
||||
setProgressLogs: noop,
|
||||
setProgressValue: noop,
|
||||
setChainProgress: noop,
|
||||
};
|
||||
|
||||
const term = {
|
||||
cols: 120,
|
||||
rows: 32,
|
||||
write: noop,
|
||||
writeln: noop,
|
||||
scrollToBottom: noop,
|
||||
};
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
|
||||
|
||||
assert.ok(capturedOptions);
|
||||
assert.equal("moshClientPath" in capturedOptions, false);
|
||||
assert.equal(capturedOptions.hostname, "example.test");
|
||||
assert.equal(capturedOptions.port, 2200);
|
||||
});
|
||||
|
||||
test("startMosh passes the saved password to the mosh backend", async () => {
|
||||
let capturedOptions: Record<string, unknown> | null = null;
|
||||
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
telnetAvailable: () => true,
|
||||
moshAvailable: () => true,
|
||||
localAvailable: () => true,
|
||||
serialAvailable: () => true,
|
||||
execAvailable: () => true,
|
||||
startSSHSession: async () => "ssh-session",
|
||||
startTelnetSession: async () => "telnet-session",
|
||||
startMoshSession: async (options: Record<string, unknown>) => {
|
||||
capturedOptions = options;
|
||||
return "mosh-session";
|
||||
},
|
||||
startLocalSession: async () => "local-session",
|
||||
startSerialSession: async () => "serial-session",
|
||||
execCommand: async () => ({}),
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Example",
|
||||
hostname: "example.test",
|
||||
username: "alice",
|
||||
password: "saved-secret",
|
||||
port: 2200,
|
||||
},
|
||||
keys: [],
|
||||
resolvedChainHosts: [],
|
||||
sessionId: "session-1",
|
||||
terminalSettings: {},
|
||||
terminalBackend,
|
||||
sessionRef: { current: null },
|
||||
hasConnectedRef: { current: false },
|
||||
hasRunStartupCommandRef: { current: false },
|
||||
disposeDataRef: { current: null },
|
||||
disposeExitRef: { current: null },
|
||||
fitAddonRef: { current: null },
|
||||
serializeAddonRef: { current: null },
|
||||
pendingAuthRef: { current: null },
|
||||
updateStatus: noop,
|
||||
setStatus: noop,
|
||||
setError: noop,
|
||||
setNeedsAuth: noop,
|
||||
setAuthRetryMessage: noop,
|
||||
setAuthPassword: noop,
|
||||
setProgressLogs: noop,
|
||||
setProgressValue: noop,
|
||||
setChainProgress: noop,
|
||||
};
|
||||
|
||||
const term = {
|
||||
cols: 120,
|
||||
rows: 32,
|
||||
write: noop,
|
||||
writeln: noop,
|
||||
scrollToBottom: noop,
|
||||
};
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
|
||||
|
||||
assert.ok(capturedOptions);
|
||||
assert.equal(capturedOptions.username, "alice");
|
||||
assert.equal(capturedOptions.password, "saved-secret");
|
||||
});
|
||||
@@ -569,6 +569,8 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
? (effectivePassphrase || sanitizeCredentialValue(attempt.key.passphrase))
|
||||
: undefined,
|
||||
agentForwarding: ctx.host.agentForwarding,
|
||||
x11Forwarding: ctx.host.x11Forwarding,
|
||||
x11Display: ctx.terminalSettings?.x11Display,
|
||||
legacyAlgorithms: ctx.host.legacyAlgorithms,
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
@@ -752,11 +754,28 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
}
|
||||
|
||||
try {
|
||||
const pendingAuth = ctx.pendingAuthRef.current;
|
||||
const resolvedAuth = resolveHostAuth({
|
||||
host: ctx.host,
|
||||
keys: ctx.keys,
|
||||
identities: ctx.identities,
|
||||
override: pendingAuth
|
||||
? {
|
||||
authMethod: pendingAuth.authMethod,
|
||||
username: pendingAuth.username,
|
||||
password: pendingAuth.password,
|
||||
keyId: pendingAuth.keyId,
|
||||
passphrase: pendingAuth.passphrase,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
const effectivePassword = sanitizeCredentialValue(resolvedAuth.password);
|
||||
const moshEnv = buildTermEnv(ctx.host, ctx.terminalSettings);
|
||||
const id = await ctx.terminalBackend.startMoshSession({
|
||||
sessionId: ctx.sessionId,
|
||||
hostname: ctx.host.hostname,
|
||||
username: ctx.host.username || "root",
|
||||
username: resolvedAuth.username || "root",
|
||||
password: effectivePassword,
|
||||
port: ctx.host.port || 22,
|
||||
moshServerPath: ctx.host.moshServerPath,
|
||||
agentForwarding: ctx.host.agentForwarding,
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
isEraseScrollbackSequence,
|
||||
preserveTerminalViewportInScrollback,
|
||||
} from "../clearTerminalViewport";
|
||||
import { installUserCursorPreferenceGuard } from "./cursorPreference";
|
||||
import type {
|
||||
Host,
|
||||
KeyBinding,
|
||||
@@ -830,6 +831,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
return true;
|
||||
});
|
||||
|
||||
const cursorPreferenceDisposable = installUserCursorPreferenceGuard(term, ctx.terminalSettingsRef);
|
||||
|
||||
let resizeTimeout: NodeJS.Timeout | null = null;
|
||||
const resizeDebounceMs = XTERM_PERFORMANCE_CONFIG.resize.debounceMs;
|
||||
term.onResize(({ cols, rows }) => {
|
||||
@@ -857,6 +860,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
eraseScrollbackDisposable.dispose();
|
||||
osc7Disposable.dispose();
|
||||
osc52Disposable.dispose();
|
||||
cursorPreferenceDisposable?.dispose();
|
||||
try {
|
||||
term.dispose();
|
||||
} catch (err) {
|
||||
|
||||
160
components/terminal/runtime/cursorPreference.test.ts
Normal file
160
components/terminal/runtime/cursorPreference.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
applyUserCursorBlinkPreference,
|
||||
applyUserCursorPreference,
|
||||
installUserCursorPreferenceGuard,
|
||||
resolveUserCursorPreference,
|
||||
} from "./cursorPreference";
|
||||
|
||||
test("resolveUserCursorPreference defaults to a blinking block cursor", () => {
|
||||
assert.deepEqual(resolveUserCursorPreference(undefined), {
|
||||
cursorShape: "block",
|
||||
cursorBlink: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("applyUserCursorPreference clears terminal-side cursor overrides before applying user settings", () => {
|
||||
const term = {
|
||||
options: {
|
||||
cursorStyle: "block" as const,
|
||||
cursorBlink: false,
|
||||
},
|
||||
_core: {
|
||||
coreService: {
|
||||
decPrivateModes: {
|
||||
cursorStyle: "bar" as const,
|
||||
cursorBlink: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
applyUserCursorPreference(term, {
|
||||
cursorShape: "underline",
|
||||
cursorBlink: true,
|
||||
});
|
||||
|
||||
assert.equal(term.options.cursorStyle, "underline");
|
||||
assert.equal(term.options.cursorBlink, true);
|
||||
assert.equal(term._core.coreService.decPrivateModes.cursorStyle, undefined);
|
||||
assert.equal(term._core.coreService.decPrivateModes.cursorBlink, undefined);
|
||||
});
|
||||
|
||||
test("applyUserCursorBlinkPreference keeps remote cursor shape overrides intact", () => {
|
||||
const term = {
|
||||
options: {
|
||||
cursorStyle: "block" as const,
|
||||
cursorBlink: false,
|
||||
},
|
||||
_core: {
|
||||
coreService: {
|
||||
decPrivateModes: {
|
||||
cursorStyle: "bar" as const,
|
||||
cursorBlink: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
applyUserCursorBlinkPreference(term, {
|
||||
cursorShape: "underline",
|
||||
cursorBlink: true,
|
||||
});
|
||||
|
||||
assert.equal(term.options.cursorStyle, "block");
|
||||
assert.equal(term.options.cursorBlink, true);
|
||||
assert.equal(term._core.coreService.decPrivateModes.cursorStyle, "bar");
|
||||
assert.equal(term._core.coreService.decPrivateModes.cursorBlink, undefined);
|
||||
});
|
||||
|
||||
test("installUserCursorPreferenceGuard restores blink without consuming cursor-style overrides", async () => {
|
||||
const handlers = new Map<string, (params: readonly (number | number[])[]) => boolean>();
|
||||
const parser = {
|
||||
registerCsiHandler(this: typeof parser, id: { prefix?: string; intermediates?: string; final: string }, callback: (params: readonly (number | number[])[]) => boolean) {
|
||||
assert.equal(this, parser);
|
||||
handlers.set(`${id.prefix ?? ""}|${id.intermediates ?? ""}|${id.final}`, callback);
|
||||
return { dispose: () => undefined };
|
||||
},
|
||||
};
|
||||
const term = {
|
||||
options: {
|
||||
cursorStyle: "block" as const,
|
||||
cursorBlink: false,
|
||||
},
|
||||
parser,
|
||||
_core: {
|
||||
coreService: {
|
||||
decPrivateModes: {
|
||||
cursorStyle: "block" as const,
|
||||
cursorBlink: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const settingsRef = {
|
||||
current: {
|
||||
cursorShape: "bar",
|
||||
cursorBlink: true,
|
||||
},
|
||||
};
|
||||
|
||||
installUserCursorPreferenceGuard(term, settingsRef);
|
||||
const handled = handlers.get("| |q")?.([2]);
|
||||
|
||||
assert.equal(handled, false);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
assert.equal(term.options.cursorStyle, "block");
|
||||
assert.equal(term.options.cursorBlink, true);
|
||||
assert.equal(term._core.coreService.decPrivateModes.cursorStyle, "block");
|
||||
assert.equal(term._core.coreService.decPrivateModes.cursorBlink, undefined);
|
||||
});
|
||||
|
||||
test("installUserCursorPreferenceGuard restores cursor blink after private mode changes", async () => {
|
||||
const handlers = new Map<string, (params: readonly (number | number[])[]) => boolean>();
|
||||
const term = {
|
||||
options: {
|
||||
cursorStyle: "block" as const,
|
||||
cursorBlink: false,
|
||||
},
|
||||
parser: {
|
||||
registerCsiHandler: (id: { prefix?: string; intermediates?: string; final: string }, callback: (params: readonly (number | number[])[]) => boolean) => {
|
||||
handlers.set(`${id.prefix ?? ""}|${id.intermediates ?? ""}|${id.final}`, callback);
|
||||
return { dispose: () => undefined };
|
||||
},
|
||||
},
|
||||
_core: {
|
||||
coreService: {
|
||||
decPrivateModes: {
|
||||
cursorStyle: "block" as const,
|
||||
cursorBlink: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const settingsRef = {
|
||||
current: {
|
||||
cursorShape: "underline",
|
||||
cursorBlink: true,
|
||||
},
|
||||
};
|
||||
|
||||
installUserCursorPreferenceGuard(term, settingsRef);
|
||||
const handled = handlers.get("?||l")?.([12]);
|
||||
|
||||
assert.equal(handled, false);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
assert.equal(term.options.cursorStyle, "block");
|
||||
assert.equal(term.options.cursorBlink, true);
|
||||
assert.equal(term._core.coreService.decPrivateModes.cursorStyle, "block");
|
||||
assert.equal(term._core.coreService.decPrivateModes.cursorBlink, undefined);
|
||||
});
|
||||
118
components/terminal/runtime/cursorPreference.ts
Normal file
118
components/terminal/runtime/cursorPreference.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { IDisposable, Terminal as XTerm } from "@xterm/xterm";
|
||||
import type { RefObject } from "react";
|
||||
|
||||
import type { TerminalSettings } from "../../../types";
|
||||
|
||||
type CursorPreferenceSettings = Pick<TerminalSettings, "cursorShape" | "cursorBlink">;
|
||||
|
||||
type MutableCursorOptions = {
|
||||
cursorStyle?: "block" | "bar" | "underline";
|
||||
cursorBlink?: boolean;
|
||||
};
|
||||
|
||||
type TerminalLike = {
|
||||
options: MutableCursorOptions;
|
||||
parser?: {
|
||||
registerCsiHandler?: (
|
||||
id: { prefix?: string; intermediates?: string; final: string },
|
||||
callback: (params: readonly (number | number[])[]) => boolean,
|
||||
) => IDisposable;
|
||||
};
|
||||
_core?: {
|
||||
coreService?: {
|
||||
decPrivateModes?: {
|
||||
cursorStyle?: "block" | "bar" | "underline";
|
||||
cursorBlink?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const scheduleAfterDefaultHandler = (callback: () => void): void => {
|
||||
if (typeof queueMicrotask === "function") {
|
||||
queueMicrotask(callback);
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(callback, 0);
|
||||
};
|
||||
|
||||
const hasCursorBlinkPrivateModeParam = (params: readonly (number | number[])[]): boolean => (
|
||||
params.some((param) => (
|
||||
Array.isArray(param)
|
||||
? param.includes(12)
|
||||
: param === 12
|
||||
))
|
||||
);
|
||||
|
||||
export const resolveUserCursorPreference = (
|
||||
settings: Partial<CursorPreferenceSettings> | undefined,
|
||||
): Required<CursorPreferenceSettings> => ({
|
||||
cursorShape: settings?.cursorShape ?? "block",
|
||||
cursorBlink: settings?.cursorBlink ?? true,
|
||||
});
|
||||
|
||||
export const applyUserCursorPreference = (
|
||||
term: TerminalLike,
|
||||
settings: Partial<CursorPreferenceSettings> | undefined,
|
||||
): void => {
|
||||
const preference = resolveUserCursorPreference(settings);
|
||||
const privateModes = term._core?.coreService?.decPrivateModes;
|
||||
if (privateModes) {
|
||||
privateModes.cursorStyle = undefined;
|
||||
privateModes.cursorBlink = undefined;
|
||||
}
|
||||
term.options.cursorStyle = preference.cursorShape;
|
||||
term.options.cursorBlink = preference.cursorBlink;
|
||||
};
|
||||
|
||||
export const applyUserCursorBlinkPreference = (
|
||||
term: TerminalLike,
|
||||
settings: Partial<CursorPreferenceSettings> | undefined,
|
||||
): void => {
|
||||
const preference = resolveUserCursorPreference(settings);
|
||||
const privateModes = term._core?.coreService?.decPrivateModes;
|
||||
if (privateModes) {
|
||||
privateModes.cursorBlink = undefined;
|
||||
}
|
||||
term.options.cursorBlink = preference.cursorBlink;
|
||||
};
|
||||
|
||||
export const installUserCursorPreferenceGuard = (
|
||||
term: XTerm | TerminalLike,
|
||||
terminalSettingsRef: RefObject<TerminalSettings | undefined>,
|
||||
): IDisposable | null => {
|
||||
const terminal = term as TerminalLike;
|
||||
const parser = terminal.parser;
|
||||
if (!parser?.registerCsiHandler) return null;
|
||||
const registerCsiHandler = parser.registerCsiHandler.bind(parser);
|
||||
|
||||
const applyBlinkPreference = () => applyUserCursorBlinkPreference(terminal, terminalSettingsRef.current);
|
||||
|
||||
const cursorStyleDisposable = registerCsiHandler({ intermediates: " ", final: "q" }, () => {
|
||||
scheduleAfterDefaultHandler(applyBlinkPreference);
|
||||
return false;
|
||||
});
|
||||
|
||||
const cursorBlinkSetDisposable = registerCsiHandler({ prefix: "?", final: "h" }, (params) => {
|
||||
if (hasCursorBlinkPrivateModeParam(params)) {
|
||||
scheduleAfterDefaultHandler(applyBlinkPreference);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const cursorBlinkResetDisposable = registerCsiHandler({ prefix: "?", final: "l" }, (params) => {
|
||||
if (hasCursorBlinkPrivateModeParam(params)) {
|
||||
scheduleAfterDefaultHandler(applyBlinkPreference);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
cursorStyleDisposable.dispose();
|
||||
cursorBlinkSetDisposable.dispose();
|
||||
cursorBlinkResetDisposable.dispose();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -78,6 +78,7 @@ export interface Host {
|
||||
savePassword?: boolean; // Whether to save the password (default: true)
|
||||
authMethod?: 'password' | 'key' | 'certificate';
|
||||
agentForwarding?: boolean;
|
||||
x11Forwarding?: boolean;
|
||||
createdAt?: number; // Timestamp when host was created
|
||||
startupCommand?: string;
|
||||
hostChaining?: string; // Deprecated: use hostChain instead
|
||||
@@ -490,6 +491,12 @@ export interface TerminalSettings {
|
||||
|
||||
// SSH Connection
|
||||
keepaliveInterval: number; // Seconds between SSH-level keepalive packets (0 = disabled)
|
||||
x11Display: string; // Optional local X11 DISPLAY override (empty = use system DISPLAY/default)
|
||||
|
||||
// Mosh Connection
|
||||
// Legacy override retained for old settings payloads and internal callers.
|
||||
// The normal UI path uses Netcatty's bundled mosh-client.
|
||||
moshClientPath: string;
|
||||
|
||||
// Server Stats Display (Linux only)
|
||||
showServerStats: boolean; // Show CPU/Memory/Disk in terminal statusbar
|
||||
@@ -635,6 +642,8 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
localShell: '', // Empty = use system default
|
||||
localStartDir: '', // Empty = use home directory
|
||||
keepaliveInterval: 0, // 0 = disabled (use SSH library defaults)
|
||||
x11Display: '', // Empty = use DISPLAY/default local X server
|
||||
moshClientPath: '', // Legacy mosh-client override; normal UI uses bundled mosh-client
|
||||
showServerStats: true, // Show server stats by default
|
||||
serverStatsRefreshInterval: 5, // Refresh every 5 seconds
|
||||
disableBracketedPaste: false, // Bracketed paste enabled by default
|
||||
@@ -771,6 +780,7 @@ export type TransferDirection = 'upload' | 'download' | 'remote-to-remote' | 'lo
|
||||
|
||||
export interface TransferTask {
|
||||
id: string;
|
||||
batchId?: string;
|
||||
fileName: string;
|
||||
originalFileName?: string;
|
||||
sourcePath: string;
|
||||
@@ -795,14 +805,21 @@ export interface TransferTask {
|
||||
parentTaskId?: string;
|
||||
sourceLastModified?: number; // Cached from file list to avoid redundant stat
|
||||
skipConflictCheck?: boolean; // Skip conflict check for replace operations
|
||||
replaceExistingTarget?: boolean; // Delete the existing target before transferring
|
||||
retryable?: boolean; // False for task types that cannot be safely replayed through generic retry
|
||||
}
|
||||
|
||||
export type FileConflictAction = 'stop' | 'skip' | 'replace' | 'duplicate' | 'merge';
|
||||
|
||||
export interface FileConflict {
|
||||
transferId: string;
|
||||
batchId?: string;
|
||||
fileName: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
isDirectory: boolean;
|
||||
existingType?: 'file' | 'directory' | 'symlink';
|
||||
applyToAllCount?: number;
|
||||
existingSize: number;
|
||||
newSize: number;
|
||||
existingModified: number;
|
||||
|
||||
35
domain/sshConfigSerializer.test.ts
Normal file
35
domain/sshConfigSerializer.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { Host } from "./models.ts";
|
||||
import { serializeHostsToSshConfig } from "./sshConfigSerializer.ts";
|
||||
|
||||
const makeHost = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "host-1",
|
||||
label: "X11 Host",
|
||||
hostname: "x11.example.com",
|
||||
username: "root",
|
||||
port: 22,
|
||||
protocol: "ssh",
|
||||
os: "linux",
|
||||
tags: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("serializeHostsToSshConfig writes ForwardX11 for hosts with X11 forwarding enabled", () => {
|
||||
const config = serializeHostsToSshConfig([makeHost({ x11Forwarding: true })]);
|
||||
|
||||
assert.match(config, /ForwardX11 yes/);
|
||||
});
|
||||
|
||||
test("serializeHostsToSshConfig omits ForwardX11 when X11 forwarding is disabled", () => {
|
||||
const config = serializeHostsToSshConfig([makeHost({ x11Forwarding: false })]);
|
||||
|
||||
assert.doesNotMatch(config, /ForwardX11/);
|
||||
});
|
||||
|
||||
test("serializeHostsToSshConfig omits ForwardX11 for mosh hosts", () => {
|
||||
const config = serializeHostsToSshConfig([makeHost({ moshEnabled: true, x11Forwarding: true })]);
|
||||
|
||||
assert.doesNotMatch(config, /ForwardX11/);
|
||||
});
|
||||
@@ -113,6 +113,10 @@ export const serializeHostsToSshConfig = (hosts: Host[], allHosts?: Host[]): str
|
||||
lines.push(` Port ${host.port}`);
|
||||
}
|
||||
|
||||
if (host.x11Forwarding && !host.moshEnabled) {
|
||||
lines.push(" ForwardX11 yes");
|
||||
}
|
||||
|
||||
// Serialize IdentityFile paths
|
||||
if (host.identityFilePaths && host.identityFilePaths.length > 0) {
|
||||
for (const keyPath of host.identityFilePaths) {
|
||||
|
||||
@@ -156,13 +156,11 @@ test("only non-hosts entity shrinks → reports that entity", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("knownHosts shrink triggers (security-sensitive)", () => {
|
||||
test("knownHosts shrink is ignored because known hosts are local-only", () => {
|
||||
const kh = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `kh${i}`, hostname: `h${i}`, port: 22, keyType: "rsa", fingerprint: "x" })) as unknown as SyncPayload["knownHosts"];
|
||||
const base = payload({ knownHosts: kh(12) });
|
||||
const out = payload({ knownHosts: kh(2) });
|
||||
const result = detectSuspiciousShrink(out, base);
|
||||
assert.equal(result.suspicious, true);
|
||||
if (result.suspicious) assert.equal(result.entityType, "knownHosts");
|
||||
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
|
||||
});
|
||||
|
||||
test("empty base (all zeros) — no shrink possible, returns not suspicious", () => {
|
||||
|
||||
@@ -12,7 +12,6 @@ export type ShrinkFinding =
|
||||
| 'snippets'
|
||||
| 'customGroups'
|
||||
| 'snippetPackages'
|
||||
| 'knownHosts'
|
||||
| 'portForwardingRules'
|
||||
| 'groupConfigs';
|
||||
baseCount: number;
|
||||
@@ -32,7 +31,6 @@ const CHECKED_ENTITIES = [
|
||||
'snippets',
|
||||
'customGroups',
|
||||
'snippetPackages',
|
||||
'knownHosts',
|
||||
'portForwardingRules',
|
||||
'groupConfigs',
|
||||
] as const;
|
||||
|
||||
40
domain/syncMerge.test.ts
Normal file
40
domain/syncMerge.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { mergeSyncPayloads } from "./syncMerge.ts";
|
||||
import type { SyncPayload } from "./sync.ts";
|
||||
|
||||
function payload(overrides: Partial<SyncPayload> = {}): SyncPayload {
|
||||
return {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
snippetPackages: [],
|
||||
portForwardingRules: [],
|
||||
groupConfigs: [],
|
||||
settings: undefined,
|
||||
syncedAt: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const knownHosts = (n: number): SyncPayload["knownHosts"] =>
|
||||
Array.from({ length: n }, (_, i) => ({
|
||||
id: `kh-${i}`,
|
||||
hostname: `host-${i}.example.com`,
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
fingerprint: `SHA256:${i}`,
|
||||
})) as SyncPayload["knownHosts"];
|
||||
|
||||
test("mergeSyncPayloads does not carry legacy known hosts forward", () => {
|
||||
const result = mergeSyncPayloads(
|
||||
payload({ knownHosts: knownHosts(2) }),
|
||||
payload(),
|
||||
payload({ knownHosts: knownHosts(3) }),
|
||||
);
|
||||
|
||||
assert.equal("knownHosts" in result.payload, false);
|
||||
});
|
||||
@@ -347,7 +347,6 @@ export function mergeSyncPayloads(
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
snippetPackages: [],
|
||||
knownHosts: [],
|
||||
portForwardingRules: [],
|
||||
settings: undefined,
|
||||
syncedAt: 0,
|
||||
@@ -365,19 +364,6 @@ export function mergeSyncPayloads(
|
||||
const keys = mergeEntityArrays(b.keys ?? [], local.keys ?? [], remote.keys ?? []);
|
||||
const identities = mergeEntityArrays(b.identities ?? [], local.identities ?? [], remote.identities ?? []);
|
||||
const snippets = mergeEntityArrays(b.snippets ?? [], local.snippets ?? [], remote.snippets ?? []);
|
||||
const knownHostsRaw = mergeEntityArrays(b.knownHosts ?? [], local.knownHosts ?? [], remote.knownHosts ?? []);
|
||||
// Deduplicate known hosts by (hostname, port, keyType) since IDs are random per device
|
||||
const knownHostSeen = new Set<string>();
|
||||
const knownHosts = {
|
||||
...knownHostsRaw,
|
||||
merged: knownHostsRaw.merged.filter((kh) => {
|
||||
const entry = kh as unknown as { hostname: string; port: number; keyType: string };
|
||||
const fp = `${entry.hostname}:${entry.port}:${entry.keyType}`;
|
||||
if (knownHostSeen.has(fp)) return false;
|
||||
knownHostSeen.add(fp);
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
const portForwardingRules = mergeEntityArrays(
|
||||
b.portForwardingRules ?? [],
|
||||
local.portForwardingRules ?? [],
|
||||
@@ -394,7 +380,7 @@ export function mergeSyncPayloads(
|
||||
|
||||
// Aggregate stats
|
||||
const entityResults: Pick<EntityMergeResult<unknown>, 'added' | 'deleted' | 'modified' | 'conflicts'>[] =
|
||||
[hosts, keys, identities, snippets, knownHosts, portForwardingRules, groupConfigsResult];
|
||||
[hosts, keys, identities, snippets, portForwardingRules, groupConfigsResult];
|
||||
for (const r of entityResults) {
|
||||
summary.added.local += r.added.local;
|
||||
summary.added.remote += r.added.remote;
|
||||
@@ -437,7 +423,6 @@ export function mergeSyncPayloads(
|
||||
snippets: snippets.merged,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
knownHosts: knownHosts.merged,
|
||||
portForwardingRules: portForwardingRules.merged,
|
||||
groupConfigs: unwrapGC(groupConfigsResult.merged),
|
||||
settings,
|
||||
|
||||
48
domain/terminalAppearance.test.ts
Normal file
48
domain/terminalAppearance.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { applyCustomAccentToTerminalTheme } from "./terminalAppearance";
|
||||
import type { TerminalTheme } from "./models";
|
||||
|
||||
const baseTheme: TerminalTheme = {
|
||||
id: "ui-snow",
|
||||
name: "Snow",
|
||||
type: "light",
|
||||
colors: {
|
||||
background: "#f1f4f8",
|
||||
foreground: "#24292f",
|
||||
cursor: "#0969da",
|
||||
selection: "#add6ff",
|
||||
black: "#24292f",
|
||||
red: "#cf222e",
|
||||
green: "#116329",
|
||||
yellow: "#9a6700",
|
||||
blue: "#0969da",
|
||||
magenta: "#8250df",
|
||||
cyan: "#0e7574",
|
||||
white: "#6e7781",
|
||||
brightBlack: "#57606a",
|
||||
brightRed: "#a40e26",
|
||||
brightGreen: "#1a7f37",
|
||||
brightYellow: "#7d4e00",
|
||||
brightBlue: "#218bff",
|
||||
brightMagenta: "#a475f9",
|
||||
brightCyan: "#0c7875",
|
||||
brightWhite: "#8c959f",
|
||||
},
|
||||
};
|
||||
|
||||
test("applies a custom accent to terminal cursor and selection colors", () => {
|
||||
const accented = applyCustomAccentToTerminalTheme(baseTheme, "custom", "160 70% 40%");
|
||||
|
||||
assert.notEqual(accented, baseTheme);
|
||||
assert.equal(accented.colors.cursor, "#1fad7e");
|
||||
assert.equal(accented.colors.selection, "#b1f1dc");
|
||||
assert.equal(baseTheme.colors.cursor, "#0969da");
|
||||
assert.equal(baseTheme.colors.selection, "#add6ff");
|
||||
});
|
||||
|
||||
test("keeps terminal theme unchanged without a valid custom accent", () => {
|
||||
assert.equal(applyCustomAccentToTerminalTheme(baseTheme, "theme", "160 70% 40%"), baseTheme);
|
||||
assert.equal(applyCustomAccentToTerminalTheme(baseTheme, "custom", "not-a-color"), baseTheme);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Host } from './models';
|
||||
import { Host, TerminalTheme } from './models';
|
||||
|
||||
const hasLegacyStringValue = (value: string | undefined): boolean =>
|
||||
typeof value === 'string' && value.trim().length > 0;
|
||||
@@ -69,6 +69,95 @@ const UI_TO_TERMINAL_THEME: Record<string, string> = {
|
||||
export const getTerminalThemeForUiTheme = (uiThemeId: string): string | undefined =>
|
||||
UI_TO_TERMINAL_THEME[uiThemeId];
|
||||
|
||||
type ParsedHslToken = {
|
||||
hue: number;
|
||||
saturation: number;
|
||||
lightness: number;
|
||||
};
|
||||
|
||||
const parseHslToken = (value: string): ParsedHslToken | null => {
|
||||
const match = value.trim().match(/^(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)%\s+(\d+(?:\.\d+)?)%$/);
|
||||
if (!match) return null;
|
||||
|
||||
const hue = Number(match[1]);
|
||||
const saturation = Number(match[2]);
|
||||
const lightness = Number(match[3]);
|
||||
if (!Number.isFinite(hue) || !Number.isFinite(saturation) || !Number.isFinite(lightness)) return null;
|
||||
if (saturation < 0 || saturation > 100 || lightness < 0 || lightness > 100) return null;
|
||||
|
||||
return {
|
||||
hue: ((hue % 360) + 360) % 360,
|
||||
saturation,
|
||||
lightness,
|
||||
};
|
||||
};
|
||||
|
||||
const toHexChannel = (value: number): string =>
|
||||
Math.round(Math.max(0, Math.min(255, value)))
|
||||
.toString(16)
|
||||
.padStart(2, '0');
|
||||
|
||||
const hslToHex = ({ hue, saturation, lightness }: ParsedHslToken): string => {
|
||||
const s = saturation / 100;
|
||||
const l = lightness / 100;
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const hp = hue / 60;
|
||||
const x = c * (1 - Math.abs((hp % 2) - 1));
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
|
||||
if (hp < 1) {
|
||||
r = c;
|
||||
g = x;
|
||||
} else if (hp < 2) {
|
||||
r = x;
|
||||
g = c;
|
||||
} else if (hp < 3) {
|
||||
g = c;
|
||||
b = x;
|
||||
} else if (hp < 4) {
|
||||
g = x;
|
||||
b = c;
|
||||
} else if (hp < 5) {
|
||||
r = x;
|
||||
b = c;
|
||||
} else {
|
||||
r = c;
|
||||
b = x;
|
||||
}
|
||||
|
||||
const m = l - c / 2;
|
||||
return `#${toHexChannel((r + m) * 255)}${toHexChannel((g + m) * 255)}${toHexChannel((b + m) * 255)}`;
|
||||
};
|
||||
|
||||
const terminalSelectionFromAccent = (accent: ParsedHslToken, type: TerminalTheme['type']): ParsedHslToken => ({
|
||||
...accent,
|
||||
lightness: type === 'dark'
|
||||
? Math.max(18, Math.min(32, accent.lightness * 0.55))
|
||||
: Math.max(72, Math.min(88, accent.lightness + 42)),
|
||||
});
|
||||
|
||||
export const applyCustomAccentToTerminalTheme = (
|
||||
theme: TerminalTheme,
|
||||
accentMode: 'theme' | 'custom',
|
||||
customAccent: string,
|
||||
): TerminalTheme => {
|
||||
if (accentMode !== 'custom') return theme;
|
||||
|
||||
const accent = parseHslToken(customAccent);
|
||||
if (!accent) return theme;
|
||||
|
||||
return {
|
||||
...theme,
|
||||
colors: {
|
||||
...theme.colors,
|
||||
cursor: hslToHex(accent),
|
||||
selection: hslToHex(terminalSelectionFromAccent(accent, theme.type)),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveHostTerminalFontFamilyId = (host: Host | null | undefined, defaultFontFamilyId: string): string =>
|
||||
hasHostFontFamilyOverride(host) && host?.fontFamily ? host.fontFamily : defaultFontFamilyId;
|
||||
|
||||
@@ -86,4 +175,3 @@ export const clearHostFontWeightOverride = (host: Host): Host => ({
|
||||
|
||||
export const resolveHostTerminalFontWeight = (host: Host | null | undefined, defaultFontWeight: number): number =>
|
||||
hasHostFontWeightOverride(host) && host?.fontWeight != null ? host.fontWeight : defaultFontWeight;
|
||||
|
||||
|
||||
28
domain/vaultImport.test.ts
Normal file
28
domain/vaultImport.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { importVaultHostsFromText } from "./vaultImport.ts";
|
||||
|
||||
test("ssh_config import maps ForwardX11 yes to host X11 forwarding", () => {
|
||||
const result = importVaultHostsFromText("ssh_config", [
|
||||
"Host x11-host",
|
||||
" HostName x11.example.com",
|
||||
" User root",
|
||||
" ForwardX11 yes",
|
||||
].join("\n"));
|
||||
|
||||
assert.equal(result.hosts.length, 1);
|
||||
assert.equal(result.hosts[0].x11Forwarding, true);
|
||||
});
|
||||
|
||||
test("ssh_config import maps ForwardX11 no to disabled host X11 forwarding", () => {
|
||||
const result = importVaultHostsFromText("ssh_config", [
|
||||
"Host no-x11-host",
|
||||
" HostName no-x11.example.com",
|
||||
" User root",
|
||||
" ForwardX11 no",
|
||||
].join("\n"));
|
||||
|
||||
assert.equal(result.hosts.length, 1);
|
||||
assert.equal(result.hosts[0].x11Forwarding, false);
|
||||
});
|
||||
@@ -526,6 +526,7 @@ const importFromSshConfig = (text: string): VaultImportResult => {
|
||||
port?: number;
|
||||
proxyJump?: string;
|
||||
identityFiles?: string[];
|
||||
forwardX11?: boolean;
|
||||
};
|
||||
|
||||
const blocks: Block[] = [];
|
||||
@@ -564,6 +565,7 @@ const importFromSshConfig = (text: string): VaultImportResult => {
|
||||
else if (keyword === "user") current.username = value;
|
||||
else if (keyword === "port") current.port = parsePort(value);
|
||||
else if (keyword === "proxyjump") current.proxyJump = value;
|
||||
else if (keyword === "forwardx11") current.forwardX11 = value.toLowerCase() === "yes";
|
||||
else if (keyword === "identityfile") {
|
||||
if (!current.identityFiles) current.identityFiles = [];
|
||||
// Remove surrounding quotes (ssh_config allows quoted paths with spaces)
|
||||
@@ -614,6 +616,9 @@ const importFromSshConfig = (text: string): VaultImportResult => {
|
||||
if (block.identityFiles && block.identityFiles.length > 0) {
|
||||
host.identityFilePaths = [...block.identityFiles];
|
||||
}
|
||||
if (block.forwardX11 !== undefined) {
|
||||
host.x11Forwarding = block.forwardX11;
|
||||
}
|
||||
|
||||
parsedHosts.push(host);
|
||||
|
||||
@@ -1092,4 +1097,3 @@ export const exportHostsToCsvWithStats = (hosts: Host[]): ExportHostsResult => {
|
||||
skippedCount: skippedHosts.length,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
const { moshExtraResources } = require('./scripts/mosh-extra-resources.cjs');
|
||||
|
||||
/**
|
||||
* @type {import('electron-builder').Configuration}
|
||||
*/
|
||||
@@ -79,7 +81,8 @@ module.exports = {
|
||||
NSCameraUsageDescription: 'Netcatty may use the camera for video calls',
|
||||
NSMicrophoneUsageDescription: 'Netcatty may use the microphone for audio',
|
||||
NSLocalNetworkUsageDescription: 'Netcatty needs local network access for SSH connections'
|
||||
}
|
||||
},
|
||||
extraResources: moshExtraResources('darwin')
|
||||
},
|
||||
dmg: {
|
||||
title: '${productName}',
|
||||
@@ -105,7 +108,8 @@ module.exports = {
|
||||
target: 'portable',
|
||||
arch: ['x64', 'arm64']
|
||||
}
|
||||
]
|
||||
],
|
||||
extraResources: moshExtraResources('win32')
|
||||
},
|
||||
portable: {
|
||||
artifactName: '${productName}-${version}-portable-${os}-${arch}.${ext}',
|
||||
@@ -125,7 +129,8 @@ module.exports = {
|
||||
// GNOME launchers or AppImage integrations.
|
||||
icon: 'public/icon-win.png',
|
||||
target: ['AppImage', 'deb', 'rpm'],
|
||||
category: 'Development'
|
||||
category: 'Development',
|
||||
extraResources: moshExtraResources('linux')
|
||||
},
|
||||
deb: {
|
||||
// Use gzip instead of default xz(lzma) for better compatibility with
|
||||
|
||||
130
electron/bridges/ai/acpModels.cjs
Normal file
130
electron/bridges/ai/acpModels.cjs
Normal file
@@ -0,0 +1,130 @@
|
||||
function toNonEmptyString(value) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function normalizeConfigOptionValue(value) {
|
||||
const id = toNonEmptyString(value?.value ?? value?.id);
|
||||
if (!id) return null;
|
||||
return {
|
||||
id,
|
||||
name: toNonEmptyString(value?.name ?? value?.displayName) || id,
|
||||
description: toNonEmptyString(value?.description) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function flattenConfigOptionValues(values) {
|
||||
if (!Array.isArray(values)) return [];
|
||||
const flattened = [];
|
||||
for (const value of values) {
|
||||
const nestedValues = Array.isArray(value?.options)
|
||||
? value.options
|
||||
: Array.isArray(value?.items)
|
||||
? value.items
|
||||
: Array.isArray(value?.children)
|
||||
? value.children
|
||||
: null;
|
||||
if (nestedValues) {
|
||||
flattened.push(...flattenConfigOptionValues(nestedValues));
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeConfigOptionValue(value);
|
||||
if (normalized) {
|
||||
flattened.push(normalized);
|
||||
}
|
||||
}
|
||||
return flattened;
|
||||
}
|
||||
|
||||
function findConfigOption(configOptions, category, fallbackIds = []) {
|
||||
if (!Array.isArray(configOptions)) return null;
|
||||
return configOptions.find((option) => {
|
||||
const optionCategory = toNonEmptyString(option?.category);
|
||||
const optionId = toNonEmptyString(option?.id);
|
||||
return optionCategory === category || (optionId && fallbackIds.includes(optionId));
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function normalizeConfigOptionsModels(sessionInfo) {
|
||||
const configOptions = Array.isArray(sessionInfo?.configOptions)
|
||||
? sessionInfo.configOptions
|
||||
: [];
|
||||
const modelOption = findConfigOption(configOptions, "model", ["model"]);
|
||||
const reasoningOption = findConfigOption(configOptions, "thought_level", [
|
||||
"reasoning_effort",
|
||||
"reasoning",
|
||||
"thought_level",
|
||||
]);
|
||||
|
||||
const modelValues = flattenConfigOptionValues(modelOption?.options);
|
||||
if (modelValues.length === 0) return null;
|
||||
|
||||
const configuredThinkingLevels = flattenConfigOptionValues(reasoningOption?.options)
|
||||
.map((option) => option.id);
|
||||
const availableModelIds = Array.isArray(sessionInfo?.models?.availableModels)
|
||||
? sessionInfo.models.availableModels
|
||||
.map((modelInfo) => toNonEmptyString(modelInfo?.modelId ?? modelInfo?.id))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const availableModelIdSet = new Set(availableModelIds);
|
||||
const thinkingLevelsByModelId = new Map();
|
||||
for (const model of modelValues) {
|
||||
const validThinkingLevels = configuredThinkingLevels.length > 0
|
||||
? configuredThinkingLevels.filter((level) => availableModelIdSet.has(`${model.id}/${level}`))
|
||||
: availableModelIds
|
||||
.filter((modelId) => modelId.startsWith(`${model.id}/`))
|
||||
.map((modelId) => modelId.slice(model.id.length + 1))
|
||||
.filter(Boolean);
|
||||
if (validThinkingLevels.length > 0) {
|
||||
thinkingLevelsByModelId.set(model.id, validThinkingLevels);
|
||||
}
|
||||
}
|
||||
|
||||
const currentFromModels = toNonEmptyString(sessionInfo?.models?.currentModelId);
|
||||
const currentModel = toNonEmptyString(modelOption?.currentValue);
|
||||
const currentThinking = toNonEmptyString(reasoningOption?.currentValue);
|
||||
let currentModelId = currentFromModels;
|
||||
if (currentModel) {
|
||||
if (currentThinking && availableModelIdSet.has(`${currentModel}/${currentThinking}`)) {
|
||||
currentModelId = `${currentModel}/${currentThinking}`;
|
||||
} else if (!currentModelId || (currentModelId !== currentModel && !currentModelId.startsWith(`${currentModel}/`))) {
|
||||
currentModelId = currentModel;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentModelId: currentModelId || null,
|
||||
models: modelValues.map((model) => {
|
||||
const modelThinkingLevels = thinkingLevelsByModelId.get(model.id);
|
||||
return {
|
||||
...model,
|
||||
...(modelThinkingLevels ? { thinkingLevels: modelThinkingLevels } : {}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLegacySessionModels(sessionInfo) {
|
||||
const availableModels = Array.isArray(sessionInfo?.models?.availableModels)
|
||||
? sessionInfo.models.availableModels
|
||||
: [];
|
||||
return {
|
||||
currentModelId: toNonEmptyString(sessionInfo?.models?.currentModelId),
|
||||
models: availableModels.map((modelInfo) => {
|
||||
const id = toNonEmptyString(modelInfo?.modelId ?? modelInfo?.id);
|
||||
if (!id) return null;
|
||||
return {
|
||||
id,
|
||||
name: toNonEmptyString(modelInfo?.name ?? modelInfo?.displayName) || id,
|
||||
description: toNonEmptyString(modelInfo?.description) || undefined,
|
||||
};
|
||||
}).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAcpSessionModels(sessionInfo) {
|
||||
return normalizeConfigOptionsModels(sessionInfo) || normalizeLegacySessionModels(sessionInfo);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeAcpSessionModels,
|
||||
};
|
||||
158
electron/bridges/ai/acpModels.test.cjs
Normal file
158
electron/bridges/ai/acpModels.test.cjs
Normal file
@@ -0,0 +1,158 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { normalizeAcpSessionModels } = require("./acpModels.cjs");
|
||||
|
||||
test("normalizeAcpSessionModels uses ACP config options for model and reasoning selectors", () => {
|
||||
const result = normalizeAcpSessionModels({
|
||||
models: {
|
||||
currentModelId: "gpt-5.5/xhigh",
|
||||
availableModels: [
|
||||
{ modelId: "gpt-5.5/low", name: "GPT 5.5 Low" },
|
||||
{ modelId: "gpt-5.5/medium", name: "GPT 5.5 Medium" },
|
||||
{ modelId: "gpt-5.5/high", name: "GPT 5.5 High" },
|
||||
{ modelId: "gpt-5.5/xhigh", name: "GPT 5.5 Extra High" },
|
||||
{ modelId: "gpt-5.1-codex-mini/medium", name: "Codex Mini Medium" },
|
||||
{ modelId: "gpt-5.1-codex-mini/high", name: "Codex Mini High" },
|
||||
],
|
||||
},
|
||||
configOptions: [
|
||||
{
|
||||
id: "model",
|
||||
category: "model",
|
||||
currentValue: "gpt-5.5",
|
||||
options: [
|
||||
{ value: "gpt-5.5", name: "GPT 5.5" },
|
||||
{ value: "gpt-5.1-codex-mini", name: "Codex Mini", description: "Fast" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "reasoning_effort",
|
||||
category: "thought_level",
|
||||
currentValue: "xhigh",
|
||||
options: [
|
||||
{ value: "low", name: "Low" },
|
||||
{ value: "medium", name: "Medium" },
|
||||
{ value: "high", name: "High" },
|
||||
{ value: "xhigh", name: "Extra High" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(result.currentModelId, "gpt-5.5/xhigh");
|
||||
assert.deepEqual(result.models, [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT 5.5",
|
||||
description: undefined,
|
||||
thinkingLevels: ["low", "medium", "high", "xhigh"],
|
||||
},
|
||||
{
|
||||
id: "gpt-5.1-codex-mini",
|
||||
name: "Codex Mini",
|
||||
description: "Fast",
|
||||
thinkingLevels: ["medium", "high"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("normalizeAcpSessionModels flattens grouped ACP config option values", () => {
|
||||
const result = normalizeAcpSessionModels({
|
||||
models: {
|
||||
currentModelId: "gpt-5.4/high",
|
||||
availableModels: [
|
||||
{ modelId: "gpt-5.4/high", name: "GPT 5.4 High" },
|
||||
],
|
||||
},
|
||||
configOptions: [
|
||||
{
|
||||
id: "model",
|
||||
category: "model",
|
||||
currentValue: "gpt-5.4",
|
||||
options: [
|
||||
{
|
||||
name: "Frontier",
|
||||
options: [
|
||||
{ value: "gpt-5.4", name: "GPT 5.4" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "reasoning_effort",
|
||||
category: "thought_level",
|
||||
currentValue: "high",
|
||||
options: [
|
||||
{
|
||||
name: "Reasoning",
|
||||
options: [
|
||||
{ value: "low", name: "Low" },
|
||||
{ value: "high", name: "High" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(result.currentModelId, "gpt-5.4/high");
|
||||
assert.deepEqual(result.models, [
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
name: "GPT 5.4",
|
||||
description: undefined,
|
||||
thinkingLevels: ["high"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("normalizeAcpSessionModels infers thinking levels from available model ids", () => {
|
||||
const result = normalizeAcpSessionModels({
|
||||
models: {
|
||||
currentModelId: "gpt-5.4/high",
|
||||
availableModels: [
|
||||
{ modelId: "gpt-5.4/low", name: "GPT 5.4 Low" },
|
||||
{ modelId: "gpt-5.4/high", name: "GPT 5.4 High" },
|
||||
],
|
||||
},
|
||||
configOptions: [
|
||||
{
|
||||
id: "model",
|
||||
category: "model",
|
||||
currentValue: "gpt-5.4",
|
||||
options: [
|
||||
{ value: "gpt-5.4", name: "GPT 5.4" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(result.currentModelId, "gpt-5.4/high");
|
||||
assert.deepEqual(result.models, [
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
name: "GPT 5.4",
|
||||
description: undefined,
|
||||
thinkingLevels: ["low", "high"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("normalizeAcpSessionModels falls back to legacy ACP models when config options are absent", () => {
|
||||
const result = normalizeAcpSessionModels({
|
||||
models: {
|
||||
currentModelId: "claude-opus-4-5",
|
||||
availableModels: [
|
||||
{ modelId: "claude-opus-4-5", displayName: "Opus 4.5" },
|
||||
{ modelId: "claude-sonnet-4-5", name: "Sonnet 4.5" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.currentModelId, "claude-opus-4-5");
|
||||
assert.deepEqual(result.models, [
|
||||
{ id: "claude-opus-4-5", name: "Opus 4.5", description: undefined },
|
||||
{ id: "claude-sonnet-4-5", name: "Sonnet 4.5", description: undefined },
|
||||
]);
|
||||
});
|
||||
@@ -353,16 +353,57 @@ function normalizeCodexIntegrationState(rawOutput) {
|
||||
|
||||
// ── Error helpers ──
|
||||
|
||||
function safeJsonStringify(value) {
|
||||
const seen = new WeakSet();
|
||||
try {
|
||||
return JSON.stringify(value, (_key, nestedValue) => {
|
||||
if (typeof nestedValue !== "object" || nestedValue === null) {
|
||||
return nestedValue;
|
||||
}
|
||||
if (seen.has(nestedValue)) {
|
||||
return "[Circular]";
|
||||
}
|
||||
seen.add(nestedValue);
|
||||
return nestedValue;
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyErrorValue(value, seen = new WeakSet()) {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
if (value instanceof Error) return value.message || value.name || String(value);
|
||||
if (typeof value !== "object") return String(value);
|
||||
if (seen.has(value)) return "[Circular error]";
|
||||
seen.add(value);
|
||||
|
||||
const candidates = [
|
||||
value?.data?.message,
|
||||
value?.data?.error,
|
||||
value?.errorText,
|
||||
value?.message,
|
||||
value?.error,
|
||||
value?.cause,
|
||||
value?.data,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const message = stringifyErrorValue(candidate, seen).trim();
|
||||
if (message && message !== "{}") {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
return safeJsonStringify(value) || String(value);
|
||||
}
|
||||
|
||||
function extractCodexError(error) {
|
||||
const message =
|
||||
error?.data?.message ||
|
||||
error?.errorText ||
|
||||
error?.message ||
|
||||
error?.error ||
|
||||
String(error);
|
||||
const code = error?.data?.code || error?.code;
|
||||
const message = stringifyErrorValue(error) || "Unknown Codex error";
|
||||
const code = error?.data?.code || error?.code || error?.error?.code || error?.data?.error?.code;
|
||||
return {
|
||||
message: typeof message === "string" ? message : String(message),
|
||||
message,
|
||||
code: typeof code === "string" ? code : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
37
electron/bridges/ai/codexHelpers.test.cjs
Normal file
37
electron/bridges/ai/codexHelpers.test.cjs
Normal file
@@ -0,0 +1,37 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { extractCodexError } = require("./codexHelpers.cjs");
|
||||
|
||||
test("extractCodexError preserves nested error object messages", () => {
|
||||
const normalized = extractCodexError({
|
||||
error: {
|
||||
code: "model_not_found",
|
||||
message: "Model gpt-test is not available",
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(normalized, {
|
||||
message: "Model gpt-test is not available",
|
||||
code: "model_not_found",
|
||||
});
|
||||
});
|
||||
|
||||
test("extractCodexError stringifies unknown object errors instead of [object Object]", () => {
|
||||
const normalized = extractCodexError({
|
||||
status: 400,
|
||||
detail: "Bad request",
|
||||
});
|
||||
|
||||
assert.equal(normalized.message, '{"status":400,"detail":"Bad request"}');
|
||||
assert.equal(normalized.code, undefined);
|
||||
});
|
||||
|
||||
test("extractCodexError handles circular structured errors", () => {
|
||||
const error = { status: 500 };
|
||||
error.self = error;
|
||||
|
||||
const normalized = extractCodexError(error);
|
||||
|
||||
assert.equal(normalized.message, '{"status":500,"self":"[Circular]"}');
|
||||
});
|
||||
@@ -12,7 +12,7 @@
|
||||
const crypto = require("crypto");
|
||||
const { StringDecoder } = require("node:string_decoder");
|
||||
const iconv = require("iconv-lite");
|
||||
const { stripAnsi } = require("./shellUtils.cjs");
|
||||
const { stripAnsi, isDefaultPowerShellPromptLine } = require("./shellUtils.cjs");
|
||||
const { classifyLocalShellType } = require("../../../lib/localShell.cjs");
|
||||
|
||||
// Build a stateful decoder for a full exec call. Serial data events can
|
||||
@@ -86,6 +86,54 @@ function escapeCmdForNestedShell(text) {
|
||||
return String(text || "").replace(/"/g, '""').replace(/%/g, "%%");
|
||||
}
|
||||
|
||||
// Matches PowerShell's default prompt only (e.g. `PS C:\Users\alice>`,
|
||||
// `PS>`). Custom prompt functions (oh-my-posh, starship, PSReadLine themes
|
||||
// that emit `❯`/`λ`/etc.) intentionally fall through — we'd rather miss
|
||||
// the override than wrap a fish/zsh prompt as PowerShell. Pattern lives
|
||||
// in shellUtils.cjs so prompt extraction and wrapper selection share one
|
||||
// source of truth.
|
||||
function isPowerShellPrompt(prompt) {
|
||||
// Treat `\r` as a line break too so a PSReadLine/ConPTY redraw like
|
||||
// `PS C:\old>\rPS C:\new>` is matched against the redrawn last line,
|
||||
// not the doubled string.
|
||||
const lastLine = stripAnsi(String(prompt || ""))
|
||||
.replace(/\r/g, "\n")
|
||||
.split("\n")
|
||||
.pop()
|
||||
.replace(/\s+$/, "");
|
||||
return isDefaultPowerShellPromptLine(lastLine);
|
||||
}
|
||||
|
||||
// Prompt-driven override is intentionally narrow: only flip to PowerShell
|
||||
// when the session has no confirmed shell type. This keeps the issue #841
|
||||
// fix working (SSH/Telnet sessions never set shellKind — see
|
||||
// sshBridge.cjs:1265) while preventing a malicious remote process from
|
||||
// spoofing a `PS ...>` line on a real bash/zsh/fish/cmd session to coerce
|
||||
// a single mis-wrapped command.
|
||||
//
|
||||
// Universe of shellKind values (see lib/localShell.cjs:23-33 and
|
||||
// terminalBridge.cjs:368, :932, :1074):
|
||||
// "posix" | "powershell" | "cmd" | "fish" | "unknown" | "raw" | "" | undefined
|
||||
// Excluded on purpose:
|
||||
// - "posix" / "fish" / "cmd": confirmed POSIX-family or cmd.exe — never override.
|
||||
// - "powershell": already correct; no override needed (would be a no-op).
|
||||
// - "raw": serial / network device — execViaRawPty bypasses buildWrappedCommand.
|
||||
const SHELL_KINDS_OPEN_TO_PROMPT_OVERRIDE = new Set([
|
||||
"",
|
||||
"unknown",
|
||||
]);
|
||||
|
||||
function resolveEffectiveShellKind(shellKind, expectedPrompt) {
|
||||
const baseKind = shellKind || "";
|
||||
if (
|
||||
SHELL_KINDS_OPEN_TO_PROMPT_OVERRIDE.has(baseKind) &&
|
||||
isPowerShellPrompt(expectedPrompt)
|
||||
) {
|
||||
return "powershell";
|
||||
}
|
||||
return baseKind || "posix";
|
||||
}
|
||||
|
||||
function buildWrappedCommand(command, shellKind, marker) {
|
||||
switch (shellKind) {
|
||||
case "powershell": {
|
||||
@@ -305,7 +353,7 @@ function startPtyJob(ptyStream, command, options) {
|
||||
} = options || {};
|
||||
|
||||
const marker = `__NCMCP_${Date.now().toString(36)}_${crypto.randomBytes(16).toString('hex')}__`;
|
||||
const resolvedShellKind = shellKind || "posix";
|
||||
const resolvedShellKind = resolveEffectiveShellKind(shellKind, expectedPrompt);
|
||||
const CANCEL_RETRY_MS = 5000;
|
||||
const CANCEL_WALL_TIMEOUT_MS = 30000;
|
||||
|
||||
@@ -1133,5 +1181,6 @@ module.exports = {
|
||||
execViaChannel,
|
||||
execViaRawPty,
|
||||
detectShellKind,
|
||||
resolveEffectiveShellKind,
|
||||
stripAnsi,
|
||||
};
|
||||
|
||||
109
electron/bridges/ai/ptyExec.test.cjs
Normal file
109
electron/bridges/ai/ptyExec.test.cjs
Normal file
@@ -0,0 +1,109 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
resolveEffectiveShellKind,
|
||||
} = require("./ptyExec.cjs");
|
||||
|
||||
test("uses PowerShell wrapping when a session with no confirmed shell sees a PowerShell prompt", () => {
|
||||
// SSH sessions don't set shellKind (sshBridge never assigns one), which
|
||||
// is exactly the issue #841 case the override targets.
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind(undefined, "PS C:\\Users\\alice>"),
|
||||
"powershell",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses PowerShell wrapping when shellKind is 'unknown'", () => {
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind("unknown", "PS C:\\Users\\alice>"),
|
||||
"powershell",
|
||||
);
|
||||
});
|
||||
|
||||
test("does NOT override an explicit non-PowerShell shell kind even if the prompt looks like PowerShell", () => {
|
||||
// Defends against a malicious remote process spoofing a `PS ...>` line
|
||||
// on a real bash/zsh/cmd/fish/raw session to coerce a single
|
||||
// mis-wrapped command.
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind("posix", "PS C:\\Users\\alice>"),
|
||||
"posix",
|
||||
);
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind("fish", "PS C:\\Users\\alice>"),
|
||||
"fish",
|
||||
);
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind("cmd", "PS C:\\Users\\alice>"),
|
||||
"cmd",
|
||||
);
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind("raw", "PS C:\\Users\\alice>"),
|
||||
"raw",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps powershell wrapping for an explicit powershell session even when nested into a non-PS shell", () => {
|
||||
// After `wsl` or similar, a confirmed PowerShell session may show a
|
||||
// posix prompt. We currently keep PowerShell wrapping (the user's
|
||||
// configured shell is the source of truth). Reverse detection would
|
||||
// be a separate feature; this test locks the current behavior so a
|
||||
// future change is intentional.
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind("powershell", "alice@host:~$"),
|
||||
"powershell",
|
||||
);
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind("powershell", ""),
|
||||
"powershell",
|
||||
);
|
||||
});
|
||||
|
||||
test("recognizes a PowerShell prompt that has trailing whitespace", () => {
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind(undefined, "PS C:\\Users\\alice> "),
|
||||
"powershell",
|
||||
);
|
||||
});
|
||||
|
||||
test("recognizes a bare PowerShell prompt without a working directory", () => {
|
||||
assert.equal(resolveEffectiveShellKind(undefined, "PS>"), "powershell");
|
||||
});
|
||||
|
||||
test("recognizes PowerShell on Linux/macOS prompts (`PS /home/alice>`)", () => {
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind(undefined, "PS /home/alice>"),
|
||||
"powershell",
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores ANSI-coloured PowerShell prompts when detecting the shell", () => {
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind(undefined, "[32mPS C:\\Users\\alice>[0m"),
|
||||
"powershell",
|
||||
);
|
||||
});
|
||||
|
||||
test("treats a CR-redrawn last line as the effective prompt, not the doubled string", () => {
|
||||
// PSReadLine / ConPTY emit `\r` to repaint the current line. Without
|
||||
// CR-as-newline normalization the regex would match a doubled prompt
|
||||
// string that never round-trips through the live PTY tail.
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind(undefined, "PS C:\\old>\rPS C:\\new>"),
|
||||
"powershell",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects spoofed `PS >` (literal space then `>`) — default PowerShell never emits this", () => {
|
||||
assert.equal(resolveEffectiveShellKind(undefined, "PS >"), "posix");
|
||||
});
|
||||
|
||||
test("falls back to posix when neither shell kind nor prompt is informative", () => {
|
||||
assert.equal(resolveEffectiveShellKind(undefined, ""), "posix");
|
||||
assert.equal(resolveEffectiveShellKind(null, undefined), "posix");
|
||||
});
|
||||
|
||||
test("does not misclassify command output that happens to contain 'PS'", () => {
|
||||
assert.equal(resolveEffectiveShellKind(undefined, "PSO>"), "posix");
|
||||
assert.equal(resolveEffectiveShellKind(undefined, "ZIPS>"), "posix");
|
||||
});
|
||||
@@ -24,14 +24,33 @@ function stripAnsi(input) {
|
||||
return String(input || "").replace(ANSI_OSC_REGEX, "").replace(ANSI_ESCAPE_REGEX, "");
|
||||
}
|
||||
|
||||
// Default PowerShell prompt (e.g. `PS C:\Users\alice>`, `PS>`,
|
||||
// `PS /home/alice>`). Anchored so command output that merely starts with
|
||||
// `PS` (e.g. `PSO>`) doesn't match. The `\S` after `\s+` rejects literal
|
||||
// `"PS >"` (which the default prompt never emits) so a script that prints
|
||||
// such a line can't trick prompt-driven shell-kind selection.
|
||||
const POWERSHELL_PROMPT_PATTERN = /^PS(?:\s+\S.*)?>$/;
|
||||
|
||||
function isDefaultPowerShellPromptLine(line) {
|
||||
return POWERSHELL_PROMPT_PATTERN.test(String(line || ""));
|
||||
}
|
||||
|
||||
function extractTrailingIdlePrompt(output) {
|
||||
const normalized = stripAnsi(output).replace(/\r/g, "");
|
||||
// Treat `\r` as a line break, not as a stripped character: PSReadLine /
|
||||
// ConPTY repaints emit bare `\r` to redraw the current line, and we
|
||||
// want only the redrawn line to be considered, not the concatenation
|
||||
// of every overwritten frame.
|
||||
const normalized = stripAnsi(output).replace(/\r/g, "\n");
|
||||
if (!normalized || normalized.endsWith("\n")) return "";
|
||||
|
||||
const lastLine = normalized.split("\n").pop() || "";
|
||||
const rightTrimmed = lastLine.replace(/\s+$/, "");
|
||||
if (!rightTrimmed) return "";
|
||||
|
||||
if (isDefaultPowerShellPromptLine(rightTrimmed)) {
|
||||
return lastLine;
|
||||
}
|
||||
|
||||
if (/^[^\s@]+@[^\s:]+(?::[^\n\r]*)?[#$]$/.test(rightTrimmed)) {
|
||||
return lastLine;
|
||||
}
|
||||
@@ -54,6 +73,32 @@ function trackSessionIdlePrompt(session, chunk) {
|
||||
return prompt;
|
||||
}
|
||||
|
||||
// Return `session.lastIdlePrompt` only if the PTY's recent rolling tail
|
||||
// still ends with it. The cached prompt is updated only when
|
||||
// extractTrailingIdlePrompt recognizes a known shape (PowerShell or
|
||||
// `user@host[:path][#$]`); a remote shell switch into cmd.exe, an
|
||||
// oh-my-posh / starship / custom PS1, or any unrecognized prompt would
|
||||
// otherwise leave a stale value behind, which `resolveEffectiveShellKind`
|
||||
// would then keep using to coerce future commands into a PowerShell
|
||||
// wrapper. By re-checking the live tail we self-correct: if the visible
|
||||
// last line no longer matches the cached prompt, the prompt is treated
|
||||
// as expired and downstream wrapper selection / suffix matching falls
|
||||
// back to `shellKind` alone.
|
||||
function getFreshIdlePrompt(session) {
|
||||
if (!session) return "";
|
||||
const cached = session.lastIdlePrompt;
|
||||
if (!cached) return "";
|
||||
|
||||
const tail = session._promptTrackTail;
|
||||
if (typeof tail !== "string" || !tail) return "";
|
||||
|
||||
const normalizedTail = stripAnsi(tail).replace(/\r/g, "\n");
|
||||
const normalizedCached = stripAnsi(cached).replace(/\r/g, "\n");
|
||||
if (!normalizedCached) return "";
|
||||
|
||||
return normalizedTail.endsWith(normalizedCached) ? cached : "";
|
||||
}
|
||||
|
||||
// ── URL helpers ──
|
||||
|
||||
function isLocalhostHostname(hostname) {
|
||||
@@ -157,6 +202,15 @@ function toUnpackedAsarPath(filePath) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function isPlausibleCliVersionOutput(value) {
|
||||
const line = stripAnsi(String(value || "")).trim().split(/\r?\n/)[0]?.trim() || "";
|
||||
if (!line) return false;
|
||||
if (/^(?:file|node):\/\//i.test(line)) return false;
|
||||
if (/^\s*at\s+/i.test(line)) return false;
|
||||
if (/\b(?:Error|TypeError|ReferenceError|SyntaxError|ERR_[A-Z_]+)\b/.test(line)) return false;
|
||||
return /(?:^|[^\d])v?\d+(?:\.\d+){1,3}(?:[-+][0-9A-Za-z.-]+)?(?:$|[^\d])/.test(line);
|
||||
}
|
||||
|
||||
// ── Shell environment (cached) ──
|
||||
|
||||
let _cachedShellEnv = null;
|
||||
@@ -319,6 +373,8 @@ function serializeStreamChunk(chunk) {
|
||||
module.exports = {
|
||||
stripAnsi,
|
||||
extractTrailingIdlePrompt,
|
||||
getFreshIdlePrompt,
|
||||
isDefaultPowerShellPromptLine,
|
||||
trackSessionIdlePrompt,
|
||||
isLocalhostHostname,
|
||||
extractFirstNonLocalhostUrl,
|
||||
@@ -327,6 +383,7 @@ module.exports = {
|
||||
resolveCliFromPath,
|
||||
resolveClaudeAcpBinaryPath,
|
||||
toUnpackedAsarPath,
|
||||
isPlausibleCliVersionOutput,
|
||||
getShellEnv,
|
||||
invalidateShellEnvCache,
|
||||
serializeStreamChunk,
|
||||
|
||||
151
electron/bridges/ai/shellUtils.test.cjs
Normal file
151
electron/bridges/ai/shellUtils.test.cjs
Normal file
@@ -0,0 +1,151 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
extractTrailingIdlePrompt,
|
||||
getFreshIdlePrompt,
|
||||
isDefaultPowerShellPromptLine,
|
||||
isPlausibleCliVersionOutput,
|
||||
trackSessionIdlePrompt,
|
||||
} = require("./shellUtils.cjs");
|
||||
|
||||
test("extracts a trailing PowerShell idle prompt", () => {
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("Microsoft Windows...\r\nPS C:\\Users\\alice>"),
|
||||
"PS C:\\Users\\alice>",
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves trailing whitespace on a captured PowerShell prompt", () => {
|
||||
// The wrapper-selection logic trims this, but the suffix-match logic in
|
||||
// hasExpectedPromptSuffix() compares against raw PTY bytes, so the trailing
|
||||
// space PowerShell emits after `>` must round-trip unchanged.
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("Microsoft Windows...\r\nPS C:\\Users\\alice> "),
|
||||
"PS C:\\Users\\alice> ",
|
||||
);
|
||||
});
|
||||
|
||||
test("extracts a bare PowerShell prompt with no working directory", () => {
|
||||
assert.equal(extractTrailingIdlePrompt("welcome\r\nPS>"), "PS>");
|
||||
});
|
||||
|
||||
test("does not extract content that merely looks PowerShell-ish", () => {
|
||||
// Any non-prompt output ending in `PSO>` or `ZIPS>` would have produced a
|
||||
// trailing newline before the next prompt; this guards against the regex
|
||||
// accidentally matching command output that just happens to contain "PS".
|
||||
assert.equal(extractTrailingIdlePrompt("nope\r\nPSO>"), "");
|
||||
assert.equal(extractTrailingIdlePrompt("nope\r\nZIPS>"), "");
|
||||
});
|
||||
|
||||
test("rejects `PS >` (literal `PS` + space + `>`) so spoofed scripts can't masquerade as a default prompt", () => {
|
||||
// Default PowerShell never emits this shape; rejecting it makes the
|
||||
// override harder to coerce via printed output.
|
||||
assert.equal(extractTrailingIdlePrompt("welcome\r\nPS >"), "");
|
||||
});
|
||||
|
||||
test("treats CR repaints as line breaks so only the redrawn line is captured", () => {
|
||||
// PSReadLine / ConPTY emit bare `\r` to repaint the current line. The
|
||||
// captured prompt must equal the visible last line, not the
|
||||
// concatenation of every overwritten frame, so hasExpectedPromptSuffix
|
||||
// can still match the live PTY tail later.
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("PS C:\\old>\rPS C:\\new>"),
|
||||
"PS C:\\new>",
|
||||
);
|
||||
});
|
||||
|
||||
test("isDefaultPowerShellPromptLine matches default shapes and rejects look-alikes", () => {
|
||||
assert.equal(isDefaultPowerShellPromptLine("PS C:\\Users\\alice>"), true);
|
||||
assert.equal(isDefaultPowerShellPromptLine("PS /home/alice>"), true);
|
||||
assert.equal(isDefaultPowerShellPromptLine("PS>"), true);
|
||||
assert.equal(isDefaultPowerShellPromptLine("PS >"), false);
|
||||
assert.equal(isDefaultPowerShellPromptLine("PSO>"), false);
|
||||
assert.equal(isDefaultPowerShellPromptLine("ZIPS>"), false);
|
||||
assert.equal(isDefaultPowerShellPromptLine(""), false);
|
||||
assert.equal(isDefaultPowerShellPromptLine(null), false);
|
||||
});
|
||||
|
||||
test("isPlausibleCliVersionOutput rejects stack traces and file URLs", () => {
|
||||
assert.equal(isPlausibleCliVersionOutput("2.1.123 (Claude Code)"), true);
|
||||
assert.equal(isPlausibleCliVersionOutput("codex-cli 0.125.0"), true);
|
||||
assert.equal(isPlausibleCliVersionOutput("file:///opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js:95"), false);
|
||||
assert.equal(isPlausibleCliVersionOutput("TypeError: Cannot read properties of undefined"), false);
|
||||
assert.equal(isPlausibleCliVersionOutput(" at runCli (cli.js:10:1)"), false);
|
||||
assert.equal(isPlausibleCliVersionOutput("permission denied"), false);
|
||||
assert.equal(isPlausibleCliVersionOutput("Usage: claude [options]"), false);
|
||||
});
|
||||
|
||||
test("tracks PowerShell idle prompt after SSH output", () => {
|
||||
const session = {};
|
||||
|
||||
const prompt = trackSessionIdlePrompt(session, "Last login...\r\nPS C:\\Windows\\System32>");
|
||||
|
||||
assert.equal(prompt, "PS C:\\Windows\\System32>");
|
||||
assert.equal(session.lastIdlePrompt, "PS C:\\Windows\\System32>");
|
||||
assert.equal(typeof session.lastIdlePromptAt, "number");
|
||||
});
|
||||
|
||||
test("getFreshIdlePrompt returns the cached prompt when the live tail still ends with it", () => {
|
||||
const session = {
|
||||
lastIdlePrompt: "PS C:\\Users\\alice>",
|
||||
_promptTrackTail: "Microsoft Windows...\r\nPS C:\\Users\\alice>",
|
||||
};
|
||||
assert.equal(getFreshIdlePrompt(session), "PS C:\\Users\\alice>");
|
||||
});
|
||||
|
||||
test("getFreshIdlePrompt drops a stale prompt when the live tail has moved on (e.g. exited PowerShell)", () => {
|
||||
// Simulates: SSH session entered PowerShell, captured `PS C:\>`, then
|
||||
// user `exit`-ed back into a shell with a custom prompt the regex
|
||||
// doesn't recognize. lastIdlePrompt is still the old PS line, but the
|
||||
// visible tail now shows the new prompt — we must NOT keep handing
|
||||
// the stale value to resolveEffectiveShellKind.
|
||||
const session = {
|
||||
lastIdlePrompt: "PS C:\\Users\\alice>",
|
||||
_promptTrackTail: "PS C:\\Users\\alice>\r\nexit\r\nlogout\r\n❯ ",
|
||||
};
|
||||
assert.equal(getFreshIdlePrompt(session), "");
|
||||
});
|
||||
|
||||
test("getFreshIdlePrompt drops a stale prompt when the live tail switched to cmd.exe", () => {
|
||||
const session = {
|
||||
lastIdlePrompt: "PS C:\\Users\\alice>",
|
||||
_promptTrackTail: "PS C:\\Users\\alice>\r\ncmd\r\nMicrosoft Windows...\r\nC:\\Users\\alice>",
|
||||
};
|
||||
assert.equal(getFreshIdlePrompt(session), "");
|
||||
});
|
||||
|
||||
test("getFreshIdlePrompt tolerates ANSI colour codes that wrap the prompt in either side", () => {
|
||||
const session = {
|
||||
lastIdlePrompt: "PS C:\\Users\\alice>",
|
||||
_promptTrackTail: "stuff\r\n[32mPS C:\\Users\\alice>[0m",
|
||||
};
|
||||
assert.equal(getFreshIdlePrompt(session), "PS C:\\Users\\alice>");
|
||||
});
|
||||
|
||||
test("getFreshIdlePrompt returns empty string when the session has no cached prompt or tail", () => {
|
||||
assert.equal(getFreshIdlePrompt(null), "");
|
||||
assert.equal(getFreshIdlePrompt(undefined), "");
|
||||
assert.equal(getFreshIdlePrompt({}), "");
|
||||
assert.equal(getFreshIdlePrompt({ lastIdlePrompt: "PS C:\\>" }), "");
|
||||
assert.equal(
|
||||
getFreshIdlePrompt({ lastIdlePrompt: "", _promptTrackTail: "anything" }),
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
test("getFreshIdlePrompt and trackSessionIdlePrompt round-trip through a real PTY-like flow", () => {
|
||||
// (1) Remote PowerShell prompt arrives — lastIdlePrompt is captured.
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, "Microsoft Windows...\r\nPS C:\\Users\\alice>");
|
||||
assert.equal(getFreshIdlePrompt(session), "PS C:\\Users\\alice>");
|
||||
|
||||
// (2) User runs `exit` and the shell now shows an unrecognized prompt.
|
||||
// trackSessionIdlePrompt does not update lastIdlePrompt (the new shape
|
||||
// doesn't match POSIX or PowerShell regexes), so the cache is stale.
|
||||
trackSessionIdlePrompt(session, "\r\nexit\r\nlogout\r\n❯ ");
|
||||
assert.equal(session.lastIdlePrompt, "PS C:\\Users\\alice>"); // unchanged
|
||||
// The freshness check rescues us: the visible tail no longer ends
|
||||
// with the cached PS line, so downstream wrapper selection sees "".
|
||||
assert.equal(getFreshIdlePrompt(session), "");
|
||||
});
|
||||
@@ -29,7 +29,9 @@ const {
|
||||
shouldUseShellForCommand,
|
||||
resolveCliFromPath,
|
||||
resolveClaudeAcpBinaryPath,
|
||||
isPlausibleCliVersionOutput,
|
||||
getShellEnv,
|
||||
getFreshIdlePrompt,
|
||||
invalidateShellEnvCache,
|
||||
serializeStreamChunk,
|
||||
toUnpackedAsarPath,
|
||||
@@ -53,6 +55,7 @@ const {
|
||||
getCodexValidationCache,
|
||||
setCodexValidationCache,
|
||||
} = require("./ai/codexHelpers.cjs");
|
||||
const { normalizeAcpSessionModels } = require("./ai/acpModels.cjs");
|
||||
|
||||
const DEBUG_MCP = process.env.NETCATTY_MCP_DEBUG === "1";
|
||||
const NETCATTY_TOOL_SKILL_PATH = toUnpackedAsarPath(
|
||||
@@ -1322,7 +1325,7 @@ function registerHandlers(ipcMain) {
|
||||
timeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
chatSessionId,
|
||||
expectedPrompt: session.lastIdlePrompt || "",
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
typedInput: true,
|
||||
echoCommand: (rawCommand) => {
|
||||
const contents = electronModule?.webContents?.fromId?.(session.webContentsId);
|
||||
@@ -1427,6 +1430,67 @@ function registerHandlers(ipcMain) {
|
||||
});
|
||||
}
|
||||
|
||||
function getCommandOutput(result) {
|
||||
return [result?.stdout, result?.stderr]
|
||||
.filter((chunk) => typeof chunk === "string" && chunk.length > 0)
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function getFirstCommandOutputLine(result) {
|
||||
return getCommandOutput(result).split(/\r?\n/)[0] || "";
|
||||
}
|
||||
|
||||
async function probeCliVersion(probeCmd, probeArgs, env) {
|
||||
try {
|
||||
const result = await runCommand(probeCmd, probeArgs, { env });
|
||||
return {
|
||||
launched: true,
|
||||
exitCode: result.exitCode,
|
||||
output: getCommandOutput(result),
|
||||
version: getFirstCommandOutputLine(result),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
launched: false,
|
||||
exitCode: null,
|
||||
output: "",
|
||||
version: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function isCodexAcpFallbackPath(command, usesAcpFallback, resolvedPath) {
|
||||
return (
|
||||
command === "codex" &&
|
||||
usesAcpFallback &&
|
||||
path.basename(resolvedPath || "").toLowerCase().startsWith("codex-acp")
|
||||
);
|
||||
}
|
||||
|
||||
function isCodexAcpFallbackProbeUsable(command, usesAcpFallback, resolvedPath, probe) {
|
||||
if (!isCodexAcpFallbackPath(command, usesAcpFallback, resolvedPath) || !probe?.launched) {
|
||||
return false;
|
||||
}
|
||||
const output = String(probe.output || "").toLowerCase();
|
||||
const hasCodexAcpUsage = /\busage:\s*codex-acp(?:\.exe)?\s+\[options\]/.test(output);
|
||||
const rejectedVersionFlag =
|
||||
/(unexpected|unrecognized|unknown)\s+(argument|option|flag)\s+['"]?--version['"]?/.test(output) ||
|
||||
/['"]?--version['"]?\s+(found|is\s+)?(unexpected|unrecognized|unknown)/.test(output);
|
||||
return hasCodexAcpUsage && rejectedVersionFlag;
|
||||
}
|
||||
|
||||
function isClaudeAcpFallbackProbeUsable(command, usesAcpFallback, probe) {
|
||||
return command === "claude" && usesAcpFallback && probe?.launched && probe.exitCode === 0;
|
||||
}
|
||||
|
||||
function isAcpFallbackProbeUsable(command, usesAcpFallback, resolvedPath, probe) {
|
||||
return (
|
||||
isCodexAcpFallbackProbeUsable(command, usesAcpFallback, resolvedPath, probe) ||
|
||||
isClaudeAcpFallbackProbeUsable(command, usesAcpFallback, probe)
|
||||
);
|
||||
}
|
||||
|
||||
async function runCodexCli(args, options) {
|
||||
const shellEnv = await getShellEnv();
|
||||
const codexCliPath = resolveCliFromPath("codex", shellEnv) || "codex";
|
||||
@@ -1675,11 +1739,17 @@ function registerHandlers(ipcMain) {
|
||||
// resolveCodexAcpBinaryPath returns a plain string.
|
||||
let versionCommand = null;
|
||||
let versionPrependArgs = [];
|
||||
if (!resolvedPath && agent.resolveAcp) {
|
||||
let usesAcpFallback = false;
|
||||
const tryResolveAcpFallback = () => {
|
||||
if (!agent.resolveAcp) return false;
|
||||
const result = agent.resolveAcp(shellEnv, electronModule);
|
||||
if (typeof result === "string") {
|
||||
if (result && result !== agent.acpCommand && existsSync(result)) {
|
||||
resolvedPath = result;
|
||||
versionCommand = null;
|
||||
versionPrependArgs = [];
|
||||
usesAcpFallback = true;
|
||||
return true;
|
||||
}
|
||||
} else if (result?.command) {
|
||||
// On Windows the command may be `node` with the script in prependArgs.
|
||||
@@ -1689,39 +1759,62 @@ function registerHandlers(ipcMain) {
|
||||
const displayPath = scriptPath || result.command;
|
||||
if (displayPath !== agent.acpCommand && existsSync(displayPath)) {
|
||||
resolvedPath = displayPath;
|
||||
usesAcpFallback = true;
|
||||
if (scriptPath) {
|
||||
versionCommand = result.command;
|
||||
versionPrependArgs = result.prependArgs;
|
||||
} else {
|
||||
versionCommand = null;
|
||||
versionPrependArgs = [];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (!resolvedPath) {
|
||||
tryResolveAcpFallback();
|
||||
}
|
||||
|
||||
if (!resolvedPath || seenPaths.has(resolvedPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let version = "";
|
||||
try {
|
||||
// When the agent is invoked via Node (Windows), probe version with
|
||||
// the full command (e.g. `node /path/to/dist/index.js --version`).
|
||||
const probeCmd = versionCommand || resolvedPath;
|
||||
const probeArgs = [...versionPrependArgs, "--version"];
|
||||
const result = await runCommand(probeCmd, probeArgs, { env: shellEnv });
|
||||
version = (result.stdout || result.stderr || "").trim().split("\n")[0];
|
||||
} catch {
|
||||
// --version failed: not a valid CLI executable (e.g. .app bundle)
|
||||
continue;
|
||||
// When the agent is invoked via Node (Windows), probe version with
|
||||
// the full command (e.g. `node /path/to/dist/index.js --version`).
|
||||
let probe = await probeCliVersion(versionCommand || resolvedPath, [...versionPrependArgs, "--version"], shellEnv);
|
||||
let version = probe.version;
|
||||
let hasPlausibleVersion = probe.exitCode === 0 && isPlausibleCliVersionOutput(version);
|
||||
let hasUsableAcpFallback = isAcpFallbackProbeUsable(
|
||||
agent.command,
|
||||
usesAcpFallback,
|
||||
resolvedPath,
|
||||
probe,
|
||||
);
|
||||
|
||||
if (!hasPlausibleVersion && !hasUsableAcpFallback && !usesAcpFallback && agent.command === "codex") {
|
||||
const previousPath = resolvedPath;
|
||||
if (tryResolveAcpFallback() && resolvedPath !== previousPath && !seenPaths.has(resolvedPath)) {
|
||||
probe = await probeCliVersion(versionCommand || resolvedPath, [...versionPrependArgs, "--version"], shellEnv);
|
||||
version = probe.version;
|
||||
hasPlausibleVersion = probe.exitCode === 0 && isPlausibleCliVersionOutput(version);
|
||||
hasUsableAcpFallback = isAcpFallbackProbeUsable(
|
||||
agent.command,
|
||||
usesAcpFallback,
|
||||
resolvedPath,
|
||||
probe,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!version) continue;
|
||||
if (!hasPlausibleVersion && !hasUsableAcpFallback) continue;
|
||||
|
||||
const { resolveAcp: _unused, ...agentInfo } = agent;
|
||||
agents.push({
|
||||
...agentInfo,
|
||||
acpCommand: agent.command === "copilot" ? resolvedPath : agentInfo.acpCommand,
|
||||
path: resolvedPath,
|
||||
version,
|
||||
version: hasPlausibleVersion ? version : "Bundled ACP",
|
||||
available: true,
|
||||
});
|
||||
seenPaths.add(resolvedPath);
|
||||
@@ -1735,6 +1828,50 @@ function registerHandlers(ipcMain) {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const shellEnv = await getShellEnv();
|
||||
let resolvedPath = null;
|
||||
let versionCommand = null;
|
||||
let versionPrependArgs = [];
|
||||
let usesAcpFallback = false;
|
||||
const getBundledAcpFallback = () => {
|
||||
if (command === "codex") {
|
||||
const acpPath = resolveCodexAcpBinaryPath(shellEnv, electronModule);
|
||||
if (acpPath && acpPath !== "codex-acp" && existsSync(acpPath)) {
|
||||
return {
|
||||
displayPath: acpPath,
|
||||
command: null,
|
||||
prependArgs: [],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (command === "claude") {
|
||||
const acpPath = resolveClaudeAcpBinaryPath(shellEnv, electronModule);
|
||||
const scriptPath = acpPath?.prependArgs?.[0];
|
||||
const displayPath = scriptPath || acpPath?.command;
|
||||
if (displayPath && displayPath !== "claude-agent-acp" && existsSync(displayPath)) {
|
||||
return {
|
||||
displayPath,
|
||||
command: scriptPath ? acpPath.command : null,
|
||||
prependArgs: scriptPath ? acpPath.prependArgs : [],
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const resolveBundledAcpFallback = () => {
|
||||
const fallback = getBundledAcpFallback();
|
||||
if (!fallback) return false;
|
||||
if (resolvedPath === fallback.displayPath) {
|
||||
versionCommand = fallback.command;
|
||||
versionPrependArgs = fallback.prependArgs;
|
||||
usesAcpFallback = true;
|
||||
return true;
|
||||
}
|
||||
resolvedPath = fallback.displayPath;
|
||||
versionCommand = fallback.command;
|
||||
versionPrependArgs = fallback.prependArgs;
|
||||
usesAcpFallback = true;
|
||||
return true;
|
||||
};
|
||||
|
||||
if (customPath) {
|
||||
// Normalize Windows shim paths like `codex` -> `codex.cmd` when present.
|
||||
@@ -1744,25 +1881,38 @@ function registerHandlers(ipcMain) {
|
||||
} else {
|
||||
resolvedPath = resolveCliFromPath(command, shellEnv);
|
||||
}
|
||||
if (!resolvedPath) {
|
||||
resolveBundledAcpFallback();
|
||||
} else {
|
||||
const fallback = getBundledAcpFallback();
|
||||
if (fallback && resolvedPath === fallback.displayPath) {
|
||||
versionCommand = fallback.command;
|
||||
versionPrependArgs = fallback.prependArgs;
|
||||
usesAcpFallback = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedPath) {
|
||||
return { path: null, version: null, available: false };
|
||||
}
|
||||
|
||||
let version = "";
|
||||
try {
|
||||
const result = await runCommand(resolvedPath, ["--version"], { env: shellEnv });
|
||||
version = (result.stdout || result.stderr || "").trim().split("\n")[0];
|
||||
} catch {
|
||||
// --version failed: not a valid CLI executable
|
||||
let probe = await probeCliVersion(versionCommand || resolvedPath, [...versionPrependArgs, "--version"], shellEnv);
|
||||
let version = probe.version;
|
||||
let hasPlausibleVersion = probe.exitCode === 0 && isPlausibleCliVersionOutput(version);
|
||||
let hasUsableAcpFallback = isAcpFallbackProbeUsable(command, usesAcpFallback, resolvedPath, probe);
|
||||
if (!hasPlausibleVersion && !hasUsableAcpFallback && !usesAcpFallback && command === "codex") {
|
||||
if (resolveBundledAcpFallback()) {
|
||||
probe = await probeCliVersion(versionCommand || resolvedPath, [...versionPrependArgs, "--version"], shellEnv);
|
||||
version = probe.version;
|
||||
hasPlausibleVersion = probe.exitCode === 0 && isPlausibleCliVersionOutput(version);
|
||||
hasUsableAcpFallback = isAcpFallbackProbeUsable(command, usesAcpFallback, resolvedPath, probe);
|
||||
}
|
||||
}
|
||||
if (!hasPlausibleVersion && !hasUsableAcpFallback) {
|
||||
return { path: resolvedPath, version: null, available: false };
|
||||
}
|
||||
|
||||
if (!version) {
|
||||
return { path: resolvedPath, version: null, available: false };
|
||||
}
|
||||
|
||||
return { path: resolvedPath, version, available: true };
|
||||
return { path: resolvedPath, version: hasPlausibleVersion ? version : "Bundled ACP", available: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:codex:get-integration", async (event, options) => {
|
||||
@@ -2268,15 +2418,13 @@ function registerHandlers(ipcMain) {
|
||||
});
|
||||
|
||||
const sessionInfo = await provider.initSession();
|
||||
const availableModels = Array.isArray(sessionInfo?.models?.availableModels)
|
||||
? sessionInfo.models.availableModels
|
||||
: [];
|
||||
const modelCatalog = normalizeAcpSessionModels(sessionInfo);
|
||||
|
||||
if (isCopilotAgent) {
|
||||
logAcpDebug(agentLabel, "Fetched session models", {
|
||||
chatSessionId: chatSessionId || null,
|
||||
currentModelId: sessionInfo?.models?.currentModelId || null,
|
||||
availableModelIds: availableModels.map((modelInfo) => modelInfo?.modelId).filter(Boolean),
|
||||
currentModelId: modelCatalog.currentModelId || null,
|
||||
availableModelIds: modelCatalog.models.map((modelInfo) => modelInfo.id),
|
||||
copilotHome: copilotConfigInfo?.copilotHome || null,
|
||||
copilotMcpConfigPath: copilotConfigInfo?.configPath || null,
|
||||
});
|
||||
@@ -2284,16 +2432,13 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
currentModelId: sessionInfo?.models?.currentModelId || null,
|
||||
models: availableModels.map((modelInfo) => ({
|
||||
id: modelInfo?.modelId,
|
||||
name: modelInfo?.name || modelInfo?.displayName || modelInfo?.modelId,
|
||||
description: modelInfo?.description || undefined,
|
||||
})).filter((modelInfo) => Boolean(modelInfo.id)),
|
||||
currentModelId: modelCatalog.currentModelId || null,
|
||||
models: modelCatalog.models,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("[ACP] Failed to list models:", err?.message || err);
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
const normalized = extractCodexError(err);
|
||||
console.error("[ACP] Failed to list models:", normalized.message);
|
||||
return { ok: false, error: normalized.message };
|
||||
} finally {
|
||||
try {
|
||||
cleanupAcpProviderInstance(provider, chatSessionId || "transient-model-list");
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const Module = require("node:module");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
function createIpcMainStub() {
|
||||
const handlers = new Map();
|
||||
@@ -27,6 +30,40 @@ function createEmptyStreamResult() {
|
||||
};
|
||||
}
|
||||
|
||||
function writeFakeCodexAcpUsage(filePath) {
|
||||
if (process.platform === "win32") {
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
"@echo off\r\necho error: unexpected argument '--version' found\r\necho.\r\necho Usage: codex-acp [OPTIONS]\r\nexit /b 2\r\n",
|
||||
"utf8",
|
||||
);
|
||||
return;
|
||||
}
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
"#!/bin/sh\necho \"error: unexpected argument '--version' found\"\necho\necho 'Usage: codex-acp [OPTIONS]'\nexit 2\n",
|
||||
"utf8",
|
||||
);
|
||||
fs.chmodSync(filePath, 0o755);
|
||||
}
|
||||
|
||||
function writeFakeCodexAcpLoaderError(filePath) {
|
||||
if (process.platform === "win32") {
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
"@echo off\r\necho codex-acp: error while loading shared libraries: libssl.so: cannot open shared object file\r\nexit /b 127\r\n",
|
||||
"utf8",
|
||||
);
|
||||
return;
|
||||
}
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
"#!/bin/sh\necho 'codex-acp: error while loading shared libraries: libssl.so: cannot open shared object file'\nexit 127\n",
|
||||
"utf8",
|
||||
);
|
||||
fs.chmodSync(filePath, 0o755);
|
||||
}
|
||||
|
||||
function loadBridgeWithMocks(options = {}) {
|
||||
const streamCalls = [];
|
||||
const safeSendCalls = [];
|
||||
@@ -74,10 +111,23 @@ function loadBridgeWithMocks(options = {}) {
|
||||
},
|
||||
"./ai/shellUtils.cjs": {
|
||||
stripAnsi: (value) => value,
|
||||
normalizeCliPathForPlatform: (value) => value,
|
||||
normalizeCliPathForPlatform: (...args) =>
|
||||
typeof options.normalizeCliPathForPlatform === "function"
|
||||
? options.normalizeCliPathForPlatform(...args)
|
||||
: args[0],
|
||||
shouldUseShellForCommand: () => false,
|
||||
resolveCliFromPath: () => null,
|
||||
resolveClaudeAcpBinaryPath: () => null,
|
||||
isPlausibleCliVersionOutput: (value) =>
|
||||
typeof options.isPlausibleCliVersionOutput === "function"
|
||||
? options.isPlausibleCliVersionOutput(value)
|
||||
: true,
|
||||
resolveCliFromPath: (...args) =>
|
||||
typeof options.resolveCliFromPath === "function"
|
||||
? options.resolveCliFromPath(...args)
|
||||
: null,
|
||||
resolveClaudeAcpBinaryPath: (...args) =>
|
||||
typeof options.resolveClaudeAcpBinaryPath === "function"
|
||||
? options.resolveClaudeAcpBinaryPath(...args)
|
||||
: null,
|
||||
getShellEnv: async () => ({}),
|
||||
invalidateShellEnvCache() {},
|
||||
serializeStreamChunk: (chunk) => chunk,
|
||||
@@ -85,7 +135,10 @@ function loadBridgeWithMocks(options = {}) {
|
||||
},
|
||||
"./ai/codexHelpers.cjs": {
|
||||
codexLoginSessions: new Map(),
|
||||
resolveCodexAcpBinaryPath: () => null,
|
||||
resolveCodexAcpBinaryPath: (...args) =>
|
||||
typeof options.resolveCodexAcpBinaryPath === "function"
|
||||
? options.resolveCodexAcpBinaryPath(...args)
|
||||
: null,
|
||||
appendCodexLoginOutput() {},
|
||||
toCodexLoginSessionResponse: () => ({}),
|
||||
getActiveCodexLoginSession: () => null,
|
||||
@@ -199,6 +252,582 @@ function loadBridgeWithMocks(options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
test("discovers bundled Codex ACP fallback when --version prints usage", async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-acp-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
|
||||
writeFakeCodexAcpUsage(codexAcpPath);
|
||||
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
isPlausibleCliVersionOutput: () => false,
|
||||
resolveCodexAcpBinaryPath: () => codexAcpPath,
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
const discoverHandler = ipcMain.handlers.get("netcatty:ai:agents:discover");
|
||||
assert.equal(typeof discoverHandler, "function");
|
||||
|
||||
const agents = await discoverHandler({ sender: { id: 1 } });
|
||||
|
||||
assert.equal(agents.length, 1);
|
||||
assert.equal(agents[0].command, "codex");
|
||||
assert.equal(agents[0].path, codexAcpPath);
|
||||
assert.equal(agents[0].version, "Bundled ACP");
|
||||
assert.equal(agents[0].available, true);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("discovers bundled Codex ACP fallback when PATH Codex shim is broken", async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-broken-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const codexPath = path.join(tempDir, process.platform === "win32" ? "codex.cmd" : "codex");
|
||||
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
|
||||
if (process.platform === "win32") {
|
||||
fs.writeFileSync(codexPath, "@echo off\r\necho TypeError: Cannot read properties of undefined\r\n", "utf8");
|
||||
writeFakeCodexAcpUsage(codexAcpPath);
|
||||
} else {
|
||||
fs.writeFileSync(codexPath, "#!/bin/sh\necho 'TypeError: Cannot read properties of undefined'\n", "utf8");
|
||||
fs.chmodSync(codexPath, 0o755);
|
||||
writeFakeCodexAcpUsage(codexAcpPath);
|
||||
}
|
||||
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
isPlausibleCliVersionOutput: () => false,
|
||||
resolveCliFromPath: (command) => (command === "codex" ? codexPath : null),
|
||||
resolveCodexAcpBinaryPath: () => codexAcpPath,
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
const discoverHandler = ipcMain.handlers.get("netcatty:ai:agents:discover");
|
||||
assert.equal(typeof discoverHandler, "function");
|
||||
|
||||
const agents = await discoverHandler({ sender: { id: 1 } });
|
||||
|
||||
assert.equal(agents.length, 1);
|
||||
assert.equal(agents[0].command, "codex");
|
||||
assert.equal(agents[0].path, codexAcpPath);
|
||||
assert.equal(agents[0].version, "Bundled ACP");
|
||||
assert.equal(agents[0].available, true);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("discovers bundled Codex ACP fallback when PATH Codex exits nonzero", async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-exit-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const codexPath = path.join(tempDir, process.platform === "win32" ? "codex.cmd" : "codex");
|
||||
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
|
||||
if (process.platform === "win32") {
|
||||
fs.writeFileSync(codexPath, "@echo off\r\necho codex-cli 1.0.0\r\nexit /b 1\r\n", "utf8");
|
||||
writeFakeCodexAcpUsage(codexAcpPath);
|
||||
} else {
|
||||
fs.writeFileSync(codexPath, "#!/bin/sh\necho 'codex-cli 1.0.0'\nexit 1\n", "utf8");
|
||||
fs.chmodSync(codexPath, 0o755);
|
||||
writeFakeCodexAcpUsage(codexAcpPath);
|
||||
}
|
||||
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
isPlausibleCliVersionOutput: (value) => String(value).startsWith("codex-cli"),
|
||||
resolveCliFromPath: (command) => (command === "codex" ? codexPath : null),
|
||||
resolveCodexAcpBinaryPath: () => codexAcpPath,
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
const discoverHandler = ipcMain.handlers.get("netcatty:ai:agents:discover");
|
||||
assert.equal(typeof discoverHandler, "function");
|
||||
|
||||
const agents = await discoverHandler({ sender: { id: 1 } });
|
||||
|
||||
assert.equal(agents.length, 1);
|
||||
assert.equal(agents[0].command, "codex");
|
||||
assert.equal(agents[0].path, codexAcpPath);
|
||||
assert.equal(agents[0].version, "Bundled ACP");
|
||||
assert.equal(agents[0].available, true);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("does not discover bundled Codex ACP fallback when the fallback cannot run", async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-acp-bad-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
|
||||
fs.mkdirSync(codexAcpPath);
|
||||
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
isPlausibleCliVersionOutput: () => false,
|
||||
resolveCodexAcpBinaryPath: () => codexAcpPath,
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
const discoverHandler = ipcMain.handlers.get("netcatty:ai:agents:discover");
|
||||
assert.equal(typeof discoverHandler, "function");
|
||||
|
||||
const agents = await discoverHandler({ sender: { id: 1 } });
|
||||
|
||||
assert.equal(agents.length, 0);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("does not discover bundled Codex ACP fallback when the fallback prints a loader error", async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-acp-loader-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
|
||||
writeFakeCodexAcpLoaderError(codexAcpPath);
|
||||
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
isPlausibleCliVersionOutput: () => false,
|
||||
resolveCodexAcpBinaryPath: () => codexAcpPath,
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
const discoverHandler = ipcMain.handlers.get("netcatty:ai:agents:discover");
|
||||
assert.equal(typeof discoverHandler, "function");
|
||||
|
||||
const agents = await discoverHandler({ sender: { id: 1 } });
|
||||
|
||||
assert.equal(agents.length, 0);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("resolve-cli accepts bundled Codex ACP fallback when --version prints usage", async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-acp-resolve-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
|
||||
writeFakeCodexAcpUsage(codexAcpPath);
|
||||
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
isPlausibleCliVersionOutput: () => false,
|
||||
resolveCodexAcpBinaryPath: () => codexAcpPath,
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
|
||||
assert.equal(typeof resolveHandler, "function");
|
||||
|
||||
const result = await resolveHandler({ sender: { id: 1 } }, { command: "codex", customPath: "" });
|
||||
|
||||
assert.deepEqual(result, {
|
||||
path: codexAcpPath,
|
||||
version: "Bundled ACP",
|
||||
available: true,
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("resolve-cli accepts stored bundled Codex ACP path", async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-acp-stored-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
|
||||
writeFakeCodexAcpUsage(codexAcpPath);
|
||||
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
isPlausibleCliVersionOutput: () => false,
|
||||
normalizeCliPathForPlatform: () => codexAcpPath,
|
||||
resolveCodexAcpBinaryPath: () => codexAcpPath,
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
|
||||
assert.equal(typeof resolveHandler, "function");
|
||||
|
||||
const result = await resolveHandler(
|
||||
{ sender: { id: 1 } },
|
||||
{ command: "codex", customPath: codexAcpPath },
|
||||
);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
path: codexAcpPath,
|
||||
version: "Bundled ACP",
|
||||
available: true,
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("resolve-cli falls back to bundled Codex ACP when a stored path is stale", async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-acp-stale-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
|
||||
writeFakeCodexAcpUsage(codexAcpPath);
|
||||
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
isPlausibleCliVersionOutput: () => false,
|
||||
normalizeCliPathForPlatform: () => null,
|
||||
resolveCliFromPath: () => null,
|
||||
resolveCodexAcpBinaryPath: () => codexAcpPath,
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
|
||||
assert.equal(typeof resolveHandler, "function");
|
||||
|
||||
const result = await resolveHandler(
|
||||
{ sender: { id: 1 } },
|
||||
{ command: "codex", customPath: "/stale/bin/codex" },
|
||||
);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
path: codexAcpPath,
|
||||
version: "Bundled ACP",
|
||||
available: true,
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("resolve-cli falls back to bundled Codex ACP when PATH Codex shim is broken", async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-resolve-broken-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const codexPath = path.join(tempDir, process.platform === "win32" ? "codex.cmd" : "codex");
|
||||
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
|
||||
if (process.platform === "win32") {
|
||||
fs.writeFileSync(codexPath, "@echo off\r\necho TypeError: Cannot read properties of undefined\r\n", "utf8");
|
||||
writeFakeCodexAcpUsage(codexAcpPath);
|
||||
} else {
|
||||
fs.writeFileSync(codexPath, "#!/bin/sh\necho 'TypeError: Cannot read properties of undefined'\n", "utf8");
|
||||
fs.chmodSync(codexPath, 0o755);
|
||||
writeFakeCodexAcpUsage(codexAcpPath);
|
||||
}
|
||||
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
isPlausibleCliVersionOutput: () => false,
|
||||
resolveCliFromPath: (command) => (command === "codex" ? codexPath : null),
|
||||
resolveCodexAcpBinaryPath: () => codexAcpPath,
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
|
||||
assert.equal(typeof resolveHandler, "function");
|
||||
|
||||
const result = await resolveHandler({ sender: { id: 1 } }, { command: "codex", customPath: "" });
|
||||
|
||||
assert.deepEqual(result, {
|
||||
path: codexAcpPath,
|
||||
version: "Bundled ACP",
|
||||
available: true,
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("resolve-cli falls back to bundled Codex ACP when PATH Codex exits nonzero", async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-resolve-exit-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const codexPath = path.join(tempDir, process.platform === "win32" ? "codex.cmd" : "codex");
|
||||
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
|
||||
if (process.platform === "win32") {
|
||||
fs.writeFileSync(codexPath, "@echo off\r\necho codex-cli 1.0.0\r\nexit /b 1\r\n", "utf8");
|
||||
writeFakeCodexAcpUsage(codexAcpPath);
|
||||
} else {
|
||||
fs.writeFileSync(codexPath, "#!/bin/sh\necho 'codex-cli 1.0.0'\nexit 1\n", "utf8");
|
||||
fs.chmodSync(codexPath, 0o755);
|
||||
writeFakeCodexAcpUsage(codexAcpPath);
|
||||
}
|
||||
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
isPlausibleCliVersionOutput: (value) => String(value).startsWith("codex-cli"),
|
||||
resolveCliFromPath: (command) => (command === "codex" ? codexPath : null),
|
||||
resolveCodexAcpBinaryPath: () => codexAcpPath,
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
|
||||
assert.equal(typeof resolveHandler, "function");
|
||||
|
||||
const result = await resolveHandler({ sender: { id: 1 } }, { command: "codex", customPath: "" });
|
||||
|
||||
assert.deepEqual(result, {
|
||||
path: codexAcpPath,
|
||||
version: "Bundled ACP",
|
||||
available: true,
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("resolve-cli rejects bundled Codex ACP fallback when the fallback cannot run", async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-acp-resolve-bad-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
|
||||
fs.mkdirSync(codexAcpPath);
|
||||
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
isPlausibleCliVersionOutput: () => false,
|
||||
resolveCodexAcpBinaryPath: () => codexAcpPath,
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
|
||||
assert.equal(typeof resolveHandler, "function");
|
||||
|
||||
const result = await resolveHandler({ sender: { id: 1 } }, { command: "codex", customPath: "" });
|
||||
|
||||
assert.deepEqual(result, {
|
||||
path: codexAcpPath,
|
||||
version: null,
|
||||
available: false,
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("resolve-cli rejects bundled Codex ACP fallback when the fallback prints a loader error", async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-acp-resolve-loader-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const codexAcpPath = path.join(tempDir, process.platform === "win32" ? "codex-acp.cmd" : "codex-acp");
|
||||
writeFakeCodexAcpLoaderError(codexAcpPath);
|
||||
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
isPlausibleCliVersionOutput: () => false,
|
||||
resolveCodexAcpBinaryPath: () => codexAcpPath,
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
|
||||
assert.equal(typeof resolveHandler, "function");
|
||||
|
||||
const result = await resolveHandler({ sender: { id: 1 } }, { command: "codex", customPath: "" });
|
||||
|
||||
assert.deepEqual(result, {
|
||||
path: codexAcpPath,
|
||||
version: null,
|
||||
available: false,
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("discovers bundled Claude ACP fallback when the version probe is silent", async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-claude-acp-discover-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const scriptPath = path.join(tempDir, "index.js");
|
||||
fs.writeFileSync(scriptPath, "process.exit(0);\n", "utf8");
|
||||
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
isPlausibleCliVersionOutput: (value) => String(value || "").trim().length > 0,
|
||||
resolveClaudeAcpBinaryPath: () => ({
|
||||
command: process.execPath,
|
||||
prependArgs: [scriptPath],
|
||||
}),
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
const discoverHandler = ipcMain.handlers.get("netcatty:ai:agents:discover");
|
||||
assert.equal(typeof discoverHandler, "function");
|
||||
|
||||
const agents = await discoverHandler({ sender: { id: 1 } });
|
||||
|
||||
assert.equal(agents.length, 1);
|
||||
assert.equal(agents[0].command, "claude");
|
||||
assert.equal(agents[0].path, scriptPath);
|
||||
assert.equal(agents[0].version, "Bundled ACP");
|
||||
assert.equal(agents[0].available, true);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("resolve-cli accepts stored bundled Claude ACP script path via its launcher", async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-claude-acp-stored-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const scriptPath = path.join(tempDir, "index.js");
|
||||
fs.writeFileSync(scriptPath, "process.exit(0);\n", "utf8");
|
||||
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
isPlausibleCliVersionOutput: (value) => String(value || "").trim().length > 0,
|
||||
normalizeCliPathForPlatform: () => scriptPath,
|
||||
resolveClaudeAcpBinaryPath: () => ({
|
||||
command: process.execPath,
|
||||
prependArgs: [scriptPath],
|
||||
}),
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
|
||||
assert.equal(typeof resolveHandler, "function");
|
||||
|
||||
const result = await resolveHandler({ sender: { id: 1 } }, { command: "claude", customPath: scriptPath });
|
||||
|
||||
assert.deepEqual(result, {
|
||||
path: scriptPath,
|
||||
version: "Bundled ACP",
|
||||
available: true,
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("replays fallback history only after creating a fresh ACP session when the recovered turn fails", async () => {
|
||||
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks();
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
@@ -12,7 +12,7 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { existsSync } = require("node:fs");
|
||||
|
||||
const { toUnpackedAsarPath } = require("./ai/shellUtils.cjs");
|
||||
const { toUnpackedAsarPath, getFreshIdlePrompt } = require("./ai/shellUtils.cjs");
|
||||
const { execViaPty, startPtyJob, execViaChannel, execViaRawPty } = require("./ai/ptyExec.cjs");
|
||||
const { safeSend } = require("./ipcUtils.cjs");
|
||||
const { getCliDiscoveryFilePath } = require("../cli/discoveryPath.cjs");
|
||||
@@ -1493,7 +1493,7 @@ function handleExec(params) {
|
||||
trackForCancellation: activePtyExecs,
|
||||
timeoutMs: commandTimeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
expectedPrompt: session.lastIdlePrompt || "",
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
typedInput: true,
|
||||
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
|
||||
chatSessionId,
|
||||
@@ -1581,7 +1581,7 @@ function handleJobStart(params) {
|
||||
timeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
chatSessionId,
|
||||
expectedPrompt: session.lastIdlePrompt || "",
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
typedInput: true,
|
||||
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
|
||||
maxBufferedChars: MAX_BACKGROUND_JOB_OUTPUT_CHARS,
|
||||
|
||||
344
electron/bridges/moshHandshake.cjs
Normal file
344
electron/bridges/moshHandshake.cjs
Normal file
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* Node-side replacement for the upstream Mosh Perl wrapper.
|
||||
*
|
||||
* The upstream `mosh` script is a tiny orchestrator: it execs `ssh` to
|
||||
* run `mosh-server new` on the remote host, scrapes the
|
||||
* "MOSH CONNECT <port> <key>" line from the SSH stream, then execs
|
||||
* `mosh-client` locally with that port/key. This module does the same
|
||||
* thing in JS so we no longer need a Perl interpreter on the user's
|
||||
* machine — and so we can drive a bundled `mosh-client` even on
|
||||
* Windows (which has no Perl wrapper).
|
||||
*
|
||||
* Flow (driven by terminalBridge.startMoshSession):
|
||||
* 1. spawn `ssh -t [-p port] [user@]host -- mosh-server new -s ...`
|
||||
* inside a node-pty, sized to the renderer's cols/rows so password
|
||||
* / 2FA prompts render natively.
|
||||
* 2. forward every byte from the ssh PTY to the renderer (parsing
|
||||
* simultaneously via parseMoshConnect).
|
||||
* 3. when `MOSH CONNECT <port> <key>` is detected, kill the ssh PTY,
|
||||
* spawn `mosh-client <ip> <port>` in a fresh node-pty with
|
||||
* MOSH_KEY=<key> in the environment, and let the bridge swap that
|
||||
* new PTY into the existing session.
|
||||
*
|
||||
* On every supported platform the module relies on the system `ssh`
|
||||
* binary for the SSH bootstrap (Windows 10 1809+ ships OpenSSH by
|
||||
* default, macOS / Linux have it everywhere). That keeps key / agent /
|
||||
* config handling identical to what the user already has working with
|
||||
* `ssh` — no need to reimplement OpenSSH features in this codebase.
|
||||
*/
|
||||
|
||||
const path = require("node:path");
|
||||
const net = require("node:net");
|
||||
|
||||
const MOSH_CONNECT_RE = /MOSH CONNECT[ \t]+(\d{1,5})[ \t]+([A-Za-z0-9+/]+={0,2})[ \t]*$/;
|
||||
const MOSH_IP_RE = /MOSH IP[ \t]+(\S+)[ \t]*/;
|
||||
const PROTOCOL_MARKERS = ["MOSH CONNECT", "MOSH IP"];
|
||||
|
||||
function shellQuote(value) {
|
||||
const text = String(value);
|
||||
return `'${text.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function validMoshKey(key) {
|
||||
return key.length === 22 || (key.length === 24 && key.endsWith("=="));
|
||||
}
|
||||
|
||||
function parseConnectLine(line) {
|
||||
const m = MOSH_CONNECT_RE.exec(line);
|
||||
if (!m) return null;
|
||||
const port = Number(m[1]);
|
||||
const key = m[2];
|
||||
if (!Number.isFinite(port) || port <= 0 || port > 65535) return null;
|
||||
if (!validMoshKey(key)) return null;
|
||||
return {
|
||||
port,
|
||||
key,
|
||||
matchStartOffset: m.index,
|
||||
matchEndOffset: m.index + m[0].length,
|
||||
};
|
||||
}
|
||||
|
||||
function parseMoshIpLine(line) {
|
||||
const m = MOSH_IP_RE.exec(line);
|
||||
if (!m) return null;
|
||||
const host = m[1];
|
||||
return net.isIP(host) ? host : null;
|
||||
}
|
||||
|
||||
function forEachCompleteLine(text, visit) {
|
||||
const lineRe = /([^\r\n]*)(\r\n|\r|\n)/g;
|
||||
let m;
|
||||
while ((m = lineRe.exec(text)) !== null) {
|
||||
if (visit({
|
||||
line: m[1],
|
||||
newline: m[2],
|
||||
startIndex: m.index,
|
||||
endIndex: lineRe.lastIndex,
|
||||
}) === false) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findMoshConnect(text) {
|
||||
let found = null;
|
||||
forEachCompleteLine(text, ({ line, newline, startIndex, endIndex }) => {
|
||||
const parsed = parseConnectLine(line);
|
||||
if (!parsed) return;
|
||||
found = {
|
||||
port: parsed.port,
|
||||
key: parsed.key,
|
||||
matchStartIndex: startIndex + parsed.matchStartOffset,
|
||||
matchEndIndex: endIndex,
|
||||
visiblePrefix: line.slice(0, parsed.matchStartOffset),
|
||||
visibleSuffix: line.slice(parsed.matchEndOffset) + newline,
|
||||
};
|
||||
return false;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function potentialProtocolStart(text) {
|
||||
if (!text) return -1;
|
||||
let best = -1;
|
||||
for (const marker of PROTOCOL_MARKERS) {
|
||||
const full = text.indexOf(marker);
|
||||
if (full !== -1) {
|
||||
best = best === -1 ? full : Math.min(best, full);
|
||||
}
|
||||
for (let len = Math.min(marker.length - 1, text.length); len > 0; len -= 1) {
|
||||
if (marker.startsWith(text.slice(text.length - len))) {
|
||||
const pos = text.length - len;
|
||||
best = best === -1 ? pos : Math.min(best, pos);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function buildMoshServerCommand(moshServerPath) {
|
||||
const trimmed = typeof moshServerPath === "string" ? moshServerPath.trim() : "";
|
||||
if (!trimmed) return "mosh-server new -s";
|
||||
return `${shellQuote(trimmed)} new -s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a buffer of bytes from the SSH PTY for a MOSH CONNECT line.
|
||||
*
|
||||
* Returns { port: number, key: string, matchEndIndex: number } when the
|
||||
* marker is found, otherwise null. matchEndIndex is the byte offset
|
||||
* immediately after the matched line in the *current* chunk so callers
|
||||
* can tell what to strip from the renderer-visible stream (since the
|
||||
* line is internal protocol, not a user-visible prompt).
|
||||
*
|
||||
* The parser is deliberately stateless: callers should keep a small
|
||||
* trailing window (≤ 4096 bytes) of unmatched data so the marker isn't
|
||||
* lost when it spans chunk boundaries.
|
||||
*/
|
||||
function parseMoshConnect(buffer) {
|
||||
const text = Buffer.isBuffer(buffer) ? buffer.toString("utf8") : String(buffer);
|
||||
const found = findMoshConnect(text);
|
||||
if (!found) return null;
|
||||
return { port: found.port, key: found.key, matchEndIndex: found.matchEndIndex };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the argv for the ssh bootstrap command.
|
||||
*
|
||||
* ssh -t [-p port] [user@]host -- LC_ALL=... mosh-server new -s [...]
|
||||
*
|
||||
* `-t` allocates a remote TTY so password / 2FA prompts work; `--`
|
||||
* separates ssh's options from the remote command we want it to run.
|
||||
* The remote command runs `mosh-server new` and exits, with the magic
|
||||
* line emitted to stdout.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.host — hostname or IP
|
||||
* @param {number} [opts.port] — ssh port (omit for default 22)
|
||||
* @param {string} [opts.username] — ssh user (defaults to ssh's choice)
|
||||
* @param {string} [opts.lang] — LC_ALL override for mosh-server
|
||||
* @param {string} [opts.moshServer]— remote command (default "mosh-server new")
|
||||
* @param {string[]} [opts.sshArgs] — extra args passed to ssh (e.g. -i path)
|
||||
* @returns {{ command: string, args: string[] }}
|
||||
*/
|
||||
function buildSshHandshakeCommand(opts) {
|
||||
if (!opts || !opts.host) throw new Error("buildSshHandshakeCommand: host is required");
|
||||
// No -t / -tt by default: this command only runs `mosh-server new`
|
||||
// and immediately exits; mosh-server itself doesn't need a TTY for
|
||||
// the `new` subcommand (it prints MOSH CONNECT to stdout and forks
|
||||
// into the background). Forcing a TTY would require -tt and break
|
||||
// BatchMode-friendly stdout capture.
|
||||
const args = [];
|
||||
if (opts.port && Number(opts.port) !== 22) {
|
||||
args.push("-p", String(opts.port));
|
||||
}
|
||||
if (Array.isArray(opts.sshArgs)) {
|
||||
args.push(...opts.sshArgs);
|
||||
}
|
||||
const target = opts.username ? `${opts.username}@${opts.host}` : opts.host;
|
||||
args.push(target);
|
||||
args.push("--");
|
||||
// Quote the remote command minimally — ssh runs it through the
|
||||
// remote shell so simple "command arg arg" works without shell
|
||||
// metacharacters from us. mosh-server prints the magic CONNECT line
|
||||
// and otherwise stays silent.
|
||||
const lang = opts.lang || "en_US.UTF-8";
|
||||
const moshServer = opts.moshServer || "mosh-server new -s";
|
||||
args.push(`LC_ALL=${shellQuote(lang)} ${moshServer}`);
|
||||
return { command: "ssh", args };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the argv for the local mosh-client invocation once the
|
||||
* handshake produced an ip + port + key.
|
||||
*
|
||||
* mosh-client <ip> <port> (with MOSH_KEY in env)
|
||||
*
|
||||
* `mosh-server` listens on UDP at the IP/port pair it announced. By
|
||||
* convention, the IP is derived from the "MOSH IP" line emitted before
|
||||
* MOSH CONNECT, but most servers omit it and the client just uses the
|
||||
* SSH-resolved hostname / IP. We default to the original hostname when
|
||||
* no MOSH IP override is available.
|
||||
*/
|
||||
function buildMoshClientCommand({ moshClientPath, host, port }) {
|
||||
if (!moshClientPath) throw new Error("buildMoshClientCommand: moshClientPath is required");
|
||||
if (!host) throw new Error("buildMoshClientCommand: host is required");
|
||||
if (!port || port <= 0) throw new Error("buildMoshClientCommand: port must be > 0");
|
||||
return { command: moshClientPath, args: [host, String(port)] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight stream sniffer: hands chunks in, emits MOSH CONNECT
|
||||
* details + the byte ranges that should be hidden from the user-
|
||||
* visible stream.
|
||||
*
|
||||
* Usage:
|
||||
* const sniffer = createMoshConnectSniffer();
|
||||
* for each chunk: const { visible, parsed } = sniffer.feed(chunk);
|
||||
* send `visible` to renderer; if `parsed`, switch to mosh-client.
|
||||
*
|
||||
* Once a parse hits, every subsequent chunk passes through unchanged
|
||||
* (defensive: the bridge will tear down the SSH PTY immediately after
|
||||
* the parse so further chunks are unlikely, but we don't want to leak
|
||||
* partial copies of MOSH CONNECT lines if we somehow get more bytes).
|
||||
*
|
||||
* The sniffer keeps a trailing window of unmatched bytes (RING_SIZE) so
|
||||
* it can detect MOSH CONNECT spanning chunk boundaries.
|
||||
*/
|
||||
function createMoshConnectSniffer() {
|
||||
const RING_SIZE = 4096;
|
||||
const MAX_PROTOCOL_LINE = 512;
|
||||
let pending = "";
|
||||
let parsed = null;
|
||||
let moshHost = null;
|
||||
|
||||
return {
|
||||
feed(chunk) {
|
||||
if (parsed) return { visible: chunk, parsed: null };
|
||||
|
||||
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
|
||||
pending += text;
|
||||
let visibleText = "";
|
||||
let consumed = 0;
|
||||
|
||||
forEachCompleteLine(pending, ({ line, newline, startIndex, endIndex }) => {
|
||||
if (startIndex > consumed) {
|
||||
visibleText += pending.slice(consumed, startIndex);
|
||||
}
|
||||
|
||||
const ip = parseMoshIpLine(line);
|
||||
if (ip) {
|
||||
moshHost = ip;
|
||||
consumed = endIndex;
|
||||
return;
|
||||
}
|
||||
|
||||
const connect = parseConnectLine(line);
|
||||
if (connect) {
|
||||
parsed = { port: connect.port, key: connect.key };
|
||||
if (moshHost) parsed.host = moshHost;
|
||||
visibleText += line.slice(0, connect.matchStartOffset);
|
||||
const suffix = line.slice(connect.matchEndOffset);
|
||||
if (suffix) visibleText += suffix + newline;
|
||||
consumed = endIndex;
|
||||
return false;
|
||||
}
|
||||
|
||||
visibleText += line + newline;
|
||||
consumed = endIndex;
|
||||
});
|
||||
|
||||
if (parsed) {
|
||||
visibleText += pending.slice(consumed);
|
||||
pending = "";
|
||||
const visible = Buffer.isBuffer(chunk) ? Buffer.from(visibleText, "utf8") : visibleText;
|
||||
return { visible, parsed };
|
||||
}
|
||||
|
||||
pending = pending.slice(consumed);
|
||||
const holdIndex = potentialProtocolStart(pending);
|
||||
if (holdIndex === -1) {
|
||||
visibleText += pending;
|
||||
pending = "";
|
||||
} else {
|
||||
visibleText += pending.slice(0, holdIndex);
|
||||
pending = pending.slice(holdIndex);
|
||||
if (pending.length > MAX_PROTOCOL_LINE) {
|
||||
visibleText += pending;
|
||||
pending = "";
|
||||
}
|
||||
}
|
||||
|
||||
if (pending.length > RING_SIZE) {
|
||||
const overflow = pending.length - RING_SIZE;
|
||||
visibleText += pending.slice(0, overflow);
|
||||
pending = pending.slice(overflow);
|
||||
}
|
||||
const visible = Buffer.isBuffer(chunk) ? Buffer.from(visibleText, "utf8") : visibleText;
|
||||
return { visible, parsed };
|
||||
},
|
||||
isParsed() { return parsed !== null; },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the env that `mosh-client` will see. MOSH_KEY is the secret
|
||||
* shared with mosh-server, and we preserve TERM + LANG so the local
|
||||
* terminfo lookups pick the right entry.
|
||||
*/
|
||||
function buildMoshClientEnv({ baseEnv, key, lang }) {
|
||||
const env = { ...(baseEnv || {}), MOSH_KEY: key };
|
||||
if (lang && !env.LANG) env.LANG = lang;
|
||||
if (!env.TERM) env.TERM = "xterm-256color";
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the absolute path of the system `ssh` binary. On Windows we
|
||||
* try the in-box OpenSSH location first because PATH may not list
|
||||
* it inside the Electron child env.
|
||||
*/
|
||||
function resolveSshExecutable({ findExecutable, fileExists, platform = process.platform }) {
|
||||
const fromPath = findExecutable("ssh");
|
||||
if (fromPath && fromPath !== "ssh" && fileExists(fromPath)) return fromPath;
|
||||
if (platform === "win32") {
|
||||
const sysRoot = process.env.SystemRoot || process.env.SYSTEMROOT || "C:\\Windows";
|
||||
// Build with the win32-flavored path module so the result is
|
||||
// back-slash-joined regardless of the host platform we're running
|
||||
// the lookup from (relevant for cross-platform unit tests).
|
||||
const inbox = path.win32.join(sysRoot, "System32", "OpenSSH", "ssh.exe");
|
||||
if (fileExists(inbox)) return inbox;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseMoshConnect,
|
||||
buildSshHandshakeCommand,
|
||||
buildMoshServerCommand,
|
||||
buildMoshClientCommand,
|
||||
createMoshConnectSniffer,
|
||||
buildMoshClientEnv,
|
||||
resolveSshExecutable,
|
||||
};
|
||||
229
electron/bridges/moshHandshake.test.cjs
Normal file
229
electron/bridges/moshHandshake.test.cjs
Normal file
@@ -0,0 +1,229 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
parseMoshConnect,
|
||||
buildSshHandshakeCommand,
|
||||
buildMoshServerCommand,
|
||||
buildMoshClientCommand,
|
||||
createMoshConnectSniffer,
|
||||
buildMoshClientEnv,
|
||||
resolveSshExecutable,
|
||||
} = require("./moshHandshake.cjs");
|
||||
|
||||
test("parseMoshConnect captures port and key from a typical mosh-server line", () => {
|
||||
const line = "Welcome\r\nMOSH CONNECT 60001 ABCDEFGHIJKLMNOPQRSTUV==\r\n";
|
||||
const got = parseMoshConnect(line);
|
||||
assert.deepEqual(got && { port: got.port, key: got.key }, {
|
||||
port: 60001,
|
||||
key: "ABCDEFGHIJKLMNOPQRSTUV==",
|
||||
});
|
||||
});
|
||||
|
||||
test("parseMoshConnect accepts unpadded base64 keys (length 22)", () => {
|
||||
const line = "MOSH CONNECT 60005 abcdefghijklmnopqrstuv\n";
|
||||
const got = parseMoshConnect(line);
|
||||
assert.equal(got && got.port, 60005);
|
||||
assert.equal(got && got.key.length, 22);
|
||||
});
|
||||
|
||||
test("parseMoshConnect rejects out-of-range ports", () => {
|
||||
assert.equal(parseMoshConnect("MOSH CONNECT 99999 ABCDEFGHIJKLMNOPQRSTUV==\n"), null);
|
||||
assert.equal(parseMoshConnect("MOSH CONNECT 0 ABCDEFGHIJKLMNOPQRSTUV==\n"), null);
|
||||
});
|
||||
|
||||
test("parseMoshConnect rejects implausibly short keys (substring noise)", () => {
|
||||
assert.equal(parseMoshConnect("MOSH CONNECT 60000 abc\n"), null);
|
||||
});
|
||||
|
||||
test("parseMoshConnect handles a Buffer chunk", () => {
|
||||
const buf = Buffer.from("garbage MOSH CONNECT 60010 ABCDEFGHIJKLMNOPQRSTUV==\n");
|
||||
const got = parseMoshConnect(buf);
|
||||
assert.equal(got && got.port, 60010);
|
||||
});
|
||||
|
||||
test("buildSshHandshakeCommand omits -t and uses default port", () => {
|
||||
const got = buildSshHandshakeCommand({ host: "example.com", username: "alice" });
|
||||
assert.equal(got.command, "ssh");
|
||||
assert.deepEqual(got.args, [
|
||||
"alice@example.com",
|
||||
"--",
|
||||
"LC_ALL='en_US.UTF-8' mosh-server new -s",
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildSshHandshakeCommand passes a non-default port via -p", () => {
|
||||
const got = buildSshHandshakeCommand({ host: "example.com", port: 2222 });
|
||||
assert.deepEqual(got.args.slice(0, 2), ["-p", "2222"]);
|
||||
});
|
||||
|
||||
test("buildSshHandshakeCommand interpolates lang and moshServer overrides", () => {
|
||||
const got = buildSshHandshakeCommand({
|
||||
host: "h",
|
||||
lang: "zh_CN.UTF-8",
|
||||
moshServer: "/opt/mosh/bin/mosh-server new -s -c 256",
|
||||
});
|
||||
assert.equal(got.args.at(-1), "LC_ALL='zh_CN.UTF-8' /opt/mosh/bin/mosh-server new -s -c 256");
|
||||
});
|
||||
|
||||
test("buildSshHandshakeCommand shell-quotes lang values", () => {
|
||||
const got = buildSshHandshakeCommand({
|
||||
host: "h",
|
||||
lang: "C; touch /tmp/netcatty-owned",
|
||||
});
|
||||
assert.equal(got.args.at(-1), "LC_ALL='C; touch /tmp/netcatty-owned' mosh-server new -s");
|
||||
});
|
||||
|
||||
test("buildMoshServerCommand treats custom server input as a path", () => {
|
||||
assert.equal(
|
||||
buildMoshServerCommand("/opt/Mosh Tools/mosh-server; touch /tmp/nope"),
|
||||
"'/opt/Mosh Tools/mosh-server; touch /tmp/nope' new -s",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSshHandshakeCommand throws when host is missing", () => {
|
||||
assert.throws(() => buildSshHandshakeCommand({}), /host is required/);
|
||||
});
|
||||
|
||||
test("buildMoshClientCommand wires moshClientPath, host, port", () => {
|
||||
const got = buildMoshClientCommand({
|
||||
moshClientPath: "/usr/local/bin/mosh-client",
|
||||
host: "10.0.0.1",
|
||||
port: 60001,
|
||||
});
|
||||
assert.equal(got.command, "/usr/local/bin/mosh-client");
|
||||
assert.deepEqual(got.args, ["10.0.0.1", "60001"]);
|
||||
});
|
||||
|
||||
test("buildMoshClientCommand validates inputs", () => {
|
||||
assert.throws(() => buildMoshClientCommand({ host: "h", port: 1 }), /moshClientPath/);
|
||||
assert.throws(() => buildMoshClientCommand({ moshClientPath: "x", port: 1 }), /host/);
|
||||
assert.throws(() => buildMoshClientCommand({ moshClientPath: "x", host: "h", port: 0 }), /port/);
|
||||
});
|
||||
|
||||
test("createMoshConnectSniffer detects MOSH CONNECT split across chunks", () => {
|
||||
const sniffer = createMoshConnectSniffer();
|
||||
const r1 = sniffer.feed("login as: alice\r\nlast login: yesterday\r\nMOSH CONNE");
|
||||
assert.equal(r1.parsed, null);
|
||||
assert.ok(!String(r1.visible).includes("MOSH CONNE"));
|
||||
const r2 = sniffer.feed("CT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
|
||||
assert.deepEqual(r2.parsed, { port: 60002, key: "ABCDEFGHIJKLMNOPQRSTUV==" });
|
||||
assert.ok(!String(r2.visible).includes("MOSH CONNECT"));
|
||||
assert.ok(!String(r2.visible).includes("ABCDEFGHIJKLMNOPQRSTUV=="));
|
||||
});
|
||||
|
||||
test("createMoshConnectSniffer does not leak a split MOSH key", () => {
|
||||
const sniffer = createMoshConnectSniffer();
|
||||
const r1 = sniffer.feed("intro\r\nMOSH CONNECT 60002 ABCDEFGHIJ");
|
||||
assert.equal(r1.parsed, null);
|
||||
assert.equal(String(r1.visible), "intro\r\n");
|
||||
const r2 = sniffer.feed("KLMNOPQRSTUV==\r\n");
|
||||
assert.deepEqual(r2.parsed, { port: 60002, key: "ABCDEFGHIJKLMNOPQRSTUV==" });
|
||||
assert.equal(String(r2.visible), "");
|
||||
});
|
||||
|
||||
test("createMoshConnectSniffer passes through prompts without waiting for a newline", () => {
|
||||
const sniffer = createMoshConnectSniffer();
|
||||
const r = sniffer.feed("password:");
|
||||
assert.equal(r.parsed, null);
|
||||
assert.equal(String(r.visible), "password:");
|
||||
});
|
||||
|
||||
test("createMoshConnectSniffer ignores invalid MOSH CONNECT lines", () => {
|
||||
for (const line of [
|
||||
"MOSH CONNECT 99999 ABCDEFGHIJKLMNOPQRSTUV==\r\n",
|
||||
"MOSH CONNECT 0 ABCDEFGHIJKLMNOPQRSTUV==\r\n",
|
||||
"MOSH CONNECT 60000 short\r\n",
|
||||
"MOSH CONNECT 60000 ABCDEFGHIJKLMNOPQRSTUVWXYZ\r\n",
|
||||
"MOSH CONNECT 60000 ABCDEFGHIJKLMNOPQRSTUV==oops\r\n",
|
||||
]) {
|
||||
const sniffer = createMoshConnectSniffer();
|
||||
const r = sniffer.feed(line);
|
||||
assert.equal(r.parsed, null, line);
|
||||
}
|
||||
});
|
||||
|
||||
test("createMoshConnectSniffer captures MOSH IP without showing protocol lines", () => {
|
||||
const sniffer = createMoshConnectSniffer();
|
||||
const r = sniffer.feed("welcome\r\nMOSH IP 203.0.113.8\r\nMOSH CONNECT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
|
||||
assert.deepEqual(r.parsed, { port: 60002, key: "ABCDEFGHIJKLMNOPQRSTUV==", host: "203.0.113.8" });
|
||||
assert.equal(String(r.visible), "welcome\r\n");
|
||||
});
|
||||
|
||||
test("createMoshConnectSniffer ignores unsafe MOSH IP values", () => {
|
||||
const sniffer = createMoshConnectSniffer();
|
||||
const r = sniffer.feed("MOSH IP --help\r\nMOSH CONNECT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
|
||||
assert.deepEqual(r.parsed, { port: 60002, key: "ABCDEFGHIJKLMNOPQRSTUV==" });
|
||||
});
|
||||
|
||||
test("createMoshConnectSniffer strips the magic line from visible output", () => {
|
||||
const sniffer = createMoshConnectSniffer();
|
||||
const chunk = "shell prompt $ \r\nMOSH CONNECT 60003 ABCDEFGHIJKLMNOPQRSTUV==\r\nbye\r\n";
|
||||
const { visible, parsed } = sniffer.feed(chunk);
|
||||
assert.deepEqual(parsed, { port: 60003, key: "ABCDEFGHIJKLMNOPQRSTUV==" });
|
||||
assert.ok(!String(visible).includes("MOSH CONNECT"), "visible output should not leak the marker");
|
||||
});
|
||||
|
||||
test("createMoshConnectSniffer is idempotent after a parse", () => {
|
||||
const sniffer = createMoshConnectSniffer();
|
||||
const r1 = sniffer.feed("MOSH CONNECT 60010 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
|
||||
assert.ok(r1.parsed);
|
||||
// Second feed should not re-parse / re-strip — it just passes through.
|
||||
const r2 = sniffer.feed("trailing bytes after handshake\r\n");
|
||||
assert.equal(r2.parsed, null);
|
||||
assert.equal(String(r2.visible), "trailing bytes after handshake\r\n");
|
||||
});
|
||||
|
||||
test("createMoshConnectSniffer trims its ring buffer so old data doesn't accumulate", () => {
|
||||
const sniffer = createMoshConnectSniffer();
|
||||
// Feed >> RING_SIZE (4096) bytes of harmless output.
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const r = sniffer.feed("x".repeat(1024));
|
||||
assert.equal(r.parsed, null);
|
||||
}
|
||||
// Now feed a CONNECT line — ring trimming must not have lost the
|
||||
// ability to match a fresh marker.
|
||||
const r = sniffer.feed("MOSH CONNECT 60020 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
|
||||
assert.equal(r.parsed && r.parsed.port, 60020);
|
||||
});
|
||||
|
||||
test("buildMoshClientEnv injects MOSH_KEY without mutating the input env", () => {
|
||||
const base = { LANG: "C", PATH: "/x" };
|
||||
const env = buildMoshClientEnv({ baseEnv: base, key: "deadbeef", lang: "C" });
|
||||
assert.equal(env.MOSH_KEY, "deadbeef");
|
||||
assert.equal(env.PATH, "/x");
|
||||
assert.equal(base.MOSH_KEY, undefined, "input env should not be mutated");
|
||||
});
|
||||
|
||||
test("buildMoshClientEnv defaults TERM when missing", () => {
|
||||
const env = buildMoshClientEnv({ baseEnv: {}, key: "k", lang: "C" });
|
||||
assert.equal(env.TERM, "xterm-256color");
|
||||
});
|
||||
|
||||
test("resolveSshExecutable prefers PATH lookups", () => {
|
||||
const resolved = resolveSshExecutable({
|
||||
findExecutable: () => "/opt/ssh/bin/ssh",
|
||||
fileExists: () => true,
|
||||
platform: "linux",
|
||||
});
|
||||
assert.equal(resolved, "/opt/ssh/bin/ssh");
|
||||
});
|
||||
|
||||
test("resolveSshExecutable falls back to in-box OpenSSH on win32", () => {
|
||||
process.env.SystemRoot = "C:\\Windows";
|
||||
const resolved = resolveSshExecutable({
|
||||
findExecutable: () => "ssh", // fakes "not found, returns the bare name"
|
||||
fileExists: (p) => p.endsWith("OpenSSH\\ssh.exe"),
|
||||
platform: "win32",
|
||||
});
|
||||
assert.equal(resolved, "C:\\Windows\\System32\\OpenSSH\\ssh.exe");
|
||||
});
|
||||
|
||||
test("resolveSshExecutable returns null when nothing is found", () => {
|
||||
const resolved = resolveSshExecutable({
|
||||
findExecutable: () => "ssh",
|
||||
fileExists: () => false,
|
||||
platform: "linux",
|
||||
});
|
||||
assert.equal(resolved, null);
|
||||
});
|
||||
@@ -6,7 +6,11 @@
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { toLocalISOString, stripAnsi, terminalDataToHtml } = require("./sessionLogsBridge.cjs");
|
||||
const {
|
||||
toLocalISOString,
|
||||
wrapTerminalHtmlContent,
|
||||
} = require("./sessionLogsBridge.cjs");
|
||||
const { createTerminalTextRenderer } = require("./terminalLogSanitizer.cjs");
|
||||
|
||||
// Active log streams keyed by sessionId
|
||||
const activeStreams = new Map();
|
||||
@@ -42,34 +46,45 @@ function startStream(sessionId, opts) {
|
||||
|
||||
const date = new Date(startTime || Date.now());
|
||||
const dateStr = toLocalISOString(date);
|
||||
// For html format, write raw data to a temp file during streaming,
|
||||
// then convert on stopStream.
|
||||
// Raw logs are written directly. Txt/html logs keep terminal parser state
|
||||
// in memory and write the rendered file on each flush.
|
||||
const isRaw = format === "raw";
|
||||
const isHtml = format === "html";
|
||||
const ext = isHtml ? "log.tmp" : format === "raw" ? "log" : "txt";
|
||||
const ext = isRaw ? "log" : isHtml ? "html" : "txt";
|
||||
const fileName = `${dateStr}.${ext}`;
|
||||
const filePath = path.join(hostDir, fileName);
|
||||
|
||||
const writeStream = fs.createWriteStream(filePath, { flags: "w", encoding: "utf8" });
|
||||
const writeStream = isRaw
|
||||
? fs.createWriteStream(filePath, { flags: "w", encoding: "utf8" })
|
||||
: null;
|
||||
|
||||
writeStream.on("error", (err) => {
|
||||
console.error(`[SessionLogStream] Write error for ${sessionId}:`, err.message);
|
||||
// Disable this stream on error to avoid cascading failures
|
||||
const entry = activeStreams.get(sessionId);
|
||||
if (entry) {
|
||||
entry.disabled = true;
|
||||
}
|
||||
});
|
||||
if (writeStream) {
|
||||
writeStream.on("error", (err) => {
|
||||
console.error(`[SessionLogStream] Write error for ${sessionId}:`, err.message);
|
||||
// Disable this stream on error to avoid cascading failures
|
||||
const entry = activeStreams.get(sessionId);
|
||||
if (entry) {
|
||||
entry.disabled = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const entry = {
|
||||
writeStream,
|
||||
filePath,
|
||||
hostDir,
|
||||
format,
|
||||
isRaw,
|
||||
isHtml,
|
||||
renderer: isRaw ? null : createTerminalTextRenderer(),
|
||||
hostLabel: hostLabel || hostname || "unknown",
|
||||
startTime: startTime || Date.now(),
|
||||
buffer: "",
|
||||
flushTimer: null,
|
||||
snapshotPromise: null,
|
||||
snapshotRequested: false,
|
||||
snapshotDirty: false,
|
||||
closing: false,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
@@ -96,14 +111,12 @@ function flushBuffer(entry) {
|
||||
const data = entry.buffer;
|
||||
entry.buffer = "";
|
||||
|
||||
if (entry.isHtml) {
|
||||
// For HTML format, write raw data during streaming; convert on close
|
||||
entry.writeStream.write(data);
|
||||
} else if (entry.format === "raw") {
|
||||
if (entry.isRaw) {
|
||||
entry.writeStream.write(data);
|
||||
} else {
|
||||
// txt format: strip ANSI codes
|
||||
entry.writeStream.write(stripAnsi(data));
|
||||
entry.renderer.feed(data);
|
||||
entry.snapshotDirty = true;
|
||||
scheduleSnapshot(entry);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[SessionLogStream] Flush error:", err.message);
|
||||
@@ -111,6 +124,43 @@ function flushBuffer(entry) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderSnapshotContent(entry) {
|
||||
return entry.isHtml
|
||||
? wrapTerminalHtmlContent(entry.renderer.toHtmlContent(), entry.hostLabel, entry.startTime)
|
||||
: entry.renderer.toString();
|
||||
}
|
||||
|
||||
function scheduleSnapshot(entry) {
|
||||
if (!entry || entry.disabled || entry.isRaw || entry.closing) return;
|
||||
if (!entry.snapshotDirty) return;
|
||||
|
||||
if (entry.snapshotPromise) {
|
||||
entry.snapshotRequested = true;
|
||||
return;
|
||||
}
|
||||
|
||||
entry.snapshotDirty = false;
|
||||
entry.snapshotPromise = fs.promises
|
||||
.writeFile(entry.filePath, renderSnapshotContent(entry), "utf8")
|
||||
.catch((err) => {
|
||||
console.error("[SessionLogStream] Snapshot write failed:", err.message);
|
||||
entry.snapshotDirty = true;
|
||||
})
|
||||
.finally(() => {
|
||||
entry.snapshotPromise = null;
|
||||
if ((entry.snapshotRequested || entry.snapshotDirty) && !entry.closing) {
|
||||
entry.snapshotRequested = false;
|
||||
scheduleSnapshot(entry);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForSnapshotIdle(entry) {
|
||||
while (entry.snapshotPromise) {
|
||||
await entry.snapshotPromise;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append data to the session's log buffer.
|
||||
* Data is flushed periodically or when the buffer exceeds MAX_BUFFER_SIZE.
|
||||
@@ -139,6 +189,7 @@ async function stopStream(sessionId) {
|
||||
const entry = activeStreams.get(sessionId);
|
||||
if (!entry) return null;
|
||||
activeStreams.delete(sessionId);
|
||||
entry.closing = true;
|
||||
|
||||
// Stop periodic flush
|
||||
if (entry.flushTimer) {
|
||||
@@ -148,34 +199,25 @@ async function stopStream(sessionId) {
|
||||
|
||||
// Flush remaining buffer
|
||||
flushBuffer(entry);
|
||||
await waitForSnapshotIdle(entry);
|
||||
|
||||
// Close the write stream and wait for it to finish
|
||||
await new Promise((resolve) => {
|
||||
entry.writeStream.end(resolve);
|
||||
});
|
||||
|
||||
let finalPath = entry.filePath;
|
||||
|
||||
// For HTML format: read the temp raw file and convert to HTML
|
||||
if (entry.isHtml && !entry.disabled) {
|
||||
// Close the raw write stream and wait for it to finish.
|
||||
if (entry.writeStream) {
|
||||
await new Promise((resolve) => {
|
||||
entry.writeStream.end(resolve);
|
||||
});
|
||||
} else if (!entry.disabled && entry.snapshotDirty) {
|
||||
try {
|
||||
const rawData = await fs.promises.readFile(entry.filePath, "utf8");
|
||||
const htmlContent = terminalDataToHtml(rawData, entry.hostLabel, entry.startTime);
|
||||
const htmlPath = entry.filePath.replace(/\.log\.tmp$/, ".html");
|
||||
await fs.promises.writeFile(htmlPath, htmlContent, "utf8");
|
||||
// Remove temp file
|
||||
try {
|
||||
await fs.promises.unlink(entry.filePath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
finalPath = htmlPath;
|
||||
await fs.promises.writeFile(entry.filePath, renderSnapshotContent(entry), "utf8");
|
||||
entry.snapshotDirty = false;
|
||||
} catch (err) {
|
||||
console.error(`[SessionLogStream] HTML conversion failed for ${sessionId}:`, err.message);
|
||||
// Keep the raw temp file as fallback
|
||||
console.error(`[SessionLogStream] Final snapshot write failed for ${sessionId}:`, err.message);
|
||||
entry.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
const finalPath = entry.filePath;
|
||||
|
||||
console.log(`[SessionLogStream] Stopped stream for ${sessionId} -> ${finalPath}`);
|
||||
return finalPath;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { dialog } = require("electron");
|
||||
const {
|
||||
terminalDataToHtmlContent,
|
||||
terminalDataToPlainText,
|
||||
} = require("./terminalLogSanitizer.cjs");
|
||||
|
||||
/**
|
||||
* Get current Date to a local ISO-like string (YYYY-MM-DDTHH-MM-SS)
|
||||
@@ -23,22 +27,6 @@ function toLocalISOString(date = new Date()) {
|
||||
return `${year}-${month}-${day}T${hours}-${minutes}-${seconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip ANSI escape codes from text
|
||||
* Used for plain text export format
|
||||
*/
|
||||
function stripAnsi(str) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return str
|
||||
// OSC: ESC ] ... BEL or ESC ] ... ESC \
|
||||
.replace(/\x1B\][\s\S]*?(?:\x07|\x1B\\)/g, '')
|
||||
// ANSI CSI / ESC sequences
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "")
|
||||
// Remove remaining control chars except \n \r \t
|
||||
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML special characters to prevent XSS
|
||||
* Must be applied before converting ANSI codes to HTML spans
|
||||
@@ -52,75 +40,12 @@ function escapeHtml(str) {
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert terminal data to HTML with colors preserved
|
||||
*/
|
||||
function terminalDataToHtml(terminalData, hostLabel, timestamp) {
|
||||
// Basic ANSI to HTML conversion for common codes
|
||||
const ansiToHtml = (text) => {
|
||||
const colorMap = {
|
||||
"30": "color: #000",
|
||||
"31": "color: #c00",
|
||||
"32": "color: #0c0",
|
||||
"33": "color: #cc0",
|
||||
"34": "color: #00c",
|
||||
"35": "color: #c0c",
|
||||
"36": "color: #0cc",
|
||||
"37": "color: #ccc",
|
||||
"90": "color: #666",
|
||||
"91": "color: #f66",
|
||||
"92": "color: #6f6",
|
||||
"93": "color: #ff6",
|
||||
"94": "color: #66f",
|
||||
"95": "color: #f6f",
|
||||
"96": "color: #6ff",
|
||||
"97": "color: #fff",
|
||||
"40": "background: #000",
|
||||
"41": "background: #c00",
|
||||
"42": "background: #0c0",
|
||||
"43": "background: #cc0",
|
||||
"44": "background: #00c",
|
||||
"45": "background: #c0c",
|
||||
"46": "background: #0cc",
|
||||
"47": "background: #ccc",
|
||||
"1": "font-weight: bold",
|
||||
"3": "font-style: italic",
|
||||
"4": "text-decoration: underline",
|
||||
};
|
||||
function terminalPlainTextToHtml(plainText, hostLabel, timestamp) {
|
||||
const htmlContent = escapeHtml(plainText || "");
|
||||
return wrapTerminalHtmlContent(htmlContent, hostLabel, timestamp);
|
||||
}
|
||||
|
||||
// First, escape HTML in the text content (not the ANSI codes)
|
||||
// We do this by splitting on ANSI sequences, escaping each text part, then rejoining
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ansiRegex = /(\x1B\[[0-9;]*m|\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]))/g;
|
||||
const parts = text.split(ansiRegex);
|
||||
|
||||
let result = parts.map((part) => {
|
||||
// Check if this part is an ANSI sequence
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/^\x1B/.test(part)) {
|
||||
// It's an ANSI sequence, convert to HTML span or remove
|
||||
const match = part.match(/^\x1B\[([0-9;]*)m$/);
|
||||
if (match) {
|
||||
const codes = match[1];
|
||||
if (codes === "0" || codes === "") {
|
||||
return "</span>";
|
||||
}
|
||||
const styles = codes.split(";").map((c) => colorMap[c]).filter(Boolean);
|
||||
if (styles.length > 0) {
|
||||
return `<span style="${styles.join("; ")}">`;
|
||||
}
|
||||
}
|
||||
// Other ANSI sequences are stripped
|
||||
return "";
|
||||
}
|
||||
// It's regular text, escape HTML
|
||||
return escapeHtml(part);
|
||||
}).join("");
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const htmlContent = ansiToHtml(terminalData);
|
||||
function wrapTerminalHtmlContent(htmlContent, hostLabel, timestamp) {
|
||||
const dateStr = new Date(timestamp).toLocaleString();
|
||||
const safeHostLabel = escapeHtml(hostLabel || "Unknown");
|
||||
const safeDateStr = escapeHtml(dateStr);
|
||||
@@ -154,11 +79,19 @@ function terminalDataToHtml(terminalData, hostLabel, timestamp) {
|
||||
Host: ${safeHostLabel}<br>
|
||||
Date: ${safeDateStr}
|
||||
</div>
|
||||
<div class="content">${htmlContent}</div>
|
||||
<div class="content">${htmlContent || ""}</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert terminal data to HTML after applying terminal text controls while
|
||||
* preserving SGR styles such as color, bold, italic, and underline.
|
||||
*/
|
||||
function terminalDataToHtml(terminalData, hostLabel, timestamp) {
|
||||
return wrapTerminalHtmlContent(terminalDataToHtmlContent(terminalData), hostLabel, timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a session log to a file (manual export via save dialog)
|
||||
*/
|
||||
@@ -201,8 +134,8 @@ async function exportSessionLog(event, payload) {
|
||||
// Raw format preserves ANSI codes
|
||||
content = terminalData;
|
||||
} else {
|
||||
// Plain text - strip ANSI codes
|
||||
content = stripAnsi(terminalData);
|
||||
// Plain text - apply terminal text controls and remove escape sequences
|
||||
content = terminalDataToPlainText(terminalData);
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(result.filePath, content, "utf8");
|
||||
@@ -258,7 +191,7 @@ async function autoSaveSessionLog(event, payload) {
|
||||
} else if (format === "raw") {
|
||||
content = terminalData;
|
||||
} else {
|
||||
content = stripAnsi(terminalData);
|
||||
content = terminalDataToPlainText(terminalData);
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(filePath, content, "utf8");
|
||||
@@ -307,7 +240,8 @@ module.exports = {
|
||||
selectSessionLogsDir,
|
||||
autoSaveSessionLog,
|
||||
openSessionLogsDir,
|
||||
stripAnsi,
|
||||
toLocalISOString,
|
||||
terminalDataToHtml,
|
||||
terminalPlainTextToHtml,
|
||||
wrapTerminalHtmlContent,
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user