mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
072377777f | ||
|
|
27b62b2372 | ||
|
|
627e652f56 | ||
|
|
17c2eb6346 | ||
|
|
01102e2d66 | ||
|
|
81dcf849fb | ||
|
|
1dfe1379cd | ||
|
|
27ec58fa40 | ||
|
|
7ac878d00e | ||
|
|
f90f40e624 | ||
|
|
dfc9ed7f61 | ||
|
|
2c2d7e0b55 | ||
|
|
ef364b69fd | ||
|
|
700d603e64 | ||
|
|
f2b3facd9f | ||
|
|
7f08bec0b9 | ||
|
|
bbd4c698a5 | ||
|
|
4beccc3d61 | ||
|
|
60e057318f | ||
|
|
33b3beaea0 | ||
|
|
e65af2fd7b | ||
|
|
5d63a9476c | ||
|
|
58c72ace32 | ||
|
|
75cbda7edc | ||
|
|
6f6d8dc36d | ||
|
|
d0f819efb0 | ||
|
|
9671468412 | ||
|
|
380e96eae5 | ||
|
|
e4f9303de5 | ||
|
|
02ce011934 | ||
|
|
8e45763e4c | ||
|
|
0d92c5d738 | ||
|
|
c8a393f12b | ||
|
|
6bf4268e8e | ||
|
|
7114288d2f | ||
|
|
f02b645af7 | ||
|
|
eb3cd06278 | ||
|
|
7ed0b32489 | ||
|
|
2fe46cbf6f | ||
|
|
53ebbec58c | ||
|
|
0382c089e1 | ||
|
|
253ce57d84 | ||
|
|
9beb7d3108 | ||
|
|
525a78e83e | ||
|
|
0e94950a5c | ||
|
|
a2ea0f6464 | ||
|
|
42b174abb3 | ||
|
|
e2ad0f526e | ||
|
|
f8c7dd44db | ||
|
|
cfeee2ec89 | ||
|
|
be3e971064 | ||
|
|
35bb83f57c | ||
|
|
5279ac2836 | ||
|
|
793ba54bda | ||
|
|
a1ca1a3443 | ||
|
|
3c528c6ab5 | ||
|
|
6dd703afce | ||
|
|
10c399f47b | ||
|
|
a4ec7bab61 | ||
|
|
79215d8896 | ||
|
|
2012fc53c7 | ||
|
|
2be738b8c2 | ||
|
|
93d7f8e1ec | ||
|
|
924c9a0efa | ||
|
|
0d9da150ec | ||
|
|
d307a2ebc3 | ||
|
|
d97439f878 | ||
|
|
8285d7010b | ||
|
|
1f0ddd2bdb | ||
|
|
98db9e6fea | ||
|
|
8e53ec829f | ||
|
|
549c5534d5 | ||
|
|
8fae84148c | ||
|
|
6d14f1e3d6 | ||
|
|
aebe25ae70 | ||
|
|
df045bf144 | ||
|
|
32447885d9 | ||
|
|
195a23e380 | ||
|
|
459ac09ebb | ||
|
|
a1ea4cc546 | ||
|
|
25224cef7b | ||
|
|
452058bd42 | ||
|
|
50c25d8789 | ||
|
|
96a38593bf | ||
|
|
e3b9e71d8f | ||
|
|
01b9f745b6 | ||
|
|
e96384cc7f | ||
|
|
a5bf1765d4 | ||
|
|
c921e7d9a4 | ||
|
|
a1b7f3708a | ||
|
|
6a4a1ffd92 | ||
|
|
105107755b | ||
|
|
028e86b111 | ||
|
|
151c15b38f | ||
|
|
544613c007 | ||
|
|
b2886ed16b | ||
|
|
57e11822f2 | ||
|
|
ff05c33fc9 | ||
|
|
a2ff82187b | ||
|
|
997bba6e2c | ||
|
|
13979790e8 | ||
|
|
19cce8c82b | ||
|
|
02746754f3 | ||
|
|
f940bbc280 | ||
|
|
7e60b64766 | ||
|
|
18330fd1d1 | ||
|
|
ebfdb645a7 | ||
|
|
b89607dbb0 | ||
|
|
2471013aba | ||
|
|
2343f80ed2 | ||
|
|
8d94252990 | ||
|
|
87b4089812 | ||
|
|
d7377fe301 | ||
|
|
518e4a12ac | ||
|
|
fbaa038b0d | ||
|
|
6c4e27cb38 | ||
|
|
5dc87ee178 | ||
|
|
a363ac5812 | ||
|
|
02bfea153c | ||
|
|
43534c34f1 | ||
|
|
a19bf708ac | ||
|
|
bbfa2ac6bf | ||
|
|
16f0304a5b | ||
|
|
e78d78b428 | ||
|
|
f6d846fbda | ||
|
|
b9702b043a | ||
|
|
89b3e27329 | ||
|
|
7e600d54e0 | ||
|
|
87aa2ea931 | ||
|
|
ac3993b789 | ||
|
|
4935066e53 | ||
|
|
315e596e08 | ||
|
|
5f874a3156 | ||
|
|
ae58d60f8f | ||
|
|
8856c7e5ab | ||
|
|
c47fcce17d | ||
|
|
53da304e39 | ||
|
|
2df408a571 | ||
|
|
a965a94f0d | ||
|
|
f9803f3642 | ||
|
|
ddfeb0a1a8 | ||
|
|
0d6fc5f7f7 | ||
|
|
2b381c87be | ||
|
|
193a14febb | ||
|
|
8ffdc671b9 | ||
|
|
837f4ddfaa | ||
|
|
f8bdd950bc | ||
|
|
4fb0f0c470 | ||
|
|
afc27e759b | ||
|
|
ce41379796 | ||
|
|
27b2c1954f | ||
|
|
b36642994c | ||
|
|
701ef1449b | ||
|
|
25291591fa | ||
|
|
0ad1713f25 | ||
|
|
d8efadc9fd | ||
|
|
c0f1504b4a | ||
|
|
b41728968e | ||
|
|
589abaaa90 | ||
|
|
cc8ba20839 | ||
|
|
589001cfb7 | ||
|
|
fcd122d4a1 | ||
|
|
9589f10ffb | ||
|
|
8d14f0f3c2 | ||
|
|
3f67ccf3b2 | ||
|
|
daa3490654 | ||
|
|
eebd9acfa2 | ||
|
|
8b84a13d9f | ||
|
|
249468b095 | ||
|
|
f4bb1775b3 | ||
|
|
f2154a853a | ||
|
|
82c339246c | ||
|
|
2169c2f95c | ||
|
|
d189692a93 | ||
|
|
fee778a775 | ||
|
|
d03f8873c5 | ||
|
|
5f464b1b8e | ||
|
|
68385b42b9 | ||
|
|
e60db5e08a | ||
|
|
71c64e3c6c | ||
|
|
d27fc24cae | ||
|
|
593da6f52d | ||
|
|
0527df7268 | ||
|
|
9ffb0d8642 | ||
|
|
d45b4dd65b | ||
|
|
63278d5d40 | ||
|
|
b6321eea6f | ||
|
|
39138096c0 | ||
|
|
5c355da8c1 | ||
|
|
e96a51bfe4 | ||
|
|
96628e8bd0 | ||
|
|
352de0131e | ||
|
|
df9284b9ab | ||
|
|
4f46a3728b | ||
|
|
5294f63544 | ||
|
|
7e843f6f2e | ||
|
|
2004f26c3e | ||
|
|
7c478215c8 | ||
|
|
01b818d598 | ||
|
|
0d453a42c4 | ||
|
|
5dcba28621 | ||
|
|
3513addb9c | ||
|
|
c9bc39d1a1 | ||
|
|
03acd48585 | ||
|
|
fd295e7529 | ||
|
|
f4e14afc9d | ||
|
|
93dce066fd | ||
|
|
49b88241db | ||
|
|
b67ff061c2 | ||
|
|
8a482dc928 | ||
|
|
3d433c8727 | ||
|
|
ea765ce074 | ||
|
|
8ee55963d8 | ||
|
|
b1a0a26d01 | ||
|
|
c718d042ac | ||
|
|
3490b0673d | ||
|
|
70607cb26d | ||
|
|
a56b868d10 | ||
|
|
c4e2391dd8 | ||
|
|
cfa47270be | ||
|
|
b7979e02be | ||
|
|
2c6390bf6f | ||
|
|
0f535d0b80 | ||
|
|
68ad8a6118 | ||
|
|
0144c9c6d3 | ||
|
|
4bcc619de4 | ||
|
|
cc5ad3be16 | ||
|
|
443202ba4e | ||
|
|
6c246bb8f4 | ||
|
|
90df595e43 | ||
|
|
ab3580982d | ||
|
|
bd81b1fbcf | ||
|
|
205d1591e8 | ||
|
|
30004e7716 | ||
|
|
75452055ae | ||
|
|
08d1bb5f81 | ||
|
|
6bc65a43de | ||
|
|
904af93a1d | ||
|
|
a8bd5e0f16 | ||
|
|
61839acaca | ||
|
|
d7b749d23f | ||
|
|
a1b338414f | ||
|
|
c4015b9b68 | ||
|
|
8bbead1261 | ||
|
|
c3a7a4758b | ||
|
|
9a3d96be76 | ||
|
|
69df81613b | ||
|
|
583c75b107 | ||
|
|
ae74f25588 | ||
|
|
07c914a73d | ||
|
|
a97002c0a7 | ||
|
|
2ea5efacba | ||
|
|
9c5da74486 | ||
|
|
7bdbbe2e16 | ||
|
|
4747c45a99 | ||
|
|
e40aace189 |
No files matched your search
@@ -176,9 +176,10 @@ jobs:
|
|||||||
pack_script: pack:mac
|
pack_script: pack:mac
|
||||||
- name: windows
|
- name: windows
|
||||||
os: windows-latest
|
os: windows-latest
|
||||||
# The mosh binary workflow currently produces win32-x64 only.
|
# Official Windows releases are x64-only for now. Bundled mosh/et
|
||||||
# Keep official packages aligned with bundled-mosh coverage
|
# are win32-x64, and win.target must not hard-code arm64 or the
|
||||||
# until Cygwin arm64 is stable enough to build win32-arm64.
|
# x64 job still emits broken win-arm64 / universal NSIS packages
|
||||||
|
# (#2570). Use pack:win-x64 until a dedicated arm64 job exists.
|
||||||
pack_script: pack:win-x64
|
pack_script: pack:win-x64
|
||||||
env:
|
env:
|
||||||
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
|
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
|
||||||
|
|||||||
@@ -196,9 +196,16 @@ jobs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event === 'issues') {
|
if (event === 'issues') {
|
||||||
return set('issue_classify', {
|
const issue = context.payload.issue;
|
||||||
issue_number: String(context.payload.issue.number),
|
const decision = auto.decideIssuesEventRoute({
|
||||||
reason: `issues:${context.payload.action}`,
|
action: context.payload.action,
|
||||||
|
labels: issue?.labels || [],
|
||||||
|
actorLogin: context.payload.sender?.login,
|
||||||
|
botLogins: process.env.ISSUE_BOT_LOGINS,
|
||||||
|
});
|
||||||
|
return set(decision.kind, {
|
||||||
|
issue_number: String(issue.number),
|
||||||
|
reason: decision.reason,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +271,7 @@ jobs:
|
|||||||
hasOpenBotPull: Boolean(pull),
|
hasOpenBotPull: Boolean(pull),
|
||||||
hasOpenRelatedPull: Boolean(relatedPull),
|
hasOpenRelatedPull: Boolean(relatedPull),
|
||||||
body: comment.body,
|
body: comment.body,
|
||||||
|
labels,
|
||||||
});
|
});
|
||||||
if (refined.kind === 'issue_classify') {
|
if (refined.kind === 'issue_classify') {
|
||||||
return set('issue_classify', {
|
return set('issue_classify', {
|
||||||
@@ -349,6 +357,41 @@ jobs:
|
|||||||
|
|
||||||
return set('skip', { reason: `unhandled ${event}` });
|
return set('skip', { reason: `unhandled ${event}` });
|
||||||
|
|
||||||
|
ready_for_human_handoff:
|
||||||
|
name: Hand reopened issue to maintainers
|
||||||
|
needs: route
|
||||||
|
if: needs.route.outputs.kind == 'ready_for_human_handoff'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
concurrency:
|
||||||
|
group: cursor-source-issue-${{ needs.route.outputs.issue_number || github.run_id }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout helpers
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.repository.default_branch }}
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Apply ready-for-human handoff
|
||||||
|
uses: actions/github-script@v9
|
||||||
|
env:
|
||||||
|
ISSUE_NUMBER: ${{ needs.route.outputs.issue_number }}
|
||||||
|
TRUSTED_COMMENT_AUTHORS: ${{ vars.AUTOMATION_OWN_ACTORS || 'binaricat,netcatty-bot,github-actions[bot]' }}
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.TRIAGE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||||
|
script: |
|
||||||
|
const auto = require(`${process.env.GITHUB_WORKSPACE}/scripts/cursor-automation.cjs`);
|
||||||
|
await auto.applyReadyForHumanHandoff({
|
||||||
|
github,
|
||||||
|
context,
|
||||||
|
issueNumber: process.env.ISSUE_NUMBER,
|
||||||
|
dedupeMarker: auto.REOPEN_HANDOFF_MARKER,
|
||||||
|
trustedCommentAuthors: process.env.TRUSTED_COMMENT_AUTHORS,
|
||||||
|
});
|
||||||
|
|
||||||
cleanup_source_issue:
|
cleanup_source_issue:
|
||||||
name: Clean source issue state
|
name: Clean source issue state
|
||||||
needs: route
|
needs: route
|
||||||
@@ -2093,6 +2136,7 @@ jobs:
|
|||||||
".cursor-runtime/followup-pending-snapshots.json",
|
".cursor-runtime/followup-pending-snapshots.json",
|
||||||
".cursor-runtime/followup-verify-result.json",
|
".cursor-runtime/followup-verify-result.json",
|
||||||
".cursor-runtime/followup-test-comparison.json",
|
".cursor-runtime/followup-test-comparison.json",
|
||||||
|
".cursor-runtime/followup-base-tests.log",
|
||||||
".cursor-runtime/followup-candidate-lint.log",
|
".cursor-runtime/followup-candidate-lint.log",
|
||||||
".cursor-runtime/followup-candidate-tests.log",
|
".cursor-runtime/followup-candidate-tests.log",
|
||||||
".cursor-runtime/followup-candidate-build.log",
|
".cursor-runtime/followup-candidate-build.log",
|
||||||
@@ -2114,6 +2158,7 @@ jobs:
|
|||||||
.cursor-runtime/followup-pending-snapshots.json
|
.cursor-runtime/followup-pending-snapshots.json
|
||||||
.cursor-runtime/followup-verify-result.json
|
.cursor-runtime/followup-verify-result.json
|
||||||
.cursor-runtime/followup-test-comparison.json
|
.cursor-runtime/followup-test-comparison.json
|
||||||
|
.cursor-runtime/followup-base-tests.log
|
||||||
.cursor-runtime/followup-candidate-lint.log
|
.cursor-runtime/followup-candidate-lint.log
|
||||||
.cursor-runtime/followup-candidate-tests.log
|
.cursor-runtime/followup-candidate-tests.log
|
||||||
.cursor-runtime/followup-candidate-build.log
|
.cursor-runtime/followup-candidate-build.log
|
||||||
@@ -3379,6 +3424,7 @@ jobs:
|
|||||||
".cursor-runtime/implement-pr-body.md",
|
".cursor-runtime/implement-pr-body.md",
|
||||||
".cursor-runtime/verify-result.json",
|
".cursor-runtime/verify-result.json",
|
||||||
".cursor-runtime/test-comparison.json",
|
".cursor-runtime/test-comparison.json",
|
||||||
|
".cursor-runtime/base-tests.log",
|
||||||
".cursor-runtime/candidate-lint.log",
|
".cursor-runtime/candidate-lint.log",
|
||||||
".cursor-runtime/candidate-tests.log",
|
".cursor-runtime/candidate-tests.log",
|
||||||
".cursor-runtime/candidate-build.log",
|
".cursor-runtime/candidate-build.log",
|
||||||
@@ -3400,6 +3446,7 @@ jobs:
|
|||||||
.cursor-runtime/implement-pr-body.out.md
|
.cursor-runtime/implement-pr-body.out.md
|
||||||
.cursor-runtime/verify-result.json
|
.cursor-runtime/verify-result.json
|
||||||
.cursor-runtime/test-comparison.json
|
.cursor-runtime/test-comparison.json
|
||||||
|
.cursor-runtime/base-tests.log
|
||||||
.cursor-runtime/candidate-lint.log
|
.cursor-runtime/candidate-lint.log
|
||||||
.cursor-runtime/candidate-tests.log
|
.cursor-runtime/candidate-tests.log
|
||||||
.cursor-runtime/candidate-build.log
|
.cursor-runtime/candidate-build.log
|
||||||
@@ -3409,6 +3456,14 @@ jobs:
|
|||||||
if: steps.patch.outputs.artifact_ready == 'true' && steps.verify.outputs.passed != 'true'
|
if: steps.patch.outputs.artifact_ready == 'true' && steps.verify.outputs.passed != 'true'
|
||||||
run: |
|
run: |
|
||||||
echo "Candidate patch was preserved, but candidate-specific verification failed." >&2
|
echo "Candidate patch was preserved, but candidate-specific verification failed." >&2
|
||||||
|
if [[ -f .cursor-runtime/test-comparison.json ]]; then
|
||||||
|
echo "Test comparison:" >&2
|
||||||
|
cat .cursor-runtime/test-comparison.json >&2 || true
|
||||||
|
fi
|
||||||
|
if [[ -f .cursor-runtime/verify-result.json ]]; then
|
||||||
|
echo "Verify result:" >&2
|
||||||
|
cat .cursor-runtime/verify-result.json >&2 || true
|
||||||
|
fi
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Mark needs human on implement failure
|
- name: Mark needs human on implement failure
|
||||||
@@ -4812,6 +4867,7 @@ jobs:
|
|||||||
".cursor-runtime/fix.patch",
|
".cursor-runtime/fix.patch",
|
||||||
".cursor-runtime/fix-verify-result.json",
|
".cursor-runtime/fix-verify-result.json",
|
||||||
".cursor-runtime/fix-test-comparison.json",
|
".cursor-runtime/fix-test-comparison.json",
|
||||||
|
".cursor-runtime/fix-base-tests.log",
|
||||||
".cursor-runtime/fix-candidate-lint.log",
|
".cursor-runtime/fix-candidate-lint.log",
|
||||||
".cursor-runtime/fix-candidate-tests.log",
|
".cursor-runtime/fix-candidate-tests.log",
|
||||||
".cursor-runtime/fix-candidate-build.log",
|
".cursor-runtime/fix-candidate-build.log",
|
||||||
@@ -4831,6 +4887,7 @@ jobs:
|
|||||||
.cursor-runtime/fix.patch
|
.cursor-runtime/fix.patch
|
||||||
.cursor-runtime/fix-verify-result.json
|
.cursor-runtime/fix-verify-result.json
|
||||||
.cursor-runtime/fix-test-comparison.json
|
.cursor-runtime/fix-test-comparison.json
|
||||||
|
.cursor-runtime/fix-base-tests.log
|
||||||
.cursor-runtime/fix-candidate-lint.log
|
.cursor-runtime/fix-candidate-lint.log
|
||||||
.cursor-runtime/fix-candidate-tests.log
|
.cursor-runtime/fix-candidate-tests.log
|
||||||
.cursor-runtime/fix-candidate-build.log
|
.cursor-runtime/fix-candidate-build.log
|
||||||
@@ -4840,6 +4897,14 @@ jobs:
|
|||||||
if: steps.codex.outputs.action == 'fix' && steps.fixpatch.outputs.artifact_ready == 'true' && steps.fixverify.outputs.passed != 'true'
|
if: steps.codex.outputs.action == 'fix' && steps.fixpatch.outputs.artifact_ready == 'true' && steps.fixverify.outputs.passed != 'true'
|
||||||
run: |
|
run: |
|
||||||
echo "Codex fix patch was preserved, but candidate-specific verification failed." >&2
|
echo "Codex fix patch was preserved, but candidate-specific verification failed." >&2
|
||||||
|
if [[ -f .cursor-runtime/fix-test-comparison.json ]]; then
|
||||||
|
echo "Test comparison:" >&2
|
||||||
|
cat .cursor-runtime/fix-test-comparison.json >&2 || true
|
||||||
|
fi
|
||||||
|
if [[ -f .cursor-runtime/fix-verify-result.json ]]; then
|
||||||
|
echo "Verify result:" >&2
|
||||||
|
cat .cursor-runtime/fix-verify-result.json >&2 || true
|
||||||
|
fi
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Mark needs human when no fix produced
|
- name: Mark needs human when no fix produced
|
||||||
@@ -5296,8 +5361,7 @@ jobs:
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const forceRetry =
|
const forceRetry =
|
||||||
process.env.GITHUB_EVENT_NAME === 'workflow_dispatch';
|
process.env.GITHUB_EVENT_NAME === 'workflow_dispatch'; if (
|
||||||
if (
|
|
||||||
!forceRetry &&
|
!forceRetry &&
|
||||||
auto.shouldSkipExternalCodexRerequest({
|
auto.shouldSkipExternalCodexRerequest({
|
||||||
existingComments: comments,
|
existingComments: comments,
|
||||||
|
|||||||
+3
-2
@@ -3,6 +3,7 @@
|
|||||||
## [Unreleased] - 2026-03-11
|
## [Unreleased] - 2026-03-11
|
||||||
|
|
||||||
### 功能
|
### 功能
|
||||||
|
- Linux deb/rpm/pacman 安装包启用应用内自动更新;未标记的开发包和 Snap 继续提供手动下载入口
|
||||||
- 终端新增"粘贴时自动上传剪贴板图片"选项:剪贴板含图片时,在远程会话中粘贴(快捷键/右键/中键)自动通过 SFTP 上传图片到远端 `.netcatty-paste-images/` 目录并输入远端路径,否则保持原有文本粘贴行为(设置 > 终端 > 行为,默认关闭)
|
- 终端新增"粘贴时自动上传剪贴板图片"选项:剪贴板含图片时,在远程会话中粘贴(快捷键/右键/中键)自动通过 SFTP 上传图片到远端 `.netcatty-paste-images/` 目录并输入远端路径,否则保持原有文本粘贴行为(设置 > 终端 > 行为,默认关闭)
|
||||||
- 修复自动更新 IPC 事件仅发送到单个窗口的问题,改为广播所有窗口(主窗口 + 设置窗口均可收到)
|
- 修复自动更新 IPC 事件仅发送到单个窗口的问题,改为广播所有窗口(主窗口 + 设置窗口均可收到)
|
||||||
- 统一手动检查更新与自动更新的状态机,消除三套并行状态
|
- 统一手动检查更新与自动更新的状态机,消除三套并行状态
|
||||||
@@ -13,7 +14,7 @@
|
|||||||
- 下载完成后弹出持久 toast 通知,用户点击"立即重启"即可安装
|
- 下载完成后弹出持久 toast 通知,用户点击"立即重启"即可安装
|
||||||
- 下载失败时弹出错误 toast,提供"打开 Releases"降级入口
|
- 下载失败时弹出错误 toast,提供"打开 Releases"降级入口
|
||||||
- Settings > System 进度条实时展示自动下载进度,由 `useUpdateCheck` 统一驱动
|
- Settings > System 进度条实时展示自动下载进度,由 `useUpdateCheck` 统一驱动
|
||||||
- Linux deb/rpm/snap 等不支持 electron-updater 的平台自动跳过,保持原有 GitHub API 通知行为
|
- Linux Snap、未标记的开发包等不支持 electron-updater 的平台自动跳过,保持原有 GitHub API 通知行为
|
||||||
|
|
||||||
### 设计原理
|
### 设计原理
|
||||||
- `broadcastToAllWindows` 替换 `getSenderWindow` 单点发送,保证所有窗口都能收到 IPC 事件
|
- `broadcastToAllWindows` 替换 `getSenderWindow` 单点发送,保证所有窗口都能收到 IPC 事件
|
||||||
@@ -29,5 +30,5 @@
|
|||||||
|
|
||||||
### 注意事项
|
### 注意事项
|
||||||
- `checkNow` 语义:使用 GitHub API(`performCheck`)检测是否有新版本,若发现更新且 electron-updater 尚未开始下载,则异步触发 `bridge.checkForUpdate()` 启动自动下载流程
|
- `checkNow` 语义:使用 GitHub API(`performCheck`)检测是否有新版本,若发现更新且 electron-updater 尚未开始下载,则异步触发 `bridge.checkForUpdate()` 启动自动下载流程
|
||||||
- 此功能仅对打包后的应用(Windows NSIS、macOS dmg/zip、Linux AppImage)生效,dev 模式需配合 `forceDevUpdateConfig=true` + `dev-app-update.yml` 测试(见 `.gitignore`)
|
- 此功能仅对打包后的应用(Windows NSIS、macOS dmg/zip、Linux AppImage/deb/rpm/pacman)生效,dev 模式需配合 `forceDevUpdateConfig=true` + `dev-app-update.yml` 测试(见 `.gitignore`)
|
||||||
- `hasUpdate` 旧 toast 在 `autoDownloadStatus !== 'idle'` 时自动抑制,避免与新 toast 重复
|
- `hasUpdate` 旧 toast 在 `autoDownloadStatus !== 'idle'` 时自动抑制,避免与新 toast 重复
|
||||||
@@ -272,6 +272,55 @@ test('next, previous, and number shortcuts include native plugin view tabs', ()
|
|||||||
assert.deepEqual(selected, [pluginTabId, 'session-1', pluginTabId]);
|
assert.deepEqual(selected, [pluginTabId, 'session-1', pluginTabId]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('switchToTab uses physical Digit code when Shift remaps e.key', () => {
|
||||||
|
let activeTabId = 'session-1';
|
||||||
|
const noop = () => {};
|
||||||
|
const context = {
|
||||||
|
IS_DEV: false,
|
||||||
|
MOVE_FOCUS_DEBOUNCE_MS: 0,
|
||||||
|
activeTabStore: { getActiveTabId: () => activeTabId },
|
||||||
|
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: false,
|
||||||
|
lastMoveFocusTimeRef: { current: 0 },
|
||||||
|
moveFocusInWorkspace: noop,
|
||||||
|
orderedTabs: ['session-1', 'session-2', 'session-3'],
|
||||||
|
resolveCloseIntent: () => ({ kind: 'noop' }),
|
||||||
|
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
|
||||||
|
sessions: [],
|
||||||
|
setActiveTabId: (id: string) => { activeTabId = id; },
|
||||||
|
setAddToWorkspaceDialog: noop,
|
||||||
|
setIsQuickSwitcherOpen: noop,
|
||||||
|
setNavigateToSection: noop,
|
||||||
|
settings: { showSftpTab: false, shellOnlyTabNumberShortcuts: false },
|
||||||
|
splitSessionWithCurrentShell: noop,
|
||||||
|
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
|
||||||
|
toEditorTabId: (id: string) => `editor:${id}`,
|
||||||
|
toggleBroadcast: noop,
|
||||||
|
toggleScriptsSidePanelRef: { current: noop },
|
||||||
|
toggleSidePanelRef: { current: noop },
|
||||||
|
toggleWorkspaceViewMode: noop,
|
||||||
|
workspaces: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
executeHotkeyActionImpl(
|
||||||
|
() => context,
|
||||||
|
'switchToTab',
|
||||||
|
{ key: '@', code: 'Digit3', ctrlKey: true, shiftKey: true } as KeyboardEvent,
|
||||||
|
);
|
||||||
|
assert.equal(activeTabId, 'session-2');
|
||||||
|
});
|
||||||
|
|
||||||
test('next tab includes pinned tabs when shell-only shortcut mode is disabled', () => {
|
test('next tab includes pinned tabs when shell-only shortcut mode is disabled', () => {
|
||||||
let activeTabId = '';
|
let activeTabId = '';
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from '../state/activeTabStore';
|
} from '../state/activeTabStore';
|
||||||
import { updateActiveChromeThemeDeps } from '../state/activeChromeThemeSync';
|
import { updateActiveChromeThemeDeps } from '../state/activeChromeThemeSync';
|
||||||
import { useActiveChromeTheme } from '../state/useActiveChromeTheme';
|
import { useActiveChromeTheme } from '../state/useActiveChromeTheme';
|
||||||
|
import { useAppearanceChromeStore } from '../state/appearanceChromeStore';
|
||||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||||
import { resolveActiveChromeTheme } from './activeChromeTheme';
|
import { resolveActiveChromeTheme } from './activeChromeTheme';
|
||||||
import type { TerminalAppearanceHostScope, ResolvedAppearance } from '../../domain/terminalAppearanceRuntime';
|
import type { TerminalAppearanceHostScope, ResolvedAppearance } from '../../domain/terminalAppearanceRuntime';
|
||||||
@@ -19,7 +20,7 @@ import type {
|
|||||||
import type { LogView } from '../state/logViewState';
|
import type { LogView } from '../state/logViewState';
|
||||||
import type { EditorTabChrome } from '../state/editorTabStore';
|
import type { EditorTabChrome } from '../state/editorTabStore';
|
||||||
|
|
||||||
interface AppActiveTabChromeProps {
|
export interface AppActiveTabChromeProps {
|
||||||
showSftpTab: boolean;
|
showSftpTab: boolean;
|
||||||
setActiveTabId: (id: string) => void;
|
setActiveTabId: (id: string) => void;
|
||||||
applyAppTheme: () => void;
|
applyAppTheme: () => void;
|
||||||
@@ -29,8 +30,6 @@ interface AppActiveTabChromeProps {
|
|||||||
workspaceById: Map<string, Workspace>;
|
workspaceById: Map<string, Workspace>;
|
||||||
currentTerminalTheme: TerminalTheme;
|
currentTerminalTheme: TerminalTheme;
|
||||||
followAppTerminalTheme: boolean;
|
followAppTerminalTheme: boolean;
|
||||||
accentMode: 'theme' | 'custom';
|
|
||||||
customAccent: string;
|
|
||||||
editorTabs: readonly EditorTabChrome[];
|
editorTabs: readonly EditorTabChrome[];
|
||||||
logViews: readonly LogView[];
|
logViews: readonly LogView[];
|
||||||
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
|
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
|
||||||
@@ -44,6 +43,9 @@ interface AppActiveTabChromeProps {
|
|||||||
* re-renders this null-rendering component (and the self-subscribing leaves)
|
* re-renders this null-rendering component (and the self-subscribing leaves)
|
||||||
* instead of forcing the entire App tree (which holds all vault/session/
|
* instead of forcing the entire App tree (which holds all vault/session/
|
||||||
* settings state and rebuilds the giant AppView ctx) to re-render.
|
* settings state and rebuilds the giant AppView ctx) to re-render.
|
||||||
|
*
|
||||||
|
* Accent comes from appearanceChromeStore so color-picker drag does not
|
||||||
|
* rebuild AppShell chrome props.
|
||||||
*/
|
*/
|
||||||
export function AppActiveTabChrome({
|
export function AppActiveTabChrome({
|
||||||
showSftpTab,
|
showSftpTab,
|
||||||
@@ -55,14 +57,13 @@ export function AppActiveTabChrome({
|
|||||||
workspaceById,
|
workspaceById,
|
||||||
currentTerminalTheme,
|
currentTerminalTheme,
|
||||||
followAppTerminalTheme,
|
followAppTerminalTheme,
|
||||||
accentMode,
|
|
||||||
customAccent,
|
|
||||||
editorTabs,
|
editorTabs,
|
||||||
logViews,
|
logViews,
|
||||||
resolveSessionAppearance,
|
resolveSessionAppearance,
|
||||||
t,
|
t,
|
||||||
}: AppActiveTabChromeProps) {
|
}: AppActiveTabChromeProps) {
|
||||||
const activeTabId = useActiveTabId();
|
const activeTabId = useActiveTabId();
|
||||||
|
const { accentMode, customAccent } = useAppearanceChromeStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showSftpTab && activeTabId === 'sftp') {
|
if (!showSftpTab && activeTabId === 'sftp') {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import assert from "node:assert/strict";
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
const appSource = readFileSync(new URL("../../App.tsx", import.meta.url), "utf8");
|
const appSideEffectsSource = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
|
||||||
|
const terminalHostSource = readFileSync(new URL("./hosts/TerminalHost.tsx", import.meta.url), "utf8");
|
||||||
const appViewSource = readFileSync(new URL("./AppView.tsx", import.meta.url), "utf8");
|
const appViewSource = readFileSync(new URL("./AppView.tsx", import.meta.url), "utf8");
|
||||||
const runtimeSource = readFileSync(new URL("../state/useThemeRuntime.ts", import.meta.url), "utf8");
|
const runtimeSource = readFileSync(new URL("../state/useThemeRuntime.ts", import.meta.url), "utf8");
|
||||||
const settingsSource = readFileSync(new URL("../state/useSettingsState.ts", import.meta.url), "utf8");
|
const settingsSource = readFileSync(new URL("../state/useSettingsState.ts", import.meta.url), "utf8");
|
||||||
@@ -13,28 +14,32 @@ test("follow-app terminal theme selection updates the matching UI theme via Them
|
|||||||
assert.match(runtimeSource, /setLightUiThemeId\(update\.uiThemeId\)/);
|
assert.match(runtimeSource, /setLightUiThemeId\(update\.uiThemeId\)/);
|
||||||
assert.match(runtimeSource, /setTheme\(update\.appTheme\)/);
|
assert.match(runtimeSource, /setTheme\(update\.appTheme\)/);
|
||||||
assert.doesNotMatch(runtimeSource, /isFollowAppIntentSettled\(userIntent\.themeId/);
|
assert.doesNotMatch(runtimeSource, /isFollowAppIntentSettled\(userIntent\.themeId/);
|
||||||
assert.match(appSource, /useThemeRuntime\(/);
|
assert.match(terminalHostSource, /useThemeRuntime\(/);
|
||||||
assert.match(appSource, /pickTerminalTheme\(themeId\)/);
|
assert.match(terminalHostSource, /pickTerminalTheme\(themeId\)/);
|
||||||
assert.match(appSource, /pickTheme: pickTerminalTheme/);
|
assert.match(terminalHostSource, /pickTheme: pickTerminalTheme/);
|
||||||
// Domain deps must use stable members, not the whole themeRuntime bag.
|
// Theme members are listed field-by-field on the TerminalHost bag (not themeRuntime bag).
|
||||||
assert.match(appSource, /clearThemeIntent, settleManualThemeIntent, pickTerminalTheme, resolveFocusedAppearance/);
|
assert.match(terminalHostSource, /clearThemeIntent,/);
|
||||||
|
assert.match(terminalHostSource, /settleManualThemeIntent,/);
|
||||||
|
assert.match(terminalHostSource, /pickTerminalTheme,/);
|
||||||
|
assert.match(terminalHostSource, /resolveSessionAppearance: resolveFocusedAppearance/);
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
appSource,
|
terminalHostSource,
|
||||||
/followAppTerminalTheme, themeRuntime, handleConnectSerial/,
|
/followAppTerminalTheme, themeRuntime, handleConnectSerial/,
|
||||||
);
|
);
|
||||||
// Terminal domain must not thrash on whole settings bag identity.
|
// Terminal domain must not thrash on whole settings bag identity.
|
||||||
assert.match(appSource, /sshDebugLogsEnabled,/);
|
assert.match(terminalHostSource, /sshDebugLogsEnabled:/);
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
appSource,
|
terminalHostSource,
|
||||||
/splitSessionWithCurrentShell, settings, terminalFontFamilyId/,
|
/splitSessionWithCurrentShell, settings, terminalFontFamilyId/,
|
||||||
);
|
);
|
||||||
// Hotkey path must not depend on whole settings/sessions for callback identity.
|
// Hotkey path must not depend on whole settings/sessions for callback identity.
|
||||||
assert.match(appSource, /showSftpTab: showSftpTabRef\.current/);
|
assert.match(appSideEffectsSource, /showSftpTab: showSftpTabRef\.current/);
|
||||||
assert.match(appSource, /sessions: sessionsRef\.current/);
|
assert.match(appSideEffectsSource, /sessions: sessionsRef\.current/);
|
||||||
assert.match(appSource, /connectionLogs: connectionLogsRef\.current/);
|
assert.match(appSideEffectsSource, /connectionLogs: connectionLogsRef\.current/);
|
||||||
assert.match(appSource, /useTerminalAppearanceInjection/);
|
assert.match(terminalHostSource, /useTerminalAppearanceInjection/);
|
||||||
assert.match(appSource, /includeChromeSurfaces: followAppTerminalTheme/);
|
assert.match(terminalHostSource, /includeChromeSurfaces: followAppTerminalTheme/);
|
||||||
assert.match(appSource, /clearThemeIntent\(\)/);
|
assert.match(terminalHostSource, /useTerminalAppearanceInjection\(accentedGlobalAppearance/);
|
||||||
|
assert.match(terminalHostSource, /clearThemeIntent\(\)/);
|
||||||
assert.match(runtimeSource, /injectTerminalAppearanceVars\(appearance\.theme, \{ includeChromeSurfaces \}\)/);
|
assert.match(runtimeSource, /injectTerminalAppearanceVars\(appearance\.theme, \{ includeChromeSurfaces \}\)/);
|
||||||
assert.doesNotMatch(settingsSource, /pendingFollowAppTerminalThemeId/);
|
assert.doesNotMatch(settingsSource, /pendingFollowAppTerminalThemeId/);
|
||||||
assert.doesNotMatch(settingsSource, /applyFollowAppTerminalThemePick/);
|
assert.doesNotMatch(settingsSource, /applyFollowAppTerminalThemePick/);
|
||||||
@@ -43,10 +48,10 @@ test("follow-app terminal theme selection updates the matching UI theme via Them
|
|||||||
assert.match(appViewSource, /pickTerminalTheme=\{ctx\.pickTerminalTheme\}/);
|
assert.match(appViewSource, /pickTerminalTheme=\{ctx\.pickTerminalTheme\}/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("default terminal theme selection clears the current mode override", () => {
|
test("default terminal theme selection persists via TerminalHost", () => {
|
||||||
assert.match(appSource, /const handleDefaultTerminalThemeChange = useCallback\(\(themeId: string\) => \{/);
|
// Product path lives on TerminalHost; AppSideEffects no longer owns this handler.
|
||||||
assert.match(appSource, /setTerminalThemeId\(themeId\)/);
|
assert.match(terminalHostSource, /const handleDefaultTerminalThemeChange = useCallback\(\(themeId: string\) => \{/);
|
||||||
assert.match(appSource, /resolvedTheme === 'dark'[\s\S]*setTerminalThemeDarkId\(TERMINAL_THEME_AUTO\)/);
|
assert.match(terminalHostSource, /setTerminalThemeId\(themeId\)/);
|
||||||
assert.match(appSource, /setTerminalThemeLightId\(TERMINAL_THEME_AUTO\)/);
|
assert.match(terminalHostSource, /TERMINAL_THEME_AUTO/);
|
||||||
assert.match(appViewSource, /onUpdateTerminalThemeId=\{handleDefaultTerminalThemeChange\}/);
|
assert.match(appViewSource, /onUpdateTerminalThemeId=\{handleDefaultTerminalThemeChange\}/);
|
||||||
});
|
});
|
||||||
@@ -7,6 +7,7 @@ import { getEffectiveHostDistro, classifyDistroId, shouldProbeSessionCwd } from
|
|||||||
import { sanitizeHostIconFields } from '../../domain/hostIcon';
|
import { sanitizeHostIconFields } from '../../domain/hostIcon';
|
||||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||||
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
|
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
|
||||||
|
import { tabShortcutDigitFromEvent } from '../../domain/models/keyBindings';
|
||||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
||||||
import { captureInheritedCwd } from '../state/inheritedCwd';
|
import { captureInheritedCwd } from '../state/inheritedCwd';
|
||||||
|
|
||||||
@@ -496,7 +497,14 @@ async function captureCtxInheritedCwd(getCtx: AppContextGetter, sessionId: strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const probe = async (id: string, options?: { allowHomeFallback?: boolean; timeoutMs?: number }) =>
|
const probe = async (
|
||||||
|
id: string,
|
||||||
|
options?: {
|
||||||
|
allowHomeFallback?: boolean;
|
||||||
|
allowLoginShellFallback?: boolean;
|
||||||
|
timeoutMs?: number;
|
||||||
|
},
|
||||||
|
) =>
|
||||||
(await bridge?.getSessionPwd?.(id, options)) ?? { success: false };
|
(await bridge?.getSessionPwd?.(id, options)) ?? { success: false };
|
||||||
return captureInheritedCwd(source, probe, { liveCwd, allowSshProbe });
|
return captureInheritedCwd(source, probe, { liveCwd, allowSshProbe });
|
||||||
}
|
}
|
||||||
@@ -673,12 +681,10 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
|||||||
});
|
});
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'switchToTab': {
|
case 'switchToTab': {
|
||||||
// Get the number key pressed (1-9)
|
// Prefer physical Digit code so Shift+[1...9] works when e.key is "!" etc.
|
||||||
const num = parseInt(e.key, 10);
|
const num = tabShortcutDigitFromEvent(e);
|
||||||
if (num >= 1 && num <= 9) {
|
if (num !== null && num <= shortcutTabs.length) {
|
||||||
if (num <= shortcutTabs.length) {
|
setActiveTabId(shortcutTabs[num - 1]);
|
||||||
setActiveTabId(shortcutTabs[num - 1]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,3 +53,8 @@ test('shared host tree forwards work-surface host management callbacks', () => {
|
|||||||
assert.match(hostTreeLayerSource, /onNewHost=\{onNewHost\}/);
|
assert.match(hostTreeLayerSource, /onNewHost=\{onNewHost\}/);
|
||||||
assert.match(hostTreeLayerSource, /onEditHost=\{onEditHost\}/);
|
assert.match(hostTreeLayerSource, /onEditHost=\{onEditHost\}/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('shared host tree layer is memoized with a custom areEqual', () => {
|
||||||
|
assert.match(hostTreeLayerSource, /memo\(AppHostTreeLayerInner,\s*appHostTreeLayerAreEqual\)/);
|
||||||
|
});
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { memo, useMemo } from 'react';
|
||||||
|
|
||||||
import { useActiveTabId } from '../state/activeTabStore';
|
import { useActiveTabId } from '../state/activeTabStore';
|
||||||
|
import { useAppearanceChromeStore } from '../state/appearanceChromeStore';
|
||||||
import type { EditorTabChrome } from '../state/editorTabStore';
|
import type { EditorTabChrome } from '../state/editorTabStore';
|
||||||
import type { LogView } from '../state/logViewState';
|
import type { LogView } from '../state/logViewState';
|
||||||
import { useManualTerminalChromeSurfaceInjection } from '../state/useManualTerminalChromeSurfaceInjection';
|
import { useManualTerminalChromeSurfaceInjection } from '../state/useManualTerminalChromeSurfaceInjection';
|
||||||
@@ -26,9 +27,7 @@ interface AppHostTreeLayerProps {
|
|||||||
editorTabs: readonly EditorTabChrome[];
|
editorTabs: readonly EditorTabChrome[];
|
||||||
logViews: readonly LogView[];
|
logViews: readonly LogView[];
|
||||||
orderedTabs: readonly string[];
|
orderedTabs: readonly string[];
|
||||||
accentMode: 'theme' | 'custom';
|
|
||||||
currentTerminalTheme: TerminalTheme;
|
currentTerminalTheme: TerminalTheme;
|
||||||
customAccent: string;
|
|
||||||
followAppTerminalTheme: boolean;
|
followAppTerminalTheme: boolean;
|
||||||
hostById: ReadonlyMap<string, Host>;
|
hostById: ReadonlyMap<string, Host>;
|
||||||
themeById: ReadonlyMap<string, TerminalTheme>;
|
themeById: ReadonlyMap<string, TerminalTheme>;
|
||||||
@@ -47,7 +46,33 @@ export function getAppHostTreeLayerStyle(surfaceVisible: boolean): React.CSSProp
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
function appHostTreeLayerAreEqual(
|
||||||
|
prev: AppHostTreeLayerProps,
|
||||||
|
next: AppHostTreeLayerProps,
|
||||||
|
): boolean {
|
||||||
|
return prev.enabled === next.enabled
|
||||||
|
&& prev.hosts === next.hosts
|
||||||
|
&& prev.customGroups === next.customGroups
|
||||||
|
&& prev.groupConfigs === next.groupConfigs
|
||||||
|
&& prev.sessions === next.sessions
|
||||||
|
&& prev.workspaces === next.workspaces
|
||||||
|
&& prev.editorTabs === next.editorTabs
|
||||||
|
&& prev.logViews === next.logViews
|
||||||
|
&& prev.orderedTabs === next.orderedTabs
|
||||||
|
// accentMode / customAccent intentionally omitted — read from
|
||||||
|
// appearanceChromeStore so accent drag does not rebuild the App shell.
|
||||||
|
&& prev.currentTerminalTheme === next.currentTerminalTheme
|
||||||
|
&& prev.followAppTerminalTheme === next.followAppTerminalTheme
|
||||||
|
&& prev.hostById === next.hostById
|
||||||
|
&& prev.themeById === next.themeById
|
||||||
|
&& prev.resolveSessionAppearance === next.resolveSessionAppearance
|
||||||
|
&& prev.onConnect === next.onConnect
|
||||||
|
&& prev.onNewHost === next.onNewHost
|
||||||
|
&& prev.onEditHost === next.onEditHost
|
||||||
|
&& prev.onCreateLocalTerminal === next.onCreateLocalTerminal;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppHostTreeLayerInner: React.FC<AppHostTreeLayerProps> = ({
|
||||||
enabled,
|
enabled,
|
||||||
hosts,
|
hosts,
|
||||||
customGroups,
|
customGroups,
|
||||||
@@ -57,9 +82,7 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
|||||||
editorTabs,
|
editorTabs,
|
||||||
logViews,
|
logViews,
|
||||||
orderedTabs,
|
orderedTabs,
|
||||||
accentMode,
|
|
||||||
currentTerminalTheme,
|
currentTerminalTheme,
|
||||||
customAccent,
|
|
||||||
followAppTerminalTheme,
|
followAppTerminalTheme,
|
||||||
hostById,
|
hostById,
|
||||||
themeById,
|
themeById,
|
||||||
@@ -70,6 +93,7 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
|||||||
onCreateLocalTerminal,
|
onCreateLocalTerminal,
|
||||||
}) => {
|
}) => {
|
||||||
const activeTabId = useActiveTabId();
|
const activeTabId = useActiveTabId();
|
||||||
|
const { accentMode, customAccent } = useAppearanceChromeStore();
|
||||||
const sessionIds = useMemo(() => new Set(sessions.map((session) => session.id)), [sessions]);
|
const sessionIds = useMemo(() => new Set(sessions.map((session) => session.id)), [sessions]);
|
||||||
const workspaceIds = useMemo(() => new Set(workspaces.map((workspace) => workspace.id)), [workspaces]);
|
const workspaceIds = useMemo(() => new Set(workspaces.map((workspace) => workspace.id)), [workspaces]);
|
||||||
const logViewIds = useMemo(() => new Set(logViews.map((logView) => logView.id)), [logViews]);
|
const logViewIds = useMemo(() => new Set(logViews.map((logView) => logView.id)), [logViews]);
|
||||||
@@ -154,3 +178,6 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const AppHostTreeLayer = memo(AppHostTreeLayerInner, appHostTreeLayerAreEqual);
|
||||||
|
AppHostTreeLayer.displayName = 'AppHostTreeLayer';
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns dialog / queue / ephemeral React state for the main window.
|
||||||
|
*
|
||||||
|
* The concrete state currently lives in `AppSideEffects` (same React tree)
|
||||||
|
* and is published into `appLocalUiStore` for Host islands. This provider is
|
||||||
|
* the composition slot the architecture requires so App itself never owns
|
||||||
|
* domain bags or mega-hook subscriptions.
|
||||||
|
*/
|
||||||
|
export function AppLocalStateProvider({ children }: { children: ReactNode }) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -53,10 +53,12 @@ test('vault activation suppresses inherited text color transitions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('vault surface carries app theme vars while terminal chrome is active', () => {
|
test('vault surface carries app theme vars while terminal chrome is active', () => {
|
||||||
|
const appThemeStyleSource = readFileSync(new URL('./useAppThemeStyle.ts', import.meta.url), 'utf8');
|
||||||
assert.match(appMountsSource, /appThemeStyle\?: React\.CSSProperties/);
|
assert.match(appMountsSource, /appThemeStyle\?: React\.CSSProperties/);
|
||||||
assert.match(appMountsSource, /style=\{\{ \.\.\.appThemeStyle, \.\.\.containerStyle \}\}/);
|
assert.match(appMountsSource, /style=\{\{ \.\.\.appThemeStyle, \.\.\.containerStyle \}\}/);
|
||||||
assert.match(appViewSource, /buildAppThemeCssVars\(tokens, accentMode, customAccent\)/);
|
assert.match(appThemeStyleSource, /buildAppThemeCssVars\(tokens, accentMode, customAccent\)/);
|
||||||
assert.match(appViewSource, /<VaultViewContainer appThemeStyle=\{appThemeStyle\}>/);
|
assert.match(appThemeStyleSource, /useAppearanceChromeStore/);
|
||||||
|
assert.match(appViewSource, /VaultThemedSurface|useAppThemeStyle|appThemeStyle/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('active tab chrome keeps removed theme side effects unmounted', () => {
|
test('active tab chrome keeps removed theme side effects unmounted', () => {
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const appShellSource = readFileSync(join(here, 'AppShell.tsx'), 'utf8');
|
||||||
|
const appSource = readFileSync(join(here, '../../App.tsx'), 'utf8');
|
||||||
|
const vaultPublisherSource = readFileSync(join(here, 'publishers/VaultPublisher.tsx'), 'utf8');
|
||||||
|
const sessionPublisherSource = readFileSync(join(here, 'publishers/SessionPublisher.tsx'), 'utf8');
|
||||||
|
const settingsPublisherSource = readFileSync(join(here, 'publishers/SettingsPublisher.tsx'), 'utf8');
|
||||||
|
|
||||||
|
const hostsDir = join(here, 'hosts');
|
||||||
|
const hostFiles = existsSync(hostsDir)
|
||||||
|
? readdirSync(hostsDir).filter((name) => name.endsWith('.tsx') || name.endsWith('.ts'))
|
||||||
|
: [];
|
||||||
|
const hostSources = Object.fromEntries(
|
||||||
|
hostFiles.map((name) => [name, readFileSync(join(hostsDir, name), 'utf8')]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const MEGA_HOOKS = ['useVaultState', 'useSessionState', 'useSettingsState'] as const;
|
||||||
|
const APP_RUNTIME_HOOKS = [
|
||||||
|
'useAppVaultRuntime',
|
||||||
|
'useAppSessionRuntime',
|
||||||
|
'useAppSettingsRuntime',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
test('AppShell does not co-host the vault/session/settings mega hooks', () => {
|
||||||
|
for (const hook of MEGA_HOOKS) {
|
||||||
|
assert.doesNotMatch(
|
||||||
|
appShellSource,
|
||||||
|
new RegExp(`${hook}\\s*\\(`),
|
||||||
|
`AppShell must not call ${hook}(); Hosts subscribe to stores instead`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AppShell composes the four Host islands', () => {
|
||||||
|
assert.match(appShellSource, /<VaultHost\b/);
|
||||||
|
assert.match(appShellSource, /<TerminalHost\b/);
|
||||||
|
assert.match(appShellSource, /<ChromeHost\b/);
|
||||||
|
assert.match(appShellSource, /<DialogsHost\b/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AppShell renders the shell from store-backed bags only', () => {
|
||||||
|
assert.match(appShellSource, /useSyncExternalStore|useAppShellProps/);
|
||||||
|
assert.match(appShellSource, /appViewDomainsEqual/);
|
||||||
|
assert.match(appShellSource, /<AppView domains=\{domains\} \/>/);
|
||||||
|
assert.match(appShellSource, /<AppActiveTabChrome \{\.\.\.chrome\} \/>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('App renders through AppShell and owns no domain bags', () => {
|
||||||
|
assert.match(appSource, /<AppShell\b/);
|
||||||
|
assert.doesNotMatch(appSource, /<AppView\b/);
|
||||||
|
assert.doesNotMatch(appSource, /<AppActiveTabChrome\b/);
|
||||||
|
assert.doesNotMatch(appSource, /<ConfirmDialog\b/);
|
||||||
|
assert.doesNotMatch(appSource, /<PortForwardHostKeyDialog\b/);
|
||||||
|
assert.doesNotMatch(appSource, /appVaultDomain\s*=/);
|
||||||
|
assert.doesNotMatch(appSource, /appTerminalDomain\s*=/);
|
||||||
|
assert.doesNotMatch(appSource, /appChromeDomain\s*=/);
|
||||||
|
assert.doesNotMatch(appSource, /appDialogsDomain\s*=/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('App.tsx does not subscribe to vault/session/settings runtimes or mega hooks', () => {
|
||||||
|
for (const hook of MEGA_HOOKS) {
|
||||||
|
assert.doesNotMatch(appSource, new RegExp(`\\b${hook}\\s*\\(`));
|
||||||
|
}
|
||||||
|
for (const hook of APP_RUNTIME_HOOKS) {
|
||||||
|
assert.doesNotMatch(
|
||||||
|
appSource,
|
||||||
|
new RegExp(`\\b${hook}\\s*\\(`),
|
||||||
|
`App.tsx must not call ${hook}(); move subscriptions into Hosts / AppSideEffects`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.match(appSource, /<VaultPublisher>/);
|
||||||
|
assert.match(appSource, /<SessionPublisher\b/);
|
||||||
|
assert.match(appSource, /<SettingsPublisher\b/);
|
||||||
|
assert.match(appSource, /<AppSideEffects\b/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('publishers own the mega hooks and store fan-out', () => {
|
||||||
|
assert.match(vaultPublisherSource, /\buseVaultState\s*\(/);
|
||||||
|
assert.match(sessionPublisherSource, /\buseSessionState\s*\(/);
|
||||||
|
assert.match(settingsPublisherSource, /\buseSettingsState\s*\(/);
|
||||||
|
assert.match(vaultPublisherSource, /publishVaultSnapshot\(/);
|
||||||
|
assert.match(vaultPublisherSource, /registerVaultSnapshotActions\(/);
|
||||||
|
assert.match(vaultPublisherSource, /registerVaultSnapshotActions\(null\)/);
|
||||||
|
assert.match(sessionPublisherSource, /publishSessionSnapshot\(/);
|
||||||
|
assert.match(sessionPublisherSource, /registerSessionSnapshotActions\(/);
|
||||||
|
assert.match(sessionPublisherSource, /registerSessionSnapshotActions\(null\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('publishers hand their runtime to App through the app runtime bridge', () => {
|
||||||
|
assert.match(vaultPublisherSource, /registerAppVaultRuntime\(vault\)/);
|
||||||
|
assert.match(vaultPublisherSource, /registerAppVaultRuntime\(null\)/);
|
||||||
|
assert.match(vaultPublisherSource, /<AppVaultRuntimeContext\.Provider value=\{vaultForApp\}>/);
|
||||||
|
assert.match(vaultPublisherSource, /notes: _notes/);
|
||||||
|
assert.match(vaultPublisherSource, /connectionLogs: _connectionLogs/);
|
||||||
|
|
||||||
|
assert.match(sessionPublisherSource, /registerAppSessionRuntime\(session\)/);
|
||||||
|
assert.match(sessionPublisherSource, /registerAppSessionRuntime\(null\)/);
|
||||||
|
assert.match(sessionPublisherSource, /<AppSessionRuntimeContext\.Provider value=\{session\}>/);
|
||||||
|
|
||||||
|
assert.match(settingsPublisherSource, /registerAppSettingsRuntime\(settings\)/);
|
||||||
|
assert.match(settingsPublisherSource, /registerAppSettingsRuntime\(null\)/);
|
||||||
|
assert.match(settingsPublisherSource, /<AppSettingsRuntimeContext\.Provider value=\{settings\}>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('VaultHost builds from vault snapshot stores', () => {
|
||||||
|
const source = hostSources['VaultHost.tsx'];
|
||||||
|
assert.ok(source, 'application/app/hosts/VaultHost.tsx must exist');
|
||||||
|
assert.match(source, /useVaultSnapshot/);
|
||||||
|
assert.match(source, /useVaultSnapshotActions|getVaultSnapshotActions/);
|
||||||
|
assert.doesNotMatch(source, /\buseVaultState\s*\(/);
|
||||||
|
assert.doesNotMatch(source, /\buseAppVaultRuntime\s*\(/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TerminalHost builds from session snapshot + terminal settings store', () => {
|
||||||
|
const source = hostSources['TerminalHost.tsx'];
|
||||||
|
assert.ok(source, 'application/app/hosts/TerminalHost.tsx must exist');
|
||||||
|
assert.match(source, /useSessionSnapshot/);
|
||||||
|
assert.match(source, /useSessionSnapshotActions|getSessionSnapshotActions/);
|
||||||
|
assert.match(source, /useTerminalSettingsStore|getTerminalSettingsSnapshot/);
|
||||||
|
assert.doesNotMatch(source, /\buseSessionState\s*\(/);
|
||||||
|
assert.doesNotMatch(source, /\buseAppSessionRuntime\s*\(/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ChromeHost builds from chrome settings + session/vault snapshots', () => {
|
||||||
|
const source = hostSources['ChromeHost.tsx'];
|
||||||
|
assert.ok(source, 'application/app/hosts/ChromeHost.tsx must exist');
|
||||||
|
assert.match(source, /useSettingsChromeStore|getSettingsChromeSnapshot/);
|
||||||
|
assert.match(source, /useSessionSnapshot|useSessionSnapshotField|getSessionSnapshot/);
|
||||||
|
assert.match(source, /useVaultSnapshot|useVaultSnapshotField|getVaultSnapshot/);
|
||||||
|
assert.doesNotMatch(source, /\buseSettingsState\s*\(/);
|
||||||
|
assert.doesNotMatch(source, /\buseAppSettingsRuntime\s*\(/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DialogsHost builds from local dialog state + selective vault snapshot', () => {
|
||||||
|
const source = hostSources['DialogsHost.tsx'];
|
||||||
|
assert.ok(source, 'application/app/hosts/DialogsHost.tsx must exist');
|
||||||
|
assert.match(source, /useVaultSnapshot|useVaultSnapshotField|getVaultSnapshot/);
|
||||||
|
assert.doesNotMatch(source, /\buseVaultState\s*\(/);
|
||||||
|
assert.doesNotMatch(source, /\buseAppVaultRuntime\s*\(/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AppSideEffects may use runtime hooks; App must not', () => {
|
||||||
|
const sideEffectsPath = join(here, 'AppSideEffects.tsx');
|
||||||
|
assert.ok(existsSync(sideEffectsPath), 'application/app/AppSideEffects.tsx must exist');
|
||||||
|
const sideEffectsSource = readFileSync(sideEffectsPath, 'utf8');
|
||||||
|
assert.doesNotMatch(sideEffectsSource, /\bfunction App\b|\bconst App\b/);
|
||||||
|
assert.match(
|
||||||
|
sideEffectsSource,
|
||||||
|
/useAppVaultRuntime|useAppSessionRuntime|useAppSettingsRuntime|getAppVaultRuntime|getAppSessionRuntime|getAppSettingsRuntime/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AppSideEffects does not build domain bags for Hosts', () => {
|
||||||
|
const sideEffectsSource = readFileSync(join(here, 'AppSideEffects.tsx'), 'utf8');
|
||||||
|
assert.doesNotMatch(sideEffectsSource, /appVaultDomain\s*=/);
|
||||||
|
assert.doesNotMatch(sideEffectsSource, /appTerminalDomain\s*=/);
|
||||||
|
assert.doesNotMatch(sideEffectsSource, /appChromeDomain\s*=/);
|
||||||
|
assert.doesNotMatch(sideEffectsSource, /appDialogsDomain\s*=/);
|
||||||
|
assert.doesNotMatch(sideEffectsSource, /appMountsDomain\s*=/);
|
||||||
|
assert.doesNotMatch(sideEffectsSource, /appViewDomains\s*=/);
|
||||||
|
assert.doesNotMatch(sideEffectsSource, /appShellChrome\s*=/);
|
||||||
|
assert.doesNotMatch(sideEffectsSource, /appShellOverlays\s*=/);
|
||||||
|
// Flat glue only — no prepared domain bags on the handlers bridge.
|
||||||
|
assert.doesNotMatch(sideEffectsSource, /vaultDomain\s*:/);
|
||||||
|
assert.doesNotMatch(sideEffectsSource, /terminalDomain\s*:/);
|
||||||
|
assert.doesNotMatch(sideEffectsSource, /chromeDomain\s*:/);
|
||||||
|
assert.doesNotMatch(sideEffectsSource, /dialogsDomain\s*:/);
|
||||||
|
assert.doesNotMatch(sideEffectsSource, /mountsDomain\s*:/);
|
||||||
|
assert.match(sideEffectsSource, /registerAppHandlers\s*\(/);
|
||||||
|
assert.match(sideEffectsSource, /publishAppLocalUi\s*\(/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Hosts assemble bags field-by-field without spreading prepared domains', () => {
|
||||||
|
for (const name of ['VaultHost.tsx', 'TerminalHost.tsx', 'ChromeHost.tsx', 'DialogsHost.tsx']) {
|
||||||
|
const source = hostSources[name];
|
||||||
|
assert.ok(source, `application/app/hosts/${name} must exist`);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
source,
|
||||||
|
/handlers\?\.(vault|terminal|chrome|dialogs)Domain|handlers\?\.appShellChrome|handlers\?\.appShellOverlays/,
|
||||||
|
`${name} must not read prepared *Domain / appShell* bags from handlers`,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
source,
|
||||||
|
/\.\.\.\s*prepared/,
|
||||||
|
`${name} must not spread a prepared domain bag`,
|
||||||
|
);
|
||||||
|
assert.match(source, /getAppHandlers|subscribeAppHandlers/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('published Host bags omit notes, accent, and connectionLogs churn fields', () => {
|
||||||
|
const vaultHost = hostSources['VaultHost.tsx'];
|
||||||
|
const terminalHost = hostSources['TerminalHost.tsx'];
|
||||||
|
assert.ok(vaultHost && terminalHost);
|
||||||
|
|
||||||
|
// Notes / connection logs live in dedicated stores — VaultHost must not
|
||||||
|
// publish them into the vault domain bag that AppView consumes.
|
||||||
|
assert.doesNotMatch(vaultHost, /\bnotes\s*,/);
|
||||||
|
assert.doesNotMatch(vaultHost, /\bconnectionLogs\s*,/);
|
||||||
|
assert.doesNotMatch(vaultHost, /notesStore|connectionLogsStore|useNotesStore|useConnectionLogs/);
|
||||||
|
|
||||||
|
// Accent feeds useThemeRuntime for local injection only; the published
|
||||||
|
// terminal domain bag must not list accentMode/customAccent fields.
|
||||||
|
const domainStart = terminalHost.indexOf('const terminalDomain = useMemo');
|
||||||
|
assert.notEqual(domainStart, -1);
|
||||||
|
const domain = terminalHost.slice(domainStart, terminalHost.indexOf('useLayoutEffect(() => {\n if (terminalDomain)', domainStart));
|
||||||
|
assert.doesNotMatch(domain, /accentMode/);
|
||||||
|
assert.doesNotMatch(domain, /customAccent/);
|
||||||
|
assert.match(domain, /currentTerminalTheme,/);
|
||||||
|
assert.match(
|
||||||
|
terminalHost,
|
||||||
|
/useTerminalAppearanceInjection\(accentedGlobalAppearance/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AppShell uses default memo (store-driven), not always-rerender comparator', () => {
|
||||||
|
assert.match(appShellSource, /memo\s*\(\s*AppShellView\s*\)/);
|
||||||
|
assert.doesNotMatch(appShellSource, /memo\s*\(\s*AppShellView\s*,\s*\(\s*\)\s*=>\s*false\s*\)/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
import { useI18n } from '../i18n/I18nProvider';
|
||||||
|
import { ConfirmDialog } from '../../components/ui/confirm-dialog';
|
||||||
|
import { PortForwardHostKeyDialog } from '../../components/port-forwarding';
|
||||||
|
import { AppActiveTabChrome } from './AppActiveTabChrome';
|
||||||
|
import { AppView } from './AppView';
|
||||||
|
import {
|
||||||
|
useAppShellProps,
|
||||||
|
type AppShellOverlays,
|
||||||
|
} from './appShellPropsStore';
|
||||||
|
import { ChromeHost } from './hosts/ChromeHost';
|
||||||
|
import { DialogsHost } from './hosts/DialogsHost';
|
||||||
|
import { TerminalHost } from './hosts/TerminalHost';
|
||||||
|
import { VaultHost } from './hosts/VaultHost';
|
||||||
|
|
||||||
|
export type { AppShellOverlays };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rendered main window. Host islands subscribe to stores and publish
|
||||||
|
* domain / chrome / overlay bags into `appShellPropsStore`; this shell only
|
||||||
|
* re-renders when those bag identities change (see `appViewDomainsEqual` /
|
||||||
|
* chrome / overlays identity checks in the store).
|
||||||
|
*
|
||||||
|
* Default `memo` is enough: AppShell takes no props, so parent App updates
|
||||||
|
* do not force a re-render; store subscriptions via `useAppShellProps` still
|
||||||
|
* drive updates when Hosts publish new bag identities.
|
||||||
|
*
|
||||||
|
* `AppShell.architecture.test.ts` enforces Host composition and that the
|
||||||
|
* three mega hooks never reappear here.
|
||||||
|
*/
|
||||||
|
function AppShellView() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { domains, chrome, overlays } = useAppShellProps();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<VaultHost />
|
||||||
|
<TerminalHost />
|
||||||
|
<ChromeHost />
|
||||||
|
<DialogsHost />
|
||||||
|
{domains && chrome && overlays ? (
|
||||||
|
<>
|
||||||
|
<PortForwardHostKeyDialog onAddKnownHost={overlays.onAddKnownHost} />
|
||||||
|
<ConfirmDialog
|
||||||
|
open={overlays.deleteHostConfirm !== null}
|
||||||
|
title={
|
||||||
|
overlays.deleteHostConfirm
|
||||||
|
? t('confirm.deleteHost', { name: overlays.deleteHostConfirm.name })
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
confirmLabel={t('action.delete')}
|
||||||
|
destructive
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) overlays.onCancelDeleteHost();
|
||||||
|
}}
|
||||||
|
onConfirm={overlays.onConfirmDeleteHost}
|
||||||
|
/>
|
||||||
|
<AppActiveTabChrome {...chrome} />
|
||||||
|
<AppView domains={domains} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppShell = memo(AppShellView);
|
||||||
|
AppShell.displayName = 'AppShell';
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const source = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
test("snippets delete handler cleans host bindings via deleteSelectedSnippets", () => {
|
||||||
|
assert.match(source, /collectSnippetDeleteIds/);
|
||||||
|
assert.match(source, /deleteSelectedSnippets/);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/netcatty:snippets:delete[\s\S]*void deleteSelectedSnippets\(ids\)/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
source,
|
||||||
|
/updateSnippets\(snippets\.filter\(\(s\) => !ids\.has\(s\.id\)\)\)/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("snippets delete handler uses vault live snapshot instead of component refs", () => {
|
||||||
|
// Component-level snippetsRef/hostsRef lag concurrent vault mutations that
|
||||||
|
// already advanced useVaultState refs before React re-renders AppSideEffects.
|
||||||
|
// Deletion must go through the vault hook's atomic live-snapshot path.
|
||||||
|
assert.match(source, /deleteSelectedSnippets,/);
|
||||||
|
assert.doesNotMatch(source, /snippetsRef\.current\s*=\s*snippets/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
source,
|
||||||
|
/deleteSelectedSnippetsFromVault\(\s*snippetsRef\.current/,
|
||||||
|
);
|
||||||
|
});
|
||||||
Executable
+1778
File diff suppressed because it is too large.
Load diff
+332
-193
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo } from 'react';
|
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
|
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
|
||||||
import { activeTabStore, toEditorTabId, useIsEditorTabActive } from '../state/activeTabStore';
|
import { activeTabStore, toEditorTabId, useIsEditorTabActive } from '../state/activeTabStore';
|
||||||
import { editorTabStore } from '../state/editorTabStore';
|
import { editorTabStore } from '../state/editorTabStore';
|
||||||
@@ -12,7 +12,7 @@ import { QuickScriptEditorDialog } from '../../components/scripts/QuickScriptEdi
|
|||||||
import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog';
|
import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog';
|
||||||
import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal';
|
import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal';
|
||||||
import { PassphraseModal } from '../../components/PassphraseModal';
|
import { PassphraseModal } from '../../components/PassphraseModal';
|
||||||
import { UnsavedChangesProvider } from '../../components/editor/UnsavedChangesDialog';
|
import { UnsavedChangesProvider, promptUnsavedChanges } from '../../components/editor/UnsavedChangesDialog';
|
||||||
import { SnippetExecutionProvider } from '../../components/SnippetExecutionProvider';
|
import { SnippetExecutionProvider } from '../../components/SnippetExecutionProvider';
|
||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../components/ui/dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../components/ui/dialog';
|
||||||
@@ -24,12 +24,17 @@ import { AppHostTreeLayer } from './AppHostTreeLayer';
|
|||||||
import { AppHostEditorLayer } from './AppHostEditorLayer';
|
import { AppHostEditorLayer } from './AppHostEditorLayer';
|
||||||
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
|
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
|
||||||
import { shouldOpenHostEditOnWorkSurface } from './workTabSurface';
|
import { shouldOpenHostEditOnWorkSurface } from './workTabSurface';
|
||||||
import { getUiThemeById } from '../../infrastructure/config/uiThemes';
|
import { useConnectionLogsStore } from '../state/connectionLogsStore';
|
||||||
import { buildAppThemeCssVars } from '../state/settingsStateDefaults';
|
import {
|
||||||
|
useSettingsChromeActions,
|
||||||
|
useSettingsChromeStore,
|
||||||
|
} from '../state/settingsChromeStore';
|
||||||
|
import { useAppThemeStyle } from './useAppThemeStyle';
|
||||||
import { useMainWindowInputFocusRecovery } from '../state/useMainWindowInputFocusRecovery';
|
import { useMainWindowInputFocusRecovery } from '../state/useMainWindowInputFocusRecovery';
|
||||||
import { useExternalMcpToggleState } from '../state/useExternalMcpToggleState';
|
import { useExternalMcpToggleState } from '../state/useExternalMcpToggleState';
|
||||||
import { selectPluginThemeTokens } from '../state/pluginContributionEnvironment';
|
import { selectPluginThemeTokens } from '../state/pluginContributionEnvironment';
|
||||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||||
|
import { resolveEffectiveTerminalHost } from '../../domain/terminalHostResolution';
|
||||||
import { pluginViewTabStore, usePluginViewTabs } from '../state/pluginViewTabStore';
|
import { pluginViewTabStore, usePluginViewTabs } from '../state/pluginViewTabStore';
|
||||||
import { buildPluginSettingScopeCatalog } from '../state/usePluginSettingScopeCatalog';
|
import { buildPluginSettingScopeCatalog } from '../state/usePluginSettingScopeCatalog';
|
||||||
import { useWorkSurfaceHostEditor } from '../state/useWorkSurfaceHostEditor';
|
import { useWorkSurfaceHostEditor } from '../state/useWorkSurfaceHostEditor';
|
||||||
@@ -66,6 +71,144 @@ const TextEditorTabFallback = ({ tabId }: { tabId: string }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Local draft so keystrokes do not rebuild App chrome domain every character. */
|
||||||
|
function RenameDraftDialog({
|
||||||
|
open,
|
||||||
|
inputId,
|
||||||
|
initialName,
|
||||||
|
title,
|
||||||
|
nameLabel,
|
||||||
|
placeholder,
|
||||||
|
cancelLabel,
|
||||||
|
saveLabel,
|
||||||
|
onCancel,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
inputId: string;
|
||||||
|
initialName: string;
|
||||||
|
title: string;
|
||||||
|
nameLabel: string;
|
||||||
|
placeholder: string;
|
||||||
|
cancelLabel: string;
|
||||||
|
saveLabel: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSave: (name: string) => void;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState(initialName);
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setDraft(initialName);
|
||||||
|
}, [open, initialName]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(nextOpen) => {
|
||||||
|
if (!nextOpen) onCancel();
|
||||||
|
}}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-2 py-2">
|
||||||
|
<Label htmlFor={inputId}>{nameLabel}</Label>
|
||||||
|
<Input
|
||||||
|
id={inputId}
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== 'Enter') return;
|
||||||
|
if (!draft.trim()) return;
|
||||||
|
onSave(draft);
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={onCancel}>{cancelLabel}</Button>
|
||||||
|
<Button onClick={() => onSave(draft)} disabled={!draft.trim()}>{saveLabel}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies app theme CSS vars to the vault surface. Subscribing here (instead of
|
||||||
|
* in AppView) keeps accent-picker drags from rebuilding the whole shell; the
|
||||||
|
* `children` element identity is unchanged so VaultView bails out.
|
||||||
|
*/
|
||||||
|
function AppVaultThemeSurface({
|
||||||
|
VaultViewContainer,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
VaultViewContainer: React.ComponentType<any>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const appThemeStyle = useAppThemeStyle();
|
||||||
|
return (
|
||||||
|
<VaultViewContainer appThemeStyle={appThemeStyle}>
|
||||||
|
{children}
|
||||||
|
</VaultViewContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plugin keybinding host with locally derived theme tokens and locale. */
|
||||||
|
function AppPluginKeybindingThemeHost({
|
||||||
|
sessions,
|
||||||
|
workspaces,
|
||||||
|
}: {
|
||||||
|
sessions: any[];
|
||||||
|
workspaces: any[];
|
||||||
|
}) {
|
||||||
|
const appThemeStyle = useAppThemeStyle();
|
||||||
|
const { resolvedTheme, uiLanguage } = useSettingsChromeStore();
|
||||||
|
const pluginThemeTokens = useMemo(
|
||||||
|
() => selectPluginThemeTokens(appThemeStyle as Record<string, unknown>),
|
||||||
|
[appThemeStyle],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<AppPluginKeybindingHost
|
||||||
|
locale={uiLanguage}
|
||||||
|
theme={resolvedTheme}
|
||||||
|
themeTokens={pluginThemeTokens}
|
||||||
|
sessions={sessions}
|
||||||
|
workspaces={workspaces}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log replay surface reads the latest log body from connectionLogsStore, so
|
||||||
|
* terminal-data appends never touch the App domain bags.
|
||||||
|
*/
|
||||||
|
function AppLogViewSurface({
|
||||||
|
LogViewWrapper,
|
||||||
|
logView,
|
||||||
|
defaultTerminalTheme,
|
||||||
|
defaultFontSize,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
LogViewWrapper: React.ComponentType<any>;
|
||||||
|
logView: any;
|
||||||
|
defaultTerminalTheme: any;
|
||||||
|
defaultFontSize: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { connectionLogs, updateConnectionLog } = useConnectionLogsStore();
|
||||||
|
const latestLog =
|
||||||
|
connectionLogs.find((log) => log.id === logView.connectionLogId) ?? logView.log;
|
||||||
|
return (
|
||||||
|
<LogViewWrapper
|
||||||
|
logView={{ ...logView, log: latestLog }}
|
||||||
|
defaultTerminalTheme={defaultTerminalTheme}
|
||||||
|
defaultFontSize={defaultFontSize}
|
||||||
|
onClose={onClose}
|
||||||
|
onUpdateLog={updateConnectionLog}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export type AppViewProps = {
|
export type AppViewProps = {
|
||||||
domains: AppViewDomains;
|
domains: AppViewDomains;
|
||||||
};
|
};
|
||||||
@@ -114,30 +257,50 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
|
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
|
||||||
|
|
||||||
const {
|
const {
|
||||||
accentMode, addShellHistoryEntry, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
addShellHistoryEntry, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||||
clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
|
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
|
||||||
connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent,
|
convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts,
|
||||||
customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
|
customGroups, currentTerminalTheme, deepLinkHostDraft, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
|
||||||
followAppTerminalTheme,
|
followAppTerminalTheme,
|
||||||
groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost,
|
groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost,
|
||||||
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
|
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
|
||||||
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
|
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
|
||||||
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
|
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
|
||||||
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
||||||
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
||||||
resolveEmptyVaultConflict, resolvedTheme, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||||
sessionRenameValue, sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
|
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
|
||||||
setNavigateToSection, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
|
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
|
||||||
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
setWorkspaceFocusedSession, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||||
sftpShowHiddenFiles, sftpUseCompressedUpload, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
|
sftpShowHiddenFiles, sftpUseCompressedUpload, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
|
||||||
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
|
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
|
||||||
toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog,
|
toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource,
|
||||||
readPersistedHosts, readPersistedManagedSources, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources,
|
readPersistedHosts, readPersistedManagedSources, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources,
|
||||||
updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaceRenameValue, workspaces,
|
updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaces,
|
||||||
VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper,
|
VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper,
|
||||||
} = ctx;
|
} = ctx;
|
||||||
|
|
||||||
|
// Chrome-visible settings slice comes from settingsChromeStore, not from the
|
||||||
|
// App chrome domain bag — the whole `settings` object changes identity on
|
||||||
|
// every settings render and would rebuild the shell.
|
||||||
|
const {
|
||||||
|
theme: themePreference,
|
||||||
|
resolvedTheme,
|
||||||
|
windowOpacity,
|
||||||
|
showSftpTab,
|
||||||
|
showHostTreeSidebar,
|
||||||
|
showRecentHosts,
|
||||||
|
hostClickBehavior,
|
||||||
|
showOnlyUngroupedHostsInRoot,
|
||||||
|
dynamicTabTitleMode,
|
||||||
|
disableTerminalFontZoom,
|
||||||
|
restoreTerminalCwd,
|
||||||
|
terminalSidePanelAutoOpen,
|
||||||
|
terminalSidePanelAutoOpenTab,
|
||||||
|
} = useSettingsChromeStore();
|
||||||
|
const { setTheme, setWindowOpacity } = useSettingsChromeActions();
|
||||||
|
|
||||||
const handleTerminalCommandExecuted = useCallback((
|
const handleTerminalCommandExecuted = useCallback((
|
||||||
command: string,
|
command: string,
|
||||||
hostId: string,
|
hostId: string,
|
||||||
@@ -155,21 +318,29 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
setAddToWorkspaceDialog({ mode: 'append', workspaceId });
|
setAddToWorkspaceDialog({ mode: 'append', workspaceId });
|
||||||
}, [setAddToWorkspaceDialog]);
|
}, [setAddToWorkspaceDialog]);
|
||||||
|
|
||||||
const appThemeStyle = useMemo(() => {
|
const validProxyProfileIds = useMemo(
|
||||||
const tokens = getUiThemeById(
|
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
||||||
resolvedTheme,
|
[proxyProfiles],
|
||||||
resolvedTheme === 'dark' ? settings.darkUiThemeId : settings.lightUiThemeId,
|
|
||||||
).tokens;
|
|
||||||
return {
|
|
||||||
...buildAppThemeCssVars(tokens, accentMode, customAccent),
|
|
||||||
colorScheme: resolvedTheme,
|
|
||||||
} as React.CSSProperties;
|
|
||||||
}, [accentMode, customAccent, resolvedTheme, settings.darkUiThemeId, settings.lightUiThemeId]);
|
|
||||||
|
|
||||||
const pluginThemeTokens = useMemo(
|
|
||||||
() => selectPluginThemeTokens(appThemeStyle as Record<string, unknown>),
|
|
||||||
[appThemeStyle],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const resolveWorkspaceAppendHost = useCallback((host: typeof hosts[number]) => (
|
||||||
|
resolveEffectiveTerminalHost({
|
||||||
|
host,
|
||||||
|
groupConfigs,
|
||||||
|
proxyProfiles,
|
||||||
|
validProxyProfileIds,
|
||||||
|
})
|
||||||
|
), [groupConfigs, proxyProfiles, validProxyProfileIds]);
|
||||||
|
|
||||||
|
const handleAppendHostToWorkspace = useCallback((workspaceId: string, hostId: string) => {
|
||||||
|
const host = hosts.find((entry) => entry.id === hostId);
|
||||||
|
if (!host) return;
|
||||||
|
const ws = workspaces.find((entry) => entry.id === workspaceId);
|
||||||
|
if (!ws) return;
|
||||||
|
const rootDir = ws.root.type === 'split' ? ws.root.direction : 'vertical';
|
||||||
|
appendHostToWorkspace(workspaceId, resolveWorkspaceAppendHost(host), rootDir);
|
||||||
|
}, [appendHostToWorkspace, hosts, resolveWorkspaceAppendHost, workspaces]);
|
||||||
|
|
||||||
const isPeerSessionWindow = typeof window !== 'undefined'
|
const isPeerSessionWindow = typeof window !== 'undefined'
|
||||||
&& window.location.hash.startsWith('#/session-window');
|
&& window.location.hash.startsWith('#/session-window');
|
||||||
const externalMcpToggle = useExternalMcpToggleState();
|
const externalMcpToggle = useExternalMcpToggleState();
|
||||||
@@ -211,6 +382,70 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
pluginViewTabStore.close(tabId);
|
pluginViewTabStore.close(tabId);
|
||||||
}, [orderedTabsWithEditors]);
|
}, [orderedTabsWithEditors]);
|
||||||
|
|
||||||
|
const orderedTabsWithEditorsRef = useRef(orderedTabsWithEditors);
|
||||||
|
orderedTabsWithEditorsRef.current = orderedTabsWithEditors;
|
||||||
|
|
||||||
|
// Stable for TopTabs memo: read ordered tabs via ref; prompt via module singleton.
|
||||||
|
const handleRequestCloseEditorTab = useCallback(async (id: string): Promise<boolean> => {
|
||||||
|
const tab = editorTabStore.getTab(id);
|
||||||
|
if (!tab) return false;
|
||||||
|
|
||||||
|
const closeEditorAndActivateNeighbor = () => {
|
||||||
|
const closingTabId = toEditorTabId(id);
|
||||||
|
const list = orderedTabsWithEditorsRef.current;
|
||||||
|
const idx = list.indexOf(closingTabId);
|
||||||
|
releaseEditorTabSaveCoordinator(id);
|
||||||
|
editorTabStore.close(id);
|
||||||
|
if (activeTabStore.getActiveTabId() !== closingTabId) return;
|
||||||
|
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
|
||||||
|
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dirty = tab.content !== tab.baselineContent;
|
||||||
|
if (!dirty) {
|
||||||
|
closeEditorAndActivateNeighbor();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const choice = await promptUnsavedChanges(tab.fileName);
|
||||||
|
if (choice === 'cancel') return false;
|
||||||
|
if (choice === 'discard') {
|
||||||
|
closeEditorAndActivateNeighbor();
|
||||||
|
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 false;
|
||||||
|
}
|
||||||
|
const latest = editorTabStore.getTab(id);
|
||||||
|
if (!latest || latest.content !== latest.baselineContent) return false;
|
||||||
|
closeEditorAndActivateNeighbor();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keep the hotkey ref current during render so Cmd/Ctrl+W never sees the
|
||||||
|
// App.tsx stub `() => false` between commit and useEffect.
|
||||||
|
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
|
||||||
|
|
||||||
|
const handleSaveSessionRename = useCallback((name: string) => {
|
||||||
|
if (!sessionRenameTarget) return;
|
||||||
|
if (!name.trim()) return;
|
||||||
|
submitSessionRename(sessionRenameTarget.id, name);
|
||||||
|
resetSessionRename();
|
||||||
|
}, [resetSessionRename, sessionRenameTarget, submitSessionRename]);
|
||||||
|
|
||||||
|
const handleSaveWorkspaceRename = useCallback((name: string) => {
|
||||||
|
if (!workspaceRenameTarget) return;
|
||||||
|
if (!name.trim()) return;
|
||||||
|
submitWorkspaceRename(workspaceRenameTarget.id, name);
|
||||||
|
resetWorkspaceRename();
|
||||||
|
}, [resetWorkspaceRename, submitWorkspaceRename, workspaceRenameTarget]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const catalog = buildPluginSettingScopeCatalog({
|
const catalog = buildPluginSettingScopeCatalog({
|
||||||
hosts,
|
hosts,
|
||||||
@@ -224,58 +459,11 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
return (
|
return (
|
||||||
<SnippetExecutionProvider>
|
<SnippetExecutionProvider>
|
||||||
<UnsavedChangesProvider>
|
<UnsavedChangesProvider>
|
||||||
{({ prompt }) => {
|
{() => (
|
||||||
// Helper: close an editor tab and activate the neighbor (left-preference), or vault.
|
|
||||||
const closeEditorAndActivateNeighbor = (id: string) => {
|
|
||||||
const closingTabId = toEditorTabId(id);
|
|
||||||
const list = orderedTabsWithEditors;
|
|
||||||
const idx = list.indexOf(closingTabId);
|
|
||||||
releaseEditorTabSaveCoordinator(id);
|
|
||||||
editorTabStore.close(id);
|
|
||||||
if (activeTabStore.getActiveTabId() !== closingTabId) return;
|
|
||||||
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
|
|
||||||
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Real dirty-confirm close handler.
|
|
||||||
const handleRequestCloseEditorTab = async (id: string): Promise<boolean> => {
|
|
||||||
const tab = editorTabStore.getTab(id);
|
|
||||||
if (!tab) return false;
|
|
||||||
const dirty = tab.content !== tab.baselineContent;
|
|
||||||
if (!dirty) {
|
|
||||||
closeEditorAndActivateNeighbor(id);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const choice = await prompt(tab.fileName);
|
|
||||||
if (choice === 'cancel') return false;
|
|
||||||
if (choice === 'discard') {
|
|
||||||
closeEditorAndActivateNeighbor(id);
|
|
||||||
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 false;
|
|
||||||
}
|
|
||||||
const latest = editorTabStore.getTab(id);
|
|
||||||
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="flex flex-col h-screen text-foreground font-sans netcatty-shell" data-terminal-appearance-root onContextMenu={handleRootContextMenu}>
|
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" data-terminal-appearance-root onContextMenu={handleRootContextMenu}>
|
||||||
<TopTabs
|
<TopTabs
|
||||||
theme={resolvedTheme}
|
theme={resolvedTheme}
|
||||||
themePreference={settings.theme}
|
themePreference={themePreference}
|
||||||
hosts={hosts}
|
hosts={hosts}
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
orphanSessions={orphanSessions}
|
orphanSessions={orphanSessions}
|
||||||
@@ -295,21 +483,23 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
onCloseLogView={closeLogView}
|
onCloseLogView={closeLogView}
|
||||||
onCloseTabsBatch={closeTabsBatch}
|
onCloseTabsBatch={closeTabsBatch}
|
||||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||||
onThemeChange={settings.setTheme}
|
onThemeChange={setTheme}
|
||||||
onOpenSettings={handleOpenSettings}
|
onOpenSettings={handleOpenSettings}
|
||||||
externalMcpEnabled={externalMcpToggle.enabled}
|
externalMcpEnabled={externalMcpToggle.enabled}
|
||||||
onToggleExternalMcp={externalMcpToggle.setEnabled}
|
onToggleExternalMcp={externalMcpToggle.setEnabled}
|
||||||
showExternalMcpToggle={!isPeerSessionWindow}
|
showExternalMcpToggle={!isPeerSessionWindow}
|
||||||
windowOpacity={settings.windowOpacity}
|
windowOpacity={windowOpacity}
|
||||||
setWindowOpacity={settings.setWindowOpacity}
|
setWindowOpacity={setWindowOpacity}
|
||||||
onSyncNow={handleSyncNowManual}
|
onSyncNow={handleSyncNowManual}
|
||||||
onStartSessionDrag={setDraggingSessionId}
|
onStartSessionDrag={setDraggingSessionId}
|
||||||
onEndSessionDrag={handleEndSessionDrag}
|
onEndSessionDrag={handleEndSessionDrag}
|
||||||
onReorderTabs={reorderWorkTabs}
|
onReorderTabs={reorderWorkTabs}
|
||||||
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
|
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
|
||||||
showSftpTab={settings.showSftpTab}
|
onAppendHostToWorkspace={handleAppendHostToWorkspace}
|
||||||
showHostTreeSidebar={settings.showHostTreeSidebar}
|
showSftpTab={showSftpTab}
|
||||||
dynamicTabTitleMode={settings.terminalSettings.dynamicTabTitleMode}
|
showHostTreeSidebar={showHostTreeSidebar}
|
||||||
|
switchTabKeyBinding={keyBindings.find((binding) => binding.action === 'switchToTab') ?? null}
|
||||||
|
dynamicTabTitleMode={dynamicTabTitleMode}
|
||||||
editorTabs={editorTabs}
|
editorTabs={editorTabs}
|
||||||
pluginViewTabs={pluginViewTabs}
|
pluginViewTabs={pluginViewTabs}
|
||||||
onClosePluginViewTab={closePluginViewTab}
|
onClosePluginViewTab={closePluginViewTab}
|
||||||
@@ -319,7 +509,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
|
|
||||||
<div className="flex-1 relative min-h-0">
|
<div className="flex-1 relative min-h-0">
|
||||||
<AppHostTreeLayer
|
<AppHostTreeLayer
|
||||||
enabled={settings.showHostTreeSidebar}
|
enabled={showHostTreeSidebar}
|
||||||
hosts={hosts}
|
hosts={hosts}
|
||||||
customGroups={customGroups}
|
customGroups={customGroups}
|
||||||
groupConfigs={groupConfigs}
|
groupConfigs={groupConfigs}
|
||||||
@@ -328,9 +518,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
editorTabs={editorTabs}
|
editorTabs={editorTabs}
|
||||||
logViews={logViews}
|
logViews={logViews}
|
||||||
orderedTabs={orderedTabsWithEditors}
|
orderedTabs={orderedTabsWithEditors}
|
||||||
accentMode={accentMode}
|
|
||||||
currentTerminalTheme={currentTerminalTheme}
|
currentTerminalTheme={currentTerminalTheme}
|
||||||
customAccent={customAccent}
|
|
||||||
followAppTerminalTheme={followAppTerminalTheme}
|
followAppTerminalTheme={followAppTerminalTheme}
|
||||||
hostById={hostById}
|
hostById={hostById}
|
||||||
themeById={themeById}
|
themeById={themeById}
|
||||||
@@ -365,7 +553,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
onUpdateHosts={updateHosts}
|
onUpdateHosts={updateHosts}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<VaultViewContainer appThemeStyle={appThemeStyle}>
|
<AppVaultThemeSurface VaultViewContainer={VaultViewContainer}>
|
||||||
<VaultView
|
<VaultView
|
||||||
hosts={hosts}
|
hosts={hosts}
|
||||||
keys={keys}
|
keys={keys}
|
||||||
@@ -373,11 +561,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
proxyProfiles={proxyProfiles}
|
proxyProfiles={proxyProfiles}
|
||||||
snippets={snippets}
|
snippets={snippets}
|
||||||
snippetPackages={snippetPackages}
|
snippetPackages={snippetPackages}
|
||||||
notes={notes}
|
|
||||||
noteGroups={noteGroups}
|
|
||||||
customGroups={customGroups}
|
customGroups={customGroups}
|
||||||
knownHosts={effectiveKnownHosts}
|
knownHosts={effectiveKnownHosts}
|
||||||
connectionLogs={connectionLogs}
|
|
||||||
managedSources={managedSources}
|
managedSources={managedSources}
|
||||||
sessionCount={sessions.filter((s) => !s.hiddenFromTabs).length}
|
sessionCount={sessions.filter((s) => !s.hiddenFromTabs).length}
|
||||||
hotkeyScheme={hotkeyScheme}
|
hotkeyScheme={hotkeyScheme}
|
||||||
@@ -401,8 +586,6 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
onUpdateProxyProfiles={updateProxyProfiles}
|
onUpdateProxyProfiles={updateProxyProfiles}
|
||||||
onUpdateSnippets={updateSnippets}
|
onUpdateSnippets={updateSnippets}
|
||||||
onUpdateSnippetPackages={updateSnippetPackages}
|
onUpdateSnippetPackages={updateSnippetPackages}
|
||||||
onUpdateNotes={updateNotes}
|
|
||||||
onUpdateNoteGroups={updateNoteGroups}
|
|
||||||
onUpdateCustomGroups={updateCustomGroups}
|
onUpdateCustomGroups={updateCustomGroups}
|
||||||
onCommitPluginImporterData={commitPluginImporterData}
|
onCommitPluginImporterData={commitPluginImporterData}
|
||||||
onUpdateKnownHosts={updateKnownHosts}
|
onUpdateKnownHosts={updateKnownHosts}
|
||||||
@@ -413,14 +596,11 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
onClearAndRemoveManagedSources={clearAndRemoveSources}
|
onClearAndRemoveManagedSources={clearAndRemoveSources}
|
||||||
onUnmanageSource={unmanageSource}
|
onUnmanageSource={unmanageSource}
|
||||||
onConvertKnownHost={convertKnownHostToHost}
|
onConvertKnownHost={convertKnownHostToHost}
|
||||||
onToggleConnectionLogSaved={toggleConnectionLogSaved}
|
|
||||||
onDeleteConnectionLog={deleteConnectionLog}
|
|
||||||
onClearUnsavedConnectionLogs={clearUnsavedConnectionLogs}
|
|
||||||
onRunSnippet={runSnippet}
|
onRunSnippet={runSnippet}
|
||||||
onOpenLogView={openLogView}
|
onOpenLogView={openLogView}
|
||||||
showRecentHosts={settings.showRecentHosts}
|
showRecentHosts={showRecentHosts}
|
||||||
hostClickBehavior={settings.hostClickBehavior}
|
hostClickBehavior={hostClickBehavior}
|
||||||
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
|
showOnlyUngroupedHostsInRoot={showOnlyUngroupedHostsInRoot}
|
||||||
navigateToSection={navigateToSection}
|
navigateToSection={navigateToSection}
|
||||||
onNavigateToSectionHandled={() => setNavigateToSection(null)}
|
onNavigateToSectionHandled={() => setNavigateToSection(null)}
|
||||||
deepLinkHostDraft={deepLinkHostDraft}
|
deepLinkHostDraft={deepLinkHostDraft}
|
||||||
@@ -429,7 +609,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
onVaultFocusRequestHandled={() => setVaultFocusRequest(null)}
|
onVaultFocusRequestHandled={() => setVaultFocusRequest(null)}
|
||||||
terminalSettings={terminalSettings}
|
terminalSettings={terminalSettings}
|
||||||
/>
|
/>
|
||||||
</VaultViewContainer>
|
</AppVaultThemeSurface>
|
||||||
|
|
||||||
<SftpViewMount
|
<SftpViewMount
|
||||||
hosts={terminalHosts}
|
hosts={terminalHosts}
|
||||||
@@ -464,8 +644,6 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
identities={identities}
|
identities={identities}
|
||||||
snippets={snippets}
|
snippets={snippets}
|
||||||
snippetPackages={snippetPackages}
|
snippetPackages={snippetPackages}
|
||||||
notes={notes}
|
|
||||||
noteGroups={noteGroups}
|
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
workspaces={workspaces}
|
workspaces={workspaces}
|
||||||
knownHosts={effectiveKnownHosts}
|
knownHosts={effectiveKnownHosts}
|
||||||
@@ -477,14 +655,12 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
clearThemeIntent={ctx.clearThemeIntent}
|
clearThemeIntent={ctx.clearThemeIntent}
|
||||||
settleManualThemeIntent={ctx.settleManualThemeIntent}
|
settleManualThemeIntent={ctx.settleManualThemeIntent}
|
||||||
resolveSessionAppearance={ctx.resolveSessionAppearance}
|
resolveSessionAppearance={ctx.resolveSessionAppearance}
|
||||||
accentMode={accentMode}
|
|
||||||
customAccent={customAccent}
|
|
||||||
terminalSettings={terminalSettings}
|
terminalSettings={terminalSettings}
|
||||||
terminalFontFamilyId={terminalFontFamilyId}
|
terminalFontFamilyId={terminalFontFamilyId}
|
||||||
fontSize={terminalFontSize}
|
fontSize={terminalFontSize}
|
||||||
hotkeyScheme={hotkeyScheme}
|
hotkeyScheme={hotkeyScheme}
|
||||||
disableTerminalFontZoom={settings.disableTerminalFontZoom}
|
disableTerminalFontZoom={disableTerminalFontZoom}
|
||||||
restoreTerminalCwd={settings.restoreTerminalCwd}
|
restoreTerminalCwd={restoreTerminalCwd}
|
||||||
keyBindings={keyBindings}
|
keyBindings={keyBindings}
|
||||||
onHotkeyAction={handleHotkeyAction}
|
onHotkeyAction={handleHotkeyAction}
|
||||||
onUpdateTerminalThemeId={handleDefaultTerminalThemeChange}
|
onUpdateTerminalThemeId={handleDefaultTerminalThemeChange}
|
||||||
@@ -508,6 +684,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
||||||
onAddSessionToWorkspace={addSessionToWorkspace}
|
onAddSessionToWorkspace={addSessionToWorkspace}
|
||||||
onRequestAddToWorkspace={handleRequestAddToWorkspace}
|
onRequestAddToWorkspace={handleRequestAddToWorkspace}
|
||||||
|
onAppendHostToWorkspace={handleAppendHostToWorkspace}
|
||||||
onUpdateSplitSizes={updateSplitSizes}
|
onUpdateSplitSizes={updateSplitSizes}
|
||||||
onSetDraggingSessionId={setDraggingSessionId}
|
onSetDraggingSessionId={setDraggingSessionId}
|
||||||
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
|
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
|
||||||
@@ -529,16 +706,14 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
updateHosts={updateTerminalHosts}
|
updateHosts={updateTerminalHosts}
|
||||||
updateSnippets={updateSnippets}
|
updateSnippets={updateSnippets}
|
||||||
updateSnippetPackages={updateSnippetPackages}
|
updateSnippetPackages={updateSnippetPackages}
|
||||||
updateNotes={updateNotes}
|
|
||||||
updateNoteGroups={updateNoteGroups}
|
|
||||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||||
sftpAutoSync={sftpAutoSync}
|
sftpAutoSync={sftpAutoSync}
|
||||||
sftpShowHiddenFiles={sftpShowHiddenFiles}
|
sftpShowHiddenFiles={sftpShowHiddenFiles}
|
||||||
sftpUseCompressedUpload={sftpUseCompressedUpload}
|
sftpUseCompressedUpload={sftpUseCompressedUpload}
|
||||||
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
|
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
|
||||||
terminalSidePanelAutoOpen={settings.terminalSidePanelAutoOpen}
|
terminalSidePanelAutoOpen={terminalSidePanelAutoOpen}
|
||||||
terminalSidePanelAutoOpenTab={settings.terminalSidePanelAutoOpenTab}
|
terminalSidePanelAutoOpenTab={terminalSidePanelAutoOpenTab}
|
||||||
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
|
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
|
||||||
setSftpFollowTerminalCwd={setSftpFollowTerminalCwd}
|
setSftpFollowTerminalCwd={setSftpFollowTerminalCwd}
|
||||||
editorWordWrap={editorWordWrap}
|
editorWordWrap={editorWordWrap}
|
||||||
@@ -548,7 +723,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
sessionLogsFormat={sessionLogsFormat}
|
sessionLogsFormat={sessionLogsFormat}
|
||||||
sessionLogsTimestampsEnabled={sessionLogsTimestampsEnabled}
|
sessionLogsTimestampsEnabled={sessionLogsTimestampsEnabled}
|
||||||
sshDebugLogsEnabled={sshDebugLogsEnabled}
|
sshDebugLogsEnabled={sshDebugLogsEnabled}
|
||||||
showHostTreeSidebar={settings.showHostTreeSidebar}
|
showHostTreeSidebar={showHostTreeSidebar}
|
||||||
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
|
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
|
||||||
toggleSidePanelRef={toggleSidePanelRef}
|
toggleSidePanelRef={toggleSidePanelRef}
|
||||||
onStartSessionRename={startSessionRename}
|
onStartSessionRename={startSessionRename}
|
||||||
@@ -556,21 +731,18 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
|
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Log Views - readonly terminal replays */}
|
{/* Log Views - readonly terminal replays. The latest log body comes
|
||||||
{logViews.map(logView => {
|
from connectionLogsStore inside AppLogViewSurface. */}
|
||||||
// Get the latest log data from connectionLogs to reflect updates
|
{logViews.map((logView: any) => (
|
||||||
const latestLog = connectionLogs.find(l => l.id === logView.connectionLogId) || logView.log;
|
<AppLogViewSurface
|
||||||
return (
|
key={logView.id}
|
||||||
<LogViewWrapper
|
LogViewWrapper={LogViewWrapper}
|
||||||
key={logView.id}
|
logView={logView}
|
||||||
logView={{ ...logView, log: latestLog }}
|
defaultTerminalTheme={currentTerminalTheme}
|
||||||
defaultTerminalTheme={currentTerminalTheme}
|
defaultFontSize={terminalFontSize}
|
||||||
defaultFontSize={terminalFontSize}
|
onClose={() => closeLogView(logView.id)}
|
||||||
onClose={() => closeLogView(logView.id)}
|
/>
|
||||||
onUpdateLog={updateConnectionLog}
|
))}
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Editor Tabs — kept mounted for Monaco instance persistence; visibility toggled via CSS */}
|
{/* Editor Tabs — kept mounted for Monaco instance persistence; visibility toggled via CSS */}
|
||||||
{editorTabs.map((tab) => (
|
{editorTabs.map((tab) => (
|
||||||
@@ -587,10 +759,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
</LazyLoadBoundary>
|
</LazyLoadBoundary>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<AppPluginKeybindingHost
|
<AppPluginKeybindingThemeHost
|
||||||
locale={settings.uiLanguage}
|
|
||||||
theme={resolvedTheme}
|
|
||||||
themeTokens={pluginThemeTokens}
|
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
workspaces={workspaces}
|
workspaces={workspaces}
|
||||||
/>
|
/>
|
||||||
@@ -637,13 +806,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
<AddToWorkspaceDialog
|
<AddToWorkspaceDialog
|
||||||
open
|
open
|
||||||
onOpenChange={(open) => { if (!open) setAddToWorkspaceDialog(null); }}
|
onOpenChange={(open) => { if (!open) setAddToWorkspaceDialog(null); }}
|
||||||
// Filter serial hosts only in append mode — appendHostToWorkspace
|
hosts={hosts}
|
||||||
// has no serial code path. Create mode goes through
|
|
||||||
// createWorkspaceFromTargets, which builds a SerialConfig-backed
|
|
||||||
// session for serial hosts, so those should remain pickable.
|
|
||||||
hosts={addToWorkspaceDialog.mode === 'append'
|
|
||||||
? hosts.filter((h) => h.protocol !== 'serial')
|
|
||||||
: hosts}
|
|
||||||
workspaceTitle={
|
workspaceTitle={
|
||||||
addToWorkspaceDialog.mode === 'append'
|
addToWorkspaceDialog.mode === 'append'
|
||||||
? workspaces.find((w) => w.id === addToWorkspaceDialog.workspaceId)?.title
|
? workspaces.find((w) => w.id === addToWorkspaceDialog.workspaceId)?.title
|
||||||
@@ -662,7 +825,11 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
if (target.kind === 'local') {
|
if (target.kind === 'local') {
|
||||||
appendLocalTerminalToWorkspace(addToWorkspaceDialog.workspaceId, undefined, rootDir);
|
appendLocalTerminalToWorkspace(addToWorkspaceDialog.workspaceId, undefined, rootDir);
|
||||||
} else {
|
} else {
|
||||||
appendHostToWorkspace(addToWorkspaceDialog.workspaceId, target.host, rootDir);
|
appendHostToWorkspace(
|
||||||
|
addToWorkspaceDialog.workspaceId,
|
||||||
|
resolveWorkspaceAppendHost(target.host),
|
||||||
|
rootDir,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -681,7 +848,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
results={quickResults}
|
results={quickResults}
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
workspaces={workspaces}
|
workspaces={workspaces}
|
||||||
showSftpTab={settings.showSftpTab}
|
showSftpTab={showSftpTab}
|
||||||
onQueryChange={setQuickSearch}
|
onQueryChange={setQuickSearch}
|
||||||
onSelect={handleHostConnectWithProtocolCheck}
|
onSelect={handleHostConnectWithProtocolCheck}
|
||||||
onEditHost={(host) => {
|
onEditHost={(host) => {
|
||||||
@@ -715,59 +882,32 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
</LazyLoadBoundary>
|
</LazyLoadBoundary>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={!!sessionRenameTarget} onOpenChange={(open) => {
|
<RenameDraftDialog
|
||||||
if (!open) {
|
open={!!sessionRenameTarget}
|
||||||
resetSessionRename();
|
inputId="session-name"
|
||||||
}
|
initialName={sessionRenameTarget ? (sessionRenameTarget.customName || sessionRenameTarget.hostLabel) : ''}
|
||||||
}}>
|
title={t('dialog.renameSession.title')}
|
||||||
<DialogContent className="max-w-sm">
|
nameLabel={t('field.name')}
|
||||||
<DialogHeader>
|
placeholder={t('placeholder.sessionName')}
|
||||||
<DialogTitle>{t('dialog.renameSession.title')}</DialogTitle>
|
cancelLabel={t('common.cancel')}
|
||||||
</DialogHeader>
|
saveLabel={t('common.save')}
|
||||||
<div className="space-y-2 py-2">
|
onCancel={resetSessionRename}
|
||||||
<Label htmlFor="session-name">{t('field.name')}</Label>
|
onSave={handleSaveSessionRename}
|
||||||
<Input
|
/>
|
||||||
id="session-name"
|
|
||||||
value={sessionRenameValue}
|
<RenameDraftDialog
|
||||||
onChange={(e) => setSessionRenameValue(e.target.value)}
|
open={!!workspaceRenameTarget}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') submitSessionRename(); }}
|
inputId="workspace-name"
|
||||||
autoFocus
|
initialName={workspaceRenameTarget ? workspaceRenameTarget.title : ''}
|
||||||
placeholder={t('placeholder.sessionName')}
|
title={t('dialog.renameWorkspace.title')}
|
||||||
/>
|
nameLabel={t('field.name')}
|
||||||
</div>
|
placeholder={t('placeholder.workspaceName')}
|
||||||
<DialogFooter>
|
cancelLabel={t('common.cancel')}
|
||||||
<Button variant="ghost" onClick={resetSessionRename}>{t('common.cancel')}</Button>
|
saveLabel={t('common.save')}
|
||||||
<Button onClick={submitSessionRename} disabled={!sessionRenameValue.trim()}>{t('common.save')}</Button>
|
onCancel={resetWorkspaceRename}
|
||||||
</DialogFooter>
|
onSave={handleSaveWorkspaceRename}
|
||||||
</DialogContent>
|
/>
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog open={!!workspaceRenameTarget} onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
resetWorkspaceRename();
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<DialogContent className="max-w-sm">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{t('dialog.renameWorkspace.title')}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-2 py-2">
|
|
||||||
<Label htmlFor="workspace-name">{t('field.name')}</Label>
|
|
||||||
<Input
|
|
||||||
id="workspace-name"
|
|
||||||
value={workspaceRenameValue}
|
|
||||||
onChange={(e) => setWorkspaceRenameValue(e.target.value)}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') submitWorkspaceRename(); }}
|
|
||||||
autoFocus
|
|
||||||
placeholder={t('placeholder.workspaceName')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="ghost" onClick={resetWorkspaceRename}>{t('common.cancel')}</Button>
|
|
||||||
<Button onClick={submitWorkspaceRename} disabled={!workspaceRenameValue.trim()}>{t('common.save')}</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{isCreateWorkspaceOpen && (
|
{isCreateWorkspaceOpen && (
|
||||||
<LazyLoadBoundary name="Create workspace" resetKey="create-workspace">
|
<LazyLoadBoundary name="Create workspace" resetKey="create-workspace">
|
||||||
@@ -871,8 +1011,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
}}
|
|
||||||
</UnsavedChangesProvider>
|
</UnsavedChangesProvider>
|
||||||
</SnippetExecutionProvider>
|
</SnippetExecutionProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const source = readFileSync(new URL('./AppView.tsx', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
test('workspace append resolves group defaults before creating host sessions', () => {
|
||||||
|
assert.match(source, /resolveEffectiveTerminalHost\(\{/);
|
||||||
|
assert.match(source, /groupConfigs,/);
|
||||||
|
assert.match(source, /proxyProfiles,/);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/appendHostToWorkspace\(workspaceId, resolveWorkspaceAppendHost\(host\), rootDir\)/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/appendHostToWorkspace\([\s\S]*?resolveWorkspaceAppendHost\(target\.host\),[\s\S]*?rootDir/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -78,6 +78,30 @@ test("editor tabs use the followed terminal theme when follow-app terminal theme
|
|||||||
assert.equal(resolved?.id, currentTheme.id);
|
assert.equal(resolved?.id, currentTheme.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("follow-app chrome applies custom accent onto the published base theme", () => {
|
||||||
|
const editorTab = {
|
||||||
|
id: "editor-1",
|
||||||
|
hostId: "host-1",
|
||||||
|
sessionId: "sftp-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolved = resolveActiveChromeTheme({
|
||||||
|
...baseInput,
|
||||||
|
accentMode: "custom",
|
||||||
|
customAccent: "0 100% 50%",
|
||||||
|
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);
|
||||||
|
assert.notEqual(resolved?.colors.cursor, currentTheme.colors.cursor);
|
||||||
|
assert.notEqual(resolved, currentTheme);
|
||||||
|
});
|
||||||
|
|
||||||
test("log tabs use the saved log theme when available", () => {
|
test("log tabs use the saved log theme when available", () => {
|
||||||
const resolved = resolveActiveChromeTheme({
|
const resolved = resolveActiveChromeTheme({
|
||||||
...baseInput,
|
...baseInput,
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ export type ResolveActiveChromeThemeInput = {
|
|||||||
customAccent: string;
|
customAccent: string;
|
||||||
editorTabs: readonly EditorTabChrome[];
|
editorTabs: readonly EditorTabChrome[];
|
||||||
followAppTerminalTheme: boolean;
|
followAppTerminalTheme: boolean;
|
||||||
hostById: Map<string, Host>;
|
hostById: ReadonlyMap<string, Host>;
|
||||||
logViews: readonly LogView[];
|
logViews: readonly LogView[];
|
||||||
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
|
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
|
||||||
sessionById: Map<string, TerminalSession>;
|
sessionById: ReadonlyMap<string, TerminalSession>;
|
||||||
themeById: Map<string, TerminalTheme>;
|
themeById: ReadonlyMap<string, TerminalTheme>;
|
||||||
workspaceById: Map<string, Workspace>;
|
workspaceById: ReadonlyMap<string, Workspace>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isActiveChromeThemeResolvable({
|
export function isActiveChromeThemeResolvable({
|
||||||
@@ -68,7 +68,9 @@ export function resolveActiveChromeTheme({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resolveHostTheme = (hostId: string): TerminalTheme => {
|
const resolveHostTheme = (hostId: string): TerminalTheme => {
|
||||||
if (followAppTerminalTheme) return currentTerminalTheme;
|
if (followAppTerminalTheme) {
|
||||||
|
return applyCustomAccentToTerminalTheme(currentTerminalTheme, accentMode, customAccent);
|
||||||
|
}
|
||||||
if (resolveSessionAppearance) {
|
if (resolveSessionAppearance) {
|
||||||
return resolveSessionAppearance(resolveHostScope(hostId)).theme;
|
return resolveSessionAppearance(resolveHostScope(hostId)).theme;
|
||||||
}
|
}
|
||||||
@@ -90,12 +92,15 @@ export function resolveActiveChromeTheme({
|
|||||||
const logView = logViews.find((item) => item.id === activeTabId);
|
const logView = logViews.find((item) => item.id === activeTabId);
|
||||||
if (logView) {
|
if (logView) {
|
||||||
const explicitThemeId = logView.log.themeId;
|
const explicitThemeId = logView.log.themeId;
|
||||||
return explicitThemeId ? themeById.get(explicitThemeId) ?? currentTerminalTheme : currentTerminalTheme;
|
const base = explicitThemeId ? themeById.get(explicitThemeId) ?? currentTerminalTheme : currentTerminalTheme;
|
||||||
|
return applyCustomAccentToTerminalTheme(base, accentMode, customAccent);
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspace = workspaceById.get(activeTabId);
|
const workspace = workspaceById.get(activeTabId);
|
||||||
if (workspace) {
|
if (workspace) {
|
||||||
if (followAppTerminalTheme) return currentTerminalTheme;
|
if (followAppTerminalTheme) {
|
||||||
|
return applyCustomAccentToTerminalTheme(currentTerminalTheme, accentMode, customAccent);
|
||||||
|
}
|
||||||
|
|
||||||
if (workspace.viewMode === "focus") {
|
if (workspace.viewMode === "focus") {
|
||||||
const focusedSession = resolveWorkspaceTargetSessionFromMap(workspace, sessionById);
|
const focusedSession = resolveWorkspaceTargetSessionFromMap(workspace, sessionById);
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Stable accessor bridge for App-local handlers that Host islands need when
|
||||||
|
* assembling domain bags. `AppSideEffects` registers the live glue via
|
||||||
|
* `useLayoutEffect`; Hosts call `getAppHandlers()` instead of receiving mega
|
||||||
|
* props from App.
|
||||||
|
*
|
||||||
|
* Keep this intentionally loose (`Record<string, unknown>`): the handler set
|
||||||
|
* tracks App glue, not a frozen public API.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Listener = () => void;
|
||||||
|
|
||||||
|
export type AppHandlers = Record<string, unknown>;
|
||||||
|
|
||||||
|
function handlersShallowEqual(prev: AppHandlers, next: AppHandlers): boolean {
|
||||||
|
const prevKeys = Object.keys(prev);
|
||||||
|
const nextKeys = Object.keys(next);
|
||||||
|
if (prevKeys.length !== nextKeys.length) return false;
|
||||||
|
for (const key of nextKeys) {
|
||||||
|
if (prev[key] !== next[key]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppHandlersBridge {
|
||||||
|
private handlers: AppHandlers | null = null;
|
||||||
|
private listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
get = (): AppHandlers | null => this.handlers;
|
||||||
|
|
||||||
|
subscribe = (listener: Listener): (() => void) => {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
set(next: AppHandlers | null): void {
|
||||||
|
if (this.handlers === next) return;
|
||||||
|
if (
|
||||||
|
this.handlers
|
||||||
|
&& next
|
||||||
|
&& handlersShallowEqual(this.handlers, next)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.handlers = next;
|
||||||
|
for (const listener of this.listeners) listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bridge = new AppHandlersBridge();
|
||||||
|
|
||||||
|
export function registerAppHandlers(handlers: AppHandlers | null): void {
|
||||||
|
bridge.set(handlers);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAppHandlers(): AppHandlers | null {
|
||||||
|
return bridge.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeAppHandlers(listener: Listener): () => void {
|
||||||
|
return bridge.subscribe(listener);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
import type { Host, PortForwardingRule } from '../../domain/models';
|
||||||
|
import type { VaultSection } from '../../components/VaultView';
|
||||||
|
import type { KeyboardInteractiveRequest } from '../../components/KeyboardInteractiveModal';
|
||||||
|
import type { PassphraseRequest } from '../../components/PassphraseModal';
|
||||||
|
|
||||||
|
type Listener = () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog / queue / ephemeral UI owned outside the vault/session/settings
|
||||||
|
* mega hooks. DialogsHost / VaultHost / TerminalHost subscribe here so
|
||||||
|
* AppSideEffects never has to rebuild domain bags when a modal opens.
|
||||||
|
*
|
||||||
|
* `portForwardingRules` is published here as a thin derived slice: the PF
|
||||||
|
* hook still lives in AppSideEffects (tray / sync / auto-start), but Hosts
|
||||||
|
* must not receive a prepared terminal domain bag.
|
||||||
|
*/
|
||||||
|
export type AppLocalUiSnapshot = {
|
||||||
|
isQuickSwitcherOpen: boolean;
|
||||||
|
isCreateWorkspaceOpen: boolean;
|
||||||
|
addToWorkspaceDialog:
|
||||||
|
| { mode: 'append'; workspaceId: string }
|
||||||
|
| { mode: 'create' }
|
||||||
|
| null;
|
||||||
|
quickSearch: string;
|
||||||
|
protocolSelectHost: Host | null;
|
||||||
|
navigateToSection: VaultSection | null;
|
||||||
|
deepLinkHostDraft: Host | null;
|
||||||
|
ephemeralHosts: readonly Host[];
|
||||||
|
portForwardingRules: readonly PortForwardingRule[];
|
||||||
|
keyboardInteractiveQueue: readonly KeyboardInteractiveRequest[];
|
||||||
|
passphraseQueue: readonly PassphraseRequest[];
|
||||||
|
deleteHostConfirm: { hostId: string; name: string } | null;
|
||||||
|
vaultFocusRequest: unknown;
|
||||||
|
openNoteRequest: unknown;
|
||||||
|
emptyVaultConflict: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EMPTY_APP_LOCAL_UI: AppLocalUiSnapshot = Object.freeze({
|
||||||
|
isQuickSwitcherOpen: false,
|
||||||
|
isCreateWorkspaceOpen: false,
|
||||||
|
addToWorkspaceDialog: null,
|
||||||
|
quickSearch: '',
|
||||||
|
protocolSelectHost: null,
|
||||||
|
navigateToSection: null,
|
||||||
|
deepLinkHostDraft: null,
|
||||||
|
ephemeralHosts: Object.freeze([]) as readonly Host[],
|
||||||
|
portForwardingRules: Object.freeze([]) as readonly PortForwardingRule[],
|
||||||
|
keyboardInteractiveQueue: Object.freeze([]) as readonly KeyboardInteractiveRequest[],
|
||||||
|
passphraseQueue: Object.freeze([]) as readonly PassphraseRequest[],
|
||||||
|
deleteHostConfirm: null,
|
||||||
|
vaultFocusRequest: null,
|
||||||
|
openNoteRequest: null,
|
||||||
|
emptyVaultConflict: null,
|
||||||
|
});
|
||||||
|
class AppLocalUiStore {
|
||||||
|
private snapshot: AppLocalUiSnapshot = EMPTY_APP_LOCAL_UI;
|
||||||
|
private listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
getSnapshot = (): AppLocalUiSnapshot => this.snapshot;
|
||||||
|
|
||||||
|
subscribe = (listener: Listener): (() => void) => {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
setSnapshot(next: AppLocalUiSnapshot): void {
|
||||||
|
const prev = this.snapshot;
|
||||||
|
if (
|
||||||
|
prev.isQuickSwitcherOpen === next.isQuickSwitcherOpen
|
||||||
|
&& prev.isCreateWorkspaceOpen === next.isCreateWorkspaceOpen
|
||||||
|
&& prev.addToWorkspaceDialog === next.addToWorkspaceDialog
|
||||||
|
&& prev.quickSearch === next.quickSearch
|
||||||
|
&& prev.protocolSelectHost === next.protocolSelectHost
|
||||||
|
&& prev.navigateToSection === next.navigateToSection
|
||||||
|
&& prev.deepLinkHostDraft === next.deepLinkHostDraft
|
||||||
|
&& prev.ephemeralHosts === next.ephemeralHosts
|
||||||
|
&& prev.portForwardingRules === next.portForwardingRules
|
||||||
|
&& prev.keyboardInteractiveQueue === next.keyboardInteractiveQueue
|
||||||
|
&& prev.passphraseQueue === next.passphraseQueue
|
||||||
|
&& prev.deleteHostConfirm === next.deleteHostConfirm
|
||||||
|
&& prev.vaultFocusRequest === next.vaultFocusRequest
|
||||||
|
&& prev.openNoteRequest === next.openNoteRequest
|
||||||
|
&& prev.emptyVaultConflict === next.emptyVaultConflict
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.snapshot = next;
|
||||||
|
for (const listener of this.listeners) listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appLocalUiStore = new AppLocalUiStore();
|
||||||
|
|
||||||
|
export function publishAppLocalUi(snapshot: AppLocalUiSnapshot): void {
|
||||||
|
appLocalUiStore.setSnapshot(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAppLocalUiSnapshot(): AppLocalUiSnapshot {
|
||||||
|
return appLocalUiStore.getSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeAppLocalUi(listener: Listener): () => void {
|
||||||
|
return appLocalUiStore.subscribe(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppLocalUiStore(): AppLocalUiSnapshot {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
subscribeAppLocalUi,
|
||||||
|
getAppLocalUiSnapshot,
|
||||||
|
getAppLocalUiSnapshot,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
import type { KnownHost } from '../../types';
|
||||||
|
import type { AppActiveTabChromeProps } from './AppActiveTabChrome';
|
||||||
|
import { appViewDomainsEqual, type AppViewDomains } from './appViewDomains';
|
||||||
|
|
||||||
|
type Listener = () => void;
|
||||||
|
|
||||||
|
export type AppShellOverlays = {
|
||||||
|
onAddKnownHost: (knownHost: KnownHost) => void;
|
||||||
|
deleteHostConfirm: { hostId: string; name: string } | null;
|
||||||
|
onCancelDeleteHost: () => void;
|
||||||
|
onConfirmDeleteHost: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppShellPropsSnapshot = {
|
||||||
|
domains: AppViewDomains | null;
|
||||||
|
chrome: AppActiveTabChromeProps | null;
|
||||||
|
overlays: AppShellOverlays | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY: AppShellPropsSnapshot = Object.freeze({
|
||||||
|
domains: null,
|
||||||
|
chrome: null,
|
||||||
|
overlays: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host islands publish domain / chrome / overlay bags here. `AppShell`
|
||||||
|
* subscribes via `useSyncExternalStore` and only re-renders when bag
|
||||||
|
* identities change (`appViewDomainsEqual` + chrome/overlays identity).
|
||||||
|
*/
|
||||||
|
class AppShellPropsStore {
|
||||||
|
private snapshot: AppShellPropsSnapshot = EMPTY;
|
||||||
|
private listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
getSnapshot = (): AppShellPropsSnapshot => this.snapshot;
|
||||||
|
|
||||||
|
subscribe = (listener: Listener): (() => void) => {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
setDomains(domains: AppViewDomains): void {
|
||||||
|
const prev = this.snapshot;
|
||||||
|
if (prev.domains && appViewDomainsEqual(prev.domains, domains)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.snapshot = { ...prev, domains };
|
||||||
|
for (const listener of this.listeners) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
setChrome(chrome: AppActiveTabChromeProps): void {
|
||||||
|
if (this.snapshot.chrome === chrome) return;
|
||||||
|
this.snapshot = { ...this.snapshot, chrome };
|
||||||
|
for (const listener of this.listeners) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
setOverlays(overlays: AppShellOverlays): void {
|
||||||
|
if (this.snapshot.overlays === overlays) return;
|
||||||
|
this.snapshot = { ...this.snapshot, overlays };
|
||||||
|
for (const listener of this.listeners) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
setDomainSlice<K extends keyof AppViewDomains>(
|
||||||
|
key: K,
|
||||||
|
slice: AppViewDomains[K],
|
||||||
|
): void {
|
||||||
|
const prev = this.snapshot.domains;
|
||||||
|
if (prev && prev[key] === slice) return;
|
||||||
|
const domains = {
|
||||||
|
vault: prev?.vault ?? {},
|
||||||
|
terminal: prev?.terminal ?? {},
|
||||||
|
chrome: prev?.chrome ?? {},
|
||||||
|
dialogs: prev?.dialogs ?? {},
|
||||||
|
mounts: prev?.mounts ?? {},
|
||||||
|
[key]: slice,
|
||||||
|
} as AppViewDomains;
|
||||||
|
this.setDomains(domains);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appShellPropsStore = new AppShellPropsStore();
|
||||||
|
|
||||||
|
export function publishAppShellDomains(domains: AppViewDomains): void {
|
||||||
|
appShellPropsStore.setDomains(domains);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publishAppShellDomainSlice<K extends keyof AppViewDomains>(
|
||||||
|
key: K,
|
||||||
|
slice: AppViewDomains[K],
|
||||||
|
): void {
|
||||||
|
appShellPropsStore.setDomainSlice(key, slice);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publishAppShellChrome(chrome: AppActiveTabChromeProps): void {
|
||||||
|
appShellPropsStore.setChrome(chrome);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publishAppShellOverlays(overlays: AppShellOverlays): void {
|
||||||
|
appShellPropsStore.setOverlays(overlays);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAppShellPropsSnapshot(): AppShellPropsSnapshot {
|
||||||
|
return appShellPropsStore.getSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeAppShellProps(listener: Listener): () => void {
|
||||||
|
return appShellPropsStore.subscribe(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppShellProps(): AppShellPropsSnapshot {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
subscribeAppShellProps,
|
||||||
|
getAppShellPropsSnapshot,
|
||||||
|
getAppShellPropsSnapshot,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { useCallback, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
import { TERMINAL_THEMES } from '../../../infrastructure/config/terminalThemes';
|
||||||
|
import { retainStableSessionsIgnoringPresentation } from '../../../domain/terminalPaneSessionsEqual';
|
||||||
|
import { useI18n } from '../../i18n/I18nProvider';
|
||||||
|
import { useCustomThemes } from '../../state/customThemeStore';
|
||||||
|
import { useEditorTabChromeList } from '../../state/editorTabStore';
|
||||||
|
import { toEditorTabId } from '../../state/activeTabStore';
|
||||||
|
import {
|
||||||
|
getSessionSnapshotActions,
|
||||||
|
useSessionSnapshot,
|
||||||
|
useSessionSnapshotActions,
|
||||||
|
} from '../../state/sessionSnapshotStore';
|
||||||
|
import { useSettingsChromeStore } from '../../state/settingsChromeStore';
|
||||||
|
import { useVaultSnapshot } from '../../state/vaultSnapshotStore';
|
||||||
|
import {
|
||||||
|
getTerminalSettingsActions,
|
||||||
|
useTerminalSettingsStore,
|
||||||
|
} from '../../state/terminalSettingsStore';
|
||||||
|
import { usePluginViewTabs } from '../../state/pluginViewTabStore';
|
||||||
|
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
|
||||||
|
import {
|
||||||
|
publishAppShellChrome,
|
||||||
|
publishAppShellDomainSlice,
|
||||||
|
} from '../appShellPropsStore';
|
||||||
|
import {
|
||||||
|
getThemeRuntimeActions,
|
||||||
|
subscribeThemeRuntimeActions,
|
||||||
|
} from '../themeRuntimeBridge';
|
||||||
|
|
||||||
|
const IS_MAC_CLIENT =
|
||||||
|
typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chrome island: TopTabs / active-tab chrome from settings chrome store,
|
||||||
|
* appearance chrome, and selective session/vault snapshot fields. Assembles
|
||||||
|
* chrome + shell chrome bags field-by-field — never spreads a prepared bag.
|
||||||
|
*/
|
||||||
|
export function ChromeHost() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const settingsChrome = useSettingsChromeStore();
|
||||||
|
const session = useSessionSnapshot();
|
||||||
|
const sessionActions = useSessionSnapshotActions();
|
||||||
|
const vault = useVaultSnapshot();
|
||||||
|
const terminalSettings = useTerminalSettingsStore();
|
||||||
|
const editorTabs = useEditorTabChromeList();
|
||||||
|
const pluginViewTabs = usePluginViewTabs();
|
||||||
|
void pluginViewTabs;
|
||||||
|
const customThemes = useCustomThemes();
|
||||||
|
const handlers = useSyncExternalStore(
|
||||||
|
subscribeAppHandlers,
|
||||||
|
getAppHandlers,
|
||||||
|
getAppHandlers,
|
||||||
|
);
|
||||||
|
const themeRuntime = useSyncExternalStore(
|
||||||
|
subscribeThemeRuntimeActions,
|
||||||
|
getThemeRuntimeActions,
|
||||||
|
getThemeRuntimeActions,
|
||||||
|
);
|
||||||
|
|
||||||
|
const orphanSessionsForShellRef = useRef(session.orphanSessions);
|
||||||
|
const orphanSessionsForShell = retainStableSessionsIgnoringPresentation(
|
||||||
|
orphanSessionsForShellRef.current,
|
||||||
|
session.orphanSessions as never,
|
||||||
|
);
|
||||||
|
orphanSessionsForShellRef.current = orphanSessionsForShell as typeof session.orphanSessions;
|
||||||
|
|
||||||
|
const themeById = useMemo(
|
||||||
|
() => new Map([...customThemes, ...TERMINAL_THEMES].map((theme) => [theme.id, theme])),
|
||||||
|
[customThemes],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hostById = useMemo(
|
||||||
|
() => new Map(vault.hosts.map((host) => [host.id, host])),
|
||||||
|
[vault.hosts],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sessionById = useMemo(
|
||||||
|
() => new Map(session.sessions.map((s) => [s.id, s])),
|
||||||
|
[session.sessions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const workspaceById = useMemo(
|
||||||
|
() => new Map(session.workspaces.map((workspace) => [workspace.id, workspace])),
|
||||||
|
[session.workspaces],
|
||||||
|
);
|
||||||
|
|
||||||
|
const editorTabTopIds = useMemo(
|
||||||
|
() => editorTabs.map((tab) => toEditorTabId(tab.id)),
|
||||||
|
[editorTabs],
|
||||||
|
);
|
||||||
|
const pluginViewTabIds = useMemo(
|
||||||
|
() => pluginViewTabs.map((tab) => tab.id),
|
||||||
|
[pluginViewTabs],
|
||||||
|
);
|
||||||
|
const additionalWorkTabIds = useMemo(
|
||||||
|
() => [...editorTabTopIds, ...pluginViewTabIds],
|
||||||
|
[editorTabTopIds, pluginViewTabIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const orderedTabsWithEditors = useMemo(
|
||||||
|
() => sessionActions?.getOrderedWorkTabs(additionalWorkTabIds) ?? ['vault'],
|
||||||
|
[additionalWorkTabIds, sessionActions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const reorderWorkTabs = useCallback((
|
||||||
|
draggedId: string,
|
||||||
|
targetId: string,
|
||||||
|
position: 'before' | 'after' = 'before',
|
||||||
|
) => {
|
||||||
|
sessionActions?.reorderTabs(draggedId, targetId, position, additionalWorkTabIds);
|
||||||
|
}, [additionalWorkTabIds, sessionActions]);
|
||||||
|
|
||||||
|
const chromeDomain = useMemo(() => {
|
||||||
|
if (!handlers) return null;
|
||||||
|
return {
|
||||||
|
closeLogView: sessionActions?.closeLogView,
|
||||||
|
handleEndSessionDrag: handlers.handleEndSessionDrag,
|
||||||
|
handleOpenQuickSwitcher: handlers.handleOpenQuickSwitcher,
|
||||||
|
handleOpenSettings: handlers.handleOpenSettings,
|
||||||
|
handleRootContextMenu: handlers.handleRootContextMenu,
|
||||||
|
handleSyncNowManual: handlers.handleSyncNowManual,
|
||||||
|
isMacClient: IS_MAC_CLIENT,
|
||||||
|
logViews: session.logViews,
|
||||||
|
openLogView: sessionActions?.openLogView,
|
||||||
|
orderedTabsWithEditors,
|
||||||
|
orphanSessions: orphanSessionsForShell,
|
||||||
|
reorderWorkTabs,
|
||||||
|
resetSessionRename: sessionActions?.resetSessionRename,
|
||||||
|
resetWorkspaceRename: sessionActions?.resetWorkspaceRename,
|
||||||
|
sessionRenameTarget: session.sessionRenameTarget,
|
||||||
|
setActiveTabId: sessionActions?.setActiveTabId,
|
||||||
|
startSessionRename: sessionActions?.startSessionRename,
|
||||||
|
renameSessionInline: sessionActions?.renameSessionInline,
|
||||||
|
startWorkspaceRename: sessionActions?.startWorkspaceRename,
|
||||||
|
submitSessionRename: sessionActions?.submitSessionRename,
|
||||||
|
submitWorkspaceRename: sessionActions?.submitWorkspaceRename,
|
||||||
|
t,
|
||||||
|
themeById,
|
||||||
|
workspaceRenameTarget: session.workspaceRenameTarget,
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
handlers,
|
||||||
|
orderedTabsWithEditors,
|
||||||
|
orphanSessionsForShell,
|
||||||
|
reorderWorkTabs,
|
||||||
|
session.logViews,
|
||||||
|
session.sessionRenameTarget,
|
||||||
|
session.workspaceRenameTarget,
|
||||||
|
sessionActions,
|
||||||
|
t,
|
||||||
|
themeById,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Call-time getters so chrome can publish before Publisher layout effects
|
||||||
|
// register action slots — avoids undefined applyAppTheme/setActiveTabId on
|
||||||
|
// the first Host publish (startup TypeError under StrictMode).
|
||||||
|
const setActiveTabId = useCallback((id: string) => {
|
||||||
|
getSessionSnapshotActions()?.setActiveTabId?.(id);
|
||||||
|
}, []);
|
||||||
|
const applyAppTheme = useCallback(() => {
|
||||||
|
getTerminalSettingsActions()?.applyAppTheme?.();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const appShellChrome = useMemo(() => {
|
||||||
|
if (!handlers) return null;
|
||||||
|
// Theme runtime actions may still be null on the first paint before
|
||||||
|
// TerminalHost registers them; wait so AppActiveTabChrome never mounts
|
||||||
|
// with a missing resolveSessionAppearance / currentTerminalTheme.
|
||||||
|
if (!themeRuntime?.currentTerminalTheme || !themeRuntime?.resolveFocusedAppearance) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
showSftpTab: settingsChrome.showSftpTab,
|
||||||
|
setActiveTabId,
|
||||||
|
applyAppTheme,
|
||||||
|
hostById,
|
||||||
|
sessionById,
|
||||||
|
themeById,
|
||||||
|
workspaceById,
|
||||||
|
currentTerminalTheme: themeRuntime.currentTerminalTheme,
|
||||||
|
followAppTerminalTheme: terminalSettings.followAppTerminalTheme,
|
||||||
|
editorTabs,
|
||||||
|
logViews: session.logViews,
|
||||||
|
resolveSessionAppearance: themeRuntime.resolveFocusedAppearance,
|
||||||
|
t,
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
applyAppTheme,
|
||||||
|
editorTabs,
|
||||||
|
handlers,
|
||||||
|
hostById,
|
||||||
|
session.logViews,
|
||||||
|
sessionById,
|
||||||
|
setActiveTabId,
|
||||||
|
settingsChrome.showSftpTab,
|
||||||
|
t,
|
||||||
|
terminalSettings.followAppTerminalTheme,
|
||||||
|
themeById,
|
||||||
|
themeRuntime,
|
||||||
|
workspaceById,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (chromeDomain) publishAppShellDomainSlice('chrome', chromeDomain);
|
||||||
|
if (appShellChrome) publishAppShellChrome(appShellChrome as never);
|
||||||
|
}, [appShellChrome, chromeDomain]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { useLayoutEffect, useMemo, useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
import { getHostSearchMatch } from '../../../lib/searchMatcher';
|
||||||
|
import type { Host } from '../../../types';
|
||||||
|
import { useEditorTabChromeList } from '../../state/editorTabStore';
|
||||||
|
import { useVaultSnapshot } from '../../state/vaultSnapshotStore';
|
||||||
|
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
|
||||||
|
import {
|
||||||
|
publishAppShellDomainSlice,
|
||||||
|
publishAppShellOverlays,
|
||||||
|
} from '../appShellPropsStore';
|
||||||
|
import { useAppLocalUiStore } from '../appLocalUiStore';
|
||||||
|
|
||||||
|
const EMPTY_HOST_RESULTS: Host[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialogs island: local dialog/queue state from `appLocalUiStore`, plus a
|
||||||
|
* selective vault hosts subscription for quick-search results when open.
|
||||||
|
* Assembles dialogs + overlays field-by-field — never spreads a prepared bag.
|
||||||
|
*/
|
||||||
|
export function DialogsHost() {
|
||||||
|
const local = useAppLocalUiStore();
|
||||||
|
const vault = useVaultSnapshot();
|
||||||
|
const editorTabs = useEditorTabChromeList();
|
||||||
|
const handlers = useSyncExternalStore(
|
||||||
|
subscribeAppHandlers,
|
||||||
|
getAppHandlers,
|
||||||
|
getAppHandlers,
|
||||||
|
);
|
||||||
|
|
||||||
|
const quickResults = useMemo(() => {
|
||||||
|
if (!local.isQuickSwitcherOpen) return EMPTY_HOST_RESULTS;
|
||||||
|
const term = local.quickSearch.trim();
|
||||||
|
if (!term) return vault.hosts as Host[];
|
||||||
|
return (vault.hosts as Host[])
|
||||||
|
.map((host) => ({ host, match: getHostSearchMatch(term, host) }))
|
||||||
|
.filter((entry) => entry.match.matched)
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.match.score !== right.match.score) {
|
||||||
|
return right.match.score - left.match.score;
|
||||||
|
}
|
||||||
|
return left.host.label.localeCompare(right.host.label);
|
||||||
|
})
|
||||||
|
.map((entry) => entry.host);
|
||||||
|
}, [local.isQuickSwitcherOpen, local.quickSearch, vault.hosts]);
|
||||||
|
|
||||||
|
const dialogsDomain = useMemo(() => {
|
||||||
|
if (!handlers) return null;
|
||||||
|
return {
|
||||||
|
addToWorkspaceDialog: local.addToWorkspaceDialog,
|
||||||
|
clearAndRemoveSource: handlers.clearAndRemoveSource,
|
||||||
|
clearAndRemoveSources: handlers.clearAndRemoveSources,
|
||||||
|
editorTabs,
|
||||||
|
emptyVaultConflict: local.emptyVaultConflict,
|
||||||
|
handleHostConnectWithProtocolCheck: handlers.handleHostConnectWithProtocolCheck,
|
||||||
|
handleKeyboardInteractiveCancel: handlers.handleKeyboardInteractiveCancel,
|
||||||
|
handleKeyboardInteractiveSubmit: handlers.handleKeyboardInteractiveSubmit,
|
||||||
|
handlePassphraseCancel: handlers.handlePassphraseCancel,
|
||||||
|
handlePassphraseSkip: handlers.handlePassphraseSkip,
|
||||||
|
handlePassphraseSubmit: handlers.handlePassphraseSubmit,
|
||||||
|
handleProtocolSelect: handlers.handleProtocolSelect,
|
||||||
|
handleRequestCloseEditorTabRef: handlers.handleRequestCloseEditorTabRef,
|
||||||
|
isCreateWorkspaceOpen: local.isCreateWorkspaceOpen,
|
||||||
|
isQuickSwitcherOpen: local.isQuickSwitcherOpen,
|
||||||
|
keyboardInteractiveQueue: local.keyboardInteractiveQueue,
|
||||||
|
passphraseQueue: local.passphraseQueue,
|
||||||
|
protocolSelectHost: local.protocolSelectHost,
|
||||||
|
quickResults,
|
||||||
|
quickSearch: local.quickSearch,
|
||||||
|
resolveEmptyVaultConflict: handlers.resolveEmptyVaultConflict,
|
||||||
|
setAddToWorkspaceDialog: handlers.setAddToWorkspaceDialog,
|
||||||
|
setIsCreateWorkspaceOpen: handlers.setIsCreateWorkspaceOpen,
|
||||||
|
setIsQuickSwitcherOpen: handlers.setIsQuickSwitcherOpen,
|
||||||
|
setProtocolSelectHost: handlers.setProtocolSelectHost,
|
||||||
|
setQuickSearch: handlers.setQuickSearch,
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
editorTabs,
|
||||||
|
handlers,
|
||||||
|
local.addToWorkspaceDialog,
|
||||||
|
local.emptyVaultConflict,
|
||||||
|
local.isCreateWorkspaceOpen,
|
||||||
|
local.isQuickSwitcherOpen,
|
||||||
|
local.keyboardInteractiveQueue,
|
||||||
|
local.passphraseQueue,
|
||||||
|
local.protocolSelectHost,
|
||||||
|
local.quickSearch,
|
||||||
|
quickResults,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const overlays = useMemo(() => {
|
||||||
|
if (!handlers) return null;
|
||||||
|
return {
|
||||||
|
onAddKnownHost: handlers.handleAddKnownHost as (knownHost: never) => void,
|
||||||
|
deleteHostConfirm: local.deleteHostConfirm,
|
||||||
|
onCancelDeleteHost: handlers.handleCancelDeleteHost as () => void,
|
||||||
|
onConfirmDeleteHost: handlers.handleConfirmDeleteHost as () => void,
|
||||||
|
};
|
||||||
|
}, [handlers, local.deleteHostConfirm]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (dialogsDomain) publishAppShellDomainSlice('dialogs', dialogsDomain);
|
||||||
|
if (overlays) publishAppShellOverlays(overlays as never);
|
||||||
|
}, [dialogsDomain, overlays]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import { useCallback, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
import { TERMINAL_THEME_AUTO } from '../../../domain/terminalAppearance';
|
||||||
|
import { retainStableSessionsIgnoringPresentation } from '../../../domain/terminalPaneSessionsEqual';
|
||||||
|
import { getAppSettingsRuntime } from '../../state/appRuntimeBridge';
|
||||||
|
import { useAppearanceChromeStore } from '../../state/appearanceChromeStore';
|
||||||
|
import { useCustomThemes } from '../../state/customThemeStore';
|
||||||
|
import {
|
||||||
|
useSessionSnapshot,
|
||||||
|
useSessionSnapshotActions,
|
||||||
|
} from '../../state/sessionSnapshotStore';
|
||||||
|
import {
|
||||||
|
useSettingsChromeActions,
|
||||||
|
useSettingsChromeStore,
|
||||||
|
} from '../../state/settingsChromeStore';
|
||||||
|
import {
|
||||||
|
useTerminalSettingsActions,
|
||||||
|
useTerminalSettingsStore,
|
||||||
|
} from '../../state/terminalSettingsStore';
|
||||||
|
import { useThemeRuntime, useTerminalAppearanceInjection } from '../../state/useThemeRuntime';
|
||||||
|
import {
|
||||||
|
useVaultSnapshot,
|
||||||
|
useVaultSnapshotActions,
|
||||||
|
} from '../../state/vaultSnapshotStore';
|
||||||
|
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
|
||||||
|
import { publishAppShellDomainSlice } from '../appShellPropsStore';
|
||||||
|
import { useAppLocalUiStore } from '../appLocalUiStore';
|
||||||
|
import { registerThemeRuntimeActions } from '../themeRuntimeBridge';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terminal island: sessions from `sessionSnapshotStore`, terminal settings
|
||||||
|
* from `terminalSettingsStore`, mutators via snapshot actions, theme runtime
|
||||||
|
* owned here, glue handlers from the app handlers bridge. Assembles the full
|
||||||
|
* terminal domain bag field-by-field — never spreads a prepared bag.
|
||||||
|
*/
|
||||||
|
export function TerminalHost() {
|
||||||
|
const session = useSessionSnapshot();
|
||||||
|
const sessionActions = useSessionSnapshotActions();
|
||||||
|
const vault = useVaultSnapshot();
|
||||||
|
const vaultActions = useVaultSnapshotActions();
|
||||||
|
const terminalSettings = useTerminalSettingsStore();
|
||||||
|
const terminalSettingsActions = useTerminalSettingsActions();
|
||||||
|
const {
|
||||||
|
followAppTerminalTheme,
|
||||||
|
terminalThemeId,
|
||||||
|
terminalThemeDarkId,
|
||||||
|
terminalThemeLightId,
|
||||||
|
} = terminalSettings;
|
||||||
|
const settingsChrome = useSettingsChromeStore();
|
||||||
|
const settingsChromeActions = useSettingsChromeActions();
|
||||||
|
const appearance = useAppearanceChromeStore();
|
||||||
|
const customThemes = useCustomThemes();
|
||||||
|
const local = useAppLocalUiStore();
|
||||||
|
const handlers = useSyncExternalStore(
|
||||||
|
subscribeAppHandlers,
|
||||||
|
getAppHandlers,
|
||||||
|
getAppHandlers,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Call-time getters: SettingsPublisher registers the runtime in a layout
|
||||||
|
// effect after this Host's first render. Capturing noop setters into
|
||||||
|
// useThemeRuntime would permanently drop follow-app UI theme persistence.
|
||||||
|
const setLightUiThemeId = useCallback((id: string) => {
|
||||||
|
getAppSettingsRuntime()?.setLightUiThemeId?.(id);
|
||||||
|
}, []);
|
||||||
|
const setDarkUiThemeId = useCallback((id: string) => {
|
||||||
|
getAppSettingsRuntime()?.setDarkUiThemeId?.(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const themeRuntime = useThemeRuntime({
|
||||||
|
terminalThemeId,
|
||||||
|
terminalThemeDarkId,
|
||||||
|
terminalThemeLightId,
|
||||||
|
followAppTerminalTheme,
|
||||||
|
resolvedTheme: settingsChrome.resolvedTheme,
|
||||||
|
lightUiThemeId: settingsChrome.lightUiThemeId,
|
||||||
|
darkUiThemeId: settingsChrome.darkUiThemeId,
|
||||||
|
accentMode: appearance.accentMode,
|
||||||
|
customAccent: appearance.customAccent,
|
||||||
|
customThemes,
|
||||||
|
setTheme: settingsChromeActions.setTheme,
|
||||||
|
setLightUiThemeId,
|
||||||
|
setDarkUiThemeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
globalAppearance,
|
||||||
|
accentedGlobalAppearance,
|
||||||
|
clearIntent: clearThemeIntent,
|
||||||
|
settleManualIntent: settleManualThemeIntent,
|
||||||
|
pickTheme: pickTerminalTheme,
|
||||||
|
resolveFocusedAppearance,
|
||||||
|
currentTerminalTheme,
|
||||||
|
} = themeRuntime;
|
||||||
|
|
||||||
|
// Inject live accent into CSS vars without publishing accented theme identity
|
||||||
|
// into the terminal domain bag (accent drag must not rebuild AppShell).
|
||||||
|
useTerminalAppearanceInjection(accentedGlobalAppearance, {
|
||||||
|
includeChromeSurfaces: followAppTerminalTheme,
|
||||||
|
});
|
||||||
|
|
||||||
|
const prevFollowAppTerminalThemeRef = useRef(followAppTerminalTheme);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (prevFollowAppTerminalThemeRef.current === followAppTerminalTheme) return;
|
||||||
|
prevFollowAppTerminalThemeRef.current = followAppTerminalTheme;
|
||||||
|
clearThemeIntent();
|
||||||
|
}, [followAppTerminalTheme, clearThemeIntent]);
|
||||||
|
|
||||||
|
// Bridge exposes the stable base theme only — ChromeHost must not republish
|
||||||
|
// when accentedGlobalAppearance identity churns during color-picker drag.
|
||||||
|
const themeBridgeActions = useMemo(() => ({
|
||||||
|
clearThemeIntent,
|
||||||
|
settleManualThemeIntent,
|
||||||
|
pickTerminalTheme,
|
||||||
|
resolveFocusedAppearance: resolveFocusedAppearance as (...args: never[]) => unknown,
|
||||||
|
currentTerminalTheme,
|
||||||
|
globalAppearance,
|
||||||
|
}), [
|
||||||
|
clearThemeIntent,
|
||||||
|
currentTerminalTheme,
|
||||||
|
globalAppearance,
|
||||||
|
pickTerminalTheme,
|
||||||
|
resolveFocusedAppearance,
|
||||||
|
settleManualThemeIntent,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
registerThemeRuntimeActions(themeBridgeActions);
|
||||||
|
return () => {
|
||||||
|
registerThemeRuntimeActions(null);
|
||||||
|
};
|
||||||
|
}, [themeBridgeActions]);
|
||||||
|
|
||||||
|
const sessionsForShellRef = useRef(session.sessions);
|
||||||
|
const sessionsForShell = retainStableSessionsIgnoringPresentation(
|
||||||
|
sessionsForShellRef.current,
|
||||||
|
session.sessions as never,
|
||||||
|
);
|
||||||
|
sessionsForShellRef.current = sessionsForShell as typeof session.sessions;
|
||||||
|
|
||||||
|
const hostById = useMemo(
|
||||||
|
() => new Map(vault.hosts.map((host) => [host.id, host])),
|
||||||
|
[vault.hosts],
|
||||||
|
);
|
||||||
|
|
||||||
|
const terminalHosts = useMemo(
|
||||||
|
() => (
|
||||||
|
local.ephemeralHosts.length > 0
|
||||||
|
? [...vault.hosts, ...local.ephemeralHosts]
|
||||||
|
: vault.hosts
|
||||||
|
),
|
||||||
|
[local.ephemeralHosts, vault.hosts],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDefaultTerminalThemeChange = useCallback((themeId: string) => {
|
||||||
|
// Persist the default theme for ephemeral/manual hosts. Mode overrides
|
||||||
|
// reset to auto so the chosen theme becomes the new baseline for the
|
||||||
|
// current resolved UI mode (same behavior as pre-Host App).
|
||||||
|
terminalSettingsActions?.setTerminalThemeId(themeId);
|
||||||
|
if (settingsChrome.resolvedTheme === 'dark') {
|
||||||
|
terminalSettingsActions?.setTerminalThemeDarkId(TERMINAL_THEME_AUTO);
|
||||||
|
} else {
|
||||||
|
terminalSettingsActions?.setTerminalThemeLightId(TERMINAL_THEME_AUTO);
|
||||||
|
}
|
||||||
|
}, [settingsChrome.resolvedTheme, terminalSettingsActions]);
|
||||||
|
|
||||||
|
const handleFollowAppTerminalThemeChange = useCallback((themeId: string) => {
|
||||||
|
pickTerminalTheme(themeId);
|
||||||
|
}, [pickTerminalTheme]);
|
||||||
|
|
||||||
|
const terminalDomain = useMemo(() => {
|
||||||
|
if (!handlers) return null;
|
||||||
|
return {
|
||||||
|
addSessionToWorkspace: sessionActions?.addSessionToWorkspace,
|
||||||
|
appendHostToWorkspace: sessionActions?.appendHostToWorkspace,
|
||||||
|
appendLocalTerminalToWorkspace: sessionActions?.appendLocalTerminalToWorkspace,
|
||||||
|
clearSessionFontSizeOverride: sessionActions?.clearSessionFontSizeOverride,
|
||||||
|
closeSession: sessionActions?.closeSession,
|
||||||
|
closeTabsBatch: handlers.closeTabsBatch,
|
||||||
|
copySessionWithCurrentShell: handlers.copySessionWithCurrentShell,
|
||||||
|
copyWorkspaceWithCurrentShell: handlers.copyWorkspaceWithCurrentShell,
|
||||||
|
copySessionToNewWindowWithCurrentShell: handlers.copySessionToNewWindowWithCurrentShell,
|
||||||
|
closeWorkspace: sessionActions?.closeWorkspace,
|
||||||
|
createWorkspaceFromSessions: sessionActions?.createWorkspaceFromSessions,
|
||||||
|
createWorkspaceFromTargets: handlers.createWorkspaceFromTargets,
|
||||||
|
createWorkspaceWithHosts: handlers.createWorkspaceWithHosts,
|
||||||
|
currentTerminalTheme,
|
||||||
|
draggingSessionId: session.draggingSessionId,
|
||||||
|
editorWordWrap: terminalSettings.editorWordWrap,
|
||||||
|
followAppTerminalTheme: terminalSettings.followAppTerminalTheme,
|
||||||
|
clearThemeIntent,
|
||||||
|
settleManualThemeIntent,
|
||||||
|
pickTerminalTheme,
|
||||||
|
resolveSessionAppearance: resolveFocusedAppearance,
|
||||||
|
handleConnectSerial: handlers.handleConnectSerial,
|
||||||
|
handleConnectToHost: handlers.handleConnectToHost,
|
||||||
|
handleCreateLocalTerminal: handlers.handleCreateLocalTerminal,
|
||||||
|
handleDefaultTerminalThemeChange,
|
||||||
|
handleFollowAppTerminalThemeChange,
|
||||||
|
handleHotkeyAction: handlers.handleHotkeyAction,
|
||||||
|
handleSessionStatusChange: handlers.handleSessionStatusChange,
|
||||||
|
handleTerminalDataCapture: handlers.handleTerminalDataCapture,
|
||||||
|
handleUpdateHostFromTerminal: handlers.handleUpdateHostFromTerminal,
|
||||||
|
hostById,
|
||||||
|
terminalHosts,
|
||||||
|
updateTerminalHosts: handlers.updateTerminalHosts,
|
||||||
|
hotkeyScheme: terminalSettings.hotkeyScheme,
|
||||||
|
isBroadcastEnabled: sessionActions?.isBroadcastEnabled,
|
||||||
|
keyBindings: terminalSettings.keyBindings,
|
||||||
|
openNoteRequest: local.openNoteRequest,
|
||||||
|
portForwardingRules: local.portForwardingRules,
|
||||||
|
removeSessionFromWorkspace: sessionActions?.removeSessionFromWorkspace,
|
||||||
|
reorderWorkspaceSessions: sessionActions?.reorderWorkspaceSessions,
|
||||||
|
runSnippet: handlers.runSnippet,
|
||||||
|
sessionLogsDir: terminalSettings.sessionLogsDir,
|
||||||
|
sessionLogsEnabled: terminalSettings.sessionLogsEnabled,
|
||||||
|
sessionLogsFormat: terminalSettings.sessionLogsFormat,
|
||||||
|
sessionLogsTimestampsEnabled: terminalSettings.sessionLogsTimestampsEnabled,
|
||||||
|
sessions: sessionsForShell,
|
||||||
|
setDraggingSessionId: sessionActions?.setDraggingSessionId,
|
||||||
|
setEditorWordWrap: terminalSettingsActions?.setEditorWordWrap,
|
||||||
|
setTerminalFontFamilyId: terminalSettingsActions?.setTerminalFontFamilyId,
|
||||||
|
setTerminalFontSize: terminalSettingsActions?.setTerminalFontSize,
|
||||||
|
setWorkspaceFocusedSession: sessionActions?.setWorkspaceFocusedSession,
|
||||||
|
sftpAutoOpenSidebar: terminalSettings.sftpAutoOpenSidebar,
|
||||||
|
sftpFollowTerminalCwd: terminalSettings.sftpFollowTerminalCwd,
|
||||||
|
setSftpFollowTerminalCwd: terminalSettingsActions?.setSftpFollowTerminalCwd,
|
||||||
|
sftpAutoSync: terminalSettings.sftpAutoSync,
|
||||||
|
sftpDefaultViewMode: terminalSettings.sftpDefaultViewMode,
|
||||||
|
sftpDoubleClickBehavior: terminalSettings.sftpDoubleClickBehavior,
|
||||||
|
sftpShowHiddenFiles: terminalSettings.sftpShowHiddenFiles,
|
||||||
|
sftpUseCompressedUpload: terminalSettings.sftpUseCompressedUpload,
|
||||||
|
splitSessionWithCurrentShell: handlers.splitSessionWithCurrentShell,
|
||||||
|
sshDebugLogsEnabled: terminalSettings.sshDebugLogsEnabled,
|
||||||
|
terminalFontFamilyId: terminalSettings.terminalFontFamilyId,
|
||||||
|
terminalFontSize: terminalSettings.terminalFontSize,
|
||||||
|
terminalSettings: terminalSettings.terminalSettings,
|
||||||
|
terminalThemeId: terminalSettings.terminalThemeId,
|
||||||
|
toggleBroadcast: sessionActions?.toggleBroadcast,
|
||||||
|
toggleScriptsSidePanelRef: handlers.toggleScriptsSidePanelRef,
|
||||||
|
toggleSidePanelRef: handlers.toggleSidePanelRef,
|
||||||
|
toggleWorkspaceViewMode: sessionActions?.toggleWorkspaceViewMode,
|
||||||
|
updateHostDistro: vaultActions?.updateHostDistro,
|
||||||
|
updateSplitSizes: sessionActions?.updateSplitSizes,
|
||||||
|
updateSessionFontSize: sessionActions?.updateSessionFontSize,
|
||||||
|
updateSessionRestoreCwd: sessionActions?.updateSessionRestoreCwd,
|
||||||
|
updateSessionDynamicTitle: sessionActions?.updateSessionDynamicTitle,
|
||||||
|
updateSessionCodingCliProvider: sessionActions?.updateSessionCodingCliProvider,
|
||||||
|
updateTerminalSetting: terminalSettingsActions?.updateTerminalSetting,
|
||||||
|
workspaces: session.workspaces,
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
clearThemeIntent,
|
||||||
|
currentTerminalTheme,
|
||||||
|
handleDefaultTerminalThemeChange,
|
||||||
|
handleFollowAppTerminalThemeChange,
|
||||||
|
handlers,
|
||||||
|
hostById,
|
||||||
|
local.openNoteRequest,
|
||||||
|
local.portForwardingRules,
|
||||||
|
pickTerminalTheme,
|
||||||
|
resolveFocusedAppearance,
|
||||||
|
session.draggingSessionId,
|
||||||
|
session.workspaces,
|
||||||
|
sessionActions,
|
||||||
|
sessionsForShell,
|
||||||
|
settleManualThemeIntent,
|
||||||
|
terminalHosts,
|
||||||
|
terminalSettings,
|
||||||
|
terminalSettingsActions,
|
||||||
|
vaultActions,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (terminalDomain) publishAppShellDomainSlice('terminal', terminalDomain);
|
||||||
|
}, [terminalDomain]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useLayoutEffect, useMemo, useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
import { getEffectiveKnownHosts } from '../../../infrastructure/syncHelpers';
|
||||||
|
import {
|
||||||
|
useVaultSnapshot,
|
||||||
|
useVaultSnapshotActions,
|
||||||
|
} from '../../state/vaultSnapshotStore';
|
||||||
|
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
|
||||||
|
import { publishAppShellDomainSlice } from '../appShellPropsStore';
|
||||||
|
import { useAppLocalUiStore } from '../appLocalUiStore';
|
||||||
|
import { APP_MOUNTS_DOMAIN } from './mountsDomain';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vault island: catalog from `vaultSnapshotStore`, glue handlers from the
|
||||||
|
* app handlers bridge, local vault UI from `appLocalUiStore`. Assembles the
|
||||||
|
* full vault domain bag field-by-field — never spreads a prepared bag from
|
||||||
|
* AppSideEffects.
|
||||||
|
*/
|
||||||
|
export function VaultHost() {
|
||||||
|
const vault = useVaultSnapshot();
|
||||||
|
const actions = useVaultSnapshotActions();
|
||||||
|
const local = useAppLocalUiStore();
|
||||||
|
const handlers = useSyncExternalStore(
|
||||||
|
subscribeAppHandlers,
|
||||||
|
getAppHandlers,
|
||||||
|
getAppHandlers,
|
||||||
|
);
|
||||||
|
|
||||||
|
const effectiveKnownHosts = useMemo(
|
||||||
|
() => getEffectiveKnownHosts(vault.knownHosts as never) ?? [],
|
||||||
|
[vault.knownHosts],
|
||||||
|
);
|
||||||
|
|
||||||
|
const vaultDomain = useMemo(() => {
|
||||||
|
if (!handlers) return null;
|
||||||
|
return {
|
||||||
|
addShellHistoryEntry: actions?.addShellHistoryEntry,
|
||||||
|
removeShellHistoryEntry: actions?.removeShellHistoryEntry,
|
||||||
|
commitPluginImporterData: actions?.commitPluginImporterData,
|
||||||
|
commitVaultImportTransaction: actions?.commitVaultImportTransaction,
|
||||||
|
convertKnownHostToHost: actions?.convertKnownHostToHost,
|
||||||
|
customGroups: vault.customGroups,
|
||||||
|
deepLinkHostDraft: local.deepLinkHostDraft,
|
||||||
|
effectiveKnownHosts,
|
||||||
|
groupConfigs: vault.groupConfigs,
|
||||||
|
handleAddKnownHost: handlers.handleAddKnownHost,
|
||||||
|
handleDeleteHost: handlers.handleDeleteHost,
|
||||||
|
handleOpenHostFromVaultNote: handlers.handleOpenHostFromVaultNote,
|
||||||
|
handleOpenVaultHostFromChat: handlers.handleOpenVaultHostFromChat,
|
||||||
|
handleOpenVaultNoteFromChat: handlers.handleOpenVaultNoteFromChat,
|
||||||
|
handleOpenVaultSectionFromChat: handlers.handleOpenVaultSectionFromChat,
|
||||||
|
handleOpenVaultSnippetFromChat: handlers.handleOpenVaultSnippetFromChat,
|
||||||
|
hosts: vault.hosts,
|
||||||
|
identities: vault.identities,
|
||||||
|
importOrReuseKey: actions?.importOrReuseKey,
|
||||||
|
keys: vault.keys,
|
||||||
|
managedSources: vault.managedSources,
|
||||||
|
navigateToSection: local.navigateToSection,
|
||||||
|
proxyProfiles: vault.proxyProfiles,
|
||||||
|
readPersistedHosts: actions?.readPersistedHosts,
|
||||||
|
readPersistedManagedSources: actions?.readPersistedManagedSources,
|
||||||
|
setDeepLinkHostDraft: handlers.setDeepLinkHostDraft,
|
||||||
|
setNavigateToSection: handlers.setNavigateToSection,
|
||||||
|
setVaultFocusRequest: handlers.setVaultFocusRequest,
|
||||||
|
snippetPackages: vault.snippetPackages,
|
||||||
|
snippets: vault.snippets,
|
||||||
|
unmanageSource: handlers.unmanageSource,
|
||||||
|
updateCustomGroups: actions?.updateCustomGroups,
|
||||||
|
updateGroupConfigs: actions?.updateGroupConfigs,
|
||||||
|
updateHosts: actions?.updateHosts,
|
||||||
|
updateIdentities: actions?.updateIdentities,
|
||||||
|
updateKeys: actions?.updateKeys,
|
||||||
|
updateKnownHosts: actions?.updateKnownHosts,
|
||||||
|
updateManagedSources: actions?.updateManagedSources,
|
||||||
|
updateProxyProfiles: actions?.updateProxyProfiles,
|
||||||
|
updateSnippetPackages: actions?.updateSnippetPackages,
|
||||||
|
updateSnippets: actions?.updateSnippets,
|
||||||
|
vaultFocusRequest: local.vaultFocusRequest,
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
actions,
|
||||||
|
effectiveKnownHosts,
|
||||||
|
handlers,
|
||||||
|
local.deepLinkHostDraft,
|
||||||
|
local.navigateToSection,
|
||||||
|
local.vaultFocusRequest,
|
||||||
|
vault.customGroups,
|
||||||
|
vault.groupConfigs,
|
||||||
|
vault.hosts,
|
||||||
|
vault.identities,
|
||||||
|
vault.keys,
|
||||||
|
vault.managedSources,
|
||||||
|
vault.proxyProfiles,
|
||||||
|
vault.snippetPackages,
|
||||||
|
vault.snippets,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (vaultDomain) publishAppShellDomainSlice('vault', vaultDomain);
|
||||||
|
publishAppShellDomainSlice('mounts', APP_MOUNTS_DOMAIN);
|
||||||
|
}, [vaultDomain]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { LogViewWrapper, SftpViewMount, TerminalLayerMount, VaultViewContainer } from '../AppMounts';
|
||||||
|
|
||||||
|
/** Lazy mount wrappers — stable module identity for the app lifetime. */
|
||||||
|
export const APP_MOUNTS_DOMAIN = Object.freeze({
|
||||||
|
VaultViewContainer,
|
||||||
|
SftpViewMount,
|
||||||
|
TerminalLayerMount,
|
||||||
|
LogViewWrapper,
|
||||||
|
});
|
||||||
@@ -5,6 +5,10 @@ import {
|
|||||||
removeKeyboardInteractiveRequest,
|
removeKeyboardInteractiveRequest,
|
||||||
shouldQueueKeyboardInteractiveRequest,
|
shouldQueueKeyboardInteractiveRequest,
|
||||||
} from "./useAppStartupEffects.ts";
|
} from "./useAppStartupEffects.ts";
|
||||||
|
import {
|
||||||
|
clearTerminalBootEpoch,
|
||||||
|
setTerminalBootEpoch,
|
||||||
|
} from "../../domain/terminalBootEpoch.ts";
|
||||||
|
|
||||||
const sessions = [{ id: "terminal-1" }, { id: "terminal-2" }];
|
const sessions = [{ id: "terminal-1" }, { id: "terminal-2" }];
|
||||||
|
|
||||||
@@ -19,6 +23,42 @@ test("terminal-scoped keyboard-interactive requests are limited to owned session
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("disconnected terminal sessions do not queue keyboard-interactive prompts", () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldQueueKeyboardInteractiveRequest(
|
||||||
|
{ scope: "terminal", sessionId: "terminal-1" },
|
||||||
|
[{ id: "terminal-1", status: "disconnected" }],
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
shouldQueueKeyboardInteractiveRequest(
|
||||||
|
{ scope: "terminal", sessionId: "terminal-1" },
|
||||||
|
[{ id: "terminal-1", status: "connecting" }],
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("superseded terminal boot epochs do not queue keyboard-interactive prompts", () => {
|
||||||
|
setTerminalBootEpoch("terminal-1", 3);
|
||||||
|
assert.equal(
|
||||||
|
shouldQueueKeyboardInteractiveRequest(
|
||||||
|
{ scope: "terminal", sessionId: "terminal-1", bootEpoch: 1 },
|
||||||
|
[{ id: "terminal-1", status: "connecting" }],
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
shouldQueueKeyboardInteractiveRequest(
|
||||||
|
{ scope: "terminal", sessionId: "terminal-1", bootEpoch: 3 },
|
||||||
|
[{ id: "terminal-1", status: "connecting" }],
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
clearTerminalBootEpoch("terminal-1");
|
||||||
|
});
|
||||||
|
|
||||||
test("external keyboard-interactive requests are not filtered by terminal session ids", () => {
|
test("external keyboard-interactive requests are not filtered by terminal session ids", () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
shouldQueueKeyboardInteractiveRequest({ scope: "external", sessionId: "sftp-conn-1" }, sessions),
|
shouldQueueKeyboardInteractiveRequest({ scope: "external", sessionId: "sftp-conn-1" }, sessions),
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { useLayoutEffect, useMemo } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AppSessionRuntimeContext,
|
||||||
|
registerAppSessionRuntime,
|
||||||
|
} from '../../state/appRuntimeBridge';
|
||||||
|
import {
|
||||||
|
publishSessionSnapshot,
|
||||||
|
registerSessionSnapshotActions,
|
||||||
|
} from '../../state/sessionSnapshotStore';
|
||||||
|
import { useSessionState } from '../../state/useSessionState';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export type SessionPublisherProps = {
|
||||||
|
/** Peer session windows must not write the main window's restore record. */
|
||||||
|
persistSessionRestore: boolean;
|
||||||
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns `useSessionState` and publishes it the same three ways `VaultPublisher`
|
||||||
|
* publishes the vault: catalog into `sessionSnapshotStore`, mutators into its
|
||||||
|
* action slot, and the whole runtime onto `appRuntimeBridge` for App.
|
||||||
|
*/
|
||||||
|
export function SessionPublisher({ persistSessionRestore, children }: SessionPublisherProps) {
|
||||||
|
const session = useSessionState({ persistSessionRestore });
|
||||||
|
const {
|
||||||
|
sessions,
|
||||||
|
orphanSessions,
|
||||||
|
workspaces,
|
||||||
|
logViews,
|
||||||
|
draggingSessionId,
|
||||||
|
sessionRenameTarget,
|
||||||
|
workspaceRenameTarget,
|
||||||
|
setActiveTabId,
|
||||||
|
closeSession,
|
||||||
|
closeSessions,
|
||||||
|
closeWorkspace,
|
||||||
|
openLogView,
|
||||||
|
closeLogView,
|
||||||
|
setDraggingSessionId,
|
||||||
|
startSessionRename,
|
||||||
|
renameSessionInline,
|
||||||
|
submitSessionRename,
|
||||||
|
resetSessionRename,
|
||||||
|
startWorkspaceRename,
|
||||||
|
submitWorkspaceRename,
|
||||||
|
resetWorkspaceRename,
|
||||||
|
removeSessionFromWorkspace,
|
||||||
|
setWorkspaceFocusedSession,
|
||||||
|
toggleWorkspaceViewMode,
|
||||||
|
createLocalTerminal,
|
||||||
|
createSerialSession,
|
||||||
|
connectToHost,
|
||||||
|
updateSessionStatus,
|
||||||
|
updateSessionFontSize,
|
||||||
|
clearSessionFontSizeOverride,
|
||||||
|
createWorkspaceWithHosts,
|
||||||
|
createWorkspaceFromSessions,
|
||||||
|
addSessionToWorkspace,
|
||||||
|
appendHostToWorkspace,
|
||||||
|
appendLocalTerminalToWorkspace,
|
||||||
|
createWorkspaceFromTargets,
|
||||||
|
updateSplitSizes,
|
||||||
|
splitSession,
|
||||||
|
reorderWorkspaceSessions,
|
||||||
|
moveFocusInWorkspace,
|
||||||
|
runSnippet,
|
||||||
|
getOrderedWorkTabs,
|
||||||
|
reorderTabs,
|
||||||
|
toggleBroadcast,
|
||||||
|
isBroadcastEnabled,
|
||||||
|
copySession,
|
||||||
|
copyWorkspace,
|
||||||
|
createSessionFromCloneSource,
|
||||||
|
updateSessionRestoreCwd,
|
||||||
|
getSessionRestoreCwd,
|
||||||
|
updateSessionDynamicTitle,
|
||||||
|
updateSessionCodingCliProvider,
|
||||||
|
} = session;
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
registerAppSessionRuntime(session);
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
// Only a real unmount clears the slot; see VaultPublisher for why.
|
||||||
|
useLayoutEffect(() => () => {
|
||||||
|
registerAppSessionRuntime(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
publishSessionSnapshot({
|
||||||
|
sessions,
|
||||||
|
orphanSessions,
|
||||||
|
workspaces,
|
||||||
|
logViews,
|
||||||
|
draggingSessionId,
|
||||||
|
sessionRenameTarget,
|
||||||
|
workspaceRenameTarget,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
draggingSessionId,
|
||||||
|
logViews,
|
||||||
|
orphanSessions,
|
||||||
|
sessionRenameTarget,
|
||||||
|
sessions,
|
||||||
|
workspaceRenameTarget,
|
||||||
|
workspaces,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sessionActions = useMemo(() => ({
|
||||||
|
setActiveTabId,
|
||||||
|
closeSession,
|
||||||
|
closeSessions,
|
||||||
|
closeWorkspace,
|
||||||
|
openLogView,
|
||||||
|
closeLogView,
|
||||||
|
setDraggingSessionId,
|
||||||
|
startSessionRename,
|
||||||
|
renameSessionInline,
|
||||||
|
submitSessionRename,
|
||||||
|
resetSessionRename,
|
||||||
|
startWorkspaceRename,
|
||||||
|
submitWorkspaceRename,
|
||||||
|
resetWorkspaceRename,
|
||||||
|
removeSessionFromWorkspace,
|
||||||
|
setWorkspaceFocusedSession,
|
||||||
|
toggleWorkspaceViewMode,
|
||||||
|
createLocalTerminal,
|
||||||
|
createSerialSession,
|
||||||
|
connectToHost,
|
||||||
|
updateSessionStatus,
|
||||||
|
updateSessionFontSize,
|
||||||
|
clearSessionFontSizeOverride,
|
||||||
|
createWorkspaceWithHosts,
|
||||||
|
createWorkspaceFromSessions,
|
||||||
|
addSessionToWorkspace,
|
||||||
|
appendHostToWorkspace,
|
||||||
|
appendLocalTerminalToWorkspace,
|
||||||
|
createWorkspaceFromTargets,
|
||||||
|
updateSplitSizes,
|
||||||
|
splitSession,
|
||||||
|
reorderWorkspaceSessions,
|
||||||
|
moveFocusInWorkspace,
|
||||||
|
runSnippet,
|
||||||
|
getOrderedWorkTabs,
|
||||||
|
reorderTabs,
|
||||||
|
toggleBroadcast,
|
||||||
|
isBroadcastEnabled,
|
||||||
|
copySession,
|
||||||
|
copyWorkspace,
|
||||||
|
createSessionFromCloneSource,
|
||||||
|
updateSessionRestoreCwd,
|
||||||
|
getSessionRestoreCwd,
|
||||||
|
updateSessionDynamicTitle,
|
||||||
|
updateSessionCodingCliProvider,
|
||||||
|
}), [
|
||||||
|
addSessionToWorkspace,
|
||||||
|
appendHostToWorkspace,
|
||||||
|
appendLocalTerminalToWorkspace,
|
||||||
|
clearSessionFontSizeOverride,
|
||||||
|
closeLogView,
|
||||||
|
closeSession,
|
||||||
|
closeSessions,
|
||||||
|
closeWorkspace,
|
||||||
|
connectToHost,
|
||||||
|
copySession,
|
||||||
|
copyWorkspace,
|
||||||
|
createLocalTerminal,
|
||||||
|
createSerialSession,
|
||||||
|
createSessionFromCloneSource,
|
||||||
|
createWorkspaceFromSessions,
|
||||||
|
createWorkspaceFromTargets,
|
||||||
|
createWorkspaceWithHosts,
|
||||||
|
getOrderedWorkTabs,
|
||||||
|
getSessionRestoreCwd,
|
||||||
|
isBroadcastEnabled,
|
||||||
|
moveFocusInWorkspace,
|
||||||
|
openLogView,
|
||||||
|
removeSessionFromWorkspace,
|
||||||
|
renameSessionInline,
|
||||||
|
reorderTabs,
|
||||||
|
reorderWorkspaceSessions,
|
||||||
|
resetSessionRename,
|
||||||
|
resetWorkspaceRename,
|
||||||
|
runSnippet,
|
||||||
|
setActiveTabId,
|
||||||
|
setDraggingSessionId,
|
||||||
|
setWorkspaceFocusedSession,
|
||||||
|
splitSession,
|
||||||
|
startSessionRename,
|
||||||
|
startWorkspaceRename,
|
||||||
|
submitSessionRename,
|
||||||
|
submitWorkspaceRename,
|
||||||
|
toggleBroadcast,
|
||||||
|
toggleWorkspaceViewMode,
|
||||||
|
updateSessionCodingCliProvider,
|
||||||
|
updateSessionDynamicTitle,
|
||||||
|
updateSessionFontSize,
|
||||||
|
updateSessionRestoreCwd,
|
||||||
|
updateSessionStatus,
|
||||||
|
updateSplitSizes,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
registerSessionSnapshotActions(sessionActions);
|
||||||
|
return () => {
|
||||||
|
registerSessionSnapshotActions(null);
|
||||||
|
};
|
||||||
|
}, [sessionActions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppSessionRuntimeContext.Provider value={session}>
|
||||||
|
{children}
|
||||||
|
</AppSessionRuntimeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useLayoutEffect, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AppSettingsRuntimeContext,
|
||||||
|
registerAppSettingsRuntime,
|
||||||
|
} from '../../state/appRuntimeBridge';
|
||||||
|
import { useSettingsState } from '../../state/useSettingsState';
|
||||||
|
|
||||||
|
export type SettingsPublisherProps = {
|
||||||
|
/** Peer session windows must not drive the main window's settings IPC sync. */
|
||||||
|
enableSettingsSync: boolean;
|
||||||
|
/** Peer session windows must not re-apply OS-level system settings effects. */
|
||||||
|
enableSystemEffects: boolean;
|
||||||
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns `useSettingsState` and hands it to consumers the same way
|
||||||
|
* `VaultPublisher` / `SessionPublisher` hand over their runtimes: a context for
|
||||||
|
* render-time reads and the `appRuntimeBridge` slot for imperative callers.
|
||||||
|
*
|
||||||
|
* The store fan-out (`settingsChromeStore` / `appearanceChromeStore`) already
|
||||||
|
* happens inside `useSettingsState`, so this publisher only relocates the hook
|
||||||
|
* out of the component that also builds the shell's domain bags.
|
||||||
|
*/
|
||||||
|
export function SettingsPublisher({
|
||||||
|
enableSettingsSync,
|
||||||
|
enableSystemEffects,
|
||||||
|
children,
|
||||||
|
}: SettingsPublisherProps) {
|
||||||
|
const settings = useSettingsState({ enableSettingsSync, enableSystemEffects });
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
registerAppSettingsRuntime(settings);
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
// Only a real unmount clears the slot; see VaultPublisher for why.
|
||||||
|
useLayoutEffect(() => () => {
|
||||||
|
registerAppSettingsRuntime(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppSettingsRuntimeContext.Provider value={settings}>
|
||||||
|
{children}
|
||||||
|
</AppSettingsRuntimeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { useLayoutEffect, useMemo, useRef, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AppVaultRuntimeContext,
|
||||||
|
registerAppVaultRuntime,
|
||||||
|
type AppVaultContextValue,
|
||||||
|
} from '../../state/appRuntimeBridge';
|
||||||
|
import {
|
||||||
|
publishVaultSnapshot,
|
||||||
|
registerVaultSnapshotActions,
|
||||||
|
} from '../../state/vaultSnapshotStore';
|
||||||
|
import { useVaultState } from '../../state/useVaultState';
|
||||||
|
import { getEffectiveKnownHosts } from '../../../infrastructure/syncHelpers';
|
||||||
|
|
||||||
|
export type VaultPublisherProps = {
|
||||||
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function vaultContextValuesEqual(
|
||||||
|
prev: AppVaultContextValue,
|
||||||
|
next: AppVaultContextValue,
|
||||||
|
): boolean {
|
||||||
|
const keys = Object.keys(next) as Array<keyof AppVaultContextValue>;
|
||||||
|
return keys.every((key) => prev[key] === next[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns `useVaultState` and publishes it three ways: the catalog into
|
||||||
|
* `vaultSnapshotStore` for shell surfaces that subscribe to a slice, the
|
||||||
|
* mutators into the same store's action slot, and a **catalog-only** runtime
|
||||||
|
* onto `AppVaultRuntimeContext` for App.
|
||||||
|
*
|
||||||
|
* `notes` / `noteGroups` / `connectionLogs` / `shellHistory` are intentionally
|
||||||
|
* absent from the context value: they churn on a different cadence and already
|
||||||
|
* fan out through dedicated stores. Including them would force App (the
|
||||||
|
* domain-bag builder) to re-render on every note edit or session log append.
|
||||||
|
* Imperative callers that still need the full hook return use
|
||||||
|
* `getAppVaultRuntime()`.
|
||||||
|
*/
|
||||||
|
export function VaultPublisher({ children }: VaultPublisherProps) {
|
||||||
|
const vault = useVaultState();
|
||||||
|
const {
|
||||||
|
isInitialized,
|
||||||
|
hosts,
|
||||||
|
keys,
|
||||||
|
identities,
|
||||||
|
proxyProfiles,
|
||||||
|
snippets,
|
||||||
|
snippetPackages,
|
||||||
|
customGroups,
|
||||||
|
knownHosts,
|
||||||
|
managedSources,
|
||||||
|
groupConfigs,
|
||||||
|
updateHosts,
|
||||||
|
updateKeys,
|
||||||
|
importOrReuseKey,
|
||||||
|
updateIdentities,
|
||||||
|
updateProxyProfiles,
|
||||||
|
updateSnippets,
|
||||||
|
updateSnippetPackages,
|
||||||
|
updateCustomGroups,
|
||||||
|
updateKnownHosts,
|
||||||
|
updateManagedSources,
|
||||||
|
updateGroupConfigs,
|
||||||
|
convertKnownHostToHost,
|
||||||
|
readPersistedHosts,
|
||||||
|
readPersistedManagedSources,
|
||||||
|
commitPluginImporterData,
|
||||||
|
commitVaultImportTransaction,
|
||||||
|
updateHostDistro,
|
||||||
|
updateHostLastConnected,
|
||||||
|
addShellHistoryEntry,
|
||||||
|
removeShellHistoryEntry,
|
||||||
|
} = vault;
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
registerAppVaultRuntime(vault);
|
||||||
|
}, [vault]);
|
||||||
|
|
||||||
|
// Only a real unmount clears the slot. A re-render re-registers through the
|
||||||
|
// effect above, and StrictMode's simulated remount re-runs both.
|
||||||
|
useLayoutEffect(() => () => {
|
||||||
|
registerAppVaultRuntime(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// useVaultState decrypts hosts/keys before it reads known hosts, so the state
|
||||||
|
// is briefly empty at boot even when storage has entries. Publish the same
|
||||||
|
// storage fallback App uses so a subscriber connecting during that window
|
||||||
|
// does not re-prompt for a fingerprint it already trusts.
|
||||||
|
const effectiveKnownHosts = useMemo(
|
||||||
|
() => getEffectiveKnownHosts(knownHosts) ?? [],
|
||||||
|
[knownHosts],
|
||||||
|
);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
publishVaultSnapshot({
|
||||||
|
isVaultInitialized: isInitialized,
|
||||||
|
hosts,
|
||||||
|
keys,
|
||||||
|
identities,
|
||||||
|
proxyProfiles,
|
||||||
|
snippets,
|
||||||
|
snippetPackages,
|
||||||
|
customGroups,
|
||||||
|
knownHosts: effectiveKnownHosts,
|
||||||
|
managedSources,
|
||||||
|
groupConfigs,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
customGroups,
|
||||||
|
effectiveKnownHosts,
|
||||||
|
groupConfigs,
|
||||||
|
hosts,
|
||||||
|
identities,
|
||||||
|
isInitialized,
|
||||||
|
keys,
|
||||||
|
managedSources,
|
||||||
|
proxyProfiles,
|
||||||
|
snippetPackages,
|
||||||
|
snippets,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
registerVaultSnapshotActions({
|
||||||
|
updateHosts,
|
||||||
|
updateKeys,
|
||||||
|
importOrReuseKey,
|
||||||
|
updateIdentities,
|
||||||
|
updateProxyProfiles,
|
||||||
|
updateSnippets,
|
||||||
|
updateSnippetPackages,
|
||||||
|
updateCustomGroups,
|
||||||
|
updateKnownHosts,
|
||||||
|
updateManagedSources,
|
||||||
|
updateGroupConfigs,
|
||||||
|
convertKnownHostToHost,
|
||||||
|
readPersistedHosts,
|
||||||
|
readPersistedManagedSources,
|
||||||
|
commitPluginImporterData,
|
||||||
|
commitVaultImportTransaction,
|
||||||
|
updateHostDistro,
|
||||||
|
updateHostLastConnected,
|
||||||
|
addShellHistoryEntry,
|
||||||
|
removeShellHistoryEntry,
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
registerVaultSnapshotActions(null);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
addShellHistoryEntry,
|
||||||
|
commitPluginImporterData,
|
||||||
|
commitVaultImportTransaction,
|
||||||
|
convertKnownHostToHost,
|
||||||
|
importOrReuseKey,
|
||||||
|
readPersistedHosts,
|
||||||
|
readPersistedManagedSources,
|
||||||
|
removeShellHistoryEntry,
|
||||||
|
updateCustomGroups,
|
||||||
|
updateGroupConfigs,
|
||||||
|
updateHostDistro,
|
||||||
|
updateHostLastConnected,
|
||||||
|
updateHosts,
|
||||||
|
updateIdentities,
|
||||||
|
updateKeys,
|
||||||
|
updateKnownHosts,
|
||||||
|
updateManagedSources,
|
||||||
|
updateProxyProfiles,
|
||||||
|
updateSnippetPackages,
|
||||||
|
updateSnippets,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Strip high-churn fields, then retain the previous object identity when only
|
||||||
|
// those fields (or exportData) changed so React context consumers stay quiet.
|
||||||
|
const vaultForAppRef = useRef<AppVaultContextValue | null>(null);
|
||||||
|
const vaultForApp = useMemo((): AppVaultContextValue => {
|
||||||
|
const {
|
||||||
|
notes: _notes,
|
||||||
|
noteGroups: _noteGroups,
|
||||||
|
connectionLogs: _connectionLogs,
|
||||||
|
shellHistory: _shellHistory,
|
||||||
|
exportData: _exportData,
|
||||||
|
...catalog
|
||||||
|
} = vault;
|
||||||
|
const prev = vaultForAppRef.current;
|
||||||
|
if (prev && vaultContextValuesEqual(prev, catalog)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
vaultForAppRef.current = catalog;
|
||||||
|
return catalog;
|
||||||
|
}, [vault]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppVaultRuntimeContext.Provider value={vaultForApp}>
|
||||||
|
{children}
|
||||||
|
</AppVaultRuntimeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const appSource = readFileSync(new URL('../../App.tsx', import.meta.url), 'utf8');
|
||||||
|
const appSideEffectsSource = readFileSync(
|
||||||
|
new URL('./AppSideEffects.tsx', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const indexSource = readFileSync(new URL('../../index.tsx', import.meta.url), 'utf8');
|
||||||
|
const startupEffectsSource = readFileSync(
|
||||||
|
new URL('./useAppStartupEffects.ts', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const updateCheckSource = readFileSync(
|
||||||
|
new URL('../state/useUpdateCheck.ts', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const portForwardingAutoStartSource = readFileSync(
|
||||||
|
new URL('../state/usePortForwardingAutoStart.ts', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
test('every renderer root mounts under StrictMode', () => {
|
||||||
|
assert.match(indexSource, /import \{ StrictMode, Suspense, lazy \} from 'react'/);
|
||||||
|
|
||||||
|
const renderCalls = indexSource.match(/root\.render\(/g) ?? [];
|
||||||
|
assert.equal(renderCalls.length, 4, 'main, settings, tray and terminal-popup roots');
|
||||||
|
|
||||||
|
let cursor = 0;
|
||||||
|
for (let index = 0; index < renderCalls.length; index += 1) {
|
||||||
|
const renderAt = indexSource.indexOf('root.render(', cursor);
|
||||||
|
assert.notEqual(renderAt, -1);
|
||||||
|
const opener = indexSource.slice(renderAt, renderAt + 60);
|
||||||
|
assert.match(opener, /root\.render\(\s*<StrictMode>/, `root.render #${index + 1} lacks StrictMode`);
|
||||||
|
cursor = renderAt + 'root.render('.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clone-session payload is consumed once even if the effect re-runs', () => {
|
||||||
|
const effectStart = appSideEffectsSource.indexOf('consumedNewWindowSessionRef');
|
||||||
|
assert.notEqual(effectStart, -1, 'clone-session effect must latch on payload identity');
|
||||||
|
const effectEnd = appSideEffectsSource.indexOf(
|
||||||
|
'}, [createSessionFromCloneSource, isVaultInitialized, pendingNewWindowSession]);',
|
||||||
|
effectStart,
|
||||||
|
);
|
||||||
|
assert.notEqual(effectEnd, -1);
|
||||||
|
const body = appSideEffectsSource.slice(effectStart, effectEnd);
|
||||||
|
|
||||||
|
// A ref comparison is required: clearing the state only lands on the next
|
||||||
|
// render, so a re-invoked effect still closes over the same payload.
|
||||||
|
assert.match(body, /if \(consumedNewWindowSessionRef\.current === pendingNewWindowSession\) return;/);
|
||||||
|
assert.ok(
|
||||||
|
body.indexOf('consumedNewWindowSessionRef.current = pending')
|
||||||
|
< body.indexOf('createSessionFromCloneSource(pending.sourceSession'),
|
||||||
|
'payload must be marked consumed before the clone is created',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rendererReady is notified once per renderer process', () => {
|
||||||
|
assert.match(appSource, /^let rendererReadySent = false;$/m);
|
||||||
|
const guardAt = appSource.indexOf('if (!rendererReadySent) {');
|
||||||
|
assert.notEqual(guardAt, -1);
|
||||||
|
const guarded = appSource.slice(guardAt, guardAt + 200);
|
||||||
|
assert.match(guarded, /rendererReadySent = true;/);
|
||||||
|
assert.match(guarded, /netcattyBridge\.get\(\)\?\.rendererReady\?\.\(\)/);
|
||||||
|
assert.ok(
|
||||||
|
guarded.indexOf('rendererReadySent = true;')
|
||||||
|
< guarded.indexOf('netcattyBridge.get()?.rendererReady?.()'),
|
||||||
|
'the latch must be set before the IPC call so a re-entrant effect is blocked',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update-available toast latches on the release version', () => {
|
||||||
|
const latchAt = startupEffectsSource.indexOf('toastedUpdateVersionRef');
|
||||||
|
assert.notEqual(latchAt, -1);
|
||||||
|
assert.match(startupEffectsSource, /const toastedUpdateVersionRef = useRef<string \| null>\(null\)/);
|
||||||
|
assert.match(startupEffectsSource, /if \(toastedUpdateVersionRef\.current === version\) return;/);
|
||||||
|
|
||||||
|
const guardAt = startupEffectsSource.indexOf('if (toastedUpdateVersionRef.current === version) return;');
|
||||||
|
const toastAt = startupEffectsSource.indexOf('toast.info(', guardAt);
|
||||||
|
assert.notEqual(toastAt, -1);
|
||||||
|
assert.ok(guardAt < toastAt, 'the version latch must gate the toast call');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('port-forward auto-start runs once across a StrictMode double effect', () => {
|
||||||
|
assert.match(
|
||||||
|
portForwardingAutoStartSource,
|
||||||
|
/const autoStartExecutedRef = useRef\(false\);/,
|
||||||
|
'the launch auto-start needs a module-render latch, not just an effect dep list',
|
||||||
|
);
|
||||||
|
|
||||||
|
const effectStart = portForwardingAutoStartSource.indexOf('if (autoStartExecutedRef.current) return;');
|
||||||
|
assert.notEqual(effectStart, -1, 'the effect must bail out when the latch is already set');
|
||||||
|
const effectEnd = portForwardingAutoStartSource.indexOf(
|
||||||
|
'}, [\n enabled,\n isVaultInitialized,\n runAutoStart,\n ]);',
|
||||||
|
effectStart,
|
||||||
|
);
|
||||||
|
assert.notEqual(effectEnd, -1, 'auto-start effect dep list moved; update this contract');
|
||||||
|
const body = portForwardingAutoStartSource.slice(effectStart, effectEnd);
|
||||||
|
|
||||||
|
// StrictMode invokes the effect twice with the same render's closure, so the
|
||||||
|
// latch has to be written before the async run is kicked off — awaiting or
|
||||||
|
// deferring the write would let the second invoke start a duplicate tunnel.
|
||||||
|
const latchAt = body.indexOf('autoStartExecutedRef.current = true;');
|
||||||
|
const runAt = body.indexOf('void runAutoStart();');
|
||||||
|
assert.notEqual(latchAt, -1);
|
||||||
|
assert.notEqual(runAt, -1);
|
||||||
|
assert.ok(latchAt < runAt, 'the latch must be set before runAutoStart() is called');
|
||||||
|
|
||||||
|
// The vault gate must also sit before the latch: latching on a pre-hydration
|
||||||
|
// invoke would permanently suppress the real auto-start.
|
||||||
|
assert.ok(
|
||||||
|
body.indexOf('if (!isVaultInitialized) return;') < latchAt,
|
||||||
|
'the vault gate must precede the latch write',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancelled startup update check resets its latch instead of skipping forever', () => {
|
||||||
|
const scheduleAt = updateCheckSource.indexOf('let checkArmed = true;');
|
||||||
|
assert.notEqual(scheduleAt, -1);
|
||||||
|
const tail = updateCheckSource.slice(scheduleAt);
|
||||||
|
|
||||||
|
// The latch is only meaningful once the timer actually fires; a cleanup that
|
||||||
|
// cancels it beforehand must let the next effect schedule again.
|
||||||
|
assert.match(tail, /startupCheckTimeoutRef\.current = setTimeout\(async \(\) => \{\s*\n\s*checkArmed = false;/);
|
||||||
|
assert.match(tail, /if \(checkArmed\) \{\s*\n\s*hasCheckedOnStartupRef\.current = false;\s*\n\s*\}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('terminal popup config survives StrictMode unsubscribe/resubscribe', () => {
|
||||||
|
const preloadSource = readFileSync(new URL('../../electron/preload.cjs', import.meta.url), 'utf8');
|
||||||
|
const apiSource = readFileSync(new URL('../../electron/preload/api.cjs', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
assert.match(preloadSource, /lastPayload:\s*null/);
|
||||||
|
assert.match(
|
||||||
|
preloadSource,
|
||||||
|
/terminalPopupConfigState\.lastPayload = payload/,
|
||||||
|
'incoming popup config must be retained beyond the one-shot pending slot',
|
||||||
|
);
|
||||||
|
|
||||||
|
const subscribeAt = apiSource.indexOf('onTerminalPopupConfig:');
|
||||||
|
assert.notEqual(subscribeAt, -1);
|
||||||
|
const subscribe = apiSource.slice(subscribeAt, subscribeAt + 700);
|
||||||
|
assert.match(
|
||||||
|
subscribe,
|
||||||
|
/terminalPopupConfigState\.pending \?\? terminalPopupConfigState\.lastPayload/,
|
||||||
|
'resubscribe must replay lastPayload after pending was drained',
|
||||||
|
);
|
||||||
|
assert.match(subscribe, /terminalPopupConfigState\.pending = null/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
subscribe,
|
||||||
|
/terminalPopupConfigState\.lastPayload = null/,
|
||||||
|
'StrictMode remount must not clear lastPayload on subscribe',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vault init cancels the superseded StrictMode effect before publishing ready', () => {
|
||||||
|
const vaultSource = readFileSync(
|
||||||
|
new URL('../state/useVaultState.ts', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const initAt = vaultSource.indexOf('let cancelled = false;');
|
||||||
|
assert.notEqual(initAt, -1, 'vault init must track cancellation');
|
||||||
|
const initSlice = vaultSource.slice(initAt, initAt + 12000);
|
||||||
|
assert.match(initSlice, /return \(\) => \{\s*\n\s*cancelled = true;\s*\n\s*\};/);
|
||||||
|
assert.match(
|
||||||
|
initSlice,
|
||||||
|
/if \(!cancelled\) \{\s*\n\s*setIsInitialized\(true\);\s*\n\s*setVaultInitialized\(true\);\s*\n\s*\}/,
|
||||||
|
'only the surviving init may mark the vault ready',
|
||||||
|
);
|
||||||
|
assert.match(initSlice, /if \(cancelled\) return;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('global hotkey registration cleans up across StrictMode remount', () => {
|
||||||
|
const systemEffectsSource = readFileSync(
|
||||||
|
new URL('../state/systemSettingsEffects.ts', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const hotkeyAt = systemEffectsSource.indexOf('Persist and sync toggle window hotkey setting');
|
||||||
|
assert.notEqual(hotkeyAt, -1);
|
||||||
|
const hotkeyEffect = systemEffectsSource.slice(hotkeyAt, hotkeyAt + 2200);
|
||||||
|
assert.match(hotkeyEffect, /let cancelled = false;/);
|
||||||
|
assert.match(hotkeyEffect, /if \(cancelled\) return;/);
|
||||||
|
assert.match(
|
||||||
|
hotkeyEffect,
|
||||||
|
/if \(didRegister\) \{\s*\n\s*bridge\?\.unregisterGlobalHotkey/,
|
||||||
|
'cleanup must unregister a registration started by this effect',
|
||||||
|
);
|
||||||
|
// Early return before notify must not skip returning the cleanup function.
|
||||||
|
assert.doesNotMatch(
|
||||||
|
hotkeyEffect,
|
||||||
|
/if \(!persistMountedRef\.current\) return;\s*\n\s*notifySettingsChanged/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settings persistMountedRef resets on StrictMode cleanup', () => {
|
||||||
|
const settingsSource = readFileSync(
|
||||||
|
new URL('../state/useSettingsState.ts', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const markAt = settingsSource.indexOf('Mark persist effects mounted AFTER all persist useEffects');
|
||||||
|
assert.notEqual(markAt, -1);
|
||||||
|
const markEffect = settingsSource.slice(markAt, markAt + 500);
|
||||||
|
assert.match(markEffect, /persistMountedRef\.current = true;/);
|
||||||
|
assert.match(
|
||||||
|
markEffect,
|
||||||
|
/return \(\) => \{\s*\n\s*persistMountedRef\.current = false;\s*\n\s*\};/,
|
||||||
|
'remount must treat boot as a fresh mount, not a settings change',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tray panel connect flush latches against StrictMode double invoke', () => {
|
||||||
|
const sideEffectsSource = readFileSync(
|
||||||
|
new URL('./AppSideEffects.tsx', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.match(sideEffectsSource, /pendingTrayConnectFlushKeyRef/);
|
||||||
|
assert.match(
|
||||||
|
sideEffectsSource,
|
||||||
|
/if \(pendingTrayConnectFlushKeyRef\.current === flushKey\) return;/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ssh transport idle TTL notify latches against StrictMode double invoke', () => {
|
||||||
|
const settingsSource = readFileSync(
|
||||||
|
new URL('../state/useSettingsState.ts', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.match(settingsSource, /lastPushedSshTransportIdleTtlRef/);
|
||||||
|
assert.match(
|
||||||
|
settingsSource,
|
||||||
|
/if \(lastPushedSshTransportIdleTtlRef\.current === sshTransportIdleTtlMs\) return;/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
settingsSource,
|
||||||
|
/lastPushedSshTransportIdleTtlRef\.current = sshTransportIdleTtlMs;/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('terminal selection Ask-AI payload is consumed once under StrictMode', () => {
|
||||||
|
const hostSource = readFileSync(
|
||||||
|
new URL('../../components/terminalLayer/TerminalLayerSupport.tsx', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.match(hostSource, /consumedTerminalSelectionRequestIds/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
hostSource,
|
||||||
|
/consumedTerminalSelectionRequestIdRef/,
|
||||||
|
'component refs reset on StrictMode remount; use a module Set',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
hostSource,
|
||||||
|
/if \(consumedTerminalSelectionRequestIds\.has\(pendingTerminalSelection\.requestId\)\)/,
|
||||||
|
);
|
||||||
|
const latchAt = hostSource.indexOf('markTerminalSelectionRequestConsumed(pendingTerminalSelection.requestId)');
|
||||||
|
const draftAt = hostSource.indexOf('updateDraft(scopeKey, defaultAgentId');
|
||||||
|
assert.ok(latchAt > 0 && draftAt > latchAt, 'must latch before mutating the draft');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Codex App Server interaction bridge is app-singleton like MCP approvals', () => {
|
||||||
|
assert.match(appSource, /setupCodexAppServerInteractionBridge/);
|
||||||
|
const panelSource = readFileSync(
|
||||||
|
new URL('../../components/AIChatSidePanel.tsx', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
panelSource,
|
||||||
|
/setupCodexAppServerInteractionBridge/,
|
||||||
|
'per-panel Codex IPC listeners fan out approvals under retained multi-tab mounts',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets.ts';
|
import { buildNumberShortcutTabTargets, buildTabShortcutNumberById } from './tabShortcutTargets.ts';
|
||||||
|
|
||||||
test('number shortcut tabs include vault and sftp by default', () => {
|
test('number shortcut tabs include vault and sftp by default', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
@@ -62,3 +62,43 @@ test('pinned tabs cannot be duplicated by a malformed persisted work ordering',
|
|||||||
['vault', 'sftp', 'session-1'],
|
['vault', 'sftp', 'session-1'],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('shortcut number map uses 1-based indices matching Ctrl/Cmd+[1...9]', () => {
|
||||||
|
const map = buildTabShortcutNumberById({
|
||||||
|
showSftpTab: true,
|
||||||
|
shellOnlyTabNumberShortcuts: false,
|
||||||
|
orderedTabs: ['session-1', 'workspace-1'],
|
||||||
|
editorTabIds: [],
|
||||||
|
});
|
||||||
|
assert.equal(map.get('vault'), 1);
|
||||||
|
assert.equal(map.get('sftp'), 2);
|
||||||
|
assert.equal(map.get('session-1'), 3);
|
||||||
|
assert.equal(map.get('workspace-1'), 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shortcut number map skips pinned tabs in shell-only mode', () => {
|
||||||
|
const map = buildTabShortcutNumberById({
|
||||||
|
showSftpTab: true,
|
||||||
|
shellOnlyTabNumberShortcuts: true,
|
||||||
|
orderedTabs: ['session-1', 'workspace-1'],
|
||||||
|
editorTabIds: [],
|
||||||
|
});
|
||||||
|
assert.equal(map.has('vault'), false);
|
||||||
|
assert.equal(map.has('sftp'), false);
|
||||||
|
assert.equal(map.get('session-1'), 1);
|
||||||
|
assert.equal(map.get('workspace-1'), 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shortcut number map caps at nine entries', () => {
|
||||||
|
const orderedTabs = Array.from({ length: 12 }, (_, index) => `session-${index + 1}`);
|
||||||
|
const map = buildTabShortcutNumberById({
|
||||||
|
showSftpTab: false,
|
||||||
|
shellOnlyTabNumberShortcuts: true,
|
||||||
|
orderedTabs,
|
||||||
|
editorTabIds: [],
|
||||||
|
});
|
||||||
|
assert.equal(map.size, 9);
|
||||||
|
assert.equal(map.get('session-1'), 1);
|
||||||
|
assert.equal(map.get('session-9'), 9);
|
||||||
|
assert.equal(map.has('session-10'), false);
|
||||||
|
});
|
||||||
@@ -12,3 +12,22 @@ export function buildNumberShortcutTabTargets(params: {
|
|||||||
const pinnedTabs = params.showSftpTab ? ['vault', 'sftp'] : ['vault'];
|
const pinnedTabs = params.showSftpTab ? ['vault', 'sftp'] : ['vault'];
|
||||||
return [...new Set([...pinnedTabs, ...workTabs])];
|
return [...new Set([...pinnedTabs, ...workTabs])];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps tab ids to Cmd/Ctrl+[1...9] shortcut indices (1-based).
|
||||||
|
* Only the first nine shortcut targets receive a number.
|
||||||
|
*/
|
||||||
|
export function buildTabShortcutNumberById(params: {
|
||||||
|
showSftpTab: boolean;
|
||||||
|
shellOnlyTabNumberShortcuts: boolean;
|
||||||
|
orderedTabs: readonly string[];
|
||||||
|
editorTabIds: readonly string[];
|
||||||
|
}): ReadonlyMap<string, number> {
|
||||||
|
const targets = buildNumberShortcutTabTargets(params);
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
const limit = Math.min(9, targets.length);
|
||||||
|
for (let index = 0; index < limit; index += 1) {
|
||||||
|
map.set(targets[index], index + 1);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Theme runtime actions produced by TerminalHost (`useThemeRuntime`).
|
||||||
|
* AppSideEffects handlers (default/follow theme changes) call through this
|
||||||
|
* bridge instead of co-hosting the hook.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Listener = () => void;
|
||||||
|
|
||||||
|
export type ThemeRuntimeBridgeActions = {
|
||||||
|
clearThemeIntent: () => void;
|
||||||
|
settleManualThemeIntent: () => void;
|
||||||
|
pickTerminalTheme: (themeId: string) => void;
|
||||||
|
resolveFocusedAppearance: (...args: never[]) => unknown;
|
||||||
|
currentTerminalTheme: unknown;
|
||||||
|
globalAppearance: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
class ThemeRuntimeBridge {
|
||||||
|
private actions: ThemeRuntimeBridgeActions | null = null;
|
||||||
|
private listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
get = (): ThemeRuntimeBridgeActions | null => this.actions;
|
||||||
|
|
||||||
|
subscribe = (listener: Listener): (() => void) => {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
set(next: ThemeRuntimeBridgeActions | null): void {
|
||||||
|
if (this.actions === next) return;
|
||||||
|
this.actions = next;
|
||||||
|
for (const listener of this.listeners) listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bridge = new ThemeRuntimeBridge();
|
||||||
|
|
||||||
|
export function registerThemeRuntimeActions(
|
||||||
|
actions: ThemeRuntimeBridgeActions | null,
|
||||||
|
): void {
|
||||||
|
bridge.set(actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getThemeRuntimeActions(): ThemeRuntimeBridgeActions | null {
|
||||||
|
return bridge.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeThemeRuntimeActions(listener: Listener): () => void {
|
||||||
|
return bridge.subscribe(listener);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from '../sta
|
|||||||
import { hasNewSourceFingerprint } from '../state/sftp/transferProgressMetadata';
|
import { hasNewSourceFingerprint } from '../state/sftp/transferProgressMetadata';
|
||||||
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from '../../infrastructure/config/storageKeys';
|
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from '../../infrastructure/config/storageKeys';
|
||||||
import type { TransferTask } from '../../domain/models';
|
import type { TransferTask } from '../../domain/models';
|
||||||
|
import { isTerminalBootEpochCurrent } from '../../domain/terminalBootEpoch';
|
||||||
import {
|
import {
|
||||||
canApplyDedicatedResumeProgress,
|
canApplyDedicatedResumeProgress,
|
||||||
createDedicatedResumeChildUpdateBatcher,
|
createDedicatedResumeChildUpdateBatcher,
|
||||||
@@ -24,8 +25,10 @@ type KeyboardInteractiveRequestLike = {
|
|||||||
scope?: KeyboardInteractiveScope;
|
scope?: KeyboardInteractiveScope;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
hostId?: string;
|
hostId?: string;
|
||||||
|
requestId?: string;
|
||||||
|
bootEpoch?: number;
|
||||||
};
|
};
|
||||||
type SessionIdLike = { id: string; hostId?: string; hostname?: string };
|
type SessionIdLike = { id: string; hostId?: string; hostname?: string; status?: string };
|
||||||
type KeyboardInteractiveQueueItem = { requestId: string };
|
type KeyboardInteractiveQueueItem = { requestId: string };
|
||||||
|
|
||||||
export function shouldQueueKeyboardInteractiveRequest(
|
export function shouldQueueKeyboardInteractiveRequest(
|
||||||
@@ -34,7 +37,14 @@ export function shouldQueueKeyboardInteractiveRequest(
|
|||||||
): boolean {
|
): boolean {
|
||||||
if (request.scope !== "terminal") return true;
|
if (request.scope !== "terminal") return true;
|
||||||
if (!request.sessionId) return false;
|
if (!request.sessionId) return false;
|
||||||
return sessions.some((session) => session.id === request.sessionId);
|
const session = sessions.find((entry) => entry.id === request.sessionId);
|
||||||
|
if (!session) return false;
|
||||||
|
// Status-bar disconnect keeps the tab; do not queue MFA for aborted panes.
|
||||||
|
if (session.status === "disconnected") return false;
|
||||||
|
// After disconnect → reconnect the tab is connecting again; reject MFA from
|
||||||
|
// a superseded SSH start that still shares this sessionId.
|
||||||
|
if (!isTerminalBootEpochCurrent(request.sessionId, request.bootEpoch)) return false;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeKeyboardInteractiveRequest<T extends KeyboardInteractiveQueueItem>(
|
export function removeKeyboardInteractiveRequest<T extends KeyboardInteractiveQueueItem>(
|
||||||
@@ -203,6 +213,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
|||||||
}, [dedicatedResumeHosts, enabled, identities, isVaultInitialized, keys, knownHosts, terminalSettings]);
|
}, [dedicatedResumeHosts, enabled, identities, isVaultInitialized, keys, knownHosts, terminalSettings]);
|
||||||
|
|
||||||
// Show toast notification when update is available (only when auto-download is idle)
|
// Show toast notification when update is available (only when auto-download is idle)
|
||||||
|
const toastedUpdateVersionRef = useRef<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
// Skip "update available" toast if auto-download has already started or completed
|
// Skip "update available" toast if auto-download has already started or completed
|
||||||
@@ -211,6 +222,8 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
|||||||
if (localStorageAdapter.readString('netcatty_auto_update_enabled_v1') === 'false') return;
|
if (localStorageAdapter.readString('netcatty_auto_update_enabled_v1') === 'false') return;
|
||||||
if (updateState.hasUpdate && updateState.latestRelease) {
|
if (updateState.hasUpdate && updateState.latestRelease) {
|
||||||
const version = updateState.latestRelease.version;
|
const version = updateState.latestRelease.version;
|
||||||
|
if (toastedUpdateVersionRef.current === version) return;
|
||||||
|
toastedUpdateVersionRef.current = version;
|
||||||
toast.info(
|
toast.info(
|
||||||
t('update.available.message', { version }),
|
t('update.available.message', { version }),
|
||||||
{
|
{
|
||||||
@@ -409,6 +422,9 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
|||||||
targetType: task.direction === "download" ? "local" : "sftp",
|
targetType: task.direction === "download" ? "local" : "sftp",
|
||||||
sourceSftpId: task.direction === "download" ? sftpId : undefined,
|
sourceSftpId: task.direction === "download" ? sftpId : undefined,
|
||||||
targetSftpId: task.direction === "upload" ? sftpId : undefined,
|
targetSftpId: task.direction === "upload" ? sftpId : undefined,
|
||||||
|
// Keep host-scoped path gates across session reopen (Codex P1).
|
||||||
|
sourceHostId: task.sourceHostId,
|
||||||
|
targetHostId: task.targetHostId,
|
||||||
totalBytes: task.totalBytes,
|
totalBytes: task.totalBytes,
|
||||||
resumable: task.resumable !== false,
|
resumable: task.resumable !== false,
|
||||||
checkpointBytes,
|
checkpointBytes,
|
||||||
@@ -420,7 +436,30 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (result?.cancelled || result?.error === "Transfer cancelled") {
|
// Same-id retry stole ownership; wait for the live owner's terminal
|
||||||
|
// status instead of treating this invoke as completed (Codex P2).
|
||||||
|
if (result?.superseded === true) {
|
||||||
|
// Wait for live owner terminal status only (no fixed deadline).
|
||||||
|
for (;;) {
|
||||||
|
const latest = sftpTransferCenterStore.getSnapshot().tasks.find((candidate) => candidate.id === task.id);
|
||||||
|
const status = latest?.status;
|
||||||
|
if (status === "completed" || status === "cancelled" || status === "failed") {
|
||||||
|
if (status === "failed") {
|
||||||
|
throw new Error(latest?.error || "Transfer failed");
|
||||||
|
}
|
||||||
|
if (status === "cancelled") {
|
||||||
|
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||||
|
type: "cancelled",
|
||||||
|
transferId: task.id,
|
||||||
|
endedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// completed: events already applied; cancelled handled above.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
}
|
||||||
|
} else if (result?.cancelled || result?.error === "Transfer cancelled") {
|
||||||
sftpTransferCenterStore.ingestBackgroundEvent({ type: "cancelled", transferId: task.id, endedAt: Date.now() });
|
sftpTransferCenterStore.ingestBackgroundEvent({ type: "cancelled", transferId: task.id, endedAt: Date.now() });
|
||||||
} else if (result?.error) {
|
} else if (result?.error) {
|
||||||
throw new Error(result.error);
|
throw new Error(result.error);
|
||||||
@@ -493,7 +532,12 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
|||||||
if (!bridge?.onKeyboardInteractive) return;
|
if (!bridge?.onKeyboardInteractive) return;
|
||||||
|
|
||||||
const unsubscribe = bridge.onKeyboardInteractive((request) => {
|
const unsubscribe = bridge.onKeyboardInteractive((request) => {
|
||||||
if (!shouldQueueKeyboardInteractiveRequest(request, sessionsRef.current)) return;
|
if (!shouldQueueKeyboardInteractiveRequest(request, sessionsRef.current)) {
|
||||||
|
if (request.scope === "terminal" && request.requestId) {
|
||||||
|
void bridge.respondKeyboardInteractive?.(request.requestId, [], true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.log('[App] Keyboard-interactive request received:', request);
|
console.log('[App] Keyboard-interactive request received:', request);
|
||||||
// Add to queue instead of replacing - supports multiple concurrent sessions
|
// Add to queue instead of replacing - supports multiple concurrent sessions
|
||||||
setKeyboardInteractiveQueue(prev => [...prev, {
|
setKeyboardInteractiveQueue(prev => [...prev, {
|
||||||
@@ -511,10 +555,23 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
|||||||
const unsubscribeCancelled = bridge.onKeyboardInteractiveCancelled?.((event) => {
|
const unsubscribeCancelled = bridge.onKeyboardInteractiveCancelled?.((event) => {
|
||||||
setKeyboardInteractiveQueue(prev => removeKeyboardInteractiveRequest(prev, event.requestId));
|
setKeyboardInteractiveQueue(prev => removeKeyboardInteractiveRequest(prev, event.requestId));
|
||||||
});
|
});
|
||||||
|
const onTerminalDisconnected = (event: Event) => {
|
||||||
|
const sessionId = (event as CustomEvent<{ sessionId?: string }>).detail?.sessionId;
|
||||||
|
if (!sessionId) return;
|
||||||
|
setKeyboardInteractiveQueue((prev) => {
|
||||||
|
const doomed = prev.filter((request) => request.sessionId === sessionId);
|
||||||
|
for (const request of doomed) {
|
||||||
|
void bridge.respondKeyboardInteractive?.(request.requestId, [], true);
|
||||||
|
}
|
||||||
|
return prev.filter((request) => request.sessionId !== sessionId);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.addEventListener("netcatty:terminal-session-disconnected", onTerminalDisconnected);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe?.();
|
unsubscribe?.();
|
||||||
unsubscribeCancelled?.();
|
unsubscribeCancelled?.();
|
||||||
|
window.removeEventListener("netcatty:terminal-session-disconnected", onTerminalDisconnected);
|
||||||
};
|
};
|
||||||
}, [enabled, setKeyboardInteractiveQueue]);
|
}, [enabled, setKeyboardInteractiveQueue]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type React from 'react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useAppearanceChromeStore } from '../state/appearanceChromeStore';
|
||||||
|
import { useSettingsChromeStore } from '../state/settingsChromeStore';
|
||||||
|
import { buildAppThemeCssVars } from '../state/settingsStateDefaults';
|
||||||
|
import { getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App theme CSS variables for surfaces that need them (vault surface, plugin
|
||||||
|
* theme tokens). Reads accent from appearanceChromeStore and UI theme ids from
|
||||||
|
* settingsChromeStore so accent drags only re-render the leaf that applies the
|
||||||
|
* vars, never the App shell.
|
||||||
|
*/
|
||||||
|
export function useAppThemeStyle(): React.CSSProperties {
|
||||||
|
const { accentMode, customAccent } = useAppearanceChromeStore();
|
||||||
|
const { resolvedTheme, darkUiThemeId, lightUiThemeId } = useSettingsChromeStore();
|
||||||
|
return useMemo(() => {
|
||||||
|
const tokens = getUiThemeById(
|
||||||
|
resolvedTheme,
|
||||||
|
resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId,
|
||||||
|
).tokens;
|
||||||
|
return {
|
||||||
|
...buildAppThemeCssVars(tokens, accentMode, customAccent),
|
||||||
|
colorScheme: resolvedTheme,
|
||||||
|
} as React.CSSProperties;
|
||||||
|
}, [accentMode, customAccent, darkUiThemeId, lightUiThemeId, resolvedTheme]);
|
||||||
|
}
|
||||||
@@ -267,6 +267,32 @@ test('shared host tree uses the followed terminal theme when follow-app terminal
|
|||||||
assert.equal(resolved.id, currentTheme.id);
|
assert.equal(resolved.id, currentTheme.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('follow-app host tree applies custom accent onto the published base theme', () => {
|
||||||
|
const currentTheme = makeTheme('app-light', 'light', '#ffffff');
|
||||||
|
const host = {
|
||||||
|
id: 'host-1',
|
||||||
|
label: 'Host',
|
||||||
|
hostname: 'host.local',
|
||||||
|
username: 'root',
|
||||||
|
tags: [],
|
||||||
|
os: 'linux',
|
||||||
|
} as Host;
|
||||||
|
|
||||||
|
const resolved = resolveWorkTabHostTreeTheme({
|
||||||
|
activeHostId: host.id,
|
||||||
|
accentMode: 'custom',
|
||||||
|
currentTerminalTheme: currentTheme,
|
||||||
|
customAccent: '0 100% 50%',
|
||||||
|
followAppTerminalTheme: true,
|
||||||
|
hostById: new Map([[host.id, host]]),
|
||||||
|
themeById: new Map([[currentTheme.id, currentTheme]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(resolved.id, currentTheme.id);
|
||||||
|
assert.notEqual(resolved.colors.cursor, currentTheme.colors.cursor);
|
||||||
|
assert.notEqual(resolved, currentTheme);
|
||||||
|
});
|
||||||
|
|
||||||
test('shared host tree falls back to the current terminal theme without an active host', () => {
|
test('shared host tree falls back to the current terminal theme without an active host', () => {
|
||||||
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
|
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
|
||||||
|
|
||||||
|
|||||||
@@ -179,7 +179,9 @@ export function resolveWorkTabHostTreeTheme({
|
|||||||
hostById: ReadonlyMap<string, Host>;
|
hostById: ReadonlyMap<string, Host>;
|
||||||
themeById: ReadonlyMap<string, TerminalTheme>;
|
themeById: ReadonlyMap<string, TerminalTheme>;
|
||||||
}): TerminalTheme {
|
}): TerminalTheme {
|
||||||
if (!activeHostId || followAppTerminalTheme) return currentTerminalTheme;
|
if (!activeHostId || followAppTerminalTheme) {
|
||||||
|
return applyCustomAccentToTerminalTheme(currentTerminalTheme, accentMode, customAccent);
|
||||||
|
}
|
||||||
|
|
||||||
const host = hostById.get(activeHostId) ?? null;
|
const host = hostById.get(activeHostId) ?? null;
|
||||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||||
|
|||||||
@@ -61,6 +61,15 @@ export const enAiMessages: Messages = {
|
|||||||
'ai.providers.contextWindow.help': 'Leave blank to use the model list value when available, otherwise Netcatty uses a safe default.',
|
'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.contextWindow.error': 'Enter a positive whole number, or leave it blank.',
|
||||||
'ai.providers.refreshModels': 'Refresh models',
|
'ai.providers.refreshModels': 'Refresh models',
|
||||||
|
'ai.providers.test': 'Test',
|
||||||
|
'ai.providers.test.testing': 'Testing…',
|
||||||
|
'ai.providers.test.ok': 'Connected ({latency} ms)',
|
||||||
|
'ai.providers.test.warn': 'Reached endpoint, but response looks incomplete ({latency} ms)',
|
||||||
|
'ai.providers.test.warnSlow': 'Connected, but slow ({latency} ms)',
|
||||||
|
'ai.providers.test.error': 'Failed ({detail})',
|
||||||
|
'ai.providers.test.missingBaseUrl': 'Enter a Base URL first',
|
||||||
|
'ai.providers.test.missingApiKey': 'Enter an API key first',
|
||||||
|
'ai.providers.test.unavailable': 'Connection test is unavailable in this environment',
|
||||||
'ai.providers.searchModel': 'Search or type model ID...',
|
'ai.providers.searchModel': 'Search or type model ID...',
|
||||||
'ai.providers.filterModels': 'Filter models...',
|
'ai.providers.filterModels': 'Filter models...',
|
||||||
'ai.providers.loadingModels': 'Loading models...',
|
'ai.providers.loadingModels': 'Loading models...',
|
||||||
@@ -404,6 +413,14 @@ export const enAiMessages: Messages = {
|
|||||||
'ai.chat.reject': 'Reject',
|
'ai.chat.reject': 'Reject',
|
||||||
'ai.chat.toolLabel': 'Tool',
|
'ai.chat.toolLabel': 'Tool',
|
||||||
'ai.chat.targetLabel': 'Target',
|
'ai.chat.targetLabel': 'Target',
|
||||||
|
'ai.chat.rawCommand': 'Command',
|
||||||
|
'ai.chat.copyCommand': 'Copy',
|
||||||
|
'ai.chat.commandCopied': 'Copied',
|
||||||
|
'ai.chat.approvalSession': 'Session',
|
||||||
|
'ai.chat.approvalShell': 'Shell',
|
||||||
|
'ai.chat.approvalCwd': 'Cwd',
|
||||||
|
'ai.chat.approvalReason': 'Reason',
|
||||||
|
'ai.chat.approvalInvocation': 'Invocation',
|
||||||
'ai.chat.permissionRequired': 'Permission Required',
|
'ai.chat.permissionRequired': 'Permission Required',
|
||||||
'ai.chat.permissionDescription': 'The AI agent wants to execute a tool call that requires your approval.',
|
'ai.chat.permissionDescription': 'The AI agent wants to execute a tool call that requires your approval.',
|
||||||
'ai.chat.commandBlocked': 'This command is blocked by your security policy and cannot be executed.',
|
'ai.chat.commandBlocked': 'This command is blocked by your security policy and cannot be executed.',
|
||||||
@@ -443,6 +460,8 @@ export const enAiMessages: Messages = {
|
|||||||
'ai.chat.newChat': 'New Chat',
|
'ai.chat.newChat': 'New Chat',
|
||||||
'ai.chat.allSessions': 'All Sessions',
|
'ai.chat.allSessions': 'All Sessions',
|
||||||
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
|
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
|
||||||
|
'ai.chat.jumpNav': 'Jump to message',
|
||||||
|
'ai.chat.jumpUntitled': '(empty message)',
|
||||||
'ai.chat.usedTools': 'Tools used: {n}',
|
'ai.chat.usedTools': 'Tools used: {n}',
|
||||||
'ai.chat.loadMoreSessions': 'Load more sessions ({n} more)',
|
'ai.chat.loadMoreSessions': 'Load more sessions ({n} more)',
|
||||||
'ai.chat.noSessions': 'No previous sessions',
|
'ai.chat.noSessions': 'No previous sessions',
|
||||||
|
|||||||
@@ -89,8 +89,12 @@ export const enCoreMessages: Messages = {
|
|||||||
'settings.tab.appearance': 'Appearance',
|
'settings.tab.appearance': 'Appearance',
|
||||||
'settings.tab.terminal': 'Terminal',
|
'settings.tab.terminal': 'Terminal',
|
||||||
'settings.tab.shortcuts': 'Shortcuts',
|
'settings.tab.shortcuts': 'Shortcuts',
|
||||||
|
'settings.tab.ai': 'AI',
|
||||||
'settings.tab.syncCloud': 'Sync & Cloud',
|
'settings.tab.syncCloud': 'Sync & Cloud',
|
||||||
'settings.tab.system': 'System',
|
'settings.tab.system': 'System',
|
||||||
|
'settings.search.open': 'Search settings',
|
||||||
|
'settings.search.placeholder': 'Search settings…',
|
||||||
|
'settings.search.noResults': 'No matching settings',
|
||||||
|
|
||||||
// Settings > System
|
// Settings > System
|
||||||
'settings.system.title': 'System',
|
'settings.system.title': 'System',
|
||||||
@@ -197,6 +201,9 @@ export const enCoreMessages: Messages = {
|
|||||||
'settings.sessionLogs.timestamps': 'Add timestamps',
|
'settings.sessionLogs.timestamps': 'Add timestamps',
|
||||||
'settings.sessionLogs.timestampsDesc': 'Prefix each line in plain text and HTML logs with the local time.',
|
'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.sessionLogs.hint': 'Session logs capture all terminal output for troubleshooting and auditing purposes.',
|
||||||
|
'settings.sessionLogs.clearAll': 'Clear all logs',
|
||||||
|
'settings.sessionLogs.clearAllDesc': 'Delete all saved session log files and host subdirectories from the save directory.',
|
||||||
|
'settings.sessionLogs.clearConfirm': 'This will permanently delete all session log files in the save directory. This cannot be undone. Continue?',
|
||||||
|
|
||||||
// Settings > SSH Debug Logs
|
// Settings > SSH Debug Logs
|
||||||
'settings.sshDebugLogs.title': 'SSH Debug Logs',
|
'settings.sshDebugLogs.title': 'SSH Debug Logs',
|
||||||
@@ -231,6 +238,7 @@ export const enCoreMessages: Messages = {
|
|||||||
'tray.status.active': 'Active',
|
'tray.status.active': 'Active',
|
||||||
'tray.status.inactive': 'Inactive',
|
'tray.status.inactive': 'Inactive',
|
||||||
'tray.status.error': 'Error',
|
'tray.status.error': 'Error',
|
||||||
|
'tray.status.unknown': 'Unknown',
|
||||||
'tray.recentHosts': 'Recent Hosts',
|
'tray.recentHosts': 'Recent Hosts',
|
||||||
'tray.empty.title': 'Nothing here yet',
|
'tray.empty.title': 'Nothing here yet',
|
||||||
'tray.empty.subtitle': 'Go connect to a server, they miss you 🚀',
|
'tray.empty.subtitle': 'Go connect to a server, they miss you 🚀',
|
||||||
@@ -781,6 +789,8 @@ Highlight the focused split pane:
|
|||||||
'settings.shortcuts.disableTerminalFontZoom.desc': 'Turn off terminal font zoom shortcuts, including Cmd/Ctrl + mouse wheel.',
|
'settings.shortcuts.disableTerminalFontZoom.desc': 'Turn off terminal font zoom shortcuts, including Cmd/Ctrl + mouse wheel.',
|
||||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Tab shortcuts skip pinned tabs',
|
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Tab shortcuts skip pinned tabs',
|
||||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'When enabled, Cmd/Ctrl+[1...9] and Ctrl+Tab switch only work tabs (terminals, workspaces, editors), not the pinned Vault or SFTP tabs.',
|
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'When enabled, Cmd/Ctrl+[1...9] and Ctrl+Tab switch only work tabs (terminals, workspaces, editors), not the pinned Vault or SFTP tabs.',
|
||||||
|
'settings.shortcuts.showTabNumberBadges.label': 'Show tab number badges',
|
||||||
|
'settings.shortcuts.showTabNumberBadges.desc': 'While holding Cmd/Ctrl, show 1-9 on tabs that match Cmd/Ctrl+[1...9], so you can jump accurately.',
|
||||||
'settings.shortcuts.section.custom': 'Custom Shortcuts',
|
'settings.shortcuts.section.custom': 'Custom Shortcuts',
|
||||||
'settings.shortcuts.resetAll': 'Reset All',
|
'settings.shortcuts.resetAll': 'Reset All',
|
||||||
'settings.shortcuts.recording': 'Press keys...',
|
'settings.shortcuts.recording': 'Press keys...',
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ export const enSystemManagerMessages: Messages = {
|
|||||||
'systemManager.empty': 'No data available.',
|
'systemManager.empty': 'No data available.',
|
||||||
'systemManager.tabs.overview': 'Overview',
|
'systemManager.tabs.overview': 'Overview',
|
||||||
'systemManager.tabs.processes': 'Processes',
|
'systemManager.tabs.processes': 'Processes',
|
||||||
|
'systemManager.tabs.ports': 'Ports',
|
||||||
|
'systemManager.tabs.services': 'Services',
|
||||||
'systemManager.tabs.tmux': 'tmux',
|
'systemManager.tabs.tmux': 'tmux',
|
||||||
'systemManager.tabs.docker': 'Docker',
|
'systemManager.tabs.docker': 'Docker',
|
||||||
|
'systemManager.tabs.gpu': 'GPU',
|
||||||
|
'systemManager.tabs.ariaLabel': 'System manager sections',
|
||||||
'systemManager.popup.loading': 'Opening terminal…',
|
'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.popup.startupFailed': 'The startup command did not complete successfully. Check that the target is still available and try again.',
|
||||||
|
|
||||||
@@ -23,6 +27,9 @@ export const enSystemManagerMessages: Messages = {
|
|||||||
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
|
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
|
||||||
'systemManager.errors.loadDockerImages': 'Failed to load images',
|
'systemManager.errors.loadDockerImages': 'Failed to load images',
|
||||||
'systemManager.errors.loadOverview': 'Failed to load system overview',
|
'systemManager.errors.loadOverview': 'Failed to load system overview',
|
||||||
|
'systemManager.errors.loadGpu': 'Failed to load GPU / NPU stats',
|
||||||
|
'systemManager.errors.loadPorts': 'Failed to load listening ports',
|
||||||
|
'systemManager.errors.loadServices': 'Failed to load systemd services',
|
||||||
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
|
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
|
||||||
|
|
||||||
'systemManager.overview.empty': 'No system overview data yet.',
|
'systemManager.overview.empty': 'No system overview data yet.',
|
||||||
@@ -187,6 +194,51 @@ export const enSystemManagerMessages: Messages = {
|
|||||||
'systemManager.docker.start': 'Start',
|
'systemManager.docker.start': 'Start',
|
||||||
'systemManager.docker.stop': 'Stop',
|
'systemManager.docker.stop': 'Stop',
|
||||||
|
|
||||||
|
'systemManager.ports.unavailable': 'No listening-port tools (ss / netstat) detected on this host.',
|
||||||
|
'systemManager.ports.loading': 'Loading listening ports…',
|
||||||
|
'systemManager.ports.empty': 'No listening ports reported.',
|
||||||
|
'systemManager.ports.search': 'Search ports…',
|
||||||
|
'systemManager.ports.meta': '{{count}} listener(s)',
|
||||||
|
'systemManager.ports.filter.all': 'All',
|
||||||
|
'systemManager.ports.unknownProcess': 'Unknown process',
|
||||||
|
'systemManager.ports.terminate': 'Terminate',
|
||||||
|
'systemManager.ports.confirmTerminate': 'Send SIGTERM to process {{pid}} that holds this port?',
|
||||||
|
|
||||||
|
'systemManager.services.unavailable': 'systemctl is not available on this host.',
|
||||||
|
'systemManager.services.loading': 'Loading systemd services…',
|
||||||
|
'systemManager.services.empty': 'No systemd services found.',
|
||||||
|
'systemManager.services.search': 'Search services…',
|
||||||
|
'systemManager.services.meta': '{{count}} service(s)',
|
||||||
|
'systemManager.services.filter.all': 'All',
|
||||||
|
'systemManager.services.filter.running': 'Running',
|
||||||
|
'systemManager.services.filter.failed': 'Failed',
|
||||||
|
'systemManager.services.filter.inactive': 'Inactive',
|
||||||
|
'systemManager.services.start': 'Start',
|
||||||
|
'systemManager.services.stop': 'Stop',
|
||||||
|
'systemManager.services.restart': 'Restart',
|
||||||
|
'systemManager.services.enable': 'Enable',
|
||||||
|
'systemManager.services.disable': 'Disable',
|
||||||
|
'systemManager.services.reload': 'Reload',
|
||||||
|
'systemManager.services.scope.user': 'user',
|
||||||
|
'systemManager.services.confirmAction': '{{action}} {{name}}?',
|
||||||
|
|
||||||
|
'systemManager.gpu.unavailable': 'No NVIDIA GPU or Ascend NPU tools detected on this host.',
|
||||||
|
'systemManager.gpu.loading': 'Loading accelerator stats…',
|
||||||
|
'systemManager.gpu.empty': 'Accelerator tools are present, but no devices were reported.',
|
||||||
|
'systemManager.gpu.meta': '{{devices}} device(s) · {{processes}} process(es)',
|
||||||
|
'systemManager.gpu.devices': 'Devices',
|
||||||
|
'systemManager.gpu.processes': 'Compute processes',
|
||||||
|
'systemManager.gpu.noProcesses': 'No compute processes reported.',
|
||||||
|
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
|
||||||
|
'systemManager.gpu.vendor.ascend': 'Ascend',
|
||||||
|
'systemManager.gpu.util': 'Util',
|
||||||
|
'systemManager.gpu.memory': 'VRAM',
|
||||||
|
'systemManager.gpu.hbm': 'HBM',
|
||||||
|
'systemManager.gpu.temperature': 'Temperature',
|
||||||
|
'systemManager.gpu.power': 'Power',
|
||||||
|
'systemManager.gpu.fan': 'Fan {{value}}%',
|
||||||
|
'systemManager.gpu.driver': 'Driver {{version}}',
|
||||||
|
|
||||||
'systemManager.inspect.status': 'Status',
|
'systemManager.inspect.status': 'Status',
|
||||||
'systemManager.inspect.image': 'Image',
|
'systemManager.inspect.image': 'Image',
|
||||||
'systemManager.inspect.created': 'Created',
|
'systemManager.inspect.created': 'Created',
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ export const enTerminalMessages: Messages = {
|
|||||||
'terminal.statusbar.copyHostname.tooltip': 'Copy host address ({hostname})',
|
'terminal.statusbar.copyHostname.tooltip': 'Copy host address ({hostname})',
|
||||||
'terminal.statusbar.copyHostname.toast': 'Copied host address: {hostname}',
|
'terminal.statusbar.copyHostname.toast': 'Copied host address: {hostname}',
|
||||||
'terminal.statusbar.copyHostname.error': 'Failed to copy host address to clipboard',
|
'terminal.statusbar.copyHostname.error': 'Failed to copy host address to clipboard',
|
||||||
|
'terminal.statusbar.disconnect.label': 'Disconnect',
|
||||||
|
'terminal.statusbar.disconnect.tooltip': 'Disconnect this session without closing the tab',
|
||||||
|
'terminal.statusbar.reconnect.label': 'Reconnect',
|
||||||
|
'terminal.statusbar.reconnect.tooltip': 'Reconnect this session',
|
||||||
'terminal.serverStats.cpu': 'CPU Usage',
|
'terminal.serverStats.cpu': 'CPU Usage',
|
||||||
'terminal.serverStats.cpuCores': 'CPU Core Usage',
|
'terminal.serverStats.cpuCores': 'CPU Core Usage',
|
||||||
'terminal.serverStats.memory': 'Memory Usage',
|
'terminal.serverStats.memory': 'Memory Usage',
|
||||||
|
|||||||
@@ -268,6 +268,8 @@ export const enVaultMessages: Messages = {
|
|||||||
'sftp.transfers.calculatingTotal': 'Calculating total size...',
|
'sftp.transfers.calculatingTotal': 'Calculating total size...',
|
||||||
'sftp.transfers.filesCount': '{count} files',
|
'sftp.transfers.filesCount': '{count} files',
|
||||||
'sftp.transfers.filesProgress': '{current}/{total} files',
|
'sftp.transfers.filesProgress': '{current}/{total} files',
|
||||||
|
// Progressive folder walk: total is "found so far", not a fixed grand total.
|
||||||
|
'sftp.transfers.filesDiscoveredProgress': '{completed} done · {discovered} found',
|
||||||
'sftp.transfers.expandChildren': 'Show files',
|
'sftp.transfers.expandChildren': 'Show files',
|
||||||
'sftp.transfers.collapseChildren': 'Hide files',
|
'sftp.transfers.collapseChildren': 'Hide files',
|
||||||
'sftp.transfers.expandChildList': 'Show detail',
|
'sftp.transfers.expandChildList': 'Show detail',
|
||||||
@@ -323,6 +325,7 @@ export const enVaultMessages: Messages = {
|
|||||||
'sftp.transfers.dragToResize': 'Drag to resize',
|
'sftp.transfers.dragToResize': 'Drag to resize',
|
||||||
'sftp.goUp': 'Go up',
|
'sftp.goUp': 'Go up',
|
||||||
'sftp.goToTerminalCwd': 'Go to terminal directory',
|
'sftp.goToTerminalCwd': 'Go to terminal directory',
|
||||||
|
'sftp.locatePathInTerminal': 'Open path in terminal',
|
||||||
'sftp.followTerminalCwd': 'Follow terminal directory',
|
'sftp.followTerminalCwd': 'Follow terminal directory',
|
||||||
'sftp.followTerminalCwd.enable': 'Enable follow terminal directory',
|
'sftp.followTerminalCwd.enable': 'Enable follow terminal directory',
|
||||||
'sftp.followTerminalCwd.disable': 'Disable follow terminal directory',
|
'sftp.followTerminalCwd.disable': 'Disable follow terminal directory',
|
||||||
@@ -560,6 +563,9 @@ export const enVaultMessages: Messages = {
|
|||||||
'settings.sftp.compressedUpload.enable': 'Enable folder compression',
|
'settings.sftp.compressedUpload.enable': 'Enable folder compression',
|
||||||
'settings.sftp.compressedUpload.enableDesc': 'Automatically compress folders using tar before transfer. Requires tar support on the server. Falls back to regular transfer if not available.',
|
'settings.sftp.compressedUpload.enableDesc': 'Automatically compress folders using tar before transfer. Requires tar support on the server. Falls back to regular transfer if not available.',
|
||||||
|
|
||||||
|
'settings.sftp.skipUnchanged.enable': 'Skip unchanged files',
|
||||||
|
'settings.sftp.skipUnchanged.enableDesc': 'Skip files whose size and modification time already match the destination (rsync-style). Disabled when replacing a folder into a staging path.',
|
||||||
|
|
||||||
// Quick Switcher
|
// Quick Switcher
|
||||||
'qs.search.placeholder': 'Search hosts or tabs',
|
'qs.search.placeholder': 'Search hosts or tabs',
|
||||||
'qs.jumpTo': 'Jump To',
|
'qs.jumpTo': 'Jump To',
|
||||||
|
|||||||
@@ -42,6 +42,15 @@ export const ruAiMessages: Messages = {
|
|||||||
'ai.providers.contextWindow.help': 'Оставьте пустым, чтобы использовать значение из списка моделей, если оно доступно; иначе Netcatty применит безопасное значение по умолчанию.',
|
'ai.providers.contextWindow.help': 'Оставьте пустым, чтобы использовать значение из списка моделей, если оно доступно; иначе Netcatty применит безопасное значение по умолчанию.',
|
||||||
'ai.providers.contextWindow.error': 'Введите положительное целое число или оставьте поле пустым.',
|
'ai.providers.contextWindow.error': 'Введите положительное целое число или оставьте поле пустым.',
|
||||||
'ai.providers.refreshModels': 'Обновить модели',
|
'ai.providers.refreshModels': 'Обновить модели',
|
||||||
|
'ai.providers.test': 'Проверить',
|
||||||
|
'ai.providers.test.testing': 'Проверка…',
|
||||||
|
'ai.providers.test.ok': 'Подключено ({latency} ms)',
|
||||||
|
'ai.providers.test.warn': 'Эндпоинт доступен, но ответ выглядит неполным ({latency} ms)',
|
||||||
|
'ai.providers.test.warnSlow': 'Подключено, но медленно ({latency} ms)',
|
||||||
|
'ai.providers.test.error': 'Ошибка ({detail})',
|
||||||
|
'ai.providers.test.missingBaseUrl': 'Сначала укажите Base URL',
|
||||||
|
'ai.providers.test.missingApiKey': 'Сначала укажите API-ключ',
|
||||||
|
'ai.providers.test.unavailable': 'Проверка соединения недоступна в этой среде',
|
||||||
'ai.providers.searchModel': 'Искать или ввести ID модели...',
|
'ai.providers.searchModel': 'Искать или ввести ID модели...',
|
||||||
'ai.providers.filterModels': 'Фильтровать модели...',
|
'ai.providers.filterModels': 'Фильтровать модели...',
|
||||||
'ai.providers.loadingModels': 'Загрузка моделей...',
|
'ai.providers.loadingModels': 'Загрузка моделей...',
|
||||||
@@ -356,6 +365,14 @@ export const ruAiMessages: Messages = {
|
|||||||
'ai.chat.reject': 'Отклонить',
|
'ai.chat.reject': 'Отклонить',
|
||||||
'ai.chat.toolLabel': 'Инструмент',
|
'ai.chat.toolLabel': 'Инструмент',
|
||||||
'ai.chat.targetLabel': 'Цель',
|
'ai.chat.targetLabel': 'Цель',
|
||||||
|
'ai.chat.rawCommand': 'Команда',
|
||||||
|
'ai.chat.copyCommand': 'Копировать',
|
||||||
|
'ai.chat.commandCopied': 'Скопировано',
|
||||||
|
'ai.chat.approvalSession': 'Сессия',
|
||||||
|
'ai.chat.approvalShell': 'Shell',
|
||||||
|
'ai.chat.approvalCwd': 'Каталог',
|
||||||
|
'ai.chat.approvalReason': 'Причина',
|
||||||
|
'ai.chat.approvalInvocation': 'Вызов',
|
||||||
'ai.chat.permissionRequired': 'Требуется разрешение',
|
'ai.chat.permissionRequired': 'Требуется разрешение',
|
||||||
'ai.chat.permissionDescription': 'AI-агент хочет выполнить вызов инструмента, для которого требуется ваше одобрение.',
|
'ai.chat.permissionDescription': 'AI-агент хочет выполнить вызов инструмента, для которого требуется ваше одобрение.',
|
||||||
'ai.chat.commandBlocked': 'Эта команда заблокирована вашей политикой безопасности и не может быть выполнена.',
|
'ai.chat.commandBlocked': 'Эта команда заблокирована вашей политикой безопасности и не может быть выполнена.',
|
||||||
@@ -395,6 +412,8 @@ export const ruAiMessages: Messages = {
|
|||||||
'ai.chat.newChat': 'Новый чат',
|
'ai.chat.newChat': 'Новый чат',
|
||||||
'ai.chat.allSessions': 'Все сессии',
|
'ai.chat.allSessions': 'Все сессии',
|
||||||
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
|
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
|
||||||
|
'ai.chat.jumpNav': 'Перейти к сообщению',
|
||||||
|
'ai.chat.jumpUntitled': '(пустое сообщение)',
|
||||||
'ai.chat.usedTools': 'Использовано инструментов: {n}',
|
'ai.chat.usedTools': 'Использовано инструментов: {n}',
|
||||||
'ai.chat.loadMoreSessions': 'Загрузить больше сессий (ещё {n})',
|
'ai.chat.loadMoreSessions': 'Загрузить больше сессий (ещё {n})',
|
||||||
'ai.chat.noSessions': 'Предыдущих сессий нет',
|
'ai.chat.noSessions': 'Предыдущих сессий нет',
|
||||||
|
|||||||
@@ -88,8 +88,12 @@ export const ruCoreMessages: Messages = {
|
|||||||
'settings.tab.appearance': 'Внешний вид',
|
'settings.tab.appearance': 'Внешний вид',
|
||||||
'settings.tab.terminal': 'Терминал',
|
'settings.tab.terminal': 'Терминал',
|
||||||
'settings.tab.shortcuts': 'Горячие клавиши',
|
'settings.tab.shortcuts': 'Горячие клавиши',
|
||||||
|
'settings.tab.ai': 'AI',
|
||||||
'settings.tab.syncCloud': 'Синхронизация и облако',
|
'settings.tab.syncCloud': 'Синхронизация и облако',
|
||||||
'settings.tab.system': 'Система',
|
'settings.tab.system': 'Система',
|
||||||
|
'settings.search.open': 'Поиск настроек',
|
||||||
|
'settings.search.placeholder': 'Искать настройки…',
|
||||||
|
'settings.search.noResults': 'Совпадений не найдено',
|
||||||
|
|
||||||
// Settings > System
|
// Settings > System
|
||||||
'settings.system.title': 'Система',
|
'settings.system.title': 'Система',
|
||||||
@@ -185,6 +189,9 @@ export const ruCoreMessages: Messages = {
|
|||||||
'settings.sessionLogs.timestamps': 'Добавлять метки времени',
|
'settings.sessionLogs.timestamps': 'Добавлять метки времени',
|
||||||
'settings.sessionLogs.timestampsDesc': 'Добавлять локальное время в начало каждой строки в текстовых и HTML-журналах.',
|
'settings.sessionLogs.timestampsDesc': 'Добавлять локальное время в начало каждой строки в текстовых и HTML-журналах.',
|
||||||
'settings.sessionLogs.hint': 'Журналы сессий сохраняют весь вывод терминала для диагностики и аудита.',
|
'settings.sessionLogs.hint': 'Журналы сессий сохраняют весь вывод терминала для диагностики и аудита.',
|
||||||
|
'settings.sessionLogs.clearAll': 'Очистить все журналы',
|
||||||
|
'settings.sessionLogs.clearAllDesc': 'Удалить все сохранённые файлы журналов сессий и вложенные папки хостов из папки сохранения.',
|
||||||
|
'settings.sessionLogs.clearConfirm': 'Это приведёт к безвозвратному удалению всех файлов журналов сессий в папке сохранения. Это действие нельзя отменить. Продолжить?',
|
||||||
|
|
||||||
// Settings > SSH Debug Logs
|
// Settings > SSH Debug Logs
|
||||||
'settings.sshDebugLogs.title': 'Отладочные журналы SSH',
|
'settings.sshDebugLogs.title': 'Отладочные журналы SSH',
|
||||||
@@ -219,6 +226,7 @@ export const ruCoreMessages: Messages = {
|
|||||||
'tray.status.active': 'Активно',
|
'tray.status.active': 'Активно',
|
||||||
'tray.status.inactive': 'Неактивно',
|
'tray.status.inactive': 'Неактивно',
|
||||||
'tray.status.error': 'Ошибка',
|
'tray.status.error': 'Ошибка',
|
||||||
|
'tray.status.unknown': 'Неизвестно',
|
||||||
'tray.recentHosts': 'Недавние хосты',
|
'tray.recentHosts': 'Недавние хосты',
|
||||||
'tray.empty.title': 'Пока здесь ничего нет',
|
'tray.empty.title': 'Пока здесь ничего нет',
|
||||||
'tray.empty.subtitle': 'Подключитесь к серверу, они по вам скучают 🚀',
|
'tray.empty.subtitle': 'Подключитесь к серверу, они по вам скучают 🚀',
|
||||||
@@ -726,6 +734,8 @@ export const ruCoreMessages: Messages = {
|
|||||||
'settings.shortcuts.disableTerminalFontZoom.desc': 'Отключает быстрый масштаб текста в терминале, включая Cmd/Ctrl + колесо мыши.',
|
'settings.shortcuts.disableTerminalFontZoom.desc': 'Отключает быстрый масштаб текста в терминале, включая Cmd/Ctrl + колесо мыши.',
|
||||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Сочетания без закреплённых вкладок',
|
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Сочетания без закреплённых вкладок',
|
||||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'Если включено, Cmd/Ctrl+[1...9] и Ctrl+Tab переключают только рабочие вкладки (терминалы, рабочие области, редакторы), а не закреплённые Vault и SFTP.',
|
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'Если включено, Cmd/Ctrl+[1...9] и Ctrl+Tab переключают только рабочие вкладки (терминалы, рабочие области, редакторы), а не закреплённые Vault и SFTP.',
|
||||||
|
'settings.shortcuts.showTabNumberBadges.label': 'Номера на вкладках',
|
||||||
|
'settings.shortcuts.showTabNumberBadges.desc': 'Показывать 1-9 на вкладках при удержании Cmd/Ctrl, чтобы точнее переключаться через Cmd/Ctrl+[1...9].',
|
||||||
'settings.shortcuts.section.custom': 'Пользовательские сочетания',
|
'settings.shortcuts.section.custom': 'Пользовательские сочетания',
|
||||||
'settings.shortcuts.resetAll': 'Сбросить все',
|
'settings.shortcuts.resetAll': 'Сбросить все',
|
||||||
'settings.shortcuts.recording': 'Нажмите клавиши...',
|
'settings.shortcuts.recording': 'Нажмите клавиши...',
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ export const ruSystemManagerMessages: Messages = {
|
|||||||
'systemManager.empty': 'Нет данных.',
|
'systemManager.empty': 'Нет данных.',
|
||||||
'systemManager.tabs.overview': 'Обзор',
|
'systemManager.tabs.overview': 'Обзор',
|
||||||
'systemManager.tabs.processes': 'Процессы',
|
'systemManager.tabs.processes': 'Процессы',
|
||||||
|
'systemManager.tabs.ports': 'Порты',
|
||||||
|
'systemManager.tabs.services': 'Службы',
|
||||||
'systemManager.tabs.tmux': 'tmux',
|
'systemManager.tabs.tmux': 'tmux',
|
||||||
'systemManager.tabs.docker': 'Docker',
|
'systemManager.tabs.docker': 'Docker',
|
||||||
|
'systemManager.tabs.gpu': 'GPU',
|
||||||
|
'systemManager.tabs.ariaLabel': 'Разделы системного менеджера',
|
||||||
'systemManager.popup.loading': 'Открытие терминала…',
|
'systemManager.popup.loading': 'Открытие терминала…',
|
||||||
'systemManager.popup.startupFailed': 'Команда запуска не была выполнена успешно. Проверьте, что цель доступна, и повторите попытку.',
|
'systemManager.popup.startupFailed': 'Команда запуска не была выполнена успешно. Проверьте, что цель доступна, и повторите попытку.',
|
||||||
|
|
||||||
@@ -23,6 +27,9 @@ export const ruSystemManagerMessages: Messages = {
|
|||||||
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
|
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
|
||||||
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
|
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
|
||||||
'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы',
|
'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы',
|
||||||
|
'systemManager.errors.loadGpu': 'Не удалось загрузить статистику GPU / NPU',
|
||||||
|
'systemManager.errors.loadPorts': 'Не удалось загрузить слушающие порты',
|
||||||
|
'systemManager.errors.loadServices': 'Не удалось загрузить службы systemd',
|
||||||
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
|
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
|
||||||
|
|
||||||
'systemManager.overview.empty': 'Данных обзора пока нет.',
|
'systemManager.overview.empty': 'Данных обзора пока нет.',
|
||||||
@@ -187,6 +194,51 @@ export const ruSystemManagerMessages: Messages = {
|
|||||||
'systemManager.docker.start': 'Запустить',
|
'systemManager.docker.start': 'Запустить',
|
||||||
'systemManager.docker.stop': 'Остановить',
|
'systemManager.docker.stop': 'Остановить',
|
||||||
|
|
||||||
|
'systemManager.ports.unavailable': 'На этом хосте не обнаружены инструменты портов (ss / netstat).',
|
||||||
|
'systemManager.ports.loading': 'Загрузка слушающих портов…',
|
||||||
|
'systemManager.ports.empty': 'Слушающие порты не найдены.',
|
||||||
|
'systemManager.ports.search': 'Поиск портов…',
|
||||||
|
'systemManager.ports.meta': '{{count}} слушающих',
|
||||||
|
'systemManager.ports.filter.all': 'Все',
|
||||||
|
'systemManager.ports.unknownProcess': 'Неизвестный процесс',
|
||||||
|
'systemManager.ports.terminate': 'Завершить',
|
||||||
|
'systemManager.ports.confirmTerminate': 'Отправить SIGTERM процессу {{pid}}, занимающему этот порт?',
|
||||||
|
|
||||||
|
'systemManager.services.unavailable': 'systemctl недоступен на этом хосте.',
|
||||||
|
'systemManager.services.loading': 'Загрузка служб systemd…',
|
||||||
|
'systemManager.services.empty': 'Службы systemd не найдены.',
|
||||||
|
'systemManager.services.search': 'Поиск служб…',
|
||||||
|
'systemManager.services.meta': '{{count}} служб',
|
||||||
|
'systemManager.services.filter.all': 'Все',
|
||||||
|
'systemManager.services.filter.running': 'Запущены',
|
||||||
|
'systemManager.services.filter.failed': 'С ошибкой',
|
||||||
|
'systemManager.services.filter.inactive': 'Неактивны',
|
||||||
|
'systemManager.services.start': 'Запустить',
|
||||||
|
'systemManager.services.stop': 'Остановить',
|
||||||
|
'systemManager.services.restart': 'Перезапустить',
|
||||||
|
'systemManager.services.enable': 'Включить автозапуск',
|
||||||
|
'systemManager.services.disable': 'Отключить автозапуск',
|
||||||
|
'systemManager.services.reload': 'Перезагрузить',
|
||||||
|
'systemManager.services.scope.user': 'user',
|
||||||
|
'systemManager.services.confirmAction': '{{action}} {{name}}?',
|
||||||
|
|
||||||
|
'systemManager.gpu.unavailable': 'На этом хосте не обнаружены инструменты NVIDIA GPU или Ascend NPU.',
|
||||||
|
'systemManager.gpu.loading': 'Загрузка статистики ускорителей…',
|
||||||
|
'systemManager.gpu.empty': 'Инструменты ускорителей найдены, но устройства не сообщены.',
|
||||||
|
'systemManager.gpu.meta': '{{devices}} устройств · {{processes}} процессов',
|
||||||
|
'systemManager.gpu.devices': 'Устройства',
|
||||||
|
'systemManager.gpu.processes': 'Вычислительные процессы',
|
||||||
|
'systemManager.gpu.noProcesses': 'Вычислительные процессы не сообщены.',
|
||||||
|
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
|
||||||
|
'systemManager.gpu.vendor.ascend': 'Ascend',
|
||||||
|
'systemManager.gpu.util': 'Загрузка',
|
||||||
|
'systemManager.gpu.memory': 'VRAM',
|
||||||
|
'systemManager.gpu.hbm': 'HBM',
|
||||||
|
'systemManager.gpu.temperature': 'Температура',
|
||||||
|
'systemManager.gpu.power': 'Питание',
|
||||||
|
'systemManager.gpu.fan': 'Вентилятор {{value}}%',
|
||||||
|
'systemManager.gpu.driver': 'Драйвер {{version}}',
|
||||||
|
|
||||||
'systemManager.inspect.status': 'Статус',
|
'systemManager.inspect.status': 'Статус',
|
||||||
'systemManager.inspect.image': 'Образ',
|
'systemManager.inspect.image': 'Образ',
|
||||||
'systemManager.inspect.created': 'Создан',
|
'systemManager.inspect.created': 'Создан',
|
||||||
|
|||||||
@@ -103,6 +103,10 @@ export const ruTerminalMessages: Messages = {
|
|||||||
'terminal.statusbar.copyHostname.tooltip': 'Копировать адрес хоста ({hostname})',
|
'terminal.statusbar.copyHostname.tooltip': 'Копировать адрес хоста ({hostname})',
|
||||||
'terminal.statusbar.copyHostname.toast': 'Адрес хоста скопирован: {hostname}',
|
'terminal.statusbar.copyHostname.toast': 'Адрес хоста скопирован: {hostname}',
|
||||||
'terminal.statusbar.copyHostname.error': 'Не удалось скопировать адрес хоста в буфер обмена',
|
'terminal.statusbar.copyHostname.error': 'Не удалось скопировать адрес хоста в буфер обмена',
|
||||||
|
'terminal.statusbar.disconnect.label': 'Отключить',
|
||||||
|
'terminal.statusbar.disconnect.tooltip': 'Отключить сессию, не закрывая вкладку',
|
||||||
|
'terminal.statusbar.reconnect.label': 'Переподключиться',
|
||||||
|
'terminal.statusbar.reconnect.tooltip': 'Переподключить эту сессию',
|
||||||
'terminal.serverStats.cpu': 'Использование CPU',
|
'terminal.serverStats.cpu': 'Использование CPU',
|
||||||
'terminal.serverStats.cpuCores': 'Использование ядер CPU',
|
'terminal.serverStats.cpuCores': 'Использование ядер CPU',
|
||||||
'terminal.serverStats.memory': 'Использование памяти',
|
'terminal.serverStats.memory': 'Использование памяти',
|
||||||
|
|||||||
@@ -305,6 +305,7 @@ export const ruVaultMessages: Messages = {
|
|||||||
'sftp.transfers.calculatingTotal': 'Вычисление общего размера...',
|
'sftp.transfers.calculatingTotal': 'Вычисление общего размера...',
|
||||||
'sftp.transfers.filesCount': '{count} файл(ов)',
|
'sftp.transfers.filesCount': '{count} файл(ов)',
|
||||||
'sftp.transfers.filesProgress': '{current}/{total} файл(ов)',
|
'sftp.transfers.filesProgress': '{current}/{total} файл(ов)',
|
||||||
|
'sftp.transfers.filesDiscoveredProgress': '{completed} готово · {discovered} найдено',
|
||||||
'sftp.transfers.expandChildren': 'Показать файлы',
|
'sftp.transfers.expandChildren': 'Показать файлы',
|
||||||
'sftp.transfers.collapseChildren': 'Скрыть файлы',
|
'sftp.transfers.collapseChildren': 'Скрыть файлы',
|
||||||
'sftp.transfers.expandChildList': 'Показать детали',
|
'sftp.transfers.expandChildList': 'Показать детали',
|
||||||
@@ -360,6 +361,7 @@ export const ruVaultMessages: Messages = {
|
|||||||
'sftp.transfers.dragToResize': 'Перетащите для изменения размера',
|
'sftp.transfers.dragToResize': 'Перетащите для изменения размера',
|
||||||
'sftp.goUp': 'Наверх',
|
'sftp.goUp': 'Наверх',
|
||||||
'sftp.goToTerminalCwd': 'Перейти в каталог терминала',
|
'sftp.goToTerminalCwd': 'Перейти в каталог терминала',
|
||||||
|
'sftp.locatePathInTerminal': 'Открыть путь в терминале',
|
||||||
'sftp.followTerminalCwd': 'Следовать за каталогом терминала',
|
'sftp.followTerminalCwd': 'Следовать за каталогом терминала',
|
||||||
'sftp.followTerminalCwd.enable': 'Включить следование за каталогом терминала',
|
'sftp.followTerminalCwd.enable': 'Включить следование за каталогом терминала',
|
||||||
'sftp.followTerminalCwd.disable': 'Отключить следование за каталогом терминала',
|
'sftp.followTerminalCwd.disable': 'Отключить следование за каталогом терминала',
|
||||||
@@ -597,6 +599,9 @@ export const ruVaultMessages: Messages = {
|
|||||||
'settings.sftp.compressedUpload.enable': 'Включить сжатие папок',
|
'settings.sftp.compressedUpload.enable': 'Включить сжатие папок',
|
||||||
'settings.sftp.compressedUpload.enableDesc': 'Автоматически сжимать папки с помощью tar перед передачей. Требует поддержки tar на сервере. Если она недоступна, будет использована обычная передача.',
|
'settings.sftp.compressedUpload.enableDesc': 'Автоматически сжимать папки с помощью tar перед передачей. Требует поддержки tar на сервере. Если она недоступна, будет использована обычная передача.',
|
||||||
|
|
||||||
|
'settings.sftp.skipUnchanged.enable': 'Пропускать неизменённые файлы',
|
||||||
|
'settings.sftp.skipUnchanged.enableDesc': 'Пропускать файлы, у которых размер и время изменения уже совпадают с назначением (в стиле rsync). Отключается при замене папки во временный путь.',
|
||||||
|
|
||||||
// Quick Switcher
|
// Quick Switcher
|
||||||
'qs.search.placeholder': 'Поиск хостов или вкладок',
|
'qs.search.placeholder': 'Поиск хостов или вкладок',
|
||||||
'qs.jumpTo': 'Перейти к',
|
'qs.jumpTo': 'Перейти к',
|
||||||
|
|||||||
@@ -61,6 +61,15 @@ export const zhCNAiMessages: Messages = {
|
|||||||
'ai.providers.contextWindow.help': '留空时优先使用模型列表返回的值;如果没有,Netcatty 会使用安全默认值。',
|
'ai.providers.contextWindow.help': '留空时优先使用模型列表返回的值;如果没有,Netcatty 会使用安全默认值。',
|
||||||
'ai.providers.contextWindow.error': '请输入正整数,或留空。',
|
'ai.providers.contextWindow.error': '请输入正整数,或留空。',
|
||||||
'ai.providers.refreshModels': '刷新模型列表',
|
'ai.providers.refreshModels': '刷新模型列表',
|
||||||
|
'ai.providers.test': '检测',
|
||||||
|
'ai.providers.test.testing': '检测中…',
|
||||||
|
'ai.providers.test.ok': '连接正常({latency} ms)',
|
||||||
|
'ai.providers.test.warn': '已连通,但响应不完整({latency} ms)',
|
||||||
|
'ai.providers.test.warnSlow': '已连通,但较慢({latency} ms)',
|
||||||
|
'ai.providers.test.error': '检测失败({detail})',
|
||||||
|
'ai.providers.test.missingBaseUrl': '请先填写 Base URL',
|
||||||
|
'ai.providers.test.missingApiKey': '请先填写 API Key',
|
||||||
|
'ai.providers.test.unavailable': '当前环境无法进行连接检测',
|
||||||
'ai.providers.searchModel': '搜索或输入模型 ID...',
|
'ai.providers.searchModel': '搜索或输入模型 ID...',
|
||||||
'ai.providers.filterModels': '筛选模型...',
|
'ai.providers.filterModels': '筛选模型...',
|
||||||
'ai.providers.loadingModels': '加载模型中...',
|
'ai.providers.loadingModels': '加载模型中...',
|
||||||
@@ -404,6 +413,14 @@ export const zhCNAiMessages: Messages = {
|
|||||||
'ai.chat.reject': '拒绝',
|
'ai.chat.reject': '拒绝',
|
||||||
'ai.chat.toolLabel': '工具',
|
'ai.chat.toolLabel': '工具',
|
||||||
'ai.chat.targetLabel': '目标',
|
'ai.chat.targetLabel': '目标',
|
||||||
|
'ai.chat.rawCommand': '命令',
|
||||||
|
'ai.chat.copyCommand': '复制',
|
||||||
|
'ai.chat.commandCopied': '已复制',
|
||||||
|
'ai.chat.approvalSession': '会话',
|
||||||
|
'ai.chat.approvalShell': 'Shell',
|
||||||
|
'ai.chat.approvalCwd': '目录',
|
||||||
|
'ai.chat.approvalReason': '原因',
|
||||||
|
'ai.chat.approvalInvocation': '调用详情',
|
||||||
'ai.chat.permissionRequired': '需要权限',
|
'ai.chat.permissionRequired': '需要权限',
|
||||||
'ai.chat.permissionDescription': 'AI Agent 希望执行一个需要你批准的工具调用。',
|
'ai.chat.permissionDescription': 'AI Agent 希望执行一个需要你批准的工具调用。',
|
||||||
'ai.chat.commandBlocked': '此命令已被安全策略拦截,无法执行。',
|
'ai.chat.commandBlocked': '此命令已被安全策略拦截,无法执行。',
|
||||||
@@ -443,6 +460,8 @@ export const zhCNAiMessages: Messages = {
|
|||||||
'ai.chat.newChat': '新对话',
|
'ai.chat.newChat': '新对话',
|
||||||
'ai.chat.allSessions': '所有会话',
|
'ai.chat.allSessions': '所有会话',
|
||||||
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
|
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
|
||||||
|
'ai.chat.jumpNav': '跳转到消息',
|
||||||
|
'ai.chat.jumpUntitled': '(空消息)',
|
||||||
'ai.chat.usedTools': '已使用 {n} 个工具',
|
'ai.chat.usedTools': '已使用 {n} 个工具',
|
||||||
'ai.chat.loadMoreSessions': '加载更多会话(还有 {n} 条)',
|
'ai.chat.loadMoreSessions': '加载更多会话(还有 {n} 条)',
|
||||||
'ai.chat.noSessions': '没有历史会话',
|
'ai.chat.noSessions': '没有历史会话',
|
||||||
|
|||||||
@@ -72,8 +72,12 @@ export const zhCNCoreMessages: Messages = {
|
|||||||
'settings.tab.appearance': '外观',
|
'settings.tab.appearance': '外观',
|
||||||
'settings.tab.terminal': '终端',
|
'settings.tab.terminal': '终端',
|
||||||
'settings.tab.shortcuts': '快捷键',
|
'settings.tab.shortcuts': '快捷键',
|
||||||
|
'settings.tab.ai': 'AI',
|
||||||
'settings.tab.syncCloud': '同步与云',
|
'settings.tab.syncCloud': '同步与云',
|
||||||
'settings.tab.system': '系统',
|
'settings.tab.system': '系统',
|
||||||
|
'settings.search.open': '搜索设置',
|
||||||
|
'settings.search.placeholder': '搜索设置项…',
|
||||||
|
'settings.search.noResults': '没有匹配的设置项',
|
||||||
'settings.terminal.connection.verifyHostKeys': '校验 SSH 主机密钥',
|
'settings.terminal.connection.verifyHostKeys': '校验 SSH 主机密钥',
|
||||||
'settings.terminal.connection.verifyHostKeys.desc': '连接到新的或已变化的 SSH 主机密钥前先询问。只建议在可信内网中关闭。',
|
'settings.terminal.connection.verifyHostKeys.desc': '连接到新的或已变化的 SSH 主机密钥前先询问。只建议在可信内网中关闭。',
|
||||||
'settings.terminal.section.sidePanel': '侧边栏',
|
'settings.terminal.section.sidePanel': '侧边栏',
|
||||||
@@ -194,6 +198,9 @@ export const zhCNCoreMessages: Messages = {
|
|||||||
'settings.sessionLogs.timestamps': '添加时间戳',
|
'settings.sessionLogs.timestamps': '添加时间戳',
|
||||||
'settings.sessionLogs.timestampsDesc': '为纯文本和 HTML 日志的每一行添加本地时间。',
|
'settings.sessionLogs.timestampsDesc': '为纯文本和 HTML 日志的每一行添加本地时间。',
|
||||||
'settings.sessionLogs.hint': '会话日志用于记录终端输出,便于故障排查和审计。',
|
'settings.sessionLogs.hint': '会话日志用于记录终端输出,便于故障排查和审计。',
|
||||||
|
'settings.sessionLogs.clearAll': '清空所有日志',
|
||||||
|
'settings.sessionLogs.clearAllDesc': '删除保存目录中所有会话日志文件及主机子目录。',
|
||||||
|
'settings.sessionLogs.clearConfirm': '此操作将永久删除保存目录中的所有会话日志文件,且无法撤销,是否继续?',
|
||||||
|
|
||||||
// Settings > SSH Debug Logs
|
// Settings > SSH Debug Logs
|
||||||
'settings.sshDebugLogs.title': 'SSH 调试日志',
|
'settings.sshDebugLogs.title': 'SSH 调试日志',
|
||||||
@@ -228,6 +235,7 @@ export const zhCNCoreMessages: Messages = {
|
|||||||
'tray.status.active': '已启用',
|
'tray.status.active': '已启用',
|
||||||
'tray.status.inactive': '未启用',
|
'tray.status.inactive': '未启用',
|
||||||
'tray.status.error': '错误',
|
'tray.status.error': '错误',
|
||||||
|
'tray.status.unknown': '未知',
|
||||||
'tray.recentHosts': '最近连接的主机',
|
'tray.recentHosts': '最近连接的主机',
|
||||||
'tray.empty.title': '一切都很安静',
|
'tray.empty.title': '一切都很安静',
|
||||||
'tray.empty.subtitle': '去连接个服务器吧,它们想念你了 🚀',
|
'tray.empty.subtitle': '去连接个服务器吧,它们想念你了 🚀',
|
||||||
@@ -860,6 +868,7 @@ export const zhCNCoreMessages: Messages = {
|
|||||||
'sftp.transfers.calculatingTotal': '正在统计总大小...',
|
'sftp.transfers.calculatingTotal': '正在统计总大小...',
|
||||||
'sftp.transfers.filesCount': '{count} 个文件',
|
'sftp.transfers.filesCount': '{count} 个文件',
|
||||||
'sftp.transfers.filesProgress': '{current}/{total} 个文件',
|
'sftp.transfers.filesProgress': '{current}/{total} 个文件',
|
||||||
|
'sftp.transfers.filesDiscoveredProgress': '已完成 {completed} · 已发现 {discovered}',
|
||||||
'sftp.transfers.expandChildren': '展开文件',
|
'sftp.transfers.expandChildren': '展开文件',
|
||||||
'sftp.transfers.collapseChildren': '收起文件',
|
'sftp.transfers.collapseChildren': '收起文件',
|
||||||
'sftp.transfers.expandChildList': '展开详情',
|
'sftp.transfers.expandChildList': '展开详情',
|
||||||
@@ -915,6 +924,7 @@ export const zhCNCoreMessages: Messages = {
|
|||||||
'sftp.transfers.dragToResize': '拖拽调整高度',
|
'sftp.transfers.dragToResize': '拖拽调整高度',
|
||||||
'sftp.goUp': '上一级',
|
'sftp.goUp': '上一级',
|
||||||
'sftp.goToTerminalCwd': '定位到终端当前目录',
|
'sftp.goToTerminalCwd': '定位到终端当前目录',
|
||||||
|
'sftp.locatePathInTerminal': '定位路径到终端',
|
||||||
'sftp.followTerminalCwd': '追随终端目录',
|
'sftp.followTerminalCwd': '追随终端目录',
|
||||||
'sftp.followTerminalCwd.enable': '开启追随终端目录',
|
'sftp.followTerminalCwd.enable': '开启追随终端目录',
|
||||||
'sftp.followTerminalCwd.disable': '关闭追随终端目录',
|
'sftp.followTerminalCwd.disable': '关闭追随终端目录',
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ export const zhCnSystemManagerMessages: Messages = {
|
|||||||
'systemManager.empty': '暂无数据。',
|
'systemManager.empty': '暂无数据。',
|
||||||
'systemManager.tabs.overview': '概览',
|
'systemManager.tabs.overview': '概览',
|
||||||
'systemManager.tabs.processes': '进程',
|
'systemManager.tabs.processes': '进程',
|
||||||
|
'systemManager.tabs.ports': '端口',
|
||||||
|
'systemManager.tabs.services': '服务',
|
||||||
'systemManager.tabs.tmux': 'tmux',
|
'systemManager.tabs.tmux': 'tmux',
|
||||||
'systemManager.tabs.docker': 'Docker',
|
'systemManager.tabs.docker': 'Docker',
|
||||||
|
'systemManager.tabs.gpu': 'GPU',
|
||||||
|
'systemManager.tabs.ariaLabel': '系统管理分区',
|
||||||
'systemManager.popup.loading': '正在打开终端…',
|
'systemManager.popup.loading': '正在打开终端…',
|
||||||
'systemManager.popup.startupFailed': '启动命令未成功。请确认目标仍然可用后重试。',
|
'systemManager.popup.startupFailed': '启动命令未成功。请确认目标仍然可用后重试。',
|
||||||
|
|
||||||
@@ -23,6 +27,9 @@ export const zhCnSystemManagerMessages: Messages = {
|
|||||||
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
|
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
|
||||||
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
|
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
|
||||||
'systemManager.errors.loadOverview': '加载系统概览失败',
|
'systemManager.errors.loadOverview': '加载系统概览失败',
|
||||||
|
'systemManager.errors.loadGpu': '加载 GPU / NPU 状态失败',
|
||||||
|
'systemManager.errors.loadPorts': '加载监听端口失败',
|
||||||
|
'systemManager.errors.loadServices': '加载 systemd 服务失败',
|
||||||
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
|
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
|
||||||
|
|
||||||
'systemManager.overview.empty': '暂无系统概览数据。',
|
'systemManager.overview.empty': '暂无系统概览数据。',
|
||||||
@@ -187,6 +194,51 @@ export const zhCnSystemManagerMessages: Messages = {
|
|||||||
'systemManager.docker.start': '启动',
|
'systemManager.docker.start': '启动',
|
||||||
'systemManager.docker.stop': '停止',
|
'systemManager.docker.stop': '停止',
|
||||||
|
|
||||||
|
'systemManager.ports.unavailable': '此主机未检测到端口工具(ss / netstat)。',
|
||||||
|
'systemManager.ports.loading': '正在加载监听端口…',
|
||||||
|
'systemManager.ports.empty': '暂无监听端口。',
|
||||||
|
'systemManager.ports.search': '搜索端口…',
|
||||||
|
'systemManager.ports.meta': '{{count}} 个监听',
|
||||||
|
'systemManager.ports.filter.all': '全部',
|
||||||
|
'systemManager.ports.unknownProcess': '未知进程',
|
||||||
|
'systemManager.ports.terminate': '结束进程',
|
||||||
|
'systemManager.ports.confirmTerminate': '向占用该端口的进程 {{pid}} 发送 SIGTERM?',
|
||||||
|
|
||||||
|
'systemManager.services.unavailable': '此主机未检测到 systemctl。',
|
||||||
|
'systemManager.services.loading': '正在加载 systemd 服务…',
|
||||||
|
'systemManager.services.empty': '暂无 systemd 服务。',
|
||||||
|
'systemManager.services.search': '搜索服务…',
|
||||||
|
'systemManager.services.meta': '{{count}} 个服务',
|
||||||
|
'systemManager.services.filter.all': '全部',
|
||||||
|
'systemManager.services.filter.running': '运行中',
|
||||||
|
'systemManager.services.filter.failed': '失败',
|
||||||
|
'systemManager.services.filter.inactive': '未运行',
|
||||||
|
'systemManager.services.start': '启动',
|
||||||
|
'systemManager.services.stop': '停止',
|
||||||
|
'systemManager.services.restart': '重启',
|
||||||
|
'systemManager.services.enable': '开机启用',
|
||||||
|
'systemManager.services.disable': '禁用开机',
|
||||||
|
'systemManager.services.reload': '重载',
|
||||||
|
'systemManager.services.scope.user': '用户',
|
||||||
|
'systemManager.services.confirmAction': '确认对 {{name}} 执行「{{action}}」?',
|
||||||
|
|
||||||
|
'systemManager.gpu.unavailable': '此主机未检测到 NVIDIA GPU 或昇腾 NPU 工具。',
|
||||||
|
'systemManager.gpu.loading': '正在加载加速卡状态…',
|
||||||
|
'systemManager.gpu.empty': '已检测到加速卡工具,但未返回设备信息。',
|
||||||
|
'systemManager.gpu.meta': '{{devices}} 张卡 · {{processes}} 个进程',
|
||||||
|
'systemManager.gpu.devices': '设备',
|
||||||
|
'systemManager.gpu.processes': '计算进程',
|
||||||
|
'systemManager.gpu.noProcesses': '当前没有计算进程。',
|
||||||
|
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
|
||||||
|
'systemManager.gpu.vendor.ascend': '昇腾',
|
||||||
|
'systemManager.gpu.util': '利用率',
|
||||||
|
'systemManager.gpu.memory': '显存',
|
||||||
|
'systemManager.gpu.hbm': 'HBM',
|
||||||
|
'systemManager.gpu.temperature': '温度',
|
||||||
|
'systemManager.gpu.power': '功耗',
|
||||||
|
'systemManager.gpu.fan': '风扇 {{value}}%',
|
||||||
|
'systemManager.gpu.driver': '驱动 {{version}}',
|
||||||
|
|
||||||
'systemManager.inspect.status': '状态',
|
'systemManager.inspect.status': '状态',
|
||||||
'systemManager.inspect.image': '镜像',
|
'systemManager.inspect.image': '镜像',
|
||||||
'systemManager.inspect.created': '创建时间',
|
'systemManager.inspect.created': '创建时间',
|
||||||
|
|||||||
@@ -206,6 +206,9 @@ export const zhCNTerminalMessages: Messages = {
|
|||||||
'settings.sftp.compressedUpload.enable': '启用文件夹压缩',
|
'settings.sftp.compressedUpload.enable': '启用文件夹压缩',
|
||||||
'settings.sftp.compressedUpload.enableDesc': '自动使用 tar 压缩文件夹后再传输。需要服务器支持 tar 命令,不支持时自动回退到普通传输。',
|
'settings.sftp.compressedUpload.enableDesc': '自动使用 tar 压缩文件夹后再传输。需要服务器支持 tar 命令,不支持时自动回退到普通传输。',
|
||||||
|
|
||||||
|
'settings.sftp.skipUnchanged.enable': '跳过未改文件',
|
||||||
|
'settings.sftp.skipUnchanged.enableDesc': '当目标文件的大小与修改时间已与源一致时跳过传输(类似 rsync)。替换到暂存路径时自动禁用。',
|
||||||
|
|
||||||
// Settings > Terminal
|
// Settings > Terminal
|
||||||
'settings.terminal.section.theme': '终端主题',
|
'settings.terminal.section.theme': '终端主题',
|
||||||
'settings.terminal.themeModal.title': '选择主题',
|
'settings.terminal.themeModal.title': '选择主题',
|
||||||
@@ -510,6 +513,8 @@ export const zhCNTerminalMessages: Messages = {
|
|||||||
'settings.shortcuts.disableTerminalFontZoom.desc': '关闭终端文字缩放快捷操作,包括 Cmd/Ctrl 加滚轮。',
|
'settings.shortcuts.disableTerminalFontZoom.desc': '关闭终端文字缩放快捷操作,包括 Cmd/Ctrl 加滚轮。',
|
||||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '标签快捷键跳过固定标签',
|
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '标签快捷键跳过固定标签',
|
||||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '开启后,Cmd/Ctrl+[1...9] 和 Ctrl+Tab 仅在终端、工作区、编辑器等可关闭标签页之间切换,不包括固定的 Vault 和 SFTP 标签页。',
|
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '开启后,Cmd/Ctrl+[1...9] 和 Ctrl+Tab 仅在终端、工作区、编辑器等可关闭标签页之间切换,不包括固定的 Vault 和 SFTP 标签页。',
|
||||||
|
'settings.shortcuts.showTabNumberBadges.label': '显示标签页序号角标',
|
||||||
|
'settings.shortcuts.showTabNumberBadges.desc': '按住 Cmd/Ctrl 时,在对应 Cmd/Ctrl+[1...9] 的标签页上显示 1-9,方便准确切换。',
|
||||||
'settings.shortcuts.section.custom': '自定义快捷键',
|
'settings.shortcuts.section.custom': '自定义快捷键',
|
||||||
'settings.shortcuts.resetAll': '全部重置',
|
'settings.shortcuts.resetAll': '全部重置',
|
||||||
'settings.shortcuts.recording': '请按键...',
|
'settings.shortcuts.recording': '请按键...',
|
||||||
|
|||||||
@@ -427,6 +427,10 @@ export const zhCNVaultMessages: Messages = {
|
|||||||
'terminal.statusbar.copyHostname.tooltip': '复制主机地址({hostname})',
|
'terminal.statusbar.copyHostname.tooltip': '复制主机地址({hostname})',
|
||||||
'terminal.statusbar.copyHostname.toast': '已复制主机地址:{hostname}',
|
'terminal.statusbar.copyHostname.toast': '已复制主机地址:{hostname}',
|
||||||
'terminal.statusbar.copyHostname.error': '复制主机地址失败',
|
'terminal.statusbar.copyHostname.error': '复制主机地址失败',
|
||||||
|
'terminal.statusbar.disconnect.label': '断开连接',
|
||||||
|
'terminal.statusbar.disconnect.tooltip': '断开当前会话,但保留标签页',
|
||||||
|
'terminal.statusbar.reconnect.label': '重新连接',
|
||||||
|
'terminal.statusbar.reconnect.tooltip': '重新连接当前会话',
|
||||||
'terminal.serverStats.cpu': 'CPU 使用率',
|
'terminal.serverStats.cpu': 'CPU 使用率',
|
||||||
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
||||||
'terminal.serverStats.memory': '内存使用',
|
'terminal.serverStats.memory': '内存使用',
|
||||||
|
|||||||
@@ -61,6 +61,15 @@ export const zhTWAiMessages: Messages = {
|
|||||||
'ai.providers.contextWindow.help': '留空時優先使用模型列表傳回的值;如果沒有,Netcatty 會使用安全預設值。',
|
'ai.providers.contextWindow.help': '留空時優先使用模型列表傳回的值;如果沒有,Netcatty 會使用安全預設值。',
|
||||||
'ai.providers.contextWindow.error': '請輸入正整數,或留空。',
|
'ai.providers.contextWindow.error': '請輸入正整數,或留空。',
|
||||||
'ai.providers.refreshModels': '重新整理模型列表',
|
'ai.providers.refreshModels': '重新整理模型列表',
|
||||||
|
'ai.providers.test': '檢測',
|
||||||
|
'ai.providers.test.testing': '檢測中…',
|
||||||
|
'ai.providers.test.ok': '連線正常({latency} ms)',
|
||||||
|
'ai.providers.test.warn': '已連通,但回應不完整({latency} ms)',
|
||||||
|
'ai.providers.test.warnSlow': '已連通,但較慢({latency} ms)',
|
||||||
|
'ai.providers.test.error': '檢測失敗({detail})',
|
||||||
|
'ai.providers.test.missingBaseUrl': '請先填寫 Base URL',
|
||||||
|
'ai.providers.test.missingApiKey': '請先填寫 API Key',
|
||||||
|
'ai.providers.test.unavailable': '目前環境無法進行連線檢測',
|
||||||
'ai.providers.searchModel': '搜尋或輸入模型 ID...',
|
'ai.providers.searchModel': '搜尋或輸入模型 ID...',
|
||||||
'ai.providers.filterModels': '篩選模型...',
|
'ai.providers.filterModels': '篩選模型...',
|
||||||
'ai.providers.loadingModels': '載入模型中...',
|
'ai.providers.loadingModels': '載入模型中...',
|
||||||
@@ -403,6 +412,14 @@ export const zhTWAiMessages: Messages = {
|
|||||||
'ai.chat.reject': '拒絕',
|
'ai.chat.reject': '拒絕',
|
||||||
'ai.chat.toolLabel': '工具',
|
'ai.chat.toolLabel': '工具',
|
||||||
'ai.chat.targetLabel': '目標',
|
'ai.chat.targetLabel': '目標',
|
||||||
|
'ai.chat.rawCommand': '指令',
|
||||||
|
'ai.chat.copyCommand': '複製',
|
||||||
|
'ai.chat.commandCopied': '已複製',
|
||||||
|
'ai.chat.approvalSession': '工作階段',
|
||||||
|
'ai.chat.approvalShell': 'Shell',
|
||||||
|
'ai.chat.approvalCwd': '目錄',
|
||||||
|
'ai.chat.approvalReason': '原因',
|
||||||
|
'ai.chat.approvalInvocation': '呼叫詳情',
|
||||||
'ai.chat.permissionRequired': '需要權限',
|
'ai.chat.permissionRequired': '需要權限',
|
||||||
'ai.chat.permissionDescription': 'AI Agent 希望執行一個需要你批准的工具呼叫。',
|
'ai.chat.permissionDescription': 'AI Agent 希望執行一個需要你批准的工具呼叫。',
|
||||||
'ai.chat.commandBlocked': '此指令已被安全策略攔截,無法執行。',
|
'ai.chat.commandBlocked': '此指令已被安全策略攔截,無法執行。',
|
||||||
@@ -442,6 +459,8 @@ export const zhTWAiMessages: Messages = {
|
|||||||
'ai.chat.newChat': '新對話',
|
'ai.chat.newChat': '新對話',
|
||||||
'ai.chat.allSessions': '所有工作階段',
|
'ai.chat.allSessions': '所有工作階段',
|
||||||
'ai.chat.loadEarlierMessages': '載入更早的訊息(還有 {n} 條)',
|
'ai.chat.loadEarlierMessages': '載入更早的訊息(還有 {n} 條)',
|
||||||
|
'ai.chat.jumpNav': '跳轉到訊息',
|
||||||
|
'ai.chat.jumpUntitled': '(空訊息)',
|
||||||
'ai.chat.usedTools': '已使用 {n} 個工具',
|
'ai.chat.usedTools': '已使用 {n} 個工具',
|
||||||
'ai.chat.loadMoreSessions': '載入更多工作階段(還有 {n} 條)',
|
'ai.chat.loadMoreSessions': '載入更多工作階段(還有 {n} 條)',
|
||||||
'ai.chat.noSessions': '沒有歷史工作階段',
|
'ai.chat.noSessions': '沒有歷史工作階段',
|
||||||
|
|||||||
@@ -72,8 +72,12 @@ export const zhTWCoreMessages: Messages = {
|
|||||||
'settings.tab.appearance': '外觀',
|
'settings.tab.appearance': '外觀',
|
||||||
'settings.tab.terminal': '終端',
|
'settings.tab.terminal': '終端',
|
||||||
'settings.tab.shortcuts': '快捷鍵',
|
'settings.tab.shortcuts': '快捷鍵',
|
||||||
|
'settings.tab.ai': 'AI',
|
||||||
'settings.tab.syncCloud': '同步與雲',
|
'settings.tab.syncCloud': '同步與雲',
|
||||||
'settings.tab.system': '系統',
|
'settings.tab.system': '系統',
|
||||||
|
'settings.search.open': '搜尋設定',
|
||||||
|
'settings.search.placeholder': '搜尋設定項…',
|
||||||
|
'settings.search.noResults': '沒有符合的設定項',
|
||||||
'settings.terminal.connection.verifyHostKeys': '驗證 SSH 主機金鑰',
|
'settings.terminal.connection.verifyHostKeys': '驗證 SSH 主機金鑰',
|
||||||
'settings.terminal.connection.verifyHostKeys.desc': '連線到新的或已變化的 SSH 主機金鑰前先詢問。只建議在可信內網中關閉。',
|
'settings.terminal.connection.verifyHostKeys.desc': '連線到新的或已變化的 SSH 主機金鑰前先詢問。只建議在可信內網中關閉。',
|
||||||
'settings.terminal.section.sidePanel': '側邊欄',
|
'settings.terminal.section.sidePanel': '側邊欄',
|
||||||
@@ -194,6 +198,9 @@ export const zhTWCoreMessages: Messages = {
|
|||||||
'settings.sessionLogs.timestamps': '新增時間戳',
|
'settings.sessionLogs.timestamps': '新增時間戳',
|
||||||
'settings.sessionLogs.timestampsDesc': '為純文字和 HTML 日誌的每一行新增本機時間。',
|
'settings.sessionLogs.timestampsDesc': '為純文字和 HTML 日誌的每一行新增本機時間。',
|
||||||
'settings.sessionLogs.hint': '工作階段日誌用於記錄終端輸出,便於故障排查和審計。',
|
'settings.sessionLogs.hint': '工作階段日誌用於記錄終端輸出,便於故障排查和審計。',
|
||||||
|
'settings.sessionLogs.clearAll': '清空所有日誌',
|
||||||
|
'settings.sessionLogs.clearAllDesc': '刪除儲存目錄中所有工作階段日誌檔案及主機子目錄。',
|
||||||
|
'settings.sessionLogs.clearConfirm': '此操作將永久刪除儲存目錄中的所有工作階段日誌檔案,且無法復原,是否繼續?',
|
||||||
|
|
||||||
// Settings > SSH Debug Logs
|
// Settings > SSH Debug Logs
|
||||||
'settings.sshDebugLogs.title': 'SSH 除錯日誌',
|
'settings.sshDebugLogs.title': 'SSH 除錯日誌',
|
||||||
@@ -228,6 +235,7 @@ export const zhTWCoreMessages: Messages = {
|
|||||||
'tray.status.active': '已啟用',
|
'tray.status.active': '已啟用',
|
||||||
'tray.status.inactive': '未啟用',
|
'tray.status.inactive': '未啟用',
|
||||||
'tray.status.error': '錯誤',
|
'tray.status.error': '錯誤',
|
||||||
|
'tray.status.unknown': '未知',
|
||||||
'tray.recentHosts': '最近連線的主機',
|
'tray.recentHosts': '最近連線的主機',
|
||||||
'tray.empty.title': '一切都很安靜',
|
'tray.empty.title': '一切都很安靜',
|
||||||
'tray.empty.subtitle': '去連線個伺服器吧,它們想念你了 🚀',
|
'tray.empty.subtitle': '去連線個伺服器吧,它們想念你了 🚀',
|
||||||
@@ -860,6 +868,7 @@ export const zhTWCoreMessages: Messages = {
|
|||||||
'sftp.transfers.calculatingTotal': '正在統計總大小...',
|
'sftp.transfers.calculatingTotal': '正在統計總大小...',
|
||||||
'sftp.transfers.filesCount': '{count} 個檔案',
|
'sftp.transfers.filesCount': '{count} 個檔案',
|
||||||
'sftp.transfers.filesProgress': '{current}/{total} 個檔案',
|
'sftp.transfers.filesProgress': '{current}/{total} 個檔案',
|
||||||
|
'sftp.transfers.filesDiscoveredProgress': '已完成 {completed} · 已發現 {discovered}',
|
||||||
'sftp.transfers.expandChildren': '展開檔案',
|
'sftp.transfers.expandChildren': '展開檔案',
|
||||||
'sftp.transfers.collapseChildren': '收起檔案',
|
'sftp.transfers.collapseChildren': '收起檔案',
|
||||||
'sftp.transfers.expandChildList': '展開詳情',
|
'sftp.transfers.expandChildList': '展開詳情',
|
||||||
@@ -915,6 +924,7 @@ export const zhTWCoreMessages: Messages = {
|
|||||||
'sftp.transfers.dragToResize': '拖拽調整高度',
|
'sftp.transfers.dragToResize': '拖拽調整高度',
|
||||||
'sftp.goUp': '上一級',
|
'sftp.goUp': '上一級',
|
||||||
'sftp.goToTerminalCwd': '定位到終端目前目錄',
|
'sftp.goToTerminalCwd': '定位到終端目前目錄',
|
||||||
|
'sftp.locatePathInTerminal': '定位路徑到終端',
|
||||||
'sftp.followTerminalCwd': '追隨終端目錄',
|
'sftp.followTerminalCwd': '追隨終端目錄',
|
||||||
'sftp.followTerminalCwd.enable': '開啟追隨終端目錄',
|
'sftp.followTerminalCwd.enable': '開啟追隨終端目錄',
|
||||||
'sftp.followTerminalCwd.disable': '關閉追隨終端目錄',
|
'sftp.followTerminalCwd.disable': '關閉追隨終端目錄',
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ export const zhTwSystemManagerMessages: Messages = {
|
|||||||
'systemManager.empty': '暫無資料。',
|
'systemManager.empty': '暫無資料。',
|
||||||
'systemManager.tabs.overview': '概覽',
|
'systemManager.tabs.overview': '概覽',
|
||||||
'systemManager.tabs.processes': '程序',
|
'systemManager.tabs.processes': '程序',
|
||||||
|
'systemManager.tabs.ports': '連接埠',
|
||||||
|
'systemManager.tabs.services': '服務',
|
||||||
'systemManager.tabs.tmux': 'tmux',
|
'systemManager.tabs.tmux': 'tmux',
|
||||||
'systemManager.tabs.docker': 'Docker',
|
'systemManager.tabs.docker': 'Docker',
|
||||||
|
'systemManager.tabs.gpu': 'GPU',
|
||||||
|
'systemManager.tabs.ariaLabel': '系統管理分區',
|
||||||
'systemManager.popup.loading': '正在開啟終端…',
|
'systemManager.popup.loading': '正在開啟終端…',
|
||||||
'systemManager.popup.startupFailed': '啟動指令未順利完成。請確認目標仍可使用後再試一次。',
|
'systemManager.popup.startupFailed': '啟動指令未順利完成。請確認目標仍可使用後再試一次。',
|
||||||
|
|
||||||
@@ -23,6 +27,9 @@ export const zhTwSystemManagerMessages: Messages = {
|
|||||||
'systemManager.errors.loadDockerStats': '載入容器效能資料失敗',
|
'systemManager.errors.loadDockerStats': '載入容器效能資料失敗',
|
||||||
'systemManager.errors.loadDockerImages': '載入映像列表失敗',
|
'systemManager.errors.loadDockerImages': '載入映像列表失敗',
|
||||||
'systemManager.errors.loadOverview': '載入系統概覽失敗',
|
'systemManager.errors.loadOverview': '載入系統概覽失敗',
|
||||||
|
'systemManager.errors.loadGpu': '載入 GPU / NPU 狀態失敗',
|
||||||
|
'systemManager.errors.loadPorts': '載入監聽連接埠失敗',
|
||||||
|
'systemManager.errors.loadServices': '載入 systemd 服務失敗',
|
||||||
'systemManager.errors.sshChannelUnavailable': '伺服器拒絕開啟新的執行通道。請稍後重試,或重新連線目前主機。',
|
'systemManager.errors.sshChannelUnavailable': '伺服器拒絕開啟新的執行通道。請稍後重試,或重新連線目前主機。',
|
||||||
|
|
||||||
'systemManager.overview.empty': '暫無系統概覽資料。',
|
'systemManager.overview.empty': '暫無系統概覽資料。',
|
||||||
@@ -187,6 +194,51 @@ export const zhTwSystemManagerMessages: Messages = {
|
|||||||
'systemManager.docker.start': '啟動',
|
'systemManager.docker.start': '啟動',
|
||||||
'systemManager.docker.stop': '停止',
|
'systemManager.docker.stop': '停止',
|
||||||
|
|
||||||
|
'systemManager.ports.unavailable': '此主機未偵測到連接埠工具(ss / netstat)。',
|
||||||
|
'systemManager.ports.loading': '正在載入監聽連接埠…',
|
||||||
|
'systemManager.ports.empty': '暫無監聽連接埠。',
|
||||||
|
'systemManager.ports.search': '搜尋連接埠…',
|
||||||
|
'systemManager.ports.meta': '{{count}} 個監聽',
|
||||||
|
'systemManager.ports.filter.all': '全部',
|
||||||
|
'systemManager.ports.unknownProcess': '未知程序',
|
||||||
|
'systemManager.ports.terminate': '結束程序',
|
||||||
|
'systemManager.ports.confirmTerminate': '向佔用該連接埠的程序 {{pid}} 傳送 SIGTERM?',
|
||||||
|
|
||||||
|
'systemManager.services.unavailable': '此主機未偵測到 systemctl。',
|
||||||
|
'systemManager.services.loading': '正在載入 systemd 服務…',
|
||||||
|
'systemManager.services.empty': '暫無 systemd 服務。',
|
||||||
|
'systemManager.services.search': '搜尋服務…',
|
||||||
|
'systemManager.services.meta': '{{count}} 個服務',
|
||||||
|
'systemManager.services.filter.all': '全部',
|
||||||
|
'systemManager.services.filter.running': '執行中',
|
||||||
|
'systemManager.services.filter.failed': '失敗',
|
||||||
|
'systemManager.services.filter.inactive': '未執行',
|
||||||
|
'systemManager.services.start': '啟動',
|
||||||
|
'systemManager.services.stop': '停止',
|
||||||
|
'systemManager.services.restart': '重新啟動',
|
||||||
|
'systemManager.services.enable': '開機啟用',
|
||||||
|
'systemManager.services.disable': '停用開機',
|
||||||
|
'systemManager.services.reload': '重新載入',
|
||||||
|
'systemManager.services.scope.user': '使用者',
|
||||||
|
'systemManager.services.confirmAction': '確認對 {{name}} 執行「{{action}}」?',
|
||||||
|
|
||||||
|
'systemManager.gpu.unavailable': '此主機未偵測到 NVIDIA GPU 或昇騰 NPU 工具。',
|
||||||
|
'systemManager.gpu.loading': '正在載入加速卡狀態…',
|
||||||
|
'systemManager.gpu.empty': '已偵測到加速卡工具,但未回傳裝置資訊。',
|
||||||
|
'systemManager.gpu.meta': '{{devices}} 張卡 · {{processes}} 個程序',
|
||||||
|
'systemManager.gpu.devices': '裝置',
|
||||||
|
'systemManager.gpu.processes': '計算程序',
|
||||||
|
'systemManager.gpu.noProcesses': '目前沒有計算程序。',
|
||||||
|
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
|
||||||
|
'systemManager.gpu.vendor.ascend': '昇騰',
|
||||||
|
'systemManager.gpu.util': '使用率',
|
||||||
|
'systemManager.gpu.memory': '顯示記憶體',
|
||||||
|
'systemManager.gpu.hbm': 'HBM',
|
||||||
|
'systemManager.gpu.temperature': '溫度',
|
||||||
|
'systemManager.gpu.power': '功耗',
|
||||||
|
'systemManager.gpu.fan': '風扇 {{value}}%',
|
||||||
|
'systemManager.gpu.driver': '驅動 {{version}}',
|
||||||
|
|
||||||
'systemManager.inspect.status': '狀態',
|
'systemManager.inspect.status': '狀態',
|
||||||
'systemManager.inspect.image': '映像',
|
'systemManager.inspect.image': '映像',
|
||||||
'systemManager.inspect.created': '建立時間',
|
'systemManager.inspect.created': '建立時間',
|
||||||
|
|||||||
@@ -206,6 +206,9 @@ export const zhTWTerminalMessages: Messages = {
|
|||||||
'settings.sftp.compressedUpload.enable': '啟用資料夾壓縮',
|
'settings.sftp.compressedUpload.enable': '啟用資料夾壓縮',
|
||||||
'settings.sftp.compressedUpload.enableDesc': '自動使用 tar 壓縮資料夾後再傳輸。需要伺服器支援 tar 指令,不支援時自動回退到普通傳輸。',
|
'settings.sftp.compressedUpload.enableDesc': '自動使用 tar 壓縮資料夾後再傳輸。需要伺服器支援 tar 指令,不支援時自動回退到普通傳輸。',
|
||||||
|
|
||||||
|
'settings.sftp.skipUnchanged.enable': '跳過未改檔案',
|
||||||
|
'settings.sftp.skipUnchanged.enableDesc': '當目標檔案的大小與修改時間已與來源一致時跳過傳輸(類似 rsync)。替換到暫存路徑時自動停用。',
|
||||||
|
|
||||||
// Settings > Terminal
|
// Settings > Terminal
|
||||||
'settings.terminal.section.theme': '終端主題',
|
'settings.terminal.section.theme': '終端主題',
|
||||||
'settings.terminal.themeModal.title': '選擇主題',
|
'settings.terminal.themeModal.title': '選擇主題',
|
||||||
@@ -510,6 +513,8 @@ export const zhTWTerminalMessages: Messages = {
|
|||||||
'settings.shortcuts.disableTerminalFontZoom.desc': '關閉終端文字縮放快捷操作,包括 Cmd/Ctrl 加滾輪。',
|
'settings.shortcuts.disableTerminalFontZoom.desc': '關閉終端文字縮放快捷操作,包括 Cmd/Ctrl 加滾輪。',
|
||||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '標籤快捷鍵跳過固定標籤',
|
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '標籤快捷鍵跳過固定標籤',
|
||||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '開啟後,Cmd/Ctrl+[1...9] 和 Ctrl+Tab 僅在終端、工作區、編輯器等可關閉標籤頁之間切換,不包括固定的 Vault 和 SFTP 標籤頁。',
|
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '開啟後,Cmd/Ctrl+[1...9] 和 Ctrl+Tab 僅在終端、工作區、編輯器等可關閉標籤頁之間切換,不包括固定的 Vault 和 SFTP 標籤頁。',
|
||||||
|
'settings.shortcuts.showTabNumberBadges.label': '顯示標籤頁序號角標',
|
||||||
|
'settings.shortcuts.showTabNumberBadges.desc': '按住 Cmd/Ctrl 時,在對應 Cmd/Ctrl+[1...9] 的標籤頁上顯示 1-9,方便準確切換。',
|
||||||
'settings.shortcuts.section.custom': '自訂快捷鍵',
|
'settings.shortcuts.section.custom': '自訂快捷鍵',
|
||||||
'settings.shortcuts.resetAll': '全部重置',
|
'settings.shortcuts.resetAll': '全部重置',
|
||||||
'settings.shortcuts.recording': '請按鍵...',
|
'settings.shortcuts.recording': '請按鍵...',
|
||||||
|
|||||||
@@ -427,6 +427,10 @@ export const zhTWVaultMessages: Messages = {
|
|||||||
'terminal.statusbar.copyHostname.tooltip': '複製主機地址({hostname})',
|
'terminal.statusbar.copyHostname.tooltip': '複製主機地址({hostname})',
|
||||||
'terminal.statusbar.copyHostname.toast': '已複製主機地址:{hostname}',
|
'terminal.statusbar.copyHostname.toast': '已複製主機地址:{hostname}',
|
||||||
'terminal.statusbar.copyHostname.error': '複製主機地址失敗',
|
'terminal.statusbar.copyHostname.error': '複製主機地址失敗',
|
||||||
|
'terminal.statusbar.disconnect.label': '中斷連線',
|
||||||
|
'terminal.statusbar.disconnect.tooltip': '中斷目前工作階段,但保留標籤頁',
|
||||||
|
'terminal.statusbar.reconnect.label': '重新連線',
|
||||||
|
'terminal.statusbar.reconnect.tooltip': '重新連線目前工作階段',
|
||||||
'terminal.serverStats.cpu': 'CPU 使用率',
|
'terminal.serverStats.cpu': 'CPU 使用率',
|
||||||
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
||||||
'terminal.serverStats.memory': '記憶體使用',
|
'terminal.serverStats.memory': '記憶體使用',
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const chromeThemeSource = readFileSync(new URL("./useActiveChromeTheme.ts", impo
|
|||||||
const storeSource = readFileSync(new URL("./activeTabStore.ts", import.meta.url), "utf8");
|
const storeSource = readFileSync(new URL("./activeTabStore.ts", import.meta.url), "utf8");
|
||||||
|
|
||||||
test("active tab changes notify chrome theme before react subscribers", () => {
|
test("active tab changes notify chrome theme before react subscribers", () => {
|
||||||
const setActiveTabIdBody = storeSource.match(/setActiveTabId = \(id: string\) => \{[\s\S]*?\n {2}\};/)?.[0] ?? "";
|
const setActiveTabIdBody = storeSource.match(/setActiveTabId = \(id: string(?:, options\?: \w+)?\) => \{[\s\S]*?\n {2}\};/)?.[0] ?? "";
|
||||||
assert.match(setActiveTabIdBody, /this\.syncListeners\.forEach\(\(listener\) => listener\(id\)\)/);
|
assert.match(setActiveTabIdBody, /this\.syncListeners\.forEach\(\(listener\) => listener\(id\)\)/);
|
||||||
assert.match(setActiveTabIdBody, /this\.scheduleNotify\(\)/);
|
assert.match(setActiveTabIdBody, /this\.scheduleNotify\(\)/);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
|
|||||||
@@ -4,6 +4,37 @@ import test from 'node:test';
|
|||||||
import { activeTabStore, fromEditorTabId, isEditorTabId, toEditorTabId } from './activeTabStore';
|
import { activeTabStore, fromEditorTabId, isEditorTabId, toEditorTabId } from './activeTabStore';
|
||||||
import { terminalLayoutSuppressStore } from './terminalLayoutSuppressStore';
|
import { terminalLayoutSuppressStore } from './terminalLayoutSuppressStore';
|
||||||
|
|
||||||
|
test('active tab store remembers the previous tab for restore-on-close', () => {
|
||||||
|
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||||
|
(globalThis as { window?: unknown }).window ??= globalThis;
|
||||||
|
|
||||||
|
const marker = `prev-tab-${Date.now()}`;
|
||||||
|
const first = `${marker}-a`;
|
||||||
|
const second = `${marker}-b`;
|
||||||
|
const third = `${marker}-c`;
|
||||||
|
const before = activeTabStore.getActiveTabId();
|
||||||
|
|
||||||
|
try {
|
||||||
|
activeTabStore.setActiveTabId(first);
|
||||||
|
activeTabStore.setActiveTabId(second);
|
||||||
|
assert.equal(activeTabStore.getPreviousActiveTabId(), first);
|
||||||
|
|
||||||
|
activeTabStore.setActiveTabId(third);
|
||||||
|
assert.equal(activeTabStore.getPreviousActiveTabId(), second);
|
||||||
|
|
||||||
|
activeTabStore.setActiveTabId(first, { recordPrevious: false });
|
||||||
|
assert.equal(activeTabStore.getActiveTabId(), first);
|
||||||
|
assert.equal(activeTabStore.getPreviousActiveTabId(), second);
|
||||||
|
} finally {
|
||||||
|
activeTabStore.setActiveTabId(before, { recordPrevious: false });
|
||||||
|
if (previousWindow === undefined) {
|
||||||
|
delete (globalThis as { window?: unknown }).window;
|
||||||
|
} else {
|
||||||
|
(globalThis as { window?: unknown }).window = previousWindow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('editor tab helpers round trip ids', () => {
|
test('editor tab helpers round trip ids', () => {
|
||||||
assert.equal(toEditorTabId('file-1'), 'editor:file-1');
|
assert.equal(toEditorTabId('file-1'), 'editor:file-1');
|
||||||
assert.equal(fromEditorTabId('editor:file-1'), 'file-1');
|
assert.equal(fromEditorTabId('editor:file-1'), 'file-1');
|
||||||
|
|||||||
@@ -16,14 +16,22 @@ export const toEditorTabId = (editorId: string): string => `${EDITOR_PREFIX}${ed
|
|||||||
/** Strip the "editor:" prefix to recover the internal editorTab id. */
|
/** Strip the "editor:" prefix to recover the internal editorTab id. */
|
||||||
export const fromEditorTabId = (tabId: string): string => tabId.slice(EDITOR_PREFIX.length);
|
export const fromEditorTabId = (tabId: string): string => tabId.slice(EDITOR_PREFIX.length);
|
||||||
|
|
||||||
|
type SetActiveTabOptions = {
|
||||||
|
/** When false, keep the previous-tab pointer unchanged (used for close restore). */
|
||||||
|
recordPrevious?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
class ActiveTabStore {
|
class ActiveTabStore {
|
||||||
private activeTabId: string = 'vault';
|
private activeTabId: string = 'vault';
|
||||||
|
private previousActiveTabId: string | null = null;
|
||||||
private listeners = new Set<Listener>();
|
private listeners = new Set<Listener>();
|
||||||
private syncListeners = new Set<SyncListener>();
|
private syncListeners = new Set<SyncListener>();
|
||||||
private notifyRafId: number | null = null;
|
private notifyRafId: number | null = null;
|
||||||
|
|
||||||
getActiveTabId = () => this.activeTabId;
|
getActiveTabId = () => this.activeTabId;
|
||||||
|
|
||||||
|
getPreviousActiveTabId = () => this.previousActiveTabId;
|
||||||
|
|
||||||
private scheduleNotify = () => {
|
private scheduleNotify = () => {
|
||||||
if (this.notifyRafId !== null) return;
|
if (this.notifyRafId !== null) return;
|
||||||
const schedule = typeof requestAnimationFrame === 'function'
|
const schedule = typeof requestAnimationFrame === 'function'
|
||||||
@@ -35,8 +43,11 @@ class ActiveTabStore {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
setActiveTabId = (id: string) => {
|
setActiveTabId = (id: string, options?: SetActiveTabOptions) => {
|
||||||
if (this.activeTabId !== id) {
|
if (this.activeTabId !== id) {
|
||||||
|
if (options?.recordPrevious !== false) {
|
||||||
|
this.previousActiveTabId = this.activeTabId;
|
||||||
|
}
|
||||||
this.activeTabId = id;
|
this.activeTabId = id;
|
||||||
this.syncListeners.forEach((listener) => listener(id));
|
this.syncListeners.forEach((listener) => listener(id));
|
||||||
// Coalesce rapid tab switches into one notification per frame and avoid
|
// Coalesce rapid tab switches into one notification per frame and avoid
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type React from 'react';
|
||||||
|
import {
|
||||||
|
STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE,
|
||||||
|
STORAGE_KEY_AI_PANEL_DIAGNOSTIC_PROFILE,
|
||||||
|
} from '../../infrastructure/config/storageKeys';
|
||||||
|
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||||
|
|
||||||
|
export const AI_PANEL_DIAGNOSTIC_HIDE_KEY = STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE;
|
||||||
|
export const AI_PANEL_DIAGNOSTIC_PROFILE_KEY = STORAGE_KEY_AI_PANEL_DIAGNOSTIC_PROFILE;
|
||||||
|
export const AI_PANEL_FORCE_HIDE_ALL_CONTENT = false;
|
||||||
|
export const AI_PANEL_FORCE_HIDE_SHELL = false;
|
||||||
|
|
||||||
|
export type AIPanelDiagnosticPart =
|
||||||
|
| 'all'
|
||||||
|
| 'attachments'
|
||||||
|
| 'header'
|
||||||
|
| 'history'
|
||||||
|
| 'input'
|
||||||
|
| 'markdown'
|
||||||
|
| 'messages'
|
||||||
|
| 'recent'
|
||||||
|
| 'toolcalls';
|
||||||
|
|
||||||
|
function readLocalStorageValue(key: string): string {
|
||||||
|
try {
|
||||||
|
return localStorageAdapter.readString(key) ?? '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAIPanelDiagnosticHiddenParts(): ReadonlySet<string> {
|
||||||
|
if (AI_PANEL_FORCE_HIDE_ALL_CONTENT) {
|
||||||
|
return new Set(['all']);
|
||||||
|
}
|
||||||
|
const raw = readLocalStorageValue(AI_PANEL_DIAGNOSTIC_HIDE_KEY);
|
||||||
|
return new Set(
|
||||||
|
raw
|
||||||
|
.split(',')
|
||||||
|
.map((part) => part.trim().toLowerCase())
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAIPanelDiagnosticPartHidden(
|
||||||
|
part: AIPanelDiagnosticPart,
|
||||||
|
hiddenParts = getAIPanelDiagnosticHiddenParts(),
|
||||||
|
): boolean {
|
||||||
|
return hiddenParts.has('all') || hiddenParts.has(part);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAIPanelDiagnosticsProfilingEnabled(): boolean {
|
||||||
|
const raw = readLocalStorageValue(AI_PANEL_DIAGNOSTIC_PROFILE_KEY).trim().toLowerCase();
|
||||||
|
return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logAIPanelProfiler(
|
||||||
|
id: string,
|
||||||
|
phase: 'mount' | 'update' | 'nested-update',
|
||||||
|
actualDuration: number,
|
||||||
|
baseDuration: number,
|
||||||
|
): void {
|
||||||
|
if (!isAIPanelDiagnosticsProfilingEnabled()) return;
|
||||||
|
console.info(
|
||||||
|
`[AI panel profile] ${id} ${phase}: actual=${actualDuration.toFixed(1)}ms base=${baseDuration.toFixed(1)}ms`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function profileAIPanelCalculation<T>(label: string, calculate: () => T): T {
|
||||||
|
if (!isAIPanelDiagnosticsProfilingEnabled()) return calculate();
|
||||||
|
const startedAt = performance.now();
|
||||||
|
try {
|
||||||
|
return calculate();
|
||||||
|
} finally {
|
||||||
|
const elapsed = performance.now() - startedAt;
|
||||||
|
console.info(`[AI panel profile] ${label}: ${elapsed.toFixed(1)}ms`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAIPanelProfilerProps(id: string): Pick<React.ProfilerProps, 'id' | 'onRender'> {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
onRender: logAIPanelProfiler,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
import type { AISession } from '../../infrastructure/ai/types';
|
||||||
|
import type { DraftsByScope, PanelViewByScope } from './aiStateSnapshots';
|
||||||
|
|
||||||
|
type Listener = () => void;
|
||||||
|
|
||||||
|
export type AISessionsSnapshot = {
|
||||||
|
sessions: readonly AISession[];
|
||||||
|
activeSessionIdMap: Readonly<Record<string, string | null>>;
|
||||||
|
draftsByScope: DraftsByScope;
|
||||||
|
panelViewByScope: PanelViewByScope;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_SESSIONS: readonly AISession[] = Object.freeze([]);
|
||||||
|
const EMPTY_MAP: Readonly<Record<string, string | null>> = Object.freeze({});
|
||||||
|
const EMPTY_DRAFTS: DraftsByScope = Object.freeze({});
|
||||||
|
const EMPTY_PANEL: PanelViewByScope = Object.freeze({});
|
||||||
|
|
||||||
|
export const EMPTY_AI_SESSIONS_SNAPSHOT: AISessionsSnapshot = Object.freeze({
|
||||||
|
sessions: EMPTY_SESSIONS,
|
||||||
|
activeSessionIdMap: EMPTY_MAP,
|
||||||
|
draftsByScope: EMPTY_DRAFTS,
|
||||||
|
panelViewByScope: EMPTY_PANEL,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hot AI chat state — streaming message updates must not invalidate
|
||||||
|
* slow AI config Context consumers (providers, permissions, etc.).
|
||||||
|
*/
|
||||||
|
class AISessionsStore {
|
||||||
|
private snapshot: AISessionsSnapshot = EMPTY_AI_SESSIONS_SNAPSHOT;
|
||||||
|
private listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
getSnapshot = (): AISessionsSnapshot => this.snapshot;
|
||||||
|
|
||||||
|
subscribe = (listener: Listener): (() => void) => {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
setSnapshot(next: AISessionsSnapshot): void {
|
||||||
|
if (
|
||||||
|
this.snapshot.sessions === next.sessions
|
||||||
|
&& this.snapshot.activeSessionIdMap === next.activeSessionIdMap
|
||||||
|
&& this.snapshot.draftsByScope === next.draftsByScope
|
||||||
|
&& this.snapshot.panelViewByScope === next.panelViewByScope
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.snapshot = next;
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aiSessionsStore = new AISessionsStore();
|
||||||
|
|
||||||
|
export function publishAISessionsSnapshot(snapshot: AISessionsSnapshot): void {
|
||||||
|
aiSessionsStore.setSnapshot(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAISessionsSnapshot(): AISessionsSnapshot {
|
||||||
|
return aiSessionsStore.getSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeAISessions(listener: Listener): () => void {
|
||||||
|
return aiSessionsStore.subscribe(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAISessionsStore(): AISessionsSnapshot {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
subscribeAISessions,
|
||||||
|
getAISessionsSnapshot,
|
||||||
|
getAISessionsSnapshot,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAppSessionRuntime,
|
||||||
|
getAppSettingsRuntime,
|
||||||
|
getAppVaultRuntime,
|
||||||
|
registerAppSessionRuntime,
|
||||||
|
registerAppSettingsRuntime,
|
||||||
|
registerAppVaultRuntime,
|
||||||
|
subscribeAppSessionRuntime,
|
||||||
|
subscribeAppSettingsRuntime,
|
||||||
|
subscribeAppVaultRuntime,
|
||||||
|
type AppSessionRuntime,
|
||||||
|
type AppSettingsRuntime,
|
||||||
|
type AppVaultRuntime,
|
||||||
|
} from './appRuntimeBridge';
|
||||||
|
|
||||||
|
const vaultRuntime = (label: string) => ({ label }) as unknown as AppVaultRuntime;
|
||||||
|
const sessionRuntime = (label: string) => ({ label }) as unknown as AppSessionRuntime;
|
||||||
|
const settingsRuntime = (label: string) => ({ label }) as unknown as AppSettingsRuntime;
|
||||||
|
|
||||||
|
test('vault slot exposes the last registered runtime and notifies on change', () => {
|
||||||
|
let notifications = 0;
|
||||||
|
const unsubscribe = subscribeAppVaultRuntime(() => {
|
||||||
|
notifications += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = vaultRuntime('first');
|
||||||
|
registerAppVaultRuntime(first);
|
||||||
|
assert.equal(getAppVaultRuntime(), first);
|
||||||
|
assert.equal(notifications, 1);
|
||||||
|
|
||||||
|
// Re-registering the same identity is what a render with unchanged state
|
||||||
|
// does; it must not wake subscribers.
|
||||||
|
registerAppVaultRuntime(first);
|
||||||
|
assert.equal(notifications, 1);
|
||||||
|
|
||||||
|
const second = vaultRuntime('second');
|
||||||
|
registerAppVaultRuntime(second);
|
||||||
|
assert.equal(getAppVaultRuntime(), second);
|
||||||
|
assert.equal(notifications, 2);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
registerAppVaultRuntime(null);
|
||||||
|
assert.equal(getAppVaultRuntime(), null);
|
||||||
|
assert.equal(notifications, 2, 'unsubscribed listeners stay quiet');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('session slot is independent from the vault slot', () => {
|
||||||
|
let vaultNotifications = 0;
|
||||||
|
let sessionNotifications = 0;
|
||||||
|
const unsubscribeVault = subscribeAppVaultRuntime(() => {
|
||||||
|
vaultNotifications += 1;
|
||||||
|
});
|
||||||
|
const unsubscribeSession = subscribeAppSessionRuntime(() => {
|
||||||
|
sessionNotifications += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtime = sessionRuntime('session');
|
||||||
|
registerAppSessionRuntime(runtime);
|
||||||
|
assert.equal(getAppSessionRuntime(), runtime);
|
||||||
|
assert.equal(sessionNotifications, 1);
|
||||||
|
assert.equal(vaultNotifications, 0);
|
||||||
|
|
||||||
|
unsubscribeVault();
|
||||||
|
unsubscribeSession();
|
||||||
|
registerAppSessionRuntime(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settings slot is independent from the vault and session slots', () => {
|
||||||
|
let settingsNotifications = 0;
|
||||||
|
let otherNotifications = 0;
|
||||||
|
const unsubscribeSettings = subscribeAppSettingsRuntime(() => {
|
||||||
|
settingsNotifications += 1;
|
||||||
|
});
|
||||||
|
const unsubscribeVault = subscribeAppVaultRuntime(() => {
|
||||||
|
otherNotifications += 1;
|
||||||
|
});
|
||||||
|
const unsubscribeSession = subscribeAppSessionRuntime(() => {
|
||||||
|
otherNotifications += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtime = settingsRuntime('settings');
|
||||||
|
registerAppSettingsRuntime(runtime);
|
||||||
|
assert.equal(getAppSettingsRuntime(), runtime);
|
||||||
|
assert.equal(settingsNotifications, 1);
|
||||||
|
assert.equal(otherNotifications, 0);
|
||||||
|
|
||||||
|
// A re-render with unchanged state re-registers the same identity.
|
||||||
|
registerAppSettingsRuntime(runtime);
|
||||||
|
assert.equal(settingsNotifications, 1);
|
||||||
|
|
||||||
|
unsubscribeSettings();
|
||||||
|
unsubscribeVault();
|
||||||
|
unsubscribeSession();
|
||||||
|
registerAppSettingsRuntime(null);
|
||||||
|
assert.equal(getAppSettingsRuntime(), null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
import type { useSessionState } from './useSessionState';
|
||||||
|
import type { useSettingsState } from './useSettingsState';
|
||||||
|
import type { useVaultState } from './useVaultState';
|
||||||
|
|
||||||
|
export type AppVaultRuntime = ReturnType<typeof useVaultState>;
|
||||||
|
export type AppSessionRuntime = ReturnType<typeof useSessionState>;
|
||||||
|
export type AppSettingsRuntime = ReturnType<typeof useSettingsState>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The slice of the vault runtime that `VaultPublisher` puts on the React
|
||||||
|
* context.
|
||||||
|
*
|
||||||
|
* `notes` / `noteGroups` / `connectionLogs` / `shellHistory` are deliberately
|
||||||
|
* absent. They churn on every note keystroke, session start/exit, and history
|
||||||
|
* append, and publishing them here would re-render every context consumer for
|
||||||
|
* data those consumers only ever read at call time. Consumers subscribe to
|
||||||
|
* `notesStore` / `connectionLogsStore` / `shellHistoryStore` instead, or read
|
||||||
|
* the full runtime imperatively through `getAppVaultRuntime()`.
|
||||||
|
*
|
||||||
|
* `exportData` is omitted for the same reason: its callback identity tracks the
|
||||||
|
* notes arrays, so keeping it would reintroduce the churn it is meant to avoid.
|
||||||
|
*/
|
||||||
|
export type AppVaultContextValue = Omit<
|
||||||
|
AppVaultRuntime,
|
||||||
|
'notes' | 'noteGroups' | 'connectionLogs' | 'shellHistory' | 'exportData'
|
||||||
|
>;
|
||||||
|
|
||||||
|
type Listener = () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the live return of one mega hook so the component that owns the hook
|
||||||
|
* and the components that consume it no longer have to be the same component.
|
||||||
|
*
|
||||||
|
* `VaultPublisher` / `SessionPublisher` / `SettingsPublisher` own
|
||||||
|
* `useVaultState` / `useSessionState` / `useSettingsState` and expose the value
|
||||||
|
* two ways:
|
||||||
|
*
|
||||||
|
* - through a React context, for render-time consumers (App). Context keeps the
|
||||||
|
* read synchronous and non-null from the first render, which a store written
|
||||||
|
* in a layout effect cannot do for a descendant.
|
||||||
|
* - through this module slot, for imperative callers that need the current
|
||||||
|
* runtime outside of a render (`getAppVaultRuntime()` in an event handler or
|
||||||
|
* a bridge callback) and for non-descendant subscribers.
|
||||||
|
*/
|
||||||
|
class RuntimeSlot<T> {
|
||||||
|
private value: T | null = null;
|
||||||
|
private listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
get = (): T | null => this.value;
|
||||||
|
|
||||||
|
subscribe = (listener: Listener): (() => void) => {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
set(next: T | null): void {
|
||||||
|
if (this.value === next) return;
|
||||||
|
this.value = next;
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const vaultSlot = new RuntimeSlot<AppVaultRuntime>();
|
||||||
|
const sessionSlot = new RuntimeSlot<AppSessionRuntime>();
|
||||||
|
const settingsSlot = new RuntimeSlot<AppSettingsRuntime>();
|
||||||
|
|
||||||
|
export function registerAppVaultRuntime(runtime: AppVaultRuntime | null): void {
|
||||||
|
vaultSlot.set(runtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAppVaultRuntime(): AppVaultRuntime | null {
|
||||||
|
return vaultSlot.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeAppVaultRuntime(listener: Listener): () => void {
|
||||||
|
return vaultSlot.subscribe(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAppSessionRuntime(runtime: AppSessionRuntime | null): void {
|
||||||
|
sessionSlot.set(runtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAppSessionRuntime(): AppSessionRuntime | null {
|
||||||
|
return sessionSlot.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeAppSessionRuntime(listener: Listener): () => void {
|
||||||
|
return sessionSlot.subscribe(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAppSettingsRuntime(runtime: AppSettingsRuntime | null): void {
|
||||||
|
settingsSlot.set(runtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAppSettingsRuntime(): AppSettingsRuntime | null {
|
||||||
|
return settingsSlot.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeAppSettingsRuntime(listener: Listener): () => void {
|
||||||
|
return settingsSlot.subscribe(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppVaultRuntimeContext = createContext<AppVaultContextValue | null>(null);
|
||||||
|
export const AppSessionRuntimeContext = createContext<AppSessionRuntime | null>(null);
|
||||||
|
export const AppSettingsRuntimeContext = createContext<AppSettingsRuntime | null>(null);
|
||||||
|
|
||||||
|
/** Read the vault runtime owned by `VaultPublisher`. */
|
||||||
|
export function useAppVaultRuntime(): AppVaultContextValue {
|
||||||
|
const runtime = useContext(AppVaultRuntimeContext);
|
||||||
|
if (!runtime) {
|
||||||
|
throw new Error('useAppVaultRuntime must be rendered inside <VaultPublisher>');
|
||||||
|
}
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the session runtime owned by `SessionPublisher`. */
|
||||||
|
export function useAppSessionRuntime(): AppSessionRuntime {
|
||||||
|
const runtime = useContext(AppSessionRuntimeContext);
|
||||||
|
if (!runtime) {
|
||||||
|
throw new Error('useAppSessionRuntime must be rendered inside <SessionPublisher>');
|
||||||
|
}
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the settings runtime owned by `SettingsPublisher`. */
|
||||||
|
export function useAppSettingsRuntime(): AppSettingsRuntime {
|
||||||
|
const runtime = useContext(AppSettingsRuntimeContext);
|
||||||
|
if (!runtime) {
|
||||||
|
throw new Error('useAppSettingsRuntime must be rendered inside <SettingsPublisher>');
|
||||||
|
}
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAppearanceChromeSnapshot,
|
||||||
|
publishAppearanceChromeSnapshot,
|
||||||
|
subscribeAppearanceChrome,
|
||||||
|
} from './appearanceChromeStore.ts';
|
||||||
|
|
||||||
|
test('appearanceChromeStore notifies subscribers only when accent fields change', () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
const unsubscribe = subscribeAppearanceChrome(() => {
|
||||||
|
const snap = getAppearanceChromeSnapshot();
|
||||||
|
events.push(`${snap.accentMode}:${snap.customAccent}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
publishAppearanceChromeSnapshot({ accentMode: 'custom', customAccent: '#ff0000' });
|
||||||
|
assert.equal(events.at(-1), 'custom:#ff0000');
|
||||||
|
assert.equal(getAppearanceChromeSnapshot().customAccent, '#ff0000');
|
||||||
|
|
||||||
|
publishAppearanceChromeSnapshot({ accentMode: 'custom', customAccent: '#ff0000' });
|
||||||
|
assert.equal(events.length, 1);
|
||||||
|
|
||||||
|
publishAppearanceChromeSnapshot({ accentMode: 'custom', customAccent: '#00ff00' });
|
||||||
|
assert.equal(events.at(-1), 'custom:#00ff00');
|
||||||
|
|
||||||
|
publishAppearanceChromeSnapshot({ accentMode: 'theme', customAccent: '#00ff00' });
|
||||||
|
assert.equal(events.at(-1), 'theme:#00ff00');
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
type Listener = () => void;
|
||||||
|
|
||||||
|
export type AppearanceChromeSnapshot = {
|
||||||
|
accentMode: 'theme' | 'custom';
|
||||||
|
customAccent: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_SNAPSHOT: AppearanceChromeSnapshot = Object.freeze({
|
||||||
|
accentMode: 'theme',
|
||||||
|
customAccent: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* External store for app accent chrome so Terminal leaves can apply custom
|
||||||
|
* accent without TerminalLayer rebuilding on every color-picker drag tick.
|
||||||
|
*/
|
||||||
|
class AppearanceChromeStore {
|
||||||
|
private snapshot: AppearanceChromeSnapshot = DEFAULT_SNAPSHOT;
|
||||||
|
private listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
getSnapshot = (): AppearanceChromeSnapshot => this.snapshot;
|
||||||
|
|
||||||
|
subscribe = (listener: Listener): (() => void) => {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
setSnapshot(next: AppearanceChromeSnapshot): void {
|
||||||
|
if (
|
||||||
|
this.snapshot.accentMode === next.accentMode
|
||||||
|
&& this.snapshot.customAccent === next.customAccent
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.snapshot = next;
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appearanceChromeStore = new AppearanceChromeStore();
|
||||||
|
|
||||||
|
export function publishAppearanceChromeSnapshot(
|
||||||
|
snapshot: AppearanceChromeSnapshot,
|
||||||
|
): void {
|
||||||
|
appearanceChromeStore.setSnapshot(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAppearanceChromeSnapshot(): AppearanceChromeSnapshot {
|
||||||
|
return appearanceChromeStore.getSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeAppearanceChrome(listener: Listener): () => void {
|
||||||
|
return appearanceChromeStore.subscribe(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppearanceChromeStore(): AppearanceChromeSnapshot {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
subscribeAppearanceChrome,
|
||||||
|
getAppearanceChromeSnapshot,
|
||||||
|
getAppearanceChromeSnapshot,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -343,3 +343,26 @@ test("the race guards are wired into the real settings paths", () => {
|
|||||||
assert.match(popupSource, /terminalTheme=\{settings\.currentTerminalTheme\}/);
|
assert.match(popupSource, /terminalTheme=\{settings\.currentTerminalTheme\}/);
|
||||||
assert.match(popupSource, /followAppTerminalTheme=\{settings\.followAppTerminalTheme\}/);
|
assert.match(popupSource, /followAppTerminalTheme=\{settings\.followAppTerminalTheme\}/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("cloud sync rehydrate picks up follow-app terminal theme from storage", () => {
|
||||||
|
// applySyncPayload writes followAppTerminalTheme to localStorage, then calls
|
||||||
|
// rehydrateAllFromStorage. Without reading TERM_FOLLOW_APP_THEME there, the
|
||||||
|
// open window keeps the pre-sync follow-app flag while terminalThemeId updates
|
||||||
|
// — black default vs synced yellow theme flicker (#2757).
|
||||||
|
const stateSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
|
||||||
|
const rehydrateIndex = stateSource.indexOf("const rehydrateAllFromStorage = useCallback(() => {");
|
||||||
|
assert.ok(rehydrateIndex >= 0, "rehydrateAllFromStorage must exist");
|
||||||
|
const nextCallbackIndex = stateSource.indexOf("}, [applyIncomingCustomKeyBindings", rehydrateIndex);
|
||||||
|
assert.ok(nextCallbackIndex > rehydrateIndex, "rehydrate callback body must be bounded");
|
||||||
|
const rehydrateBody = stateSource.slice(rehydrateIndex, nextCallbackIndex);
|
||||||
|
assert.match(
|
||||||
|
rehydrateBody,
|
||||||
|
/STORAGE_KEY_TERM_FOLLOW_APP_THEME/,
|
||||||
|
"rehydrate must read the follow-app terminal theme key written by applySyncableSettings",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
rehydrateBody,
|
||||||
|
/setFollowAppTerminalThemeState/,
|
||||||
|
"rehydrate must update React follow-app state from storage after cloud sync",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import type { ConnectionLog } from '../../domain/models.ts';
|
||||||
|
import {
|
||||||
|
EMPTY_CONNECTION_LOGS_SNAPSHOT,
|
||||||
|
getConnectionLogsActions,
|
||||||
|
getConnectionLogsSnapshot,
|
||||||
|
getEmptyConnectionLogsSnapshot,
|
||||||
|
publishConnectionLogsSnapshot,
|
||||||
|
registerConnectionLogsActions,
|
||||||
|
subscribeConnectionLogs,
|
||||||
|
subscribeConnectionLogsActions,
|
||||||
|
} from './connectionLogsStore.ts';
|
||||||
|
|
||||||
|
const baseLog: ConnectionLog = {
|
||||||
|
id: 'log-1',
|
||||||
|
sessionId: 'session-1',
|
||||||
|
hostId: 'host-1',
|
||||||
|
hostLabel: 'Example',
|
||||||
|
hostname: 'example.com',
|
||||||
|
username: 'user',
|
||||||
|
protocol: 'ssh',
|
||||||
|
startTime: 1000,
|
||||||
|
localUsername: 'local',
|
||||||
|
localHostname: 'machine',
|
||||||
|
saved: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
test('connectionLogsStore notifies subscribers only when the log array identity changes', () => {
|
||||||
|
const events: number[] = [];
|
||||||
|
const unsubscribe = subscribeConnectionLogs(() => {
|
||||||
|
events.push(getConnectionLogsSnapshot().connectionLogs.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstLogs = [baseLog];
|
||||||
|
publishConnectionLogsSnapshot({ connectionLogs: firstLogs });
|
||||||
|
assert.equal(events.at(-1), 1);
|
||||||
|
assert.equal(getConnectionLogsSnapshot().connectionLogs, firstLogs);
|
||||||
|
|
||||||
|
// Same identity must not wake the Vault logs section on unrelated renders.
|
||||||
|
publishConnectionLogsSnapshot({ connectionLogs: firstLogs });
|
||||||
|
assert.equal(events.length, 1);
|
||||||
|
|
||||||
|
const secondLogs = [baseLog, { ...baseLog, id: 'log-2', startTime: 2000 }];
|
||||||
|
publishConnectionLogsSnapshot({ connectionLogs: secondLogs });
|
||||||
|
assert.equal(events.at(-1), 2);
|
||||||
|
assert.equal(events.length, 2);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
publishConnectionLogsSnapshot({ connectionLogs: [] });
|
||||||
|
assert.equal(events.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('connectionLogsStore exposes a frozen empty snapshot for gated mounts', () => {
|
||||||
|
assert.equal(getEmptyConnectionLogsSnapshot(), EMPTY_CONNECTION_LOGS_SNAPSHOT);
|
||||||
|
assert.equal(getEmptyConnectionLogsSnapshot().connectionLogs.length, 0);
|
||||||
|
assert.equal(Object.isFrozen(EMPTY_CONNECTION_LOGS_SNAPSHOT), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registerConnectionLogsActions exposes the vault log mutators', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const actionEvents: number[] = [];
|
||||||
|
const unsubscribe = subscribeConnectionLogsActions(() => {
|
||||||
|
actionEvents.push(actionEvents.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
registerConnectionLogsActions({
|
||||||
|
updateConnectionLog: () => {
|
||||||
|
calls.push('update');
|
||||||
|
},
|
||||||
|
toggleConnectionLogSaved: () => {
|
||||||
|
calls.push('toggle');
|
||||||
|
},
|
||||||
|
deleteConnectionLog: () => {
|
||||||
|
calls.push('delete');
|
||||||
|
},
|
||||||
|
clearUnsavedConnectionLogs: () => {
|
||||||
|
calls.push('clear');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const actions = getConnectionLogsActions();
|
||||||
|
assert.ok(actions);
|
||||||
|
actions.updateConnectionLog('log-1', { saved: true });
|
||||||
|
actions.toggleConnectionLogSaved('log-1');
|
||||||
|
actions.deleteConnectionLog('log-1');
|
||||||
|
actions.clearUnsavedConnectionLogs();
|
||||||
|
assert.deepEqual(calls, ['update', 'toggle', 'delete', 'clear']);
|
||||||
|
assert.equal(actionEvents.length, 1);
|
||||||
|
|
||||||
|
registerConnectionLogsActions(null);
|
||||||
|
assert.equal(getConnectionLogsActions(), null);
|
||||||
|
assert.equal(actionEvents.length, 2);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('connectionLogsStore keeps values and actions on independent slots', () => {
|
||||||
|
const valueEvents: number[] = [];
|
||||||
|
const actionEvents: number[] = [];
|
||||||
|
const unsubValues = subscribeConnectionLogs(() => {
|
||||||
|
valueEvents.push(valueEvents.length);
|
||||||
|
});
|
||||||
|
const unsubActions = subscribeConnectionLogsActions(() => {
|
||||||
|
actionEvents.push(actionEvents.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-registering unstable mutator identities must not invalidate the logs.
|
||||||
|
registerConnectionLogsActions({
|
||||||
|
updateConnectionLog: () => {},
|
||||||
|
toggleConnectionLogSaved: () => {},
|
||||||
|
deleteConnectionLog: () => {},
|
||||||
|
clearUnsavedConnectionLogs: () => {},
|
||||||
|
});
|
||||||
|
assert.equal(valueEvents.length, 0);
|
||||||
|
assert.equal(actionEvents.length, 1);
|
||||||
|
|
||||||
|
publishConnectionLogsSnapshot({ connectionLogs: [baseLog] });
|
||||||
|
assert.equal(valueEvents.length, 1);
|
||||||
|
assert.equal(actionEvents.length, 1);
|
||||||
|
|
||||||
|
registerConnectionLogsActions(null);
|
||||||
|
unsubValues();
|
||||||
|
unsubActions();
|
||||||
|
});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
import type { ConnectionLog } from '../../domain/models';
|
||||||
|
|
||||||
|
type Listener = () => void;
|
||||||
|
|
||||||
|
export type ConnectionLogsSnapshot = {
|
||||||
|
connectionLogs: readonly ConnectionLog[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConnectionLogsActions = {
|
||||||
|
updateConnectionLog: (id: string, updates: Partial<ConnectionLog>) => void;
|
||||||
|
toggleConnectionLogSaved: (id: string) => void;
|
||||||
|
deleteConnectionLog: (id: string) => void;
|
||||||
|
clearUnsavedConnectionLogs: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_CONNECTION_LOGS: readonly ConnectionLog[] = Object.freeze([]);
|
||||||
|
|
||||||
|
export const EMPTY_CONNECTION_LOGS_SNAPSHOT: ConnectionLogsSnapshot = Object.freeze({
|
||||||
|
connectionLogs: EMPTY_CONNECTION_LOGS,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* External store for connection logs so the Vault logs section and log-view
|
||||||
|
* replays can subscribe without keeping logs in the App domain bags. Every
|
||||||
|
* session start/exit appends a log, which would otherwise rebuild the whole
|
||||||
|
* shell.
|
||||||
|
*/
|
||||||
|
class ConnectionLogsStore {
|
||||||
|
private snapshot: ConnectionLogsSnapshot = EMPTY_CONNECTION_LOGS_SNAPSHOT;
|
||||||
|
private actions: ConnectionLogsActions | null = null;
|
||||||
|
private listeners = new Set<Listener>();
|
||||||
|
private actionListeners = new Set<Listener>();
|
||||||
|
|
||||||
|
getSnapshot = (): ConnectionLogsSnapshot => this.snapshot;
|
||||||
|
|
||||||
|
subscribe = (listener: Listener): (() => void) => {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
setSnapshot(next: ConnectionLogsSnapshot): void {
|
||||||
|
if (this.snapshot.connectionLogs === next.connectionLogs) return;
|
||||||
|
this.snapshot = next;
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getActions = (): ConnectionLogsActions | null => this.actions;
|
||||||
|
|
||||||
|
subscribeActions = (listener: Listener): (() => void) => {
|
||||||
|
this.actionListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.actionListeners.delete(listener);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
setActions(next: ConnectionLogsActions | null): void {
|
||||||
|
if (this.actions === next) return;
|
||||||
|
this.actions = next;
|
||||||
|
for (const listener of this.actionListeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const connectionLogsStore = new ConnectionLogsStore();
|
||||||
|
|
||||||
|
export function publishConnectionLogsSnapshot(
|
||||||
|
snapshot: ConnectionLogsSnapshot,
|
||||||
|
): void {
|
||||||
|
connectionLogsStore.setSnapshot(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConnectionLogsSnapshot(): ConnectionLogsSnapshot {
|
||||||
|
return connectionLogsStore.getSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeConnectionLogs(listener: Listener): () => void {
|
||||||
|
return connectionLogsStore.subscribe(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEmptyConnectionLogsSnapshot(): ConnectionLogsSnapshot {
|
||||||
|
return EMPTY_CONNECTION_LOGS_SNAPSHOT;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerConnectionLogsActions(
|
||||||
|
actions: ConnectionLogsActions | null,
|
||||||
|
): void {
|
||||||
|
connectionLogsStore.setActions(actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConnectionLogsActions(): ConnectionLogsActions | null {
|
||||||
|
return connectionLogsStore.getActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeConnectionLogsActions(listener: Listener): () => void {
|
||||||
|
return connectionLogsStore.subscribeActions(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
const noopUpdateConnectionLog: ConnectionLogsActions['updateConnectionLog'] = () => {};
|
||||||
|
const noopToggleConnectionLogSaved: ConnectionLogsActions['toggleConnectionLogSaved'] = () => {};
|
||||||
|
const noopDeleteConnectionLog: ConnectionLogsActions['deleteConnectionLog'] = () => {};
|
||||||
|
const noopClearUnsavedConnectionLogs: ConnectionLogsActions['clearUnsavedConnectionLogs'] = () => {};
|
||||||
|
|
||||||
|
/** Subscribe to the connection log catalog plus its vault mutation actions. */
|
||||||
|
export function useConnectionLogsStore(): {
|
||||||
|
connectionLogs: ConnectionLog[];
|
||||||
|
updateConnectionLog: ConnectionLogsActions['updateConnectionLog'];
|
||||||
|
toggleConnectionLogSaved: ConnectionLogsActions['toggleConnectionLogSaved'];
|
||||||
|
deleteConnectionLog: ConnectionLogsActions['deleteConnectionLog'];
|
||||||
|
clearUnsavedConnectionLogs: ConnectionLogsActions['clearUnsavedConnectionLogs'];
|
||||||
|
} {
|
||||||
|
const snapshot = useSyncExternalStore(
|
||||||
|
subscribeConnectionLogs,
|
||||||
|
getConnectionLogsSnapshot,
|
||||||
|
getConnectionLogsSnapshot,
|
||||||
|
);
|
||||||
|
const actions = useSyncExternalStore(
|
||||||
|
subscribeConnectionLogsActions,
|
||||||
|
getConnectionLogsActions,
|
||||||
|
getConnectionLogsActions,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
connectionLogs: snapshot.connectionLogs as ConnectionLog[],
|
||||||
|
updateConnectionLog: actions?.updateConnectionLog ?? noopUpdateConnectionLog,
|
||||||
|
toggleConnectionLogSaved: actions?.toggleConnectionLogSaved ?? noopToggleConnectionLogSaved,
|
||||||
|
deleteConnectionLog: actions?.deleteConnectionLog ?? noopDeleteConnectionLog,
|
||||||
|
clearUnsavedConnectionLogs: actions?.clearUnsavedConnectionLogs ?? noopClearUnsavedConnectionLogs,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -77,7 +77,7 @@ export function shouldApplyCustomAccentRecord(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Decide whether a custom-accent state change should be rebroadcast to peer
|
* Decide whether a custom-accent state change should be rebroadcast to peer
|
||||||
* windows. Incoming IPC/storage updates must not notify again — otherwise a
|
* windows. Incoming IPC/storage updates must not notify again - otherwise a
|
||||||
* fast native color-picker drag in the settings window ping-pongs with the
|
* fast native color-picker drag in the settings window ping-pongs with the
|
||||||
* main window and the accent CSS variables oscillate (see #2743, same class
|
* main window and the accent CSS variables oscillate (see #2743, same class
|
||||||
* as window-opacity #2018).
|
* as window-opacity #2018).
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ test("loadDefaultKeyPassphrase removes undecryptable credential placeholders", a
|
|||||||
globalThis.localStorage.setItem(
|
globalThis.localStorage.setItem(
|
||||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
[keyPath]: "enc:v1:djEwYWJj",
|
[keyPath]: "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
|
||||||
"/Users/alice/.ssh/id_rsa": "still-valid",
|
"/Users/alice/.ssh/id_rsa": "still-valid",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -206,7 +206,7 @@ test("loadDefaultKeyPassphrase removes undecryptable credential placeholders", a
|
|||||||
test("export read reports unavailable encrypted passphrases without deleting them", async (t) => {
|
test("export read reports unavailable encrypted passphrases without deleting them", async (t) => {
|
||||||
installLocalStorage(t);
|
installLocalStorage(t);
|
||||||
const keyPath = "/Users/alice/.ssh/id_ed25519";
|
const keyPath = "/Users/alice/.ssh/id_ed25519";
|
||||||
const encrypted = "enc:v1:djEwYWJj";
|
const encrypted = "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==";
|
||||||
globalThis.localStorage.setItem(
|
globalThis.localStorage.setItem(
|
||||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||||
JSON.stringify({ [keyPath]: encrypted }),
|
JSON.stringify({ [keyPath]: encrypted }),
|
||||||
@@ -225,7 +225,7 @@ test("export read reports unavailable encrypted passphrases without deleting the
|
|||||||
test("export read retries when the passphrase changes during decryption", async (t) => {
|
test("export read retries when the passphrase changes during decryption", async (t) => {
|
||||||
installLocalStorage(t);
|
installLocalStorage(t);
|
||||||
const keyPath = "/Users/alice/.ssh/id_ed25519";
|
const keyPath = "/Users/alice/.ssh/id_ed25519";
|
||||||
const encrypted = "enc:v1:djEwYWJj";
|
const encrypted = "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==";
|
||||||
let releaseDecrypt: (() => void) | undefined;
|
let releaseDecrypt: (() => void) | undefined;
|
||||||
const decryptGate = new Promise<void>((resolve) => {
|
const decryptGate = new Promise<void>((resolve) => {
|
||||||
releaseDecrypt = resolve;
|
releaseDecrypt = resolve;
|
||||||
@@ -286,7 +286,7 @@ test("remembered passphrase read marks encrypted Keychain placeholders unreadabl
|
|||||||
installLocalStorage(t);
|
installLocalStorage(t);
|
||||||
const key = {
|
const key = {
|
||||||
...referenceKey(),
|
...referenceKey(),
|
||||||
passphrase: "enc:v1:djEwYWJj",
|
passphrase: "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
|
||||||
savePassphrase: true,
|
savePassphrase: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -311,7 +311,7 @@ test("passphrase verification reads every alias and preserves unreadable state",
|
|||||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
"~/.ssh/id_ed25519": "readable-secret",
|
"~/.ssh/id_ed25519": "readable-secret",
|
||||||
"/Users/alice/.ssh/id_ed25519": "enc:v1:djEwYWJj",
|
"/Users/alice/.ssh/id_ed25519": "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -455,7 +455,7 @@ test("loadDefaultKeyPassphrase cleanup preserves a passphrase saved concurrently
|
|||||||
});
|
});
|
||||||
globalThis.localStorage.setItem(
|
globalThis.localStorage.setItem(
|
||||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||||
JSON.stringify({ "/Users/alice/.ssh/id_old": "enc:v1:djEwYWJj" }),
|
JSON.stringify({ "/Users/alice/.ssh/id_old": "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const pendingLoad = loadDefaultKeyPassphrase("/Users/alice/.ssh/id_old");
|
const pendingLoad = loadDefaultKeyPassphrase("/Users/alice/.ssh/id_old");
|
||||||
@@ -549,7 +549,7 @@ test("loadDefaultKeyPassphrase prefers an exact saved path over a stale alias",
|
|||||||
globalThis.localStorage.setItem(
|
globalThis.localStorage.setItem(
|
||||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
"~/.ssh/id_ed25519": "enc:v1:djEwYWJj",
|
"~/.ssh/id_ed25519": "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
|
||||||
"/Users/alice/.ssh/id_ed25519": "valid-exact-passphrase",
|
"/Users/alice/.ssh/id_ed25519": "valid-exact-passphrase",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -574,7 +574,7 @@ test("loadDefaultKeyPassphrase falls back to a valid alias and removes an invali
|
|||||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
"~/.ssh/id_ed25519": "valid-alias-passphrase",
|
"~/.ssh/id_ed25519": "valid-alias-passphrase",
|
||||||
"/Users/alice/.ssh/id_ed25519": "enc:v1:djEwYWJj",
|
"/Users/alice/.ssh/id_ed25519": "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -913,7 +913,7 @@ test("shouldUpdateReferenceKeyPassphrase replaces missing or undecryptable passp
|
|||||||
assert.equal(
|
assert.equal(
|
||||||
shouldUpdateReferenceKeyPassphrase({
|
shouldUpdateReferenceKeyPassphrase({
|
||||||
...referenceKey(),
|
...referenceKey(),
|
||||||
passphrase: "enc:v1:djEwAAAA",
|
passphrase: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||||
}),
|
}),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import type { TerminalSession } from "../../domain/models";
|
|||||||
|
|
||||||
export type SessionPwdProbe = (
|
export type SessionPwdProbe = (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
options?: { allowHomeFallback?: boolean; timeoutMs?: number },
|
options?: {
|
||||||
|
allowHomeFallback?: boolean;
|
||||||
|
allowLoginShellFallback?: boolean;
|
||||||
|
timeoutMs?: number;
|
||||||
|
},
|
||||||
) => Promise<{ success: boolean; cwd?: string }>;
|
) => Promise<{ success: boolean; cwd?: string }>;
|
||||||
|
|
||||||
type CaptureSession = Pick<TerminalSession, "id" | "protocol" | "status" | "lastCwd" | "localStartDir">;
|
type CaptureSession = Pick<TerminalSession, "id" | "protocol" | "status" | "lastCwd" | "localStartDir">;
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
EMPTY_NOTES_SNAPSHOT,
|
||||||
|
getEmptyNotesSnapshot,
|
||||||
|
getNotesActions,
|
||||||
|
getNotesSnapshot,
|
||||||
|
publishNotesSnapshot,
|
||||||
|
registerNotesActions,
|
||||||
|
subscribeNotes,
|
||||||
|
subscribeNotesNoop,
|
||||||
|
} from './notesStore.ts';
|
||||||
|
|
||||||
|
test('notesStore notifies subscribers only when snapshot identity changes', () => {
|
||||||
|
const events: number[] = [];
|
||||||
|
const unsubscribe = subscribeNotes(() => {
|
||||||
|
events.push(getNotesSnapshot().notes.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstNotes = [{
|
||||||
|
id: 'n1',
|
||||||
|
title: 'One',
|
||||||
|
content: 'body',
|
||||||
|
tags: [],
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
order: 1000,
|
||||||
|
}];
|
||||||
|
const firstGroups = ['Ops'];
|
||||||
|
publishNotesSnapshot({ notes: firstNotes, noteGroups: firstGroups });
|
||||||
|
assert.equal(events.at(-1), 1);
|
||||||
|
assert.equal(getNotesSnapshot().notes, firstNotes);
|
||||||
|
assert.equal(getNotesSnapshot().noteGroups, firstGroups);
|
||||||
|
|
||||||
|
publishNotesSnapshot({ notes: firstNotes, noteGroups: firstGroups });
|
||||||
|
assert.equal(events.length, 1);
|
||||||
|
|
||||||
|
const secondNotes = [
|
||||||
|
...firstNotes,
|
||||||
|
{
|
||||||
|
id: 'n2',
|
||||||
|
title: 'Two',
|
||||||
|
content: 'more',
|
||||||
|
tags: [],
|
||||||
|
createdAt: 2,
|
||||||
|
updatedAt: 2,
|
||||||
|
order: 2000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
publishNotesSnapshot({ notes: secondNotes, noteGroups: firstGroups });
|
||||||
|
assert.equal(events.at(-1), 2);
|
||||||
|
|
||||||
|
publishNotesSnapshot({ notes: secondNotes, noteGroups: ['Ops', 'DB'] });
|
||||||
|
assert.equal(events.length, 3);
|
||||||
|
assert.deepEqual(getNotesSnapshot().noteGroups, ['Ops', 'DB']);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notesStore gated helpers stay empty and never notify', () => {
|
||||||
|
let called = 0;
|
||||||
|
const unsub = subscribeNotesNoop(() => {
|
||||||
|
called += 1;
|
||||||
|
});
|
||||||
|
publishNotesSnapshot({
|
||||||
|
notes: [{
|
||||||
|
id: 'n1',
|
||||||
|
title: 'One',
|
||||||
|
content: 'body',
|
||||||
|
tags: [],
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
order: 1000,
|
||||||
|
}],
|
||||||
|
noteGroups: ['Ops'],
|
||||||
|
});
|
||||||
|
assert.equal(called, 0);
|
||||||
|
assert.equal(getEmptyNotesSnapshot(), EMPTY_NOTES_SNAPSHOT);
|
||||||
|
assert.equal(getEmptyNotesSnapshot().notes.length, 0);
|
||||||
|
unsub();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('useNotesStore source gates subscribe when enabled is false', async () => {
|
||||||
|
const { readFileSync } = await import('node:fs');
|
||||||
|
const source = readFileSync(new URL('./notesStore.ts', import.meta.url), 'utf8');
|
||||||
|
assert.match(source, /enabled \? subscribeNotes : subscribeNotesNoop/);
|
||||||
|
assert.match(source, /enabled \? subscribeNotesActions : subscribeNotesNoop/);
|
||||||
|
assert.match(source, /getNotesSnapshot/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
source,
|
||||||
|
/enabled \? getNotesSnapshot : getEmptyNotesSnapshot/,
|
||||||
|
'hidden notes mounts should keep the last live snapshot, not flash empty',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registerNotesActions exposes update handlers', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
registerNotesActions({
|
||||||
|
updateNotes: () => {
|
||||||
|
calls.push('notes');
|
||||||
|
},
|
||||||
|
updateNoteGroups: () => {
|
||||||
|
calls.push('groups');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const actions = getNotesActions();
|
||||||
|
assert.ok(actions);
|
||||||
|
actions!.updateNotes([]);
|
||||||
|
actions!.updateNoteGroups([]);
|
||||||
|
assert.deepEqual(calls, ['notes', 'groups']);
|
||||||
|
registerNotesActions(null);
|
||||||
|
assert.equal(getNotesActions(), null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
import type { VaultNote } from '../../domain/models';
|
||||||
|
|
||||||
|
type Listener = () => void;
|
||||||
|
|
||||||
|
export type NotesSnapshot = {
|
||||||
|
notes: readonly VaultNote[];
|
||||||
|
noteGroups: readonly string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NotesActions = {
|
||||||
|
updateNotes: (notes: VaultNote[]) => boolean | void;
|
||||||
|
updateNoteGroups: (groups: string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_NOTES: readonly VaultNote[] = Object.freeze([]);
|
||||||
|
const EMPTY_NOTE_GROUPS: readonly string[] = Object.freeze([]);
|
||||||
|
|
||||||
|
export const EMPTY_NOTES_SNAPSHOT: NotesSnapshot = Object.freeze({
|
||||||
|
notes: EMPTY_NOTES,
|
||||||
|
noteGroups: EMPTY_NOTE_GROUPS,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* External store for vault notes so Notes / AI side-panel consumers can
|
||||||
|
* subscribe without forcing TerminalLayer re-renders on every note edit.
|
||||||
|
*/
|
||||||
|
class NotesStore {
|
||||||
|
private snapshot: NotesSnapshot = EMPTY_NOTES_SNAPSHOT;
|
||||||
|
private actions: NotesActions | null = null;
|
||||||
|
private listeners = new Set<Listener>();
|
||||||
|
private actionListeners = new Set<Listener>();
|
||||||
|
|
||||||
|
getSnapshot = (): NotesSnapshot => this.snapshot;
|
||||||
|
|
||||||
|
subscribe = (listener: Listener): (() => void) => {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
setSnapshot(next: NotesSnapshot): void {
|
||||||
|
if (
|
||||||
|
this.snapshot.notes === next.notes
|
||||||
|
&& this.snapshot.noteGroups === next.noteGroups
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.snapshot = next;
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getActions = (): NotesActions | null => this.actions;
|
||||||
|
|
||||||
|
subscribeActions = (listener: Listener): (() => void) => {
|
||||||
|
this.actionListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.actionListeners.delete(listener);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
setActions(next: NotesActions | null): void {
|
||||||
|
if (this.actions === next) return;
|
||||||
|
this.actions = next;
|
||||||
|
for (const listener of this.actionListeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notesStore = new NotesStore();
|
||||||
|
|
||||||
|
export function publishNotesSnapshot(snapshot: NotesSnapshot): void {
|
||||||
|
notesStore.setSnapshot(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNotesSnapshot(): NotesSnapshot {
|
||||||
|
return notesStore.getSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeNotes(listener: Listener): () => void {
|
||||||
|
return notesStore.subscribe(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No-op subscribe for gated (hidden) panel mounts. */
|
||||||
|
export function subscribeNotesNoop(_listener: Listener): () => void {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEmptyNotesSnapshot(): NotesSnapshot {
|
||||||
|
return EMPTY_NOTES_SNAPSHOT;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerNotesActions(actions: NotesActions | null): void {
|
||||||
|
notesStore.setActions(actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNotesActions(): NotesActions | null {
|
||||||
|
return notesStore.getActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeNotesActions(listener: Listener): () => void {
|
||||||
|
return notesStore.subscribeActions(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
const noopUpdateNotes: NotesActions['updateNotes'] = () => {};
|
||||||
|
const noopUpdateNoteGroups: NotesActions['updateNoteGroups'] = () => {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to notes catalog + vault mutation actions for Notes / AI panels.
|
||||||
|
*
|
||||||
|
* Pass `{ enabled: false }` for retained-but-hidden mounts (e.g. terminal
|
||||||
|
* notes side panel) so vault note publishes do not re-render them. Snapshot
|
||||||
|
* reads still use the live store so reopen does not flash empty data.
|
||||||
|
*/
|
||||||
|
export function useNotesStore(options?: { enabled?: boolean }): {
|
||||||
|
notes: VaultNote[];
|
||||||
|
noteGroups: string[];
|
||||||
|
updateNotes: NotesActions['updateNotes'];
|
||||||
|
updateNoteGroups: NotesActions['updateNoteGroups'];
|
||||||
|
} {
|
||||||
|
const enabled = options?.enabled !== false;
|
||||||
|
const snapshot = useSyncExternalStore(
|
||||||
|
enabled ? subscribeNotes : subscribeNotesNoop,
|
||||||
|
getNotesSnapshot,
|
||||||
|
getNotesSnapshot,
|
||||||
|
);
|
||||||
|
const actions = useSyncExternalStore(
|
||||||
|
enabled ? subscribeNotesActions : subscribeNotesNoop,
|
||||||
|
getNotesActions,
|
||||||
|
getNotesActions,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
notes: snapshot.notes as VaultNote[],
|
||||||
|
noteGroups: snapshot.noteGroups as string[],
|
||||||
|
updateNotes: actions?.updateNotes ?? noopUpdateNotes,
|
||||||
|
updateNoteGroups: actions?.updateNoteGroups ?? noopUpdateNoteGroups,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
|
runConnectScriptsSequential,
|
||||||
selectScriptOverlayRun,
|
selectScriptOverlayRun,
|
||||||
setScriptRuns,
|
setScriptRuns,
|
||||||
waitForScriptRun,
|
waitForScriptRun,
|
||||||
} from './scriptAutomationCoordinator.ts';
|
} from './scriptAutomationCoordinator.ts';
|
||||||
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
|
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
|
||||||
|
import type { Snippet } from '@/domain/models';
|
||||||
|
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
|
||||||
|
|
||||||
test('waitForScriptRun resolves when run is already completed on subscribe', async () => {
|
test('waitForScriptRun resolves when run is already completed on subscribe', async () => {
|
||||||
const runId = 'run-already-done';
|
const runId = 'run-already-done';
|
||||||
@@ -64,3 +67,182 @@ test('selectScriptOverlayRun does not resurface older completed runs after dismi
|
|||||||
dismissedRunIds.add(latestRun.runId);
|
dismissedRunIds.add(latestRun.runId);
|
||||||
assert.equal(selectScriptOverlayRun([olderRun, latestRun], 'sess1', dismissedRunIds), undefined);
|
assert.equal(selectScriptOverlayRun([olderRun, latestRun], 'sess1', dismissedRunIds), undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('runConnectScriptsSequential cancels only its own queued run and waits for stop', async () => {
|
||||||
|
const originalGet = netcattyBridge.get;
|
||||||
|
const sessionId = 'sess-connect-abort';
|
||||||
|
let queuedRunId: string | undefined;
|
||||||
|
let releaseStop: (() => void) | undefined;
|
||||||
|
const scriptStopCalls: string[] = [];
|
||||||
|
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, String(value)); },
|
||||||
|
removeItem: (key: string) => { storage.delete(key); },
|
||||||
|
clear: () => { storage.clear(); },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setScriptRuns([{
|
||||||
|
runId: 'unrelated-active-run',
|
||||||
|
scriptId: 'manual-script',
|
||||||
|
sessionId,
|
||||||
|
status: 'running',
|
||||||
|
startedAt: Date.now(),
|
||||||
|
logs: [],
|
||||||
|
}]);
|
||||||
|
|
||||||
|
netcattyBridge.get = () => ({
|
||||||
|
scriptRun: async (params) => {
|
||||||
|
queuedRunId = params.runId;
|
||||||
|
assert.equal(params.returnWhenQueued, true);
|
||||||
|
return { runId: params.runId!, runIds: [params.runId!] };
|
||||||
|
},
|
||||||
|
scriptStop: (id: string) => new Promise((resolve) => {
|
||||||
|
scriptStopCalls.push(id);
|
||||||
|
releaseStop = () => resolve({ ok: true });
|
||||||
|
}),
|
||||||
|
}) as ReturnType<typeof netcattyBridge.get>;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
let stopCurrentRun: (() => Promise<void>) | null = null;
|
||||||
|
const snippet: Snippet = {
|
||||||
|
id: 'connect-script',
|
||||||
|
label: 'Connect',
|
||||||
|
command: 'nct.session.sleep(60)',
|
||||||
|
kind: 'script',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const running = runConnectScriptsSequential({
|
||||||
|
scripts: [snippet],
|
||||||
|
sessionId,
|
||||||
|
signal: controller.signal,
|
||||||
|
onCancelableRunChange: (stop) => { stopCurrentRun = stop; },
|
||||||
|
});
|
||||||
|
void running.catch(() => {});
|
||||||
|
await Promise.resolve();
|
||||||
|
controller.abort();
|
||||||
|
for (let attempt = 0; attempt < 10 && scriptStopCalls.length === 0; attempt += 1) {
|
||||||
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
assert.deepEqual(scriptStopCalls, [queuedRunId]);
|
||||||
|
assert.notEqual(queuedRunId, 'unrelated-active-run');
|
||||||
|
assert.equal(typeof stopCurrentRun, 'function');
|
||||||
|
|
||||||
|
let settled = false;
|
||||||
|
void running.finally(() => { settled = true; }).catch(() => {});
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(settled, false);
|
||||||
|
|
||||||
|
releaseStop?.();
|
||||||
|
await assert.rejects(
|
||||||
|
() => running,
|
||||||
|
(error: unknown) => error instanceof DOMException && error.name === 'AbortError',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
netcattyBridge.get = originalGet;
|
||||||
|
setScriptRuns([]);
|
||||||
|
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runConnectScriptsSequential retries the exact stop after a transient failure', async () => {
|
||||||
|
const originalGet = netcattyBridge.get;
|
||||||
|
const storage = new Map<string, string>();
|
||||||
|
const stopCalls: string[] = [];
|
||||||
|
let stopCurrentRun: (() => Promise<void>) | null = null;
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
getItem: (key: string) => storage.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
|
||||||
|
removeItem: (key: string) => { storage.delete(key); },
|
||||||
|
clear: () => { storage.clear(); },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
netcattyBridge.get = () => ({
|
||||||
|
scriptRun: async (params) => ({ runId: params.runId!, runIds: [params.runId!] }),
|
||||||
|
scriptStop: async (id: string) => {
|
||||||
|
stopCalls.push(id);
|
||||||
|
return { ok: stopCalls.length > 1 };
|
||||||
|
},
|
||||||
|
}) as ReturnType<typeof netcattyBridge.get>;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
try {
|
||||||
|
const running = runConnectScriptsSequential({
|
||||||
|
scripts: [{ id: 'retry-stop', label: 'Retry stop', command: 'await nct.sleep(60)', kind: 'script' }],
|
||||||
|
sessionId: 'sess-retry-stop',
|
||||||
|
signal: controller.signal,
|
||||||
|
onCancelableRunChange: (stop) => { stopCurrentRun = stop; },
|
||||||
|
});
|
||||||
|
void running.catch(() => {});
|
||||||
|
for (let attempt = 0; attempt < 10 && !stopCurrentRun; attempt += 1) {
|
||||||
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
controller.abort();
|
||||||
|
await assert.rejects(() => running, /could not be stopped/);
|
||||||
|
assert.equal(typeof stopCurrentRun, 'function');
|
||||||
|
await stopCurrentRun!();
|
||||||
|
assert.equal(stopCalls.length, 2);
|
||||||
|
} finally {
|
||||||
|
netcattyBridge.get = originalGet;
|
||||||
|
setScriptRuns([]);
|
||||||
|
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runConnectScriptsSequential does not treat a script error named Aborted as cancellation', async () => {
|
||||||
|
const originalGet = netcattyBridge.get;
|
||||||
|
const storage = new Map<string, string>();
|
||||||
|
const scriptStopCalls: string[] = [];
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
getItem: (key: string) => storage.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
|
||||||
|
removeItem: (key: string) => { storage.delete(key); },
|
||||||
|
clear: () => { storage.clear(); },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
netcattyBridge.get = () => ({
|
||||||
|
scriptRun: async (params) => {
|
||||||
|
setScriptRuns([{
|
||||||
|
runId: params.runId!,
|
||||||
|
scriptId: params.scriptId,
|
||||||
|
sessionId: params.sessionId!,
|
||||||
|
status: 'failed',
|
||||||
|
startedAt: Date.now() - 10,
|
||||||
|
endedAt: Date.now(),
|
||||||
|
error: 'Aborted',
|
||||||
|
logs: [],
|
||||||
|
}]);
|
||||||
|
return { runId: params.runId!, runIds: [params.runId!] };
|
||||||
|
},
|
||||||
|
scriptStop: async (id: string) => {
|
||||||
|
scriptStopCalls.push(id);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
}) as ReturnType<typeof netcattyBridge.get>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
() => runConnectScriptsSequential({
|
||||||
|
scripts: [{ id: 'fails', label: 'Fails', command: "throw new Error('Aborted')", kind: 'script' }],
|
||||||
|
sessionId: 'sess-real-error',
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
}),
|
||||||
|
/Aborted/,
|
||||||
|
);
|
||||||
|
assert.deepEqual(scriptStopCalls, []);
|
||||||
|
} finally {
|
||||||
|
netcattyBridge.get = originalGet;
|
||||||
|
setScriptRuns([]);
|
||||||
|
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -83,6 +83,8 @@ export function getActiveScriptRunForSession(sessionId: string): ScriptRun | und
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runAutomationScript(params: {
|
export async function runAutomationScript(params: {
|
||||||
|
runId?: string;
|
||||||
|
returnWhenQueued?: boolean;
|
||||||
snippet: Snippet;
|
snippet: Snippet;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
sessionIds?: string[];
|
sessionIds?: string[];
|
||||||
@@ -104,6 +106,8 @@ export async function runAutomationScript(params: {
|
|||||||
throw new Error('Script bridge unavailable');
|
throw new Error('Script bridge unavailable');
|
||||||
}
|
}
|
||||||
return bridge.scriptRun({
|
return bridge.scriptRun({
|
||||||
|
runId: params.runId,
|
||||||
|
returnWhenQueued: params.returnWhenQueued,
|
||||||
scriptId: params.snippet.id,
|
scriptId: params.snippet.id,
|
||||||
scriptLabel: params.snippet.label,
|
scriptLabel: params.snippet.label,
|
||||||
content: params.snippet.command,
|
content: params.snippet.command,
|
||||||
@@ -176,6 +180,7 @@ export async function runConnectScriptsSequential(params: {
|
|||||||
scripts: Snippet[];
|
scripts: Snippet[];
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
onCancelableRunChange?: (stopCurrentRun: (() => Promise<void>) | null) => void;
|
||||||
onScriptStart?: (snippet: Snippet) => void;
|
onScriptStart?: (snippet: Snippet) => void;
|
||||||
onScriptComplete?: (snippet: Snippet) => void;
|
onScriptComplete?: (snippet: Snippet) => void;
|
||||||
sessionMeta?: {
|
sessionMeta?: {
|
||||||
@@ -185,18 +190,63 @@ export async function runConnectScriptsSequential(params: {
|
|||||||
username?: string;
|
username?: string;
|
||||||
};
|
};
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
for (const snippet of params.scripts) {
|
const throwIfAborted = () => {
|
||||||
if (params.signal?.aborted) {
|
if (params.signal?.aborted) {
|
||||||
throw new Error('Aborted');
|
throw new DOMException('Connect script run cancelled', 'AbortError');
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const snippet of params.scripts) {
|
||||||
|
throwIfAborted();
|
||||||
|
const runId = crypto.randomUUID();
|
||||||
|
let stopPromise: Promise<void> | undefined;
|
||||||
|
let stopped = false;
|
||||||
params.onScriptStart?.(snippet);
|
params.onScriptStart?.(snippet);
|
||||||
const { runId } = await runAutomationScript({
|
const queueAccepted = runAutomationScript({
|
||||||
|
runId,
|
||||||
|
returnWhenQueued: true,
|
||||||
snippet,
|
snippet,
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
sessionMeta: params.sessionMeta,
|
sessionMeta: params.sessionMeta,
|
||||||
});
|
});
|
||||||
await waitForScriptRun(runId, { signal: params.signal });
|
const stopThisRun = () => {
|
||||||
params.onScriptComplete?.(snippet);
|
if (stopPromise) return stopPromise;
|
||||||
|
const attempt = queueAccepted.then(async () => {
|
||||||
|
const result = await stopScriptRun(runId);
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new Error(`Connect script run could not be stopped: ${runId}`);
|
||||||
|
}
|
||||||
|
stopped = true;
|
||||||
|
});
|
||||||
|
stopPromise = attempt;
|
||||||
|
void attempt.catch(() => {
|
||||||
|
if (stopPromise === attempt) stopPromise = undefined;
|
||||||
|
});
|
||||||
|
return attempt;
|
||||||
|
};
|
||||||
|
params.onCancelableRunChange?.(stopThisRun);
|
||||||
|
const onAbort = () => {
|
||||||
|
void stopThisRun().catch(() => {});
|
||||||
|
};
|
||||||
|
params.signal?.addEventListener('abort', onAbort, { once: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await queueAccepted;
|
||||||
|
throwIfAborted();
|
||||||
|
await waitForScriptRun(runId, { signal: params.signal });
|
||||||
|
params.onScriptComplete?.(snippet);
|
||||||
|
} catch (err) {
|
||||||
|
if (params.signal?.aborted) {
|
||||||
|
await stopThisRun();
|
||||||
|
throw new DOMException('Connect script run cancelled', 'AbortError');
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
params.signal?.removeEventListener('abort', onAbort);
|
||||||
|
if (!params.signal?.aborted || stopped) {
|
||||||
|
params.onCancelableRunChange?.(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ function isExpired(entry: StoreEntry): boolean {
|
|||||||
return Date.now() > entry.expiresAt;
|
return Date.now() > entry.expiresAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeCapabilities(capabilities: SessionCapabilities): SessionCapabilities {
|
||||||
|
return {
|
||||||
|
...capabilities,
|
||||||
|
hasSs: capabilities.hasSs === true,
|
||||||
|
hasNetstat: capabilities.hasNetstat === true,
|
||||||
|
hasLsof: capabilities.hasLsof === true,
|
||||||
|
hasSystemctl: capabilities.hasSystemctl === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function notifySession(sessionId: string) {
|
function notifySession(sessionId: string) {
|
||||||
listenersBySessionId.get(sessionId)?.forEach((listener) => listener());
|
listenersBySessionId.get(sessionId)?.forEach((listener) => listener());
|
||||||
}
|
}
|
||||||
@@ -28,13 +38,13 @@ export const sessionCapabilitiesStore = {
|
|||||||
notifySession(sessionId);
|
notifySession(sessionId);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return entry.capabilities;
|
return normalizeCapabilities(entry.capabilities);
|
||||||
},
|
},
|
||||||
|
|
||||||
set(sessionId: string, capabilities: SessionCapabilities, ttlMs: number) {
|
set(sessionId: string, capabilities: SessionCapabilities, ttlMs: number) {
|
||||||
const entry: StoreEntry = {
|
const entry: StoreEntry = {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
...capabilities,
|
...normalizeCapabilities(capabilities),
|
||||||
probedAt: Date.now(),
|
probedAt: Date.now(),
|
||||||
},
|
},
|
||||||
expiresAt: Date.now() + ttlMs,
|
expiresAt: Date.now() + ttlMs,
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import assert from "node:assert/strict";
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import type { Host } from "../../domain/models";
|
import type { Host } from "../../domain/models";
|
||||||
import { applyGroupDefaults } from "../../domain/groupConfig";
|
|
||||||
import { prepareSerialConfigForSavedHost } from "../../domain/serialBackspace";
|
import { prepareSerialConfigForSavedHost } from "../../domain/serialBackspace";
|
||||||
import { buildTelnetDeepLinkConnectionHost } from "../../domain/telnetDeepLink";
|
import { buildTelnetDeepLinkConnectionHost } from "../../domain/telnetDeepLink";
|
||||||
|
import { resolveEffectiveTerminalHost } from "../../domain/terminalHostResolution";
|
||||||
import {
|
import {
|
||||||
createHostTerminalSession,
|
createHostTerminalSession,
|
||||||
createSerialTerminalSession,
|
createSerialTerminalSession,
|
||||||
@@ -74,7 +74,34 @@ test("serial session factories snapshot effective legacy Backspace behavior", ()
|
|||||||
assert.equal(explicitDefaultSession.serialConfig?.backspaceBehavior, "default");
|
assert.equal(explicitDefaultSession.serialConfig?.backspaceBehavior, "default");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("saved quick-connect hosts can inherit Ctrl-H after moving into a group", () => {
|
test("workspace host factory creates a complete serial session", () => {
|
||||||
|
const session = createWorkspaceHostTerminalSession("session-serial", host({
|
||||||
|
protocol: "serial",
|
||||||
|
hostname: "COM7",
|
||||||
|
port: 57600,
|
||||||
|
username: "",
|
||||||
|
serialConfig: {
|
||||||
|
path: "COM7",
|
||||||
|
baudRate: 57600,
|
||||||
|
dataBits: 7,
|
||||||
|
stopBits: 2,
|
||||||
|
parity: "even",
|
||||||
|
},
|
||||||
|
}), "workspace-1");
|
||||||
|
|
||||||
|
assert.equal(session.workspaceId, "workspace-1");
|
||||||
|
assert.equal(session.protocol, "serial");
|
||||||
|
assert.deepEqual(session.serialConfig, {
|
||||||
|
path: "COM7",
|
||||||
|
baudRate: 57600,
|
||||||
|
dataBits: 7,
|
||||||
|
stopBits: 2,
|
||||||
|
parity: "even",
|
||||||
|
backspaceBehavior: "default",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("workspace append snapshots serial Backspace behavior inherited from a group", () => {
|
||||||
const savedHost = host({
|
const savedHost = host({
|
||||||
protocol: "serial",
|
protocol: "serial",
|
||||||
hostname: "COM3",
|
hostname: "COM3",
|
||||||
@@ -87,12 +114,15 @@ test("saved quick-connect hosts can inherit Ctrl-H after moving into a group", (
|
|||||||
backspaceBehavior: "default",
|
backspaceBehavior: "default",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const effectiveHost = applyGroupDefaults(savedHost, {
|
const effectiveHost = resolveEffectiveTerminalHost({
|
||||||
backspaceBehavior: "ctrl-h",
|
host: savedHost,
|
||||||
|
groupConfigs: [{ path: "network", backspaceBehavior: "ctrl-h" }],
|
||||||
|
proxyProfiles: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const session = createHostTerminalSession("session-1", effectiveHost);
|
const session = createWorkspaceHostTerminalSession("session-1", effectiveHost, "workspace-1");
|
||||||
|
|
||||||
|
assert.equal(session.workspaceId, "workspace-1");
|
||||||
assert.equal(session.serialConfig?.backspaceBehavior, "ctrl-h");
|
assert.equal(session.serialConfig?.backspaceBehavior, "ctrl-h");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,13 @@ test("session restore persistence can be disabled for non-main windows", () => {
|
|||||||
assert.match(hookSource, /if \(!persistSessionRestore\) return;/);
|
assert.match(hookSource, /if \(!persistSessionRestore\) return;/);
|
||||||
assert.match(traySource, /useSessionState\(\{ persistSessionRestore: false \}\)/);
|
assert.match(traySource, /useSessionState\(\{ persistSessionRestore: false \}\)/);
|
||||||
assert.match(appSource, /window\.location\.hash\.startsWith\('#\/session-window'\)/);
|
assert.match(appSource, /window\.location\.hash\.startsWith\('#\/session-window'\)/);
|
||||||
assert.match(appSource, /persistSessionRestore: !isPeerSessionWindow/);
|
// App no longer calls useSessionState; SessionPublisher owns the hook and
|
||||||
|
// App passes the peer-window flag down to it.
|
||||||
|
assert.match(appSource, /<SessionPublisher persistSessionRestore=\{!isPeerSessionWindow\}>/);
|
||||||
|
assert.match(
|
||||||
|
readFileSync(new URL("../app/publishers/SessionPublisher.tsx", import.meta.url), "utf8"),
|
||||||
|
/useSessionState\(\{ persistSessionRestore \}\)/,
|
||||||
|
);
|
||||||
assert.match(indexSource, /hash === '#\/session-window'/);
|
assert.match(indexSource, /hash === '#\/session-window'/);
|
||||||
assert.match(registerBridgesSource, /route: "session-window"/);
|
assert.match(registerBridgesSource, /route: "session-window"/);
|
||||||
assert.match(registerBridgesSource, /registerAsMainWindow: false/);
|
assert.match(registerBridgesSource, /registerAsMainWindow: false/);
|
||||||
@@ -100,7 +106,8 @@ test("session restore persistence can be disabled for non-main windows", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("session peer windows do not run main-window startup effects", () => {
|
test("session peer windows do not run main-window startup effects", () => {
|
||||||
const appSource = readFileSync(new URL("../../App.tsx", import.meta.url), "utf8");
|
const appSource = readFileSync(new URL("../app/AppSideEffects.tsx", import.meta.url), "utf8");
|
||||||
|
const appRootSource = readFileSync(new URL("../../App.tsx", import.meta.url), "utf8");
|
||||||
const autoSyncSource = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
const autoSyncSource = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
||||||
const startupEffectsSource = readFileSync(new URL("../app/useAppStartupEffects.ts", import.meta.url), "utf8");
|
const startupEffectsSource = readFileSync(new URL("../app/useAppStartupEffects.ts", import.meta.url), "utf8");
|
||||||
const updateCheckSource = readFileSync(new URL("./useUpdateCheck.ts", import.meta.url), "utf8");
|
const updateCheckSource = readFileSync(new URL("./useUpdateCheck.ts", import.meta.url), "utf8");
|
||||||
@@ -112,7 +119,13 @@ test("session peer windows do not run main-window startup effects", () => {
|
|||||||
const trayPanelJumpIndex = appSource.indexOf("onTrayPanelJumpToSession");
|
const trayPanelJumpIndex = appSource.indexOf("onTrayPanelJumpToSession");
|
||||||
|
|
||||||
assert.match(appSource, /const isPeerSessionWindow = typeof window !== 'undefined' && window\.location\.hash\.startsWith\('#\/session-window'\)/);
|
assert.match(appSource, /const isPeerSessionWindow = typeof window !== 'undefined' && window\.location\.hash\.startsWith\('#\/session-window'\)/);
|
||||||
assert.match(appSource, /useSettingsState\(\{[^}]*enableSettingsSync: !isPeerSessionWindow[^}]*enableSystemEffects: !isPeerSessionWindow/s);
|
// SettingsPublisher owns useSettingsState; App only forwards the peer-window
|
||||||
|
// flags to it.
|
||||||
|
assert.match(appRootSource, /<SettingsPublisher\s+enableSettingsSync=\{!isPeerSessionWindow\}\s+enableSystemEffects=\{!isPeerSessionWindow\}/s);
|
||||||
|
assert.match(
|
||||||
|
readFileSync(new URL("../app/publishers/SettingsPublisher.tsx", import.meta.url), "utf8"),
|
||||||
|
/useSettingsState\(\{ enableSettingsSync, enableSystemEffects \}\)/,
|
||||||
|
);
|
||||||
assert.match(appSource, /useAppStartupEffects\(\{[^}]*enabled: !isPeerSessionWindow/s);
|
assert.match(appSource, /useAppStartupEffects\(\{[^}]*enabled: !isPeerSessionWindow/s);
|
||||||
assert.match(appSource, /useUpdateCheck\(\{[^}]*enabled: !isPeerSessionWindow/s);
|
assert.match(appSource, /useUpdateCheck\(\{[^}]*enabled: !isPeerSessionWindow/s);
|
||||||
assert.match(appSource, /if \(isPeerSessionWindow \|\| !isVaultInitialized \|\| versionBackupAttemptedRef\.current\) return;/);
|
assert.match(appSource, /if \(isPeerSessionWindow \|\| !isVaultInitialized \|\| versionBackupAttemptedRef\.current\) return;/);
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import type { TerminalSession } from '../../domain/models.ts';
|
||||||
|
import {
|
||||||
|
EMPTY_SESSION_SNAPSHOT,
|
||||||
|
getSessionSnapshot,
|
||||||
|
getSessionSnapshotActions,
|
||||||
|
publishSessionSnapshot,
|
||||||
|
registerSessionSnapshotActions,
|
||||||
|
sessionSnapshotsEqual,
|
||||||
|
subscribeSessionSnapshot,
|
||||||
|
} from './sessionSnapshotStore.ts';
|
||||||
|
|
||||||
|
function makeSession(id: string): TerminalSession {
|
||||||
|
return { id, hostLabel: id, status: 'connected' } as TerminalSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('sessionSnapshotStore notifies only when a field identity changes', () => {
|
||||||
|
const seen: number[] = [];
|
||||||
|
const unsubscribe = subscribeSessionSnapshot(() => {
|
||||||
|
seen.push(getSessionSnapshot().sessions.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessions = [makeSession('s1')];
|
||||||
|
const snapshot = { ...EMPTY_SESSION_SNAPSHOT, sessions, orphanSessions: sessions };
|
||||||
|
publishSessionSnapshot(snapshot);
|
||||||
|
assert.deepEqual(seen, [1]);
|
||||||
|
assert.equal(getSessionSnapshot().sessions, sessions);
|
||||||
|
|
||||||
|
publishSessionSnapshot({ ...snapshot });
|
||||||
|
assert.deepEqual(seen, [1]);
|
||||||
|
|
||||||
|
// Presentation-only fields still count: rename targets drive dialog state.
|
||||||
|
publishSessionSnapshot({ ...snapshot, sessionRenameTarget: sessions[0] });
|
||||||
|
assert.deepEqual(seen, [1, 1]);
|
||||||
|
assert.equal(getSessionSnapshot().sessionRenameTarget, sessions[0]);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
publishSessionSnapshot(EMPTY_SESSION_SNAPSHOT);
|
||||||
|
assert.deepEqual(seen, [1, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sessionSnapshotsEqual compares every published field', () => {
|
||||||
|
assert.equal(
|
||||||
|
sessionSnapshotsEqual(EMPTY_SESSION_SNAPSHOT, { ...EMPTY_SESSION_SNAPSHOT }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
sessionSnapshotsEqual(EMPTY_SESSION_SNAPSHOT, {
|
||||||
|
...EMPTY_SESSION_SNAPSHOT,
|
||||||
|
draggingSessionId: 's1',
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
sessionSnapshotsEqual(EMPTY_SESSION_SNAPSHOT, { ...EMPTY_SESSION_SNAPSHOT, logViews: [] }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('session actions register and unregister', () => {
|
||||||
|
assert.equal(getSessionSnapshotActions(), null);
|
||||||
|
const calls: string[] = [];
|
||||||
|
const noop = (() => {}) as never;
|
||||||
|
registerSessionSnapshotActions({
|
||||||
|
setActiveTabId: ((id: string) => calls.push(id)) as never,
|
||||||
|
closeSession: noop,
|
||||||
|
closeSessions: noop,
|
||||||
|
closeWorkspace: noop,
|
||||||
|
openLogView: noop,
|
||||||
|
closeLogView: noop,
|
||||||
|
setDraggingSessionId: noop,
|
||||||
|
startSessionRename: noop,
|
||||||
|
renameSessionInline: noop,
|
||||||
|
submitSessionRename: noop,
|
||||||
|
resetSessionRename: noop,
|
||||||
|
startWorkspaceRename: noop,
|
||||||
|
submitWorkspaceRename: noop,
|
||||||
|
resetWorkspaceRename: noop,
|
||||||
|
removeSessionFromWorkspace: noop,
|
||||||
|
setWorkspaceFocusedSession: noop,
|
||||||
|
toggleWorkspaceViewMode: noop,
|
||||||
|
createLocalTerminal: noop,
|
||||||
|
createSerialSession: noop,
|
||||||
|
connectToHost: noop,
|
||||||
|
updateSessionStatus: noop,
|
||||||
|
updateSessionFontSize: noop,
|
||||||
|
clearSessionFontSizeOverride: noop,
|
||||||
|
createWorkspaceWithHosts: noop,
|
||||||
|
createWorkspaceFromSessions: noop,
|
||||||
|
addSessionToWorkspace: noop,
|
||||||
|
appendHostToWorkspace: noop,
|
||||||
|
appendLocalTerminalToWorkspace: noop,
|
||||||
|
createWorkspaceFromTargets: noop,
|
||||||
|
updateSplitSizes: noop,
|
||||||
|
splitSession: noop,
|
||||||
|
reorderWorkspaceSessions: noop,
|
||||||
|
moveFocusInWorkspace: noop,
|
||||||
|
runSnippet: noop,
|
||||||
|
getOrderedWorkTabs: noop,
|
||||||
|
reorderTabs: noop,
|
||||||
|
toggleBroadcast: noop,
|
||||||
|
isBroadcastEnabled: noop,
|
||||||
|
copySession: noop,
|
||||||
|
copyWorkspace: noop,
|
||||||
|
createSessionFromCloneSource: noop,
|
||||||
|
updateSessionRestoreCwd: noop,
|
||||||
|
getSessionRestoreCwd: noop,
|
||||||
|
updateSessionDynamicTitle: noop,
|
||||||
|
updateSessionCodingCliProvider: noop,
|
||||||
|
});
|
||||||
|
getSessionSnapshotActions()?.setActiveTabId('vault');
|
||||||
|
assert.deepEqual(calls, ['vault']);
|
||||||
|
|
||||||
|
registerSessionSnapshotActions(null);
|
||||||
|
assert.equal(getSessionSnapshotActions(), null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
import type { TerminalSession, Workspace } from '../../domain/models';
|
||||||
|
import type { LogView } from './logViewState';
|
||||||
|
import type { useSessionState } from './useSessionState';
|
||||||
|
|
||||||
|
type SessionState = ReturnType<typeof useSessionState>;
|
||||||
|
|
||||||
|
type Listener = () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session/workspace catalog for shell surfaces. `activeTabId` is intentionally
|
||||||
|
* absent — it lives in `activeTabStore` so tab switches stay off this path.
|
||||||
|
*/
|
||||||
|
export type SessionSnapshot = {
|
||||||
|
sessions: readonly TerminalSession[];
|
||||||
|
/** Sessions with no workspace and not hidden from tabs; TopTabs renders these. */
|
||||||
|
orphanSessions: readonly TerminalSession[];
|
||||||
|
workspaces: readonly Workspace[];
|
||||||
|
logViews: readonly LogView[];
|
||||||
|
draggingSessionId: string | null;
|
||||||
|
sessionRenameTarget: TerminalSession | null;
|
||||||
|
workspaceRenameTarget: Workspace | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session mutators the shell needs. Derived from the hook return so the store
|
||||||
|
* contract cannot drift from `useSessionState`.
|
||||||
|
*/
|
||||||
|
export type SessionSnapshotActions = Pick<
|
||||||
|
SessionState,
|
||||||
|
| 'setActiveTabId'
|
||||||
|
| 'closeSession'
|
||||||
|
| 'closeSessions'
|
||||||
|
| 'closeWorkspace'
|
||||||
|
| 'openLogView'
|
||||||
|
| 'closeLogView'
|
||||||
|
| 'setDraggingSessionId'
|
||||||
|
| 'startSessionRename'
|
||||||
|
| 'renameSessionInline'
|
||||||
|
| 'submitSessionRename'
|
||||||
|
| 'resetSessionRename'
|
||||||
|
| 'startWorkspaceRename'
|
||||||
|
| 'submitWorkspaceRename'
|
||||||
|
| 'resetWorkspaceRename'
|
||||||
|
| 'removeSessionFromWorkspace'
|
||||||
|
| 'setWorkspaceFocusedSession'
|
||||||
|
| 'toggleWorkspaceViewMode'
|
||||||
|
// Terminal / chrome mutators previously read only via App session runtime.
|
||||||
|
| 'createLocalTerminal'
|
||||||
|
| 'createSerialSession'
|
||||||
|
| 'connectToHost'
|
||||||
|
| 'updateSessionStatus'
|
||||||
|
| 'updateSessionFontSize'
|
||||||
|
| 'clearSessionFontSizeOverride'
|
||||||
|
| 'createWorkspaceWithHosts'
|
||||||
|
| 'createWorkspaceFromSessions'
|
||||||
|
| 'addSessionToWorkspace'
|
||||||
|
| 'appendHostToWorkspace'
|
||||||
|
| 'appendLocalTerminalToWorkspace'
|
||||||
|
| 'createWorkspaceFromTargets'
|
||||||
|
| 'updateSplitSizes'
|
||||||
|
| 'splitSession'
|
||||||
|
| 'reorderWorkspaceSessions'
|
||||||
|
| 'moveFocusInWorkspace'
|
||||||
|
| 'runSnippet'
|
||||||
|
| 'getOrderedWorkTabs'
|
||||||
|
| 'reorderTabs'
|
||||||
|
| 'toggleBroadcast'
|
||||||
|
| 'isBroadcastEnabled'
|
||||||
|
| 'copySession'
|
||||||
|
| 'copyWorkspace'
|
||||||
|
| 'createSessionFromCloneSource'
|
||||||
|
| 'updateSessionRestoreCwd'
|
||||||
|
| 'getSessionRestoreCwd'
|
||||||
|
| 'updateSessionDynamicTitle'
|
||||||
|
| 'updateSessionCodingCliProvider'
|
||||||
|
>;
|
||||||
|
|
||||||
|
const EMPTY_SESSIONS: readonly TerminalSession[] = Object.freeze([]);
|
||||||
|
const EMPTY_WORKSPACES: readonly Workspace[] = Object.freeze([]);
|
||||||
|
const EMPTY_LOG_VIEWS: readonly LogView[] = Object.freeze([]);
|
||||||
|
|
||||||
|
export const EMPTY_SESSION_SNAPSHOT: SessionSnapshot = Object.freeze({
|
||||||
|
sessions: EMPTY_SESSIONS,
|
||||||
|
orphanSessions: EMPTY_SESSIONS,
|
||||||
|
workspaces: EMPTY_WORKSPACES,
|
||||||
|
logViews: EMPTY_LOG_VIEWS,
|
||||||
|
draggingSessionId: null,
|
||||||
|
sessionRenameTarget: null,
|
||||||
|
workspaceRenameTarget: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const SNAPSHOT_KEYS = Object.keys(EMPTY_SESSION_SNAPSHOT) as (keyof SessionSnapshot)[];
|
||||||
|
|
||||||
|
export function sessionSnapshotsEqual(
|
||||||
|
prev: SessionSnapshot,
|
||||||
|
next: SessionSnapshot,
|
||||||
|
): boolean {
|
||||||
|
return SNAPSHOT_KEYS.every((key) => prev[key] === next[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* External store for the session catalog so TopTabs, the terminal layer and
|
||||||
|
* the host tree can subscribe directly instead of receiving sessions through
|
||||||
|
* the App domain bags.
|
||||||
|
*/
|
||||||
|
class SessionSnapshotStore {
|
||||||
|
private snapshot: SessionSnapshot = EMPTY_SESSION_SNAPSHOT;
|
||||||
|
private actions: SessionSnapshotActions | null = null;
|
||||||
|
private listeners = new Set<Listener>();
|
||||||
|
private actionListeners = new Set<Listener>();
|
||||||
|
|
||||||
|
getSnapshot = (): SessionSnapshot => this.snapshot;
|
||||||
|
|
||||||
|
subscribe = (listener: Listener): (() => void) => {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
setSnapshot(next: SessionSnapshot): void {
|
||||||
|
if (sessionSnapshotsEqual(this.snapshot, next)) return;
|
||||||
|
this.snapshot = next;
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getActions = (): SessionSnapshotActions | null => this.actions;
|
||||||
|
|
||||||
|
subscribeActions = (listener: Listener): (() => void) => {
|
||||||
|
this.actionListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.actionListeners.delete(listener);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
setActions(next: SessionSnapshotActions | null): void {
|
||||||
|
if (this.actions === next) return;
|
||||||
|
this.actions = next;
|
||||||
|
for (const listener of this.actionListeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sessionSnapshotStore = new SessionSnapshotStore();
|
||||||
|
|
||||||
|
export function publishSessionSnapshot(snapshot: SessionSnapshot): void {
|
||||||
|
sessionSnapshotStore.setSnapshot(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionSnapshot(): SessionSnapshot {
|
||||||
|
return sessionSnapshotStore.getSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeSessionSnapshot(listener: Listener): () => void {
|
||||||
|
return sessionSnapshotStore.subscribe(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSessionSnapshotActions(
|
||||||
|
actions: SessionSnapshotActions | null,
|
||||||
|
): void {
|
||||||
|
sessionSnapshotStore.setActions(actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionSnapshotActions(): SessionSnapshotActions | null {
|
||||||
|
return sessionSnapshotStore.getActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeSessionSnapshotActions(listener: Listener): () => void {
|
||||||
|
return sessionSnapshotStore.subscribeActions(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to the whole session catalog. Prefer a narrower selector hook. */
|
||||||
|
export function useSessionSnapshot(): SessionSnapshot {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
subscribeSessionSnapshot,
|
||||||
|
getSessionSnapshot,
|
||||||
|
getSessionSnapshot,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to one session field so unrelated session churn stays out. */
|
||||||
|
export function useSessionSnapshotField<K extends keyof SessionSnapshot>(
|
||||||
|
field: K,
|
||||||
|
): SessionSnapshot[K] {
|
||||||
|
const read = () => getSessionSnapshot()[field];
|
||||||
|
return useSyncExternalStore(subscribeSessionSnapshot, read, read);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSessionSnapshotActions(): SessionSnapshotActions | null {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
subscribeSessionSnapshotActions,
|
||||||
|
getSessionSnapshotActions,
|
||||||
|
getSessionSnapshotActions,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -147,6 +147,7 @@ test("closing one split pane keeps the other terminal as an orphan tab", () => {
|
|||||||
workspaceId: "ws-1",
|
workspaceId: "ws-1",
|
||||||
layoutResult,
|
layoutResult,
|
||||||
remainingSessions: nextSessions,
|
remainingSessions: nextSessions,
|
||||||
|
preferredTabId: "s-outside",
|
||||||
}),
|
}),
|
||||||
"s2",
|
"s2",
|
||||||
);
|
);
|
||||||
@@ -197,6 +198,81 @@ test("closing the last visible solo tab does not fall back to a hidden MCP sessi
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("closing a temporary solo tab restores the previously focused tab", () => {
|
||||||
|
const standalone = (id: string, status: TerminalSession["status"] = "connected"): TerminalSession => ({
|
||||||
|
...session(id),
|
||||||
|
workspaceId: undefined,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
const remainingSessions = [
|
||||||
|
standalone("s1", "disconnected"),
|
||||||
|
standalone("s2"),
|
||||||
|
standalone("s3"),
|
||||||
|
standalone("s4"),
|
||||||
|
standalone("s5", "disconnected"),
|
||||||
|
standalone("s6"),
|
||||||
|
];
|
||||||
|
const layoutResult = closeSessionWorkspaceLayoutState([], undefined, "s7");
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
resolveActiveTabAfterCloseSession({
|
||||||
|
currentActiveTabId: "s7",
|
||||||
|
closedSessionId: "s7",
|
||||||
|
workspaceId: undefined,
|
||||||
|
layoutResult,
|
||||||
|
remainingSessions,
|
||||||
|
preferredTabId: "s5",
|
||||||
|
}),
|
||||||
|
"s5",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closing a temporary solo tab ignores a preferred tab that no longer exists", () => {
|
||||||
|
const standalone = (id: string): TerminalSession => ({
|
||||||
|
...session(id),
|
||||||
|
workspaceId: undefined,
|
||||||
|
});
|
||||||
|
const remainingSessions = [standalone("s1"), standalone("s2"), standalone("s6")];
|
||||||
|
const layoutResult = closeSessionWorkspaceLayoutState([], undefined, "s7");
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
resolveActiveTabAfterCloseSession({
|
||||||
|
currentActiveTabId: "s7",
|
||||||
|
closedSessionId: "s7",
|
||||||
|
workspaceId: undefined,
|
||||||
|
layoutResult,
|
||||||
|
remainingSessions,
|
||||||
|
preferredTabId: "s5",
|
||||||
|
}),
|
||||||
|
"s6",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closeSessionsState restores the previous tab instead of the newest remaining tab", () => {
|
||||||
|
const standaloneSession = (id: string): TerminalSession => ({
|
||||||
|
...session(id),
|
||||||
|
workspaceId: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = closeSessionsState({
|
||||||
|
sessions: [
|
||||||
|
standaloneSession("s1"),
|
||||||
|
standaloneSession("s2"),
|
||||||
|
standaloneSession("s5"),
|
||||||
|
standaloneSession("s6"),
|
||||||
|
standaloneSession("s7"),
|
||||||
|
],
|
||||||
|
workspaces: [],
|
||||||
|
sessionIds: ["s7"],
|
||||||
|
currentActiveTabId: "s7",
|
||||||
|
preferredTabId: "s5",
|
||||||
|
tabOrder: ["s1", "s2", "s5", "s6", "s7"],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.activeTabId, "s5");
|
||||||
|
assert.deepEqual(result.sessions.map((candidate) => candidate.id), ["s1", "s2", "s5", "s6"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("closing several standalone sessions removes the whole batch", () => {
|
test("closing several standalone sessions removes the whole batch", () => {
|
||||||
const standaloneSession = (id: string): TerminalSession => ({
|
const standaloneSession = (id: string): TerminalSession => ({
|
||||||
...session(id),
|
...session(id),
|
||||||
|
|||||||
@@ -129,22 +129,64 @@ export function applyCloseSessionToSessions(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isPreferredTabAvailableAfterClose(
|
||||||
|
input: {
|
||||||
|
preferredTabId: string | null | undefined;
|
||||||
|
closedSessionId: string;
|
||||||
|
layoutResult: CloseSessionWorkspaceLayoutResult;
|
||||||
|
remainingSessions: readonly TerminalSession[];
|
||||||
|
},
|
||||||
|
): input is {
|
||||||
|
preferredTabId: string;
|
||||||
|
closedSessionId: string;
|
||||||
|
layoutResult: CloseSessionWorkspaceLayoutResult;
|
||||||
|
remainingSessions: readonly TerminalSession[];
|
||||||
|
} {
|
||||||
|
const { preferredTabId, closedSessionId, layoutResult, remainingSessions } = input;
|
||||||
|
if (!preferredTabId || preferredTabId === closedSessionId) return false;
|
||||||
|
if (
|
||||||
|
preferredTabId === layoutResult.dissolvedWorkspaceId
|
||||||
|
|| preferredTabId === layoutResult.removedWorkspaceId
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (preferredTabId === "vault" || preferredTabId === "sftp") return true;
|
||||||
|
if (layoutResult.workspaces.some((workspace) => workspace.id === preferredTabId)) return true;
|
||||||
|
return remainingSessions.some((session) => (
|
||||||
|
session.id === preferredTabId
|
||||||
|
&& !session.workspaceId
|
||||||
|
&& !session.hiddenFromTabs
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveActiveTabAfterCloseSession({
|
export function resolveActiveTabAfterCloseSession({
|
||||||
currentActiveTabId,
|
currentActiveTabId,
|
||||||
closedSessionId,
|
closedSessionId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
layoutResult,
|
layoutResult,
|
||||||
remainingSessions,
|
remainingSessions,
|
||||||
|
preferredTabId,
|
||||||
}: {
|
}: {
|
||||||
currentActiveTabId: string | null;
|
currentActiveTabId: string | null;
|
||||||
closedSessionId: string;
|
closedSessionId: string;
|
||||||
workspaceId: string | undefined;
|
workspaceId: string | undefined;
|
||||||
layoutResult: CloseSessionWorkspaceLayoutResult;
|
layoutResult: CloseSessionWorkspaceLayoutResult;
|
||||||
remainingSessions: readonly TerminalSession[];
|
remainingSessions: readonly TerminalSession[];
|
||||||
|
preferredTabId?: string | null;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
const fallbackWorkspace = layoutResult.workspaces[layoutResult.workspaces.length - 1];
|
const fallbackWorkspace = layoutResult.workspaces[layoutResult.workspaces.length - 1];
|
||||||
const fallbackSolo = remainingSessions.filter((session) => !session.workspaceId && !session.hiddenFromTabs).slice(-1)[0];
|
const fallbackSolo = remainingSessions.filter((session) => !session.workspaceId && !session.hiddenFromTabs).slice(-1)[0];
|
||||||
|
const preferredInput = {
|
||||||
|
preferredTabId,
|
||||||
|
closedSessionId,
|
||||||
|
layoutResult,
|
||||||
|
remainingSessions,
|
||||||
|
};
|
||||||
|
const preferredFallback = isPreferredTabAvailableAfterClose(preferredInput)
|
||||||
|
? preferredInput.preferredTabId
|
||||||
|
: undefined;
|
||||||
const fallback = layoutResult.lastRemainingSessionId
|
const fallback = layoutResult.lastRemainingSessionId
|
||||||
|
?? preferredFallback
|
||||||
?? fallbackWorkspace?.id
|
?? fallbackWorkspace?.id
|
||||||
?? fallbackSolo?.id
|
?? fallbackSolo?.id
|
||||||
?? "vault";
|
?? "vault";
|
||||||
@@ -166,12 +208,14 @@ export function closeSessionsState({
|
|||||||
workspaces,
|
workspaces,
|
||||||
sessionIds,
|
sessionIds,
|
||||||
currentActiveTabId,
|
currentActiveTabId,
|
||||||
|
preferredTabId,
|
||||||
tabOrder,
|
tabOrder,
|
||||||
}: {
|
}: {
|
||||||
sessions: readonly TerminalSession[];
|
sessions: readonly TerminalSession[];
|
||||||
workspaces: readonly Workspace[];
|
workspaces: readonly Workspace[];
|
||||||
sessionIds: readonly string[];
|
sessionIds: readonly string[];
|
||||||
currentActiveTabId: string | null;
|
currentActiveTabId: string | null;
|
||||||
|
preferredTabId?: string | null;
|
||||||
tabOrder: readonly string[];
|
tabOrder: readonly string[];
|
||||||
}): CloseSessionsStateResult {
|
}): CloseSessionsStateResult {
|
||||||
let nextSessions = [...sessions];
|
let nextSessions = [...sessions];
|
||||||
@@ -207,6 +251,7 @@ export function closeSessionsState({
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
layoutResult,
|
layoutResult,
|
||||||
remainingSessions: nextSessions,
|
remainingSessions: nextSessions,
|
||||||
|
preferredTabId,
|
||||||
});
|
});
|
||||||
if (activeTabAfterClose) {
|
if (activeTabAfterClose) {
|
||||||
nextActiveTabId = activeTabAfterClose;
|
nextActiveTabId = activeTabAfterClose;
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_SETTINGS_CHROME_SNAPSHOT,
|
||||||
|
getSettingsChromeActions,
|
||||||
|
getSettingsChromeSnapshot,
|
||||||
|
publishSettingsChromeSnapshot,
|
||||||
|
registerSettingsChromeActions,
|
||||||
|
settingsChromeSnapshotsEqual,
|
||||||
|
subscribeSettingsChrome,
|
||||||
|
subscribeSettingsChromeActions,
|
||||||
|
type SettingsChromeSnapshot,
|
||||||
|
} from './settingsChromeStore.ts';
|
||||||
|
|
||||||
|
type SnapshotKey = keyof SettingsChromeSnapshot;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the probe value instead of hard-coding a field list: the chrome slice
|
||||||
|
* grows as surfaces move off the App settings bag, and this suite is about the
|
||||||
|
* dedupe contract, not about which fields happen to be in it today.
|
||||||
|
*/
|
||||||
|
function mutate(value: unknown): unknown {
|
||||||
|
if (typeof value === 'boolean') return !value;
|
||||||
|
if (typeof value === 'number') return value + 1;
|
||||||
|
if (typeof value === 'string') return `${value}-changed`;
|
||||||
|
throw new Error(`Unsupported snapshot field type: ${typeof value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('settingsChromeStore ignores structurally identical republishes', () => {
|
||||||
|
const base = getSettingsChromeSnapshot();
|
||||||
|
let events = 0;
|
||||||
|
const unsubscribe = subscribeSettingsChrome(() => {
|
||||||
|
events += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// useSettingsState rebuilds this object literal on every render, so a fresh
|
||||||
|
// identity with unchanged fields must not wake chrome consumers.
|
||||||
|
publishSettingsChromeSnapshot({ ...base });
|
||||||
|
publishSettingsChromeSnapshot({ ...base });
|
||||||
|
assert.equal(events, 0);
|
||||||
|
assert.equal(getSettingsChromeSnapshot(), base);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settingsChromeStore notifies on any single chrome field change', () => {
|
||||||
|
const base = getSettingsChromeSnapshot();
|
||||||
|
const keys = Object.keys(base) as SnapshotKey[];
|
||||||
|
assert.ok(keys.length > 0, 'chrome snapshot must publish at least one field');
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
let events = 0;
|
||||||
|
const unsubscribe = subscribeSettingsChrome(() => {
|
||||||
|
events += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const changed = { ...base, [key]: mutate(base[key]) } as SettingsChromeSnapshot;
|
||||||
|
publishSettingsChromeSnapshot(changed);
|
||||||
|
assert.equal(events, 1, `expected a notification for ${key}`);
|
||||||
|
assert.equal(getSettingsChromeSnapshot()[key], changed[key]);
|
||||||
|
|
||||||
|
publishSettingsChromeSnapshot({ ...changed });
|
||||||
|
assert.equal(events, 1, `expected no repeat notification for ${key}`);
|
||||||
|
|
||||||
|
publishSettingsChromeSnapshot(base);
|
||||||
|
assert.equal(events, 2, `expected a restore notification for ${key}`);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(getSettingsChromeSnapshot(), base);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settingsChromeStore stops notifying after unsubscribe', () => {
|
||||||
|
const base = getSettingsChromeSnapshot();
|
||||||
|
const [firstKey] = Object.keys(base) as SnapshotKey[];
|
||||||
|
let events = 0;
|
||||||
|
const unsubscribe = subscribeSettingsChrome(() => {
|
||||||
|
events += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
publishSettingsChromeSnapshot({
|
||||||
|
...base,
|
||||||
|
[firstKey]: mutate(base[firstKey]),
|
||||||
|
} as SettingsChromeSnapshot);
|
||||||
|
assert.equal(events, 0);
|
||||||
|
|
||||||
|
publishSettingsChromeSnapshot(base);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chrome snapshot fields stay in sync with what useSettingsState publishes', () => {
|
||||||
|
// The whole point of the slice is that chrome leaves can stop reading the
|
||||||
|
// App settings bag. A field the publisher forgets would silently freeze at
|
||||||
|
// its default, so pin the two shapes together.
|
||||||
|
const source = readFileSync(new URL('./useSettingsState.ts', import.meta.url), 'utf8');
|
||||||
|
const callStart = source.indexOf('publishSettingsChromeSnapshot({');
|
||||||
|
assert.notEqual(callStart, -1, 'useSettingsState must publish the chrome slice');
|
||||||
|
const callEnd = source.indexOf('});', callStart);
|
||||||
|
assert.notEqual(callEnd, -1);
|
||||||
|
|
||||||
|
const published = source
|
||||||
|
.slice(callStart, callEnd)
|
||||||
|
.split('\n')
|
||||||
|
.slice(1)
|
||||||
|
.map((line) => line.trim().match(/^([A-Za-z0-9_]+)\s*[:,]/)?.[1])
|
||||||
|
.filter((name): name is string => Boolean(name));
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
published.slice().sort(),
|
||||||
|
Object.keys(DEFAULT_SETTINGS_CHROME_SNAPSHOT).sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settingsChromeSnapshotsEqual compares by field, not object identity', () => {
|
||||||
|
const base = DEFAULT_SETTINGS_CHROME_SNAPSHOT;
|
||||||
|
assert.equal(settingsChromeSnapshotsEqual({ ...base }, { ...base }), true);
|
||||||
|
assert.equal(
|
||||||
|
settingsChromeSnapshotsEqual(base, { ...base, windowOpacity: 0.8 }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
settingsChromeSnapshotsEqual(base, { ...base, showSftpTab: !base.showSftpTab }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registerSettingsChromeActions exposes the theme and opacity setters', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
let actionEvents = 0;
|
||||||
|
const unsubscribe = subscribeSettingsChromeActions(() => {
|
||||||
|
actionEvents += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
registerSettingsChromeActions({
|
||||||
|
setTheme: () => calls.push('theme'),
|
||||||
|
setWindowOpacity: () => calls.push('opacity'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const actions = getSettingsChromeActions();
|
||||||
|
assert.ok(actions);
|
||||||
|
actions.setTheme('light');
|
||||||
|
actions.setWindowOpacity(0.5);
|
||||||
|
assert.deepEqual(calls, ['theme', 'opacity']);
|
||||||
|
assert.equal(actionEvents, 1);
|
||||||
|
|
||||||
|
registerSettingsChromeActions(null);
|
||||||
|
assert.equal(getSettingsChromeActions(), null);
|
||||||
|
assert.equal(actionEvents, 2);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setter churn never invalidates the chrome value snapshot', () => {
|
||||||
|
// useSettingsState re-registers on every setter identity change; chrome
|
||||||
|
// consumers of the value snapshot must not re-render for that.
|
||||||
|
let valueEvents = 0;
|
||||||
|
const unsubscribe = subscribeSettingsChrome(() => {
|
||||||
|
valueEvents += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
registerSettingsChromeActions({ setTheme: () => {}, setWindowOpacity: () => {} });
|
||||||
|
registerSettingsChromeActions({ setTheme: () => {}, setWindowOpacity: () => {} });
|
||||||
|
assert.equal(valueEvents, 0);
|
||||||
|
|
||||||
|
registerSettingsChromeActions(null);
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
import type { UILanguage } from '../../domain/models/connection';
|
||||||
|
import type { HotkeyScheme } from '../../domain/models/keyBindings';
|
||||||
|
import type { DynamicTabTitleMode } from '../../domain/models/terminal';
|
||||||
|
import type { HostClickBehavior } from '../../domain/hostClickBehavior';
|
||||||
|
import type { TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
|
||||||
|
|
||||||
|
type Listener = () => void;
|
||||||
|
|
||||||
|
export type SettingsChromeTheme = 'dark' | 'light' | 'system';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chrome-facing settings slice. Keep this aligned with what AppView / TopTabs /
|
||||||
|
* HostTree actually read. Unrelated settings edits must not rebuild App chrome
|
||||||
|
* domain bags — leaves subscribe here instead of receiving the whole settings
|
||||||
|
* object through domain props.
|
||||||
|
*/
|
||||||
|
export type SettingsChromeSnapshot = {
|
||||||
|
theme: SettingsChromeTheme;
|
||||||
|
resolvedTheme: 'light' | 'dark';
|
||||||
|
lightUiThemeId: string;
|
||||||
|
darkUiThemeId: string;
|
||||||
|
uiLanguage: UILanguage;
|
||||||
|
windowOpacity: number;
|
||||||
|
showSftpTab: boolean;
|
||||||
|
showHostTreeSidebar: boolean;
|
||||||
|
showRecentHosts: boolean;
|
||||||
|
hostClickBehavior: HostClickBehavior;
|
||||||
|
showOnlyUngroupedHostsInRoot: boolean;
|
||||||
|
dynamicTabTitleMode: DynamicTabTitleMode;
|
||||||
|
disableTerminalFontZoom: boolean;
|
||||||
|
hotkeyScheme: HotkeyScheme;
|
||||||
|
shellOnlyTabNumberShortcuts: boolean;
|
||||||
|
showTabNumberBadges: boolean;
|
||||||
|
restoreTerminalCwd: boolean;
|
||||||
|
terminalSidePanelAutoOpen: boolean;
|
||||||
|
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setters chrome leaves need. Kept on a separate slot from the value snapshot:
|
||||||
|
* `useSettingsState` rebuilds these callbacks on its own cadence, and that must
|
||||||
|
* never invalidate the value snapshot.
|
||||||
|
*/
|
||||||
|
export type SettingsChromeActions = {
|
||||||
|
setTheme: (theme: SettingsChromeTheme) => void;
|
||||||
|
setWindowOpacity: (opacity: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS_CHROME_SNAPSHOT: SettingsChromeSnapshot = Object.freeze({
|
||||||
|
theme: 'system',
|
||||||
|
resolvedTheme: 'dark',
|
||||||
|
lightUiThemeId: 'default',
|
||||||
|
darkUiThemeId: 'default',
|
||||||
|
uiLanguage: 'en',
|
||||||
|
windowOpacity: 1,
|
||||||
|
showSftpTab: true,
|
||||||
|
showHostTreeSidebar: true,
|
||||||
|
showRecentHosts: true,
|
||||||
|
hostClickBehavior: 'connect',
|
||||||
|
showOnlyUngroupedHostsInRoot: false,
|
||||||
|
dynamicTabTitleMode: 'off',
|
||||||
|
disableTerminalFontZoom: false,
|
||||||
|
hotkeyScheme: 'pc',
|
||||||
|
shellOnlyTabNumberShortcuts: false,
|
||||||
|
showTabNumberBadges: true,
|
||||||
|
restoreTerminalCwd: true,
|
||||||
|
terminalSidePanelAutoOpen: false,
|
||||||
|
terminalSidePanelAutoOpenTab: 'ai',
|
||||||
|
} satisfies SettingsChromeSnapshot);
|
||||||
|
|
||||||
|
export function settingsChromeSnapshotsEqual(
|
||||||
|
a: SettingsChromeSnapshot,
|
||||||
|
b: SettingsChromeSnapshot,
|
||||||
|
): boolean {
|
||||||
|
return a.theme === b.theme
|
||||||
|
&& a.resolvedTheme === b.resolvedTheme
|
||||||
|
&& a.lightUiThemeId === b.lightUiThemeId
|
||||||
|
&& a.darkUiThemeId === b.darkUiThemeId
|
||||||
|
&& a.uiLanguage === b.uiLanguage
|
||||||
|
&& a.windowOpacity === b.windowOpacity
|
||||||
|
&& a.showSftpTab === b.showSftpTab
|
||||||
|
&& a.showHostTreeSidebar === b.showHostTreeSidebar
|
||||||
|
&& a.showRecentHosts === b.showRecentHosts
|
||||||
|
&& a.hostClickBehavior === b.hostClickBehavior
|
||||||
|
&& a.showOnlyUngroupedHostsInRoot === b.showOnlyUngroupedHostsInRoot
|
||||||
|
&& a.dynamicTabTitleMode === b.dynamicTabTitleMode
|
||||||
|
&& a.disableTerminalFontZoom === b.disableTerminalFontZoom
|
||||||
|
&& a.hotkeyScheme === b.hotkeyScheme
|
||||||
|
&& a.shellOnlyTabNumberShortcuts === b.shellOnlyTabNumberShortcuts
|
||||||
|
&& a.showTabNumberBadges === b.showTabNumberBadges
|
||||||
|
&& a.restoreTerminalCwd === b.restoreTerminalCwd
|
||||||
|
&& a.terminalSidePanelAutoOpen === b.terminalSidePanelAutoOpen
|
||||||
|
&& a.terminalSidePanelAutoOpenTab === b.terminalSidePanelAutoOpenTab;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SettingsChromeStore {
|
||||||
|
private snapshot: SettingsChromeSnapshot = DEFAULT_SETTINGS_CHROME_SNAPSHOT;
|
||||||
|
private actions: SettingsChromeActions | null = null;
|
||||||
|
private listeners = new Set<Listener>();
|
||||||
|
private actionListeners = new Set<Listener>();
|
||||||
|
|
||||||
|
getSnapshot = (): SettingsChromeSnapshot => this.snapshot;
|
||||||
|
|
||||||
|
subscribe = (listener: Listener): (() => void) => {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
setSnapshot(next: SettingsChromeSnapshot): void {
|
||||||
|
if (settingsChromeSnapshotsEqual(this.snapshot, next)) return;
|
||||||
|
this.snapshot = next;
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getActions = (): SettingsChromeActions | null => this.actions;
|
||||||
|
|
||||||
|
subscribeActions = (listener: Listener): (() => void) => {
|
||||||
|
this.actionListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.actionListeners.delete(listener);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
setActions(next: SettingsChromeActions | null): void {
|
||||||
|
if (this.actions === next) return;
|
||||||
|
this.actions = next;
|
||||||
|
for (const listener of this.actionListeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const settingsChromeStore = new SettingsChromeStore();
|
||||||
|
|
||||||
|
export function publishSettingsChromeSnapshot(
|
||||||
|
snapshot: SettingsChromeSnapshot,
|
||||||
|
): void {
|
||||||
|
settingsChromeStore.setSnapshot(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSettingsChromeSnapshot(): SettingsChromeSnapshot {
|
||||||
|
return settingsChromeStore.getSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeSettingsChrome(listener: Listener): () => void {
|
||||||
|
return settingsChromeStore.subscribe(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSettingsChromeActions(
|
||||||
|
actions: SettingsChromeActions | null,
|
||||||
|
): void {
|
||||||
|
settingsChromeStore.setActions(actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSettingsChromeActions(): SettingsChromeActions | null {
|
||||||
|
return settingsChromeStore.getActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeSettingsChromeActions(listener: Listener): () => void {
|
||||||
|
return settingsChromeStore.subscribeActions(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSettingsChromeStore(): SettingsChromeSnapshot {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
subscribeSettingsChrome,
|
||||||
|
getSettingsChromeSnapshot,
|
||||||
|
getSettingsChromeSnapshot,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const noopSetTheme: SettingsChromeActions['setTheme'] = () => {};
|
||||||
|
const noopSetWindowOpacity: SettingsChromeActions['setWindowOpacity'] = () => {};
|
||||||
|
|
||||||
|
/** Chrome setters, safe to call before `useSettingsState` has registered. */
|
||||||
|
export function useSettingsChromeActions(): SettingsChromeActions {
|
||||||
|
const actions = useSyncExternalStore(
|
||||||
|
subscribeSettingsChromeActions,
|
||||||
|
getSettingsChromeActions,
|
||||||
|
getSettingsChromeActions,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
setTheme: actions?.setTheme ?? noopSetTheme,
|
||||||
|
setWindowOpacity: actions?.setWindowOpacity ?? noopSetWindowOpacity,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -82,6 +82,7 @@ export const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
|
|||||||
export const DEFAULT_SHOW_SFTP_TAB = true;
|
export const DEFAULT_SHOW_SFTP_TAB = true;
|
||||||
export const DEFAULT_SHOW_HOST_TREE_SIDEBAR = true;
|
export const DEFAULT_SHOW_HOST_TREE_SIDEBAR = true;
|
||||||
export const DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS = false;
|
export const DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS = false;
|
||||||
|
export const DEFAULT_SHOW_TAB_NUMBER_BADGES = true;
|
||||||
export const DEFAULT_DISABLE_TERMINAL_FONT_ZOOM = false;
|
export const DEFAULT_DISABLE_TERMINAL_FONT_ZOOM = false;
|
||||||
export { DEFAULT_RESTORE_PREVIOUS_SESSION } from './sessionRestoreSettings';
|
export { DEFAULT_RESTORE_PREVIOUS_SESSION } from './sessionRestoreSettings';
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
|
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
|
||||||
STORAGE_KEY_SSH_TRANSPORT_IDLE_TTL_MS,
|
STORAGE_KEY_SSH_TRANSPORT_IDLE_TTL_MS,
|
||||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||||
|
STORAGE_KEY_SFTP_SKIP_UNCHANGED,
|
||||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||||
|
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||||
@@ -79,6 +81,7 @@ interface UseSettingsStorageSyncParams {
|
|||||||
sftpAutoSync: boolean;
|
sftpAutoSync: boolean;
|
||||||
sftpShowHiddenFiles: boolean;
|
sftpShowHiddenFiles: boolean;
|
||||||
sftpUseCompressedUpload: boolean;
|
sftpUseCompressedUpload: boolean;
|
||||||
|
sftpSkipUnchanged: boolean;
|
||||||
sftpAutoOpenSidebar: boolean;
|
sftpAutoOpenSidebar: boolean;
|
||||||
sftpFollowTerminalCwd: boolean;
|
sftpFollowTerminalCwd: boolean;
|
||||||
sftpDefaultViewMode: 'list' | 'tree';
|
sftpDefaultViewMode: 'list' | 'tree';
|
||||||
@@ -90,6 +93,7 @@ interface UseSettingsStorageSyncParams {
|
|||||||
terminalSidePanelAutoOpen: boolean;
|
terminalSidePanelAutoOpen: boolean;
|
||||||
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
|
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
|
||||||
shellOnlyTabNumberShortcuts: boolean;
|
shellOnlyTabNumberShortcuts: boolean;
|
||||||
|
showTabNumberBadges: boolean;
|
||||||
disableTerminalFontZoom: boolean;
|
disableTerminalFontZoom: boolean;
|
||||||
restorePreviousSession: boolean;
|
restorePreviousSession: boolean;
|
||||||
restoreTerminalCwd: boolean;
|
restoreTerminalCwd: boolean;
|
||||||
@@ -125,6 +129,7 @@ interface UseSettingsStorageSyncParams {
|
|||||||
setSftpAutoSync: Dispatch<SetStateAction<boolean>>;
|
setSftpAutoSync: Dispatch<SetStateAction<boolean>>;
|
||||||
setSftpShowHiddenFiles: Dispatch<SetStateAction<boolean>>;
|
setSftpShowHiddenFiles: Dispatch<SetStateAction<boolean>>;
|
||||||
setSftpUseCompressedUpload: Dispatch<SetStateAction<boolean>>;
|
setSftpUseCompressedUpload: Dispatch<SetStateAction<boolean>>;
|
||||||
|
setSftpSkipUnchanged: Dispatch<SetStateAction<boolean>>;
|
||||||
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
|
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
|
||||||
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
|
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
|
||||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||||
@@ -136,6 +141,7 @@ interface UseSettingsStorageSyncParams {
|
|||||||
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
|
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
|
||||||
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
|
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
|
||||||
setShellOnlyTabNumberShortcutsState: Dispatch<SetStateAction<boolean>>;
|
setShellOnlyTabNumberShortcutsState: Dispatch<SetStateAction<boolean>>;
|
||||||
|
setShowTabNumberBadgesState: Dispatch<SetStateAction<boolean>>;
|
||||||
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
|
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
|
||||||
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
|
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
|
||||||
setRestoreTerminalCwdState: Dispatch<SetStateAction<boolean>>;
|
setRestoreTerminalCwdState: Dispatch<SetStateAction<boolean>>;
|
||||||
@@ -165,8 +171,8 @@ export function useSettingsStorageSync({
|
|||||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
|
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
|
||||||
@@ -174,8 +180,8 @@ export function useSettingsStorageSync({
|
|||||||
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
|
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
|
||||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
|
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
|
||||||
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||||
@@ -189,8 +195,8 @@ export function useSettingsStorageSync({
|
|||||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||||
});
|
});
|
||||||
@@ -199,8 +205,8 @@ export function useSettingsStorageSync({
|
|||||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||||
};
|
};
|
||||||
@@ -379,6 +385,12 @@ export function useSettingsStorageSync({
|
|||||||
setSftpUseCompressedUpload(newValue);
|
setSftpUseCompressedUpload(newValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (e.key === STORAGE_KEY_SFTP_SKIP_UNCHANGED && e.newValue !== null) {
|
||||||
|
const newValue = e.newValue === 'true';
|
||||||
|
if (newValue !== s.sftpSkipUnchanged) {
|
||||||
|
setSftpSkipUnchanged(newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Sync SFTP auto-open sidebar setting from other windows
|
// Sync SFTP auto-open sidebar setting from other windows
|
||||||
if (e.key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && e.newValue !== null) {
|
if (e.key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && e.newValue !== null) {
|
||||||
const newValue = e.newValue === 'true';
|
const newValue = e.newValue === 'true';
|
||||||
@@ -444,6 +456,12 @@ export function useSettingsStorageSync({
|
|||||||
setShellOnlyTabNumberShortcutsState(newValue);
|
setShellOnlyTabNumberShortcutsState(newValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (e.key === STORAGE_KEY_SHOW_TAB_NUMBER_BADGES && e.newValue !== null) {
|
||||||
|
const newValue = e.newValue === 'true';
|
||||||
|
if (newValue !== s.showTabNumberBadges) {
|
||||||
|
setShowTabNumberBadgesState(newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (e.key === STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM && e.newValue !== null) {
|
if (e.key === STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM && e.newValue !== null) {
|
||||||
const newValue = e.newValue === 'true';
|
const newValue = e.newValue === 'true';
|
||||||
if (newValue !== s.disableTerminalFontZoom) {
|
if (newValue !== s.disableTerminalFontZoom) {
|
||||||
@@ -542,6 +560,7 @@ export function useSettingsStorageSync({
|
|||||||
setSftpTransferConcurrencyState,
|
setSftpTransferConcurrencyState,
|
||||||
setSshTransportIdleTtlMsState,
|
setSshTransportIdleTtlMsState,
|
||||||
setSftpUseCompressedUpload,
|
setSftpUseCompressedUpload,
|
||||||
|
setSftpSkipUnchanged,
|
||||||
setShowOnlyUngroupedHostsInRootState,
|
setShowOnlyUngroupedHostsInRootState,
|
||||||
setShowHostTreeSidebarState,
|
setShowHostTreeSidebarState,
|
||||||
setTerminalSidePanelAutoOpenState,
|
setTerminalSidePanelAutoOpenState,
|
||||||
@@ -549,6 +568,7 @@ export function useSettingsStorageSync({
|
|||||||
setShowRecentHostsState,
|
setShowRecentHostsState,
|
||||||
setShowSftpTabState,
|
setShowSftpTabState,
|
||||||
setShellOnlyTabNumberShortcutsState,
|
setShellOnlyTabNumberShortcutsState,
|
||||||
|
setShowTabNumberBadgesState,
|
||||||
setDisableTerminalFontZoomState,
|
setDisableTerminalFontZoomState,
|
||||||
setRestorePreviousSessionState,
|
setRestorePreviousSessionState,
|
||||||
setRestoreTerminalCwdState,
|
setRestoreTerminalCwdState,
|
||||||
|
|||||||
@@ -39,6 +39,32 @@ test("keeps a hidden SFTP owner interactive while its promoted editor tab is ope
|
|||||||
assert.equal(shouldParkBrowseSessions({ interactive, browseParked: false }), false);
|
assert.equal(shouldParkBrowseSessions({ interactive, browseParked: false }), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps browse warm while the terminal side panel stays open on another tool", () => {
|
||||||
|
// SFTP→History/System replaces the focused pane tool, so surfaceVisible is
|
||||||
|
// false, but the owner stays mounted for instant switch-back. Parking here
|
||||||
|
// forces reconnect + directory reload (and intermittent blank lists).
|
||||||
|
const interactive = isBrowseSessionInteractive({
|
||||||
|
surfaceVisible: false,
|
||||||
|
ownerPanelOpen: true,
|
||||||
|
hasOwnedEditorTab: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(interactive, true);
|
||||||
|
assert.equal(shouldParkBrowseSessions({ interactive, browseParked: false }), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parks browse when the side panel is closed and nothing else retains it", () => {
|
||||||
|
const interactive = isBrowseSessionInteractive({
|
||||||
|
surfaceVisible: false,
|
||||||
|
ownerPanelOpen: false,
|
||||||
|
hasOwnedEditorTab: false,
|
||||||
|
hasActiveExternalEdit: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(interactive, false);
|
||||||
|
assert.equal(shouldParkBrowseSessions({ interactive, browseParked: false }), true);
|
||||||
|
});
|
||||||
|
|
||||||
test("parks browse only when the interactive surface hides and not already parked", () => {
|
test("parks browse only when the interactive surface hides and not already parked", () => {
|
||||||
assert.equal(shouldParkBrowseSessions({ interactive: false, browseParked: false }), true);
|
assert.equal(shouldParkBrowseSessions({ interactive: false, browseParked: false }), true);
|
||||||
assert.equal(shouldParkBrowseSessions({ interactive: false, browseParked: true }), false);
|
assert.equal(shouldParkBrowseSessions({ interactive: false, browseParked: true }), false);
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* Browse vs transfer session lifecycle helpers.
|
* Browse vs transfer session lifecycle helpers.
|
||||||
*
|
*
|
||||||
* FileZilla model: the interactive browser can soft-close its SFTP channels
|
* FileZilla model: a *closed* terminal SFTP side panel can soft-close its browse
|
||||||
* while bulk transfers keep dedicated pool connections (and any leased browse
|
* SFTP channels while bulk transfers keep dedicated pool connections (and any
|
||||||
* sessions held by in-flight streams).
|
* leased browse sessions held by in-flight streams). Switching to another side
|
||||||
|
* panel tool (History / System / …) must keep browse warm — the owner stays
|
||||||
|
* mounted for instant switch-back, and parking would force reconnect + reload.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function isBrowseSessionInteractive(params: {
|
export function isBrowseSessionInteractive(params: {
|
||||||
surfaceVisible: boolean;
|
surfaceVisible: boolean;
|
||||||
|
/**
|
||||||
|
* Terminal side panel still open for this owner (another tool may be focused).
|
||||||
|
* Keeps browse sessions alive across tool switches without treating that as a
|
||||||
|
* full panel dismiss.
|
||||||
|
*/
|
||||||
|
ownerPanelOpen?: boolean;
|
||||||
hasOwnedEditorTab: boolean;
|
hasOwnedEditorTab: boolean;
|
||||||
/** External editor temp files (Notepad++ etc.) still need the browse session. */
|
/** External editor temp files (Notepad++ etc.) still need the browse session. */
|
||||||
hasActiveExternalEdit?: boolean;
|
hasActiveExternalEdit?: boolean;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return params.surfaceVisible
|
return params.surfaceVisible
|
||||||
|
|| !!params.ownerPanelOpen
|
||||||
|| params.hasOwnedEditorTab
|
|| params.hasOwnedEditorTab
|
||||||
|| !!params.hasActiveExternalEdit;
|
|| !!params.hasActiveExternalEdit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export type SortField = "name" | "size" | "modified" | "type";
|
||||||
|
export type SortOrder = "asc" | "desc";
|
||||||
|
|
||||||
|
export interface ColumnWidths {
|
||||||
|
name: number;
|
||||||
|
modified: number;
|
||||||
|
size: number;
|
||||||
|
type: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SftpColumnVisibility = Record<keyof ColumnWidths, boolean>;
|
||||||
|
|
||||||
|
export const DEFAULT_SFTP_COLUMN_VISIBILITY: SftpColumnVisibility = {
|
||||||
|
name: true,
|
||||||
|
modified: true,
|
||||||
|
size: true,
|
||||||
|
type: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeSftpColumnVisibility = (value: unknown): SftpColumnVisibility => {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return DEFAULT_SFTP_COLUMN_VISIBILITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = value as Partial<Record<keyof ColumnWidths, unknown>>;
|
||||||
|
return {
|
||||||
|
name: true,
|
||||||
|
modified: stored.modified !== false,
|
||||||
|
size: stored.size !== false,
|
||||||
|
type: stored.type !== false,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
isMissingDirectoryReplacePathError,
|
isMissingDirectoryReplacePathError,
|
||||||
promoteDirectoryReplaceStage as promoteDirectoryReplacePaths,
|
promoteDirectoryReplaceStage as promoteDirectoryReplacePaths,
|
||||||
} from "./directoryReplacePromotion";
|
} from "./directoryReplacePromotion";
|
||||||
|
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||||
|
|
||||||
export interface DedicatedResumeDeps {
|
export interface DedicatedResumeDeps {
|
||||||
hosts: readonly Host[];
|
hosts: readonly Host[];
|
||||||
@@ -161,7 +162,6 @@ export async function openTransferSftpSession(
|
|||||||
if (
|
if (
|
||||||
!wantDedicated
|
!wantDedicated
|
||||||
&& options?.sourceSessionId
|
&& options?.sourceSessionId
|
||||||
&& !host.sftpSudo
|
|
||||||
&& bridge.openSftpForSession
|
&& bridge.openSftpForSession
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
@@ -708,8 +708,19 @@ async function resumeSingleFileWithDedicatedSession(
|
|||||||
skipAdmission: true,
|
skipAdmission: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (streamResult?.error) {
|
if (streamResult?.superseded === true) {
|
||||||
throw new Error(streamResult.error);
|
// Live same-id owner still running; wait for terminal events only.
|
||||||
|
for (;;) {
|
||||||
|
if (shouldAbort?.()) throw new Error("Transfer cancelled");
|
||||||
|
const latest = sftpTransferCenterStore.getTask(task.id);
|
||||||
|
const status = latest?.status;
|
||||||
|
if (status === "completed") break;
|
||||||
|
if (status === "failed") throw new Error(latest?.error || "Transfer failed");
|
||||||
|
if (status === "cancelled") throw new Error("Transfer cancelled");
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
}
|
||||||
|
} else if (streamResult?.error || streamResult?.cancelled) {
|
||||||
|
throw new Error(streamResult.error || "Transfer cancelled");
|
||||||
}
|
}
|
||||||
}, { retries: 1, delayMs: 600 });
|
}, { retries: 1, delayMs: 600 });
|
||||||
return { transferId: task.id };
|
return { transferId: task.id };
|
||||||
@@ -1258,7 +1269,17 @@ async function resumeDirectoryWithDedicatedSession(
|
|||||||
skipAdmission: true,
|
skipAdmission: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (streamResult?.error || streamResult?.cancelled) {
|
if (streamResult?.superseded === true) {
|
||||||
|
for (;;) {
|
||||||
|
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
|
||||||
|
const latest = sftpTransferCenterStore.getTask(childBase.id);
|
||||||
|
const status = latest?.status;
|
||||||
|
if (status === "completed") break;
|
||||||
|
if (status === "failed") throw new Error(latest?.error || "Transfer failed");
|
||||||
|
if (status === "cancelled") throw new Error("Transfer cancelled");
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
}
|
||||||
|
} else if (streamResult?.error || streamResult?.cancelled) {
|
||||||
throw new Error(streamResult.error || "Transfer cancelled");
|
throw new Error(streamResult.error || "Transfer cancelled");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import type { Host } from "../../../domain/models";
|
import type { Host } from "../../../domain/models";
|
||||||
import { ensureRemoteSftpSession } from "./ensureRemoteSftpSession";
|
import { ensureRemoteSftpSession, probeSftpSession } from "./ensureRemoteSftpSession";
|
||||||
import type { SftpPane } from "./types";
|
import type { SftpPane } from "./types";
|
||||||
|
|
||||||
const host = {
|
const host = {
|
||||||
@@ -37,6 +37,24 @@ const remotePane = (connectionId: string): SftpPane => ({
|
|||||||
|
|
||||||
const noopReleaseConnection = async () => {};
|
const noopReleaseConnection = async () => {};
|
||||||
|
|
||||||
|
test("SFTP session probe accepts a virtual root directory", async () => {
|
||||||
|
const calls: Array<[string, string]> = [];
|
||||||
|
const ok = await probeSftpSession({
|
||||||
|
realpathSftp: async (sftpId, remotePath) => {
|
||||||
|
calls.push([sftpId, remotePath]);
|
||||||
|
return "/";
|
||||||
|
},
|
||||||
|
}, "sftp-jumpserver");
|
||||||
|
|
||||||
|
assert.equal(ok, true);
|
||||||
|
assert.deepEqual(calls, [["sftp-jumpserver", "."]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SFTP session probe does not require home discovery", async () => {
|
||||||
|
const ok = await probeSftpSession(undefined, "sftp-live");
|
||||||
|
assert.equal(ok, true);
|
||||||
|
});
|
||||||
|
|
||||||
test("returns an existing mapped SFTP session without reconnecting", async () => {
|
test("returns an existing mapped SFTP session without reconnecting", async () => {
|
||||||
let connectCalls = 0;
|
let connectCalls = 0;
|
||||||
const sftpId = await ensureRemoteSftpSession({
|
const sftpId = await ensureRemoteSftpSession({
|
||||||
|
|||||||
@@ -3,6 +3,24 @@ import type { MutableRefObject } from "react";
|
|||||||
import { isSessionError } from "./errors";
|
import { isSessionError } from "./errors";
|
||||||
import type { SftpPane } from "./types";
|
import type { SftpPane } from "./types";
|
||||||
|
|
||||||
|
export interface SftpSessionProbeBridge {
|
||||||
|
realpathSftp?: (sftpId: string, path: string) => Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe the SFTP protocol itself instead of using remote home discovery.
|
||||||
|
* Restricted/chroot servers can deny SSH exec or expose '/' as their virtual
|
||||||
|
* root while the SFTP session remains fully usable.
|
||||||
|
*/
|
||||||
|
export async function probeSftpSession(
|
||||||
|
bridge: SftpSessionProbeBridge | null | undefined,
|
||||||
|
sftpId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!bridge?.realpathSftp) return true;
|
||||||
|
await bridge.realpathSftp(sftpId, ".");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EnsureRemoteSftpSessionParams {
|
export interface EnsureRemoteSftpSessionParams {
|
||||||
side: "left" | "right";
|
side: "left" | "right";
|
||||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import type { TransferTask } from "../../../domain/models";
|
||||||
|
import {
|
||||||
|
isExternalDragDropFileUpload,
|
||||||
|
retryExternalDragDropFileUpload,
|
||||||
|
} from "./externalDragDropRetry";
|
||||||
|
|
||||||
|
const baseTask = (overrides: Partial<TransferTask> = {}): TransferTask => ({
|
||||||
|
id: "child-1",
|
||||||
|
fileName: "a.txt",
|
||||||
|
sourcePath: "/tmp/docs/a.txt",
|
||||||
|
targetPath: "/remote/docs/a.txt",
|
||||||
|
sourceConnectionId: "external",
|
||||||
|
targetConnectionId: "conn-1",
|
||||||
|
targetHostId: "host-1",
|
||||||
|
direction: "upload",
|
||||||
|
status: "failed",
|
||||||
|
totalBytes: 12,
|
||||||
|
transferredBytes: 0,
|
||||||
|
speed: 0,
|
||||||
|
startTime: 1,
|
||||||
|
isDirectory: false,
|
||||||
|
origin: "drag-drop",
|
||||||
|
parentTaskId: "folder-1",
|
||||||
|
error: "Network error",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects progressive drag-drop file children as retryable", () => {
|
||||||
|
assert.equal(isExternalDragDropFileUpload(baseTask()), true);
|
||||||
|
assert.equal(isExternalDragDropFileUpload(baseTask({ isDirectory: true })), false);
|
||||||
|
assert.equal(isExternalDragDropFileUpload(baseTask({ origin: "manual" })), false);
|
||||||
|
assert.equal(isExternalDragDropFileUpload(baseTask({ sourceConnectionId: "local" })), false);
|
||||||
|
assert.equal(isExternalDragDropFileUpload(baseTask({ status: "completed" })), false);
|
||||||
|
assert.equal(isExternalDragDropFileUpload(baseTask({ retryable: false })), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retry reuses the same transfer id and starts a stream upload", async () => {
|
||||||
|
const patches: Array<{ id: string; updates: Partial<TransferTask> }> = [];
|
||||||
|
const streams: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
|
// No pool acquire → falls back to browse sftp id.
|
||||||
|
const result = await retryExternalDragDropFileUpload(baseTask(), {
|
||||||
|
getBrowseSftpId: () => "sftp-live",
|
||||||
|
startStreamTransfer: async (options) => {
|
||||||
|
streams.push(options as unknown as Record<string, unknown>);
|
||||||
|
return { transferId: options.transferId };
|
||||||
|
},
|
||||||
|
onPatch: (taskId, updates) => patches.push({ id: taskId, updates }),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.success, true);
|
||||||
|
assert.equal(streams.length, 1);
|
||||||
|
assert.equal(streams[0].transferId, "child-1");
|
||||||
|
assert.equal(streams[0].sourcePath, "/tmp/docs/a.txt");
|
||||||
|
assert.equal(streams[0].targetPath, "/remote/docs/a.txt");
|
||||||
|
assert.equal(streams[0].targetSftpId, "sftp-live");
|
||||||
|
assert.equal(patches[0].updates.status, "transferring");
|
||||||
|
assert.equal(patches.at(-1)?.updates.status, "completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retry prefers a dedicated pool session over the browse sftp id", async () => {
|
||||||
|
let released = false;
|
||||||
|
let acquired = false;
|
||||||
|
const result = await retryExternalDragDropFileUpload(baseTask(), {
|
||||||
|
getBrowseSftpId: () => "sftp-live",
|
||||||
|
acquireTransferSession: async () => {
|
||||||
|
acquired = true;
|
||||||
|
return {
|
||||||
|
sftpId: "sftp-pool",
|
||||||
|
poolKey: "host-1",
|
||||||
|
release: () => { released = true; },
|
||||||
|
discard: () => {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
startStreamTransfer: async (options) => {
|
||||||
|
assert.equal(options.targetSftpId, "sftp-pool");
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
onPatch: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.success, true);
|
||||||
|
assert.equal(acquired, true);
|
||||||
|
assert.equal(released, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retry falls back to browse when pool acquire is unavailable", async () => {
|
||||||
|
const result = await retryExternalDragDropFileUpload(baseTask(), {
|
||||||
|
getBrowseSftpId: () => "sftp-live",
|
||||||
|
startStreamTransfer: async (options) => {
|
||||||
|
assert.equal(options.targetSftpId, "sftp-live");
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
onPatch: () => {},
|
||||||
|
});
|
||||||
|
assert.equal(result.success, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retry rolls parent to completed when all siblings succeed", async () => {
|
||||||
|
const patches: Array<{ id: string; updates: Partial<TransferTask> }> = [];
|
||||||
|
const result = await retryExternalDragDropFileUpload(baseTask(), {
|
||||||
|
getBrowseSftpId: () => "sftp-live",
|
||||||
|
startStreamTransfer: async () => ({}),
|
||||||
|
getTask: (id) => (
|
||||||
|
id === "folder-1"
|
||||||
|
? baseTask({
|
||||||
|
id: "folder-1",
|
||||||
|
isDirectory: true,
|
||||||
|
status: "failed",
|
||||||
|
sourcePath: "/tmp/docs",
|
||||||
|
targetPath: "/remote/docs",
|
||||||
|
error: "1 of 2 files failed",
|
||||||
|
totalBytes: 2,
|
||||||
|
transferredBytes: 1,
|
||||||
|
parentTaskId: undefined,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
),
|
||||||
|
getChildTasks: () => [
|
||||||
|
baseTask({ id: "child-1", status: "transferring" }),
|
||||||
|
baseTask({ id: "child-2", status: "completed", sourcePath: "/tmp/docs/b.txt" }),
|
||||||
|
],
|
||||||
|
onPatch: (taskId, updates) => patches.push({ id: taskId, updates }),
|
||||||
|
});
|
||||||
|
assert.equal(result.success, true);
|
||||||
|
const parentPatch = patches.find((p) => p.id === "folder-1");
|
||||||
|
assert.ok(parentPatch);
|
||||||
|
assert.equal(parentPatch?.updates.status, "completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retry does not rollup a still-transferring progressive parent", async () => {
|
||||||
|
const patches: Array<{ id: string; updates: Partial<TransferTask> }> = [];
|
||||||
|
await retryExternalDragDropFileUpload(baseTask(), {
|
||||||
|
getBrowseSftpId: () => "sftp-live",
|
||||||
|
startStreamTransfer: async () => ({}),
|
||||||
|
getTask: (id) => (
|
||||||
|
id === "folder-1"
|
||||||
|
? baseTask({
|
||||||
|
id: "folder-1",
|
||||||
|
isDirectory: true,
|
||||||
|
status: "transferring",
|
||||||
|
sourcePath: "/tmp/docs",
|
||||||
|
parentTaskId: undefined,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
),
|
||||||
|
getChildTasks: () => [
|
||||||
|
baseTask({ id: "child-1", status: "transferring" }),
|
||||||
|
baseTask({ id: "child-2", status: "completed", sourcePath: "/tmp/docs/b.txt" }),
|
||||||
|
],
|
||||||
|
onPatch: (taskId, updates) => patches.push({ id: taskId, updates }),
|
||||||
|
});
|
||||||
|
assert.equal(patches.some((p) => p.id === "folder-1"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retry fails clearly when no sftp session can be opened", async () => {
|
||||||
|
const patches: Array<Partial<TransferTask>> = [];
|
||||||
|
const result = await retryExternalDragDropFileUpload(baseTask(), {
|
||||||
|
getBrowseSftpId: () => undefined,
|
||||||
|
startStreamTransfer: async () => {
|
||||||
|
throw new Error("should not start");
|
||||||
|
},
|
||||||
|
onPatch: (_id, updates) => patches.push(updates),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.success, false);
|
||||||
|
assert.match(result.error || "", /No SFTP session/);
|
||||||
|
assert.equal(patches.at(-1)?.status, "failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stream error keeps the row failed with the backend message", async () => {
|
||||||
|
const patches: Array<Partial<TransferTask>> = [];
|
||||||
|
const result = await retryExternalDragDropFileUpload(baseTask(), {
|
||||||
|
getBrowseSftpId: () => "sftp-live",
|
||||||
|
startStreamTransfer: async () => ({ error: "Permission denied" }),
|
||||||
|
onPatch: (_id, updates) => patches.push(updates),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.success, false);
|
||||||
|
assert.equal(result.error, "Permission denied");
|
||||||
|
assert.equal(patches.at(-1)?.status, "failed");
|
||||||
|
assert.equal(patches.at(-1)?.error, "Permission denied");
|
||||||
|
});
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
/**
|
||||||
|
* Low-cost retry for progressive / external drag-drop file uploads.
|
||||||
|
*
|
||||||
|
* These rows use sourceConnectionId "external" and never have dual-pane
|
||||||
|
* endpoints, so the generic processTransfer retry path silently no-ops.
|
||||||
|
* Retry re-opens a single startStreamTransfer with the stored local path.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TransferTask } from "../../../domain/models";
|
||||||
|
import type { TransferConnectionLease } from "./transferConnectionPool";
|
||||||
|
import { isTransferCancelledFlag } from "./transferCancelLatch";
|
||||||
|
|
||||||
|
export function isExternalDragDropFileUpload(
|
||||||
|
task: Pick<
|
||||||
|
TransferTask,
|
||||||
|
| "origin"
|
||||||
|
| "direction"
|
||||||
|
| "sourceConnectionId"
|
||||||
|
| "isDirectory"
|
||||||
|
| "sourcePath"
|
||||||
|
| "targetPath"
|
||||||
|
| "retryable"
|
||||||
|
| "status"
|
||||||
|
>,
|
||||||
|
): boolean {
|
||||||
|
if (task.retryable === false) return false;
|
||||||
|
if (task.origin !== "drag-drop") return false;
|
||||||
|
if (task.direction !== "upload") return false;
|
||||||
|
if (task.sourceConnectionId !== "external") return false;
|
||||||
|
if (task.isDirectory) return false;
|
||||||
|
if (!task.sourcePath || task.sourcePath === "local") return false;
|
||||||
|
if (!task.targetPath) return false;
|
||||||
|
return task.status === "failed" || task.status === "cancelled" || task.status === "attention";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExternalDragDropRetryDeps = {
|
||||||
|
getBrowseSftpId: (connectionId: string) => string | undefined;
|
||||||
|
acquireTransferSession?: (
|
||||||
|
hostId: string,
|
||||||
|
transferId: string,
|
||||||
|
) => Promise<TransferConnectionLease>;
|
||||||
|
startStreamTransfer: (options: {
|
||||||
|
transferId: string;
|
||||||
|
sourcePath: string;
|
||||||
|
targetPath: string;
|
||||||
|
sourceType: "local";
|
||||||
|
targetType: "local" | "sftp";
|
||||||
|
targetSftpId?: string;
|
||||||
|
targetHostId?: string;
|
||||||
|
totalBytes?: number;
|
||||||
|
resumable?: boolean;
|
||||||
|
checkpointBytes?: number;
|
||||||
|
}) => Promise<{ error?: string; cancelled?: boolean } | undefined>;
|
||||||
|
clearPendingCancel?: (transferId: string) => Promise<unknown>;
|
||||||
|
cleanupArtifacts?: (task: TransferTask) => Promise<void>;
|
||||||
|
onPatch: (taskId: string, updates: Partial<TransferTask>) => void;
|
||||||
|
/** Live store lookup for terminal cancel races and completion bytes. */
|
||||||
|
getTask?: (taskId: string) => TransferTask | undefined;
|
||||||
|
/** Children of a progressive parent (for rollup after child success). */
|
||||||
|
getChildTasks?: (parentTaskId: string) => TransferTask[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restart a failed/cancelled external drag-drop file upload in place (same id).
|
||||||
|
* Returns true when the stream completed successfully.
|
||||||
|
*/
|
||||||
|
export async function retryExternalDragDropFileUpload(
|
||||||
|
task: TransferTask,
|
||||||
|
deps: ExternalDragDropRetryDeps,
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (!isExternalDragDropFileUpload(task)) {
|
||||||
|
return { success: false, error: "Not an external drag-drop file upload" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deps.clearPendingCancel?.(task.id);
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await deps.cleanupArtifacts?.(task);
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetIsLocal = task.targetConnectionId === "local" || !task.targetHostId;
|
||||||
|
let lease: TransferConnectionLease | null = null;
|
||||||
|
let targetSftpId: string | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!targetIsLocal) {
|
||||||
|
// Prefer a dedicated pool session when hostId is known — same policy as
|
||||||
|
// progressive upload (do not pin the browse/tab session).
|
||||||
|
if (task.targetHostId && deps.acquireTransferSession) {
|
||||||
|
lease = await deps.acquireTransferSession(task.targetHostId, `${task.id}:retry`);
|
||||||
|
targetSftpId = lease.sftpId;
|
||||||
|
}
|
||||||
|
if (!targetSftpId) {
|
||||||
|
targetSftpId = deps.getBrowseSftpId(task.targetConnectionId);
|
||||||
|
}
|
||||||
|
if (!targetSftpId) {
|
||||||
|
const error = "No SFTP session available to retry this upload. Reconnect and try again.";
|
||||||
|
deps.onPatch(task.id, {
|
||||||
|
status: "failed",
|
||||||
|
error,
|
||||||
|
endTime: Date.now(),
|
||||||
|
speed: 0,
|
||||||
|
});
|
||||||
|
return { success: false, error };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.onPatch(task.id, {
|
||||||
|
status: "transferring",
|
||||||
|
error: undefined,
|
||||||
|
transferredBytes: 0,
|
||||||
|
checkpointBytes: 0,
|
||||||
|
speed: 0,
|
||||||
|
endTime: undefined,
|
||||||
|
phase: "transferring",
|
||||||
|
reconnectRequired: false,
|
||||||
|
pauseUnavailableReason: undefined,
|
||||||
|
startTime: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await deps.startStreamTransfer({
|
||||||
|
transferId: task.id,
|
||||||
|
sourcePath: task.sourcePath,
|
||||||
|
targetPath: task.targetPath,
|
||||||
|
sourceType: "local",
|
||||||
|
targetType: targetIsLocal ? "local" : "sftp",
|
||||||
|
targetSftpId: targetIsLocal ? undefined : targetSftpId,
|
||||||
|
targetHostId: targetIsLocal ? undefined : task.targetHostId,
|
||||||
|
totalBytes: task.totalBytes > 0 ? task.totalBytes : undefined,
|
||||||
|
resumable: true,
|
||||||
|
checkpointBytes: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Late cancel can settle the row before this return; never resurrect it.
|
||||||
|
if (isTransferCancelledFlag(task.id) || result?.cancelled) {
|
||||||
|
deps.onPatch(task.id, {
|
||||||
|
status: "cancelled",
|
||||||
|
error: undefined,
|
||||||
|
endTime: Date.now(),
|
||||||
|
speed: 0,
|
||||||
|
phase: undefined,
|
||||||
|
});
|
||||||
|
return { success: false, error: "Transfer cancelled" };
|
||||||
|
}
|
||||||
|
if (result?.error) {
|
||||||
|
deps.onPatch(task.id, {
|
||||||
|
status: "failed",
|
||||||
|
error: result.error,
|
||||||
|
endTime: Date.now(),
|
||||||
|
speed: 0,
|
||||||
|
phase: undefined,
|
||||||
|
});
|
||||||
|
return { success: false, error: result.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
const live = deps.getTask?.(task.id);
|
||||||
|
const completedBytes = Math.max(
|
||||||
|
live?.totalBytes ?? 0,
|
||||||
|
live?.transferredBytes ?? 0,
|
||||||
|
task.totalBytes,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
deps.onPatch(task.id, {
|
||||||
|
status: "completed",
|
||||||
|
error: undefined,
|
||||||
|
transferredBytes: completedBytes,
|
||||||
|
totalBytes: Math.max(live?.totalBytes ?? 0, task.totalBytes, completedBytes),
|
||||||
|
endTime: Date.now(),
|
||||||
|
speed: 0,
|
||||||
|
phase: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Progressive parents finalize once with "N of M failed". After a child
|
||||||
|
// retry succeeds, re-roll the parent when no failed children remain.
|
||||||
|
if (task.parentTaskId && deps.getChildTasks) {
|
||||||
|
rollupParentAfterChildSuccess(task.parentTaskId, task.id, deps);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (isTransferCancelledFlag(task.id) || /cancel/i.test(message)) {
|
||||||
|
deps.onPatch(task.id, {
|
||||||
|
status: "cancelled",
|
||||||
|
error: undefined,
|
||||||
|
endTime: Date.now(),
|
||||||
|
speed: 0,
|
||||||
|
phase: undefined,
|
||||||
|
});
|
||||||
|
return { success: false, error: "Transfer cancelled" };
|
||||||
|
}
|
||||||
|
deps.onPatch(task.id, {
|
||||||
|
status: "failed",
|
||||||
|
error: message,
|
||||||
|
endTime: Date.now(),
|
||||||
|
speed: 0,
|
||||||
|
phase: undefined,
|
||||||
|
});
|
||||||
|
if (lease && /session|sftp|disconnect|not found/i.test(message)) {
|
||||||
|
try { lease.discard(); } catch { /* best-effort */ }
|
||||||
|
lease = null;
|
||||||
|
}
|
||||||
|
return { success: false, error: message };
|
||||||
|
} finally {
|
||||||
|
try { lease?.release(); } catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rollupParentAfterChildSuccess(
|
||||||
|
parentTaskId: string,
|
||||||
|
completedChildId: string,
|
||||||
|
deps: ExternalDragDropRetryDeps,
|
||||||
|
): void {
|
||||||
|
// Only re-roll parents that already finished the progressive walk as failed.
|
||||||
|
// Never promote a still-scanning/transferring parent mid-walk.
|
||||||
|
const parent = deps.getTask?.(parentTaskId);
|
||||||
|
if (
|
||||||
|
parent
|
||||||
|
&& parent.status !== "failed"
|
||||||
|
&& parent.status !== "attention"
|
||||||
|
&& parent.status !== "cancelled"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const children = deps.getChildTasks?.(parentTaskId) ?? [];
|
||||||
|
if (children.length === 0) return;
|
||||||
|
const stillFailed = children.some(
|
||||||
|
(child) =>
|
||||||
|
child.id !== completedChildId
|
||||||
|
&& (child.status === "failed" || child.status === "attention"),
|
||||||
|
);
|
||||||
|
if (stillFailed) return;
|
||||||
|
const active = children.some(
|
||||||
|
(child) =>
|
||||||
|
child.id !== completedChildId
|
||||||
|
&& (
|
||||||
|
child.status === "transferring"
|
||||||
|
|| child.status === "pending"
|
||||||
|
|| child.status === "queued"
|
||||||
|
|| child.status === "pausing"
|
||||||
|
|| child.status === "paused"
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (active) return;
|
||||||
|
const completedCount = children.filter(
|
||||||
|
(child) => child.id === completedChildId || child.status === "completed",
|
||||||
|
).length;
|
||||||
|
const total = Math.max(
|
||||||
|
Number(parent?.totalBytes) || 0,
|
||||||
|
children.length,
|
||||||
|
completedCount,
|
||||||
|
);
|
||||||
|
deps.onPatch(parentTaskId, {
|
||||||
|
status: "completed",
|
||||||
|
error: undefined,
|
||||||
|
transferredBytes: completedCount,
|
||||||
|
totalBytes: total,
|
||||||
|
speed: 0,
|
||||||
|
endTime: Date.now(),
|
||||||
|
phase: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||||
|
import type { SftpBookmark } from "../../../domain/models";
|
||||||
|
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||||
|
import { STORAGE_KEY_SFTP_LOCAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
|
||||||
|
import { createSftpBookmark } from "./bookmarkHelpers";
|
||||||
|
|
||||||
|
type Listener = () => void;
|
||||||
|
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
let snapshot: SftpBookmark[] =
|
||||||
|
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) ?? [];
|
||||||
|
|
||||||
|
export function subscribeLocalSftpBookmarks(listener: Listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalSftpBookmarksSnapshot() {
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rehydrateLocalSftpBookmarks() {
|
||||||
|
snapshot = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) ?? [];
|
||||||
|
for (const listener of listeners) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLocalSftpBookmarks(
|
||||||
|
next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[]),
|
||||||
|
) {
|
||||||
|
snapshot = typeof next === "function" ? next(snapshot) : next;
|
||||||
|
localStorageAdapter.write(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS, snapshot);
|
||||||
|
for (const listener of listeners) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.addEventListener("storage", (event) => {
|
||||||
|
if (event.key === STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) {
|
||||||
|
rehydrateLocalSftpBookmarks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseLocalSftpBookmarksParams {
|
||||||
|
currentPath: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLocalSftpBookmarks = ({
|
||||||
|
currentPath,
|
||||||
|
}: UseLocalSftpBookmarksParams) => {
|
||||||
|
const bookmarks = useSyncExternalStore(
|
||||||
|
subscribeLocalSftpBookmarks,
|
||||||
|
getLocalSftpBookmarksSnapshot,
|
||||||
|
getLocalSftpBookmarksSnapshot,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isCurrentPathBookmarked = useMemo(
|
||||||
|
() => !!currentPath && bookmarks.some((b) => b.path === currentPath),
|
||||||
|
[currentPath, bookmarks],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleBookmark = useCallback(() => {
|
||||||
|
if (!currentPath) return;
|
||||||
|
if (isCurrentPathBookmarked) {
|
||||||
|
setLocalSftpBookmarks((prev) => prev.filter((b) => b.path !== currentPath));
|
||||||
|
} else {
|
||||||
|
setLocalSftpBookmarks((prev) => [...prev, createSftpBookmark(currentPath)]);
|
||||||
|
}
|
||||||
|
}, [currentPath, isCurrentPathBookmarked]);
|
||||||
|
|
||||||
|
const deleteBookmark = useCallback((id: string) => {
|
||||||
|
setLocalSftpBookmarks((prev) => prev.filter((b) => b.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bookmarks,
|
||||||
|
isCurrentPathBookmarked,
|
||||||
|
toggleBookmark,
|
||||||
|
deleteBookmark,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* SFTP Clipboard Store
|
||||||
|
*
|
||||||
|
* Manages clipboard state for SFTP file operations (copy/cut/paste)
|
||||||
|
* This is a simple store that holds the clipboard state and operation type.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
type SftpClipboardOperation = "copy" | "cut";
|
||||||
|
|
||||||
|
export interface SftpClipboardFile {
|
||||||
|
name: string;
|
||||||
|
isDirectory: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SftpClipboardState {
|
||||||
|
files: SftpClipboardFile[];
|
||||||
|
sourcePath: string;
|
||||||
|
sourceConnectionId: string;
|
||||||
|
sourceSide: "left" | "right";
|
||||||
|
operation: SftpClipboardOperation;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClipboardListener = () => void;
|
||||||
|
|
||||||
|
let clipboardState: SftpClipboardState | null = null;
|
||||||
|
const clipboardListeners = new Set<ClipboardListener>();
|
||||||
|
|
||||||
|
const notifyListeners = () => {
|
||||||
|
clipboardListeners.forEach((listener) => listener());
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sftpClipboardStore = {
|
||||||
|
getSnapshot: (): SftpClipboardState | null => clipboardState,
|
||||||
|
|
||||||
|
subscribe: (listener: ClipboardListener) => {
|
||||||
|
clipboardListeners.add(listener);
|
||||||
|
return () => clipboardListeners.delete(listener);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy files to clipboard
|
||||||
|
*/
|
||||||
|
copy: (
|
||||||
|
files: SftpClipboardFile[],
|
||||||
|
sourcePath: string,
|
||||||
|
sourceConnectionId: string,
|
||||||
|
sourceSide: "left" | "right"
|
||||||
|
) => {
|
||||||
|
clipboardState = {
|
||||||
|
files,
|
||||||
|
sourcePath,
|
||||||
|
sourceConnectionId,
|
||||||
|
sourceSide,
|
||||||
|
operation: "copy",
|
||||||
|
};
|
||||||
|
notifyListeners();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cut files to clipboard
|
||||||
|
*/
|
||||||
|
cut: (
|
||||||
|
files: SftpClipboardFile[],
|
||||||
|
sourcePath: string,
|
||||||
|
sourceConnectionId: string,
|
||||||
|
sourceSide: "left" | "right"
|
||||||
|
) => {
|
||||||
|
clipboardState = {
|
||||||
|
files,
|
||||||
|
sourcePath,
|
||||||
|
sourceConnectionId,
|
||||||
|
sourceSide,
|
||||||
|
operation: "cut",
|
||||||
|
};
|
||||||
|
notifyListeners();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear clipboard (called after paste for cut operation)
|
||||||
|
*/
|
||||||
|
clear: () => {
|
||||||
|
clipboardState = null;
|
||||||
|
notifyListeners();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update clipboard file list (used for partial cut transfers)
|
||||||
|
*/
|
||||||
|
updateFiles: (files: SftpClipboardFile[]) => {
|
||||||
|
if (!clipboardState) return;
|
||||||
|
if (files.length === 0) {
|
||||||
|
clipboardState = null;
|
||||||
|
} else {
|
||||||
|
clipboardState = {
|
||||||
|
...clipboardState,
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if there are files in the clipboard
|
||||||
|
*/
|
||||||
|
hasFiles: (): boolean => clipboardState !== null && clipboardState.files.length > 0,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the clipboard state
|
||||||
|
*/
|
||||||
|
get: (): SftpClipboardState | null => clipboardState,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook to subscribe to clipboard state changes
|
||||||
|
*/
|
||||||
|
export const useSftpClipboard = (): SftpClipboardState | null => {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
sftpClipboardStore.subscribe,
|
||||||
|
sftpClipboardStore.getSnapshot,
|
||||||
|
sftpClipboardStore.getSnapshot
|
||||||
|
);
|
||||||
|
};
|
||||||
Loaded 100 of 704 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user