Compare commits
146 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
517cbb6cee | ||
|
|
3bc373dbec | ||
|
|
273fe10296 | ||
|
|
2a10a28cc8 | ||
|
|
f74645e1a4 | ||
|
|
15ec02dcae | ||
|
|
e75c654a1a | ||
|
|
29b1eca1fd | ||
|
|
e2d036e710 | ||
|
|
094f0abe4a | ||
|
|
8ab2003dae | ||
|
|
b1b0c5648c | ||
|
|
36e5779d94 | ||
|
|
53aef452cc | ||
|
|
3ef5a64b94 | ||
|
|
c28db932a4 | ||
|
|
f2c2501fa5 | ||
|
|
b1f930a995 | ||
|
|
de60b616cd | ||
|
|
6e6a0240a7 | ||
|
|
2e2360a9fc | ||
|
|
8011f4e2e8 | ||
|
|
970037682c | ||
|
|
42b58efc5c | ||
|
|
b20163d762 | ||
|
|
e0a56cbb14 | ||
|
|
8dae851ea3 | ||
|
|
03ba9595c0 | ||
|
|
4b07b4826a | ||
|
|
80d9b33c59 | ||
|
|
3be3c14912 | ||
|
|
4171f85c73 | ||
|
|
5a78ebcf7c | ||
|
|
9294a7130f | ||
|
|
9ce3abc2b4 | ||
|
|
327594a598 | ||
|
|
31cccdec03 | ||
|
|
29a6172120 | ||
|
|
06486e06dd | ||
|
|
ada55ab461 | ||
|
|
a9e4de65a9 | ||
|
|
2867262e4d | ||
|
|
779c09186c | ||
|
|
6a0408b942 | ||
|
|
43e094c345 | ||
|
|
7d30b19421 | ||
|
|
e9e8c35178 | ||
|
|
646e7ce001 | ||
|
|
21da34187e | ||
|
|
d2fa4f1cd9 | ||
|
|
72a6fc14f9 | ||
|
|
97c2cb1f86 | ||
|
|
73fd091b80 | ||
|
|
5bb4052f3d | ||
|
|
36e7e3cb7f | ||
|
|
25b73187f5 | ||
|
|
75a9600089 | ||
|
|
c9216b32ab | ||
|
|
70e374ef11 | ||
|
|
24840c539c | ||
|
|
461be76821 | ||
|
|
28a7184cc4 | ||
|
|
13f1453276 | ||
|
|
ff25c36ede | ||
|
|
092aa45fd9 | ||
|
|
64acf80024 | ||
|
|
099beb8438 | ||
|
|
8bee13c3f9 | ||
|
|
65cd8aba79 | ||
|
|
37856e5608 | ||
|
|
5b1deaa08a | ||
|
|
a41bced1d7 | ||
|
|
8c207a1dff | ||
|
|
99e1974a69 | ||
|
|
132bf288ac | ||
|
|
0a9f9848b7 | ||
|
|
11da55abf7 | ||
|
|
e751c0f23e | ||
|
|
f4b5beec01 | ||
|
|
9e2b8093fb | ||
|
|
7b2f66000c | ||
|
|
6e7593dee2 | ||
|
|
2098b2b09d | ||
|
|
8181fe71cf | ||
|
|
d06009684e | ||
|
|
55236ce34a | ||
|
|
b89f06b7f0 | ||
|
|
a01d1f770f | ||
|
|
f1fdb61195 | ||
|
|
9d0f6a9cea | ||
|
|
e0403412e7 | ||
|
|
bb67aa77f5 | ||
|
|
e948a7a869 | ||
|
|
3fc56df111 | ||
|
|
008890a688 | ||
|
|
178f56455e | ||
|
|
8376e35022 | ||
|
|
0b8206aecb | ||
|
|
203505bc25 | ||
|
|
c5ac85ae5b | ||
|
|
c6552ddc75 | ||
|
|
7972b19bdd | ||
|
|
33918a2433 | ||
|
|
9e7f6d98fd | ||
|
|
ceda20510f | ||
|
|
82f3250b5b | ||
|
|
c37e087332 | ||
|
|
f282c58edc | ||
|
|
45e208f1d8 | ||
|
|
132c597d1e | ||
|
|
85f486e6cd | ||
|
|
2c96773679 | ||
|
|
a8f9fd7a56 | ||
|
|
aae4ad4da8 | ||
|
|
014d7b4d39 | ||
|
|
5912339813 | ||
|
|
20bfa0e3bd | ||
|
|
41ebe0fa64 | ||
|
|
66cf610cc0 | ||
|
|
62ec391523 | ||
|
|
7927da2085 | ||
|
|
d45dea4bff | ||
|
|
816e274dfc | ||
|
|
1a20a6a4a8 | ||
|
|
910049b0ea | ||
|
|
173a83aafa | ||
|
|
b7093f88b1 | ||
|
|
2e66bcf254 | ||
|
|
95208294b0 | ||
|
|
a4bf2234cd | ||
|
|
e527e7233f | ||
|
|
afe959835d | ||
|
|
3b2b05064b | ||
|
|
1e94fe983f | ||
|
|
274ac4e0e1 | ||
|
|
1ad4443e3b | ||
|
|
031bf0ee45 | ||
|
|
0efe80b06d | ||
|
|
3fb7c6dd21 | ||
|
|
c7e4ac82ca | ||
|
|
d5e29598d3 | ||
|
|
fca7782634 | ||
|
|
42b23a9faa | ||
|
|
06011d01d6 | ||
|
|
4bf4e65df8 | ||
|
|
45e62ed43e |
118
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
118
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,118 @@
|
||||
name: Bug Report
|
||||
description: Report a reproducible problem in Netcatty
|
||||
title: "[Bug] "
|
||||
labels: ["bug", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug. Incomplete reports may be closed automatically.
|
||||
Please search [existing issues](https://github.com/binaricat/Netcatty/issues) first.
|
||||
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Operating system
|
||||
options:
|
||||
- macOS
|
||||
- Windows
|
||||
- Linux
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Netcatty version
|
||||
description: Find it in Settings > Application, or on the [latest release](https://github.com/binaricat/Netcatty/releases/latest) page.
|
||||
placeholder: "e.g. 1.2.3"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: install_source
|
||||
attributes:
|
||||
label: How did you install Netcatty?
|
||||
options:
|
||||
- GitHub Release (.dmg / .exe / .AppImage / .deb)
|
||||
- Homebrew
|
||||
- Built from source (npm run dev / pack)
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Affected area
|
||||
multiple: true
|
||||
options:
|
||||
- SSH connection / terminal
|
||||
- SFTP / file browser
|
||||
- Host vault / keychain
|
||||
- Port forwarding
|
||||
- Snippets
|
||||
- AI assistant
|
||||
- Settings / sync
|
||||
- UI / layout
|
||||
- Crash / app won't start
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: reproducibility
|
||||
attributes:
|
||||
label: Can you reproduce it?
|
||||
options:
|
||||
- Always (100%)
|
||||
- Often (>50%)
|
||||
- Sometimes
|
||||
- Once / not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Numbered steps so we can follow exactly.
|
||||
placeholder: |
|
||||
1. Open Netcatty and connect to host X
|
||||
2. Click SFTP tab
|
||||
3. ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / screenshots
|
||||
description: |
|
||||
Optional but helpful. Crash logs: Settings > System > Crash Logs > Open folder.
|
||||
For SSH errors, include redacted connection details (no passwords / private keys).
|
||||
placeholder: Paste relevant log lines or attach screenshots.
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Before submitting
|
||||
options:
|
||||
- label: I searched existing issues and did not find a duplicate
|
||||
required: true
|
||||
- label: I removed passwords, private keys, and other secrets from this report
|
||||
required: true
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions & general help
|
||||
url: https://github.com/binaricat/Netcatty/discussions
|
||||
about: Not sure if it is a bug? Ask in Discussions first.
|
||||
- name: Latest release
|
||||
url: https://github.com/binaricat/Netcatty/releases/latest
|
||||
about: Check your Netcatty version before reporting.
|
||||
72
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
72
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
name: Feature Request
|
||||
description: Suggest an improvement or new capability
|
||||
title: "[Feature] "
|
||||
labels: ["enhancement", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Describe the problem you are trying to solve and the change you want.
|
||||
Vague requests like "make it better" may be closed.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem / pain point
|
||||
description: What is hard, missing, or frustrating today?
|
||||
placeholder: When I manage 50+ hosts, I cannot ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: What would you like Netcatty to do?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Other tools, workarounds, or designs you thought about.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Related area
|
||||
multiple: true
|
||||
options:
|
||||
- SSH / terminal
|
||||
- SFTP
|
||||
- Host vault / keychain
|
||||
- Port forwarding
|
||||
- Snippets
|
||||
- AI assistant
|
||||
- Settings / sync
|
||||
- UI / UX
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: How important is this to you?
|
||||
options:
|
||||
- Nice to have
|
||||
- Would improve my daily workflow
|
||||
- Blocking / critical for my use case
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Before submitting
|
||||
options:
|
||||
- label: I searched existing issues and discussions for similar requests
|
||||
required: true
|
||||
222
.github/workflows/build-et-binaries.yml
vendored
Normal file
222
.github/workflows/build-et-binaries.yml
vendored
Normal file
@@ -0,0 +1,222 @@
|
||||
name: build-et-binaries
|
||||
|
||||
# Trigger philosophy (mirrors build-mosh-binaries.yml):
|
||||
# - Pushes that touch the et 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-et-bin` by default).
|
||||
#
|
||||
# `paths` keeps unrelated commits (UI, bridges, etc) from rebuilding the et
|
||||
# binaries on every push.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
et_ref:
|
||||
description: "EternalTerminal git ref (tag/branch/commit) — see https://github.com/MisterTea/EternalTerminal"
|
||||
type: string
|
||||
default: "et-v6.2.10"
|
||||
release_tag:
|
||||
description: "Optional release tag to attach binaries to (e.g. et-bin-6.2.10-1). Empty = artifacts only."
|
||||
type: string
|
||||
default: ""
|
||||
release_repo:
|
||||
description: "Repository that stores et binary releases."
|
||||
type: string
|
||||
default: "binaricat/Netcatty-et-bin"
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
paths:
|
||||
- ".github/workflows/build-et-binaries.yml"
|
||||
- "electron-builder.config.cjs"
|
||||
- "package.json"
|
||||
- "scripts/build-et/**"
|
||||
- "scripts/fetch-et-binaries.cjs"
|
||||
- "scripts/et-extra-resources.cjs"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/build-et-binaries.yml"
|
||||
- "electron-builder.config.cjs"
|
||||
- "package.json"
|
||||
- "scripts/build-et/**"
|
||||
- "scripts/fetch-et-binaries.cjs"
|
||||
- "scripts/et-extra-resources.cjs"
|
||||
|
||||
concurrency:
|
||||
group: build-et-binaries-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
ET_REF: ${{ inputs.et_ref || 'et-v6.2.10' }}
|
||||
|
||||
jobs:
|
||||
# ------------------------------------------------------------------
|
||||
# Linux x64 (manylinux2014 / glibc 2.17, broad distro compatibility).
|
||||
# ------------------------------------------------------------------
|
||||
build-linux-x64:
|
||||
name: build-linux-x64
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build et (linux-x64)
|
||||
run: |
|
||||
docker run --rm \
|
||||
-e ET_REF="${ET_REF}" \
|
||||
-e OUT_DIR=/work/out \
|
||||
-e ARCH=x64 \
|
||||
-v "${GITHUB_WORKSPACE}:/work" \
|
||||
-w /work \
|
||||
quay.io/pypa/manylinux2014_x86_64 \
|
||||
bash scripts/build-et/build-linux.sh
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: et-linux-x64
|
||||
path: out/
|
||||
|
||||
build-linux-arm64:
|
||||
name: build-linux-arm64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build et (linux-arm64)
|
||||
run: |
|
||||
docker run --rm \
|
||||
-e ET_REF="${ET_REF}" \
|
||||
-e OUT_DIR=/work/out \
|
||||
-e ARCH=arm64 \
|
||||
-v "${GITHUB_WORKSPACE}:/work" \
|
||||
-w /work \
|
||||
quay.io/pypa/manylinux2014_aarch64 \
|
||||
bash scripts/build-et/build-linux.sh
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: et-linux-arm64
|
||||
path: out/
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# macOS universal2 (arm64 + x86_64 lipo). Min deployment target macOS 11.
|
||||
# ------------------------------------------------------------------
|
||||
build-macos-universal:
|
||||
name: build-macos-universal
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build et (darwin-universal)
|
||||
env:
|
||||
ET_REF: ${{ env.ET_REF }}
|
||||
OUT_DIR: ${{ github.workspace }}/out
|
||||
MACOSX_DEPLOYMENT_TARGET: "11.0"
|
||||
run: bash scripts/build-et/build-macos.sh
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: et-darwin-universal
|
||||
path: out/
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Windows x64 — static MSVC build (no DLL bundle).
|
||||
# ------------------------------------------------------------------
|
||||
build-windows-x64:
|
||||
name: build-windows-x64
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install ninja
|
||||
run: choco install -y ninja
|
||||
- name: Set up MSVC developer command prompt
|
||||
uses: ilammy/msvc-dev-cmd@v1
|
||||
with:
|
||||
arch: x64
|
||||
- name: Build et (win32-x64)
|
||||
env:
|
||||
ET_REF: ${{ env.ET_REF }}
|
||||
OUT_DIR: ${{ github.workspace }}\out
|
||||
shell: pwsh
|
||||
run: pwsh -File scripts/build-et/build-windows.ps1
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: et-win32-x64
|
||||
path: out/
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Windows arm64 — intentionally not built until a tested client exists.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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" =~ ^et-bin-[A-Za-z0-9._-]+$ ]]; then
|
||||
echo "Invalid et binary release tag: $tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'name=%s\n' "$tag" >> "$GITHUB_OUTPUT"
|
||||
- name: Create / update release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ET_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::ET_BIN_RELEASE_TOKEN is required to publish into ${RELEASE_REPO}."
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
printf '%s\n' 'Pre-built EternalTerminal `et` client binaries consumed by `scripts/fetch-et-binaries.cjs` during `npm run pack`.'
|
||||
printf 'Built from `MisterTea/EternalTerminal` upstream ref `%s`.\n\n' "${ET_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 Apache-2.0; see `resources/et/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
|
||||
110
.github/workflows/build.yml
vendored
110
.github/workflows/build.yml
vendored
@@ -29,6 +29,10 @@ on:
|
||||
description: "Release tag containing bundled mosh-client binaries"
|
||||
type: string
|
||||
default: ""
|
||||
et_bin_release:
|
||||
description: "Release tag containing bundled et (EternalTerminal) binaries"
|
||||
type: string
|
||||
default: ""
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
@@ -54,6 +58,8 @@ permissions:
|
||||
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 != '') }}
|
||||
ET_BIN_RELEASE: ${{ github.event.inputs.et_bin_release || vars.ET_BIN_RELEASE || '' }}
|
||||
BUNDLE_ET: ${{ (startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))) || (github.event_name == 'workflow_dispatch' && inputs.et_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:
|
||||
@@ -191,9 +197,38 @@ jobs:
|
||||
fi
|
||||
echo "mosh_bin_release=${release}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
resolve-et:
|
||||
name: resolve bundled et-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.et_bin_release != '')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
et_bin_release: ${{ steps.resolve.outputs.et_bin_release }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve bundled et-client release
|
||||
id: resolve
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
node scripts/resolve-et-bin-release.cjs
|
||||
release="$(grep '^ET_BIN_RELEASE=' "$GITHUB_ENV" | tail -n 1 | cut -d= -f2-)"
|
||||
if [[ -z "$release" ]]; then
|
||||
echo "::error::ET_BIN_RELEASE was not resolved."
|
||||
exit 1
|
||||
fi
|
||||
echo "et_bin_release=${release}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && format('deduped build-{0}', matrix.name) || format('build-{0}', matrix.name) }}
|
||||
needs: [dedupe, resolve-mosh]
|
||||
needs: [dedupe, resolve-mosh, resolve-et]
|
||||
if: |
|
||||
always()
|
||||
&& needs.dedupe.result == 'success'
|
||||
@@ -214,6 +249,7 @@ jobs:
|
||||
pack_script: pack:win-x64
|
||||
env:
|
||||
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
|
||||
ET_BIN_RELEASE: ${{ needs.resolve-et.outputs.et_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 }}
|
||||
@@ -230,6 +266,17 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate bundled et-client release
|
||||
if: env.BUNDLE_ET == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
RESOLVE_ET_RESULT: ${{ needs.resolve-et.result }}
|
||||
run: |
|
||||
if [[ "$RESOLVE_ET_RESULT" != "success" || -z "$ET_BIN_RELEASE" ]]; then
|
||||
echo "::error::Bundled et-client release was not resolved for this package build."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -242,21 +289,6 @@ jobs:
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Install cross-platform native binaries
|
||||
shell: bash
|
||||
run: |
|
||||
# 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}" --no-save --force
|
||||
fi
|
||||
|
||||
- name: Fetch bundled mosh-client
|
||||
if: env.BUNDLE_MOSH == 'true'
|
||||
shell: bash
|
||||
@@ -267,6 +299,16 @@ jobs:
|
||||
npm run fetch:mosh -- --platform=win32 --arch=x64
|
||||
fi
|
||||
|
||||
- name: Fetch bundled et-client
|
||||
if: env.BUNDLE_ET == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${{ matrix.name }}" == "macos" ]]; then
|
||||
npm run fetch:et -- --platform=darwin --arch=universal
|
||||
elif [[ "${{ matrix.name }}" == "windows" ]]; then
|
||||
npm run fetch:et -- --platform=win32 --arch=x64
|
||||
fi
|
||||
|
||||
- name: Set version
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -318,7 +360,7 @@ jobs:
|
||||
# See #264.
|
||||
build-linux-x64:
|
||||
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }}
|
||||
needs: [dedupe, resolve-mosh]
|
||||
needs: [dedupe, resolve-mosh, resolve-et]
|
||||
if: |
|
||||
always()
|
||||
&& needs.dedupe.result == 'success'
|
||||
@@ -326,6 +368,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
|
||||
ET_BIN_RELEASE: ${{ needs.resolve-et.outputs.et_bin_release }}
|
||||
npm_config_arch: x64
|
||||
npm_config_target_arch: x64
|
||||
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
|
||||
@@ -344,6 +387,17 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate bundled et-client release
|
||||
if: env.BUNDLE_ET == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
RESOLVE_ET_RESULT: ${{ needs.resolve-et.result }}
|
||||
run: |
|
||||
if [[ "$RESOLVE_ET_RESULT" != "success" || -z "$ET_BIN_RELEASE" ]]; then
|
||||
echo "::error::Bundled et-client release was not resolved for this package build."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -379,6 +433,10 @@ jobs:
|
||||
if: env.BUNDLE_MOSH == 'true'
|
||||
run: npm run fetch:mosh -- --platform=linux --arch=x64
|
||||
|
||||
- name: Fetch bundled et-client
|
||||
if: env.BUNDLE_ET == 'true'
|
||||
run: npm run fetch:et -- --platform=linux --arch=x64
|
||||
|
||||
- name: Build package
|
||||
env:
|
||||
npm_config_arch: x64
|
||||
@@ -408,7 +466,7 @@ jobs:
|
||||
# Key: GLIBC < 2.34 avoids the libpthread-merge symbol requirement.
|
||||
build-linux-arm64:
|
||||
name: ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }}
|
||||
needs: [dedupe, resolve-mosh]
|
||||
needs: [dedupe, resolve-mosh, resolve-et]
|
||||
if: |
|
||||
always()
|
||||
&& needs.dedupe.result == 'success'
|
||||
@@ -418,6 +476,7 @@ jobs:
|
||||
image: debian:bullseye
|
||||
env:
|
||||
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
|
||||
ET_BIN_RELEASE: ${{ needs.resolve-et.outputs.et_bin_release }}
|
||||
npm_config_arch: arm64
|
||||
npm_config_target_arch: arm64
|
||||
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
|
||||
@@ -436,6 +495,17 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate bundled et-client release
|
||||
if: env.BUNDLE_ET == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
RESOLVE_ET_RESULT: ${{ needs.resolve-et.result }}
|
||||
run: |
|
||||
if [[ "$RESOLVE_ET_RESULT" != "success" || -z "$ET_BIN_RELEASE" ]]; then
|
||||
echo "::error::Bundled et-client release was not resolved for this package build."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
@@ -474,6 +544,10 @@ jobs:
|
||||
if: env.BUNDLE_MOSH == 'true'
|
||||
run: npm run fetch:mosh -- --platform=linux --arch=arm64
|
||||
|
||||
- name: Fetch bundled et-client
|
||||
if: env.BUNDLE_ET == 'true'
|
||||
run: npm run fetch:et -- --platform=linux --arch=arm64
|
||||
|
||||
- name: Build package
|
||||
env:
|
||||
npm_config_arch: arm64
|
||||
|
||||
139
.github/workflows/issue-format.yml
vendored
Normal file
139
.github/workflows/issue-format.yml
vendored
Normal file
@@ -0,0 +1,139 @@
|
||||
name: issue-format
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
# Skip issues opened by bots (e.g. dependabot) and maintainers fixing format
|
||||
if: >-
|
||||
github.event.issue.user.type != 'Bot' &&
|
||||
!contains(github.event.issue.labels.*.name, 'format-exempt')
|
||||
steps:
|
||||
- name: Validate title and body
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const title = issue.title.trim();
|
||||
const body = (issue.body || '').trim();
|
||||
const errors = [];
|
||||
|
||||
const modernTitle = /^\[(Bug|Feature)\] .{8,}/.test(title);
|
||||
const legacyAppTitle = /^Bug:\s*.{5,}/i.test(title);
|
||||
if (!modernTitle && !legacyAppTitle) {
|
||||
errors.push(
|
||||
'Title must start with `[Bug]` or `[Feature]` followed by a short summary (at least 8 characters after the prefix). Legacy app links using `Bug: ...` are also accepted. Example: `[Bug] SFTP upload fails on Windows`'
|
||||
);
|
||||
}
|
||||
|
||||
if (body.length < 120) {
|
||||
errors.push(
|
||||
'Body is too short. Please use the Bug Report or Feature Request template and fill in all required fields.'
|
||||
);
|
||||
}
|
||||
|
||||
const templateMarkers = [
|
||||
'Steps to reproduce',
|
||||
'Expected behavior',
|
||||
'Actual behavior',
|
||||
'Describe the problem',
|
||||
'Problem / pain point',
|
||||
'Proposed solution',
|
||||
'Operating system',
|
||||
];
|
||||
const hasTemplateStructure = templateMarkers.some((marker) =>
|
||||
body.includes(marker)
|
||||
);
|
||||
if (!hasTemplateStructure) {
|
||||
errors.push(
|
||||
'Body does not look like it came from an issue template. Choose **Bug Report** or **Feature Request** when opening an issue.'
|
||||
);
|
||||
}
|
||||
|
||||
const labels = new Set(
|
||||
(issue.labels || []).map((label) =>
|
||||
typeof label === 'string' ? label : label.name
|
||||
)
|
||||
);
|
||||
|
||||
if (errors.length === 0) {
|
||||
if (
|
||||
issue.state === 'closed' &&
|
||||
labels.has('invalid-format')
|
||||
) {
|
||||
labels.delete('invalid-format');
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: 'open',
|
||||
labels: [...labels],
|
||||
});
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: '<!-- issue-format-bot --> Format looks good now. Reopening this issue.',
|
||||
});
|
||||
}
|
||||
core.info('Issue format OK');
|
||||
return;
|
||||
}
|
||||
|
||||
const issueNumber = issue.number;
|
||||
const marker = '<!-- issue-format-bot -->';
|
||||
const bodyText = [
|
||||
marker,
|
||||
'## Issue format check failed',
|
||||
'',
|
||||
'This issue was closed automatically because it does not follow the required format.',
|
||||
'',
|
||||
...errors.map((e) => `- ${e}`),
|
||||
'',
|
||||
'### How to resubmit',
|
||||
'',
|
||||
'1. Go to [New Issue](https://github.com/binaricat/Netcatty/issues/new/choose)',
|
||||
'2. Pick **Bug Report** or **Feature Request**',
|
||||
'3. Fill in every required field',
|
||||
'4. Keep the `[Bug]` or `[Feature]` prefix in the title and add a clear summary after it (older app versions may use `Bug: ...`)',
|
||||
'',
|
||||
'For questions and open-ended discussion, use [GitHub Discussions](https://github.com/binaricat/Netcatty/discussions) instead.',
|
||||
'',
|
||||
'If you believe this was a mistake, reply here after fixing the title/body and a maintainer can reopen.',
|
||||
].join('\n');
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
const alreadyNotified = comments.some((c) =>
|
||||
(c.body || '').includes(marker)
|
||||
);
|
||||
|
||||
if (!alreadyNotified) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
body: bodyText,
|
||||
});
|
||||
}
|
||||
|
||||
labels.add('invalid-format');
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
labels: [...labels],
|
||||
});
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -73,3 +73,12 @@ build_with_vs2022.bat
|
||||
/resources/mosh/*/mosh-client-*-dlls/
|
||||
/resources/mosh/*/*.dll
|
||||
/resources/mosh/*/terminfo/
|
||||
|
||||
# Bundled EternalTerminal `et` client binaries fetched at pack time by
|
||||
# scripts/fetch-et-binaries.cjs. resources/et/README.md is committed; the
|
||||
# actual binaries (and any DLL bundle for dynamically-linked Windows builds)
|
||||
# are pulled from the dedicated et binary repository, never committed.
|
||||
/resources/et/*/et
|
||||
/resources/et/*/et.exe
|
||||
/resources/et/*/et-*-dlls/
|
||||
/resources/et/*/*.dll
|
||||
|
||||
176
App.tsx
176
App.tsx
@@ -1,7 +1,6 @@
|
||||
import React, { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react';
|
||||
import { activeTabStore, useActiveTabId, toEditorTabId, fromEditorTabId, isEditorTabId } from './application/state/activeTabStore';
|
||||
import { activeTabStore, toEditorTabId, fromEditorTabId, isEditorTabId } from './application/state/activeTabStore';
|
||||
import { useAutoSync } from './application/state/useAutoSync';
|
||||
import { useImmersiveMode } from './application/state/useImmersiveMode';
|
||||
import { useManagedSourceSync } from './application/state/useManagedSourceSync';
|
||||
import { usePortForwardingState } from './application/state/usePortForwardingState';
|
||||
import { useSessionState } from './application/state/useSessionState';
|
||||
@@ -28,14 +27,13 @@ import { materializeHostProxyProfile } from './domain/proxyProfiles';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
|
||||
import {
|
||||
applyCustomAccentToTerminalTheme,
|
||||
mergeTerminalHostUpdate,
|
||||
resolveHostTerminalThemeId,
|
||||
} from './domain/terminalAppearance';
|
||||
import { selectConnectionLogForTerminalDataCapture } from './domain/connectionLog';
|
||||
import { collectSessionIds } from './domain/workspace';
|
||||
import { resolveCloseIntent } from './application/state/resolveCloseIntent';
|
||||
import { resolveSnippetsShortcutIntent } from './application/state/resolveSnippetsShortcutIntent';
|
||||
import { resolveWindowCommandCloseIntent } from './application/state/windowCommandClose';
|
||||
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from './application/state/customThemeStore';
|
||||
import type { SyncPayload } from './domain/sync';
|
||||
@@ -59,18 +57,24 @@ import { KeyboardInteractiveRequest } from './components/KeyboardInteractiveModa
|
||||
import { PassphraseRequest } from './components/PassphraseModal';
|
||||
import { classifyLocalShellType } from './lib/localShell';
|
||||
import { useDiscoveredShells, resolveShellSetting } from './lib/useDiscoveredShells';
|
||||
import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession, TerminalTheme } from './types';
|
||||
import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession } from './types';
|
||||
import { resolveSnippetCommand } from './components/SnippetExecutionProvider';
|
||||
import { AppView } from './application/app/AppView';
|
||||
import { AppActiveTabChrome } from './application/app/AppActiveTabChrome';
|
||||
import { useAppStartupEffects } from './application/app/useAppStartupEffects';
|
||||
import { LogViewWrapper, SftpViewMount, TerminalLayerMount, VaultViewContainer } from './application/app/AppMounts';
|
||||
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleToggleThemeImpl, handleRootContextMenuImpl } from './application/app/AppHandlers';
|
||||
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleToggleThemeImpl, handleRootContextMenuImpl } from './application/app/AppHandlers';
|
||||
|
||||
// Initialize fonts eagerly at app startup
|
||||
initializeFonts();
|
||||
initializeUIFonts();
|
||||
|
||||
type SettingsState = ReturnType<typeof useSettingsState>;
|
||||
type OpenSessionInNewWindowPayload = {
|
||||
title?: string;
|
||||
sourceSession?: TerminalSession;
|
||||
localShellType?: TerminalSession['shellType'];
|
||||
};
|
||||
|
||||
const IS_DEV = import.meta.env.DEV;
|
||||
const HOTKEY_DEBUG =
|
||||
@@ -99,6 +103,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
|
||||
// Passphrase request queue for encrypted SSH keys
|
||||
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
|
||||
const [pendingNewWindowSession, setPendingNewWindowSession] = useState<OpenSessionInNewWindowPayload | null>(null);
|
||||
|
||||
const {
|
||||
theme,
|
||||
@@ -124,12 +129,15 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
sftpAutoOpenSidebar,
|
||||
sftpFollowTerminalCwd,
|
||||
setSftpFollowTerminalCwd,
|
||||
sftpDefaultViewMode,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
sessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
sessionLogsTimestampsEnabled,
|
||||
reapplyCurrentTheme,
|
||||
workspaceFocusStyle,
|
||||
} = settings;
|
||||
@@ -243,6 +251,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
openLogView,
|
||||
closeLogView,
|
||||
copySession,
|
||||
createSessionFromCloneSource,
|
||||
} = useSessionState();
|
||||
|
||||
const handleRunSnippet = useCallback(
|
||||
@@ -260,16 +269,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Immersive Mode — derive UI chrome colors from the active terminal's theme
|
||||
// ---------------------------------------------------------------------------
|
||||
const activeTabId = useActiveTabId();
|
||||
const customThemes = useCustomThemes();
|
||||
const editorTabs = useEditorTabs();
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings.showSftpTab && activeTabId === 'sftp') {
|
||||
setActiveTabId('vault');
|
||||
}
|
||||
}, [settings.showSftpTab, activeTabId, setActiveTabId]);
|
||||
|
||||
// Resolve the effective TerminalTheme for the currently focused terminal tab
|
||||
const hostById = useMemo(
|
||||
() => new Map(hosts.map((host) => [host.id, host])),
|
||||
@@ -289,59 +291,25 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
() => new Map([...customThemes, ...TERMINAL_THEMES].map((theme) => [theme.id, theme])),
|
||||
[customThemes],
|
||||
);
|
||||
const activeTerminalTheme = useMemo<TerminalTheme | null>(() => {
|
||||
if (activeTabId === 'vault' || activeTabId === 'sftp') return null;
|
||||
// activeTabId-derived chrome (immersive theme, window title, sftp guard) is
|
||||
// owned by <AppActiveTabChrome/> so switching tabs does not re-render App.
|
||||
|
||||
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) {
|
||||
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);
|
||||
};
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onOpenSessionInNewWindow) return undefined;
|
||||
return bridge.onOpenSessionInNewWindow((payload) => {
|
||||
if (!payload?.sourceSession) return;
|
||||
setPendingNewWindowSession(payload);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Workspace
|
||||
const workspace = workspaceById.get(activeTabId);
|
||||
if (workspace) {
|
||||
// Focus mode: use the focused (or first remaining) session's theme
|
||||
if (workspace.viewMode === 'focus') {
|
||||
const wsSessionIds = collectSessionIds(workspace.root);
|
||||
const focused = (workspace.focusedSessionId
|
||||
? sessionById.get(workspace.focusedSessionId)
|
||||
: null)
|
||||
?? wsSessionIds.map((id) => sessionById.get(id)).find(Boolean);
|
||||
return focused ? resolveTheme(focused) : null;
|
||||
}
|
||||
// Split mode: require all sessions to share the same theme
|
||||
const sessionIds = collectSessionIds(workspace.root);
|
||||
const wsSessions = sessionIds
|
||||
.map((id) => sessionById.get(id))
|
||||
.filter(Boolean) as TerminalSession[];
|
||||
if (wsSessions.length === 0) return null;
|
||||
const firstTheme = resolveTheme(wsSessions[0]);
|
||||
const allSame = wsSessions.every(s => resolveTheme(s).id === firstTheme.id);
|
||||
return allSame ? firstTheme : null;
|
||||
}
|
||||
|
||||
// Single session tab
|
||||
const session = sessionById.get(activeTabId);
|
||||
if (!session) return null;
|
||||
return resolveTheme(session);
|
||||
}, [accentMode, activeTabId, currentTerminalTheme, customAccent, followAppTerminalTheme, hostById, sessionById, themeById, workspaceById]);
|
||||
|
||||
useImmersiveMode({
|
||||
activeTabId,
|
||||
activeTerminalTheme,
|
||||
restoreOriginalTheme: reapplyCurrentTheme,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!isVaultInitialized || !pendingNewWindowSession?.sourceSession) return;
|
||||
createSessionFromCloneSource(pendingNewWindowSession.sourceSession, {
|
||||
localShellType: pendingNewWindowSession.localShellType,
|
||||
});
|
||||
setPendingNewWindowSession(null);
|
||||
}, [createSessionFromCloneSource, isVaultInitialized, pendingNewWindowSession]);
|
||||
|
||||
// Get port forwarding rules and import function
|
||||
const { rules: portForwardingRules, importRules: importPortForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
|
||||
@@ -509,7 +477,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, [handleSyncNow]);
|
||||
|
||||
// Update check hook - checks for new versions on startup
|
||||
const { updateState, dismissUpdate, installUpdate } = useUpdateCheck();
|
||||
const { updateState, dismissUpdate, installUpdate } = useUpdateCheck({
|
||||
// Install blocked because an editor has unsaved changes (#1215). The main
|
||||
// process broadcasts this; show an actionable toast telling the user to save
|
||||
// and click "Restart Now" again.
|
||||
onNeedsSave: () => toast.warning(t('update.needsSave.message'), t('update.needsSave.title')),
|
||||
});
|
||||
|
||||
// Window controls - must be before update toast effect which uses openSettingsWindow
|
||||
const { openSettingsWindow } = useWindowControls();
|
||||
@@ -700,7 +673,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Populated by UnsavedChangesProvider render-prop below so that the hotkey
|
||||
// dispatcher (defined outside that scope) can still reach the dirty-confirm
|
||||
// close flow.
|
||||
const handleRequestCloseEditorTabRef = useRef<(id: string) => void>(() => {});
|
||||
const handleRequestCloseEditorTabRef = useRef<(id: string) => boolean | Promise<boolean>>(() => false);
|
||||
|
||||
const createLocalTerminalWithCurrentShell = useCallback(() => { return createLocalTerminalWithCurrentShellImpl(() => ({ classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, terminalSettings })); }, [createLocalTerminal, terminalSettings, discoveredShells]);
|
||||
|
||||
@@ -708,6 +681,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
const copySessionWithCurrentShell = useCallback((sessionId: string) => { return copySessionWithCurrentShellImpl(() => ({ classifyLocalShellType, copySession, discoveredShells, resolveShellSetting, sessionId, terminalSettings }), sessionId); }, [copySession, terminalSettings, discoveredShells]);
|
||||
|
||||
const copySessionToNewWindowWithCurrentShell = useCallback((sessionId: string) => { return copySessionToNewWindowWithCurrentShellImpl(() => ({ classifyLocalShellType, discoveredShells, netcattyBridge, resolveShellSetting, sessions, terminalSettings, t, toast }), sessionId); }, [sessions, terminalSettings, discoveredShells, t]);
|
||||
|
||||
const closeTabKeyStr = useMemo(() => {
|
||||
if (hotkeyScheme === 'disabled') return null;
|
||||
const closeTabBinding = keyBindings.find((binding) => binding.action === 'closeTab');
|
||||
@@ -722,6 +697,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
const closeTabsInFlightRef = useRef(false);
|
||||
|
||||
// 顶层标签顺序需要包含编辑器标签,供顶部标签和编辑器邻居计算使用。
|
||||
const orderedTabsWithEditors = useMemo(
|
||||
() => [...orderedTabs, ...editorTabs.map((tab) => toEditorTabId(tab.id))],
|
||||
[orderedTabs, editorTabs],
|
||||
);
|
||||
|
||||
// Close many tabs at once with a single batched busy-shell confirmation.
|
||||
// Used by the "Close all / Close others / Close to the right" context-menu
|
||||
// actions on tabs (#748).
|
||||
@@ -733,6 +714,43 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Shared hotkey action handler - used by both global handler and terminal callback
|
||||
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => { return executeHotkeyActionImpl(() => ({ IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, action, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, e, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces }), action, e); }, [orderedTabs, editorTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings, confirmIfBusyLocalTerminal]);
|
||||
|
||||
const handleWindowCommandCloseRequest = useCallback(async () => {
|
||||
const openDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][data-state="open"]'));
|
||||
const topmostOpenDialog = openDialogs[openDialogs.length - 1] ?? null;
|
||||
const topmostDialogClose = topmostOpenDialog?.querySelector<HTMLElement>('[data-dialog-close="true"]');
|
||||
if (topmostDialogClose) {
|
||||
topmostDialogClose.click();
|
||||
return;
|
||||
}
|
||||
|
||||
const intent = resolveWindowCommandCloseIntent({
|
||||
activeTabId: activeTabStore.getActiveTabId(),
|
||||
editorTabIds: editorTabs.map((tab) => toEditorTabId(tab.id)),
|
||||
sessionIds: sessions.map((session) => session.id),
|
||||
workspaceIds: workspaces.map((workspace) => workspace.id),
|
||||
logViewIds: logViews.map((logView) => logView.id),
|
||||
});
|
||||
|
||||
if (intent.kind === 'closeTab') {
|
||||
executeHotkeyAction('closeTab', new KeyboardEvent('keydown', { key: 'w', metaKey: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (intent.kind === 'closeLogView') {
|
||||
closeLogView(intent.tabId);
|
||||
return;
|
||||
}
|
||||
|
||||
await netcattyBridge.get()?.windowClose?.();
|
||||
}, [closeLogView, editorTabs, executeHotkeyAction, logViews, sessions, workspaces]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = netcattyBridge.get()?.onWindowCommandCloseRequested?.(() => {
|
||||
void handleWindowCommandCloseRequest();
|
||||
});
|
||||
return () => unsubscribe?.();
|
||||
}, [handleWindowCommandCloseRequest]);
|
||||
|
||||
// Callback for terminal to invoke app-level hotkey actions
|
||||
const handleHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
|
||||
executeHotkeyAction(action, e);
|
||||
@@ -936,13 +954,27 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
const handleRootContextMenu = useCallback((e: React.MouseEvent<HTMLDivElement>) => { return handleRootContextMenuImpl(() => ({ e }), e); }, []);
|
||||
|
||||
// Combined ordered tab list including editor tab ids (for TopTabs scrollable area)
|
||||
const orderedTabsWithEditors = useMemo(
|
||||
() => [...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))],
|
||||
[orderedTabs, editorTabs],
|
||||
return (
|
||||
<>
|
||||
<AppActiveTabChrome
|
||||
showSftpTab={settings.showSftpTab}
|
||||
setActiveTabId={setActiveTabId}
|
||||
hostById={hostById}
|
||||
sessionById={sessionById}
|
||||
workspaceById={workspaceById}
|
||||
themeById={themeById}
|
||||
currentTerminalTheme={currentTerminalTheme}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
accentMode={accentMode}
|
||||
customAccent={customAccent}
|
||||
reapplyCurrentTheme={reapplyCurrentTheme}
|
||||
editorTabs={editorTabs}
|
||||
logViews={logViews}
|
||||
t={t}
|
||||
/>
|
||||
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
|
||||
</>
|
||||
);
|
||||
|
||||
return <AppView ctx={{ accentMode, activeTabId, activeTerminalTheme, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />;
|
||||
}
|
||||
|
||||
function AppWithProviders() {
|
||||
|
||||
216
ET_INTEGRATION_CHECKLIST.md
Normal file
216
ET_INTEGRATION_CHECKLIST.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# EternalTerminal (ET) 集成清单 — 按 Mosh 方式重做
|
||||
|
||||
> 目标:在上游最新架构(分支 `feat/et-history-reapply`,基于 `031bf0ee`)上,
|
||||
> **完全照搬 Mosh 的方式**重新集成 EternalTerminal:
|
||||
> 1. **打包客户端** —— 像 `mosh-client` 那样,把 `et` 客户端二进制构建 + 下载 +
|
||||
> 捆绑进安装包,运行时只用捆绑的二进制(不依赖系统安装的 et)。
|
||||
> 2. **接入协议** —— 把旧分支 `feat/eternal-terminal`(tip `67e81616`)里的 ET
|
||||
> 后端 + UI 重新落到上游重构后的目录结构上,并让它启动**捆绑的** `et`。
|
||||
>
|
||||
> 旧实现参考:`git show 67e81616`(共 7 个 ET 提交,见 `feat/eternal-terminal`)。
|
||||
> Mosh 模板参考:`resources/mosh/README.md`、`scripts/*mosh*`、
|
||||
> `electron/bridges/terminalBridge/moshSession.cjs`、`.github/workflows/build-mosh-binaries.yml`。
|
||||
|
||||
## 关键设计差异(ET vs Mosh)
|
||||
|
||||
- **协议**:Mosh 需要 Node 重写 Perl 包装器(SSH bootstrap + 抓 `MOSH CONNECT` +
|
||||
换 PTY)。**ET 不需要** —— `et` 客户端自己完成 SSH 引导 + 协议握手,我们只要
|
||||
把 `et` 当作普通 PTY 进程 `pty.spawn` 即可。所以**没有** `etHandshake.cjs`。
|
||||
- **凭证注入**:Mosh 自己驱动 ssh、直接往 PTY 里敲密码;ET 内部驱动 ssh,需用
|
||||
**SSH_ASKPASS + 临时 ~/.ssh 环境**把保存的密码/密钥/跳板/算法喂给 et 内部的 ssh
|
||||
(旧实现 `prepareEtSshEnvironment` 已完整实现,直接搬运)。
|
||||
- **terminfo**:`et` 是纯传输客户端、本地不渲染终端,**无需** 捆绑 terminfo
|
||||
(Mosh 因静态 ncurses 才需要)。打包目录里只放 `et[.exe]`(+ Windows DLL)。
|
||||
- **构建系统**:Mosh 用 autotools;**ET 用 CMake + Ninja + vcpkg**
|
||||
(`cmake -DDISABLE_TELEMETRY=ON -GNinja -DCMAKE_BUILD_TYPE=RelWithDebInfo`),
|
||||
产物是单个 `et`(Windows `et.exe`)。
|
||||
|
||||
## 命名约定(镜像 Mosh)
|
||||
|
||||
| Mosh | ET |
|
||||
|------|----|
|
||||
| `resources/mosh/<plat-arch>/mosh-client[.exe]` | `resources/et/<plat-arch>/et[.exe]` |
|
||||
| 打包后 `<Resources>/mosh/mosh-client` | 打包后 `<Resources>/et/et` |
|
||||
| `scripts/build-mosh/` | `scripts/build-et/` |
|
||||
| `scripts/fetch-mosh-binaries.cjs` | `scripts/fetch-et-binaries.cjs` |
|
||||
| `scripts/resolve-mosh-bin-release.cjs` | `scripts/resolve-et-bin-release.cjs` |
|
||||
| `scripts/mosh-extra-resources.cjs` | `scripts/et-extra-resources.cjs` |
|
||||
| env `MOSH_BIN_RELEASE` / 仓库 `Netcatty-mosh-bin` / tag `mosh-bin-*` | env `ET_BIN_RELEASE` / 仓库 `Netcatty-et-bin` / tag `et-bin-*` |
|
||||
| `npm run fetch:mosh[:dev]` | `npm run fetch:et[:dev]` |
|
||||
| `bundledMoshClient()` / `resolveBareMoshClient()` | `bundledEtClient()` / `resolveBareEtClient()` |
|
||||
| `.github/workflows/build-mosh-binaries.yml` | `.github/workflows/build-et-binaries.yml` |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — 打包基础设施(构建/下载/捆绑)
|
||||
|
||||
- [x] **1.1** `resources/et/README.md` —— 镜像 `resources/mosh/README.md`:说明
|
||||
二进制来源、`Netcatty-et-bin` 发布仓库、`et-bin-*` tag、许可证(ET 为
|
||||
Apache-2.0,与 GPL-3.0 兼容)、可复现构建命令。
|
||||
- [x] **1.2** `.gitignore` —— 追加 ET 段(镜像 mosh 段):
|
||||
`/resources/et/*/et`、`/resources/et/*/et.exe`、`/resources/et/*/*.dll`、
|
||||
`/resources/et/*/et-win32-*-dlls/`。保留 `resources/et/README.md`。
|
||||
- [x] **1.3** `scripts/build-et/build-linux.sh` —— manylinux2014 + vcpkg 静态三元组
|
||||
构建 `et`(x64/arm64),产物 `et-linux-<arch>.tar.gz`(+.sha256),内含单个 `et`。
|
||||
校验非系统动态库(同 mosh 的 ldd 白名单)。
|
||||
- [x] **1.4** `scripts/build-et/build-macos.sh` —— arm64 + x86_64 分别构建后 `lipo`
|
||||
成 universal,`MACOSX_DEPLOYMENT_TARGET=11.0`,产物 `et-darwin-universal.tar.gz`。
|
||||
- [x] **1.5** `scripts/build-et/build-windows.ps1`(或 `.sh`)—— MSVC + vcpkg
|
||||
`x64-windows-static`,产物 `et-win32-x64.tar.gz`(含 `et.exe`;若动态链接 CRT
|
||||
则随附 DLL 目录 `et-win32-x64-dlls/`,否则纯静态无 DLL)。
|
||||
- [x] **1.6** `scripts/et-extra-resources.cjs` —— 镜像 `mosh-extra-resources.cjs`:
|
||||
按平台/arch 仅当 `resources/et/<plat-arch>/et[.exe]` 存在时才产出 extraResources
|
||||
指令(`to: "et/"`);Windows 额外处理可选 DLL 目录。**去掉 terminfo 分支**。
|
||||
- [x] **1.7** `scripts/resolve-et-bin-release.cjs` —— 镜像 `resolve-mosh-bin-release.cjs`:
|
||||
`TAG_RE=/^et-bin-.../`,默认仓库 `Netcatty-et-bin`,env `ET_BIN_RELEASE` 优先。
|
||||
- [x] **1.8** `scripts/fetch-et-binaries.cjs` —— 镜像 `fetch-mosh-binaries.cjs`:
|
||||
`TARGETS` 四项(linux-x64/arm64、darwin-universal、win32-x64),全部 tar.gz;
|
||||
SHA256SUMS 校验;解包到 `resources/et/<plat-arch>/`。**Windows 用自建产物**
|
||||
(ET 官方有 Windows 构建,无需 FluentTerminal 那种 fallback)。去掉 terminfo 校验。
|
||||
- [x] **1.9** 单元测试:`scripts/fetch-et-binaries.test.cjs`、
|
||||
`scripts/resolve-et-bin-release.test.cjs`、`scripts/et-extra-resources.test.cjs`
|
||||
(镜像对应 mosh 测试,改名/改路径)。
|
||||
- [x] **1.10** `package.json` scripts:新增
|
||||
`"fetch:et": "node scripts/fetch-et-binaries.cjs"`、
|
||||
`"fetch:et:dev": "node scripts/fetch-et-binaries.cjs --host --resolve-release"`;
|
||||
把 `dev` 脚本改成先 `fetch:mosh:dev && fetch:et:dev`;`test` glob 已覆盖
|
||||
`scripts/*.test.cjs`(确认即可)。
|
||||
- [x] **1.11** `electron-builder.config.cjs`:引入 `etExtraResources`,在 darwin/win32/
|
||||
linux 三处把 `etExtraResources(plat)` 合并进 `extraResources`(与 mosh 数组拼接)。
|
||||
- [x] **1.12** `.github/workflows/build-et-binaries.yml` —— 镜像
|
||||
`build-mosh-binaries.yml`:四个构建 job + 一个 `release` job(dispatch 且
|
||||
`release_tag` 非空时发布到 `Netcatty-et-bin`,附 `SHA256SUMS`)。`paths` 过滤
|
||||
指向 `scripts/build-et/**`、`scripts/fetch-et-binaries.cjs`、`scripts/et-extra-resources.cjs`。
|
||||
env 用 `ET_REF`(默认 ET release tag,如 `et-v6.2.x`)。
|
||||
> 注:实际二进制由用户手动 `workflow_dispatch` 触发产出;本地/CI 未设
|
||||
> `ET_BIN_RELEASE` 时 fetch 步骤安静跳过(同 mosh)。
|
||||
|
||||
## Phase 2 — 运行时定位捆绑客户端
|
||||
|
||||
- [x] **2.1** `electron/bridges/terminalBridge.cjs` 新增 `bundledEtClient(opts)`
|
||||
—— 镜像 `bundledMoshClient`:打包路径 `<Resources>/et/et[.exe]`;dev 回退
|
||||
`<projectRoot>/resources/et/<plat-arch>/et[.exe]`;导出到 module.exports。
|
||||
|
||||
## Phase 3 — ET 协议后端(搬运旧实现到新架构)
|
||||
|
||||
- [x] **3.1** 新建 `electron/bridges/terminalBridge/etSession.cjs` —— 用上游
|
||||
`moshSession.cjs` 的 `createXxxSessionApi(ctx)` + `with(ctx)` 工厂模式,封装:
|
||||
`ET_ASKPASS_SCRIPT`、`writeSecureFile`、`prepareEtSshEnvironment`、
|
||||
`createEtAskpassArtifacts`、`cleanupStaleEtTempDirs`、
|
||||
`cleanupSessionExternalAuthArtifacts`、`execOnEtSession`、`startEtSession`。
|
||||
**改动点**:`etCmd` 由 `findExecutable('et')` 改为 `resolveBareEtClient()`
|
||||
(取捆绑二进制);找不到时抛错(同 mosh:提示跑 `npm run fetch:et:dev`)。
|
||||
Windows 若有 DLL 目录,复用 `prependEnvPath` 思路把 DLL 目录加进 PATH。
|
||||
- [x] **3.2** `terminalBridge.cjs` 接线 `createEtSessionApi(ctx)`(镜像 moshSessionApi
|
||||
的 ctx),传入 `bundledEtClient`、`tempDirBridge`、`execFile/execFileSync` 等;
|
||||
解构出 `startEtSession`、`execOnEtSession`、`cleanupStaleEtTempDirs`、
|
||||
`cleanupSessionExternalAuthArtifacts`、`resolveBareEtClient`。
|
||||
- [x] **3.3** `init()` 调 `cleanupStaleEtTempDirs()`;`registerHandlers` 加
|
||||
`ipcMain.handle("netcatty:et:start", startEtSession)`;`closeSession` 与
|
||||
`cleanupAllSessions` 调 `cleanupSessionExternalAuthArtifacts(session)`;
|
||||
`module.exports` 导出 `startEtSession`、`execOnEtSession`、`bundledEtClient`。
|
||||
- [x] **3.4** 测试:`terminalBridge.bundledEt.test.cjs`(路径解析)+
|
||||
`terminalBridge/etSession.test.cjs`(prepareEtSshEnvironment 的端口/密钥/
|
||||
askpass/跳板/legacy 算法分支)。可参考旧分支是否已有 ET 测试并搬运。
|
||||
|
||||
## Phase 4 — domain / 类型 / preload 接口面
|
||||
|
||||
- [x] **4.1** `domain/models.ts`:`HostProtocol` 加 `'et'`;`ProtocolConfig.etPort?`;
|
||||
`Host`/`GroupConfig` 加 `etEnabled?`/`etPort?`/`etTerminalPath?`;
|
||||
`TerminalSession.etEnabled?`;`ConnectionLog.protocol` 加 `'et'`。
|
||||
(照搬 `git show 794eecdf -- domain/models.ts`)
|
||||
- [x] **4.2** `domain/groupConfig.ts`:加 `etEnabled` 默认项(照搬旧 diff)。
|
||||
- [x] **4.3** `global.d.ts`:`NetcattyBridge` 加 `startEtSession?(options): Promise<...>`
|
||||
及相关 options 类型(照搬 `git show 794eecdf -- global.d.ts`,并补齐后续 ET 提交
|
||||
新增的 etPort/terminalPath/jumpHosts/legacyAlgorithms 字段)。
|
||||
- [x] **4.4** `electron/preload/api.cjs`:加 `startEtSession`(镜像第 26 行的
|
||||
`startMoshSession`)→ `ipcRenderer.invoke("netcatty:et:start", options)`。
|
||||
**注意**:上游已把 preload 重构成 `createPreloadApi`,落点在 `preload/api.cjs`,
|
||||
不是旧的 `preload.cjs` 内联对象。
|
||||
|
||||
## Phase 5 — 渲染层 + UI + i18n
|
||||
|
||||
- [x] **5.1** `application/state/useTerminalBackend.ts`:加 `etAvailable`(查
|
||||
`bridge?.startEtSession`)+ `startEtSession`,并在返回对象/依赖数组里登记
|
||||
(镜像 mosh 的第 10/42/198/205 行处)。
|
||||
- [x] **5.2** `application/state/useSessionState.ts`:路由 ET 会话(照搬旧 diff,+6 行)。
|
||||
- [x] **5.3** `components/terminal/runtime/createTerminalSessionStarters.ts`:加
|
||||
`startEt(term)`(镜像 `startMosh`,组装 options:etPort/terminalPath/
|
||||
jumpHosts/legacyAlgorithms/凭证/identityFilePaths)。
|
||||
**注意**:上游把它从旧的 `infrastructure/runtime/` 移到了
|
||||
`components/terminal/runtime/` —— 落点以上游为准。
|
||||
- [x] **5.4** UI 组件(照搬 `git show b1a306f8 6c0d5bf3 55caa268` 的相应文件,
|
||||
映射到上游同名组件):
|
||||
- [ ] `components/ProtocolSelectDialog.tsx` —— 新增 ET 选项
|
||||
- [ ] `components/QuickConnectWizard.tsx`
|
||||
- [ ] `components/HostDetailsPanel.tsx` —— ET 设置(启用、ET 端口、etterminal 路径)
|
||||
- [ ] `components/GroupDetailsPanel.tsx`
|
||||
- [ ] `components/VaultView.tsx`
|
||||
- [ ] `components/Terminal.tsx` / `components/TerminalLayer.tsx`
|
||||
- [ ] `components/terminal/TerminalConnectionDialog.tsx` / `TerminalToolbar.tsx`
|
||||
- [ ] `App.tsx`
|
||||
- [x] **5.5** i18n:`application/i18n/locales/en.ts` 与 `zh-CN.ts` 加 ET 文案
|
||||
(照搬旧 diff,键名对齐上游现有 mosh 文案结构)。
|
||||
|
||||
## Phase 6 — 校验
|
||||
|
||||
- [x] **6.1** `npm run lint`(确保新 .cjs 在 scripts/ 下不受 ESLint 限制,
|
||||
或按需加 eslint-disable,与 mosh 脚本一致)。
|
||||
- [x] **6.2** `npm test`(新增的 fetch/resolve/extra-resources/etSession 测试全绿)。
|
||||
- [x] **6.3** `npm run build`(渲染层 TS 编译通过,无类型错误)。
|
||||
- [ ] **6.4** 手动冒烟(需先有发布的二进制):
|
||||
`ET_BIN_RELEASE=et-bin-... npm run fetch:et` → `npm run start` →
|
||||
新建 ET 会话连一台装了 etserver 的主机,验证连接/输入/退出/凭证注入。
|
||||
|
||||
---
|
||||
|
||||
## 进度记录
|
||||
|
||||
- 状态:**Phase 1–5 已完成并通过校验**(仅余 1 个可选项 + CI 产二进制)
|
||||
- 验证结果:
|
||||
- `npx eslint <所有改动文件>` → 干净(0 错 0 警)
|
||||
- `npx tsc --noEmit` → 我的改动 **0 个新增类型错误**
|
||||
(`TerminalConnectionDialog` 里 `case 'mosh'` 的 TS2678 是既有问题,行号因我插入 ET 早返回从 60→64,非新增)
|
||||
- `node --test`(ET 相关)→ etSession/bundledEt/3 个脚本测试 **全绿**
|
||||
- `npm test` → 1383 通过 / 16 失败,**16 个全是既有的 Windows 环境失败**
|
||||
(mosh 打包测试的 GNU-tar `C:` 问题、`isExecutableFile` 无 x 位、ACP execPath、SKILL.md 权限、Comware DH 等;均在我未改动的文件里)
|
||||
- `npm run build`(Vite)→ **构建成功**(8.55s),渲染层打包通过
|
||||
|
||||
### 已完成
|
||||
- **Phase 1**:`scripts/et-extra-resources.cjs` / `resolve-et-bin-release.cjs` /
|
||||
`fetch-et-binaries.cjs`(+3 测试,27 通过)、`scripts/build-et/{build-linux.sh,
|
||||
build-macos.sh,build-windows.ps1}`、`.github/workflows/build-et-binaries.yml`、
|
||||
`resources/et/README.md`、`.gitignore`、`package.json`、`electron-builder.config.cjs`。
|
||||
- **Phase 2**:`terminalBridge.cjs` 新增并导出 `bundledEtClient`。
|
||||
- **Phase 3**:`terminalBridge/etSession.cjs`(startEtSession + prepareEtSshEnvironment +
|
||||
SSH_ASKPASS 机制 + execOnEtSession + 清理),接线进 terminalBridge.cjs(ctx/IPC
|
||||
`netcatty:et:start`/init 清理/close/quit 清理/导出),+2 测试(13 通过)。
|
||||
**et 指向捆绑二进制**(resolveBareEtClient→bundledEtClient),找不到则报错。
|
||||
- **Phase 4**:domain `connection.ts`/`history.ts`/`terminal.ts`、`groupConfig.ts`、
|
||||
`types/global/netcatty-bridge-session.d.ts`(startEtSession + NetcattyJumpHost[])、
|
||||
`electron/preload/api.cjs`、`domain/vaultImport.ts`(排除 'et' 导入协议)。
|
||||
- **Phase 5**:
|
||||
- 启动派发:`useTerminalEffects.ts`、`Terminal.tsx`(×3) → `startEt`
|
||||
- 运行时 starter:`createTerminalSessionStarters.ts` 新增 `startEt`(含单跳板/凭证/
|
||||
legacy 算法/askpass 路径),`.types.ts` 加 `etAvailable`/`startEtSession`
|
||||
- 后端 hook:`useTerminalBackend.ts`(etAvailable + startEtSession)
|
||||
- 会话透传 etEnabled:`sessionFactories.ts`、`useSessionState.ts`(×6)、
|
||||
`TerminalLayer.tsx`(×3)、`TerminalLayerSupport.tsx`、`AppHandlers.ts`(协议解析/日志/选择)
|
||||
- UI:`HostDetailsAdvancedSections.tsx`(ET 开关+端口+etterminal 路径,与 Mosh 互斥)、
|
||||
`HostDetailsPanel.tsx`、`ProtocolSelectDialog.tsx`(ET 选项)、
|
||||
`TerminalConnectionDialog.tsx`(ET 标签)、`TerminalToolbar.tsx`(编码菜单门控)、
|
||||
`GroupSshSettingsSection.tsx` + `GroupDetailsPanel.tsx`(组级 ET)、`VaultView.tsx`
|
||||
- i18n:en/zh-CN 的 `hostDetails.section.et`、`hostDetails.et.*`、
|
||||
`terminal.connection.protocol.et`、`terminal.et.*`
|
||||
|
||||
### 剩余(可选 / 非阻塞)
|
||||
- [ ] **QuickConnectWizard.tsx**:把 ET 加为“快速连接”协议按钮(type/端口/建主机映射 +
|
||||
UI 按钮)。当前快速连接未列 ET;保存主机后开启 ET 再连即可,故仅为便利项。
|
||||
- [ ] **产出二进制**:手动 `workflow_dispatch` 跑 `build-et-binaries.yml`(带
|
||||
`release_tag=et-bin-<ver>-1`)发布到 `Netcatty-et-bin`,并配 `ET_BIN_RELEASE_TOKEN`
|
||||
secret。之后 `ET_BIN_RELEASE=... npm run fetch:et` 即可本地/打包捆绑 `et`。
|
||||
build-et 脚本本机无法编译 C++,需在 CI 验证。
|
||||
- [ ] **端到端冒烟**:有二进制后 `npm run dev`,对装有 etserver 的主机建 ET 会话验证。
|
||||
|
||||
- 当前分支:`feat/et-history-reapply`(基于上游 `031bf0ee`)
|
||||
- 旧 ET 实现参考分支:`feat/eternal-terminal`(tip `67e81616`,7 个 ET 提交)
|
||||
153
application/AppHandlers.newWindow.test.ts
Normal file
153
application/AppHandlers.newWindow.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { TerminalSession } from "../domain/models";
|
||||
import { copySessionToNewWindowWithCurrentShellImpl } from "./app/AppHandlers";
|
||||
|
||||
const sourceSession = (overrides: Partial<TerminalSession> = {}): TerminalSession => ({
|
||||
id: "session-1",
|
||||
hostId: "host-1",
|
||||
hostLabel: "Prod SSH",
|
||||
hostname: "prod.example.com",
|
||||
username: "deploy",
|
||||
status: "connected",
|
||||
protocol: "ssh",
|
||||
port: 22,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("copySessionToNewWindowWithCurrentShellImpl asks Electron to open a peer window for the selected session", async () => {
|
||||
const openedPayloads: unknown[] = [];
|
||||
|
||||
await copySessionToNewWindowWithCurrentShellImpl(
|
||||
() => ({
|
||||
classifyLocalShellType: () => "zsh",
|
||||
discoveredShells: [],
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
openSessionInNewWindow: async (payload: unknown) => {
|
||||
openedPayloads.push(payload);
|
||||
return { success: true };
|
||||
},
|
||||
}),
|
||||
},
|
||||
resolveShellSetting: () => ({ command: "/bin/zsh" }),
|
||||
sessions: [sourceSession()],
|
||||
terminalSettings: { localShell: "system-default" },
|
||||
}),
|
||||
"session-1",
|
||||
);
|
||||
|
||||
assert.equal(openedPayloads.length, 1);
|
||||
assert.deepEqual(openedPayloads[0], {
|
||||
title: "Prod SSH",
|
||||
sourceSession: sourceSession(),
|
||||
localShellType: "zsh",
|
||||
});
|
||||
});
|
||||
|
||||
test("copySessionToNewWindowWithCurrentShellImpl does nothing when the source session is gone", async () => {
|
||||
let called = false;
|
||||
|
||||
await copySessionToNewWindowWithCurrentShellImpl(
|
||||
() => ({
|
||||
classifyLocalShellType: () => "zsh",
|
||||
discoveredShells: [],
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
openSessionInNewWindow: async () => {
|
||||
called = true;
|
||||
return { success: true };
|
||||
},
|
||||
}),
|
||||
},
|
||||
resolveShellSetting: () => ({ command: "/bin/zsh" }),
|
||||
sessions: [],
|
||||
terminalSettings: { localShell: "system-default" },
|
||||
}),
|
||||
"missing-session",
|
||||
);
|
||||
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test("copySessionToNewWindowWithCurrentShellImpl shows an error when Electron cannot open the window", async () => {
|
||||
const errors: string[] = [];
|
||||
|
||||
const result = await copySessionToNewWindowWithCurrentShellImpl(
|
||||
() => ({
|
||||
classifyLocalShellType: () => "zsh",
|
||||
discoveredShells: [],
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
openSessionInNewWindow: async () => ({ success: false }),
|
||||
}),
|
||||
},
|
||||
resolveShellSetting: () => ({ command: "/bin/zsh" }),
|
||||
sessions: [sourceSession()],
|
||||
terminalSettings: { localShell: "system-default" },
|
||||
t: (key: string) => key === "tabs.copyTabToNewWindowFailed" ? "Could not open" : key,
|
||||
toast: {
|
||||
error: (message: string) => errors.push(message),
|
||||
},
|
||||
}),
|
||||
"session-1",
|
||||
);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.deepEqual(errors, ["Could not open"]);
|
||||
});
|
||||
|
||||
test("copySessionToNewWindowWithCurrentShellImpl shows an error when the bridge is unavailable", async () => {
|
||||
const errors: string[] = [];
|
||||
|
||||
const result = await copySessionToNewWindowWithCurrentShellImpl(
|
||||
() => ({
|
||||
classifyLocalShellType: () => "zsh",
|
||||
discoveredShells: [],
|
||||
netcattyBridge: {
|
||||
get: () => ({}),
|
||||
},
|
||||
resolveShellSetting: () => ({ command: "/bin/zsh" }),
|
||||
sessions: [sourceSession()],
|
||||
terminalSettings: { localShell: "system-default" },
|
||||
t: (key: string) => key === "tabs.copyTabToNewWindowFailed" ? "Could not open" : key,
|
||||
toast: {
|
||||
error: (message: string) => errors.push(message),
|
||||
},
|
||||
}),
|
||||
"session-1",
|
||||
);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.deepEqual(errors, ["Could not open"]);
|
||||
});
|
||||
|
||||
test("copySessionToNewWindowWithCurrentShellImpl shows an error when the bridge throws", async () => {
|
||||
const errors: string[] = [];
|
||||
|
||||
const result = await copySessionToNewWindowWithCurrentShellImpl(
|
||||
() => ({
|
||||
classifyLocalShellType: () => "zsh",
|
||||
discoveredShells: [],
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
openSessionInNewWindow: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
}),
|
||||
},
|
||||
resolveShellSetting: () => ({ command: "/bin/zsh" }),
|
||||
sessions: [sourceSession()],
|
||||
terminalSettings: { localShell: "system-default" },
|
||||
t: (key: string) => key === "tabs.copyTabToNewWindowFailed" ? "Could not open" : key,
|
||||
toast: {
|
||||
error: (message: string) => errors.push(message),
|
||||
},
|
||||
}),
|
||||
"session-1",
|
||||
);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.deepEqual(errors, ["Could not open"]);
|
||||
});
|
||||
161
application/app/AppActiveTabChrome.tsx
Normal file
161
application/app/AppActiveTabChrome.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
fromEditorTabId,
|
||||
isEditorTabId,
|
||||
useActiveTabId,
|
||||
} from '../state/activeTabStore';
|
||||
import { setImmersiveActive } from '../state/immersiveStore';
|
||||
import { useImmersiveMode } from '../state/useImmersiveMode';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
applyCustomAccentToTerminalTheme,
|
||||
resolveHostTerminalThemeId,
|
||||
} from '../../domain/terminalAppearance';
|
||||
import { collectSessionIds } from '../../domain/workspace';
|
||||
import type {
|
||||
Host,
|
||||
TerminalSession,
|
||||
TerminalTheme,
|
||||
Workspace,
|
||||
} from '../../types';
|
||||
import type { LogView } from '../state/logViewState';
|
||||
import type { EditorTab } from '../state/editorTabStore';
|
||||
|
||||
interface AppActiveTabChromeProps {
|
||||
showSftpTab: boolean;
|
||||
setActiveTabId: (id: string) => void;
|
||||
hostById: Map<string, Host>;
|
||||
sessionById: Map<string, TerminalSession>;
|
||||
workspaceById: Map<string, Workspace>;
|
||||
themeById: Map<string, TerminalTheme>;
|
||||
currentTerminalTheme: TerminalTheme;
|
||||
followAppTerminalTheme: boolean;
|
||||
accentMode: 'theme' | 'custom';
|
||||
customAccent: string;
|
||||
reapplyCurrentTheme: () => void;
|
||||
editorTabs: readonly EditorTab[];
|
||||
logViews: readonly LogView[];
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the `activeTabId` subscription and the purely side-effectful "chrome"
|
||||
* work derived from it: immersive-mode theming, window title, and the
|
||||
* SFTP-tab guard. Extracted out of <App> so that switching top tabs only
|
||||
* re-renders this null-rendering component (and the self-subscribing leaves)
|
||||
* instead of forcing the entire App tree (which holds all vault/session/
|
||||
* settings state and rebuilds the giant AppView ctx) to re-render.
|
||||
*
|
||||
* Renders nothing; publishes "immersive active" to immersiveStore so AppView
|
||||
* and TopTabs can read it without re-rendering App.
|
||||
*/
|
||||
export function AppActiveTabChrome({
|
||||
showSftpTab,
|
||||
setActiveTabId,
|
||||
hostById,
|
||||
sessionById,
|
||||
workspaceById,
|
||||
themeById,
|
||||
currentTerminalTheme,
|
||||
followAppTerminalTheme,
|
||||
accentMode,
|
||||
customAccent,
|
||||
reapplyCurrentTheme,
|
||||
editorTabs,
|
||||
logViews,
|
||||
t,
|
||||
}: AppActiveTabChromeProps) {
|
||||
const activeTabId = useActiveTabId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSftpTab && activeTabId === 'sftp') {
|
||||
setActiveTabId('vault');
|
||||
}
|
||||
}, [showSftpTab, activeTabId, setActiveTabId]);
|
||||
|
||||
const activeTerminalTheme = useMemo<TerminalTheme | null>(() => {
|
||||
if (activeTabId === 'vault' || activeTabId === 'sftp') return null;
|
||||
|
||||
const resolveTheme = (s: TerminalSession): TerminalTheme => {
|
||||
let baseTheme: TerminalTheme;
|
||||
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);
|
||||
};
|
||||
|
||||
const workspace = workspaceById.get(activeTabId);
|
||||
if (workspace) {
|
||||
if (workspace.viewMode === 'focus') {
|
||||
const wsSessionIds = collectSessionIds(workspace.root);
|
||||
const focused = (workspace.focusedSessionId
|
||||
? sessionById.get(workspace.focusedSessionId)
|
||||
: null)
|
||||
?? wsSessionIds.map((id) => sessionById.get(id)).find(Boolean);
|
||||
return focused ? resolveTheme(focused) : null;
|
||||
}
|
||||
const sessionIds = collectSessionIds(workspace.root);
|
||||
const wsSessions = sessionIds
|
||||
.map((id) => sessionById.get(id))
|
||||
.filter(Boolean) as TerminalSession[];
|
||||
if (wsSessions.length === 0) return null;
|
||||
const firstTheme = resolveTheme(wsSessions[0]);
|
||||
const allSame = wsSessions.every((s) => resolveTheme(s).id === firstTheme.id);
|
||||
return allSame ? firstTheme : null;
|
||||
}
|
||||
|
||||
const session = sessionById.get(activeTabId);
|
||||
if (!session) return null;
|
||||
return resolveTheme(session);
|
||||
}, [accentMode, activeTabId, currentTerminalTheme, customAccent, followAppTerminalTheme, hostById, sessionById, themeById, workspaceById]);
|
||||
|
||||
useImmersiveMode({
|
||||
activeTabId,
|
||||
activeTerminalTheme,
|
||||
restoreOriginalTheme: reapplyCurrentTheme,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setImmersiveActive(activeTerminalTheme !== null);
|
||||
}, [activeTerminalTheme]);
|
||||
|
||||
const editorTabFileNameCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const tab of editorTabs) counts.set(tab.fileName, (counts.get(tab.fileName) ?? 0) + 1);
|
||||
return counts;
|
||||
}, [editorTabs]);
|
||||
|
||||
const activeWindowTitle = useMemo(() => {
|
||||
if (activeTabId === 'vault') return 'Vaults';
|
||||
if (activeTabId === 'sftp') return 'SFTP';
|
||||
if (isEditorTabId(activeTabId)) {
|
||||
const editorTab = editorTabs.find((tab) => tab.id === fromEditorTabId(activeTabId));
|
||||
if (!editorTab) return 'Editor';
|
||||
const suffix = (editorTabFileNameCounts.get(editorTab.fileName) ?? 0) > 1
|
||||
? ` · ${editorTab.remotePath.split('/').slice(-2, -1)[0] || '/'}`
|
||||
: '';
|
||||
return `${editorTab.fileName}${suffix}`;
|
||||
}
|
||||
const workspace = workspaceById.get(activeTabId);
|
||||
if (workspace) return workspace.title;
|
||||
const session = sessionById.get(activeTabId);
|
||||
if (session) return session.hostLabel;
|
||||
const logView = logViews.find((item) => item.id === activeTabId);
|
||||
if (logView) {
|
||||
const isLocal = logView.log.protocol === 'local' || logView.log.hostname === 'localhost';
|
||||
return `${t('tabs.logPrefix')} ${isLocal ? t('tabs.logLocal') : logView.log.hostname}`;
|
||||
}
|
||||
return 'Netcatty';
|
||||
}, [activeTabId, editorTabFileNameCounts, editorTabs, logViews, sessionById, t, workspaceById]);
|
||||
|
||||
useEffect(() => {
|
||||
void netcattyBridge.get()?.setWindowTitle?.(activeWindowTitle);
|
||||
}, [activeWindowTitle]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -73,7 +73,7 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
|
||||
const protocol = effectiveHost.etEnabled ? 'et' : effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
|
||||
const resolvedAuth = resolveHostAuth({ host: effectiveHost, keys, identities });
|
||||
const sessionId = connectToHost(effectiveHost);
|
||||
addConnectionLog({
|
||||
@@ -82,7 +82,7 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: resolvedAuth.username || 'root',
|
||||
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh',
|
||||
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh' | 'et',
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
@@ -203,7 +203,7 @@ export function handleKeyboardInteractiveSubmitImpl(getCtx: AppContextGetter, re
|
||||
if (session?.hostId && (!request.hostname || request.hostname === session.hostname)) {
|
||||
const host = hosts.find(h => h.id === session.hostId);
|
||||
if (host) {
|
||||
updateHosts(hosts.map(h => h.id === host.id ? { ...h, password: savePassword } : h));
|
||||
updateHosts(hosts.map(h => h.id === host.id ? { ...h, password: savePassword, savePassword: true } : h));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,7 +287,7 @@ export function handlePassphraseSkipImpl(getCtx: AppContextGetter, requestId: st
|
||||
export function createLocalTerminalWithCurrentShellImpl(getCtx: AppContextGetter) {
|
||||
const { classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, terminalSettings } = getCtx();
|
||||
{
|
||||
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
||||
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells, terminalSettings.localShellArgs);
|
||||
const matchedShell = discoveredShells.find(s => s.id === terminalSettings.localShell);
|
||||
return createLocalTerminal({
|
||||
shellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
|
||||
@@ -319,6 +319,36 @@ export function copySessionWithCurrentShellImpl(getCtx: AppContextGetter, sessio
|
||||
}
|
||||
}
|
||||
|
||||
export async function copySessionToNewWindowWithCurrentShellImpl(getCtx: AppContextGetter, sessionId: string) {
|
||||
const { classifyLocalShellType, discoveredShells, netcattyBridge, resolveShellSetting, sessions, terminalSettings, t, toast } = getCtx();
|
||||
{
|
||||
const sourceSession = sessions.find((session: { id: string }) => session.id === sessionId);
|
||||
if (!sourceSession) return false;
|
||||
|
||||
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.openSessionInNewWindow) {
|
||||
toast?.error?.(t?.('tabs.copyTabToNewWindowFailed') ?? 'Failed to open tab in a new window');
|
||||
return false;
|
||||
}
|
||||
|
||||
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : '';
|
||||
try {
|
||||
const result = await bridge.openSessionInNewWindow({
|
||||
title: sourceSession.hostLabel,
|
||||
sourceSession,
|
||||
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, userAgent),
|
||||
});
|
||||
const success = result?.success === true;
|
||||
if (!success) toast?.error?.(t?.('tabs.copyTabToNewWindowFailed') ?? 'Failed to open tab in a new window');
|
||||
return success;
|
||||
} catch {
|
||||
toast?.error?.(t?.('tabs.copyTabToNewWindowFailed') ?? 'Failed to open tab in a new window');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function confirmIfBusyLocalTerminalImpl(getCtx: AppContextGetter, sessionIds: string[]) {
|
||||
const { netcattyBridge, sessions, t } = getCtx();
|
||||
{
|
||||
@@ -633,7 +663,7 @@ export function handleCreateLocalTerminalImpl(getCtx: AppContextGetter, shell?:
|
||||
const { addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, systemInfoRef, terminalSettings } = getCtx();
|
||||
{
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
const resolved = shell ?? resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
||||
const resolved = shell ?? resolveShellSetting(terminalSettings.localShell, discoveredShells, terminalSettings.localShellArgs);
|
||||
// Match by ID (not command) to avoid WSL distros all sharing wsl.exe
|
||||
const matchedShell = !shell ? discoveredShells.find(s => s.id === terminalSettings.localShell) : undefined;
|
||||
const shellName = shell?.name ?? matchedShell?.name;
|
||||
@@ -686,7 +716,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host) {
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
|
||||
const protocol = effectiveHost.etEnabled ? 'et' : effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
|
||||
const resolvedAuth = resolveHostAuth({ host: effectiveHost, keys, identities });
|
||||
const sessionId = connectToHost(effectiveHost);
|
||||
addConnectionLog({
|
||||
@@ -695,7 +725,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host) {
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: resolvedAuth.username || 'root',
|
||||
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh',
|
||||
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh' | 'et',
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
@@ -766,9 +796,10 @@ export function handleProtocolSelectImpl(getCtx: AppContextGetter, protocol: Hos
|
||||
if (protocolSelectHost) {
|
||||
const hostWithProtocol: Host = {
|
||||
...protocolSelectHost,
|
||||
protocol: protocol === 'mosh' ? 'ssh' : protocol,
|
||||
protocol: (protocol === 'mosh' || protocol === 'et') ? 'ssh' : protocol,
|
||||
port,
|
||||
moshEnabled: protocol === 'mosh',
|
||||
etEnabled: protocol === 'et',
|
||||
};
|
||||
handleConnectToHost(hostWithProtocol);
|
||||
setProtocolSelectHost(null);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
|
||||
import { activeTabStore, toEditorTabId } from '../state/activeTabStore';
|
||||
import { useImmersiveActive } from '../state/immersiveStore';
|
||||
import { editorTabStore } from '../state/editorTabStore';
|
||||
import { releaseEditorTabSaveCoordinator, saveEditorTab } from '../state/editorTabSave';
|
||||
import { TopTabs } from '../../components/TopTabs';
|
||||
@@ -32,8 +33,8 @@ type AppViewContext = Record<string, any>;
|
||||
|
||||
export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
const {
|
||||
accentMode, activeTabId, activeTerminalTheme, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||
clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, closeWorkspace, copySessionWithCurrentShell,
|
||||
accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||
clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, closeWorkspace, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell,
|
||||
connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent,
|
||||
customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
|
||||
followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost,
|
||||
@@ -43,10 +44,10 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderTabs, reorderWorkspaceSessions, resetSessionRename,
|
||||
resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionRenameTarget,
|
||||
resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen,
|
||||
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId,
|
||||
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||
sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
|
||||
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId,
|
||||
toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog,
|
||||
@@ -55,6 +56,12 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper,
|
||||
} = ctx;
|
||||
|
||||
// Immersive flag from store (not ctx) so toggling it doesn't re-render <App>.
|
||||
// Note: we intentionally do NOT subscribe to the active tab id here — editor
|
||||
// tab visibility self-subscribes inside TextEditorTabView — so plain tab
|
||||
// switches don't re-render AppView/App at all.
|
||||
const isImmersive = useImmersiveActive();
|
||||
|
||||
return (
|
||||
<SnippetExecutionProvider>
|
||||
<UnsavedChangesProvider>
|
||||
@@ -72,38 +79,41 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
};
|
||||
|
||||
// Real dirty-confirm close handler.
|
||||
const handleRequestCloseEditorTab = async (id: string) => {
|
||||
const handleRequestCloseEditorTab = async (id: string): Promise<boolean> => {
|
||||
const tab = editorTabStore.getTab(id);
|
||||
if (!tab) return;
|
||||
if (!tab) return false;
|
||||
const dirty = tab.content !== tab.baselineContent;
|
||||
if (!dirty) {
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
const choice = await prompt(tab.fileName);
|
||||
if (choice === 'cancel') return;
|
||||
if (choice === 'cancel') return false;
|
||||
if (choice === 'discard') {
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (choice === 'save') {
|
||||
const ok = await saveEditorTab(id);
|
||||
if (!ok) {
|
||||
const msg = editorTabStore.getTab(id)?.saveError ?? 'Save failed';
|
||||
toast.error(msg, 'SFTP');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const latest = editorTabStore.getTab(id);
|
||||
if (!latest || latest.content !== latest.baselineContent) return;
|
||||
if (!latest || latest.content !== latest.baselineContent) return false;
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Expose to the hotkey dispatcher (Cmd/Ctrl+W).
|
||||
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-screen text-foreground font-sans netcatty-shell", activeTerminalTheme && "immersive-transition")} onContextMenu={handleRootContextMenu}>
|
||||
<div className={cn("flex flex-col h-screen text-foreground font-sans netcatty-shell", isImmersive && "immersive-transition")} onContextMenu={handleRootContextMenu}>
|
||||
<TopTabs
|
||||
theme={resolvedTheme}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
@@ -118,6 +128,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onCloseSession={closeSession}
|
||||
onRenameSession={startSessionRename}
|
||||
onCopySession={copySessionWithCurrentShell}
|
||||
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
|
||||
onRenameWorkspace={startWorkspaceRename}
|
||||
onCloseWorkspace={closeWorkspace}
|
||||
onCloseLogView={closeLogView}
|
||||
@@ -125,8 +136,10 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onToggleTheme={handleToggleTheme}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
windowOpacity={settings.windowOpacity}
|
||||
setWindowOpacity={settings.setWindowOpacity}
|
||||
onSyncNow={handleSyncNowManual}
|
||||
isImmersiveActive={activeTerminalTheme !== null}
|
||||
isImmersiveActive={isImmersive}
|
||||
onStartSessionDrag={setDraggingSessionId}
|
||||
onEndSessionDrag={handleEndSessionDrag}
|
||||
onReorderTabs={reorderTabs}
|
||||
@@ -211,6 +224,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
|
||||
<TerminalLayerMount
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
groupConfigs={groupConfigs}
|
||||
proxyProfiles={proxyProfiles}
|
||||
keys={keys}
|
||||
@@ -255,6 +269,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
|
||||
onReorderWorkspaceSessions={reorderWorkspaceSessions}
|
||||
onSplitSession={splitSessionWithCurrentShell}
|
||||
onConnectToHost={handleConnectToHost}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
onToggleBroadcast={toggleBroadcast}
|
||||
updateHosts={updateHosts}
|
||||
@@ -264,11 +280,15 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
sftpShowHiddenFiles={sftpShowHiddenFiles}
|
||||
sftpUseCompressedUpload={sftpUseCompressedUpload}
|
||||
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
|
||||
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
|
||||
setSftpFollowTerminalCwd={setSftpFollowTerminalCwd}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
sessionLogsEnabled={sessionLogsEnabled}
|
||||
sessionLogsDir={sessionLogsDir}
|
||||
sessionLogsFormat={sessionLogsFormat}
|
||||
sessionLogsTimestampsEnabled={sessionLogsTimestampsEnabled}
|
||||
sshDebugLogsEnabled={sshDebugLogsEnabled}
|
||||
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
|
||||
toggleSidePanelRef={toggleSidePanelRef}
|
||||
/>
|
||||
@@ -294,7 +314,6 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
<TextEditorTabView
|
||||
key={tab.id}
|
||||
tabId={tab.id}
|
||||
isVisible={activeTabId === toEditorTabId(tab.id)}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
hostById={hostById}
|
||||
|
||||
@@ -34,6 +34,10 @@ export const enAiMessages: Messages = {
|
||||
'ai.providers.skipTLSVerify': 'Skip TLS certificate verification (for self-signed certs)',
|
||||
'ai.providers.defaultModel': 'Default Model',
|
||||
'ai.providers.defaultModel.placeholder': 'e.g. gpt-4o, claude-sonnet-4-20250514',
|
||||
'ai.providers.contextWindow': 'Context window',
|
||||
'ai.providers.contextWindow.placeholder': 'e.g. 128000',
|
||||
'ai.providers.contextWindow.help': 'Leave blank to use the model list value when available, otherwise Netcatty uses a safe default.',
|
||||
'ai.providers.contextWindow.error': 'Enter a positive whole number, or leave it blank.',
|
||||
'ai.providers.refreshModels': 'Refresh models',
|
||||
'ai.providers.searchModel': 'Search or type model ID...',
|
||||
'ai.providers.filterModels': 'Filter models...',
|
||||
@@ -49,7 +53,7 @@ export const enAiMessages: Messages = {
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': 'Uses codex + codex-acp for ACP protocol streaming. Login with ChatGPT here, or enable an OpenAI-compatible provider API key and custom endpoint in Settings.',
|
||||
'ai.codex.description': 'Connect OpenAI Codex. Sign in with ChatGPT here, or enable an OpenAI-compatible provider API key and custom endpoint in Settings.',
|
||||
'ai.codex.detecting': 'Detecting...',
|
||||
'ai.codex.notFound': 'Not found',
|
||||
'ai.codex.awaitingLogin': 'Awaiting login',
|
||||
@@ -83,6 +87,9 @@ export const enAiMessages: Messages = {
|
||||
'ai.claude.configDir': 'Config directory',
|
||||
'ai.claude.configDir.placeholder': '~/.claude (leave blank for default)',
|
||||
'ai.claude.configDir.hint': 'Sets CLAUDE_CONFIG_DIR — point at a folder where you have run `claude` login (contains settings.json + credentials).',
|
||||
'ai.claude.settings': 'Settings file',
|
||||
'ai.claude.settings.placeholder': '~/team-settings.json (path, or inline {"model":"..."})',
|
||||
'ai.claude.settings.hint': 'Optional. A settings.json path or inline JSON, passed to the SDK as `settings`. Additive to — and independent of — the config directory above (merged on top, not a replacement).',
|
||||
'ai.claude.envVars': 'Environment variables',
|
||||
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
|
||||
'ai.claude.envVars.hint': 'One KEY=VALUE per line, passed to the Claude agent. Stored locally in plaintext — for API keys / credentials, prefer the config directory above (a `claude` login).',
|
||||
@@ -90,7 +97,7 @@ export const enAiMessages: Messages = {
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
'ai.copilot.description': 'Uses GitHub Copilot CLI via ACP over stdio (`copilot --acp --stdio`). Once detected, it can be selected as an external coding agent.',
|
||||
'ai.copilot.description': 'Uses the GitHub Copilot CLI. Once detected, it can be selected as an external coding agent.',
|
||||
'ai.copilot.detecting': 'Detecting...',
|
||||
'ai.copilot.detected': 'Detected',
|
||||
'ai.copilot.notFound': 'Not found',
|
||||
@@ -105,7 +112,7 @@ export const enAiMessages: Messages = {
|
||||
'ai.defaultAgent.catty': 'Catty (Built-in)',
|
||||
'ai.toolAccess.title': 'Tool Access',
|
||||
'ai.toolAccess.mode': 'Netcatty Access Mode',
|
||||
'ai.toolAccess.description': 'Choose how external ACP agents access Netcatty sessions. MCP exposes the built-in server, while Skills + CLI points agents to the local Netcatty skill and CLI commands.',
|
||||
'ai.toolAccess.description': 'Choose how external agents access Netcatty sessions. MCP exposes the built-in server, while Skills + CLI points agents to the local Netcatty skill and CLI commands.',
|
||||
'ai.toolAccess.mode.mcp': 'MCP',
|
||||
'ai.toolAccess.mode.skills': 'Skills + CLI',
|
||||
'ai.userSkills.title': 'User Skills',
|
||||
@@ -167,6 +174,8 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.daysAgo': '{n}d ago',
|
||||
'ai.chat.newChat': 'New Chat',
|
||||
'ai.chat.allSessions': 'All Sessions',
|
||||
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
|
||||
'ai.chat.loadMoreSessions': 'Load more sessions ({n} more)',
|
||||
'ai.chat.noSessions': 'No previous sessions',
|
||||
'ai.chat.retryHint': 'You can retry by sending your message again.',
|
||||
'ai.chat.approvalTimeout': 'Tool approval timed out after 5 minutes. You can retry by sending your message again.',
|
||||
@@ -198,21 +207,21 @@ export const enAiMessages: Messages = {
|
||||
// AI Safety Settings
|
||||
'ai.safety.title': 'Safety',
|
||||
'ai.safety.permissionMode': 'Permission Mode',
|
||||
'ai.safety.permissionMode.description': 'Controls how the AI interacts with your terminals. Observer mode blocks all write operations through Netcatty, enforced for both built-in and ACP agents. Confirm mode is advisory for ACP agents (they control their own tool approval flow).',
|
||||
'ai.safety.permissionMode.description': 'Controls how the AI interacts with your Netcatty terminal sessions. Observer mode blocks write operations that go through Netcatty. External agent CLIs may still have their own local tools and approval flow.',
|
||||
'ai.safety.permissionMode.observer': 'Observer - Read only, no actions',
|
||||
'ai.safety.permissionMode.confirm': 'Confirm - Ask before actions',
|
||||
'ai.safety.permissionMode.autonomous': 'Autonomous - Execute freely',
|
||||
'ai.safety.commandTimeout': 'Command Timeout',
|
||||
'ai.safety.commandTimeout.description': 'Maximum seconds a command can run before being terminated. Applies to both built-in and ACP agents.',
|
||||
'ai.safety.commandTimeout.description': 'Maximum seconds a command can run before being terminated through Netcatty execution.',
|
||||
'ai.safety.commandTimeout.unit': 'sec',
|
||||
'ai.safety.maxIterations': 'Max Iterations',
|
||||
'ai.safety.maxIterations.description': 'Maximum number of AI tool-use loops to prevent runaway execution. ACP agents may have their own internal iteration limits that take precedence.',
|
||||
'ai.safety.maxIterations.description': 'Maximum number of AI tool-use loops to prevent runaway execution. External agents may have their own internal iteration limits that take precedence.',
|
||||
'ai.safety.blocklist': 'Command Blocklist',
|
||||
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands. Applies to both built-in and ACP agents through Netcatty execution.',
|
||||
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands executed through Netcatty.',
|
||||
'ai.safety.blocklist.placeholder': 'Regex pattern...',
|
||||
'ai.safety.blocklist.reset': 'Reset to defaults',
|
||||
'ai.safety.blocklist.add': 'Add pattern',
|
||||
'ai.safety.note': 'Command Blocklist, Command Timeout, and Observer mode are enforced at the MCP Server level, applying to all agent types. Confirm mode and Max Iterations are fully enforced for the built-in agent; ACP agents may have their own internal controls for these settings.',
|
||||
'ai.safety.note': 'These safety settings are enforced for actions that go through Netcatty. External agent CLIs may also expose local tools that are governed by the agent itself.',
|
||||
|
||||
// Unified tooltips for terminal workspace and top tabs (issue #954)
|
||||
'terminal.layer.addTerminal': 'Add Terminal',
|
||||
@@ -224,13 +233,26 @@ export const enAiMessages: Messages = {
|
||||
'terminal.layer.movePanelLeft': 'Move panel to left',
|
||||
'terminal.layer.movePanelRight': 'Move panel to right',
|
||||
'terminal.layer.closePanel': 'Close panel',
|
||||
'terminal.layer.hostTree.search': 'Search hosts...',
|
||||
'terminal.layer.hostTree.searchButton': 'Search',
|
||||
'terminal.layer.hostTree.tagsButton': 'Filter by tags',
|
||||
'terminal.layer.hostTree.newGroup': 'New group',
|
||||
'terminal.layer.hostTree.localShell': 'Local shell',
|
||||
'terminal.layer.hostTree.tagsEmpty': 'No tags available',
|
||||
'terminal.layer.hostTree.clearTags': 'Clear selection',
|
||||
'terminal.layer.hostTree.collapse': 'Collapse host list',
|
||||
'terminal.layer.hostTree.expand': 'Expand host list',
|
||||
'terminal.layer.hostTree.empty': 'No hosts found',
|
||||
'topTabs.openQuickSwitcher': 'Open quick switcher',
|
||||
'topTabs.moreTabs': 'More tabs',
|
||||
'topTabs.aiAssistant': 'AI Assistant',
|
||||
'topTabs.windowOpacity': 'Window opacity',
|
||||
'topTabs.toggleTheme': 'Toggle theme',
|
||||
'topTabs.openSettings': 'Open Settings',
|
||||
'ai.chat.sessionHistory': 'Session history',
|
||||
'ai.chat.attach': 'Attach',
|
||||
'ai.chat.terminalSelectionAttachment': 'Terminal selection',
|
||||
'ai.chat.terminalSelectionLines': 'lines: {count}',
|
||||
'ai.chat.collapse': 'Collapse',
|
||||
'ai.chat.expand': 'Expand',
|
||||
'ai.chat.enableAgent': 'Enable {name}',
|
||||
|
||||
@@ -159,8 +159,21 @@ export const enCoreMessages: Messages = {
|
||||
'settings.sessionLogs.formatTxt': 'Plain Text (.txt)',
|
||||
'settings.sessionLogs.formatRaw': 'Raw with ANSI (.log)',
|
||||
'settings.sessionLogs.formatHtml': 'HTML (.html)',
|
||||
'settings.sessionLogs.timestamps': 'Add timestamps',
|
||||
'settings.sessionLogs.timestampsDesc': 'Prefix each line in plain text and HTML logs with the local time.',
|
||||
'settings.sessionLogs.hint': 'Session logs capture all terminal output for troubleshooting and auditing purposes.',
|
||||
|
||||
// Settings > SSH Debug Logs
|
||||
'settings.sshDebugLogs.title': 'SSH Debug Logs',
|
||||
'settings.sshDebugLogs.enable': 'Enable SSH debug logs',
|
||||
'settings.sshDebugLogs.enableDesc': 'Record connection, auth, handshake, disconnect, and error reasons without saving terminal output.',
|
||||
'settings.sshDebugLogs.location': 'Log Location',
|
||||
'settings.sshDebugLogs.status': 'Status',
|
||||
'settings.sshDebugLogs.statusOn': 'On',
|
||||
'settings.sshDebugLogs.statusOff': 'Off',
|
||||
'settings.sshDebugLogs.size': 'Size',
|
||||
'settings.sshDebugLogs.hint': 'When enabled, newly started SSH connections write diagnostic events for bastion, auth, and unexpected disconnect troubleshooting.',
|
||||
|
||||
// Settings > Global Hotkey (Quake Mode)
|
||||
'settings.globalHotkey.title': 'Global Hotkey',
|
||||
'settings.globalHotkey.toggleWindow': 'Toggle Window',
|
||||
@@ -227,6 +240,8 @@ export const enCoreMessages: Messages = {
|
||||
'update.restartNow': 'Restart Now',
|
||||
'update.downloadFailed.title': 'Update Failed',
|
||||
'update.downloadFailed.message': 'Failed to download update. You can download it manually.',
|
||||
'update.needsSave.title': 'Unsaved Changes',
|
||||
'update.needsSave.message': 'Save your open editors first, then click Restart Now again to install the update.',
|
||||
'update.openReleases': 'Open Releases',
|
||||
'update.remindLater': 'Remind Later',
|
||||
'update.skipVersion': 'Skip This Version',
|
||||
@@ -249,14 +264,15 @@ export const enCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': 'Dark palette',
|
||||
'settings.appearance.customCss': 'Custom CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'Add custom CSS to personalize the app appearance. Changes apply immediately. Major UI regions expose a [data-section="..."] attribute you can target — e.g. snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar, top-tabs.',
|
||||
'Add custom CSS to personalize the app appearance. Changes apply immediately. Major UI regions expose a [data-section="..."] attribute you can target — e.g. snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (focus-mode terminal list), terminal-host-tree-sidebar, terminal-host-tree-sidebar-content, terminal-host-tree-sidebar-row, terminal-side-panel (SFTP/Scripts/Theme/AI panel, available while open), terminal-side-panel-tabs, terminal-side-panel-content, terminal-sftp-panel, terminal-sftp-host-header, terminal-sftp-pane, terminal-sftp-toolbar, terminal-sftp-path, terminal-sftp-filter-bar, terminal-sftp-list, terminal-sftp-list-header, terminal-sftp-list-row, terminal-sftp-tree, terminal-sftp-tree-row, terminal-sftp-transfer-queue, terminal-sftp-transfer-row, terminal-split-pane, terminal-split-resizer, top-tabs.',
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Make snippet sidebar text larger */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* Custom terminal background */\n.terminal { background: #1a1a2e !important; }\n\n/* Tweak global border radius */\n:root { --radius: 0.25rem; }',
|
||||
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Border around the SFTP / side panel (does not linger after closing) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Change the whole side panel background, not only the top tabs */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Style selected SFTP file rows */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */',
|
||||
'settings.appearance.language': 'Language',
|
||||
'settings.appearance.language.desc': 'Choose the UI language',
|
||||
'settings.appearance.uiFont': 'Interface Font',
|
||||
'settings.appearance.uiFont.desc': 'Choose the font for the application interface',
|
||||
|
||||
'settings.appearance.windowOpacity': 'Window Opacity',
|
||||
'settings.appearance.windowOpacity.desc': 'Adjust the transparency of the entire application window. Lower values also fade terminal text. Some Linux desktop environments may not support this.',
|
||||
// Settings > Terminal
|
||||
'settings.terminal.section.theme': 'Terminal Theme',
|
||||
'settings.terminal.themeModal.title': 'Select Theme',
|
||||
@@ -394,6 +410,9 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.localShell.shell.default': 'System Default',
|
||||
'settings.terminal.localShell.shell.custom': 'Custom...',
|
||||
'settings.terminal.localShell.shell.customPath': 'Shell executable path',
|
||||
'settings.terminal.localShell.shell.customArgs': 'Launch arguments',
|
||||
'settings.terminal.localShell.shell.customArgs.placeholder': 'e.g. --login -i',
|
||||
'settings.terminal.localShell.shell.customArgs.desc': 'Arguments passed to the shell. Some shells need them to work — e.g. msys2 bash requires --login -i to load the environment.',
|
||||
'settings.terminal.localShell.shell.commonPaths': 'Common paths',
|
||||
'settings.terminal.localShell.shell.pathValid': 'Path valid',
|
||||
'settings.terminal.localShell.startDir': 'Starting directory',
|
||||
@@ -421,6 +440,8 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.rendering.renderer': 'Renderer',
|
||||
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use DOM on low-memory devices. Changes take effect on new terminal sessions.',
|
||||
'settings.terminal.rendering.auto': 'Auto',
|
||||
'settings.terminal.rendering.lineTimestamps': 'Prefix output with timestamps',
|
||||
'settings.terminal.rendering.lineTimestamps.desc': 'Insert local time before terminal output lines. The timestamp becomes part of the visible terminal content.',
|
||||
|
||||
// Settings > Terminal > Workspace Focus Indicator
|
||||
'settings.terminal.section.workspaceFocus': 'Workspace Focus Indicator',
|
||||
@@ -557,12 +578,12 @@ export const enCoreMessages: Messages = {
|
||||
'proxyProfiles.section.proxies': 'Proxies',
|
||||
'proxyProfiles.count.items': '{count} items',
|
||||
'proxyProfiles.empty.title': 'No Proxies',
|
||||
'proxyProfiles.empty.desc': 'Create reusable HTTP or SOCKS5 proxies and select them from host details.',
|
||||
'proxyProfiles.empty.desc': 'Create reusable HTTP, SOCKS5, or command proxies and select them from host details.',
|
||||
'proxyProfiles.usage': '{count} linked',
|
||||
'proxyProfiles.copyName': '{name} Copy',
|
||||
'proxyProfiles.panel.newTitle': 'New Proxy',
|
||||
'proxyProfiles.field.name': 'Proxy name',
|
||||
'proxyProfiles.error.required': 'Name, host, and port are required.',
|
||||
'proxyProfiles.error.required': 'Name and proxy details are required.',
|
||||
'proxyProfiles.error.port': 'Port must be between 1 and 65535.',
|
||||
'proxyProfiles.viewMode': 'Proxy view mode',
|
||||
'proxyProfiles.delete.title': 'Delete proxy?',
|
||||
@@ -573,6 +594,7 @@ export const enCoreMessages: Messages = {
|
||||
'vault.groups.hostsCount': '{count} Hosts',
|
||||
'vault.groups.newSubgroup': 'New Subgroup',
|
||||
'vault.groups.rename': 'Rename Group',
|
||||
'vault.groups.unnamed': 'Unnamed Group',
|
||||
'vault.groups.delete': 'Delete Group',
|
||||
'vault.groups.createSubfolder': 'Create Subfolder',
|
||||
'vault.groups.createRoot': 'Create Root Group',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enTerminalMessages: Messages = {
|
||||
'terminal.sudoHint.pressEnter': 'Press Enter to paste sudo password',
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Open SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': 'Available after connect',
|
||||
@@ -70,6 +71,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.search.nextMatch': 'Next match (Enter)',
|
||||
'terminal.menu.copy': 'Copy',
|
||||
'terminal.menu.paste': 'Paste',
|
||||
'terminal.menu.addSelectionToAI': 'Add to Conversation',
|
||||
'terminal.menu.pasteSelection': 'Paste Selection',
|
||||
'terminal.menu.selectAll': 'Select All',
|
||||
'terminal.menu.reconnect': 'Reconnect',
|
||||
@@ -77,6 +79,8 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.menu.splitVertical': 'Split Vertical',
|
||||
'terminal.menu.clearBuffer': 'Clear Buffer',
|
||||
'terminal.menu.closeTerminal': 'Close terminal',
|
||||
'terminal.selection.addToAI': 'Add to Conversation',
|
||||
'terminal.selection.addToAIDesc': 'Attach selected terminal output to the AI draft',
|
||||
'terminal.auth.password': 'Password',
|
||||
'terminal.auth.sshKey': 'SSH Key',
|
||||
'terminal.auth.username': 'Username',
|
||||
@@ -104,6 +108,9 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.connection.protocol.ssh': 'SSH',
|
||||
'terminal.connection.protocol.telnet': 'Telnet',
|
||||
'terminal.connection.protocol.mosh': 'Mosh',
|
||||
'terminal.connection.protocol.et': 'EternalTerminal',
|
||||
'terminal.et.proxyUnsupported': 'EternalTerminal does not currently support Netcatty proxy settings. Use SSH or remove the proxy for this host.',
|
||||
'terminal.et.multiJumpUnsupported': 'EternalTerminal currently supports at most one jump host in Netcatty.',
|
||||
'terminal.connection.protocol.serial': 'Serial',
|
||||
'terminal.connection.protocol.local': 'Local Shell',
|
||||
'terminal.hostKey.unknownTitle': 'Confirm this host key',
|
||||
@@ -489,6 +496,8 @@ export const enTerminalMessages: Messages = {
|
||||
'tabs.logPrefix': 'Log:',
|
||||
'tabs.logLocal': 'Local',
|
||||
'tabs.copyTab': 'Copy Tab',
|
||||
'tabs.copyTabToNewWindow': 'Copy Tab to New Window',
|
||||
'tabs.copyTabToNewWindowFailed': 'Failed to open tab in a new window',
|
||||
'tabs.closeOthers': 'Close Others',
|
||||
'tabs.closeToRight': 'Close Tabs to the Right',
|
||||
'tabs.closeAll': 'Close All',
|
||||
@@ -514,6 +523,9 @@ export const enTerminalMessages: Messages = {
|
||||
'snippets.field.packagePlaceholder': 'Select or create package',
|
||||
'snippets.field.createPackage': 'Create Package',
|
||||
'snippets.field.scriptRequired': 'Script *',
|
||||
'snippets.scriptEditor.expand': 'Open in dialog',
|
||||
'snippets.scriptEditor.resize': 'Resize editor height',
|
||||
'snippets.scriptEditor.modalTitle': 'Edit script',
|
||||
'snippets.targets.title': 'Targets',
|
||||
'snippets.targets.add': 'Add targets',
|
||||
'snippets.history.title': 'Shell History',
|
||||
|
||||
@@ -197,6 +197,9 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.transfers.dragToResize': 'Drag to resize',
|
||||
'sftp.goUp': 'Go up',
|
||||
'sftp.goToTerminalCwd': 'Go to terminal directory',
|
||||
'sftp.followTerminalCwd': 'Follow terminal directory',
|
||||
'sftp.followTerminalCwd.enable': 'Enable follow terminal directory',
|
||||
'sftp.followTerminalCwd.disable': 'Disable follow terminal directory',
|
||||
'sftp.encoding.label': 'Filename Encoding',
|
||||
'sftp.encoding.auto': 'Auto',
|
||||
'sftp.encoding.utf8': 'UTF-8',
|
||||
@@ -275,6 +278,7 @@ export const enVaultMessages: Messages = {
|
||||
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': 'Copy file path',
|
||||
'sftp.context.openWithDefault': 'Open with system default',
|
||||
'sftp.context.openWith': 'Open with...',
|
||||
'sftp.context.edit': 'Edit',
|
||||
'sftp.context.preview': 'Preview',
|
||||
@@ -347,6 +351,10 @@ export const enVaultMessages: Messages = {
|
||||
'settings.sftp.autoOpenSidebar.desc': 'Automatically open the SFTP file browser sidebar when connecting to a host',
|
||||
'settings.sftp.autoOpenSidebar.enable': 'Enable auto-open sidebar',
|
||||
'settings.sftp.autoOpenSidebar.enableDesc': 'The SFTP sidebar will open automatically when a terminal session connects to a remote host',
|
||||
'settings.sftp.followTerminalCwd': 'Follow terminal directory',
|
||||
'settings.sftp.followTerminalCwd.desc': 'Automatically sync the sidebar SFTP browser with the terminal working directory (toggle in toolbar)',
|
||||
'settings.sftp.followTerminalCwd.enable': 'Enable follow terminal directory by default',
|
||||
'settings.sftp.followTerminalCwd.enableDesc': 'When the SFTP sidebar is open, follow mode stays on by default and updates after terminal cd commands',
|
||||
|
||||
'settings.sftp.defaultViewMode': 'Default View Mode',
|
||||
'settings.sftp.defaultViewMode.desc': 'Choose the default view mode when opening a new SFTP tab. Per-host preferences override this setting.',
|
||||
@@ -471,6 +479,7 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
|
||||
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.alinux': 'Alibaba Cloud Linux',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.distro.option.cisco': 'Cisco',
|
||||
@@ -483,6 +492,9 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.distro.option.zyxel': 'ZyXEL',
|
||||
'hostDetails.distro.option.ruijie': 'Ruijie',
|
||||
'hostDetails.section.mosh': 'Mosh',
|
||||
'hostDetails.section.et': 'EternalTerminal',
|
||||
'hostDetails.et.port': 'ET server port',
|
||||
'hostDetails.et.port.desc': 'Port etserver listens on (default 2022)',
|
||||
'hostDetails.username.placeholder': 'Username',
|
||||
'hostDetails.password.placeholder': 'Password',
|
||||
'hostDetails.password.show': 'Show password',
|
||||
@@ -542,12 +554,15 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.jumpHosts.hops': '{count} hop(s)',
|
||||
'hostDetails.jumpHosts.direct': 'Direct',
|
||||
'hostDetails.jumpHosts.configure': 'Configure Proxy Hosts',
|
||||
'hostDetails.proxy': 'Proxy via HTTP/SOCKS5',
|
||||
'hostDetails.proxy': 'Proxy via HTTP/SOCKS5/Command',
|
||||
'hostDetails.proxy.none': 'None',
|
||||
'hostDetails.proxy.edit': 'Edit Proxy',
|
||||
'hostDetails.proxy.configure': 'Configure Proxy',
|
||||
'hostDetails.proxyPanel.title': 'Proxy via HTTP/SOCKS5',
|
||||
'hostDetails.proxyPanel.title': 'Proxy via HTTP/SOCKS5/Command',
|
||||
'hostDetails.proxyPanel.hostPlaceholder': 'Proxy host',
|
||||
'hostDetails.proxyPanel.command': 'ProxyCommand',
|
||||
'hostDetails.proxyPanel.commandPlaceholder': 'cloudflared access ssh --hostname %h',
|
||||
'hostDetails.proxyPanel.commandHelp': 'Use %h for the target host, %p for the target port, and %% for a literal percent.',
|
||||
'hostDetails.proxyPanel.credentials': 'Credentials',
|
||||
'hostDetails.proxyPanel.usernamePlaceholder': 'Username',
|
||||
'hostDetails.proxyPanel.passwordPlaceholder': 'Password',
|
||||
@@ -558,7 +573,7 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.proxyPanel.customProxy': 'Custom proxy',
|
||||
'hostDetails.proxyPanel.missing': 'Missing',
|
||||
'hostDetails.proxyPanel.missingSaved': 'Missing saved proxy',
|
||||
'hostDetails.proxyPanel.error.required': 'Proxy host and port are required.',
|
||||
'hostDetails.proxyPanel.error.required': 'Proxy host and port, or a ProxyCommand, are required.',
|
||||
'hostDetails.envVars': 'Environment Variables',
|
||||
'hostDetails.envVars.add': 'Add Environment Variable',
|
||||
'hostDetails.envVars.title': 'Environment Variables',
|
||||
|
||||
@@ -34,6 +34,10 @@ export const ruAiMessages: Messages = {
|
||||
'ai.providers.skipTLSVerify': 'Пропустить проверку TLS-сертификата (для самоподписанных сертификатов)',
|
||||
'ai.providers.defaultModel': 'Модель по умолчанию',
|
||||
'ai.providers.defaultModel.placeholder': 'например, gpt-4o, claude-sonnet-4-20250514',
|
||||
'ai.providers.contextWindow': 'Контекстное окно',
|
||||
'ai.providers.contextWindow.placeholder': 'например, 128000',
|
||||
'ai.providers.contextWindow.help': 'Оставьте пустым, чтобы использовать значение из списка моделей, если оно доступно; иначе Netcatty применит безопасное значение по умолчанию.',
|
||||
'ai.providers.contextWindow.error': 'Введите положительное целое число или оставьте поле пустым.',
|
||||
'ai.providers.refreshModels': 'Обновить модели',
|
||||
'ai.providers.searchModel': 'Искать или ввести ID модели...',
|
||||
'ai.providers.filterModels': 'Фильтровать модели...',
|
||||
@@ -49,7 +53,7 @@ export const ruAiMessages: Messages = {
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': 'Использует codex + codex-acp для потоковой передачи по протоколу ACP. Здесь можно войти через ChatGPT или включить API-ключ OpenAI-совместимого провайдера и пользовательский endpoint в настройках.',
|
||||
'ai.codex.description': 'Подключение OpenAI Codex. Здесь можно войти через ChatGPT или включить API-ключ OpenAI-совместимого провайдера и пользовательский endpoint в настройках.',
|
||||
'ai.codex.detecting': 'Обнаружение...',
|
||||
'ai.codex.notFound': 'Не найден',
|
||||
'ai.codex.awaitingLogin': 'Ожидание входа',
|
||||
@@ -83,6 +87,9 @@ export const ruAiMessages: Messages = {
|
||||
'ai.claude.configDir': 'Каталог конфигурации',
|
||||
'ai.claude.configDir.placeholder': '~/.claude (пусто — по умолчанию)',
|
||||
'ai.claude.configDir.hint': 'Задаёт CLAUDE_CONFIG_DIR — укажите папку, где выполнен вход `claude` (содержит settings.json и учётные данные).',
|
||||
'ai.claude.settings': 'Файл настроек',
|
||||
'ai.claude.settings.placeholder': '~/team-settings.json (путь или встроенный {"model":"..."})',
|
||||
'ai.claude.settings.hint': 'Опционально. Путь к settings.json или встроенный JSON, передаётся в SDK как `settings`. Дополняет «Каталог конфигурации» выше и независим от него (накладывается сверху, не заменяет).',
|
||||
'ai.claude.envVars': 'Переменные окружения',
|
||||
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
|
||||
'ai.claude.envVars.hint': 'По одному KEY=VALUE в строке, передаётся агенту Claude. Хранится локально в открытом виде — для API-ключей и учётных данных используйте «Каталог конфигурации» выше (вход `claude`).',
|
||||
@@ -90,7 +97,7 @@ export const ruAiMessages: Messages = {
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
'ai.copilot.description': 'Использует GitHub Copilot CLI через ACP по stdio (`copilot --acp --stdio`). После обнаружения может быть выбран как внешний агент для программирования.',
|
||||
'ai.copilot.description': 'Использует GitHub Copilot CLI. После обнаружения может быть выбран как внешний агент для программирования.',
|
||||
'ai.copilot.detecting': 'Обнаружение...',
|
||||
'ai.copilot.detected': 'Обнаружен',
|
||||
'ai.copilot.notFound': 'Не найден',
|
||||
@@ -105,7 +112,7 @@ export const ruAiMessages: Messages = {
|
||||
'ai.defaultAgent.catty': 'Catty (встроенный)',
|
||||
'ai.toolAccess.title': 'Доступ к инструментам',
|
||||
'ai.toolAccess.mode': 'Режим доступа Netcatty',
|
||||
'ai.toolAccess.description': 'Выберите, как внешние ACP-агенты получают доступ к сессиям Netcatty. MCP предоставляет встроенный сервер, а Skills + CLI указывает агентам на локальный skill Netcatty и команды CLI.',
|
||||
'ai.toolAccess.description': 'Выберите, как внешние агенты получают доступ к сессиям Netcatty. MCP предоставляет встроенный сервер, а Skills + CLI указывает агентам на локальный skill Netcatty и команды CLI.',
|
||||
'ai.toolAccess.mode.mcp': 'MCP',
|
||||
'ai.toolAccess.mode.skills': 'Skills + CLI',
|
||||
'ai.userSkills.title': 'Пользовательские skills',
|
||||
@@ -167,6 +174,8 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.daysAgo': '{n}д назад',
|
||||
'ai.chat.newChat': 'Новый чат',
|
||||
'ai.chat.allSessions': 'Все сессии',
|
||||
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
|
||||
'ai.chat.loadMoreSessions': 'Загрузить больше сессий (ещё {n})',
|
||||
'ai.chat.noSessions': 'Предыдущих сессий нет',
|
||||
'ai.chat.retryHint': 'Вы можете повторить попытку, отправив сообщение ещё раз.',
|
||||
'ai.chat.approvalTimeout': 'Время ожидания одобрения инструмента истекло через 5 минут. Вы можете повторить попытку, отправив сообщение ещё раз.',
|
||||
@@ -198,21 +207,21 @@ export const ruAiMessages: Messages = {
|
||||
// AI Safety Settings
|
||||
'ai.safety.title': 'Безопасность',
|
||||
'ai.safety.permissionMode': 'Режим разрешений',
|
||||
'ai.safety.permissionMode.description': 'Управляет тем, как AI взаимодействует с вашими терминалами. Режим наблюдателя блокирует все операции записи через Netcatty и применяется как к встроенным, так и к ACP-агентам. Режим подтверждения носит рекомендательный характер для ACP-агентов (они управляют собственным потоком одобрения инструментов).',
|
||||
'ai.safety.permissionMode.description': 'Управляет тем, как AI взаимодействует с вашими терминалами. Режим наблюдателя блокирует все операции записи через Netcatty и применяется как к встроенным, так и к внешним агентам. Режим подтверждения носит рекомендательный характер для внешних агентов (они управляют собственным потоком одобрения инструментов).',
|
||||
'ai.safety.permissionMode.observer': 'Наблюдатель — только чтение, без действий',
|
||||
'ai.safety.permissionMode.confirm': 'Подтверждение — спрашивать перед действиями',
|
||||
'ai.safety.permissionMode.autonomous': 'Автономный — выполнять свободно',
|
||||
'ai.safety.commandTimeout': 'Тайм-аут команды',
|
||||
'ai.safety.commandTimeout.description': 'Максимальное число секунд, которое команда может выполняться до принудительного завершения. Применяется как к встроенным, так и к ACP-агентам.',
|
||||
'ai.safety.commandTimeout.description': 'Максимальное число секунд, которое команда может выполняться до принудительного завершения. Применяется как к встроенным, так и к внешним агентам.',
|
||||
'ai.safety.commandTimeout.unit': 'с',
|
||||
'ai.safety.maxIterations': 'Макс. число итераций',
|
||||
'ai.safety.maxIterations.description': 'Максимальное число циклов использования инструментов AI, чтобы предотвратить бесконтрольное выполнение. У ACP-агентов могут быть собственные внутренние лимиты итераций, имеющие приоритет.',
|
||||
'ai.safety.maxIterations.description': 'Максимальное число циклов использования инструментов AI, чтобы предотвратить бесконтрольное выполнение. У внешних агентов могут быть собственные внутренние лимиты итераций, имеющие приоритет.',
|
||||
'ai.safety.blocklist': 'Чёрный список команд',
|
||||
'ai.safety.blocklist.description': 'Regex-шаблоны для блокировки опасных команд. Применяется как к встроенным, так и к ACP-агентам через механизм выполнения Netcatty.',
|
||||
'ai.safety.blocklist.description': 'Regex-шаблоны для блокировки опасных команд. Применяется как к встроенным, так и к внешним агентам через механизм выполнения Netcatty.',
|
||||
'ai.safety.blocklist.placeholder': 'Regex-шаблон...',
|
||||
'ai.safety.blocklist.reset': 'Сбросить по умолчанию',
|
||||
'ai.safety.blocklist.add': 'Добавить шаблон',
|
||||
'ai.safety.note': 'Чёрный список команд, тайм-аут команд и режим наблюдателя применяются на уровне MCP Server ко всем типам агентов. Режим подтверждения и максимальное число итераций полностью применяются к встроенному агенту; у ACP-агентов могут быть свои внутренние механизмы управления этими настройками.',
|
||||
'ai.safety.note': 'Эти настройки безопасности применяются к действиям, выполняемым через Netcatty. Внешние CLI-агенты могут иметь собственные локальные инструменты и собственные правила управления ими.',
|
||||
|
||||
// Unified tooltips for terminal workspace and top tabs (issue #954)
|
||||
'terminal.layer.addTerminal': 'Добавить терминал',
|
||||
@@ -224,13 +233,26 @@ export const ruAiMessages: Messages = {
|
||||
'terminal.layer.movePanelLeft': 'Переместить панель влево',
|
||||
'terminal.layer.movePanelRight': 'Переместить панель вправо',
|
||||
'terminal.layer.closePanel': 'Закрыть панель',
|
||||
'terminal.layer.hostTree.search': 'Поиск хостов...',
|
||||
'terminal.layer.hostTree.searchButton': 'Поиск',
|
||||
'terminal.layer.hostTree.tagsButton': 'Фильтр по тегам',
|
||||
'terminal.layer.hostTree.newGroup': 'Новая группа',
|
||||
'terminal.layer.hostTree.localShell': 'Локальная оболочка',
|
||||
'terminal.layer.hostTree.tagsEmpty': 'Нет доступных тегов',
|
||||
'terminal.layer.hostTree.clearTags': 'Сбросить выбор',
|
||||
'terminal.layer.hostTree.collapse': 'Свернуть список хостов',
|
||||
'terminal.layer.hostTree.expand': 'Развернуть список хостов',
|
||||
'terminal.layer.hostTree.empty': 'Хосты не найдены',
|
||||
'topTabs.openQuickSwitcher': 'Открыть быстрый переключатель',
|
||||
'topTabs.moreTabs': 'Больше вкладок',
|
||||
'topTabs.aiAssistant': 'AI-помощник',
|
||||
'topTabs.windowOpacity': 'Прозрачность окна',
|
||||
'topTabs.toggleTheme': 'Переключить тему',
|
||||
'topTabs.openSettings': 'Открыть настройки',
|
||||
'ai.chat.sessionHistory': 'История сессий',
|
||||
'ai.chat.attach': 'Прикрепить',
|
||||
'ai.chat.terminalSelectionAttachment': 'Выделение терминала',
|
||||
'ai.chat.terminalSelectionLines': 'строк: {count}',
|
||||
'ai.chat.collapse': 'Свернуть',
|
||||
'ai.chat.expand': 'Развернуть',
|
||||
'ai.chat.enableAgent': 'Включить {name}',
|
||||
|
||||
@@ -159,8 +159,21 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.sessionLogs.formatTxt': 'Обычный текст (.txt)',
|
||||
'settings.sessionLogs.formatRaw': 'Сырые данные с ANSI (.log)',
|
||||
'settings.sessionLogs.formatHtml': 'HTML (.html)',
|
||||
'settings.sessionLogs.timestamps': 'Добавлять метки времени',
|
||||
'settings.sessionLogs.timestampsDesc': 'Добавлять локальное время в начало каждой строки в текстовых и HTML-журналах.',
|
||||
'settings.sessionLogs.hint': 'Журналы сессий сохраняют весь вывод терминала для диагностики и аудита.',
|
||||
|
||||
// Settings > SSH Debug Logs
|
||||
'settings.sshDebugLogs.title': 'Отладочные журналы SSH',
|
||||
'settings.sshDebugLogs.enable': 'Включить отладочные журналы SSH',
|
||||
'settings.sshDebugLogs.enableDesc': 'Записывать подключение, аутентификацию, рукопожатие, отключение и причины ошибок без вывода терминала.',
|
||||
'settings.sshDebugLogs.location': 'Расположение журнала',
|
||||
'settings.sshDebugLogs.status': 'Статус',
|
||||
'settings.sshDebugLogs.statusOn': 'Включено',
|
||||
'settings.sshDebugLogs.statusOff': 'Отключено',
|
||||
'settings.sshDebugLogs.size': 'Размер',
|
||||
'settings.sshDebugLogs.hint': 'Когда включено, новые SSH-подключения записывают диагностические события для разбора бастионов, аутентификации и неожиданных отключений.',
|
||||
|
||||
// Settings > Global Hotkey (Quake Mode)
|
||||
'settings.globalHotkey.title': 'Глобальная горячая клавиша',
|
||||
'settings.globalHotkey.toggleWindow': 'Переключение окна',
|
||||
@@ -227,6 +240,8 @@ export const ruCoreMessages: Messages = {
|
||||
'update.restartNow': 'Перезапустить сейчас',
|
||||
'update.downloadFailed.title': 'Ошибка обновления',
|
||||
'update.downloadFailed.message': 'Не удалось скачать обновление. Вы можете скачать его вручную.',
|
||||
'update.needsSave.title': 'Несохранённые изменения',
|
||||
'update.needsSave.message': 'Сначала сохраните открытые редакторы, затем снова нажмите «Перезапустить сейчас», чтобы установить обновление.',
|
||||
'update.openReleases': 'Открыть релизы',
|
||||
'update.remindLater': 'Напомнить позже',
|
||||
'update.skipVersion': 'Пропустить эту версию',
|
||||
@@ -249,14 +264,15 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': 'Палитра тёмной темы',
|
||||
'settings.appearance.customCss': 'Пользовательский CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'Добавьте пользовательский CSS, чтобы настроить внешний вид приложения. Изменения применяются сразу. Основные области интерфейса имеют атрибут [data-section="..."], который можно использовать для выбора элементов, например: snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar, top-tabs.',
|
||||
'Добавьте пользовательский CSS, чтобы настроить внешний вид приложения. Изменения применяются сразу. Основные области интерфейса имеют атрибут [data-section="..."], который можно использовать для выбора элементов, например: snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (список терминалов в режиме Focus), terminal-host-tree-sidebar, terminal-host-tree-sidebar-content, terminal-host-tree-sidebar-row, terminal-side-panel (панель SFTP/скриптов/темы/AI, доступна пока открыта), terminal-side-panel-tabs, terminal-side-panel-content, terminal-sftp-panel, terminal-sftp-host-header, terminal-sftp-pane, terminal-sftp-toolbar, terminal-sftp-path, terminal-sftp-filter-bar, terminal-sftp-list, terminal-sftp-list-header, terminal-sftp-list-row, terminal-sftp-tree, terminal-sftp-tree-row, terminal-sftp-transfer-queue, terminal-sftp-transfer-row, terminal-split-pane, terminal-split-resizer, top-tabs.',
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Сделать текст в боковой панели сниппетов крупнее */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* Пользовательский фон терминала */\n.terminal { background: #1a1a2e !important; }\n\n/* Настройка глобального радиуса скругления */\n:root { --radius: 0.25rem; }',
|
||||
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Рамка вокруг боковой панели SFTP (не остаётся после закрытия) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Изменить фон всей боковой панели, а не только верхних вкладок */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Настроить выбранные строки SFTP */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Более заметные разделители сплита */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Подсветка активной панели сплита */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
|
||||
'settings.appearance.language': 'Язык',
|
||||
'settings.appearance.language.desc': 'Выберите язык интерфейса',
|
||||
'settings.appearance.uiFont': 'Шрифт интерфейса',
|
||||
'settings.appearance.uiFont.desc': 'Выберите шрифт для интерфейса приложения',
|
||||
|
||||
'settings.appearance.windowOpacity': 'Прозрачность окна',
|
||||
'settings.appearance.windowOpacity.desc': 'Настройте прозрачность всего окна приложения. При низких значениях текст терминала тоже бледнеет. В некоторых средах Linux это может не поддерживаться.',
|
||||
// Settings > Terminal
|
||||
'settings.terminal.section.theme': 'Тема терминала',
|
||||
'settings.terminal.themeModal.title': 'Выберите тему',
|
||||
@@ -394,6 +410,9 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.localShell.shell.default': 'Системная по умолчанию',
|
||||
'settings.terminal.localShell.shell.custom': 'Пользовательская...',
|
||||
'settings.terminal.localShell.shell.customPath': 'Путь к исполняемому файлу оболочки',
|
||||
'settings.terminal.localShell.shell.customArgs': 'Аргументы запуска',
|
||||
'settings.terminal.localShell.shell.customArgs.placeholder': 'напр. --login -i',
|
||||
'settings.terminal.localShell.shell.customArgs.desc': 'Аргументы, передаваемые оболочке. Некоторым оболочкам они необходимы — например, msys2 bash требует --login -i для загрузки окружения.',
|
||||
'settings.terminal.localShell.shell.commonPaths': 'Частые пути',
|
||||
'settings.terminal.localShell.shell.pathValid': 'Путь корректен',
|
||||
'settings.terminal.localShell.startDir': 'Начальный каталог',
|
||||
@@ -421,6 +440,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.rendering.renderer': 'Рендерер',
|
||||
'settings.terminal.rendering.renderer.desc': 'Выберите технологию рендеринга терминала. В режиме "Авто" на устройствах с малым объёмом памяти будет использоваться DOM. Изменения применяются к новым терминальным сессиям.',
|
||||
'settings.terminal.rendering.auto': 'Авто',
|
||||
'settings.terminal.rendering.lineTimestamps': 'Добавлять время к выводу',
|
||||
'settings.terminal.rendering.lineTimestamps.desc': 'Вставлять локальное время перед строками вывода терминала. Метка времени становится частью видимого содержимого терминала.',
|
||||
|
||||
// Settings > Terminal > Workspace Focus Indicator
|
||||
'settings.terminal.section.workspaceFocus': 'Индикатор фокуса рабочей области',
|
||||
@@ -594,12 +615,12 @@ export const ruCoreMessages: Messages = {
|
||||
'proxyProfiles.section.proxies': 'Прокси',
|
||||
'proxyProfiles.count.items': 'Элементов: {count}',
|
||||
'proxyProfiles.empty.title': 'Нет прокси',
|
||||
'proxyProfiles.empty.desc': 'Создавайте переиспользуемые HTTP- или SOCKS5-прокси и выбирайте их в настройках хоста.',
|
||||
'proxyProfiles.empty.desc': 'Создавайте переиспользуемые HTTP-, SOCKS5- или командные прокси и выбирайте их в настройках хоста.',
|
||||
'proxyProfiles.usage': 'Связано: {count}',
|
||||
'proxyProfiles.copyName': '{name} Копия',
|
||||
'proxyProfiles.panel.newTitle': 'Новый прокси',
|
||||
'proxyProfiles.field.name': 'Имя прокси',
|
||||
'proxyProfiles.error.required': 'Имя, хост и порт обязательны.',
|
||||
'proxyProfiles.error.required': 'Имя и параметры прокси обязательны.',
|
||||
'proxyProfiles.error.port': 'Порт должен быть в диапазоне от 1 до 65535.',
|
||||
'proxyProfiles.viewMode': 'Режим просмотра прокси',
|
||||
'proxyProfiles.delete.title': 'Удалить прокси?',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruTerminalMessages: Messages = {
|
||||
'terminal.sudoHint.pressEnter': 'Нажмите Enter, чтобы вставить пароль sudo',
|
||||
// Connection logs
|
||||
'logs.table.date': 'Дата',
|
||||
'logs.table.user': 'Пользователь',
|
||||
@@ -91,6 +92,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.search.nextMatch': 'Следующее совпадение (Enter)',
|
||||
'terminal.menu.copy': 'Копировать',
|
||||
'terminal.menu.paste': 'Вставить',
|
||||
'terminal.menu.addSelectionToAI': 'Добавить в чат',
|
||||
'terminal.menu.pasteSelection': 'Вставить выделенное',
|
||||
'terminal.menu.selectAll': 'Выбрать всё',
|
||||
'terminal.menu.reconnect': 'Переподключиться',
|
||||
@@ -98,6 +100,8 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.menu.splitVertical': 'Разделить по вертикали',
|
||||
'terminal.menu.clearBuffer': 'Очистить буфер',
|
||||
'terminal.menu.closeTerminal': 'Закрыть терминал',
|
||||
'terminal.selection.addToAI': 'Добавить в чат',
|
||||
'terminal.selection.addToAIDesc': 'Прикрепить выбранный вывод терминала к черновику AI',
|
||||
'terminal.auth.password': 'Пароль',
|
||||
'terminal.auth.sshKey': 'SSH-ключ',
|
||||
'terminal.auth.username': 'Имя пользователя',
|
||||
@@ -507,6 +511,8 @@ export const ruTerminalMessages: Messages = {
|
||||
'tabs.logPrefix': 'Журнал:',
|
||||
'tabs.logLocal': 'Локальный',
|
||||
'tabs.copyTab': 'Копировать вкладку',
|
||||
'tabs.copyTabToNewWindow': 'Копировать вкладку в новое окно',
|
||||
'tabs.copyTabToNewWindowFailed': 'Не удалось открыть вкладку в новом окне',
|
||||
'tabs.closeOthers': 'Закрыть остальные',
|
||||
'tabs.closeToRight': 'Закрыть вкладки справа',
|
||||
'tabs.closeAll': 'Закрыть все',
|
||||
@@ -532,6 +538,20 @@ export const ruTerminalMessages: Messages = {
|
||||
'snippets.field.packagePlaceholder': 'Выберите или создайте пакет',
|
||||
'snippets.field.createPackage': 'Создать пакет',
|
||||
'snippets.field.scriptRequired': 'Скрипт *',
|
||||
'snippets.scriptEditor.expand': 'Открыть в окне',
|
||||
'snippets.scriptEditor.resize': 'Изменить высоту редактора',
|
||||
'snippets.scriptEditor.modalTitle': 'Редактировать скрипт',
|
||||
'snippets.variables.dialogTitle': 'Переменные сниппета',
|
||||
'snippets.variables.dialogDesc': 'Заполните значения для "{label}" перед запуском.',
|
||||
'snippets.variables.hint': 'Значения вставляются в скрипт как есть (без shell-экранирования).',
|
||||
'snippets.variables.preview': 'Предпросмотр',
|
||||
'snippets.variables.placeholder': 'Введите значение',
|
||||
'snippets.variables.placeholderDefault': 'По умолчанию: {value}',
|
||||
'snippets.variables.required': 'Эта переменная обязательна',
|
||||
'snippets.variables.run': 'Запустить',
|
||||
'snippets.field.variablesHelp': 'Используйте {{name}} или {{name:default}} для плейсхолдеров в скрипте.',
|
||||
'snippets.field.variablesDetected': 'Переменные',
|
||||
'snippets.field.variableDefault': 'по умолчанию {value}',
|
||||
'snippets.targets.title': 'Цели',
|
||||
'snippets.targets.add': 'Добавить цели',
|
||||
'snippets.history.title': 'История оболочки',
|
||||
|
||||
@@ -232,6 +232,9 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.transfers.dragToResize': 'Перетащите для изменения размера',
|
||||
'sftp.goUp': 'Наверх',
|
||||
'sftp.goToTerminalCwd': 'Перейти в каталог терминала',
|
||||
'sftp.followTerminalCwd': 'Следовать за каталогом терминала',
|
||||
'sftp.followTerminalCwd.enable': 'Включить следование за каталогом терминала',
|
||||
'sftp.followTerminalCwd.disable': 'Отключить следование за каталогом терминала',
|
||||
'sftp.encoding.label': 'Кодировка имён файлов',
|
||||
'sftp.encoding.auto': 'Авто',
|
||||
'sftp.encoding.utf8': 'UTF-8',
|
||||
@@ -310,6 +313,7 @@ export const ruVaultMessages: Messages = {
|
||||
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': 'Копировать путь к файлу',
|
||||
'sftp.context.openWithDefault': 'Открыть в системном приложении',
|
||||
'sftp.context.openWith': 'Открыть с помощью...',
|
||||
'sftp.context.edit': 'Редактировать',
|
||||
'sftp.context.preview': 'Предпросмотр',
|
||||
@@ -382,6 +386,10 @@ export const ruVaultMessages: Messages = {
|
||||
'settings.sftp.autoOpenSidebar.desc': 'Автоматически открывать боковую панель файлового браузера SFTP при подключении к хосту',
|
||||
'settings.sftp.autoOpenSidebar.enable': 'Включить автооткрытие боковой панели',
|
||||
'settings.sftp.autoOpenSidebar.enableDesc': 'Боковая панель SFTP будет автоматически открываться при подключении терминальной сессии к удалённому хосту',
|
||||
'settings.sftp.followTerminalCwd': 'Следовать за каталогом терминала',
|
||||
'settings.sftp.followTerminalCwd.desc': 'Автоматически синхронизировать боковую панель SFTP с рабочим каталогом терминала (переключатель на панели инструментов)',
|
||||
'settings.sftp.followTerminalCwd.enable': 'Включать следование по умолчанию',
|
||||
'settings.sftp.followTerminalCwd.enableDesc': 'При открытой боковой панели SFTP режим следования включён по умолчанию и обновляется после команд cd в терминале',
|
||||
|
||||
'settings.sftp.defaultViewMode': 'Режим просмотра по умолчанию',
|
||||
'settings.sftp.defaultViewMode.desc': 'Выберите режим просмотра по умолчанию при открытии новой вкладки SFTP. Настройки конкретного хоста имеют приоритет.',
|
||||
@@ -506,6 +514,7 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
|
||||
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.alinux': 'Alibaba Cloud Linux',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.distro.option.cisco': 'Cisco',
|
||||
@@ -577,12 +586,15 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.jumpHosts.hops': '{count} hop(s)',
|
||||
'hostDetails.jumpHosts.direct': 'Напрямую',
|
||||
'hostDetails.jumpHosts.configure': 'Настроить прокси-хосты',
|
||||
'hostDetails.proxy': 'Прокси через HTTP/SOCKS5',
|
||||
'hostDetails.proxy': 'Прокси через HTTP/SOCKS5/Command',
|
||||
'hostDetails.proxy.none': 'Нет',
|
||||
'hostDetails.proxy.edit': 'Редактировать прокси',
|
||||
'hostDetails.proxy.configure': 'Настроить прокси',
|
||||
'hostDetails.proxyPanel.title': 'Прокси через HTTP/SOCKS5',
|
||||
'hostDetails.proxyPanel.title': 'Прокси через HTTP/SOCKS5/Command',
|
||||
'hostDetails.proxyPanel.hostPlaceholder': 'Прокси-хост',
|
||||
'hostDetails.proxyPanel.command': 'ProxyCommand',
|
||||
'hostDetails.proxyPanel.commandPlaceholder': 'cloudflared access ssh --hostname %h',
|
||||
'hostDetails.proxyPanel.commandHelp': 'Используйте %h для целевого хоста, %p для целевого порта и %% для символа процента.',
|
||||
'hostDetails.proxyPanel.credentials': 'Учётные данные',
|
||||
'hostDetails.proxyPanel.usernamePlaceholder': 'Имя пользователя',
|
||||
'hostDetails.proxyPanel.passwordPlaceholder': 'Пароль',
|
||||
@@ -593,7 +605,7 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.proxyPanel.customProxy': 'Пользовательский прокси',
|
||||
'hostDetails.proxyPanel.missing': 'Отсутствует',
|
||||
'hostDetails.proxyPanel.missingSaved': 'Сохранённый прокси отсутствует',
|
||||
'hostDetails.proxyPanel.error.required': 'Прокси-хост и порт обязательны.',
|
||||
'hostDetails.proxyPanel.error.required': 'Требуются хост и порт прокси или ProxyCommand.',
|
||||
'hostDetails.envVars': 'Переменные окружения',
|
||||
'hostDetails.envVars.add': 'Добавить переменную окружения',
|
||||
'hostDetails.envVars.title': 'Переменные окружения',
|
||||
|
||||
@@ -34,6 +34,10 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.providers.skipTLSVerify': '跳过 TLS 证书验证(用于自签名证书)',
|
||||
'ai.providers.defaultModel': '默认模型',
|
||||
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
|
||||
'ai.providers.contextWindow': '上下文窗口',
|
||||
'ai.providers.contextWindow.placeholder': '例如 128000',
|
||||
'ai.providers.contextWindow.help': '留空时优先使用模型列表返回的值;如果没有,Netcatty 会使用安全默认值。',
|
||||
'ai.providers.contextWindow.error': '请输入正整数,或留空。',
|
||||
'ai.providers.refreshModels': '刷新模型列表',
|
||||
'ai.providers.searchModel': '搜索或输入模型 ID...',
|
||||
'ai.providers.filterModels': '筛选模型...',
|
||||
@@ -49,7 +53,7 @@ export const zhCNAiMessages: Messages = {
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': '使用 codex + codex-acp 进行 ACP 协议流式传输。可以在这里连接 ChatGPT,也可以在设置里启用兼容 OpenAI 的 API Key 和自定义接口地址。',
|
||||
'ai.codex.description': '接入 OpenAI Codex。可以在这里登录 ChatGPT,也可以在设置里启用兼容 OpenAI 的 API Key 和自定义接口地址。',
|
||||
'ai.codex.detecting': '检测中...',
|
||||
'ai.codex.notFound': '未找到',
|
||||
'ai.codex.awaitingLogin': '等待登录',
|
||||
@@ -83,6 +87,9 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.claude.configDir': '配置目录',
|
||||
'ai.claude.configDir.placeholder': '~/.claude(留空用默认)',
|
||||
'ai.claude.configDir.hint': '设置 CLAUDE_CONFIG_DIR —— 指向你已运行 `claude` 登录的目录(含 settings.json 和凭据)。',
|
||||
'ai.claude.settings': 'Settings 文件',
|
||||
'ai.claude.settings.placeholder': '~/team-settings.json(路径,或内联 {"model":"..."})',
|
||||
'ai.claude.settings.hint': '可选。settings.json 路径或内联 JSON,作为 SDK 的 `settings` 传入。与上面的「配置目录」互补且独立(叠加合并,不是替换)。',
|
||||
'ai.claude.envVars': '环境变量',
|
||||
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
|
||||
'ai.claude.envVars.hint': '每行一个 KEY=VALUE,传给 Claude agent。明文存在本地——API key/凭据建议用上面的「配置目录」(claude 登录),不要放这里。',
|
||||
@@ -90,7 +97,7 @@ export const zhCNAiMessages: Messages = {
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
'ai.copilot.description': '通过 ACP over stdio(`copilot --acp --stdio`)接入 GitHub Copilot CLI。检测到后即可作为外部编程 Agent 使用。',
|
||||
'ai.copilot.description': '接入 GitHub Copilot CLI。检测到后即可作为外部编程 Agent 使用。',
|
||||
'ai.copilot.detecting': '检测中...',
|
||||
'ai.copilot.detected': '已检测到',
|
||||
'ai.copilot.notFound': '未找到',
|
||||
@@ -105,7 +112,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.defaultAgent.catty': 'Catty(内置)',
|
||||
'ai.toolAccess.title': '工具接入',
|
||||
'ai.toolAccess.mode': 'Netcatty 接入模式',
|
||||
'ai.toolAccess.description': '选择外部 ACP Agent 访问 Netcatty 会话的方式。MCP 会暴露内置服务器,Skills + CLI 会引导 Agent 读取本地 Skill 并调用 Netcatty CLI。',
|
||||
'ai.toolAccess.description': '选择外部 Agent 访问 Netcatty 会话的方式。MCP 会暴露内置服务器,Skills + CLI 会引导 Agent 读取本地 Skill 并调用 Netcatty CLI。',
|
||||
'ai.toolAccess.mode.mcp': 'MCP',
|
||||
'ai.toolAccess.mode.skills': 'Skills + CLI',
|
||||
'ai.userSkills.title': '用户 Skills',
|
||||
@@ -167,6 +174,8 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.daysAgo': '{n}天前',
|
||||
'ai.chat.newChat': '新对话',
|
||||
'ai.chat.allSessions': '所有会话',
|
||||
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
|
||||
'ai.chat.loadMoreSessions': '加载更多会话(还有 {n} 条)',
|
||||
'ai.chat.noSessions': '没有历史会话',
|
||||
'ai.chat.retryHint': '你可以重新发送消息来重试。',
|
||||
'ai.chat.approvalTimeout': '工具审批已超时(5 分钟)。你可以重新发送消息来重试。',
|
||||
@@ -198,21 +207,21 @@ export const zhCNAiMessages: Messages = {
|
||||
// AI Safety Settings
|
||||
'ai.safety.title': '安全',
|
||||
'ai.safety.permissionMode': '权限模式',
|
||||
'ai.safety.permissionMode.description': '控制 AI 与终端的交互方式。观察者模式会通过 Netcatty 阻止所有写操作,对内置和 ACP Agent 均生效。确认模式对 ACP Agent 仅为建议性(ACP Agent 有自己的工具审批流程)。',
|
||||
'ai.safety.permissionMode.description': '控制 AI 通过 Netcatty 访问终端会话的方式。观察者模式会阻止经由 Netcatty 的写操作;外部 Agent CLI 可能仍有自己的本机工具和审批流程。',
|
||||
'ai.safety.permissionMode.observer': '观察者 - 只读,禁止操作',
|
||||
'ai.safety.permissionMode.confirm': '确认 - 操作前询问',
|
||||
'ai.safety.permissionMode.autonomous': '自主 - 自由执行',
|
||||
'ai.safety.commandTimeout': '命令超时',
|
||||
'ai.safety.commandTimeout.description': '命令执行的最大秒数,超时将被终止。对内置和 ACP Agent 均生效。',
|
||||
'ai.safety.commandTimeout.description': '通过 Netcatty 执行命令时允许运行的最长秒数,超时将被终止。',
|
||||
'ai.safety.commandTimeout.unit': '秒',
|
||||
'ai.safety.maxIterations': '最大迭代次数',
|
||||
'ai.safety.maxIterations.description': '防止 AI 失控执行的最大工具调用循环次数。ACP Agent 可能有自己的内部迭代限制,以其为准。',
|
||||
'ai.safety.maxIterations.description': '防止 AI 失控执行的最大工具调用循环次数。外部 Agent 可能有自己的内部迭代限制,以其为准。',
|
||||
'ai.safety.blocklist': '命令黑名单',
|
||||
'ai.safety.blocklist.description': '用于拦截危险命令的正则表达式。通过 Netcatty 执行层对内置和 ACP Agent 均生效。',
|
||||
'ai.safety.blocklist.description': '用于拦截通过 Netcatty 执行的危险命令的正则表达式。',
|
||||
'ai.safety.blocklist.placeholder': '正则表达式...',
|
||||
'ai.safety.blocklist.reset': '恢复默认',
|
||||
'ai.safety.blocklist.add': '添加规则',
|
||||
'ai.safety.note': '命令黑名单、命令超时和观察者模式通过 MCP Server 层强制执行,对所有 Agent 类型生效。确认模式和最大迭代次数对内置 Agent 完全强制执行;ACP Agent 可能有自己的内部控制。',
|
||||
'ai.safety.note': '这些安全设置会约束经由 Netcatty 执行的操作。外部 Agent CLI 也可能提供本机工具,那部分由 Agent 自己的控制规则约束。',
|
||||
|
||||
// 统一终端工作区和顶部标签的 tooltip 文案 (issue #954)
|
||||
'terminal.layer.addTerminal': '添加终端',
|
||||
@@ -224,13 +233,26 @@ export const zhCNAiMessages: Messages = {
|
||||
'terminal.layer.movePanelLeft': '面板移至左侧',
|
||||
'terminal.layer.movePanelRight': '面板移至右侧',
|
||||
'terminal.layer.closePanel': '关闭面板',
|
||||
'terminal.layer.hostTree.search': '搜索主机...',
|
||||
'terminal.layer.hostTree.searchButton': '搜索',
|
||||
'terminal.layer.hostTree.tagsButton': '按标签筛选',
|
||||
'terminal.layer.hostTree.newGroup': '新建分组',
|
||||
'terminal.layer.hostTree.localShell': '本地 Shell',
|
||||
'terminal.layer.hostTree.tagsEmpty': '暂无标签',
|
||||
'terminal.layer.hostTree.clearTags': '清除筛选',
|
||||
'terminal.layer.hostTree.collapse': '收起主机列表',
|
||||
'terminal.layer.hostTree.expand': '展开主机列表',
|
||||
'terminal.layer.hostTree.empty': '没有匹配的主机',
|
||||
'topTabs.openQuickSwitcher': '打开快速切换',
|
||||
'topTabs.moreTabs': '更多标签页',
|
||||
'topTabs.aiAssistant': 'AI 助手',
|
||||
'topTabs.windowOpacity': '窗口透明度',
|
||||
'topTabs.toggleTheme': '切换主题',
|
||||
'topTabs.openSettings': '打开设置',
|
||||
'ai.chat.sessionHistory': '会话历史',
|
||||
'ai.chat.attach': '附件',
|
||||
'ai.chat.terminalSelectionAttachment': '终端选区',
|
||||
'ai.chat.terminalSelectionLines': '{count} 行',
|
||||
'ai.chat.collapse': '收起',
|
||||
'ai.chat.expand': '展开',
|
||||
'ai.chat.enableAgent': '启用 {name}',
|
||||
|
||||
@@ -143,8 +143,21 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.sessionLogs.formatTxt': '纯文本 (.txt)',
|
||||
'settings.sessionLogs.formatRaw': '原始格式 (.log)',
|
||||
'settings.sessionLogs.formatHtml': 'HTML (.html)',
|
||||
'settings.sessionLogs.timestamps': '添加时间戳',
|
||||
'settings.sessionLogs.timestampsDesc': '为纯文本和 HTML 日志的每一行添加本地时间。',
|
||||
'settings.sessionLogs.hint': '会话日志用于记录终端输出,便于故障排查和审计。',
|
||||
|
||||
// Settings > SSH Debug Logs
|
||||
'settings.sshDebugLogs.title': 'SSH 调试日志',
|
||||
'settings.sshDebugLogs.enable': '启用 SSH 调试日志',
|
||||
'settings.sshDebugLogs.enableDesc': '记录连接、认证、握手、断开和错误原因,不记录终端输出。',
|
||||
'settings.sshDebugLogs.location': '日志位置',
|
||||
'settings.sshDebugLogs.status': '状态',
|
||||
'settings.sshDebugLogs.statusOn': '已开启',
|
||||
'settings.sshDebugLogs.statusOff': '未开启',
|
||||
'settings.sshDebugLogs.size': '大小',
|
||||
'settings.sshDebugLogs.hint': '开启后,新发起的 SSH 连接会写入诊断信息,方便排查堡垒机、认证和异常断开问题。',
|
||||
|
||||
// Settings > Global Hotkey (Quake Mode)
|
||||
'settings.globalHotkey.title': '全局快捷键',
|
||||
'settings.globalHotkey.toggleWindow': '切换窗口',
|
||||
@@ -211,6 +224,8 @@ export const zhCNCoreMessages: Messages = {
|
||||
'update.restartNow': '立即重启',
|
||||
'update.downloadFailed.title': '更新失败',
|
||||
'update.downloadFailed.message': '下载更新失败,可前往 GitHub 手动下载。',
|
||||
'update.needsSave.title': '有未保存内容',
|
||||
'update.needsSave.message': '请先保存已打开的编辑器,然后再次点击「立即重启」以安装更新。',
|
||||
'update.openReleases': '打开 Releases',
|
||||
'update.remindLater': '稍后提醒',
|
||||
'update.skipVersion': '跳过此版本',
|
||||
@@ -233,14 +248,15 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': '深色主题',
|
||||
'settings.appearance.customCss': '自定义 CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'使用自定义 CSS 个性化界面,修改会立即生效。主要 UI 区块都暴露了 [data-section="..."] 属性供你定位,比如:snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebar、top-tabs。',
|
||||
'使用自定义 CSS 个性化界面,修改会立即生效。主要 UI 区块都暴露了 [data-section="..."] 属性供你定位,比如:snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebar(Focus 模式终端列表)、terminal-host-tree-sidebar、terminal-host-tree-sidebar-content、terminal-host-tree-sidebar-row、terminal-side-panel(SFTP/脚本/主题/AI 侧栏,打开时生效)、terminal-side-panel-tabs、terminal-side-panel-content、terminal-sftp-panel、terminal-sftp-host-header、terminal-sftp-pane、terminal-sftp-toolbar、terminal-sftp-path、terminal-sftp-filter-bar、terminal-sftp-list、terminal-sftp-list-header、terminal-sftp-list-row、terminal-sftp-tree、terminal-sftp-tree-row、terminal-sftp-transfer-queue、terminal-sftp-transfer-row、terminal-split-pane、terminal-split-resizer、top-tabs。',
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 放大代码片段侧边栏字号 */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* 自定义终端背景色 */\n.terminal { background: #1a1a2e !important; }\n\n/* 调整全局圆角 */\n:root { --radius: 0.25rem; }',
|
||||
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* SFTP / 操作侧栏边框(关闭侧栏后不会残留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整个操作侧栏背景,而不只是顶部标签 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改选中的 SFTP 文件行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
|
||||
'settings.appearance.language': '语言',
|
||||
'settings.appearance.language.desc': '选择界面语言',
|
||||
'settings.appearance.uiFont': '界面字体',
|
||||
'settings.appearance.uiFont.desc': '选择软件界面使用的字体',
|
||||
|
||||
'settings.appearance.windowOpacity': '窗口透明度',
|
||||
'settings.appearance.windowOpacity.desc': '调节整个应用窗口的透明度,方便叠在其他内容上方。较低时终端文字也会变淡;部分 Linux 桌面环境可能不支持。',
|
||||
// Context menus / common actions
|
||||
'action.newHost': '新建主机',
|
||||
'action.newSubfolder': '新建文件夹',
|
||||
@@ -336,12 +352,12 @@ export const zhCNCoreMessages: Messages = {
|
||||
'proxyProfiles.section.proxies': '代理',
|
||||
'proxyProfiles.count.items': '{count} 项',
|
||||
'proxyProfiles.empty.title': '暂无代理',
|
||||
'proxyProfiles.empty.desc': '创建可复用的 HTTP 或 SOCKS5 代理,然后在主机详情里选择。',
|
||||
'proxyProfiles.empty.desc': '创建可复用的 HTTP、SOCKS5 或命令代理,然后在主机详情里选择。',
|
||||
'proxyProfiles.usage': '已关联 {count} 处',
|
||||
'proxyProfiles.copyName': '{name} 副本',
|
||||
'proxyProfiles.panel.newTitle': '新建代理',
|
||||
'proxyProfiles.field.name': '代理名称',
|
||||
'proxyProfiles.error.required': '名称、主机和端口不能为空。',
|
||||
'proxyProfiles.error.required': '名称和代理详情不能为空。',
|
||||
'proxyProfiles.error.port': '端口必须在 1 到 65535 之间。',
|
||||
'proxyProfiles.viewMode': '代理显示方式',
|
||||
'proxyProfiles.delete.title': '删除代理?',
|
||||
@@ -352,6 +368,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.groups.hostsCount': '{count} 台主机',
|
||||
'vault.groups.newSubgroup': '新建子分组',
|
||||
'vault.groups.rename': '重命名分组',
|
||||
'vault.groups.unnamed': '未命名分组',
|
||||
'vault.groups.delete': '删除分组',
|
||||
'vault.groups.createSubfolder': '创建子分组',
|
||||
'vault.groups.createRoot': '创建根分组',
|
||||
@@ -598,6 +615,9 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.transfers.dragToResize': '拖拽调整高度',
|
||||
'sftp.goUp': '上一级',
|
||||
'sftp.goToTerminalCwd': '定位到终端当前目录',
|
||||
'sftp.followTerminalCwd': '追随终端目录',
|
||||
'sftp.followTerminalCwd.enable': '开启追随终端目录',
|
||||
'sftp.followTerminalCwd.disable': '关闭追随终端目录',
|
||||
'sftp.encoding.label': '文件名编码',
|
||||
'sftp.encoding.auto': '自动',
|
||||
'sftp.encoding.utf8': 'UTF-8',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNTerminalMessages: Messages = {
|
||||
'terminal.sudoHint.pressEnter': '按 Enter 粘贴 sudo 密码',
|
||||
'terminal.connection.protocol.et': 'EternalTerminal',
|
||||
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支持 Netcatty 的代理设置。请改用 SSH,或移除该主机的代理。',
|
||||
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支持一个跳板机。',
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': '复制文件路径',
|
||||
'sftp.context.openWith': '打开方式...',
|
||||
@@ -76,6 +80,11 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.sftp.autoOpenSidebar.enable': '启用自动打开侧栏',
|
||||
'settings.sftp.autoOpenSidebar.enableDesc': '当终端会话连接到远程主机时,SFTP 侧栏将自动打开',
|
||||
|
||||
'settings.sftp.followTerminalCwd': '追随终端目录',
|
||||
'settings.sftp.followTerminalCwd.desc': '在侧栏 SFTP 中自动跟随终端当前工作目录变化(可在工具栏切换)',
|
||||
'settings.sftp.followTerminalCwd.enable': '默认开启追随终端目录',
|
||||
'settings.sftp.followTerminalCwd.enableDesc': '打开侧栏 SFTP 时默认启用追随模式,终端执行 cd 后文件浏览器会自动跳转',
|
||||
|
||||
'settings.sftp.defaultViewMode': '默认视图模式',
|
||||
'settings.sftp.defaultViewMode.desc': '选择打开新 SFTP 标签页时的默认视图模式。每个主机的偏好设置会覆盖此全局设置。',
|
||||
'settings.sftp.defaultViewMode.list': '列表视图',
|
||||
@@ -253,6 +262,9 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.localShell.shell.default': '系统默认',
|
||||
'settings.terminal.localShell.shell.custom': '自定义...',
|
||||
'settings.terminal.localShell.shell.customPath': 'Shell 可执行文件路径',
|
||||
'settings.terminal.localShell.shell.customArgs': '启动参数',
|
||||
'settings.terminal.localShell.shell.customArgs.placeholder': '例如 --login -i',
|
||||
'settings.terminal.localShell.shell.customArgs.desc': '传给 Shell 的启动参数。部分 Shell 必须指定才能正常工作,例如 msys2 bash 需要 --login -i 才能加载环境变量。',
|
||||
'settings.terminal.localShell.shell.commonPaths': '常用路径',
|
||||
'settings.terminal.localShell.shell.pathValid': '路径有效',
|
||||
'settings.terminal.localShell.startDir': '起始目录',
|
||||
@@ -280,6 +292,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.rendering.renderer': '渲染器',
|
||||
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 DOM 渲染。更改将在新终端会话中生效。',
|
||||
'settings.terminal.rendering.auto': '自动',
|
||||
'settings.terminal.rendering.lineTimestamps': '给输出加时间戳',
|
||||
'settings.terminal.rendering.lineTimestamps.desc': '在终端输出行前插入本地时间,时间戳会成为终端可见内容的一部分。',
|
||||
|
||||
// Settings > Terminal > Autocomplete
|
||||
'settings.terminal.section.autocomplete': '自动补全',
|
||||
@@ -340,8 +354,11 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.shortcuts.binding.sftp-new-folder': '新建文件夹',
|
||||
|
||||
// Host Details (sub-panels)
|
||||
'hostDetails.proxyPanel.title': '通过 HTTP/SOCKS5 代理',
|
||||
'hostDetails.proxyPanel.title': '通过 HTTP/SOCKS5/命令代理',
|
||||
'hostDetails.proxyPanel.hostPlaceholder': '代理主机',
|
||||
'hostDetails.proxyPanel.command': 'ProxyCommand',
|
||||
'hostDetails.proxyPanel.commandPlaceholder': 'cloudflared access ssh --hostname %h',
|
||||
'hostDetails.proxyPanel.commandHelp': '使用 %h 表示目标主机,%p 表示目标端口,%% 表示字面百分号。',
|
||||
'hostDetails.proxyPanel.credentials': '凭据',
|
||||
'hostDetails.proxyPanel.usernamePlaceholder': '用户名',
|
||||
'hostDetails.proxyPanel.passwordPlaceholder': '密码',
|
||||
@@ -352,7 +369,7 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'hostDetails.proxyPanel.customProxy': '自定义代理',
|
||||
'hostDetails.proxyPanel.missing': '缺失',
|
||||
'hostDetails.proxyPanel.missingSaved': '保存的代理不存在',
|
||||
'hostDetails.proxyPanel.error.required': '代理主机和端口不能为空。',
|
||||
'hostDetails.proxyPanel.error.required': '代理主机和端口,或 ProxyCommand 不能为空。',
|
||||
'hostDetails.envVars.title': '环境变量',
|
||||
'hostDetails.envVars.desc': '为 {host} 设置环境变量。',
|
||||
'hostDetails.envVars.note': '部分 SSH 服务器默认只允许以 LC_ 和 LANG_ 为前缀的变量。',
|
||||
@@ -470,6 +487,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'tabs.logPrefix': '日志:',
|
||||
'tabs.logLocal': '本地',
|
||||
'tabs.copyTab': '复制标签页',
|
||||
'tabs.copyTabToNewWindow': '复制标签页到新窗口',
|
||||
'tabs.copyTabToNewWindowFailed': '无法在新窗口打开标签页',
|
||||
'tabs.closeOthers': '关闭其他标签',
|
||||
'tabs.closeToRight': '关闭右侧标签',
|
||||
'tabs.closeAll': '关闭所有标签',
|
||||
@@ -495,6 +514,9 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'snippets.field.packagePlaceholder': '选择或创建代码包',
|
||||
'snippets.field.createPackage': '创建代码包',
|
||||
'snippets.field.scriptRequired': '脚本 *',
|
||||
'snippets.scriptEditor.expand': '弹窗编辑',
|
||||
'snippets.scriptEditor.resize': '调整编辑器高度',
|
||||
'snippets.scriptEditor.modalTitle': '编辑脚本',
|
||||
'snippets.targets.title': '目标主机',
|
||||
'snippets.targets.add': '添加目标主机',
|
||||
'snippets.history.title': 'Shell 历史',
|
||||
|
||||
@@ -65,6 +65,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
|
||||
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.alinux': '阿里云 Linux',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.distro.option.cisco': '思科',
|
||||
@@ -77,6 +78,9 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.distro.option.zyxel': '合勤',
|
||||
'hostDetails.distro.option.ruijie': '锐捷',
|
||||
'hostDetails.section.mosh': 'Mosh',
|
||||
'hostDetails.section.et': 'EternalTerminal',
|
||||
'hostDetails.et.port': 'ET 服务端口',
|
||||
'hostDetails.et.port.desc': 'etserver 监听端口(默认 2022)',
|
||||
'hostDetails.username.placeholder': '用户名',
|
||||
'hostDetails.password.placeholder': '密码',
|
||||
'hostDetails.password.show': '显示密码',
|
||||
@@ -136,7 +140,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.jumpHosts.hops': '{count} 跳',
|
||||
'hostDetails.jumpHosts.direct': '直连',
|
||||
'hostDetails.jumpHosts.configure': '配置代理主机',
|
||||
'hostDetails.proxy': '通过 HTTP/SOCKS5 代理',
|
||||
'hostDetails.proxy': '通过 HTTP/SOCKS5/命令代理',
|
||||
'hostDetails.proxy.none': '无',
|
||||
'hostDetails.proxy.edit': '编辑代理',
|
||||
'hostDetails.proxy.configure': '配置代理',
|
||||
@@ -275,6 +279,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.search.nextMatch': '下一个匹配 (Enter)',
|
||||
'terminal.menu.copy': '复制',
|
||||
'terminal.menu.paste': '粘贴',
|
||||
'terminal.menu.addSelectionToAI': '添加到对话',
|
||||
'terminal.menu.pasteSelection': '粘贴选中文本',
|
||||
'terminal.menu.selectAll': '全选',
|
||||
'terminal.menu.reconnect': '重新连接',
|
||||
@@ -282,6 +287,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.menu.splitVertical': '垂直分屏',
|
||||
'terminal.menu.clearBuffer': '清空缓冲区',
|
||||
'terminal.menu.closeTerminal': '关闭终端',
|
||||
'terminal.selection.addToAI': '添加到对话',
|
||||
'terminal.selection.addToAIDesc': '将选中的终端输出作为附件加入 AI 草稿',
|
||||
'terminal.auth.password': '密码',
|
||||
'terminal.auth.sshKey': 'SSH Key',
|
||||
'terminal.auth.username': '用户名',
|
||||
@@ -599,6 +606,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'common.generate': '生成',
|
||||
'common.delete': '删除',
|
||||
'common.edit': '编辑',
|
||||
'sftp.context.openWithDefault': '系统默认程序打开',
|
||||
'common.clear': '清除',
|
||||
'common.optional': '可选',
|
||||
'common.selectPlaceholder': '请选择...',
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
|
||||
import { terminalLayoutSuppressStore } from './terminalLayoutSuppressStore';
|
||||
|
||||
// Simple store for active tab that allows fine-grained subscriptions
|
||||
type Listener = () => void;
|
||||
|
||||
@@ -18,19 +20,35 @@ export const fromEditorTabId = (tabId: string): string => tabId.slice(EDITOR_PRE
|
||||
class ActiveTabStore {
|
||||
private activeTabId: string = 'vault';
|
||||
private listeners = new Set<Listener>();
|
||||
private pendingNotify = false;
|
||||
private notifyRafId: number | null = null;
|
||||
|
||||
getActiveTabId = () => this.activeTabId;
|
||||
|
||||
private scheduleNotify = () => {
|
||||
if (this.notifyRafId !== null) return;
|
||||
const schedule = typeof requestAnimationFrame === 'function'
|
||||
? requestAnimationFrame
|
||||
: (cb: () => void) => window.setTimeout(cb, 0) as unknown as number;
|
||||
this.notifyRafId = schedule(() => {
|
||||
this.notifyRafId = null;
|
||||
this.listeners.forEach((listener) => listener());
|
||||
});
|
||||
};
|
||||
|
||||
setActiveTabId = (id: string) => {
|
||||
if (this.activeTabId !== id) {
|
||||
terminalLayoutSuppressStore.begin();
|
||||
this.activeTabId = id;
|
||||
// Defer listener notification to avoid "setState during render" if called from a render phase
|
||||
if (this.pendingNotify) return;
|
||||
this.pendingNotify = true;
|
||||
Promise.resolve().then(() => {
|
||||
this.pendingNotify = false;
|
||||
this.listeners.forEach(listener => listener());
|
||||
// Coalesce rapid tab switches into one notification per frame and avoid
|
||||
// "setState during render" if called from a render phase.
|
||||
this.scheduleNotify();
|
||||
const schedule = typeof requestAnimationFrame === 'function'
|
||||
? requestAnimationFrame
|
||||
: (cb: () => void) => window.setTimeout(cb, 0) as unknown as number;
|
||||
schedule(() => {
|
||||
schedule(() => {
|
||||
terminalLayoutSuppressStore.end();
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -47,7 +65,8 @@ export const activeTabStore = new ActiveTabStore();
|
||||
export const useActiveTabId = () => {
|
||||
return useSyncExternalStore(
|
||||
activeTabStore.subscribe,
|
||||
activeTabStore.getActiveTabId
|
||||
activeTabStore.getActiveTabId,
|
||||
activeTabStore.getActiveTabId,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -59,7 +78,7 @@ export const useSetActiveTabId = () => {
|
||||
// Check if a specific tab is active - only re-renders when this specific tab's active state changes
|
||||
export const useIsTabActive = (tabId: string) => {
|
||||
const getSnapshot = useCallback(() => activeTabStore.getActiveTabId() === tabId, [tabId]);
|
||||
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot);
|
||||
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot, getSnapshot);
|
||||
};
|
||||
|
||||
// Stable snapshot functions - defined once outside components
|
||||
@@ -70,7 +89,8 @@ const getIsSftpActive = () => activeTabStore.getActiveTabId() === 'sftp';
|
||||
export const useIsVaultActive = () => {
|
||||
return useSyncExternalStore(
|
||||
activeTabStore.subscribe,
|
||||
getIsVaultActive
|
||||
getIsVaultActive,
|
||||
getIsVaultActive,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -78,7 +98,8 @@ export const useIsVaultActive = () => {
|
||||
export const useIsSftpActive = () => {
|
||||
return useSyncExternalStore(
|
||||
activeTabStore.subscribe,
|
||||
getIsSftpActive
|
||||
getIsSftpActive,
|
||||
getIsSftpActive,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -86,7 +107,7 @@ export const useIsSftpActive = () => {
|
||||
export const useIsEditorTabActive = (tabId: string): boolean => {
|
||||
const editorTopId = toEditorTabId(tabId);
|
||||
const getSnapshot = useCallback(() => activeTabStore.getActiveTabId() === editorTopId, [editorTopId]);
|
||||
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot);
|
||||
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot, getSnapshot);
|
||||
};
|
||||
|
||||
// Check if terminal layer should be visible
|
||||
@@ -98,5 +119,5 @@ export const useIsTerminalLayerVisible = (draggingSessionId: string | null) => {
|
||||
return isTerminalTab || !!draggingSessionId;
|
||||
}, [draggingSessionId]);
|
||||
|
||||
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot);
|
||||
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot, getSnapshot);
|
||||
};
|
||||
|
||||
@@ -65,7 +65,7 @@ test("pruneInactiveScopedTransientState removes closed workspace and terminal sc
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneInactiveScopedSessions preserves restorable terminal ACP ids across reconnects", () => {
|
||||
test("pruneInactiveScopedSessions preserves restorable terminal external session ids across reconnects", () => {
|
||||
const sessions = [
|
||||
createSession("terminal-restorable", {
|
||||
type: "terminal",
|
||||
@@ -131,7 +131,7 @@ test("pruneInactiveScopedSessions treats sessions displayed elsewhere as in-use,
|
||||
// terminal-restorable's original scope (terminal-closed-A) is gone, but
|
||||
// the user resumed it into terminal-open-B from history. The session's
|
||||
// externalSessionId must be preserved and it must not appear in the
|
||||
// orphaned list, otherwise the active chat loses ACP continuity.
|
||||
// orphaned list, otherwise the active chat loses external agent continuity.
|
||||
const resumedElsewhere = createSession("terminal-restorable", {
|
||||
type: "terminal",
|
||||
targetId: "terminal-closed-A",
|
||||
|
||||
@@ -23,7 +23,7 @@ import { emitAIStateChanged } from './aiStateEvents';
|
||||
|
||||
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
|
||||
export interface AIBridge {
|
||||
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
|
||||
aiSdkAgentCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
|
||||
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
|
||||
aiMcpSetToolIntegrationMode?: (mode: AIToolIntegrationMode) => Promise<unknown> | unknown;
|
||||
aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown;
|
||||
@@ -42,11 +42,11 @@ export const AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE = 'netcatty:ai-panel-view-by-s
|
||||
export type DraftsByScope = Partial<Record<string, AIDraft>>;
|
||||
export type PanelViewByScope = Partial<Record<string, AIPanelView>>;
|
||||
|
||||
export function cleanupAcpSessions(sessionIds: string[]) {
|
||||
export function cleanupSdkAgentSessions(sessionIds: string[]) {
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiAcpCleanup || sessionIds.length === 0) return;
|
||||
if (!bridge?.aiSdkAgentCleanup || sessionIds.length === 0) return;
|
||||
for (const sessionId of sessionIds) {
|
||||
void bridge.aiAcpCleanup(sessionId).catch(() => {});
|
||||
void bridge.aiSdkAgentCleanup(sessionId).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
);
|
||||
|
||||
if (nextSessionCleanup.orphanedSessionIds.length > 0) {
|
||||
cleanupAcpSessions(nextSessionCleanup.orphanedSessionIds);
|
||||
cleanupSdkAgentSessions(nextSessionCleanup.orphanedSessionIds);
|
||||
}
|
||||
|
||||
if (nextSessionCleanup.sessions !== currentSessions) {
|
||||
|
||||
@@ -238,9 +238,9 @@ export const editorTabStore = new EditorTabStore();
|
||||
const getTabsSnapshot = () => editorTabStore.getTabs();
|
||||
|
||||
export const useEditorTabs = (): readonly EditorTab[] =>
|
||||
useSyncExternalStore(editorTabStore.subscribe, getTabsSnapshot);
|
||||
useSyncExternalStore(editorTabStore.subscribe, getTabsSnapshot, getTabsSnapshot);
|
||||
|
||||
export const useEditorTab = (id: EditorTabId): EditorTab | undefined => {
|
||||
const getSnapshot = useCallback(() => editorTabStore.getTab(id), [id]);
|
||||
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
|
||||
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot, getSnapshot);
|
||||
};
|
||||
|
||||
36
application/state/hostTreeInlineGroupDeleteStore.ts
Normal file
36
application/state/hostTreeInlineGroupDeleteStore.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
class HostTreeInlineGroupDeleteStore {
|
||||
private targetPath: string | null = null;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getTargetPath = () => this.targetPath;
|
||||
|
||||
open = (groupPath: string) => {
|
||||
this.targetPath = groupPath;
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
close = () => {
|
||||
if (!this.targetPath) return;
|
||||
this.targetPath = null;
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
subscribe = (listener: Listener) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export const hostTreeInlineGroupDeleteStore = new HostTreeInlineGroupDeleteStore();
|
||||
|
||||
export const useHostTreeInlineGroupDeleteTarget = () => {
|
||||
return useSyncExternalStore(
|
||||
hostTreeInlineGroupDeleteStore.subscribe,
|
||||
hostTreeInlineGroupDeleteStore.getTargetPath,
|
||||
hostTreeInlineGroupDeleteStore.getTargetPath,
|
||||
);
|
||||
};
|
||||
52
application/state/hostTreeInlineGroupEditStore.ts
Normal file
52
application/state/hostTreeInlineGroupEditStore.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
export type HostTreeInlineGroupEdit = {
|
||||
groupPath: string;
|
||||
initialName: string;
|
||||
isNew: boolean;
|
||||
shouldScrollIntoView?: boolean;
|
||||
};
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
class HostTreeInlineGroupEditStore {
|
||||
private edit: HostTreeInlineGroupEdit | null = null;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getEdit = () => this.edit;
|
||||
|
||||
startEdit = (edit: HostTreeInlineGroupEdit) => {
|
||||
this.edit = {
|
||||
...edit,
|
||||
shouldScrollIntoView: edit.isNew ? true : edit.shouldScrollIntoView,
|
||||
};
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
markScrollHandled = () => {
|
||||
if (!this.edit?.shouldScrollIntoView) return;
|
||||
this.edit = { ...this.edit, shouldScrollIntoView: false };
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
clear = () => {
|
||||
if (!this.edit) return;
|
||||
this.edit = null;
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
subscribe = (listener: Listener) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export const hostTreeInlineGroupEditStore = new HostTreeInlineGroupEditStore();
|
||||
|
||||
export const useHostTreeInlineGroupEdit = () => {
|
||||
return useSyncExternalStore(
|
||||
hostTreeInlineGroupEditStore.subscribe,
|
||||
hostTreeInlineGroupEditStore.getEdit,
|
||||
hostTreeInlineGroupEditStore.getEdit,
|
||||
);
|
||||
};
|
||||
32
application/state/immersiveStore.ts
Normal file
32
application/state/immersiveStore.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
/**
|
||||
* Tiny external store for "immersive mode active" (whether the active terminal
|
||||
* tab's theme is driving the app chrome). Kept out of the App component's render
|
||||
* so that toggling immersive — and tab switches in general — do not force a
|
||||
* full App re-render. The owner (AppActiveTabChrome) calls setImmersiveActive;
|
||||
* AppView/TopTabs read it via useImmersiveActive without re-rendering App.
|
||||
*/
|
||||
type Listener = () => void;
|
||||
|
||||
let immersiveActive = false;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
export function setImmersiveActive(active: boolean): void {
|
||||
if (immersiveActive === active) return;
|
||||
immersiveActive = active;
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function subscribe(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function getSnapshot(): boolean {
|
||||
return immersiveActive;
|
||||
}
|
||||
|
||||
export function useImmersiveActive(): boolean {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
20
application/state/resolveAiSidePanelToggleIntent.test.ts
Normal file
20
application/state/resolveAiSidePanelToggleIntent.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveAiSidePanelToggleIntent } from "./resolveAiSidePanelToggleIntent.ts";
|
||||
|
||||
test("close: AI panel already open → close the side panel", () => {
|
||||
const r = resolveAiSidePanelToggleIntent("ai");
|
||||
assert.deepEqual(r, { kind: "closeTerminalSidePanel" });
|
||||
});
|
||||
|
||||
test("open: no panel open → open AI", () => {
|
||||
const r = resolveAiSidePanelToggleIntent(null);
|
||||
assert.deepEqual(r, { kind: "openAi" });
|
||||
});
|
||||
|
||||
test("open: a different sub-panel is open → switch to AI", () => {
|
||||
assert.deepEqual(resolveAiSidePanelToggleIntent("sftp"), { kind: "openAi" });
|
||||
assert.deepEqual(resolveAiSidePanelToggleIntent("scripts"), { kind: "openAi" });
|
||||
assert.deepEqual(resolveAiSidePanelToggleIntent("theme"), { kind: "openAi" });
|
||||
});
|
||||
19
application/state/resolveAiSidePanelToggleIntent.ts
Normal file
19
application/state/resolveAiSidePanelToggleIntent.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export type AiSidePanelToggleIntent =
|
||||
| { kind: 'closeTerminalSidePanel' }
|
||||
| { kind: 'openAi' };
|
||||
|
||||
/**
|
||||
* Decide what the top-bar AI button should do given the side panel that is
|
||||
* currently open for the active tab.
|
||||
* - If the AI panel is already the open sub-panel → close the whole side panel.
|
||||
* - Otherwise (closed, or showing a different sub-panel) → switch to AI.
|
||||
*/
|
||||
export function resolveAiSidePanelToggleIntent(
|
||||
activePanel: string | null,
|
||||
): AiSidePanelToggleIntent {
|
||||
if (activePanel === 'ai') {
|
||||
return { kind: 'closeTerminalSidePanel' };
|
||||
}
|
||||
|
||||
return { kind: 'openAi' };
|
||||
}
|
||||
@@ -74,5 +74,6 @@ export const useSessionActivityMap = () => {
|
||||
return useSyncExternalStore(
|
||||
sessionActivityStore.subscribe,
|
||||
sessionActivityStore.getSnapshot,
|
||||
sessionActivityStore.getSnapshot,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -84,6 +84,7 @@ export const createHostTerminalSession = (
|
||||
protocol: host.protocol,
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
etEnabled: host.etEnabled,
|
||||
charset: host.charset,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -15,7 +15,10 @@ import {
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
@@ -31,9 +34,14 @@ import {
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { isValidUiFontId, migrateIncomingTerminalFontId } from './settingsStateDefaults';
|
||||
import {
|
||||
clampWindowOpacity,
|
||||
isValidUiFontId,
|
||||
migrateIncomingTerminalFontId,
|
||||
} from './settingsStateDefaults';
|
||||
|
||||
interface UseSettingsIpcSyncParams {
|
||||
syncAppearanceFromStorage: () => void;
|
||||
@@ -51,12 +59,16 @@ interface UseSettingsIpcSyncParams {
|
||||
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsDir: Dispatch<SetStateAction<string>>;
|
||||
setSessionLogsFormat: Dispatch<SetStateAction<SessionLogFormat>>;
|
||||
setSessionLogsTimestampsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setHotkeyScheme: Dispatch<SetStateAction<HotkeyScheme>>;
|
||||
applyIncomingCustomKeyBindings: (incoming: { bindings: CustomKeyBindings; version: number; origin: string }) => void;
|
||||
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
|
||||
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWindowOpacity: Dispatch<SetStateAction<number>>;
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
|
||||
@@ -78,12 +90,16 @@ export function useSettingsIpcSync({
|
||||
setSessionLogsEnabled,
|
||||
setSessionLogsDir,
|
||||
setSessionLogsFormat,
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDebugLogsEnabled,
|
||||
setHotkeyScheme,
|
||||
applyIncomingCustomKeyBindings,
|
||||
setIsHotkeyRecordingState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setAutoUpdateEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState,
|
||||
@@ -164,6 +180,12 @@ export function useSettingsIpcSync({
|
||||
) {
|
||||
setSessionLogsFormat((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED && typeof value === 'boolean') {
|
||||
setSessionLogsTimestampsEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED && typeof value === 'boolean') {
|
||||
setSshDebugLogsEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
|
||||
setHotkeyScheme(value);
|
||||
}
|
||||
@@ -179,12 +201,19 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && typeof value === 'boolean') {
|
||||
setGlobalHotkeyEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_WINDOW_OPACITY && (typeof value === 'number' || typeof value === 'string')) {
|
||||
const nextOpacity = clampWindowOpacity(value);
|
||||
setWindowOpacity((prev) => (prev === nextOpacity ? prev : nextOpacity));
|
||||
}
|
||||
if (key === STORAGE_KEY_AUTO_UPDATE_ENABLED && typeof value === 'boolean') {
|
||||
setAutoUpdateEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
|
||||
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD && typeof value === 'boolean') {
|
||||
setSftpFollowTerminalCwd((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE && typeof value === 'string') {
|
||||
if (value === 'list' || value === 'tree') {
|
||||
setSftpDefaultViewMode((prev) => (prev === value ? prev : value));
|
||||
@@ -211,12 +240,16 @@ export function useSettingsIpcSync({
|
||||
setEditorWordWrapState,
|
||||
setFollowAppTerminalThemeState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setHotkeyScheme,
|
||||
setIsHotkeyRecordingState,
|
||||
setSessionLogsDir,
|
||||
setSessionLogsEnabled,
|
||||
setSessionLogsFormat,
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDebugLogsEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setSftpTransferConcurrencyState,
|
||||
setTerminalFontFamilyId,
|
||||
|
||||
@@ -8,6 +8,12 @@ import { localStorageAdapter } from '../../infrastructure/persistence/localStora
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export const DEFAULT_THEME: 'light' | 'dark' | 'system' = 'dark';
|
||||
export const DEFAULT_WINDOW_OPACITY = 1;
|
||||
export function clampWindowOpacity(opacity: unknown): number {
|
||||
const value = Number(opacity);
|
||||
if (!Number.isFinite(value)) return DEFAULT_WINDOW_OPACITY;
|
||||
return Math.min(1, Math.max(0.5, value));
|
||||
}
|
||||
|
||||
/** Resolve the current OS color scheme preference. */
|
||||
export const getSystemPreference = (): 'light' | 'dark' =>
|
||||
@@ -52,6 +58,7 @@ export const DEFAULT_SFTP_AUTO_SYNC = false;
|
||||
export const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
|
||||
export const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
|
||||
export const DEFAULT_SFTP_AUTO_OPEN_SIDEBAR = false;
|
||||
export const DEFAULT_SFTP_FOLLOW_TERMINAL_CWD = false;
|
||||
export const DEFAULT_SFTP_DEFAULT_VIEW_MODE: 'list' | 'tree' = 'list';
|
||||
export const DEFAULT_SHOW_RECENT_HOSTS = true;
|
||||
export const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
|
||||
@@ -63,6 +70,8 @@ export const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
// Session Logs defaults
|
||||
export const DEFAULT_SESSION_LOGS_ENABLED = false;
|
||||
export const DEFAULT_SESSION_LOGS_FORMAT: SessionLogFormat = 'txt';
|
||||
export const DEFAULT_SESSION_LOGS_TIMESTAMPS_ENABLED = false;
|
||||
export const DEFAULT_SSH_DEBUG_LOGS_ENABLED = false;
|
||||
|
||||
export const readStoredString = (key: string): string | null => {
|
||||
const raw = localStorageAdapter.readString(key);
|
||||
@@ -155,4 +164,3 @@ export const applyThemeTokens = (
|
||||
netcattyBridge.get()?.setTheme?.(themeSource);
|
||||
netcattyBridge.get()?.setBackgroundColor?.(tokens.background);
|
||||
};
|
||||
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
|
||||
@@ -37,8 +40,10 @@ import {
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import {
|
||||
clampWindowOpacity,
|
||||
isValidHslToken,
|
||||
isValidTheme,
|
||||
isValidUiFontId,
|
||||
@@ -65,6 +70,7 @@ interface UseSettingsStorageSyncParams {
|
||||
sftpShowHiddenFiles: boolean;
|
||||
sftpUseCompressedUpload: boolean;
|
||||
sftpAutoOpenSidebar: boolean;
|
||||
sftpFollowTerminalCwd: boolean;
|
||||
sftpDefaultViewMode: 'list' | 'tree';
|
||||
showRecentHosts: boolean;
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
@@ -73,8 +79,11 @@ interface UseSettingsStorageSyncParams {
|
||||
sessionLogsEnabled: boolean;
|
||||
sessionLogsDir: string;
|
||||
sessionLogsFormat: SessionLogFormat;
|
||||
sessionLogsTimestampsEnabled: boolean;
|
||||
sshDebugLogsEnabled: boolean;
|
||||
globalHotkeyEnabled: boolean;
|
||||
autoUpdateEnabled: boolean;
|
||||
windowOpacity: number;
|
||||
setTheme: Dispatch<SetStateAction<'dark' | 'light' | 'system'>>;
|
||||
setLightUiThemeId: Dispatch<SetStateAction<string>>;
|
||||
setDarkUiThemeId: Dispatch<SetStateAction<string>>;
|
||||
@@ -95,6 +104,7 @@ interface UseSettingsStorageSyncParams {
|
||||
setSftpShowHiddenFiles: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpUseCompressedUpload: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||
setShowRecentHostsState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -103,7 +113,10 @@ interface UseSettingsStorageSyncParams {
|
||||
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsDir: Dispatch<SetStateAction<string>>;
|
||||
setSessionLogsFormat: Dispatch<SetStateAction<SessionLogFormat>>;
|
||||
setSessionLogsTimestampsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWindowOpacity: Dispatch<SetStateAction<number>>;
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
|
||||
@@ -116,19 +129,19 @@ export function useSettingsStorageSync({
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpDefaultViewMode,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat,
|
||||
setGlobalHotkeyEnabled, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
}: UseSettingsStorageSyncParams) {
|
||||
// Fix 4: Keep a ref snapshot of current settings so the storage event handler
|
||||
@@ -139,20 +152,20 @@ export function useSettingsStorageSync({
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
});
|
||||
settingsSnapshotRef.current = {
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
};
|
||||
|
||||
// Listen for storage changes from other windows (cross-window sync)
|
||||
@@ -302,6 +315,18 @@ export function useSettingsStorageSync({
|
||||
setSessionLogsFormat(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.sessionLogsTimestampsEnabled) {
|
||||
setSessionLogsTimestampsEnabled(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.sshDebugLogsEnabled) {
|
||||
setSshDebugLogsEnabled(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP compressed upload setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
|
||||
@@ -316,6 +341,12 @@ export function useSettingsStorageSync({
|
||||
setSftpAutoOpenSidebar(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.sftpFollowTerminalCwd) {
|
||||
setSftpFollowTerminalCwd(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP default view mode from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE && e.newValue) {
|
||||
if ((e.newValue === 'list' || e.newValue === 'tree') && e.newValue !== s.sftpDefaultViewMode) {
|
||||
@@ -354,6 +385,12 @@ export function useSettingsStorageSync({
|
||||
setAutoUpdateEnabled(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_WINDOW_OPACITY && e.newValue !== null) {
|
||||
const newValue = clampWindowOpacity(e.newValue);
|
||||
if (newValue !== s.windowOpacity) {
|
||||
setWindowOpacity(newValue);
|
||||
}
|
||||
}
|
||||
// Sync workspace focus style from other windows
|
||||
if (e.key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && e.newValue !== null) {
|
||||
if (e.newValue === 'dim' || e.newValue === 'border') {
|
||||
@@ -382,12 +419,16 @@ export function useSettingsStorageSync({
|
||||
setEditorWordWrapState,
|
||||
setFollowAppTerminalThemeState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setHotkeyScheme,
|
||||
setLightUiThemeId,
|
||||
setSessionLogsDir,
|
||||
setSessionLogsEnabled,
|
||||
setSessionLogsFormat,
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDebugLogsEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpAutoSync,
|
||||
setSftpDefaultViewMode,
|
||||
setSftpDoubleClickBehavior,
|
||||
|
||||
@@ -71,7 +71,7 @@ export const useSftpConnections = ({
|
||||
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
|
||||
|
||||
const connect = useCallback(
|
||||
async (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean; onTabCreated?: (tabId: string) => void }) => {
|
||||
async (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean; onTabCreated?: (tabId: string) => void; sourceSessionId?: string }) => {
|
||||
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
|
||||
|
||||
let activeTabId: string | null = null;
|
||||
@@ -207,6 +207,11 @@ export const useSftpConnections = ({
|
||||
isLocal: false,
|
||||
status: "connecting",
|
||||
currentPath: cachedStartPath,
|
||||
// Suppress loading animation when connection reuse is requested.
|
||||
// If the backend falls back to a fresh connection, the pane stays
|
||||
// non-interactive (loading=true) with stale cached files visible —
|
||||
// no worse than the previous UX of always showing a spinner.
|
||||
reusedConnection: !!options?.sourceSessionId,
|
||||
};
|
||||
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
@@ -292,6 +297,7 @@ export const useSftpConnections = ({
|
||||
const keyFirstCredentials = {
|
||||
sessionId: `sftp-${connectionId}`,
|
||||
...credentials,
|
||||
sourceSessionId: options?.sourceSessionId,
|
||||
};
|
||||
if (!credentials.sudo) {
|
||||
keyFirstCredentials.password = undefined;
|
||||
@@ -302,6 +308,7 @@ export const useSftpConnections = ({
|
||||
sftpId = await openSftp({
|
||||
sessionId: `sftp-${connectionId}`,
|
||||
...credentials,
|
||||
sourceSessionId: options?.sourceSessionId,
|
||||
privateKey: undefined,
|
||||
certificate: undefined,
|
||||
publicKey: undefined,
|
||||
@@ -317,6 +324,7 @@ export const useSftpConnections = ({
|
||||
sftpId = await openSftp({
|
||||
sessionId: `sftp-${connectionId}`,
|
||||
...credentials,
|
||||
sourceSessionId: options?.sourceSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -452,6 +460,7 @@ export const useSftpConnections = ({
|
||||
status: "connected",
|
||||
currentPath: startPath,
|
||||
homeDir,
|
||||
reusedConnection: undefined,
|
||||
}
|
||||
: null,
|
||||
files,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useRef, useMemo, useState } from "react";
|
||||
import { FileConflict, FileConflictAction, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { notify } from "../../notification";
|
||||
import { joinPath } from "./utils";
|
||||
import { createUploadTaskCallbacks } from "./uploadTaskCallbacks";
|
||||
import {
|
||||
@@ -178,27 +179,24 @@ export const useSftpExternalOperations = (
|
||||
[getPaneByConnectionId, sftpSessionsRef],
|
||||
);
|
||||
|
||||
const downloadToTempAndOpen = useCallback(
|
||||
const downloadToTemp = useCallback(
|
||||
async (
|
||||
side: "left" | "right",
|
||||
remotePath: string,
|
||||
fileName: string,
|
||||
appPath: string,
|
||||
options?: { enableWatch?: boolean }
|
||||
): Promise<{ localTempPath: string; watchId?: string }> => {
|
||||
): Promise<{ localTempPath: string; sftpId: string; externalTransferId?: string }> => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("No connection available");
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.downloadSftpToTemp || !bridge?.openWithApplication) {
|
||||
throw new Error("System app opening not supported");
|
||||
if (!bridge?.downloadSftpToTemp) {
|
||||
throw new Error("SFTP temp download not supported");
|
||||
}
|
||||
|
||||
if (pane.connection.isLocal) {
|
||||
await bridge.openWithApplication(remotePath, appPath);
|
||||
return { localTempPath: remotePath };
|
||||
throw new Error("Temp download is only available for remote files");
|
||||
}
|
||||
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
@@ -287,12 +285,12 @@ export const useSftpExternalOperations = (
|
||||
if (localTempPath && bridge.deleteTempFile) {
|
||||
bridge.deleteTempFile(localTempPath).catch(() => {});
|
||||
}
|
||||
return { localTempPath: "" };
|
||||
return { localTempPath: "", sftpId, externalTransferId };
|
||||
}
|
||||
|
||||
if (isLocalTempDownloadCancelled()) {
|
||||
await cleanupTempDownload(localTempPath);
|
||||
return { localTempPath: "" };
|
||||
return { localTempPath: "", sftpId, externalTransferId };
|
||||
}
|
||||
|
||||
updateExternalUpload(externalTransferId, {
|
||||
@@ -311,7 +309,7 @@ export const useSftpExternalOperations = (
|
||||
|
||||
if (isLocalTempDownloadCancelled()) {
|
||||
await cleanupTempDownload(localTempPath);
|
||||
return { localTempPath: "" };
|
||||
return { localTempPath: "", sftpId, externalTransferId };
|
||||
}
|
||||
|
||||
if (bridge.registerTempFile) {
|
||||
@@ -322,11 +320,44 @@ export const useSftpExternalOperations = (
|
||||
}
|
||||
}
|
||||
|
||||
return { localTempPath, sftpId, externalTransferId };
|
||||
},
|
||||
[getActivePane, sftpSessionsRef, addExternalUpload, updateExternalUpload, isTransferCancelled],
|
||||
);
|
||||
|
||||
const downloadToTempAndOpen = useCallback(
|
||||
async (
|
||||
side: "left" | "right",
|
||||
remotePath: string,
|
||||
fileName: string,
|
||||
appPath: string,
|
||||
options?: { enableWatch?: boolean }
|
||||
): Promise<{ localTempPath: string; watchId?: string }> => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("No connection available");
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.openWithApplication) {
|
||||
throw new Error("System app opening not supported");
|
||||
}
|
||||
|
||||
if (pane.connection.isLocal) {
|
||||
await bridge.openWithApplication(remotePath, appPath);
|
||||
return { localTempPath: remotePath };
|
||||
}
|
||||
|
||||
const { localTempPath, sftpId, externalTransferId } = await downloadToTemp(side, remotePath, fileName);
|
||||
if (!localTempPath) {
|
||||
return { localTempPath: "" };
|
||||
}
|
||||
|
||||
try {
|
||||
await bridge.openWithApplication(localTempPath, appPath);
|
||||
} catch (err) {
|
||||
if (externalTransferId) {
|
||||
updateExternalUpload(externalTransferId, {
|
||||
updateExternalUpload?.(externalTransferId, {
|
||||
status: "failed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
@@ -354,7 +385,67 @@ export const useSftpExternalOperations = (
|
||||
|
||||
return { localTempPath, watchId };
|
||||
},
|
||||
[getActivePane, sftpSessionsRef, addExternalUpload, updateExternalUpload, isTransferCancelled],
|
||||
[downloadToTemp, getActivePane, updateExternalUpload],
|
||||
);
|
||||
|
||||
const openWithSystemDefault = useCallback(
|
||||
async (
|
||||
side: "left" | "right",
|
||||
remotePath: string,
|
||||
fileName: string,
|
||||
options?: { enableWatch?: boolean }
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("No connection available");
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.openWithSystemDefault) {
|
||||
throw new Error("System default opening not supported");
|
||||
}
|
||||
|
||||
const bridgeMethods = bridge;
|
||||
|
||||
const { localTempPath, sftpId, externalTransferId } = pane.connection.isLocal
|
||||
? { localTempPath: remotePath, sftpId: "", externalTransferId: undefined }
|
||||
: await downloadToTemp(side, remotePath, fileName);
|
||||
|
||||
if (!localTempPath) return;
|
||||
|
||||
const result = await bridgeMethods.openWithSystemDefault(localTempPath);
|
||||
if (!result.success) {
|
||||
if (externalTransferId) {
|
||||
updateExternalUpload?.(externalTransferId, {
|
||||
status: "failed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
error: result.error || "Failed to open file",
|
||||
speed: 0,
|
||||
});
|
||||
}
|
||||
throw new Error(result.error || "Failed to open file");
|
||||
}
|
||||
|
||||
// Start file watch for remote SFTP auto-sync (mirrors downloadToTempAndOpen behavior)
|
||||
if (options?.enableWatch && !pane.connection.isLocal && bridgeMethods.startFileWatch) {
|
||||
try {
|
||||
await bridgeMethods.startFileWatch(
|
||||
localTempPath,
|
||||
remotePath,
|
||||
sftpId,
|
||||
pane.filenameEncoding,
|
||||
);
|
||||
activeFileWatchCountRef.current += 1;
|
||||
} catch (err) {
|
||||
console.warn("[SFTP] Failed to start file watch for default app open:", err);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
notify.error(err instanceof Error ? err.message : String(err), "SFTP");
|
||||
}
|
||||
},
|
||||
[downloadToTemp, getActivePane, updateExternalUpload],
|
||||
);
|
||||
|
||||
// Create upload callbacks that translate to TransferTask updates
|
||||
@@ -914,6 +1005,7 @@ export const useSftpExternalOperations = (
|
||||
writeTextFile,
|
||||
writeTextFileByConnection,
|
||||
downloadToTempAndOpen,
|
||||
openWithSystemDefault,
|
||||
uploadExternalFiles,
|
||||
uploadExternalFileList,
|
||||
uploadExternalFolderPath,
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface SftpExternalOperationsResult {
|
||||
appPath: string,
|
||||
options?: { enableWatch?: boolean }
|
||||
) => Promise<{ localTempPath: string; watchId?: string }>;
|
||||
openWithSystemDefault: (side: "left" | "right", remotePath: string, fileName: string, options?: { enableWatch?: boolean }) => Promise<void>;
|
||||
activeFileWatchCountRef: React.MutableRefObject<number>;
|
||||
uploadExternalFiles: (
|
||||
side: "left" | "right",
|
||||
@@ -62,4 +63,3 @@ export interface SftpExternalOperationsResult {
|
||||
uploadConflicts: FileConflict[];
|
||||
resolveUploadConflict: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import type { FileWatchErrorEvent, FileWatchSyncedEvent, SftpStateOptions } from "./types";
|
||||
|
||||
export const useSftpFileWatch = (options?: SftpStateOptions) => {
|
||||
const optionsRef = useRef(options);
|
||||
optionsRef.current = options;
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onFileWatchSynced || !bridge?.onFileWatchError) return;
|
||||
|
||||
const unsubscribeSynced = bridge.onFileWatchSynced((payload: FileWatchSyncedEvent) => {
|
||||
options?.onFileWatchSynced?.(payload);
|
||||
optionsRef.current?.onFileWatchSynced?.(payload);
|
||||
});
|
||||
|
||||
const unsubscribeError = bridge.onFileWatchError((payload: FileWatchErrorEvent) => {
|
||||
options?.onFileWatchError?.(payload);
|
||||
optionsRef.current?.onFileWatchError?.(payload);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -23,5 +26,5 @@ export const useSftpFileWatch = (options?: SftpStateOptions) => {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
};
|
||||
}, [options]);
|
||||
}, []);
|
||||
};
|
||||
|
||||
@@ -52,6 +52,31 @@ test("buildSftpHostCredentials rejects missing saved proxy profiles on jump host
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials forwards custom ProxyCommand settings", () => {
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({
|
||||
proxyConfig: {
|
||||
type: "command",
|
||||
host: "",
|
||||
port: 0,
|
||||
command: "cloudflared access ssh --hostname %h",
|
||||
},
|
||||
}),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(credentials.proxy, {
|
||||
type: "command",
|
||||
host: "",
|
||||
port: 0,
|
||||
command: "cloudflared access ssh --hostname %h",
|
||||
username: undefined,
|
||||
password: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials passes reference keys as identity file paths", () => {
|
||||
const key: SSHKey = {
|
||||
id: "key-1",
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Host, Identity, SSHKey, TerminalSettings } from "../../../domain/m
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
|
||||
import { resolveBridgeKeyAuth, resolveHostAuth } from "../../../domain/sshAuth";
|
||||
import { resolveHostKeepalive } from "../../../domain/host";
|
||||
import { hasUsableProxyConfig } from "../../../domain/proxyProfiles";
|
||||
|
||||
// Fallback used when no global TerminalSettings are wired through (older
|
||||
// call sites or tests). Matches DEFAULT_TERMINAL_SETTINGS so behavior is
|
||||
@@ -36,6 +37,7 @@ export const buildSftpHostCredentials = ({
|
||||
type: host.proxyConfig.type,
|
||||
host: host.proxyConfig.host,
|
||||
port: host.proxyConfig.port,
|
||||
command: host.proxyConfig.command,
|
||||
username: host.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(host.proxyConfig.password),
|
||||
}
|
||||
@@ -69,7 +71,7 @@ export const buildSftpHostCredentials = ({
|
||||
const hasJumpKeyMaterial = Boolean(jumpKeyAuth.privateKey || jumpKeyAuth.identityFilePaths?.length);
|
||||
const hasConfiguredJumpProxyEndpoint =
|
||||
index === 0 &&
|
||||
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
|
||||
hasUsableProxyConfig(jumpHost.proxyConfig);
|
||||
if (
|
||||
hasConfiguredJumpProxyEndpoint &&
|
||||
jumpHost.proxyConfig?.username &&
|
||||
@@ -101,11 +103,12 @@ export const buildSftpHostCredentials = ({
|
||||
keyId: jumpAuth.keyId,
|
||||
keySource: jumpKey?.source,
|
||||
label: jumpHost.label,
|
||||
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
|
||||
proxy: hasUsableProxyConfig(jumpHost.proxyConfig)
|
||||
? {
|
||||
type: jumpHost.proxyConfig.type,
|
||||
host: jumpHost.proxyConfig.host,
|
||||
port: jumpHost.proxyConfig.port,
|
||||
command: jumpHost.proxyConfig.command,
|
||||
username: jumpHost.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
@@ -12,6 +13,7 @@ interface UseSystemSettingsEffectsParams {
|
||||
toggleWindowHotkey: string;
|
||||
globalHotkeyEnabled: boolean;
|
||||
closeToTray: boolean;
|
||||
windowOpacity: number;
|
||||
autoUpdateEnabled: boolean;
|
||||
persistMountedRef: MutableRefObject<boolean>;
|
||||
setHotkeyRegistrationError: (error: string | null) => void;
|
||||
@@ -23,6 +25,7 @@ export function useSystemSettingsEffects({
|
||||
toggleWindowHotkey,
|
||||
globalHotkeyEnabled,
|
||||
closeToTray,
|
||||
windowOpacity,
|
||||
autoUpdateEnabled,
|
||||
persistMountedRef,
|
||||
setHotkeyRegistrationError,
|
||||
@@ -89,6 +92,17 @@ export function useSystemSettingsEffects({
|
||||
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
|
||||
}, [closeToTray, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
// Persist and sync window opacity
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.setWindowOpacity?.(windowOpacity).catch((err) => {
|
||||
console.warn('[WindowOpacity] Failed to apply window opacity:', err);
|
||||
});
|
||||
localStorageAdapter.writeString(STORAGE_KEY_WINDOW_OPACITY, String(windowOpacity));
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_WINDOW_OPACITY, windowOpacity);
|
||||
}, [windowOpacity, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
// Hydrate auto-update state from the main-process preference file on mount.
|
||||
// This reconciles localStorage (renderer) with auto-update-pref.json (main)
|
||||
// in case localStorage was cleared or is stale.
|
||||
|
||||
62
application/state/terminalConnectionReuse.test.ts
Normal file
62
application/state/terminalConnectionReuse.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { TerminalSession } from "../../domain/models";
|
||||
import {
|
||||
canReuseTerminalConnection,
|
||||
createCopiedTerminalSessionClone,
|
||||
createSplitTerminalSessionClone,
|
||||
} from "./terminalConnectionReuse";
|
||||
|
||||
const session = (overrides: Partial<TerminalSession> = {}): TerminalSession => ({
|
||||
id: "session-1",
|
||||
hostId: "host-1",
|
||||
hostLabel: "Host",
|
||||
hostname: "example.com",
|
||||
username: "alice",
|
||||
status: "connected",
|
||||
protocol: "ssh",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("connected SSH sessions can reuse their authenticated connection", () => {
|
||||
assert.equal(canReuseTerminalConnection(session()), true);
|
||||
assert.equal(canReuseTerminalConnection(session({ protocol: undefined })), true);
|
||||
});
|
||||
|
||||
test("non-SSH or unavailable sessions do not reuse a connection", () => {
|
||||
assert.equal(canReuseTerminalConnection(session({ status: "connecting" })), false);
|
||||
assert.equal(canReuseTerminalConnection(session({ status: "disconnected" })), false);
|
||||
assert.equal(canReuseTerminalConnection(session({ protocol: "local" })), false);
|
||||
assert.equal(canReuseTerminalConnection(session({ protocol: "serial" })), false);
|
||||
assert.equal(canReuseTerminalConnection(session({ protocol: "telnet" })), false);
|
||||
assert.equal(canReuseTerminalConnection(session({ moshEnabled: true })), false);
|
||||
assert.equal(canReuseTerminalConnection(session({ etEnabled: true })), false);
|
||||
});
|
||||
|
||||
test("split session clones reuse only connected SSH sources", () => {
|
||||
assert.equal(
|
||||
createSplitTerminalSessionClone(session(), { id: "split-1", workspaceId: "workspace-1" }).reuseConnectionFromSessionId,
|
||||
"session-1",
|
||||
);
|
||||
assert.equal(
|
||||
createSplitTerminalSessionClone(session({ etEnabled: true }), { id: "split-2" }).reuseConnectionFromSessionId,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
createSplitTerminalSessionClone(session({ moshEnabled: true }), { id: "split-3" }).reuseConnectionFromSessionId,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("copy session clones reuse SSH sources and preserve serial config", () => {
|
||||
const copied = createCopiedTerminalSessionClone(
|
||||
session({
|
||||
serialConfig: { path: "/dev/tty.usbserial", baudRate: 115200 },
|
||||
}),
|
||||
{ id: "copy-1" },
|
||||
);
|
||||
|
||||
assert.equal(copied.reuseConnectionFromSessionId, "session-1");
|
||||
assert.deepEqual(copied.serialConfig, { path: "/dev/tty.usbserial", baudRate: 115200 });
|
||||
});
|
||||
71
application/state/terminalConnectionReuse.ts
Normal file
71
application/state/terminalConnectionReuse.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { TerminalSession } from "../../domain/models";
|
||||
|
||||
export function canReuseTerminalConnection(session: TerminalSession): boolean {
|
||||
return (
|
||||
(session.protocol === "ssh" || session.protocol === undefined) &&
|
||||
!session.moshEnabled &&
|
||||
!session.etEnabled &&
|
||||
session.status === "connected"
|
||||
);
|
||||
}
|
||||
|
||||
type CloneSessionOptions = {
|
||||
id: string;
|
||||
localShellType?: TerminalSession["shellType"];
|
||||
workspaceId?: string;
|
||||
};
|
||||
|
||||
function getClonedShellType(
|
||||
session: TerminalSession,
|
||||
localShellType?: TerminalSession["shellType"],
|
||||
): TerminalSession["shellType"] {
|
||||
return session.protocol === "local" ? localShellType : session.shellType;
|
||||
}
|
||||
|
||||
function createTerminalSessionClone(
|
||||
session: TerminalSession,
|
||||
options: CloneSessionOptions,
|
||||
): TerminalSession {
|
||||
const clonedSession: TerminalSession = {
|
||||
id: options.id,
|
||||
hostId: session.hostId,
|
||||
hostLabel: session.hostLabel,
|
||||
hostname: session.hostname,
|
||||
username: session.username,
|
||||
status: "connecting",
|
||||
protocol: session.protocol,
|
||||
port: session.port,
|
||||
moshEnabled: session.moshEnabled,
|
||||
etEnabled: session.etEnabled,
|
||||
shellType: getClonedShellType(session, options.localShellType),
|
||||
charset: session.charset,
|
||||
localShell: session.localShell,
|
||||
localShellArgs: session.localShellArgs,
|
||||
localShellName: session.localShellName,
|
||||
localShellIcon: session.localShellIcon,
|
||||
reuseConnectionFromSessionId: canReuseTerminalConnection(session) ? session.id : undefined,
|
||||
};
|
||||
|
||||
if (options.workspaceId) {
|
||||
clonedSession.workspaceId = options.workspaceId;
|
||||
}
|
||||
|
||||
return clonedSession;
|
||||
}
|
||||
|
||||
export function createSplitTerminalSessionClone(
|
||||
session: TerminalSession,
|
||||
options: CloneSessionOptions,
|
||||
): TerminalSession {
|
||||
return createTerminalSessionClone(session, options);
|
||||
}
|
||||
|
||||
export function createCopiedTerminalSessionClone(
|
||||
session: TerminalSession,
|
||||
options: CloneSessionOptions,
|
||||
): TerminalSession {
|
||||
return {
|
||||
...createTerminalSessionClone(session, options),
|
||||
serialConfig: session.serialConfig,
|
||||
};
|
||||
}
|
||||
76
application/state/terminalHostTreeStore.ts
Normal file
76
application/state/terminalHostTreeStore.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
|
||||
import { STORAGE_KEY_TERMINAL_HOST_TREE_COLLAPSED } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
function readIsOpen(): boolean {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_TERMINAL_HOST_TREE_COLLAPSED);
|
||||
// Legacy key stores "collapsed"; open is the inverse.
|
||||
if (stored === 'true') return false;
|
||||
if (stored === 'false') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
class TerminalHostTreeStore {
|
||||
private isOpen = readIsOpen();
|
||||
/** Live sidebar width (0 when collapsed) for top-tab alignment. */
|
||||
private layoutWidth = 0;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getIsOpen = () => this.isOpen;
|
||||
|
||||
getLayoutWidth = () => this.layoutWidth;
|
||||
|
||||
setIsOpen = (open: boolean) => {
|
||||
if (this.isOpen === open) return;
|
||||
this.isOpen = open;
|
||||
if (!open) {
|
||||
this.layoutWidth = 0;
|
||||
}
|
||||
localStorageAdapter.writeString(
|
||||
STORAGE_KEY_TERMINAL_HOST_TREE_COLLAPSED,
|
||||
open ? 'false' : 'true',
|
||||
);
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
setLayoutWidth = (width: number) => {
|
||||
const next = Math.max(0, width);
|
||||
if (this.layoutWidth === next) return;
|
||||
this.layoutWidth = next;
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
toggle = () => {
|
||||
this.setIsOpen(!this.isOpen);
|
||||
};
|
||||
|
||||
subscribe = (listener: Listener) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export const terminalHostTreeStore = new TerminalHostTreeStore();
|
||||
|
||||
export const useTerminalHostTreeOpen = () => {
|
||||
return useSyncExternalStore(
|
||||
terminalHostTreeStore.subscribe,
|
||||
terminalHostTreeStore.getIsOpen,
|
||||
terminalHostTreeStore.getIsOpen,
|
||||
);
|
||||
};
|
||||
|
||||
export const useToggleTerminalHostTree = () => {
|
||||
return useCallback(() => terminalHostTreeStore.toggle(), []);
|
||||
};
|
||||
|
||||
export const useTerminalHostTreeLayoutWidth = () => {
|
||||
return useSyncExternalStore(
|
||||
terminalHostTreeStore.subscribe,
|
||||
terminalHostTreeStore.getLayoutWidth,
|
||||
terminalHostTreeStore.getLayoutWidth,
|
||||
);
|
||||
};
|
||||
16
application/state/terminalLayoutSuppressStore.test.ts
Normal file
16
application/state/terminalLayoutSuppressStore.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { terminalLayoutSuppressStore } from './terminalLayoutSuppressStore';
|
||||
|
||||
test('terminalLayoutSuppressStore tracks nested begin/end', () => {
|
||||
assert.equal(terminalLayoutSuppressStore.getActive(), false);
|
||||
terminalLayoutSuppressStore.begin();
|
||||
assert.equal(terminalLayoutSuppressStore.getActive(), true);
|
||||
terminalLayoutSuppressStore.begin();
|
||||
assert.equal(terminalLayoutSuppressStore.getActive(), true);
|
||||
terminalLayoutSuppressStore.end();
|
||||
assert.equal(terminalLayoutSuppressStore.getActive(), true);
|
||||
terminalLayoutSuppressStore.end();
|
||||
assert.equal(terminalLayoutSuppressStore.getActive(), false);
|
||||
});
|
||||
40
application/state/terminalLayoutSuppressStore.ts
Normal file
40
application/state/terminalLayoutSuppressStore.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
let suppressDepth = 0;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
function emit() {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export const terminalLayoutSuppressStore = {
|
||||
getActive: () => suppressDepth > 0,
|
||||
|
||||
subscribe: (listener: Listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
|
||||
begin: () => {
|
||||
suppressDepth += 1;
|
||||
emit();
|
||||
},
|
||||
|
||||
end: () => {
|
||||
const wasActive = suppressDepth > 0;
|
||||
suppressDepth = Math.max(0, suppressDepth - 1);
|
||||
if (wasActive) {
|
||||
emit();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export function useTerminalLayoutSuppressActive(): boolean {
|
||||
return useSyncExternalStore(
|
||||
terminalLayoutSuppressStore.subscribe,
|
||||
terminalLayoutSuppressStore.getActive,
|
||||
terminalLayoutSuppressStore.getActive,
|
||||
);
|
||||
}
|
||||
52
application/state/terminalSelectionAttachment.test.ts
Normal file
52
application/state/terminalSelectionAttachment.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE,
|
||||
buildPromptWithTerminalSelectionAttachments,
|
||||
createTerminalSelectionAttachment,
|
||||
decodeTerminalSelectionAttachment,
|
||||
} from "./terminalSelectionAttachment.ts";
|
||||
|
||||
test("createTerminalSelectionAttachment returns null for blank selections", () => {
|
||||
assert.equal(createTerminalSelectionAttachment(" \n\t"), null);
|
||||
});
|
||||
|
||||
test("createTerminalSelectionAttachment creates a compact terminal log attachment", () => {
|
||||
const attachment = createTerminalSelectionAttachment("line one\nline two");
|
||||
|
||||
assert.ok(attachment);
|
||||
assert.equal(attachment.mediaType, TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE);
|
||||
assert.match(attachment.filename, /^terminal-selection-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}\.log$/);
|
||||
assert.equal(attachment.terminalSelection, true);
|
||||
assert.equal(attachment.previewText, "line one");
|
||||
assert.equal(attachment.lineCount, 2);
|
||||
assert.equal(decodeTerminalSelectionAttachment(attachment), "line one\nline two");
|
||||
});
|
||||
|
||||
test("createTerminalSelectionAttachment preserves utf-8 terminal output", () => {
|
||||
const attachment = createTerminalSelectionAttachment("错误: 权限不足\n路径: /tmp/测试");
|
||||
|
||||
assert.ok(attachment);
|
||||
assert.equal(decodeTerminalSelectionAttachment(attachment), "错误: 权限不足\n路径: /tmp/测试");
|
||||
});
|
||||
|
||||
test("buildPromptWithTerminalSelectionAttachments expands terminal selections into prompt text", () => {
|
||||
const attachment = createTerminalSelectionAttachment("docker ps -a\npermission denied");
|
||||
|
||||
assert.ok(attachment);
|
||||
assert.equal(
|
||||
buildPromptWithTerminalSelectionAttachments("帮我看看", [attachment]),
|
||||
`帮我看看\n\n[Terminal selection: ${attachment.filename}]\ndocker ps -a\npermission denied`,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildPromptWithTerminalSelectionAttachments supports terminal-only prompts", () => {
|
||||
const attachment = createTerminalSelectionAttachment("systemctl status nginx");
|
||||
|
||||
assert.ok(attachment);
|
||||
assert.equal(
|
||||
buildPromptWithTerminalSelectionAttachments("", [attachment]),
|
||||
`[Terminal selection: ${attachment.filename}]\nsystemctl status nginx`,
|
||||
);
|
||||
});
|
||||
101
application/state/terminalSelectionAttachment.ts
Normal file
101
application/state/terminalSelectionAttachment.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { ChatMessageAttachment, UploadedFile } from "../../infrastructure/ai/types";
|
||||
|
||||
export const TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE = "text/plain";
|
||||
|
||||
const MAX_PREVIEW_CHARS = 80;
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
const chunkSize = 0x8000;
|
||||
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
const chunk = bytes.subarray(i, i + chunkSize);
|
||||
binary += String.fromCharCode(...chunk);
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToText(base64Data: string): string {
|
||||
const binary = atob(base64Data);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
function buildTimestamp(date: Date): string {
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
return [
|
||||
date.getFullYear(),
|
||||
pad(date.getMonth() + 1),
|
||||
pad(date.getDate()),
|
||||
pad(date.getHours()),
|
||||
pad(date.getMinutes()),
|
||||
pad(date.getSeconds()),
|
||||
].join("-");
|
||||
}
|
||||
|
||||
function getPreviewText(text: string): string {
|
||||
const firstLine = text.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
|
||||
return firstLine.length > MAX_PREVIEW_CHARS
|
||||
? `${firstLine.slice(0, MAX_PREVIEW_CHARS - 1)}...`
|
||||
: firstLine;
|
||||
}
|
||||
|
||||
export function createTerminalSelectionAttachment(
|
||||
selection: string,
|
||||
now: Date = new Date(),
|
||||
): UploadedFile | null {
|
||||
const text = selection.trim();
|
||||
if (!text) return null;
|
||||
|
||||
const base64Data = bytesToBase64(new TextEncoder().encode(text));
|
||||
const filename = `terminal-selection-${buildTimestamp(now)}.log`;
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
filename,
|
||||
dataUrl: `data:${TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE};base64,${base64Data}`,
|
||||
base64Data,
|
||||
mediaType: TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE,
|
||||
terminalSelection: true,
|
||||
previewText: getPreviewText(text),
|
||||
lineCount: text.split(/\r?\n/).length,
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeTerminalSelectionAttachment(
|
||||
attachment: Pick<UploadedFile | ChatMessageAttachment, "base64Data" | "terminalSelection">,
|
||||
): string | null {
|
||||
if (!attachment.terminalSelection) return null;
|
||||
return base64ToText(attachment.base64Data);
|
||||
}
|
||||
|
||||
export function isTerminalSelectionAttachment(
|
||||
attachment: Pick<UploadedFile | ChatMessageAttachment, "terminalSelection">,
|
||||
): boolean {
|
||||
return attachment.terminalSelection === true;
|
||||
}
|
||||
|
||||
export function buildPromptWithTerminalSelectionAttachments(
|
||||
prompt: string,
|
||||
attachments: Array<ChatMessageAttachment | UploadedFile>,
|
||||
): string {
|
||||
const terminalBlocks = attachments
|
||||
.filter(isTerminalSelectionAttachment)
|
||||
.map((attachment, index) => {
|
||||
const text = decodeTerminalSelectionAttachment(attachment);
|
||||
if (!text) return null;
|
||||
const label = attachment.filename || `terminal-selection-${index + 1}.log`;
|
||||
return `\n\n[Terminal selection: ${label}]\n${text}`;
|
||||
})
|
||||
.filter((block): block is string => block !== null);
|
||||
|
||||
if (terminalBlocks.length === 0) return prompt;
|
||||
if (!prompt.trim()) return terminalBlocks.join("").trimStart();
|
||||
return `${prompt}${terminalBlocks.join("")}`;
|
||||
}
|
||||
@@ -139,6 +139,101 @@ test("uploads picked folder files with their relative directory structure", asyn
|
||||
]);
|
||||
});
|
||||
|
||||
test("uploads path-backed clipboard files through stream transfer", async () => {
|
||||
const transfers: Array<{ sourcePath: string; targetPath: string; totalBytes?: number }> = [];
|
||||
const taskTotals: number[] = [];
|
||||
|
||||
const results = await uploadEntriesDirect(
|
||||
[
|
||||
{
|
||||
file: null,
|
||||
localPath: "/Users/me/Desktop/report.txt",
|
||||
relativePath: "report.txt",
|
||||
isDirectory: false,
|
||||
size: 42,
|
||||
},
|
||||
],
|
||||
{
|
||||
targetPath: "/target",
|
||||
sftpId: "sftp-1",
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
startStreamTransfer: async (payload) => {
|
||||
transfers.push({
|
||||
sourcePath: payload.sourcePath,
|
||||
targetPath: payload.targetPath,
|
||||
totalBytes: payload.totalBytes,
|
||||
});
|
||||
return { transferId: payload.transferId };
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
callbacks: {
|
||||
onTaskCreated: (task) => taskTotals.push(task.totalBytes),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(taskTotals, [42]);
|
||||
assert.deepEqual(transfers, [
|
||||
{
|
||||
sourcePath: "/Users/me/Desktop/report.txt",
|
||||
targetPath: "/target/report.txt",
|
||||
totalBytes: 42,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(results, [
|
||||
{ fileName: "report.txt", success: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test("copies path-backed clipboard files into local panes through stream transfer", async () => {
|
||||
const transfers: Array<{ sourcePath: string; targetPath: string; targetType: string; totalBytes?: number }> = [];
|
||||
|
||||
const results = await uploadEntriesDirect(
|
||||
[
|
||||
{
|
||||
file: null,
|
||||
localPath: "/Users/me/Desktop/report.txt",
|
||||
relativePath: "report.txt",
|
||||
isDirectory: false,
|
||||
size: 42,
|
||||
},
|
||||
],
|
||||
{
|
||||
targetPath: "/target",
|
||||
sftpId: null,
|
||||
isLocal: true,
|
||||
bridge: {
|
||||
mkdirLocal: async () => {},
|
||||
startStreamTransfer: async (payload) => {
|
||||
transfers.push({
|
||||
sourcePath: payload.sourcePath,
|
||||
targetPath: payload.targetPath,
|
||||
targetType: payload.targetType,
|
||||
totalBytes: payload.totalBytes,
|
||||
});
|
||||
return { transferId: payload.transferId };
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(transfers, [
|
||||
{
|
||||
sourcePath: "/Users/me/Desktop/report.txt",
|
||||
targetPath: "/target/report.txt",
|
||||
targetType: "local",
|
||||
totalBytes: 42,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(results, [
|
||||
{ fileName: "report.txt", success: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test("reports empty directory creation failures", async () => {
|
||||
const madeDirs: string[] = [];
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
STORAGE_KEY_AI_PROVIDERS,
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE,
|
||||
bumpDraftMutationVersion,
|
||||
bumpDraftUploadGeneration,
|
||||
cleanupAcpSessions,
|
||||
cleanupSdkAgentSessions,
|
||||
cleanupOrphanedAISessions,
|
||||
getAIBridge,
|
||||
getDraftUploadGeneration,
|
||||
@@ -321,7 +321,7 @@ export function useAIState() {
|
||||
const setCommandBlocklist = useCallback((value: string[]) => {
|
||||
setCommandBlocklistRaw(value);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, value);
|
||||
// Sync to MCP Server bridge so ACP agents also respect the blocklist
|
||||
// Sync to MCP Server bridge so SDK agents also respect the blocklist
|
||||
const bridge = getAIBridge();
|
||||
bridge?.aiMcpSetCommandBlocklist?.(value);
|
||||
}, []);
|
||||
@@ -337,7 +337,7 @@ export function useAIState() {
|
||||
const setMaxIterations = useCallback((value: number) => {
|
||||
setMaxIterationsRaw(value);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, value);
|
||||
// Sync to MCP Server bridge (used by ACP agent path)
|
||||
// Sync to MCP Server bridge (used by SDK agent path)
|
||||
const bridge = getAIBridge();
|
||||
bridge?.aiMcpSetMaxIterations?.(value);
|
||||
}, []);
|
||||
@@ -571,7 +571,7 @@ export function useAIState() {
|
||||
}, [defaultAgentId, persistSessions, setActiveSessionId]);
|
||||
|
||||
const deleteSession = useCallback((sessionId: string, scopeKey?: string) => {
|
||||
cleanupAcpSessions([sessionId]);
|
||||
cleanupSdkAgentSessions([sessionId]);
|
||||
if (persistTimerRef.current) {
|
||||
clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = null;
|
||||
@@ -600,7 +600,7 @@ export function useAIState() {
|
||||
const removedSessionIds = sessionsRef.current
|
||||
.filter(s => s.scope.type === scopeType && s.scope.targetId === targetId)
|
||||
.map(s => s.id);
|
||||
cleanupAcpSessions(removedSessionIds);
|
||||
cleanupSdkAgentSessions(removedSessionIds);
|
||||
if (persistTimerRef.current) {
|
||||
clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = null;
|
||||
@@ -941,7 +941,7 @@ export function useAIState() {
|
||||
// ── Computed ──
|
||||
const activeProvider = providers.find(p => p.id === activeProviderId) ?? null;
|
||||
|
||||
return {
|
||||
return useMemo(() => ({
|
||||
providers,
|
||||
setProviders,
|
||||
addProvider,
|
||||
@@ -996,5 +996,60 @@ export function useAIState() {
|
||||
updateMessageById,
|
||||
clearSessionMessages,
|
||||
cleanupOrphanedSessions,
|
||||
};
|
||||
}), [
|
||||
providers,
|
||||
setProviders,
|
||||
addProvider,
|
||||
updateProvider,
|
||||
removeProvider,
|
||||
activeProviderId,
|
||||
setActiveProviderId,
|
||||
activeModelId,
|
||||
setActiveModelId,
|
||||
activeProvider,
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode,
|
||||
toolIntegrationMode,
|
||||
setToolIntegrationMode,
|
||||
hostPermissions,
|
||||
setHostPermissions,
|
||||
externalAgents,
|
||||
setExternalAgents,
|
||||
defaultAgentId,
|
||||
setDefaultAgentId,
|
||||
commandBlocklist,
|
||||
setCommandBlocklist,
|
||||
commandTimeout,
|
||||
setCommandTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
agentModelMap,
|
||||
setAgentModel,
|
||||
agentProviderMap,
|
||||
setAgentProvider,
|
||||
webSearchConfig,
|
||||
setWebSearchConfig,
|
||||
sessions,
|
||||
activeSessionIdMap,
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
setActiveSessionId,
|
||||
ensureDraftForScope,
|
||||
updateDraft,
|
||||
showDraftView,
|
||||
showSessionView,
|
||||
clearDraftForScope,
|
||||
addDraftFiles,
|
||||
removeDraftFile,
|
||||
createSession,
|
||||
deleteSession,
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
updateSessionExternalSessionId,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
clearSessionMessages,
|
||||
cleanupOrphanedSessions,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { DiscoveredAgent, ExternalAgentConfig } from '../../infrastructure/ai/types';
|
||||
import { getExternalAgentSdkBackend } from '../../infrastructure/ai/managedAgents';
|
||||
|
||||
interface NetcattyBridge {
|
||||
aiDiscoverAgents(): Promise<DiscoveredAgent[]>;
|
||||
@@ -12,7 +13,9 @@ function getBridge(): NetcattyBridge | undefined {
|
||||
export function useAgentDiscovery(
|
||||
externalAgents: ExternalAgentConfig[],
|
||||
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void,
|
||||
options?: { enabled?: boolean },
|
||||
) {
|
||||
const enabled = options?.enabled ?? true;
|
||||
const [discoveredAgents, setDiscoveredAgents] = useState<DiscoveredAgent[]>([]);
|
||||
const [isDiscovering, setIsDiscovering] = useState(false);
|
||||
|
||||
@@ -31,10 +34,28 @@ export function useAgentDiscovery(
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Discover on mount
|
||||
useEffect(() => {
|
||||
discover();
|
||||
}, [discover]);
|
||||
if (!enabled) return;
|
||||
|
||||
let cancelled = false;
|
||||
const runDiscover = () => {
|
||||
if (!cancelled) void discover();
|
||||
};
|
||||
|
||||
if (typeof requestIdleCallback === 'function') {
|
||||
const idleId = requestIdleCallback(runDiscover, { timeout: 2000 });
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelIdleCallback(idleId);
|
||||
};
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(runDiscover, 0);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [discover, enabled]);
|
||||
|
||||
// Auto-update args for already-configured discovered agents when
|
||||
// the canonical args from discovery change (e.g. after an app update).
|
||||
@@ -52,19 +73,23 @@ export function useAgentDiscovery(
|
||||
);
|
||||
if (!match) return ea;
|
||||
|
||||
// Check if args, ACP config, or Claude's resolved system path differ
|
||||
// Check if args, SDK backend, or Claude's resolved system path differ
|
||||
const currentArgs = JSON.stringify(ea.args || []);
|
||||
const newArgs = JSON.stringify(match.args);
|
||||
const acpChanged = ea.acpCommand !== match.acpCommand
|
||||
|| JSON.stringify(ea.acpArgs || []) !== JSON.stringify(match.acpArgs || []);
|
||||
const backend = match.sdkBackend ?? match.command;
|
||||
const backendChanged = getExternalAgentSdkBackend(ea) !== backend
|
||||
|| Boolean(ea.acpCommand)
|
||||
|| JSON.stringify(ea.acpArgs || []) !== JSON.stringify([]);
|
||||
const matchPath = match.binPath || match.path;
|
||||
const env = match.command === 'claude'
|
||||
? { ...(ea.env ?? {}), CLAUDE_CODE_EXECUTABLE: match.path }
|
||||
? { ...(ea.env ?? {}), CLAUDE_CODE_EXECUTABLE: matchPath }
|
||||
: ea.env;
|
||||
const envChanged = match.command === 'claude'
|
||||
&& ea.env?.CLAUDE_CODE_EXECUTABLE !== match.path;
|
||||
if (currentArgs !== newArgs || acpChanged || envChanged) {
|
||||
&& ea.env?.CLAUDE_CODE_EXECUTABLE !== matchPath;
|
||||
if (currentArgs !== newArgs || backendChanged || envChanged) {
|
||||
changed = true;
|
||||
return { ...ea, args: match.args, acpCommand: match.acpCommand, acpArgs: match.acpArgs, ...(env ? { env } : {}) };
|
||||
const { acpCommand: _legacyCommand, acpArgs: _legacyArgs, ...rest } = ea;
|
||||
return { ...rest, args: match.args, sdkBackend: backend, ...(env ? { env } : {}) };
|
||||
}
|
||||
return ea;
|
||||
});
|
||||
@@ -82,16 +107,18 @@ export function useAgentDiscovery(
|
||||
// Build ExternalAgentConfig from a discovered agent
|
||||
const enableAgent = useCallback(
|
||||
(agent: DiscoveredAgent): ExternalAgentConfig => {
|
||||
const backend = agent.sdkBackend ?? agent.command;
|
||||
return {
|
||||
id: `discovered_${agent.command}`,
|
||||
name: agent.name,
|
||||
command: agent.path || agent.command,
|
||||
command: agent.binPath || agent.path || agent.command,
|
||||
args: agent.args,
|
||||
icon: agent.icon,
|
||||
enabled: true,
|
||||
acpCommand: agent.acpCommand,
|
||||
acpArgs: agent.acpArgs,
|
||||
...(agent.command === 'claude' ? { env: { CLAUDE_CODE_EXECUTABLE: agent.path } } : {}),
|
||||
sdkBackend: backend,
|
||||
...(agent.command === 'claude'
|
||||
? { env: { CLAUDE_CODE_EXECUTABLE: agent.binPath || agent.path || '' } }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -109,11 +109,16 @@ interface RemoteVersionCheckOptions {
|
||||
|
||||
export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const { t } = useI18n();
|
||||
const tRef = useRef(t);
|
||||
useEffect(() => {
|
||||
tRef.current = t;
|
||||
}, [t]);
|
||||
const sync = useCloudSync();
|
||||
const { onApplyPayload } = config;
|
||||
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastSyncedDataRef = useRef<string>('');
|
||||
const hasCheckedRemoteRef = useRef(false);
|
||||
const inspectFailureToastShownRef = useRef(false);
|
||||
/** True once checkRemoteVersion has completed (success or failure). Until
|
||||
* this is set, the debounced auto-sync effect will not fire, preventing
|
||||
* an empty local vault from racing ahead and overwriting a non-empty
|
||||
@@ -513,7 +518,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
});
|
||||
skipNextSyncRef.current = true;
|
||||
startupConsistent = true;
|
||||
notify.success(t('sync.autoSync.restoredMessage'), t('sync.autoSync.restoredTitle'));
|
||||
notify.success(tRef.current('sync.autoSync.restoredMessage'), tRef.current('sync.autoSync.restoredTitle'));
|
||||
} else {
|
||||
// User chose to keep the empty vault. Deliberately do NOT advance
|
||||
// the anchor or base — the next sync must still treat remote as
|
||||
@@ -521,7 +526,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// keeps protecting the cloud copy. startupConsistent stays false
|
||||
// so hasCheckedRemoteRef is not latched and the next startup will
|
||||
// re-prompt if the user still has not added anything.
|
||||
notify.info(t('sync.autoSync.keptLocalMessage'), t('sync.autoSync.keptLocalTitle'));
|
||||
notify.info(tRef.current('sync.autoSync.keptLocalMessage'), tRef.current('sync.autoSync.keptLocalTitle'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -555,7 +560,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
} else if (!roundTripFullySynced) {
|
||||
console.warn('[AutoSync] Cloud-wins round-trip did not update every provider; leaving next auto-sync enabled for retry.');
|
||||
}
|
||||
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
|
||||
notify.success(tRef.current('sync.autoSync.syncedMessage'), tRef.current('sync.autoSync.syncedTitle'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -590,7 +595,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload);
|
||||
startupConsistent = true;
|
||||
markCurrentDataSynced = false;
|
||||
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
|
||||
notify.success(tRef.current('sync.autoSync.syncedMessage'), tRef.current('sync.autoSync.syncedTitle'));
|
||||
|
||||
// If the three-way merge introduced any local-only additions that the
|
||||
// remote does not yet have, we MUST round-trip those to the cloud.
|
||||
@@ -637,14 +642,13 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AutoSync] Failed to check remote version:', error);
|
||||
if (notifyOnFailure) {
|
||||
// Surface a degraded-sync hint to the user rather than silently
|
||||
// opening the auto-sync gate. Auto-sync will still retry on next
|
||||
// data change (see finally block), but without this toast the user
|
||||
// has no visible signal that startup reconciliation failed.
|
||||
if (notifyOnFailure && !inspectFailureToastShownRef.current) {
|
||||
// Surface a degraded-sync hint once per session. Retries and
|
||||
// incidental re-triggers (e.g. effect restarts) must not spam toasts.
|
||||
inspectFailureToastShownRef.current = true;
|
||||
notify.error(
|
||||
t('sync.autoSync.inspectFailedMessage'),
|
||||
t('sync.autoSync.inspectFailedTitle'),
|
||||
tRef.current('sync.autoSync.inspectFailedMessage'),
|
||||
tRef.current('sync.autoSync.inspectFailedTitle'),
|
||||
);
|
||||
}
|
||||
// Leave hasCheckedRemoteRef=false so the next startup (or the next
|
||||
@@ -677,7 +681,14 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// identity flips (every vault edit produces a fresh `buildPayload`
|
||||
// and a fresh AutoSyncConfig literal) cannot re-memoize this
|
||||
// callback and restart the retry-timer's exponential backoff.
|
||||
}, [t]);
|
||||
// `t` is read through tRef so locale updates don't rebuild this
|
||||
// callback and re-fire the startup retry effect on unrelated renders.
|
||||
}, []);
|
||||
|
||||
const checkRemoteVersionRef = useRef(checkRemoteVersion);
|
||||
useEffect(() => {
|
||||
checkRemoteVersionRef.current = checkRemoteVersion;
|
||||
}, [checkRemoteVersion]);
|
||||
|
||||
// Debounced auto-sync when data changes
|
||||
useEffect(() => {
|
||||
@@ -789,7 +800,10 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const tick = () => {
|
||||
if (cancelled) return;
|
||||
void (async () => {
|
||||
await checkRemoteVersion();
|
||||
const notifyOnFailure = attempt === 0;
|
||||
await checkRemoteVersionRef.current(
|
||||
notifyOnFailure ? undefined : { notifyOnFailure: false },
|
||||
);
|
||||
if (cancelled || hasCheckedRemoteRef.current) return;
|
||||
// Cap retries at ~5 minutes total (30s + 60s + 120s + 240s). A
|
||||
// persistent failure beyond that is almost certainly a
|
||||
@@ -824,7 +838,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
cancelled = true;
|
||||
if (timerId) clearTimeout(timerId);
|
||||
};
|
||||
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady, checkRemoteVersion]);
|
||||
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady]);
|
||||
|
||||
const runRuntimeRemoteCheck = useCallback(async (options?: { force?: boolean }) => {
|
||||
const now = Date.now();
|
||||
|
||||
@@ -12,6 +12,91 @@ export type { UploadedFile } from '../../infrastructure/ai/types';
|
||||
/** Reject only known binary blobs that AI models can't process */
|
||||
const REJECTED_MIME_PREFIXES = ['video/', 'audio/'];
|
||||
|
||||
/**
|
||||
* Infer MIME type from file extension when the browser/Electron doesn't
|
||||
* provide one (common for .yaml, .sh, .toml, and other code/text files).
|
||||
*/
|
||||
const EXTENSION_MIME_TYPES: Record<string, string> = {
|
||||
// Code & Scripts — all use text/plain for maximum provider compatibility
|
||||
js: 'text/plain',
|
||||
mjs: 'text/plain',
|
||||
cjs: 'text/plain',
|
||||
jsx: 'text/plain',
|
||||
ts: 'text/plain',
|
||||
tsx: 'text/plain',
|
||||
py: 'text/plain',
|
||||
rb: 'text/plain',
|
||||
rs: 'text/plain',
|
||||
go: 'text/plain',
|
||||
java: 'text/plain',
|
||||
c: 'text/plain',
|
||||
h: 'text/plain',
|
||||
cpp: 'text/plain',
|
||||
hpp: 'text/plain',
|
||||
cs: 'text/plain',
|
||||
swift: 'text/plain',
|
||||
kt: 'text/plain',
|
||||
scala: 'text/plain',
|
||||
php: 'text/plain',
|
||||
pl: 'text/plain',
|
||||
sh: 'text/plain',
|
||||
bash: 'text/plain',
|
||||
zsh: 'text/plain',
|
||||
fish: 'text/plain',
|
||||
ps1: 'text/plain',
|
||||
bat: 'text/plain',
|
||||
cmd: 'text/plain',
|
||||
sql: 'text/plain',
|
||||
r: 'text/plain',
|
||||
lua: 'text/plain',
|
||||
dart: 'text/plain',
|
||||
// Web
|
||||
html: 'text/html',
|
||||
htm: 'text/html',
|
||||
css: 'text/css',
|
||||
scss: 'text/plain',
|
||||
sass: 'text/plain',
|
||||
less: 'text/plain',
|
||||
vue: 'text/plain',
|
||||
svelte: 'text/plain',
|
||||
// Config / Data
|
||||
yaml: 'text/plain',
|
||||
yml: 'text/plain',
|
||||
json: 'application/json',
|
||||
jsonc: 'application/json',
|
||||
jsonl: 'application/jsonl',
|
||||
xml: 'application/xml',
|
||||
toml: 'application/toml',
|
||||
csv: 'text/csv',
|
||||
tsv: 'text/tab-separated-values',
|
||||
ini: 'text/plain',
|
||||
cfg: 'text/plain',
|
||||
conf: 'text/plain',
|
||||
env: 'text/plain',
|
||||
// Docs
|
||||
md: 'text/markdown',
|
||||
markdown: 'text/markdown',
|
||||
txt: 'text/plain',
|
||||
tex: 'text/x-tex',
|
||||
rst: 'text/x-rst',
|
||||
log: 'text/plain',
|
||||
// Other typed files
|
||||
pdf: 'application/pdf',
|
||||
dockerfile: 'text/plain',
|
||||
};
|
||||
|
||||
function getExtension(fileName: string): string {
|
||||
const dot = fileName.lastIndexOf('.');
|
||||
if (dot === -1) return fileName.toLowerCase(); // e.g. "Dockerfile", "Makefile"
|
||||
return fileName.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
function inferMediaType(fileName: string, fileType: string): string {
|
||||
if (fileType) return fileType;
|
||||
const ext = getExtension(fileName);
|
||||
return EXTENSION_MIME_TYPES[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
function isSupportedFile(file: File): boolean {
|
||||
// Allow files with empty MIME (common in Electron for .sh, .yaml, etc.)
|
||||
if (!file.type) return true;
|
||||
@@ -39,7 +124,7 @@ export async function convertFilesToUploads(inputFiles: File[]): Promise<Uploade
|
||||
supported.map(async (file) => {
|
||||
const id = crypto.randomUUID();
|
||||
const filename = file.name || `file-${Date.now()}`;
|
||||
const mediaType = file.type || 'application/octet-stream';
|
||||
const mediaType = inferMediaType(filename, file.type);
|
||||
try {
|
||||
const result = await fileToDataUrl(file);
|
||||
const filePath = getPathForFile(file);
|
||||
|
||||
@@ -17,6 +17,10 @@ SplitHint,
|
||||
updateWorkspaceSplitSizes,
|
||||
} from '../../domain/workspace';
|
||||
import { activeTabStore } from './activeTabStore';
|
||||
import {
|
||||
createCopiedTerminalSessionClone,
|
||||
createSplitTerminalSessionClone,
|
||||
} from './terminalConnectionReuse';
|
||||
|
||||
|
||||
export const useSessionState = () => {
|
||||
@@ -286,6 +290,7 @@ export const useSessionState = () => {
|
||||
protocol: host.protocol,
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
etEnabled: host.etEnabled,
|
||||
charset: host.charset,
|
||||
};
|
||||
});
|
||||
@@ -372,6 +377,7 @@ export const useSessionState = () => {
|
||||
protocol: host.protocol,
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
etEnabled: host.etEnabled,
|
||||
charset: host.charset,
|
||||
};
|
||||
});
|
||||
@@ -475,6 +481,7 @@ export const useSessionState = () => {
|
||||
protocol: host.protocol,
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
etEnabled: host.etEnabled,
|
||||
charset: host.charset,
|
||||
workspaceId,
|
||||
};
|
||||
@@ -574,31 +581,15 @@ export const useSessionState = () => {
|
||||
setSessions(prevSessions => {
|
||||
const session = prevSessions.find(s => s.id === sessionId);
|
||||
if (!session) return prevSessions;
|
||||
const nextShellType = session.protocol === 'local'
|
||||
? options?.localShellType
|
||||
: session.shellType;
|
||||
|
||||
// If session is already in a workspace, split within that workspace
|
||||
if (session.workspaceId) {
|
||||
// Create a new session with the same host
|
||||
const newSession: TerminalSession = {
|
||||
const newSession = createSplitTerminalSessionClone(session, {
|
||||
id: crypto.randomUUID(),
|
||||
hostId: session.hostId,
|
||||
hostLabel: session.hostLabel,
|
||||
hostname: session.hostname,
|
||||
username: session.username,
|
||||
status: 'connecting',
|
||||
localShellType: options?.localShellType,
|
||||
workspaceId: session.workspaceId,
|
||||
protocol: session.protocol,
|
||||
port: session.port,
|
||||
moshEnabled: session.moshEnabled,
|
||||
shellType: nextShellType,
|
||||
charset: session.charset,
|
||||
localShell: session.localShell,
|
||||
localShellArgs: session.localShellArgs,
|
||||
localShellName: session.localShellName,
|
||||
localShellIcon: session.localShellIcon,
|
||||
};
|
||||
});
|
||||
|
||||
// Add pane to existing workspace
|
||||
const hint: SplitHint = {
|
||||
@@ -618,23 +609,10 @@ export const useSessionState = () => {
|
||||
}
|
||||
|
||||
// Session is standalone - create a new workspace
|
||||
const newSession: TerminalSession = {
|
||||
const newSession = createSplitTerminalSessionClone(session, {
|
||||
id: crypto.randomUUID(),
|
||||
hostId: session.hostId,
|
||||
hostLabel: session.hostLabel,
|
||||
hostname: session.hostname,
|
||||
username: session.username,
|
||||
status: 'connecting',
|
||||
protocol: session.protocol,
|
||||
port: session.port,
|
||||
moshEnabled: session.moshEnabled,
|
||||
shellType: nextShellType,
|
||||
charset: session.charset,
|
||||
localShell: session.localShell,
|
||||
localShellArgs: session.localShellArgs,
|
||||
localShellName: session.localShellName,
|
||||
localShellIcon: session.localShellIcon,
|
||||
};
|
||||
localShellType: options?.localShellType,
|
||||
});
|
||||
|
||||
const hint: SplitHint = {
|
||||
direction,
|
||||
@@ -802,28 +780,10 @@ export const useSessionState = () => {
|
||||
// update running; in that case skip entirely — do NOT switch the
|
||||
// active tab or insert into tabOrder, which would leave dangling ids.
|
||||
if (!session) return prevSessions;
|
||||
const nextShellType = session.protocol === 'local'
|
||||
? options?.localShellType
|
||||
: session.shellType;
|
||||
|
||||
const newSession: TerminalSession = {
|
||||
const newSession = createCopiedTerminalSessionClone(session, {
|
||||
id: newSessionId,
|
||||
hostId: session.hostId,
|
||||
hostLabel: session.hostLabel,
|
||||
hostname: session.hostname,
|
||||
username: session.username,
|
||||
status: 'connecting',
|
||||
protocol: session.protocol,
|
||||
port: session.port,
|
||||
moshEnabled: session.moshEnabled,
|
||||
shellType: nextShellType,
|
||||
charset: session.charset,
|
||||
serialConfig: session.serialConfig,
|
||||
localShell: session.localShell,
|
||||
localShellArgs: session.localShellArgs,
|
||||
localShellName: session.localShellName,
|
||||
localShellIcon: session.localShellIcon,
|
||||
};
|
||||
localShellType: options?.localShellType,
|
||||
});
|
||||
|
||||
// Schedule the activeTab + tabOrder updates only when creation
|
||||
// actually happens. These nested setStates are idempotent, so
|
||||
@@ -860,6 +820,25 @@ export const useSessionState = () => {
|
||||
});
|
||||
}, [orphanSessions, workspaces, logViews, setActiveTabId]);
|
||||
|
||||
const createSessionFromCloneSource = useCallback((sourceSession: TerminalSession, options?: {
|
||||
localShellType?: TerminalSession['shellType'];
|
||||
}) => {
|
||||
const newSessionId = crypto.randomUUID();
|
||||
const newSession = createCopiedTerminalSessionClone(sourceSession, {
|
||||
id: newSessionId,
|
||||
localShellType: options?.localShellType,
|
||||
});
|
||||
delete newSession.workspaceId;
|
||||
|
||||
setSessions(prevSessions => {
|
||||
if (prevSessions.some(session => session.id === newSessionId)) return prevSessions;
|
||||
return [...prevSessions, newSession];
|
||||
});
|
||||
setTabOrder(prevTabOrder => [...prevTabOrder, newSessionId]);
|
||||
setActiveTabId(newSessionId);
|
||||
return newSessionId;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
// Toggle broadcast mode for a workspace
|
||||
const toggleBroadcast = useCallback((workspaceId: string) => {
|
||||
setBroadcastWorkspaceIds(prev => {
|
||||
@@ -986,5 +965,6 @@ export const useSessionState = () => {
|
||||
closeLogView,
|
||||
// Copy session
|
||||
copySession,
|
||||
createSessionFromCloneSource,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -25,15 +25,19 @@ import {
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
STORAGE_KEY_EDITOR_WORD_WRAP,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
@@ -68,7 +72,9 @@ import {
|
||||
DEFAULT_LIGHT_UI_THEME,
|
||||
DEFAULT_SESSION_LOGS_ENABLED,
|
||||
DEFAULT_SESSION_LOGS_FORMAT,
|
||||
DEFAULT_SESSION_LOGS_TIMESTAMPS_ENABLED,
|
||||
DEFAULT_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
DEFAULT_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
DEFAULT_SFTP_AUTO_SYNC,
|
||||
DEFAULT_SFTP_DEFAULT_VIEW_MODE,
|
||||
DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR,
|
||||
@@ -77,8 +83,11 @@ import {
|
||||
DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
DEFAULT_SHOW_RECENT_HOSTS,
|
||||
DEFAULT_SHOW_SFTP_TAB,
|
||||
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
|
||||
DEFAULT_TERMINAL_THEME,
|
||||
DEFAULT_THEME,
|
||||
DEFAULT_WINDOW_OPACITY,
|
||||
clampWindowOpacity,
|
||||
applyThemeTokens,
|
||||
areTerminalSettingsEqual,
|
||||
createCustomKeyBindingsSyncOrigin,
|
||||
@@ -200,6 +209,10 @@ export const useSettingsState = () => {
|
||||
const stored = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
||||
return stored === 'true' ? true : DEFAULT_SFTP_AUTO_OPEN_SIDEBAR;
|
||||
});
|
||||
const [sftpFollowTerminalCwd, setSftpFollowTerminalCwd] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD);
|
||||
return stored === 'true' ? true : DEFAULT_SFTP_FOLLOW_TERMINAL_CWD;
|
||||
});
|
||||
const [sftpDefaultViewMode, setSftpDefaultViewMode] = useState<'list' | 'tree'>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
|
||||
return (stored === 'list' || stored === 'tree') ? stored : DEFAULT_SFTP_DEFAULT_VIEW_MODE;
|
||||
@@ -240,6 +253,14 @@ export const useSettingsState = () => {
|
||||
if (stored === 'txt' || stored === 'raw' || stored === 'html') return stored;
|
||||
return DEFAULT_SESSION_LOGS_FORMAT;
|
||||
});
|
||||
const [sessionLogsTimestampsEnabled, setSessionLogsTimestampsEnabled] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED);
|
||||
return stored === 'true' ? true : DEFAULT_SESSION_LOGS_TIMESTAMPS_ENABLED;
|
||||
});
|
||||
const [sshDebugLogsEnabled, setSshDebugLogsEnabled] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED);
|
||||
return stored === 'true' ? true : DEFAULT_SSH_DEBUG_LOGS_ENABLED;
|
||||
});
|
||||
|
||||
// Global Toggle Window Settings (Quake Mode)
|
||||
const [toggleWindowHotkey, setToggleWindowHotkey] = useState<string>(() => {
|
||||
@@ -266,6 +287,19 @@ export const useSettingsState = () => {
|
||||
if (stored === null) return true; // Default to enabled
|
||||
return stored === 'true';
|
||||
});
|
||||
const [windowOpacity, setWindowOpacityState] = useState<number>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_WINDOW_OPACITY);
|
||||
if (stored === null) return DEFAULT_WINDOW_OPACITY;
|
||||
return clampWindowOpacity(stored);
|
||||
});
|
||||
const setWindowOpacity = useCallback((nextValue: SetStateAction<number>) => {
|
||||
setWindowOpacityState((prev) => {
|
||||
const candidate = typeof nextValue === 'function'
|
||||
? (nextValue as (prevState: number) => number)(prev)
|
||||
: nextValue;
|
||||
return clampWindowOpacity(candidate);
|
||||
});
|
||||
}, []);
|
||||
const incomingTerminalSettingsSignatureRef = useRef<string | null>(null);
|
||||
const localTerminalSettingsVersionRef = useRef(0);
|
||||
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
|
||||
@@ -460,6 +494,12 @@ export const useSettingsState = () => {
|
||||
const storedWrap = readStoredString(STORAGE_KEY_EDITOR_WORD_WRAP);
|
||||
if (storedWrap === 'true' || storedWrap === 'false') setEditorWordWrapState(storedWrap === 'true');
|
||||
|
||||
// SSH diagnostics
|
||||
const storedSshDebugLogsEnabled = readStoredString(STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED);
|
||||
if (storedSshDebugLogsEnabled === 'true' || storedSshDebugLogsEnabled === 'false') {
|
||||
setSshDebugLogsEnabled(storedSshDebugLogsEnabled === 'true');
|
||||
}
|
||||
|
||||
// SFTP
|
||||
const storedDblClick = readStoredString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
|
||||
if (storedDblClick === 'open' || storedDblClick === 'transfer') setSftpDoubleClickBehavior(storedDblClick);
|
||||
@@ -471,6 +511,10 @@ export const useSettingsState = () => {
|
||||
if (storedCompress === 'true' || storedCompress === 'false') setSftpUseCompressedUpload(storedCompress === 'true');
|
||||
const storedAutoOpenSidebar = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
||||
if (storedAutoOpenSidebar === 'true' || storedAutoOpenSidebar === 'false') setSftpAutoOpenSidebar(storedAutoOpenSidebar === 'true');
|
||||
const storedFollowTerminalCwd = readStoredString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD);
|
||||
if (storedFollowTerminalCwd === 'true' || storedFollowTerminalCwd === 'false') {
|
||||
setSftpFollowTerminalCwd(storedFollowTerminalCwd === 'true');
|
||||
}
|
||||
const storedDefaultViewMode = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
|
||||
if (storedDefaultViewMode === 'list' || storedDefaultViewMode === 'tree') setSftpDefaultViewMode(storedDefaultViewMode);
|
||||
const storedShowRecentHosts = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
|
||||
@@ -552,12 +596,16 @@ export const useSettingsState = () => {
|
||||
setSessionLogsEnabled,
|
||||
setSessionLogsDir,
|
||||
setSessionLogsFormat,
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDebugLogsEnabled,
|
||||
setHotkeyScheme,
|
||||
applyIncomingCustomKeyBindings,
|
||||
setIsHotkeyRecordingState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setAutoUpdateEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState,
|
||||
@@ -585,19 +633,19 @@ export const useSettingsState = () => {
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpDefaultViewMode,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat,
|
||||
setGlobalHotkeyEnabled, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
});
|
||||
|
||||
@@ -753,6 +801,13 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar);
|
||||
}, [sftpAutoOpenSidebar, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP follow terminal cwd setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD, sftpFollowTerminalCwd ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD, sftpFollowTerminalCwd);
|
||||
}, [sftpFollowTerminalCwd, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP default view mode
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE, sftpDefaultViewMode);
|
||||
@@ -779,10 +834,23 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
|
||||
}, [sessionLogsFormat, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED, sessionLogsTimestampsEnabled ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED, sessionLogsTimestampsEnabled);
|
||||
}, [sessionLogsTimestampsEnabled, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED, sshDebugLogsEnabled ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED, sshDebugLogsEnabled);
|
||||
}, [sshDebugLogsEnabled, notifySettingsChanged]);
|
||||
|
||||
useSystemSettingsEffects({
|
||||
toggleWindowHotkey,
|
||||
globalHotkeyEnabled,
|
||||
closeToTray,
|
||||
windowOpacity,
|
||||
autoUpdateEnabled,
|
||||
persistMountedRef,
|
||||
setHotkeyRegistrationError,
|
||||
@@ -916,6 +984,8 @@ export const useSettingsState = () => {
|
||||
setSftpUseCompressedUpload,
|
||||
sftpAutoOpenSidebar,
|
||||
setSftpAutoOpenSidebar,
|
||||
sftpFollowTerminalCwd,
|
||||
setSftpFollowTerminalCwd,
|
||||
sftpDefaultViewMode,
|
||||
setSftpDefaultViewMode,
|
||||
showRecentHosts,
|
||||
@@ -940,6 +1010,10 @@ export const useSettingsState = () => {
|
||||
setSessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
setSessionLogsFormat,
|
||||
sessionLogsTimestampsEnabled,
|
||||
setSessionLogsTimestampsEnabled,
|
||||
sshDebugLogsEnabled,
|
||||
setSshDebugLogsEnabled,
|
||||
// Global Toggle Window (Quake Mode)
|
||||
toggleWindowHotkey,
|
||||
setToggleWindowHotkey,
|
||||
@@ -950,6 +1024,8 @@ export const useSettingsState = () => {
|
||||
hotkeyRegistrationError,
|
||||
globalHotkeyEnabled,
|
||||
setGlobalHotkeyEnabled,
|
||||
windowOpacity,
|
||||
setWindowOpacity,
|
||||
rehydrateAllFromStorage,
|
||||
reapplyCurrentTheme,
|
||||
workspaceFocusStyle,
|
||||
@@ -961,9 +1037,9 @@ export const useSettingsState = () => {
|
||||
uiFontFamilyId, uiLanguage, customCSS,
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
customThemes, workspaceFocusStyle,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -304,6 +304,7 @@ export const useSftpState = (
|
||||
writeTextFile,
|
||||
writeTextFileByConnection,
|
||||
downloadToTempAndOpen,
|
||||
openWithSystemDefault,
|
||||
uploadExternalFiles,
|
||||
uploadExternalFileList,
|
||||
uploadExternalFolderPath,
|
||||
@@ -383,6 +384,7 @@ export const useSftpState = (
|
||||
writeTextFile,
|
||||
writeTextFileByConnection,
|
||||
downloadToTempAndOpen,
|
||||
openWithSystemDefault,
|
||||
uploadExternalFiles,
|
||||
uploadExternalFileList,
|
||||
uploadExternalFolderPath,
|
||||
@@ -440,6 +442,7 @@ export const useSftpState = (
|
||||
writeTextFile,
|
||||
writeTextFileByConnection,
|
||||
downloadToTempAndOpen,
|
||||
openWithSystemDefault,
|
||||
uploadExternalFiles,
|
||||
uploadExternalFileList,
|
||||
uploadExternalFolderPath,
|
||||
@@ -507,6 +510,8 @@ export const useSftpState = (
|
||||
writeTextFileByConnection: (...args: Parameters<typeof writeTextFileByConnection>) =>
|
||||
methodsRef.current.writeTextFileByConnection(...args),
|
||||
downloadToTempAndOpen: (...args: Parameters<typeof downloadToTempAndOpen>) => methodsRef.current.downloadToTempAndOpen(...args),
|
||||
openWithSystemDefault: (...args: Parameters<typeof openWithSystemDefault>) =>
|
||||
methodsRef.current.openWithSystemDefault(...args),
|
||||
uploadExternalFiles: (...args: Parameters<typeof uploadExternalFiles>) => methodsRef.current.uploadExternalFiles(...args),
|
||||
uploadExternalFileList: (...args: Parameters<typeof uploadExternalFileList>) =>
|
||||
methodsRef.current.uploadExternalFileList(...args),
|
||||
|
||||
@@ -12,6 +12,11 @@ export const useTerminalBackend = () => {
|
||||
return !!bridge?.startMoshSession;
|
||||
}, []);
|
||||
|
||||
const etAvailable = useCallback(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return !!bridge?.startEtSession;
|
||||
}, []);
|
||||
|
||||
const localAvailable = useCallback(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return !!bridge?.startLocalSession;
|
||||
@@ -45,6 +50,12 @@ export const useTerminalBackend = () => {
|
||||
return bridge.startMoshSession(options);
|
||||
}, []);
|
||||
|
||||
const startEtSession = useCallback(async (options: Parameters<NonNullable<NetcattyBridge["startEtSession"]>>[0]) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.startEtSession) throw new Error("startEtSession unavailable");
|
||||
return bridge.startEtSession(options);
|
||||
}, []);
|
||||
|
||||
const startLocalSession = useCallback(async (options: Parameters<NonNullable<NetcattyBridge["startLocalSession"]>>[0]) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.startLocalSession) throw new Error("startLocalSession unavailable");
|
||||
@@ -116,6 +127,11 @@ export const useTerminalBackend = () => {
|
||||
return bridge?.onChainProgress?.(cb);
|
||||
}, []);
|
||||
|
||||
const onConnectionReuseFallback = useCallback((cb: (sessionId: string, sourceSessionId?: string) => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onConnectionReuseFallback?.(cb);
|
||||
}, []);
|
||||
|
||||
const onHostKeyVerification = useCallback((cb: Parameters<NonNullable<NetcattyBridge["onHostKeyVerification"]>>[0]) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onHostKeyVerification?.(cb);
|
||||
@@ -196,6 +212,7 @@ export const useTerminalBackend = () => {
|
||||
backendAvailable,
|
||||
telnetAvailable,
|
||||
moshAvailable,
|
||||
etAvailable,
|
||||
localAvailable,
|
||||
serialAvailable,
|
||||
execAvailable,
|
||||
@@ -203,6 +220,7 @@ export const useTerminalBackend = () => {
|
||||
startSSHSession,
|
||||
startTelnetSession,
|
||||
startMoshSession,
|
||||
startEtSession,
|
||||
startLocalSession,
|
||||
startSerialSession,
|
||||
listSerialPorts,
|
||||
@@ -221,6 +239,7 @@ export const useTerminalBackend = () => {
|
||||
onTelnetAutoLoginComplete,
|
||||
onTelnetAutoLoginCancelled,
|
||||
onChainProgress,
|
||||
onConnectionReuseFallback,
|
||||
onHostKeyVerification,
|
||||
respondHostKeyVerification,
|
||||
openExternal,
|
||||
@@ -229,6 +248,7 @@ export const useTerminalBackend = () => {
|
||||
backendAvailable,
|
||||
telnetAvailable,
|
||||
moshAvailable,
|
||||
etAvailable,
|
||||
localAvailable,
|
||||
serialAvailable,
|
||||
execAvailable,
|
||||
@@ -236,6 +256,7 @@ export const useTerminalBackend = () => {
|
||||
startSSHSession,
|
||||
startTelnetSession,
|
||||
startMoshSession,
|
||||
startEtSession,
|
||||
startLocalSession,
|
||||
startSerialSession,
|
||||
listSerialPorts,
|
||||
@@ -254,6 +275,7 @@ export const useTerminalBackend = () => {
|
||||
onTelnetAutoLoginComplete,
|
||||
onTelnetAutoLoginCancelled,
|
||||
onChainProgress,
|
||||
onConnectionReuseFallback,
|
||||
onHostKeyVerification,
|
||||
respondHostKeyVerification,
|
||||
openExternal,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
export const useTreeExpandedState = (storageKey: string) => {
|
||||
@@ -20,28 +20,40 @@ export const useTreeExpandedState = (storageKey: string) => {
|
||||
localStorageAdapter.writeString(storageKey, JSON.stringify(pathsArray));
|
||||
}, [storageKey, expandedPaths]);
|
||||
|
||||
const togglePath = (path: string) => {
|
||||
const newExpanded = new Set(expandedPaths);
|
||||
if (newExpanded.has(path)) {
|
||||
newExpanded.delete(path);
|
||||
} else {
|
||||
newExpanded.add(path);
|
||||
}
|
||||
setExpandedPaths(newExpanded);
|
||||
};
|
||||
const togglePath = useCallback((path: string) => {
|
||||
setExpandedPaths((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const expandAll = (allPaths: string[]) => {
|
||||
const expandAll = useCallback((allPaths: string[]) => {
|
||||
setExpandedPaths(new Set(allPaths));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const collapseAll = () => {
|
||||
const collapseAll = useCallback(() => {
|
||||
setExpandedPaths(new Set());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const ensurePathExpanded = useCallback((path: string) => {
|
||||
setExpandedPaths((current) => {
|
||||
if (current.has(path)) return current;
|
||||
const next = new Set(current);
|
||||
next.add(path);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
expandedPaths,
|
||||
togglePath,
|
||||
expandAll,
|
||||
collapseAll,
|
||||
ensurePathExpanded,
|
||||
};
|
||||
};
|
||||
@@ -53,13 +53,20 @@ export interface UseUpdateCheckResult {
|
||||
* - Respects dismissed version to avoid nagging
|
||||
* - Provides manual check capability
|
||||
*/
|
||||
export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUpdateCheckResult {
|
||||
export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; onNeedsSave?: () => void }): UseUpdateCheckResult {
|
||||
// Accept auto-update toggle from the caller (e.g. useSettingsState) so it
|
||||
// reacts immediately in the same window. Falls back to reading localStorage
|
||||
// when no caller provides the value (e.g. in non-settings contexts).
|
||||
const autoUpdateEnabled = options?.autoUpdateEnabled ??
|
||||
(localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED) !== 'false');
|
||||
|
||||
// Latest "install blocked by unsaved editors" callback (#1215). Kept in a ref
|
||||
// so the listener effect (empty deps) always calls the current one without
|
||||
// re-subscribing on every render. The consuming component shows the toast;
|
||||
// this hook only owns the bridge subscription (toasts live in the view layer).
|
||||
const onNeedsSaveRef = useRef(options?.onNeedsSave);
|
||||
onNeedsSaveRef.current = options?.onNeedsSave;
|
||||
|
||||
const [updateState, setUpdateState] = useState<UpdateState>({
|
||||
isChecking: false,
|
||||
hasUpdate: false,
|
||||
@@ -252,12 +259,22 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUp
|
||||
}));
|
||||
});
|
||||
|
||||
// Install was requested but blocked by unsaved editors (#1215). The main
|
||||
// process broadcasts this to every window so whichever one the user clicked
|
||||
// "Restart Now" from gets feedback. Delegate to the caller's handler (which
|
||||
// shows the toast) — registered here because bridge subscriptions belong in
|
||||
// the state layer, not in components.
|
||||
const cleanupNeedsSave = bridge?.onUpdateNeedsSave?.(() => {
|
||||
onNeedsSaveRef.current?.();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cleanupNotAvailable?.();
|
||||
cleanupAvailable?.();
|
||||
cleanupProgress?.();
|
||||
cleanupDownloaded?.();
|
||||
cleanupError?.();
|
||||
cleanupNeedsSave?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -109,6 +109,29 @@ const safeParse = <T,>(value: string | null): T | null => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip the bulky `terminalData` replay buffer from transient (unsaved)
|
||||
* connection logs before persisting. `terminalData` is the full terminal
|
||||
* scrollback for a session; with up to 500 logs it grew the
|
||||
* `netcatty_connection_logs_v1` localStorage blob to ~11 MB, and every
|
||||
* add/update re-serialized + wrote the whole thing synchronously
|
||||
* (50–73 ms on the main thread), causing freezes on connect/disconnect.
|
||||
*
|
||||
* The full `terminalData` stays in the in-memory React state (so in-session
|
||||
* replay still works); only explicitly *saved* logs keep it on disk. This
|
||||
* keeps the persisted blob small and writes fast.
|
||||
*/
|
||||
const pruneConnectionLogsForStorage = (logs: ConnectionLog[]): ConnectionLog[] => {
|
||||
let changed = false;
|
||||
const next = logs.map((log) => {
|
||||
if (log.saved || log.terminalData === undefined) return log;
|
||||
changed = true;
|
||||
const { terminalData: _omitted, ...rest } = log;
|
||||
return rest;
|
||||
});
|
||||
return changed ? next : logs;
|
||||
};
|
||||
|
||||
export const useVaultState = () => {
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
@@ -318,7 +341,7 @@ export const useVaultState = () => {
|
||||
const final = [...updated, ...savedLogs].sort(
|
||||
(a, b) => b.startTime - a.startTime
|
||||
);
|
||||
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, final);
|
||||
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, pruneConnectionLogsForStorage(final));
|
||||
return final;
|
||||
});
|
||||
return newLog.id;
|
||||
@@ -332,7 +355,7 @@ export const useVaultState = () => {
|
||||
const updated = prev.map((log) =>
|
||||
log.id === id ? { ...log, ...updates } : log
|
||||
);
|
||||
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, updated);
|
||||
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, pruneConnectionLogsForStorage(updated));
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
@@ -360,7 +383,7 @@ export const useVaultState = () => {
|
||||
const clearUnsavedConnectionLogs = useCallback(() => {
|
||||
setConnectionLogs((prev) => {
|
||||
const saved = prev.filter((log) => log.saved);
|
||||
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, saved);
|
||||
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, pruneConnectionLogsForStorage(saved));
|
||||
return saved;
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -50,6 +50,11 @@ export const useWindowControls = () => {
|
||||
return bridge?.onWindowFullScreenChanged?.(cb) ?? (() => {});
|
||||
}, []);
|
||||
|
||||
const onWindowCommandCloseRequested = useCallback((cb: () => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onWindowCommandCloseRequested?.(cb) ?? (() => {});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
notifyRendererReady,
|
||||
closeSettingsWindow,
|
||||
@@ -60,5 +65,6 @@ export const useWindowControls = () => {
|
||||
isMaximized,
|
||||
isFullscreen,
|
||||
onFullscreenChanged,
|
||||
onWindowCommandCloseRequested,
|
||||
};
|
||||
};
|
||||
|
||||
46
application/state/vaultHostTreeActionsStore.ts
Normal file
46
application/state/vaultHostTreeActionsStore.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import type { Host } from '../../types';
|
||||
|
||||
export interface VaultHostTreeActions {
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
onNewGroup: (parentPath?: string) => void;
|
||||
onRenameGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
commitInlineGroupRename: (name: string) => void;
|
||||
cancelInlineGroupEdit: () => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetParent: string | null) => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
class VaultHostTreeActionsStore {
|
||||
private actions: VaultHostTreeActions | null = null;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getActions = () => this.actions;
|
||||
|
||||
setActions = (actions: VaultHostTreeActions | null) => {
|
||||
this.actions = actions;
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
subscribe = (listener: Listener) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export const vaultHostTreeActionsStore = new VaultHostTreeActionsStore();
|
||||
|
||||
export const useVaultHostTreeActions = () => {
|
||||
return useSyncExternalStore(
|
||||
vaultHostTreeActionsStore.subscribe,
|
||||
vaultHostTreeActionsStore.getActions,
|
||||
vaultHostTreeActionsStore.getActions,
|
||||
);
|
||||
};
|
||||
69
application/state/windowCommandClose.test.ts
Normal file
69
application/state/windowCommandClose.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveWindowCommandCloseIntent } from "./windowCommandClose.ts";
|
||||
|
||||
test("Cmd+W closes the active closable tab first", () => {
|
||||
assert.deepEqual(
|
||||
resolveWindowCommandCloseIntent({
|
||||
activeTabId: "s1",
|
||||
editorTabIds: [],
|
||||
sessionIds: ["s1", "s2"],
|
||||
workspaceIds: [],
|
||||
logViewIds: [],
|
||||
}),
|
||||
{ kind: "closeTab" },
|
||||
);
|
||||
});
|
||||
|
||||
test("Cmd+W on a log view closes the log view", () => {
|
||||
assert.deepEqual(
|
||||
resolveWindowCommandCloseIntent({
|
||||
activeTabId: "log-1",
|
||||
editorTabIds: [],
|
||||
sessionIds: ["s1", "s2"],
|
||||
workspaceIds: [],
|
||||
logViewIds: ["log-1"],
|
||||
}),
|
||||
{ kind: "closeLogView", tabId: "log-1" },
|
||||
);
|
||||
});
|
||||
|
||||
test("Cmd+W closes an editor tab through the existing close flow", () => {
|
||||
assert.deepEqual(
|
||||
resolveWindowCommandCloseIntent({
|
||||
activeTabId: "editor:1",
|
||||
editorTabIds: ["editor:1"],
|
||||
sessionIds: [],
|
||||
workspaceIds: [],
|
||||
logViewIds: [],
|
||||
}),
|
||||
{ kind: "closeTab" },
|
||||
);
|
||||
});
|
||||
|
||||
test("Cmd+W closes the window from the Vault page", () => {
|
||||
assert.deepEqual(
|
||||
resolveWindowCommandCloseIntent({
|
||||
activeTabId: "vault",
|
||||
editorTabIds: [],
|
||||
sessionIds: [],
|
||||
workspaceIds: [],
|
||||
logViewIds: [],
|
||||
}),
|
||||
{ kind: "closeWindow" },
|
||||
);
|
||||
});
|
||||
|
||||
test("Cmd+W closes the window when nothing else is active", () => {
|
||||
assert.deepEqual(
|
||||
resolveWindowCommandCloseIntent({
|
||||
activeTabId: null,
|
||||
editorTabIds: [],
|
||||
sessionIds: [],
|
||||
workspaceIds: [],
|
||||
logViewIds: [],
|
||||
}),
|
||||
{ kind: "closeWindow" },
|
||||
);
|
||||
});
|
||||
42
application/state/windowCommandClose.ts
Normal file
42
application/state/windowCommandClose.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export type WindowCommandCloseIntent =
|
||||
| { kind: 'closeTab' }
|
||||
| { kind: 'closeLogView'; tabId: string }
|
||||
| { kind: 'closeWindow' };
|
||||
|
||||
interface ResolveWindowCommandCloseIntentInput {
|
||||
activeTabId: string | null;
|
||||
editorTabIds: string[];
|
||||
sessionIds: string[];
|
||||
workspaceIds: string[];
|
||||
logViewIds: string[];
|
||||
}
|
||||
|
||||
export function resolveWindowCommandCloseIntent({
|
||||
activeTabId,
|
||||
editorTabIds,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
logViewIds,
|
||||
}: ResolveWindowCommandCloseIntentInput): WindowCommandCloseIntent {
|
||||
if (!activeTabId) {
|
||||
return { kind: 'closeWindow' };
|
||||
}
|
||||
|
||||
if (editorTabIds.includes(activeTabId)) {
|
||||
return { kind: 'closeTab' };
|
||||
}
|
||||
|
||||
if (sessionIds.includes(activeTabId) || workspaceIds.includes(activeTabId)) {
|
||||
return { kind: 'closeTab' };
|
||||
}
|
||||
|
||||
if (logViewIds.includes(activeTabId)) {
|
||||
return { kind: 'closeLogView', tabId: activeTabId };
|
||||
}
|
||||
|
||||
if (activeTabId === 'vault' || activeTabId === 'sftp') {
|
||||
return { kind: 'closeWindow' };
|
||||
}
|
||||
|
||||
return { kind: 'closeWindow' };
|
||||
}
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
|
||||
STORAGE_KEY_CUSTOM_THEMES,
|
||||
@@ -190,7 +191,7 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'showServerStats',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'showServerStats', 'showLineTimestamps',
|
||||
'serverStatsRefreshInterval', 'rendererType',
|
||||
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
|
||||
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
|
||||
@@ -220,6 +221,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
@@ -386,6 +388,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (compress === 'true' || compress === 'false') settings.sftpUseCompressedUpload = compress === 'true';
|
||||
const autoOpenSidebar = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
||||
if (autoOpenSidebar === 'true' || autoOpenSidebar === 'false') settings.sftpAutoOpenSidebar = autoOpenSidebar === 'true';
|
||||
const followTerminalCwd = localStorageAdapter.readString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD);
|
||||
if (followTerminalCwd === 'true' || followTerminalCwd === 'false') settings.sftpFollowTerminalCwd = followTerminalCwd === 'true';
|
||||
const defaultViewMode = localStorageAdapter.readString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
|
||||
if (defaultViewMode === 'list' || defaultViewMode === 'tree') settings.sftpDefaultViewMode = defaultViewMode;
|
||||
|
||||
@@ -512,6 +516,7 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
if (settings.sftpShowHiddenFiles != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, String(settings.sftpShowHiddenFiles));
|
||||
if (settings.sftpUseCompressedUpload != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, String(settings.sftpUseCompressedUpload));
|
||||
if (settings.sftpAutoOpenSidebar != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, String(settings.sftpAutoOpenSidebar));
|
||||
if (settings.sftpFollowTerminalCwd != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD, String(settings.sftpFollowTerminalCwd));
|
||||
if (settings.sftpDefaultViewMode != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE, settings.sftpDefaultViewMode);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Trash2, X } from 'lucide-react';
|
||||
import type { AISession } from '../infrastructure/ai/types';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
@@ -19,6 +19,9 @@ interface SessionHistoryDrawerProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SESSION_RENDER_BATCH = 80;
|
||||
const SESSION_RENDER_STEP = 60;
|
||||
|
||||
export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
@@ -27,6 +30,15 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [renderCount, setRenderCount] = useState(SESSION_RENDER_BATCH);
|
||||
|
||||
useEffect(() => {
|
||||
setRenderCount(SESSION_RENDER_BATCH);
|
||||
}, [sessions]);
|
||||
|
||||
const displayedSessions = sessions.slice(0, renderCount);
|
||||
const hiddenSessionCount = Math.max(0, sessions.length - renderCount);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="px-4 py-2.5 flex items-center justify-between shrink-0 border-b border-border/30">
|
||||
@@ -47,7 +59,17 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((session) => {
|
||||
<>
|
||||
{hiddenSessionCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRenderCount((count) => count + SESSION_RENDER_STEP)}
|
||||
className="w-full py-2 text-center text-[12px] text-muted-foreground/50 hover:text-muted-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
{t('ai.chat.loadMoreSessions').replace('{n}', String(hiddenSessionCount))}
|
||||
</button>
|
||||
)}
|
||||
{displayedSessions.map((session) => {
|
||||
const isActive = session.id === activeSessionId;
|
||||
const time = new Date(session.updatedAt);
|
||||
const timeStr = formatRelativeTime(time, t);
|
||||
@@ -85,7 +107,8 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useDeferredValue, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useWindowControls } from '../application/state/useWindowControls';
|
||||
import type {
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
} from '../infrastructure/ai/types';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
import { getAgentModelPresets } from '../infrastructure/ai/types';
|
||||
import { matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
|
||||
import { getExternalAgentSdkBackend, matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
|
||||
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
|
||||
import {
|
||||
getReadyUserSkillOptions,
|
||||
@@ -29,15 +29,20 @@ import {
|
||||
endDraftSend,
|
||||
tryBeginDraftSend,
|
||||
} from './ai/draftSendGate';
|
||||
import { getSessionScopeMatchRank } from './ai/sessionScopeMatch';
|
||||
import { selectDraftForAgentSwitch } from '../application/state/aiDraftState';
|
||||
import {
|
||||
buildPromptWithTerminalSelectionAttachments,
|
||||
isTerminalSelectionAttachment,
|
||||
} from '../application/state/terminalSelectionAttachment';
|
||||
import type { CodexIntegrationStatus } from './settings/tabs/ai/types';
|
||||
import {
|
||||
useAIChatStreaming,
|
||||
getNetcattyBridge,
|
||||
isAIChatSessionStreaming,
|
||||
type DefaultTargetSessionHint,
|
||||
} from './ai/hooks/useAIChatStreaming';
|
||||
import { buildAcpHistoryMessagesForBridge } from './ai/acpHistory';
|
||||
import { getScopedHistorySessions } from './ai/scopedHistorySessions';
|
||||
import { buildExternalAgentHistoryMessagesForBridge } from './ai/externalAgentHistory';
|
||||
import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligibility';
|
||||
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
|
||||
import { useConversationExport } from './ai/hooks/useConversationExport';
|
||||
@@ -45,7 +50,16 @@ import type { AIChatSidePanelProps } from './AIChatSidePanel.types';
|
||||
import { generateId, isCopilotAgentConfig, modelPresetsContainId } from './AIChatSidePanelHelpers';
|
||||
import { AIChatPanelContent } from './AIChatPanelContent';
|
||||
|
||||
const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
function shouldKeepAIChatSidePanelMounted(props: AIChatSidePanelProps): boolean {
|
||||
if (props.isVisible ?? true) {
|
||||
return true;
|
||||
}
|
||||
const scopeKey = `${props.scopeType}:${props.scopeTargetId ?? ''}`;
|
||||
const sessionId = props.activeSessionIdMap[scopeKey] ?? null;
|
||||
return isAIChatSessionStreaming(sessionId);
|
||||
}
|
||||
|
||||
const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
sessions,
|
||||
activeSessionIdMap,
|
||||
draftsByScope,
|
||||
@@ -130,23 +144,16 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
return sessionIds;
|
||||
}, [activeSessionIdMap, scopeKey]);
|
||||
|
||||
const deferredSessions = useDeferredValue(sessions);
|
||||
const historySessions = useMemo(
|
||||
() =>
|
||||
sessions
|
||||
.map((session) => ({
|
||||
session,
|
||||
matchRank: getSessionScopeMatchRank(
|
||||
session,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
activeTerminalSessionIds,
|
||||
),
|
||||
}))
|
||||
.filter(({ matchRank }) => matchRank > 0)
|
||||
.sort((a, b) => b.matchRank - a.matchRank || b.session.updatedAt - a.session.updatedAt)
|
||||
.map(({ session }) => session),
|
||||
[sessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalSessionIds],
|
||||
() => getScopedHistorySessions(
|
||||
deferredSessions,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
activeTerminalSessionIds,
|
||||
),
|
||||
[deferredSessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalSessionIds],
|
||||
);
|
||||
|
||||
const explicitPanelView = panelViewByScope[scopeKey];
|
||||
@@ -197,16 +204,24 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}, [terminalSessions, scopeType, scopeTargetId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
const bridge = getNetcattyBridge();
|
||||
if (bridge?.aiMcpUpdateSessions) {
|
||||
if (!bridge?.aiMcpUpdateSessions) return;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void bridge.aiMcpUpdateSessions(terminalSessions, activeSessionId ?? undefined);
|
||||
}
|
||||
}, [terminalSessions, scopeKey, activeSessionId]);
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [isVisible, terminalSessions, activeSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
if (!explicitPanelView || normalizedPanelView === explicitPanelView) return;
|
||||
showDraftView(scopeKey);
|
||||
}, [normalizedPanelView, explicitPanelView, scopeKey, showDraftView]);
|
||||
}, [isVisible, normalizedPanelView, explicitPanelView, scopeKey, showDraftView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSession) return;
|
||||
@@ -338,30 +353,27 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}, [isVisible, scopeKey, toolIntegrationMode, updateScopeDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
const bridge = getNetcattyBridge();
|
||||
if (bridge?.aiSyncProviders && providers.length > 0) {
|
||||
void bridge.aiSyncProviders(providers);
|
||||
}
|
||||
}, [providers]);
|
||||
}, [isVisible, providers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
const bridge = getNetcattyBridge();
|
||||
if (bridge?.aiSyncWebSearch) {
|
||||
void bridge.aiSyncWebSearch(webSearchConfig?.apiHost || null, webSearchConfig?.apiKey || null);
|
||||
}
|
||||
}, [webSearchConfig?.apiHost, webSearchConfig?.apiKey, webSearchConfig?.enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
};
|
||||
}, []);
|
||||
}, [isVisible, webSearchConfig?.apiHost, webSearchConfig?.apiKey, webSearchConfig?.enabled]);
|
||||
|
||||
const {
|
||||
discoveredAgents,
|
||||
isDiscovering,
|
||||
rediscover,
|
||||
enableAgent,
|
||||
} = useAgentDiscovery(externalAgents, setExternalAgents);
|
||||
} = useAgentDiscovery(externalAgents, setExternalAgents, { enabled: isVisible });
|
||||
|
||||
const handleEnableDiscoveredAgent = useCallback(
|
||||
(agent: DiscoveredAgent) => {
|
||||
@@ -452,6 +464,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
const [codexConfigModel, setCodexConfigModel] = useState<string | null>(null);
|
||||
const [codexCustomConfigResolved, setCodexCustomConfigResolved] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
setCodexCustomConfigResolved(false);
|
||||
if (!isCodexManagedAgent) {
|
||||
setCodexConfigModel(null);
|
||||
@@ -474,22 +487,23 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [isCodexManagedAgent, currentAgentId]);
|
||||
}, [isVisible, isCodexManagedAgent, currentAgentId]);
|
||||
|
||||
const agentModelMapRef = useRef(agentModelMap);
|
||||
agentModelMapRef.current = agentModelMap;
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentAgentConfig?.acpCommand) return;
|
||||
if (!isVisible) return;
|
||||
const sdkBackend = getExternalAgentSdkBackend(currentAgentConfig);
|
||||
if (!sdkBackend) return;
|
||||
if (!isCopilotExternalAgent && !isClaudeManagedAgent && !isCodexManagedAgent) return;
|
||||
|
||||
const bridge = getNetcattyBridge();
|
||||
if (!bridge?.aiAcpListModels) return;
|
||||
if (!bridge?.aiSdkAgentListModels) return;
|
||||
|
||||
let cancelled = false;
|
||||
void bridge.aiAcpListModels(
|
||||
currentAgentConfig.acpCommand,
|
||||
currentAgentConfig.acpArgs || [],
|
||||
void bridge.aiSdkAgentListModels(
|
||||
sdkBackend,
|
||||
undefined,
|
||||
undefined,
|
||||
`models_${currentAgentId}`,
|
||||
@@ -515,14 +529,14 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
}).catch((err) => {
|
||||
if (!cancelled) {
|
||||
console.warn('[AIChatSidePanel] Failed to load ACP agent models:', err);
|
||||
console.warn('[AIChatSidePanel] Failed to load SDK agent models:', err);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, isClaudeManagedAgent, isCodexManagedAgent, setAgentModel]);
|
||||
}, [isVisible, currentAgentConfig, currentAgentId, isCopilotExternalAgent, isClaudeManagedAgent, isCodexManagedAgent, setAgentModel]);
|
||||
|
||||
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent;
|
||||
|
||||
@@ -650,18 +664,24 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
const currentSessionView = activeSessionRef.current;
|
||||
const trimmed = draft?.text.trim() ?? '';
|
||||
const sendScopeKey = scopeKey;
|
||||
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,
|
||||
mediaType: file.mediaType,
|
||||
filename: file.filename,
|
||||
filePath: file.filePath,
|
||||
terminalSelection: file.terminalSelection,
|
||||
previewText: file.previewText,
|
||||
lineCount: file.lineCount,
|
||||
}));
|
||||
const hasTerminalSelectionAttachments = attachments.some(isTerminalSelectionAttachment);
|
||||
if ((!trimmed && !hasTerminalSelectionAttachments) || 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 modelPrompt = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
|
||||
const modelAttachments = attachments.filter((attachment) => !isTerminalSelectionAttachment(attachment));
|
||||
const isDraftMode = currentPanelView.mode === 'draft';
|
||||
|
||||
if (isDraftMode && !tryBeginDraftSend(draftSendInFlightRef)) {
|
||||
@@ -691,7 +711,11 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
const sendActiveModelId = isExternalAgent ? activeModelId : effectiveActiveModelId;
|
||||
|
||||
if (!isExternalAgent && !sendActiveProvider) {
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'user', content: trimmed,
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
|
||||
if (currentPanelView.mode === 'session') {
|
||||
clearScopeDraft();
|
||||
@@ -701,7 +725,11 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
|
||||
if (!isExternalAgent && !sendActiveModelId.trim()) {
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'user', content: trimmed,
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProviderModel'), timestamp: Date.now() });
|
||||
if (currentPanelView.mode === 'session') {
|
||||
clearScopeDraft();
|
||||
@@ -741,10 +769,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
try {
|
||||
const existingExternalSessionId = currentSession?.externalSessionId;
|
||||
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachments, {
|
||||
await sendToExternalAgent(sessionId, modelPrompt, agentConfig, abortController, modelAttachments, {
|
||||
existingSessionId: existingExternalSessionId,
|
||||
updateExternalSessionId: updateSessionExternalSessionId,
|
||||
historyMessages: buildAcpHistoryMessagesForBridge(currentSession?.messages ?? [], existingExternalSessionId),
|
||||
historyMessages: buildExternalAgentHistoryMessagesForBridge(currentSession?.messages ?? [], existingExternalSessionId),
|
||||
terminalSessions,
|
||||
defaultTargetSession,
|
||||
providers,
|
||||
@@ -765,7 +793,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
targetId: scopeTargetId,
|
||||
label: scopeLabel,
|
||||
} as const;
|
||||
await sendToCattyAgent(sessionId, sendScopeKey, trimmed, abortController, currentSession ?? undefined, assistantMsgId, {
|
||||
await sendToCattyAgent(sessionId, sendScopeKey, modelPrompt, abortController, currentSession ?? undefined, assistantMsgId, {
|
||||
activeProvider: sendActiveProvider,
|
||||
activeModelId: sendActiveModelId,
|
||||
scopeType,
|
||||
@@ -778,7 +806,8 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
getExecutorContext: () => buildExecutorContextForScope(toolScope),
|
||||
autoTitleSession,
|
||||
selectedUserSkillSlugs: selectedSkillSlugs,
|
||||
}, attachments.length > 0 ? attachments : undefined);
|
||||
titleText: trimmed,
|
||||
}, modelAttachments.length > 0 ? modelAttachments : undefined);
|
||||
}
|
||||
} finally {
|
||||
if (isDraftMode) {
|
||||
@@ -811,7 +840,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
clearAllPendingApprovals(activeSessionId);
|
||||
const bridge = getNetcattyBridge();
|
||||
bridge?.aiCattyCancelExec?.(activeSessionId);
|
||||
bridge?.aiAcpCancel?.('', activeSessionId);
|
||||
bridge?.aiSdkAgentCancel?.('', activeSessionId);
|
||||
}, [activeSessionId, setStreamingForScope, updateLastMessage, abortControllersRef]);
|
||||
|
||||
const handleSelectSession = useCallback(
|
||||
@@ -847,8 +876,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}, [ensureScopeDraft, showScopeDraftView, updateScopeDraft]);
|
||||
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<AIChatPanelContent
|
||||
t={t}
|
||||
@@ -900,7 +927,81 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
};
|
||||
|
||||
|
||||
const AIChatSidePanel = React.memo(AIChatSidePanelInner);
|
||||
const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
|
||||
'sessions',
|
||||
'activeSessionIdMap',
|
||||
'draftsByScope',
|
||||
'panelViewByScope',
|
||||
'setActiveSessionId',
|
||||
'ensureDraftForScope',
|
||||
'updateDraft',
|
||||
'showDraftView',
|
||||
'showSessionView',
|
||||
'clearDraftForScope',
|
||||
'addDraftFiles',
|
||||
'removeDraftFile',
|
||||
'createSession',
|
||||
'deleteSession',
|
||||
'updateSessionTitle',
|
||||
'updateSessionExternalSessionId',
|
||||
'addMessageToSession',
|
||||
'updateLastMessage',
|
||||
'updateMessageById',
|
||||
'providers',
|
||||
'activeProviderId',
|
||||
'activeModelId',
|
||||
'defaultAgentId',
|
||||
'toolIntegrationMode',
|
||||
'externalAgents',
|
||||
'setExternalAgents',
|
||||
'agentModelMap',
|
||||
'setAgentModel',
|
||||
'agentProviderMap',
|
||||
'setAgentProvider',
|
||||
'globalPermissionMode',
|
||||
'setGlobalPermissionMode',
|
||||
'commandBlocklist',
|
||||
'maxIterations',
|
||||
'webSearchConfig',
|
||||
] as const satisfies readonly (keyof AIChatSidePanelProps)[];
|
||||
|
||||
function aiChatSidePanelPropsAreEqual(
|
||||
prev: AIChatSidePanelProps,
|
||||
next: AIChatSidePanelProps,
|
||||
): boolean {
|
||||
const prevKeep = shouldKeepAIChatSidePanelMounted(prev);
|
||||
const nextKeep = shouldKeepAIChatSidePanelMounted(next);
|
||||
if (!prevKeep && !nextKeep) {
|
||||
return true;
|
||||
}
|
||||
if (prevKeep !== nextKeep) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.scopeType !== next.scopeType) return false;
|
||||
if (prev.scopeTargetId !== next.scopeTargetId) return false;
|
||||
if (prev.scopeLabel !== next.scopeLabel) return false;
|
||||
if (prev.scopeHostIds !== next.scopeHostIds) return false;
|
||||
if (prev.terminalSessions !== next.terminalSessions) return false;
|
||||
if (prev.resolveExecutorContext !== next.resolveExecutorContext) return false;
|
||||
|
||||
for (const key of AI_CHAT_SIDE_PANEL_AI_STATE_KEYS) {
|
||||
if (prev[key] !== next[key]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const AIChatSidePanel = React.memo(function AIChatSidePanel(props: AIChatSidePanelProps) {
|
||||
// Keep every mounted AI panel alive — the parent (AIChatPanelsHost) only hides
|
||||
// inactive tabs via CSS, mirroring the SFTP/Scripts/Theme panels. Returning
|
||||
// null here used to tear down the whole subtree on each top-tab switch, which
|
||||
// forced the Streamdown-backed message list to re-parse + re-highlight up to
|
||||
// 50 messages synchronously on every switch (the source of the jank). Effects
|
||||
// inside AIChatSidePanelActive are gated by `isVisible`, and re-renders for
|
||||
// hidden, non-streaming panels are skipped by `aiChatSidePanelPropsAreEqual`,
|
||||
// so staying mounted is cheap while eliminating the remount cost.
|
||||
return <AIChatSidePanelActive {...props} />;
|
||||
}, aiChatSidePanelPropsAreEqual);
|
||||
AIChatSidePanel.displayName = 'AIChatSidePanel';
|
||||
|
||||
export default AIChatSidePanel;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentModelPreset, ExternalAgentConfig } from '../infrastructure/ai/types';
|
||||
import { getExternalAgentSdkBackend } from '../infrastructure/ai/managedAgents';
|
||||
|
||||
export function modelPresetMatchesId(preset: AgentModelPreset, modelId: string): boolean {
|
||||
if (preset.thinkingLevels?.length) {
|
||||
@@ -18,7 +19,7 @@ export function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
|
||||
agent.name,
|
||||
agent.icon,
|
||||
agent.command,
|
||||
agent.acpCommand,
|
||||
getExternalAgentSdkBackend(agent),
|
||||
]
|
||||
.filter((value): value is string => typeof value === 'string' && value.length > 0)
|
||||
.map((value) => value.split('/').pop()?.toLowerCase() ?? value.toLowerCase());
|
||||
|
||||
@@ -18,6 +18,7 @@ export const DISTRO_LOGOS: Record<string, string> = {
|
||||
oracle: "/distro/oracle.svg",
|
||||
kali: "/distro/kali.svg",
|
||||
almalinux: "/distro/almalinux.svg",
|
||||
alinux: "/distro/alinux.svg",
|
||||
// OS-level logos (used by local terminal tab icons)
|
||||
macos: "/distro/macos.svg",
|
||||
windows: "/distro/windows.svg",
|
||||
@@ -48,6 +49,7 @@ export const DISTRO_COLORS: Record<string, string> = {
|
||||
oracle: "bg-[#C74634]",
|
||||
kali: "bg-[#0F6DB3]",
|
||||
almalinux: "bg-[#173B66]",
|
||||
alinux: "bg-[#FF6A00]",
|
||||
// OS-level colors
|
||||
macos: "bg-[#333333]",
|
||||
windows: "bg-[#0078D4]",
|
||||
@@ -69,7 +71,8 @@ type DistroAvatarProps = {
|
||||
host: Host;
|
||||
fallback: string;
|
||||
className?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
/** xs matches top tab bar icons (h-4 rounded rect) */
|
||||
size?: "xs" | "sm" | "md" | "lg";
|
||||
};
|
||||
|
||||
const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
@@ -83,16 +86,18 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
const [errored, setErrored] = React.useState(false);
|
||||
const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default;
|
||||
|
||||
// Size variants - all use rounded corners for consistency
|
||||
// Size variants — rounded rects (same corner style as SessionTabIcon in TopTabItems)
|
||||
const sizeClasses = {
|
||||
sm: "h-6 w-6 rounded",
|
||||
md: "h-11 w-11 rounded-lg",
|
||||
lg: "h-14 w-14 rounded-xl",
|
||||
xs: "h-4 w-4 rounded",
|
||||
sm: "h-5 w-5 rounded",
|
||||
md: "h-8 w-8 rounded",
|
||||
lg: "h-11 w-11 rounded",
|
||||
};
|
||||
const iconSizes = {
|
||||
sm: "h-3.5 w-3.5",
|
||||
md: "h-5 w-5",
|
||||
lg: "h-6 w-6",
|
||||
xs: "h-2.5 w-2.5",
|
||||
sm: "h-3 w-3",
|
||||
md: "h-4 w-4",
|
||||
lg: "h-5 w-5",
|
||||
};
|
||||
|
||||
const containerClass = sizeClasses[size];
|
||||
@@ -103,8 +108,8 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded flex items-center justify-center bg-amber-500/15 text-amber-500",
|
||||
containerClass,
|
||||
"flex items-center justify-center bg-amber-500/15 text-amber-500",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -117,8 +122,8 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded flex items-center justify-center overflow-hidden",
|
||||
containerClass,
|
||||
"flex items-center justify-center overflow-hidden",
|
||||
bg,
|
||||
className,
|
||||
)}
|
||||
@@ -136,8 +141,8 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded flex items-center justify-center bg-primary/15 text-primary",
|
||||
containerClass,
|
||||
"flex items-center justify-center bg-primary/15 text-primary",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* FileOpenerDialog - Dialog for choosing how to open a file
|
||||
*/
|
||||
import { Edit2, FolderOpen } from 'lucide-react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import type { FileOpenerType, SystemAppInfo } from '../lib/sftpFileUtils';
|
||||
import { getFileExtension, isKnownBinaryFile } from '../lib/sftpFileUtils';
|
||||
import { getFileExtension, hasFileExtension, isKnownBinaryFile } from '../lib/sftpFileUtils';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
|
||||
@@ -26,13 +26,17 @@ const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [isSelectingApp, setIsSelectingApp] = useState(false);
|
||||
const [rememberChoice, setRememberChoice] = useState(true);
|
||||
const [rememberChoice, setRememberChoice] = useState(() => hasFileExtension(fileName));
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRememberChoice(hasFileExtension(fileName));
|
||||
}
|
||||
}, [open, fileName]);
|
||||
|
||||
const extension = getFileExtension(fileName);
|
||||
// Show edit option for files that are not known binary formats
|
||||
const canEdit = !isKnownBinaryFile(fileName);
|
||||
// For files without extension, we use 'file' as virtual extension
|
||||
// So we always allow setting default (hasExtension is always true)
|
||||
const displayExtension = extension === 'file' ? t('sftp.opener.noExtension') : `.${extension}`;
|
||||
|
||||
const handleSelectBuiltIn = useCallback((openerType: FileOpenerType) => {
|
||||
|
||||
@@ -13,7 +13,12 @@ import React, { useCallback, useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { customThemeStore } from "../application/state/customThemeStore";
|
||||
import { resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/groupConfig";
|
||||
import { isCompleteProxyConfig, normalizeManualProxyConfig } from "../domain/proxyProfiles";
|
||||
import {
|
||||
formatProxyConfigEndpoint,
|
||||
formatProxyConfigType,
|
||||
isCompleteProxyConfig,
|
||||
normalizeManualProxyConfig,
|
||||
} from "../domain/proxyProfiles";
|
||||
import {
|
||||
EnvVar,
|
||||
GroupConfig,
|
||||
@@ -103,6 +108,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
!!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.legacyAlgorithms !== undefined || c.skipEcdsaHostKey !== undefined || c.algorithms !== undefined || c.backspaceBehavior !== undefined ||
|
||||
(c.environmentVariables && c.environmentVariables.length > 0) ||
|
||||
c.moshEnabled !== undefined || !!c.moshServerPath ||
|
||||
c.etEnabled !== undefined || c.etPort !== undefined ||
|
||||
(c.identityFilePaths && c.identityFilePaths.length > 0);
|
||||
const hasTelnetFields = (c: Partial<GroupConfig>) =>
|
||||
c.telnetPort !== undefined || !!c.telnetUsername || !!c.telnetPassword || c.telnetEnabled === true;
|
||||
@@ -137,7 +143,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
? t("hostDetails.proxyPanel.missingSaved")
|
||||
: selectedProxyProfile
|
||||
? selectedProxyProfile.label
|
||||
: `${form.proxyConfig?.type?.toUpperCase()} ${form.proxyConfig?.host}:${form.proxyConfig?.port}`;
|
||||
: `${formatProxyConfigType(form.proxyConfig)} ${formatProxyConfigEndpoint(form.proxyConfig)}`;
|
||||
|
||||
const update = <K extends keyof GroupConfig>(key: K, value: GroupConfig[K] | undefined) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
@@ -171,6 +177,8 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
delete next.protocol;
|
||||
delete next.moshEnabled;
|
||||
delete next.moshServerPath;
|
||||
delete next.etEnabled;
|
||||
delete next.etPort;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
@@ -391,6 +399,8 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
...(form.environmentVariables !== undefined && { environmentVariables: form.environmentVariables }),
|
||||
...(form.moshEnabled !== undefined && { moshEnabled: form.moshEnabled }),
|
||||
...(form.moshServerPath !== undefined && { moshServerPath: form.moshServerPath }),
|
||||
...(form.etEnabled !== undefined && { etEnabled: form.etEnabled }),
|
||||
...(form.etPort !== undefined && { etPort: form.etPort }),
|
||||
}),
|
||||
// Only include Telnet fields if Telnet section is enabled
|
||||
...(telnetEnabled && {
|
||||
|
||||
@@ -449,7 +449,7 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
|
||||
<span className="text-sm">{t("hostDetails.proxy")}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{(form.proxyConfig?.host || form.proxyProfileId) && (
|
||||
{(form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="min-w-0 cursor-default">
|
||||
@@ -523,6 +523,25 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* EternalTerminal */}
|
||||
<ToggleRow
|
||||
label="EternalTerminal"
|
||||
enabled={!!form.etEnabled}
|
||||
onToggle={() => update("etEnabled", !form.etEnabled)}
|
||||
/>
|
||||
{form.etEnabled && (
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t("hostDetails.et.port") || "ET server port (2022)"}
|
||||
value={form.etPort ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
update("etPort", v === "" ? undefined : Number(v));
|
||||
}}
|
||||
className="h-10"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Backspace behavior — terminal input mapping, lives at the
|
||||
bottom of the SSH section so it doesn't get visually
|
||||
grouped with the algorithm controls above. */}
|
||||
|
||||
@@ -175,6 +175,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
moshEnabled: true,
|
||||
etEnabled: false,
|
||||
deviceType: prev.deviceType === 'network' ? undefined : prev.deviceType,
|
||||
x11Forwarding: undefined,
|
||||
}));
|
||||
@@ -185,6 +186,46 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
/>
|
||||
</HostDetailsSection>
|
||||
|
||||
<HostDetailsSection
|
||||
icon={<Wifi size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.et")}
|
||||
>
|
||||
<ToggleRow
|
||||
label="EternalTerminal"
|
||||
enabled={!!form.etEnabled}
|
||||
onToggle={() => {
|
||||
const enabling = !form.etEnabled;
|
||||
if (enabling) {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
etEnabled: true,
|
||||
moshEnabled: false,
|
||||
deviceType: prev.deviceType === 'network' ? undefined : prev.deviceType,
|
||||
x11Forwarding: undefined,
|
||||
}));
|
||||
} else {
|
||||
update("etEnabled", false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{form.etEnabled && (
|
||||
<>
|
||||
<HostDetailsSettingRow label={t("hostDetails.et.port")} hint={t("hostDetails.et.port.desc")}>
|
||||
<Input
|
||||
type="number"
|
||||
className="w-28"
|
||||
placeholder="2022"
|
||||
value={form.etPort ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
update("etPort", v === "" ? undefined : Number(v));
|
||||
}}
|
||||
/>
|
||||
</HostDetailsSettingRow>
|
||||
</>
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* Agent Forwarding */}
|
||||
<HostDetailsSection
|
||||
icon={<Forward size={14} className="text-muted-foreground" />}
|
||||
@@ -212,7 +253,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* X11 Forwarding */}
|
||||
{(!form.protocol || form.protocol === "ssh") && !form.moshEnabled && (
|
||||
{(!form.protocol || form.protocol === "ssh") && !form.moshEnabled && !form.etEnabled && (
|
||||
<HostDetailsSection
|
||||
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.x11Forwarding")}
|
||||
@@ -226,8 +267,8 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
</HostDetailsSection>
|
||||
)}
|
||||
|
||||
{/* Network Device Mode — only for SSH hosts without Mosh (serial already uses raw mode) */}
|
||||
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && (
|
||||
{/* Network Device Mode — only for SSH hosts without Mosh / ET (serial already uses raw mode) */}
|
||||
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && !form.etEnabled && (
|
||||
<HostDetailsSection
|
||||
icon={<Router size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.deviceType")}
|
||||
@@ -481,7 +522,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
title={t("hostDetails.proxy")}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{form.proxyConfig?.host || form.proxyProfileId ? (
|
||||
{form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId ? (
|
||||
<div className="w-full min-w-0 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -45,7 +45,8 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
|
||||
distroOptions,
|
||||
effectiveFormDistro,
|
||||
getDistroOptionLabel,
|
||||
}) => (
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<HostDetailsSection
|
||||
icon={<MapPin size={14} className="text-muted-foreground" />}
|
||||
@@ -732,4 +733,5 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
|
||||
</HostDetailsSection>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,40 @@ import { LINUX_DISTRO_OPTIONS, NETWORK_DEVICE_OPTIONS } from "../domain/host";
|
||||
export const parseOptionalPortInput = (value: string): number | undefined =>
|
||||
value ? Number(value) : undefined;
|
||||
|
||||
export const resolvePrimaryProtocolSwitchPort = (
|
||||
currentPort: number | undefined,
|
||||
nextProtocol: "ssh" | "telnet",
|
||||
hasGroupTelnetPortDefault: boolean,
|
||||
hasGroupSshPortDefault: boolean,
|
||||
): number | undefined => {
|
||||
if (nextProtocol === "telnet") {
|
||||
// Don't override if group provides a Telnet default
|
||||
if (hasGroupTelnetPortDefault || hasGroupSshPortDefault) return currentPort;
|
||||
if (currentPort === 22 || currentPort === undefined) return 23;
|
||||
return currentPort;
|
||||
}
|
||||
if (nextProtocol === "ssh") {
|
||||
if (hasGroupSshPortDefault) return currentPort;
|
||||
if (currentPort === 23 || currentPort === undefined) return 22;
|
||||
return currentPort;
|
||||
}
|
||||
return currentPort;
|
||||
};
|
||||
|
||||
export const resolvePrimaryProtocolSavePort = (
|
||||
protocol: Host["protocol"],
|
||||
currentPort: number | undefined,
|
||||
hasGroupSshPortDefault: boolean,
|
||||
hasGroupTelnetPortDefault: boolean,
|
||||
): number | undefined => {
|
||||
if (protocol === "telnet") {
|
||||
if (currentPort !== undefined) return currentPort;
|
||||
if (hasGroupTelnetPortDefault || hasGroupSshPortDefault) return undefined;
|
||||
return 23;
|
||||
}
|
||||
return currentPort ?? (hasGroupSshPortDefault ? undefined : 22);
|
||||
};
|
||||
|
||||
export const resolveDetailsTelnetPort = (
|
||||
host: Host,
|
||||
groupDefaults?: Partial<GroupConfig>,
|
||||
|
||||
@@ -6,6 +6,10 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import type { Host } from "../types.ts";
|
||||
import HostDetailsPanel, { parseOptionalPortInput } from "./HostDetailsPanel.tsx";
|
||||
import {
|
||||
resolvePrimaryProtocolSavePort,
|
||||
resolvePrimaryProtocolSwitchPort,
|
||||
} from "./HostDetailsPanel.helpers.ts";
|
||||
import { TooltipProvider } from "./ui/tooltip.tsx";
|
||||
|
||||
const hostWithMissingProxyProfile: Host = {
|
||||
@@ -67,6 +71,24 @@ test("HostDetailsPanel shows a missing saved proxy without undefined fields", ()
|
||||
assert.doesNotMatch(markup, /undefined:undefined/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel labels command proxy summaries consistently", () => {
|
||||
const markup = renderHostDetails({
|
||||
...hostWithMissingProxyProfile,
|
||||
proxyProfileId: undefined,
|
||||
proxyConfig: {
|
||||
type: "command",
|
||||
host: "",
|
||||
port: 0,
|
||||
command: "cloudflared access ssh --hostname %h --token secret",
|
||||
},
|
||||
});
|
||||
|
||||
assert.match(markup, /ProxyCommand/);
|
||||
assert.doesNotMatch(markup, /COMMAND/);
|
||||
assert.doesNotMatch(markup, /cloudflared access ssh/);
|
||||
assert.doesNotMatch(markup, /secret/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel keeps explicitly cleared telnet credentials empty", () => {
|
||||
const markup = renderHostDetails({
|
||||
...hostWithMissingProxyProfile,
|
||||
@@ -241,6 +263,26 @@ test("parseOptionalPortInput clears empty port values", () => {
|
||||
assert.equal(parseOptionalPortInput("2325"), 2325);
|
||||
});
|
||||
|
||||
test("resolvePrimaryProtocolSwitchPort only migrates opposite protocol defaults", () => {
|
||||
assert.equal(resolvePrimaryProtocolSwitchPort(22, "telnet", false, false), 23);
|
||||
assert.equal(resolvePrimaryProtocolSwitchPort(23, "ssh", false, false), 22);
|
||||
assert.equal(resolvePrimaryProtocolSwitchPort(2222, "telnet", false, false), 2222);
|
||||
assert.equal(resolvePrimaryProtocolSwitchPort(2323, "ssh", false, false), 2323);
|
||||
assert.equal(resolvePrimaryProtocolSwitchPort(undefined, "telnet", false, false), 23);
|
||||
assert.equal(resolvePrimaryProtocolSwitchPort(undefined, "ssh", false, false), 22);
|
||||
assert.equal(resolvePrimaryProtocolSwitchPort(22, "telnet", false, true), 22);
|
||||
assert.equal(resolvePrimaryProtocolSwitchPort(22, "telnet", true, false), 22);
|
||||
});
|
||||
|
||||
test("resolvePrimaryProtocolSavePort falls back to telnet default for primary telnet", () => {
|
||||
assert.equal(resolvePrimaryProtocolSavePort("telnet", undefined, false, false), 23);
|
||||
assert.equal(resolvePrimaryProtocolSavePort("telnet", 2323, false, false), 2323);
|
||||
assert.equal(resolvePrimaryProtocolSavePort("ssh", undefined, false, false), 22);
|
||||
assert.equal(resolvePrimaryProtocolSavePort("ssh", undefined, true, false), undefined);
|
||||
assert.equal(resolvePrimaryProtocolSavePort("telnet", undefined, false, true), undefined);
|
||||
assert.equal(resolvePrimaryProtocolSavePort("telnet", undefined, true, false), undefined);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel does not offer to disable telnet when telnet is the primary protocol", () => {
|
||||
const markup = renderHostDetails({
|
||||
...hostWithMissingProxyProfile,
|
||||
|
||||
@@ -17,7 +17,12 @@ import {
|
||||
getEffectiveHostDistro,
|
||||
normalizePrimaryTelnetState,
|
||||
} from "../domain/host";
|
||||
import { isCompleteProxyConfig, normalizeManualProxyConfig } from "../domain/proxyProfiles";
|
||||
import {
|
||||
formatProxyConfigEndpoint,
|
||||
formatProxyConfigType,
|
||||
isCompleteProxyConfig,
|
||||
normalizeManualProxyConfig,
|
||||
} from "../domain/proxyProfiles";
|
||||
import { customThemeStore } from "../application/state/customThemeStore";
|
||||
import {
|
||||
hasHostFontSizeOverride,
|
||||
@@ -36,7 +41,15 @@ import {
|
||||
} from "./ui/aside-panel";
|
||||
import { HostDetailsAdvancedSections } from "./HostDetailsAdvancedSections";
|
||||
import { HostDetailsConnectionSections } from "./HostDetailsConnectionSections";
|
||||
import { LINUX_DISTRO_OPTION_IDS, parseOptionalPortInput, resolveDetailsTelnetPassword, resolveDetailsTelnetPort, resolveDetailsTelnetUsername } from "./HostDetailsPanel.helpers";
|
||||
import {
|
||||
LINUX_DISTRO_OPTION_IDS,
|
||||
parseOptionalPortInput,
|
||||
resolveDetailsTelnetPassword,
|
||||
resolveDetailsTelnetPort,
|
||||
resolveDetailsTelnetUsername,
|
||||
resolvePrimaryProtocolSavePort,
|
||||
resolvePrimaryProtocolSwitchPort,
|
||||
} from "./HostDetailsPanel.helpers";
|
||||
export { parseOptionalPortInput } from "./HostDetailsPanel.helpers";
|
||||
import { Button } from "./ui/button";
|
||||
import { Combobox, ComboboxOption, MultiCombobox } from "./ui/combobox";
|
||||
@@ -246,17 +259,17 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
const hasMissingProxyProfile = Boolean(form.proxyProfileId && !selectedProxyProfile);
|
||||
const proxySummaryType = hasMissingProxyProfile
|
||||
? t("hostDetails.proxyPanel.missing")
|
||||
: (selectedProxyProfile?.config.type || form.proxyConfig?.type || "http").toUpperCase();
|
||||
: formatProxyConfigType(selectedProxyProfile?.config || form.proxyConfig) || "HTTP";
|
||||
const proxySummaryLabel = hasMissingProxyProfile
|
||||
? t("hostDetails.proxyPanel.missingSaved")
|
||||
: selectedProxyProfile
|
||||
? selectedProxyProfile.label
|
||||
: `${form.proxyConfig?.host}:${form.proxyConfig?.port}`;
|
||||
: formatProxyConfigEndpoint(form.proxyConfig);
|
||||
const proxySummaryTooltip = hasMissingProxyProfile
|
||||
? t("hostDetails.proxyPanel.missingSaved")
|
||||
: selectedProxyProfile
|
||||
? `${selectedProxyProfile.label} - ${selectedProxyProfile.config.host}:${selectedProxyProfile.config.port}`
|
||||
: `${form.proxyConfig?.type?.toUpperCase()} ${form.proxyConfig?.host}:${form.proxyConfig?.port}`;
|
||||
? `${selectedProxyProfile.label} - ${formatProxyConfigEndpoint(selectedProxyProfile.config)}`
|
||||
: `${formatProxyConfigType(form.proxyConfig)} ${formatProxyConfigEndpoint(form.proxyConfig)}`;
|
||||
|
||||
const handleDistroModeChange = useCallback((mode: "auto" | "manual") => {
|
||||
setForm((prev) => ({
|
||||
@@ -385,10 +398,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
}
|
||||
|
||||
const { proxyConfig: _draftProxyConfig, ...formWithoutProxyDraft } = form;
|
||||
const finalPort =
|
||||
form.protocol === "telnet"
|
||||
? form.port
|
||||
: form.port ?? (groupDefaults?.port ? undefined : 22);
|
||||
const finalPort = resolvePrimaryProtocolSavePort(
|
||||
form.protocol,
|
||||
form.port,
|
||||
Boolean(groupDefaults?.port),
|
||||
Boolean(groupDefaults?.telnetPort),
|
||||
);
|
||||
let cleaned: Host = {
|
||||
...formWithoutProxyDraft,
|
||||
...(normalizedProxyConfig && { proxyConfig: normalizedProxyConfig }),
|
||||
@@ -443,7 +458,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
cleaned.fontSize = initialData?.fontSize;
|
||||
}
|
||||
|
||||
if ((cleaned.protocol && cleaned.protocol !== "ssh") || cleaned.moshEnabled) {
|
||||
if ((cleaned.protocol && cleaned.protocol !== "ssh") || cleaned.moshEnabled || cleaned.etEnabled) {
|
||||
delete cleaned.x11Forwarding;
|
||||
}
|
||||
onSave(cleaned);
|
||||
@@ -873,7 +888,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.telnet.setDefault")}</span>
|
||||
<Switch
|
||||
checked={form.protocol === "telnet"}
|
||||
onCheckedChange={(checked) => update("protocol", checked ? "telnet" : "ssh")}
|
||||
onCheckedChange={(checked) => {
|
||||
const nextProtocol = checked ? "telnet" : "ssh";
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
protocol: nextProtocol,
|
||||
port: resolvePrimaryProtocolSwitchPort(
|
||||
prev.port,
|
||||
nextProtocol,
|
||||
Boolean(groupDefaults?.telnetPort),
|
||||
Boolean(groupDefaults?.port),
|
||||
),
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { CheckSquare, ChevronRight, Edit2, FileSymlink, Folder, FolderOpen, Monitor, Server, Square, Expand, Minimize2 } from 'lucide-react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { CheckSquare, ChevronRight, Edit2, FileSymlink, Folder, FolderOpen, Server, Square, Expand, Minimize2 } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import {
|
||||
hostTreeInlineGroupEditStore,
|
||||
useHostTreeInlineGroupEdit,
|
||||
} from '../application/state/hostTreeInlineGroupEditStore';
|
||||
import { useVaultHostTreeActions } from '../application/state/vaultHostTreeActionsStore';
|
||||
import { useTreeExpandedState } from '../application/state/useTreeExpandedState';
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from '../domain/groupConfig';
|
||||
import { resolveTelnetPort, resolveTelnetUsername, sanitizeHost } from '../domain/host';
|
||||
@@ -8,7 +13,9 @@ import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/
|
||||
import { cn } from '../lib/utils';
|
||||
import { GroupConfig, GroupNode, Host } from '../types';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { HostTreeGroupContextMenuContent, HostTreeHostContextMenuContent } from './host/HostTreeContextMenus';
|
||||
import { HostTreeGroupInlineRenameInput } from './host/HostTreeGroupInlineRenameInput';
|
||||
import { ContextMenu, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import { HostNotesIndicator } from './host/HostNotesIndicator';
|
||||
import { Button } from './ui/button';
|
||||
@@ -26,12 +33,14 @@ interface HostTreeViewProps {
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
onNewHost: (groupPath?: string) => void;
|
||||
onNewGroup: (parentPath?: string) => void;
|
||||
onRenameGroup: (groupPath: string) => void;
|
||||
onEditGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetPath: string) => void;
|
||||
moveGroup: (sourcePath: string, targetParent: string | null) => void;
|
||||
commitInlineGroupRename?: (name: string) => void;
|
||||
cancelInlineGroupEdit?: () => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
|
||||
@@ -54,12 +63,14 @@ interface TreeNodeProps {
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
onNewHost: (groupPath?: string) => void;
|
||||
onNewGroup: (parentPath?: string) => void;
|
||||
onRenameGroup: (groupPath: string) => void;
|
||||
onEditGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetPath: string) => void;
|
||||
moveGroup: (sourcePath: string, targetParent: string | null) => void;
|
||||
commitInlineGroupRename?: (name: string) => void;
|
||||
cancelInlineGroupEdit?: () => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
|
||||
@@ -83,14 +94,16 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
onNewHost,
|
||||
onNewGroup,
|
||||
onRenameGroup,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
moveHostToGroup,
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
commitInlineGroupRename,
|
||||
cancelInlineGroupEdit,
|
||||
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
@@ -99,8 +112,22 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
setDragOverDropTarget,
|
||||
groupConfigs,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const inlineEdit = useHostTreeInlineGroupEdit();
|
||||
const vaultTreeActions = useVaultHostTreeActions();
|
||||
const commitRename = commitInlineGroupRename ?? vaultTreeActions?.commitInlineGroupRename;
|
||||
const cancelRename = cancelInlineGroupEdit ?? vaultTreeActions?.cancelInlineGroupEdit;
|
||||
const isInlineEditing = inlineEdit?.groupPath === node.path;
|
||||
const groupRowRef = useRef<HTMLDivElement>(null);
|
||||
const isExpanded = expandedPaths.has(node.path);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInlineEditing || !inlineEdit?.shouldScrollIntoView) return;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
groupRowRef.current?.scrollIntoView({ block: 'nearest' });
|
||||
hostTreeInlineGroupEditStore.markScrollHandled();
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [inlineEdit?.groupPath, inlineEdit?.shouldScrollIntoView, isInlineEditing]);
|
||||
const hasChildren = node.children && Object.keys(node.children).length > 0;
|
||||
const paddingLeft = `${depth * 20 + 12}px`;
|
||||
const isManaged = managedGroupPaths?.has(node.path) ?? false;
|
||||
@@ -149,6 +176,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
<ContextMenuTrigger>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div
|
||||
ref={groupRowRef}
|
||||
className={cn(
|
||||
"flex items-center py-2 pr-3 text-sm font-medium cursor-pointer transition-colors select-none group hover:bg-secondary/60 rounded-lg",
|
||||
getDropTargetClasses?.(node.path),
|
||||
@@ -188,7 +216,16 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
<div className="mr-3 text-primary/80 group-hover:text-primary transition-colors">
|
||||
{isExpanded ? <FolderOpen size={18} /> : <Folder size={18} />}
|
||||
</div>
|
||||
<span className="truncate flex-1 font-semibold">{node.name}</span>
|
||||
{isInlineEditing && commitRename && cancelRename ? (
|
||||
<HostTreeGroupInlineRenameInput
|
||||
initialName={inlineEdit.initialName}
|
||||
onCommit={commitRename}
|
||||
onCancel={cancelRename}
|
||||
className="flex-1 font-semibold"
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate flex-1 font-semibold">{node.name}</span>
|
||||
)}
|
||||
{isManaged && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium px-1.5 py-0.5 rounded bg-primary/15 text-primary shrink-0 mr-1.5">
|
||||
<FileSymlink size={10} />
|
||||
@@ -212,28 +249,14 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => onNewHost(node.path)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("vault.hosts.newHost")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onNewGroup(node.path)}>
|
||||
<Folder className="mr-2 h-4 w-4" /> {t("vault.hosts.newGroup")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onEditGroup(node.path)}>
|
||||
<FolderOpen className="mr-2 h-4 w-4" /> {t("vault.groups.rename")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onDeleteGroup(node.path)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<FolderOpen className="mr-2 h-4 w-4" /> {t("vault.groups.delete")}
|
||||
</ContextMenuItem>
|
||||
{isManaged && onUnmanageGroup && (
|
||||
<ContextMenuItem onClick={() => onUnmanageGroup(node.path)}>
|
||||
<FileSymlink className="mr-2 h-4 w-4" /> {t("vault.managedSource.unmanage")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
<HostTreeGroupContextMenuContent
|
||||
groupPath={node.path}
|
||||
isManaged={isManaged}
|
||||
onNewGroup={onNewGroup}
|
||||
onRenameGroup={onRenameGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
/>
|
||||
</ContextMenu>
|
||||
|
||||
<CollapsibleContent>
|
||||
@@ -251,14 +274,16 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
onNewHost={onNewHost}
|
||||
onNewGroup={onNewGroup}
|
||||
onRenameGroup={onRenameGroup}
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
commitInlineGroupRename={commitInlineGroupRename}
|
||||
cancelInlineGroupEdit={cancelInlineGroupEdit}
|
||||
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
@@ -334,7 +359,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
depth,
|
||||
onConnect,
|
||||
onEditHost,
|
||||
onDuplicateHost,
|
||||
onDuplicateHost: _onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
moveHostToGroup: _moveHostToGroup,
|
||||
@@ -344,7 +369,6 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
toggleHostSelection,
|
||||
groupConfigs,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const paddingLeft = `${depth * 20 + 12}px`;
|
||||
const safeHost = sanitizeHost(host);
|
||||
const tags = host.tags || [];
|
||||
@@ -390,7 +414,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
)}
|
||||
{!isMultiSelectMode && <div className="mr-2 flex-shrink-0 w-4 h-4" />}
|
||||
<div className="mr-3 flex-shrink-0">
|
||||
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="sm" />
|
||||
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="xs" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate flex items-center gap-1.5">
|
||||
@@ -425,26 +449,12 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => onConnect(safeHost)}>
|
||||
<Monitor className="mr-2 h-4 w-4" /> {t("vault.hosts.connect")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onEditHost(host)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("action.edit")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onDuplicateHost(host)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("action.duplicate")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onCopyCredentials(host)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("vault.hosts.copyCredentials")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onDeleteHost(host)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("action.delete")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
<HostTreeHostContextMenuContent
|
||||
host={host}
|
||||
onConnect={onConnect}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
onDeleteHost={onDeleteHost}
|
||||
/>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
@@ -462,14 +472,16 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
onNewHost,
|
||||
onNewGroup,
|
||||
onRenameGroup,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
moveHostToGroup,
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
commitInlineGroupRename,
|
||||
cancelInlineGroupEdit,
|
||||
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
@@ -589,14 +601,16 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
onNewHost={onNewHost}
|
||||
onNewGroup={onNewGroup}
|
||||
onRenameGroup={onRenameGroup}
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
commitInlineGroupRename={commitInlineGroupRename}
|
||||
cancelInlineGroupEdit={cancelInlineGroupEdit}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import {
|
||||
BadgeCheck,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Edit2,
|
||||
ExternalLink,
|
||||
Key,
|
||||
LayoutGrid,
|
||||
List as ListIcon,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
Shield,
|
||||
Trash2,
|
||||
@@ -838,9 +839,35 @@ echo $3 >> "$FILE"`);
|
||||
</AsideActionMenuItem>
|
||||
</AsideActionMenu>
|
||||
) : panel.type === "view" ? (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal size={16} />
|
||||
</Button>
|
||||
<AsideActionMenu>
|
||||
{panel.key.publicKey ? (
|
||||
<AsideActionMenuItem
|
||||
icon={<Copy size={14} />}
|
||||
onClick={() => copyPublicKey(panel.key)}
|
||||
>
|
||||
{t("action.copyPublicKey")}
|
||||
</AsideActionMenuItem>
|
||||
) : null}
|
||||
<AsideActionMenuItem
|
||||
icon={<ExternalLink size={14} />}
|
||||
onClick={() => openKeyExport(panel.key)}
|
||||
>
|
||||
{t("action.keyExport")}
|
||||
</AsideActionMenuItem>
|
||||
<AsideActionMenuItem
|
||||
icon={<Edit2 size={14} />}
|
||||
onClick={() => openKeyEdit(panel.key)}
|
||||
>
|
||||
{t("action.edit")}
|
||||
</AsideActionMenuItem>
|
||||
<AsideActionMenuItem
|
||||
variant="destructive"
|
||||
icon={<Trash2 size={14} />}
|
||||
onClick={() => handleDelete(panel.key.id)}
|
||||
>
|
||||
{t("action.delete")}
|
||||
</AsideActionMenuItem>
|
||||
</AsideActionMenu>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
|
||||
@@ -63,6 +63,18 @@ const ProtocolSelectDialog: React.FC<ProtocolSelectDialogProps> = ({
|
||||
});
|
||||
}
|
||||
|
||||
// EternalTerminal (if enabled)
|
||||
if (host.etEnabled || host.protocols?.some(p => p.protocol === 'et' && p.enabled)) {
|
||||
options.push({
|
||||
protocol: 'et',
|
||||
port: host.port || 22,
|
||||
label: 'EternalTerminal',
|
||||
icon: <Wifi size={18} />,
|
||||
description: `et ${host.hostname}`,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Telnet (if enabled)
|
||||
if (host.telnetEnabled || host.protocol === 'telnet' || host.protocols?.some(p => p.protocol === 'telnet' && p.enabled)) {
|
||||
const telnetConfig = host.protocols?.find(p => p.protocol === 'telnet');
|
||||
|
||||
@@ -49,6 +49,29 @@ test("ProxyPanel shows saved proxy selection when reusable profiles exist", () =
|
||||
assert.doesNotMatch(markup, /Proxy host/);
|
||||
});
|
||||
|
||||
test("ProxyPanel labels saved ProxyCommand profiles without showing command contents", () => {
|
||||
const commandProxy: ProxyProfile = {
|
||||
id: "proxy-command-1",
|
||||
label: "Cloudflare Access",
|
||||
config: {
|
||||
type: "command",
|
||||
host: "",
|
||||
port: 0,
|
||||
command: "cloudflared access ssh --hostname %h --token secret",
|
||||
},
|
||||
createdAt: 1,
|
||||
};
|
||||
const markup = renderPanel({
|
||||
proxyProfiles: [commandProxy],
|
||||
selectedProxyProfileId: commandProxy.id,
|
||||
});
|
||||
|
||||
assert.match(markup, /ProxyCommand/);
|
||||
assert.doesNotMatch(markup, /COMMAND/);
|
||||
assert.doesNotMatch(markup, /cloudflared access ssh/);
|
||||
assert.doesNotMatch(markup, /secret/);
|
||||
});
|
||||
|
||||
test("ProxyPanel keeps manual proxy fields available without a saved profile selection", () => {
|
||||
const markup = renderPanel({
|
||||
proxyProfiles: [proxyProfile],
|
||||
@@ -78,3 +101,29 @@ test("ProxyPanel disables saving invalid manual proxy ports", () => {
|
||||
assert.match(markup, /Port must be between 1 and 65535/);
|
||||
assert.match(markup, /disabled=""/);
|
||||
});
|
||||
|
||||
test("ProxyPanel supports custom ProxyCommand settings", () => {
|
||||
const markup = renderPanel({
|
||||
proxyConfig: {
|
||||
type: "command",
|
||||
host: "",
|
||||
port: 0,
|
||||
command: "cloudflared access ssh --hostname %h",
|
||||
},
|
||||
});
|
||||
|
||||
assert.match(markup, /Command/);
|
||||
assert.match(markup, /cloudflared access ssh --hostname %h/);
|
||||
assert.match(markup, /Use %h for the target host/);
|
||||
assert.doesNotMatch(markup, /Proxy host/);
|
||||
assert.doesNotMatch(markup, /Credentials/);
|
||||
});
|
||||
|
||||
test("ProxyPanel uses a dropdown for proxy type selection", () => {
|
||||
const markup = renderPanel({
|
||||
proxyConfig: { type: "http", host: "manual-proxy.example.com", port: 3128 },
|
||||
});
|
||||
|
||||
assert.match(markup, /role="combobox"/);
|
||||
assert.match(markup, /aria-label="Type"/);
|
||||
});
|
||||
|
||||
@@ -40,14 +40,17 @@ const installStorageStub = (viewMode: string | null = null) => {
|
||||
});
|
||||
};
|
||||
|
||||
const renderManager = (viewMode: string | null = null) => {
|
||||
const renderManager = (
|
||||
viewMode: string | null = null,
|
||||
profiles: ProxyProfile[] = [proxyProfile],
|
||||
) => {
|
||||
installStorageStub(viewMode);
|
||||
return renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(ProxyProfilesManager, {
|
||||
proxyProfiles: [proxyProfile],
|
||||
proxyProfiles: profiles,
|
||||
hosts: [],
|
||||
groupConfigs: [],
|
||||
onUpdateProxyProfiles: () => {},
|
||||
@@ -83,3 +86,25 @@ test("ProxyProfilesManager validates proxy ports", () => {
|
||||
assert.equal(isValidProxyPort(65536), false);
|
||||
assert.equal(isValidProxyPort(10.5), false);
|
||||
});
|
||||
|
||||
test("ProxyProfilesManager hides ProxyCommand contents in profile summaries", () => {
|
||||
const markup = renderManager(null, [
|
||||
{
|
||||
id: "proxy-command-1",
|
||||
label: "Cloudflare Access",
|
||||
config: {
|
||||
type: "command",
|
||||
host: "",
|
||||
port: 0,
|
||||
command: "cloudflared access ssh --hostname %h --token secret",
|
||||
},
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
assert.match(markup, /aria-label="Cloudflare Access, ProxyCommand, ProxyCommand, 0 linked"/);
|
||||
assert.match(markup, /Cloudflare Access/);
|
||||
assert.match(markup, /ProxyCommand/);
|
||||
assert.doesNotMatch(markup, /cloudflared access ssh/);
|
||||
assert.doesNotMatch(markup, /secret/);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Globe,
|
||||
@@ -11,12 +10,18 @@ import {
|
||||
Plus,
|
||||
Route,
|
||||
Settings2,
|
||||
SquareTerminal,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import { isValidProxyPort, removeProxyProfileReferences } from "../domain/proxyProfiles";
|
||||
import {
|
||||
formatProxyConfigEndpoint,
|
||||
isProxyCommandConfig,
|
||||
isValidProxyPort,
|
||||
removeProxyProfileReferences,
|
||||
} from "../domain/proxyProfiles";
|
||||
import {
|
||||
STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE,
|
||||
} from "../infrastructure/config/storageKeys";
|
||||
@@ -47,6 +52,7 @@ import {
|
||||
} from "./ui/dialog";
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
||||
import { Input } from "./ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
import { toast } from "./ui/toast";
|
||||
import {
|
||||
VaultHeaderSearch,
|
||||
@@ -100,8 +106,14 @@ const proxyProtocolMeta = {
|
||||
Icon: Route,
|
||||
iconClassName: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
|
||||
},
|
||||
command: {
|
||||
labelKey: "hostDetails.proxyPanel.command",
|
||||
Icon: SquareTerminal,
|
||||
iconClassName: "bg-violet-500/10 text-violet-600 dark:text-violet-400",
|
||||
},
|
||||
} satisfies Record<ProxyConfig["type"], {
|
||||
label: string;
|
||||
label?: string;
|
||||
labelKey?: string;
|
||||
Icon: React.ComponentType<{ size?: number; className?: string }>;
|
||||
iconClassName: string;
|
||||
}>;
|
||||
@@ -130,8 +142,10 @@ const ProxyProfileCard: React.FC<ProxyProfileCardProps> = ({
|
||||
const { t } = useI18n();
|
||||
const usageLabel = t("proxyProfiles.usage", { count: usageCount });
|
||||
const protocol = proxyProtocolMeta[profile.config.type];
|
||||
const protocolLabel = protocol.labelKey ? t(protocol.labelKey) : protocol.label;
|
||||
const ProtocolIcon = protocol.Icon;
|
||||
const accessibleLabel = `${profile.label}, ${protocol.label}, ${profile.config.host}:${profile.config.port}, ${usageLabel}`;
|
||||
const endpoint = formatProxyConfigEndpoint(profile.config);
|
||||
const accessibleLabel = `${profile.label}, ${protocolLabel}, ${endpoint}, ${usageLabel}`;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
@@ -154,7 +168,7 @@ const ProxyProfileCard: React.FC<ProxyProfileCardProps> = ({
|
||||
"h-11 w-11 rounded-xl flex items-center justify-center",
|
||||
protocol.iconClassName,
|
||||
)}
|
||||
title={protocol.label}
|
||||
title={protocolLabel}
|
||||
>
|
||||
<ProtocolIcon size={18} />
|
||||
</div>
|
||||
@@ -163,8 +177,8 @@ const ProxyProfileCard: React.FC<ProxyProfileCardProps> = ({
|
||||
<div className="text-sm font-semibold truncate">{profile.label}</div>
|
||||
</div>
|
||||
<div className="text-[11px] font-mono text-muted-foreground truncate">
|
||||
{profile.config.host}:{profile.config.port} -{" "}
|
||||
{protocol.label}
|
||||
{endpoint} -{" "}
|
||||
{protocolLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -222,6 +236,7 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
return proxyProfiles.filter((profile) =>
|
||||
profile.label.toLowerCase().includes(q) ||
|
||||
profile.config.host.toLowerCase().includes(q) ||
|
||||
(profile.config.command || "").toLowerCase().includes(q) ||
|
||||
profile.config.type.toLowerCase().includes(q),
|
||||
);
|
||||
}, [proxyProfiles, search]);
|
||||
@@ -269,11 +284,13 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
if (!draft) return;
|
||||
const label = draft.label.trim();
|
||||
const host = draft.config.host.trim();
|
||||
if (!label || !host || !draft.config.port) {
|
||||
const command = draft.config.command?.trim() || "";
|
||||
const isCommand = isProxyCommandConfig(draft.config);
|
||||
if (!label || (isCommand ? !command : (!host || !draft.config.port))) {
|
||||
toast.error(t("proxyProfiles.error.required"));
|
||||
return;
|
||||
}
|
||||
if (!isValidProxyPort(draft.config.port)) {
|
||||
if (!isCommand && !isValidProxyPort(draft.config.port)) {
|
||||
toast.error(t("proxyProfiles.error.port"));
|
||||
return;
|
||||
}
|
||||
@@ -281,13 +298,20 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
const saved: ProxyProfile = {
|
||||
...draft,
|
||||
label,
|
||||
config: {
|
||||
...draft.config,
|
||||
host,
|
||||
port: Number(draft.config.port),
|
||||
username: draft.config.username?.trim() || undefined,
|
||||
password: draft.config.password || undefined,
|
||||
},
|
||||
config: isCommand
|
||||
? {
|
||||
type: "command",
|
||||
host: "",
|
||||
port: 0,
|
||||
command,
|
||||
}
|
||||
: {
|
||||
...draft.config,
|
||||
host,
|
||||
port: Number(draft.config.port),
|
||||
username: draft.config.username?.trim() || undefined,
|
||||
password: draft.config.password || undefined,
|
||||
},
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
@@ -446,56 +470,64 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("field.type")}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={draft.config.type === "http" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className={cn("h-8", draft.config.type === "http" && "bg-primary/15")}
|
||||
onClick={() => updateDraftConfig("type", "http")}
|
||||
>
|
||||
<Check size={14} className={cn("mr-1", draft.config.type !== "http" && "opacity-0")} />
|
||||
HTTP
|
||||
</Button>
|
||||
<Button
|
||||
variant={draft.config.type === "socks5" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className={cn("h-8", draft.config.type === "socks5" && "bg-primary/15")}
|
||||
onClick={() => updateDraftConfig("type", "socks5")}
|
||||
>
|
||||
<Check size={14} className={cn("mr-1", draft.config.type !== "socks5" && "opacity-0")} />
|
||||
SOCKS5
|
||||
</Button>
|
||||
</div>
|
||||
<Select
|
||||
value={draft.config.type}
|
||||
onValueChange={(value) => updateDraftConfig("type", value as ProxyConfig["type"])}
|
||||
>
|
||||
<SelectTrigger aria-label={t("field.type")} className="h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">HTTP</SelectItem>
|
||||
<SelectItem value="socks5">SOCKS5</SelectItem>
|
||||
<SelectItem value="command">{t("hostDetails.proxyPanel.command")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
aria-label={t("hostDetails.proxyPanel.hostPlaceholder")}
|
||||
value={draft.config.host}
|
||||
onChange={(event) => updateDraftConfig("host", event.target.value)}
|
||||
placeholder={t("hostDetails.proxyPanel.hostPlaceholder")}
|
||||
className="h-10 flex-1"
|
||||
/>
|
||||
<Input
|
||||
aria-label={t("hostDetails.port")}
|
||||
type="number"
|
||||
value={draft.config.port || ""}
|
||||
onChange={(event) => updateDraftConfig("port", event.target.value === "" ? 0 : Number(event.target.value))}
|
||||
placeholder="3128"
|
||||
min={1}
|
||||
max={65535}
|
||||
step={1}
|
||||
className="h-10 w-24 text-center"
|
||||
/>
|
||||
</div>
|
||||
{isProxyCommandConfig(draft.config) ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.proxyPanel.commandHelp")}
|
||||
</p>
|
||||
<Input
|
||||
aria-label={t("hostDetails.proxyPanel.commandPlaceholder")}
|
||||
value={draft.config.command || ""}
|
||||
onChange={(event) => updateDraftConfig("command", event.target.value)}
|
||||
placeholder={t("hostDetails.proxyPanel.commandPlaceholder")}
|
||||
className="h-10 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
aria-label={t("hostDetails.proxyPanel.hostPlaceholder")}
|
||||
value={draft.config.host}
|
||||
onChange={(event) => updateDraftConfig("host", event.target.value)}
|
||||
placeholder={t("hostDetails.proxyPanel.hostPlaceholder")}
|
||||
className="h-10 flex-1"
|
||||
/>
|
||||
<Input
|
||||
aria-label={t("hostDetails.port")}
|
||||
type="number"
|
||||
value={draft.config.port || ""}
|
||||
onChange={(event) => updateDraftConfig("port", event.target.value === "" ? 0 : Number(event.target.value))}
|
||||
placeholder="3128"
|
||||
min={1}
|
||||
max={65535}
|
||||
step={1}
|
||||
className="h-10 w-24 text-center"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
{!isProxyCommandConfig(draft.config) && <Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound size={14} className="text-muted-foreground" />
|
||||
@@ -518,7 +550,7 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
placeholder={t("hostDetails.proxyPanel.passwordPlaceholder")}
|
||||
className="h-10"
|
||||
/>
|
||||
</Card>
|
||||
</Card>}
|
||||
</AsidePanelContent>
|
||||
<AsidePanelFooter>
|
||||
<Button className="w-full" onClick={saveDraft}>
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
} from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { CodeTextarea } from './ui/code-textarea';
|
||||
import { SnippetScriptEditor } from './snippets/SnippetScriptEditor';
|
||||
|
||||
export interface QuickAddSnippetDialogProps {
|
||||
snippets: Snippet[];
|
||||
@@ -148,8 +148,11 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-md" onKeyDown={handleKeyDown}>
|
||||
<DialogHeader>
|
||||
<DialogContent
|
||||
className="max-w-md max-h-[min(90vh,720px)] flex flex-col overflow-hidden"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>
|
||||
{t(editing ? 'snippets.panel.editTitle' : 'snippets.panel.newTitle')}
|
||||
</DialogTitle>
|
||||
@@ -158,7 +161,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="min-h-0 space-y-3 overflow-y-auto pr-1">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="quick-add-snippet-label" className="text-xs">
|
||||
{t('snippets.field.description')}
|
||||
@@ -174,18 +177,13 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="quick-add-snippet-command" className="text-xs">
|
||||
{t('snippets.field.scriptRequired')}
|
||||
</Label>
|
||||
<CodeTextarea
|
||||
id="quick-add-snippet-command"
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
placeholder="echo hello"
|
||||
className="min-h-[120px]"
|
||||
/>
|
||||
</div>
|
||||
<SnippetScriptEditor
|
||||
id="quick-add-snippet-command"
|
||||
label={t('snippets.field.scriptRequired')}
|
||||
value={command}
|
||||
onChange={setCommand}
|
||||
placeholder="echo hello"
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs flex items-center gap-1.5">
|
||||
@@ -203,7 +201,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
|
||||
@@ -18,10 +18,17 @@ import {
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from './ui/context-menu';
|
||||
import { FixedSizeVirtualList } from './ui/FixedSizeVirtualList';
|
||||
import { Input } from './ui/input';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||
|
||||
const SCRIPT_ROW_HEIGHT = 34;
|
||||
|
||||
const isRootPackagePath = (path: string): boolean => {
|
||||
const body = path.startsWith('/') ? path.slice(1) : path;
|
||||
return body.length > 0 && !body.includes('/');
|
||||
};
|
||||
|
||||
interface ScriptsSidePanelProps {
|
||||
snippets: Snippet[];
|
||||
packages: string[];
|
||||
@@ -69,6 +76,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
// Normalize the package list + derive ancestor packages implied by each path
|
||||
// (e.g. package "a/b/c" implies roots "a" and "a/b" even when not listed).
|
||||
const normalizedPackages = useMemo(() => {
|
||||
if (!isVisible) return new Set<string>();
|
||||
const set = new Set<string>();
|
||||
const addWithAncestors = (raw: string) => {
|
||||
const path = raw.trim();
|
||||
@@ -87,7 +95,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
if (s.package) addWithAncestors(s.package);
|
||||
});
|
||||
return set;
|
||||
}, [packages, snippets]);
|
||||
}, [packages, snippets, isVisible]);
|
||||
|
||||
// Track every package we've ever observed so we can tell "new" from
|
||||
// "previously-seen-but-user-collapsed". Without this, any unrelated refresh
|
||||
@@ -99,6 +107,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
// everything without drilling in. After that, respect the user's collapse
|
||||
// choices across unrelated refreshes.
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
const seen = seenPackagesRef.current;
|
||||
const newlySeen: string[] = [];
|
||||
normalizedPackages.forEach((p) => {
|
||||
@@ -110,10 +119,53 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
if (newlySeen.length === 0) return;
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
newlySeen.forEach((p) => next.add(p));
|
||||
// Only auto-expand root packages on first sight — expanding the full
|
||||
// tree upfront was freezing the panel on large snippet libraries.
|
||||
newlySeen.filter(isRootPackagePath).forEach((p) => next.add(p));
|
||||
return next;
|
||||
});
|
||||
}, [normalizedPackages]);
|
||||
}, [normalizedPackages, isVisible]);
|
||||
|
||||
const snippetIndex = useMemo(() => {
|
||||
if (!isVisible) {
|
||||
return {
|
||||
snippetsByPackage: new Map<string, Snippet[]>(),
|
||||
descendantCountByPackage: new Map<string, number>(),
|
||||
};
|
||||
}
|
||||
const snippetsByPackage = new Map<string, Snippet[]>();
|
||||
const descendantCountByPackage = new Map<string, number>();
|
||||
|
||||
const bumpCount = (path: string) => {
|
||||
descendantCountByPackage.set(path, (descendantCountByPackage.get(path) ?? 0) + 1);
|
||||
};
|
||||
|
||||
for (const snippet of snippets) {
|
||||
const pkg = snippet.package || '';
|
||||
const bucket = snippetsByPackage.get(pkg);
|
||||
if (bucket) bucket.push(snippet);
|
||||
else snippetsByPackage.set(pkg, [snippet]);
|
||||
|
||||
if (pkg === '') {
|
||||
bumpCount('');
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = pkg;
|
||||
while (true) {
|
||||
bumpCount(path);
|
||||
const slash = path.lastIndexOf('/');
|
||||
if (slash < 0) break;
|
||||
path = path.slice(0, slash);
|
||||
}
|
||||
}
|
||||
|
||||
for (const bucket of snippetsByPackage.values()) {
|
||||
bucket.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
return { snippetsByPackage, descendantCountByPackage };
|
||||
}, [snippets, isVisible]);
|
||||
|
||||
const togglePackage = useCallback((path: string) => {
|
||||
setExpandedPaths((prev) => {
|
||||
@@ -126,6 +178,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
|
||||
// When search is active, flatten everything (no tree, no packages).
|
||||
const searchMatches = useMemo(() => {
|
||||
if (!isVisible) return null;
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return null;
|
||||
return snippets.filter(
|
||||
@@ -133,9 +186,10 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
s.label.toLowerCase().includes(q) ||
|
||||
s.command.toLowerCase().includes(q),
|
||||
);
|
||||
}, [snippets, search]);
|
||||
}, [snippets, search, isVisible]);
|
||||
|
||||
const rows = useMemo<TreeRow[]>(() => {
|
||||
if (!isVisible) return [];
|
||||
if (searchMatches !== null) return [];
|
||||
|
||||
const out: TreeRow[] = [];
|
||||
@@ -159,15 +213,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
};
|
||||
|
||||
const snippetsIn = (pkg: string | null): Snippet[] =>
|
||||
snippets
|
||||
.filter((s) => (s.package || '') === (pkg ?? ''))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
const countDescendants = (pkg: string): number =>
|
||||
snippets.filter((s) => {
|
||||
const sp = s.package || '';
|
||||
return sp === pkg || sp.startsWith(pkg + '/');
|
||||
}).length;
|
||||
snippetIndex.snippetsByPackage.get(pkg ?? '') ?? [];
|
||||
|
||||
const walk = (pkg: string, depth: number) => {
|
||||
const children = childPackagesOf(pkg);
|
||||
@@ -181,7 +227,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
path: pkg,
|
||||
name: pkgDisplayName(pkg),
|
||||
depth,
|
||||
count: countDescendants(pkg),
|
||||
count: snippetIndex.descendantCountByPackage.get(pkg) ?? 0,
|
||||
hasChildren,
|
||||
isExpanded,
|
||||
});
|
||||
@@ -200,7 +246,38 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
childPackagesOf(null).forEach((root) => walk(root, 0));
|
||||
|
||||
return out;
|
||||
}, [normalizedPackages, snippets, expandedPaths, searchMatches]);
|
||||
}, [normalizedPackages, snippetIndex, expandedPaths, searchMatches, isVisible]);
|
||||
|
||||
type ScriptsListItem =
|
||||
| { key: string; kind: 'search'; snippet: Snippet }
|
||||
| { key: string; kind: 'package'; row: Extract<TreeRow, { type: 'package' }>; countLabel: string }
|
||||
| { key: string; kind: 'snippet'; row: Extract<TreeRow, { type: 'snippet' }> };
|
||||
|
||||
const listItems = useMemo((): ScriptsListItem[] => {
|
||||
if (!isVisible) return [];
|
||||
if (searchMatches !== null) {
|
||||
return searchMatches.map((snippet) => ({
|
||||
key: `search:${snippet.id}`,
|
||||
kind: 'search',
|
||||
snippet,
|
||||
}));
|
||||
}
|
||||
return rows.flatMap((row): ScriptsListItem[] => {
|
||||
if (row.type === 'package') {
|
||||
return [{
|
||||
key: `pkg:${row.id}`,
|
||||
kind: 'package',
|
||||
row,
|
||||
countLabel: t('snippets.package.count', { count: row.count }),
|
||||
}];
|
||||
}
|
||||
return [{
|
||||
key: `snip:${row.id}`,
|
||||
kind: 'snippet',
|
||||
row,
|
||||
}];
|
||||
});
|
||||
}, [rows, searchMatches, t, isVisible]);
|
||||
|
||||
const handleSnippetClick = useCallback(
|
||||
(snippet: Snippet) => {
|
||||
@@ -265,62 +342,62 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="py-1">
|
||||
{!hasAnyContent && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<Zap size={24} className="opacity-40 mb-2" />
|
||||
<span className="text-xs">{t('terminal.toolbar.noSnippets')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search flat list */}
|
||||
{searchMatches !== null && searchMatches.length > 0 &&
|
||||
searchMatches.map((s) => (
|
||||
<SnippetRow
|
||||
key={s.id}
|
||||
snippet={s}
|
||||
depth={0}
|
||||
subtitle={s.package || t('terminal.toolbar.library')}
|
||||
onClick={() => handleSnippetClick(s)}
|
||||
onEdit={() => handleEditSnippet(s)}
|
||||
onDelete={() => handleDeleteSnippet(s.id)}
|
||||
editLabel={t('action.edit')}
|
||||
deleteLabel={t('action.delete')}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Tree */}
|
||||
{searchMatches === null &&
|
||||
rows.map((row) =>
|
||||
row.type === 'package' ? (
|
||||
<PackageRow
|
||||
key={`pkg:${row.id}`}
|
||||
row={row}
|
||||
countLabel={t('snippets.package.count', { count: row.count })}
|
||||
onToggle={() => togglePackage(row.path)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0">
|
||||
{!hasAnyContent ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<Zap size={24} className="opacity-40 mb-2" />
|
||||
<span className="text-xs">{t('terminal.toolbar.noSnippets')}</span>
|
||||
</div>
|
||||
) : hasAnyContent && searchMatches !== null && searchMatches.length === 0 ? (
|
||||
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
|
||||
{t('common.noResultsFound')}
|
||||
</div>
|
||||
) : (
|
||||
<FixedSizeVirtualList
|
||||
className="h-full"
|
||||
contentClassName="py-1"
|
||||
items={listItems}
|
||||
itemHeight={SCRIPT_ROW_HEIGHT}
|
||||
getItemKey={(item) => item.key}
|
||||
renderItem={(item) => {
|
||||
if (item.kind === 'search') {
|
||||
return (
|
||||
<SnippetRow
|
||||
snippet={item.snippet}
|
||||
depth={0}
|
||||
subtitle={item.snippet.package || t('terminal.toolbar.library')}
|
||||
onClick={() => handleSnippetClick(item.snippet)}
|
||||
onEdit={() => handleEditSnippet(item.snippet)}
|
||||
onDelete={() => handleDeleteSnippet(item.snippet.id)}
|
||||
editLabel={t('action.edit')}
|
||||
deleteLabel={t('action.delete')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (item.kind === 'package') {
|
||||
return (
|
||||
<PackageRow
|
||||
row={item.row}
|
||||
countLabel={item.countLabel}
|
||||
onToggle={() => togglePackage(item.row.path)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SnippetRow
|
||||
key={`snip:${row.id}`}
|
||||
snippet={row.snippet}
|
||||
depth={row.depth}
|
||||
onClick={() => handleSnippetClick(row.snippet)}
|
||||
onEdit={() => handleEditSnippet(row.snippet)}
|
||||
onDelete={() => handleDeleteSnippet(row.snippet.id)}
|
||||
snippet={item.row.snippet}
|
||||
depth={item.row.depth}
|
||||
onClick={() => handleSnippetClick(item.row.snippet)}
|
||||
onEdit={() => handleEditSnippet(item.row.snippet)}
|
||||
onDelete={() => handleDeleteSnippet(item.row.snippet.id)}
|
||||
editLabel={t('action.edit')}
|
||||
deleteLabel={t('action.delete')}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
{hasAnyContent && searchMatches !== null && searchMatches.length === 0 && (
|
||||
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
|
||||
{t('common.noResultsFound')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
@@ -332,7 +409,7 @@ interface PackageRowProps {
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
const PackageRow: React.FC<PackageRowProps> = ({ row, countLabel, onToggle }) => (
|
||||
const PackageRow = memo<PackageRowProps>(({ row, countLabel, onToggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
@@ -351,7 +428,8 @@ const PackageRow: React.FC<PackageRowProps> = ({ row, countLabel, onToggle }) =>
|
||||
<span className="flex-1 min-w-0 truncate text-xs font-medium">{row.name}</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">{countLabel}</span>
|
||||
</button>
|
||||
);
|
||||
));
|
||||
PackageRow.displayName = 'PackageRow';
|
||||
|
||||
interface SnippetRowProps {
|
||||
snippet: Snippet;
|
||||
@@ -364,7 +442,7 @@ interface SnippetRowProps {
|
||||
deleteLabel: string;
|
||||
}
|
||||
|
||||
const SnippetRow: React.FC<SnippetRowProps> = ({
|
||||
const SnippetRow = memo<SnippetRowProps>(({
|
||||
snippet,
|
||||
depth,
|
||||
subtitle,
|
||||
@@ -415,7 +493,8 @@ const SnippetRow: React.FC<SnippetRowProps> = ({
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
));
|
||||
SnippetRow.displayName = 'SnippetRow';
|
||||
|
||||
export const ScriptsSidePanel = memo(ScriptsSidePanelInner);
|
||||
ScriptsSidePanel.displayName = 'ScriptsSidePanel';
|
||||
|
||||
@@ -343,7 +343,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={host.os[0].toUpperCase()}
|
||||
className="h-8 w-8 rounded-md"
|
||||
size="md"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Tooltip>
|
||||
|
||||
@@ -16,28 +16,49 @@ type AppInfo = {
|
||||
};
|
||||
|
||||
const REPO_URL = "https://github.com/binaricat/Netcatty";
|
||||
const BUG_REPORT_TEMPLATE = "bug_report.yml";
|
||||
|
||||
const buildIssueUrl = (appInfo: AppInfo) => {
|
||||
const title = "Bug: ";
|
||||
const bodyLines = [
|
||||
"## Describe the problem",
|
||||
"",
|
||||
"## Steps to reproduce",
|
||||
"1.",
|
||||
"",
|
||||
"## Expected behavior",
|
||||
"",
|
||||
"## Actual behavior",
|
||||
"",
|
||||
"## Environment",
|
||||
`- App: ${appInfo.name} ${appInfo.version}`,
|
||||
`- Platform: ${appInfo.platform || "unknown"}`,
|
||||
`- UA: ${typeof navigator !== "undefined" ? navigator.userAgent : "unknown"}`,
|
||||
];
|
||||
const mapIssuePlatform = (platform?: string) => {
|
||||
switch (platform) {
|
||||
case "darwin":
|
||||
return "macOS";
|
||||
case "win32":
|
||||
return "Windows";
|
||||
case "linux":
|
||||
return "Linux";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** Opens GitHub's Bug Report issue form with fields prefilled from the running app. */
|
||||
export const buildIssueUrl = (appInfo: AppInfo) => {
|
||||
const params = new URLSearchParams({
|
||||
title,
|
||||
body: bodyLines.join("\n"),
|
||||
template: BUG_REPORT_TEMPLATE,
|
||||
title: "[Bug] ",
|
||||
});
|
||||
|
||||
if (appInfo.version) {
|
||||
params.set("version", appInfo.version);
|
||||
}
|
||||
|
||||
const platform = mapIssuePlatform(appInfo.platform);
|
||||
if (platform) {
|
||||
params.set("platform", platform);
|
||||
}
|
||||
|
||||
const installSource =
|
||||
appInfo.version === "0.0.0"
|
||||
? "Built from source (npm run dev / pack)"
|
||||
: "GitHub Release (.dmg / .exe / .AppImage / .deb)";
|
||||
params.set("install_source", installSource);
|
||||
|
||||
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "unknown";
|
||||
params.set(
|
||||
"logs",
|
||||
`Reported from Netcatty Settings (${appInfo.name} ${appInfo.version || "unknown"}).\n\nUser-Agent: ${ua}`,
|
||||
);
|
||||
|
||||
return `${REPO_URL}/issues/new?${params.toString()}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useUpdateCheck } from "../application/state/useUpdateCheck";
|
||||
import { useAIState } from "../application/state/useAIState";
|
||||
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
|
||||
import { sanitizePortForwardingRulesForSync } from "../application/syncPayload";
|
||||
import { toast } from "./ui/toast";
|
||||
import SettingsApplicationTab from "./SettingsApplicationTab";
|
||||
import SettingsAppearanceTab from "./settings/tabs/SettingsAppearanceTab";
|
||||
import SettingsFileAssociationsTab from "./settings/tabs/SettingsFileAssociationsTab";
|
||||
@@ -163,8 +164,13 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
|
||||
|
||||
const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }) => {
|
||||
const { t } = useI18n();
|
||||
const { notifyRendererReady, closeSettingsWindow } = useWindowControls();
|
||||
const { updateState, checkNow, installUpdate, openReleasePage, startDownload, isUpdateDemoMode } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
|
||||
const { notifyRendererReady, closeSettingsWindow, onWindowCommandCloseRequested } = useWindowControls();
|
||||
const { updateState, checkNow, installUpdate, openReleasePage, startDownload, isUpdateDemoMode } = useUpdateCheck({
|
||||
autoUpdateEnabled: settings.autoUpdateEnabled,
|
||||
// Install blocked by unsaved editors in the main window — surface a toast
|
||||
// here so a click from the Settings window isn't a silent no-op (#1215).
|
||||
onNeedsSave: () => toast.warning(t('update.needsSave.message'), t('update.needsSave.title')),
|
||||
});
|
||||
const [activeTab, setActiveTab] = useState("application");
|
||||
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
|
||||
|
||||
@@ -172,6 +178,13 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
notifyRendererReady();
|
||||
}, [notifyRendererReady]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onWindowCommandCloseRequested(() => {
|
||||
void closeSettingsWindow();
|
||||
});
|
||||
return () => unsubscribe?.();
|
||||
}, [closeSettingsWindow, onWindowCommandCloseRequested]);
|
||||
|
||||
useEffect(() => {
|
||||
setMountedTabs((prev) => {
|
||||
if (prev.has(activeTab)) return prev;
|
||||
@@ -312,6 +325,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setShowOnlyUngroupedHostsInRoot={settings.setShowOnlyUngroupedHostsInRoot}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
setShowSftpTab={settings.setShowSftpTab}
|
||||
windowOpacity={settings.windowOpacity}
|
||||
setWindowOpacity={settings.setWindowOpacity}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -353,6 +368,10 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setSessionLogsDir={settings.setSessionLogsDir}
|
||||
sessionLogsFormat={settings.sessionLogsFormat}
|
||||
setSessionLogsFormat={settings.setSessionLogsFormat}
|
||||
sessionLogsTimestampsEnabled={settings.sessionLogsTimestampsEnabled}
|
||||
setSessionLogsTimestampsEnabled={settings.setSessionLogsTimestampsEnabled}
|
||||
sshDebugLogsEnabled={settings.sshDebugLogsEnabled}
|
||||
setSshDebugLogsEnabled={settings.setSshDebugLogsEnabled}
|
||||
toggleWindowHotkey={settings.toggleWindowHotkey}
|
||||
setToggleWindowHotkey={settings.setToggleWindowHotkey}
|
||||
closeToTray={settings.closeToTray}
|
||||
|
||||
160
components/SftpClipboardUpload.test.ts
Normal file
160
components/SftpClipboardUpload.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
createDropEntriesFromClipboardFiles,
|
||||
getSftpClipboardSystemTextPaths,
|
||||
getSupportedClipboardUploadFiles,
|
||||
isSftpNativeClipboardPasteEnabled,
|
||||
resolveSftpClipboardUploadTarget,
|
||||
shouldLetNativePasteEventHandleSftpPaste,
|
||||
type ClipboardLocalFile,
|
||||
} from "./sftp/clipboardUpload.ts";
|
||||
import type { SftpFileEntry } from "../types";
|
||||
|
||||
const file = (name: string, overrides: Partial<SftpFileEntry> = {}): SftpFileEntry => ({
|
||||
name,
|
||||
type: "file",
|
||||
size: 1,
|
||||
modified: new Date(0),
|
||||
permissions: "-rw-r--r--",
|
||||
owner: "",
|
||||
group: "",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("clipboard upload targets the selected folder in the file list", () => {
|
||||
const target = resolveSftpClipboardUploadTarget({
|
||||
currentPath: "/home/app",
|
||||
selectedFileNames: ["logs"],
|
||||
files: [file("logs", { type: "directory" })],
|
||||
treeSelection: [],
|
||||
});
|
||||
|
||||
assert.equal(target, "/home/app/logs");
|
||||
});
|
||||
|
||||
test("clipboard upload targets the current directory without a concrete folder selection", () => {
|
||||
const target = resolveSftpClipboardUploadTarget({
|
||||
currentPath: "/home/app",
|
||||
selectedFileNames: [],
|
||||
files: [file("logs", { type: "directory" })],
|
||||
treeSelection: [],
|
||||
});
|
||||
|
||||
assert.equal(target, "/home/app");
|
||||
});
|
||||
|
||||
test("clipboard upload ignores selected regular files when resolving the target", () => {
|
||||
const target = resolveSftpClipboardUploadTarget({
|
||||
currentPath: "/home/app",
|
||||
selectedFileNames: ["readme.md"],
|
||||
files: [file("readme.md")],
|
||||
treeSelection: [],
|
||||
});
|
||||
|
||||
assert.equal(target, "/home/app");
|
||||
});
|
||||
|
||||
test("clipboard upload targets the selected folder in the tree", () => {
|
||||
const target = resolveSftpClipboardUploadTarget({
|
||||
currentPath: "/home/app",
|
||||
selectedFileNames: [],
|
||||
files: [],
|
||||
treeSelection: [{ name: "logs", path: "/var/logs", isDirectory: true }],
|
||||
});
|
||||
|
||||
assert.equal(target, "/var/logs");
|
||||
});
|
||||
|
||||
test("SFTP clipboard system text uses selected list paths", () => {
|
||||
assert.deepEqual(
|
||||
getSftpClipboardSystemTextPaths({
|
||||
currentPath: "/home/app",
|
||||
selectedFileNames: ["one.txt", "nested two.txt"],
|
||||
treeSelection: [],
|
||||
}),
|
||||
["/home/app/one.txt", "/home/app/nested two.txt"],
|
||||
);
|
||||
});
|
||||
|
||||
test("SFTP clipboard system text uses selected tree paths", () => {
|
||||
assert.deepEqual(
|
||||
getSftpClipboardSystemTextPaths({
|
||||
currentPath: "/home/app",
|
||||
selectedFileNames: ["ignored.txt"],
|
||||
treeSelection: [
|
||||
{ name: "logs", path: "/var/logs", isDirectory: true },
|
||||
{ name: "report.txt", path: "/var/report.txt", isDirectory: false },
|
||||
],
|
||||
}),
|
||||
["/var/logs", "/var/report.txt"],
|
||||
);
|
||||
});
|
||||
|
||||
test("clipboard files become path-backed upload entries", () => {
|
||||
const files: ClipboardLocalFile[] = [
|
||||
{ path: "/Users/me/Desktop/report.txt", name: "report.txt", isDirectory: false, size: 42 },
|
||||
];
|
||||
|
||||
assert.deepEqual(createDropEntriesFromClipboardFiles(files), [
|
||||
{
|
||||
file: null,
|
||||
localPath: "/Users/me/Desktop/report.txt",
|
||||
relativePath: "report.txt",
|
||||
isDirectory: false,
|
||||
size: 42,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("clipboard upload keeps directories for recursive folder paste", () => {
|
||||
const files: ClipboardLocalFile[] = [
|
||||
{ path: "/Users/me/Desktop/report.txt", name: "report.txt", isDirectory: false, size: 42 },
|
||||
{ path: "/Users/me/Desktop/folder", name: "folder", isDirectory: true, size: 0 },
|
||||
];
|
||||
|
||||
assert.deepEqual(getSupportedClipboardUploadFiles(files), files);
|
||||
});
|
||||
|
||||
test("SFTP paste keydown lets the native paste event handle OS clipboard files", () => {
|
||||
assert.equal(shouldLetNativePasteEventHandleSftpPaste("sftpPaste", "Ctrl + V"), true);
|
||||
assert.equal(shouldLetNativePasteEventHandleSftpPaste("sftpPaste", "⌘ + V"), true);
|
||||
assert.equal(shouldLetNativePasteEventHandleSftpPaste("sftpPaste", "Ctrl + Shift + V"), false);
|
||||
assert.equal(shouldLetNativePasteEventHandleSftpPaste("sftpPaste", "Cmd + Shift + V"), false);
|
||||
assert.equal(shouldLetNativePasteEventHandleSftpPaste("sftpPaste", "F9"), false);
|
||||
assert.equal(shouldLetNativePasteEventHandleSftpPaste("sftpCopy", "Ctrl + V"), false);
|
||||
});
|
||||
|
||||
test("native clipboard paste follows SFTP paste shortcut availability", () => {
|
||||
assert.equal(
|
||||
isSftpNativeClipboardPasteEnabled("disabled", [
|
||||
{ id: "sftp-paste", action: "sftpPaste", label: "Paste", mac: "⌘ + V", pc: "Ctrl + V", category: "sftp" },
|
||||
]),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isSftpNativeClipboardPasteEnabled("pc", [
|
||||
{ id: "sftp-paste", action: "sftpPaste", label: "Paste", mac: "⌘ + V", pc: "Disabled", category: "sftp" },
|
||||
]),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isSftpNativeClipboardPasteEnabled("pc", [
|
||||
{ id: "sftp-paste", action: "sftpPaste", label: "Paste", mac: "⌘ + V", pc: "F9", category: "sftp" },
|
||||
]),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isSftpNativeClipboardPasteEnabled("pc", [
|
||||
{ id: "sftp-paste", action: "sftpPaste", label: "Paste", mac: "⌘ + V", pc: "Ctrl + Shift + V", category: "sftp" },
|
||||
]),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isSftpNativeClipboardPasteEnabled("pc", [
|
||||
{ id: "sftp-paste", action: "sftpPaste", label: "Paste", mac: "⌘ + V", pc: "Ctrl + V", category: "sftp" },
|
||||
]),
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -10,7 +10,8 @@
|
||||
* Used in TerminalLayer to provide SFTP alongside terminal sessions.
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
|
||||
import { SftpSidePanelDeferredMount } from "./SftpSidePanelDeferredMount";
|
||||
import { formatHostPort } from "../domain/host";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
@@ -39,6 +40,7 @@ import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts"
|
||||
import { sftpFocusStore } from "./sftp/hooks/useSftpFocusedPane";
|
||||
import { keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
|
||||
import { KeyBinding, HotkeyScheme } from "../domain/models";
|
||||
import { shouldFollowTerminalCwdNavigate } from "./sftp/sftpFollowTerminalCwd";
|
||||
|
||||
interface SftpSidePanelProps {
|
||||
hosts: Host[];
|
||||
@@ -49,6 +51,8 @@ interface SftpSidePanelProps {
|
||||
sftpDefaultViewMode: "list" | "tree";
|
||||
/** The host to connect to (follows focused terminal) */
|
||||
activeHost: Host | null;
|
||||
/** The terminal session id whose SSH connection can be reused for SFTP */
|
||||
activeSessionId?: string | null;
|
||||
initialLocation?: { hostId: string; path: string } | null;
|
||||
onInitialLocationApplied?: (location: { hostId: string; path: string }) => void;
|
||||
showWorkspaceHostHeader?: boolean;
|
||||
@@ -70,7 +74,10 @@ interface SftpSidePanelProps {
|
||||
keyBindings: KeyBinding[];
|
||||
editorWordWrap: boolean;
|
||||
setEditorWordWrap: (value: boolean) => void;
|
||||
onGetTerminalCwd?: () => Promise<string | null>;
|
||||
onGetTerminalCwd?: (options?: { preferFreshBackend?: boolean }) => Promise<string | null>;
|
||||
activeTerminalCwd?: string | null;
|
||||
sftpFollowTerminalCwd?: boolean;
|
||||
onSftpFollowTerminalCwdChange?: (enabled: boolean) => void;
|
||||
onRequestTerminalFocus?: () => void;
|
||||
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
|
||||
}
|
||||
@@ -83,6 +90,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
updateHosts,
|
||||
sftpDefaultViewMode,
|
||||
activeHost,
|
||||
activeSessionId,
|
||||
initialLocation,
|
||||
onInitialLocationApplied,
|
||||
showWorkspaceHostHeader = false,
|
||||
@@ -99,6 +107,9 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
onGetTerminalCwd,
|
||||
activeTerminalCwd = null,
|
||||
sftpFollowTerminalCwd = false,
|
||||
onSftpFollowTerminalCwdChange,
|
||||
onRequestTerminalFocus,
|
||||
terminalSettings,
|
||||
}) => {
|
||||
@@ -187,199 +198,34 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
|
||||
const autoSyncRef = useRef(sftpAutoSync);
|
||||
autoSyncRef.current = sftpAutoSync;
|
||||
const panelRootRef = useRef<HTMLDivElement>(null);
|
||||
const dialogActionScopeIdRef = useRef(`sftp-side-panel:${crypto.randomUUID()}`);
|
||||
const [hasPaneFocus, setHasPaneFocus] = useState(false);
|
||||
|
||||
useSftpKeyboardShortcuts({
|
||||
keyBindings,
|
||||
hotkeyScheme,
|
||||
sftpRef,
|
||||
dialogActionScopeId: dialogActionScopeIdRef.current,
|
||||
isActive: isVisible && hasPaneFocus,
|
||||
});
|
||||
|
||||
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
|
||||
const getOpenerForFileRef = useRef(getOpenerForFile);
|
||||
getOpenerForFileRef.current = getOpenerForFile;
|
||||
|
||||
const handleToggleHiddenFiles = useCallback((paneId: string) => {
|
||||
const pane = sftpRef.current.leftTabs.tabs.find((tab) => tab.id === paneId);
|
||||
if (!pane) return;
|
||||
sftpRef.current.setShowHiddenFiles("left", paneId, !pane.showHiddenFiles);
|
||||
}, []);
|
||||
|
||||
const syncFocusedSelection = useCallback((tabId: string | null) => {
|
||||
if (tabId) {
|
||||
keepOnlyPaneSelections(sftpRef.current, { side: "left", tabId });
|
||||
return;
|
||||
}
|
||||
keepOnlyPaneSelections(sftpRef.current, null);
|
||||
}, []);
|
||||
|
||||
const handlePaneFocus = useCallback(() => {
|
||||
sftpFocusStore.setFocusedSide("left");
|
||||
setHasPaneFocus(true);
|
||||
syncFocusedSelection(sftpRef.current.getActiveTabId("left"));
|
||||
}, [syncFocusedSelection]);
|
||||
|
||||
// NOTE: We intentionally do NOT sync to activeTabStore here.
|
||||
// activeTabStore is a global singleton shared with SftpView.
|
||||
// Writing to it here would corrupt SftpView's left pane visibility.
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
setHasPaneFocus(false);
|
||||
syncFocusedSelection(null);
|
||||
}
|
||||
}, [isVisible, syncFocusedSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
const elementTarget = target instanceof Element ? target : null;
|
||||
const isPortalInteraction = !!elementTarget?.closest(
|
||||
'#netcatty-context-menu-root, [role="dialog"], [data-radix-popper-content-wrapper]',
|
||||
);
|
||||
if (isPortalInteraction) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (panelRootRef.current?.contains(target)) {
|
||||
sftpFocusStore.setFocusedSide("left");
|
||||
setHasPaneFocus(true);
|
||||
syncFocusedSelection(sftpRef.current.getActiveTabId("left"));
|
||||
} else {
|
||||
setHasPaneFocus(false);
|
||||
syncFocusedSelection(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
};
|
||||
}, [isVisible, syncFocusedSelection]);
|
||||
|
||||
const {
|
||||
leftCallbacks,
|
||||
rightCallbacks,
|
||||
dragCallbacks,
|
||||
draggedFiles,
|
||||
permissionsState,
|
||||
setPermissionsState,
|
||||
showTextEditor,
|
||||
setShowTextEditor,
|
||||
textEditorTarget,
|
||||
setTextEditorTarget,
|
||||
textEditorContent,
|
||||
setTextEditorContent,
|
||||
showFileOpenerDialog,
|
||||
setShowFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
handleSaveTextFile,
|
||||
onPromoteToTab,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
} = useSftpViewPaneCallbacks({
|
||||
sftpRef,
|
||||
behaviorRef,
|
||||
autoSyncRef,
|
||||
getOpenerForFileRef,
|
||||
setOpenerForExtension,
|
||||
t,
|
||||
listSftp,
|
||||
mkdirLocal,
|
||||
deleteLocalFile,
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection: sftp.getSftpIdForConnection,
|
||||
listLocalFiles: listLocalDir,
|
||||
listDrives,
|
||||
});
|
||||
|
||||
const {
|
||||
leftPanes,
|
||||
showHostPickerLeft,
|
||||
showHostPickerRight,
|
||||
hostSearchLeft,
|
||||
hostSearchRight,
|
||||
setShowHostPickerLeft,
|
||||
setShowHostPickerRight,
|
||||
setHostSearchLeft,
|
||||
setHostSearchRight,
|
||||
handleHostSelectLeft,
|
||||
handleHostSelectRight,
|
||||
} = useSftpViewTabs({ sftp, sftpRef });
|
||||
|
||||
// Auto-connect when activeHost changes.
|
||||
// Uses sftpRef to avoid re-triggering on every sftp state change.
|
||||
const connectedKeyRef = useRef<string | null>(null);
|
||||
// Store the Host object used for the current connection so the header
|
||||
// can show session-time overrides even during deferred host switches.
|
||||
const connectedHostObjRef = useRef<Host | null>(null);
|
||||
const lastAppliedInitialLocationKeyRef = useRef<string | null>(null);
|
||||
const handledPendingUploadIdRef = useRef<string | null>(null);
|
||||
// Maps tab IDs to the connectionKey used to create them, so we can
|
||||
// correctly identify tabs when the same host ID has different overrides.
|
||||
const tabConnectionKeyMapRef = useRef<Map<string, string>>(new Map());
|
||||
const [interactiveWorkActive, setInteractiveWorkActive] = useState(false);
|
||||
const [sftpUiReady, setSftpUiReady] = useState(false);
|
||||
|
||||
// NOTE: We intentionally do NOT reset lastAppliedInitialLocationKeyRef on
|
||||
// visibility changes. When the user switches terminal tabs, the panel
|
||||
// toggles isVisible but should preserve its navigation state (the user may
|
||||
// have navigated away from initialLocation). When the panel is truly
|
||||
// closed, the component unmounts and all refs are naturally reset.
|
||||
|
||||
// Navigate SFTP to the terminal's current working directory
|
||||
const handleGoToTerminalCwd = useCallback(async () => {
|
||||
if (!onGetTerminalCwd) return;
|
||||
const cwd = await onGetTerminalCwd();
|
||||
if (cwd) {
|
||||
sftpRef.current.navigateTo("left", cwd);
|
||||
}
|
||||
}, [onGetTerminalCwd]);
|
||||
|
||||
// Track whether there's active work that should block connection switching.
|
||||
// Computed outside the effect so it can be in the dependency array.
|
||||
// Block host-following while any connection-sensitive interactive UI is
|
||||
// active: text editor, permissions dialog, file-opener dialog, or
|
||||
// auto-synced external file watches.
|
||||
// Note: transfers are NOT included here — they run on their own sftpId
|
||||
// independent of the active tab, and forceNewTab preserves old connections.
|
||||
const hasActiveWork = showTextEditor || !!permissionsState || showFileOpenerDialog
|
||||
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const runAutoConnect = useCallback(() => {
|
||||
if (!activeHost) return;
|
||||
|
||||
const s = sftpRef.current;
|
||||
const hasActiveWork = interactiveWorkActive
|
||||
|| (s.activeFileWatchCountRef?.current ?? 0) > 0;
|
||||
|
||||
// Serial terminals don't support SFTP — disconnect any existing
|
||||
// connection (remote or local) so the panel doesn't remain bound to
|
||||
// a previous host.
|
||||
const proto = activeHost.protocol;
|
||||
if (proto === 'serial' || activeHost.id?.startsWith('serial-')) {
|
||||
// Serial terminals don't support SFTP. Just clear the tracked
|
||||
// connection key so switching back to a remote terminal will
|
||||
// trigger auto-connect. Don't disconnect existing tabs — they
|
||||
// may be reused when focus returns.
|
||||
connectedKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
// Local terminals connect to the local file browser
|
||||
if (proto === 'local' || activeHost.id?.startsWith('local-')) {
|
||||
if (hasActiveWork) return;
|
||||
const leftConn = s.leftPane.connection;
|
||||
if (leftConn?.isLocal) {
|
||||
// Already connected locally
|
||||
connectedKeyRef.current = "local";
|
||||
return;
|
||||
}
|
||||
// Check for an existing local tab to reuse
|
||||
const existingLocalTab = s.leftTabs.tabs.find((tab) =>
|
||||
tab.connection?.isLocal && tab.connection.status === "connected",
|
||||
);
|
||||
@@ -389,27 +235,28 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
return;
|
||||
}
|
||||
connectedKeyRef.current = "local";
|
||||
// Preserve existing remote tab when switching to local
|
||||
const needsNewTab = !!(leftConn && leftConn.status === "connected");
|
||||
if (needsNewTab) {
|
||||
s.connect("left", "local", { forceNewTab: true });
|
||||
} else if (leftConn) {
|
||||
// Await disconnect before connecting locally to avoid the async
|
||||
// disconnect wiping out the fresh local connection.
|
||||
void s.disconnect("left").then(() => s.connect("left", "local"));
|
||||
} else {
|
||||
s.connect("left", "local");
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Build a connection key that accounts for session-time overrides
|
||||
// (same host ID may have different port/protocol in different workspace panes).
|
||||
// Uses buildCacheKey to stay consistent with the key recorded on upload tasks.
|
||||
const connectionKey = buildCacheKey(activeHost.id, activeHost.hostname, activeHost.port, activeHost.protocol, activeHost.sftpSudo, activeHost.username);
|
||||
if (connectedKeyRef.current === connectionKey) return;
|
||||
|
||||
// Don't switch connections while transfers or editor are active
|
||||
const connectionKey = buildCacheKey(
|
||||
activeHost.id,
|
||||
activeHost.hostname,
|
||||
activeHost.port,
|
||||
activeHost.protocol,
|
||||
activeHost.sftpSudo,
|
||||
activeHost.username,
|
||||
);
|
||||
if (connectedKeyRef.current === connectionKey) return;
|
||||
if (hasActiveWork) return;
|
||||
|
||||
logger.info("[SftpSidePanel] Auto-connect triggered", {
|
||||
hostId: activeHost.id,
|
||||
hostLabel: activeHost.label,
|
||||
@@ -417,14 +264,9 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
hostname: activeHost.hostname,
|
||||
});
|
||||
|
||||
// Check if an existing SFTP tab matches this exact endpoint.
|
||||
// We track which connectionKey was used to create each tab so that
|
||||
// tabs for the same host ID with different session-time overrides
|
||||
// (port/protocol) are not incorrectly reused.
|
||||
const tabs = s.leftTabs.tabs;
|
||||
const existingTab = tabs.find((tab) => {
|
||||
if (!tab.connection || tab.connection.hostId !== activeHost.id) return false;
|
||||
// Don't reuse errored tabs — they need a fresh connection
|
||||
if (tab.connection.status === "error" || tab.connection.status === "disconnected") return false;
|
||||
return tabConnectionKeyMapRef.current.get(tab.id) === connectionKey;
|
||||
});
|
||||
@@ -435,28 +277,33 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new tab when there's already an active connection, so the
|
||||
// previous tab is preserved for instant switching on focus change.
|
||||
// This covers both different hosts AND same host with different
|
||||
// session-time overrides (port/protocol), preventing the old SFTP
|
||||
// session from being closed while it may have in-flight transfers.
|
||||
const currentConn = s.leftPane.connection;
|
||||
const needsNewTab = !!(currentConn && currentConn.status === "connected");
|
||||
|
||||
connectedKeyRef.current = connectionKey;
|
||||
connectedHostObjRef.current = activeHost;
|
||||
s.connect("left", activeHost, {
|
||||
sourceSessionId: activeSessionId ?? undefined,
|
||||
...(needsNewTab ? { forceNewTab: true } : undefined),
|
||||
onTabCreated: (tabId) => {
|
||||
tabConnectionKeyMapRef.current.set(tabId, connectionKey);
|
||||
},
|
||||
});
|
||||
}, [activeHost, hasActiveWork]); // Re-evaluate when work finishes so deferred switch can proceed
|
||||
}, [activeHost, activeSessionId, interactiveWorkActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeHost || !isVisible) return;
|
||||
|
||||
let cancelled = false;
|
||||
const frameId = requestAnimationFrame(() => {
|
||||
if (!cancelled) runAutoConnect();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [activeHost, activeSessionId, interactiveWorkActive, isVisible, runAutoConnect]);
|
||||
|
||||
// Clear the remembered connection key when the pane disconnects or the
|
||||
// session is lost, so re-opening SFTP for the same terminal reconnects.
|
||||
// Also reset the file-watch counter — watches are bound to the SFTP session,
|
||||
// so they stop when the session disconnects.
|
||||
useEffect(() => {
|
||||
const connection = sftp.leftPane.connection;
|
||||
if (!connection || connection.status === "error" || connection.status === "disconnected") {
|
||||
@@ -476,8 +323,6 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
if (!connection || connection.isLocal || connection.hostId !== activeHost.id) return;
|
||||
if (connection.status !== "connected") return;
|
||||
|
||||
// Include full endpoint key so that same-hostId sessions with
|
||||
// different overrides each get their initial location applied.
|
||||
const locationKey = `${connectedKeyRef.current}:${initialLocation.path}`;
|
||||
if (lastAppliedInitialLocationKeyRef.current === locationKey) return;
|
||||
|
||||
@@ -559,6 +404,321 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
t,
|
||||
]);
|
||||
|
||||
return (
|
||||
<SftpSidePanelDeferredMount ready={sftpUiReady} onReady={() => setSftpUiReady(true)}>
|
||||
<SftpSidePanelInteractiveBody
|
||||
hosts={hosts}
|
||||
hostWriteSource={hostWriteSource}
|
||||
updateHosts={updateHosts}
|
||||
sftp={sftp}
|
||||
sftpRef={sftpRef}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
activeHost={activeHost}
|
||||
showWorkspaceHostHeader={showWorkspaceHostHeader}
|
||||
renderOverlays={renderOverlays}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
sftpAutoSync={sftpAutoSync}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
onGetTerminalCwd={onGetTerminalCwd}
|
||||
activeTerminalCwd={activeTerminalCwd}
|
||||
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
|
||||
onSftpFollowTerminalCwdChange={onSftpFollowTerminalCwdChange}
|
||||
onRequestTerminalFocus={onRequestTerminalFocus}
|
||||
isVisible={isVisible}
|
||||
behaviorRef={behaviorRef}
|
||||
autoSyncRef={autoSyncRef}
|
||||
connectedHostObjRef={connectedHostObjRef}
|
||||
connectedKeyRef={connectedKeyRef}
|
||||
onInteractiveWorkChange={setInteractiveWorkActive}
|
||||
listSftp={listSftp}
|
||||
mkdirLocal={mkdirLocal}
|
||||
deleteLocalFile={deleteLocalFile}
|
||||
showSaveDialog={showSaveDialog}
|
||||
selectDirectory={selectDirectory}
|
||||
startStreamTransfer={startStreamTransfer}
|
||||
listLocalDir={listLocalDir}
|
||||
listDrives={listDrives}
|
||||
openPath={openPath}
|
||||
t={t}
|
||||
/>
|
||||
</SftpSidePanelDeferredMount>
|
||||
);
|
||||
};
|
||||
|
||||
type SftpSidePanelInteractiveBodyProps = {
|
||||
hosts: Host[];
|
||||
hostWriteSource: Host[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
sftp: ReturnType<typeof useSftpState>;
|
||||
sftpRef: MutableRefObject<ReturnType<typeof useSftpState>>;
|
||||
sftpDefaultViewMode: "list" | "tree";
|
||||
activeHost: Host | null;
|
||||
showWorkspaceHostHeader: boolean;
|
||||
renderOverlays: boolean;
|
||||
sftpDoubleClickBehavior: "open" | "transfer";
|
||||
sftpAutoSync: boolean;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
editorWordWrap: boolean;
|
||||
setEditorWordWrap: (value: boolean) => void;
|
||||
onGetTerminalCwd?: (options?: { preferFreshBackend?: boolean }) => Promise<string | null>;
|
||||
activeTerminalCwd?: string | null;
|
||||
sftpFollowTerminalCwd: boolean;
|
||||
onSftpFollowTerminalCwdChange?: (enabled: boolean) => void;
|
||||
onRequestTerminalFocus?: () => void;
|
||||
isVisible: boolean;
|
||||
behaviorRef: MutableRefObject<"open" | "transfer">;
|
||||
autoSyncRef: MutableRefObject<boolean>;
|
||||
connectedHostObjRef: MutableRefObject<Host | null>;
|
||||
connectedKeyRef: MutableRefObject<string | null>;
|
||||
onInteractiveWorkChange: (active: boolean) => void;
|
||||
listSftp: ReturnType<typeof useSftpBackend>["listSftp"];
|
||||
mkdirLocal: ReturnType<typeof useSftpBackend>["mkdirLocal"];
|
||||
deleteLocalFile: ReturnType<typeof useSftpBackend>["deleteLocalFile"];
|
||||
showSaveDialog: ReturnType<typeof useSftpBackend>["showSaveDialog"];
|
||||
selectDirectory: ReturnType<typeof useSftpBackend>["selectDirectory"];
|
||||
startStreamTransfer: ReturnType<typeof useSftpBackend>["startStreamTransfer"];
|
||||
listLocalDir: ReturnType<typeof useSftpBackend>["listLocalDir"];
|
||||
listDrives: ReturnType<typeof useSftpBackend>["listDrives"];
|
||||
openPath: ReturnType<typeof useSftpBackend>["openPath"];
|
||||
t: ReturnType<typeof useI18n>["t"];
|
||||
};
|
||||
|
||||
const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps> = ({
|
||||
hosts,
|
||||
hostWriteSource,
|
||||
updateHosts,
|
||||
sftp,
|
||||
sftpRef,
|
||||
sftpDefaultViewMode,
|
||||
activeHost,
|
||||
showWorkspaceHostHeader,
|
||||
renderOverlays,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
onGetTerminalCwd,
|
||||
activeTerminalCwd = null,
|
||||
sftpFollowTerminalCwd,
|
||||
onSftpFollowTerminalCwdChange,
|
||||
onRequestTerminalFocus,
|
||||
isVisible,
|
||||
behaviorRef,
|
||||
autoSyncRef,
|
||||
connectedHostObjRef,
|
||||
connectedKeyRef,
|
||||
onInteractiveWorkChange,
|
||||
listSftp,
|
||||
mkdirLocal,
|
||||
deleteLocalFile,
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
startStreamTransfer,
|
||||
listLocalDir,
|
||||
listDrives,
|
||||
openPath,
|
||||
t,
|
||||
}) => {
|
||||
const panelRootRef = useRef<HTMLDivElement>(null);
|
||||
const dialogActionScopeIdRef = useRef(`sftp-side-panel:${crypto.randomUUID()}`);
|
||||
const [hasPaneFocus, setHasPaneFocus] = useState(false);
|
||||
|
||||
useSftpKeyboardShortcuts({
|
||||
keyBindings,
|
||||
hotkeyScheme,
|
||||
sftpRef,
|
||||
dialogActionScopeId: dialogActionScopeIdRef.current,
|
||||
isActive: hasPaneFocus,
|
||||
});
|
||||
|
||||
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
|
||||
const getOpenerForFileRef = useRef(getOpenerForFile);
|
||||
getOpenerForFileRef.current = getOpenerForFile;
|
||||
|
||||
const handleToggleHiddenFiles = useCallback((paneId: string) => {
|
||||
const pane = sftpRef.current.leftTabs.tabs.find((tab) => tab.id === paneId);
|
||||
if (!pane) return;
|
||||
sftpRef.current.setShowHiddenFiles("left", paneId, !pane.showHiddenFiles);
|
||||
}, [sftpRef]);
|
||||
|
||||
const syncFocusedSelection = useCallback((tabId: string | null) => {
|
||||
if (tabId) {
|
||||
keepOnlyPaneSelections(sftpRef.current, { side: "left", tabId });
|
||||
return;
|
||||
}
|
||||
keepOnlyPaneSelections(sftpRef.current, null);
|
||||
}, [sftpRef]);
|
||||
|
||||
const handlePaneFocus = useCallback(() => {
|
||||
sftpFocusStore.setFocusedSide("left");
|
||||
setHasPaneFocus(true);
|
||||
syncFocusedSelection(sftpRef.current.getActiveTabId("left"));
|
||||
}, [sftpRef, syncFocusedSelection]);
|
||||
|
||||
// NOTE: We intentionally do NOT sync to activeTabStore here.
|
||||
// activeTabStore is a global singleton shared with SftpView.
|
||||
// Writing to it here would corrupt SftpView's left pane visibility.
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
const elementTarget = target instanceof Element ? target : null;
|
||||
const isPortalInteraction = !!elementTarget?.closest(
|
||||
'#netcatty-context-menu-root, [role="dialog"], [data-radix-popper-content-wrapper]',
|
||||
);
|
||||
if (isPortalInteraction) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (panelRootRef.current?.contains(target)) {
|
||||
sftpFocusStore.setFocusedSide("left");
|
||||
setHasPaneFocus(true);
|
||||
syncFocusedSelection(sftpRef.current.getActiveTabId("left"));
|
||||
} else {
|
||||
setHasPaneFocus(false);
|
||||
syncFocusedSelection(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
};
|
||||
}, [sftpRef, syncFocusedSelection]);
|
||||
|
||||
const {
|
||||
leftCallbacks,
|
||||
rightCallbacks,
|
||||
dragCallbacks,
|
||||
draggedFiles,
|
||||
permissionsState,
|
||||
setPermissionsState,
|
||||
showTextEditor,
|
||||
setShowTextEditor,
|
||||
textEditorTarget,
|
||||
setTextEditorTarget,
|
||||
textEditorContent,
|
||||
setTextEditorContent,
|
||||
showFileOpenerDialog,
|
||||
setShowFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
handleSaveTextFile,
|
||||
onPromoteToTab,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
} = useSftpViewPaneCallbacks({
|
||||
sftpRef,
|
||||
behaviorRef,
|
||||
autoSyncRef,
|
||||
getOpenerForFileRef,
|
||||
setOpenerForExtension,
|
||||
t,
|
||||
listSftp,
|
||||
mkdirLocal,
|
||||
deleteLocalFile,
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection: sftp.getSftpIdForConnection,
|
||||
listLocalFiles: listLocalDir,
|
||||
listDrives,
|
||||
});
|
||||
|
||||
const {
|
||||
leftPanes,
|
||||
showHostPickerLeft,
|
||||
showHostPickerRight,
|
||||
hostSearchLeft,
|
||||
hostSearchRight,
|
||||
setShowHostPickerLeft,
|
||||
setShowHostPickerRight,
|
||||
setHostSearchLeft,
|
||||
setHostSearchRight,
|
||||
handleHostSelectLeft,
|
||||
handleHostSelectRight,
|
||||
} = useSftpViewTabs({ sftp, sftpRef });
|
||||
|
||||
useEffect(() => {
|
||||
onInteractiveWorkChange(showTextEditor || !!permissionsState || showFileOpenerDialog);
|
||||
}, [onInteractiveWorkChange, permissionsState, showFileOpenerDialog, showTextEditor]);
|
||||
|
||||
const canFollowTerminalCwd = useMemo(() => {
|
||||
if (!onGetTerminalCwd || !activeHost) return false;
|
||||
const proto = activeHost.protocol;
|
||||
if (proto === "local" || proto === "serial") return false;
|
||||
if (activeHost.id?.startsWith("local-") || activeHost.id?.startsWith("serial-")) return false;
|
||||
return true;
|
||||
}, [activeHost, onGetTerminalCwd]);
|
||||
|
||||
const hasActiveWork = showTextEditor || !!permissionsState || showFileOpenerDialog
|
||||
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0;
|
||||
|
||||
const handleGoToTerminalCwd = useCallback(async () => {
|
||||
if (!onGetTerminalCwd) return;
|
||||
const cwd = await onGetTerminalCwd({ preferFreshBackend: true });
|
||||
if (cwd) {
|
||||
sftpRef.current.navigateTo("left", cwd);
|
||||
}
|
||||
}, [onGetTerminalCwd, sftpRef]);
|
||||
|
||||
const syncFollowToTerminalCwd = useCallback(async () => {
|
||||
if (!onGetTerminalCwd || !sftpFollowTerminalCwd || !canFollowTerminalCwd) {
|
||||
return;
|
||||
}
|
||||
|
||||
let terminalCwd = activeTerminalCwd;
|
||||
if (!terminalCwd) {
|
||||
terminalCwd = await onGetTerminalCwd({ preferFreshBackend: true });
|
||||
}
|
||||
if (!terminalCwd) return;
|
||||
|
||||
const connection = sftpRef.current.leftPane.connection;
|
||||
if (!shouldFollowTerminalCwdNavigate({
|
||||
followEnabled: sftpFollowTerminalCwd,
|
||||
isVisible,
|
||||
terminalCwd,
|
||||
currentPath: connection?.currentPath,
|
||||
hasActiveWork,
|
||||
isConnected: Boolean(connection && !connection.isLocal && connection.status === "connected"),
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
await sftpRef.current.navigateTo("left", terminalCwd);
|
||||
}, [
|
||||
activeTerminalCwd,
|
||||
canFollowTerminalCwd,
|
||||
hasActiveWork,
|
||||
isVisible,
|
||||
onGetTerminalCwd,
|
||||
sftpRef,
|
||||
sftpFollowTerminalCwd,
|
||||
]);
|
||||
|
||||
const handleToggleFollowTerminalCwd = useCallback(() => {
|
||||
onSftpFollowTerminalCwdChange?.(!sftpFollowTerminalCwd);
|
||||
}, [onSftpFollowTerminalCwdChange, sftpFollowTerminalCwd]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sftpFollowTerminalCwd || !canFollowTerminalCwd || !isVisible || hasActiveWork) return;
|
||||
void syncFollowToTerminalCwd();
|
||||
}, [
|
||||
activeTerminalCwd,
|
||||
canFollowTerminalCwd,
|
||||
hasActiveWork,
|
||||
isVisible,
|
||||
sftpFollowTerminalCwd,
|
||||
sftp.leftPane.connection?.currentPath,
|
||||
sftp.leftPane.connection?.status,
|
||||
sftp.leftPane.connection?.isLocal,
|
||||
syncFollowToTerminalCwd,
|
||||
]);
|
||||
|
||||
const MAX_VISIBLE_TRANSFERS = 5;
|
||||
const visibleTransfers = useMemo(() => {
|
||||
const connection = sftp.leftPane.connection;
|
||||
@@ -596,7 +756,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
|
||||
await sftpRef.current.navigateTo("left", revealPath, { force: true });
|
||||
},
|
||||
[openPath, t],
|
||||
[openPath, sftpRef, t],
|
||||
);
|
||||
|
||||
const canRevealTransferTarget = useCallback(
|
||||
@@ -623,7 +783,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
|
||||
return connection.id === task.targetConnectionId;
|
||||
},
|
||||
[sftp.leftPane.connection],
|
||||
[connectedKeyRef, sftp.leftPane.connection],
|
||||
);
|
||||
|
||||
const canCopyTransferTargetPath = useCallback(
|
||||
@@ -659,7 +819,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
return hosts.find((h) => h.id === conn.hostId) ?? activeHost;
|
||||
}
|
||||
return activeHost;
|
||||
}, [sftp.leftPane.connection, hosts, activeHost]);
|
||||
}, [activeHost, connectedHostObjRef, hosts, sftp.leftPane.connection]);
|
||||
|
||||
// Determine the active pane to render (without using global activeTabStore)
|
||||
const activeLeftPaneId = sftp.leftTabs.activeTabId;
|
||||
@@ -677,12 +837,14 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
<div
|
||||
ref={panelRootRef}
|
||||
className="h-full flex flex-col bg-background overflow-hidden"
|
||||
style={isVisible ? undefined : { display: "none" }}
|
||||
aria-hidden={!isVisible}
|
||||
data-section="terminal-sftp-panel"
|
||||
onClick={handlePaneFocus}
|
||||
>
|
||||
{showWorkspaceHostHeader && displayHost && (
|
||||
<div className="shrink-0 border-b border-border/50 bg-muted/20 px-3 py-1.5">
|
||||
<div
|
||||
className="shrink-0 border-b border-border/50 bg-muted/20 px-3 py-1.5"
|
||||
data-section="terminal-sftp-host-header"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<DistroAvatar
|
||||
host={displayHost}
|
||||
@@ -724,13 +886,15 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
side="left"
|
||||
pane={pane}
|
||||
dialogActionScopeId={dialogActionScopeIdRef.current}
|
||||
isPaneFocused={isVisible && hasPaneFocus}
|
||||
isPaneFocused={hasPaneFocus}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
showHeader
|
||||
showEmptyHeader
|
||||
forceActive
|
||||
onToggleShowHiddenFiles={() => handleToggleHiddenFiles(pane.id)}
|
||||
onGoToTerminalCwd={onGetTerminalCwd ? handleGoToTerminalCwd : undefined}
|
||||
followTerminalCwd={canFollowTerminalCwd ? sftpFollowTerminalCwd : undefined}
|
||||
onToggleFollowTerminalCwd={canFollowTerminalCwd ? handleToggleFollowTerminalCwd : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -803,6 +967,7 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
|
||||
prev.updateHosts === next.updateHosts &&
|
||||
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
|
||||
prev.activeHost === next.activeHost &&
|
||||
prev.activeSessionId === next.activeSessionId &&
|
||||
prev.showWorkspaceHostHeader === next.showWorkspaceHostHeader &&
|
||||
prev.isVisible === next.isVisible &&
|
||||
prev.renderOverlays === next.renderOverlays &&
|
||||
@@ -817,6 +982,9 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
|
||||
prev.editorWordWrap === next.editorWordWrap &&
|
||||
prev.setEditorWordWrap === next.setEditorWordWrap &&
|
||||
prev.onGetTerminalCwd === next.onGetTerminalCwd &&
|
||||
prev.activeTerminalCwd === next.activeTerminalCwd &&
|
||||
prev.sftpFollowTerminalCwd === next.sftpFollowTerminalCwd &&
|
||||
prev.onSftpFollowTerminalCwdChange === next.onSftpFollowTerminalCwdChange &&
|
||||
prev.onRequestTerminalFocus === next.onRequestTerminalFocus &&
|
||||
prev.initialLocation?.hostId === next.initialLocation?.hostId &&
|
||||
prev.initialLocation?.path === next.initialLocation?.path &&
|
||||
|
||||
38
components/SftpSidePanelDeferredMount.tsx
Normal file
38
components/SftpSidePanelDeferredMount.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React, { startTransition, useEffect } from 'react';
|
||||
|
||||
type SftpSidePanelDeferredMountProps = {
|
||||
children: React.ReactNode;
|
||||
ready: boolean;
|
||||
onReady: () => void;
|
||||
};
|
||||
|
||||
export const SftpSidePanelDeferredMount: React.FC<SftpSidePanelDeferredMountProps> = ({
|
||||
children,
|
||||
ready,
|
||||
onReady,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (ready) return;
|
||||
|
||||
let cancelled = false;
|
||||
const frameId = requestAnimationFrame(() => {
|
||||
if (cancelled) return;
|
||||
startTransition(() => onReady());
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [ready, onReady]);
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="absolute inset-0 z-10 flex h-full items-center justify-center bg-background text-xs text-muted-foreground">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -7,7 +7,7 @@ import { AsidePanel, AsidePanelContent, AsidePanelFooter } from './ui/aside-pane
|
||||
import { Button } from './ui/button';
|
||||
import { Card } from './ui/card';
|
||||
import { Input } from './ui/input';
|
||||
import { CodeTextarea } from './ui/code-textarea';
|
||||
import { SnippetScriptEditor } from './snippets/SnippetScriptEditor';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import { Combobox } from './ui/combobox';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
@@ -165,12 +165,11 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
|
||||
{/* Script */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<p className="text-xs font-semibold text-muted-foreground">{t('snippets.field.scriptRequired')}</p>
|
||||
<CodeTextarea
|
||||
<SnippetScriptEditor
|
||||
label={t('snippets.field.scriptRequired')}
|
||||
placeholder="ls -l"
|
||||
className="min-h-[120px]"
|
||||
value={editingSnippet.command || ''}
|
||||
onChange={(e) => setEditingSnippet({ ...editingSnippet, command: e.target.value })}
|
||||
onChange={(command) => setEditingSnippet({ ...editingSnippet, command })}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
{t('snippets.field.variablesHelp')}
|
||||
|
||||
@@ -107,12 +107,14 @@ interface SyncStatusButtonProps {
|
||||
onOpenSettings?: () => void;
|
||||
onSyncNow?: () => Promise<void>; // Callback to trigger sync with current data
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
onOpenSettings,
|
||||
onSyncNow,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -177,9 +179,10 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8 relative text-muted-foreground hover:text-foreground app-no-drag",
|
||||
"h-7 w-7 relative text-muted-foreground hover:text-foreground app-no-drag",
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
{getButtonIcon()}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FitAddon } from "@xterm/addon-fit";
|
||||
import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Cpu, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
|
||||
import { Cpu, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine, Sparkles } from "lucide-react";
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { detectLocalOs } from "../lib/localShell";
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
|
||||
import { resolveHostAuth } from "../domain/sshAuth";
|
||||
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
import { useTerminalLayoutSuppressActive } from "../application/state/terminalLayoutSuppressStore";
|
||||
// SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer
|
||||
import { Button } from "./ui/button";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
|
||||
@@ -56,14 +57,20 @@ import {
|
||||
createPromptLineBreakState,
|
||||
type PromptLineBreakState,
|
||||
} from "./terminal/runtime/promptLineBreak";
|
||||
import { recordTerminalCommandExecution } from "./terminal/runtime/terminalCommandExecution";
|
||||
import {
|
||||
prepareSudoAutofillInput,
|
||||
type SudoPasswordAutofill,
|
||||
} from "./terminal/runtime/terminalSudoAutofill";
|
||||
import {
|
||||
recordTerminalCommandExecution,
|
||||
shouldRecordShellHistory,
|
||||
} from "./terminal/runtime/terminalCommandExecution";
|
||||
import { shouldPreserveTerminalFocusOnMouseDown } from "./terminal/toolbarFocus";
|
||||
import { preserveTerminalViewportInScrollback } from "./terminal/clearTerminalViewport";
|
||||
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
|
||||
import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch";
|
||||
import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextActions";
|
||||
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
|
||||
import { useServerStats } from "./terminal/hooks/useServerStats";
|
||||
import { useTerminalDragDrop } from "./terminal/hooks/useTerminalDragDrop";
|
||||
import { TerminalAutocomplete } from "./terminal/TerminalAutocomplete";
|
||||
import { createTerminalCwdTracker, resolvePreferredTerminalCwd } from "./terminal/sftpCwd";
|
||||
@@ -71,10 +78,12 @@ import { useTerminalEffects } from "./terminal/useTerminalEffects";
|
||||
import { TerminalView } from "./terminal/TerminalView";
|
||||
import {
|
||||
forceSyncRenderAfterResize,
|
||||
formatNetSpeed,
|
||||
MAX_CONNECTION_LOG_DATA_CHARS,
|
||||
shouldHideConnectingDialogForConnectionReuse,
|
||||
shouldShowTerminalConnectionDialog,
|
||||
type TerminalProps,
|
||||
} from "./terminal/terminalHelpers";
|
||||
import { terminalPropsAreEqual } from "./terminal/terminalMemo";
|
||||
|
||||
const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
host,
|
||||
@@ -85,6 +94,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
themePreviewId,
|
||||
knownHosts = [],
|
||||
isVisible,
|
||||
paneLayoutKey,
|
||||
inWorkspace,
|
||||
isResizing,
|
||||
isFocusMode,
|
||||
@@ -99,6 +109,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
sessionId,
|
||||
startupCommand,
|
||||
noAutoRun,
|
||||
reuseConnectionFromSessionId,
|
||||
serialConfig,
|
||||
hotkeyScheme = "disabled",
|
||||
keyBindings = [],
|
||||
@@ -113,6 +124,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onAddKnownHost,
|
||||
onExpandToFocus,
|
||||
onCommandExecuted,
|
||||
onCommandSubmitted,
|
||||
onSplitHorizontal,
|
||||
onSplitVertical,
|
||||
onOpenSftp,
|
||||
@@ -126,7 +138,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onBroadcastInput,
|
||||
onSnippetExecutorChange,
|
||||
sessionLog,
|
||||
sshDebugLogEnabled,
|
||||
sudoAutofillPassword,
|
||||
onAddSelectionToAI,
|
||||
}) => {
|
||||
const layoutSuppressActive = useTerminalLayoutSuppressActive();
|
||||
const deferTerminalResize = isResizing || layoutSuppressActive;
|
||||
|
||||
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
|
||||
const CONNECTION_TIMEOUT = 120000;
|
||||
const { t } = useI18n();
|
||||
@@ -142,6 +160,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const disposeDataRef = useRef<(() => void) | null>(null);
|
||||
const disposeExitRef = useRef<(() => void) | null>(null);
|
||||
const sessionRef = useRef<string | null>(null);
|
||||
const isBootActiveRef = useRef(false);
|
||||
const hasConnectedRef = useRef(false);
|
||||
const hasRunStartupCommandRef = useRef(false);
|
||||
// Token for an in-flight retry chain. handleRetry sets this to a fresh
|
||||
@@ -211,6 +230,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const autocompleteInputRef = useRef<((data: string) => void) | undefined>(undefined);
|
||||
const autocompleteRepositionRef = useRef<(() => void) | undefined>(undefined);
|
||||
const autocompleteCloseRef = useRef<(() => void) | undefined>(undefined);
|
||||
const sudoHintRef = useRef<((active: boolean) => boolean) | undefined>(undefined);
|
||||
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const { resizeSession, setSessionEncoding } = terminalBackend;
|
||||
@@ -228,10 +248,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const [showSFTP, setShowSFTP] = useState(false);
|
||||
const [progressValue, setProgressValue] = useState(15);
|
||||
const [hasSelection, setHasSelection] = useState(false);
|
||||
const [selectionOverlayPosition, setSelectionOverlayPosition] = useState<{ left: number; top: number } | null>(null);
|
||||
const [isDisconnectedDialogDismissed, setIsDisconnectedDialogDismissed] = useState(false);
|
||||
const [connectionReuseFellBack, setConnectionReuseFellBack] = useState(false);
|
||||
|
||||
const statusRef = useRef<TerminalSession["status"]>(status);
|
||||
statusRef.current = status;
|
||||
const sudoAutofillRef = useRef<SudoPasswordAutofill | null>(null);
|
||||
const sudoAutofillPasswordRef = useRef(sudoAutofillPassword);
|
||||
sudoAutofillPasswordRef.current = sudoAutofillPassword;
|
||||
|
||||
const [chainProgress, setChainProgress] = useState<{
|
||||
currentHop: number;
|
||||
@@ -266,11 +291,38 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
handleCloseSearch,
|
||||
} = terminalSearch;
|
||||
|
||||
const prepareProgrammaticSudoInput = useCallback((data: string): string => {
|
||||
if (
|
||||
statusRef.current !== "connected" ||
|
||||
(isBroadcastEnabledRef.current && onBroadcastInputRef.current)
|
||||
) {
|
||||
return data;
|
||||
}
|
||||
const pastedCommand = data.match(/^([^\r\n]+)(\r\n|\r|\n)$/);
|
||||
if (!pastedCommand || !shouldRecordShellHistory(pastedCommand[1], termRef.current)) {
|
||||
return data;
|
||||
}
|
||||
prepareSudoAutofillInput(data, null, sudoAutofillRef.current);
|
||||
return data;
|
||||
}, []);
|
||||
|
||||
// Terminal autocomplete — onAcceptText writes directly to session (no CustomEvent)
|
||||
const autocompleteAcceptTextRef = useRef<((text: string) => void) | undefined>(undefined);
|
||||
autocompleteAcceptTextRef.current = (text: string) => {
|
||||
const id = sessionRef.current;
|
||||
if (id && text) {
|
||||
let textToWrite = text;
|
||||
let handledSubmittedInput = false;
|
||||
if (
|
||||
host.protocol !== "serial" &&
|
||||
statusRef.current === "connected" &&
|
||||
!(isBroadcastEnabledRef.current && onBroadcastInputRef.current)
|
||||
) {
|
||||
const preparedText = prepareProgrammaticSudoInput(text);
|
||||
handledSubmittedInput = preparedText !== text;
|
||||
textToWrite = preparedText;
|
||||
}
|
||||
|
||||
// Serial line mode: buffer text and handle local echo instead of direct send
|
||||
if (host.protocol === "serial" && serialConfig?.lineMode) {
|
||||
for (const ch of text) {
|
||||
@@ -298,7 +350,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// (fall through to shared bookkeeping below — don't return early)
|
||||
} else if (host.protocol === "serial" && serialConfig?.localEcho) {
|
||||
// Serial character mode with local echo: echo accepted text locally
|
||||
terminalBackend.writeToSession(id, text);
|
||||
terminalBackend.writeToSession(id, textToWrite);
|
||||
for (const ch of text) {
|
||||
if (ch === "\r") {
|
||||
writeLocalTerminalData("\r\n");
|
||||
@@ -307,7 +359,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
}
|
||||
} else {
|
||||
terminalBackend.writeToSession(id, text);
|
||||
terminalBackend.writeToSession(id, textToWrite);
|
||||
}
|
||||
|
||||
// Broadcast to other sessions if broadcast mode is enabled
|
||||
@@ -317,12 +369,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
// Update command buffer for onCommandExecuted tracking
|
||||
for (const ch of text) {
|
||||
if (ch === "\r" || ch === "\n") {
|
||||
if (handledSubmittedInput) {
|
||||
commandBufferRef.current = "";
|
||||
break;
|
||||
} else if (ch === "\r" || ch === "\n") {
|
||||
const rawCommand = commandBufferRef.current;
|
||||
recordTerminalCommandExecution(rawCommand, {
|
||||
host,
|
||||
sessionId,
|
||||
onCommandExecuted,
|
||||
onCommandSubmitted,
|
||||
commandBufferRef,
|
||||
promptLineBreakStateRef,
|
||||
}, termRef.current);
|
||||
@@ -398,20 +454,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const isSupportedOs =
|
||||
!isNetworkDevice &&
|
||||
(host.os === 'linux' || host.os === 'macos' || detectedDeviceClass === 'linux-like');
|
||||
const { stats: serverStats } = useServerStats({
|
||||
sessionId,
|
||||
enabled: terminalSettings?.showServerStats ?? true,
|
||||
refreshInterval: terminalSettings?.serverStatsRefreshInterval ?? 5,
|
||||
isSupportedOs,
|
||||
isConnected: status === 'connected',
|
||||
isVisible,
|
||||
});
|
||||
// Server-stats polling now lives inside <TerminalServerStats> (rendered by
|
||||
// TerminalView) so its ~5s refresh only re-renders that widget, not the whole
|
||||
// terminal. We just forward `isSupportedOs` via ctx.
|
||||
|
||||
const zmodem = useZmodemTransfer(sessionId);
|
||||
|
||||
const zmodemToastedRef = useRef(false);
|
||||
|
||||
const pendingAuthRef = useRef<PendingAuth>(null);
|
||||
useEffect(() => {
|
||||
sudoAutofillRef.current?.updatePassword(sudoAutofillPassword);
|
||||
}, [sudoAutofillPassword]);
|
||||
const sessionStartersRef = useRef<ReturnType<typeof createTerminalSessionStarters> | null>(null);
|
||||
const auth = useTerminalAuthState({
|
||||
host,
|
||||
@@ -425,6 +479,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
starters.startMosh(term);
|
||||
return;
|
||||
}
|
||||
if (host.etEnabled) {
|
||||
starters.startEt(term);
|
||||
return;
|
||||
}
|
||||
starters.startSSH(term);
|
||||
},
|
||||
setStatus: (next) => setStatus(next),
|
||||
@@ -551,6 +609,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
};
|
||||
|
||||
const teardown = () => {
|
||||
isBootActiveRef.current = false;
|
||||
retryTokenRef.current = null;
|
||||
cleanupSession();
|
||||
xtermRuntimeRef.current?.dispose();
|
||||
@@ -568,6 +627,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
knownHosts,
|
||||
resolvedChainHosts,
|
||||
sessionId,
|
||||
reuseConnectionFromSessionId,
|
||||
startupCommand,
|
||||
noAutoRun,
|
||||
terminalSettings,
|
||||
@@ -575,6 +635,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
terminalBackend,
|
||||
serialConfig,
|
||||
isVisibleRef,
|
||||
isBootActiveRef,
|
||||
pendingOutputScrollRef,
|
||||
sessionRef,
|
||||
hasConnectedRef,
|
||||
@@ -585,6 +646,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
serializeAddonRef,
|
||||
pendingAuthRef,
|
||||
promptLineBreakStateRef,
|
||||
sudoAutofillRef,
|
||||
onSudoHint: (active: boolean) => sudoHintRef.current?.(active) ?? false,
|
||||
updateStatus,
|
||||
setStatus,
|
||||
setError,
|
||||
@@ -605,7 +668,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const isSerial = host.protocol === 'serial' || host.id?.startsWith('serial-');
|
||||
const isTelnet = host.protocol === 'telnet';
|
||||
const isMosh = host.protocol === 'mosh' || host.moshEnabled;
|
||||
const isSSH = !isLocal && !isSerial && !isTelnet && !isMosh;
|
||||
const isEt = host.protocol === 'et' || host.etEnabled;
|
||||
const isSSH = !isLocal && !isSerial && !isTelnet && !isMosh && !isEt;
|
||||
if (isSSH) {
|
||||
setSessionEncoding(id, terminalEncodingRef.current);
|
||||
return;
|
||||
@@ -629,9 +693,23 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onOsDetected,
|
||||
onCommandExecuted,
|
||||
sessionLog,
|
||||
sshDebugLogEnabled,
|
||||
sudoAutofillPassword,
|
||||
sudoAutofillPasswordRef,
|
||||
});
|
||||
sessionStartersRef.current = sessionStarters;
|
||||
|
||||
useEffect(() => {
|
||||
setConnectionReuseFellBack(false);
|
||||
if (!reuseConnectionFromSessionId) return undefined;
|
||||
|
||||
return terminalBackend.onConnectionReuseFallback?.((fallbackSessionId) => {
|
||||
if (fallbackSessionId === sessionId) {
|
||||
setConnectionReuseFellBack(true);
|
||||
}
|
||||
});
|
||||
}, [reuseConnectionFromSessionId, sessionId, terminalBackend]);
|
||||
|
||||
const safeFit = (options?: { force?: boolean; requireVisible?: boolean }) => {
|
||||
const fitAddon = fitAddonRef.current;
|
||||
if (!fitAddon) return;
|
||||
@@ -717,7 +795,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const executeSnippetCommand = useCallback((command: string, noAutoRun?: boolean) => {
|
||||
const executeSnippetCommand = useCallback((
|
||||
command: string,
|
||||
noAutoRun?: boolean,
|
||||
options?: { broadcast?: boolean },
|
||||
) => {
|
||||
const term = termRef.current;
|
||||
const id = sessionRef.current;
|
||||
if (!term || !id) return;
|
||||
@@ -739,14 +821,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// without re-wrapping. Without broadcasting at all, accepting a snippet in
|
||||
// broadcast mode would clear peer input (the clear keystrokes already go
|
||||
// through the broadcast-aware path) but never send the command.
|
||||
if (isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
|
||||
if (options?.broadcast !== false && isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
|
||||
onBroadcastInputRef.current(data, sessionId);
|
||||
}
|
||||
|
||||
data = prepareProgrammaticSudoInput(data);
|
||||
terminalBackend.writeToSession(id, data);
|
||||
scrollToBottomAfterProgrammaticInput(data);
|
||||
term.focus();
|
||||
}, [scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId]);
|
||||
}, [prepareProgrammaticSudoInput, scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId]);
|
||||
|
||||
const executeSnippet = useCallback(async (snippet: Snippet) => {
|
||||
const command = await resolveSnippetCommand(snippet);
|
||||
@@ -773,15 +856,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const terminalContextActionsRef = useRef(terminalContextActions);
|
||||
terminalContextActionsRef.current = terminalContextActions;
|
||||
|
||||
const handleSetTerminalEncoding = (encoding: 'utf-8' | 'gb18030') => {
|
||||
const handleAddSelectionToAI = useCallback(() => {
|
||||
const selection = termRef.current?.getSelection() ?? "";
|
||||
if (!selection.trim()) return;
|
||||
onAddSelectionToAI?.(sessionId, selection);
|
||||
}, [onAddSelectionToAI, sessionId]);
|
||||
|
||||
const handleSetTerminalEncoding = useCallback((encoding: 'utf-8' | 'gb18030') => {
|
||||
setTerminalEncoding(encoding);
|
||||
userPickedEncodingRef.current = true;
|
||||
if (sessionRef.current) {
|
||||
setSessionEncoding(sessionRef.current, encoding);
|
||||
}
|
||||
};
|
||||
}, [setSessionEncoding]);
|
||||
|
||||
const handleOpenSFTP = async () => {
|
||||
const handleOpenSFTP = useCallback(async () => {
|
||||
if (onOpenSftp) {
|
||||
// Delegate to parent (TerminalLayer) for shared SFTP side panel
|
||||
const initialPath = await resolveSftpInitialPath();
|
||||
@@ -795,7 +884,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return;
|
||||
}
|
||||
setShowSFTP(true);
|
||||
};
|
||||
}, [host, onOpenSftp, resolveSftpInitialPath, sessionId, showSFTP]);
|
||||
|
||||
const handleCancelConnect = () => {
|
||||
if (pendingHostKeyRequestId) {
|
||||
@@ -878,6 +967,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
terminalDataCapturedRef.current = false;
|
||||
hasRunStartupCommandRef.current = false;
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
setConnectionReuseFellBack(false);
|
||||
setStatus("connecting");
|
||||
setError(null);
|
||||
setProgressLogs(["Retrying secure channel..."]);
|
||||
@@ -893,6 +983,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
sessionStarters.startTelnet(term);
|
||||
} else if (host.moshEnabled) {
|
||||
sessionStarters.startMosh(term);
|
||||
} else if (host.etEnabled) {
|
||||
sessionStarters.startEt(term);
|
||||
} else {
|
||||
sessionStarters.startSSH(term);
|
||||
}
|
||||
@@ -929,9 +1021,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
const shouldShowConnectionDialog = status !== "connected"
|
||||
&& !((isLocalConnection || isSerialConnection) && status === "connecting")
|
||||
&& !(status === "disconnected" && isDisconnectedDialogDismissed);
|
||||
const shouldShowConnectionDialog = shouldShowTerminalConnectionDialog({
|
||||
status,
|
||||
isLocalConnection,
|
||||
isSerialConnection,
|
||||
isDisconnectedDialogDismissed,
|
||||
hideConnectingDialogForConnectionReuse: shouldHideConnectingDialogForConnectionReuse({
|
||||
reuseConnectionFromSessionId,
|
||||
host,
|
||||
connectionReuseFellBack,
|
||||
}),
|
||||
});
|
||||
|
||||
const {
|
||||
handleDragEnter,
|
||||
@@ -953,7 +1053,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef,
|
||||
});
|
||||
|
||||
const renderControls = (opts?: { showClose?: boolean }) => (
|
||||
const renderControls = useCallback((opts?: { showClose?: boolean }) => (
|
||||
<TerminalToolbar
|
||||
status={status}
|
||||
host={host}
|
||||
@@ -970,7 +1070,24 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
terminalEncoding={terminalEncoding}
|
||||
onSetTerminalEncoding={handleSetTerminalEncoding}
|
||||
/>
|
||||
);
|
||||
), [
|
||||
handleOpenSFTP,
|
||||
handleSetTerminalEncoding,
|
||||
handleToggleSearch,
|
||||
host,
|
||||
inWorkspace,
|
||||
isComposeBarOpen,
|
||||
isSearchOpen,
|
||||
isWorkspaceComposeBarOpen,
|
||||
onCloseSession,
|
||||
onOpenScripts,
|
||||
onOpenTheme,
|
||||
onToggleComposeBar,
|
||||
onUpdateHost,
|
||||
sessionId,
|
||||
status,
|
||||
terminalEncoding,
|
||||
]);
|
||||
|
||||
const statusDotTone =
|
||||
status === "connected"
|
||||
@@ -987,12 +1104,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
['--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]);
|
||||
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, primaryFontFamily, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, primaryFontFamily, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
|
||||
|
||||
return <TerminalView ctx={{ ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, containerRef, effectiveTheme, error, executeSnippet, executeSnippetCommand, formatNetSpeed, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSearchOpen, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onBroadcastInput, onCloseSession, onExpandToFocus, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, scrollToBottomAfterProgrammaticInput, searchMatchCount, serverStats, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
|
||||
return <TerminalView ctx={{ ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, containerRef, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSearchOpen, isSupportedOs, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
|
||||
};
|
||||
|
||||
const Terminal = memo(TerminalComponent);
|
||||
const Terminal = memo(TerminalComponent, terminalPropsAreEqual);
|
||||
Terminal.displayName = "Terminal";
|
||||
|
||||
export default Terminal;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { terminalLayerAreEqual } from "./terminalLayerMemo.ts";
|
||||
|
||||
const baseProps = {
|
||||
hosts: [],
|
||||
customGroups: [],
|
||||
groupConfigs: [],
|
||||
proxyProfiles: [],
|
||||
keys: [],
|
||||
@@ -28,7 +29,10 @@ const baseProps = {
|
||||
sftpShowHiddenFiles: false,
|
||||
sftpUseCompressedUpload: false,
|
||||
sftpAutoOpenSidebar: false,
|
||||
sftpFollowTerminalCwd: false,
|
||||
setSftpFollowTerminalCwd: () => {},
|
||||
editorWordWrap: false,
|
||||
sshDebugLogsEnabled: false,
|
||||
setEditorWordWrap: () => {},
|
||||
onHotkeyAction: () => {},
|
||||
onUpdateHost: () => {},
|
||||
@@ -38,6 +42,7 @@ const baseProps = {
|
||||
isBroadcastEnabled: () => false,
|
||||
onToggleBroadcast: () => {},
|
||||
onSplitSession: () => {},
|
||||
onConnectToHost: () => {},
|
||||
toggleScriptsSidePanelRef: { current: null },
|
||||
};
|
||||
|
||||
@@ -118,3 +123,13 @@ test("TerminalLayer re-renders when broadcast toggle handler changes", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer re-renders when SSH debug logging changes", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, sshDebugLogsEnabled: true } as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user