Compare commits
22 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 |
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
|
||||
|
||||
44
App.tsx
44
App.tsx
@@ -17,14 +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,
|
||||
@@ -58,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();
|
||||
@@ -207,6 +207,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
theme,
|
||||
setTheme,
|
||||
resolvedTheme,
|
||||
accentMode,
|
||||
customAccent,
|
||||
terminalThemeId,
|
||||
setTerminalThemeId,
|
||||
followAppTerminalTheme,
|
||||
@@ -366,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
|
||||
@@ -403,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,
|
||||
@@ -441,7 +448,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
}
|
||||
|
||||
return buildSyncPayload(
|
||||
return buildLocalVaultPayload(
|
||||
{
|
||||
hosts,
|
||||
keys,
|
||||
@@ -557,7 +564,6 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
portForwardingRules: portForwardingRulesForSync,
|
||||
knownHosts,
|
||||
groupConfigs,
|
||||
settingsVersion: settings.settingsVersion,
|
||||
startupReady: startupSyncSafetyReady,
|
||||
@@ -1763,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';
|
||||
@@ -1785,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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1915,6 +1921,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
draggingSessionId={draggingSessionId}
|
||||
terminalTheme={currentTerminalTheme}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
accentMode={accentMode}
|
||||
customAccent={customAccent}
|
||||
terminalSettings={terminalSettings}
|
||||
terminalFontFamilyId={terminalFontFamilyId}
|
||||
fontSize={terminalFontSize}
|
||||
|
||||
@@ -378,18 +378,6 @@ const en: Messages = {
|
||||
'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.mosh.client': 'Mosh client path',
|
||||
'settings.terminal.mosh.client.desc': 'Absolute path to the local mosh executable. Leave empty to auto-detect on PATH and common install locations (Homebrew, MacPorts, ~/.nix-profile, ~/.cargo, ~/.local).',
|
||||
'settings.terminal.mosh.client.placeholder': 'Auto-detect',
|
||||
'settings.terminal.mosh.client.notFound': 'File not found at that path.',
|
||||
'settings.terminal.mosh.client.isDirectory': 'Path points to a directory, not an executable.',
|
||||
'settings.terminal.mosh.client.notExecutable': 'File exists but is not executable. Run `chmod +x` on it or pick another binary.',
|
||||
'settings.terminal.mosh.client.notAbsolute': 'Path must be absolute. Use Browse… to pick the binary, leave the field empty to auto-detect, or enter a full path.',
|
||||
'settings.terminal.mosh.detect': 'Detect',
|
||||
'settings.terminal.mosh.browse': 'Browse…',
|
||||
'settings.terminal.mosh.autoDetected': 'Auto-detected',
|
||||
'settings.terminal.mosh.detected': 'Detected at',
|
||||
'settings.terminal.mosh.notDetected': 'Mosh not found in:',
|
||||
'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).',
|
||||
@@ -790,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',
|
||||
@@ -856,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
|
||||
|
||||
@@ -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': '定位到终端当前目录',
|
||||
@@ -1214,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
|
||||
@@ -1462,18 +1468,6 @@ const zhCN: Messages = {
|
||||
'settings.terminal.connection.x11Display': 'X11 显示地址',
|
||||
'settings.terminal.connection.x11Display.desc': '可选的本机 X11 显示地址。留空则使用系统默认值。',
|
||||
'settings.terminal.connection.x11Display.placeholder': '自动(:0 或 DISPLAY)',
|
||||
'settings.terminal.mosh.client': 'Mosh 客户端路径',
|
||||
'settings.terminal.mosh.client.desc': '本机 mosh 可执行文件的绝对路径。留空则自动从 PATH 与常见安装目录中查找(Homebrew、MacPorts、~/.nix-profile、~/.cargo、~/.local)。',
|
||||
'settings.terminal.mosh.client.placeholder': '自动探测',
|
||||
'settings.terminal.mosh.client.notFound': '该路径下未找到文件。',
|
||||
'settings.terminal.mosh.client.isDirectory': '该路径指向目录而非可执行文件。',
|
||||
'settings.terminal.mosh.client.notExecutable': '文件存在但不可执行。请对其执行 `chmod +x`,或选择其它二进制文件。',
|
||||
'settings.terminal.mosh.client.notAbsolute': '路径必须为绝对路径。请使用 浏览… 选择二进制、留空以自动探测,或输入完整路径。',
|
||||
'settings.terminal.mosh.detect': '探测',
|
||||
'settings.terminal.mosh.browse': '浏览…',
|
||||
'settings.terminal.mosh.autoDetected': '自动检测到',
|
||||
'settings.terminal.mosh.detected': '已找到',
|
||||
'settings.terminal.mosh.notDetected': '在以下位置未找到 mosh:',
|
||||
'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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -167,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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
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";
|
||||
@@ -127,6 +128,8 @@ interface TerminalProps {
|
||||
fontSize: number;
|
||||
terminalTheme: TerminalTheme;
|
||||
followAppTerminalTheme?: boolean;
|
||||
accentMode?: "theme" | "custom";
|
||||
customAccent?: string;
|
||||
terminalSettings?: TerminalSettings;
|
||||
sessionId: string;
|
||||
startupCommand?: string;
|
||||
@@ -225,6 +228,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
fontSize,
|
||||
terminalTheme,
|
||||
followAppTerminalTheme = false,
|
||||
accentMode = "theme",
|
||||
customAccent = "",
|
||||
terminalSettings,
|
||||
sessionId,
|
||||
startupCommand,
|
||||
@@ -682,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;
|
||||
@@ -1725,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';
|
||||
@@ -395,6 +396,8 @@ interface TerminalLayerProps {
|
||||
draggingSessionId: string | null;
|
||||
terminalTheme: TerminalTheme;
|
||||
followAppTerminalTheme?: boolean;
|
||||
accentMode?: 'theme' | 'custom';
|
||||
customAccent?: string;
|
||||
terminalSettings?: TerminalSettings;
|
||||
terminalFontFamilyId: string;
|
||||
fontSize?: number;
|
||||
@@ -455,6 +458,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
draggingSessionId,
|
||||
terminalTheme,
|
||||
followAppTerminalTheme = false,
|
||||
accentMode = 'theme',
|
||||
customAccent = '',
|
||||
terminalSettings,
|
||||
terminalFontFamilyId,
|
||||
fontSize = 14,
|
||||
@@ -1580,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);
|
||||
@@ -1625,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 () => {
|
||||
@@ -1889,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
|
||||
@@ -2201,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)',
|
||||
@@ -2223,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)',
|
||||
@@ -2240,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)',
|
||||
@@ -2257,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)',
|
||||
@@ -2274,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)',
|
||||
@@ -2525,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}
|
||||
@@ -2641,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 &&
|
||||
|
||||
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()}>
|
||||
|
||||
@@ -35,7 +35,7 @@ export const MessageContent = ({ children, className, from, ...props }: MessageC
|
||||
<div
|
||||
className={cn(
|
||||
'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-2',
|
||||
'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,
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -300,16 +300,6 @@ export default function SettingsTerminalTab(props: {
|
||||
const [shellValidation, setShellValidation] = useState<{ valid: boolean; message?: string } | null>(null);
|
||||
const [dirValidation, setDirValidation] = useState<{ valid: boolean; message?: string } | null>(null);
|
||||
|
||||
// Mosh settings state
|
||||
const [moshValidation, setMoshValidation] = useState<{ valid: boolean; message?: string } | null>(null);
|
||||
const [moshDetectStatus, setMoshDetectStatus] = useState<
|
||||
| { kind: "idle" }
|
||||
| { kind: "running" }
|
||||
| { kind: "found"; path: string }
|
||||
| { kind: "not-found"; searchedPaths: string[] }
|
||||
>({ kind: "idle" });
|
||||
const [autoDetectedMoshPath, setAutoDetectedMoshPath] = useState<string | null>(null);
|
||||
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
const [showCustomShellInput, setShowCustomShellInput] = useState(() => {
|
||||
if (!terminalSettings.localShell) return false;
|
||||
@@ -465,109 +455,6 @@ export default function SettingsTerminalTab(props: {
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [terminalSettings.localShell, discoveredShells, t]);
|
||||
|
||||
// Validate mosh client path when it changes (debounced)
|
||||
useEffect(() => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
const moshPath = terminalSettings.moshClientPath;
|
||||
if (!moshPath) {
|
||||
setMoshValidation(null);
|
||||
return;
|
||||
}
|
||||
// The shared validatePath bridge resolves bare names through PATH (good
|
||||
// for localShell where "powershell.exe" is a valid choice), but
|
||||
// startMoshSession treats moshClientPath as a literal filesystem path —
|
||||
// so any non-absolute entry would look valid here yet fail at connect
|
||||
// time. Gate on absolute paths first; accept ~ since the main process
|
||||
// will expand it. Tolerant across platforms so e.g. a user pasting a
|
||||
// Windows-style absolute path on macOS still gets a real error
|
||||
// downstream rather than a misleading "not absolute".
|
||||
const looksAbsolute =
|
||||
moshPath.startsWith("/") ||
|
||||
moshPath.startsWith("~") ||
|
||||
/^[a-zA-Z]:[\\/]/.test(moshPath) ||
|
||||
moshPath.startsWith("\\\\");
|
||||
if (!looksAbsolute) {
|
||||
setMoshValidation({ valid: false, message: t("settings.terminal.mosh.client.notAbsolute") });
|
||||
return;
|
||||
}
|
||||
if (!bridge?.validatePath) {
|
||||
setMoshValidation(null);
|
||||
return;
|
||||
}
|
||||
const timeoutId = setTimeout(() => {
|
||||
bridge.validatePath(moshPath, "file").then((result) => {
|
||||
if (result.exists && result.isFile && !result.isExecutable) {
|
||||
// Stays consistent with startMoshSession's isExecutableFile check —
|
||||
// a regular file without the execute bit can't actually launch.
|
||||
setMoshValidation({ valid: false, message: t("settings.terminal.mosh.client.notExecutable") });
|
||||
} else if (result.exists && result.isFile) {
|
||||
setMoshValidation({ valid: true });
|
||||
} else if (result.exists && result.isDirectory) {
|
||||
setMoshValidation({ valid: false, message: t("settings.terminal.mosh.client.isDirectory") });
|
||||
} else {
|
||||
setMoshValidation({ valid: false, message: t("settings.terminal.mosh.client.notFound") });
|
||||
}
|
||||
}).catch(() => {
|
||||
setMoshValidation(null);
|
||||
});
|
||||
}, 300);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [terminalSettings.moshClientPath, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (!bridge?.detectMoshClient) return;
|
||||
let canceled = false;
|
||||
bridge.detectMoshClient()
|
||||
.then((result) => {
|
||||
if (!canceled) {
|
||||
setAutoDetectedMoshPath(result.found && result.path ? result.path : null);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!canceled) setAutoDetectedMoshPath(null);
|
||||
});
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDetectMosh = useCallback(async () => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (!bridge?.detectMoshClient) return;
|
||||
setMoshDetectStatus({ kind: "running" });
|
||||
try {
|
||||
const result = await bridge.detectMoshClient();
|
||||
if (result.found && result.path) {
|
||||
setMoshDetectStatus({ kind: "found", path: result.path });
|
||||
// Auto-fill the input only when it is empty so we don't override
|
||||
// a value the user is in the middle of editing.
|
||||
if (!terminalSettings.moshClientPath) {
|
||||
updateTerminalSetting("moshClientPath", result.path);
|
||||
}
|
||||
} else {
|
||||
setMoshDetectStatus({ kind: "not-found", searchedPaths: result.searchedPaths });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Settings] detectMoshClient failed:", err);
|
||||
setMoshDetectStatus({ kind: "not-found", searchedPaths: [] });
|
||||
}
|
||||
}, [terminalSettings.moshClientPath, updateTerminalSetting]);
|
||||
|
||||
const handleBrowseMosh = useCallback(async () => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (!bridge?.pickMoshClient) return;
|
||||
try {
|
||||
const result = await bridge.pickMoshClient();
|
||||
if (!result.canceled && result.filePath) {
|
||||
updateTerminalSetting("moshClientPath", result.filePath);
|
||||
setMoshDetectStatus({ kind: "idle" });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Settings] pickMoshClient failed:", err);
|
||||
}
|
||||
}, [updateTerminalSetting]);
|
||||
|
||||
// Validate directory path when it changes
|
||||
useEffect(() => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
@@ -1158,74 +1045,6 @@ export default function SettingsTerminalTab(props: {
|
||||
className="w-48"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.terminal.mosh.client")}
|
||||
description={t("settings.terminal.mosh.client.desc")}
|
||||
>
|
||||
<div className="flex max-w-full flex-col gap-1.5" style={{ width: "min(420px, 100%)" }}>
|
||||
<div className="grid grid-cols-[minmax(220px,1fr)_auto_auto] gap-2">
|
||||
<Input
|
||||
value={terminalSettings.moshClientPath}
|
||||
placeholder={t("settings.terminal.mosh.client.placeholder")}
|
||||
onChange={(e) => updateTerminalSetting("moshClientPath", e.target.value)}
|
||||
className={cn(
|
||||
"flex-1",
|
||||
moshValidation && !moshValidation.valid && "border-destructive focus-visible:ring-destructive",
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDetectMosh}
|
||||
disabled={moshDetectStatus.kind === "running"}
|
||||
>
|
||||
{t("settings.terminal.mosh.detect")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBrowseMosh}
|
||||
>
|
||||
{t("settings.terminal.mosh.browse")}
|
||||
</Button>
|
||||
</div>
|
||||
{!terminalSettings.moshClientPath && autoDetectedMoshPath && moshDetectStatus.kind !== "found" && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("settings.terminal.mosh.autoDetected")}: <span className="break-all font-mono">{autoDetectedMoshPath}</span>
|
||||
</span>
|
||||
)}
|
||||
{moshValidation && !moshValidation.valid && moshValidation.message && (
|
||||
<span className="text-xs text-destructive flex items-center gap-1">
|
||||
<AlertCircle size={12} />
|
||||
{moshValidation.message}
|
||||
</span>
|
||||
)}
|
||||
{moshDetectStatus.kind === "found" && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("settings.terminal.mosh.detected")}: <span className="break-all font-mono">{moshDetectStatus.path}</span>
|
||||
</span>
|
||||
)}
|
||||
{moshDetectStatus.kind === "not-found" && (
|
||||
<span className="text-xs text-destructive flex items-start gap-1">
|
||||
<AlertCircle size={12} className="mt-0.5 shrink-0" />
|
||||
<span>
|
||||
{t("settings.terminal.mosh.notDetected")}
|
||||
{moshDetectStatus.searchedPaths.length > 0 && (
|
||||
<>
|
||||
{" "}
|
||||
<span className="text-muted-foreground">
|
||||
({moshDetectStatus.searchedPaths.slice(0, 4).join(", ")}
|
||||
{moshDetectStatus.searchedPaths.length > 4 ? "…" : ""})
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</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')}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -17,7 +17,11 @@ const sshHost: Host = {
|
||||
protocol: "ssh",
|
||||
};
|
||||
|
||||
const renderToolbar = (host: Host, status: "connecting" | "connected" | "disconnected" = "connected") =>
|
||||
const renderToolbar = (
|
||||
host: Host,
|
||||
status: "connecting" | "connected" | "disconnected" = "connected",
|
||||
props: Partial<React.ComponentProps<typeof TerminalToolbar>> = {},
|
||||
) =>
|
||||
renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
@@ -28,6 +32,7 @@ const renderToolbar = (host: Host, status: "connecting" | "connected" | "disconn
|
||||
onOpenSFTP: () => {},
|
||||
onOpenScripts: () => {},
|
||||
onOpenTheme: () => {},
|
||||
...props,
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -52,3 +57,15 @@ test("hides SFTP for local terminal sessions", () => {
|
||||
|
||||
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\)"/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -111,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>
|
||||
@@ -127,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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -754,14 +754,30 @@ 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,
|
||||
moshClientPath: ctx.terminalSettings?.moshClientPath || undefined,
|
||||
agentForwarding: ctx.host.agentForwarding,
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
|
||||
@@ -494,9 +494,8 @@ export interface TerminalSettings {
|
||||
x11Display: string; // Optional local X11 DISPLAY override (empty = use system DISPLAY/default)
|
||||
|
||||
// Mosh Connection
|
||||
// Absolute path to the local `mosh` client binary. Empty triggers
|
||||
// auto-discovery (PATH + Homebrew/MacPorts/nix fallbacks). When set,
|
||||
// the value is used as-is and a missing file produces a clear error.
|
||||
// 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)
|
||||
@@ -644,7 +643,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
localStartDir: '', // Empty = use home directory
|
||||
keepaliveInterval: 0, // 0 = disabled (use SSH library defaults)
|
||||
x11Display: '', // Empty = use DISPLAY/default local X server
|
||||
moshClientPath: '', // Empty = auto-detect mosh on PATH / common install dirs
|
||||
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
|
||||
@@ -781,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;
|
||||
@@ -805,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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]"}');
|
||||
});
|
||||
@@ -202,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;
|
||||
@@ -374,6 +383,7 @@ module.exports = {
|
||||
resolveCliFromPath,
|
||||
resolveClaudeAcpBinaryPath,
|
||||
toUnpackedAsarPath,
|
||||
isPlausibleCliVersionOutput,
|
||||
getShellEnv,
|
||||
invalidateShellEnvCache,
|
||||
serializeStreamChunk,
|
||||
|
||||
@@ -5,6 +5,7 @@ const {
|
||||
extractTrailingIdlePrompt,
|
||||
getFreshIdlePrompt,
|
||||
isDefaultPowerShellPromptLine,
|
||||
isPlausibleCliVersionOutput,
|
||||
trackSessionIdlePrompt,
|
||||
} = require("./shellUtils.cjs");
|
||||
|
||||
@@ -65,6 +66,16 @@ test("isDefaultPowerShellPromptLine matches default shapes and rejects look-alik
|
||||
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 = {};
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ const {
|
||||
shouldUseShellForCommand,
|
||||
resolveCliFromPath,
|
||||
resolveClaudeAcpBinaryPath,
|
||||
isPlausibleCliVersionOutput,
|
||||
getShellEnv,
|
||||
getFreshIdlePrompt,
|
||||
invalidateShellEnvCache,
|
||||
@@ -54,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(
|
||||
@@ -1428,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";
|
||||
@@ -1676,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.
|
||||
@@ -1690,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);
|
||||
@@ -1736,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.
|
||||
@@ -1745,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) => {
|
||||
@@ -2269,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,
|
||||
});
|
||||
@@ -2285,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();
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -35,6 +35,10 @@ const PREFERRED_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
// Match any private key file: id_* but not *.pub
|
||||
const SSH_KEY_PATTERN = /^id_[\w-]+$/;
|
||||
|
||||
function quoteShellArg(value) {
|
||||
return "'" + String(value).replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick check if file content looks like an SSH private key.
|
||||
* Rejects non-key files that happen to match the id_* filename pattern.
|
||||
@@ -1990,10 +1994,56 @@ async function getSessionPwd(event, payload) {
|
||||
resolve({ success: false, error: 'Timeout getting pwd' });
|
||||
}, 5000);
|
||||
|
||||
// Find the interactive shell's cwd silently via a separate exec channel.
|
||||
// Both the exec channel and the interactive shell share the same sshd
|
||||
// parent ($PPID). We exclude our own PID ($$) to avoid reading our own cwd.
|
||||
const cmd = `p=$(ps --ppid $PPID -o pid=,comm= 2>/dev/null | awk -v self=$$ '$1!=self && $2~/^(ba|z|fi|k|da)?sh$/{pid=$1}END{print pid}'); [ -n "$p" ] && readlink /proc/$p/cwd 2>/dev/null && exit 0; p=$(ps -e -o pid=,ppid=,comm= 2>/dev/null | awk -v pp=$PPID -v self=$$ '$1!=self && $2==pp && $3~/^(ba|z|fi|k|da)?sh$/{pid=$1}END{print pid}'); [ -n "$p" ] && readlink /proc/$p/cwd 2>/dev/null && exit 0; eval echo "~"`;
|
||||
// POSIX sh script that:
|
||||
// 1. Finds the sibling interactive shell under sshd ($PPID).
|
||||
// 2. Follows foreground child shells only, which covers bash->fish
|
||||
// without mistaking background shell scripts for the active shell.
|
||||
// 3. Reads /proc/<pid>/cwd via readlink.
|
||||
// 4. Falls back to the user's home directory if anything fails.
|
||||
//
|
||||
// `exec` makes sh replace the user's login shell (fish/bash/...)
|
||||
// so sh keeps the same PID and $PPID = sshd. Starting another shell
|
||||
// without exec would make $PPID point at the intermediate shell instead.
|
||||
const posixScript = `SELF=$$
|
||||
find_child_shell() {
|
||||
mode=$2
|
||||
ps -e -o pid=,ppid=,stat=,comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" -v mode="$mode" '
|
||||
$1 != self && $2 == pp && $4 ~ /^(ba|z|fi|k|da)?sh$/ {
|
||||
if (index($3, "+") > 0) { print $1; found=1; exit }
|
||||
if (mode != "foreground" && pid == "") pid=$1
|
||||
}
|
||||
END { if (!found && mode != "foreground" && pid != "") print pid }
|
||||
'
|
||||
}
|
||||
pid=$(find_child_shell "$PPID" any)
|
||||
while [ -n "$pid" ]; do
|
||||
child=$(find_child_shell "$pid" foreground)
|
||||
[ -n "$child" ] || break
|
||||
pid="$child"
|
||||
done
|
||||
if [ -n "$pid" ]; then
|
||||
cwd=$(readlink /proc/$pid/cwd 2>/dev/null)
|
||||
[ -n "$cwd" ] && printf '%s\\n' "$cwd" && exit 0
|
||||
fi
|
||||
emit_home() {
|
||||
case "$1" in
|
||||
/*) printf '%s\\n' "$1"; exit 0 ;;
|
||||
esac
|
||||
}
|
||||
home=$(eval echo "~" 2>/dev/null)
|
||||
emit_home "$home"
|
||||
uid=$(id -u 2>/dev/null)
|
||||
if [ -n "$uid" ]; then
|
||||
home=$(getent passwd "$uid" 2>/dev/null | awk -F: 'NR == 1 { print $6; exit }')
|
||||
emit_home "$home"
|
||||
home=$(awk -F: -v uid="$uid" '$3 == uid { print $6; exit }' /etc/passwd 2>/dev/null)
|
||||
emit_home "$home"
|
||||
fi
|
||||
home=$(id -P 2>/dev/null | awk -F: 'NR == 1 { print $9; exit }')
|
||||
emit_home "$home"
|
||||
emit_home "$HOME"
|
||||
exit 1`;
|
||||
const cmd = `exec sh -c ${quoteShellArg(posixScript)}`;
|
||||
|
||||
session.conn.exec(cmd, (err, stream) => {
|
||||
if (err) {
|
||||
|
||||
134
electron/bridges/terminalBridge.bareMoshClient.test.cjs
Normal file
134
electron/bridges/terminalBridge.bareMoshClient.test.cjs
Normal file
@@ -0,0 +1,134 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const { addBundledMoshDllPath, resolveBareMoshClient } = require("./terminalBridge.cjs");
|
||||
|
||||
function makeTmp() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-resolve-"));
|
||||
}
|
||||
|
||||
function writeExecutable(filePath) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, "#!/bin/sh\nexit 0\n");
|
||||
fs.chmodSync(filePath, 0o755);
|
||||
}
|
||||
|
||||
test("resolveBareMoshClient ignores explicit local mosh-client paths", () => {
|
||||
const tmp = makeTmp();
|
||||
const p = path.join(tmp, "mosh-client");
|
||||
writeExecutable(p);
|
||||
assert.equal(resolveBareMoshClient({ moshClientPath: p }, { projectRoot: tmp, resourcesPath: path.join(tmp, "missing") }), null);
|
||||
});
|
||||
|
||||
test("resolveBareMoshClient resolves only the bundled client", () => {
|
||||
const tmp = makeTmp();
|
||||
const bundled = path.join(tmp, "resources", "mosh", "linux-x64", "mosh-client");
|
||||
writeExecutable(bundled);
|
||||
|
||||
assert.equal(
|
||||
resolveBareMoshClient({}, {
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
projectRoot: tmp,
|
||||
resourcesPath: path.join(tmp, "missing"),
|
||||
}),
|
||||
bundled,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveBareMoshClient rejects relative explicit paths", () => {
|
||||
const tmp = makeTmp();
|
||||
const got = resolveBareMoshClient({ moshClientPath: "./mosh-client" }, {
|
||||
projectRoot: tmp,
|
||||
resourcesPath: path.join(tmp, "missing"),
|
||||
});
|
||||
assert.equal(got, null);
|
||||
});
|
||||
|
||||
test("resolveBareMoshClient ignores a non-executable explicit path", () => {
|
||||
const tmp = makeTmp();
|
||||
const p = path.join(tmp, "mosh-client");
|
||||
fs.writeFileSync(p, "");
|
||||
fs.chmodSync(p, 0o644);
|
||||
const got = resolveBareMoshClient({ moshClientPath: p }, {
|
||||
projectRoot: tmp,
|
||||
resourcesPath: path.join(tmp, "missing"),
|
||||
});
|
||||
assert.equal(got, null);
|
||||
});
|
||||
|
||||
test("resolveBareMoshClient ignores mosh-client on PATH", () => {
|
||||
const tmp = makeTmp();
|
||||
const p = path.join(tmp, "mosh-client");
|
||||
writeExecutable(p);
|
||||
|
||||
assert.equal(resolveBareMoshClient({}, {
|
||||
pathOverride: tmp,
|
||||
projectRoot: tmp,
|
||||
resourcesPath: path.join(tmp, "missing"),
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("mosh fallback messages do not point users to the removed Mosh settings field", () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, "terminalBridge.cjs"), "utf8");
|
||||
|
||||
assert.equal(source.includes("Settings → Terminal → Mosh"), false);
|
||||
});
|
||||
|
||||
test("mosh runtime does not fall back to system mosh or mosh-client", () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, "terminalBridge.cjs"), "utf8");
|
||||
|
||||
assert.equal(source.includes('resolvePosixExecutable("mosh-client"'), false);
|
||||
assert.equal(source.includes('findExecutable("mosh-client"'), false);
|
||||
assert.equal(source.includes('resolvePosixExecutable("mosh"'), false);
|
||||
assert.equal(source.includes('findExecutable("mosh"'), false);
|
||||
assert.equal(source.includes("brew install mosh"), false);
|
||||
});
|
||||
|
||||
test("Windows dev mosh-client prepends the bundled DLL directory", () => {
|
||||
const tmp = makeTmp();
|
||||
const client = path.join(tmp, "resources", "mosh", "win32-x64", "mosh-client.exe");
|
||||
const dllDir = path.join(tmp, "resources", "mosh", "win32-x64", "mosh-client-win32-x64-dlls");
|
||||
writeExecutable(client);
|
||||
fs.mkdirSync(dllDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dllDir, "cygwin1.dll"), "dll");
|
||||
|
||||
const env = { Path: "C:\\Windows\\System32" };
|
||||
addBundledMoshDllPath(env, client, { platform: "win32", arch: "x64" });
|
||||
|
||||
assert.equal(env.Path.split(";")[0], dllDir);
|
||||
});
|
||||
|
||||
test("Windows dev mosh-client updates the PATH key used by child process env", () => {
|
||||
const tmp = makeTmp();
|
||||
const client = path.join(tmp, "resources", "mosh", "win32-x64", "mosh-client.exe");
|
||||
const dllDir = path.join(tmp, "resources", "mosh", "win32-x64", "mosh-client-win32-x64-dlls");
|
||||
writeExecutable(client);
|
||||
fs.mkdirSync(dllDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dllDir, "cygwin1.dll"), "dll");
|
||||
|
||||
const env = {
|
||||
Path: "C:\\Windows\\System32",
|
||||
PATH: "C:\\Tools",
|
||||
};
|
||||
addBundledMoshDllPath(env, client, { platform: "win32", arch: "x64" });
|
||||
|
||||
assert.equal(env.PATH.split(";")[0], dllDir);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(env, "Path"), false);
|
||||
});
|
||||
|
||||
test("removed Mosh client detection APIs are not exposed to the renderer", () => {
|
||||
const bridgeSource = fs.readFileSync(path.join(__dirname, "terminalBridge.cjs"), "utf8");
|
||||
const preloadSource = fs.readFileSync(path.join(__dirname, "..", "preload.cjs"), "utf8");
|
||||
const globalTypes = fs.readFileSync(path.join(__dirname, "..", "..", "global.d.ts"), "utf8");
|
||||
|
||||
for (const source of [bridgeSource, preloadSource, globalTypes]) {
|
||||
assert.equal(source.includes("detectMoshClient"), false);
|
||||
assert.equal(source.includes("pickMoshClient"), false);
|
||||
assert.equal(source.includes("netcatty:mosh:detectClient"), false);
|
||||
assert.equal(source.includes("netcatty:mosh:pickClient"), false);
|
||||
}
|
||||
});
|
||||
101
electron/bridges/terminalBridge.bundledMosh.test.cjs
Normal file
101
electron/bridges/terminalBridge.bundledMosh.test.cjs
Normal file
@@ -0,0 +1,101 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const { bundledMoshClient } = require("./terminalBridge.cjs");
|
||||
|
||||
function makeTmp() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-"));
|
||||
}
|
||||
|
||||
function writeExecutable(filePath, contents = "#!/bin/sh\nexit 0\n") {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, contents);
|
||||
fs.chmodSync(filePath, 0o755);
|
||||
}
|
||||
|
||||
test("bundledMoshClient returns null when no binary is present", () => {
|
||||
const projectRoot = makeTmp();
|
||||
const result = bundledMoshClient({
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
projectRoot,
|
||||
resourcesPath: path.join(projectRoot, "missing-resources"),
|
||||
});
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("bundledMoshClient prefers the packaged Resources path", () => {
|
||||
const projectRoot = makeTmp();
|
||||
const resourcesPath = makeTmp();
|
||||
const packagedBin = path.join(resourcesPath, "mosh", "mosh-client");
|
||||
writeExecutable(packagedBin);
|
||||
|
||||
const devBin = path.join(projectRoot, "resources", "mosh", "linux-x64", "mosh-client");
|
||||
writeExecutable(devBin);
|
||||
|
||||
const result = bundledMoshClient({ platform: "linux", arch: "x64", projectRoot, resourcesPath });
|
||||
assert.equal(result, packagedBin);
|
||||
});
|
||||
|
||||
test("bundledMoshClient falls back to the project-root dev path", () => {
|
||||
const projectRoot = makeTmp();
|
||||
const devBin = path.join(projectRoot, "resources", "mosh", "linux-x64", "mosh-client");
|
||||
writeExecutable(devBin);
|
||||
|
||||
const result = bundledMoshClient({
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
projectRoot,
|
||||
resourcesPath: path.join(projectRoot, "missing"),
|
||||
});
|
||||
assert.equal(result, devBin);
|
||||
});
|
||||
|
||||
test("bundledMoshClient looks under darwin-universal regardless of arch on macOS", () => {
|
||||
const projectRoot = makeTmp();
|
||||
const universalBin = path.join(projectRoot, "resources", "mosh", "darwin-universal", "mosh-client");
|
||||
writeExecutable(universalBin);
|
||||
|
||||
for (const arch of ["arm64", "x64"]) {
|
||||
const result = bundledMoshClient({
|
||||
platform: "darwin",
|
||||
arch,
|
||||
projectRoot,
|
||||
resourcesPath: path.join(projectRoot, "missing"),
|
||||
});
|
||||
assert.equal(result, universalBin, `arch=${arch}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("bundledMoshClient uses .exe basename on win32 (when running on a POSIX host)", { skip: process.platform === "win32" }, () => {
|
||||
const projectRoot = makeTmp();
|
||||
const winBin = path.join(projectRoot, "resources", "mosh", "win32-x64", "mosh-client.exe");
|
||||
writeExecutable(winBin);
|
||||
|
||||
const result = bundledMoshClient({
|
||||
platform: "win32",
|
||||
arch: "x64",
|
||||
projectRoot,
|
||||
resourcesPath: path.join(projectRoot, "missing"),
|
||||
});
|
||||
assert.equal(result, winBin);
|
||||
});
|
||||
|
||||
test("bundledMoshClient ignores non-executable matches", () => {
|
||||
const projectRoot = makeTmp();
|
||||
const candidate = path.join(projectRoot, "resources", "mosh", "linux-x64", "mosh-client");
|
||||
fs.mkdirSync(path.dirname(candidate), { recursive: true });
|
||||
fs.writeFileSync(candidate, "");
|
||||
fs.chmodSync(candidate, 0o644);
|
||||
|
||||
const result = bundledMoshClient({
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
projectRoot,
|
||||
resourcesPath: path.join(projectRoot, "missing"),
|
||||
});
|
||||
assert.equal(result, null);
|
||||
});
|
||||
@@ -19,6 +19,7 @@ const { detectShellKind } = require("./ai/ptyExec.cjs");
|
||||
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
|
||||
const { createZmodemSentry } = require("./zmodemHelper.cjs");
|
||||
const { discoverShells } = require("./shellDiscovery.cjs");
|
||||
const moshHandshake = require("./moshHandshake.cjs");
|
||||
|
||||
// Shared references
|
||||
let sessions = null;
|
||||
@@ -229,12 +230,17 @@ function isWindowsAppExecutionAlias(filePath) {
|
||||
return !!windowsAppsDir && normalizedPath.startsWith(`${windowsAppsDir}${path.sep}`);
|
||||
}
|
||||
|
||||
function findExecutable(name) {
|
||||
function findExecutable(name, opts = {}) {
|
||||
if (process.platform !== "win32") return name;
|
||||
|
||||
const { execFileSync } = require("child_process");
|
||||
try {
|
||||
const result = execFileSync("where.exe", [name], { encoding: "utf8" });
|
||||
const pathOverride = Object.prototype.hasOwnProperty.call(opts, "pathOverride")
|
||||
? opts.pathOverride
|
||||
: process.env.PATH;
|
||||
const env = { ...process.env, PATH: pathOverride || "" };
|
||||
const whereExe = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "where.exe");
|
||||
const result = execFileSync(fs.existsSync(whereExe) ? whereExe : "where.exe", [name], { encoding: "utf8", env });
|
||||
const candidates = result
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
@@ -249,7 +255,6 @@ function findExecutable(name) {
|
||||
console.warn(`Could not find ${name} via where.exe:`, err.message);
|
||||
}
|
||||
|
||||
const path = require("node:path");
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(name)) return name;
|
||||
|
||||
const commonPaths = [];
|
||||
@@ -779,229 +784,388 @@ async function startTelnetSession(event, options) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a Mosh session using system mosh-client
|
||||
* Resolve Netcatty's bundled bare `mosh-client` binary.
|
||||
*
|
||||
* Returns the absolute path or null.
|
||||
*/
|
||||
async function startMoshSession(event, options) {
|
||||
const sessionId = options.sessionId || randomUUID();
|
||||
function resolveBareMoshClient(_options, opts = {}) {
|
||||
return bundledMoshClient(opts);
|
||||
}
|
||||
|
||||
function getEnvPathKey(env) {
|
||||
const pathKeys = Object.keys(env).filter((key) => key.toLowerCase() === "path");
|
||||
if (pathKeys.length === 0) return "PATH";
|
||||
return pathKeys.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))[0];
|
||||
}
|
||||
|
||||
function getEnvPathDelimiter(opts = {}) {
|
||||
return (opts.platform || process.platform) === "win32" ? ";" : path.delimiter;
|
||||
}
|
||||
|
||||
function normalizeEnvPathPart(part, opts = {}) {
|
||||
const pathApi = (opts.platform || process.platform) === "win32" ? path.win32 : path;
|
||||
return pathApi.normalize(part).toLowerCase();
|
||||
}
|
||||
|
||||
function prependEnvPath(env, dir, opts = {}) {
|
||||
if (!dir) return env;
|
||||
const pathKey = getEnvPathKey(env);
|
||||
const duplicatePathKeys = Object.keys(env)
|
||||
.filter((key) => key.toLowerCase() === "path" && key !== pathKey);
|
||||
for (const key of duplicatePathKeys) {
|
||||
delete env[key];
|
||||
}
|
||||
const current = env[pathKey] || "";
|
||||
const delimiter = getEnvPathDelimiter(opts);
|
||||
const parts = String(current).split(delimiter).filter(Boolean);
|
||||
const normalizedDir = normalizeEnvPathPart(dir, opts);
|
||||
if (!parts.some((part) => normalizeEnvPathPart(part, opts) === normalizedDir)) {
|
||||
env[pathKey] = current ? `${dir}${delimiter}${current}` : dir;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function findBundledMoshDllDir(bareClient, opts = {}) {
|
||||
const platform = opts.platform || process.platform;
|
||||
if (platform !== "win32" || !bareClient) return null;
|
||||
|
||||
const clientDir = path.dirname(bareClient);
|
||||
const arch = opts.arch || process.arch;
|
||||
const preferred = path.join(clientDir, `mosh-client-win32-${arch}-dlls`);
|
||||
if (fs.existsSync(preferred) && fs.statSync(preferred).isDirectory()) {
|
||||
return preferred;
|
||||
}
|
||||
|
||||
try {
|
||||
const match = fs.readdirSync(clientDir)
|
||||
.map((name) => path.join(clientDir, name))
|
||||
.find((candidate) => {
|
||||
const name = path.basename(candidate);
|
||||
return /^mosh-client-win32-.+-dlls$/.test(name)
|
||||
&& fs.existsSync(candidate)
|
||||
&& fs.statSync(candidate).isDirectory();
|
||||
});
|
||||
return match || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function addBundledMoshDllPath(env, bareClient, opts = {}) {
|
||||
const dllDir = findBundledMoshDllDir(bareClient, opts);
|
||||
return dllDir ? prependEnvPath(env, dllDir, opts) : env;
|
||||
}
|
||||
|
||||
function createMoshSshPasswordResponder(sshPty, password) {
|
||||
if (typeof password !== "string" || password.length === 0) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
let answered = false;
|
||||
let tail = "";
|
||||
|
||||
return (chunk) => {
|
||||
if (answered) return;
|
||||
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk || "");
|
||||
if (!text) return;
|
||||
|
||||
tail = (tail + text).slice(-512);
|
||||
if (!/(^|[\r\n]).*password:\s*$/i.test(tail)) return;
|
||||
|
||||
answered = true;
|
||||
sshPty.write(`${password}\r`);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase-2 / Phase-3b path: run the SSH bootstrap ourselves *inside the
|
||||
* user's terminal PTY* so password / 2FA / known-hosts prompts render
|
||||
* naturally, then swap to a bare `mosh-client` once `MOSH CONNECT` is
|
||||
* detected. Replaces both the upstream Mosh Perl wrapper and the
|
||||
* earlier non-PTY (BatchMode-style) implementation that couldn't show
|
||||
* prompts.
|
||||
*
|
||||
* State machine:
|
||||
* ssh-spawn ──onData──▶ sniffer.feed ──visible──▶ renderer
|
||||
* └──parsed──▶ remember port/key
|
||||
* ssh-pty exits ─────▶ if parsed: spawn mosh-client + swap
|
||||
* else: surface error
|
||||
*
|
||||
* The session keeps a stable sessionId across the swap. session.proc
|
||||
* is updated atomically before any user input arrives at the new
|
||||
* mosh-client (writeToSession / resizeSession route through
|
||||
* session.proc, so they automatically address the right process). The
|
||||
* ZMODEM sentry is recreated for the new proc because its
|
||||
* writeToRemote closure captures the previous handle.
|
||||
*
|
||||
* Caller has already validated that `bareClient` and `sshExe` exist.
|
||||
*/
|
||||
async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe }) {
|
||||
const sessionId = options.sessionId || randomUUID();
|
||||
const cols = options.cols || 80;
|
||||
const rows = options.rows || 24;
|
||||
|
||||
// Resolve the mosh client to an absolute path before spawning. Bare names
|
||||
// rely on the spawn-time PATH search, which on macOS GUI apps is reduced to
|
||||
// `/usr/bin:/bin:/usr/sbin:/sbin` and silently fails for Homebrew installs
|
||||
// (see issue #842). On Windows keep the existing behaviour.
|
||||
//
|
||||
// Resolution must consider the same PATH the spawned process will see —
|
||||
// host-level `environmentVariables.PATH` is merged into the child env
|
||||
// below, so the resolver checks that merged value first to avoid
|
||||
// rejecting a binary the child would actually have found.
|
||||
const optionsEnv = options.env || {};
|
||||
const mergedPathForResolution = Object.prototype.hasOwnProperty.call(optionsEnv, "PATH")
|
||||
? optionsEnv.PATH
|
||||
: process.env.PATH;
|
||||
const lang = optionsEnv.LANG || resolveLangFromCharsetForMosh(options.charset);
|
||||
|
||||
let moshCmd;
|
||||
let resolvedMoshDir = null;
|
||||
// 1. Honor user-supplied moshClientPath (Settings → Terminal → Mosh).
|
||||
// Strict failure: a missing/non-executable file produces a clear error
|
||||
// instead of silently falling back, so users notice typos / stale paths.
|
||||
const explicitClient = typeof options.moshClientPath === "string" ? options.moshClientPath.trim() : "";
|
||||
if (explicitClient) {
|
||||
const expanded = expandHomePath(explicitClient);
|
||||
// Reject relative paths up front. validatePath in the renderer is shared
|
||||
// with localShell and resolves bare names through PATH (so "mosh.exe"
|
||||
// would look valid in the UI), but here moshClientPath is taken as a
|
||||
// literal filesystem path and any non-absolute value would be resolved
|
||||
// against the app's cwd and silently fail.
|
||||
if (!path.isAbsolute(expanded)) {
|
||||
throw new Error(
|
||||
`Mosh client path must be absolute: "${explicitClient}". Use Settings → Terminal → Mosh to pick the binary, leave it empty to auto-detect, or enter an absolute path.`,
|
||||
);
|
||||
}
|
||||
if (!isExecutableFile(expanded)) {
|
||||
throw new Error(
|
||||
`Configured Mosh client not usable: ${explicitClient}. Update Settings → Terminal → Mosh, leave it empty to auto-detect, or pick another binary.`,
|
||||
);
|
||||
}
|
||||
moshCmd = path.resolve(expanded);
|
||||
// Always remember the directory so we can extend PATH and locate
|
||||
// mosh-client / ssh helpers regardless of platform — Windows
|
||||
// installs outside %PATH% otherwise can't resolve siblings even
|
||||
// though the wrapper itself runs.
|
||||
resolvedMoshDir = path.dirname(moshCmd);
|
||||
} else if (process.platform === "win32") {
|
||||
moshCmd = findExecutable("mosh") || "mosh.exe";
|
||||
} else {
|
||||
const resolved = resolvePosixExecutable("mosh", { pathOverride: mergedPathForResolution });
|
||||
if (!resolved) {
|
||||
const installHint =
|
||||
process.platform === "darwin"
|
||||
? "macOS: brew install mosh"
|
||||
: "Linux: sudo apt install mosh / sudo dnf install mosh / sudo pacman -S mosh";
|
||||
throw new Error(
|
||||
`Mosh client not found on PATH. Install it (${installHint}) or place the 'mosh' binary somewhere on PATH such as /opt/homebrew/bin or /usr/local/bin. You can also point Settings → Terminal → Mosh at an absolute path.`,
|
||||
);
|
||||
}
|
||||
moshCmd = resolved;
|
||||
resolvedMoshDir = path.dirname(resolved);
|
||||
const { args: sshArgs } = moshHandshake.buildSshHandshakeCommand({
|
||||
host: options.hostname,
|
||||
port: options.port,
|
||||
username: options.username,
|
||||
lang,
|
||||
moshServer: moshHandshake.buildMoshServerCommand(options.moshServerPath),
|
||||
});
|
||||
|
||||
const sshEnv = { ...process.env, ...optionsEnv, TERM: "xterm-256color" };
|
||||
if (options.agentForwarding && process.env.SSH_AUTH_SOCK) {
|
||||
sshEnv.SSH_AUTH_SOCK = process.env.SSH_AUTH_SOCK;
|
||||
}
|
||||
|
||||
const args = [];
|
||||
const sshPty = pty.spawn(sshExe, sshArgs, {
|
||||
cols,
|
||||
rows,
|
||||
env: sshEnv,
|
||||
cwd: os.homedir(),
|
||||
encoding: null,
|
||||
});
|
||||
|
||||
if (options.port && options.port !== 22) {
|
||||
args.push('--ssh=ssh -p ' + options.port);
|
||||
}
|
||||
|
||||
if (options.moshServerPath) {
|
||||
args.push('--server=' + options.moshServerPath);
|
||||
}
|
||||
|
||||
const userHost = options.username
|
||||
? `${options.username}@${options.hostname}`
|
||||
: options.hostname;
|
||||
args.push(userHost);
|
||||
|
||||
const resolveLangFromCharset = (charset) => {
|
||||
if (!charset) return 'en_US.UTF-8';
|
||||
const trimmed = String(charset).trim();
|
||||
if (/^utf-?8$/i.test(trimmed) || /^utf8$/i.test(trimmed)) {
|
||||
return 'en_US.UTF-8';
|
||||
}
|
||||
return trimmed;
|
||||
const session = {
|
||||
proc: sshPty,
|
||||
pty: sshPty,
|
||||
type: "mosh",
|
||||
protocol: "mosh",
|
||||
webContentsId: event.sender.id,
|
||||
hostname: options.hostname || "",
|
||||
username: options.username || "",
|
||||
label: options.label || options.hostname || "Mosh Session",
|
||||
shellKind: "posix",
|
||||
shellExecutable: "remote-shell",
|
||||
flushPendingData: null,
|
||||
lastIdlePrompt: "",
|
||||
lastIdlePromptAt: 0,
|
||||
_promptTrackTail: "",
|
||||
cols,
|
||||
rows,
|
||||
moshHandshakePhase: "ssh",
|
||||
moshHandshakeResult: null,
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
...optionsEnv,
|
||||
TERM: 'xterm-256color',
|
||||
LANG: resolveLangFromCharset(options.charset),
|
||||
};
|
||||
if (options.sessionLog?.enabled && options.sessionLog?.directory) {
|
||||
sessionLogStreamManager.startStream(sessionId, {
|
||||
hostLabel: options.label || options.hostname,
|
||||
hostname: options.hostname,
|
||||
directory: options.sessionLog.directory,
|
||||
format: options.sessionLog.format || "txt",
|
||||
startTime: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// The mosh wrapper is a Perl script that exec's `mosh-client` (and `ssh`)
|
||||
// by name, so it needs them on PATH. Prepend the resolved mosh's directory
|
||||
// to the env PATH (typical layout: mosh + mosh-client live side by side).
|
||||
// Also point MOSH_CLIENT at the absolute mosh-client when present, so the
|
||||
// wrapper picks it up even if PATH is overridden downstream.
|
||||
if (resolvedMoshDir) {
|
||||
const sep = path.delimiter; // ":" on POSIX, ";" on Win32
|
||||
const existingPath = env.PATH || "";
|
||||
const onPath = existingPath
|
||||
.split(sep)
|
||||
.some((p) => p && path.normalize(p) === path.normalize(resolvedMoshDir));
|
||||
if (!onPath) {
|
||||
env.PATH = existingPath ? `${resolvedMoshDir}${sep}${existingPath}` : resolvedMoshDir;
|
||||
}
|
||||
if (!env.MOSH_CLIENT) {
|
||||
const clientCandidates =
|
||||
process.platform === "win32"
|
||||
? ["mosh-client.exe", "mosh-client.bat", "mosh-client.cmd", "mosh-client"]
|
||||
: ["mosh-client"];
|
||||
for (const name of clientCandidates) {
|
||||
const candidate = path.join(resolvedMoshDir, name);
|
||||
if (isExecutableFile(candidate)) {
|
||||
env.MOSH_CLIENT = candidate;
|
||||
break;
|
||||
}
|
||||
const { bufferData, flush } = createPtyBuffer((data) => {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data });
|
||||
});
|
||||
session.flushPendingData = flush;
|
||||
|
||||
const sniffer = moshHandshake.createMoshConnectSniffer();
|
||||
const respondToPasswordPrompt = createMoshSshPasswordResponder(sshPty, options.password);
|
||||
|
||||
// Forward bytes from the ssh PTY to the renderer, redacting the
|
||||
// MOSH CONNECT magic line. ZMODEM is intentionally not enabled
|
||||
// during handshake — it can't appear during ssh login output and
|
||||
// would only complicate the swap.
|
||||
sshPty.onData((chunk) => {
|
||||
const { visible, parsed } = sniffer.feed(chunk);
|
||||
if (visible && (visible.length || (typeof visible === "string" && visible))) {
|
||||
const str = Buffer.isBuffer(visible) ? visible.toString("utf8") : visible;
|
||||
if (str.length > 0) {
|
||||
respondToPasswordPrompt(str);
|
||||
bufferData(str);
|
||||
sessionLogStreamManager.appendData(sessionId, str);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (parsed && session.moshHandshakePhase === "ssh") {
|
||||
session.moshHandshakePhase = "parsed";
|
||||
session.moshHandshakeResult = parsed;
|
||||
}
|
||||
});
|
||||
|
||||
sshPty.onExit(({ exitCode, signal }) => {
|
||||
if (sessions.get(sessionId) !== session || session.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.moshHandshakePhase === "parsed" && session.moshHandshakeResult) {
|
||||
try {
|
||||
swapToMoshClient(session, options, {
|
||||
bareClient,
|
||||
optionsEnv,
|
||||
lang,
|
||||
parsed: session.moshHandshakeResult,
|
||||
bufferData,
|
||||
flush,
|
||||
sessionId,
|
||||
});
|
||||
} catch (err) {
|
||||
flush();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", {
|
||||
sessionId,
|
||||
reason: "error",
|
||||
error: `Failed to spawn mosh-client: ${err.message}`,
|
||||
});
|
||||
sessions.delete(sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handshake failed before MOSH CONNECT — ssh exited without parse.
|
||||
// The user has already seen the failure output (auth error, host
|
||||
// key warning, etc). Just surface a session-exit with the code so
|
||||
// the renderer can label the session "disconnected".
|
||||
flush();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", {
|
||||
sessionId,
|
||||
exitCode,
|
||||
signal,
|
||||
reason: "error",
|
||||
});
|
||||
sessions.delete(sessionId);
|
||||
});
|
||||
|
||||
return { sessionId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mid-session PTY swap: replaces session.proc (currently the ssh
|
||||
* handshake PTY) with a freshly-spawned mosh-client PTY, re-wiring
|
||||
* the data / exit listeners and (on POSIX) recreating the ZMODEM
|
||||
* sentry whose writeToRemote closure captured the previous handle.
|
||||
*/
|
||||
function swapToMoshClient(session, options, ctx) {
|
||||
const { bareClient, optionsEnv, lang, parsed, bufferData, flush, sessionId } = ctx;
|
||||
|
||||
const env = moshHandshake.buildMoshClientEnv({
|
||||
baseEnv: { ...process.env, ...optionsEnv, TERM: "xterm-256color" },
|
||||
key: parsed.key,
|
||||
lang,
|
||||
});
|
||||
addBundledMoshDllPath(env, bareClient);
|
||||
if (options.agentForwarding && process.env.SSH_AUTH_SOCK) {
|
||||
env.SSH_AUTH_SOCK = process.env.SSH_AUTH_SOCK;
|
||||
}
|
||||
|
||||
try {
|
||||
const proc = pty.spawn(moshCmd, args, {
|
||||
cols,
|
||||
rows,
|
||||
env,
|
||||
cwd: os.homedir(),
|
||||
encoding: null, // Return Buffer for ZMODEM binary support
|
||||
const { command, args: clientArgs } = moshHandshake.buildMoshClientCommand({
|
||||
moshClientPath: bareClient,
|
||||
host: parsed.host || options.hostname,
|
||||
port: parsed.port,
|
||||
});
|
||||
|
||||
const mcPty = pty.spawn(command, clientArgs, {
|
||||
cols: session.cols,
|
||||
rows: session.rows,
|
||||
env,
|
||||
cwd: os.homedir(),
|
||||
encoding: null,
|
||||
});
|
||||
|
||||
// Atomic swap — writeToSession / resizeSession both read
|
||||
// session.proc lazily, so any keystroke that arrives after this
|
||||
// assignment goes to mosh-client, not the dead ssh PTY.
|
||||
session.proc = mcPty;
|
||||
session.pty = mcPty;
|
||||
session.moshHandshakePhase = "mosh-client";
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
const decoder = new StringDecoder("utf8");
|
||||
const sentry = createZmodemSentry({
|
||||
sessionId,
|
||||
onData(buf) {
|
||||
const str = decoder.write(buf);
|
||||
if (!str) return;
|
||||
trackSessionIdlePrompt(session, str);
|
||||
bufferData(str);
|
||||
sessionLogStreamManager.appendData(sessionId, str);
|
||||
},
|
||||
writeToRemote(buf) {
|
||||
try { return mcPty.write(buf); } catch { return true; }
|
||||
},
|
||||
getWebContents() { return electronModule.webContents.fromId(session.webContentsId); },
|
||||
protocolLabel: "Mosh",
|
||||
});
|
||||
|
||||
const session = {
|
||||
proc,
|
||||
pty: proc,
|
||||
type: 'mosh',
|
||||
protocol: 'mosh',
|
||||
webContentsId: event.sender.id,
|
||||
hostname: options.hostname || '',
|
||||
username: options.username || '',
|
||||
label: options.label || options.hostname || 'Mosh Session',
|
||||
shellKind: 'posix',
|
||||
shellExecutable: 'remote-shell',
|
||||
flushPendingData: null,
|
||||
lastIdlePrompt: "",
|
||||
lastIdlePromptAt: 0,
|
||||
_promptTrackTail: "",
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
// Start real-time session log stream if configured
|
||||
if (options.sessionLog?.enabled && options.sessionLog?.directory) {
|
||||
sessionLogStreamManager.startStream(sessionId, {
|
||||
hostLabel: options.label || options.hostname,
|
||||
hostname: options.hostname,
|
||||
directory: options.sessionLog.directory,
|
||||
format: options.sessionLog.format || "txt",
|
||||
startTime: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
const { bufferData: bufferMoshData, flush: flushMosh } = createPtyBuffer((data) => {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data });
|
||||
session.zmodemSentry = sentry;
|
||||
mcPty.onData((data) => sentry.consume(data));
|
||||
} else {
|
||||
mcPty.onData((data) => {
|
||||
const str = data.toString("utf8");
|
||||
trackSessionIdlePrompt(session, str);
|
||||
bufferData(str);
|
||||
sessionLogStreamManager.appendData(sessionId, str);
|
||||
});
|
||||
session.flushPendingData = flushMosh;
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
const moshDecoder = new StringDecoder("utf8");
|
||||
const moshZmodemSentry = createZmodemSentry({
|
||||
sessionId,
|
||||
onData(buf) {
|
||||
const str = moshDecoder.write(buf);
|
||||
if (!str) return;
|
||||
trackSessionIdlePrompt(session, str);
|
||||
bufferMoshData(str);
|
||||
sessionLogStreamManager.appendData(sessionId, str);
|
||||
},
|
||||
writeToRemote(buf) {
|
||||
try { return proc.write(buf); } catch { return true; }
|
||||
},
|
||||
getWebContents() {
|
||||
return electronModule.webContents.fromId(session.webContentsId);
|
||||
},
|
||||
label: "Mosh",
|
||||
});
|
||||
session.zmodemSentry = moshZmodemSentry;
|
||||
|
||||
proc.onData((data) => {
|
||||
moshZmodemSentry.consume(data);
|
||||
});
|
||||
} else {
|
||||
proc.onData((data) => {
|
||||
trackSessionIdlePrompt(session, data);
|
||||
bufferMoshData(data);
|
||||
sessionLogStreamManager.appendData(sessionId, data);
|
||||
});
|
||||
}
|
||||
|
||||
proc.onExit((evt) => {
|
||||
flushMosh();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
ptyProcessTree.unregisterPid(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
// Mosh non-zero exit typically means connection/auth failure — show error UI
|
||||
contents?.send("netcatty:exit", { sessionId, ...evt, reason: evt.exitCode === 0 ? "exited" : "error" });
|
||||
});
|
||||
|
||||
return { sessionId };
|
||||
} catch (err) {
|
||||
console.error("[Mosh] Failed to start mosh session:", err.message);
|
||||
throw err;
|
||||
}
|
||||
|
||||
mcPty.onExit(({ exitCode, signal }) => {
|
||||
if (sessions.get(sessionId) !== session || session.closed) {
|
||||
return;
|
||||
}
|
||||
flush();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", {
|
||||
sessionId,
|
||||
exitCode,
|
||||
signal,
|
||||
reason: exitCode !== 0 ? "error" : "exited",
|
||||
});
|
||||
sessions.delete(sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
function resolveLangFromCharsetForMosh(charset) {
|
||||
if (!charset) return "en_US.UTF-8";
|
||||
const trimmed = String(charset).trim();
|
||||
if (/^utf-?8$/i.test(trimmed) || /^utf8$/i.test(trimmed)) return "en_US.UTF-8";
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a Mosh session.
|
||||
*
|
||||
* Netcatty only uses its bundled `mosh-client` binary here. System
|
||||
* `mosh` / `mosh-client` installs are intentionally ignored so dev,
|
||||
* CI, and release builds exercise the same binary.
|
||||
*/
|
||||
async function startMoshSession(event, options, opts = {}) {
|
||||
const optionsEnv = options.env || {};
|
||||
// Program discovery must consider the same PATH the spawned PTY will
|
||||
// receive, including host-level terminal environment overrides.
|
||||
const mergedPathForResolution = Object.prototype.hasOwnProperty.call(optionsEnv, "PATH")
|
||||
? optionsEnv.PATH
|
||||
: process.env.PATH;
|
||||
|
||||
const bareClient = resolveBareMoshClient(options, opts.moshClientLookup || {});
|
||||
if (!bareClient) {
|
||||
throw new Error(
|
||||
"Bundled mosh-client not found. Run `npm run fetch:mosh:dev` for local dev, " +
|
||||
"or ensure release packaging downloads the mosh binary release before building.",
|
||||
);
|
||||
}
|
||||
|
||||
const sshExe = moshHandshake.resolveSshExecutable({
|
||||
findExecutable: (name) => (
|
||||
process.platform === "win32"
|
||||
? findExecutable(name, { pathOverride: mergedPathForResolution })
|
||||
: resolvePosixExecutable(name, { pathOverride: mergedPathForResolution })
|
||||
),
|
||||
fileExists: (p) => isExecutableFile(p) || fs.existsSync(p),
|
||||
});
|
||||
if (!sshExe) {
|
||||
throw new Error("OpenSSH client not found. Netcatty needs ssh to start the remote mosh-server handshake.");
|
||||
}
|
||||
|
||||
return startMoshSessionViaHandshake(event, options, { bareClient, sshExe });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1183,6 +1347,8 @@ function writeToSession(event, payload) {
|
||||
function resizeSession(event, payload) {
|
||||
const session = sessions.get(payload.sessionId);
|
||||
if (!session) return;
|
||||
if (Number.isFinite(payload.cols)) session.cols = payload.cols;
|
||||
if (Number.isFinite(payload.rows)) session.rows = payload.rows;
|
||||
|
||||
try {
|
||||
if (session.stream) {
|
||||
@@ -1214,6 +1380,7 @@ function resizeSession(event, payload) {
|
||||
function closeSession(event, payload) {
|
||||
const session = sessions.get(payload.sessionId);
|
||||
if (!session) return;
|
||||
session.closed = true;
|
||||
|
||||
try {
|
||||
session.zmodemSentry?.cancel();
|
||||
@@ -1273,8 +1440,6 @@ function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:local:start", startLocalSession);
|
||||
ipcMain.handle("netcatty:telnet:start", startTelnetSession);
|
||||
ipcMain.handle("netcatty:mosh:start", startMoshSession);
|
||||
ipcMain.handle("netcatty:mosh:detectClient", () => detectMoshClient());
|
||||
ipcMain.handle("netcatty:mosh:pickClient", () => pickMoshClient());
|
||||
ipcMain.handle("netcatty:serial:start", startSerialSession);
|
||||
ipcMain.handle("netcatty:serial:list", listSerialPorts);
|
||||
ipcMain.handle("netcatty:local:defaultShell", getDefaultShell);
|
||||
@@ -1369,69 +1534,45 @@ function validatePath(event, payload) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the same auto-discovery startMoshSession uses, surfacing the result
|
||||
* (and the search list when nothing was found) to the Settings UI.
|
||||
* Locate the mosh-client binary bundled by electron-builder via
|
||||
* `extraResources` (see electron-builder.config.cjs and
|
||||
* .github/workflows/build-mosh-binaries.yml).
|
||||
*
|
||||
* Returns an absolute path when the binary is on disk, otherwise null.
|
||||
* In dev / non-packaged runs the path is computed against the project
|
||||
* root so the helper is testable without packaging the app.
|
||||
*
|
||||
* Note this returns the network-protocol `mosh-client`, not the `mosh`
|
||||
* wrapper script. Netcatty drives the SSH bootstrap itself and then
|
||||
* launches this bundled client directly.
|
||||
*/
|
||||
function detectMoshClient() {
|
||||
if (process.platform === "win32") {
|
||||
const resolved = findExecutable("mosh");
|
||||
const found = !!resolved && resolved !== "mosh" && fs.existsSync(resolved);
|
||||
return {
|
||||
platform: "win32",
|
||||
found,
|
||||
path: found ? resolved : null,
|
||||
searchedPaths: [],
|
||||
};
|
||||
}
|
||||
const dirs = [];
|
||||
const seen = new Set();
|
||||
for (const dir of (process.env.PATH || "").split(":")) {
|
||||
if (dir && !seen.has(dir)) { seen.add(dir); dirs.push(dir); }
|
||||
}
|
||||
for (const dir of POSIX_EXTRA_PATH_DIRS) {
|
||||
if (!seen.has(dir)) { seen.add(dir); dirs.push(dir); }
|
||||
}
|
||||
const home = process.env.HOME;
|
||||
if (home) {
|
||||
for (const sub of [".nix-profile/bin", ".cargo/bin", ".local/bin"]) {
|
||||
const dir = path.join(home, sub);
|
||||
if (!seen.has(dir)) { seen.add(dir); dirs.push(dir); }
|
||||
}
|
||||
}
|
||||
const resolved = resolvePosixExecutable("mosh");
|
||||
return {
|
||||
platform: process.platform,
|
||||
found: !!resolved,
|
||||
path: resolved,
|
||||
searchedPaths: dirs,
|
||||
};
|
||||
}
|
||||
function bundledMoshClient(opts = {}) {
|
||||
const isWin = (opts.platform || process.platform) === "win32";
|
||||
const basename = isWin ? "mosh-client.exe" : "mosh-client";
|
||||
|
||||
/**
|
||||
* Open a native file picker so the user can select a Mosh client binary.
|
||||
* Returns { canceled, filePath } so the renderer can decide what to do.
|
||||
*/
|
||||
async function pickMoshClient() {
|
||||
const { dialog, BrowserWindow } = electronModule || {};
|
||||
if (!dialog) {
|
||||
return { canceled: true, filePath: null };
|
||||
// Packaged: <Resources>/mosh/mosh-client[.exe]
|
||||
const resourcesPath = opts.resourcesPath || process.resourcesPath;
|
||||
if (resourcesPath) {
|
||||
const packaged = path.join(resourcesPath, "mosh", basename);
|
||||
if (fs.existsSync(packaged) && isExecutableFile(packaged)) return packaged;
|
||||
}
|
||||
const win = BrowserWindow?.getFocusedWindow?.() || undefined;
|
||||
const isWin = process.platform === "win32";
|
||||
const result = await dialog.showOpenDialog(win, {
|
||||
title: "Select Mosh client",
|
||||
properties: ["openFile", "showHiddenFiles"],
|
||||
filters: isWin
|
||||
? [
|
||||
{ name: "Executables", extensions: ["exe", "bat", "cmd"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
]
|
||||
: [{ name: "All Files", extensions: ["*"] }],
|
||||
});
|
||||
if (result.canceled || !result.filePaths || result.filePaths.length === 0) {
|
||||
return { canceled: true, filePath: null };
|
||||
|
||||
// Dev fallback: resources/mosh/<platform-arch>/mosh-client[.exe] under
|
||||
// the project root. Useful for `npm run start` after running
|
||||
// `npm run fetch:mosh` locally.
|
||||
const projectRoot = opts.projectRoot || path.resolve(__dirname, "..", "..");
|
||||
const platform = opts.platform || process.platform;
|
||||
const arch = opts.arch || process.arch;
|
||||
const candidates = [];
|
||||
if (platform === "darwin") {
|
||||
candidates.push(path.join(projectRoot, "resources", "mosh", "darwin-universal", basename));
|
||||
} else {
|
||||
candidates.push(path.join(projectRoot, "resources", "mosh", `${platform}-${arch}`, basename));
|
||||
}
|
||||
return { canceled: false, filePath: result.filePaths[0] };
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(c) && isExecutableFile(c)) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1483,8 +1624,9 @@ module.exports = {
|
||||
startLocalSession,
|
||||
startTelnetSession,
|
||||
startMoshSession,
|
||||
detectMoshClient,
|
||||
pickMoshClient,
|
||||
bundledMoshClient,
|
||||
resolveBareMoshClient,
|
||||
addBundledMoshDllPath,
|
||||
startSerialSession,
|
||||
listSerialPorts,
|
||||
writeToSession,
|
||||
|
||||
280
electron/bridges/terminalBridge.moshHandshakeSession.test.cjs
Normal file
280
electron/bridges/terminalBridge.moshHandshakeSession.test.cjs
Normal file
@@ -0,0 +1,280 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const Module = require("node:module");
|
||||
|
||||
class FakePty {
|
||||
constructor(command, args, opts) {
|
||||
this.command = command;
|
||||
this.args = args;
|
||||
this.opts = opts;
|
||||
this.pid = FakePty.nextPid += 1;
|
||||
this.dataHandlers = [];
|
||||
this.exitHandlers = [];
|
||||
this.writes = [];
|
||||
this.resizes = [];
|
||||
this.killed = false;
|
||||
}
|
||||
|
||||
onData(handler) {
|
||||
this.dataHandlers.push(handler);
|
||||
}
|
||||
|
||||
onExit(handler) {
|
||||
this.exitHandlers.push(handler);
|
||||
}
|
||||
|
||||
write(data) {
|
||||
this.writes.push(data);
|
||||
}
|
||||
|
||||
resize(cols, rows) {
|
||||
this.resizes.push({ cols, rows });
|
||||
}
|
||||
|
||||
kill() {
|
||||
this.killed = true;
|
||||
}
|
||||
|
||||
emitData(data) {
|
||||
for (const handler of this.dataHandlers) handler(data);
|
||||
}
|
||||
|
||||
emitExit(evt) {
|
||||
for (const handler of this.exitHandlers) handler(evt);
|
||||
}
|
||||
}
|
||||
FakePty.nextPid = 1000;
|
||||
|
||||
function writeExecutable(filePath) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, "#!/bin/sh\nexit 0\n");
|
||||
fs.chmodSync(filePath, 0o755);
|
||||
}
|
||||
|
||||
function loadBridgeWithFakePty(spawns) {
|
||||
const bridgePath = require.resolve("./terminalBridge.cjs");
|
||||
delete require.cache[bridgePath];
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function patchedLoad(request, parent, isMain) {
|
||||
if (request === "node-pty") {
|
||||
return {
|
||||
spawn(command, args, opts) {
|
||||
const pty = new FakePty(command, args, opts);
|
||||
spawns.push(pty);
|
||||
return pty;
|
||||
},
|
||||
};
|
||||
}
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
try {
|
||||
return require("./terminalBridge.cjs");
|
||||
} finally {
|
||||
Module._load = originalLoad;
|
||||
}
|
||||
}
|
||||
|
||||
function makeHarness(t) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-session-"));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
const binDir = path.join(tmp, "bin");
|
||||
const sshPath = path.join(binDir, "ssh");
|
||||
const moshClientPath = path.join(tmp, "resources", "mosh", "linux-x64", "mosh-client");
|
||||
writeExecutable(sshPath);
|
||||
writeExecutable(moshClientPath);
|
||||
|
||||
const oldPath = process.env.PATH;
|
||||
process.env.PATH = `${binDir}${path.delimiter}${oldPath || ""}`;
|
||||
t.after(() => { process.env.PATH = oldPath; });
|
||||
|
||||
const spawns = [];
|
||||
const bridge = loadBridgeWithFakePty(spawns);
|
||||
const sessions = new Map();
|
||||
const sent = [];
|
||||
bridge.init({
|
||||
sessions,
|
||||
electronModule: {
|
||||
webContents: {
|
||||
fromId() {
|
||||
return { send: (channel, payload) => sent.push({ channel, payload }) };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
bridge,
|
||||
sessions,
|
||||
sent,
|
||||
spawns,
|
||||
options: {
|
||||
sessionId: "mosh-test-session",
|
||||
hostname: "example.com",
|
||||
username: "alice",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
},
|
||||
event: { sender: { id: 42 } },
|
||||
lookupOpts: {
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
projectRoot: tmp,
|
||||
resourcesPath: path.join(tmp, "missing"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("startMoshSession handshake path returns the same shape as the legacy path", async (t) => {
|
||||
const h = makeHarness(t);
|
||||
const result = await h.bridge.startMoshSession(h.event, h.options, { moshClientLookup: h.lookupOpts });
|
||||
assert.deepEqual(result, { sessionId: "mosh-test-session" });
|
||||
});
|
||||
|
||||
test("startMoshSession uses bundled mosh-client even when PATH contains another client", async (t) => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-session-path-"));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
const binDir = path.join(tmp, "bin");
|
||||
const sshPath = path.join(binDir, "ssh");
|
||||
const pathMoshClient = path.join(binDir, "mosh-client");
|
||||
const bundledMoshClient = path.join(tmp, "resources", "mosh", "linux-x64", "mosh-client");
|
||||
writeExecutable(sshPath);
|
||||
writeExecutable(pathMoshClient);
|
||||
writeExecutable(bundledMoshClient);
|
||||
|
||||
const oldPath = process.env.PATH;
|
||||
process.env.PATH = "";
|
||||
t.after(() => { process.env.PATH = oldPath; });
|
||||
|
||||
const spawns = [];
|
||||
const bridge = loadBridgeWithFakePty(spawns);
|
||||
const sessions = new Map();
|
||||
const sent = [];
|
||||
bridge.init({
|
||||
sessions,
|
||||
electronModule: {
|
||||
webContents: {
|
||||
fromId() {
|
||||
return { send: (channel, payload) => sent.push({ channel, payload }) };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await bridge.startMoshSession(
|
||||
{ sender: { id: 42 } },
|
||||
{
|
||||
sessionId: "mosh-path-session",
|
||||
hostname: "example.com",
|
||||
username: "alice",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
env: { PATH: binDir },
|
||||
},
|
||||
{
|
||||
moshClientLookup: {
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
projectRoot: tmp,
|
||||
resourcesPath: path.join(tmp, "missing"),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(result, { sessionId: "mosh-path-session" });
|
||||
assert.equal(spawns[0].command, sshPath);
|
||||
|
||||
spawns[0].emitData("MOSH CONNECT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
|
||||
spawns[0].emitExit({ exitCode: 0, signal: 0 });
|
||||
|
||||
assert.equal(spawns[1].command, bundledMoshClient);
|
||||
});
|
||||
|
||||
test("startMoshSession handshake path sends the existing exit event on failure", async (t) => {
|
||||
const h = makeHarness(t);
|
||||
await h.bridge.startMoshSession(h.event, h.options, { moshClientLookup: h.lookupOpts });
|
||||
|
||||
h.spawns[0].emitExit({ exitCode: 255, signal: 0 });
|
||||
|
||||
const exit = h.sent.find((evt) => evt.channel === "netcatty:exit");
|
||||
assert.ok(exit);
|
||||
assert.equal(exit.payload.sessionId, "mosh-test-session");
|
||||
assert.equal(exit.payload.reason, "error");
|
||||
});
|
||||
|
||||
test("startMoshSession writes the saved password when ssh prompts for one", async (t) => {
|
||||
const h = makeHarness(t);
|
||||
await h.bridge.startMoshSession(
|
||||
h.event,
|
||||
{ ...h.options, password: "saved-secret" },
|
||||
{ moshClientLookup: h.lookupOpts },
|
||||
);
|
||||
|
||||
h.spawns[0].emitData("(alice@example.com) Password:");
|
||||
|
||||
assert.deepEqual(h.spawns[0].writes, ["saved-secret\r"]);
|
||||
});
|
||||
|
||||
test("startMoshSession handshake path sends the existing exit event after mosh-client exits", async (t) => {
|
||||
const h = makeHarness(t);
|
||||
await h.bridge.startMoshSession(h.event, h.options, { moshClientLookup: h.lookupOpts });
|
||||
|
||||
h.spawns[0].emitData("MOSH CONNECT 60002 ABCDEFGHIJKLMNOPQRSTUV==\r\n");
|
||||
h.spawns[0].emitExit({ exitCode: 0, signal: 0 });
|
||||
|
||||
assert.equal(h.spawns.length, 2);
|
||||
h.spawns[1].emitExit({ exitCode: 0, signal: 0 });
|
||||
|
||||
const exit = h.sent.find((evt) => evt.channel === "netcatty:exit");
|
||||
assert.ok(exit);
|
||||
assert.equal(exit.payload.sessionId, "mosh-test-session");
|
||||
assert.equal(exit.payload.reason, "exited");
|
||||
});
|
||||
|
||||
test("startMoshSession fails when bundled mosh-client is missing even if PATH has mosh-client", async (t) => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-session-missing-"));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
const binDir = path.join(tmp, "bin");
|
||||
writeExecutable(path.join(binDir, "ssh"));
|
||||
writeExecutable(path.join(binDir, "mosh-client"));
|
||||
|
||||
const spawns = [];
|
||||
const bridge = loadBridgeWithFakePty(spawns);
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
electronModule: {
|
||||
webContents: {
|
||||
fromId() {
|
||||
return { send() {} };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
bridge.startMoshSession(
|
||||
{ sender: { id: 42 } },
|
||||
{
|
||||
sessionId: "mosh-missing-bundled",
|
||||
hostname: "example.com",
|
||||
username: "alice",
|
||||
env: { PATH: binDir },
|
||||
},
|
||||
{
|
||||
moshClientLookup: {
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
projectRoot: tmp,
|
||||
resourcesPath: path.join(tmp, "missing"),
|
||||
},
|
||||
},
|
||||
),
|
||||
/Bundled mosh-client not found/,
|
||||
);
|
||||
assert.equal(spawns.length, 0);
|
||||
});
|
||||
@@ -1263,21 +1263,36 @@ async function createWindow(electronModule, options) {
|
||||
* calling `webContents.focus()` covers (2) so the renderer marks the page as
|
||||
* focused regardless of whether the OS granted foreground.
|
||||
*/
|
||||
function showAndFocusWindow(win) {
|
||||
if (!win || win.isDestroyed()) return;
|
||||
try {
|
||||
win.show();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
function restoreWindowInputFocus(win, options = {}) {
|
||||
if (!win || win.isDestroyed()) return false;
|
||||
const shouldShow = options.show === true;
|
||||
const platform = options.platform || process.platform;
|
||||
|
||||
if (shouldShow) {
|
||||
try {
|
||||
win.setAlwaysOnTop(true);
|
||||
win.focus();
|
||||
win.setAlwaysOnTop(false);
|
||||
win.show();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === "win32") {
|
||||
try {
|
||||
win.setAlwaysOnTop(true);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
win.focus();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
try {
|
||||
win.setAlwaysOnTop(false);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
win.focus();
|
||||
@@ -1285,6 +1300,7 @@ function showAndFocusWindow(win) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (win.webContents && !win.webContents.isDestroyed()) {
|
||||
win.webContents.focus();
|
||||
@@ -1292,6 +1308,11 @@ function showAndFocusWindow(win) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function showAndFocusWindow(win) {
|
||||
restoreWindowInputFocus(win, { show: true });
|
||||
}
|
||||
|
||||
async function openSettingsWindow(electronModule, options, { showOnLoad = true } = {}) {
|
||||
@@ -1576,6 +1597,11 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
|
||||
return false;
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:window:focus", (event) => {
|
||||
const win = getWindowForIpcEvent(event);
|
||||
return restoreWindowInputFocus(win);
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:setTheme", (_event, theme) => {
|
||||
currentTheme = theme;
|
||||
nativeTheme.themeSource = theme;
|
||||
@@ -1754,6 +1780,8 @@ module.exports = {
|
||||
getMainWindow,
|
||||
getSettingsWindow,
|
||||
isWindowUsable,
|
||||
registerWindowHandlers,
|
||||
restoreWindowInputFocus,
|
||||
waitForRendererReady,
|
||||
setIsQuitting,
|
||||
openFallbackBrowser,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { isWindowUsable } = require("./windowManager.cjs");
|
||||
const { isWindowUsable, registerWindowHandlers, restoreWindowInputFocus } = require("./windowManager.cjs");
|
||||
|
||||
function createWindowStub({ destroyed = false, webContents } = {}) {
|
||||
return {
|
||||
@@ -65,3 +65,145 @@ test("isWindowUsable can require a visible window", () => {
|
||||
assert.equal(isWindowUsable(hiddenWin, { requireVisible: true }), false);
|
||||
assert.equal(isWindowUsable(hiddenWin, { requireVisible: false }), true);
|
||||
});
|
||||
|
||||
test("restoreWindowInputFocus focuses the window and renderer on Windows without showing hidden windows", () => {
|
||||
const calls = [];
|
||||
const win = {
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
show() {
|
||||
calls.push("show");
|
||||
},
|
||||
focus() {
|
||||
calls.push("focus");
|
||||
},
|
||||
setAlwaysOnTop(value) {
|
||||
calls.push(`alwaysOnTop:${value}`);
|
||||
},
|
||||
webContents: {
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
focus() {
|
||||
calls.push("webContents.focus");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const restored = restoreWindowInputFocus(win, { platform: "win32" });
|
||||
|
||||
assert.equal(restored, true);
|
||||
assert.deepEqual(calls, [
|
||||
"alwaysOnTop:true",
|
||||
"focus",
|
||||
"alwaysOnTop:false",
|
||||
"webContents.focus",
|
||||
]);
|
||||
});
|
||||
|
||||
test("restoreWindowInputFocus clears Windows always-on-top even if window focus throws", () => {
|
||||
const calls = [];
|
||||
const win = {
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
focus() {
|
||||
calls.push("focus");
|
||||
throw new Error("focus failed");
|
||||
},
|
||||
setAlwaysOnTop(value) {
|
||||
calls.push(`alwaysOnTop:${value}`);
|
||||
},
|
||||
webContents: {
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
focus() {
|
||||
calls.push("webContents.focus");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const restored = restoreWindowInputFocus(win, { platform: "win32" });
|
||||
|
||||
assert.equal(restored, true);
|
||||
assert.deepEqual(calls, [
|
||||
"alwaysOnTop:true",
|
||||
"focus",
|
||||
"alwaysOnTop:false",
|
||||
"webContents.focus",
|
||||
]);
|
||||
});
|
||||
|
||||
test("restoreWindowInputFocus can show the window when requested", () => {
|
||||
const calls = [];
|
||||
const win = {
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
show() {
|
||||
calls.push("show");
|
||||
},
|
||||
focus() {
|
||||
calls.push("focus");
|
||||
},
|
||||
webContents: {
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
focus() {
|
||||
calls.push("webContents.focus");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const restored = restoreWindowInputFocus(win, { platform: "darwin", show: true });
|
||||
|
||||
assert.equal(restored, true);
|
||||
assert.deepEqual(calls, ["show", "focus", "webContents.focus"]);
|
||||
});
|
||||
|
||||
test("window focus IPC handler focuses the sender owner window", async () => {
|
||||
const handlers = new Map();
|
||||
const ipcMain = {
|
||||
handle(channel, handler) {
|
||||
handlers.set(channel, handler);
|
||||
},
|
||||
on(channel, handler) {
|
||||
handlers.set(channel, handler);
|
||||
},
|
||||
};
|
||||
const calls = [];
|
||||
const win = {
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
focus() {
|
||||
calls.push("focus");
|
||||
},
|
||||
webContents: {
|
||||
id: 101,
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
focus() {
|
||||
calls.push("webContents.focus");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
registerWindowHandlers(ipcMain, { themeSource: "light" });
|
||||
|
||||
const result = await handlers.get("netcatty:window:focus")({
|
||||
sender: {
|
||||
id: 202,
|
||||
getOwnerBrowserWindow() {
|
||||
return win;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.deepEqual(calls, ["focus", "webContents.focus"]);
|
||||
});
|
||||
|
||||
@@ -548,12 +548,6 @@ const api = {
|
||||
const result = await ipcRenderer.invoke("netcatty:mosh:start", options);
|
||||
return result.sessionId;
|
||||
},
|
||||
detectMoshClient: async () => {
|
||||
return ipcRenderer.invoke("netcatty:mosh:detectClient");
|
||||
},
|
||||
pickMoshClient: async () => {
|
||||
return ipcRenderer.invoke("netcatty:mosh:pickClient");
|
||||
},
|
||||
startLocalSession: async (options) => {
|
||||
const result = await ipcRenderer.invoke("netcatty:local:start", options || {});
|
||||
return result.sessionId;
|
||||
@@ -822,6 +816,7 @@ const api = {
|
||||
windowClose: () => ipcRenderer.invoke("netcatty:window:close"),
|
||||
windowIsMaximized: () => ipcRenderer.invoke("netcatty:window:isMaximized"),
|
||||
windowIsFullscreen: () => ipcRenderer.invoke("netcatty:window:isFullscreen"),
|
||||
windowFocus: () => ipcRenderer.invoke("netcatty:window:focus"),
|
||||
onWindowFullScreenChanged: (cb) => {
|
||||
fullscreenChangeListeners.add(cb);
|
||||
return () => fullscreenChangeListeners.delete(cb);
|
||||
|
||||
10
global.d.ts
vendored
10
global.d.ts
vendored
@@ -177,6 +177,7 @@ declare global {
|
||||
sessionId?: string;
|
||||
hostname: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
port?: number;
|
||||
moshServerPath?: string;
|
||||
moshClientPath?: string;
|
||||
@@ -187,13 +188,6 @@ declare global {
|
||||
env?: Record<string, string>;
|
||||
sessionLog?: { enabled: boolean; directory: string; format: string };
|
||||
}): Promise<string>;
|
||||
detectMoshClient?(): Promise<{
|
||||
platform: string;
|
||||
found: boolean;
|
||||
path: string | null;
|
||||
searchedPaths: string[];
|
||||
}>;
|
||||
pickMoshClient?(): Promise<{ canceled: boolean; filePath: string | null }>;
|
||||
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; shellArgs?: string[]; cwd?: string; env?: Record<string, string>; sessionLog?: { enabled: boolean; directory: string; format: string } }): Promise<string>;
|
||||
startSerialSession?(options: {
|
||||
sessionId?: string;
|
||||
@@ -471,6 +465,7 @@ declare global {
|
||||
windowClose?(): Promise<void>;
|
||||
windowIsMaximized?(): Promise<boolean>;
|
||||
windowIsFullscreen?(): Promise<boolean>;
|
||||
windowFocus?(): Promise<boolean>;
|
||||
onWindowFullScreenChanged?(cb: (isFullscreen: boolean) => void): () => void;
|
||||
|
||||
// Settings window
|
||||
@@ -943,6 +938,7 @@ declare global {
|
||||
aiCloseAgentStdin?(agentId: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiKillAgent?(agentId: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiAcpStream?(requestId: string, chatSessionId: string, acpCommand: string, acpArgs: string[], prompt: string, cwd?: string, providerId?: string, model?: string, existingSessionId?: string, historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>, images?: Array<{ base64Data: string; mediaType: string; filename?: string }>, toolIntegrationMode?: 'mcp' | 'skills', defaultTargetSession?: { sessionId: string; hostname: string; label: string; os?: string; username?: string; protocol?: string; shellType?: string; deviceType?: string; connected: boolean; source: 'scope-target' | 'only-connected-in-scope' }, userSkillsContext?: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiAcpListModels?(acpCommand: string, acpArgs?: string[], cwd?: string, providerId?: string, chatSessionId?: string): Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[] }>; currentModelId?: string | null; error?: string }>;
|
||||
aiAcpCancel?(requestId: string, chatSessionId?: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiAcpCleanup?(chatSessionId: string): Promise<{ ok: boolean }>;
|
||||
onAiAcpEvent?(requestId: string, cb: (event: Record<string, unknown>) => void): () => void;
|
||||
|
||||
25
index.css
25
index.css
@@ -462,10 +462,20 @@ body {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
[data-streamdown="code-block-actions"] {
|
||||
[data-streamdown="code-block"] > div:has(> [data-streamdown="code-block-actions"]) {
|
||||
position: absolute !important;
|
||||
top: 4px !important;
|
||||
right: 4px !important;
|
||||
z-index: 10 !important;
|
||||
display: flex !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
margin: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
[data-streamdown="code-block-actions"] {
|
||||
position: static !important;
|
||||
border: none !important;
|
||||
background: none !important;
|
||||
backdrop-filter: none !important;
|
||||
@@ -507,10 +517,21 @@ body {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
padding: 0 12px 10px !important;
|
||||
padding: 6px 12px 10px !important;
|
||||
width: max-content !important;
|
||||
min-width: 100% !important;
|
||||
font-size: 12px !important;
|
||||
line-height: 1.5 !important;
|
||||
white-space: pre !important;
|
||||
}
|
||||
|
||||
/* Streamdown table overrides */
|
||||
.ai-chat-message-content[data-role="assistant"] [data-streamdown="table-wrapper"] {
|
||||
gap: 4px !important;
|
||||
padding: 4px 8px 8px !important;
|
||||
}
|
||||
|
||||
.ai-chat-message-content[data-role="assistant"] [data-streamdown="table-wrapper"] > div:first-child > button,
|
||||
.ai-chat-message-content[data-role="assistant"] [data-streamdown="table-wrapper"] > div:first-child > div > button {
|
||||
padding: 3px !important;
|
||||
}
|
||||
|
||||
161
infrastructure/ai/acpAgentAdapter.test.ts
Normal file
161
infrastructure/ai/acpAgentAdapter.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { formatAcpErrorForDisplay, runAcpAgentTurn } from './acpAgentAdapter';
|
||||
import type { AcpAgentCallbacks } from './acpAgentAdapter';
|
||||
import type { ExternalAgentConfig } from './types';
|
||||
|
||||
function createCallbacks(errors: string[]): AcpAgentCallbacks {
|
||||
return {
|
||||
onTextDelta: () => {},
|
||||
onThinkingDelta: () => {},
|
||||
onThinkingDone: () => {},
|
||||
onToolCall: () => {},
|
||||
onToolResult: () => {},
|
||||
onError: (error) => errors.push(error),
|
||||
onDone: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
const acpConfig: ExternalAgentConfig = {
|
||||
id: 'agent',
|
||||
name: 'Agent',
|
||||
command: 'agent',
|
||||
enabled: true,
|
||||
acpCommand: 'agent-acp',
|
||||
acpArgs: [],
|
||||
};
|
||||
|
||||
test('formatAcpErrorForDisplay preserves nested ACP error messages', () => {
|
||||
assert.equal(
|
||||
formatAcpErrorForDisplay({
|
||||
error: {
|
||||
code: 'invalid_model',
|
||||
message: 'Model is not available',
|
||||
},
|
||||
}),
|
||||
'Model is not available',
|
||||
);
|
||||
});
|
||||
|
||||
test('formatAcpErrorForDisplay stringifies unknown objects instead of [object Object]', () => {
|
||||
assert.equal(
|
||||
formatAcpErrorForDisplay({ status: 502, detail: 'Proxy failed' }),
|
||||
'{"status":502,"detail":"Proxy failed"}',
|
||||
);
|
||||
});
|
||||
|
||||
test('formatAcpErrorForDisplay handles circular errors', () => {
|
||||
const error: Record<string, unknown> = { status: 500 };
|
||||
error.self = error;
|
||||
|
||||
assert.equal(
|
||||
formatAcpErrorForDisplay(error),
|
||||
'{"status":500,"self":"[Circular]"}',
|
||||
);
|
||||
});
|
||||
|
||||
test('runAcpAgentTurn formats structured startup errors', async () => {
|
||||
const errors: string[] = [];
|
||||
const bridge: Record<string, (...args: unknown[]) => unknown> = {
|
||||
aiAcpStream: async () => ({
|
||||
ok: false,
|
||||
error: {
|
||||
error: {
|
||||
code: 'invalid_model',
|
||||
message: 'Model is not available',
|
||||
},
|
||||
},
|
||||
}),
|
||||
aiAcpCancel: async () => ({ ok: true }),
|
||||
onAiAcpEvent: () => () => {},
|
||||
onAiAcpDone: () => () => {},
|
||||
onAiAcpError: () => () => {},
|
||||
};
|
||||
|
||||
await runAcpAgentTurn(
|
||||
bridge,
|
||||
'request-1',
|
||||
'chat-1',
|
||||
acpConfig,
|
||||
'hello',
|
||||
createCallbacks(errors),
|
||||
);
|
||||
|
||||
assert.deepEqual(errors, ['Model is not available']);
|
||||
});
|
||||
|
||||
test('runAcpAgentTurn formats structured async error events', async () => {
|
||||
const errors: string[] = [];
|
||||
let onError: ((error: unknown) => void) | null = null;
|
||||
const bridge: Record<string, (...args: unknown[]) => unknown> = {
|
||||
aiAcpStream: async () => {
|
||||
queueMicrotask(() => {
|
||||
onError?.({
|
||||
data: {
|
||||
error: {
|
||||
message: 'Proxy failed',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
aiAcpCancel: async () => ({ ok: true }),
|
||||
onAiAcpEvent: () => () => {},
|
||||
onAiAcpDone: () => () => {},
|
||||
onAiAcpError: (_requestId: unknown, cb: unknown) => {
|
||||
onError = cb as (error: unknown) => void;
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
await runAcpAgentTurn(
|
||||
bridge,
|
||||
'request-2',
|
||||
'chat-1',
|
||||
acpConfig,
|
||||
'hello',
|
||||
createCallbacks(errors),
|
||||
);
|
||||
|
||||
assert.deepEqual(errors, ['Proxy failed']);
|
||||
});
|
||||
|
||||
test('runAcpAgentTurn formats structured stream error events', async () => {
|
||||
const errors: string[] = [];
|
||||
let onEvent: ((event: unknown) => void) | null = null;
|
||||
const bridge: Record<string, (...args: unknown[]) => unknown> = {
|
||||
aiAcpStream: async () => {
|
||||
queueMicrotask(() => {
|
||||
onEvent?.({
|
||||
type: 'error',
|
||||
error: {
|
||||
error: {
|
||||
message: 'Stream failed',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
aiAcpCancel: async () => ({ ok: true }),
|
||||
onAiAcpEvent: (_requestId: unknown, cb: unknown) => {
|
||||
onEvent = cb as (event: unknown) => void;
|
||||
return () => {};
|
||||
},
|
||||
onAiAcpDone: () => () => {},
|
||||
onAiAcpError: () => () => {},
|
||||
};
|
||||
|
||||
await runAcpAgentTurn(
|
||||
bridge,
|
||||
'request-3',
|
||||
'chat-1',
|
||||
acpConfig,
|
||||
'hello',
|
||||
createCallbacks(errors),
|
||||
);
|
||||
|
||||
assert.deepEqual(errors, ['Stream failed']);
|
||||
});
|
||||
@@ -49,11 +49,11 @@ interface AcpBridge {
|
||||
toolIntegrationMode?: AIToolIntegrationMode,
|
||||
defaultTargetSession?: DefaultTargetSessionHint,
|
||||
userSkillsContext?: string,
|
||||
): Promise<{ ok: boolean; error?: string }>;
|
||||
): Promise<{ ok: boolean; error?: unknown }>;
|
||||
aiAcpCancel(requestId: string, chatSessionId?: string): Promise<{ ok: boolean }>;
|
||||
onAiAcpEvent(requestId: string, cb: (event: StreamEvent) => void): () => void;
|
||||
onAiAcpDone(requestId: string, cb: () => void): () => void;
|
||||
onAiAcpError(requestId: string, cb: (error: string) => void): () => void;
|
||||
onAiAcpError(requestId: string, cb: (error: unknown) => void): () => void;
|
||||
}
|
||||
|
||||
interface StreamEvent {
|
||||
@@ -73,6 +73,61 @@ export interface FileAttachment {
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
function safeJsonStringify(value: unknown): string | null {
|
||||
const seen = new WeakSet<object>();
|
||||
try {
|
||||
return JSON.stringify(value, (_key, nestedValue: unknown) => {
|
||||
if (typeof nestedValue !== 'object' || nestedValue === null) {
|
||||
return nestedValue;
|
||||
}
|
||||
if (seen.has(nestedValue)) {
|
||||
return '[Circular]';
|
||||
}
|
||||
seen.add(nestedValue);
|
||||
return nestedValue;
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatAcpErrorValue(error: unknown, seen = new WeakSet<object>()): string {
|
||||
if (error == null) return '';
|
||||
if (typeof error === 'string') return error;
|
||||
if (typeof error === 'number' || typeof error === 'boolean') return String(error);
|
||||
if (error instanceof Error) return error.message || error.name || '';
|
||||
if (typeof error !== 'object') return String(error);
|
||||
if (seen.has(error)) return '[Circular error]';
|
||||
seen.add(error);
|
||||
|
||||
const record = error as Record<string, unknown>;
|
||||
const data = record.data as Record<string, unknown> | undefined;
|
||||
const nestedError = record.error as Record<string, unknown> | undefined;
|
||||
const candidates: unknown[] = [
|
||||
data?.message,
|
||||
data?.error,
|
||||
record.errorText,
|
||||
record.message,
|
||||
record.error,
|
||||
record.cause,
|
||||
nestedError?.message,
|
||||
record.data,
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const message = formatAcpErrorValue(candidate, seen).trim();
|
||||
if (message && message !== '{}') {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
return safeJsonStringify(error) || String(error);
|
||||
}
|
||||
|
||||
export function formatAcpErrorForDisplay(error: unknown): string {
|
||||
return formatAcpErrorValue(error).trim() || 'Unknown error';
|
||||
}
|
||||
|
||||
export async function runAcpAgentTurn(
|
||||
bridge: Record<string, (...args: unknown[]) => unknown>,
|
||||
requestId: string,
|
||||
@@ -98,15 +153,11 @@ export async function runAcpAgentTurn(
|
||||
}
|
||||
|
||||
const cleanupFns: (() => void)[] = [];
|
||||
|
||||
// Set up event listeners before starting stream
|
||||
const unsubEvent = acpBridge.onAiAcpEvent(requestId, (event: StreamEvent) => {
|
||||
handleStreamEvent(event, callbacks);
|
||||
});
|
||||
cleanupFns.push(unsubEvent);
|
||||
|
||||
let settled = false;
|
||||
let resolveDone!: () => void;
|
||||
let resolveDone: () => void = () => {};
|
||||
const donePromise = new Promise<void>((resolve) => {
|
||||
resolveDone = resolve;
|
||||
});
|
||||
const settle = (fn?: () => void) => {
|
||||
if (settled) return false;
|
||||
settled = true;
|
||||
@@ -115,22 +166,28 @@ export async function runAcpAgentTurn(
|
||||
return true;
|
||||
};
|
||||
|
||||
const donePromise = new Promise<void>((resolve) => {
|
||||
resolveDone = resolve;
|
||||
const unsubDone = acpBridge.onAiAcpDone(requestId, () => {
|
||||
settle(() => {
|
||||
callbacks.onDone();
|
||||
});
|
||||
});
|
||||
cleanupFns.push(unsubDone);
|
||||
|
||||
const unsubError = acpBridge.onAiAcpError(requestId, (error: string) => {
|
||||
settle(() => {
|
||||
callbacks.onError(error);
|
||||
});
|
||||
});
|
||||
cleanupFns.push(unsubError);
|
||||
// Set up event listeners before starting stream
|
||||
const unsubEvent = acpBridge.onAiAcpEvent(requestId, (event: StreamEvent) => {
|
||||
const streamFailed = handleStreamEvent(event, callbacks);
|
||||
if (streamFailed) {
|
||||
settle();
|
||||
}
|
||||
});
|
||||
cleanupFns.push(unsubEvent);
|
||||
|
||||
const unsubDone = acpBridge.onAiAcpDone(requestId, () => {
|
||||
settle(() => {
|
||||
callbacks.onDone();
|
||||
});
|
||||
});
|
||||
cleanupFns.push(unsubDone);
|
||||
|
||||
const unsubError = acpBridge.onAiAcpError(requestId, (error: unknown) => {
|
||||
settle(() => {
|
||||
callbacks.onError(formatAcpErrorForDisplay(error));
|
||||
});
|
||||
});
|
||||
cleanupFns.push(unsubError);
|
||||
|
||||
// Handle abort
|
||||
if (signal) {
|
||||
@@ -167,12 +224,16 @@ export async function runAcpAgentTurn(
|
||||
).then((result) => {
|
||||
if (result?.ok === false) {
|
||||
settle(() => {
|
||||
callbacks.onError(result.error || 'Failed to start ACP stream');
|
||||
callbacks.onError(
|
||||
result.error == null
|
||||
? 'Failed to start ACP stream'
|
||||
: formatAcpErrorForDisplay(result.error),
|
||||
);
|
||||
});
|
||||
}
|
||||
}).catch((err: Error) => {
|
||||
}).catch((err: unknown) => {
|
||||
settle(() => {
|
||||
callbacks.onError(err.message);
|
||||
callbacks.onError(formatAcpErrorForDisplay(err));
|
||||
});
|
||||
}).finally(() => {
|
||||
if (settled) {
|
||||
@@ -195,32 +256,32 @@ function cleanup(fns: (() => void)[]) {
|
||||
* Handle a single stream event from the AI SDK fullStream.
|
||||
* Events come from `streamText().fullStream` in the main process.
|
||||
*/
|
||||
function handleStreamEvent(event: StreamEvent, callbacks: AcpAgentCallbacks) {
|
||||
function handleStreamEvent(event: StreamEvent, callbacks: AcpAgentCallbacks): boolean {
|
||||
switch (event.type) {
|
||||
case 'text-delta': {
|
||||
const text = (event.textDelta as string) || (event.delta as string) || '';
|
||||
if (text) callbacks.onTextDelta(text);
|
||||
break;
|
||||
return false;
|
||||
}
|
||||
case 'reasoning-start': {
|
||||
// Reasoning block started — nothing to render yet
|
||||
break;
|
||||
return false;
|
||||
}
|
||||
case 'reasoning-delta': {
|
||||
const text = (event.delta as string) || '';
|
||||
if (text) callbacks.onThinkingDelta(text);
|
||||
break;
|
||||
return false;
|
||||
}
|
||||
case 'reasoning-end': {
|
||||
callbacks.onThinkingDone();
|
||||
break;
|
||||
return false;
|
||||
}
|
||||
case 'tool-call': {
|
||||
const toolName = (event.toolName as string) || 'unknown';
|
||||
const input = (event.input as Record<string, unknown>) || {};
|
||||
const toolCallId = (event.toolCallId as string) || undefined;
|
||||
callbacks.onToolCall(toolName, input, toolCallId);
|
||||
break;
|
||||
return false;
|
||||
}
|
||||
case 'tool-result': {
|
||||
const toolCallId = (event.toolCallId as string) || '';
|
||||
@@ -230,22 +291,24 @@ function handleStreamEvent(event: StreamEvent, callbacks: AcpAgentCallbacks) {
|
||||
? output
|
||||
: JSON.stringify(output);
|
||||
callbacks.onToolResult(toolCallId, result, toolName);
|
||||
break;
|
||||
return false;
|
||||
}
|
||||
case 'status': {
|
||||
const msg = (event.message as string) || '';
|
||||
if (msg) callbacks.onStatus?.(msg);
|
||||
break;
|
||||
return false;
|
||||
}
|
||||
case 'session-id': {
|
||||
const sessionId = (event.sessionId as string) || '';
|
||||
if (sessionId) callbacks.onSessionId?.(sessionId);
|
||||
break;
|
||||
return false;
|
||||
}
|
||||
case 'error': {
|
||||
callbacks.onError(String(event.error || 'Unknown error'));
|
||||
break;
|
||||
callbacks.onError(formatAcpErrorForDisplay(event.error));
|
||||
return true;
|
||||
}
|
||||
// step-start, step-finish, etc. — ignore silently
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
356
infrastructure/ai/providerContinuation.test.ts
Normal file
356
infrastructure/ai/providerContinuation.test.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyOpenAIChatContinuationToBody,
|
||||
extractProviderContinuationFromRawChunk,
|
||||
getOpenAIChatAssistantFieldsForHistoryMessage,
|
||||
isProviderContinuationForSource,
|
||||
mergeProviderContinuation,
|
||||
normalizeProviderContinuationOptions,
|
||||
rawOpenAIChatChunkHasToolCalls,
|
||||
withProviderContinuationSource,
|
||||
} from './providerContinuation';
|
||||
|
||||
test('extracts OpenAI-compatible reasoning deltas from raw provider chunks', () => {
|
||||
const first = extractProviderContinuationFromRawChunk({
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
reasoning_content: 'check ',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const second = extractProviderContinuationFromRawChunk({
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
reasoning_content: 'tools',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const merged = mergeProviderContinuation(first, second);
|
||||
|
||||
assert.equal(merged?.openAIChatAssistantFields?.reasoning_content, 'check tools');
|
||||
assert.deepEqual(merged?.reasoningParts, [{ text: 'check tools' }]);
|
||||
});
|
||||
|
||||
test('patches OpenAI-compatible assistant tool-call messages with saved continuation fields', () => {
|
||||
const body = JSON.stringify({
|
||||
model: 'deepseek-v4-flash',
|
||||
stream: true,
|
||||
messages: [
|
||||
{ role: 'system', content: 'system' },
|
||||
{ role: 'user', content: 'inspect the host' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'run_command', arguments: '{}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_1', content: '{"ok":true}' },
|
||||
],
|
||||
});
|
||||
|
||||
const patched = JSON.parse(
|
||||
applyOpenAIChatContinuationToBody(body, [
|
||||
{ reasoning_content: 'need shell context' },
|
||||
]),
|
||||
);
|
||||
|
||||
assert.equal(patched.messages[2].reasoning_content, 'need shell context');
|
||||
});
|
||||
|
||||
test('patches the final assistant message after a tool result with saved continuation fields', () => {
|
||||
const body = JSON.stringify({
|
||||
model: 'deepseek-v4-flash',
|
||||
stream: true,
|
||||
messages: [
|
||||
{ role: 'user', content: 'inspect the host' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'run_command', arguments: '{}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_1', content: '{"ok":true}' },
|
||||
{ role: 'assistant', content: 'host is healthy' },
|
||||
{ role: 'user', content: 'continue' },
|
||||
],
|
||||
});
|
||||
|
||||
const patched = JSON.parse(
|
||||
applyOpenAIChatContinuationToBody(body, [
|
||||
{ reasoning_content: 'need shell context' },
|
||||
{ reasoning_content: 'summarize result' },
|
||||
]),
|
||||
);
|
||||
|
||||
assert.equal(patched.messages[1].reasoning_content, 'need shell context');
|
||||
assert.equal(patched.messages[3].reasoning_content, 'summarize result');
|
||||
});
|
||||
|
||||
test('rebuilds OpenAI-compatible continuation fields from saved thinking for legacy history', () => {
|
||||
const source = { providerConfigId: 'deepseek-custom', providerType: 'custom', modelId: 'deepseek-v4-flash' };
|
||||
|
||||
assert.deepEqual(
|
||||
getOpenAIChatAssistantFieldsForHistoryMessage(
|
||||
{
|
||||
thinking: 'legacy visible reasoning',
|
||||
providerId: 'custom',
|
||||
model: 'deepseek-v4-flash',
|
||||
},
|
||||
source,
|
||||
),
|
||||
{ reasoning_content: 'legacy visible reasoning' },
|
||||
);
|
||||
});
|
||||
|
||||
test('does not rebuild continuation fields from thinking when provider or model differs', () => {
|
||||
const source = { providerConfigId: 'deepseek-custom', providerType: 'custom', modelId: 'deepseek-v4-flash' };
|
||||
|
||||
assert.equal(
|
||||
getOpenAIChatAssistantFieldsForHistoryMessage(
|
||||
{
|
||||
thinking: 'other provider reasoning',
|
||||
providerId: 'openai',
|
||||
model: 'deepseek-v4-flash',
|
||||
},
|
||||
source,
|
||||
),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
getOpenAIChatAssistantFieldsForHistoryMessage(
|
||||
{
|
||||
thinking: 'other model reasoning',
|
||||
providerId: 'custom',
|
||||
model: 'another-model',
|
||||
},
|
||||
source,
|
||||
),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
getOpenAIChatAssistantFieldsForHistoryMessage(
|
||||
{
|
||||
thinking: 'missing provider metadata',
|
||||
model: 'deepseek-v4-flash',
|
||||
},
|
||||
source,
|
||||
),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
getOpenAIChatAssistantFieldsForHistoryMessage(
|
||||
{
|
||||
thinking: 'missing model metadata',
|
||||
providerId: 'custom',
|
||||
},
|
||||
source,
|
||||
),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test('detects OpenAI-compatible tool calls in raw chunks', () => {
|
||||
assert.equal(rawOpenAIChatChunkHasToolCalls({
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'run_command', arguments: '{}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}), true);
|
||||
|
||||
assert.equal(rawOpenAIChatChunkHasToolCalls({
|
||||
choices: [{ delta: { reasoning_content: 'think' } }],
|
||||
}), false);
|
||||
assert.equal(rawOpenAIChatChunkHasToolCalls('[DONE]'), false);
|
||||
});
|
||||
|
||||
test('merges provider reasoning metadata into the reasoning part it belongs to', () => {
|
||||
const merged = mergeProviderContinuation(
|
||||
{ reasoningParts: [{ text: 'consider options' }] },
|
||||
{ reasoningParts: [{ text: '', providerOptions: { anthropic: { signature: 'sig-1' } } }] },
|
||||
);
|
||||
|
||||
assert.deepEqual(merged?.reasoningParts, [
|
||||
{
|
||||
text: 'consider options',
|
||||
providerOptions: { anthropic: { signature: 'sig-1' } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('normalizes provider metadata without unsafe object keys', () => {
|
||||
const unsafeMetadata = JSON.parse('{"google":{"thoughtSignature":"sig-1","__proto__":{"polluted":true},"nested":{"constructor":{"bad":true},"value":"safe"}},"__proto__":{"ignored":true}}');
|
||||
const normalized = normalizeProviderContinuationOptions(unsafeMetadata);
|
||||
|
||||
assert.deepEqual(normalized, {
|
||||
google: {
|
||||
thoughtSignature: 'sig-1',
|
||||
nested: { value: 'safe' },
|
||||
},
|
||||
});
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(normalized?.google ?? {}, '__proto__'), false);
|
||||
});
|
||||
|
||||
test('merges equivalent provider options without depending on key order', () => {
|
||||
const merged = mergeProviderContinuation(
|
||||
{ reasoningParts: [{ text: 'one ', providerOptions: { google: { b: 2, a: 1 } } }] },
|
||||
{ reasoningParts: [{ text: 'two', providerOptions: { google: { a: 1, b: 2 } } }] },
|
||||
);
|
||||
|
||||
assert.deepEqual(merged?.reasoningParts, [
|
||||
{
|
||||
text: 'one two',
|
||||
providerOptions: { google: { b: 2, a: 1 } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('cleans nested unsafe provider option keys when merging saved data', () => {
|
||||
const unsafeOptions = JSON.parse('{"google":{"nested":{"prototype":{"bad":true},"value":"safe"}}}');
|
||||
const merged = mergeProviderContinuation(
|
||||
{ reasoningParts: [{ text: 'one ', providerOptions: unsafeOptions }] },
|
||||
{ reasoningParts: [{ text: 'two' }] },
|
||||
);
|
||||
|
||||
assert.deepEqual(merged?.reasoningParts, [
|
||||
{
|
||||
text: 'one ',
|
||||
providerOptions: { google: { nested: { value: 'safe' } } },
|
||||
},
|
||||
{ text: 'two' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('tracks continuation source so provider switches do not replay hidden context', () => {
|
||||
const source = { providerConfigId: 'deepseek-custom', providerType: 'custom', modelId: 'deepseek-v4-flash' };
|
||||
const continuation = withProviderContinuationSource(
|
||||
{ openAIChatAssistantFields: { reasoning_content: 'think' } },
|
||||
source,
|
||||
);
|
||||
|
||||
assert.equal(isProviderContinuationForSource(continuation, source), true);
|
||||
assert.equal(
|
||||
isProviderContinuationForSource(continuation, {
|
||||
providerConfigId: 'openai',
|
||||
providerType: 'openai',
|
||||
modelId: 'gpt-5',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('drops old hidden context instead of relabeling it when sources differ', () => {
|
||||
const deepseek = { providerConfigId: 'deepseek-custom', providerType: 'custom', modelId: 'deepseek-v4-flash' };
|
||||
const openai = { providerConfigId: 'openai', providerType: 'openai', modelId: 'gpt-5' };
|
||||
const merged = mergeProviderContinuation(
|
||||
{ source: deepseek, openAIChatAssistantFields: { reasoning_content: 'old' } },
|
||||
{ source: openai, reasoningParts: [{ text: 'new' }] },
|
||||
);
|
||||
|
||||
assert.deepEqual(merged, {
|
||||
source: openai,
|
||||
reasoningParts: [{ text: 'new' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('merges tool-call provider options by tool call id', () => {
|
||||
const merged = mergeProviderContinuation(
|
||||
{ toolCallProviderOptionsById: { call_1: { google: { thoughtSignature: 'sig-1' } } } },
|
||||
{ toolCallProviderOptionsById: { call_1: { google: { extra: true } } } },
|
||||
);
|
||||
|
||||
assert.deepEqual(merged?.toolCallProviderOptionsById, {
|
||||
call_1: {
|
||||
google: {
|
||||
thoughtSignature: 'sig-1',
|
||||
extra: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('skips plain assistant messages that are not part of a tool loop', () => {
|
||||
const body = JSON.stringify({
|
||||
stream: true,
|
||||
messages: [
|
||||
{ role: 'assistant', content: 'plain answer' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'run_command', arguments: '{}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const patched = JSON.parse(
|
||||
applyOpenAIChatContinuationToBody(body, [
|
||||
{ reasoning_content: 'tool reasoning' },
|
||||
]),
|
||||
);
|
||||
|
||||
assert.equal(patched.messages[0].reasoning_content, undefined);
|
||||
assert.equal(patched.messages[1].reasoning_content, 'tool reasoning');
|
||||
});
|
||||
|
||||
test('keeps assistant tool-call continuation fields aligned with message order', () => {
|
||||
const toolCall = (id: string) => ({
|
||||
id,
|
||||
type: 'function',
|
||||
function: { name: 'run_command', arguments: '{}' },
|
||||
});
|
||||
const body = JSON.stringify({
|
||||
stream: true,
|
||||
messages: [
|
||||
{ role: 'assistant', content: '', tool_calls: [toolCall('call_1')] },
|
||||
{ role: 'tool', tool_call_id: 'call_1', content: '{"ok":true}' },
|
||||
{ role: 'assistant', content: '', tool_calls: [toolCall('call_2')] },
|
||||
],
|
||||
});
|
||||
|
||||
const patched = JSON.parse(
|
||||
applyOpenAIChatContinuationToBody(body, [
|
||||
undefined,
|
||||
{ reasoning_content: 'second reasoning' },
|
||||
]),
|
||||
);
|
||||
|
||||
assert.equal(patched.messages[0].reasoning_content, undefined);
|
||||
assert.equal(patched.messages[2].reasoning_content, 'second reasoning');
|
||||
});
|
||||
|
||||
test('leaves invalid or unchanged OpenAI-compatible request bodies alone', () => {
|
||||
assert.equal(applyOpenAIChatContinuationToBody('{', []), '{');
|
||||
|
||||
const body = JSON.stringify({ stream: true, messages: [{ role: 'user', content: 'hi' }] });
|
||||
assert.equal(applyOpenAIChatContinuationToBody(body, [{ reasoning_content: 'unused' }]), body);
|
||||
});
|
||||
410
infrastructure/ai/providerContinuation.ts
Normal file
410
infrastructure/ai/providerContinuation.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
export type ProviderContinuationJSONValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| ProviderContinuationJSONValue[]
|
||||
| { [key: string]: ProviderContinuationJSONValue };
|
||||
|
||||
export type ProviderContinuationOptions = Record<string, Record<string, ProviderContinuationJSONValue>>;
|
||||
|
||||
export interface ProviderContinuationReasoningPart {
|
||||
text: string;
|
||||
providerOptions?: ProviderContinuationOptions;
|
||||
}
|
||||
|
||||
export interface ProviderContinuationSource {
|
||||
providerConfigId: string;
|
||||
providerType: string;
|
||||
modelId?: string;
|
||||
}
|
||||
|
||||
export interface ProviderContinuation {
|
||||
source?: ProviderContinuationSource;
|
||||
reasoningParts?: ProviderContinuationReasoningPart[];
|
||||
textProviderOptions?: ProviderContinuationOptions;
|
||||
toolCallProviderOptionsById?: Record<string, ProviderContinuationOptions>;
|
||||
openAIChatAssistantFields?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type OpenAIChatAssistantFields = Record<string, unknown>;
|
||||
|
||||
export interface ProviderContinuationHistoryMessage {
|
||||
providerContinuation?: ProviderContinuation;
|
||||
thinking?: string;
|
||||
model?: string;
|
||||
providerId?: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isSafeObjectKey(key: string): boolean {
|
||||
return key !== '__proto__' && key !== 'prototype' && key !== 'constructor';
|
||||
}
|
||||
|
||||
function parseRawValue(rawValue: unknown): unknown {
|
||||
if (typeof rawValue !== 'string') return rawValue;
|
||||
try {
|
||||
return JSON.parse(rawValue);
|
||||
} catch {
|
||||
return rawValue;
|
||||
}
|
||||
}
|
||||
|
||||
function toContinuationJSONValue(value: unknown): ProviderContinuationJSONValue | undefined {
|
||||
if (value === null) return null;
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const values: ProviderContinuationJSONValue[] = [];
|
||||
for (const item of value) {
|
||||
const converted = toContinuationJSONValue(item);
|
||||
if (converted !== undefined) values.push(converted);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
const converted: { [key: string]: ProviderContinuationJSONValue } = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (!isSafeObjectKey(key)) continue;
|
||||
const convertedItem = toContinuationJSONValue(item);
|
||||
if (convertedItem !== undefined) converted[key] = convertedItem;
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function normalizeProviderContinuationOptions(value: unknown): ProviderContinuationOptions | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const options: ProviderContinuationOptions = {};
|
||||
for (const [provider, providerOptions] of Object.entries(value)) {
|
||||
if (!isSafeObjectKey(provider)) continue;
|
||||
if (!isRecord(providerOptions)) continue;
|
||||
const normalizedProviderOptions: Record<string, ProviderContinuationJSONValue> = {};
|
||||
for (const [key, optionValue] of Object.entries(providerOptions)) {
|
||||
if (!isSafeObjectKey(key)) continue;
|
||||
const normalizedValue = toContinuationJSONValue(optionValue);
|
||||
if (normalizedValue !== undefined) normalizedProviderOptions[key] = normalizedValue;
|
||||
}
|
||||
if (Object.keys(normalizedProviderOptions).length) {
|
||||
options[provider] = normalizedProviderOptions;
|
||||
}
|
||||
}
|
||||
return Object.keys(options).length ? options : undefined;
|
||||
}
|
||||
|
||||
function cloneProviderOptions(options: ProviderContinuationOptions | undefined): ProviderContinuationOptions | undefined {
|
||||
if (!options) return undefined;
|
||||
const cloned: ProviderContinuationOptions = {};
|
||||
for (const [provider, providerOptions] of Object.entries(options)) {
|
||||
if (!isSafeObjectKey(provider)) continue;
|
||||
const safeProviderOptions: Record<string, ProviderContinuationJSONValue> = {};
|
||||
for (const [key, value] of Object.entries(providerOptions)) {
|
||||
if (!isSafeObjectKey(key)) continue;
|
||||
const normalizedValue = toContinuationJSONValue(value);
|
||||
if (normalizedValue !== undefined) safeProviderOptions[key] = normalizedValue;
|
||||
}
|
||||
if (Object.keys(safeProviderOptions).length) cloned[provider] = safeProviderOptions;
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
function cloneReasoningPart(part: ProviderContinuationReasoningPart): ProviderContinuationReasoningPart {
|
||||
return {
|
||||
text: part.text,
|
||||
...(part.providerOptions ? { providerOptions: cloneProviderOptions(part.providerOptions) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeProviderOptions(
|
||||
current: ProviderContinuationOptions | undefined,
|
||||
incoming: ProviderContinuationOptions | undefined,
|
||||
): ProviderContinuationOptions | undefined {
|
||||
if (!current && !incoming) return undefined;
|
||||
const merged: ProviderContinuationOptions = cloneProviderOptions(current) ?? {};
|
||||
for (const [provider, providerOptions] of Object.entries(incoming ?? {})) {
|
||||
if (!isSafeObjectKey(provider)) continue;
|
||||
const safeIncoming: Record<string, ProviderContinuationJSONValue> = {};
|
||||
for (const [key, value] of Object.entries(providerOptions)) {
|
||||
if (!isSafeObjectKey(key)) continue;
|
||||
const normalizedValue = toContinuationJSONValue(value);
|
||||
if (normalizedValue !== undefined) safeIncoming[key] = normalizedValue;
|
||||
}
|
||||
const existing = isRecord(merged[provider]) ? merged[provider] : undefined;
|
||||
if (!existing && !Object.keys(safeIncoming).length) continue;
|
||||
merged[provider] = {
|
||||
...(existing ?? {}),
|
||||
...safeIncoming,
|
||||
};
|
||||
}
|
||||
return Object.keys(merged).length ? merged : undefined;
|
||||
}
|
||||
|
||||
function mergeAssistantFields(
|
||||
current: Record<string, unknown> | undefined,
|
||||
incoming: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!current && !incoming) return undefined;
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(current ?? {})) {
|
||||
if (!isSafeObjectKey(key)) continue;
|
||||
merged[key] = value;
|
||||
}
|
||||
for (const [key, value] of Object.entries(incoming ?? {})) {
|
||||
if (!isSafeObjectKey(key)) continue;
|
||||
if (value === undefined) continue;
|
||||
const safeValue = typeof value === 'string' ? value : toContinuationJSONValue(value);
|
||||
if (safeValue === undefined) continue;
|
||||
const previous = merged[key];
|
||||
merged[key] = typeof previous === 'string' && typeof safeValue === 'string'
|
||||
? previous + safeValue
|
||||
: safeValue;
|
||||
}
|
||||
return Object.keys(merged).length ? merged : undefined;
|
||||
}
|
||||
|
||||
function isSameProviderContinuationSource(
|
||||
current: ProviderContinuationSource | undefined,
|
||||
incoming: ProviderContinuationSource | undefined,
|
||||
): boolean {
|
||||
if (!current || !incoming) return false;
|
||||
return current.providerConfigId === incoming.providerConfigId
|
||||
&& current.providerType === incoming.providerType
|
||||
&& current.modelId === incoming.modelId;
|
||||
}
|
||||
|
||||
function stableJSONValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(stableJSONValue);
|
||||
if (!isRecord(value)) return value;
|
||||
const stable: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(value).filter(isSafeObjectKey).sort()) {
|
||||
stable[key] = stableJSONValue(value[key]);
|
||||
}
|
||||
return stable;
|
||||
}
|
||||
|
||||
function providerOptionsKey(options: ProviderContinuationOptions | undefined): string {
|
||||
return JSON.stringify(stableJSONValue(options ?? {}));
|
||||
}
|
||||
|
||||
function canMergeReasoningPart(
|
||||
current: ProviderContinuationReasoningPart,
|
||||
incoming: ProviderContinuationReasoningPart,
|
||||
): boolean {
|
||||
if (!incoming.text) return true;
|
||||
return providerOptionsKey(current.providerOptions) === providerOptionsKey(incoming.providerOptions);
|
||||
}
|
||||
|
||||
function appendReasoningParts(
|
||||
current: ProviderContinuationReasoningPart[] | undefined,
|
||||
incoming: ProviderContinuationReasoningPart[] | undefined,
|
||||
): ProviderContinuationReasoningPart[] | undefined {
|
||||
const merged = (current ?? []).map(cloneReasoningPart);
|
||||
|
||||
for (const part of incoming ?? []) {
|
||||
if (!part.text && !part.providerOptions) continue;
|
||||
const normalizedPart = cloneReasoningPart(part);
|
||||
const last = merged.at(-1);
|
||||
if (last && canMergeReasoningPart(last, normalizedPart)) {
|
||||
last.text += normalizedPart.text;
|
||||
const providerOptions = mergeProviderOptions(last.providerOptions, normalizedPart.providerOptions);
|
||||
if (providerOptions) {
|
||||
last.providerOptions = providerOptions;
|
||||
} else {
|
||||
delete last.providerOptions;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
merged.push(normalizedPart);
|
||||
}
|
||||
|
||||
return merged.length ? merged : undefined;
|
||||
}
|
||||
|
||||
export function mergeProviderContinuation(
|
||||
current?: ProviderContinuation | null,
|
||||
incoming?: ProviderContinuation | null,
|
||||
): ProviderContinuation | undefined {
|
||||
const base = current?.source && incoming?.source && !isSameProviderContinuationSource(current.source, incoming.source)
|
||||
? undefined
|
||||
: current;
|
||||
const reasoningParts = appendReasoningParts(base?.reasoningParts, incoming?.reasoningParts);
|
||||
const textProviderOptions = mergeProviderOptions(base?.textProviderOptions, incoming?.textProviderOptions);
|
||||
const toolCallProviderOptionsById = mergeToolCallProviderOptions(
|
||||
base?.toolCallProviderOptionsById,
|
||||
incoming?.toolCallProviderOptionsById,
|
||||
);
|
||||
const openAIChatAssistantFields = mergeAssistantFields(
|
||||
base?.openAIChatAssistantFields,
|
||||
incoming?.openAIChatAssistantFields,
|
||||
);
|
||||
const source = incoming?.source ?? base?.source;
|
||||
|
||||
if (!reasoningParts && !textProviderOptions && !toolCallProviderOptionsById && !openAIChatAssistantFields) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...(source ? { source } : {}),
|
||||
...(reasoningParts ? { reasoningParts } : {}),
|
||||
...(textProviderOptions ? { textProviderOptions } : {}),
|
||||
...(toolCallProviderOptionsById ? { toolCallProviderOptionsById } : {}),
|
||||
...(openAIChatAssistantFields ? { openAIChatAssistantFields } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeToolCallProviderOptions(
|
||||
current: Record<string, ProviderContinuationOptions> | undefined,
|
||||
incoming: Record<string, ProviderContinuationOptions> | undefined,
|
||||
): Record<string, ProviderContinuationOptions> | undefined {
|
||||
if (!current && !incoming) return undefined;
|
||||
const merged: Record<string, ProviderContinuationOptions> = {};
|
||||
for (const [toolCallId, providerOptions] of Object.entries(current ?? {})) {
|
||||
if (!isSafeObjectKey(toolCallId)) continue;
|
||||
const cloned = cloneProviderOptions(providerOptions);
|
||||
if (cloned) merged[toolCallId] = cloned;
|
||||
}
|
||||
for (const [toolCallId, providerOptions] of Object.entries(incoming ?? {})) {
|
||||
if (!isSafeObjectKey(toolCallId)) continue;
|
||||
const next = mergeProviderOptions(merged[toolCallId], providerOptions);
|
||||
if (next) merged[toolCallId] = next;
|
||||
}
|
||||
return Object.keys(merged).length ? merged : undefined;
|
||||
}
|
||||
|
||||
export function withProviderContinuationSource(
|
||||
continuation: ProviderContinuation | undefined,
|
||||
source: ProviderContinuationSource | undefined,
|
||||
): ProviderContinuation | undefined {
|
||||
if (!continuation) return undefined;
|
||||
return source ? { ...continuation, source } : continuation;
|
||||
}
|
||||
|
||||
export function isProviderContinuationForSource(
|
||||
continuation: ProviderContinuation | undefined,
|
||||
source: ProviderContinuationSource | undefined,
|
||||
): boolean {
|
||||
if (!continuation?.source || !source) return false;
|
||||
return continuation.source.providerConfigId === source.providerConfigId
|
||||
&& continuation.source.providerType === source.providerType
|
||||
&& continuation.source.modelId === source.modelId;
|
||||
}
|
||||
|
||||
export function getOpenAIChatAssistantFieldsForHistoryMessage(
|
||||
message: ProviderContinuationHistoryMessage,
|
||||
source: ProviderContinuationSource | undefined,
|
||||
): OpenAIChatAssistantFields | undefined {
|
||||
const activeContinuation = isProviderContinuationForSource(message.providerContinuation, source)
|
||||
? message.providerContinuation
|
||||
: undefined;
|
||||
if (activeContinuation?.openAIChatAssistantFields) {
|
||||
return activeContinuation.openAIChatAssistantFields;
|
||||
}
|
||||
|
||||
if (!source) return undefined;
|
||||
if (message.providerId !== source.providerType) return undefined;
|
||||
if (source.modelId && message.model !== source.modelId) return undefined;
|
||||
|
||||
const thinking = typeof message.thinking === 'string' ? message.thinking.trim() : '';
|
||||
return thinking ? { reasoning_content: thinking } : undefined;
|
||||
}
|
||||
|
||||
export function extractProviderContinuationFromRawChunk(rawValue: unknown): ProviderContinuation | undefined {
|
||||
const parsed = parseRawValue(rawValue);
|
||||
if (!isRecord(parsed) || !Array.isArray(parsed.choices)) return undefined;
|
||||
|
||||
let reasoningContent = '';
|
||||
for (const choice of parsed.choices) {
|
||||
if (!isRecord(choice)) continue;
|
||||
const delta = isRecord(choice.delta) ? choice.delta : undefined;
|
||||
const message = isRecord(choice.message) ? choice.message : undefined;
|
||||
const rawReasoning = delta?.reasoning_content ?? message?.reasoning_content;
|
||||
if (typeof rawReasoning === 'string' && rawReasoning) {
|
||||
reasoningContent += rawReasoning;
|
||||
}
|
||||
}
|
||||
|
||||
if (!reasoningContent) return undefined;
|
||||
return {
|
||||
reasoningParts: [{ text: reasoningContent }],
|
||||
openAIChatAssistantFields: { reasoning_content: reasoningContent },
|
||||
};
|
||||
}
|
||||
|
||||
export function rawOpenAIChatChunkHasToolCalls(rawValue: unknown): boolean {
|
||||
const parsed = parseRawValue(rawValue);
|
||||
if (!isRecord(parsed) || !Array.isArray(parsed.choices)) return false;
|
||||
|
||||
for (const choice of parsed.choices) {
|
||||
if (!isRecord(choice)) continue;
|
||||
const delta = isRecord(choice.delta) ? choice.delta : undefined;
|
||||
const message = isRecord(choice.message) ? choice.message : undefined;
|
||||
if (delta && hasToolCalls(delta)) return true;
|
||||
if (message && hasToolCalls(message)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasToolCalls(message: Record<string, unknown>): boolean {
|
||||
return Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
|
||||
}
|
||||
|
||||
function compactAssistantFields(fields: OpenAIChatAssistantFields | undefined): OpenAIChatAssistantFields | undefined {
|
||||
const compacted: OpenAIChatAssistantFields = {};
|
||||
for (const [key, value] of Object.entries(fields ?? {})) {
|
||||
if (!isSafeObjectKey(key)) continue;
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
const safeValue = typeof value === 'string' ? value : toContinuationJSONValue(value);
|
||||
if (safeValue === undefined || safeValue === null || safeValue === '') continue;
|
||||
compacted[key] = safeValue;
|
||||
}
|
||||
return Object.keys(compacted).length ? compacted : undefined;
|
||||
}
|
||||
|
||||
export function applyOpenAIChatContinuationToBody(
|
||||
body: string,
|
||||
assistantFieldsByContinuationMessage: Array<OpenAIChatAssistantFields | undefined>,
|
||||
): string {
|
||||
if (!assistantFieldsByContinuationMessage.length) return body;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(body);
|
||||
} catch {
|
||||
return body;
|
||||
}
|
||||
|
||||
if (!isRecord(parsed) || !Array.isArray(parsed.messages)) return body;
|
||||
|
||||
let fieldIndex = 0;
|
||||
let changed = false;
|
||||
let previousMessageWasTool = false;
|
||||
const messages = parsed.messages.map((message) => {
|
||||
const isToolMessage = isRecord(message) && message.role === 'tool';
|
||||
const needsContinuationFields = isRecord(message)
|
||||
&& message.role === 'assistant'
|
||||
&& (hasToolCalls(message) || previousMessageWasTool);
|
||||
previousMessageWasTool = isToolMessage;
|
||||
|
||||
if (!needsContinuationFields) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const fields = compactAssistantFields(assistantFieldsByContinuationMessage[fieldIndex]);
|
||||
fieldIndex += 1;
|
||||
if (!fields) return message;
|
||||
|
||||
changed = true;
|
||||
return {
|
||||
...message,
|
||||
...mergeAssistantFields(message, fields),
|
||||
};
|
||||
});
|
||||
|
||||
if (!changed) return body;
|
||||
return JSON.stringify({ ...parsed, messages });
|
||||
}
|
||||
387
infrastructure/ai/providersBridgeFetch.test.ts
Normal file
387
infrastructure/ai/providersBridgeFetch.test.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { stepCountIs, streamText, tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { createBridgeFetchForSDK, createModelFromConfig } from './sdk/providers';
|
||||
import type { OpenAIChatAssistantFields } from './providerContinuation';
|
||||
|
||||
test('captures OpenAI-compatible reasoning_content before the tool follow-up request', async (t) => {
|
||||
const originalWindow = (globalThis as typeof globalThis & { window?: unknown }).window;
|
||||
t.after(() => {
|
||||
(globalThis as typeof globalThis & { window?: unknown }).window = originalWindow;
|
||||
});
|
||||
|
||||
const dataHandlers = new Map<string, (data: string) => void>();
|
||||
const endHandlers = new Map<string, () => void>();
|
||||
const sentBodies: Array<Record<string, unknown>> = [];
|
||||
const assistantFields: Array<OpenAIChatAssistantFields | undefined> = [];
|
||||
|
||||
const toolCall = {
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'terminal_exec', arguments: '{}' },
|
||||
};
|
||||
|
||||
(globalThis as typeof globalThis & { window?: unknown }).window = {
|
||||
netcatty: {
|
||||
aiFetch: async () => ({ ok: true, status: 200, data: '{}' }),
|
||||
aiChatCancel: async () => true,
|
||||
onAiStreamData: (requestId: string, cb: (data: string) => void) => {
|
||||
dataHandlers.set(requestId, cb);
|
||||
return () => dataHandlers.delete(requestId);
|
||||
},
|
||||
onAiStreamEnd: (requestId: string, cb: () => void) => {
|
||||
endHandlers.set(requestId, cb);
|
||||
return () => endHandlers.delete(requestId);
|
||||
},
|
||||
onAiStreamError: () => () => undefined,
|
||||
aiChatStream: async (
|
||||
requestId: string,
|
||||
_url: string,
|
||||
_headers: Record<string, string>,
|
||||
body: string,
|
||||
) => {
|
||||
sentBodies.push(JSON.parse(body));
|
||||
if (sentBodies.length === 1) {
|
||||
const emit = dataHandlers.get(requestId);
|
||||
assert.ok(emit, 'stream data handler should be registered before aiChatStream starts');
|
||||
emit(JSON.stringify({ choices: [{ index: 0, delta: { reasoning_content: 'need shell ' } }] }));
|
||||
emit(JSON.stringify({ choices: [{ index: 0, delta: { reasoning_content: 'context' } }] }));
|
||||
emit(JSON.stringify({ choices: [{ index: 0, delta: { tool_calls: [toolCall] } }] }));
|
||||
}
|
||||
endHandlers.get(requestId)?.();
|
||||
return { ok: true, statusCode: 200, statusText: 'OK' };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const fetch = createBridgeFetchForSDK('deepseek-custom', {
|
||||
getOpenAIChatAssistantFields: () => assistantFields,
|
||||
});
|
||||
|
||||
await fetch('https://api.deepseek.com/chat/completions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'inspect the host' }],
|
||||
}),
|
||||
});
|
||||
|
||||
await fetch('https://api.deepseek.com/chat/completions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
stream: true,
|
||||
messages: [
|
||||
{ role: 'user', content: 'inspect the host' },
|
||||
{ role: 'assistant', content: '', tool_calls: [toolCall] },
|
||||
{ role: 'tool', tool_call_id: 'call_1', content: '{"ok":true}' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const followUpBody = sentBodies[1];
|
||||
const messages = followUpBody.messages as Array<Record<string, unknown>>;
|
||||
assert.equal(messages[1].reasoning_content, 'need shell context');
|
||||
});
|
||||
|
||||
test('does not duplicate reasoning_content when tool calls stream across chunks', async (t) => {
|
||||
const originalWindow = (globalThis as typeof globalThis & { window?: unknown }).window;
|
||||
t.after(() => {
|
||||
(globalThis as typeof globalThis & { window?: unknown }).window = originalWindow;
|
||||
});
|
||||
|
||||
const dataHandlers = new Map<string, (data: string) => void>();
|
||||
const endHandlers = new Map<string, () => void>();
|
||||
const sentBodies: Array<Record<string, unknown>> = [];
|
||||
const assistantFields: Array<OpenAIChatAssistantFields | undefined> = [];
|
||||
|
||||
(globalThis as typeof globalThis & { window?: unknown }).window = {
|
||||
netcatty: {
|
||||
aiFetch: async () => ({ ok: true, status: 200, data: '{}' }),
|
||||
aiChatCancel: async () => true,
|
||||
onAiStreamData: (requestId: string, cb: (data: string) => void) => {
|
||||
dataHandlers.set(requestId, cb);
|
||||
return () => dataHandlers.delete(requestId);
|
||||
},
|
||||
onAiStreamEnd: (requestId: string, cb: () => void) => {
|
||||
endHandlers.set(requestId, cb);
|
||||
return () => endHandlers.delete(requestId);
|
||||
},
|
||||
onAiStreamError: () => () => undefined,
|
||||
aiChatStream: async (
|
||||
requestId: string,
|
||||
_url: string,
|
||||
_headers: Record<string, string>,
|
||||
body: string,
|
||||
) => {
|
||||
sentBodies.push(JSON.parse(body));
|
||||
if (sentBodies.length === 1) {
|
||||
const emit = dataHandlers.get(requestId);
|
||||
assert.ok(emit, 'stream data handler should be registered before aiChatStream starts');
|
||||
emit(JSON.stringify({ choices: [{ index: 0, delta: { reasoning_content: 'need shell context' } }] }));
|
||||
emit(JSON.stringify({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [{
|
||||
index: 0,
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'terminal_exec', arguments: '' },
|
||||
}],
|
||||
},
|
||||
}],
|
||||
}));
|
||||
emit(JSON.stringify({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [{
|
||||
index: 0,
|
||||
function: { arguments: '{}' },
|
||||
}],
|
||||
},
|
||||
}],
|
||||
}));
|
||||
}
|
||||
endHandlers.get(requestId)?.();
|
||||
return { ok: true, statusCode: 200, statusText: 'OK' };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const fetch = createBridgeFetchForSDK('deepseek-custom', {
|
||||
getOpenAIChatAssistantFields: () => assistantFields,
|
||||
});
|
||||
|
||||
await fetch('https://api.deepseek.com/chat/completions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'inspect the host' }],
|
||||
}),
|
||||
});
|
||||
|
||||
await fetch('https://api.deepseek.com/chat/completions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
stream: true,
|
||||
messages: [
|
||||
{ role: 'user', content: 'inspect the host' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'terminal_exec', arguments: '{}' },
|
||||
}],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_1', content: '{"ok":true}' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const followUpBody = sentBodies[1];
|
||||
const messages = followUpBody.messages as Array<Record<string, unknown>>;
|
||||
assert.equal(messages[1].reasoning_content, 'need shell context');
|
||||
});
|
||||
|
||||
test('keeps captured reasoning_content aligned across consecutive tool calls', async (t) => {
|
||||
const originalWindow = (globalThis as typeof globalThis & { window?: unknown }).window;
|
||||
t.after(() => {
|
||||
(globalThis as typeof globalThis & { window?: unknown }).window = originalWindow;
|
||||
});
|
||||
|
||||
const dataHandlers = new Map<string, (data: string) => void>();
|
||||
const endHandlers = new Map<string, () => void>();
|
||||
const sentBodies: Array<Record<string, unknown>> = [];
|
||||
const assistantFields: Array<OpenAIChatAssistantFields | undefined> = [];
|
||||
const toolCall = (id: string) => ({
|
||||
id,
|
||||
type: 'function',
|
||||
function: { name: 'terminal_exec', arguments: '{}' },
|
||||
});
|
||||
|
||||
(globalThis as typeof globalThis & { window?: unknown }).window = {
|
||||
netcatty: {
|
||||
aiFetch: async () => ({ ok: true, status: 200, data: '{}' }),
|
||||
aiChatCancel: async () => true,
|
||||
onAiStreamData: (requestId: string, cb: (data: string) => void) => {
|
||||
dataHandlers.set(requestId, cb);
|
||||
return () => dataHandlers.delete(requestId);
|
||||
},
|
||||
onAiStreamEnd: (requestId: string, cb: () => void) => {
|
||||
endHandlers.set(requestId, cb);
|
||||
return () => endHandlers.delete(requestId);
|
||||
},
|
||||
onAiStreamError: () => () => undefined,
|
||||
aiChatStream: async (
|
||||
requestId: string,
|
||||
_url: string,
|
||||
_headers: Record<string, string>,
|
||||
body: string,
|
||||
) => {
|
||||
sentBodies.push(JSON.parse(body));
|
||||
const emit = dataHandlers.get(requestId);
|
||||
assert.ok(emit, 'stream data handler should be registered before aiChatStream starts');
|
||||
if (sentBodies.length === 1) {
|
||||
emit(JSON.stringify({ choices: [{ index: 0, delta: { reasoning_content: 'first tool reasoning' } }] }));
|
||||
emit(JSON.stringify({ choices: [{ index: 0, delta: { tool_calls: [toolCall('call_1')] } }] }));
|
||||
} else if (sentBodies.length === 2) {
|
||||
emit(JSON.stringify({ choices: [{ index: 0, delta: { reasoning_content: 'second tool reasoning' } }] }));
|
||||
emit(JSON.stringify({ choices: [{ index: 0, delta: { tool_calls: [toolCall('call_2')] } }] }));
|
||||
}
|
||||
endHandlers.get(requestId)?.();
|
||||
return { ok: true, statusCode: 200, statusText: 'OK' };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const fetch = createBridgeFetchForSDK('deepseek-custom', {
|
||||
getOpenAIChatAssistantFields: () => assistantFields,
|
||||
});
|
||||
|
||||
await fetch('https://api.deepseek.com/chat/completions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'inspect the host' }],
|
||||
}),
|
||||
});
|
||||
|
||||
await fetch('https://api.deepseek.com/chat/completions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
stream: true,
|
||||
messages: [
|
||||
{ role: 'user', content: 'inspect the host' },
|
||||
{ role: 'assistant', content: '', tool_calls: [toolCall('call_1')] },
|
||||
{ role: 'tool', tool_call_id: 'call_1', content: '{"ok":true}' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await fetch('https://api.deepseek.com/chat/completions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
stream: true,
|
||||
messages: [
|
||||
{ role: 'user', content: 'inspect the host' },
|
||||
{ role: 'assistant', content: '', tool_calls: [toolCall('call_1')] },
|
||||
{ role: 'tool', tool_call_id: 'call_1', content: '{"ok":true}' },
|
||||
{ role: 'assistant', content: '', tool_calls: [toolCall('call_2')] },
|
||||
{ role: 'tool', tool_call_id: 'call_2', content: '{"ok":true}' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const secondRequestMessages = sentBodies[1].messages as Array<Record<string, unknown>>;
|
||||
const thirdRequestMessages = sentBodies[2].messages as Array<Record<string, unknown>>;
|
||||
assert.equal(secondRequestMessages[1].reasoning_content, 'first tool reasoning');
|
||||
assert.equal(thirdRequestMessages[1].reasoning_content, 'first tool reasoning');
|
||||
assert.equal(thirdRequestMessages[3].reasoning_content, 'second tool reasoning');
|
||||
});
|
||||
|
||||
test('replays reasoning_content through the SDK tool loop', async (t) => {
|
||||
const originalWindow = (globalThis as typeof globalThis & { window?: unknown }).window;
|
||||
t.after(() => {
|
||||
(globalThis as typeof globalThis & { window?: unknown }).window = originalWindow;
|
||||
});
|
||||
|
||||
const dataHandlers = new Map<string, (data: string) => void>();
|
||||
const endHandlers = new Map<string, () => void>();
|
||||
const sentBodies: Array<Record<string, unknown>> = [];
|
||||
const assistantFields: Array<OpenAIChatAssistantFields | undefined> = [];
|
||||
const toolCall = {
|
||||
index: 0,
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'terminal_exec', arguments: '{}' },
|
||||
};
|
||||
|
||||
const emitChatChunk = (emit: (data: string) => void, delta: Record<string, unknown>, finishReason?: string) => {
|
||||
emit(JSON.stringify({
|
||||
id: 'chatcmpl-test',
|
||||
object: 'chat.completion.chunk',
|
||||
created: 1777600000,
|
||||
model: 'deepseek-v4-flash',
|
||||
choices: [{ index: 0, delta, finish_reason: finishReason ?? null }],
|
||||
}));
|
||||
};
|
||||
|
||||
(globalThis as typeof globalThis & { window?: unknown }).window = {
|
||||
netcatty: {
|
||||
aiFetch: async () => ({ ok: true, status: 200, data: '{}' }),
|
||||
aiChatCancel: async () => true,
|
||||
onAiStreamData: (requestId: string, cb: (data: string) => void) => {
|
||||
dataHandlers.set(requestId, cb);
|
||||
return () => dataHandlers.delete(requestId);
|
||||
},
|
||||
onAiStreamEnd: (requestId: string, cb: () => void) => {
|
||||
endHandlers.set(requestId, cb);
|
||||
return () => endHandlers.delete(requestId);
|
||||
},
|
||||
onAiStreamError: () => () => undefined,
|
||||
aiChatStream: async (
|
||||
requestId: string,
|
||||
_url: string,
|
||||
_headers: Record<string, string>,
|
||||
body: string,
|
||||
) => {
|
||||
sentBodies.push(JSON.parse(body));
|
||||
const requestNumber = sentBodies.length;
|
||||
setTimeout(() => {
|
||||
const emit = dataHandlers.get(requestId);
|
||||
assert.ok(emit, 'stream data handler should be registered before aiChatStream starts');
|
||||
if (requestNumber === 1) {
|
||||
emitChatChunk(emit, { reasoning_content: 'need disk ' });
|
||||
emitChatChunk(emit, { reasoning_content: 'context' });
|
||||
emitChatChunk(emit, { tool_calls: [toolCall] });
|
||||
emitChatChunk(emit, {}, 'tool_calls');
|
||||
} else {
|
||||
emitChatChunk(emit, { reasoning_content: 'read result' });
|
||||
emitChatChunk(emit, { content: 'disk usage is 81%' });
|
||||
emitChatChunk(emit, {}, 'stop');
|
||||
}
|
||||
endHandlers.get(requestId)?.();
|
||||
}, 0);
|
||||
return { ok: true, statusCode: 200, statusText: 'OK' };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const model = createModelFromConfig(
|
||||
{
|
||||
id: 'deepseek-custom',
|
||||
providerId: 'custom',
|
||||
name: 'DeepSeek',
|
||||
apiKey: 'test-key',
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
defaultModel: 'deepseek-v4-flash',
|
||||
enabled: true,
|
||||
},
|
||||
{ getOpenAIChatAssistantFields: () => assistantFields },
|
||||
);
|
||||
|
||||
const result = streamText({
|
||||
model,
|
||||
messages: [{ role: 'user', content: 'inspect disk' }],
|
||||
tools: {
|
||||
terminal_exec: tool({
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => ({ ok: true }),
|
||||
}),
|
||||
},
|
||||
stopWhen: stepCountIs(2),
|
||||
includeRawChunks: true,
|
||||
});
|
||||
|
||||
for await (const _chunk of result.fullStream) {
|
||||
// Drain the stream so the SDK completes the tool loop.
|
||||
}
|
||||
|
||||
const followUpBody = sentBodies[1];
|
||||
const messages = followUpBody.messages as Array<Record<string, unknown>>;
|
||||
assert.equal(messages[1].reasoning_content, 'need disk context');
|
||||
});
|
||||
@@ -2,6 +2,17 @@ import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||
import { createGoogleGenerativeAI } from '@ai-sdk/google';
|
||||
import type { ProviderConfig } from '../types';
|
||||
import {
|
||||
applyOpenAIChatContinuationToBody,
|
||||
extractProviderContinuationFromRawChunk,
|
||||
mergeProviderContinuation,
|
||||
rawOpenAIChatChunkHasToolCalls,
|
||||
type OpenAIChatAssistantFields,
|
||||
} from '../providerContinuation';
|
||||
|
||||
export interface ProviderRequestContext {
|
||||
getOpenAIChatAssistantFields?: () => Array<OpenAIChatAssistantFields | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge API subset used for SDK fetch adapter.
|
||||
@@ -53,6 +64,57 @@ function isStreamingRequest(init?: RequestInit): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function mergeOpenAIChatAssistantFields(
|
||||
current: OpenAIChatAssistantFields | undefined,
|
||||
incoming: OpenAIChatAssistantFields | undefined,
|
||||
): OpenAIChatAssistantFields | undefined {
|
||||
return mergeProviderContinuation(
|
||||
{ openAIChatAssistantFields: current },
|
||||
{ openAIChatAssistantFields: incoming },
|
||||
)?.openAIChatAssistantFields;
|
||||
}
|
||||
|
||||
function createOpenAIChatStreamFieldCapture(
|
||||
requestContext?: ProviderRequestContext,
|
||||
): (data: string) => void {
|
||||
const assistantFields = requestContext?.getOpenAIChatAssistantFields?.();
|
||||
if (!assistantFields) return () => undefined;
|
||||
|
||||
let streamFieldIndex: number | undefined;
|
||||
let pendingFields: OpenAIChatAssistantFields | undefined;
|
||||
|
||||
const ensureStreamFieldSlot = (): number => {
|
||||
if (streamFieldIndex !== undefined) return streamFieldIndex;
|
||||
streamFieldIndex = assistantFields.length;
|
||||
assistantFields.push(undefined);
|
||||
return streamFieldIndex;
|
||||
};
|
||||
|
||||
const flushPendingFields = (fieldIndex: number) => {
|
||||
if (!pendingFields) return;
|
||||
assistantFields[fieldIndex] = mergeOpenAIChatAssistantFields(
|
||||
assistantFields[fieldIndex],
|
||||
pendingFields,
|
||||
);
|
||||
pendingFields = undefined;
|
||||
};
|
||||
|
||||
return (data: string) => {
|
||||
const continuation = extractProviderContinuationFromRawChunk(data);
|
||||
const fields = continuation?.openAIChatAssistantFields;
|
||||
if (fields) {
|
||||
pendingFields = mergeOpenAIChatAssistantFields(pendingFields, fields);
|
||||
if (streamFieldIndex !== undefined) {
|
||||
flushPendingFields(streamFieldIndex);
|
||||
}
|
||||
}
|
||||
|
||||
if (rawOpenAIChatChunkHasToolCalls(data)) {
|
||||
flushPendingFields(ensureStreamFieldSlot());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract headers as a plain Record<string, string> from various header formats.
|
||||
*/
|
||||
@@ -100,7 +162,10 @@ function toSafeStatusText(message: string, fallback: string): string {
|
||||
return byteStringSafe.slice(0, 120) || fallback;
|
||||
}
|
||||
|
||||
export function createBridgeFetchForSDK(providerId?: string): typeof globalThis.fetch {
|
||||
export function createBridgeFetchForSDK(
|
||||
providerId?: string,
|
||||
requestContext?: ProviderRequestContext,
|
||||
): typeof globalThis.fetch {
|
||||
return async (
|
||||
input: string | URL | Request,
|
||||
init?: RequestInit,
|
||||
@@ -132,10 +197,17 @@ export function createBridgeFetchForSDK(providerId?: string): typeof globalThis.
|
||||
const headers = extractHeaders(resolvedInit?.headers);
|
||||
const body =
|
||||
resolvedInit?.body != null ? String(resolvedInit.body) : undefined;
|
||||
const requestBody = body != null
|
||||
? applyOpenAIChatContinuationToBody(
|
||||
body,
|
||||
requestContext?.getOpenAIChatAssistantFields?.() ?? [],
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// Streaming path
|
||||
if (isStreamingRequest(resolvedInit)) {
|
||||
const requestId = `sdk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const captureOpenAIChatFields = createOpenAIChatStreamFieldCapture(requestContext);
|
||||
|
||||
// Set up IPC event listeners BEFORE starting the stream to avoid
|
||||
// missing early events.
|
||||
@@ -144,6 +216,7 @@ export function createBridgeFetchForSDK(providerId?: string): typeof globalThis.
|
||||
let cleanedUp = false;
|
||||
|
||||
const unsubData = bridge.onAiStreamData(requestId, (data: string) => {
|
||||
captureOpenAIChatFields(data);
|
||||
// Re-wrap as SSE so the SDK can parse it
|
||||
streamController?.enqueue(encoder.encode(`data: ${data}\n\n`));
|
||||
});
|
||||
@@ -186,7 +259,7 @@ export function createBridgeFetchForSDK(providerId?: string): typeof globalThis.
|
||||
requestId,
|
||||
url,
|
||||
headers,
|
||||
body || '',
|
||||
requestBody || '',
|
||||
providerId,
|
||||
);
|
||||
|
||||
@@ -231,7 +304,7 @@ export function createBridgeFetchForSDK(providerId?: string): typeof globalThis.
|
||||
}
|
||||
|
||||
// Non-streaming path
|
||||
const result = await bridge.aiFetch(url, method, headers, body, providerId);
|
||||
const result = await bridge.aiFetch(url, method, headers, requestBody, providerId);
|
||||
|
||||
return new Response(result.data, {
|
||||
status: result.status,
|
||||
@@ -249,10 +322,13 @@ export function createBridgeFetchForSDK(providerId?: string): typeof globalThis.
|
||||
* process replaces the placeholder with the real decrypted key before
|
||||
* making the HTTP request.
|
||||
*/
|
||||
export function createModelFromConfig(config: ProviderConfig) {
|
||||
export function createModelFromConfig(
|
||||
config: ProviderConfig,
|
||||
requestContext?: ProviderRequestContext,
|
||||
) {
|
||||
// Use placeholder API key — the main process will inject the real key
|
||||
const safeApiKey = config.apiKey ? API_KEY_PLACEHOLDER : undefined;
|
||||
const customFetch = createBridgeFetchForSDK(config.id);
|
||||
const customFetch = createBridgeFetchForSDK(config.id, requestContext);
|
||||
const modelId = config.defaultModel || '';
|
||||
|
||||
switch (config.providerId) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// AI Provider types
|
||||
import type { ProviderContinuation } from './providerContinuation';
|
||||
|
||||
export type AIProviderId = 'openai' | 'anthropic' | 'google' | 'ollama' | 'openrouter' | 'custom';
|
||||
|
||||
export interface ProviderAdvancedParams {
|
||||
@@ -69,6 +71,7 @@ export interface ChatMessage {
|
||||
images?: ChatMessageAttachment[];
|
||||
thinking?: string;
|
||||
thinkingDurationMs?: number;
|
||||
providerContinuation?: ProviderContinuation;
|
||||
toolCalls?: ToolCall[];
|
||||
toolResults?: ToolResult[];
|
||||
timestamp: number;
|
||||
|
||||
@@ -1425,7 +1425,6 @@ export class CloudSyncManager {
|
||||
customGroups: SyncPayload['customGroups'];
|
||||
snippetPackages?: SyncPayload['snippetPackages'];
|
||||
portForwardingRules?: SyncPayload['portForwardingRules'];
|
||||
knownHosts?: SyncPayload['knownHosts'];
|
||||
settings?: SyncPayload['settings'];
|
||||
}): SyncPayload {
|
||||
return {
|
||||
|
||||
@@ -3,11 +3,11 @@ import { STORAGE_KEY_KNOWN_HOSTS } from './config/storageKeys';
|
||||
import { localStorageAdapter } from './persistence/localStorageAdapter';
|
||||
|
||||
/**
|
||||
* Get effective knownHosts for sync payload.
|
||||
* Get effective knownHosts for local vault payloads.
|
||||
*
|
||||
* If the hook/state knownHosts is empty but localStorage has data,
|
||||
* read from localStorage to avoid uploading an empty array that
|
||||
* overwrites the cloud snapshot.
|
||||
* read from localStorage so local backups do not miss entries while
|
||||
* async store initialization is still settling.
|
||||
*/
|
||||
export function getEffectiveKnownHosts(
|
||||
knownHostsFromState: KnownHost[] | undefined,
|
||||
|
||||
@@ -65,7 +65,11 @@ export interface UploadCallbacks {
|
||||
export interface UploadBridge {
|
||||
writeLocalFile?: (path: string, data: ArrayBuffer) => Promise<void>;
|
||||
mkdirLocal?: (path: string) => Promise<void>;
|
||||
statLocal?: (path: string) => Promise<{ type: 'file' | 'directory' | 'symlink'; size: number; lastModified: number } | null>;
|
||||
deleteLocalFile?: (path: string) => Promise<void>;
|
||||
mkdirSftp: (sftpId: string, path: string) => Promise<void>;
|
||||
statSftp?: (sftpId: string, path: string) => Promise<{ type: 'file' | 'directory' | 'symlink'; size: number; lastModified: number } | null>;
|
||||
deleteSftp?: (sftpId: string, path: string) => Promise<void>;
|
||||
writeSftpBinary?: (sftpId: string, path: string, data: ArrayBuffer) => Promise<void>;
|
||||
writeSftpBinaryWithProgress?: (
|
||||
sftpId: string,
|
||||
@@ -111,6 +115,17 @@ export interface UploadConfig {
|
||||
callbacks?: UploadCallbacks;
|
||||
/** Use compressed upload for folders (requires tar on both local and remote) */
|
||||
useCompressedUpload?: boolean;
|
||||
resolveConflict?: (conflict: {
|
||||
fileName: string;
|
||||
targetPath: string;
|
||||
isDirectory: boolean;
|
||||
existingType?: 'file' | 'directory' | 'symlink';
|
||||
existingSize: number;
|
||||
newSize: number;
|
||||
existingModified: number;
|
||||
newModified: number;
|
||||
applyToAllCount: number;
|
||||
}) => Promise<'stop' | 'skip' | 'replace' | 'duplicate' | 'merge'>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -314,7 +329,7 @@ export async function uploadFromDataTransfer(
|
||||
config: UploadConfig,
|
||||
controller?: UploadController
|
||||
): Promise<UploadResult[]> {
|
||||
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks, useCompressedUpload } = config;
|
||||
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks, useCompressedUpload, resolveConflict } = config;
|
||||
|
||||
// Reset controller if provided
|
||||
if (controller) {
|
||||
@@ -324,6 +339,12 @@ export async function uploadFromDataTransfer(
|
||||
|
||||
// Create scanning placeholder
|
||||
const scanningTaskId = crypto.randomUUID();
|
||||
let scanningEnded = false;
|
||||
const endScanning = () => {
|
||||
if (scanningEnded) return;
|
||||
scanningEnded = true;
|
||||
callbacks?.onScanningEnd?.(scanningTaskId);
|
||||
};
|
||||
callbacks?.onScanningStart?.(scanningTaskId);
|
||||
|
||||
const scanT0 = performance.now();
|
||||
@@ -331,22 +352,18 @@ export async function uploadFromDataTransfer(
|
||||
try {
|
||||
entries = await extractDropEntries(dataTransfer);
|
||||
} catch (error) {
|
||||
callbacks?.onScanningEnd?.(scanningTaskId);
|
||||
endScanning();
|
||||
throw error;
|
||||
}
|
||||
endScanning();
|
||||
logger.debug(`[SFTP:perf] extractDropEntries — ${entries.length} entries — ${(performance.now() - scanT0).toFixed(0)}ms`);
|
||||
|
||||
if (entries.length === 0) {
|
||||
callbacks?.onScanningEnd?.(scanningTaskId);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!entries.some((entry) => !entry.isDirectory && entry.file)) {
|
||||
callbacks?.onScanningEnd?.(scanningTaskId);
|
||||
}
|
||||
|
||||
// Check if this is a folder upload and compressed upload is enabled
|
||||
if (useCompressedUpload && !isLocal && sftpId) {
|
||||
if (useCompressedUpload && !resolveConflict && !isLocal && sftpId) {
|
||||
const rootFolders = detectRootFolders(entries);
|
||||
const folderEntries = Array.from(rootFolders.entries()).filter(([key]) => !key.startsWith("__file__"));
|
||||
const standaloneFileEntries = Array.from(rootFolders.entries()).filter(([key]) => key.startsWith("__file__"));
|
||||
@@ -373,7 +390,7 @@ export async function uploadFromDataTransfer(
|
||||
});
|
||||
|
||||
if (failedFolderEntries.length > 0) {
|
||||
fallbackResults = await uploadEntries(failedFolderEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
|
||||
fallbackResults = await uploadEntries(failedFolderEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller, resolveConflict);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,19 +398,19 @@ export async function uploadFromDataTransfer(
|
||||
let standaloneResults: UploadResult[] = [];
|
||||
if (standaloneFileEntries.length > 0) {
|
||||
const standaloneEntries = standaloneFileEntries.flatMap(([, entries]) => entries);
|
||||
standaloneResults = await uploadEntries(standaloneEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
|
||||
standaloneResults = await uploadEntries(standaloneEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller, resolveConflict);
|
||||
}
|
||||
|
||||
// Combine results: successful compressed + fallback results + standalone files
|
||||
return [...successfulFolders, ...fallbackResults, ...standaloneResults];
|
||||
} catch {
|
||||
// Fall back to regular upload
|
||||
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
|
||||
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller, resolveConflict);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
|
||||
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller, resolveConflict);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -404,7 +421,7 @@ export async function uploadFromFileList(
|
||||
config: UploadConfig,
|
||||
controller?: UploadController
|
||||
): Promise<UploadResult[]> {
|
||||
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks, useCompressedUpload } = config;
|
||||
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks, useCompressedUpload, resolveConflict } = config;
|
||||
|
||||
if (controller) {
|
||||
controller.reset();
|
||||
@@ -433,7 +450,7 @@ export async function uploadFromFileList(
|
||||
}
|
||||
|
||||
// Check if this is a folder upload and compressed upload is enabled
|
||||
if (useCompressedUpload && !isLocal && sftpId) {
|
||||
if (useCompressedUpload && !resolveConflict && !isLocal && sftpId) {
|
||||
const rootFolders = detectRootFolders(entries);
|
||||
const folderEntries = Array.from(rootFolders.entries()).filter(([key]) => !key.startsWith("__file__"));
|
||||
const standaloneFileEntries = Array.from(rootFolders.entries()).filter(([key]) => key.startsWith("__file__"));
|
||||
@@ -460,7 +477,7 @@ export async function uploadFromFileList(
|
||||
});
|
||||
|
||||
if (failedFolderEntries.length > 0) {
|
||||
fallbackResults = await uploadEntries(failedFolderEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
|
||||
fallbackResults = await uploadEntries(failedFolderEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller, resolveConflict);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,19 +485,19 @@ export async function uploadFromFileList(
|
||||
let standaloneResults: UploadResult[] = [];
|
||||
if (standaloneFileEntries.length > 0) {
|
||||
const standaloneEntries = standaloneFileEntries.flatMap(([, entries]) => entries);
|
||||
standaloneResults = await uploadEntries(standaloneEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
|
||||
standaloneResults = await uploadEntries(standaloneEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller, resolveConflict);
|
||||
}
|
||||
|
||||
// Combine results: successful compressed + fallback results + standalone files
|
||||
return [...successfulFolders, ...fallbackResults, ...standaloneResults];
|
||||
} catch {
|
||||
// Fall back to regular upload
|
||||
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
|
||||
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller, resolveConflict);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
|
||||
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller, resolveConflict);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -494,11 +511,59 @@ async function uploadEntries(
|
||||
bridge: UploadBridge,
|
||||
joinPath: (base: string, name: string) => string,
|
||||
callbacks?: UploadCallbacks,
|
||||
controller?: UploadController
|
||||
controller?: UploadController,
|
||||
resolveConflict?: UploadConfig["resolveConflict"]
|
||||
): Promise<UploadResult[]> {
|
||||
const results: UploadResult[] = [];
|
||||
const createdDirs = new Set<string>();
|
||||
|
||||
const statTarget = async (path: string) => {
|
||||
try {
|
||||
if (isLocal) return await bridge.statLocal?.(path);
|
||||
if (sftpId) return await bridge.statSftp?.(sftpId, path);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const deleteTarget = async (path: string) => {
|
||||
if (isLocal) {
|
||||
await bridge.deleteLocalFile?.(path);
|
||||
} else if (sftpId) {
|
||||
await bridge.deleteSftp?.(sftpId, path);
|
||||
}
|
||||
};
|
||||
|
||||
const splitNameForDuplicate = (name: string, isDirectory: boolean) => {
|
||||
if (isDirectory) return { baseName: name, ext: "" };
|
||||
const lastDot = name.lastIndexOf(".");
|
||||
if (lastDot <= 0) return { baseName: name, ext: "" };
|
||||
return { baseName: name.slice(0, lastDot), ext: name.slice(lastDot) };
|
||||
};
|
||||
|
||||
const getDuplicateName = async (name: string, isDirectory: boolean) => {
|
||||
const { baseName, ext } = splitNameForDuplicate(name, isDirectory);
|
||||
for (let index = 1; index < 1000; index++) {
|
||||
const suffix = index === 1 ? " (copy)" : ` (copy ${index})`;
|
||||
const candidate = `${baseName}${suffix}${ext}`;
|
||||
const candidatePath = joinPath(targetPath, candidate);
|
||||
const existing = await statTarget(candidatePath);
|
||||
if (!existing) return candidate;
|
||||
}
|
||||
return `${baseName} (copy ${Date.now()})${ext}`;
|
||||
};
|
||||
|
||||
const renameRoot = (entry: DropEntry, oldName: string, newName: string): DropEntry => {
|
||||
if (entry.relativePath === oldName) {
|
||||
return { ...entry, relativePath: newName };
|
||||
}
|
||||
if (entry.relativePath.startsWith(`${oldName}/`)) {
|
||||
return { ...entry, relativePath: `${newName}/${entry.relativePath.slice(oldName.length + 1)}` };
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
|
||||
const ensureDirectory = async (dirPath: string) => {
|
||||
if (createdDirs.has(dirPath)) return;
|
||||
|
||||
@@ -518,7 +583,77 @@ async function uploadEntries(
|
||||
|
||||
// Group entries by root folder
|
||||
const rootFolders = detectRootFolders(entries);
|
||||
const sortedEntries = sortEntries(entries);
|
||||
let resolvedEntries = entries;
|
||||
|
||||
if (resolveConflict) {
|
||||
const resolved: DropEntry[] = [];
|
||||
let stop = false;
|
||||
const groups = Array.from(rootFolders.entries());
|
||||
|
||||
for (const [key, groupEntries] of groups) {
|
||||
if (stop || controller?.isCancelled()) break;
|
||||
|
||||
const isStandaloneFile = key.startsWith("__file__");
|
||||
const rootName = isStandaloneFile ? key.slice("__file__".length) : key;
|
||||
const isDirectory = !isStandaloneFile;
|
||||
const rootTargetPath = joinPath(targetPath, rootName);
|
||||
const existing = await statTarget(rootTargetPath);
|
||||
|
||||
if (!existing) {
|
||||
resolved.push(...groupEntries);
|
||||
continue;
|
||||
}
|
||||
|
||||
const newSize = groupEntries.reduce((sum, entry) => sum + (entry.file?.size ?? 0), 0);
|
||||
const action = await resolveConflict({
|
||||
fileName: rootName,
|
||||
targetPath: rootTargetPath,
|
||||
isDirectory,
|
||||
existingType: existing.type,
|
||||
existingSize: existing.size,
|
||||
newSize,
|
||||
existingModified: existing.lastModified,
|
||||
newModified: Date.now(),
|
||||
applyToAllCount: groups.filter(([groupKey]) => groupKey.startsWith("__file__") !== isDirectory).length,
|
||||
});
|
||||
|
||||
if (action === "stop") {
|
||||
stop = true;
|
||||
await controller?.cancel();
|
||||
resolved.length = 0;
|
||||
results.push({ fileName: rootName, success: false, cancelled: true });
|
||||
break;
|
||||
}
|
||||
|
||||
if (action === "skip") {
|
||||
results.push({ fileName: rootName, success: false, cancelled: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (action === "replace") {
|
||||
await deleteTarget(rootTargetPath);
|
||||
resolved.push(...groupEntries);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (action === "duplicate") {
|
||||
const duplicateName = await getDuplicateName(rootName, isDirectory);
|
||||
resolved.push(...groupEntries.map((entry) => renameRoot(entry, rootName, duplicateName)));
|
||||
continue;
|
||||
}
|
||||
|
||||
resolved.push(...groupEntries);
|
||||
}
|
||||
|
||||
resolvedEntries = resolved;
|
||||
}
|
||||
|
||||
if (resolvedEntries.length === 0) {
|
||||
return results;
|
||||
}
|
||||
|
||||
const resolvedRootFolders = detectRootFolders(resolvedEntries);
|
||||
const sortedEntries = sortEntries(resolvedEntries);
|
||||
|
||||
// Pre-create all needed directories in batch before file transfers
|
||||
const uploadT0 = performance.now();
|
||||
@@ -572,7 +707,7 @@ async function uploadEntries(
|
||||
// Create bundled tasks for each root folder
|
||||
const bundleTaskIds = new Map<string, string>(); // rootName -> bundleTaskId
|
||||
|
||||
for (const [rootName, rootEntries] of rootFolders) {
|
||||
for (const [rootName, rootEntries] of resolvedRootFolders) {
|
||||
const isStandaloneFile = rootName.startsWith("__file__");
|
||||
if (isStandaloneFile) continue;
|
||||
|
||||
@@ -947,7 +1082,7 @@ export async function uploadEntriesDirect(
|
||||
config: UploadConfig,
|
||||
controller?: UploadController
|
||||
): Promise<UploadResult[]> {
|
||||
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks, useCompressedUpload } = config;
|
||||
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks, useCompressedUpload, resolveConflict } = config;
|
||||
|
||||
if (controller) {
|
||||
controller.reset();
|
||||
@@ -959,7 +1094,7 @@ export async function uploadEntriesDirect(
|
||||
}
|
||||
|
||||
// Support compressed folder uploads (same logic as uploadFromDataTransfer)
|
||||
if (useCompressedUpload && !isLocal && sftpId) {
|
||||
if (useCompressedUpload && !resolveConflict && !isLocal && sftpId) {
|
||||
const rootFolders = detectRootFolders(entries);
|
||||
const folderEntries = Array.from(rootFolders.entries()).filter(([key]) => !key.startsWith("__file__"));
|
||||
const standaloneFileEntries = Array.from(rootFolders.entries()).filter(([key]) => key.startsWith("__file__"));
|
||||
@@ -983,24 +1118,24 @@ export async function uploadEntriesDirect(
|
||||
return failedFolderNames.has(topFolder);
|
||||
});
|
||||
if (failedFolderEntries.length > 0) {
|
||||
fallbackResults = await uploadEntries(failedFolderEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
|
||||
fallbackResults = await uploadEntries(failedFolderEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller, resolveConflict);
|
||||
}
|
||||
}
|
||||
|
||||
let standaloneResults: UploadResult[] = [];
|
||||
if (standaloneFileEntries.length > 0) {
|
||||
const standaloneEntries = standaloneFileEntries.flatMap(([, e]) => e);
|
||||
standaloneResults = await uploadEntries(standaloneEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
|
||||
standaloneResults = await uploadEntries(standaloneEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller, resolveConflict);
|
||||
}
|
||||
|
||||
return [...successfulFolders, ...fallbackResults, ...standaloneResults];
|
||||
} catch {
|
||||
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
|
||||
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller, resolveConflict);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
|
||||
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller, resolveConflict);
|
||||
}
|
||||
/**
|
||||
* Upload folders using compression
|
||||
|
||||
13
package.json
13
package.json
@@ -12,25 +12,28 @@
|
||||
"netcatty-tool-cli": "./electron/cli/netcatty-tool-cli.cjs"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "npm run lint && concurrently -k \"vite\" \"npm:dev:electron\"",
|
||||
"dev": "npm run fetch:mosh:dev && npm run lint && concurrently -k \"vite\" \"npm:dev:electron\"",
|
||||
"dev:electron": "wait-on http-get://localhost:5173 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 node electron/launch.cjs",
|
||||
"prebuild": "node scripts/copy-monaco.cjs",
|
||||
"fetch:mosh": "node scripts/fetch-mosh-binaries.cjs",
|
||||
"fetch:mosh:dev": "node scripts/fetch-mosh-binaries.cjs --host --resolve-release",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "node electron/launch.cjs",
|
||||
"pack": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --publish=never",
|
||||
"pack:dir": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --dir --publish=never",
|
||||
"pack:win": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --win --publish=never",
|
||||
"pack:win": "npm run build && cross-env npm_config_arch=x64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --win --x64 --publish=never && cross-env npm_config_arch=arm64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --win --arm64 --publish=never",
|
||||
"pack:win-x64": "npm run build && cross-env npm_config_arch=x64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --win --x64 --publish=never",
|
||||
"pack:mac": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --mac --publish=never",
|
||||
"pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --publish=never",
|
||||
"pack:linux-x64": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --x64 --publish=never",
|
||||
"pack:linux-arm64": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --arm64 --publish=never",
|
||||
"pack:linux-x64": "npm run build && cross-env npm_config_arch=x64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --x64 --publish=never",
|
||||
"pack:linux-arm64": "npm run build && cross-env npm_config_arch=arm64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --arm64 --publish=never",
|
||||
"postinstall": "electron-builder install-app-deps && patch-package",
|
||||
"rebuild": "electron-builder install-app-deps",
|
||||
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs application/state/*.test.ts components/*.test.tsx components/ai/*.test.ts components/terminal/*.test.ts components/terminal/runtime/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts"
|
||||
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs scripts/*.test.cjs application/state/*.test.ts components/*.test.tsx components/editor/*.test.tsx components/ai/*.test.ts components/terminal/*.test.ts components/terminal/runtime/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.58",
|
||||
|
||||
109
resources/mosh/README.md
Normal file
109
resources/mosh/README.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Bundled `mosh-client`
|
||||
|
||||
This directory holds the network-protocol-only `mosh-client` binary
|
||||
bundled with the Netcatty installer. Netcatty drives the `ssh` +
|
||||
`mosh-server` bootstrap itself and then launches this bundled client
|
||||
directly (see `electron/bridges/moshHandshake.cjs` and
|
||||
`electron/bridges/terminalBridge.cjs`).
|
||||
|
||||
## How binaries land here
|
||||
|
||||
1. `.github/workflows/build-mosh-binaries.yml` builds `mosh-client` on
|
||||
relevant pushes/PRs, or on a manual `workflow_dispatch`. It uses
|
||||
`scripts/build-mosh/{build-linux,build-macos,build-windows}.sh` to
|
||||
produce one binary per target from upstream `mobile-shell/mosh`
|
||||
source:
|
||||
|
||||
| target | provenance |
|
||||
|-------------------|-----------------------------------------------------------------|
|
||||
| `linux-x64` | upstream source, manylinux2014, static third-party deps + glibc |
|
||||
| `linux-arm64` | upstream source, manylinux2014, static third-party deps + glibc |
|
||||
| `darwin-universal`| upstream source, lipo arm64 + x86_64, macOS system dylibs only |
|
||||
| `win32-x64` | upstream source, Cygwin GCC, ships with bundled Cygwin DLLs |
|
||||
| `win32-arm64` | (not built — Cygwin arm64 port not yet stable) |
|
||||
|
||||
`fetch-windows.sh` is preserved as an emergency fallback that pulls
|
||||
the FluentTerminal-pinned binary; it's no longer wired into the
|
||||
default workflow.
|
||||
|
||||
2. When manually dispatched with `release_tag`, that workflow publishes
|
||||
the binaries to the dedicated `binaricat/Netcatty-mosh-bin`
|
||||
repository. The release gets a tag like `mosh-bin-1.4.0-1`, with
|
||||
`SHA256SUMS` attached.
|
||||
|
||||
3. Release packaging runs `scripts/resolve-mosh-bin-release.cjs` before
|
||||
`npm run fetch:mosh`. It uses an explicit workflow input first, then
|
||||
the `MOSH_BIN_RELEASE` repository variable, then the latest
|
||||
non-draft `mosh-bin-*` GitHub Release from the dedicated binary
|
||||
repository. The fetch step pulls the binaries into
|
||||
`resources/mosh/<platform-arch>/`. For local packaging, set
|
||||
`MOSH_BIN_RELEASE` yourself before running the same fetch command.
|
||||
Override `MOSH_BIN_OWNER` / `MOSH_BIN_REPO` only when testing a
|
||||
different binary repository. `electron-builder.config.cjs` then
|
||||
copies the matching binary into `Resources/mosh/mosh-client[.exe]`.
|
||||
|
||||
Local dev uses the same binary path: `npm run dev` runs
|
||||
`npm run fetch:mosh:dev` first, which downloads the host platform's
|
||||
bundled `mosh-client` into this gitignored directory. Netcatty does
|
||||
not fall back to a system-installed `mosh` or `mosh-client`; if the
|
||||
bundled binary is missing, Mosh startup fails loudly instead of using
|
||||
whatever happens to be installed on the developer machine.
|
||||
|
||||
Official Windows package builds currently ship x64 only for bundled
|
||||
Mosh coverage. Windows arm64 packaging should be re-enabled there
|
||||
after the `build-mosh-binaries` workflow can produce `win32-arm64`.
|
||||
|
||||
The directory is otherwise empty (binaries are gitignored).
|
||||
|
||||
## Licenses
|
||||
|
||||
- Mosh itself is licensed under **GPL-3.0**
|
||||
(https://github.com/mobile-shell/mosh).
|
||||
- Netcatty is **GPL-3.0**, so redistribution as part of the installer
|
||||
is permitted.
|
||||
- The Windows binary is built in CI from upstream
|
||||
https://github.com/mobile-shell/mosh @ tag `MOSH_REF` (default
|
||||
`mosh-1.4.0`) using the Cygwin GCC toolchain. The bundled DLLs are
|
||||
redistributable Cygwin runtime libraries — see
|
||||
`mosh-client-win32-x64-dlls/README.txt` (generated by the build) for
|
||||
the per-DLL license listing.
|
||||
- Bundled/static deps (OpenSSL Apache-2.0, protobuf BSD-3-Clause,
|
||||
ncurses MIT) are compatible with GPL-3.0.
|
||||
|
||||
## Reproducible build
|
||||
|
||||
To reproduce the binaries locally:
|
||||
|
||||
```sh
|
||||
docker run --rm -v $PWD:/workspace -w /workspace \
|
||||
-e MOSH_REF=mosh-1.4.0 -e ARCH=x64 -e OUT_DIR=/workspace/out \
|
||||
quay.io/pypa/manylinux2014_x86_64 \
|
||||
bash scripts/build-mosh/build-linux.sh
|
||||
```
|
||||
|
||||
For macOS the build needs an Xcode toolchain; see
|
||||
`scripts/build-mosh/build-macos.sh`.
|
||||
|
||||
## Phase 2/3 — done in this PR
|
||||
|
||||
- `electron/bridges/moshHandshake.cjs` reimplements the upstream Mosh
|
||||
Perl wrapper in Node: parser + sniffer + command builders as pure
|
||||
functions.
|
||||
- `terminalBridge.startMoshSession` runs the SSH bootstrap in a
|
||||
node-pty so password / 2FA / known-hosts prompts render naturally
|
||||
in the user's terminal, then swaps `session.proc` from the ssh PTY
|
||||
to a freshly-spawned `mosh-client` PTY when `MOSH CONNECT` is
|
||||
detected. Keystrokes that arrive after the swap go to mosh-client
|
||||
because `writeToSession` reads `session.proc` lazily.
|
||||
- Mosh startup requires Netcatty's bundled `mosh-client` and a usable
|
||||
`ssh` client for the remote bootstrap. System-installed `mosh` /
|
||||
`mosh-client` binaries are intentionally ignored.
|
||||
- Windows binary built in-CI from upstream source via Cygwin GCC; ships
|
||||
alongside `cygwin1.dll` + transitive deps so it runs on a stock
|
||||
Windows machine without a Cygwin install.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- Cygwin arm64 port stabilizes → add a `build-windows-arm64` matrix
|
||||
leg using the same `build-windows.sh` script.
|
||||
- Make `MOSH_REF` track upstream release tags automatically.
|
||||
114
scripts/build-mosh/build-linux.sh
Executable file
114
scripts/build-mosh/build-linux.sh
Executable file
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build a portable mosh-client binary inside manylinux2014.
|
||||
#
|
||||
# Inputs (env):
|
||||
# MOSH_REF — git ref of mobile-shell/mosh to build (e.g. mosh-1.4.0)
|
||||
# ARCH — x64 | arm64 (for output naming only; container is already that arch)
|
||||
# OUT_DIR — directory to write mosh-client-linux-<arch> + sha256
|
||||
#
|
||||
# Output:
|
||||
# $OUT_DIR/mosh-client-linux-<arch>
|
||||
# $OUT_DIR/mosh-client-linux-<arch>.sha256
|
||||
#
|
||||
# Strategy: build OpenSSL, protobuf, ncurses as static archives in a
|
||||
# scratch prefix, then build mosh against those and link libstdc++/libgcc
|
||||
# statically. The resulting binary still depends on standard Linux system
|
||||
# libraries such as glibc/libz/libutil from the manylinux2014 baseline
|
||||
# (compatible with virtually every distro released since 2014, including
|
||||
# Debian 9+, Ubuntu 18.04+, CentOS 7+).
|
||||
set -euo pipefail
|
||||
|
||||
: "${MOSH_REF:?missing MOSH_REF}"
|
||||
: "${ARCH:?missing ARCH}"
|
||||
: "${OUT_DIR:?missing OUT_DIR}"
|
||||
|
||||
validate_mosh_ref() {
|
||||
if [[ ! "$MOSH_REF" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] \
|
||||
|| [[ "$MOSH_REF" == *..* ]] \
|
||||
|| [[ "$MOSH_REF" == *@\{* ]] \
|
||||
|| [[ "$MOSH_REF" == */ ]] \
|
||||
|| [[ "$MOSH_REF" == *.lock ]]; then
|
||||
echo "ERROR: invalid MOSH_REF: $MOSH_REF" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
validate_mosh_ref
|
||||
|
||||
OPENSSL_VER=3.0.13
|
||||
PROTOBUF_VER=21.12
|
||||
NCURSES_VER=6.4
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
PREFIX="$WORK/prefix"
|
||||
mkdir -p "$PREFIX/lib" "$PREFIX/include" "$OUT_DIR"
|
||||
|
||||
yum install -y -q autoconf automake libtool perl perl-IPC-Cmd make gcc gcc-c++ pkgconfig zlib-devel
|
||||
|
||||
cd "$WORK"
|
||||
|
||||
# OpenSSL static
|
||||
curl -fsSL "https://www.openssl.org/source/openssl-$OPENSSL_VER.tar.gz" | tar xz
|
||||
( cd "openssl-$OPENSSL_VER"
|
||||
./config no-shared no-tests --prefix="$PREFIX" --openssldir="$PREFIX/ssl"
|
||||
make -j"$(nproc)"
|
||||
make install_sw )
|
||||
|
||||
# protobuf static (3.x stays compatible with mosh's generated proto code)
|
||||
curl -fsSL "https://github.com/protocolbuffers/protobuf/releases/download/v$PROTOBUF_VER/protobuf-cpp-3.$PROTOBUF_VER.tar.gz" | tar xz
|
||||
( cd "protobuf-3.$PROTOBUF_VER"
|
||||
./configure --prefix="$PREFIX" --enable-static --disable-shared --with-pic
|
||||
make -j"$(nproc)"
|
||||
make install )
|
||||
|
||||
# ncurses static
|
||||
curl -fsSL "https://invisible-island.net/archives/ncurses/ncurses-$NCURSES_VER.tar.gz" | tar xz
|
||||
( cd "ncurses-$NCURSES_VER"
|
||||
CFLAGS="-fPIC -O2" CXXFLAGS="-fPIC -O2" \
|
||||
./configure --prefix="$PREFIX" --without-shared --without-debug --without-cxx-shared --without-tests --disable-pc-files --enable-widec
|
||||
make -j"$(nproc)"
|
||||
make install )
|
||||
|
||||
# Mosh. Fetch the requested ref explicitly so branch names, tags, and commit
|
||||
# SHAs all work from workflow_dispatch.
|
||||
git init mosh
|
||||
git -C mosh remote add origin https://github.com/mobile-shell/mosh.git
|
||||
git -C mosh fetch --depth 1 origin "$MOSH_REF"
|
||||
git -C mosh checkout --detach FETCH_HEAD
|
||||
( cd mosh
|
||||
export PATH="$PREFIX/bin:$PATH"
|
||||
./autogen.sh
|
||||
PKG_CONFIG_PATH="$PREFIX/lib/pkgconfig:$PREFIX/lib64/pkgconfig" \
|
||||
./configure --enable-completion=no --disable-server \
|
||||
CPPFLAGS="-I$PREFIX/include -I$PREFIX/include/ncursesw" \
|
||||
CXXFLAGS="-I$PREFIX/include -I$PREFIX/include/ncursesw -O2" \
|
||||
CFLAGS="-I$PREFIX/include -I$PREFIX/include/ncursesw -O2" \
|
||||
LDFLAGS="-L$PREFIX/lib -L$PREFIX/lib64 -static-libstdc++ -static-libgcc" \
|
||||
LIBS="-ldl -lpthread"
|
||||
make -j"$(nproc)" )
|
||||
|
||||
OUT_BIN="$OUT_DIR/mosh-client-linux-$ARCH"
|
||||
cp mosh/src/frontend/mosh-client "$OUT_BIN"
|
||||
strip "$OUT_BIN"
|
||||
|
||||
echo "--- file ---"
|
||||
file "$OUT_BIN"
|
||||
echo "--- ldd ---"
|
||||
ldd "$OUT_BIN" || true
|
||||
echo "--- size ---"
|
||||
ls -lh "$OUT_BIN"
|
||||
|
||||
# Sanity check: must not link any non-system shared libraries. Allow only
|
||||
# the glibc runtime family and the ELF loader.
|
||||
ldd "$OUT_BIN" > "$WORK/ldd.txt" || true
|
||||
awk '
|
||||
/=>/ { print $1; next }
|
||||
/^[[:space:]]*\/.*ld-linux/ { print $1; next }
|
||||
' "$WORK/ldd.txt" > "$WORK/deps.txt"
|
||||
if grep -Ev '^(linux-vdso\.so\.1|lib(c|m|pthread|rt|dl|resolv|util|z)\.so\.[0-9]+|/lib.*/ld-linux.*\.so\.[0-9]+|ld-linux.*\.so\.[0-9]+)$' "$WORK/deps.txt"; then
|
||||
echo "ERROR: mosh-client links a non-system shared library; static linking failed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
( cd "$OUT_DIR" && sha256sum "mosh-client-linux-$ARCH" > "mosh-client-linux-$ARCH.sha256" )
|
||||
cat "$OUT_DIR/mosh-client-linux-$ARCH.sha256"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user