Compare commits
281 Commits
v1.1.19
...
fix/scroll
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
850d038c5a | ||
|
|
52bc48f73a | ||
|
|
46755465f9 | ||
|
|
ecadc1fc2d | ||
|
|
79ccf47655 | ||
|
|
6ef0a4ad6b | ||
|
|
88142d2a92 | ||
|
|
f5c3302329 | ||
|
|
bb02f8e162 | ||
|
|
d57dd664a2 | ||
|
|
74ec6678bb | ||
|
|
b9e88cd99d | ||
|
|
32afade4f9 | ||
|
|
66de2db912 | ||
|
|
0a38da8867 | ||
|
|
5e739f8293 | ||
|
|
6f64245d10 | ||
|
|
d48ca65a1e | ||
|
|
285fcd55a9 | ||
|
|
05b713ab18 | ||
|
|
293b15f67a | ||
|
|
83aec35f2f | ||
|
|
910ef72205 | ||
|
|
550a37b379 | ||
|
|
2b396c14e3 | ||
|
|
36724a3abd | ||
|
|
4459aa4ef3 | ||
|
|
64a6986d01 | ||
|
|
a301ecb2ca | ||
|
|
f16429e30f | ||
|
|
46b9bf6ccb | ||
|
|
17c8f11194 | ||
|
|
4d1a7ea55a | ||
|
|
babe06a944 | ||
|
|
9e31d53bdd | ||
|
|
ea24841939 | ||
|
|
bf9f557e42 | ||
|
|
106e748a9b | ||
|
|
94fff62f9b | ||
|
|
324253f23a | ||
|
|
e9a2e44a91 | ||
|
|
7b4f046001 | ||
|
|
1a3560a19f | ||
|
|
3b525300e0 | ||
|
|
08ff49d3f5 | ||
|
|
f5c4271a07 | ||
|
|
74d41b43b6 | ||
|
|
3408bba303 | ||
|
|
5e00e998a8 | ||
|
|
3847f0cda0 | ||
|
|
1ebcd017bd | ||
|
|
9013a7e312 | ||
|
|
afefbd953f | ||
|
|
535b141b23 | ||
|
|
b21e44b65f | ||
|
|
b42be379e3 | ||
|
|
b2f0a3bea3 | ||
|
|
df3745d185 | ||
|
|
f85bb3f9b2 | ||
|
|
566f3e3c32 | ||
|
|
58eb91fb23 | ||
|
|
36267717ac | ||
|
|
5e323f1f8f | ||
|
|
c0efc9d5c1 | ||
|
|
61188ab8e2 | ||
|
|
ae209d37c1 | ||
|
|
a5b0efba75 | ||
|
|
5adb64e40e | ||
|
|
41fea1028d | ||
|
|
5a90a4331b | ||
|
|
881f3b1a34 | ||
|
|
8be5865b76 | ||
|
|
685d1cb41a | ||
|
|
14fe1e3ecb | ||
|
|
636f4d7037 | ||
|
|
c92ad2f601 | ||
|
|
8a876fd67d | ||
|
|
d39cd60863 | ||
|
|
602ca92476 | ||
|
|
f413035295 | ||
|
|
bfd3fb4dad | ||
|
|
733e19a6f6 | ||
|
|
85b552e1a6 | ||
|
|
068730c53c | ||
|
|
c9d84c7ce3 | ||
|
|
d558aea7de | ||
|
|
e211eec693 | ||
|
|
6b1277d3e1 | ||
|
|
35bf38be70 | ||
|
|
555c00406e | ||
|
|
e67012654a | ||
|
|
ecdb1d17cd | ||
|
|
a5578b5e60 | ||
|
|
fb4641878f | ||
|
|
7d6f30f51f | ||
|
|
9869b645b1 | ||
|
|
037b85bd66 | ||
|
|
ba784b8b35 | ||
|
|
eae760db3f | ||
|
|
4b5993cad6 | ||
|
|
6af62aa093 | ||
|
|
61e8de4270 | ||
|
|
27dce4e427 | ||
|
|
8b53fb1c7b | ||
|
|
6c1661dc3c | ||
|
|
3662b45121 | ||
|
|
437253179e | ||
|
|
d85f4edbbb | ||
|
|
96c9ccaaa0 | ||
|
|
3203ed7a19 | ||
|
|
517cbb6cee | ||
|
|
3bc373dbec | ||
|
|
273fe10296 | ||
|
|
2a10a28cc8 | ||
|
|
f74645e1a4 | ||
|
|
15ec02dcae | ||
|
|
e75c654a1a | ||
|
|
29b1eca1fd | ||
|
|
e2d036e710 | ||
|
|
094f0abe4a | ||
|
|
8ab2003dae | ||
|
|
b1b0c5648c | ||
|
|
36e5779d94 | ||
|
|
846d8246a3 | ||
|
|
26a04b22d3 | ||
|
|
53aef452cc | ||
|
|
f5f55ffc2e | ||
|
|
3ef5a64b94 | ||
|
|
c28db932a4 | ||
|
|
0792ce1415 | ||
|
|
f2c2501fa5 | ||
|
|
b1f930a995 | ||
|
|
eca23a2691 | ||
|
|
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 | ||
|
|
aa1781577b | ||
|
|
409d293faa | ||
|
|
8181fe71cf | ||
|
|
d06009684e | ||
|
|
55236ce34a | ||
|
|
b89f06b7f0 | ||
|
|
a01d1f770f | ||
|
|
f1fdb61195 | ||
|
|
39fea86f13 | ||
|
|
ce5d1d0e5a | ||
|
|
7ac29366ae | ||
|
|
9d0f6a9cea | ||
|
|
e0403412e7 | ||
|
|
bb67aa77f5 | ||
|
|
e948a7a869 | ||
|
|
3fc56df111 | ||
|
|
4860581525 | ||
|
|
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 | ||
|
|
d9156349e1 | ||
|
|
983b0b2f1d | ||
|
|
a552c14cbd | ||
|
|
3f5787ceb1 | ||
|
|
e4ec2363d0 | ||
|
|
84b71910ee | ||
|
|
371217832b | ||
|
|
afb514b472 | ||
|
|
e14dc22bba | ||
|
|
6b7c12c23c | ||
|
|
222b3869dd | ||
|
|
56af2d3840 | ||
|
|
1695470089 | ||
|
|
d4b5f799cb |
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 / .rpm / .pacman)
|
||||
- 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
|
||||
11
.github/scripts/generate-release-note.js
vendored
11
.github/scripts/generate-release-note.js
vendored
@@ -50,6 +50,7 @@ const baseUrl = `https://github.com/${repo}/releases/download/${tag}`;
|
||||
// - AppImage: x64 -> x86_64, arm64 -> arm64
|
||||
// - deb: x64 -> amd64, arm64 -> arm64
|
||||
// - rpm: x64 -> x86_64, arm64 -> aarch64
|
||||
// - pacman: x64 -> x64, arm64 -> aarch64
|
||||
const files = {
|
||||
mac: {
|
||||
arm64: `Netcatty-${version}-mac-arm64.dmg`,
|
||||
@@ -70,6 +71,10 @@ const files = {
|
||||
rpm: {
|
||||
x64: `Netcatty-${version}-linux-x86_64.rpm`,
|
||||
arm64: `Netcatty-${version}-linux-aarch64.rpm`
|
||||
},
|
||||
pacman: {
|
||||
x64: `Netcatty-${version}-linux-x64.pacman`,
|
||||
arm64: `Netcatty-${version}-linux-aarch64.pacman`
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -88,7 +93,9 @@ const badges = {
|
||||
deb_x64: `[](${baseUrl}/${files.linux.deb.x64})`,
|
||||
deb_arm64: `[](${baseUrl}/${files.linux.deb.arm64})`,
|
||||
rpm_x64: `[](${baseUrl}/${files.linux.rpm.x64})`,
|
||||
rpm_arm64: `[](${baseUrl}/${files.linux.rpm.arm64})`
|
||||
rpm_arm64: `[](${baseUrl}/${files.linux.rpm.arm64})`,
|
||||
pacman_x64: `[](${baseUrl}/${files.linux.pacman.x64})`,
|
||||
pacman_arm64: `[](${baseUrl}/${files.linux.pacman.arm64})`
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,7 +106,7 @@ const content = `
|
||||
| :--- | :--- |
|
||||
| **Windows** | ${badges.win.setup_x64} |
|
||||
| **macOS** | ${badges.mac.apple_silicon} ${badges.mac.intel} |
|
||||
| **Linux** | ${badges.linux.appimage_x64} ${badges.linux.deb_x64} ${badges.linux.rpm_x64} <br> ${badges.linux.appimage_arm64} ${badges.linux.deb_arm64} ${badges.linux.rpm_arm64} |
|
||||
| **Linux** | ${badges.linux.appimage_x64} ${badges.linux.deb_x64} ${badges.linux.rpm_x64} ${badges.linux.pacman_x64} <br> ${badges.linux.appimage_arm64} ${badges.linux.deb_arm64} ${badges.linux.rpm_arm64} ${badges.linux.pacman_arm64} |
|
||||
`;
|
||||
|
||||
fs.writeFileSync('release_notes.md', content);
|
||||
|
||||
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
|
||||
118
.github/workflows/build.yml
vendored
118
.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: |
|
||||
@@ -306,6 +348,7 @@ jobs:
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.rpm
|
||||
release/*.pacman
|
||||
release/*.tar.gz
|
||||
release/*.yml
|
||||
release/*.blockmap
|
||||
@@ -318,7 +361,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 +369,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 +388,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
|
||||
|
||||
@@ -356,6 +411,9 @@ jobs:
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Install pacman packaging dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y libarchive-tools
|
||||
|
||||
- name: Set version
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -379,6 +437,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
|
||||
@@ -399,6 +461,7 @@ jobs:
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.rpm
|
||||
release/*.pacman
|
||||
release/*.yml
|
||||
release/*.blockmap
|
||||
if-no-files-found: ignore
|
||||
@@ -408,7 +471,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 +481,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,10 +500,22 @@ 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
|
||||
apt-get install -y curl build-essential python3 git libfuse2 file rpm \
|
||||
libarchive-tools \
|
||||
libglib2.0-0 libgtk-3-0 libnss3 libxss1 libxtst6 libasound2 \
|
||||
libatk-bridge2.0-0 libdrm2 libgbm1 libx11-xcb1 libxcb-dri3-0
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
@@ -474,6 +550,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
|
||||
@@ -494,6 +574,7 @@ jobs:
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.rpm
|
||||
release/*.pacman
|
||||
release/*.yml
|
||||
release/*.blockmap
|
||||
if-no-files-found: ignore
|
||||
@@ -599,6 +680,7 @@ jobs:
|
||||
artifacts/*.AppImage
|
||||
artifacts/*.deb
|
||||
artifacts/*.rpm
|
||||
artifacts/*.pacman
|
||||
artifacts/*.yml
|
||||
artifacts/*.blockmap
|
||||
generate_release_notes: true
|
||||
|
||||
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],
|
||||
});
|
||||
19
.gitignore
vendored
19
.gitignore
vendored
@@ -8,6 +8,7 @@ pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
@@ -41,6 +42,15 @@ coverage
|
||||
# Codex
|
||||
/.codex/
|
||||
|
||||
# Qoder
|
||||
.qoder
|
||||
|
||||
# Workbuddy
|
||||
.workbuddy
|
||||
|
||||
# Codebuddy
|
||||
.codebuddy
|
||||
|
||||
# AI / Superpowers generated docs (local only)
|
||||
/docs/superpowers/
|
||||
|
||||
@@ -73,3 +83,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
|
||||
|
||||
206
App.tsx
206
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,14 @@ import { materializeHostProxyProfile } from './domain/proxyProfiles';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
|
||||
import {
|
||||
applyCustomAccentToTerminalTheme,
|
||||
mergeTerminalHostUpdate,
|
||||
resolveHostTerminalThemeId,
|
||||
type TerminalHostUpdate,
|
||||
} 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 +58,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 +104,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,13 +130,16 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
sftpAutoOpenSidebar,
|
||||
sftpFollowTerminalCwd,
|
||||
setSftpFollowTerminalCwd,
|
||||
sftpDefaultViewMode,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
sessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
reapplyCurrentTheme,
|
||||
sessionLogsTimestampsEnabled,
|
||||
applyAppTheme,
|
||||
workspaceFocusStyle,
|
||||
} = settings;
|
||||
|
||||
@@ -185,7 +194,6 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const keysRef = useRef(keys);
|
||||
keysRef.current = keys;
|
||||
const knownHostsRef = useRef(knownHosts);
|
||||
knownHostsRef.current = knownHosts;
|
||||
// Bridge the gap while useVaultState hydrates: its async init awaits
|
||||
// hosts/keys/identities/proxyProfiles decryption before reading knownHosts,
|
||||
// so the state is briefly [] at boot even when localStorage has entries.
|
||||
@@ -196,6 +204,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
() => getEffectiveKnownHosts(knownHosts) ?? [],
|
||||
[knownHosts],
|
||||
);
|
||||
knownHostsRef.current = effectiveKnownHosts;
|
||||
|
||||
const {
|
||||
sessions,
|
||||
@@ -207,6 +216,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sessionRenameValue,
|
||||
setSessionRenameValue,
|
||||
startSessionRename,
|
||||
renameSessionInline,
|
||||
submitSessionRename,
|
||||
resetSessionRename,
|
||||
workspaceRenameTarget,
|
||||
@@ -221,9 +231,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
closeSession,
|
||||
closeWorkspace,
|
||||
updateSessionStatus,
|
||||
updateSessionFontSize,
|
||||
clearSessionFontSizeOverride,
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
removeSessionFromWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendLocalTerminalToWorkspace,
|
||||
createWorkspaceFromTargets,
|
||||
@@ -236,6 +249,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
runSnippet,
|
||||
orphanSessions,
|
||||
orderedTabs,
|
||||
getOrderedWorkTabs,
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
@@ -243,6 +257,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
openLogView,
|
||||
closeLogView,
|
||||
copySession,
|
||||
createSessionFromCloneSource,
|
||||
} = useSessionState();
|
||||
|
||||
const handleRunSnippet = useCallback(
|
||||
@@ -258,19 +273,11 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const isMacClient = typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Immersive Mode — derive UI chrome colors from the active terminal's theme
|
||||
// Active tab lookup maps
|
||||
// ---------------------------------------------------------------------------
|
||||
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])),
|
||||
[hosts],
|
||||
@@ -289,59 +296,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 (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 +482,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 +678,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 +686,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 +702,25 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
const closeTabsInFlightRef = useRef(false);
|
||||
|
||||
const editorTabTopIds = useMemo(
|
||||
() => editorTabs.map((tab) => toEditorTabId(tab.id)),
|
||||
[editorTabs],
|
||||
);
|
||||
|
||||
// 顶层标签顺序需要包含编辑器标签,供顶部标签和编辑器邻居计算使用。
|
||||
const orderedTabsWithEditors = useMemo(
|
||||
() => getOrderedWorkTabs(editorTabTopIds),
|
||||
[editorTabTopIds, getOrderedWorkTabs],
|
||||
);
|
||||
|
||||
const reorderWorkTabs = useCallback((
|
||||
draggedId: string,
|
||||
targetId: string,
|
||||
position: 'before' | 'after' = 'before',
|
||||
) => {
|
||||
reorderTabs(draggedId, targetId, position, editorTabTopIds);
|
||||
}, [editorTabTopIds, reorderTabs]);
|
||||
|
||||
// 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).
|
||||
@@ -731,7 +730,44 @@ 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 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, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, workspaces }), action, e); }, [orderedTabs, editorTabs, sessions, workspaces, isQuickSwitcherOpen, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, toggleWorkspaceViewMode, 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) => {
|
||||
@@ -841,7 +877,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
}, [updateSessionStatus, updateHostLastConnected]);
|
||||
|
||||
const handleUpdateHostFromTerminal = useCallback((host: Host) => {
|
||||
const handleUpdateHostFromTerminal = useCallback((host: TerminalHostUpdate) => {
|
||||
updateHosts(hosts.map((h) => (
|
||||
h.id === host.id ? mergeTerminalHostUpdate(h, host) : h
|
||||
)));
|
||||
@@ -936,13 +972,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}
|
||||
applyAppTheme={applyAppTheme}
|
||||
hostById={hostById}
|
||||
sessionById={sessionById}
|
||||
themeById={themeById}
|
||||
workspaceById={workspaceById}
|
||||
currentTerminalTheme={currentTerminalTheme}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
accentMode={accentMode}
|
||||
customAccent={customAccent}
|
||||
editorTabs={editorTabs}
|
||||
logViews={logViews}
|
||||
t={t}
|
||||
/>
|
||||
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, 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, removeSessionFromWorkspace, reorderWorkTabs, 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, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, 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 提交)
|
||||
@@ -1,10 +1,19 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { handleGlobalHotkeyKeyDownImpl } from './app/AppHandlers.ts';
|
||||
import { executeHotkeyActionImpl, getLogHostVisualSnapshot, handleGlobalHotkeyKeyDownImpl } from './app/AppHandlers.ts';
|
||||
import { matchesKeyBinding } from '../domain/models.ts';
|
||||
import { DEFAULT_KEY_BINDINGS } from '../domain/models/keyBindings.ts';
|
||||
|
||||
class FakeInputHTMLElement {
|
||||
tagName = 'INPUT';
|
||||
isContentEditable = false;
|
||||
|
||||
closest(): FakeInputHTMLElement | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeHTMLElement {
|
||||
tagName = 'TEXTAREA';
|
||||
isContentEditable = false;
|
||||
@@ -68,3 +77,119 @@ test('global hotkey handler lets terminal font size shortcuts reach xterm', () =
|
||||
assert.equal(prevented, false);
|
||||
assert.equal(stopped, false);
|
||||
});
|
||||
|
||||
test('global hotkey handler routes quick switch through focused search inputs', () => {
|
||||
const target = new FakeInputHTMLElement();
|
||||
const handledActions: string[] = [];
|
||||
const event = {
|
||||
key: 'j',
|
||||
code: 'KeyJ',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as unknown as KeyboardEvent;
|
||||
|
||||
handleGlobalHotkeyKeyDownImpl(
|
||||
() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: 'Ctrl + W',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
handledActions.push(action);
|
||||
},
|
||||
hotkeyScheme: 'pc',
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}),
|
||||
event,
|
||||
);
|
||||
|
||||
assert.deepEqual(handledActions, ['quickSwitch']);
|
||||
});
|
||||
|
||||
test('quick switch hotkey toggles the quick switcher open state', () => {
|
||||
let isQuickSwitcherOpen = false;
|
||||
const setIsQuickSwitcherOpen = (next: boolean) => {
|
||||
isQuickSwitcherOpen = next;
|
||||
};
|
||||
const noop = () => {};
|
||||
const baseCtx = {
|
||||
IS_DEV: false,
|
||||
MOVE_FOCUS_DEBOUNCE_MS: 0,
|
||||
activeTabStore: { getActiveTabId: () => 'vault' },
|
||||
addConnectionLogRef: { current: noop },
|
||||
closeSession: noop,
|
||||
closeTabInFlightRef: { current: false },
|
||||
closeWorkspace: noop,
|
||||
collectSessionIds: () => [],
|
||||
confirmIfBusyLocalTerminal: async () => true,
|
||||
createLocalTerminalWithCurrentShell: noop,
|
||||
editorTabs: [],
|
||||
fromEditorTabId: () => null,
|
||||
handleOpenSettingsRef: { current: noop },
|
||||
handleRequestCloseEditorTabRef: { current: noop },
|
||||
isEditorTabId: () => false,
|
||||
isQuickSwitcherOpen,
|
||||
lastMoveFocusTimeRef: { current: 0 },
|
||||
moveFocusInWorkspace: noop,
|
||||
orderedTabs: [],
|
||||
resolveCloseIntent: () => ({ kind: 'noop' }),
|
||||
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
|
||||
sessions: [],
|
||||
setActiveTabId: noop,
|
||||
setAddToWorkspaceDialog: noop,
|
||||
setIsQuickSwitcherOpen,
|
||||
setNavigateToSection: noop,
|
||||
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: false },
|
||||
splitSessionWithCurrentShell: noop,
|
||||
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
|
||||
toEditorTabId: (id: string) => `editor:${id}`,
|
||||
toggleBroadcast: noop,
|
||||
toggleScriptsSidePanelRef: { current: noop },
|
||||
toggleSidePanelRef: { current: noop },
|
||||
workspaces: [],
|
||||
};
|
||||
|
||||
const event = {
|
||||
key: 'j',
|
||||
code: 'KeyJ',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
} as KeyboardEvent;
|
||||
|
||||
executeHotkeyActionImpl(() => baseCtx, 'quickSwitch', event);
|
||||
assert.equal(isQuickSwitcherOpen, true);
|
||||
|
||||
executeHotkeyActionImpl(() => ({ ...baseCtx, isQuickSwitcherOpen: true }), 'quickSwitch', event);
|
||||
assert.equal(isQuickSwitcherOpen, false);
|
||||
});
|
||||
|
||||
test('connection log host snapshot includes custom host icon fields', () => {
|
||||
assert.deepEqual(
|
||||
getLogHostVisualSnapshot({
|
||||
id: 'host-1',
|
||||
label: 'Database',
|
||||
hostname: 'db.example.com',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
distro: 'ubuntu',
|
||||
iconMode: 'custom',
|
||||
iconId: 'database',
|
||||
iconColor: 'blue',
|
||||
}),
|
||||
{
|
||||
hostOs: 'linux',
|
||||
hostDistro: 'ubuntu',
|
||||
hostIconMode: 'custom',
|
||||
hostIconId: 'database',
|
||||
hostIconColor: 'blue',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
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"]);
|
||||
});
|
||||
142
application/app/AppActiveTabChrome.tsx
Normal file
142
application/app/AppActiveTabChrome.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
fromEditorTabId,
|
||||
isEditorTabId,
|
||||
useActiveTabId,
|
||||
} from '../state/activeTabStore';
|
||||
import { updateActiveChromeThemeDeps } from '../state/activeChromeThemeSync';
|
||||
import { useActiveChromeTheme } from '../state/useActiveChromeTheme';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { resolveActiveChromeTheme } from './activeChromeTheme';
|
||||
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;
|
||||
applyAppTheme: () => void;
|
||||
hostById: Map<string, Host>;
|
||||
sessionById: Map<string, TerminalSession>;
|
||||
themeById: Map<string, TerminalTheme>;
|
||||
workspaceById: Map<string, Workspace>;
|
||||
currentTerminalTheme: TerminalTheme;
|
||||
followAppTerminalTheme: boolean;
|
||||
accentMode: 'theme' | 'custom';
|
||||
customAccent: string;
|
||||
editorTabs: readonly EditorTab[];
|
||||
logViews: readonly LogView[];
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the `activeTabId` subscription and the purely side-effectful "chrome"
|
||||
* work derived from it: 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.
|
||||
*/
|
||||
export function AppActiveTabChrome({
|
||||
showSftpTab,
|
||||
setActiveTabId,
|
||||
applyAppTheme,
|
||||
hostById,
|
||||
sessionById,
|
||||
themeById,
|
||||
workspaceById,
|
||||
currentTerminalTheme,
|
||||
followAppTerminalTheme,
|
||||
accentMode,
|
||||
customAccent,
|
||||
editorTabs,
|
||||
logViews,
|
||||
t,
|
||||
}: AppActiveTabChromeProps) {
|
||||
const activeTabId = useActiveTabId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSftpTab && activeTabId === 'sftp') {
|
||||
setActiveTabId('vault');
|
||||
}
|
||||
}, [showSftpTab, activeTabId, setActiveTabId]);
|
||||
|
||||
const chromeThemeDeps = useMemo(() => ({
|
||||
accentMode,
|
||||
applyAppTheme,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
editorTabs,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
logViews,
|
||||
sessionById,
|
||||
themeById,
|
||||
workspaceById,
|
||||
}), [
|
||||
accentMode,
|
||||
applyAppTheme,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
editorTabs,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
logViews,
|
||||
sessionById,
|
||||
themeById,
|
||||
workspaceById,
|
||||
]);
|
||||
|
||||
updateActiveChromeThemeDeps(chromeThemeDeps);
|
||||
|
||||
const activeChromeTheme = useMemo(() => resolveActiveChromeTheme({
|
||||
...chromeThemeDeps,
|
||||
activeTabId,
|
||||
}), [chromeThemeDeps, activeTabId]);
|
||||
|
||||
useActiveChromeTheme({
|
||||
activeTheme: activeChromeTheme,
|
||||
applyAppTheme,
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -2,11 +2,25 @@
|
||||
import type React from 'react';
|
||||
import type { Host, HostProtocol } from '../../types';
|
||||
import type { PassphraseRequest } from '../../components/PassphraseModal';
|
||||
import { getEffectiveHostDistro } from '../../domain/host';
|
||||
import { sanitizeHostIconFields } from '../../domain/hostIcon';
|
||||
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
||||
|
||||
type AppContextGetter = () => Record<string, any>;
|
||||
const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions();
|
||||
|
||||
export const getLogHostVisualSnapshot = (host: Host) => {
|
||||
const icon = sanitizeHostIconFields(host);
|
||||
return {
|
||||
hostOs: host.os,
|
||||
hostDistro: getEffectiveHostDistro(host) || undefined,
|
||||
hostIconMode: icon.iconMode,
|
||||
hostIconId: icon.iconId,
|
||||
hostIconColor: icon.iconColor,
|
||||
};
|
||||
};
|
||||
|
||||
export function handleTrayJumpToSessionImpl(getCtx: AppContextGetter, sessionId: string) {
|
||||
const { sessions, setActiveTabId, setWorkspaceFocusedSession } = getCtx();
|
||||
{
|
||||
@@ -65,6 +79,7 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
|
||||
hostname: host.hostname,
|
||||
username,
|
||||
protocol: 'serial',
|
||||
...getLogHostVisualSnapshot(effectiveHost),
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
@@ -73,7 +88,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 +97,8 @@ 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',
|
||||
...getLogHostVisualSnapshot(effectiveHost),
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
@@ -123,7 +139,11 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
|
||||
target instanceof HTMLElement &&
|
||||
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
|
||||
|
||||
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape') {
|
||||
const quickSwitchBinding = keyBindings.find((binding) => binding.action === 'quickSwitch');
|
||||
const quickSwitchKeyStr = quickSwitchBinding ? (isMac ? quickSwitchBinding.mac : quickSwitchBinding.pc) : null;
|
||||
const isQuickSwitchHotkey = quickSwitchKeyStr ? matchesKeyBinding(e, quickSwitchKeyStr, isMac) : false;
|
||||
|
||||
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape' && !isQuickSwitchHotkey) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -203,7 +223,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 +307,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 +339,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();
|
||||
{
|
||||
@@ -397,7 +447,7 @@ export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: st
|
||||
}
|
||||
|
||||
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, workspaces } = getCtx();
|
||||
{
|
||||
// Build complete tab list: vault + (sftp when visible) + sessions/workspaces + editor tabs.
|
||||
// Hiding the SFTP tab must also remove it from keyboard cycling so nextTab
|
||||
@@ -406,13 +456,19 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
const allTabs = settings.showSftpTab
|
||||
? ['vault', 'sftp', ...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))]
|
||||
: ['vault', ...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))];
|
||||
const numberShortcutTabs = buildNumberShortcutTabTargets({
|
||||
showSftpTab: settings.showSftpTab ?? true,
|
||||
shellOnlyTabNumberShortcuts: settings.shellOnlyTabNumberShortcuts ?? false,
|
||||
orderedTabs,
|
||||
editorTabIds: editorTabs.map((t) => toEditorTabId(t.id)),
|
||||
});
|
||||
switch (action) {
|
||||
case 'switchToTab': {
|
||||
// Get the number key pressed (1-9)
|
||||
const num = parseInt(e.key, 10);
|
||||
if (num >= 1 && num <= 9) {
|
||||
if (num <= allTabs.length) {
|
||||
setActiveTabId(allTabs[num - 1]);
|
||||
if (num <= numberShortcutTabs.length) {
|
||||
setActiveTabId(numberShortcutTabs[num - 1]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -490,6 +546,40 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
|
||||
break;
|
||||
}
|
||||
case 'closeSession': {
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
if (!currentId || currentId === 'vault' || currentId === 'sftp') break;
|
||||
if (closeTabInFlightRef.current) break;
|
||||
|
||||
const session = sessions.find((s) => s.id === currentId) ?? null;
|
||||
const workspace = workspaces.find((w) => w.id === currentId) ?? null;
|
||||
|
||||
closeTabInFlightRef.current = true;
|
||||
(async () => {
|
||||
try {
|
||||
// If active tab is a workspace, close the focused session (pane)
|
||||
if (workspace) {
|
||||
// Validate focusedSessionId is still valid — it can become stale
|
||||
// if the previously focused session was already closed
|
||||
const aliveIds = collectSessionIds(workspace.root);
|
||||
const focusedId = aliveIds.includes(workspace.focusedSessionId)
|
||||
? workspace.focusedSessionId
|
||||
: aliveIds[0];
|
||||
if (focusedId) {
|
||||
const ok = await confirmIfBusyLocalTerminal([focusedId]);
|
||||
if (ok) closeSession(focusedId);
|
||||
}
|
||||
} else if (session) {
|
||||
// Standalone session tab — close the session
|
||||
const ok = await confirmIfBusyLocalTerminal([session.id]);
|
||||
if (ok) closeSession(session.id);
|
||||
}
|
||||
} finally {
|
||||
closeTabInFlightRef.current = false;
|
||||
}
|
||||
})();
|
||||
break;
|
||||
}
|
||||
case 'newTab':
|
||||
case 'openLocal':
|
||||
// Add connection log for local terminal
|
||||
@@ -515,6 +605,8 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
}
|
||||
break;
|
||||
case 'quickSwitch':
|
||||
setIsQuickSwitcherOpen(!isQuickSwitcherOpen);
|
||||
break;
|
||||
case 'commandPalette':
|
||||
setIsQuickSwitcherOpen(true);
|
||||
break;
|
||||
@@ -593,6 +685,15 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'togglePaneZoom': {
|
||||
// Toggle workspace between split and focus (zoom) mode
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
const activeWs = workspaces.find(w => w.id === currentId);
|
||||
if (activeWs) {
|
||||
toggleWorkspaceViewMode(activeWs.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'moveFocus': {
|
||||
// Debounce to prevent double-triggering when focus switches between terminals
|
||||
const now = Date.now();
|
||||
@@ -633,7 +734,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;
|
||||
@@ -678,6 +779,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host) {
|
||||
hostname: host.hostname,
|
||||
username: username,
|
||||
protocol: 'serial',
|
||||
...getLogHostVisualSnapshot(effectiveHost),
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
@@ -686,7 +788,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 +797,8 @@ 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',
|
||||
...getLogHostVisualSnapshot(effectiveHost),
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
@@ -766,9 +869,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);
|
||||
|
||||
44
application/app/AppHostTreeLayer.test.ts
Normal file
44
application/app/AppHostTreeLayer.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
getAppHostTreeLayerStyle,
|
||||
} = await import('./AppHostTreeLayer');
|
||||
const hostTreeLayerSource = readFileSync(new URL('./AppHostTreeLayer.tsx', import.meta.url), 'utf8');
|
||||
|
||||
test('shared host tree layer is visible above work tabs', () => {
|
||||
assert.deepEqual(getAppHostTreeLayerStyle(true), {
|
||||
visibility: 'visible',
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 30,
|
||||
});
|
||||
});
|
||||
|
||||
test('shared host tree layer is hidden behind root pages', () => {
|
||||
assert.deepEqual(getAppHostTreeLayerStyle(false), {
|
||||
visibility: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('shared host tree does not force open when entering a work tab surface', () => {
|
||||
assert.doesNotMatch(hostTreeLayerSource, /setIsOpen\(true\)/);
|
||||
assert.doesNotMatch(hostTreeLayerSource, /shouldAutoOpenHostTreeOnSurfaceChange/);
|
||||
});
|
||||
|
||||
test('host tree layer hides immediately when leaving work tab surfaces', () => {
|
||||
assert.match(hostTreeLayerSource, /getAppHostTreeLayerStyle\(surfaceVisible\)/);
|
||||
assert.doesNotMatch(hostTreeLayerSource, /layerVisible/);
|
||||
});
|
||||
118
application/app/AppHostTreeLayer.tsx
Normal file
118
application/app/AppHostTreeLayer.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { useActiveTabId } from '../state/activeTabStore';
|
||||
import type { EditorTab } from '../state/editorTabStore';
|
||||
import type { LogView } from '../state/logViewState';
|
||||
import { TerminalHostTreeSidebar } from '../../components/terminalLayer/TerminalHostTreeSidebar';
|
||||
import type { GroupConfig, Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
import {
|
||||
isHostTreeWorkTabSurface,
|
||||
resolveWorkTabActiveHostId,
|
||||
resolveWorkTabHostTreeTheme,
|
||||
} from './workTabSurface';
|
||||
|
||||
interface AppHostTreeLayerProps {
|
||||
enabled: boolean;
|
||||
hosts: Host[];
|
||||
customGroups: string[];
|
||||
groupConfigs: GroupConfig[];
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
editorTabs: readonly EditorTab[];
|
||||
logViews: readonly LogView[];
|
||||
orderedTabs: readonly string[];
|
||||
accentMode: 'theme' | 'custom';
|
||||
currentTerminalTheme: TerminalTheme;
|
||||
customAccent: string;
|
||||
followAppTerminalTheme: boolean;
|
||||
hostById: ReadonlyMap<string, Host>;
|
||||
themeById: ReadonlyMap<string, TerminalTheme>;
|
||||
onConnect: (host: Host) => void;
|
||||
onCreateLocalTerminal?: () => void;
|
||||
}
|
||||
|
||||
export function getAppHostTreeLayerStyle(surfaceVisible: boolean): React.CSSProperties {
|
||||
return {
|
||||
visibility: surfaceVisible ? 'visible' : 'hidden',
|
||||
pointerEvents: surfaceVisible ? 'auto' : 'none',
|
||||
zIndex: surfaceVisible ? 30 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
enabled,
|
||||
hosts,
|
||||
customGroups,
|
||||
groupConfigs,
|
||||
sessions,
|
||||
workspaces,
|
||||
editorTabs,
|
||||
logViews,
|
||||
orderedTabs,
|
||||
accentMode,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
themeById,
|
||||
onConnect,
|
||||
onCreateLocalTerminal,
|
||||
}) => {
|
||||
const activeTabId = useActiveTabId();
|
||||
const sessionIds = useMemo(() => new Set(sessions.map((session) => session.id)), [sessions]);
|
||||
const workspaceIds = useMemo(() => new Set(workspaces.map((workspace) => workspace.id)), [workspaces]);
|
||||
const logViewIds = useMemo(() => new Set(logViews.map((logView) => logView.id)), [logViews]);
|
||||
const surfaceVisible = isHostTreeWorkTabSurface({
|
||||
enabled,
|
||||
activeTabId,
|
||||
logViewIds,
|
||||
orderedTabs,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
});
|
||||
|
||||
const activeHostId = useMemo(() => resolveWorkTabActiveHostId({
|
||||
activeTabId,
|
||||
editorTabs,
|
||||
sessions,
|
||||
workspaces,
|
||||
}), [activeTabId, editorTabs, sessions, workspaces]);
|
||||
|
||||
const hostTreeTheme = useMemo(() => resolveWorkTabHostTreeTheme({
|
||||
activeHostId,
|
||||
accentMode,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
themeById,
|
||||
}), [
|
||||
activeHostId,
|
||||
accentMode,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
themeById,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 flex min-h-0"
|
||||
data-section="app-host-tree-layer"
|
||||
style={getAppHostTreeLayerStyle(surfaceVisible)}
|
||||
>
|
||||
<TerminalHostTreeSidebar
|
||||
enabled={enabled}
|
||||
surfaceVisible={surfaceVisible}
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
groupConfigs={groupConfigs}
|
||||
resolvedPreviewTheme={hostTreeTheme}
|
||||
activeHostId={activeHostId}
|
||||
onConnect={onConnect}
|
||||
onCreateLocalTerminal={onCreateLocalTerminal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
45
application/app/AppMounts.test.ts
Normal file
45
application/app/AppMounts.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
},
|
||||
});
|
||||
|
||||
const { getLogViewWrapperStyle, shouldRenderTerminalLayerMount } = await import('./AppMounts.tsx');
|
||||
const activeTabChromeSource = readFileSync(new URL('./AppActiveTabChrome.tsx', import.meta.url), 'utf8');
|
||||
|
||||
test('visible log view leaves room for the terminal host sidebar', () => {
|
||||
assert.deepEqual(getLogViewWrapperStyle(true, 220), {
|
||||
left: 220,
|
||||
});
|
||||
});
|
||||
|
||||
test('hidden log view remains hidden while preserving host sidebar offset', () => {
|
||||
assert.deepEqual(getLogViewWrapperStyle(false, 220), {
|
||||
visibility: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
zIndex: -1,
|
||||
left: 220,
|
||||
});
|
||||
});
|
||||
|
||||
test('terminal layer renders only after terminal content is visible or mounted', () => {
|
||||
assert.equal(shouldRenderTerminalLayerMount(true, false), true);
|
||||
assert.equal(shouldRenderTerminalLayerMount(false, true), true);
|
||||
assert.equal(shouldRenderTerminalLayerMount(false, false), false);
|
||||
});
|
||||
|
||||
test('active tab chrome keeps removed theme side effects unmounted', () => {
|
||||
const removedThemeHook = ['use', 'Im', 'mersive', 'Mode'].join('');
|
||||
const removedThemeStoreSetter = ['set', 'Im', 'mersive', 'Active'].join('');
|
||||
assert.equal(activeTabChromeSource.includes(removedThemeHook), false);
|
||||
assert.equal(activeTabChromeSource.includes(removedThemeStoreSetter), false);
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { Suspense, lazy, useEffect, useState } from 'react';
|
||||
import { useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive } from '../state/activeTabStore';
|
||||
import React, { Suspense, lazy, useEffect, useMemo, useState } from 'react';
|
||||
import { useActiveTabId, useIsSftpActive, useIsVaultActive } from '../state/activeTabStore';
|
||||
import { useTerminalHostTreeLayoutWidth } from '../state/terminalHostTreeStore';
|
||||
import { isTerminalContentTabSurface } from './workTabSurface';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { ConnectionLog, TerminalTheme } from '../../types';
|
||||
import type { LogView as LogViewType } from '../state/logViewState';
|
||||
@@ -29,14 +31,24 @@ interface LogViewWrapperProps {
|
||||
onUpdateLog: (logId: string, updates: Partial<ConnectionLog>) => void;
|
||||
}
|
||||
|
||||
export function getLogViewWrapperStyle(
|
||||
isVisible: boolean,
|
||||
hostTreeLayoutWidth: number,
|
||||
): React.CSSProperties {
|
||||
const baseStyle = {
|
||||
left: hostTreeLayoutWidth,
|
||||
};
|
||||
return isVisible
|
||||
? baseStyle
|
||||
: { visibility: 'hidden', pointerEvents: 'none', position: 'absolute', zIndex: -1, ...baseStyle };
|
||||
}
|
||||
|
||||
export const LogViewWrapper: React.FC<LogViewWrapperProps> = ({ logView, defaultTerminalTheme, defaultFontSize, onClose, onUpdateLog }) => {
|
||||
const activeTabId = useActiveTabId();
|
||||
const isVisible = activeTabId === logView.id;
|
||||
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
|
||||
|
||||
// Use same pattern as VaultViewContainer for visibility
|
||||
const containerStyle: React.CSSProperties = isVisible
|
||||
? {}
|
||||
: { visibility: 'hidden', pointerEvents: 'none', position: 'absolute', zIndex: -1 };
|
||||
const containerStyle = getLogViewWrapperStyle(isVisible, hostTreeLayoutWidth);
|
||||
|
||||
return (
|
||||
<div className={cn("absolute inset-0", isVisible ? "z-20" : "")} style={containerStyle}>
|
||||
@@ -67,6 +79,13 @@ const LazyTerminalLayer = lazy(() =>
|
||||
type SftpViewProps = React.ComponentProps<typeof SftpViewComponent>;
|
||||
type TerminalLayerProps = React.ComponentProps<typeof TerminalLayerComponent>;
|
||||
|
||||
export function shouldRenderTerminalLayerMount(
|
||||
isVisible: boolean,
|
||||
shouldMount: boolean,
|
||||
): boolean {
|
||||
return isVisible || shouldMount;
|
||||
}
|
||||
|
||||
export const SftpViewMount: React.FC<SftpViewProps> = (props) => {
|
||||
const isActive = useIsSftpActive();
|
||||
const [shouldMount, setShouldMount] = useState(isActive);
|
||||
@@ -85,7 +104,14 @@ export const SftpViewMount: React.FC<SftpViewProps> = (props) => {
|
||||
};
|
||||
|
||||
export const TerminalLayerMount: React.FC<TerminalLayerProps> = (props) => {
|
||||
const isVisible = useIsTerminalLayerVisible(props.draggingSessionId);
|
||||
const activeTabId = useActiveTabId();
|
||||
const sessionIds = useMemo(() => new Set(props.sessions.map((session) => session.id)), [props.sessions]);
|
||||
const workspaceIds = useMemo(() => new Set(props.workspaces.map((workspace) => workspace.id)), [props.workspaces]);
|
||||
const isVisible = isTerminalContentTabSurface({
|
||||
activeTabId,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
}) || !!props.draggingSessionId;
|
||||
const [shouldMount, setShouldMount] = useState(isVisible);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -107,7 +133,7 @@ export const TerminalLayerMount: React.FC<TerminalLayerProps> = (props) => {
|
||||
return () => window.clearTimeout(id);
|
||||
}, [shouldMount]);
|
||||
|
||||
const shouldRender = shouldMount || isVisible;
|
||||
const shouldRender = shouldRenderTerminalLayerMount(isVisible, shouldMount);
|
||||
|
||||
if (!shouldRender) return null;
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import { toast } from '../../components/ui/toast';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { AppHostTreeLayer } from './AppHostTreeLayer';
|
||||
|
||||
const LazyProtocolSelectDialog = lazy(() => import('../../components/ProtocolSelectDialog'));
|
||||
const LazyQuickSwitcher = lazy(() =>
|
||||
@@ -32,8 +32,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,
|
||||
@@ -42,13 +42,13 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
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, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionRenameTarget,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename,
|
||||
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,
|
||||
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, updateSessionFontSize, clearSessionFontSizeOverride,
|
||||
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||
sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
|
||||
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId,
|
||||
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
|
||||
toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog,
|
||||
updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources,
|
||||
updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces,
|
||||
@@ -72,41 +72,43 @@ 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="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
|
||||
<TopTabs
|
||||
theme={resolvedTheme}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
hosts={hosts}
|
||||
sessions={sessions}
|
||||
orphanSessions={orphanSessions}
|
||||
@@ -118,6 +120,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onCloseSession={closeSession}
|
||||
onRenameSession={startSessionRename}
|
||||
onCopySession={copySessionWithCurrentShell}
|
||||
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
|
||||
onRenameWorkspace={startWorkspaceRename}
|
||||
onCloseWorkspace={closeWorkspace}
|
||||
onCloseLogView={closeLogView}
|
||||
@@ -125,18 +128,41 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onToggleTheme={handleToggleTheme}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
windowOpacity={settings.windowOpacity}
|
||||
setWindowOpacity={settings.setWindowOpacity}
|
||||
onSyncNow={handleSyncNowManual}
|
||||
isImmersiveActive={activeTerminalTheme !== null}
|
||||
onStartSessionDrag={setDraggingSessionId}
|
||||
onEndSessionDrag={handleEndSessionDrag}
|
||||
onReorderTabs={reorderTabs}
|
||||
onReorderTabs={reorderWorkTabs}
|
||||
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
showHostTreeSidebar={settings.showHostTreeSidebar}
|
||||
editorTabs={editorTabs}
|
||||
onRequestCloseEditorTab={handleRequestCloseEditorTab}
|
||||
hostById={hostById}
|
||||
/>
|
||||
|
||||
<div className="flex-1 relative min-h-0">
|
||||
<AppHostTreeLayer
|
||||
enabled={settings.showHostTreeSidebar}
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
groupConfigs={groupConfigs}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
editorTabs={editorTabs}
|
||||
logViews={logViews}
|
||||
orderedTabs={orderedTabsWithEditors}
|
||||
accentMode={accentMode}
|
||||
currentTerminalTheme={currentTerminalTheme}
|
||||
customAccent={customAccent}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
hostById={hostById}
|
||||
themeById={themeById}
|
||||
onConnect={handleConnectToHost}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
/>
|
||||
|
||||
<VaultViewContainer>
|
||||
<VaultView
|
||||
hosts={hosts}
|
||||
@@ -194,9 +220,11 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
knownHosts={effectiveKnownHosts}
|
||||
proxyProfiles={proxyProfiles}
|
||||
groupConfigs={groupConfigs}
|
||||
updateHosts={updateHosts}
|
||||
onAddKnownHost={handleAddKnownHost}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
sftpAutoSync={sftpAutoSync}
|
||||
@@ -211,6 +239,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
|
||||
<TerminalLayerMount
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
groupConfigs={groupConfigs}
|
||||
proxyProfiles={proxyProfiles}
|
||||
keys={keys}
|
||||
@@ -229,11 +258,14 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
terminalFontFamilyId={terminalFontFamilyId}
|
||||
fontSize={terminalFontSize}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
disableTerminalFontZoom={settings.disableTerminalFontZoom}
|
||||
keyBindings={keyBindings}
|
||||
onHotkeyAction={handleHotkeyAction}
|
||||
onUpdateTerminalThemeId={setTerminalThemeId}
|
||||
onUpdateTerminalFontFamilyId={setTerminalFontFamilyId}
|
||||
onUpdateTerminalFontSize={setTerminalFontSize}
|
||||
onUpdateSessionFontSize={updateSessionFontSize}
|
||||
onClearSessionFontSizeOverride={clearSessionFontSizeOverride}
|
||||
onUpdateTerminalFontWeight={(w) => updateTerminalSetting('fontWeight', w)}
|
||||
onCloseSession={closeSession}
|
||||
onUpdateSessionStatus={handleSessionStatusChange}
|
||||
@@ -243,6 +275,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onCommandExecuted={(command, hostId, hostLabel, sessionId) => {
|
||||
addShellHistoryEntry({ command, hostId, hostLabel, sessionId });
|
||||
}}
|
||||
shellHistory={shellHistory}
|
||||
onTerminalDataCapture={handleTerminalDataCapture}
|
||||
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
||||
onAddSessionToWorkspace={addSessionToWorkspace}
|
||||
@@ -254,23 +287,38 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
|
||||
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
|
||||
onReorderWorkspaceSessions={reorderWorkspaceSessions}
|
||||
onReorderTabs={reorderWorkTabs}
|
||||
onCopySession={copySessionWithCurrentShell}
|
||||
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
|
||||
onSplitSession={splitSessionWithCurrentShell}
|
||||
onConnectToHost={handleConnectToHost}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
onToggleBroadcast={toggleBroadcast}
|
||||
updateHosts={updateHosts}
|
||||
updateSnippets={updateSnippets}
|
||||
updateSnippetPackages={updateSnippetPackages}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
sftpAutoSync={sftpAutoSync}
|
||||
sftpShowHiddenFiles={sftpShowHiddenFiles}
|
||||
sftpUseCompressedUpload={sftpUseCompressedUpload}
|
||||
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
|
||||
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
|
||||
setSftpFollowTerminalCwd={setSftpFollowTerminalCwd}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
sessionLogsEnabled={sessionLogsEnabled}
|
||||
sessionLogsDir={sessionLogsDir}
|
||||
sessionLogsFormat={sessionLogsFormat}
|
||||
sessionLogsTimestampsEnabled={sessionLogsTimestampsEnabled}
|
||||
sshDebugLogsEnabled={sshDebugLogsEnabled}
|
||||
showHostTreeSidebar={settings.showHostTreeSidebar}
|
||||
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
|
||||
toggleSidePanelRef={toggleSidePanelRef}
|
||||
onStartSessionRename={startSessionRename}
|
||||
onSubmitSessionRename={submitSessionRename}
|
||||
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
|
||||
/>
|
||||
|
||||
{/* Log Views - readonly terminal replays */}
|
||||
@@ -294,7 +342,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}
|
||||
|
||||
126
application/app/activeChromeTheme.test.ts
Normal file
126
application/app/activeChromeTheme.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { toEditorTabId } from "../state/activeTabStore.ts";
|
||||
import type { EditorTab } from "../state/editorTabStore.ts";
|
||||
import type { LogView } from "../state/logViewState.ts";
|
||||
import { isActiveChromeThemeResolvable, resolveActiveChromeTheme } from "./activeChromeTheme.ts";
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from "../../types";
|
||||
|
||||
const theme = (id: string, type: "dark" | "light" = "dark"): TerminalTheme => ({
|
||||
id,
|
||||
name: id,
|
||||
type,
|
||||
colors: {
|
||||
background: type === "dark" ? "#111111" : "#eeeeee",
|
||||
foreground: type === "dark" ? "#eeeeee" : "#111111",
|
||||
cursor: "#22aaff",
|
||||
},
|
||||
});
|
||||
|
||||
const currentTheme = theme("current");
|
||||
const hostTheme = theme("host-theme");
|
||||
const logTheme = theme("log-theme", "light");
|
||||
|
||||
const baseInput = {
|
||||
accentMode: "theme" as const,
|
||||
currentTerminalTheme: currentTheme,
|
||||
customAccent: "221.2 83.2% 53.3%",
|
||||
editorTabs: [],
|
||||
followAppTerminalTheme: false,
|
||||
hostById: new Map<string, Host>(),
|
||||
logViews: [],
|
||||
sessionById: new Map<string, TerminalSession>(),
|
||||
themeById: new Map([
|
||||
[currentTheme.id, currentTheme],
|
||||
[hostTheme.id, hostTheme],
|
||||
[logTheme.id, logTheme],
|
||||
]),
|
||||
workspaceById: new Map<string, Workspace>(),
|
||||
};
|
||||
|
||||
test("editor tabs use the owning host terminal theme when follow-app terminal theme is off", () => {
|
||||
const editorTab = {
|
||||
id: "editor-1",
|
||||
hostId: "host-1",
|
||||
sessionId: "sftp-1",
|
||||
};
|
||||
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
activeTabId: toEditorTabId(editorTab.id),
|
||||
editorTabs: [editorTab as unknown as EditorTab],
|
||||
hostById: new Map([
|
||||
["host-1", { id: "host-1", theme: hostTheme.id } as unknown as Host],
|
||||
]),
|
||||
});
|
||||
|
||||
assert.equal(resolved?.id, hostTheme.id);
|
||||
});
|
||||
|
||||
test("editor tabs use the followed terminal theme when follow-app terminal theme is on", () => {
|
||||
const editorTab = {
|
||||
id: "editor-1",
|
||||
hostId: "host-1",
|
||||
sessionId: "sftp-1",
|
||||
};
|
||||
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
activeTabId: toEditorTabId(editorTab.id),
|
||||
editorTabs: [editorTab as unknown as EditorTab],
|
||||
followAppTerminalTheme: true,
|
||||
hostById: new Map([
|
||||
["host-1", { id: "host-1", theme: hostTheme.id } as unknown as Host],
|
||||
]),
|
||||
});
|
||||
|
||||
assert.equal(resolved?.id, currentTheme.id);
|
||||
});
|
||||
|
||||
test("log tabs use the saved log theme when available", () => {
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
activeTabId: "log-1",
|
||||
logViews: [{
|
||||
id: "log-1",
|
||||
connectionLogId: "1",
|
||||
log: { id: "1", themeId: logTheme.id },
|
||||
} as unknown as LogView],
|
||||
});
|
||||
|
||||
assert.equal(resolved?.id, logTheme.id);
|
||||
});
|
||||
|
||||
test("root pages use the normal application theme", () => {
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
activeTabId: "vault",
|
||||
});
|
||||
|
||||
assert.equal(resolved, null);
|
||||
});
|
||||
|
||||
test("chrome theme sync waits until a newly opened session is present in deps", () => {
|
||||
assert.equal(
|
||||
isActiveChromeThemeResolvable({
|
||||
activeTabId: "session-new",
|
||||
editorTabs: [],
|
||||
logViews: [],
|
||||
sessionById: new Map(),
|
||||
workspaceById: new Map(),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
isActiveChromeThemeResolvable({
|
||||
activeTabId: "session-new",
|
||||
editorTabs: [],
|
||||
logViews: [],
|
||||
sessionById: new Map([["session-new", { id: "session-new" } as TerminalSession]]),
|
||||
workspaceById: new Map(),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
103
application/app/activeChromeTheme.ts
Normal file
103
application/app/activeChromeTheme.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { fromEditorTabId, isEditorTabId } from "../state/activeTabStore";
|
||||
|
||||
export type ResolveActiveChromeThemeInput = {
|
||||
accentMode: "theme" | "custom";
|
||||
activeTabId: string;
|
||||
currentTerminalTheme: TerminalTheme;
|
||||
customAccent: string;
|
||||
editorTabs: readonly EditorTab[];
|
||||
followAppTerminalTheme: boolean;
|
||||
hostById: Map<string, Host>;
|
||||
logViews: readonly LogView[];
|
||||
sessionById: Map<string, TerminalSession>;
|
||||
themeById: Map<string, TerminalTheme>;
|
||||
workspaceById: Map<string, Workspace>;
|
||||
};
|
||||
|
||||
export function isActiveChromeThemeResolvable({
|
||||
activeTabId,
|
||||
editorTabs,
|
||||
logViews,
|
||||
sessionById,
|
||||
workspaceById,
|
||||
}: Pick<
|
||||
ResolveActiveChromeThemeInput,
|
||||
"activeTabId" | "editorTabs" | "logViews" | "sessionById" | "workspaceById"
|
||||
>): boolean {
|
||||
if (activeTabId === "vault" || activeTabId === "sftp") return true;
|
||||
if (isEditorTabId(activeTabId)) {
|
||||
return editorTabs.some((tab) => tab.id === fromEditorTabId(activeTabId));
|
||||
}
|
||||
if (logViews.some((item) => item.id === activeTabId)) return true;
|
||||
if (workspaceById.has(activeTabId)) return true;
|
||||
if (sessionById.has(activeTabId)) return true;
|
||||
return false;
|
||||
}
|
||||
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from "../../domain/terminalAppearance";
|
||||
import { collectSessionIds } from "../../domain/workspace";
|
||||
import type { EditorTab } from "../state/editorTabStore";
|
||||
import type { LogView } from "../state/logViewState";
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from "../../types";
|
||||
|
||||
export function resolveActiveChromeTheme({
|
||||
accentMode,
|
||||
activeTabId,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
editorTabs,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
logViews,
|
||||
sessionById,
|
||||
themeById,
|
||||
workspaceById,
|
||||
}: ResolveActiveChromeThemeInput): TerminalTheme | null {
|
||||
if (activeTabId === "vault" || activeTabId === "sftp") return null;
|
||||
|
||||
const resolveHostTheme = (hostId: string): TerminalTheme => {
|
||||
if (followAppTerminalTheme) return currentTerminalTheme;
|
||||
const host = hostById.get(hostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
};
|
||||
|
||||
const resolveSessionTheme = (session: TerminalSession): TerminalTheme => resolveHostTheme(session.hostId);
|
||||
|
||||
if (isEditorTabId(activeTabId)) {
|
||||
const editorTabId = fromEditorTabId(activeTabId);
|
||||
const editorTab = editorTabs.find((tab) => tab.id === editorTabId);
|
||||
if (!editorTab) return null;
|
||||
return resolveHostTheme(editorTab.hostId);
|
||||
}
|
||||
|
||||
const logView = logViews.find((item) => item.id === activeTabId);
|
||||
if (logView) {
|
||||
const explicitThemeId = logView.log.themeId;
|
||||
return explicitThemeId ? themeById.get(explicitThemeId) ?? currentTerminalTheme : currentTerminalTheme;
|
||||
}
|
||||
|
||||
const workspace = workspaceById.get(activeTabId);
|
||||
if (workspace) {
|
||||
if (workspace.viewMode === "focus") {
|
||||
const workspaceSessionIds = collectSessionIds(workspace.root);
|
||||
const focusedSession = (workspace.focusedSessionId
|
||||
? sessionById.get(workspace.focusedSessionId)
|
||||
: null)
|
||||
?? workspaceSessionIds.map((id) => sessionById.get(id)).find(Boolean);
|
||||
return focusedSession ? resolveSessionTheme(focusedSession) : null;
|
||||
}
|
||||
|
||||
const workspaceSessions = collectSessionIds(workspace.root)
|
||||
.map((id) => sessionById.get(id))
|
||||
.filter(Boolean) as TerminalSession[];
|
||||
if (workspaceSessions.length === 0) return null;
|
||||
|
||||
const firstTheme = resolveSessionTheme(workspaceSessions[0]);
|
||||
const allSame = workspaceSessions.every((session) => resolveSessionTheme(session).id === firstTheme.id);
|
||||
return allSame ? firstTheme : null;
|
||||
}
|
||||
|
||||
const session = sessionById.get(activeTabId);
|
||||
return session ? resolveSessionTheme(session) : null;
|
||||
}
|
||||
40
application/app/tabShortcutTargets.test.ts
Normal file
40
application/app/tabShortcutTargets.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets.ts';
|
||||
|
||||
test('number shortcut tabs include vault and sftp by default', () => {
|
||||
assert.deepEqual(
|
||||
buildNumberShortcutTabTargets({
|
||||
showSftpTab: true,
|
||||
shellOnlyTabNumberShortcuts: false,
|
||||
orderedTabs: ['session-1', 'workspace-1'],
|
||||
editorTabIds: ['editor:file-1'],
|
||||
}),
|
||||
['vault', 'sftp', 'session-1', 'workspace-1', 'editor:file-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('number shortcut tabs skip vault and sftp when shell-only mode is enabled', () => {
|
||||
assert.deepEqual(
|
||||
buildNumberShortcutTabTargets({
|
||||
showSftpTab: true,
|
||||
shellOnlyTabNumberShortcuts: true,
|
||||
orderedTabs: ['session-1', 'workspace-1'],
|
||||
editorTabIds: ['editor:file-1'],
|
||||
}),
|
||||
['session-1', 'workspace-1', 'editor:file-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('hidden sftp tab is omitted from default number shortcut targets', () => {
|
||||
assert.deepEqual(
|
||||
buildNumberShortcutTabTargets({
|
||||
showSftpTab: false,
|
||||
shellOnlyTabNumberShortcuts: false,
|
||||
orderedTabs: ['session-1'],
|
||||
editorTabIds: [],
|
||||
}),
|
||||
['vault', 'session-1'],
|
||||
);
|
||||
});
|
||||
14
application/app/tabShortcutTargets.ts
Normal file
14
application/app/tabShortcutTargets.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/** Tab ids targeted by Cmd/Ctrl+[1...9] number shortcuts. */
|
||||
export function buildNumberShortcutTabTargets(params: {
|
||||
showSftpTab: boolean;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
orderedTabs: readonly string[];
|
||||
editorTabIds: readonly string[];
|
||||
}): string[] {
|
||||
const workTabs = [...params.orderedTabs, ...params.editorTabIds];
|
||||
if (params.shellOnlyTabNumberShortcuts) {
|
||||
return workTabs;
|
||||
}
|
||||
const pinnedTabs = params.showSftpTab ? ['vault', 'sftp'] : ['vault'];
|
||||
return [...pinnedTabs, ...workTabs];
|
||||
}
|
||||
18
application/app/topTabsChromeTheme.test.ts
Normal file
18
application/app/topTabsChromeTheme.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
test("active chrome theme applies top tab vars and clears them before vault restore transition", () => {
|
||||
const chromeThemeSource = readFileSync(new URL("../state/useActiveChromeTheme.ts", import.meta.url), "utf8");
|
||||
const syncSource = readFileSync(new URL("../state/activeChromeThemeSync.ts", import.meta.url), "utf8");
|
||||
const effectsSource = readFileSync(new URL("../../components/terminalLayer/useTerminalLayerEffects.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(chromeThemeSource, /applyTopTabsChromeThemeVars\(theme\)/);
|
||||
const restoreBlock = chromeThemeSource.match(
|
||||
/clearTopTabsChromeThemeVars\(\);\s*runThemeTransition\(\(\) => \{\s*removeActiveChromeTheme\(\);/,
|
||||
)?.[0] ?? "";
|
||||
assert.notEqual(restoreBlock, "", "top tab vars must clear before the vault restore transition starts");
|
||||
assert.match(syncSource, /activeTabId === 'vault' \|\| activeTabId === 'sftp'\)[\s\S]*clearTopTabsChromeThemeVars\(\)/);
|
||||
assert.match(effectsSource, /if \(!isTerminalLayerVisible\) \{[\s\S]*clearTopTabsPreviewVars\(\)/);
|
||||
});
|
||||
109
application/app/topTabsChromeTheme.ts
Normal file
109
application/app/topTabsChromeTheme.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { TerminalTheme } from '../../types';
|
||||
|
||||
function hexToHslToken(hex: string): string {
|
||||
const normalized = hex.startsWith('#') ? hex : `#${hex}`;
|
||||
const r = parseInt(normalized.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(normalized.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(normalized.slice(5, 7), 16) / 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
const l = (max + min) / 2;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r:
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
break;
|
||||
case g:
|
||||
h = ((b - r) / d + 2) / 6;
|
||||
break;
|
||||
default:
|
||||
h = ((r - g) / d + 4) / 6;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return `${Math.round(h * 3600) / 10} ${Math.round(s * 1000) / 10}% ${Math.round(l * 1000) / 10}%`;
|
||||
}
|
||||
|
||||
function adjustLightnessToken(hsl: string, delta: number): string {
|
||||
const parts = hsl.split(/\s+/);
|
||||
const newL = Math.max(0, Math.min(100, parseFloat(parts[2]) + delta));
|
||||
return `${parts[0]} ${parts[1]} ${Math.round(newL * 10) / 10}%`;
|
||||
}
|
||||
|
||||
function adjustSaturationToken(hsl: string, factor: number): string {
|
||||
const parts = hsl.split(/\s+/);
|
||||
const newS = Math.max(0, Math.min(100, parseFloat(parts[1]) * factor));
|
||||
return `${parts[0]} ${Math.round(newS * 10) / 10}% ${parts[2]}`;
|
||||
}
|
||||
|
||||
const setStylePropertyIfChanged = (element: HTMLElement, property: string, value: string) => {
|
||||
if (element.style.getPropertyValue(property) === value) return;
|
||||
element.style.setProperty(property, value);
|
||||
};
|
||||
|
||||
const removeStylePropertyIfSet = (element: HTMLElement, property: string) => {
|
||||
if (!element.style.getPropertyValue(property)) return;
|
||||
element.style.removeProperty(property);
|
||||
};
|
||||
|
||||
const TOP_TABS_THEME_PROPERTIES = [
|
||||
'--top-tabs-bg',
|
||||
'--top-tabs-fg',
|
||||
'--top-tabs-muted',
|
||||
'--top-tabs-active-bg',
|
||||
'--top-tabs-accent',
|
||||
'--background',
|
||||
'--foreground',
|
||||
'--accent',
|
||||
'--primary',
|
||||
'--secondary',
|
||||
'--border',
|
||||
'--muted-foreground',
|
||||
] as const;
|
||||
|
||||
export function clearTopTabsChromeThemeVars(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
if (!tabsRoot) return;
|
||||
for (const property of TOP_TABS_THEME_PROPERTIES) {
|
||||
removeStylePropertyIfSet(tabsRoot, property);
|
||||
}
|
||||
}
|
||||
|
||||
export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
if (!tabsRoot) return;
|
||||
|
||||
const bg = hexToHslToken(theme.colors.background);
|
||||
const fg = hexToHslToken(theme.colors.foreground);
|
||||
const accent = hexToHslToken(theme.colors.cursor);
|
||||
const isDark = theme.type === 'dark';
|
||||
const secondary = adjustLightnessToken(bg, isDark ? 6 : -5);
|
||||
const border = adjustLightnessToken(bg, isDark ? 12 : -10);
|
||||
const mutedFg = adjustSaturationToken(adjustLightnessToken(fg, isDark ? -20 : 20), 0.5);
|
||||
|
||||
setStylePropertyIfChanged(tabsRoot, '--background', bg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--foreground', fg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--secondary', secondary);
|
||||
setStylePropertyIfChanged(tabsRoot, '--border', border);
|
||||
setStylePropertyIfChanged(tabsRoot, '--muted-foreground', mutedFg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-bg', 'hsl(var(--secondary))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-fg', 'hsl(var(--foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-muted', 'hsl(var(--muted-foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-active-bg', 'hsl(var(--background))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
|
||||
}
|
||||
|
||||
export function hasActiveChromeThemeDataset(): boolean {
|
||||
if (typeof document === 'undefined') return false;
|
||||
return Boolean(document.documentElement.dataset.activeChromeTheme);
|
||||
}
|
||||
205
application/app/workTabSurface.test.ts
Normal file
205
application/app/workTabSurface.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildOrderedWorkTabIds,
|
||||
isHostTreeWorkTabSurface,
|
||||
isRootPageTabId,
|
||||
isTerminalContentTabSurface,
|
||||
reorderWorkTabIds,
|
||||
resolveWorkTabActiveHostId,
|
||||
resolveWorkTabHostTreeTheme,
|
||||
} from './workTabSurface';
|
||||
import type { EditorTab } from '../state/editorTabStore';
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
|
||||
const makeTheme = (id: string, type: TerminalTheme['type'], background: string): TerminalTheme => ({
|
||||
id,
|
||||
name: id,
|
||||
type,
|
||||
colors: {
|
||||
background,
|
||||
foreground: type === 'dark' ? '#ffffff' : '#000000',
|
||||
cursor: '#888888',
|
||||
selection: '#555555',
|
||||
black: '#000000',
|
||||
red: '#ff0000',
|
||||
green: '#00ff00',
|
||||
yellow: '#ffff00',
|
||||
blue: '#0000ff',
|
||||
magenta: '#ff00ff',
|
||||
cyan: '#00ffff',
|
||||
white: '#ffffff',
|
||||
brightBlack: '#444444',
|
||||
brightRed: '#ff5555',
|
||||
brightGreen: '#55ff55',
|
||||
brightYellow: '#ffff55',
|
||||
brightBlue: '#5555ff',
|
||||
brightMagenta: '#ff55ff',
|
||||
brightCyan: '#55ffff',
|
||||
brightWhite: '#ffffff',
|
||||
},
|
||||
});
|
||||
|
||||
test('work tab order keeps custom positions and appends new tabs', () => {
|
||||
assert.deepEqual(
|
||||
buildOrderedWorkTabIds(['log-1', 'session-1'], ['session-1', 'workspace-1', 'log-1', 'editor:file-1']),
|
||||
['log-1', 'session-1', 'workspace-1', 'editor:file-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('work tab order removes duplicate ids before rendering', () => {
|
||||
assert.deepEqual(
|
||||
buildOrderedWorkTabIds(
|
||||
['session-2', 'session-1', 'session-2', 'session-1'],
|
||||
['session-1', 'session-2', 'session-3', 'session-3'],
|
||||
),
|
||||
['session-2', 'session-1', 'session-3'],
|
||||
);
|
||||
});
|
||||
|
||||
test('work tab order reorders with newly materialized tabs', () => {
|
||||
assert.deepEqual(
|
||||
reorderWorkTabIds(
|
||||
['session-1', 'session-2', 'session-3'],
|
||||
['session-1', 'session-2', 'session-3'],
|
||||
'session-1',
|
||||
'session-3',
|
||||
'after',
|
||||
),
|
||||
['session-2', 'session-3', 'session-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('root pages are not work tab surfaces', () => {
|
||||
assert.equal(isRootPageTabId('vault'), true);
|
||||
assert.equal(isRootPageTabId('sftp'), true);
|
||||
assert.equal(isRootPageTabId('session-1'), false);
|
||||
});
|
||||
|
||||
test('shared host tree is visible for editor, log, session, and workspace tabs', () => {
|
||||
const sessionIds = new Set(['session-1']);
|
||||
const workspaceIds = new Set(['workspace-1']);
|
||||
const logViewIds = new Set(['log-1']);
|
||||
const orderedTabs = ['session-1', 'workspace-1', 'editor:file-1', 'log-1'];
|
||||
|
||||
for (const activeTabId of orderedTabs) {
|
||||
assert.equal(isHostTreeWorkTabSurface({
|
||||
enabled: true,
|
||||
activeTabId,
|
||||
logViewIds,
|
||||
orderedTabs,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
}), true);
|
||||
}
|
||||
});
|
||||
|
||||
test('shared host tree recognizes active log view before tab ordering catches up', () => {
|
||||
assert.equal(isHostTreeWorkTabSurface({
|
||||
enabled: true,
|
||||
activeTabId: 'log-1',
|
||||
logViewIds: new Set(['log-1']),
|
||||
orderedTabs: [],
|
||||
sessionIds: new Set(),
|
||||
workspaceIds: new Set(),
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('terminal content surface is limited to sessions and workspaces', () => {
|
||||
const sessionIds = new Set(['session-1']);
|
||||
const workspaceIds = new Set(['workspace-1']);
|
||||
|
||||
assert.equal(isTerminalContentTabSurface({ activeTabId: 'session-1', sessionIds, workspaceIds }), true);
|
||||
assert.equal(isTerminalContentTabSurface({ activeTabId: 'workspace-1', sessionIds, workspaceIds }), true);
|
||||
assert.equal(isTerminalContentTabSurface({ activeTabId: 'editor:file-1', sessionIds, workspaceIds }), false);
|
||||
assert.equal(isTerminalContentTabSurface({ activeTabId: 'log-1', sessionIds, workspaceIds }), false);
|
||||
});
|
||||
|
||||
test('shared host tree resolves active host ids across work tab types', () => {
|
||||
const sessions = [
|
||||
{ id: 'session-1', hostId: 'host-1' },
|
||||
{ id: 'session-2', hostId: 'host-2' },
|
||||
] as TerminalSession[];
|
||||
const workspaces = [
|
||||
{ id: 'workspace-1', focusedSessionId: 'session-2' },
|
||||
] as Workspace[];
|
||||
const editorTabs = [
|
||||
{ id: 'file-1', hostId: 'host-3' },
|
||||
] as EditorTab[];
|
||||
|
||||
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'session-1', sessions, workspaces, editorTabs }), 'host-1');
|
||||
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'workspace-1', sessions, workspaces, editorTabs }), 'host-2');
|
||||
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'editor:file-1', sessions, workspaces, editorTabs }), 'host-3');
|
||||
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'log-1', sessions, workspaces, editorTabs }), null);
|
||||
});
|
||||
|
||||
test('shared host tree uses the active host theme when follow-app terminal theme is off', () => {
|
||||
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
|
||||
const hostTheme = makeTheme('host-light', 'light', '#fafafa');
|
||||
const host = {
|
||||
id: 'host-1',
|
||||
label: 'Host',
|
||||
hostname: 'host.local',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
theme: hostTheme.id,
|
||||
themeOverride: true,
|
||||
} as Host;
|
||||
|
||||
const resolved = resolveWorkTabHostTreeTheme({
|
||||
activeHostId: host.id,
|
||||
accentMode: 'theme',
|
||||
currentTerminalTheme: currentTheme,
|
||||
customAccent: '#8b5cf6',
|
||||
followAppTerminalTheme: false,
|
||||
hostById: new Map([[host.id, host]]),
|
||||
themeById: new Map([[currentTheme.id, currentTheme], [hostTheme.id, hostTheme]]),
|
||||
});
|
||||
|
||||
assert.equal(resolved.id, hostTheme.id);
|
||||
});
|
||||
|
||||
test('shared host tree uses the followed terminal theme when follow-app terminal theme is on', () => {
|
||||
const currentTheme = makeTheme('app-light', 'light', '#ffffff');
|
||||
const hostTheme = makeTheme('host-dark', 'dark', '#050505');
|
||||
const host = {
|
||||
id: 'host-1',
|
||||
label: 'Host',
|
||||
hostname: 'host.local',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
theme: hostTheme.id,
|
||||
themeOverride: true,
|
||||
} as Host;
|
||||
|
||||
const resolved = resolveWorkTabHostTreeTheme({
|
||||
activeHostId: host.id,
|
||||
accentMode: 'theme',
|
||||
currentTerminalTheme: currentTheme,
|
||||
customAccent: '#8b5cf6',
|
||||
followAppTerminalTheme: true,
|
||||
hostById: new Map([[host.id, host]]),
|
||||
themeById: new Map([[currentTheme.id, currentTheme], [hostTheme.id, hostTheme]]),
|
||||
});
|
||||
|
||||
assert.equal(resolved.id, currentTheme.id);
|
||||
});
|
||||
|
||||
test('shared host tree falls back to the current terminal theme without an active host', () => {
|
||||
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
|
||||
|
||||
const resolved = resolveWorkTabHostTreeTheme({
|
||||
activeHostId: null,
|
||||
accentMode: 'theme',
|
||||
currentTerminalTheme: currentTheme,
|
||||
customAccent: '#8b5cf6',
|
||||
followAppTerminalTheme: false,
|
||||
hostById: new Map(),
|
||||
themeById: new Map([[currentTheme.id, currentTheme]]),
|
||||
});
|
||||
|
||||
assert.equal(resolved.id, currentTheme.id);
|
||||
});
|
||||
153
application/app/workTabSurface.ts
Normal file
153
application/app/workTabSurface.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
fromEditorTabId,
|
||||
isEditorTabId,
|
||||
} from '../state/activeTabStore';
|
||||
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from '../../domain/terminalAppearance';
|
||||
import type { EditorTab } from '../state/editorTabStore';
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
|
||||
function uniqueTabIds(tabIds: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const uniqueIds: string[] = [];
|
||||
for (const tabId of tabIds) {
|
||||
if (!tabId || seen.has(tabId)) continue;
|
||||
seen.add(tabId);
|
||||
uniqueIds.push(tabId);
|
||||
}
|
||||
return uniqueIds;
|
||||
}
|
||||
|
||||
export function isRootPageTabId(activeTabId: string): boolean {
|
||||
return activeTabId === 'vault' || activeTabId === 'sftp';
|
||||
}
|
||||
|
||||
export function buildOrderedWorkTabIds(
|
||||
tabOrder: readonly string[],
|
||||
allTabIds: readonly string[],
|
||||
): string[] {
|
||||
const uniqueAllTabIds = uniqueTabIds(allTabIds);
|
||||
const allTabIdSet = new Set(uniqueAllTabIds);
|
||||
const orderedIds = uniqueTabIds(tabOrder.filter((id) => allTabIdSet.has(id)));
|
||||
const orderedIdSet = new Set(orderedIds);
|
||||
const newIds = uniqueAllTabIds.filter((id) => !orderedIdSet.has(id));
|
||||
return [...orderedIds, ...newIds];
|
||||
}
|
||||
|
||||
export function reorderWorkTabIds(
|
||||
tabOrder: readonly string[],
|
||||
allTabIds: readonly string[],
|
||||
draggedId: string,
|
||||
targetId: string,
|
||||
position: 'before' | 'after' = 'before',
|
||||
): string[] {
|
||||
if (draggedId === targetId) return buildOrderedWorkTabIds(tabOrder, allTabIds);
|
||||
|
||||
const currentOrder = buildOrderedWorkTabIds(tabOrder, allTabIds);
|
||||
const draggedIndex = currentOrder.indexOf(draggedId);
|
||||
const targetIndex = currentOrder.indexOf(targetId);
|
||||
if (draggedIndex === -1 || targetIndex === -1) return [...tabOrder];
|
||||
|
||||
currentOrder.splice(draggedIndex, 1);
|
||||
|
||||
let nextTargetIndex = targetIndex;
|
||||
if (draggedIndex < targetIndex) {
|
||||
nextTargetIndex -= 1;
|
||||
}
|
||||
if (position === 'after') {
|
||||
nextTargetIndex += 1;
|
||||
}
|
||||
|
||||
currentOrder.splice(nextTargetIndex, 0, draggedId);
|
||||
return currentOrder;
|
||||
}
|
||||
|
||||
export function isHostTreeWorkTabSurface({
|
||||
enabled,
|
||||
activeTabId,
|
||||
logViewIds = new Set(),
|
||||
orderedTabs,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
activeTabId: string;
|
||||
logViewIds?: ReadonlySet<string>;
|
||||
orderedTabs: readonly string[];
|
||||
sessionIds: ReadonlySet<string>;
|
||||
workspaceIds: ReadonlySet<string>;
|
||||
}): boolean {
|
||||
if (!enabled) return false;
|
||||
if (isRootPageTabId(activeTabId)) return false;
|
||||
return orderedTabs.includes(activeTabId)
|
||||
|| isEditorTabId(activeTabId)
|
||||
|| logViewIds.has(activeTabId)
|
||||
|| sessionIds.has(activeTabId)
|
||||
|| workspaceIds.has(activeTabId);
|
||||
}
|
||||
|
||||
export function isTerminalContentTabSurface({
|
||||
activeTabId,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
}: {
|
||||
activeTabId: string;
|
||||
sessionIds: ReadonlySet<string>;
|
||||
workspaceIds: ReadonlySet<string>;
|
||||
}): boolean {
|
||||
return sessionIds.has(activeTabId) || workspaceIds.has(activeTabId);
|
||||
}
|
||||
|
||||
export function resolveWorkTabActiveHostId({
|
||||
activeTabId,
|
||||
editorTabs,
|
||||
sessions,
|
||||
workspaces,
|
||||
}: {
|
||||
activeTabId: string;
|
||||
editorTabs: readonly EditorTab[];
|
||||
sessions: readonly TerminalSession[];
|
||||
workspaces: readonly Workspace[];
|
||||
}): string | null {
|
||||
if (isEditorTabId(activeTabId)) {
|
||||
const editorId = fromEditorTabId(activeTabId);
|
||||
return editorTabs.find((tab) => tab.id === editorId)?.hostId ?? null;
|
||||
}
|
||||
|
||||
const activeSession = sessions.find((session) => session.id === activeTabId);
|
||||
if (activeSession) return activeSession.hostId ?? null;
|
||||
|
||||
const activeWorkspace = workspaces.find((workspace) => workspace.id === activeTabId);
|
||||
if (!activeWorkspace) return null;
|
||||
|
||||
const focusedSessionId = activeWorkspace.focusedSessionId;
|
||||
if (focusedSessionId) {
|
||||
return sessions.find((session) => session.id === focusedSessionId)?.hostId ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveWorkTabHostTreeTheme({
|
||||
activeHostId,
|
||||
accentMode,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
themeById,
|
||||
}: {
|
||||
activeHostId: string | null;
|
||||
accentMode: 'theme' | 'custom';
|
||||
currentTerminalTheme: TerminalTheme;
|
||||
customAccent: string;
|
||||
followAppTerminalTheme: boolean;
|
||||
hostById: ReadonlyMap<string, Host>;
|
||||
themeById: ReadonlyMap<string, TerminalTheme>;
|
||||
}): TerminalTheme {
|
||||
if (!activeHostId || followAppTerminalTheme) return currentTerminalTheme;
|
||||
|
||||
const host = hostById.get(activeHostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
}
|
||||
@@ -14,11 +14,19 @@ const I18nContext = createContext<I18nContextValue | null>(null);
|
||||
|
||||
const interpolate = (template: string, values?: InterpolationValues): string => {
|
||||
if (!values) return template;
|
||||
return template.replace(/\{(\w+)\}/g, (_match, key: string) => {
|
||||
const replaceDoubleBraceToken = (match: string, key: string) => {
|
||||
const v = values[key];
|
||||
if (v === null || v === undefined) return match;
|
||||
return String(v);
|
||||
};
|
||||
const replaceSingleBraceToken = (_match: string, key: string) => {
|
||||
const v = values[key];
|
||||
if (v === null || v === undefined) return '';
|
||||
return String(v);
|
||||
});
|
||||
};
|
||||
return template
|
||||
.replace(/\{\{(\w+)\}\}/g, replaceDoubleBraceToken)
|
||||
.replace(/\{(\w+)\}/g, replaceSingleBraceToken);
|
||||
};
|
||||
|
||||
const resolveMessage = (resolvedLocale: string, key: string): string | undefined => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { enCoreMessages } from './en/core';
|
||||
import { enVaultMessages } from './en/vault';
|
||||
import { enTerminalMessages } from './en/terminal';
|
||||
import { enAiMessages } from './en/ai';
|
||||
import { enSystemManagerMessages } from './en/systemManager';
|
||||
|
||||
export type { Messages } from './types';
|
||||
|
||||
@@ -11,6 +12,7 @@ const en: Messages = {
|
||||
...enVaultMessages,
|
||||
...enTerminalMessages,
|
||||
...enAiMessages,
|
||||
...enSystemManagerMessages,
|
||||
};
|
||||
|
||||
export default en;
|
||||
|
||||
@@ -3,9 +3,11 @@ import type { Messages } from '../types';
|
||||
export const enAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent Settings',
|
||||
'ai.chat.preparing': 'Preparing…',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': 'Configure AI providers, agents, and safety settings',
|
||||
'ai.providers': 'Providers',
|
||||
'ai.agents': 'Agents',
|
||||
'ai.providers.empty': 'No providers configured. Add a provider to get started.',
|
||||
'ai.providers.add': 'Add Provider',
|
||||
'ai.providers.active': 'Active',
|
||||
@@ -34,6 +36,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 +55,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 +89,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 +99,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',
|
||||
@@ -99,13 +108,61 @@ export const enAiMessages: Messages = {
|
||||
'ai.copilot.customPathPlaceholder': 'e.g. /usr/local/bin/copilot',
|
||||
'ai.copilot.check': 'Check',
|
||||
|
||||
// AI Cursor SDK
|
||||
'ai.cursor.title': 'Cursor',
|
||||
'ai.cursor.description': 'Uses the Cursor SDK.',
|
||||
'ai.cursor.detecting': 'Detecting...',
|
||||
'ai.cursor.detected': 'Available',
|
||||
'ai.cursor.notFound': 'Unavailable',
|
||||
'ai.cursor.path': 'Runtime:',
|
||||
'ai.cursor.notFoundHint': 'Enter an API key to enable Cursor.',
|
||||
'ai.cursor.notInstalledHint': 'Cursor SDK was not detected.',
|
||||
'ai.cursor.installStatus': 'Cursor SDK',
|
||||
'ai.cursor.installed': 'Detected',
|
||||
'ai.cursor.notInstalled': 'Not detected',
|
||||
'ai.cursor.apiKeyStatus': 'API Key',
|
||||
'ai.cursor.apiKeyConfigured': 'Configured',
|
||||
'ai.cursor.apiKeyMissing': 'Missing',
|
||||
'ai.cursor.apiKeyFromEnv': 'From environment',
|
||||
'ai.cursor.apiKey': 'API Key',
|
||||
'ai.cursor.apiKeyPlaceholder': 'Enter Cursor API key',
|
||||
'ai.cursor.apiKeyPlaceholder.env': 'Using CURSOR_API_KEY; enter a key to override',
|
||||
'ai.cursor.apiKeyEnvHint': 'Cursor can use CURSOR_API_KEY from your shell. Save a key here only if you want Netcatty to override it.',
|
||||
'ai.cursor.apiKeyOverrideHint': 'Netcatty will use the saved key here before CURSOR_API_KEY.',
|
||||
'ai.cursor.saveApiKey': 'Save',
|
||||
'ai.cursor.saved': 'Saved',
|
||||
'ai.cursor.showApiKey': 'Show API key',
|
||||
'ai.cursor.hideApiKey': 'Hide API key',
|
||||
'ai.cursor.customPathPlaceholder': 'e.g. /usr/local/bin/cursor',
|
||||
'ai.cursor.check': 'Check',
|
||||
|
||||
// AI CodeBuddy Code
|
||||
'ai.codebuddy.title': 'CodeBuddy Code',
|
||||
'ai.codebuddy.description': 'Uses CodeBuddy Code via the official Agent SDK (`@tencent-ai/agent-sdk`). Once detected, it can be selected as an external coding agent.',
|
||||
'ai.codebuddy.detecting': 'Detecting...',
|
||||
'ai.codebuddy.detected': 'Detected',
|
||||
'ai.codebuddy.notFound': 'Not found',
|
||||
'ai.codebuddy.path': 'Path:',
|
||||
'ai.codebuddy.notFoundHint': 'Could not find codebuddy in PATH. Install it or specify the executable path below.',
|
||||
'ai.codebuddy.customPathPlaceholder': 'e.g. /usr/local/bin/codebuddy',
|
||||
'ai.codebuddy.check': 'Check',
|
||||
'ai.codebuddy.configSection': 'Authentication & config (optional)',
|
||||
'ai.codebuddy.internetEnv': 'Internet Environment',
|
||||
'ai.codebuddy.internetEnv.default': 'Default (overseas)',
|
||||
'ai.codebuddy.internetEnv.internal': 'Internal',
|
||||
'ai.codebuddy.internetEnv.ioa': 'IOA',
|
||||
'ai.codebuddy.internetEnv.hint': 'Sets CODEBUDDY_INTERNET_ENVIRONMENT — choose Internal or IOA for restricted network environments.',
|
||||
'ai.codebuddy.envVars': 'Environment variables',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': 'One KEY=VALUE per line, passed to the CodeBuddy agent. Set CODEBUDDY_API_KEY or CODEBUDDY_AUTH_TOKEN here for authentication. Stored locally in plaintext.',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': 'Default Agent',
|
||||
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
|
||||
'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',
|
||||
@@ -120,6 +177,29 @@ export const enAiMessages: Messages = {
|
||||
'ai.userSkills.status.ready': 'Ready',
|
||||
'ai.userSkills.status.warning': 'Warning',
|
||||
|
||||
// AI Quick Messages
|
||||
'ai.quickMessages.title': 'Quick Messages',
|
||||
'ai.quickMessages.description': 'Create reusable prompts you can insert from the AI chat with / or the quick-message button. Unlike user skills, quick messages fill the composer with text.',
|
||||
'ai.quickMessages.add': 'Add Quick Message',
|
||||
'ai.quickMessages.createTitle': 'New Quick Message',
|
||||
'ai.quickMessages.editTitle': 'Edit Quick Message',
|
||||
'ai.quickMessages.name': 'Name',
|
||||
'ai.quickMessages.name.placeholder': 'e.g. Check disk space',
|
||||
'ai.quickMessages.slug': 'Command',
|
||||
'ai.quickMessages.slug.placeholder': 'disk-check',
|
||||
'ai.quickMessages.descriptionField': 'Description (optional)',
|
||||
'ai.quickMessages.descriptionField.placeholder': 'Short hint about what this prompt does',
|
||||
'ai.quickMessages.content': 'Message content',
|
||||
'ai.quickMessages.content.placeholder': 'Full prompt text to insert when selected...',
|
||||
'ai.quickMessages.empty': 'No quick messages yet. Add a few prompts you use often.',
|
||||
'ai.quickMessages.confirmDelete': 'Delete quick message "{name}"?',
|
||||
'ai.quickMessages.error.nameRequired': 'Name is required.',
|
||||
'ai.quickMessages.error.invalidSlug': 'Command may only contain lowercase letters, numbers, and hyphens.',
|
||||
'ai.quickMessages.error.contentRequired': 'Message content is required.',
|
||||
'ai.quickMessages.error.slugTaken': 'This command is already used by another quick message.',
|
||||
'ai.quickMessages.error.slugConflictsWithSkill': 'This command conflicts with user skill "/{slug}". Choose another.',
|
||||
'ai.quickMessages.error.maxItems': 'You can save at most {max} quick messages.',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
|
||||
'ai.chat.toolDenied': 'Action was rejected by the user.',
|
||||
@@ -167,6 +247,9 @@ 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.usedTools': 'Tools used: {n}',
|
||||
'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.',
|
||||
@@ -176,6 +259,18 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.menuImage': 'Image',
|
||||
'ai.chat.menuMentionHost': 'Mention Host',
|
||||
'ai.chat.menuUserSkills': 'User Skills',
|
||||
'ai.chat.menuSlashCommands': 'Slash Commands',
|
||||
'ai.chat.slashCommands': 'Slash commands',
|
||||
'ai.chat.slashQuickMessages': 'Quick messages',
|
||||
'ai.chat.slashUserSkills': 'User skills',
|
||||
'ai.chat.quickMessages': 'Slash commands',
|
||||
'ai.chat.slashNoResults': 'No matching commands',
|
||||
'ai.chat.slashEmptyHint': 'Add prompts in Settings → AI → Quick Messages.',
|
||||
|
||||
// AI Chat Shortcuts
|
||||
'ai.chatShortcuts.title': 'Chat Shortcuts',
|
||||
'ai.chatShortcuts.selectionAction': 'Show Add to Conversation when selecting terminal text',
|
||||
'ai.chatShortcuts.selectionAction.description': 'Show a small AI button next to selected terminal text.',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
|
||||
@@ -198,39 +293,60 @@ 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',
|
||||
'terminal.layer.switchToSplitView': 'Switch to Split View',
|
||||
'terminal.layer.sftp': 'SFTP',
|
||||
'terminal.layer.scripts': 'Scripts',
|
||||
'terminal.layer.history': 'History',
|
||||
'terminal.layer.theme': 'Theme',
|
||||
'terminal.layer.aiChat': 'AI Chat',
|
||||
'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',
|
||||
'terminal.layer.hostTree.details.host': 'Host',
|
||||
'terminal.layer.hostTree.details.user': 'User',
|
||||
'terminal.layer.hostTree.details.port': 'Port',
|
||||
'terminal.layer.hostTree.details.protocol': 'Protocol',
|
||||
'terminal.layer.hostTree.details.group': 'Group',
|
||||
'terminal.layer.hostTree.details.tags': 'Tags',
|
||||
'terminal.layer.hostTree.details.lastConnected': 'Last connected',
|
||||
'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}',
|
||||
|
||||
@@ -42,6 +42,7 @@ export const enCoreMessages: Messages = {
|
||||
'common.more': 'More',
|
||||
'common.selectAHost': 'Select a host',
|
||||
'common.selectAHostPlaceholder': 'Select a host...',
|
||||
'sort.manual': 'Manual order',
|
||||
'sort.az': 'A-z',
|
||||
'sort.za': 'Z-a',
|
||||
'sort.newest': 'Newest to oldest',
|
||||
@@ -159,8 +160,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',
|
||||
@@ -212,6 +226,8 @@ export const enCoreMessages: Messages = {
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'When enabled, the root host list only shows hosts without a group. Open a group from the sidebar to see grouped hosts.',
|
||||
'settings.vault.showSftpTab': 'Show SFTP tab',
|
||||
'settings.vault.showSftpTabDesc': 'Display the standalone SFTP view in the top tab bar. When hidden, use the in-session SFTP side panel instead.',
|
||||
'settings.vault.showHostTreeSidebar': 'Show host list sidebar',
|
||||
'settings.vault.showHostTreeSidebarDesc': 'Display the host list sidebar and its top-bar toggle on terminal and editor tabs.',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': 'Update Available',
|
||||
@@ -227,6 +243,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 +267,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, top-tabs-host-tree-toggle, top-tabs-quick-switcher-toggle.',
|
||||
'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/* Hide the host-list toggle in the top tab bar */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Hide the plus button that opens the quick switcher */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\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',
|
||||
@@ -293,6 +312,15 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.font.size.desc': 'Terminal text size',
|
||||
'settings.terminal.font.weight': 'Font weight',
|
||||
'settings.terminal.font.weight.desc': 'Weight for regular text (100-900)',
|
||||
'settings.terminal.font.weight.thin': 'Thin',
|
||||
'settings.terminal.font.weight.extraLight': 'Extra Light',
|
||||
'settings.terminal.font.weight.light': 'Light',
|
||||
'settings.terminal.font.weight.normal': 'Normal',
|
||||
'settings.terminal.font.weight.medium': 'Medium',
|
||||
'settings.terminal.font.weight.semiBold': 'Semi Bold',
|
||||
'settings.terminal.font.weight.bold': 'Bold',
|
||||
'settings.terminal.font.weight.extraBold': 'Extra Bold',
|
||||
'settings.terminal.font.weight.black': 'Black',
|
||||
'settings.terminal.font.weightBold': 'Bold font weight',
|
||||
'settings.terminal.font.weightBold.desc': 'Weight for bold text (100-900)',
|
||||
'settings.terminal.font.linePadding': 'Line padding',
|
||||
@@ -322,6 +350,11 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.behavior.middleClickPaste': 'Middle-click paste',
|
||||
'settings.terminal.behavior.middleClickPaste.desc':
|
||||
'Paste clipboard content on middle-click',
|
||||
'settings.terminal.behavior.middleClick': 'Middle-click behavior',
|
||||
'settings.terminal.behavior.middleClick.desc': 'Action when middle-clicking in terminal',
|
||||
'settings.terminal.behavior.middleClick.menu': 'Show menu',
|
||||
'settings.terminal.behavior.middleClick.paste': 'Paste',
|
||||
'settings.terminal.behavior.middleClick.disabled': 'Do nothing',
|
||||
'settings.terminal.behavior.bracketedPaste': 'Bracketed paste mode',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'Wrap pasted text with escape sequences so the shell can distinguish paste from typed input. Disable if you see ^[[200~ artifacts.',
|
||||
@@ -394,6 +427,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',
|
||||
@@ -410,6 +446,15 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.connection.x11Display.desc': 'Optional local display address for X11 forwarding. Leave empty to use the system default.',
|
||||
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 or DISPLAY)',
|
||||
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
|
||||
'settings.terminal.section.systemManager': 'System Manager',
|
||||
'settings.terminal.systemManager.processRefreshInterval': 'Process list refresh',
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'How often to refresh the process list in the system manager side panel.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux session refresh',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'How often to refresh the tmux session list.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker container list refresh',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh the Docker container list.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker stats refresh',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'How often to refresh Docker container CPU/memory/network stats.',
|
||||
'settings.terminal.serverStats.show': 'Show Server Stats',
|
||||
'settings.terminal.serverStats.show.desc': 'Display CPU, memory, and disk usage in the terminal statusbar (Linux servers only).',
|
||||
'settings.terminal.serverStats.refreshInterval': 'Refresh Interval',
|
||||
@@ -445,6 +490,10 @@ export const enCoreMessages: Messages = {
|
||||
'settings.shortcuts.scheme.disabled': 'Disabled',
|
||||
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
|
||||
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
|
||||
'settings.shortcuts.disableTerminalFontZoom.label': 'Disable terminal zoom',
|
||||
'settings.shortcuts.disableTerminalFontZoom.desc': 'Turn off terminal font zoom shortcuts, including Cmd/Ctrl + mouse wheel.',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Number keys skip pinned tabs',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'When enabled, Cmd/Ctrl+[1...9] switches only work tabs (terminals, workspaces, editors), not the pinned Vault or SFTP tabs.',
|
||||
'settings.shortcuts.section.custom': 'Custom Shortcuts',
|
||||
'settings.shortcuts.resetAll': 'Reset All',
|
||||
'settings.shortcuts.recording': 'Press keys...',
|
||||
@@ -557,12 +606,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 +622,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',
|
||||
@@ -645,6 +695,7 @@ export const enCoreMessages: Messages = {
|
||||
'vault.hosts.connectSelected': 'Connect ({count})',
|
||||
'vault.hosts.connectMultiple.success': 'Connecting {count} hosts',
|
||||
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
|
||||
'vault.hosts.errors.nameRequired': 'Host name is required.',
|
||||
'vault.hosts.empty.title': 'Set up your hosts',
|
||||
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
|
||||
|
||||
|
||||
181
application/i18n/locales/en/systemManager.ts
Normal file
181
application/i18n/locales/en/systemManager.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enSystemManagerMessages: Messages = {
|
||||
'terminal.layer.system': 'System',
|
||||
|
||||
'systemManager.noSession': 'No active terminal session.',
|
||||
'systemManager.notConnected': 'Connect to a host to manage processes and services.',
|
||||
'systemManager.empty': 'No data available.',
|
||||
'systemManager.tabs.processes': 'Processes',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.popup.loading': 'Opening terminal…',
|
||||
'systemManager.popup.startupFailed': 'The startup command did not complete successfully. Check that the target is still available and try again.',
|
||||
|
||||
'systemManager.errors.loadProcesses': 'Failed to load processes',
|
||||
'systemManager.errors.loadTmux': 'Failed to load tmux sessions',
|
||||
'systemManager.errors.loadTmuxWindows': 'Failed to load tmux windows',
|
||||
'systemManager.errors.loadTmuxPanes': 'Failed to load tmux panes',
|
||||
'systemManager.errors.loadTmuxClients': 'Failed to load tmux clients',
|
||||
'systemManager.errors.actionFailed': 'Action failed',
|
||||
'systemManager.errors.loadDocker': 'Failed to load containers',
|
||||
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
|
||||
'systemManager.errors.loadDockerImages': 'Failed to load images',
|
||||
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
|
||||
|
||||
'systemManager.processes.search': 'Search processes…',
|
||||
'systemManager.processes.command': 'Command',
|
||||
'systemManager.processes.user': 'User',
|
||||
'systemManager.processes.term': 'Terminate',
|
||||
'systemManager.processes.kill': 'Kill',
|
||||
'systemManager.processes.stop': 'Stop (SIGSTOP)',
|
||||
'systemManager.processes.cont': 'Continue (SIGCONT)',
|
||||
'systemManager.processes.hup': 'Hang up (SIGHUP)',
|
||||
'systemManager.processes.renice': 'Renice',
|
||||
'systemManager.processes.renicePrompt': 'Nice value (-20 to 19)',
|
||||
'systemManager.processes.reniceInvalid': 'Nice value must be between -20 and 19',
|
||||
'systemManager.processes.confirmKill': 'Send SIGKILL to process {{pid}}?',
|
||||
'systemManager.processes.confirmSignal': 'Send SIG{{signal}} to process {{pid}}?',
|
||||
'systemManager.processes.filter.all': 'All',
|
||||
'systemManager.processes.filter.running': 'Running',
|
||||
'systemManager.processes.ppid': 'Parent PID',
|
||||
'systemManager.processes.rss': 'RSS',
|
||||
'systemManager.processes.vsz': 'Virtual size',
|
||||
'systemManager.processes.elapsed': 'Elapsed',
|
||||
'systemManager.processes.stat': 'State',
|
||||
'systemManager.processes.meta': '{{count}} process(es)',
|
||||
'systemManager.processes.loading': 'Loading processes…',
|
||||
'systemManager.processes.loadingMore': 'Loading more processes…',
|
||||
'systemManager.processes.state.running': 'Running',
|
||||
'systemManager.processes.state.sleeping': 'Sleeping',
|
||||
'systemManager.processes.state.stopped': 'Stopped',
|
||||
'systemManager.processes.state.zombie': 'Zombie',
|
||||
'systemManager.processes.sort.cpu': 'CPU',
|
||||
'systemManager.processes.sort.mem': 'MEM',
|
||||
'systemManager.processes.sort.pid': 'PID',
|
||||
'systemManager.processes.sort.command': 'Command',
|
||||
'systemManager.processes.sort.user': 'User',
|
||||
|
||||
'systemManager.common.dismiss': 'Dismiss',
|
||||
'systemManager.common.checkingAvailability': 'Checking availability…',
|
||||
'systemManager.common.loading': 'Loading…',
|
||||
'systemManager.common.loadingDetails': 'Loading details…',
|
||||
'systemManager.common.loadingStats': 'Loading stats…',
|
||||
|
||||
'systemManager.tmux.new': 'New',
|
||||
'systemManager.tmux.search': 'Search sessions…',
|
||||
'systemManager.tmux.newSessionTitle': 'New tmux session',
|
||||
'systemManager.tmux.newSessionDesc': 'Name the session and optionally run a script on start.',
|
||||
'systemManager.tmux.newSessionTabCustom': 'Custom command',
|
||||
'systemManager.tmux.newSessionTabSnippet': 'From snippet',
|
||||
'systemManager.tmux.pickSnippet': 'From snippets',
|
||||
'systemManager.tmux.pickSnippetEmpty': 'No snippets yet — add some in the Scripts panel or Vault.',
|
||||
'systemManager.tmux.selectedSnippet': 'Using snippet: {{label}}',
|
||||
'systemManager.tmux.newSessionName': 'Session name',
|
||||
'systemManager.tmux.newSessionCommand': 'Start command',
|
||||
'systemManager.tmux.newSessionCommandPlaceholder': 'e.g. htop or npm run dev (optional)',
|
||||
'systemManager.tmux.newSessionCommandHint': 'Leave empty for a default shell session.',
|
||||
'systemManager.tmux.creating': 'Creating…',
|
||||
'systemManager.tmux.newSessionPlaceholder': 'my-session',
|
||||
'systemManager.tmux.newSessionRequired': 'Enter a session name first',
|
||||
'systemManager.tmux.empty': 'No tmux sessions',
|
||||
'systemManager.tmux.attach': 'Attach',
|
||||
'systemManager.tmux.attached': 'Attached',
|
||||
'systemManager.tmux.detached': 'Detached',
|
||||
'systemManager.tmux.windows': '{{count}} window(s)',
|
||||
'systemManager.tmux.created': 'Created',
|
||||
'systemManager.tmux.activity': 'Activity',
|
||||
'systemManager.tmux.rename': 'Rename',
|
||||
'systemManager.tmux.detach': 'Detach all',
|
||||
'systemManager.tmux.killSession': 'Kill session',
|
||||
'systemManager.tmux.killServer': 'Kill server',
|
||||
'systemManager.tmux.loadingDetails': 'Loading details…',
|
||||
'systemManager.tmux.clients': 'Attached clients',
|
||||
'systemManager.tmux.windowList': 'Windows',
|
||||
'systemManager.tmux.newWindow': 'New window',
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Window name (optional)',
|
||||
'systemManager.tmux.noWindows': 'No windows',
|
||||
'systemManager.tmux.unavailable': 'tmux is not available on this host',
|
||||
'systemManager.docker.unavailable': 'Docker is not available on this host',
|
||||
'systemManager.tmux.windowsMismatch': 'Session reports {{count}} window(s) but list-windows returned none',
|
||||
'systemManager.tmux.lastCommand': 'last command: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'No panes',
|
||||
'systemManager.tmux.panes': '{{count}} pane(s)',
|
||||
'systemManager.tmux.active': 'active',
|
||||
'systemManager.tmux.unnamedWindow': 'Unnamed window',
|
||||
'systemManager.tmux.unnamedPane': 'Unnamed pane',
|
||||
'systemManager.tmux.attachWindow': 'Attach to window',
|
||||
'systemManager.tmux.selectWindow': 'Select window',
|
||||
'systemManager.tmux.killWindow': 'Kill window',
|
||||
'systemManager.tmux.killPane': 'Kill pane',
|
||||
'systemManager.tmux.splitHorizontal': 'Split horizontal',
|
||||
'systemManager.tmux.splitVertical': 'Split vertical',
|
||||
'systemManager.tmux.sendKeys': 'Send keys',
|
||||
'systemManager.tmux.sendKeysTo': 'Send keys to window {{window}} pane {{pane}}',
|
||||
'systemManager.tmux.sendKeysPlaceholder': 'Command or text…',
|
||||
'systemManager.tmux.renameSessionPrompt': 'Rename session',
|
||||
'systemManager.tmux.renameWindowPrompt': 'Rename window',
|
||||
'systemManager.tmux.windowName': 'Window name',
|
||||
'systemManager.tmux.confirmKillSession': 'Kill tmux session "{{name}}"?',
|
||||
'systemManager.tmux.confirmDetachSession': 'Detach all clients from "{{name}}"?',
|
||||
'systemManager.tmux.confirmKillWindow': 'Kill window "{{name}}"?',
|
||||
'systemManager.tmux.confirmKillPane': 'Kill pane #{{index}}?',
|
||||
'systemManager.tmux.confirmKillServer': 'Kill tmux server? All sessions will be terminated.',
|
||||
'systemManager.tmux.meta': '{{count}} session(s)',
|
||||
|
||||
'systemManager.docker.title': 'Containers',
|
||||
'systemManager.docker.subTabs.containers': 'Containers',
|
||||
'systemManager.docker.subTabs.images': 'Images',
|
||||
'systemManager.docker.empty': 'No containers found',
|
||||
'systemManager.docker.imagesEmpty': 'No images found',
|
||||
'systemManager.docker.search': 'Search containers…',
|
||||
'systemManager.docker.searchImages': 'Search images…',
|
||||
'systemManager.docker.filter.all': 'All',
|
||||
'systemManager.docker.filter.running': 'Running',
|
||||
'systemManager.docker.filter.stopped': 'Stopped',
|
||||
'systemManager.docker.filter.paused': 'Paused',
|
||||
'systemManager.docker.shell': 'Shell',
|
||||
'systemManager.docker.logs': 'Logs',
|
||||
'systemManager.docker.details': 'Details',
|
||||
'systemManager.docker.inspect': 'Inspect',
|
||||
'systemManager.docker.imageInspect': 'Image inspect',
|
||||
'systemManager.docker.confirmRemove': 'Remove this container?',
|
||||
'systemManager.docker.confirmKill': 'Force kill this container?',
|
||||
'systemManager.docker.confirmRemoveImage': 'Remove image "{{name}}"?',
|
||||
'systemManager.docker.confirmPrune': 'Remove dangling images?',
|
||||
'systemManager.docker.confirmPruneAll': 'Remove all unused images?',
|
||||
'systemManager.docker.pause': 'Pause',
|
||||
'systemManager.docker.unpause': 'Unpause',
|
||||
'systemManager.docker.restart': 'Restart',
|
||||
'systemManager.docker.kill': 'Kill',
|
||||
'systemManager.docker.renamePrompt': 'Container name',
|
||||
'systemManager.docker.prune': 'Prune',
|
||||
'systemManager.docker.pruneAll': 'Prune all',
|
||||
'systemManager.docker.tag': 'Tag',
|
||||
'systemManager.docker.tagRepoPrompt': 'Repository name',
|
||||
'systemManager.docker.tagNamePrompt': 'Tag name',
|
||||
'systemManager.docker.meta': '{{count}} container(s)',
|
||||
'systemManager.docker.imagesMeta': '{{count}} image(s)',
|
||||
'systemManager.docker.start': 'Start',
|
||||
'systemManager.docker.stop': 'Stop',
|
||||
|
||||
'systemManager.inspect.status': 'Status',
|
||||
'systemManager.inspect.image': 'Image',
|
||||
'systemManager.inspect.created': 'Created',
|
||||
'systemManager.inspect.started': 'Started',
|
||||
'systemManager.inspect.restartPolicy': 'Restart policy',
|
||||
'systemManager.inspect.command': 'Command',
|
||||
'systemManager.inspect.ports': 'Ports',
|
||||
'systemManager.inspect.networks': 'Networks',
|
||||
'systemManager.inspect.mounts': 'Mounts',
|
||||
'systemManager.inspect.env': 'Environment',
|
||||
'systemManager.inspect.labels': 'Labels',
|
||||
'systemManager.inspect.tags': 'Tags',
|
||||
'systemManager.inspect.digests': 'Digests',
|
||||
'systemManager.inspect.size': 'Size',
|
||||
'systemManager.inspect.platform': 'Platform',
|
||||
'systemManager.inspect.workdir': 'Working dir',
|
||||
'systemManager.inspect.exposedPorts': 'Exposed ports',
|
||||
'systemManager.inspect.showRaw': 'JSON',
|
||||
'systemManager.inspect.hideRaw': 'Hide JSON',
|
||||
};
|
||||
@@ -1,17 +1,38 @@
|
||||
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',
|
||||
'terminal.toolbar.sendYmodem': 'Send with YMODEM',
|
||||
'terminal.toolbar.receiveYmodem': 'Receive with YMODEM',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': 'More actions',
|
||||
'terminal.toolbar.scripts': 'Scripts',
|
||||
'terminal.toolbar.history': 'Command history',
|
||||
'history.scope.label': 'History scope',
|
||||
'history.tab.host': 'Host',
|
||||
'history.tab.global': 'Global',
|
||||
'history.searchPlaceholder': 'Search history...',
|
||||
'history.loading': 'Loading remote history...',
|
||||
'history.meta.count': '{count} commands',
|
||||
'history.empty.noSession': 'Open a remote session to view its command history.',
|
||||
'history.empty.unsupportedProtocol': 'Command history is only available for SSH/Mosh/ET sessions.',
|
||||
'history.empty.noHistory': 'No command history found on this host.',
|
||||
'history.empty.noGlobalHistory': 'No global command history yet. Commands you run will appear here.',
|
||||
'history.action.refresh': 'Refresh',
|
||||
'history.action.retry': 'Retry',
|
||||
'history.action.paste': 'Paste to terminal',
|
||||
'history.action.run': 'Run in terminal',
|
||||
'history.action.saveAsSnippet': 'Save as snippet',
|
||||
'terminal.toolbar.library': 'Library',
|
||||
'terminal.toolbar.noSnippets': 'No snippets available',
|
||||
'terminal.toolbar.terminalSettings': 'Terminal settings',
|
||||
'terminal.toolbar.searchTerminal': 'Search terminal',
|
||||
'terminal.toolbar.search': 'Search',
|
||||
'terminal.toolbar.timestampsEnable': 'Show timestamps',
|
||||
'terminal.toolbar.timestampsDisable': 'Hide timestamps',
|
||||
'terminal.toolbar.broadcast': 'Broadcast',
|
||||
'terminal.toolbar.broadcastEnable': 'Enable Broadcast Mode',
|
||||
'terminal.toolbar.broadcastDisable': 'Disable Broadcast Mode',
|
||||
@@ -20,8 +41,17 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Send',
|
||||
'terminal.composeBar.close': 'Close compose bar',
|
||||
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
|
||||
'terminal.composeBar.resize': 'Resize compose bar height',
|
||||
'terminal.composeBar.manageSnippets': 'Manage quick snippets',
|
||||
'terminal.composeBar.searchSnippets': 'Search snippets...',
|
||||
'terminal.composeBar.noPinnedSnippets': 'Pin snippets with + for quick access',
|
||||
'terminal.composeBar.noMatchingSnippets': 'No matching snippets',
|
||||
'terminal.composeBar.pinnedCount': '{count} pinned',
|
||||
'terminal.composeBar.unpinSnippet': 'Remove {label} from quick bar',
|
||||
'terminal.composeBar.snippetClickHint': 'Click to insert · Shift+Click to send',
|
||||
'terminal.toolbar.focus': 'Focus',
|
||||
'terminal.toolbar.focusMode': 'Focus Mode',
|
||||
'terminal.toolbar.detach': 'Detach to standalone tab',
|
||||
'terminal.toolbar.encoding': 'Terminal Encoding',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
@@ -60,7 +90,9 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.dragDrop.localTitle': 'Drop to Insert Paths',
|
||||
'terminal.dragDrop.localMessage': 'File paths will be inserted into the terminal',
|
||||
'terminal.dragDrop.remoteTitle': 'Drop to Upload Files',
|
||||
'terminal.dragDrop.remoteMessage': 'Files will be uploaded via SFTP',
|
||||
'terminal.dragDrop.remoteZmodemMessage': 'Files will be uploaded via ZMODEM (PTY)',
|
||||
'terminal.dragDrop.remoteSftpMessage': 'Files will be uploaded via SFTP',
|
||||
'terminal.dragDrop.noFiles': 'No files to upload',
|
||||
'terminal.dragDrop.notConnected': 'Cannot drop files - terminal is not connected',
|
||||
'terminal.dragDrop.errorTitle': 'Drop Error',
|
||||
'terminal.dragDrop.errorMessage': 'Failed to process dropped files',
|
||||
@@ -70,13 +102,33 @@ 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',
|
||||
'terminal.menu.sendYmodem': 'Send with YMODEM',
|
||||
'terminal.menu.receiveYmodem': 'Receive with YMODEM',
|
||||
'terminal.menu.splitHorizontal': 'Split Horizontal',
|
||||
'terminal.menu.splitVertical': 'Split Vertical',
|
||||
'terminal.menu.clearBuffer': 'Clear Buffer',
|
||||
'terminal.menu.closeTerminal': 'Close terminal',
|
||||
'terminal.menu.rename': 'Rename',
|
||||
'terminal.menu.detach': 'Detach from workspace',
|
||||
'terminal.menu.detachSession': 'Detach {name}',
|
||||
'terminal.ymodem.selectFile': 'Select file to send',
|
||||
'terminal.ymodem.allFiles': 'All files',
|
||||
'terminal.ymodem.started': 'YMODEM sending {fileName}',
|
||||
'terminal.ymodem.complete': 'YMODEM sent {fileName}',
|
||||
'terminal.ymodem.failed': 'YMODEM send failed',
|
||||
'terminal.ymodem.selectReceiveDirectory': 'Select folder to save received files',
|
||||
'terminal.ymodem.receiveStarted': 'YMODEM receiving...',
|
||||
'terminal.ymodem.receiveComplete': 'YMODEM received {fileName}',
|
||||
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM received {count} files',
|
||||
'terminal.ymodem.receiveEmpty': 'No YMODEM files received',
|
||||
'terminal.ymodem.receiveFailed': 'YMODEM receive failed',
|
||||
'terminal.ymodem.unavailable': 'YMODEM is unavailable',
|
||||
'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 +156,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 +544,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 +571,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',
|
||||
|
||||
@@ -123,6 +123,7 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.filter.placeholder': 'Filter by filename...',
|
||||
'sftp.bookmark.add': 'Bookmark this path',
|
||||
'sftp.bookmark.remove': 'Remove bookmark',
|
||||
'sftp.bookmark.list': 'Bookmarked paths',
|
||||
'sftp.bookmark.addGlobal': '+Global',
|
||||
'sftp.bookmark.addGlobalTooltip': 'Save as global bookmark (shared across all hosts)',
|
||||
'sftp.bookmark.empty': 'No bookmarks yet',
|
||||
@@ -150,9 +151,14 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.moveTo.pathNotFound': 'Directory not found or inaccessible',
|
||||
'sftp.context.download': 'Download',
|
||||
'sftp.context.copyToOtherPane': 'Copy to other pane',
|
||||
'sftp.copyCurrentPath': 'Copy current path',
|
||||
'sftp.copyCurrentPath.success': 'Current path copied',
|
||||
'sftp.copyCurrentPath.error': 'Could not copy current path',
|
||||
'sftp.viewMode.label': 'View mode',
|
||||
'sftp.viewMode.list': 'List view',
|
||||
'sftp.viewMode.tree': 'Tree view',
|
||||
'sftp.viewMode.switchToList': 'Switch to list view',
|
||||
'sftp.viewMode.switchToTree': 'Switch to tree view',
|
||||
'sftp.tree.loadError': 'Failed to load directory',
|
||||
'sftp.tree.loading': 'Loading...',
|
||||
'sftp.kind.folder': 'Folder',
|
||||
@@ -197,6 +203,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',
|
||||
@@ -252,6 +261,8 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.tabs.addTab': 'Add new tab',
|
||||
'sftp.tabs.closeTab': 'Close tab',
|
||||
'sftp.tabs.newTab': 'New Tab',
|
||||
'sftp.tabs.copyDefaultPath': 'Copy tab (default path)',
|
||||
'sftp.tabs.copyCurrentPath': 'Copy and go to current path',
|
||||
'sftp.conflict.title': 'File Conflict',
|
||||
'sftp.conflict.desc': 'A file with the same name already exists at the destination',
|
||||
'sftp.conflict.alreadyExistsSuffix': 'already exists',
|
||||
@@ -275,6 +286,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 +359,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.',
|
||||
@@ -451,7 +467,52 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.section.portCredentials': 'Port & Credentials',
|
||||
'hostDetails.section.appearance': 'Appearance',
|
||||
'hostDetails.distro.title': 'Linux Distribution',
|
||||
'hostDetails.distro.desc': 'Auto-detect on connect, or override the distro icon manually.',
|
||||
'hostDetails.distro.desc': 'Controls the automatic host icon. A custom Host Icon overrides this display.',
|
||||
'hostDetails.icon.title': 'Host Icon',
|
||||
'hostDetails.icon.desc': 'Use automatic distro icons with optional color, or choose a built-in icon.',
|
||||
'hostDetails.icon.mode.auto': 'Automatic',
|
||||
'hostDetails.icon.mode.custom': 'Custom',
|
||||
'hostDetails.icon.reset': 'Reset host icon',
|
||||
'hostDetails.icon.showLibrary': 'Show icon library',
|
||||
'hostDetails.icon.hideLibrary': 'Hide icon library',
|
||||
'hostDetails.icon.autoUsesDistro': 'Use Linux Distribution icon and selected color for this host.',
|
||||
'hostDetails.icon.customOverridesDistro': 'Built-in icon replaces Linux Distribution for this host.',
|
||||
'hostDetails.icon.option.server': 'Server',
|
||||
'hostDetails.icon.option.terminal': 'Terminal',
|
||||
'hostDetails.icon.option.database': 'Database',
|
||||
'hostDetails.icon.option.cloud': 'Cloud',
|
||||
'hostDetails.icon.option.router': 'Router',
|
||||
'hostDetails.icon.option.shield': 'Shield',
|
||||
'hostDetails.icon.option.code': 'Code',
|
||||
'hostDetails.icon.option.box': 'Box',
|
||||
'hostDetails.icon.option.globe': 'Globe',
|
||||
'hostDetails.icon.option.cpu': 'CPU',
|
||||
'hostDetails.icon.option.hard-drive': 'Storage',
|
||||
'hostDetails.icon.option.network': 'Network',
|
||||
'hostDetails.icon.option.wifi': 'Wireless',
|
||||
'hostDetails.icon.option.lock': 'Lock',
|
||||
'hostDetails.icon.option.key': 'Key',
|
||||
'hostDetails.icon.option.monitor': 'Monitor',
|
||||
'hostDetails.icon.option.container': 'Container',
|
||||
'hostDetails.icon.option.activity': 'Activity',
|
||||
'hostDetails.icon.option.zap': 'Fast',
|
||||
'hostDetails.icon.option.server-cog': 'Server settings',
|
||||
'hostDetails.icon.color.blue': 'Blue',
|
||||
'hostDetails.icon.color.green': 'Green',
|
||||
'hostDetails.icon.color.red': 'Red',
|
||||
'hostDetails.icon.color.amber': 'Amber',
|
||||
'hostDetails.icon.color.purple': 'Purple',
|
||||
'hostDetails.icon.color.cyan': 'Cyan',
|
||||
'hostDetails.icon.color.orange': 'Orange',
|
||||
'hostDetails.icon.color.slate': 'Slate',
|
||||
'hostDetails.icon.color.violet': 'Violet',
|
||||
'hostDetails.icon.color.pink': 'Pink',
|
||||
'hostDetails.icon.color.rose': 'Rose',
|
||||
'hostDetails.icon.color.lime': 'Lime',
|
||||
'hostDetails.icon.color.teal': 'Teal',
|
||||
'hostDetails.icon.color.sky': 'Sky',
|
||||
'hostDetails.icon.color.indigo': 'Indigo',
|
||||
'hostDetails.icon.color.zinc': 'Zinc',
|
||||
'hostDetails.distro.mode': 'Source',
|
||||
'hostDetails.distro.mode.auto': 'Auto-detect',
|
||||
'hostDetails.distro.mode.manual': 'Manual override',
|
||||
@@ -471,6 +532,8 @@ 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.openeuler': 'openEuler',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.distro.option.cisco': 'Cisco',
|
||||
@@ -483,6 +546,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',
|
||||
@@ -515,6 +581,8 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.deviceType.warning': 'AI agent commands will be sent directly without exit code tracking. Only enable for devices that do not run a standard shell.',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH Algorithms',
|
||||
'hostDetails.section.terminalBehavior': 'Terminal Behavior',
|
||||
'hostDetails.lineTimestamps': 'Show output timestamps',
|
||||
'hostDetails.lineTimestamps.desc': 'Show local time beside visible output lines for this host without changing terminal text.',
|
||||
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
|
||||
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
|
||||
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',
|
||||
@@ -542,12 +610,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 +629,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',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ruCoreMessages } from './ru/core';
|
||||
import { ruVaultMessages } from './ru/vault';
|
||||
import { ruTerminalMessages } from './ru/terminal';
|
||||
import { ruAiMessages } from './ru/ai';
|
||||
import { ruSystemManagerMessages } from './ru/systemManager';
|
||||
|
||||
export type { Messages } from './types';
|
||||
|
||||
@@ -11,6 +12,7 @@ const ru: Messages = {
|
||||
...ruVaultMessages,
|
||||
...ruTerminalMessages,
|
||||
...ruAiMessages,
|
||||
...ruSystemManagerMessages,
|
||||
};
|
||||
|
||||
export default ru;
|
||||
|
||||
@@ -3,9 +3,11 @@ import type { Messages } from '../types';
|
||||
export const ruAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Настройки агента',
|
||||
'ai.chat.preparing': 'Подготовка…',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': 'Настройка AI-провайдеров, агентов и параметров безопасности',
|
||||
'ai.providers': 'Провайдеры',
|
||||
'ai.agents': 'Агенты',
|
||||
'ai.providers.empty': 'Провайдеры не настроены. Добавьте провайдера, чтобы начать.',
|
||||
'ai.providers.add': 'Добавить провайдера',
|
||||
'ai.providers.active': 'Активен',
|
||||
@@ -34,6 +36,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 +55,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 +89,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 +99,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': 'Не найден',
|
||||
@@ -99,13 +108,61 @@ export const ruAiMessages: Messages = {
|
||||
'ai.copilot.customPathPlaceholder': 'например, /usr/local/bin/copilot',
|
||||
'ai.copilot.check': 'Проверить',
|
||||
|
||||
// AI Cursor SDK
|
||||
'ai.cursor.title': 'Cursor',
|
||||
'ai.cursor.description': 'Использует Cursor SDK.',
|
||||
'ai.cursor.detecting': 'Обнаружение...',
|
||||
'ai.cursor.detected': 'Доступен',
|
||||
'ai.cursor.notFound': 'Недоступен',
|
||||
'ai.cursor.path': 'Среда:',
|
||||
'ai.cursor.notFoundHint': 'Укажите API-ключ, чтобы включить Cursor.',
|
||||
'ai.cursor.notInstalledHint': 'Cursor SDK не обнаружен.',
|
||||
'ai.cursor.installStatus': 'Cursor SDK',
|
||||
'ai.cursor.installed': 'Обнаружено',
|
||||
'ai.cursor.notInstalled': 'Не обнаружено',
|
||||
'ai.cursor.apiKeyStatus': 'API-ключ',
|
||||
'ai.cursor.apiKeyConfigured': 'Настроен',
|
||||
'ai.cursor.apiKeyMissing': 'Не указан',
|
||||
'ai.cursor.apiKeyFromEnv': 'Из окружения',
|
||||
'ai.cursor.apiKey': 'API-ключ',
|
||||
'ai.cursor.apiKeyPlaceholder': 'Введите API-ключ Cursor',
|
||||
'ai.cursor.apiKeyPlaceholder.env': 'Используется CURSOR_API_KEY; введите ключ для замены',
|
||||
'ai.cursor.apiKeyEnvHint': 'Cursor может использовать CURSOR_API_KEY из shell. Сохраняйте ключ здесь только если хотите переопределить его в Netcatty.',
|
||||
'ai.cursor.apiKeyOverrideHint': 'Netcatty сначала использует сохранённый здесь ключ, затем CURSOR_API_KEY.',
|
||||
'ai.cursor.saveApiKey': 'Сохранить',
|
||||
'ai.cursor.saved': 'Сохранено',
|
||||
'ai.cursor.showApiKey': 'Показать API-ключ',
|
||||
'ai.cursor.hideApiKey': 'Скрыть API-ключ',
|
||||
'ai.cursor.customPathPlaceholder': 'например, /usr/local/bin/cursor',
|
||||
'ai.cursor.check': 'Проверить',
|
||||
|
||||
// AI CodeBuddy Code
|
||||
'ai.codebuddy.title': 'CodeBuddy Code',
|
||||
'ai.codebuddy.description': 'Использует CodeBuddy Code через официальный Agent SDK (`@tencent-ai/agent-sdk`). После обнаружения может быть выбран как внешний агент для программирования.',
|
||||
'ai.codebuddy.detecting': 'Обнаружение...',
|
||||
'ai.codebuddy.detected': 'Обнаружен',
|
||||
'ai.codebuddy.notFound': 'Не найден',
|
||||
'ai.codebuddy.path': 'Путь:',
|
||||
'ai.codebuddy.notFoundHint': 'Не удалось найти codebuddy в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
|
||||
'ai.codebuddy.customPathPlaceholder': 'например, /usr/local/bin/codebuddy',
|
||||
'ai.codebuddy.check': 'Проверить',
|
||||
'ai.codebuddy.configSection': 'Аутентификация и конфигурация (необязательно)',
|
||||
'ai.codebuddy.internetEnv': 'Сетевая среда',
|
||||
'ai.codebuddy.internetEnv.default': 'По умолчанию (зарубежная)',
|
||||
'ai.codebuddy.internetEnv.internal': 'Internal',
|
||||
'ai.codebuddy.internetEnv.ioa': 'IOA',
|
||||
'ai.codebuddy.internetEnv.hint': 'Устанавливает CODEBUDDY_INTERNET_ENVIRONMENT — выберите Internal или IOA для ограниченных сетевых сред.',
|
||||
'ai.codebuddy.envVars': 'Переменные окружения',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': 'По одной записи KEY=VALUE на строку, передаются агенту CodeBuddy. Укажите CODEBUDDY_API_KEY или CODEBUDDY_AUTH_TOKEN для аутентификации. Хранятся локально в открытом виде.',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': 'Агент по умолчанию',
|
||||
'ai.defaultAgent.description': 'Агент, который будет использоваться при запуске новой AI-сессии',
|
||||
'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',
|
||||
@@ -120,6 +177,29 @@ export const ruAiMessages: Messages = {
|
||||
'ai.userSkills.status.ready': 'Готово',
|
||||
'ai.userSkills.status.warning': 'Предупреждение',
|
||||
|
||||
// AI Quick Messages
|
||||
'ai.quickMessages.title': 'Быстрые сообщения',
|
||||
'ai.quickMessages.description': 'Создавайте часто используемые подсказки и вставляйте их в AI-чат через / или кнопку быстрых сообщений. В отличие от user skills, быстрые сообщения заполняют поле ввода текстом.',
|
||||
'ai.quickMessages.add': 'Добавить быстрое сообщение',
|
||||
'ai.quickMessages.createTitle': 'Новое быстрое сообщение',
|
||||
'ai.quickMessages.editTitle': 'Редактировать быстрое сообщение',
|
||||
'ai.quickMessages.name': 'Название',
|
||||
'ai.quickMessages.name.placeholder': 'например: Проверить диск',
|
||||
'ai.quickMessages.slug': 'Команда',
|
||||
'ai.quickMessages.slug.placeholder': 'disk-check',
|
||||
'ai.quickMessages.descriptionField': 'Описание (необязательно)',
|
||||
'ai.quickMessages.descriptionField.placeholder': 'Краткая подсказка о назначении',
|
||||
'ai.quickMessages.content': 'Текст сообщения',
|
||||
'ai.quickMessages.content.placeholder': 'Полный текст подсказки для вставки...',
|
||||
'ai.quickMessages.empty': 'Быстрых сообщений пока нет. Добавьте несколько часто используемых подсказок.',
|
||||
'ai.quickMessages.confirmDelete': 'Удалить быстрое сообщение «{name}»?',
|
||||
'ai.quickMessages.error.nameRequired': 'Укажите название.',
|
||||
'ai.quickMessages.error.invalidSlug': 'Команда может содержать только строчные буквы, цифры и дефисы.',
|
||||
'ai.quickMessages.error.contentRequired': 'Укажите текст сообщения.',
|
||||
'ai.quickMessages.error.slugTaken': 'Эта команда уже используется другим быстрым сообщением.',
|
||||
'ai.quickMessages.error.slugConflictsWithSkill': 'Команда конфликтует с user skill «/{slug}». Выберите другую.',
|
||||
'ai.quickMessages.error.maxItems': 'Можно сохранить не более {max} быстрых сообщений.',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': 'AI-провайдер не настроен. Перейдите в **Настройки → AI → Провайдеры**, чтобы добавить и включить провайдера.',
|
||||
'ai.chat.toolDenied': 'Действие было отклонено пользователем.',
|
||||
@@ -167,6 +247,9 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.daysAgo': '{n}д назад',
|
||||
'ai.chat.newChat': 'Новый чат',
|
||||
'ai.chat.allSessions': 'Все сессии',
|
||||
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
|
||||
'ai.chat.usedTools': 'Использовано инструментов: {n}',
|
||||
'ai.chat.loadMoreSessions': 'Загрузить больше сессий (ещё {n})',
|
||||
'ai.chat.noSessions': 'Предыдущих сессий нет',
|
||||
'ai.chat.retryHint': 'Вы можете повторить попытку, отправив сообщение ещё раз.',
|
||||
'ai.chat.approvalTimeout': 'Время ожидания одобрения инструмента истекло через 5 минут. Вы можете повторить попытку, отправив сообщение ещё раз.',
|
||||
@@ -176,6 +259,18 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.menuImage': 'Изображение',
|
||||
'ai.chat.menuMentionHost': 'Упомянуть хост',
|
||||
'ai.chat.menuUserSkills': 'Пользовательские skills',
|
||||
'ai.chat.menuSlashCommands': 'Команды /',
|
||||
'ai.chat.slashCommands': 'Команды /',
|
||||
'ai.chat.slashQuickMessages': 'Быстрые сообщения',
|
||||
'ai.chat.slashUserSkills': 'User skills',
|
||||
'ai.chat.quickMessages': 'Команды /',
|
||||
'ai.chat.slashNoResults': 'Нет подходящих команд',
|
||||
'ai.chat.slashEmptyHint': 'Добавьте подсказки в Настройки → AI → Быстрые сообщения.',
|
||||
|
||||
// AI Chat Shortcuts
|
||||
'ai.chatShortcuts.title': 'Быстрые действия чата',
|
||||
'ai.chatShortcuts.selectionAction': 'Показывать «Добавить в чат» при выделении в терминале',
|
||||
'ai.chatShortcuts.selectionAction.description': 'Показывать небольшую кнопку AI рядом с выделенным текстом терминала.',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Обработчики главного процесса Codex ещё не загружены. Полностью перезапустите Netcatty или dev-процесс Electron и попробуйте снова.',
|
||||
@@ -198,39 +293,53 @@ 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': 'Добавить терминал',
|
||||
'terminal.layer.switchToSplitView': 'Переключить в режим разделения',
|
||||
'terminal.layer.sftp': 'SFTP',
|
||||
'terminal.layer.scripts': 'Скрипты',
|
||||
'terminal.layer.history': 'История',
|
||||
'terminal.layer.theme': 'Тема',
|
||||
'terminal.layer.aiChat': 'AI-чат',
|
||||
'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}',
|
||||
|
||||
@@ -42,6 +42,7 @@ export const ruCoreMessages: Messages = {
|
||||
'common.more': 'Ещё',
|
||||
'common.selectAHost': 'Выберите хост',
|
||||
'common.selectAHostPlaceholder': 'Выберите хост...',
|
||||
'sort.manual': 'Ручной порядок',
|
||||
'sort.az': 'А-Я',
|
||||
'sort.za': 'Я-А',
|
||||
'sort.newest': 'Сначала новые',
|
||||
@@ -159,8 +160,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': 'Переключение окна',
|
||||
@@ -212,6 +226,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'Если включено, в корневом списке хостов будут показаны только хосты без группы. Откройте группу на боковой панели, чтобы увидеть сгруппированные хосты.',
|
||||
'settings.vault.showSftpTab': 'Показывать вкладку SFTP',
|
||||
'settings.vault.showSftpTabDesc': 'Показывать отдельный SFTP-вид в верхней панели вкладок. Если скрыто, используйте боковую панель SFTP внутри сессии.',
|
||||
'settings.vault.showHostTreeSidebar': 'Показывать боковую панель хостов',
|
||||
'settings.vault.showHostTreeSidebarDesc': 'Показывать список хостов и кнопку в верхней панели для вкладок терминала и редактора.',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': 'Доступно обновление',
|
||||
@@ -227,6 +243,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 +267,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, top-tabs-host-tree-toggle, top-tabs-quick-switcher-toggle.',
|
||||
'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/* Скрыть переключатель списка хостов в верхней панели вкладок */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Скрыть кнопку плюса, открывающую быстрый переключатель */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\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': 'Выберите тему',
|
||||
@@ -293,6 +312,15 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.font.size.desc': 'Размер текста терминала',
|
||||
'settings.terminal.font.weight': 'Толщина шрифта',
|
||||
'settings.terminal.font.weight.desc': 'Толщина обычного текста (100-900)',
|
||||
'settings.terminal.font.weight.thin': 'Тонкий',
|
||||
'settings.terminal.font.weight.extraLight': 'Очень светлый',
|
||||
'settings.terminal.font.weight.light': 'Светлый',
|
||||
'settings.terminal.font.weight.normal': 'Обычный',
|
||||
'settings.terminal.font.weight.medium': 'Средний',
|
||||
'settings.terminal.font.weight.semiBold': 'Полужирный',
|
||||
'settings.terminal.font.weight.bold': 'Жирный',
|
||||
'settings.terminal.font.weight.extraBold': 'Очень жирный',
|
||||
'settings.terminal.font.weight.black': 'Максимально жирный',
|
||||
'settings.terminal.font.weightBold': 'Толщина жирного шрифта',
|
||||
'settings.terminal.font.weightBold.desc': 'Толщина жирного текста (100-900)',
|
||||
'settings.terminal.font.linePadding': 'Межстрочный отступ',
|
||||
@@ -322,6 +350,11 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.behavior.middleClickPaste': 'Вставка средней кнопкой мыши',
|
||||
'settings.terminal.behavior.middleClickPaste.desc':
|
||||
'Вставлять содержимое буфера обмена по щелчку средней кнопкой',
|
||||
'settings.terminal.behavior.middleClick': 'Поведение средней кнопки мыши',
|
||||
'settings.terminal.behavior.middleClick.desc': 'Действие при щелчке средней кнопкой в терминале',
|
||||
'settings.terminal.behavior.middleClick.menu': 'Показать меню',
|
||||
'settings.terminal.behavior.middleClick.paste': 'Вставить',
|
||||
'settings.terminal.behavior.middleClick.disabled': 'Ничего не делать',
|
||||
'settings.terminal.behavior.bracketedPaste': 'Режим bracketed paste',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'Оборачивать вставляемый текст escape-последовательностями, чтобы оболочка отличала вставку от обычного ввода. Отключите, если видите артефакты вида ^[[200~.',
|
||||
@@ -394,6 +427,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': 'Начальный каталог',
|
||||
@@ -410,6 +446,15 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.connection.x11Display.desc': 'Необязательный адрес локального дисплея для перенаправления X11. Оставьте пустым, чтобы использовать системное значение по умолчанию.',
|
||||
'settings.terminal.connection.x11Display.placeholder': 'Авто (:0 или DISPLAY)',
|
||||
'settings.terminal.section.serverStats': 'Статистика сервера (Linux)',
|
||||
'settings.terminal.section.systemManager': 'Системный менеджер',
|
||||
'settings.terminal.systemManager.processRefreshInterval': 'Обновление списка процессов',
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'Как часто обновлять список процессов в боковой панели системного менеджера.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'Обновление сессий tmux',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'Как часто обновлять список сессий tmux.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Обновление списка контейнеров Docker',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Как часто обновлять список контейнеров Docker.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Обновление статистики Docker',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Как часто обновлять CPU/память/сеть контейнеров Docker.',
|
||||
'settings.terminal.serverStats.show': 'Показывать статистику сервера',
|
||||
'settings.terminal.serverStats.show.desc': 'Показывать загрузку CPU, памяти и диска в строке состояния терминала (только для Linux-серверов).',
|
||||
'settings.terminal.serverStats.refreshInterval': 'Интервал обновления',
|
||||
@@ -445,6 +490,10 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.shortcuts.scheme.disabled': 'Отключено',
|
||||
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
|
||||
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
|
||||
'settings.shortcuts.disableTerminalFontZoom.label': 'Отключить масштаб терминала',
|
||||
'settings.shortcuts.disableTerminalFontZoom.desc': 'Отключает быстрый масштаб текста в терминале, включая Cmd/Ctrl + колесо мыши.',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Цифры без закреплённых вкладок',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'Если включено, Cmd/Ctrl+[1...9] переключает только рабочие вкладки (терминалы, рабочие области, редакторы), а не закреплённые Vault и SFTP.',
|
||||
'settings.shortcuts.section.custom': 'Пользовательские сочетания',
|
||||
'settings.shortcuts.resetAll': 'Сбросить все',
|
||||
'settings.shortcuts.recording': 'Нажмите клавиши...',
|
||||
@@ -460,6 +509,7 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.shortcuts.binding.next-tab': 'Следующая вкладка',
|
||||
'settings.shortcuts.binding.prev-tab': 'Предыдущая вкладка',
|
||||
'settings.shortcuts.binding.close-tab': 'Закрыть вкладку',
|
||||
'settings.shortcuts.binding.close-session': 'Закрыть панель сессии',
|
||||
'settings.shortcuts.binding.new-tab': 'Новая локальная вкладка',
|
||||
'settings.shortcuts.binding.copy': 'Копировать из терминала',
|
||||
'settings.shortcuts.binding.paste': 'Вставить в терминал',
|
||||
@@ -467,9 +517,13 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.shortcuts.binding.select-all': 'Выделить всё содержимое терминала',
|
||||
'settings.shortcuts.binding.clear-buffer': 'Очистить буфер терминала',
|
||||
'settings.shortcuts.binding.search-terminal': 'Открыть поиск по терминалу',
|
||||
'settings.shortcuts.binding.increase-terminal-font-size': 'Увеличить размер шрифта терминала',
|
||||
'settings.shortcuts.binding.decrease-terminal-font-size': 'Уменьшить размер шрифта терминала',
|
||||
'settings.shortcuts.binding.reset-terminal-font-size': 'Сбросить размер шрифта терминала',
|
||||
'settings.shortcuts.binding.move-focus': 'Переместить фокус между разделёнными окнами',
|
||||
'settings.shortcuts.binding.split-horizontal': 'Горизонтальное разделение',
|
||||
'settings.shortcuts.binding.split-vertical': 'Вертикальное разделение',
|
||||
'settings.shortcuts.binding.toggle-pane-zoom': 'Переключить масштаб панели',
|
||||
'settings.shortcuts.binding.open-hosts': 'Открыть список хостов',
|
||||
'settings.shortcuts.binding.open-local': 'Открыть локальный терминал',
|
||||
'settings.shortcuts.binding.open-sftp': 'Открыть SFTP',
|
||||
@@ -594,12 +648,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': 'Удалить прокси?',
|
||||
|
||||
181
application/i18n/locales/ru/systemManager.ts
Normal file
181
application/i18n/locales/ru/systemManager.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruSystemManagerMessages: Messages = {
|
||||
'terminal.layer.system': 'Система',
|
||||
|
||||
'systemManager.noSession': 'Нет активного терминального сеанса.',
|
||||
'systemManager.notConnected': 'Подключитесь к хосту для управления процессами и сервисами.',
|
||||
'systemManager.empty': 'Нет данных.',
|
||||
'systemManager.tabs.processes': 'Процессы',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.popup.loading': 'Открытие терминала…',
|
||||
'systemManager.popup.startupFailed': 'Команда запуска не была выполнена успешно. Проверьте, что цель доступна, и повторите попытку.',
|
||||
|
||||
'systemManager.errors.loadProcesses': 'Не удалось загрузить процессы',
|
||||
'systemManager.errors.loadTmux': 'Не удалось загрузить сессии tmux',
|
||||
'systemManager.errors.loadTmuxWindows': 'Не удалось загрузить окна tmux',
|
||||
'systemManager.errors.loadTmuxPanes': 'Не удалось загрузить панели tmux',
|
||||
'systemManager.errors.loadTmuxClients': 'Не удалось загрузить клиентов tmux',
|
||||
'systemManager.errors.actionFailed': 'Не удалось выполнить действие',
|
||||
'systemManager.errors.loadDocker': 'Не удалось загрузить контейнеры',
|
||||
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
|
||||
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
|
||||
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
|
||||
|
||||
'systemManager.processes.search': 'Поиск процессов…',
|
||||
'systemManager.processes.command': 'Команда',
|
||||
'systemManager.processes.user': 'Пользователь',
|
||||
'systemManager.processes.term': 'Завершить',
|
||||
'systemManager.processes.kill': 'Убить',
|
||||
'systemManager.processes.stop': 'Остановить (SIGSTOP)',
|
||||
'systemManager.processes.cont': 'Продолжить (SIGCONT)',
|
||||
'systemManager.processes.hup': 'Сигнал SIGHUP',
|
||||
'systemManager.processes.renice': 'Renice',
|
||||
'systemManager.processes.renicePrompt': 'Значение nice (-20 до 19)',
|
||||
'systemManager.processes.reniceInvalid': 'Nice должно быть от -20 до 19',
|
||||
'systemManager.processes.confirmKill': 'Отправить SIGKILL процессу {{pid}}?',
|
||||
'systemManager.processes.confirmSignal': 'Отправить SIG{{signal}} процессу {{pid}}?',
|
||||
'systemManager.processes.filter.all': 'Все',
|
||||
'systemManager.processes.filter.running': 'Активные',
|
||||
'systemManager.processes.ppid': 'Родительский PID',
|
||||
'systemManager.processes.rss': 'RSS',
|
||||
'systemManager.processes.vsz': 'Виртуальный размер',
|
||||
'systemManager.processes.elapsed': 'Время работы',
|
||||
'systemManager.processes.stat': 'Состояние',
|
||||
'systemManager.processes.meta': '{{count}} проц.',
|
||||
'systemManager.processes.loading': 'Загрузка процессов…',
|
||||
'systemManager.processes.loadingMore': 'Загрузка следующих процессов…',
|
||||
'systemManager.processes.state.running': 'Активен',
|
||||
'systemManager.processes.state.sleeping': 'Сон',
|
||||
'systemManager.processes.state.stopped': 'Остановлен',
|
||||
'systemManager.processes.state.zombie': 'Зомби',
|
||||
'systemManager.processes.sort.cpu': 'CPU',
|
||||
'systemManager.processes.sort.mem': 'Память',
|
||||
'systemManager.processes.sort.pid': 'PID',
|
||||
'systemManager.processes.sort.command': 'Команда',
|
||||
'systemManager.processes.sort.user': 'Пользователь',
|
||||
|
||||
'systemManager.common.dismiss': 'Закрыть',
|
||||
'systemManager.common.checkingAvailability': 'Проверка доступности…',
|
||||
'systemManager.common.loading': 'Загрузка…',
|
||||
'systemManager.common.loadingDetails': 'Загрузка деталей…',
|
||||
'systemManager.common.loadingStats': 'Загрузка статистики…',
|
||||
|
||||
'systemManager.tmux.new': 'Создать',
|
||||
'systemManager.tmux.search': 'Поиск сессий…',
|
||||
'systemManager.tmux.newSessionTitle': 'Новая сессия tmux',
|
||||
'systemManager.tmux.newSessionDesc': 'Задайте имя сессии и при необходимости команду запуска.',
|
||||
'systemManager.tmux.newSessionTabCustom': 'Своя команда',
|
||||
'systemManager.tmux.newSessionTabSnippet': 'Из сниппета',
|
||||
'systemManager.tmux.pickSnippet': 'Из сниппетов',
|
||||
'systemManager.tmux.pickSnippetEmpty': 'Сниппетов пока нет — добавьте их на панели скриптов или в хранилище.',
|
||||
'systemManager.tmux.selectedSnippet': 'Выбран сниппет: {{label}}',
|
||||
'systemManager.tmux.newSessionName': 'Имя сессии',
|
||||
'systemManager.tmux.newSessionCommand': 'Команда запуска',
|
||||
'systemManager.tmux.newSessionCommandPlaceholder': 'например htop или npm run dev (необяз.)',
|
||||
'systemManager.tmux.newSessionCommandHint': 'Оставьте пустым для сессии с shell по умолчанию.',
|
||||
'systemManager.tmux.creating': 'Создание…',
|
||||
'systemManager.tmux.newSessionPlaceholder': 'my-session',
|
||||
'systemManager.tmux.newSessionRequired': 'Сначала введите имя сессии',
|
||||
'systemManager.tmux.empty': 'Нет сессий tmux',
|
||||
'systemManager.tmux.attach': 'Подключить',
|
||||
'systemManager.tmux.attached': 'Подключена',
|
||||
'systemManager.tmux.detached': 'Отключена',
|
||||
'systemManager.tmux.windows': '{{count}} окон',
|
||||
'systemManager.tmux.created': 'Создана',
|
||||
'systemManager.tmux.activity': 'Активность',
|
||||
'systemManager.tmux.rename': 'Переименовать',
|
||||
'systemManager.tmux.detach': 'Отключить всех',
|
||||
'systemManager.tmux.killSession': 'Завершить сессию',
|
||||
'systemManager.tmux.killServer': 'Остановить сервер',
|
||||
'systemManager.tmux.loadingDetails': 'Загрузка деталей…',
|
||||
'systemManager.tmux.clients': 'Подключённые клиенты',
|
||||
'systemManager.tmux.windowList': 'Окна',
|
||||
'systemManager.tmux.newWindow': 'Новое окно',
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Имя окна (необязательно)',
|
||||
'systemManager.tmux.noWindows': 'Нет окон',
|
||||
'systemManager.tmux.unavailable': 'tmux недоступен на этом хосте',
|
||||
'systemManager.docker.unavailable': 'Docker недоступен на этом хосте',
|
||||
'systemManager.tmux.windowsMismatch': 'В сессии указано {{count}} окон, но list-windows ничего не вернул',
|
||||
'systemManager.tmux.lastCommand': 'последняя команда: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'Нет панелей',
|
||||
'systemManager.tmux.panes': '{{count}} пан.',
|
||||
'systemManager.tmux.active': 'активно',
|
||||
'systemManager.tmux.unnamedWindow': 'Безымянное окно',
|
||||
'systemManager.tmux.unnamedPane': 'Безымянная панель',
|
||||
'systemManager.tmux.attachWindow': 'Подключить к окну',
|
||||
'systemManager.tmux.selectWindow': 'Выбрать окно',
|
||||
'systemManager.tmux.killWindow': 'Закрыть окно',
|
||||
'systemManager.tmux.killPane': 'Закрыть панель',
|
||||
'systemManager.tmux.splitHorizontal': 'Разделить горизонтально',
|
||||
'systemManager.tmux.splitVertical': 'Разделить вертикально',
|
||||
'systemManager.tmux.sendKeys': 'Отправить клавиши',
|
||||
'systemManager.tmux.sendKeysTo': 'Отправить клавиши в окно {{window}} панель {{pane}}',
|
||||
'systemManager.tmux.sendKeysPlaceholder': 'Команда или текст…',
|
||||
'systemManager.tmux.renameSessionPrompt': 'Переименовать сессию',
|
||||
'systemManager.tmux.renameWindowPrompt': 'Переименовать окно',
|
||||
'systemManager.tmux.windowName': 'Имя окна',
|
||||
'systemManager.tmux.confirmKillSession': 'Завершить сессию tmux «{{name}}»?',
|
||||
'systemManager.tmux.confirmDetachSession': 'Отключить всех клиентов от «{{name}}»?',
|
||||
'systemManager.tmux.confirmKillWindow': 'Закрыть окно «{{name}}»?',
|
||||
'systemManager.tmux.confirmKillPane': 'Закрыть панель #{{index}}?',
|
||||
'systemManager.tmux.confirmKillServer': 'Остановить сервер tmux? Все сессии будут завершены.',
|
||||
'systemManager.tmux.meta': '{{count}} сессий',
|
||||
|
||||
'systemManager.docker.title': 'Контейнеры',
|
||||
'systemManager.docker.subTabs.containers': 'Контейнеры',
|
||||
'systemManager.docker.subTabs.images': 'Образы',
|
||||
'systemManager.docker.empty': 'Контейнеры не найдены',
|
||||
'systemManager.docker.imagesEmpty': 'Образы не найдены',
|
||||
'systemManager.docker.search': 'Поиск контейнеров…',
|
||||
'systemManager.docker.searchImages': 'Поиск образов…',
|
||||
'systemManager.docker.filter.all': 'Все',
|
||||
'systemManager.docker.filter.running': 'Запущены',
|
||||
'systemManager.docker.filter.stopped': 'Остановлены',
|
||||
'systemManager.docker.filter.paused': 'На паузе',
|
||||
'systemManager.docker.shell': 'Shell',
|
||||
'systemManager.docker.logs': 'Логи',
|
||||
'systemManager.docker.details': 'Детали',
|
||||
'systemManager.docker.inspect': 'Inspect',
|
||||
'systemManager.docker.imageInspect': 'Inspect образа',
|
||||
'systemManager.docker.confirmRemove': 'Удалить этот контейнер?',
|
||||
'systemManager.docker.confirmKill': 'Принудительно завершить контейнер?',
|
||||
'systemManager.docker.confirmRemoveImage': 'Удалить образ «{{name}}»?',
|
||||
'systemManager.docker.confirmPrune': 'Удалить dangling-образы?',
|
||||
'systemManager.docker.confirmPruneAll': 'Удалить все неиспользуемые образы?',
|
||||
'systemManager.docker.pause': 'Пауза',
|
||||
'systemManager.docker.unpause': 'Возобновить',
|
||||
'systemManager.docker.restart': 'Перезапустить',
|
||||
'systemManager.docker.kill': 'Kill',
|
||||
'systemManager.docker.renamePrompt': 'Имя контейнера',
|
||||
'systemManager.docker.prune': 'Prune',
|
||||
'systemManager.docker.pruneAll': 'Prune all',
|
||||
'systemManager.docker.tag': 'Tag',
|
||||
'systemManager.docker.tagRepoPrompt': 'Имя репозитория',
|
||||
'systemManager.docker.tagNamePrompt': 'Имя тега',
|
||||
'systemManager.docker.meta': '{{count}} конт.',
|
||||
'systemManager.docker.imagesMeta': '{{count}} образов',
|
||||
'systemManager.docker.start': 'Запустить',
|
||||
'systemManager.docker.stop': 'Остановить',
|
||||
|
||||
'systemManager.inspect.status': 'Статус',
|
||||
'systemManager.inspect.image': 'Образ',
|
||||
'systemManager.inspect.created': 'Создан',
|
||||
'systemManager.inspect.started': 'Запущен',
|
||||
'systemManager.inspect.restartPolicy': 'Перезапуск',
|
||||
'systemManager.inspect.command': 'Команда',
|
||||
'systemManager.inspect.ports': 'Порты',
|
||||
'systemManager.inspect.networks': 'Сети',
|
||||
'systemManager.inspect.mounts': 'Тома',
|
||||
'systemManager.inspect.env': 'Окружение',
|
||||
'systemManager.inspect.labels': 'Метки',
|
||||
'systemManager.inspect.tags': 'Теги',
|
||||
'systemManager.inspect.digests': 'Дайджесты',
|
||||
'systemManager.inspect.size': 'Размер',
|
||||
'systemManager.inspect.platform': 'Платформа',
|
||||
'systemManager.inspect.workdir': 'Рабочий каталог',
|
||||
'systemManager.inspect.exposedPorts': 'Открытые порты',
|
||||
'systemManager.inspect.showRaw': 'JSON',
|
||||
'systemManager.inspect.hideRaw': 'Скрыть JSON',
|
||||
};
|
||||
@@ -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': 'Пользователь',
|
||||
@@ -25,14 +26,34 @@ export const ruTerminalMessages: Messages = {
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Открыть SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': 'Доступно после подключения',
|
||||
'terminal.toolbar.sendYmodem': 'Отправить через YMODEM',
|
||||
'terminal.toolbar.receiveYmodem': 'Получить через YMODEM',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': 'Другие действия',
|
||||
'terminal.toolbar.scripts': 'Скрипты',
|
||||
'terminal.toolbar.history': 'История команд',
|
||||
'history.scope.label': 'Область истории',
|
||||
'history.tab.host': 'Хост',
|
||||
'history.tab.global': 'Глобальная',
|
||||
'history.searchPlaceholder': 'Поиск по истории...',
|
||||
'history.loading': 'Загрузка удалённой истории...',
|
||||
'history.meta.count': '{count} команд',
|
||||
'history.empty.noSession': 'Откройте удалённую сессию, чтобы просмотреть историю команд.',
|
||||
'history.empty.unsupportedProtocol': 'История команд доступна только для сессий SSH/Mosh/ET.',
|
||||
'history.empty.noHistory': 'История команд на этом хосте не найдена.',
|
||||
'history.empty.noGlobalHistory': 'Глобальной истории команд пока нет. Выполненные команды появятся здесь.',
|
||||
'history.action.refresh': 'Обновить',
|
||||
'history.action.retry': 'Повторить',
|
||||
'history.action.paste': 'Вставить в терминал',
|
||||
'history.action.run': 'Выполнить в терминале',
|
||||
'history.action.saveAsSnippet': 'Сохранить как сниппет',
|
||||
'terminal.toolbar.library': 'Библиотека',
|
||||
'terminal.toolbar.noSnippets': 'Нет доступных сниппетов',
|
||||
'terminal.toolbar.terminalSettings': 'Настройки терминала',
|
||||
'terminal.toolbar.searchTerminal': 'Поиск по терминалу',
|
||||
'terminal.toolbar.search': 'Поиск',
|
||||
'terminal.toolbar.timestampsEnable': 'Показать время',
|
||||
'terminal.toolbar.timestampsDisable': 'Скрыть время',
|
||||
'terminal.toolbar.broadcast': 'Трансляция',
|
||||
'terminal.toolbar.broadcastEnable': 'Включить режим трансляции',
|
||||
'terminal.toolbar.broadcastDisable': 'Отключить режим трансляции',
|
||||
@@ -41,8 +62,17 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Отправить',
|
||||
'terminal.composeBar.close': 'Закрыть строку ввода',
|
||||
'terminal.composeBar.broadcasting': 'Трансляция во все сессии',
|
||||
'terminal.composeBar.resize': 'Изменить высоту строки ввода',
|
||||
'terminal.composeBar.manageSnippets': 'Управление быстрыми сниппетами',
|
||||
'terminal.composeBar.searchSnippets': 'Поиск сниппетов...',
|
||||
'terminal.composeBar.noPinnedSnippets': 'Закрепите сниппеты через + для быстрого доступа',
|
||||
'terminal.composeBar.noMatchingSnippets': 'Сниппеты не найдены',
|
||||
'terminal.composeBar.pinnedCount': 'Закреплено: {count}',
|
||||
'terminal.composeBar.unpinSnippet': 'Убрать {label} из панели',
|
||||
'terminal.composeBar.snippetClickHint': 'Клик — вставить · Shift+клик — отправить',
|
||||
'terminal.toolbar.focus': 'Фокус',
|
||||
'terminal.toolbar.focusMode': 'Режим фокуса',
|
||||
'terminal.toolbar.detach': 'Открепить в отдельную вкладку',
|
||||
'terminal.toolbar.encoding': 'Кодировка терминала',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
@@ -81,7 +111,9 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.dragDrop.localTitle': 'Перетащите для вставки путей',
|
||||
'terminal.dragDrop.localMessage': 'Пути к файлам будут вставлены в терминал',
|
||||
'terminal.dragDrop.remoteTitle': 'Перетащите для загрузки файлов',
|
||||
'terminal.dragDrop.remoteMessage': 'Файлы будут загружены через SFTP',
|
||||
'terminal.dragDrop.remoteZmodemMessage': 'Файлы будут загружены через ZMODEM (PTY)',
|
||||
'terminal.dragDrop.remoteSftpMessage': 'Файлы будут загружены через SFTP',
|
||||
'terminal.dragDrop.noFiles': 'Нет файлов для загрузки',
|
||||
'terminal.dragDrop.notConnected': 'Нельзя перетащить файлы — терминал не подключён',
|
||||
'terminal.dragDrop.errorTitle': 'Ошибка перетаскивания',
|
||||
'terminal.dragDrop.errorMessage': 'Не удалось обработать перетащенные файлы',
|
||||
@@ -91,13 +123,33 @@ 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': 'Переподключиться',
|
||||
'terminal.menu.sendYmodem': 'Отправить через YMODEM',
|
||||
'terminal.menu.receiveYmodem': 'Получить через YMODEM',
|
||||
'terminal.menu.splitHorizontal': 'Разделить по горизонтали',
|
||||
'terminal.menu.splitVertical': 'Разделить по вертикали',
|
||||
'terminal.menu.clearBuffer': 'Очистить буфер',
|
||||
'terminal.menu.closeTerminal': 'Закрыть терминал',
|
||||
'terminal.menu.rename': 'Переименовать',
|
||||
'terminal.menu.detach': 'Открепить из рабочей области',
|
||||
'terminal.menu.detachSession': 'Открепить {name}',
|
||||
'terminal.ymodem.selectFile': 'Выберите файл для отправки',
|
||||
'terminal.ymodem.allFiles': 'Все файлы',
|
||||
'terminal.ymodem.started': 'YMODEM отправляет {fileName}',
|
||||
'terminal.ymodem.complete': 'YMODEM отправил {fileName}',
|
||||
'terminal.ymodem.failed': 'Не удалось отправить через YMODEM',
|
||||
'terminal.ymodem.selectReceiveDirectory': 'Выберите папку для полученных файлов',
|
||||
'terminal.ymodem.receiveStarted': 'YMODEM получает...',
|
||||
'terminal.ymodem.receiveComplete': 'YMODEM получил {fileName}',
|
||||
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM получил файлов: {count}',
|
||||
'terminal.ymodem.receiveEmpty': 'Файлы YMODEM не получены',
|
||||
'terminal.ymodem.receiveFailed': 'Не удалось получить через YMODEM',
|
||||
'terminal.ymodem.unavailable': 'YMODEM недоступен',
|
||||
'terminal.selection.addToAI': 'Добавить в чат',
|
||||
'terminal.selection.addToAIDesc': 'Прикрепить выбранный вывод терминала к черновику AI',
|
||||
'terminal.auth.password': 'Пароль',
|
||||
'terminal.auth.sshKey': 'SSH-ключ',
|
||||
'terminal.auth.username': 'Имя пользователя',
|
||||
@@ -507,6 +559,8 @@ export const ruTerminalMessages: Messages = {
|
||||
'tabs.logPrefix': 'Журнал:',
|
||||
'tabs.logLocal': 'Локальный',
|
||||
'tabs.copyTab': 'Копировать вкладку',
|
||||
'tabs.copyTabToNewWindow': 'Копировать вкладку в новое окно',
|
||||
'tabs.copyTabToNewWindowFailed': 'Не удалось открыть вкладку в новом окне',
|
||||
'tabs.closeOthers': 'Закрыть остальные',
|
||||
'tabs.closeToRight': 'Закрыть вкладки справа',
|
||||
'tabs.closeAll': 'Закрыть все',
|
||||
@@ -532,6 +586,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': 'История оболочки',
|
||||
|
||||
@@ -158,6 +158,7 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.filter.placeholder': 'Фильтр по имени файла...',
|
||||
'sftp.bookmark.add': 'Добавить путь в закладки',
|
||||
'sftp.bookmark.remove': 'Удалить закладку',
|
||||
'sftp.bookmark.list': 'Закладки путей',
|
||||
'sftp.bookmark.addGlobal': '+Глобальная',
|
||||
'sftp.bookmark.addGlobalTooltip': 'Сохранить как глобальную закладку (общую для всех хостов)',
|
||||
'sftp.bookmark.empty': 'Пока нет закладок',
|
||||
@@ -185,9 +186,14 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.moveTo.pathNotFound': 'Каталог не найден или недоступен',
|
||||
'sftp.context.download': 'Скачать',
|
||||
'sftp.context.copyToOtherPane': 'Копировать в другую панель',
|
||||
'sftp.copyCurrentPath': 'Копировать текущий путь',
|
||||
'sftp.copyCurrentPath.success': 'Текущий путь скопирован',
|
||||
'sftp.copyCurrentPath.error': 'Не удалось скопировать текущий путь',
|
||||
'sftp.viewMode.label': 'Режим просмотра',
|
||||
'sftp.viewMode.list': 'Список',
|
||||
'sftp.viewMode.tree': 'Дерево',
|
||||
'sftp.viewMode.switchToList': 'Переключиться на список',
|
||||
'sftp.viewMode.switchToTree': 'Переключиться на дерево',
|
||||
'sftp.tree.loadError': 'Не удалось загрузить каталог',
|
||||
'sftp.tree.loading': 'Загрузка...',
|
||||
'sftp.kind.folder': 'Папка',
|
||||
@@ -232,6 +238,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',
|
||||
@@ -287,6 +296,8 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.tabs.addTab': 'Добавить новую вкладку',
|
||||
'sftp.tabs.closeTab': 'Закрыть вкладку',
|
||||
'sftp.tabs.newTab': 'Новая вкладка',
|
||||
'sftp.tabs.copyDefaultPath': 'Копировать вкладку (путь по умолчанию)',
|
||||
'sftp.tabs.copyCurrentPath': 'Копировать и перейти к текущему пути',
|
||||
'sftp.conflict.title': 'Конфликт файлов',
|
||||
'sftp.conflict.desc': 'В месте назначения уже существует файл с таким именем',
|
||||
'sftp.conflict.alreadyExistsSuffix': 'уже существует',
|
||||
@@ -310,6 +321,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 +394,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. Настройки конкретного хоста имеют приоритет.',
|
||||
@@ -486,7 +502,52 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.section.portCredentials': 'Порт и учётные данные',
|
||||
'hostDetails.section.appearance': 'Внешний вид',
|
||||
'hostDetails.distro.title': 'Дистрибутив Linux',
|
||||
'hostDetails.distro.desc': 'Автоопределение при подключении или ручное переопределение значка дистрибутива.',
|
||||
'hostDetails.distro.desc': 'Управляет автоматическим значком хоста. Свой значок хоста переопределяет это отображение.',
|
||||
'hostDetails.icon.title': 'Значок хоста',
|
||||
'hostDetails.icon.desc': 'Используйте автоматический значок дистрибутива с отдельным цветом или выберите встроенный значок.',
|
||||
'hostDetails.icon.mode.auto': 'Авто',
|
||||
'hostDetails.icon.mode.custom': 'Свой',
|
||||
'hostDetails.icon.reset': 'Сбросить значок',
|
||||
'hostDetails.icon.showLibrary': 'Показать библиотеку значков',
|
||||
'hostDetails.icon.hideLibrary': 'Скрыть библиотеку значков',
|
||||
'hostDetails.icon.autoUsesDistro': 'Использует значок дистрибутива Linux и выбранный цвет для этого хоста.',
|
||||
'hostDetails.icon.customOverridesDistro': 'Встроенный значок заменяет значок дистрибутива Linux для этого хоста.',
|
||||
'hostDetails.icon.option.server': 'Сервер',
|
||||
'hostDetails.icon.option.terminal': 'Терминал',
|
||||
'hostDetails.icon.option.database': 'База данных',
|
||||
'hostDetails.icon.option.cloud': 'Облако',
|
||||
'hostDetails.icon.option.router': 'Маршрутизатор',
|
||||
'hostDetails.icon.option.shield': 'Защита',
|
||||
'hostDetails.icon.option.code': 'Код',
|
||||
'hostDetails.icon.option.box': 'Узел',
|
||||
'hostDetails.icon.option.globe': 'Глобус',
|
||||
'hostDetails.icon.option.cpu': 'CPU',
|
||||
'hostDetails.icon.option.hard-drive': 'Хранилище',
|
||||
'hostDetails.icon.option.network': 'Сеть',
|
||||
'hostDetails.icon.option.wifi': 'Wi-Fi',
|
||||
'hostDetails.icon.option.lock': 'Замок',
|
||||
'hostDetails.icon.option.key': 'Ключ',
|
||||
'hostDetails.icon.option.monitor': 'Монитор',
|
||||
'hostDetails.icon.option.container': 'Контейнер',
|
||||
'hostDetails.icon.option.activity': 'Активность',
|
||||
'hostDetails.icon.option.zap': 'Быстрый',
|
||||
'hostDetails.icon.option.server-cog': 'Настройки сервера',
|
||||
'hostDetails.icon.color.blue': 'Синий',
|
||||
'hostDetails.icon.color.green': 'Зеленый',
|
||||
'hostDetails.icon.color.red': 'Красный',
|
||||
'hostDetails.icon.color.amber': 'Янтарный',
|
||||
'hostDetails.icon.color.purple': 'Фиолетовый',
|
||||
'hostDetails.icon.color.cyan': 'Голубой',
|
||||
'hostDetails.icon.color.orange': 'Оранжевый',
|
||||
'hostDetails.icon.color.slate': 'Серый',
|
||||
'hostDetails.icon.color.violet': 'Фиолетово-синий',
|
||||
'hostDetails.icon.color.pink': 'Розовый',
|
||||
'hostDetails.icon.color.rose': 'Розово-красный',
|
||||
'hostDetails.icon.color.lime': 'Лаймовый',
|
||||
'hostDetails.icon.color.teal': 'Бирюзовый',
|
||||
'hostDetails.icon.color.sky': 'Небесный',
|
||||
'hostDetails.icon.color.indigo': 'Индиго',
|
||||
'hostDetails.icon.color.zinc': 'Цинковый',
|
||||
'hostDetails.distro.mode': 'Источник',
|
||||
'hostDetails.distro.mode.auto': 'Автоопределение',
|
||||
'hostDetails.distro.mode.manual': 'Ручное переопределение',
|
||||
@@ -506,6 +567,8 @@ 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.openeuler': 'openEuler',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.distro.option.cisco': 'Cisco',
|
||||
@@ -550,6 +613,8 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.deviceType.warning': 'Команды AI-агента будут отправляться напрямую без отслеживания кода выхода. Включайте только для устройств, на которых нет стандартной оболочки.',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH-алгоритмы',
|
||||
'hostDetails.section.terminalBehavior': 'Поведение терминала',
|
||||
'hostDetails.lineTimestamps': 'Показывать время вывода',
|
||||
'hostDetails.lineTimestamps.desc': 'Показывать локальное время рядом с видимыми строками вывода для этого хоста, не изменяя текст терминала.',
|
||||
'hostDetails.legacyAlgorithms': 'Разрешить устаревшие алгоритмы',
|
||||
'hostDetails.legacyAlgorithms.desc': 'Включить устаревшие SSH-алгоритмы (diffie-hellman-group1, ssh-dss, 3des-cbc и т. д.) для подключения к старому сетевому оборудованию.',
|
||||
'hostDetails.legacyAlgorithms.warning': 'У этих алгоритмов есть известные слабые места безопасности. Включайте только для устаревших устройств, которые не поддерживают современную криптографию.',
|
||||
@@ -577,12 +642,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 +661,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': 'Переменные окружения',
|
||||
|
||||
77
application/i18n/locales/settingsLocales.test.ts
Normal file
77
application/i18n/locales/settingsLocales.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
|
||||
import { HOST_ICON_COLORS, HOST_ICON_IDS } from "../../../domain/hostIcon.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
import ru from "./ru.ts";
|
||||
|
||||
const LOCALIZED_SETTINGS_LOCALES = [
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "ru", messages: ru },
|
||||
];
|
||||
|
||||
test("localized settings include names for every default shortcut", () => {
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = DEFAULT_KEY_BINDINGS
|
||||
.map((binding) => `settings.shortcuts.binding.${binding.id}`)
|
||||
.filter((key) => !locale.messages[key]);
|
||||
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing shortcut labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include workspace focus indicator labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.section.workspaceFocus",
|
||||
"settings.terminal.workspaceFocus.style",
|
||||
"settings.terminal.workspaceFocus.style.desc",
|
||||
"settings.terminal.workspaceFocus.dim",
|
||||
"settings.terminal.workspaceFocus.border",
|
||||
];
|
||||
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing workspace focus labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include terminal font weight option labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.font.weight.thin",
|
||||
"settings.terminal.font.weight.extraLight",
|
||||
"settings.terminal.font.weight.light",
|
||||
"settings.terminal.font.weight.normal",
|
||||
"settings.terminal.font.weight.medium",
|
||||
"settings.terminal.font.weight.semiBold",
|
||||
"settings.terminal.font.weight.bold",
|
||||
"settings.terminal.font.weight.extraBold",
|
||||
"settings.terminal.font.weight.black",
|
||||
];
|
||||
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing font weight labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized vault messages include host icon labels", () => {
|
||||
const keys = [
|
||||
"hostDetails.icon.title",
|
||||
"hostDetails.icon.desc",
|
||||
"hostDetails.icon.mode.auto",
|
||||
"hostDetails.icon.mode.custom",
|
||||
"hostDetails.icon.reset",
|
||||
"hostDetails.icon.showLibrary",
|
||||
"hostDetails.icon.hideLibrary",
|
||||
"hostDetails.icon.autoUsesDistro",
|
||||
"hostDetails.icon.customOverridesDistro",
|
||||
...HOST_ICON_IDS.map((id) => `hostDetails.icon.option.${id}`),
|
||||
...HOST_ICON_COLORS.map((color) => `hostDetails.icon.color.${color.id}`),
|
||||
];
|
||||
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing host icon labels`);
|
||||
}
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { zhCNCoreMessages } from './zh-CN/core';
|
||||
import { zhCNVaultMessages } from './zh-CN/vault';
|
||||
import { zhCNTerminalMessages } from './zh-CN/terminal';
|
||||
import { zhCNAiMessages } from './zh-CN/ai';
|
||||
import { zhCnSystemManagerMessages } from './zh-CN/systemManager';
|
||||
|
||||
export type { Messages } from './types';
|
||||
|
||||
@@ -11,6 +12,7 @@ const zhCN: Messages = {
|
||||
...zhCNVaultMessages,
|
||||
...zhCNTerminalMessages,
|
||||
...zhCNAiMessages,
|
||||
...zhCnSystemManagerMessages,
|
||||
};
|
||||
|
||||
export default zhCN;
|
||||
|
||||
@@ -3,9 +3,11 @@ import type { Messages } from '../types';
|
||||
export const zhCNAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 设置',
|
||||
'ai.chat.preparing': '准备中…',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': '配置 AI 提供商、Agent 和安全设置',
|
||||
'ai.providers': '提供商',
|
||||
'ai.agents': 'Agent',
|
||||
'ai.providers.empty': '尚未配置提供商。添加一个提供商以开始使用。',
|
||||
'ai.providers.add': '添加提供商',
|
||||
'ai.providers.active': '活跃',
|
||||
@@ -34,6 +36,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 +55,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 +89,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 +99,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': '未找到',
|
||||
@@ -99,13 +108,61 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.copilot.customPathPlaceholder': '例如 /usr/local/bin/copilot',
|
||||
'ai.copilot.check': '检查',
|
||||
|
||||
// AI Cursor SDK
|
||||
'ai.cursor.title': 'Cursor',
|
||||
'ai.cursor.description': '使用 Cursor SDK。',
|
||||
'ai.cursor.detecting': '检测中...',
|
||||
'ai.cursor.detected': '可用',
|
||||
'ai.cursor.notFound': '不可用',
|
||||
'ai.cursor.path': '运行环境:',
|
||||
'ai.cursor.notFoundHint': '填写 API Key 后即可使用。',
|
||||
'ai.cursor.notInstalledHint': '未检测到 Cursor SDK。',
|
||||
'ai.cursor.installStatus': 'Cursor SDK',
|
||||
'ai.cursor.installed': '已检测到',
|
||||
'ai.cursor.notInstalled': '未检测到',
|
||||
'ai.cursor.apiKeyStatus': 'API Key',
|
||||
'ai.cursor.apiKeyConfigured': '已填写',
|
||||
'ai.cursor.apiKeyMissing': '未填写',
|
||||
'ai.cursor.apiKeyFromEnv': '来自环境变量',
|
||||
'ai.cursor.apiKey': 'API Key',
|
||||
'ai.cursor.apiKeyPlaceholder': '输入 Cursor API Key',
|
||||
'ai.cursor.apiKeyPlaceholder.env': '已使用 CURSOR_API_KEY;填写后会覆盖',
|
||||
'ai.cursor.apiKeyEnvHint': '已检测到本机 CURSOR_API_KEY。留空即可继续使用,填写保存后会覆盖它。',
|
||||
'ai.cursor.apiKeyOverrideHint': '当前优先使用这里保存的 Key;清空保存后会回到 CURSOR_API_KEY。',
|
||||
'ai.cursor.saveApiKey': '保存',
|
||||
'ai.cursor.saved': '已保存',
|
||||
'ai.cursor.showApiKey': '显示 API Key',
|
||||
'ai.cursor.hideApiKey': '隐藏 API Key',
|
||||
'ai.cursor.customPathPlaceholder': '例如 /usr/local/bin/cursor',
|
||||
'ai.cursor.check': '检查',
|
||||
|
||||
// AI CodeBuddy Code
|
||||
'ai.codebuddy.title': 'CodeBuddy Code',
|
||||
'ai.codebuddy.description': '通过官方 Agent SDK(`@tencent-ai/agent-sdk`)接入 CodeBuddy Code。检测到后即可作为外部编程 Agent 使用。',
|
||||
'ai.codebuddy.detecting': '检测中...',
|
||||
'ai.codebuddy.detected': '已检测到',
|
||||
'ai.codebuddy.notFound': '未找到',
|
||||
'ai.codebuddy.path': '路径:',
|
||||
'ai.codebuddy.notFoundHint': '在 PATH 中未找到 codebuddy。请安装或在下方指定可执行文件路径。',
|
||||
'ai.codebuddy.customPathPlaceholder': '例如 /usr/local/bin/codebuddy',
|
||||
'ai.codebuddy.check': '检查',
|
||||
'ai.codebuddy.configSection': '认证与配置(可选)',
|
||||
'ai.codebuddy.internetEnv': '网络环境',
|
||||
'ai.codebuddy.internetEnv.default': '默认(海外)',
|
||||
'ai.codebuddy.internetEnv.internal': 'Internal',
|
||||
'ai.codebuddy.internetEnv.ioa': 'IOA',
|
||||
'ai.codebuddy.internetEnv.hint': '设置 CODEBUDDY_INTERNET_ENVIRONMENT —— 受限网络环境请选择 Internal 或 IOA。',
|
||||
'ai.codebuddy.envVars': '环境变量',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': '每行一个 KEY=VALUE,传给 CodeBuddy agent。可在此设置 CODEBUDDY_API_KEY 或 CODEBUDDY_AUTH_TOKEN 完成认证。明文存在本地。',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': '默认 Agent',
|
||||
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
|
||||
'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',
|
||||
@@ -120,6 +177,29 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.userSkills.status.ready': '正常',
|
||||
'ai.userSkills.status.warning': '警告',
|
||||
|
||||
// AI Quick Messages
|
||||
'ai.quickMessages.title': '快捷消息',
|
||||
'ai.quickMessages.description': '创建常用提示词,在 AI 聊天框输入 / 或点击快捷按钮即可插入到输入框。与用户 Skills 不同,快捷消息会直接填入消息内容。',
|
||||
'ai.quickMessages.add': '添加快捷消息',
|
||||
'ai.quickMessages.createTitle': '新建快捷消息',
|
||||
'ai.quickMessages.editTitle': '编辑快捷消息',
|
||||
'ai.quickMessages.name': '名称',
|
||||
'ai.quickMessages.name.placeholder': '例如:检查磁盘空间',
|
||||
'ai.quickMessages.slug': '命令',
|
||||
'ai.quickMessages.slug.placeholder': 'disk-check',
|
||||
'ai.quickMessages.descriptionField': '说明(可选)',
|
||||
'ai.quickMessages.descriptionField.placeholder': '简短描述这条快捷消息的用途',
|
||||
'ai.quickMessages.content': '消息内容',
|
||||
'ai.quickMessages.content.placeholder': '输入选择后要插入的完整提示词...',
|
||||
'ai.quickMessages.empty': '还没有快捷消息。添加几条常用提示,聊天时就能一键插入。',
|
||||
'ai.quickMessages.confirmDelete': '确定删除快捷消息「{name}」吗?',
|
||||
'ai.quickMessages.error.nameRequired': '请填写名称。',
|
||||
'ai.quickMessages.error.invalidSlug': '命令只能包含小写字母、数字和连字符。',
|
||||
'ai.quickMessages.error.contentRequired': '请填写消息内容。',
|
||||
'ai.quickMessages.error.slugTaken': '该命令已被其他快捷消息使用。',
|
||||
'ai.quickMessages.error.slugConflictsWithSkill': '该命令与用户 Skill「/{slug}」冲突,请换一个命令。',
|
||||
'ai.quickMessages.error.maxItems': '最多只能保存 {max} 条快捷消息。',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
|
||||
'ai.chat.toolDenied': '操作已被用户拒绝。',
|
||||
@@ -167,6 +247,9 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.daysAgo': '{n}天前',
|
||||
'ai.chat.newChat': '新对话',
|
||||
'ai.chat.allSessions': '所有会话',
|
||||
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
|
||||
'ai.chat.usedTools': '已使用 {n} 个工具',
|
||||
'ai.chat.loadMoreSessions': '加载更多会话(还有 {n} 条)',
|
||||
'ai.chat.noSessions': '没有历史会话',
|
||||
'ai.chat.retryHint': '你可以重新发送消息来重试。',
|
||||
'ai.chat.approvalTimeout': '工具审批已超时(5 分钟)。你可以重新发送消息来重试。',
|
||||
@@ -176,6 +259,18 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.menuImage': '图片',
|
||||
'ai.chat.menuMentionHost': '提及主机',
|
||||
'ai.chat.menuUserSkills': '用户 Skills',
|
||||
'ai.chat.menuSlashCommands': '快捷命令',
|
||||
'ai.chat.slashCommands': '快捷命令',
|
||||
'ai.chat.slashQuickMessages': '快捷消息',
|
||||
'ai.chat.slashUserSkills': '用户 Skills',
|
||||
'ai.chat.quickMessages': '快捷命令',
|
||||
'ai.chat.slashNoResults': '没有匹配的命令',
|
||||
'ai.chat.slashEmptyHint': '可在 设置 → AI → 快捷消息 中添加常用提示词。',
|
||||
|
||||
// AI 聊天快捷入口
|
||||
'ai.chatShortcuts.title': '聊天快捷入口',
|
||||
'ai.chatShortcuts.selectionAction': '选中终端内容时显示“添加到对话”',
|
||||
'ai.chatShortcuts.selectionAction.description': '在终端里选中文本后显示 AI 快捷按钮。',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
|
||||
@@ -198,39 +293,60 @@ 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': '添加终端',
|
||||
'terminal.layer.switchToSplitView': '切换到分屏视图',
|
||||
'terminal.layer.sftp': '文件传输',
|
||||
'terminal.layer.scripts': '脚本',
|
||||
'terminal.layer.history': '命令历史',
|
||||
'terminal.layer.theme': '主题',
|
||||
'terminal.layer.aiChat': 'AI 助手',
|
||||
'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': '没有匹配的主机',
|
||||
'terminal.layer.hostTree.details.host': '主机',
|
||||
'terminal.layer.hostTree.details.user': '用户',
|
||||
'terminal.layer.hostTree.details.port': '端口',
|
||||
'terminal.layer.hostTree.details.protocol': '协议',
|
||||
'terminal.layer.hostTree.details.group': '分组',
|
||||
'terminal.layer.hostTree.details.tags': '标签',
|
||||
'terminal.layer.hostTree.details.lastConnected': '最近连接',
|
||||
'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}',
|
||||
|
||||
@@ -29,6 +29,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'common.right': '右侧',
|
||||
'common.more': '更多',
|
||||
'common.selectAHost': '选择主机',
|
||||
'sort.manual': '手动顺序',
|
||||
'sort.az': 'A-z',
|
||||
'sort.za': 'Z-a',
|
||||
'sort.newest': '从新到旧',
|
||||
@@ -143,8 +144,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': '切换窗口',
|
||||
@@ -196,6 +210,8 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': '开启后,主机库根目录的主机列表只显示没有分组的主机,已分组主机请从左侧分组进入查看。',
|
||||
'settings.vault.showSftpTab': '显示 SFTP 标签页',
|
||||
'settings.vault.showSftpTabDesc': '在顶部标签栏显示独立的 SFTP 视图。关闭后可改用会话内左侧的 SFTP 侧栏。',
|
||||
'settings.vault.showHostTreeSidebar': '显示主机列表侧栏',
|
||||
'settings.vault.showHostTreeSidebarDesc': '在终端和编辑器标签页显示主机列表侧栏及顶部开关。',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': '发现新版本',
|
||||
@@ -211,6 +227,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 +251,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、top-tabs-host-tree-toggle、top-tabs-quick-switcher-toggle。',
|
||||
'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/* 隐藏顶部标签栏里的主机列表开关 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隐藏打开快速切换器的加号按钮 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\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 +355,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 +371,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': '创建根分组',
|
||||
@@ -424,6 +444,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.hosts.connectSelected': '连接 ({count})',
|
||||
'vault.hosts.connectMultiple.success': '正在连接 {count} 个主机',
|
||||
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
|
||||
'vault.hosts.errors.nameRequired': '主机名称不能为空。',
|
||||
'vault.hosts.empty.title': '设置你的主机',
|
||||
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
|
||||
|
||||
@@ -524,6 +545,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.filter.placeholder': '按文件名筛选...',
|
||||
'sftp.bookmark.add': '收藏此路径',
|
||||
'sftp.bookmark.remove': '取消收藏',
|
||||
'sftp.bookmark.list': '收藏路径',
|
||||
'sftp.bookmark.addGlobal': '+全局',
|
||||
'sftp.bookmark.addGlobalTooltip': '保存为全局收藏(所有主机共享)',
|
||||
'sftp.bookmark.empty': '暂无收藏路径',
|
||||
@@ -551,9 +573,14 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.moveTo.pathNotFound': '目录不存在或无法访问',
|
||||
'sftp.context.download': '下载',
|
||||
'sftp.context.copyToOtherPane': '复制到另一侧',
|
||||
'sftp.copyCurrentPath': '复制当前路径',
|
||||
'sftp.copyCurrentPath.success': '已复制当前路径',
|
||||
'sftp.copyCurrentPath.error': '无法复制当前路径',
|
||||
'sftp.viewMode.label': '视图模式',
|
||||
'sftp.viewMode.list': '列表视图',
|
||||
'sftp.viewMode.tree': '树形视图',
|
||||
'sftp.viewMode.switchToList': '切换到列表视图',
|
||||
'sftp.viewMode.switchToTree': '切换到树形视图',
|
||||
'sftp.tree.loadError': '加载目录失败',
|
||||
'sftp.tree.loading': '加载中...',
|
||||
'sftp.kind.folder': '文件夹',
|
||||
@@ -598,6 +625,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',
|
||||
|
||||
181
application/i18n/locales/zh-CN/systemManager.ts
Normal file
181
application/i18n/locales/zh-CN/systemManager.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCnSystemManagerMessages: Messages = {
|
||||
'terminal.layer.system': '系统',
|
||||
|
||||
'systemManager.noSession': '没有活动的终端会话。',
|
||||
'systemManager.notConnected': '请先连接到主机以管理进程与服务。',
|
||||
'systemManager.empty': '暂无数据。',
|
||||
'systemManager.tabs.processes': '进程',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.popup.loading': '正在打开终端…',
|
||||
'systemManager.popup.startupFailed': '启动命令未成功。请确认目标仍然可用后重试。',
|
||||
|
||||
'systemManager.errors.loadProcesses': '加载进程列表失败',
|
||||
'systemManager.errors.loadTmux': '加载 tmux 会话失败',
|
||||
'systemManager.errors.loadTmuxWindows': '加载 tmux 窗口失败',
|
||||
'systemManager.errors.loadTmuxPanes': '加载 tmux 面板失败',
|
||||
'systemManager.errors.loadTmuxClients': '加载 tmux 客户端失败',
|
||||
'systemManager.errors.actionFailed': '操作失败',
|
||||
'systemManager.errors.loadDocker': '加载容器列表失败',
|
||||
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
|
||||
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
|
||||
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
|
||||
|
||||
'systemManager.processes.search': '搜索进程…',
|
||||
'systemManager.processes.command': '命令',
|
||||
'systemManager.processes.user': '用户',
|
||||
'systemManager.processes.term': '终止',
|
||||
'systemManager.processes.kill': '强杀',
|
||||
'systemManager.processes.stop': '暂停 (SIGSTOP)',
|
||||
'systemManager.processes.cont': '继续 (SIGCONT)',
|
||||
'systemManager.processes.hup': '挂断 (SIGHUP)',
|
||||
'systemManager.processes.renice': '调整优先级',
|
||||
'systemManager.processes.renicePrompt': 'Nice 值 (-20 到 19)',
|
||||
'systemManager.processes.reniceInvalid': 'Nice 值必须在 -20 到 19 之间',
|
||||
'systemManager.processes.confirmKill': '向进程 {{pid}} 发送 SIGKILL?',
|
||||
'systemManager.processes.confirmSignal': '向进程 {{pid}} 发送 SIG{{signal}}?',
|
||||
'systemManager.processes.filter.all': '全部',
|
||||
'systemManager.processes.filter.running': '运行中',
|
||||
'systemManager.processes.ppid': '父进程 PID',
|
||||
'systemManager.processes.rss': '物理内存',
|
||||
'systemManager.processes.vsz': '虚拟内存',
|
||||
'systemManager.processes.elapsed': '运行时长',
|
||||
'systemManager.processes.stat': '状态',
|
||||
'systemManager.processes.meta': '{{count}} 个进程',
|
||||
'systemManager.processes.loading': '正在加载进程…',
|
||||
'systemManager.processes.loadingMore': '正在显示更多进程…',
|
||||
'systemManager.processes.state.running': '运行中',
|
||||
'systemManager.processes.state.sleeping': '睡眠',
|
||||
'systemManager.processes.state.stopped': '已暂停',
|
||||
'systemManager.processes.state.zombie': '僵尸',
|
||||
'systemManager.processes.sort.cpu': 'CPU',
|
||||
'systemManager.processes.sort.mem': '内存',
|
||||
'systemManager.processes.sort.pid': 'PID',
|
||||
'systemManager.processes.sort.command': '命令',
|
||||
'systemManager.processes.sort.user': '用户',
|
||||
|
||||
'systemManager.common.dismiss': '关闭',
|
||||
'systemManager.common.checkingAvailability': '正在检查可用状态…',
|
||||
'systemManager.common.loading': '正在加载…',
|
||||
'systemManager.common.loadingDetails': '正在加载详情…',
|
||||
'systemManager.common.loadingStats': '正在加载性能数据…',
|
||||
|
||||
'systemManager.tmux.new': '新建',
|
||||
'systemManager.tmux.search': '搜索会话…',
|
||||
'systemManager.tmux.newSessionTitle': '新建 tmux 会话',
|
||||
'systemManager.tmux.newSessionDesc': '为会话命名,并可选在启动时执行脚本。',
|
||||
'systemManager.tmux.newSessionTabCustom': '自定义命令',
|
||||
'systemManager.tmux.newSessionTabSnippet': '从代码片段',
|
||||
'systemManager.tmux.pickSnippet': '从代码片段选择',
|
||||
'systemManager.tmux.pickSnippetEmpty': '暂无代码片段,可在脚本侧栏或仓库中添加。',
|
||||
'systemManager.tmux.selectedSnippet': '已选片段:{{label}}',
|
||||
'systemManager.tmux.newSessionName': '会话名称',
|
||||
'systemManager.tmux.newSessionCommand': '启动命令',
|
||||
'systemManager.tmux.newSessionCommandPlaceholder': '例如 htop 或 npm run dev(可选)',
|
||||
'systemManager.tmux.newSessionCommandHint': '留空则创建默认 shell 会话。',
|
||||
'systemManager.tmux.creating': '创建中…',
|
||||
'systemManager.tmux.newSessionPlaceholder': 'my-session',
|
||||
'systemManager.tmux.newSessionRequired': '请先输入会话名称',
|
||||
'systemManager.tmux.empty': '没有 tmux 会话',
|
||||
'systemManager.tmux.attach': '附加',
|
||||
'systemManager.tmux.attached': '已附加',
|
||||
'systemManager.tmux.detached': '未附加',
|
||||
'systemManager.tmux.windows': '{{count}} 个窗口',
|
||||
'systemManager.tmux.created': '创建时间',
|
||||
'systemManager.tmux.activity': '活动时间',
|
||||
'systemManager.tmux.rename': '重命名',
|
||||
'systemManager.tmux.detach': '全部分离',
|
||||
'systemManager.tmux.killSession': '结束会话',
|
||||
'systemManager.tmux.killServer': '结束 tmux 服务',
|
||||
'systemManager.tmux.loadingDetails': '正在加载详情…',
|
||||
'systemManager.tmux.clients': '已附加客户端',
|
||||
'systemManager.tmux.windowList': '窗口',
|
||||
'systemManager.tmux.newWindow': '新建窗口',
|
||||
'systemManager.tmux.newWindowPlaceholder': '窗口名称(可选)',
|
||||
'systemManager.tmux.noWindows': '没有窗口',
|
||||
'systemManager.tmux.unavailable': '此主机未检测到 tmux',
|
||||
'systemManager.docker.unavailable': '此主机未检测到 Docker',
|
||||
'systemManager.tmux.windowsMismatch': '会话显示有 {{count}} 个窗口,但 list-windows 未返回任何窗口',
|
||||
'systemManager.tmux.lastCommand': '最后执行的命令:{{command}}',
|
||||
'systemManager.tmux.noPanes': '没有面板',
|
||||
'systemManager.tmux.panes': '{{count}} 个面板',
|
||||
'systemManager.tmux.active': '当前',
|
||||
'systemManager.tmux.unnamedWindow': '未命名窗口',
|
||||
'systemManager.tmux.unnamedPane': '未命名面板',
|
||||
'systemManager.tmux.attachWindow': '附加到窗口',
|
||||
'systemManager.tmux.selectWindow': '选中窗口',
|
||||
'systemManager.tmux.killWindow': '关闭窗口',
|
||||
'systemManager.tmux.killPane': '关闭面板',
|
||||
'systemManager.tmux.splitHorizontal': '水平分屏',
|
||||
'systemManager.tmux.splitVertical': '垂直分屏',
|
||||
'systemManager.tmux.sendKeys': '发送按键',
|
||||
'systemManager.tmux.sendKeysTo': '向窗口 {{window}} 面板 {{pane}} 发送按键',
|
||||
'systemManager.tmux.sendKeysPlaceholder': '命令或文本…',
|
||||
'systemManager.tmux.renameSessionPrompt': '重命名会话',
|
||||
'systemManager.tmux.renameWindowPrompt': '重命名窗口',
|
||||
'systemManager.tmux.windowName': '窗口名称',
|
||||
'systemManager.tmux.confirmKillSession': '确定结束 tmux 会话「{{name}}」?',
|
||||
'systemManager.tmux.confirmDetachSession': '确定将所有客户端从「{{name}}」分离?',
|
||||
'systemManager.tmux.confirmKillWindow': '确定关闭窗口「{{name}}」?',
|
||||
'systemManager.tmux.confirmKillPane': '确定关闭面板 #{{index}}?',
|
||||
'systemManager.tmux.confirmKillServer': '确定结束 tmux 服务?所有会话将被终止。',
|
||||
'systemManager.tmux.meta': '{{count}} 个会话',
|
||||
|
||||
'systemManager.docker.title': '容器',
|
||||
'systemManager.docker.subTabs.containers': '容器',
|
||||
'systemManager.docker.subTabs.images': '镜像',
|
||||
'systemManager.docker.empty': '未找到容器',
|
||||
'systemManager.docker.imagesEmpty': '未找到镜像',
|
||||
'systemManager.docker.search': '搜索容器…',
|
||||
'systemManager.docker.searchImages': '搜索镜像…',
|
||||
'systemManager.docker.filter.all': '全部',
|
||||
'systemManager.docker.filter.running': '运行中',
|
||||
'systemManager.docker.filter.stopped': '已停止',
|
||||
'systemManager.docker.filter.paused': '已暂停',
|
||||
'systemManager.docker.shell': 'Shell',
|
||||
'systemManager.docker.logs': '日志',
|
||||
'systemManager.docker.details': '详情',
|
||||
'systemManager.docker.inspect': 'Inspect',
|
||||
'systemManager.docker.imageInspect': '镜像 Inspect',
|
||||
'systemManager.docker.confirmRemove': '确定删除此容器?',
|
||||
'systemManager.docker.confirmKill': '确定强制终止此容器?',
|
||||
'systemManager.docker.confirmRemoveImage': '确定删除镜像「{{name}}」?',
|
||||
'systemManager.docker.confirmPrune': '确定清理悬空镜像?',
|
||||
'systemManager.docker.confirmPruneAll': '确定清理所有未使用镜像?',
|
||||
'systemManager.docker.pause': '暂停',
|
||||
'systemManager.docker.unpause': '恢复',
|
||||
'systemManager.docker.restart': '重启',
|
||||
'systemManager.docker.kill': '强杀',
|
||||
'systemManager.docker.renamePrompt': '容器名称',
|
||||
'systemManager.docker.prune': '清理悬空',
|
||||
'systemManager.docker.pruneAll': '清理全部',
|
||||
'systemManager.docker.tag': '打标签',
|
||||
'systemManager.docker.tagRepoPrompt': '仓库名',
|
||||
'systemManager.docker.tagNamePrompt': '标签名',
|
||||
'systemManager.docker.meta': '{{count}} 个容器',
|
||||
'systemManager.docker.imagesMeta': '{{count}} 个镜像',
|
||||
'systemManager.docker.start': '启动',
|
||||
'systemManager.docker.stop': '停止',
|
||||
|
||||
'systemManager.inspect.status': '状态',
|
||||
'systemManager.inspect.image': '镜像',
|
||||
'systemManager.inspect.created': '创建时间',
|
||||
'systemManager.inspect.started': '启动时间',
|
||||
'systemManager.inspect.restartPolicy': '重启策略',
|
||||
'systemManager.inspect.command': '启动命令',
|
||||
'systemManager.inspect.ports': '端口映射',
|
||||
'systemManager.inspect.networks': '网络',
|
||||
'systemManager.inspect.mounts': '挂载',
|
||||
'systemManager.inspect.env': '环境变量',
|
||||
'systemManager.inspect.labels': '标签',
|
||||
'systemManager.inspect.tags': '镜像标签',
|
||||
'systemManager.inspect.digests': '摘要',
|
||||
'systemManager.inspect.size': '大小',
|
||||
'systemManager.inspect.platform': '平台',
|
||||
'systemManager.inspect.workdir': '工作目录',
|
||||
'systemManager.inspect.exposedPorts': '暴露端口',
|
||||
'systemManager.inspect.showRaw': 'JSON',
|
||||
'systemManager.inspect.hideRaw': '收起 JSON',
|
||||
};
|
||||
@@ -1,6 +1,31 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNTerminalMessages: Messages = {
|
||||
'terminal.sudoHint.pressEnter': '按 Enter 粘贴 sudo 密码',
|
||||
'terminal.menu.rename': '重命名',
|
||||
'terminal.toolbar.detach': '移出到独立标签',
|
||||
'terminal.menu.detach': '从工作区移出',
|
||||
'terminal.toolbar.timestampsEnable': '显示时间戳',
|
||||
'terminal.toolbar.timestampsDisable': '隐藏时间戳',
|
||||
'terminal.connection.protocol.et': 'EternalTerminal',
|
||||
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支持 Netcatty 的代理设置。请改用 SSH,或移除该主机的代理。',
|
||||
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支持一个跳板机。',
|
||||
// Command history side panel
|
||||
'history.scope.label': '历史范围',
|
||||
'history.tab.host': '主机',
|
||||
'history.tab.global': '全局',
|
||||
'history.searchPlaceholder': '搜索历史命令...',
|
||||
'history.loading': '正在读取远程历史...',
|
||||
'history.meta.count': '{count} 条',
|
||||
'history.empty.noSession': '请先打开一个远程会话以查看其命令历史。',
|
||||
'history.empty.unsupportedProtocol': '仅 SSH/Mosh/ET 会话支持命令历史。',
|
||||
'history.empty.noHistory': '该主机上未找到命令历史。',
|
||||
'history.empty.noGlobalHistory': '暂无全局命令历史。你执行的命令会记录在这里。',
|
||||
'history.action.refresh': '刷新',
|
||||
'history.action.retry': '重试',
|
||||
'history.action.paste': '粘贴到终端',
|
||||
'history.action.run': '在终端执行',
|
||||
'history.action.saveAsSnippet': '保存为代码片段',
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': '复制文件路径',
|
||||
'sftp.context.openWith': '打开方式...',
|
||||
@@ -76,6 +101,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': '列表视图',
|
||||
@@ -160,6 +190,15 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.font.size.desc': '终端文字大小',
|
||||
'settings.terminal.font.weight': '字重',
|
||||
'settings.terminal.font.weight.desc': '常规文本字重 (100-900)',
|
||||
'settings.terminal.font.weight.thin': '极细',
|
||||
'settings.terminal.font.weight.extraLight': '特细',
|
||||
'settings.terminal.font.weight.light': '细',
|
||||
'settings.terminal.font.weight.normal': '常规',
|
||||
'settings.terminal.font.weight.medium': '中等',
|
||||
'settings.terminal.font.weight.semiBold': '半粗',
|
||||
'settings.terminal.font.weight.bold': '粗',
|
||||
'settings.terminal.font.weight.extraBold': '特粗',
|
||||
'settings.terminal.font.weight.black': '黑体',
|
||||
'settings.terminal.font.weightBold': '粗体字重',
|
||||
'settings.terminal.font.weightBold.desc': '粗体文本字重 (100-900)',
|
||||
'settings.terminal.font.linePadding': '行间距',
|
||||
@@ -185,6 +224,11 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.copyOnSelect.desc': '自动复制选中的文本。在 tmux/vim 鼠标模式下,macOS 按住 Option,Windows/Linux 按住 Shift 拖选即可选中文本',
|
||||
'settings.terminal.behavior.middleClickPaste': '中键粘贴',
|
||||
'settings.terminal.behavior.middleClickPaste.desc': '中键点击时粘贴剪贴板内容',
|
||||
'settings.terminal.behavior.middleClick': '中键行为',
|
||||
'settings.terminal.behavior.middleClick.desc': '在终端中点击鼠标中键时执行的操作',
|
||||
'settings.terminal.behavior.middleClick.menu': '显示菜单',
|
||||
'settings.terminal.behavior.middleClick.paste': '粘贴',
|
||||
'settings.terminal.behavior.middleClick.disabled': '无动作',
|
||||
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
|
||||
@@ -253,6 +297,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': '起始目录',
|
||||
@@ -274,6 +321,15 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.serverStats.refreshInterval': '刷新间隔',
|
||||
'settings.terminal.serverStats.refreshInterval.desc': '服务器状态刷新的频率。',
|
||||
'settings.terminal.serverStats.seconds': '秒',
|
||||
'settings.terminal.section.systemManager': '系统管理',
|
||||
'settings.terminal.systemManager.processRefreshInterval': '进程列表刷新间隔',
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': '系统管理侧栏中进程列表的刷新频率。',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 会话刷新间隔',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 会话列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表刷新间隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 性能数据刷新间隔',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/内存/网络指标的刷新频率。',
|
||||
|
||||
// Settings > Terminal > Rendering
|
||||
'settings.terminal.section.rendering': '渲染',
|
||||
@@ -281,6 +337,13 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 DOM 渲染。更改将在新终端会话中生效。',
|
||||
'settings.terminal.rendering.auto': '自动',
|
||||
|
||||
// Settings > Terminal > Workspace Focus Indicator
|
||||
'settings.terminal.section.workspaceFocus': '工作区焦点提示',
|
||||
'settings.terminal.workspaceFocus.style': '焦点提示样式',
|
||||
'settings.terminal.workspaceFocus.style.desc': '在分屏视图中如何标识当前聚焦的窗格。',
|
||||
'settings.terminal.workspaceFocus.dim': '淡化未聚焦窗格',
|
||||
'settings.terminal.workspaceFocus.border': '为聚焦窗格显示边框',
|
||||
|
||||
// Settings > Terminal > Autocomplete
|
||||
'settings.terminal.section.autocomplete': '自动补全',
|
||||
'settings.terminal.autocomplete.enabled': '启用自动补全',
|
||||
@@ -297,6 +360,10 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.shortcuts.scheme.disabled': '禁用',
|
||||
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
|
||||
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
|
||||
'settings.shortcuts.disableTerminalFontZoom.label': '禁用终端缩放',
|
||||
'settings.shortcuts.disableTerminalFontZoom.desc': '关闭终端文字缩放快捷操作,包括 Cmd/Ctrl 加滚轮。',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '数字键跳过固定标签',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '开启后,Cmd/Ctrl+[1...9] 仅在终端、工作区、编辑器等可关闭标签页之间切换,不包括固定的 Vault 和 SFTP 标签页。',
|
||||
'settings.shortcuts.section.custom': '自定义快捷键',
|
||||
'settings.shortcuts.resetAll': '全部重置',
|
||||
'settings.shortcuts.recording': '请按键...',
|
||||
@@ -311,18 +378,25 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.shortcuts.binding.next-tab': '下一个标签页',
|
||||
'settings.shortcuts.binding.prev-tab': '上一个标签页',
|
||||
'settings.shortcuts.binding.close-tab': '关闭标签页',
|
||||
'settings.shortcuts.binding.close-session': '关闭会话窗格',
|
||||
'settings.shortcuts.binding.new-tab': '新建本地标签页',
|
||||
'settings.shortcuts.binding.copy': '从终端复制',
|
||||
'settings.shortcuts.binding.paste': '粘贴到终端',
|
||||
'settings.shortcuts.binding.paste-selection': '将选区粘贴到终端',
|
||||
'settings.shortcuts.binding.select-all': '全选终端内容',
|
||||
'settings.shortcuts.binding.clear-buffer': '清空终端缓冲区',
|
||||
'settings.shortcuts.binding.search-terminal': '打开终端搜索',
|
||||
'settings.shortcuts.binding.increase-terminal-font-size': '增大终端字号',
|
||||
'settings.shortcuts.binding.decrease-terminal-font-size': '减小终端字号',
|
||||
'settings.shortcuts.binding.reset-terminal-font-size': '重置终端字号',
|
||||
'settings.shortcuts.binding.move-focus': '在分屏间移动焦点',
|
||||
'settings.shortcuts.binding.split-horizontal': '水平分屏',
|
||||
'settings.shortcuts.binding.split-vertical': '垂直分屏',
|
||||
'settings.shortcuts.binding.toggle-pane-zoom': '切换窗格缩放',
|
||||
'settings.shortcuts.binding.open-hosts': '打开主机列表',
|
||||
'settings.shortcuts.binding.open-local': '打开本地终端',
|
||||
'settings.shortcuts.binding.open-sftp': '打开 SFTP',
|
||||
'settings.shortcuts.binding.open-settings': '打开设置',
|
||||
'settings.shortcuts.binding.port-forwarding': '打开端口转发',
|
||||
'settings.shortcuts.binding.command-palette': '打开命令面板',
|
||||
'settings.shortcuts.binding.quick-switch': '快速切换',
|
||||
@@ -338,10 +412,16 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.shortcuts.binding.sftp-delete': '删除文件',
|
||||
'settings.shortcuts.binding.sftp-refresh': '刷新',
|
||||
'settings.shortcuts.binding.sftp-new-folder': '新建文件夹',
|
||||
'settings.shortcuts.binding.sftp-open': '打开文件 / 进入目录',
|
||||
'settings.shortcuts.binding.sftp-go-parent': '转到上级目录',
|
||||
'settings.shortcuts.binding.sftp-navigate-to': '转到选中的目录',
|
||||
|
||||
// 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 +432,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 +550,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'tabs.logPrefix': '日志:',
|
||||
'tabs.logLocal': '本地',
|
||||
'tabs.copyTab': '复制标签页',
|
||||
'tabs.copyTabToNewWindow': '复制标签页到新窗口',
|
||||
'tabs.copyTabToNewWindowFailed': '无法在新窗口打开标签页',
|
||||
'tabs.closeOthers': '关闭其他标签',
|
||||
'tabs.closeToRight': '关闭右侧标签',
|
||||
'tabs.closeAll': '关闭所有标签',
|
||||
@@ -495,6 +577,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 历史',
|
||||
|
||||
@@ -45,7 +45,52 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.section.portCredentials': '端口与凭据',
|
||||
'hostDetails.section.appearance': '外观',
|
||||
'hostDetails.distro.title': 'Linux 发行版',
|
||||
'hostDetails.distro.desc': '可在连接后自动探测,也可以手动覆盖图标所用的发行版。',
|
||||
'hostDetails.distro.desc': '控制自动主机图标。自定义主机图标会覆盖此显示。',
|
||||
'hostDetails.icon.title': '主机图标',
|
||||
'hostDetails.icon.desc': '使用自动发行版图标并可单独改色,或选择内置图标。',
|
||||
'hostDetails.icon.mode.auto': '自动',
|
||||
'hostDetails.icon.mode.custom': '自定义',
|
||||
'hostDetails.icon.reset': '重置主机图标',
|
||||
'hostDetails.icon.showLibrary': '展开图标库',
|
||||
'hostDetails.icon.hideLibrary': '收起图标库',
|
||||
'hostDetails.icon.autoUsesDistro': '使用 Linux 发行版图标和所选颜色显示此主机。',
|
||||
'hostDetails.icon.customOverridesDistro': '内置图标会替换此主机的 Linux 发行版图标。',
|
||||
'hostDetails.icon.option.server': '服务器',
|
||||
'hostDetails.icon.option.terminal': '终端',
|
||||
'hostDetails.icon.option.database': '数据库',
|
||||
'hostDetails.icon.option.cloud': '云主机',
|
||||
'hostDetails.icon.option.router': '路由器',
|
||||
'hostDetails.icon.option.shield': '安全',
|
||||
'hostDetails.icon.option.code': '代码',
|
||||
'hostDetails.icon.option.box': '节点',
|
||||
'hostDetails.icon.option.globe': '公网',
|
||||
'hostDetails.icon.option.cpu': '计算',
|
||||
'hostDetails.icon.option.hard-drive': '存储',
|
||||
'hostDetails.icon.option.network': '网络',
|
||||
'hostDetails.icon.option.wifi': '无线',
|
||||
'hostDetails.icon.option.lock': '锁定',
|
||||
'hostDetails.icon.option.key': '密钥',
|
||||
'hostDetails.icon.option.monitor': '显示器',
|
||||
'hostDetails.icon.option.container': '容器',
|
||||
'hostDetails.icon.option.activity': '活动',
|
||||
'hostDetails.icon.option.zap': '高速',
|
||||
'hostDetails.icon.option.server-cog': '服务器设置',
|
||||
'hostDetails.icon.color.blue': '蓝色',
|
||||
'hostDetails.icon.color.green': '绿色',
|
||||
'hostDetails.icon.color.red': '红色',
|
||||
'hostDetails.icon.color.amber': '琥珀色',
|
||||
'hostDetails.icon.color.purple': '紫色',
|
||||
'hostDetails.icon.color.cyan': '青色',
|
||||
'hostDetails.icon.color.orange': '橙色',
|
||||
'hostDetails.icon.color.slate': '石板灰',
|
||||
'hostDetails.icon.color.violet': '紫罗兰',
|
||||
'hostDetails.icon.color.pink': '粉色',
|
||||
'hostDetails.icon.color.rose': '玫瑰红',
|
||||
'hostDetails.icon.color.lime': '青柠',
|
||||
'hostDetails.icon.color.teal': '蓝绿色',
|
||||
'hostDetails.icon.color.sky': '天蓝',
|
||||
'hostDetails.icon.color.indigo': '靛蓝',
|
||||
'hostDetails.icon.color.zinc': '锌灰',
|
||||
'hostDetails.distro.mode': '来源',
|
||||
'hostDetails.distro.mode.auto': '自动探测',
|
||||
'hostDetails.distro.mode.manual': '手动覆盖',
|
||||
@@ -65,6 +110,8 @@ 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.openeuler': 'openEuler',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.distro.option.cisco': '思科',
|
||||
@@ -77,6 +124,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': '显示密码',
|
||||
@@ -109,6 +159,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.deviceType.warning': 'AI 代理命令将直接发送,无法获取退出码。仅建议在设备不运行标准 Shell 时启用。',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH 算法',
|
||||
'hostDetails.section.terminalBehavior': '终端行为',
|
||||
'hostDetails.lineTimestamps': '显示输出时间',
|
||||
'hostDetails.lineTimestamps.desc': '在终端输出行旁边显示本地时间,不改变终端文本内容。',
|
||||
'hostDetails.legacyAlgorithms': '允许旧版算法',
|
||||
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法(diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
|
||||
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
|
||||
@@ -136,7 +188,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': '配置代理',
|
||||
@@ -209,9 +261,12 @@ export const zhCNVaultMessages: Messages = {
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': '打开 SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': '连接后可用',
|
||||
'terminal.toolbar.sendYmodem': 'YMODEM 发送',
|
||||
'terminal.toolbar.receiveYmodem': 'YMODEM 接收',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': '更多操作',
|
||||
'terminal.toolbar.scripts': '脚本',
|
||||
'terminal.toolbar.history': '命令历史',
|
||||
'terminal.toolbar.library': '库',
|
||||
'terminal.toolbar.noSnippets': '暂无代码片段',
|
||||
'terminal.toolbar.terminalSettings': '终端设置',
|
||||
@@ -225,8 +280,17 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.composeBar.send': '发送',
|
||||
'terminal.composeBar.close': '关闭撰写栏',
|
||||
'terminal.composeBar.broadcasting': '正在广播到所有会话',
|
||||
'terminal.composeBar.resize': '拖拽调整撰写栏高度',
|
||||
'terminal.composeBar.manageSnippets': '管理快捷代码片段',
|
||||
'terminal.composeBar.searchSnippets': '搜索代码片段...',
|
||||
'terminal.composeBar.noPinnedSnippets': '点击 + 固定常用代码片段',
|
||||
'terminal.composeBar.noMatchingSnippets': '没有匹配的代码片段',
|
||||
'terminal.composeBar.pinnedCount': '已固定 {count} 个',
|
||||
'terminal.composeBar.unpinSnippet': '从快捷栏移除 {label}',
|
||||
'terminal.composeBar.snippetClickHint': '单击插入 · Shift+单击直接发送',
|
||||
'terminal.toolbar.focus': '聚焦',
|
||||
'terminal.toolbar.focusMode': '聚焦模式',
|
||||
'terminal.toolbar.detach': '移出到独立标签',
|
||||
'terminal.toolbar.encoding': '终端编码',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
@@ -265,7 +329,9 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.dragDrop.localTitle': '拖放以插入路径',
|
||||
'terminal.dragDrop.localMessage': '文件路径将被插入到终端',
|
||||
'terminal.dragDrop.remoteTitle': '拖放以上传文件',
|
||||
'terminal.dragDrop.remoteMessage': '文件将通过 SFTP 上传',
|
||||
'terminal.dragDrop.remoteZmodemMessage': '文件将通过 ZMODEM(PTY)上传',
|
||||
'terminal.dragDrop.remoteSftpMessage': '文件将通过 SFTP 上传',
|
||||
'terminal.dragDrop.noFiles': '没有可上传的文件',
|
||||
'terminal.dragDrop.notConnected': '无法拖放文件 - 终端未连接',
|
||||
'terminal.dragDrop.errorTitle': '拖放错误',
|
||||
'terminal.dragDrop.errorMessage': '处理拖放文件失败',
|
||||
@@ -275,13 +341,33 @@ 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': '重新连接',
|
||||
'terminal.menu.sendYmodem': 'YMODEM 发送',
|
||||
'terminal.menu.receiveYmodem': 'YMODEM 接收',
|
||||
'terminal.menu.splitHorizontal': '水平分屏',
|
||||
'terminal.menu.splitVertical': '垂直分屏',
|
||||
'terminal.menu.clearBuffer': '清空缓冲区',
|
||||
'terminal.menu.closeTerminal': '关闭终端',
|
||||
'terminal.menu.rename': '重命名',
|
||||
'terminal.menu.detach': '从工作区移出',
|
||||
'terminal.menu.detachSession': '移出 {name}',
|
||||
'terminal.ymodem.selectFile': '选择要发送的文件',
|
||||
'terminal.ymodem.allFiles': '所有文件',
|
||||
'terminal.ymodem.started': '正在通过 YMODEM 发送 {fileName}',
|
||||
'terminal.ymodem.complete': 'YMODEM 已发送 {fileName}',
|
||||
'terminal.ymodem.failed': 'YMODEM 发送失败',
|
||||
'terminal.ymodem.selectReceiveDirectory': '选择接收文件保存位置',
|
||||
'terminal.ymodem.receiveStarted': '正在通过 YMODEM 接收...',
|
||||
'terminal.ymodem.receiveComplete': 'YMODEM 已接收 {fileName}',
|
||||
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM 已接收 {count} 个文件',
|
||||
'terminal.ymodem.receiveEmpty': '没有接收到 YMODEM 文件',
|
||||
'terminal.ymodem.receiveFailed': 'YMODEM 接收失败',
|
||||
'terminal.ymodem.unavailable': 'YMODEM 当前不可用',
|
||||
'terminal.selection.addToAI': '添加到对话',
|
||||
'terminal.selection.addToAIDesc': '将选中的终端输出作为附件加入 AI 草稿',
|
||||
'terminal.auth.password': '密码',
|
||||
'terminal.auth.sshKey': 'SSH Key',
|
||||
'terminal.auth.username': '用户名',
|
||||
@@ -599,6 +685,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'common.generate': '生成',
|
||||
'common.delete': '删除',
|
||||
'common.edit': '编辑',
|
||||
'sftp.context.openWithDefault': '系统默认程序打开',
|
||||
'common.clear': '清除',
|
||||
'common.optional': '可选',
|
||||
'common.selectPlaceholder': '请选择...',
|
||||
@@ -649,6 +736,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'sftp.tabs.addTab': '新建标签页',
|
||||
'sftp.tabs.closeTab': '关闭标签页',
|
||||
'sftp.tabs.newTab': '新标签页',
|
||||
'sftp.tabs.copyDefaultPath': '复制标签页(默认路径)',
|
||||
'sftp.tabs.copyCurrentPath': '复制并跳转到当前路径',
|
||||
'sftp.conflict.title': '文件冲突',
|
||||
'sftp.conflict.desc': '目标位置已存在同名文件',
|
||||
'sftp.conflict.alreadyExistsSuffix': '已存在',
|
||||
|
||||
20
application/state/activeChromeThemeSync.test.ts
Normal file
20
application/state/activeChromeThemeSync.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
test("active tab changes notify chrome theme before react subscribers", () => {
|
||||
const storeSource = readFileSync(new URL("./activeTabStore.ts", import.meta.url), "utf8");
|
||||
const syncSource = readFileSync(new URL("./activeChromeThemeSync.ts", import.meta.url), "utf8");
|
||||
|
||||
const setActiveTabIdBody = storeSource.match(/setActiveTabId = \(id: string\) => \{[\s\S]*?\n {2}\};/)?.[0] ?? "";
|
||||
assert.match(setActiveTabIdBody, /this\.syncListeners\.forEach\(\(listener\) => listener\(id\)\)/);
|
||||
assert.match(setActiveTabIdBody, /this\.scheduleNotify\(\)/);
|
||||
assert.ok(
|
||||
setActiveTabIdBody.indexOf("syncListeners.forEach") < setActiveTabIdBody.indexOf("scheduleNotify"),
|
||||
"sync chrome theme listeners must run before deferred react notify",
|
||||
);
|
||||
assert.match(syncSource, /activeTabStore\.subscribeSync\(notifyActiveChromeThemeForTab\)/);
|
||||
assert.match(syncSource, /isActiveChromeThemeResolvable/);
|
||||
assert.match(syncSource, /clearTopTabsChromeThemeVars/);
|
||||
});
|
||||
39
application/state/activeChromeThemeSync.ts
Normal file
39
application/state/activeChromeThemeSync.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { isActiveChromeThemeResolvable, resolveActiveChromeTheme } from '../app/activeChromeTheme';
|
||||
import { clearTopTabsChromeThemeVars } from '../app/topTabsChromeTheme';
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
import { activeTabStore } from './activeTabStore';
|
||||
import type { EditorTab } from './editorTabStore';
|
||||
import type { LogView } from './logViewState';
|
||||
import { syncActiveChromeTheme } from './useActiveChromeTheme';
|
||||
|
||||
export type ActiveChromeThemeDeps = {
|
||||
accentMode: 'theme' | 'custom';
|
||||
applyAppTheme: () => void;
|
||||
currentTerminalTheme: TerminalTheme;
|
||||
customAccent: string;
|
||||
editorTabs: readonly EditorTab[];
|
||||
followAppTerminalTheme: boolean;
|
||||
hostById: Map<string, Host>;
|
||||
logViews: readonly LogView[];
|
||||
sessionById: Map<string, TerminalSession>;
|
||||
themeById: Map<string, TerminalTheme>;
|
||||
workspaceById: Map<string, Workspace>;
|
||||
};
|
||||
|
||||
let depsRef: ActiveChromeThemeDeps | null = null;
|
||||
|
||||
export function updateActiveChromeThemeDeps(deps: ActiveChromeThemeDeps): void {
|
||||
depsRef = deps;
|
||||
}
|
||||
|
||||
export function notifyActiveChromeThemeForTab(activeTabId: string): void {
|
||||
if (!depsRef || typeof document === 'undefined') return;
|
||||
if (activeTabId === 'vault' || activeTabId === 'sftp') {
|
||||
clearTopTabsChromeThemeVars();
|
||||
}
|
||||
if (!isActiveChromeThemeResolvable({ ...depsRef, activeTabId })) return;
|
||||
const activeTheme = resolveActiveChromeTheme({ ...depsRef, activeTabId });
|
||||
syncActiveChromeTheme(activeTheme, depsRef.applyAppTheme);
|
||||
}
|
||||
|
||||
activeTabStore.subscribeSync(notifyActiveChromeThemeForTab);
|
||||
14
application/state/activeTabStore.test.ts
Normal file
14
application/state/activeTabStore.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { fromEditorTabId, isEditorTabId, toEditorTabId } from './activeTabStore';
|
||||
|
||||
test('editor tab helpers round trip ids', () => {
|
||||
assert.equal(toEditorTabId('file-1'), 'editor:file-1');
|
||||
assert.equal(fromEditorTabId('editor:file-1'), 'file-1');
|
||||
});
|
||||
|
||||
test('editor tab helper detects editor top-tab ids', () => {
|
||||
assert.equal(isEditorTabId('editor:file-1'), true);
|
||||
assert.equal(isEditorTabId('session-1'), false);
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
|
||||
import { terminalLayoutSuppressStore } from './terminalLayoutSuppressStore';
|
||||
|
||||
// Simple store for active tab that allows fine-grained subscriptions
|
||||
type Listener = () => void;
|
||||
type SyncListener = (activeTabId: string) => void;
|
||||
|
||||
// ----- Editor tab id helpers -----
|
||||
export const EDITOR_PREFIX = 'editor:';
|
||||
@@ -18,19 +21,37 @@ 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 syncListeners = new Set<SyncListener>();
|
||||
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());
|
||||
this.syncListeners.forEach((listener) => listener(id));
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -39,6 +60,11 @@ class ActiveTabStore {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
subscribeSync = (listener: SyncListener) => {
|
||||
this.syncListeners.add(listener);
|
||||
return () => this.syncListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export const activeTabStore = new ActiveTabStore();
|
||||
@@ -47,7 +73,8 @@ export const activeTabStore = new ActiveTabStore();
|
||||
export const useActiveTabId = () => {
|
||||
return useSyncExternalStore(
|
||||
activeTabStore.subscribe,
|
||||
activeTabStore.getActiveTabId
|
||||
activeTabStore.getActiveTabId,
|
||||
activeTabStore.getActiveTabId,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -59,7 +86,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 +97,8 @@ const getIsSftpActive = () => activeTabStore.getActiveTabId() === 'sftp';
|
||||
export const useIsVaultActive = () => {
|
||||
return useSyncExternalStore(
|
||||
activeTabStore.subscribe,
|
||||
getIsVaultActive
|
||||
getIsVaultActive,
|
||||
getIsVaultActive,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -78,7 +106,8 @@ export const useIsVaultActive = () => {
|
||||
export const useIsSftpActive = () => {
|
||||
return useSyncExternalStore(
|
||||
activeTabStore.subscribe,
|
||||
getIsSftpActive
|
||||
getIsSftpActive,
|
||||
getIsSftpActive,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -86,17 +115,5 @@ 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);
|
||||
};
|
||||
|
||||
// Check if terminal layer should be visible
|
||||
// Editor tabs are NOT terminal tabs, so exclude them from the visibility condition.
|
||||
export const useIsTerminalLayerVisible = (draggingSessionId: string | null) => {
|
||||
const getSnapshot = useCallback(() => {
|
||||
const activeTabId = activeTabStore.getActiveTabId();
|
||||
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !isEditorTabId(activeTabId);
|
||||
return isTerminalTab || !!draggingSessionId;
|
||||
}, [draggingSessionId]);
|
||||
|
||||
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot);
|
||||
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot, getSnapshot);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ensureDraftForScopeState,
|
||||
getDraftMutationVersionState,
|
||||
getDraftUploadGenerationState,
|
||||
pruneStaleSessionPanelViews,
|
||||
pruneTerminalScopeState,
|
||||
pruneTerminalTransientState,
|
||||
resolvePanelView,
|
||||
@@ -89,6 +90,39 @@ test("setSessionView records target session id", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneStaleSessionPanelViews resets session views that no longer exist", () => {
|
||||
const panelViewByScope = {
|
||||
"terminal:1": { mode: "session", sessionId: "deleted-session" },
|
||||
"workspace:2": { mode: "session", sessionId: "session-keep" },
|
||||
"terminal:3": { mode: "draft" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = pruneStaleSessionPanelViews(
|
||||
panelViewByScope,
|
||||
new Set(["session-keep"]),
|
||||
);
|
||||
|
||||
assert.deepEqual(next, {
|
||||
"terminal:1": { mode: "draft" },
|
||||
"workspace:2": { mode: "session", sessionId: "session-keep" },
|
||||
"terminal:3": { mode: "draft" },
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneStaleSessionPanelViews returns the original ref when nothing is stale", () => {
|
||||
const panelViewByScope = {
|
||||
"terminal:1": { mode: "session", sessionId: "session-keep" },
|
||||
"terminal:2": { mode: "draft" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = pruneStaleSessionPanelViews(
|
||||
panelViewByScope,
|
||||
new Set(["session-keep"]),
|
||||
);
|
||||
|
||||
assert.equal(next, panelViewByScope);
|
||||
});
|
||||
|
||||
test("clearScopeDraftState removes both the draft and current panel view", () => {
|
||||
const draftsByScope = {
|
||||
"terminal:1": createEmptyDraft("agent-alpha"),
|
||||
|
||||
@@ -115,6 +115,25 @@ export function setSessionView(
|
||||
};
|
||||
}
|
||||
|
||||
export function pruneStaleSessionPanelViews(
|
||||
panelViewByScope: PanelViewByScope,
|
||||
validSessionIds: Set<string>,
|
||||
): PanelViewByScope {
|
||||
let next = panelViewByScope;
|
||||
|
||||
for (const [scopeKey, panelView] of Object.entries(panelViewByScope)) {
|
||||
if (panelView?.mode !== 'session' || validSessionIds.has(panelView.sessionId)) {
|
||||
continue;
|
||||
}
|
||||
const updated = setDraftView(next, scopeKey);
|
||||
if (updated !== next) {
|
||||
next = updated;
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export function updateDraftForScope(
|
||||
draftsByScope: DraftsByScope,
|
||||
scopeKey: string,
|
||||
|
||||
@@ -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) {
|
||||
@@ -196,6 +196,21 @@ export function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<s
|
||||
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
|
||||
}
|
||||
|
||||
export function prewarmAIStateStorageSnapshots() {
|
||||
try {
|
||||
if (latestAISessionsSnapshot === null) {
|
||||
latestAISessionsSnapshot =
|
||||
localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? [];
|
||||
}
|
||||
if (latestAIActiveSessionMapSnapshot === null) {
|
||||
latestAIActiveSessionMapSnapshot =
|
||||
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[AIState] Failed to prewarm AI state storage snapshots:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function setLatestAIDraftsByScopeSnapshot(draftsByScope: DraftsByScope) {
|
||||
latestAIDraftsByScopeSnapshot = draftsByScope;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
41
application/state/hostTreeInlineHostEditStore.ts
Normal file
41
application/state/hostTreeInlineHostEditStore.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
export type HostTreeInlineHostEdit = {
|
||||
hostId: string;
|
||||
initialName: string;
|
||||
};
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
class HostTreeInlineHostEditStore {
|
||||
private edit: HostTreeInlineHostEdit | null = null;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getEdit = () => this.edit;
|
||||
|
||||
startEdit = (edit: HostTreeInlineHostEdit) => {
|
||||
this.edit = edit;
|
||||
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 hostTreeInlineHostEditStore = new HostTreeInlineHostEditStore();
|
||||
|
||||
export const useHostTreeInlineHostEdit = () => {
|
||||
return useSyncExternalStore(
|
||||
hostTreeInlineHostEditStore.subscribe,
|
||||
hostTreeInlineHostEditStore.getEdit,
|
||||
hostTreeInlineHostEditStore.getEdit,
|
||||
);
|
||||
};
|
||||
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' };
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveTerminalSessionExitIntent } from "./resolveTerminalSessionExitIntent.ts";
|
||||
import {
|
||||
resolveTerminalSessionExitIntent,
|
||||
shouldCloseTerminalPopupOnExit,
|
||||
} from "./resolveTerminalSessionExitIntent.ts";
|
||||
|
||||
test("normal backend exited events close the session tab", () => {
|
||||
assert.deepEqual(
|
||||
@@ -30,3 +33,10 @@ test("backend closed events keep the tab and mark it disconnected", () => {
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal popup only auto-closes after clean command exit", () => {
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 0 }), true);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 1 }), false);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "error", error: "connection reset" }), false);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "closed", exitCode: 0 }), false);
|
||||
});
|
||||
|
||||
@@ -20,3 +20,7 @@ export function resolveTerminalSessionExitIntent(
|
||||
// so the user can inspect output and reconnect.
|
||||
return { kind: "markDisconnected" };
|
||||
}
|
||||
|
||||
export function shouldCloseTerminalPopupOnExit(evt: TerminalSessionExitEvent): boolean {
|
||||
return evt.reason === "exited" && evt.exitCode === 0;
|
||||
}
|
||||
|
||||
@@ -74,5 +74,6 @@ export const useSessionActivityMap = () => {
|
||||
return useSyncExternalStore(
|
||||
sessionActivityStore.subscribe,
|
||||
sessionActivityStore.getSnapshot,
|
||||
sessionActivityStore.getSnapshot,
|
||||
);
|
||||
};
|
||||
|
||||
76
application/state/sessionCapabilitiesStore.ts
Normal file
76
application/state/sessionCapabilitiesStore.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { SessionCapabilities } from '../../domain/systemManager/types';
|
||||
|
||||
/** Internal entry: capabilities plus computed expiry timestamp. */
|
||||
interface StoreEntry {
|
||||
capabilities: SessionCapabilities;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const capabilitiesBySessionId = new Map<string, StoreEntry>();
|
||||
const listenersBySessionId = new Map<string, Set<Listener>>();
|
||||
|
||||
function isExpired(entry: StoreEntry): boolean {
|
||||
return Date.now() > entry.expiresAt;
|
||||
}
|
||||
|
||||
function notifySession(sessionId: string) {
|
||||
listenersBySessionId.get(sessionId)?.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export const sessionCapabilitiesStore = {
|
||||
get(sessionId: string): SessionCapabilities | undefined {
|
||||
const entry = capabilitiesBySessionId.get(sessionId);
|
||||
if (!entry) return undefined;
|
||||
if (isExpired(entry)) {
|
||||
capabilitiesBySessionId.delete(sessionId);
|
||||
notifySession(sessionId);
|
||||
return undefined;
|
||||
}
|
||||
return entry.capabilities;
|
||||
},
|
||||
|
||||
set(sessionId: string, capabilities: SessionCapabilities, ttlMs: number) {
|
||||
const entry: StoreEntry = {
|
||||
capabilities: {
|
||||
...capabilities,
|
||||
probedAt: Date.now(),
|
||||
},
|
||||
expiresAt: Date.now() + ttlMs,
|
||||
};
|
||||
capabilitiesBySessionId.set(sessionId, entry);
|
||||
notifySession(sessionId);
|
||||
},
|
||||
|
||||
delete(sessionId: string) {
|
||||
if (!capabilitiesBySessionId.delete(sessionId)) return;
|
||||
notifySession(sessionId);
|
||||
listenersBySessionId.delete(sessionId);
|
||||
},
|
||||
|
||||
/** Drop cached capabilities for sessions that no longer exist. */
|
||||
prune(liveSessionIds: ReadonlySet<string>) {
|
||||
for (const sessionId of capabilitiesBySessionId.keys()) {
|
||||
if (!liveSessionIds.has(sessionId)) {
|
||||
capabilitiesBySessionId.delete(sessionId);
|
||||
listenersBySessionId.delete(sessionId);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
subscribe(sessionId: string, listener: Listener): () => void {
|
||||
let set = listenersBySessionId.get(sessionId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
listenersBySessionId.set(sessionId, set);
|
||||
}
|
||||
set.add(listener);
|
||||
return () => {
|
||||
set?.delete(listener);
|
||||
if (set && set.size === 0) {
|
||||
listenersBySessionId.delete(sessionId);
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -84,6 +84,7 @@ export const createHostTerminalSession = (
|
||||
protocol: host.protocol,
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
etEnabled: host.etEnabled,
|
||||
charset: host.charset,
|
||||
};
|
||||
};
|
||||
|
||||
123
application/state/sessionWorkspaceDetach.test.ts
Normal file
123
application/state/sessionWorkspaceDetach.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { TerminalSession, Workspace } from "../../domain/models";
|
||||
import {
|
||||
closeSessionWorkspaceLayoutState,
|
||||
detachSessionFromWorkspaceState,
|
||||
replaceDissolvedWorkspaceTabOrder,
|
||||
} from "./sessionWorkspaceDetach";
|
||||
|
||||
const session = (id: string, workspaceId = "ws-1"): TerminalSession => ({
|
||||
id,
|
||||
hostId: id,
|
||||
hostLabel: id,
|
||||
status: "connected",
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const workspace = (sessionIds: string[]): Workspace => ({
|
||||
id: "ws-1",
|
||||
title: "Workspace",
|
||||
focusedSessionId: sessionIds[0],
|
||||
focusSessionOrder: sessionIds,
|
||||
root: sessionIds.length === 1
|
||||
? { id: "pane-1", type: "pane", sessionId: sessionIds[0] }
|
||||
: {
|
||||
id: "split-1",
|
||||
type: "split",
|
||||
direction: "vertical",
|
||||
children: sessionIds.map((sessionId, index) => ({
|
||||
id: `pane-${index + 1}`,
|
||||
type: "pane" as const,
|
||||
sessionId,
|
||||
})),
|
||||
sizes: sessionIds.map(() => 1),
|
||||
},
|
||||
});
|
||||
|
||||
test("detach dissolves the original workspace when one session remains", () => {
|
||||
const result = detachSessionFromWorkspaceState({
|
||||
sessions: [session("s1"), session("s2")],
|
||||
workspaces: [workspace(["s1", "s2"])],
|
||||
sessionId: "s1",
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(result.activeTabId, "s1");
|
||||
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
|
||||
["s1", undefined],
|
||||
["s2", undefined],
|
||||
]);
|
||||
assert.equal(result.workspaces.length, 0);
|
||||
assert.equal(result.dissolvedWorkspaceId, "ws-1");
|
||||
assert.deepEqual(result.replacementTabIds, ["s1", "s2"]);
|
||||
});
|
||||
|
||||
test("detach preserves the other sessions in a multi-pane workspace", () => {
|
||||
const result = detachSessionFromWorkspaceState({
|
||||
sessions: [session("s1"), session("s2"), session("s3")],
|
||||
workspaces: [workspace(["s1", "s2", "s3"])],
|
||||
sessionId: "s2",
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
|
||||
["s1", "ws-1"],
|
||||
["s2", undefined],
|
||||
["s3", "ws-1"],
|
||||
]);
|
||||
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s3"]);
|
||||
assert.equal(result.workspaces[0].focusedSessionId, "s1");
|
||||
assert.deepEqual(
|
||||
result.workspaces[0].root.type === "split"
|
||||
? result.workspaces[0].root.children.map((child) => child.type === "pane" ? child.sessionId : null)
|
||||
: [],
|
||||
["s1", "s3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("dissolved workspace replacement preserves its tab position", () => {
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]),
|
||||
["log-1", "s1", "s2", "session-3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("dissolved workspace replacement removes duplicate replacement ids", () => {
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(["s1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]),
|
||||
["s1", "s2", "session-3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("dissolved workspace replacement is idempotent", () => {
|
||||
const once = replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]);
|
||||
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(once, "ws-1", ["s1", "s2"]),
|
||||
once,
|
||||
);
|
||||
});
|
||||
|
||||
test("single remaining session preserves dissolved workspace tab position", () => {
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s2"]),
|
||||
["log-1", "s2", "session-3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("closing a workspace session dissolves the workspace when one terminal remains", () => {
|
||||
const result = closeSessionWorkspaceLayoutState([workspace(["s1", "s2"])], "ws-1", "s1");
|
||||
|
||||
assert.equal(result.dissolvedWorkspaceId, "ws-1");
|
||||
assert.equal(result.lastRemainingSessionId, "s2");
|
||||
assert.deepEqual(result.workspaces, []);
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(
|
||||
["log-1", result.dissolvedWorkspaceId!, "session-3"],
|
||||
result.dissolvedWorkspaceId,
|
||||
result.lastRemainingSessionId ? [result.lastRemainingSessionId] : undefined,
|
||||
),
|
||||
["log-1", "s2", "session-3"],
|
||||
);
|
||||
});
|
||||
182
application/state/sessionWorkspaceDetach.ts
Normal file
182
application/state/sessionWorkspaceDetach.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { TerminalSession, Workspace } from "../../domain/models";
|
||||
import { collectSessionIds, pruneWorkspaceNode } from "../../domain/workspace";
|
||||
|
||||
export type DetachSessionFromWorkspaceStateResult = {
|
||||
changed: boolean;
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
activeTabId?: string;
|
||||
dissolvedWorkspaceId?: string;
|
||||
replacementTabIds?: string[];
|
||||
};
|
||||
|
||||
export type CloseSessionWorkspaceLayoutResult = {
|
||||
workspaces: Workspace[];
|
||||
removedWorkspaceId?: string;
|
||||
dissolvedWorkspaceId?: string;
|
||||
lastRemainingSessionId?: string;
|
||||
};
|
||||
|
||||
type DetachSessionFromWorkspaceStateOptions = {
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export function replaceDissolvedWorkspaceTabOrder(
|
||||
tabOrder: readonly string[],
|
||||
workspaceId: string | undefined,
|
||||
replacementTabIds: readonly string[] | undefined,
|
||||
): string[] {
|
||||
if (!workspaceId || !replacementTabIds?.length) return [...tabOrder];
|
||||
|
||||
const uniqueReplacementIds = replacementTabIds.filter((tabId, index, list) => (
|
||||
tabId && list.indexOf(tabId) === index
|
||||
));
|
||||
if (uniqueReplacementIds.length === 0) return [...tabOrder];
|
||||
|
||||
if (!tabOrder.includes(workspaceId)) {
|
||||
const hasAllReplacementIds = uniqueReplacementIds.every((tabId) => tabOrder.includes(tabId));
|
||||
return hasAllReplacementIds ? [...tabOrder] : [
|
||||
...tabOrder,
|
||||
...uniqueReplacementIds.filter((tabId) => !tabOrder.includes(tabId)),
|
||||
];
|
||||
}
|
||||
|
||||
const replacementIdSet = new Set(uniqueReplacementIds);
|
||||
let inserted = false;
|
||||
const nextOrder: string[] = [];
|
||||
|
||||
for (const tabId of tabOrder) {
|
||||
if (tabId === workspaceId) {
|
||||
if (!inserted) {
|
||||
nextOrder.push(...uniqueReplacementIds);
|
||||
inserted = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!replacementIdSet.has(tabId)) {
|
||||
nextOrder.push(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
return nextOrder;
|
||||
}
|
||||
|
||||
export function closeSessionWorkspaceLayoutState(
|
||||
workspaces: readonly Workspace[],
|
||||
workspaceId: string | undefined,
|
||||
sessionId: string,
|
||||
): CloseSessionWorkspaceLayoutResult {
|
||||
if (!workspaceId) return { workspaces: [...workspaces] };
|
||||
|
||||
let removedWorkspaceId: string | undefined;
|
||||
let dissolvedWorkspaceId: string | undefined;
|
||||
let lastRemainingSessionId: string | undefined;
|
||||
const nextWorkspaces = workspaces
|
||||
.map((workspace) => {
|
||||
if (workspace.id !== workspaceId) return workspace;
|
||||
const prunedRoot = pruneWorkspaceNode(workspace.root, sessionId);
|
||||
if (!prunedRoot) {
|
||||
removedWorkspaceId = workspace.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
const remainingSessionIds = collectSessionIds(prunedRoot);
|
||||
if (remainingSessionIds.length === 1) {
|
||||
dissolvedWorkspaceId = workspace.id;
|
||||
lastRemainingSessionId = remainingSessionIds[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
return { ...workspace, root: prunedRoot };
|
||||
})
|
||||
.filter((workspace): workspace is Workspace => Boolean(workspace));
|
||||
|
||||
return {
|
||||
workspaces: nextWorkspaces,
|
||||
removedWorkspaceId,
|
||||
dissolvedWorkspaceId,
|
||||
lastRemainingSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
export function detachSessionFromWorkspaceState({
|
||||
sessions,
|
||||
workspaces,
|
||||
sessionId,
|
||||
}: DetachSessionFromWorkspaceStateOptions): DetachSessionFromWorkspaceStateResult {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (!session?.workspaceId) {
|
||||
return { changed: false, sessions, workspaces };
|
||||
}
|
||||
|
||||
const workspaceId = session.workspaceId;
|
||||
const targetWorkspace = workspaces.find((workspace) => workspace.id === workspaceId);
|
||||
if (!targetWorkspace) {
|
||||
return { changed: false, sessions, workspaces };
|
||||
}
|
||||
|
||||
const prunedRoot = pruneWorkspaceNode(targetWorkspace.root, sessionId);
|
||||
let nextSessions = sessions.map((candidate) => (
|
||||
candidate.id === sessionId ? { ...candidate, workspaceId: undefined } : candidate
|
||||
));
|
||||
|
||||
if (!prunedRoot) {
|
||||
return {
|
||||
changed: true,
|
||||
sessions: nextSessions,
|
||||
workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId),
|
||||
activeTabId: sessionId,
|
||||
dissolvedWorkspaceId: workspaceId,
|
||||
replacementTabIds: [sessionId],
|
||||
};
|
||||
}
|
||||
|
||||
const remainingSessionIds = collectSessionIds(prunedRoot);
|
||||
if (remainingSessionIds.length === 1) {
|
||||
nextSessions = nextSessions.map((candidate) => (
|
||||
candidate.id === remainingSessionIds[0] ? { ...candidate, workspaceId: undefined } : candidate
|
||||
));
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
sessions: nextSessions,
|
||||
workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId),
|
||||
activeTabId: sessionId,
|
||||
dissolvedWorkspaceId: workspaceId,
|
||||
replacementTabIds: [sessionId, ...remainingSessionIds],
|
||||
};
|
||||
}
|
||||
|
||||
const nextFocusedSessionId = remainingSessionIds.includes(targetWorkspace.focusedSessionId)
|
||||
? targetWorkspace.focusedSessionId
|
||||
: remainingSessionIds[0];
|
||||
const nextFocusSessionOrder = (targetWorkspace.focusSessionOrder ?? [])
|
||||
.filter((candidateId, index, list) => (
|
||||
candidateId !== sessionId &&
|
||||
remainingSessionIds.includes(candidateId) &&
|
||||
list.indexOf(candidateId) === index
|
||||
));
|
||||
for (const remainingSessionId of remainingSessionIds) {
|
||||
if (!nextFocusSessionOrder.includes(remainingSessionId)) {
|
||||
nextFocusSessionOrder.push(remainingSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
sessions: nextSessions,
|
||||
workspaces: workspaces.map((workspace) => (
|
||||
workspace.id === workspaceId
|
||||
? {
|
||||
...workspace,
|
||||
root: prunedRoot,
|
||||
focusedSessionId: nextFocusedSessionId,
|
||||
focusSessionOrder: nextFocusSessionOrder,
|
||||
}
|
||||
: workspace
|
||||
)),
|
||||
activeTabId: sessionId,
|
||||
};
|
||||
}
|
||||
@@ -12,10 +12,14 @@ import {
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_HOTKEY_RECORDING,
|
||||
STORAGE_KEY_HOTKEY_SCHEME,
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
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 +35,15 @@ import {
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
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,14 +61,20 @@ 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'>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
|
||||
}
|
||||
|
||||
@@ -78,14 +94,20 @@ export function useSettingsIpcSync({
|
||||
setSessionLogsEnabled,
|
||||
setSessionLogsDir,
|
||||
setSessionLogsFormat,
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDebugLogsEnabled,
|
||||
setHotkeyScheme,
|
||||
applyIncomingCustomKeyBindings,
|
||||
setIsHotkeyRecordingState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setAutoUpdateEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setShowHostTreeSidebarState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setSftpTransferConcurrencyState,
|
||||
}: UseSettingsIpcSyncParams) {
|
||||
// Listen for settings changes from other windows via IPC
|
||||
@@ -164,6 +186,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 +207,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));
|
||||
@@ -193,6 +228,12 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && (value === 'dim' || value === 'border')) {
|
||||
setWorkspaceFocusStyleState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && typeof value === 'boolean') {
|
||||
setShowHostTreeSidebarState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM && typeof value === 'boolean') {
|
||||
setDisableTerminalFontZoomState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && typeof value === 'number') {
|
||||
setSftpTransferConcurrencyState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
@@ -211,13 +252,19 @@ export function useSettingsIpcSync({
|
||||
setEditorWordWrapState,
|
||||
setFollowAppTerminalThemeState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setHotkeyScheme,
|
||||
setIsHotkeyRecordingState,
|
||||
setSessionLogsDir,
|
||||
setSessionLogsEnabled,
|
||||
setSessionLogsFormat,
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDebugLogsEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setShowHostTreeSidebarState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setSftpTransferConcurrencyState,
|
||||
setTerminalFontFamilyId,
|
||||
setTerminalFontSize,
|
||||
|
||||
@@ -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,10 +58,14 @@ 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;
|
||||
export const DEFAULT_SHOW_SFTP_TAB = true;
|
||||
export const DEFAULT_SHOW_HOST_TREE_SIDEBAR = true;
|
||||
export const DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS = false;
|
||||
export const DEFAULT_DISABLE_TERMINAL_FONT_ZOOM = false;
|
||||
|
||||
// Editor defaults
|
||||
export const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
@@ -63,6 +73,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);
|
||||
@@ -120,11 +132,8 @@ export const applyThemeTokens = (
|
||||
accentOverride: string,
|
||||
) => {
|
||||
const root = window.document.documentElement;
|
||||
// If immersive override is active (style tag present), it owns the dark/light class — don't override
|
||||
if (!document.getElementById('netcatty-immersive-override')) {
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(resolvedTheme);
|
||||
}
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(resolvedTheme);
|
||||
root.style.setProperty('--background', tokens.background);
|
||||
root.style.setProperty('--foreground', tokens.foreground);
|
||||
root.style.setProperty('--card', tokens.card);
|
||||
@@ -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,
|
||||
@@ -24,6 +27,9 @@ import {
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
@@ -37,8 +43,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,16 +73,23 @@ interface UseSettingsStorageSyncParams {
|
||||
sftpShowHiddenFiles: boolean;
|
||||
sftpUseCompressedUpload: boolean;
|
||||
sftpAutoOpenSidebar: boolean;
|
||||
sftpFollowTerminalCwd: boolean;
|
||||
sftpDefaultViewMode: 'list' | 'tree';
|
||||
showRecentHosts: boolean;
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
showSftpTab: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
disableTerminalFontZoom: boolean;
|
||||
editorWordWrap: boolean;
|
||||
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,15 +110,22 @@ 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>>;
|
||||
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setShellOnlyTabNumberShortcutsState: Dispatch<SetStateAction<boolean>>;
|
||||
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
|
||||
setEditorWordWrapState: Dispatch<SetStateAction<boolean>>;
|
||||
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 +138,19 @@ export function useSettingsStorageSync({
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
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,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat,
|
||||
setGlobalHotkeyEnabled, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState,
|
||||
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 +161,20 @@ export function useSettingsStorageSync({
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
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,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
};
|
||||
|
||||
// Listen for storage changes from other windows (cross-window sync)
|
||||
@@ -302,6 +324,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 +350,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) {
|
||||
@@ -340,6 +380,24 @@ export function useSettingsStorageSync({
|
||||
setShowSftpTabState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showHostTreeSidebar) {
|
||||
setShowHostTreeSidebarState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.shellOnlyTabNumberShortcuts) {
|
||||
setShellOnlyTabNumberShortcutsState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.disableTerminalFontZoom) {
|
||||
setDisableTerminalFontZoomState(newValue);
|
||||
}
|
||||
}
|
||||
// Sync global hotkey enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
@@ -354,6 +412,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 +446,16 @@ export function useSettingsStorageSync({
|
||||
setEditorWordWrapState,
|
||||
setFollowAppTerminalThemeState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setHotkeyScheme,
|
||||
setLightUiThemeId,
|
||||
setSessionLogsDir,
|
||||
setSessionLogsEnabled,
|
||||
setSessionLogsFormat,
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDebugLogsEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpAutoSync,
|
||||
setSftpDefaultViewMode,
|
||||
setSftpDoubleClickBehavior,
|
||||
@@ -395,8 +463,11 @@ export function useSettingsStorageSync({
|
||||
setSftpTransferConcurrencyState,
|
||||
setSftpUseCompressedUpload,
|
||||
setShowOnlyUngroupedHostsInRootState,
|
||||
setShowHostTreeSidebarState,
|
||||
setShowRecentHostsState,
|
||||
setShowSftpTabState,
|
||||
setShellOnlyTabNumberShortcutsState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setTerminalFontFamilyId,
|
||||
setTerminalFontSize,
|
||||
setTerminalThemeDarkId,
|
||||
|
||||
56
application/state/sftp/sftpConnectStartPath.test.ts
Normal file
56
application/state/sftp/sftpConnectStartPath.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { RemoteSftpStartCache } from "./sftpConnectStartPath.ts";
|
||||
import {
|
||||
normalizeSftpInitialPath,
|
||||
resolveRemoteSftpStartState,
|
||||
} from "./sftpConnectStartPath.ts";
|
||||
|
||||
const cached: RemoteSftpStartCache = {
|
||||
path: "/var/cache",
|
||||
homeDir: "/home/deploy",
|
||||
files: [],
|
||||
filenameEncoding: "auto",
|
||||
};
|
||||
|
||||
test("remote SFTP default-path duplication ignores the shared host cache", () => {
|
||||
const state = resolveRemoteSftpStartState({
|
||||
filenameEncoding: "auto",
|
||||
ignoreSharedCache: true,
|
||||
sharedHostCacheCandidate: cached,
|
||||
});
|
||||
|
||||
assert.equal(state.initialPath, undefined);
|
||||
assert.equal(state.sharedHostCache, null);
|
||||
assert.equal(state.cachedStartPath, "/");
|
||||
});
|
||||
|
||||
test("remote SFTP current-path duplication uses the requested path instead of stale cache", () => {
|
||||
const state = resolveRemoteSftpStartState({
|
||||
filenameEncoding: "auto",
|
||||
initialPath: "/var/www/app",
|
||||
sharedHostCacheCandidate: cached,
|
||||
});
|
||||
|
||||
assert.equal(state.initialPath, "/var/www/app");
|
||||
assert.equal(state.sharedHostCache, null);
|
||||
assert.equal(state.cachedStartPath, "/var/www/app");
|
||||
});
|
||||
|
||||
test("remote SFTP initial paths preserve meaningful whitespace", () => {
|
||||
assert.equal(normalizeSftpInitialPath("/var/www/app "), "/var/www/app ");
|
||||
|
||||
const state = resolveRemoteSftpStartState({
|
||||
filenameEncoding: "auto",
|
||||
initialPath: "/var/www/app ",
|
||||
sharedHostCacheCandidate: {
|
||||
...cached,
|
||||
path: "/var/www/app",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(state.initialPath, "/var/www/app ");
|
||||
assert.equal(state.sharedHostCache, null);
|
||||
assert.equal(state.cachedStartPath, "/var/www/app ");
|
||||
});
|
||||
44
application/state/sftp/sftpConnectStartPath.ts
Normal file
44
application/state/sftp/sftpConnectStartPath.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
|
||||
export interface RemoteSftpStartCache {
|
||||
path: string;
|
||||
homeDir: string;
|
||||
files: SftpFileEntry[];
|
||||
filenameEncoding: SftpFilenameEncoding;
|
||||
}
|
||||
|
||||
interface ResolveRemoteSftpStartStateParams {
|
||||
filenameEncoding: SftpFilenameEncoding;
|
||||
ignoreSharedCache?: boolean;
|
||||
initialPath?: string;
|
||||
sharedHostCacheCandidate: RemoteSftpStartCache | null;
|
||||
}
|
||||
|
||||
export function normalizeSftpInitialPath(initialPath?: string): string | undefined {
|
||||
return initialPath === undefined || initialPath.length === 0 ? undefined : initialPath;
|
||||
}
|
||||
|
||||
export function resolveRemoteSftpStartState({
|
||||
filenameEncoding,
|
||||
ignoreSharedCache,
|
||||
initialPath,
|
||||
sharedHostCacheCandidate,
|
||||
}: ResolveRemoteSftpStartStateParams): {
|
||||
initialPath: string | undefined;
|
||||
sharedHostCache: RemoteSftpStartCache | null;
|
||||
cachedStartPath: string;
|
||||
} {
|
||||
const requestedInitialPath = normalizeSftpInitialPath(initialPath);
|
||||
const sharedHostCache =
|
||||
!ignoreSharedCache
|
||||
&& sharedHostCacheCandidate?.filenameEncoding === filenameEncoding
|
||||
&& (!requestedInitialPath || sharedHostCacheCandidate.path === requestedInitialPath)
|
||||
? sharedHostCacheCandidate
|
||||
: null;
|
||||
|
||||
return {
|
||||
initialPath: requestedInitialPath,
|
||||
sharedHostCache,
|
||||
cachedStartPath: requestedInitialPath ?? sharedHostCache?.path ?? "/",
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
|
||||
interface SharedRemoteHostCacheEntry {
|
||||
export interface SharedRemoteHostCacheEntry {
|
||||
path: string;
|
||||
homeDir: string;
|
||||
files: SftpFileEntry[];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SftpConnection, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
|
||||
export interface SftpPane {
|
||||
id: string;
|
||||
@@ -15,6 +15,22 @@ export interface SftpPane {
|
||||
transferMutationToken: number;
|
||||
}
|
||||
|
||||
export interface SftpHostKeyInfo {
|
||||
hostname: string;
|
||||
port: number;
|
||||
keyType: string;
|
||||
fingerprint: string;
|
||||
publicKey?: string;
|
||||
status?: "unknown" | "changed";
|
||||
knownHostId?: string;
|
||||
knownFingerprint?: string;
|
||||
}
|
||||
|
||||
export interface SftpHostKeyVerificationState {
|
||||
hostKeyInfo: SftpHostKeyInfo;
|
||||
progressLogs: string[];
|
||||
}
|
||||
|
||||
// Multi-tab state for left and right sides
|
||||
export interface SftpSideTabs {
|
||||
tabs: SftpPane[];
|
||||
@@ -70,4 +86,6 @@ export interface SftpStateOptions {
|
||||
* is honored for SFTP browsing too (not just the terminal session).
|
||||
*/
|
||||
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
|
||||
knownHosts?: KnownHost[];
|
||||
onAddKnownHost?: (knownHost: KnownHost) => void;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import React, { useCallback, useEffect, useRef } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import type { Host, Identity, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
|
||||
import type { SftpPane } from "./types";
|
||||
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
|
||||
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
|
||||
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
|
||||
import { useSftpHostCredentials } from "./useSftpHostCredentials";
|
||||
import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
|
||||
import { resolveRemoteSftpStartState } from "./sftpConnectStartPath";
|
||||
|
||||
interface UseSftpConnectionsParams {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
knownHosts?: KnownHost[];
|
||||
onAddKnownHost?: (knownHost: KnownHost) => void;
|
||||
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
|
||||
leftTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
|
||||
rightTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
|
||||
@@ -34,17 +37,61 @@ interface UseSftpConnectionsParams {
|
||||
autoConnectLocalOnMount?: boolean;
|
||||
}
|
||||
|
||||
export interface SftpConnectOptions {
|
||||
forceNewTab?: boolean;
|
||||
ignoreSharedCache?: boolean;
|
||||
initialPath?: string;
|
||||
onTabCreated?: (tabId: string) => void;
|
||||
sourceSessionId?: string;
|
||||
}
|
||||
|
||||
interface UseSftpConnectionsResult {
|
||||
connect: (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean; onTabCreated?: (tabId: string) => void }) => Promise<void>;
|
||||
connect: (side: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => Promise<void>;
|
||||
disconnect: (side: "left" | "right") => Promise<void>;
|
||||
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
|
||||
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
|
||||
hostKeyVerification: SftpHostKeyVerificationState | null;
|
||||
rejectHostKeyVerification: () => void;
|
||||
acceptHostKeyVerification: () => void;
|
||||
acceptAndSaveHostKeyVerification: () => void;
|
||||
}
|
||||
|
||||
type HostKeyVerificationRequest = SftpHostKeyInfo & {
|
||||
requestId: string;
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
const toSftpHostKeyInfo = (request: HostKeyVerificationRequest): SftpHostKeyInfo => ({
|
||||
hostname: request.hostname,
|
||||
port: request.port || 22,
|
||||
keyType: request.keyType,
|
||||
fingerprint: request.fingerprint,
|
||||
publicKey: request.publicKey,
|
||||
status: request.status,
|
||||
knownHostId: request.knownHostId,
|
||||
knownFingerprint: request.knownFingerprint,
|
||||
});
|
||||
|
||||
const createKnownHostFromSftpHostKeyInfo = (
|
||||
hostKeyInfo: SftpHostKeyInfo,
|
||||
now = Date.now(),
|
||||
idSuffix = Math.random().toString(36).slice(2, 11),
|
||||
): KnownHost => ({
|
||||
id: hostKeyInfo.knownHostId || `kh-${now}-${idSuffix}`,
|
||||
hostname: hostKeyInfo.hostname,
|
||||
port: hostKeyInfo.port || 22,
|
||||
keyType: hostKeyInfo.keyType,
|
||||
publicKey: hostKeyInfo.publicKey || `SHA256:${hostKeyInfo.fingerprint}`,
|
||||
fingerprint: hostKeyInfo.fingerprint,
|
||||
discoveredAt: now,
|
||||
});
|
||||
|
||||
export const useSftpConnections = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts,
|
||||
onAddKnownHost,
|
||||
terminalSettings,
|
||||
leftTabsRef,
|
||||
rightTabsRef,
|
||||
@@ -67,11 +114,79 @@ export const useSftpConnections = ({
|
||||
createEmptyPane,
|
||||
autoConnectLocalOnMount = true,
|
||||
}: UseSftpConnectionsParams): UseSftpConnectionsResult => {
|
||||
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities, terminalSettings });
|
||||
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities, knownHosts, terminalSettings });
|
||||
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
|
||||
const [hostKeyVerification, setHostKeyVerification] = useState<SftpHostKeyVerificationState | null>(null);
|
||||
const hostKeyVerificationRef = useRef<(SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null>(null);
|
||||
const activeHostKeySessionsRef = useRef<Map<string, { side: "left" | "right"; tabId: string }>>(new Map());
|
||||
|
||||
const setPendingHostKeyVerification = useCallback((
|
||||
next: (SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null,
|
||||
) => {
|
||||
hostKeyVerificationRef.current = next;
|
||||
setHostKeyVerification(next ? {
|
||||
hostKeyInfo: next.hostKeyInfo,
|
||||
progressLogs: next.progressLogs,
|
||||
} : null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const dispose = netcattyBridge.get()?.onHostKeyVerification?.((request: HostKeyVerificationRequest) => {
|
||||
const sessionId = request.sessionId;
|
||||
if (!sessionId) return;
|
||||
const activeSession = activeHostKeySessionsRef.current.get(sessionId);
|
||||
if (!activeSession) return;
|
||||
|
||||
const hostKeyInfo = toSftpHostKeyInfo(request);
|
||||
const logLine = request.status === "changed"
|
||||
? `Host key changed for ${request.hostname}. Waiting for confirmation...`
|
||||
: `Host key verification required for ${request.hostname}.`;
|
||||
|
||||
updateTab(activeSession.side, activeSession.tabId, (prev) => ({
|
||||
...prev,
|
||||
connectionLogs: [...prev.connectionLogs, logLine],
|
||||
}));
|
||||
setPendingHostKeyVerification({
|
||||
requestId: request.requestId,
|
||||
sessionId,
|
||||
hostKeyInfo,
|
||||
progressLogs: [logLine],
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
dispose?.();
|
||||
};
|
||||
}, [setPendingHostKeyVerification, updateTab]);
|
||||
|
||||
const respondToHostKeyVerification = useCallback((accept: boolean, addToKnownHosts = false) => {
|
||||
const pending = hostKeyVerificationRef.current;
|
||||
if (!pending) return;
|
||||
if (accept && addToKnownHosts) {
|
||||
onAddKnownHost?.(createKnownHostFromSftpHostKeyInfo(pending.hostKeyInfo));
|
||||
}
|
||||
void netcattyBridge.get()?.respondHostKeyVerification?.(
|
||||
pending.requestId,
|
||||
accept,
|
||||
addToKnownHosts,
|
||||
);
|
||||
setPendingHostKeyVerification(null);
|
||||
}, [onAddKnownHost, setPendingHostKeyVerification]);
|
||||
|
||||
const rejectHostKeyVerification = useCallback(() => {
|
||||
respondToHostKeyVerification(false);
|
||||
}, [respondToHostKeyVerification]);
|
||||
|
||||
const acceptHostKeyVerification = useCallback(() => {
|
||||
respondToHostKeyVerification(true, false);
|
||||
}, [respondToHostKeyVerification]);
|
||||
|
||||
const acceptAndSaveHostKeyVerification = useCallback(() => {
|
||||
respondToHostKeyVerification(true, true);
|
||||
}, [respondToHostKeyVerification]);
|
||||
|
||||
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?: SftpConnectOptions) => {
|
||||
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
|
||||
|
||||
let activeTabId: string | null = null;
|
||||
@@ -101,6 +216,33 @@ export const useSftpConnections = ({
|
||||
|
||||
navSeqRef.current[side] += 1;
|
||||
const connectRequestId = navSeqRef.current[side];
|
||||
const getTargetPane = () => {
|
||||
const tabs = side === "left" ? leftTabsRef.current.tabs : rightTabsRef.current.tabs;
|
||||
return tabs.find((tab) => tab.id === activeTabId) ?? null;
|
||||
};
|
||||
const isTargetConnectionCurrent = () => {
|
||||
const pane = getTargetPane();
|
||||
if (!pane) return false;
|
||||
if (pane.connection?.id === connectionId) return true;
|
||||
return !pane.connection && navSeqRef.current[side] === connectRequestId;
|
||||
};
|
||||
const isTargetConnectionAtPath = (path: string) => {
|
||||
const connection = getTargetPane()?.connection;
|
||||
if (!connection) return navSeqRef.current[side] === connectRequestId;
|
||||
return connection?.id === connectionId && connection.currentPath === path;
|
||||
};
|
||||
const closeSftpSessionForConnection = async () => {
|
||||
const sftpId = sftpSessionsRef.current.get(connectionId);
|
||||
sftpSessionsRef.current.delete(connectionId);
|
||||
connectionCacheKeyMapRef.current.delete(connectionId);
|
||||
clearCacheForConnection(connectionId);
|
||||
if (!sftpId) return;
|
||||
try {
|
||||
await netcattyBridge.get()?.closeSftp(sftpId);
|
||||
} catch {
|
||||
// Ignore errors when closing stale SFTP sessions
|
||||
}
|
||||
};
|
||||
|
||||
lastConnectedHostRef.current[side] = host;
|
||||
// Store the cache key for this connection so pane actions can look it up
|
||||
@@ -147,13 +289,15 @@ export const useSftpConnections = ({
|
||||
homeDir = isWindows ? "C:\\Users\\damao" : "/Users/damao";
|
||||
}
|
||||
|
||||
const startPath = options?.initialPath || homeDir;
|
||||
|
||||
const connection: SftpConnection = {
|
||||
id: connectionId,
|
||||
hostId: "local",
|
||||
hostLabel: "Local",
|
||||
isLocal: true,
|
||||
status: "connected",
|
||||
currentPath: homeDir,
|
||||
currentPath: startPath,
|
||||
homeDir,
|
||||
};
|
||||
|
||||
@@ -168,9 +312,9 @@ export const useSftpConnections = ({
|
||||
}));
|
||||
|
||||
try {
|
||||
const files = await listLocalFiles(homeDir);
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
dirCacheRef.current.set(makeCacheKey(connectionId, homeDir, filenameEncoding), {
|
||||
const files = await listLocalFiles(startPath);
|
||||
if (!isTargetConnectionAtPath(startPath)) return;
|
||||
dirCacheRef.current.set(makeCacheKey(connectionId, startPath, filenameEncoding), {
|
||||
files,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
@@ -182,7 +326,7 @@ export const useSftpConnections = ({
|
||||
reconnecting: false,
|
||||
}));
|
||||
} catch (err) {
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
if (!isTargetConnectionAtPath(startPath)) return;
|
||||
reconnectingRef.current[side] = false;
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
@@ -193,12 +337,15 @@ export const useSftpConnections = ({
|
||||
}
|
||||
} else {
|
||||
const hostCacheKey = buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username);
|
||||
const sharedHostCacheCandidate = getSharedRemoteHostCache(hostCacheKey);
|
||||
const sharedHostCache =
|
||||
sharedHostCacheCandidate?.filenameEncoding === filenameEncoding
|
||||
? sharedHostCacheCandidate
|
||||
: null;
|
||||
const cachedStartPath = sharedHostCache?.path ?? "/";
|
||||
const sharedHostCacheCandidate = options?.ignoreSharedCache
|
||||
? null
|
||||
: getSharedRemoteHostCache(hostCacheKey);
|
||||
const { initialPath, sharedHostCache, cachedStartPath } = resolveRemoteSftpStartState({
|
||||
filenameEncoding,
|
||||
ignoreSharedCache: options?.ignoreSharedCache,
|
||||
initialPath: options?.initialPath,
|
||||
sharedHostCacheCandidate,
|
||||
});
|
||||
|
||||
const connection: SftpConnection = {
|
||||
id: connectionId,
|
||||
@@ -207,6 +354,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) => ({
|
||||
@@ -225,6 +377,7 @@ export const useSftpConnections = ({
|
||||
|
||||
// Subscribe to SFTP connection progress events for auth logging
|
||||
const sftpSessionId = `sftp-${connectionId}`;
|
||||
activeHostKeySessionsRef.current.set(sftpSessionId, { side, tabId: activeTabId });
|
||||
let unsubSftpProgress: (() => void) | undefined;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.onSftpConnectionProgress) {
|
||||
@@ -259,7 +412,7 @@ export const useSftpConnections = ({
|
||||
logLine = `${label} - ${status}${detail ? `: ${detail}` : ''}`;
|
||||
}
|
||||
// Only update if this is still the active request (avoids stale logs leaking)
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
if (!isTargetConnectionCurrent()) return;
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connectionLogs: [...prev.connectionLogs, logLine],
|
||||
@@ -290,8 +443,9 @@ export const useSftpConnections = ({
|
||||
if (hasKey) {
|
||||
try {
|
||||
const keyFirstCredentials = {
|
||||
sessionId: `sftp-${connectionId}`,
|
||||
sessionId: sftpSessionId,
|
||||
...credentials,
|
||||
sourceSessionId: options?.sourceSessionId,
|
||||
};
|
||||
if (!credentials.sudo) {
|
||||
keyFirstCredentials.password = undefined;
|
||||
@@ -300,8 +454,9 @@ export const useSftpConnections = ({
|
||||
} catch (err) {
|
||||
if (hasPassword && isAuthError(err)) {
|
||||
sftpId = await openSftp({
|
||||
sessionId: `sftp-${connectionId}`,
|
||||
sessionId: sftpSessionId,
|
||||
...credentials,
|
||||
sourceSessionId: options?.sourceSessionId,
|
||||
privateKey: undefined,
|
||||
certificate: undefined,
|
||||
publicKey: undefined,
|
||||
@@ -315,14 +470,19 @@ export const useSftpConnections = ({
|
||||
}
|
||||
} else {
|
||||
sftpId = await openSftp({
|
||||
sessionId: `sftp-${connectionId}`,
|
||||
sessionId: sftpSessionId,
|
||||
...credentials,
|
||||
sourceSessionId: options?.sourceSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
if (!sftpId) throw new Error("Failed to open SFTP session");
|
||||
|
||||
sftpSessionsRef.current.set(connectionId, sftpId);
|
||||
if (!isTargetConnectionCurrent()) {
|
||||
await closeSftpSessionForConnection();
|
||||
return;
|
||||
}
|
||||
|
||||
let startPath = sharedHostCache?.path ?? "/";
|
||||
let homeDir = sharedHostCache?.homeDir ?? startPath;
|
||||
@@ -387,6 +547,10 @@ export const useSftpConnections = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (initialPath) {
|
||||
startPath = initialPath;
|
||||
}
|
||||
|
||||
const provisionalCacheKey = sharedHostCache
|
||||
? makeCacheKey(connectionId, startPath, filenameEncoding)
|
||||
: null;
|
||||
@@ -430,7 +594,10 @@ export const useSftpConnections = ({
|
||||
throw new Error("Cannot list any remote directory");
|
||||
}
|
||||
}
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
if (!isTargetConnectionCurrent()) {
|
||||
await closeSftpSessionForConnection();
|
||||
return;
|
||||
}
|
||||
dirCacheRef.current.set(makeCacheKey(connectionId, startPath, filenameEncoding), {
|
||||
files,
|
||||
timestamp: Date.now(),
|
||||
@@ -452,6 +619,7 @@ export const useSftpConnections = ({
|
||||
status: "connected",
|
||||
currentPath: startPath,
|
||||
homeDir,
|
||||
reusedConnection: undefined,
|
||||
}
|
||||
: null,
|
||||
files,
|
||||
@@ -460,7 +628,10 @@ export const useSftpConnections = ({
|
||||
connectionLogs: [], // Clear after successful connect to avoid replay during navigation
|
||||
}));
|
||||
} catch (err) {
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
if (!isTargetConnectionCurrent()) {
|
||||
await closeSftpSessionForConnection();
|
||||
return;
|
||||
}
|
||||
reconnectingRef.current[side] = false;
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
@@ -480,6 +651,10 @@ export const useSftpConnections = ({
|
||||
reconnecting: false,
|
||||
}));
|
||||
} finally {
|
||||
activeHostKeySessionsRef.current.delete(sftpSessionId);
|
||||
if (hostKeyVerificationRef.current?.sessionId === sftpSessionId) {
|
||||
setPendingHostKeyVerification(null);
|
||||
}
|
||||
unsubSftpProgress?.();
|
||||
}
|
||||
}
|
||||
@@ -494,6 +669,7 @@ export const useSftpConnections = ({
|
||||
makeCacheKey,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
setPendingHostKeyVerification,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -579,5 +755,9 @@ export const useSftpConnections = ({
|
||||
disconnect,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
hostKeyVerification,
|
||||
rejectHostKeyVerification,
|
||||
acceptHostKeyVerification,
|
||||
acceptAndSaveHostKeyVerification,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useRef, useMemo, useState } from "react";
|
||||
import { FileConflict, FileConflictAction, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { getSftpConflictTypeKey } from "../../../domain/sftpConflict";
|
||||
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 +180,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 +286,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 +310,7 @@ export const useSftpExternalOperations = (
|
||||
|
||||
if (isLocalTempDownloadCancelled()) {
|
||||
await cleanupTempDownload(localTempPath);
|
||||
return { localTempPath: "" };
|
||||
return { localTempPath: "", sftpId, externalTransferId };
|
||||
}
|
||||
|
||||
if (bridge.registerTempFile) {
|
||||
@@ -322,11 +321,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 +386,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
|
||||
@@ -410,7 +502,7 @@ export const useSftpExternalOperations = (
|
||||
newModified: number;
|
||||
applyToAllCount: number;
|
||||
}): Promise<FileConflictAction> => {
|
||||
const conflictType = conflict.isDirectory ? "directory" : "file";
|
||||
const conflictType = getSftpConflictTypeKey(conflict.isDirectory, conflict.existingType);
|
||||
const defaultAction = conflictDefaults.get(conflictType);
|
||||
if (defaultAction) return defaultAction;
|
||||
|
||||
@@ -914,6 +1006,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]);
|
||||
}, []);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { buildSftpHostCredentials } from "./useSftpHostCredentials.ts";
|
||||
import type { Host, SSHKey } from "../../../domain/models.ts";
|
||||
import type { Host, KnownHost, SSHKey } from "../../../domain/models.ts";
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "host-1",
|
||||
@@ -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",
|
||||
@@ -77,6 +102,28 @@ test("buildSftpHostCredentials passes reference keys as identity file paths", ()
|
||||
assert.equal(credentials.passphrase, "saved-passphrase");
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials forwards known hosts for SFTP host-key checks", () => {
|
||||
const knownHosts: KnownHost[] = [{
|
||||
id: "kh-1",
|
||||
hostname: "example.com",
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
publicKey: "SHA256:abc",
|
||||
fingerprint: "abc",
|
||||
discoveredAt: 1,
|
||||
}];
|
||||
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host(),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
knownHosts,
|
||||
});
|
||||
|
||||
assert.equal(credentials.knownHosts, knownHosts);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials passes jump host reference keys as identity file paths", () => {
|
||||
const key: SSHKey = {
|
||||
id: "jump-key",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Host, Identity, SSHKey, TerminalSettings } from "../../../domain/models";
|
||||
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from "../../../domain/models";
|
||||
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
|
||||
@@ -13,6 +14,7 @@ interface UseSftpHostCredentialsParams {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
knownHosts?: KnownHost[];
|
||||
terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>;
|
||||
}
|
||||
|
||||
@@ -21,6 +23,7 @@ export const buildSftpHostCredentials = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts,
|
||||
terminalSettings,
|
||||
}: UseSftpHostCredentialsParams & { host: Host }): NetcattySSHOptions => {
|
||||
const globalKeepalive = terminalSettings ?? FALLBACK_KEEPALIVE;
|
||||
@@ -36,6 +39,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 +73,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 +105,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),
|
||||
}
|
||||
@@ -162,6 +167,7 @@ export const buildSftpHostCredentials = ({
|
||||
identityFilePaths: keyAuth.identityFilePaths,
|
||||
keepaliveInterval: targetKeepalive.interval,
|
||||
keepaliveCountMax: targetKeepalive.countMax,
|
||||
knownHosts,
|
||||
// Algorithm settings — must reach the SFTP bridge or hosts that need
|
||||
// legacy mode / the ECDSA skip / advanced overrides would still hit
|
||||
// the original negotiation failure when opening their SFTP pane,
|
||||
@@ -176,9 +182,10 @@ export const useSftpHostCredentials = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts,
|
||||
terminalSettings,
|
||||
}: UseSftpHostCredentialsParams) =>
|
||||
useCallback(
|
||||
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities, terminalSettings }),
|
||||
[hosts, identities, keys, terminalSettings],
|
||||
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities, knownHosts, terminalSettings }),
|
||||
[hosts, identities, keys, knownHosts, terminalSettings],
|
||||
);
|
||||
|
||||
@@ -7,6 +7,12 @@ import {
|
||||
TransferStatus,
|
||||
TransferTask,
|
||||
} from "../../../domain/models";
|
||||
import {
|
||||
canReplaceSftpConflict,
|
||||
describeSftpExistingKind,
|
||||
describeSftpIncomingKind,
|
||||
getSftpConflictTypeKey,
|
||||
} from "../../../domain/sftpConflict";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { SftpPane } from "./types";
|
||||
@@ -69,8 +75,14 @@ export const useSftpTransfers = ({
|
||||
);
|
||||
|
||||
const conflictDefaultKey = useCallback(
|
||||
(batchId: string | undefined, isDirectory: boolean) =>
|
||||
`${batchId ?? "global"}:${isDirectory ? "directory" : "file"}`,
|
||||
(batchId: string | undefined, isDirectory: boolean, existingType?: "file" | "directory" | "symlink") =>
|
||||
`${batchId ?? "global"}:${getSftpConflictTypeKey(isDirectory, existingType)}`,
|
||||
[],
|
||||
);
|
||||
|
||||
const buildReplaceTypeMismatchError = useCallback(
|
||||
(isDirectory: boolean, existingType: "file" | "directory" | "symlink" | undefined, targetPath: string) =>
|
||||
`Cannot replace existing ${describeSftpExistingKind(existingType)} with ${describeSftpIncomingKind(isDirectory)}: ${targetPath}`,
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -233,6 +245,33 @@ export const useSftpTransfers = ({
|
||||
const existingStat = await statTargetPath(targetPane, targetSftpId, task.targetPath, targetEncoding);
|
||||
|
||||
if (existingStat) {
|
||||
const applyToAllCount = task.batchId
|
||||
? await (async () => {
|
||||
const candidates = transfersRef.current.filter((candidate) =>
|
||||
candidate.batchId === task.batchId &&
|
||||
candidate.isDirectory === task.isDirectory &&
|
||||
!candidate.parentTaskId &&
|
||||
candidate.status !== "completed" &&
|
||||
candidate.status !== "cancelled",
|
||||
);
|
||||
const matches = await Promise.all(candidates.map(async (candidate) => {
|
||||
if (candidate.id === task.id) return true;
|
||||
try {
|
||||
const candidateStat = await statTargetPath(
|
||||
targetPane,
|
||||
targetSftpId,
|
||||
candidate.targetPath,
|
||||
targetEncoding,
|
||||
);
|
||||
return candidateStat?.type === existingStat.type;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
return Math.max(1, matches.filter(Boolean).length);
|
||||
})()
|
||||
: 1;
|
||||
|
||||
return {
|
||||
transferId: task.id,
|
||||
batchId: task.batchId,
|
||||
@@ -241,15 +280,7 @@ export const useSftpTransfers = ({
|
||||
targetPath: task.targetPath,
|
||||
isDirectory: task.isDirectory,
|
||||
existingType: existingStat.type,
|
||||
applyToAllCount: task.batchId
|
||||
? transfersRef.current.filter((candidate) =>
|
||||
candidate.batchId === task.batchId &&
|
||||
candidate.isDirectory === task.isDirectory &&
|
||||
!candidate.parentTaskId &&
|
||||
candidate.status !== "completed" &&
|
||||
candidate.status !== "cancelled",
|
||||
).length
|
||||
: 1,
|
||||
applyToAllCount,
|
||||
existingSize: existingStat.size,
|
||||
newSize: sourceStat?.size || task.totalBytes || 0,
|
||||
existingModified: existingStat.mtime,
|
||||
@@ -271,7 +302,9 @@ export const useSftpTransfers = ({
|
||||
const conflict = await conflictCheckPromise;
|
||||
|
||||
if (conflict) {
|
||||
const defaultAction = conflictDefaultsRef.current.get(conflictDefaultKey(task.batchId, task.isDirectory));
|
||||
const defaultAction = conflictDefaultsRef.current.get(
|
||||
conflictDefaultKey(task.batchId, task.isDirectory, conflict.existingType),
|
||||
);
|
||||
if (defaultAction) {
|
||||
if (defaultAction === "stop") {
|
||||
await markBatchStopped(task);
|
||||
@@ -285,6 +318,16 @@ export const useSftpTransfers = ({
|
||||
return "cancelled";
|
||||
}
|
||||
|
||||
if (defaultAction === "replace" && !canReplaceSftpConflict(task.isDirectory, conflict.existingType)) {
|
||||
updateTask({
|
||||
status: "failed",
|
||||
endTime: Date.now(),
|
||||
error: buildReplaceTypeMismatchError(task.isDirectory, conflict.existingType, task.targetPath),
|
||||
retryable: false,
|
||||
});
|
||||
return "failed";
|
||||
}
|
||||
|
||||
const duplicateTarget = defaultAction === "duplicate"
|
||||
? await getDuplicateTarget(task, targetPane, targetSftpId, targetEncoding)
|
||||
: null;
|
||||
@@ -728,16 +771,19 @@ export const useSftpTransfers = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedConflictKey = conflictDefaultKey(task.batchId, task.isDirectory);
|
||||
const selectedConflictKey = conflictDefaultKey(conflict.batchId, conflict.isDirectory, conflict.existingType);
|
||||
const affectedConflicts = applyToAll
|
||||
? conflictsRef.current.filter((candidate) =>
|
||||
conflictDefaultKey(candidate.batchId, candidate.isDirectory) === selectedConflictKey,
|
||||
conflictDefaultKey(candidate.batchId, candidate.isDirectory, candidate.existingType) === selectedConflictKey,
|
||||
)
|
||||
: [conflict];
|
||||
const affectedConflictIds = new Set(affectedConflicts.map((candidate) => candidate.transferId));
|
||||
const affectedTasks = affectedConflicts
|
||||
.map((candidate) => transfersRef.current.find((transfer) => transfer.id === candidate.transferId))
|
||||
.filter((candidate): candidate is TransferTask => Boolean(candidate));
|
||||
const affectedConflictById = new Map<string, FileConflict>(
|
||||
affectedConflicts.map((candidate): [string, FileConflict] => [candidate.transferId, candidate]),
|
||||
);
|
||||
|
||||
if (applyToAll) {
|
||||
conflictDefaultsRef.current.set(selectedConflictKey, action);
|
||||
@@ -771,9 +817,11 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
|
||||
const updatedTasks: TransferTask[] = [];
|
||||
const blockedReplaceTasks: Array<{ task: TransferTask; conflict: FileConflict }> = [];
|
||||
|
||||
for (const affectedTask of affectedTasks) {
|
||||
let updatedTask = { ...affectedTask };
|
||||
const affectedConflict = affectedConflictById.get(affectedTask.id);
|
||||
|
||||
if (action === "duplicate") {
|
||||
const endpoints = resolveTaskEndpoints(affectedTask);
|
||||
@@ -792,6 +840,13 @@ export const useSftpTransfers = ({
|
||||
skipConflictCheck: true,
|
||||
};
|
||||
} else if (action === "replace") {
|
||||
if (
|
||||
affectedConflict &&
|
||||
!canReplaceSftpConflict(affectedTask.isDirectory, affectedConflict.existingType)
|
||||
) {
|
||||
blockedReplaceTasks.push({ task: affectedTask, conflict: affectedConflict });
|
||||
continue;
|
||||
}
|
||||
updatedTask = {
|
||||
...affectedTask,
|
||||
skipConflictCheck: true,
|
||||
@@ -808,6 +863,28 @@ export const useSftpTransfers = ({
|
||||
updatedTasks.push(updatedTask);
|
||||
}
|
||||
|
||||
if (blockedReplaceTasks.length > 0) {
|
||||
const blockedTaskIds = new Set(blockedReplaceTasks.map(({ task }) => task.id));
|
||||
const blockedErrors = new Map(
|
||||
blockedReplaceTasks.map(({ task, conflict }) => [
|
||||
task.id,
|
||||
buildReplaceTypeMismatchError(task.isDirectory, conflict.existingType, task.targetPath),
|
||||
]),
|
||||
);
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => blockedTaskIds.has(t.id)
|
||||
? {
|
||||
...t,
|
||||
status: "failed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
error: blockedErrors.get(t.id),
|
||||
retryable: false,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const updatedTaskMap = new Map(updatedTasks.map((updatedTask) => [updatedTask.id, updatedTask]));
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => {
|
||||
|
||||
53
application/state/shellHistoryPersistence.test.ts
Normal file
53
application/state/shellHistoryPersistence.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { buildDockerLogsCommand } from '../../domain/systemManager/dockerShell.ts';
|
||||
import { loadSanitizedShellHistory } from './shellHistoryPersistence.ts';
|
||||
import type { ShellHistoryEntry } from '../../domain/models.ts';
|
||||
|
||||
const entry = (id: string, command: string): ShellHistoryEntry => ({
|
||||
id,
|
||||
command,
|
||||
hostId: 'host-1',
|
||||
hostLabel: 'Host',
|
||||
sessionId: 'session-1',
|
||||
timestamp: 1000,
|
||||
});
|
||||
|
||||
test('loadSanitizedShellHistory removes persisted managed startup commands and writes back cleaned history', () => {
|
||||
const stored = [
|
||||
entry('managed', buildDockerLogsCommand('587abcdef123')),
|
||||
entry('user', 'docker ps -a'),
|
||||
];
|
||||
let written: ShellHistoryEntry[] | null = null;
|
||||
|
||||
const loaded = loadSanitizedShellHistory({
|
||||
read: () => stored,
|
||||
write: (_key, value) => {
|
||||
written = value;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
loaded?.map((item) => item.command),
|
||||
['docker ps -a'],
|
||||
);
|
||||
assert.deepEqual(written, loaded);
|
||||
});
|
||||
|
||||
test('loadSanitizedShellHistory does not write when persisted history is already clean', () => {
|
||||
const stored = [entry('user', 'docker ps -a')];
|
||||
let writeCount = 0;
|
||||
|
||||
const loaded = loadSanitizedShellHistory({
|
||||
read: () => stored,
|
||||
write: () => {
|
||||
writeCount += 1;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(loaded, stored);
|
||||
assert.equal(writeCount, 0);
|
||||
});
|
||||
23
application/state/shellHistoryPersistence.ts
Normal file
23
application/state/shellHistoryPersistence.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ShellHistoryEntry } from '../../domain/models';
|
||||
import { sanitizeGlobalHistoryEntries } from '../../domain/globalHistory';
|
||||
import { STORAGE_KEY_SHELL_HISTORY } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
type ShellHistoryStorage = {
|
||||
read<T>(key: string): T | null;
|
||||
write<T>(key: string, value: T): boolean;
|
||||
};
|
||||
|
||||
export function loadSanitizedShellHistory(
|
||||
storage: ShellHistoryStorage = localStorageAdapter,
|
||||
storageKey = STORAGE_KEY_SHELL_HISTORY,
|
||||
): ShellHistoryEntry[] | null {
|
||||
const savedShellHistory = storage.read<ShellHistoryEntry[]>(storageKey);
|
||||
if (!savedShellHistory) return null;
|
||||
|
||||
const cleanedShellHistory = sanitizeGlobalHistoryEntries(savedShellHistory);
|
||||
if (cleanedShellHistory.length !== savedShellHistory.length) {
|
||||
storage.write(storageKey, cleanedShellHistory);
|
||||
}
|
||||
return cleanedShellHistory;
|
||||
}
|
||||
16
application/state/systemManagerDiagnostics.ts
Normal file
16
application/state/systemManagerDiagnostics.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export async function writeSystemManagerDiagnostic(
|
||||
message: string,
|
||||
extra?: Record<string, unknown>,
|
||||
) {
|
||||
try {
|
||||
await netcattyBridge.get()?.logDiagnostic?.({
|
||||
source: 'system-manager',
|
||||
message,
|
||||
extra,
|
||||
});
|
||||
} catch {
|
||||
// Diagnostics must never block the user action being diagnosed.
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
73
application/state/terminalConnectionReuse.ts
Normal file
73
application/state/terminalConnectionReuse.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
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,
|
||||
fontSize: session.fontSize,
|
||||
fontSizeOverride: session.fontSizeOverride,
|
||||
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,
|
||||
};
|
||||
}
|
||||
62
application/state/terminalDragData.ts
Normal file
62
application/state/terminalDragData.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export const WORKSPACE_SESSION_DRAG_TYPE = 'application/x-netcatty-workspace-session';
|
||||
|
||||
type DataTransferLike = {
|
||||
types: DOMStringList | readonly string[];
|
||||
getData: (format: string) => string;
|
||||
};
|
||||
|
||||
export function dataTransferHasType(dataTransfer: Pick<DataTransferLike, 'types'>, type: string): boolean {
|
||||
return Array.prototype.includes.call(dataTransfer.types, type);
|
||||
}
|
||||
|
||||
export function hasWorkspaceSessionDrag(dataTransfer: Pick<DataTransferLike, 'types'>): boolean {
|
||||
return dataTransferHasType(dataTransfer, WORKSPACE_SESSION_DRAG_TYPE);
|
||||
}
|
||||
|
||||
export function getWorkspaceSessionDragId(dataTransfer: DataTransferLike): string {
|
||||
return dataTransfer.getData(WORKSPACE_SESSION_DRAG_TYPE) || dataTransfer.getData('session-id');
|
||||
}
|
||||
|
||||
export function isPointInsideRect(
|
||||
point: { clientX: number; clientY: number },
|
||||
rect: Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom'>,
|
||||
): boolean {
|
||||
return point.clientX >= rect.left
|
||||
&& point.clientX <= rect.right
|
||||
&& point.clientY >= rect.top
|
||||
&& point.clientY <= rect.bottom;
|
||||
}
|
||||
|
||||
export type TopTabInsertionTarget = {
|
||||
tabId: string;
|
||||
position: 'before' | 'after';
|
||||
};
|
||||
|
||||
export function getTopTabInsertionTarget(
|
||||
point: { clientX: number; clientY: number },
|
||||
topTabsRoot: HTMLElement | null,
|
||||
): TopTabInsertionTarget | null {
|
||||
if (!topTabsRoot || !isPointInsideRect(point, topTabsRoot.getBoundingClientRect())) return null;
|
||||
|
||||
const tabs = Array.from(topTabsRoot.querySelectorAll<HTMLElement>('[data-tab-id]'))
|
||||
.filter((tab) => tab.dataset.tabType !== 'root');
|
||||
|
||||
if (tabs.length === 0) return null;
|
||||
|
||||
for (const tab of tabs) {
|
||||
const rect = tab.getBoundingClientRect();
|
||||
const midpoint = rect.left + rect.width / 2;
|
||||
const tabId = tab.dataset.tabId;
|
||||
if (!tabId) continue;
|
||||
if (point.clientX <= midpoint) {
|
||||
return { tabId, position: 'before' };
|
||||
}
|
||||
if (point.clientX <= rect.right) {
|
||||
return { tabId, position: 'after' };
|
||||
}
|
||||
}
|
||||
|
||||
const lastTab = tabs[tabs.length - 1];
|
||||
const lastTabId = lastTab?.dataset.tabId;
|
||||
return lastTabId ? { tabId: lastTabId, position: 'after' } : null;
|
||||
}
|
||||
5
application/state/terminalHostTreeAnimation.ts
Normal file
5
application/state/terminalHostTreeAnimation.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const TERMINAL_HOST_TREE_ANIMATION_MS = 220;
|
||||
export const TERMINAL_HOST_TREE_ANIMATION_EASING = 'cubic-bezier(0.4, 0, 0.2, 1)';
|
||||
export const TERMINAL_HOST_TREE_ANIMATION = `${TERMINAL_HOST_TREE_ANIMATION_MS}ms ${TERMINAL_HOST_TREE_ANIMATION_EASING}`;
|
||||
export const TERMINAL_HOST_TREE_LEFT_TRANSITION = `left ${TERMINAL_HOST_TREE_ANIMATION}`;
|
||||
export const TERMINAL_HOST_TREE_WIDTH_TRANSITION = `width ${TERMINAL_HOST_TREE_ANIMATION}`;
|
||||
46
application/state/terminalHostTreeStore.test.ts
Normal file
46
application/state/terminalHostTreeStore.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
TERMINAL_HOST_TREE_DEFAULT_WIDTH,
|
||||
clampTerminalHostTreeWidth,
|
||||
terminalHostTreeStore,
|
||||
} = await import('./terminalHostTreeStore.ts');
|
||||
|
||||
test('closing host tree state does not mutate layout width by itself', () => {
|
||||
terminalHostTreeStore.setIsOpen(true);
|
||||
terminalHostTreeStore.setLayoutWidth(240);
|
||||
|
||||
terminalHostTreeStore.setIsOpen(false);
|
||||
|
||||
assert.equal(terminalHostTreeStore.getLayoutWidth(), 240);
|
||||
terminalHostTreeStore.setLayoutWidth(0);
|
||||
});
|
||||
|
||||
test('opening host tree state does not jump the layout width', () => {
|
||||
storage.set('netcatty_terminal_host_tree_width_v1', '300');
|
||||
terminalHostTreeStore.setLayoutWidth(0);
|
||||
terminalHostTreeStore.setIsOpen(false);
|
||||
|
||||
terminalHostTreeStore.setIsOpen(true);
|
||||
|
||||
assert.equal(terminalHostTreeStore.getLayoutWidth(), 0);
|
||||
terminalHostTreeStore.setLayoutWidth(0);
|
||||
});
|
||||
|
||||
test('host tree restored layout width is clamped', () => {
|
||||
assert.equal(clampTerminalHostTreeWidth(80), 160);
|
||||
assert.equal(clampTerminalHostTreeWidth(999), 360);
|
||||
assert.equal(clampTerminalHostTreeWidth(0), 160);
|
||||
assert.equal(TERMINAL_HOST_TREE_DEFAULT_WIDTH, 220);
|
||||
});
|
||||
84
application/state/terminalHostTreeStore.ts
Normal file
84
application/state/terminalHostTreeStore.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
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;
|
||||
|
||||
export const TERMINAL_HOST_TREE_MIN_WIDTH = 160;
|
||||
export const TERMINAL_HOST_TREE_DEFAULT_WIDTH = 220;
|
||||
export const TERMINAL_HOST_TREE_MAX_WIDTH = 360;
|
||||
|
||||
export function clampTerminalHostTreeWidth(width: number): number {
|
||||
return Math.max(
|
||||
TERMINAL_HOST_TREE_MIN_WIDTH,
|
||||
Math.min(TERMINAL_HOST_TREE_MAX_WIDTH, width),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
localStorageAdapter.writeString(
|
||||
STORAGE_KEY_TERMINAL_HOST_TREE_COLLAPSED,
|
||||
open ? 'false' : 'true',
|
||||
);
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
setLayoutWidth = (width: number) => {
|
||||
const next = Math.max(0, Math.round(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("")}`;
|
||||
}
|
||||
129
application/state/themeTransition.test.ts
Normal file
129
application/state/themeTransition.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
THEME_TRANSITION_ATTR,
|
||||
THEME_TRANSITION_MS,
|
||||
runThemeTransition,
|
||||
} from "./themeTransition.ts";
|
||||
|
||||
function createRoot() {
|
||||
const attributes = new Map<string, string>();
|
||||
return {
|
||||
attributes,
|
||||
ownerDocument: { startViewTransition: undefined },
|
||||
setAttribute: (name: string, value: string) => attributes.set(name, value),
|
||||
removeAttribute: (name: string) => attributes.delete(name),
|
||||
getAttribute: (name: string) => attributes.get(name) ?? null,
|
||||
} as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
test("runThemeTransition applies tokens and clears fallback marker after duration", async () => {
|
||||
const root = createRoot();
|
||||
let applied = false;
|
||||
|
||||
runThemeTransition(() => {
|
||||
applied = true;
|
||||
}, root);
|
||||
|
||||
assert.equal(applied, true);
|
||||
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), "true");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, THEME_TRANSITION_MS + 60));
|
||||
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), null);
|
||||
});
|
||||
|
||||
test("runThemeTransition cancels a pending fallback reset when invoked again", () => {
|
||||
const root = createRoot();
|
||||
let count = 0;
|
||||
|
||||
runThemeTransition(() => {
|
||||
count += 1;
|
||||
}, root);
|
||||
runThemeTransition(() => {
|
||||
count += 2;
|
||||
}, root);
|
||||
|
||||
assert.equal(count, 3);
|
||||
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), "true");
|
||||
});
|
||||
|
||||
test("runThemeTransition uses view transition API when available", async () => {
|
||||
const root = createRoot();
|
||||
let applied = false;
|
||||
let finished = false;
|
||||
const doc = {
|
||||
startViewTransition: (callback: () => void) => {
|
||||
callback();
|
||||
return {
|
||||
finished: Promise.resolve().then(() => {
|
||||
finished = true;
|
||||
}),
|
||||
skipTransition: () => {},
|
||||
};
|
||||
},
|
||||
};
|
||||
(root as { ownerDocument: typeof doc }).ownerDocument = doc;
|
||||
|
||||
runThemeTransition(() => {
|
||||
applied = true;
|
||||
}, root);
|
||||
|
||||
assert.equal(applied, true);
|
||||
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), null);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(finished, true);
|
||||
});
|
||||
|
||||
test("runThemeTransition handles skipped view transitions", async () => {
|
||||
const root = createRoot();
|
||||
let applied = false;
|
||||
let rejectFinished!: (reason: unknown) => void;
|
||||
const doc = {
|
||||
startViewTransition: (callback: () => void) => {
|
||||
callback();
|
||||
return {
|
||||
finished: new Promise<void>((_, reject) => {
|
||||
rejectFinished = reject;
|
||||
}),
|
||||
skipTransition: () => {},
|
||||
};
|
||||
},
|
||||
};
|
||||
(root as { ownerDocument: typeof doc }).ownerDocument = doc;
|
||||
|
||||
runThemeTransition(() => {
|
||||
applied = true;
|
||||
}, root);
|
||||
|
||||
rejectFinished(new DOMException("Transition was skipped", "AbortError"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(applied, true);
|
||||
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), null);
|
||||
});
|
||||
|
||||
test("runThemeTransition can apply without animation for heavy tab switches", () => {
|
||||
const root = createRoot();
|
||||
let applied = false;
|
||||
let startViewTransitionCalled = false;
|
||||
const doc = {
|
||||
startViewTransition: (callback: () => void) => {
|
||||
startViewTransitionCalled = true;
|
||||
callback();
|
||||
return {
|
||||
finished: Promise.resolve(),
|
||||
skipTransition: () => {},
|
||||
};
|
||||
},
|
||||
};
|
||||
(root as { ownerDocument: typeof doc }).ownerDocument = doc;
|
||||
|
||||
runThemeTransition(() => {
|
||||
applied = true;
|
||||
}, { root, mode: "instant" });
|
||||
|
||||
assert.equal(applied, true);
|
||||
assert.equal(startViewTransitionCalled, false);
|
||||
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), null);
|
||||
});
|
||||
108
application/state/themeTransition.ts
Normal file
108
application/state/themeTransition.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { TERMINAL_HOST_TREE_ANIMATION_MS } from './terminalHostTreeAnimation';
|
||||
|
||||
export const THEME_TRANSITION_ATTR = 'data-theme-transition';
|
||||
export const THEME_TRANSITION_MS = TERMINAL_HOST_TREE_ANIMATION_MS;
|
||||
export type ThemeTransitionMode = 'view' | 'css' | 'instant';
|
||||
|
||||
type DocumentWithViewTransition = Document & {
|
||||
startViewTransition?: (callback: () => void | Promise<void>) => {
|
||||
finished: Promise<void>;
|
||||
skipTransition: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
type ThemeTransitionOptions = {
|
||||
root?: HTMLElement;
|
||||
mode?: ThemeTransitionMode;
|
||||
};
|
||||
|
||||
let cancelThemeTransitionReset: (() => void) | null = null;
|
||||
|
||||
function resolveOptions(rootOrOptions?: HTMLElement | ThemeTransitionOptions): Required<ThemeTransitionOptions> {
|
||||
if (
|
||||
rootOrOptions
|
||||
&& (
|
||||
Object.prototype.hasOwnProperty.call(rootOrOptions, 'root')
|
||||
|| Object.prototype.hasOwnProperty.call(rootOrOptions, 'mode')
|
||||
)
|
||||
) {
|
||||
const options = rootOrOptions as ThemeTransitionOptions;
|
||||
return {
|
||||
root: options.root ?? document.documentElement,
|
||||
mode: options.mode ?? 'view',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
root: rootOrOptions as HTMLElement | undefined ?? document.documentElement,
|
||||
mode: 'view',
|
||||
};
|
||||
}
|
||||
|
||||
function runCssThemeTransition(apply: () => void, root: HTMLElement, cleanup: () => void): void {
|
||||
root.setAttribute(THEME_TRANSITION_ATTR, 'true');
|
||||
apply();
|
||||
const timer = globalThis.setTimeout(cleanup, THEME_TRANSITION_MS + 40);
|
||||
cancelThemeTransitionReset = () => {
|
||||
globalThis.clearTimeout(timer);
|
||||
cleanup();
|
||||
};
|
||||
}
|
||||
|
||||
function skipViewTransition(transition: ReturnType<NonNullable<DocumentWithViewTransition['startViewTransition']>>): void {
|
||||
try {
|
||||
transition.skipTransition();
|
||||
} catch {
|
||||
// Already finished or skipped by the browser.
|
||||
}
|
||||
}
|
||||
|
||||
export function runThemeTransition(
|
||||
apply: () => void,
|
||||
rootOrOptions?: HTMLElement | ThemeTransitionOptions,
|
||||
): void {
|
||||
const { root, mode } = resolveOptions(rootOrOptions);
|
||||
cancelThemeTransitionReset?.();
|
||||
|
||||
const cleanup = () => {
|
||||
root.removeAttribute(THEME_TRANSITION_ATTR);
|
||||
cancelThemeTransitionReset = null;
|
||||
};
|
||||
|
||||
if (mode === 'instant') {
|
||||
apply();
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'css') {
|
||||
runCssThemeTransition(apply, root, cleanup);
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = root.ownerDocument as DocumentWithViewTransition | null;
|
||||
const startViewTransition = doc?.startViewTransition?.bind(doc);
|
||||
|
||||
if (startViewTransition) {
|
||||
let transition: ReturnType<NonNullable<DocumentWithViewTransition['startViewTransition']>> | null = null;
|
||||
try {
|
||||
transition = startViewTransition(() => {
|
||||
apply();
|
||||
});
|
||||
} catch {
|
||||
runCssThemeTransition(apply, root, cleanup);
|
||||
return;
|
||||
}
|
||||
|
||||
cancelThemeTransitionReset = () => {
|
||||
if (transition) {
|
||||
skipViewTransition(transition);
|
||||
}
|
||||
cleanup();
|
||||
};
|
||||
void transition.finished.then(cleanup, cleanup);
|
||||
return;
|
||||
}
|
||||
|
||||
runCssThemeTransition(apply, root, cleanup);
|
||||
}
|
||||
@@ -139,6 +139,181 @@ test("uploads picked folder files with their relative directory structure", asyn
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not replace an existing directory when uploading a same-named file", async () => {
|
||||
const file = new File(["local"], "dddd", { lastModified: 1234 });
|
||||
const deletedPaths: string[] = [];
|
||||
const uploadedPaths: string[] = [];
|
||||
|
||||
const results = await uploadFromFileList(
|
||||
[file],
|
||||
{
|
||||
targetPath: "/target",
|
||||
sftpId: "sftp-1",
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
statSftp: async (_sftpId, path) =>
|
||||
path === "/target/dddd"
|
||||
? { type: "directory", size: 0, lastModified: 1000 }
|
||||
: null,
|
||||
deleteSftp: async (_sftpId, path) => {
|
||||
deletedPaths.push(path);
|
||||
},
|
||||
writeSftpBinary: async (_sftpId, path) => {
|
||||
uploadedPaths.push(path);
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
resolveConflict: async () => "replace",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(deletedPaths, []);
|
||||
assert.deepEqual(uploadedPaths, []);
|
||||
assert.equal(results.length, 1);
|
||||
assert.equal(results[0].fileName, "dddd");
|
||||
assert.equal(results[0].success, false);
|
||||
assert.match(results[0].error ?? "", /directory/i);
|
||||
});
|
||||
|
||||
test("counts apply-to-all upload conflicts by incoming and existing type", async () => {
|
||||
const files = [
|
||||
new File(["local"], "existing-file", { lastModified: 1234 }),
|
||||
new File(["local"], "existing-directory", { lastModified: 1234 }),
|
||||
];
|
||||
const conflictCounts: number[] = [];
|
||||
|
||||
const results = await uploadFromFileList(
|
||||
files,
|
||||
{
|
||||
targetPath: "/target",
|
||||
sftpId: "sftp-1",
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
statSftp: async (_sftpId, path) => {
|
||||
if (path === "/target/existing-file") {
|
||||
return { type: "file", size: 2, lastModified: 1000 };
|
||||
}
|
||||
if (path === "/target/existing-directory") {
|
||||
return { type: "directory", size: 0, lastModified: 1000 };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
writeSftpBinary: async () => {
|
||||
throw new Error("skipped conflicts should not upload");
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
resolveConflict: async (conflict) => {
|
||||
conflictCounts.push(conflict.applyToAllCount);
|
||||
return "skip";
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(conflictCounts, [1, 1]);
|
||||
assert.deepEqual(results, [
|
||||
{ fileName: "existing-file", success: false, cancelled: true },
|
||||
{ fileName: "existing-directory", success: false, cancelled: true },
|
||||
]);
|
||||
});
|
||||
|
||||
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[] = [];
|
||||
|
||||
|
||||
350
application/state/useAISettingsState.ts
Normal file
350
application/state/useAISettingsState.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
STORAGE_KEY_AI_PROVIDERS,
|
||||
STORAGE_KEY_AI_ACTIVE_PROVIDER,
|
||||
STORAGE_KEY_AI_ACTIVE_MODEL,
|
||||
STORAGE_KEY_AI_PERMISSION_MODE,
|
||||
STORAGE_KEY_AI_TOOL_INTEGRATION_MODE,
|
||||
STORAGE_KEY_AI_EXTERNAL_AGENTS,
|
||||
STORAGE_KEY_AI_DEFAULT_AGENT,
|
||||
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
|
||||
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
STORAGE_KEY_AI_QUICK_MESSAGES,
|
||||
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type { AIQuickMessage } from '../../infrastructure/ai/quickMessages';
|
||||
import { sanitizeQuickMessages } from '../../infrastructure/ai/quickMessages';
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
AIToolIntegrationMode,
|
||||
ExternalAgentConfig,
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
|
||||
import { removeProviderReferences } from './aiProviderCleanup';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
import { getAIBridge } from './aiStateSnapshots';
|
||||
import { useStoredBoolean } from './useStoredBoolean';
|
||||
|
||||
function readPermissionMode(): AIPermissionMode {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
|
||||
if (stored === 'observer' || stored === 'confirm' || stored === 'autonomous') return stored;
|
||||
return 'confirm';
|
||||
}
|
||||
|
||||
function readToolIntegrationMode(): AIToolIntegrationMode {
|
||||
return localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE) === 'skills'
|
||||
? 'skills'
|
||||
: 'mcp';
|
||||
}
|
||||
|
||||
export function useAISettingsState() {
|
||||
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
|
||||
localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS) ?? []
|
||||
);
|
||||
const [activeProviderId, setActiveProviderIdRaw] = useState<string>(() =>
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER) ?? ''
|
||||
);
|
||||
const [activeModelId, setActiveModelIdRaw] = useState<string>(() =>
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL) ?? ''
|
||||
);
|
||||
const [globalPermissionMode, setGlobalPermissionModeRaw] = useState<AIPermissionMode>(readPermissionMode);
|
||||
const [toolIntegrationMode, setToolIntegrationModeRaw] = useState<AIToolIntegrationMode>(readToolIntegrationMode);
|
||||
const [externalAgents, setExternalAgentsRaw] = useState<ExternalAgentConfig[]>(() =>
|
||||
localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS) ?? []
|
||||
);
|
||||
const [defaultAgentId, setDefaultAgentIdRaw] = useState<string>(() =>
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty'
|
||||
);
|
||||
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(() =>
|
||||
localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST]
|
||||
);
|
||||
const [commandTimeout, setCommandTimeoutRaw] = useState<number>(() =>
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60
|
||||
);
|
||||
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
|
||||
);
|
||||
const [webSearchConfig, setWebSearchConfigRaw] = useState<WebSearchConfig | null>(() =>
|
||||
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
|
||||
);
|
||||
const [quickMessages, setQuickMessagesRaw] = useState<AIQuickMessage[]>(() =>
|
||||
sanitizeQuickMessages(localStorageAdapter.read<unknown>(STORAGE_KEY_AI_QUICK_MESSAGES)),
|
||||
);
|
||||
const [showTerminalSelectionAIAction, setShowTerminalSelectionAIAction] = useStoredBoolean(
|
||||
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
|
||||
true,
|
||||
);
|
||||
|
||||
const setProviders = useCallback((value: ProviderConfig[] | ((prev: ProviderConfig[]) => ProviderConfig[])) => {
|
||||
setProvidersRaw((prev) => {
|
||||
const next = typeof value === 'function' ? value(prev) : value;
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PROVIDERS, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addProvider = useCallback((provider: ProviderConfig) => {
|
||||
setProviders((prev) => [...prev, provider]);
|
||||
}, [setProviders]);
|
||||
|
||||
const updateProvider = useCallback((id: string, updates: Partial<ProviderConfig>) => {
|
||||
setProviders((prev) => prev.map((provider) => (
|
||||
provider.id === id ? { ...provider, ...updates } : provider
|
||||
)));
|
||||
}, [setProviders]);
|
||||
|
||||
const removeProvider = useCallback((id: string) => {
|
||||
setProviders((prev) => prev.filter((provider) => provider.id !== id));
|
||||
setActiveProviderIdRaw((prevId) => {
|
||||
if (prevId !== id) return prevId;
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, '');
|
||||
return '';
|
||||
});
|
||||
|
||||
const agentProviderMap =
|
||||
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP) ?? {};
|
||||
const agentModelMap =
|
||||
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {};
|
||||
const cleanup = removeProviderReferences(id, agentProviderMap, agentModelMap);
|
||||
if (cleanup.providerMapChanged) {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, cleanup.agentProviderMap);
|
||||
}
|
||||
if (cleanup.modelMapChanged) {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, cleanup.agentModelMap);
|
||||
}
|
||||
}, [setProviders]);
|
||||
|
||||
const setActiveProviderId = useCallback((id: string) => {
|
||||
setActiveProviderIdRaw(id);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, id);
|
||||
}, []);
|
||||
|
||||
const setActiveModelId = useCallback((id: string) => {
|
||||
setActiveModelIdRaw(id);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_MODEL, id);
|
||||
}, []);
|
||||
|
||||
const setGlobalPermissionMode = useCallback((mode: AIPermissionMode) => {
|
||||
setGlobalPermissionModeRaw(mode);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_PERMISSION_MODE, mode);
|
||||
getAIBridge()?.aiMcpSetPermissionMode?.(mode);
|
||||
}, []);
|
||||
|
||||
const setToolIntegrationMode = useCallback((mode: AIToolIntegrationMode) => {
|
||||
setToolIntegrationModeRaw(mode);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE, mode);
|
||||
getAIBridge()?.aiMcpSetToolIntegrationMode?.(mode);
|
||||
}, []);
|
||||
|
||||
const setExternalAgents = useCallback((value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => {
|
||||
setExternalAgentsRaw((prev) => {
|
||||
const next = typeof value === 'function' ? value(prev) : value;
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_EXTERNAL_AGENTS, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setDefaultAgentId = useCallback((id: string) => {
|
||||
setDefaultAgentIdRaw(id);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_DEFAULT_AGENT, id);
|
||||
}, []);
|
||||
|
||||
const setCommandBlocklist = useCallback((value: string[]) => {
|
||||
setCommandBlocklistRaw(value);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, value);
|
||||
getAIBridge()?.aiMcpSetCommandBlocklist?.(value);
|
||||
}, []);
|
||||
|
||||
const setCommandTimeout = useCallback((value: number) => {
|
||||
setCommandTimeoutRaw(value);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, value);
|
||||
getAIBridge()?.aiMcpSetCommandTimeout?.(value);
|
||||
}, []);
|
||||
|
||||
const setMaxIterations = useCallback((value: number) => {
|
||||
setMaxIterationsRaw(value);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, value);
|
||||
getAIBridge()?.aiMcpSetMaxIterations?.(value);
|
||||
}, []);
|
||||
|
||||
const setWebSearchConfig = useCallback((config: WebSearchConfig | null) => {
|
||||
setWebSearchConfigRaw(config);
|
||||
if (config) {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_WEB_SEARCH, config);
|
||||
} else {
|
||||
localStorageAdapter.remove(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setQuickMessages = useCallback((value: AIQuickMessage[] | ((prev: AIQuickMessage[]) => AIQuickMessage[])) => {
|
||||
setQuickMessagesRaw((prev) => {
|
||||
const nextRaw = typeof value === 'function' ? value(prev) : value;
|
||||
const next = sanitizeQuickMessages(nextRaw);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_QUICK_MESSAGES, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_QUICK_MESSAGES);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const syncFromStorageKey = (key: string | null) => {
|
||||
try {
|
||||
switch (key) {
|
||||
case STORAGE_KEY_AI_PROVIDERS: {
|
||||
const parsed = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS);
|
||||
if (parsed != null && !Array.isArray(parsed)) break;
|
||||
setProvidersRaw(parsed ?? []);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_ACTIVE_PROVIDER:
|
||||
setActiveProviderIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER) ?? '');
|
||||
break;
|
||||
case STORAGE_KEY_AI_ACTIVE_MODEL:
|
||||
setActiveModelIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL) ?? '');
|
||||
break;
|
||||
case STORAGE_KEY_AI_PERMISSION_MODE:
|
||||
setGlobalPermissionModeRaw(readPermissionMode());
|
||||
getAIBridge()?.aiMcpSetPermissionMode?.(readPermissionMode());
|
||||
break;
|
||||
case STORAGE_KEY_AI_TOOL_INTEGRATION_MODE:
|
||||
setToolIntegrationModeRaw(readToolIntegrationMode());
|
||||
getAIBridge()?.aiMcpSetToolIntegrationMode?.(readToolIntegrationMode());
|
||||
break;
|
||||
case STORAGE_KEY_AI_EXTERNAL_AGENTS: {
|
||||
const agents = localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS);
|
||||
if (agents != null && !Array.isArray(agents)) break;
|
||||
setExternalAgentsRaw(agents ?? []);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_DEFAULT_AGENT:
|
||||
setDefaultAgentIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty');
|
||||
break;
|
||||
case STORAGE_KEY_AI_COMMAND_BLOCKLIST: {
|
||||
const list = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
|
||||
if (list != null && !Array.isArray(list)) break;
|
||||
const blocklist = list ?? [...DEFAULT_COMMAND_BLOCKLIST];
|
||||
setCommandBlocklistRaw(blocklist);
|
||||
getAIBridge()?.aiMcpSetCommandBlocklist?.(blocklist);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_COMMAND_TIMEOUT: {
|
||||
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60;
|
||||
if (!Number.isFinite(timeout)) break;
|
||||
setCommandTimeoutRaw(timeout);
|
||||
getAIBridge()?.aiMcpSetCommandTimeout?.(timeout);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_MAX_ITERATIONS: {
|
||||
const iters = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
|
||||
if (!Number.isFinite(iters)) break;
|
||||
setMaxIterationsRaw(iters);
|
||||
getAIBridge()?.aiMcpSetMaxIterations?.(iters);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_WEB_SEARCH:
|
||||
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
|
||||
break;
|
||||
case STORAGE_KEY_AI_QUICK_MESSAGES:
|
||||
setQuickMessagesRaw(sanitizeQuickMessages(localStorageAdapter.read<unknown>(STORAGE_KEY_AI_QUICK_MESSAGES)));
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[useAISettingsState] Failed to process AI settings storage change', key, err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStorage = (event: StorageEvent) => syncFromStorageKey(event.key);
|
||||
const handleLocalStateChanged = (event: Event) => {
|
||||
syncFromStorageKey((event as CustomEvent<{ key?: string }>).detail?.key ?? null);
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorage);
|
||||
window.addEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorage);
|
||||
window.removeEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = getAIBridge();
|
||||
bridge?.aiMcpSetCommandBlocklist?.(commandBlocklist);
|
||||
bridge?.aiMcpSetCommandTimeout?.(commandTimeout);
|
||||
bridge?.aiMcpSetMaxIterations?.(maxIterations);
|
||||
bridge?.aiMcpSetPermissionMode?.(globalPermissionMode);
|
||||
bridge?.aiMcpSetToolIntegrationMode?.(toolIntegrationMode);
|
||||
}, [commandBlocklist, commandTimeout, globalPermissionMode, maxIterations, toolIntegrationMode]);
|
||||
|
||||
const activeProvider = providers.find((provider) => provider.id === activeProviderId) ?? null;
|
||||
|
||||
return useMemo(() => ({
|
||||
providers,
|
||||
setProviders,
|
||||
addProvider,
|
||||
updateProvider,
|
||||
removeProvider,
|
||||
activeProviderId,
|
||||
setActiveProviderId,
|
||||
activeModelId,
|
||||
setActiveModelId,
|
||||
activeProvider,
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode,
|
||||
toolIntegrationMode,
|
||||
setToolIntegrationMode,
|
||||
externalAgents,
|
||||
setExternalAgents,
|
||||
defaultAgentId,
|
||||
setDefaultAgentId,
|
||||
commandBlocklist,
|
||||
setCommandBlocklist,
|
||||
commandTimeout,
|
||||
setCommandTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
webSearchConfig,
|
||||
setWebSearchConfig,
|
||||
quickMessages,
|
||||
setQuickMessages,
|
||||
showTerminalSelectionAIAction,
|
||||
setShowTerminalSelectionAIAction,
|
||||
}), [
|
||||
providers,
|
||||
setProviders,
|
||||
addProvider,
|
||||
updateProvider,
|
||||
removeProvider,
|
||||
activeProviderId,
|
||||
setActiveProviderId,
|
||||
activeModelId,
|
||||
setActiveModelId,
|
||||
activeProvider,
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode,
|
||||
toolIntegrationMode,
|
||||
setToolIntegrationMode,
|
||||
externalAgents,
|
||||
setExternalAgents,
|
||||
defaultAgentId,
|
||||
setDefaultAgentId,
|
||||
commandBlocklist,
|
||||
setCommandBlocklist,
|
||||
commandTimeout,
|
||||
setCommandTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
webSearchConfig,
|
||||
setWebSearchConfig,
|
||||
quickMessages,
|
||||
setQuickMessages,
|
||||
showTerminalSelectionAIAction,
|
||||
setShowTerminalSelectionAIAction,
|
||||
]);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user