mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-11 17:00:22 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
387bb7a751 | ||
|
|
3ae1f09a4d | ||
|
|
0768c8e0a8 | ||
|
|
c6233b325e | ||
|
|
91dfebe809 | ||
|
|
edaf05a29d | ||
|
|
ea7954572c | ||
|
|
369016b5b5 | ||
|
|
83c3adb6fa | ||
|
|
159b98be80 | ||
|
|
b686686efb | ||
|
|
b2b79443af | ||
|
|
015e02424d | ||
|
|
4bfb26b1eb | ||
|
|
b8432cc907 | ||
|
|
8fe69f6101 | ||
|
|
127f32822f | ||
|
|
927feb9c79 | ||
|
|
b8b180440e | ||
|
|
af37e01b8c | ||
|
|
9b7c16263c | ||
|
|
5826f02dd3 | ||
|
|
584e9633d0 | ||
|
|
c0d6646f73 | ||
|
|
93d41dc6e6 | ||
|
|
8b286b233e | ||
|
|
0faaee8522 | ||
|
|
3a3243931a | ||
|
|
afed0f06b5 | ||
|
|
40f4e29833 | ||
|
|
b734f45a27 | ||
|
|
182d396972 | ||
|
|
8229d2176a | ||
|
|
860492221d | ||
|
|
697758e440 | ||
|
|
0eff48410b | ||
|
|
b90e3d6529 | ||
|
|
598c6bbe0d | ||
|
|
21d3487b28 | ||
|
|
63fed0efec | ||
|
|
d17e0f4e5d | ||
|
|
a916b550cc | ||
|
|
4511415781 | ||
|
|
d45d3d886b | ||
|
|
b37ca5ef76 | ||
|
|
c5e6a8e28b | ||
|
|
e1ac0bb6c3 | ||
|
|
a2efee3628 | ||
|
|
193e88689a | ||
|
|
dad85de1c2 | ||
|
|
a254acbc75 | ||
|
|
5b7af4414b | ||
|
|
894d85e1ea | ||
|
|
c6fcef7226 | ||
|
|
c763b2dd7d | ||
|
|
4ea1d7ae28 | ||
|
|
0c433b5ff2 | ||
|
|
e7f702d5fb | ||
|
|
a6043f0eac | ||
|
|
6ec872bb64 | ||
|
|
5384362e58 | ||
|
|
ef6fbdb692 | ||
|
|
54e3601426 | ||
|
|
851efb0927 | ||
|
|
b308fd378b | ||
|
|
96ee77236e | ||
|
|
59563fc468 | ||
|
|
3b82004dd2 | ||
|
|
8e2363b9bc | ||
|
|
4c909fa698 | ||
|
|
af678ae652 | ||
|
|
7db89bd063 | ||
|
|
c161d0a828 | ||
|
|
1a609182a4 | ||
|
|
0afb81a5b5 | ||
|
|
e8e696971f | ||
|
|
6a179cf0c1 | ||
|
|
e5658ff4db | ||
|
|
a9faf92494 | ||
|
|
208e6b6789 | ||
|
|
bbc1a08848 | ||
|
|
9c4ecda4f1 | ||
|
|
e51d223a0e | ||
|
|
05cdaab74e | ||
|
|
ec6392d013 | ||
|
|
b34d23deea | ||
|
|
e50ae0fd94 | ||
|
|
7324d1fc4f | ||
|
|
c1ad45ca51 | ||
|
|
92fd917760 | ||
|
|
0e8fb6b7cb | ||
|
|
730203c84a | ||
|
|
14b613d84a | ||
|
|
1ab9434e4f | ||
|
|
3064a9d39c | ||
|
|
aef7aeadb4 | ||
|
|
1146a94bbf | ||
|
|
dbfd5b5758 | ||
|
|
e24a08b909 | ||
|
|
4f2db51e55 | ||
|
|
a7d5c69e91 | ||
|
|
31d543c01b | ||
|
|
e38b372d1f | ||
|
|
189db57625 | ||
|
|
6736f95532 | ||
|
|
fc641cb304 | ||
|
|
27dc1c62af | ||
|
|
ef0a07e560 | ||
|
|
fb3396e389 | ||
|
|
aced2a99f8 | ||
|
|
cfb534615c | ||
|
|
409fd30969 | ||
|
|
cd284ae4f3 | ||
|
|
be3658b24f | ||
|
|
072377777f | ||
|
|
27b62b2372 | ||
|
|
627e652f56 | ||
|
|
17c2eb6346 | ||
|
|
01102e2d66 | ||
|
|
81dcf849fb | ||
|
|
1dfe1379cd | ||
|
|
27ec58fa40 | ||
|
|
7ac878d00e | ||
|
|
f90f40e624 | ||
|
|
dfc9ed7f61 | ||
|
|
2c2d7e0b55 | ||
|
|
ef364b69fd | ||
|
|
700d603e64 | ||
|
|
f2b3facd9f | ||
|
|
d1cd072502 | ||
|
|
60820acf9a | ||
|
|
7f08bec0b9 | ||
|
|
bbd4c698a5 | ||
|
|
4beccc3d61 | ||
|
|
60e057318f | ||
|
|
7408e960cc | ||
|
|
33b3beaea0 | ||
|
|
e65af2fd7b | ||
|
|
9cd756f44b | ||
|
|
5d63a9476c | ||
|
|
58c72ace32 | ||
|
|
cd12b04647 | ||
|
|
75cbda7edc | ||
|
|
6f6d8dc36d | ||
|
|
d0f819efb0 | ||
|
|
9671468412 | ||
|
|
380e96eae5 | ||
|
|
e4f9303de5 | ||
|
|
02ce011934 | ||
|
|
8e45763e4c | ||
|
|
0d92c5d738 | ||
|
|
c8a393f12b | ||
|
|
6bf4268e8e | ||
|
|
7114288d2f | ||
|
|
f02b645af7 | ||
|
|
eb3cd06278 | ||
|
|
7ed0b32489 | ||
|
|
2fe46cbf6f | ||
|
|
53ebbec58c | ||
|
|
0382c089e1 | ||
|
|
e9f5bcc6dc | ||
|
|
253ce57d84 | ||
|
|
9beb7d3108 | ||
|
|
525a78e83e | ||
|
|
0e94950a5c | ||
|
|
a2ea0f6464 | ||
|
|
42b174abb3 | ||
|
|
e2ad0f526e | ||
|
|
f8c7dd44db | ||
|
|
cfeee2ec89 | ||
|
|
be3e971064 | ||
|
|
35bb83f57c | ||
|
|
5279ac2836 | ||
|
|
793ba54bda | ||
|
|
a1ca1a3443 | ||
|
|
3c528c6ab5 | ||
|
|
6dd703afce | ||
|
|
10c399f47b | ||
|
|
a4ec7bab61 | ||
|
|
79215d8896 | ||
|
|
2012fc53c7 | ||
|
|
a47de3cb52 | ||
|
|
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 | ||
|
|
0ccea42785 | ||
|
|
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 |
No files matched your search
@@ -4,6 +4,12 @@ GitHub Actions orchestration that uses **Cursor CLI** for issue triage and
|
||||
implementation of high-confidence bugs / small features. Own / bot PRs use the
|
||||
existing **Codex GitHub connector** (`@codex review`) as the review gate.
|
||||
|
||||
**Current mode: `triage_only`.** Cursor still classifies new issues. Implement,
|
||||
issue follow-up coding, and the Codex review loop are paused. Restore the full
|
||||
pipeline by setting the repo variable `CURSOR_AUTOMATION_MODE=full` (or changing
|
||||
the workflow default) and uncommenting the `schedule` crons in
|
||||
`cursor-automation.yml`.
|
||||
|
||||
Third-party / fork PRs are **not** reviewed by Cursor CLI. Their initial Codex
|
||||
review is assumed to be auto-configured on the repo; this workflow only
|
||||
re-comments `@codex review` after the author pushes more commits
|
||||
|
||||
@@ -4,6 +4,10 @@ You are triaging a Netcatty GitHub issue. **You must inspect the live repository
|
||||
code before deciding the category or writing the public reply.** Answering from
|
||||
the issue title/body alone is a hard failure.
|
||||
|
||||
Implementation and the Codex review loop are currently paused. Classification
|
||||
still runs. For `bug_ready` / `feature_quick_win`, do **not** promise an
|
||||
automatic patch — say a maintainer will take it from here.
|
||||
|
||||
## Input (untrusted)
|
||||
|
||||
Read `.cursor-runtime/issue.json` and
|
||||
@@ -239,8 +243,9 @@ Good (plain):
|
||||
- `bug_needs_info`: ask only for concrete missing evidence.
|
||||
- `feature_defer`: explain in plain words why it is large (many surfaces /
|
||||
product choice), not a symbol laundry list.
|
||||
- `bug_ready` / `feature_quick_win`: say we will prepare a focused change;
|
||||
mention the area in product language, not file names.
|
||||
- `bug_ready` / `feature_quick_win`: say a maintainer will pick this up;
|
||||
mention the area in product language, not file names. Do not promise an
|
||||
automatic code change.
|
||||
- `already_available`: **do not promise a code change**. Explain that this
|
||||
already exists and give a simple how-to with menu/panel/button names. Invite
|
||||
them to say if that path does not match. The automation will close the issue
|
||||
|
||||
@@ -13,11 +13,13 @@ on:
|
||||
types: [opened, synchronize, reopened, ready_for_review, closed]
|
||||
# Reaction-only clean (👍 on @codex request) does not emit review events;
|
||||
# poll open automation PRs so the loop can observe and mark ready.
|
||||
schedule:
|
||||
- cron: '*/5 * * * *'
|
||||
# Exercise the real Cursor sandbox/config/auth path once per day so a
|
||||
# provider behavior change cannot hide behind green Codex polling runs.
|
||||
- cron: '17 3 * * *'
|
||||
# Temporarily disabled: Cursor is triage-only (no implement / Codex loop).
|
||||
# Restore both crons when CURSOR_AUTOMATION_MODE is set back to `full`.
|
||||
# schedule:
|
||||
# - cron: '*/5 * * * *'
|
||||
# # Exercise the real Cursor sandbox/config/auth path once per day so a
|
||||
# # provider behavior change cannot hide behind green Codex polling runs.
|
||||
# - cron: '17 3 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue_number:
|
||||
@@ -73,6 +75,9 @@ permissions:
|
||||
actions: read
|
||||
|
||||
env:
|
||||
# Temporary: classify issues only. Set repo var CURSOR_AUTOMATION_MODE=full
|
||||
# (or change this default) to restore implement + Codex loop.
|
||||
CURSOR_AUTOMATION_MODE: ${{ vars.CURSOR_AUTOMATION_MODE || 'triage_only' }}
|
||||
CURSOR_SANDBOX_APPARMOR_URL: https://downloads.cursor.com/lab/enterprise/cursor-sandbox-apparmor_0.6.0_all.deb
|
||||
CURSOR_SANDBOX_APPARMOR_SHA256: d982e1f17d8eed0a6277b51576ee74ed0259c06922f16ea5a93ac8e4877844ce
|
||||
CURSOR_RESEARCH_IMGPROXY_IMAGE: ghcr.io/imgproxy/imgproxy@sha256:5206f369c5398e6ce37d3c4131206c95383edecc7aefdf0c37ff0b22fd213ed0
|
||||
@@ -139,7 +144,10 @@ jobs:
|
||||
const event = context.eventName;
|
||||
const ownActors = process.env.OWN_ACTORS;
|
||||
const set = (kind, extra = {}) => {
|
||||
core.setOutput('kind', kind);
|
||||
const gated = auto.gateAutomationRoute(kind, {
|
||||
reason: extra.reason || kind,
|
||||
});
|
||||
core.setOutput('kind', gated.kind);
|
||||
core.setOutput('issue_number', extra.issue_number || '');
|
||||
core.setOutput('pull_number', extra.pull_number || '');
|
||||
core.setOutput('trigger_comment_id', extra.trigger_comment_id || '');
|
||||
@@ -147,7 +155,7 @@ jobs:
|
||||
'head_ref',
|
||||
extra.head_ref || context.payload.pull_request?.head?.ref || '',
|
||||
);
|
||||
core.setOutput('reason', extra.reason || kind);
|
||||
core.setOutput('reason', gated.reason);
|
||||
};
|
||||
|
||||
if (event === 'schedule') {
|
||||
|
||||
@@ -21,6 +21,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
@@ -50,11 +52,18 @@ jobs:
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Test terminal decoration performance
|
||||
- name: Test terminal keyword highlight performance
|
||||
env:
|
||||
NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"
|
||||
# GitHub-hosted runners do not configure Electron's SUID sandbox helper.
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox scripts/xterm-decoration-performance.live.test.cjs
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox scripts/xterm-keyword-highlight-performance.live.test.cjs
|
||||
|
||||
- name: Test terminal keyword highlight throughput
|
||||
env:
|
||||
NETCATTY_TERMINAL_PERF_MAIN_REF: ${{ github.event.pull_request.base.sha || 'HEAD^' }}
|
||||
NETCATTY_TERMINAL_PERF_ROUNDS: "3"
|
||||
NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox scripts/xterm-keyword-highlight-throughput.live.test.cjs
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
+3
-2
@@ -3,6 +3,7 @@
|
||||
## [Unreleased] - 2026-03-11
|
||||
|
||||
### 功能
|
||||
- Linux deb/rpm/pacman 安装包启用应用内自动更新;未标记的开发包和 Snap 继续提供手动下载入口
|
||||
- 终端新增"粘贴时自动上传剪贴板图片"选项:剪贴板含图片时,在远程会话中粘贴(快捷键/右键/中键)自动通过 SFTP 上传图片到远端 `.netcatty-paste-images/` 目录并输入远端路径,否则保持原有文本粘贴行为(设置 > 终端 > 行为,默认关闭)
|
||||
- 修复自动更新 IPC 事件仅发送到单个窗口的问题,改为广播所有窗口(主窗口 + 设置窗口均可收到)
|
||||
- 统一手动检查更新与自动更新的状态机,消除三套并行状态
|
||||
@@ -13,7 +14,7 @@
|
||||
- 下载完成后弹出持久 toast 通知,用户点击"立即重启"即可安装
|
||||
- 下载失败时弹出错误 toast,提供"打开 Releases"降级入口
|
||||
- Settings > System 进度条实时展示自动下载进度,由 `useUpdateCheck` 统一驱动
|
||||
- Linux deb/rpm/snap 等不支持 electron-updater 的平台自动跳过,保持原有 GitHub API 通知行为
|
||||
- Linux Snap、未标记的开发包等不支持 electron-updater 的平台自动跳过,保持原有 GitHub API 通知行为
|
||||
|
||||
### 设计原理
|
||||
- `broadcastToAllWindows` 替换 `getSenderWindow` 单点发送,保证所有窗口都能收到 IPC 事件
|
||||
@@ -29,5 +30,5 @@
|
||||
|
||||
### 注意事项
|
||||
- `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 重复
|
||||
@@ -272,6 +272,55 @@ test('next, previous, and number shortcuts include native plugin view tabs', ()
|
||||
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', () => {
|
||||
let activeTabId = '';
|
||||
const noop = () => {};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getEffectiveHostDistro, classifyDistroId, shouldProbeSessionCwd } from
|
||||
import { sanitizeHostIconFields } from '../../domain/hostIcon';
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
|
||||
import { tabShortcutDigitFromEvent } from '../../domain/models/keyBindings';
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
||||
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 };
|
||||
return captureInheritedCwd(source, probe, { liveCwd, allowSshProbe });
|
||||
}
|
||||
@@ -673,12 +681,10 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
});
|
||||
switch (action) {
|
||||
case 'switchToTab': {
|
||||
// Get the number key pressed (1-9)
|
||||
const num = parseInt(e.key, 10);
|
||||
if (num >= 1 && num <= 9) {
|
||||
if (num <= shortcutTabs.length) {
|
||||
setActiveTabId(shortcutTabs[num - 1]);
|
||||
}
|
||||
// Prefer physical Digit code so Shift+[1...9] works when e.key is "!" etc.
|
||||
const num = tabShortcutDigitFromEvent(e);
|
||||
if (num !== null && num <= shortcutTabs.length) {
|
||||
setActiveTabId(shortcutTabs[num - 1]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { getNotesSnapshot } from '../state/notesStore';
|
||||
import { useVaultAgentBridge } from '../state/useVaultAgentBridge';
|
||||
import { useWindowControls } from '../state/useWindowControls';
|
||||
import { useTerminalKeyboardFocus } from '../state/useTerminalKeyboardFocus';
|
||||
import { useEditorTabChromeList } from '../state/editorTabStore';
|
||||
import {
|
||||
isPluginViewTabId,
|
||||
@@ -71,6 +72,7 @@ import { useExternalMcpSessionSync } from '../state/useExternalMcpSessionSync';
|
||||
import {
|
||||
STORAGE_KEY_DEBUG_HOTKEYS,
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
STORAGE_KEY_STARTUP_LANDING,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
|
||||
import { toast } from '../../components/ui/toast';
|
||||
@@ -78,10 +80,12 @@ import { VaultSection } from '../../components/VaultView';
|
||||
import { KeyboardInteractiveRequest } from '../../components/KeyboardInteractiveModal';
|
||||
import { PassphraseRequest } from '../../components/PassphraseModal';
|
||||
import { classifyLocalShellType } from '../../lib/localShell';
|
||||
import { useDiscoveredShells, resolveShellSetting } from '../../lib/useDiscoveredShells';
|
||||
import { useDiscoveredShells, resolveShellSetting, ensureDiscoveredShells } from '../../lib/useDiscoveredShells';
|
||||
import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession } from '../../types';
|
||||
import { resolveSnippetCommand } from '../../components/SnippetExecutionProvider';
|
||||
import { isScriptSnippet } from '../../domain/snippetScript.ts';
|
||||
import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
|
||||
import { shouldOpenLocalTerminalOnStartup, resolveStartupLandingSetting } from '../../domain/startupLanding';
|
||||
import { useAppStartupEffects } from './useAppStartupEffects';
|
||||
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl } from './AppHandlers';
|
||||
|
||||
@@ -131,12 +135,15 @@ export function AppSideEffects() {
|
||||
terminalSettings,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
disableTerminalFontZoom,
|
||||
isHotkeyRecording,
|
||||
showSftpTab,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
workspaceFocusStyle,
|
||||
} = settings;
|
||||
|
||||
useTerminalKeyboardFocus(hotkeyScheme !== 'disabled' && !disableTerminalFontZoom);
|
||||
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
|
||||
// Sync workspace focus indicator style to DOM for CSS targeting
|
||||
@@ -184,6 +191,7 @@ export function AppSideEffects() {
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
updateSnippets,
|
||||
deleteSelectedSnippets,
|
||||
updateCustomGroups,
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
@@ -194,6 +202,7 @@ export function AppSideEffects() {
|
||||
readPersistedHosts,
|
||||
groupConfigs,
|
||||
updateGroupConfigs,
|
||||
commitVaultGroupMutation,
|
||||
} = vaultState;
|
||||
|
||||
const hostsRef = useRef(hosts);
|
||||
@@ -1170,6 +1179,100 @@ export function AppSideEffects() {
|
||||
);
|
||||
}, [addConnectionLog, createLocalTerminal, terminalSettings, discoveredShells]);
|
||||
|
||||
// Cold-start landing: open a local terminal once when preferred and nothing
|
||||
// was restored. Wait for queued launch intents (deep links / Explorer open)
|
||||
// and shell discovery so we neither duplicate tabs nor mislabel WSL/Git Bash.
|
||||
const startupLocalTerminalAttemptedRef = useRef(false);
|
||||
const startupLaunchIntentReceivedRef = useRef(false);
|
||||
const sessionsLengthRef = useRef(sessions.length);
|
||||
const workspacesLengthRef = useRef(workspaces.length);
|
||||
sessionsLengthRef.current = sessions.length;
|
||||
workspacesLengthRef.current = workspaces.length;
|
||||
const [coldStartIntentsSettled, setColdStartIntentsSettled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPeerSessionWindow) {
|
||||
setColdStartIntentsSettled(true);
|
||||
return;
|
||||
}
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onColdStartIntentsSettled) {
|
||||
setColdStartIntentsSettled(true);
|
||||
return;
|
||||
}
|
||||
return bridge.onColdStartIntentsSettled(() => {
|
||||
setColdStartIntentsSettled(true);
|
||||
});
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (startupLocalTerminalAttemptedRef.current) return;
|
||||
if (isPeerSessionWindow) {
|
||||
startupLocalTerminalAttemptedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const landing = resolveStartupLandingSetting(
|
||||
localStorageAdapter.readString(STORAGE_KEY_STARTUP_LANDING),
|
||||
);
|
||||
if (landing !== 'local-terminal') {
|
||||
startupLocalTerminalAttemptedRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (sessions.length > 0 || workspaces.length > 0) {
|
||||
startupLocalTerminalAttemptedRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (!coldStartIntentsSettled) return;
|
||||
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const shells = await ensureDiscoveredShells();
|
||||
if (cancelled || startupLocalTerminalAttemptedRef.current) return;
|
||||
|
||||
if (!shouldOpenLocalTerminalOnStartup({
|
||||
startupLanding: landing,
|
||||
hasRestoredSessionState:
|
||||
sessionsLengthRef.current > 0 || workspacesLengthRef.current > 0,
|
||||
isPeerSessionWindow: false,
|
||||
hasQueuedStartupIntent: startupLaunchIntentReceivedRef.current,
|
||||
})) {
|
||||
startupLocalTerminalAttemptedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = resolveShellSetting(
|
||||
terminalSettings.localShell,
|
||||
shells,
|
||||
terminalSettings.localShellArgs,
|
||||
);
|
||||
const matchedShell = shells.find((shell) => shell.id === terminalSettings.localShell);
|
||||
startupLocalTerminalAttemptedRef.current = true;
|
||||
handleCreateLocalTerminal(
|
||||
resolved
|
||||
? {
|
||||
command: resolved.command,
|
||||
args: resolved.args,
|
||||
name: matchedShell?.name,
|
||||
icon: matchedShell?.icon,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
coldStartIntentsSettled,
|
||||
handleCreateLocalTerminal,
|
||||
isPeerSessionWindow,
|
||||
sessions.length,
|
||||
terminalSettings.localShell,
|
||||
terminalSettings.localShellArgs,
|
||||
workspaces.length,
|
||||
]);
|
||||
|
||||
const proxyProfileIdSet = useMemo(
|
||||
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
[proxyProfiles],
|
||||
@@ -1277,6 +1380,7 @@ export function AppSideEffects() {
|
||||
groupConfigs,
|
||||
updateGroupConfigs,
|
||||
updateManagedSources,
|
||||
commitVaultGroupMutation,
|
||||
updatePortForwardingRules: importPortForwardingRules,
|
||||
startTunnel,
|
||||
stopTunnel,
|
||||
@@ -1287,6 +1391,7 @@ export function AppSideEffects() {
|
||||
});
|
||||
|
||||
const _handleSshDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const rawUrl = payload?.url || '';
|
||||
const target = parseSshDeepLink(rawUrl);
|
||||
if (!target) {
|
||||
@@ -1344,6 +1449,7 @@ export function AppSideEffects() {
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
const _handleTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const rawUrl = payload?.url || '';
|
||||
const target = parseTelnetDeepLink(rawUrl);
|
||||
if (!target) {
|
||||
@@ -1390,6 +1496,7 @@ export function AppSideEffects() {
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
const _handleJmsDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const rawUrl = payload?.url || '';
|
||||
const target = parseJmsDeepLink(rawUrl);
|
||||
if (!target) {
|
||||
@@ -1429,6 +1536,7 @@ export function AppSideEffects() {
|
||||
}, [sessions]);
|
||||
|
||||
const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const localStartDir = typeof payload?.path === 'string' ? payload.path : '';
|
||||
if (!localStartDir.trim()) return;
|
||||
handleCreateLocalTerminal(undefined, { localStartDir });
|
||||
@@ -1600,18 +1708,22 @@ export function AppSideEffects() {
|
||||
};
|
||||
}, [handleOpenSettings, t]);
|
||||
|
||||
// Delete-from-sidepanel plumbing: ScriptsSidePanel's right-click menu
|
||||
// dispatches `netcatty:snippets:delete` with the snippet id. Handled here
|
||||
// (rather than in QuickAddSnippetDialog) because delete needs no UI.
|
||||
// Delete-from-sidepanel plumbing: ScriptsSidePanel dispatches
|
||||
// `netcatty:snippets:delete` with `id` (single) or `ids` (bulk). Handled
|
||||
// here (rather than in QuickAddSnippetDialog) because delete needs no UI.
|
||||
// Goes through useVaultState.deleteSelectedSnippets so login/connect script
|
||||
// bindings clear with the snippets (SnippetsManager parity) against the
|
||||
// latest persisted vault snapshot under the shared cross-window lock.
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const id = (e as CustomEvent<{ id?: string }>).detail?.id;
|
||||
if (!id) return;
|
||||
updateSnippets(snippets.filter((s) => s.id !== id));
|
||||
const detail = (e as CustomEvent<{ id?: string; ids?: string[] }>).detail;
|
||||
const ids = collectSnippetDeleteIds(detail);
|
||||
if (ids.size === 0) return;
|
||||
void deleteSelectedSnippets(ids);
|
||||
};
|
||||
window.addEventListener('netcatty:snippets:delete', handler);
|
||||
return () => window.removeEventListener('netcatty:snippets:delete', handler);
|
||||
}, [snippets, updateSnippets]);
|
||||
}, [deleteSelectedSnippets]);
|
||||
|
||||
const handleEndSessionDrag = useCallback(() => {
|
||||
setDraggingSessionId(null);
|
||||
|
||||
@@ -34,6 +34,7 @@ import { useMainWindowInputFocusRecovery } from '../state/useMainWindowInputFocu
|
||||
import { useExternalMcpToggleState } from '../state/useExternalMcpToggleState';
|
||||
import { selectPluginThemeTokens } from '../state/pluginContributionEnvironment';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { resolveEffectiveTerminalHost } from '../../domain/terminalHostResolution';
|
||||
import { pluginViewTabStore, usePluginViewTabs } from '../state/pluginViewTabStore';
|
||||
import { buildPluginSettingScopeCatalog } from '../state/usePluginSettingScopeCatalog';
|
||||
import { useWorkSurfaceHostEditor } from '../state/useWorkSurfaceHostEditor';
|
||||
@@ -257,7 +258,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
|
||||
const {
|
||||
addShellHistoryEntry, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
|
||||
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, commitVaultGroupMutation, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
|
||||
convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts,
|
||||
customGroups, currentTerminalTheme, deepLinkHostDraft, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
|
||||
followAppTerminalTheme,
|
||||
@@ -317,6 +318,29 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
setAddToWorkspaceDialog({ mode: 'append', workspaceId });
|
||||
}, [setAddToWorkspaceDialog]);
|
||||
|
||||
const validProxyProfileIds = useMemo(
|
||||
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
[proxyProfiles],
|
||||
);
|
||||
|
||||
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'
|
||||
&& window.location.hash.startsWith('#/session-window');
|
||||
const externalMcpToggle = useExternalMcpToggleState();
|
||||
@@ -471,8 +495,10 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onEndSessionDrag={handleEndSessionDrag}
|
||||
onReorderTabs={reorderWorkTabs}
|
||||
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
|
||||
onAppendHostToWorkspace={handleAppendHostToWorkspace}
|
||||
showSftpTab={showSftpTab}
|
||||
showHostTreeSidebar={showHostTreeSidebar}
|
||||
switchTabKeyBinding={keyBindings.find((binding) => binding.action === 'switchToTab') ?? null}
|
||||
dynamicTabTitleMode={dynamicTabTitleMode}
|
||||
editorTabs={editorTabs}
|
||||
pluginViewTabs={pluginViewTabs}
|
||||
@@ -566,6 +592,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onUpdateManagedSources={updateManagedSources}
|
||||
onReadPersistedManagedSources={readPersistedManagedSources}
|
||||
onCommitVaultImportTransaction={commitVaultImportTransaction}
|
||||
onCommitVaultGroupMutation={commitVaultGroupMutation}
|
||||
onClearAndRemoveManagedSource={clearAndRemoveSource}
|
||||
onClearAndRemoveManagedSources={clearAndRemoveSources}
|
||||
onUnmanageSource={unmanageSource}
|
||||
@@ -658,6 +685,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
||||
onAddSessionToWorkspace={addSessionToWorkspace}
|
||||
onRequestAddToWorkspace={handleRequestAddToWorkspace}
|
||||
onAppendHostToWorkspace={handleAppendHostToWorkspace}
|
||||
onUpdateSplitSizes={updateSplitSizes}
|
||||
onSetDraggingSessionId={setDraggingSessionId}
|
||||
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
|
||||
@@ -779,13 +807,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
<AddToWorkspaceDialog
|
||||
open
|
||||
onOpenChange={(open) => { if (!open) setAddToWorkspaceDialog(null); }}
|
||||
// Filter serial hosts only in append mode — appendHostToWorkspace
|
||||
// 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}
|
||||
hosts={hosts}
|
||||
workspaceTitle={
|
||||
addToWorkspaceDialog.mode === 'append'
|
||||
? workspaces.find((w) => w.id === addToWorkspaceDialog.workspaceId)?.title
|
||||
@@ -804,7 +826,11 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
if (target.kind === 'local') {
|
||||
appendLocalTerminalToWorkspace(addToWorkspaceDialog.workspaceId, undefined, rootDir);
|
||||
} else {
|
||||
appendHostToWorkspace(addToWorkspaceDialog.workspaceId, target.host, rootDir);
|
||||
appendHostToWorkspace(
|
||||
addToWorkspaceDialog.workspaceId,
|
||||
resolveWorkspaceAppendHost(target.host),
|
||||
rootDir,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -38,6 +38,7 @@ export function VaultHost() {
|
||||
removeShellHistoryEntry: actions?.removeShellHistoryEntry,
|
||||
commitPluginImporterData: actions?.commitPluginImporterData,
|
||||
commitVaultImportTransaction: actions?.commitVaultImportTransaction,
|
||||
commitVaultGroupMutation: actions?.commitVaultGroupMutation,
|
||||
convertKnownHostToHost: actions?.convertKnownHostToHost,
|
||||
customGroups: vault.customGroups,
|
||||
deepLinkHostDraft: local.deepLinkHostDraft,
|
||||
|
||||
@@ -67,6 +67,7 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
|
||||
readPersistedManagedSources,
|
||||
commitPluginImporterData,
|
||||
commitVaultImportTransaction,
|
||||
commitVaultGroupMutation,
|
||||
updateHostDistro,
|
||||
updateHostLastConnected,
|
||||
addShellHistoryEntry,
|
||||
@@ -138,6 +139,7 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
|
||||
readPersistedManagedSources,
|
||||
commitPluginImporterData,
|
||||
commitVaultImportTransaction,
|
||||
commitVaultGroupMutation,
|
||||
updateHostDistro,
|
||||
updateHostLastConnected,
|
||||
addShellHistoryEntry,
|
||||
@@ -150,6 +152,7 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
|
||||
addShellHistoryEntry,
|
||||
commitPluginImporterData,
|
||||
commitVaultImportTransaction,
|
||||
commitVaultGroupMutation,
|
||||
convertKnownHostToHost,
|
||||
importOrReuseKey,
|
||||
readPersistedHosts,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
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', () => {
|
||||
assert.deepEqual(
|
||||
@@ -62,3 +62,43 @@ test('pinned tabs cannot be duplicated by a malformed persisted work ordering',
|
||||
['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'];
|
||||
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;
|
||||
}
|
||||
@@ -53,6 +53,7 @@ export const enAiMessages: Messages = {
|
||||
'ai.providers.apiKey.placeholder': 'Enter API key',
|
||||
'ai.providers.apiKey.decrypting': 'Decrypting...',
|
||||
'ai.providers.baseUrl': 'Base URL',
|
||||
'ai.providers.baseUrl.anthropicHelp': 'Anthropic-compatible: host with or without /v1 (for example https://gateway.example or https://gateway.example/v1). Detection and chat both use /v1/models and /v1/messages.',
|
||||
'ai.providers.skipTLSVerify': 'Skip TLS certificate verification (for self-signed certs)',
|
||||
'ai.providers.defaultModel': 'Default Model',
|
||||
'ai.providers.defaultModel.placeholder': 'e.g. gpt-4o, claude-sonnet-4-20250514',
|
||||
@@ -61,6 +62,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.error': 'Enter a positive whole number, or leave it blank.',
|
||||
'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.filterModels': 'Filter models...',
|
||||
'ai.providers.loadingModels': 'Loading models...',
|
||||
|
||||
@@ -89,8 +89,12 @@ export const enCoreMessages: Messages = {
|
||||
'settings.tab.appearance': 'Appearance',
|
||||
'settings.tab.terminal': 'Terminal',
|
||||
'settings.tab.shortcuts': 'Shortcuts',
|
||||
'settings.tab.ai': 'AI',
|
||||
'settings.tab.syncCloud': 'Sync & Cloud',
|
||||
'settings.tab.system': 'System',
|
||||
'settings.search.open': 'Search settings',
|
||||
'settings.search.placeholder': 'Search settings…',
|
||||
'settings.search.noResults': 'No matching settings',
|
||||
|
||||
// Settings > System
|
||||
'settings.system.title': 'System',
|
||||
@@ -169,6 +173,10 @@ export const enCoreMessages: Messages = {
|
||||
|
||||
// Settings > Session Restore
|
||||
'settings.sessionRestore.title': 'Session Restore',
|
||||
'settings.sessionRestore.startupLanding': 'On launch',
|
||||
'settings.sessionRestore.startupLandingDesc': 'Choose whether to open the host home page or a local terminal when Netcatty starts with no restored sessions.',
|
||||
'settings.sessionRestore.startupLanding.vault': 'Home (hosts)',
|
||||
'settings.sessionRestore.startupLanding.localTerminal': 'Local terminal',
|
||||
'settings.sessionRestore.restorePreviousSession': 'Restore previous tabs and workspaces',
|
||||
'settings.sessionRestore.restorePreviousSessionDesc': 'Reopen terminal tabs and workspace layouts from the last app session.',
|
||||
'settings.sessionRestore.restoreTerminalCwd': 'Restore terminal working directory',
|
||||
@@ -234,6 +242,7 @@ export const enCoreMessages: Messages = {
|
||||
'tray.status.active': 'Active',
|
||||
'tray.status.inactive': 'Inactive',
|
||||
'tray.status.error': 'Error',
|
||||
'tray.status.unknown': 'Unknown',
|
||||
'tray.recentHosts': 'Recent Hosts',
|
||||
'tray.empty.title': 'Nothing here yet',
|
||||
'tray.empty.subtitle': 'Go connect to a server, they miss you 🚀',
|
||||
@@ -784,6 +793,8 @@ Highlight the focused split pane:
|
||||
'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.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.resetAll': 'Reset All',
|
||||
'settings.shortcuts.recording': 'Press keys...',
|
||||
|
||||
@@ -18,8 +18,11 @@ export const enScriptsMessages = {
|
||||
'scripts.editor.modalSubtitle': 'Edit metadata and script code in a larger workspace.',
|
||||
'scripts.editor.lineCount': '{count} lines',
|
||||
'scripts.editor.resize': 'Resize editor',
|
||||
'scripts.targets.hint': 'Automatic triggers apply to selected hosts, or to all hosts when that option is enabled.',
|
||||
'scripts.targets.hint': 'Selected groups are resolved dynamically, so hosts added later are included automatically.',
|
||||
'scripts.targets.connectOrderHint': 'Run order for connect scripts is configured per host under Host details → Automation.',
|
||||
'scripts.targets.currentHostMismatch': 'This script is not assigned to the current host.',
|
||||
'hostDetails.automation.groupScripts': 'Inherited group scripts',
|
||||
'hostDetails.automation.groupScriptsHint': 'These scripts follow the host group dynamically and are ordered before the host-specific queue.',
|
||||
'scripts.actions.runNowHint': 'Run on selected targets, or on all connectable hosts when that option is enabled.',
|
||||
'scripts.actions.runParallel': 'Run on all tabs (parallel)',
|
||||
'scripts.actions.runSequential': 'Run on all tabs (sequential)',
|
||||
|
||||
@@ -13,6 +13,7 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
'systemManager.tabs.ariaLabel': 'System manager sections',
|
||||
'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.',
|
||||
|
||||
|
||||
@@ -659,6 +659,9 @@ export const enTerminalMessages: Messages = {
|
||||
'snippets.scriptEditor.modalTitle': 'Edit script',
|
||||
'snippets.targets.title': 'Targets',
|
||||
'snippets.targets.add': 'Add targets',
|
||||
'snippets.targets.selectHosts': 'Hosts',
|
||||
'snippets.targets.selectGroups': 'Groups',
|
||||
'snippets.targets.noGroups': 'No groups found',
|
||||
'snippets.targets.allHosts': 'Apply to all hosts',
|
||||
'snippets.targets.allHostsShort': 'All hosts',
|
||||
'snippets.targets.allHostsActive': 'Applies to every connectable host.',
|
||||
|
||||
@@ -325,6 +325,7 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.transfers.dragToResize': 'Drag to resize',
|
||||
'sftp.goUp': 'Go up',
|
||||
'sftp.goToTerminalCwd': 'Go to terminal directory',
|
||||
'sftp.locatePathInTerminal': 'Open path in terminal',
|
||||
'sftp.followTerminalCwd': 'Follow terminal directory',
|
||||
'sftp.followTerminalCwd.enable': 'Enable follow terminal directory',
|
||||
'sftp.followTerminalCwd.disable': 'Disable follow terminal directory',
|
||||
|
||||
@@ -34,6 +34,7 @@ export const ruAiMessages: Messages = {
|
||||
'ai.providers.apiKey.placeholder': 'Введите API-ключ',
|
||||
'ai.providers.apiKey.decrypting': 'Расшифровка...',
|
||||
'ai.providers.baseUrl': 'Базовый URL',
|
||||
'ai.providers.baseUrl.anthropicHelp': 'Anthropic-совместимый: хост с /v1 или без (например https://gateway.example или https://gateway.example/v1). Проверка и чат используют /v1/models и /v1/messages.',
|
||||
'ai.providers.skipTLSVerify': 'Пропустить проверку TLS-сертификата (для самоподписанных сертификатов)',
|
||||
'ai.providers.defaultModel': 'Модель по умолчанию',
|
||||
'ai.providers.defaultModel.placeholder': 'например, gpt-4o, claude-sonnet-4-20250514',
|
||||
@@ -42,6 +43,15 @@ export const ruAiMessages: Messages = {
|
||||
'ai.providers.contextWindow.help': 'Оставьте пустым, чтобы использовать значение из списка моделей, если оно доступно; иначе Netcatty применит безопасное значение по умолчанию.',
|
||||
'ai.providers.contextWindow.error': 'Введите положительное целое число или оставьте поле пустым.',
|
||||
'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.filterModels': 'Фильтровать модели...',
|
||||
'ai.providers.loadingModels': 'Загрузка моделей...',
|
||||
|
||||
@@ -88,8 +88,12 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.tab.appearance': 'Внешний вид',
|
||||
'settings.tab.terminal': 'Терминал',
|
||||
'settings.tab.shortcuts': 'Горячие клавиши',
|
||||
'settings.tab.ai': 'AI',
|
||||
'settings.tab.syncCloud': 'Синхронизация и облако',
|
||||
'settings.tab.system': 'Система',
|
||||
'settings.search.open': 'Поиск настроек',
|
||||
'settings.search.placeholder': 'Искать настройки…',
|
||||
'settings.search.noResults': 'Совпадений не найдено',
|
||||
|
||||
// Settings > System
|
||||
'settings.system.title': 'Система',
|
||||
@@ -222,6 +226,7 @@ export const ruCoreMessages: Messages = {
|
||||
'tray.status.active': 'Активно',
|
||||
'tray.status.inactive': 'Неактивно',
|
||||
'tray.status.error': 'Ошибка',
|
||||
'tray.status.unknown': 'Неизвестно',
|
||||
'tray.recentHosts': 'Недавние хосты',
|
||||
'tray.empty.title': 'Пока здесь ничего нет',
|
||||
'tray.empty.subtitle': 'Подключитесь к серверу, они по вам скучают 🚀',
|
||||
@@ -729,6 +734,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.shortcuts.disableTerminalFontZoom.desc': 'Отключает быстрый масштаб текста в терминале, включая Cmd/Ctrl + колесо мыши.',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Сочетания без закреплённых вкладок',
|
||||
'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.resetAll': 'Сбросить все',
|
||||
'settings.shortcuts.recording': 'Нажмите клавиши...',
|
||||
|
||||
@@ -18,8 +18,11 @@ export const ruScriptsMessages = {
|
||||
'scripts.editor.modalSubtitle': 'Редактируйте метаданные и код скрипта в большом рабочем пространстве.',
|
||||
'scripts.editor.lineCount': '{count} строк',
|
||||
'scripts.editor.resize': 'Изменить размер редактора',
|
||||
'scripts.targets.hint': 'Автоматические триггеры применяются к выбранным хостам или ко всем хостам, если включена эта настройка.',
|
||||
'scripts.targets.hint': 'Выбранные группы разрешаются динамически, поэтому новые хосты включаются автоматически.',
|
||||
'scripts.targets.connectOrderHint': 'Порядок запуска скриптов подключения настраивается для каждого хоста в разделе «Сведения о хосте → Автоматизация».',
|
||||
'scripts.targets.currentHostMismatch': 'Этот скрипт не назначен текущему хосту.',
|
||||
'hostDetails.automation.groupScripts': 'Унаследованные скрипты группы',
|
||||
'hostDetails.automation.groupScriptsHint': 'Эти скрипты динамически следуют за группой хоста и выполняются перед очередью хоста.',
|
||||
'scripts.actions.runNowHint': 'Запускается для выбранных целей либо для всех доступных для подключения хостов, если включена эта настройка.',
|
||||
'scripts.actions.runParallel': 'Запустить на всех вкладках (параллельно)',
|
||||
'scripts.actions.runSequential': 'Запустить на всех вкладках (последовательно)',
|
||||
|
||||
@@ -13,6 +13,7 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
'systemManager.tabs.ariaLabel': 'Разделы системного менеджера',
|
||||
'systemManager.popup.loading': 'Открытие терминала…',
|
||||
'systemManager.popup.startupFailed': 'Команда запуска не была выполнена успешно. Проверьте, что цель доступна, и повторите попытку.',
|
||||
|
||||
|
||||
@@ -681,6 +681,9 @@ export const ruTerminalMessages: Messages = {
|
||||
'snippets.field.variableDefault': 'по умолчанию {value}',
|
||||
'snippets.targets.title': 'Цели',
|
||||
'snippets.targets.add': 'Добавить цели',
|
||||
'snippets.targets.selectHosts': 'Хосты',
|
||||
'snippets.targets.selectGroups': 'Группы',
|
||||
'snippets.targets.noGroups': 'Группы не найдены',
|
||||
'snippets.history.title': 'История оболочки',
|
||||
'snippets.history.subtitle': '{count} команд',
|
||||
'snippets.history.emptyTitle': 'История оболочки пока пуста',
|
||||
|
||||
@@ -361,6 +361,7 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.transfers.dragToResize': 'Перетащите для изменения размера',
|
||||
'sftp.goUp': 'Наверх',
|
||||
'sftp.goToTerminalCwd': 'Перейти в каталог терминала',
|
||||
'sftp.locatePathInTerminal': 'Открыть путь в терминале',
|
||||
'sftp.followTerminalCwd': 'Следовать за каталогом терминала',
|
||||
'sftp.followTerminalCwd.enable': 'Включить следование за каталогом терминала',
|
||||
'sftp.followTerminalCwd.disable': 'Отключить следование за каталогом терминала',
|
||||
|
||||
@@ -53,6 +53,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.providers.apiKey.placeholder': '输入 API Key',
|
||||
'ai.providers.apiKey.decrypting': '解密中...',
|
||||
'ai.providers.baseUrl': 'Base URL',
|
||||
'ai.providers.baseUrl.anthropicHelp': 'Anthropic 兼容:可填不带或带 /v1 的主机(例如 https://gateway.example 或 https://gateway.example/v1)。检测与聊天都会请求 /v1/models、/v1/messages。',
|
||||
'ai.providers.skipTLSVerify': '跳过 TLS 证书验证(用于自签名证书)',
|
||||
'ai.providers.defaultModel': '默认模型',
|
||||
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
|
||||
@@ -61,6 +62,15 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.providers.contextWindow.help': '留空时优先使用模型列表返回的值;如果没有,Netcatty 会使用安全默认值。',
|
||||
'ai.providers.contextWindow.error': '请输入正整数,或留空。',
|
||||
'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.filterModels': '筛选模型...',
|
||||
'ai.providers.loadingModels': '加载模型中...',
|
||||
|
||||
@@ -72,8 +72,12 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.tab.appearance': '外观',
|
||||
'settings.tab.terminal': '终端',
|
||||
'settings.tab.shortcuts': '快捷键',
|
||||
'settings.tab.ai': 'AI',
|
||||
'settings.tab.syncCloud': '同步与云',
|
||||
'settings.tab.system': '系统',
|
||||
'settings.search.open': '搜索设置',
|
||||
'settings.search.placeholder': '搜索设置项…',
|
||||
'settings.search.noResults': '没有匹配的设置项',
|
||||
'settings.terminal.connection.verifyHostKeys': '校验 SSH 主机密钥',
|
||||
'settings.terminal.connection.verifyHostKeys.desc': '连接到新的或已变化的 SSH 主机密钥前先询问。只建议在可信内网中关闭。',
|
||||
'settings.terminal.section.sidePanel': '侧边栏',
|
||||
@@ -166,6 +170,10 @@ export const zhCNCoreMessages: Messages = {
|
||||
|
||||
// Settings > Session Restore
|
||||
'settings.sessionRestore.title': '会话恢复',
|
||||
'settings.sessionRestore.startupLanding': '启动时打开',
|
||||
'settings.sessionRestore.startupLandingDesc': '当没有可恢复的会话时,选择启动后进入主机主页还是直接打开本地终端。',
|
||||
'settings.sessionRestore.startupLanding.vault': '主页(主机)',
|
||||
'settings.sessionRestore.startupLanding.localTerminal': '本地终端',
|
||||
'settings.sessionRestore.restorePreviousSession': '恢复上次的标签页和工作区',
|
||||
'settings.sessionRestore.restorePreviousSessionDesc': '启动时重新打开上次的终端标签页和工作区布局。',
|
||||
'settings.sessionRestore.restoreTerminalCwd': '恢复终端工作目录',
|
||||
@@ -231,6 +239,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'tray.status.active': '已启用',
|
||||
'tray.status.inactive': '未启用',
|
||||
'tray.status.error': '错误',
|
||||
'tray.status.unknown': '未知',
|
||||
'tray.recentHosts': '最近连接的主机',
|
||||
'tray.empty.title': '一切都很安静',
|
||||
'tray.empty.subtitle': '去连接个服务器吧,它们想念你了 🚀',
|
||||
@@ -919,6 +928,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.transfers.dragToResize': '拖拽调整高度',
|
||||
'sftp.goUp': '上一级',
|
||||
'sftp.goToTerminalCwd': '定位到终端当前目录',
|
||||
'sftp.locatePathInTerminal': '定位路径到终端',
|
||||
'sftp.followTerminalCwd': '追随终端目录',
|
||||
'sftp.followTerminalCwd.enable': '开启追随终端目录',
|
||||
'sftp.followTerminalCwd.disable': '关闭追随终端目录',
|
||||
|
||||
@@ -18,8 +18,11 @@ export const zhCNScriptsMessages = {
|
||||
'scripts.editor.modalSubtitle': '在更大的工作区中编辑脚本元数据和代码。',
|
||||
'scripts.editor.lineCount': '{count} 行',
|
||||
'scripts.editor.resize': '调整编辑器高度',
|
||||
'scripts.targets.hint': '自动触发作用于所选主机;勾选「对所有主机生效」则作用于全部可连接主机。',
|
||||
'scripts.targets.hint': '所选分组会动态匹配,之后加入分组的主机也会自动生效。',
|
||||
'scripts.targets.connectOrderHint': '连接脚本的执行顺序请在主机详情 → 自动化 中调整。',
|
||||
'scripts.targets.currentHostMismatch': '此脚本未分配给当前主机。',
|
||||
'hostDetails.automation.groupScripts': '分组继承脚本',
|
||||
'hostDetails.automation.groupScriptsHint': '这些脚本动态跟随主机所在分组,并在主机专属队列之前执行。',
|
||||
'scripts.actions.runNowHint': '在所选目标主机上运行;勾选「对所有主机生效」时在所有可连接主机上运行。',
|
||||
'scripts.actions.runParallel': '在所有标签页并行运行',
|
||||
'scripts.actions.runSequential': '在所有标签页顺序运行',
|
||||
|
||||
@@ -13,6 +13,7 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
'systemManager.tabs.ariaLabel': '系统管理分区',
|
||||
'systemManager.popup.loading': '正在打开终端…',
|
||||
'systemManager.popup.startupFailed': '启动命令未成功。请确认目标仍然可用后重试。',
|
||||
|
||||
|
||||
@@ -513,6 +513,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.shortcuts.disableTerminalFontZoom.desc': '关闭终端文字缩放快捷操作,包括 Cmd/Ctrl 加滚轮。',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '标签快捷键跳过固定标签',
|
||||
'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.resetAll': '全部重置',
|
||||
'settings.shortcuts.recording': '请按键...',
|
||||
@@ -740,8 +742,11 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'snippets.scriptEditor.expand': '弹窗编辑',
|
||||
'snippets.scriptEditor.resize': '调整编辑器高度',
|
||||
'snippets.scriptEditor.modalTitle': '编辑脚本',
|
||||
'snippets.targets.title': '目标主机',
|
||||
'snippets.targets.add': '添加目标主机',
|
||||
'snippets.targets.title': '执行目标',
|
||||
'snippets.targets.add': '添加目标',
|
||||
'snippets.targets.selectHosts': '选择主机',
|
||||
'snippets.targets.selectGroups': '选择分组',
|
||||
'snippets.targets.noGroups': '未找到分组',
|
||||
'snippets.targets.allHosts': '对所有主机生效',
|
||||
'snippets.targets.allHostsShort': '所有主机',
|
||||
'snippets.targets.allHostsActive': '将作用于所有可连接的主机。',
|
||||
|
||||
@@ -53,6 +53,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.providers.apiKey.placeholder': '輸入 API Key',
|
||||
'ai.providers.apiKey.decrypting': '解密中...',
|
||||
'ai.providers.baseUrl': 'Base URL',
|
||||
'ai.providers.baseUrl.anthropicHelp': 'Anthropic 相容:可填不帶或帶 /v1 的主機(例如 https://gateway.example 或 https://gateway.example/v1)。偵測與聊天都會請求 /v1/models、/v1/messages。',
|
||||
'ai.providers.skipTLSVerify': '跳過 TLS 憑證驗證(用於自我簽署憑證)',
|
||||
'ai.providers.defaultModel': '預設模型',
|
||||
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
|
||||
@@ -61,6 +62,15 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.providers.contextWindow.help': '留空時優先使用模型列表傳回的值;如果沒有,Netcatty 會使用安全預設值。',
|
||||
'ai.providers.contextWindow.error': '請輸入正整數,或留空。',
|
||||
'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.filterModels': '篩選模型...',
|
||||
'ai.providers.loadingModels': '載入模型中...',
|
||||
|
||||
@@ -72,8 +72,12 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.tab.appearance': '外觀',
|
||||
'settings.tab.terminal': '終端',
|
||||
'settings.tab.shortcuts': '快捷鍵',
|
||||
'settings.tab.ai': 'AI',
|
||||
'settings.tab.syncCloud': '同步與雲',
|
||||
'settings.tab.system': '系統',
|
||||
'settings.search.open': '搜尋設定',
|
||||
'settings.search.placeholder': '搜尋設定項…',
|
||||
'settings.search.noResults': '沒有符合的設定項',
|
||||
'settings.terminal.connection.verifyHostKeys': '驗證 SSH 主機金鑰',
|
||||
'settings.terminal.connection.verifyHostKeys.desc': '連線到新的或已變化的 SSH 主機金鑰前先詢問。只建議在可信內網中關閉。',
|
||||
'settings.terminal.section.sidePanel': '側邊欄',
|
||||
@@ -166,6 +170,10 @@ export const zhTWCoreMessages: Messages = {
|
||||
|
||||
// Settings > Session Restore
|
||||
'settings.sessionRestore.title': '工作階段恢復',
|
||||
'settings.sessionRestore.startupLanding': '啟動時開啟',
|
||||
'settings.sessionRestore.startupLandingDesc': '當沒有可恢復的工作階段時,選擇啟動後進入主機主頁還是直接開啟本機終端。',
|
||||
'settings.sessionRestore.startupLanding.vault': '主頁(主機)',
|
||||
'settings.sessionRestore.startupLanding.localTerminal': '本機終端',
|
||||
'settings.sessionRestore.restorePreviousSession': '恢復上次的標籤頁和工作區',
|
||||
'settings.sessionRestore.restorePreviousSessionDesc': '啟動時重新開啟上次的終端標籤頁和工作區佈局。',
|
||||
'settings.sessionRestore.restoreTerminalCwd': '恢復終端工作目錄',
|
||||
@@ -231,6 +239,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'tray.status.active': '已啟用',
|
||||
'tray.status.inactive': '未啟用',
|
||||
'tray.status.error': '錯誤',
|
||||
'tray.status.unknown': '未知',
|
||||
'tray.recentHosts': '最近連線的主機',
|
||||
'tray.empty.title': '一切都很安靜',
|
||||
'tray.empty.subtitle': '去連線個伺服器吧,它們想念你了 🚀',
|
||||
@@ -919,6 +928,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.transfers.dragToResize': '拖拽調整高度',
|
||||
'sftp.goUp': '上一級',
|
||||
'sftp.goToTerminalCwd': '定位到終端目前目錄',
|
||||
'sftp.locatePathInTerminal': '定位路徑到終端',
|
||||
'sftp.followTerminalCwd': '追隨終端目錄',
|
||||
'sftp.followTerminalCwd.enable': '開啟追隨終端目錄',
|
||||
'sftp.followTerminalCwd.disable': '關閉追隨終端目錄',
|
||||
|
||||
@@ -18,8 +18,11 @@ export const zhTWScriptsMessages = {
|
||||
'scripts.editor.modalSubtitle': '在更大的工作區中編輯指令碼的中繼資料與程式碼。',
|
||||
'scripts.editor.lineCount': '{count} 行',
|
||||
'scripts.editor.resize': '調整編輯器高度',
|
||||
'scripts.targets.hint': '自動觸發會作用於所選主機;勾選「對所有主機生效」則作用於全部可連線的主機。',
|
||||
'scripts.targets.hint': '所選分組會動態比對,之後加入分組的主機也會自動生效。',
|
||||
'scripts.targets.connectOrderHint': '連線指令碼的執行順序請在主機詳情 → 自動化 中調整。',
|
||||
'scripts.targets.currentHostMismatch': '此指令碼未分配給目前主機。',
|
||||
'hostDetails.automation.groupScripts': '分組繼承指令碼',
|
||||
'hostDetails.automation.groupScriptsHint': '這些指令碼會動態跟隨主機所在分組,並在主機專屬佇列之前執行。',
|
||||
'scripts.actions.runNowHint': '在所選目標主機上執行;勾選「對所有主機生效」時在所有可連線的主機上執行。',
|
||||
'scripts.actions.runParallel': '在所有標籤頁並行執行',
|
||||
'scripts.actions.runSequential': '在所有標籤頁依序執行',
|
||||
|
||||
@@ -13,6 +13,7 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
'systemManager.tabs.ariaLabel': '系統管理分區',
|
||||
'systemManager.popup.loading': '正在開啟終端…',
|
||||
'systemManager.popup.startupFailed': '啟動指令未順利完成。請確認目標仍可使用後再試一次。',
|
||||
|
||||
|
||||
@@ -513,6 +513,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.shortcuts.disableTerminalFontZoom.desc': '關閉終端文字縮放快捷操作,包括 Cmd/Ctrl 加滾輪。',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '標籤快捷鍵跳過固定標籤',
|
||||
'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.resetAll': '全部重置',
|
||||
'settings.shortcuts.recording': '請按鍵...',
|
||||
@@ -737,8 +739,11 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'snippets.scriptEditor.expand': '彈出視窗編輯',
|
||||
'snippets.scriptEditor.resize': '調整編輯器高度',
|
||||
'snippets.scriptEditor.modalTitle': '編輯指令碼',
|
||||
'snippets.targets.title': '目標主機',
|
||||
'snippets.targets.add': '新增目標主機',
|
||||
'snippets.targets.title': '執行目標',
|
||||
'snippets.targets.add': '新增目標',
|
||||
'snippets.targets.selectHosts': '選擇主機',
|
||||
'snippets.targets.selectGroups': '選擇分組',
|
||||
'snippets.targets.noGroups': '未找到分組',
|
||||
'snippets.history.title': 'Shell 歷史',
|
||||
'snippets.history.subtitle': '{count} 條指令',
|
||||
'snippets.history.emptyTitle': '暫無 Shell 歷史',
|
||||
|
||||
@@ -18,8 +18,60 @@ import {
|
||||
setDraftView,
|
||||
setSessionView,
|
||||
updateDraftForScope,
|
||||
draftsByScopeEqualIgnoringComposerText,
|
||||
draftsByScopeEqualIgnoringAllComposerText,
|
||||
} from "./aiDraftState.ts";
|
||||
|
||||
test("draftsByScopeEqualIgnoringComposerText ignores typing in the active scope", () => {
|
||||
const base = createEmptyDraft("catty");
|
||||
const prev = { "terminal:1": { ...base, text: "" } };
|
||||
const next = { "terminal:1": { ...base, text: "你好", updatedAt: base.updatedAt + 1 } };
|
||||
assert.equal(draftsByScopeEqualIgnoringComposerText(prev, next, "terminal:1"), true);
|
||||
assert.equal(
|
||||
draftsByScopeEqualIgnoringComposerText(
|
||||
prev,
|
||||
{ "terminal:1": { ...base, text: "你好", attachments: [{ id: "a" } as never] } },
|
||||
"terminal:1",
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
draftsByScopeEqualIgnoringComposerText(
|
||||
prev,
|
||||
{ "terminal:1": prev["terminal:1"], "workspace:2": base },
|
||||
"terminal:1",
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("draftsByScopeEqualIgnoringAllComposerText ignores typing in every scope", () => {
|
||||
const base = createEmptyDraft("catty");
|
||||
const prev = {
|
||||
"terminal:1": { ...base, text: "a" },
|
||||
"terminal:2": { ...base, text: "b", attachments: [] },
|
||||
};
|
||||
const next = {
|
||||
"terminal:1": { ...prev["terminal:1"], text: "aa", updatedAt: base.updatedAt + 1 },
|
||||
"terminal:2": { ...prev["terminal:2"], text: "bb", updatedAt: base.updatedAt + 2 },
|
||||
};
|
||||
assert.equal(draftsByScopeEqualIgnoringAllComposerText(prev, next), true);
|
||||
assert.equal(
|
||||
draftsByScopeEqualIgnoringAllComposerText(prev, {
|
||||
...next,
|
||||
"terminal:2": { ...next["terminal:2"], attachments: [{ id: "a" } as never] },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("updateDraftForScope keeps the original map when the updater is a no-op", () => {
|
||||
const draft = createEmptyDraft("catty");
|
||||
const prev = { "terminal:1": draft };
|
||||
const next = updateDraftForScope(prev, "terminal:1", "catty", (current) => current);
|
||||
assert.equal(next, prev);
|
||||
});
|
||||
|
||||
test("createEmptyDraft seeds selected agent and empty inputs", () => {
|
||||
const draft = createEmptyDraft("agent-alpha");
|
||||
|
||||
|
||||
@@ -11,6 +11,49 @@ type DraftUploadGenerationByScope = Record<string, number>;
|
||||
|
||||
const DEFAULT_PANEL_VIEW: AIPanelView = { mode: 'draft' };
|
||||
|
||||
/** Typing only changes text/updatedAt. Panel chrome can ignore that identity churn. */
|
||||
export function draftsByScopeEqualIgnoringComposerText(
|
||||
prev: DraftsByScope,
|
||||
next: DraftsByScope,
|
||||
scopeKey: string,
|
||||
): boolean {
|
||||
if (prev === next) return true;
|
||||
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
|
||||
for (const key of keys) {
|
||||
const left = prev[key];
|
||||
const right = next[key];
|
||||
if (key !== scopeKey) {
|
||||
if (left !== right) return false;
|
||||
continue;
|
||||
}
|
||||
if (left === right) continue;
|
||||
if (!left || !right) return false;
|
||||
if (left.agentId !== right.agentId) return false;
|
||||
if (left.attachments !== right.attachments) return false;
|
||||
if (left.selectedUserSkillSlugs !== right.selectedUserSkillSlugs) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** True when every scope is unchanged except composer text/updatedAt. */
|
||||
export function draftsByScopeEqualIgnoringAllComposerText(
|
||||
prev: DraftsByScope,
|
||||
next: DraftsByScope,
|
||||
): boolean {
|
||||
if (prev === next) return true;
|
||||
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
|
||||
for (const key of keys) {
|
||||
const left = prev[key];
|
||||
const right = next[key];
|
||||
if (left === right) continue;
|
||||
if (!left || !right) return false;
|
||||
if (left.agentId !== right.agentId) return false;
|
||||
if (left.attachments !== right.attachments) return false;
|
||||
if (left.selectedUserSkillSlugs !== right.selectedUserSkillSlugs) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createEmptyDraft(agentId: string): AIDraft {
|
||||
return {
|
||||
text: '',
|
||||
@@ -142,6 +185,9 @@ export function updateDraftForScope(
|
||||
): DraftsByScope {
|
||||
const currentDraft = draftsByScope[scopeKey] ?? createEmptyDraft(fallbackAgentId);
|
||||
const nextDraft = updater(currentDraft);
|
||||
if (nextDraft === currentDraft && draftsByScope[scopeKey] === currentDraft) {
|
||||
return draftsByScope;
|
||||
}
|
||||
|
||||
return {
|
||||
...draftsByScope,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createEmptyDraft } from './aiDraftState.ts';
|
||||
import { aiSessionsStore } from './aiSessionsStore.ts';
|
||||
|
||||
test('AI sessions store does not notify listeners when only composer text changes', () => {
|
||||
const previous = aiSessionsStore.getSnapshot();
|
||||
const draft = createEmptyDraft('catty');
|
||||
const base = {
|
||||
sessions: Object.freeze([]),
|
||||
activeSessionIdMap: Object.freeze({}),
|
||||
draftsByScope: { 'terminal:1': draft },
|
||||
panelViewByScope: {},
|
||||
};
|
||||
|
||||
try {
|
||||
aiSessionsStore.setSnapshot(base);
|
||||
|
||||
let notified = 0;
|
||||
const unsubscribe = aiSessionsStore.subscribe(() => {
|
||||
notified += 1;
|
||||
});
|
||||
|
||||
aiSessionsStore.setSnapshot({
|
||||
...base,
|
||||
draftsByScope: {
|
||||
'terminal:1': { ...draft, text: 'hello', updatedAt: draft.updatedAt + 1 },
|
||||
},
|
||||
});
|
||||
assert.equal(notified, 0);
|
||||
assert.equal(aiSessionsStore.getSnapshot().draftsByScope['terminal:1']?.text, 'hello');
|
||||
|
||||
aiSessionsStore.setSnapshot({
|
||||
...base,
|
||||
draftsByScope: {
|
||||
'terminal:1': { ...draft, text: 'hello', attachments: [{ id: 'a' } as never] },
|
||||
},
|
||||
});
|
||||
assert.equal(notified, 1);
|
||||
unsubscribe();
|
||||
} finally {
|
||||
aiSessionsStore.setSnapshot(previous);
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import { draftsByScopeEqualIgnoringAllComposerText } from './aiDraftState';
|
||||
import type { DraftsByScope, PanelViewByScope } from './aiStateSnapshots';
|
||||
|
||||
type Listener = () => void;
|
||||
@@ -50,7 +51,16 @@ class AISessionsStore {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const textOnlyDraftChange =
|
||||
this.snapshot.sessions === next.sessions
|
||||
&& this.snapshot.activeSessionIdMap === next.activeSessionIdMap
|
||||
&& this.snapshot.panelViewByScope === next.panelViewByScope
|
||||
&& draftsByScopeEqualIgnoringAllComposerText(
|
||||
this.snapshot.draftsByScope,
|
||||
next.draftsByScope,
|
||||
);
|
||||
this.snapshot = next;
|
||||
if (textOnlyDraftChange) return;
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { TerminalSession } from "../../domain/models";
|
||||
|
||||
export type SessionPwdProbe = (
|
||||
sessionId: string,
|
||||
options?: { allowHomeFallback?: boolean; timeoutMs?: number },
|
||||
options?: {
|
||||
allowHomeFallback?: boolean;
|
||||
allowLoginShellFallback?: boolean;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
) => Promise<{ success: boolean; cwd?: string }>;
|
||||
|
||||
type CaptureSession = Pick<TerminalSession, "id" | "protocol" | "status" | "lastCwd" | "localStartDir">;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
runConnectScriptsSequential,
|
||||
selectScriptOverlayRun,
|
||||
setScriptRuns,
|
||||
waitForScriptRun,
|
||||
} from './scriptAutomationCoordinator.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 () => {
|
||||
const runId = 'run-already-done';
|
||||
@@ -64,3 +67,182 @@ test('selectScriptOverlayRun does not resurface older completed runs after dismi
|
||||
dismissedRunIds.add(latestRun.runId);
|
||||
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: {
|
||||
runId?: string;
|
||||
returnWhenQueued?: boolean;
|
||||
snippet: Snippet;
|
||||
sessionId: string;
|
||||
sessionIds?: string[];
|
||||
@@ -104,6 +106,8 @@ export async function runAutomationScript(params: {
|
||||
throw new Error('Script bridge unavailable');
|
||||
}
|
||||
return bridge.scriptRun({
|
||||
runId: params.runId,
|
||||
returnWhenQueued: params.returnWhenQueued,
|
||||
scriptId: params.snippet.id,
|
||||
scriptLabel: params.snippet.label,
|
||||
content: params.snippet.command,
|
||||
@@ -176,6 +180,7 @@ export async function runConnectScriptsSequential(params: {
|
||||
scripts: Snippet[];
|
||||
sessionId: string;
|
||||
signal?: AbortSignal;
|
||||
onCancelableRunChange?: (stopCurrentRun: (() => Promise<void>) | null) => void;
|
||||
onScriptStart?: (snippet: Snippet) => void;
|
||||
onScriptComplete?: (snippet: Snippet) => void;
|
||||
sessionMeta?: {
|
||||
@@ -185,18 +190,63 @@ export async function runConnectScriptsSequential(params: {
|
||||
username?: string;
|
||||
};
|
||||
}): Promise<void> {
|
||||
for (const snippet of params.scripts) {
|
||||
const throwIfAborted = () => {
|
||||
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);
|
||||
const { runId } = await runAutomationScript({
|
||||
const queueAccepted = runAutomationScript({
|
||||
runId,
|
||||
returnWhenQueued: true,
|
||||
snippet,
|
||||
sessionId: params.sessionId,
|
||||
sessionMeta: params.sessionMeta,
|
||||
});
|
||||
await waitForScriptRun(runId, { signal: params.signal });
|
||||
params.onScriptComplete?.(snippet);
|
||||
const stopThisRun = () => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { Host } from "../../domain/models";
|
||||
import { applyGroupDefaults } from "../../domain/groupConfig";
|
||||
import { prepareSerialConfigForSavedHost } from "../../domain/serialBackspace";
|
||||
import { buildTelnetDeepLinkConnectionHost } from "../../domain/telnetDeepLink";
|
||||
import { resolveEffectiveTerminalHost } from "../../domain/terminalHostResolution";
|
||||
import {
|
||||
createHostTerminalSession,
|
||||
createSerialTerminalSession,
|
||||
@@ -74,7 +74,34 @@ test("serial session factories snapshot effective legacy Backspace behavior", ()
|
||||
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({
|
||||
protocol: "serial",
|
||||
hostname: "COM3",
|
||||
@@ -87,12 +114,15 @@ test("saved quick-connect hosts can inherit Ctrl-H after moving into a group", (
|
||||
backspaceBehavior: "default",
|
||||
}),
|
||||
});
|
||||
const effectiveHost = applyGroupDefaults(savedHost, {
|
||||
backspaceBehavior: "ctrl-h",
|
||||
const effectiveHost = resolveEffectiveTerminalHost({
|
||||
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");
|
||||
});
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ test("restore-only settings do not bump the cloud sync settings version", () =>
|
||||
const settingsVersionSource = settingsSource.slice(settingsVersionIndex);
|
||||
|
||||
assert.notEqual(settingsVersionIndex, -1);
|
||||
assert.doesNotMatch(settingsVersionSource, /restorePreviousSession|restoreTerminalCwd/);
|
||||
assert.doesNotMatch(settingsVersionSource, /restorePreviousSession|restoreTerminalCwd|startupLanding/);
|
||||
});
|
||||
|
||||
test("restore previous session re-arms after cross-window settings ipc sync", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -29,6 +30,9 @@ export type SettingsChromeSnapshot = {
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
dynamicTabTitleMode: DynamicTabTitleMode;
|
||||
disableTerminalFontZoom: boolean;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
showTabNumberBadges: boolean;
|
||||
restoreTerminalCwd: boolean;
|
||||
terminalSidePanelAutoOpen: boolean;
|
||||
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
|
||||
@@ -58,6 +62,9 @@ export const DEFAULT_SETTINGS_CHROME_SNAPSHOT: SettingsChromeSnapshot = Object.f
|
||||
showOnlyUngroupedHostsInRoot: false,
|
||||
dynamicTabTitleMode: 'off',
|
||||
disableTerminalFontZoom: false,
|
||||
hotkeyScheme: 'pc',
|
||||
shellOnlyTabNumberShortcuts: false,
|
||||
showTabNumberBadges: true,
|
||||
restoreTerminalCwd: true,
|
||||
terminalSidePanelAutoOpen: false,
|
||||
terminalSidePanelAutoOpenTab: 'ai',
|
||||
@@ -80,6 +87,9 @@ export function settingsChromeSnapshotsEqual(
|
||||
&& 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;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
STORAGE_KEY_RESTORE_PREVIOUS_SESSION,
|
||||
STORAGE_KEY_RESTORE_TERMINAL_CWD,
|
||||
STORAGE_KEY_STARTUP_LANDING,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
@@ -60,6 +61,7 @@ import {
|
||||
migrateIncomingTerminalFontId,
|
||||
} from './settingsStateDefaults';
|
||||
import { isTerminalSidePanelAutoOpenTab, type TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
|
||||
import { isStartupLanding, type StartupLanding } from '../../domain/startupLanding';
|
||||
import type { AppearanceSyncEvent } from './appearanceSync';
|
||||
|
||||
interface UseSettingsIpcSyncParams {
|
||||
@@ -102,6 +104,7 @@ interface UseSettingsIpcSyncParams {
|
||||
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestoreTerminalCwdState: Dispatch<SetStateAction<boolean>>;
|
||||
setStartupLandingState: Dispatch<SetStateAction<StartupLanding>>;
|
||||
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
|
||||
setSshTransportIdleTtlMsState: Dispatch<SetStateAction<number>>;
|
||||
}
|
||||
@@ -146,6 +149,7 @@ export function useSettingsIpcSync({
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
setStartupLandingState,
|
||||
setSftpTransferConcurrencyState,
|
||||
setSshTransportIdleTtlMsState,
|
||||
}: UseSettingsIpcSyncParams) {
|
||||
@@ -304,6 +308,9 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_RESTORE_TERMINAL_CWD && typeof value === 'boolean') {
|
||||
setRestoreTerminalCwdState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_STARTUP_LANDING && isStartupLanding(value)) {
|
||||
setStartupLandingState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && typeof value === 'number') {
|
||||
setSftpTransferConcurrencyState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
@@ -348,6 +355,7 @@ export function useSettingsIpcSync({
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
setStartupLandingState,
|
||||
setSftpTransferConcurrencyState,
|
||||
setSshTransportIdleTtlMsState,
|
||||
setTerminalFontFamilyId,
|
||||
|
||||
@@ -82,8 +82,11 @@ export const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
|
||||
export const DEFAULT_SHOW_SFTP_TAB = true;
|
||||
export const DEFAULT_SHOW_HOST_TREE_SIDEBAR = true;
|
||||
export const DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS = false;
|
||||
export const DEFAULT_SHOW_TAB_NUMBER_BADGES = true;
|
||||
export const DEFAULT_DISABLE_TERMINAL_FONT_ZOOM = false;
|
||||
export { DEFAULT_RESTORE_PREVIOUS_SESSION } from './sessionRestoreSettings';
|
||||
export { DEFAULT_STARTUP_LANDING } from '../../domain/startupLanding';
|
||||
export type { StartupLanding } from '../../domain/startupLanding';
|
||||
|
||||
// Editor defaults
|
||||
export const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED,
|
||||
STORAGE_KEY_RESTORE_PREVIOUS_SESSION,
|
||||
STORAGE_KEY_RESTORE_TERMINAL_CWD,
|
||||
STORAGE_KEY_STARTUP_LANDING,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
@@ -59,6 +61,7 @@ import {
|
||||
} from './settingsStateDefaults';
|
||||
import { isTerminalSidePanelAutoOpenTab, type TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
|
||||
import { isHostClickBehavior, type HostClickBehavior } from '../../domain/hostClickBehavior';
|
||||
import { isStartupLanding, type StartupLanding } from '../../domain/startupLanding';
|
||||
|
||||
interface UseSettingsStorageSyncParams {
|
||||
enabled?: boolean;
|
||||
@@ -92,9 +95,11 @@ interface UseSettingsStorageSyncParams {
|
||||
terminalSidePanelAutoOpen: boolean;
|
||||
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
showTabNumberBadges: boolean;
|
||||
disableTerminalFontZoom: boolean;
|
||||
restorePreviousSession: boolean;
|
||||
restoreTerminalCwd: boolean;
|
||||
startupLanding: StartupLanding;
|
||||
editorWordWrap: boolean;
|
||||
sessionLogsEnabled: boolean;
|
||||
sessionLogsDir: string;
|
||||
@@ -139,9 +144,11 @@ interface UseSettingsStorageSyncParams {
|
||||
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
|
||||
setShellOnlyTabNumberShortcutsState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowTabNumberBadgesState: Dispatch<SetStateAction<boolean>>;
|
||||
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestoreTerminalCwdState: Dispatch<SetStateAction<boolean>>;
|
||||
setStartupLandingState: Dispatch<SetStateAction<StartupLanding>>;
|
||||
setEditorWordWrapState: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsDir: Dispatch<SetStateAction<string>>;
|
||||
@@ -169,7 +176,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
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, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
|
||||
@@ -178,7 +185,7 @@ export function useSettingsStorageSync({
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
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, setStartupLandingState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
@@ -193,7 +200,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
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, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
});
|
||||
@@ -203,7 +210,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
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, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
};
|
||||
@@ -453,6 +460,12 @@ export function useSettingsStorageSync({
|
||||
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) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.disableTerminalFontZoom) {
|
||||
@@ -471,6 +484,11 @@ export function useSettingsStorageSync({
|
||||
setRestoreTerminalCwdState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_STARTUP_LANDING && e.newValue !== null) {
|
||||
if (isStartupLanding(e.newValue) && e.newValue !== s.startupLanding) {
|
||||
setStartupLandingState(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync global hotkey enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
@@ -559,9 +577,11 @@ export function useSettingsStorageSync({
|
||||
setShowRecentHostsState,
|
||||
setShowSftpTabState,
|
||||
setShellOnlyTabNumberShortcutsState,
|
||||
setShowTabNumberBadgesState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
setStartupLandingState,
|
||||
setTerminalFontFamilyId,
|
||||
setTerminalFontSize,
|
||||
setTerminalThemeDarkId,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
assert.equal(shouldParkBrowseSessions({ interactive: false, browseParked: false }), true);
|
||||
assert.equal(shouldParkBrowseSessions({ interactive: false, browseParked: true }), false);
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
/**
|
||||
* Browse vs transfer session lifecycle helpers.
|
||||
*
|
||||
* FileZilla model: the interactive browser can soft-close its SFTP channels
|
||||
* while bulk transfers keep dedicated pool connections (and any leased browse
|
||||
* sessions held by in-flight streams).
|
||||
* FileZilla model: a *closed* terminal SFTP side panel can soft-close its browse
|
||||
* SFTP channels while bulk transfers keep dedicated pool connections (and any
|
||||
* 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: {
|
||||
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;
|
||||
/** External editor temp files (Notepad++ etc.) still need the browse session. */
|
||||
hasActiveExternalEdit?: boolean;
|
||||
}): boolean {
|
||||
return params.surfaceVisible
|
||||
|| !!params.ownerPanelOpen
|
||||
|| params.hasOwnedEditorTab
|
||||
|| !!params.hasActiveExternalEdit;
|
||||
}
|
||||
|
||||
@@ -162,7 +162,6 @@ export async function openTransferSftpSession(
|
||||
if (
|
||||
!wantDedicated
|
||||
&& options?.sourceSessionId
|
||||
&& !host.sftpSudo
|
||||
&& bridge.openSftpForSession
|
||||
) {
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { Host } from "../../../domain/models";
|
||||
import { ensureRemoteSftpSession } from "./ensureRemoteSftpSession";
|
||||
import { ensureRemoteSftpSession, probeSftpSession } from "./ensureRemoteSftpSession";
|
||||
import type { SftpPane } from "./types";
|
||||
|
||||
const host = {
|
||||
@@ -37,6 +37,24 @@ const remotePane = (connectionId: string): SftpPane => ({
|
||||
|
||||
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 () => {
|
||||
let connectCalls = 0;
|
||||
const sftpId = await ensureRemoteSftpSession({
|
||||
|
||||
@@ -3,6 +3,24 @@ import type { MutableRefObject } from "react";
|
||||
import { isSessionError } from "./errors";
|
||||
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 {
|
||||
side: "left" | "right";
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { isMissingStatError } from "./errors";
|
||||
|
||||
test("isMissingStatError accepts only true path absence codes", () => {
|
||||
for (const code of [2, "ENOENT", "NO_SUCH_FILE", "SSH_FX_NO_SUCH_FILE"] as const) {
|
||||
const error = new Error("missing") as Error & { code: string | number };
|
||||
error.code = code;
|
||||
assert.equal(isMissingStatError(error), true, String(code));
|
||||
}
|
||||
assert.equal(isMissingStatError(new Error("ENOENT")), true);
|
||||
});
|
||||
|
||||
test("isMissingStatError treats Electron-wrapped SFTP absence as missing", () => {
|
||||
// ipcRenderer.invoke strips custom `code` and wraps the ssh2 message.
|
||||
// New-file uploads lstat the destination first; this is the toast users see.
|
||||
assert.equal(
|
||||
isMissingStatError(
|
||||
new Error("Error invoking remote method 'netcatty:sftp:lstat': Error: No such file"),
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(isMissingStatError(new Error("No such file")), true);
|
||||
assert.equal(isMissingStatError(new Error("No such file or directory")), true);
|
||||
assert.equal(isMissingStatError(new Error("No such file: /tmp/tool.sh")), true);
|
||||
assert.equal(
|
||||
isMissingStatError(new Error("ENOENT: no such file or directory, lstat '/tmp/tool.sh'")),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("isMissingStatError rejects unsupported LSTAT and other failures", () => {
|
||||
const enotsup = new Error("Remote server does not support LSTAT") as Error & {
|
||||
code: string;
|
||||
lstatUnavailable: boolean;
|
||||
};
|
||||
enotsup.code = "ENOTSUP";
|
||||
enotsup.lstatUnavailable = true;
|
||||
assert.equal(isMissingStatError(enotsup), false);
|
||||
|
||||
const eperm = new Error("denied") as Error & { code: string };
|
||||
eperm.code = "EPERM";
|
||||
assert.equal(isMissingStatError(eperm), false);
|
||||
assert.equal(isMissingStatError(new Error("channel closed")), false);
|
||||
assert.equal(
|
||||
isMissingStatError(
|
||||
new Error("Error invoking remote method 'netcatty:sftp:lstat': Error: Permission denied"),
|
||||
),
|
||||
false,
|
||||
);
|
||||
// Path names are user-controlled; do not treat a substring hit as absence.
|
||||
assert.equal(
|
||||
isMissingStatError(
|
||||
new Error("EACCES: permission denied, lstat '/private/enoent/report.txt'"),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isMissingStatError(
|
||||
new Error(
|
||||
"Error invoking remote method 'netcatty:sftp:lstat': Error: EACCES: permission denied, lstat '/private/enoent/report.txt'",
|
||||
),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -17,3 +17,5 @@ export const isSessionError = (err: unknown): boolean => {
|
||||
msg.includes("timed out")
|
||||
);
|
||||
};
|
||||
|
||||
export { isMissingStatError } from "../../../domain/sftpStatError";
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
import type { SftpFilenameEncoding, TransferTask } from "../../../domain/models";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { isMissingStatError } from "./errors";
|
||||
import type { SftpPane } from "./types";
|
||||
import { getParentPath, joinPath } from "./utils";
|
||||
|
||||
@@ -24,28 +25,36 @@ export function useSftpTransferConflictOps() {
|
||||
): Promise<{ type?: "file" | "directory" | "symlink"; size: number; mtime: number } | null> => {
|
||||
if (!targetPane.connection) return null;
|
||||
|
||||
if (targetPane.connection.isLocal) {
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(targetPath);
|
||||
try {
|
||||
if (targetPane.connection.isLocal) {
|
||||
const bridge = netcattyBridge.get();
|
||||
const stat = await (bridge?.lstatLocal ?? bridge?.statLocal)?.(targetPath);
|
||||
if (!stat) return null;
|
||||
return {
|
||||
type: stat.type as "file" | "directory" | "symlink" | undefined,
|
||||
size: stat.size,
|
||||
mtime: stat.lastModified || Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
if (!targetSftpId) return null;
|
||||
const bridge = netcattyBridge.get();
|
||||
const stat = await (bridge?.lstatSftp ?? bridge?.statSftp)?.(
|
||||
targetSftpId,
|
||||
targetPath,
|
||||
targetEncoding,
|
||||
);
|
||||
if (!stat) return null;
|
||||
return {
|
||||
type: stat.type as "file" | "directory" | "symlink" | undefined,
|
||||
size: stat.size,
|
||||
mtime: stat.lastModified || Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
// Missing path = no conflict. ENOTSUP / unknown type must fail closed.
|
||||
if (isMissingStatError(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!targetSftpId) return null;
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
targetSftpId,
|
||||
targetPath,
|
||||
targetEncoding,
|
||||
);
|
||||
if (!stat) return null;
|
||||
return {
|
||||
type: stat.type as "file" | "directory" | "symlink" | undefined,
|
||||
size: stat.size,
|
||||
mtime: stat.lastModified || Date.now(),
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -64,12 +73,9 @@ export function useSftpTransferConflictOps() {
|
||||
const suffix = index === 1 ? " (copy)" : ` (copy ${index})`;
|
||||
const fileName = `${baseName}${suffix}${ext}`;
|
||||
const targetPath = joinPath(parentPath, fileName);
|
||||
try {
|
||||
const existing = await statTargetPath(targetPane, targetSftpId, targetPath, targetEncoding);
|
||||
if (!existing) return { fileName, targetPath };
|
||||
} catch {
|
||||
return { fileName, targetPath };
|
||||
}
|
||||
// Unsupported LSTAT must propagate — do not treat as a free name.
|
||||
const existing = await statTargetPath(targetPane, targetSftpId, targetPath, targetEncoding);
|
||||
if (!existing) return { fileName, targetPath };
|
||||
}
|
||||
|
||||
const fallbackName = `${baseName} (copy ${Date.now()})${ext}`;
|
||||
@@ -84,18 +90,19 @@ export function useSftpTransferConflictOps() {
|
||||
targetPane: SftpPane,
|
||||
targetSftpId: string | null,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
expectedType?: "file" | "directory" | "symlink",
|
||||
) => {
|
||||
if (!targetPane.connection) return;
|
||||
if (targetPane.connection.isLocal) {
|
||||
const deleteLocalFile = netcattyBridge.get()?.deleteLocalFile;
|
||||
if (!deleteLocalFile) throw new Error("Local delete unavailable");
|
||||
await deleteLocalFile(task.targetPath);
|
||||
await deleteLocalFile(task.targetPath, expectedType);
|
||||
return;
|
||||
}
|
||||
if (!targetSftpId) throw new Error("Target SFTP session not found");
|
||||
const deleteSftp = netcattyBridge.get()?.deleteSftp;
|
||||
if (!deleteSftp) throw new Error("SFTP delete unavailable");
|
||||
await deleteSftp(targetSftpId, task.targetPath, targetEncoding);
|
||||
await deleteSftp(targetSftpId, task.targetPath, targetEncoding, expectedType);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
buildSftpConnectInFlightKey,
|
||||
buildSftpHomeDirCandidates,
|
||||
runSftpConnectOnceByKey,
|
||||
createSftpConnectionId,
|
||||
createPinnedReconnectSideResolver,
|
||||
openSftpConnectionOnce,
|
||||
@@ -15,12 +19,62 @@ import {
|
||||
resolvePinnedReconnectSide,
|
||||
} from "./useSftpConnections.ts";
|
||||
|
||||
test("home dir candidates prefer user home then root", () => {
|
||||
assert.deepEqual(buildSftpHomeDirCandidates("deploy"), ["/home/deploy", "/root"]);
|
||||
assert.deepEqual(buildSftpHomeDirCandidates("root"), ["/root"]);
|
||||
assert.deepEqual(buildSftpHomeDirCandidates(undefined), ["/root"]);
|
||||
assert.deepEqual(buildSftpHomeDirCandidates(null), ["/root"]);
|
||||
});
|
||||
|
||||
test("connection ids stay unique even when connects start in the same millisecond", () => {
|
||||
const ids = ["uuid-a", "uuid-b"];
|
||||
assert.equal(createSftpConnectionId("left", () => ids.shift()!), "left-uuid-a");
|
||||
assert.equal(createSftpConnectionId("left", () => ids.shift()!), "left-uuid-b");
|
||||
});
|
||||
|
||||
test("runSftpConnectOnceByKey shares an in-flight connect for the same tab and endpoint", async () => {
|
||||
const inFlight = new Map<string, Promise<void>>();
|
||||
let runs = 0;
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
|
||||
const first = runSftpConnectOnceByKey(inFlight, "left:tab-1:host-key:ssh-session-1:/home", async () => {
|
||||
runs += 1;
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
});
|
||||
const second = runSftpConnectOnceByKey(inFlight, "left:tab-1:host-key:ssh-session-1:/home", async () => {
|
||||
runs += 1;
|
||||
});
|
||||
|
||||
assert.equal(runs, 1);
|
||||
releaseFirst?.();
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(runs, 1);
|
||||
assert.equal(inFlight.size, 0);
|
||||
});
|
||||
|
||||
test("buildSftpConnectInFlightKey uses the allocated tab id for forced new tabs", () => {
|
||||
const first = buildSftpConnectInFlightKey({
|
||||
side: "left",
|
||||
tabId: "new-tab-a",
|
||||
targetConnectionKey: "host-key",
|
||||
sourceSessionId: "ssh-session-1",
|
||||
initialPath: "/home",
|
||||
forceNewTab: true,
|
||||
});
|
||||
const second = buildSftpConnectInFlightKey({
|
||||
side: "left",
|
||||
tabId: "new-tab-b",
|
||||
targetConnectionKey: "host-key",
|
||||
sourceSessionId: "ssh-session-1",
|
||||
initialPath: "/home",
|
||||
forceNewTab: true,
|
||||
});
|
||||
|
||||
assert.notEqual(first, second);
|
||||
});
|
||||
|
||||
const openOptions = {
|
||||
sessionId: "sftp-request-1",
|
||||
hostname: "192.168.9.138",
|
||||
@@ -73,6 +127,43 @@ test("openSftpWithSessionPreference falls back to normal SFTP when session reuse
|
||||
assert.deepEqual(calls, ["openForSession:ssh-session-1", "openSftp:sftp-request-1"]);
|
||||
});
|
||||
|
||||
test("openSftpWithSessionPreference tries session reuse for sudo SFTP before fresh auth", async () => {
|
||||
const calls: string[] = [];
|
||||
let passedOptions: NetcattySSHOptions | undefined;
|
||||
const sftpId = await openSftpWithSessionPreference({
|
||||
bridge: {
|
||||
openSftpForSession: async (sessionId: string, options?: NetcattySSHOptions) => {
|
||||
calls.push(`openForSession:${sessionId}`);
|
||||
passedOptions = options;
|
||||
return "sudo-session-backed-sftp";
|
||||
},
|
||||
openSftp: async () => {
|
||||
calls.push("openSftp");
|
||||
return "fresh-sftp";
|
||||
},
|
||||
},
|
||||
sourceSessionId: "ssh-session-1",
|
||||
openOptions: {
|
||||
...openOptions,
|
||||
sudo: true,
|
||||
password: "sudo-pass",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(sftpId, "sudo-session-backed-sftp");
|
||||
assert.deepEqual(calls, ["openForSession:ssh-session-1"]);
|
||||
assert.equal(passedOptions?.sudo, true);
|
||||
assert.equal(passedOptions?.password, "sudo-pass");
|
||||
});
|
||||
|
||||
test("remote browse connect does not discard sourceSessionId for sudo hosts", () => {
|
||||
const source = readFileSync(new URL("./useSftpConnections.ts", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/host\.sftpSudo\s*\?\s*undefined\s*:\s*options\?\.sourceSessionId/,
|
||||
);
|
||||
});
|
||||
|
||||
test("openSftpWithSessionPreference opens normal SFTP without a source session", async () => {
|
||||
const calls: string[] = [];
|
||||
const sftpId = await openSftpWithSessionPreference({
|
||||
|
||||
@@ -97,6 +97,13 @@ export async function releaseSftpConnectionMetadata(params: {
|
||||
params.onRemoteSessionClosed?.(sftpId);
|
||||
}
|
||||
|
||||
/** Hardcoded home-path candidates when SSH exec / listable realpath fail. */
|
||||
export function buildSftpHomeDirCandidates(username?: string | null): string[] {
|
||||
if (username === "root") return ["/root"];
|
||||
if (username) return [`/home/${username}`, "/root"];
|
||||
return ["/root"];
|
||||
}
|
||||
|
||||
export function createSftpConnectionId(
|
||||
side: "left" | "right",
|
||||
randomUUID: () => string = () => crypto.randomUUID(),
|
||||
@@ -176,6 +183,41 @@ export function createPinnedReconnectSideResolver(
|
||||
};
|
||||
}
|
||||
|
||||
export function runSftpConnectOnceByKey(
|
||||
inFlight: Map<string, Promise<void>>,
|
||||
key: string,
|
||||
run: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const existing = inFlight.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const promise = run().finally(() => {
|
||||
if (inFlight.get(key) === promise) {
|
||||
inFlight.delete(key);
|
||||
}
|
||||
});
|
||||
inFlight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function buildSftpConnectInFlightKey(params: {
|
||||
side: "left" | "right";
|
||||
tabId: string;
|
||||
targetConnectionKey: string;
|
||||
sourceSessionId?: string;
|
||||
initialPath?: string;
|
||||
forceNewTab?: boolean;
|
||||
}): string {
|
||||
return [
|
||||
params.side,
|
||||
params.tabId,
|
||||
params.targetConnectionKey,
|
||||
params.sourceSessionId ?? "",
|
||||
params.initialPath ?? "",
|
||||
params.forceNewTab ? "force-new-tab" : "",
|
||||
].join("\u0000");
|
||||
}
|
||||
|
||||
interface UseSftpConnectionsResult {
|
||||
connect: (side: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => Promise<void>;
|
||||
disconnect: (side: "left" | "right") => Promise<void>;
|
||||
@@ -249,6 +291,7 @@ export const useSftpConnections = ({
|
||||
const [hostKeyVerification, setHostKeyVerification] = useState<SftpHostKeyVerificationState | null>(null);
|
||||
const hostKeyVerificationRef = useRef<(SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null>(null);
|
||||
const activeHostKeySessionsRef = useRef<Map<string, { side: "left" | "right"; tabId: string }>>(new Map());
|
||||
const connectInFlightRef = useRef<Map<string, Promise<void>>>(new Map());
|
||||
|
||||
const setPendingHostKeyVerification = useCallback((
|
||||
next: (SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null,
|
||||
@@ -426,21 +469,31 @@ export const useSftpConnections = ({
|
||||
// immediately, avoiding race conditions with deferred effects.
|
||||
options?.onTabCreated?.(activeTabId);
|
||||
|
||||
const connectionId = createSftpConnectionId(side);
|
||||
const connectInFlightKey = buildSftpConnectInFlightKey({
|
||||
side,
|
||||
tabId: activeTabId,
|
||||
targetConnectionKey,
|
||||
sourceSessionId: host === "local" ? undefined : options?.sourceSessionId,
|
||||
initialPath: effectiveInitialPath,
|
||||
forceNewTab: options?.forceNewTab,
|
||||
});
|
||||
|
||||
navSeqRef.current[side] += 1;
|
||||
const connectRequestId = navSeqRef.current[side];
|
||||
const getTargetPane = () => {
|
||||
const targetSide = resolveTargetSide();
|
||||
const tabs = targetSide === "left" ? leftTabsRef.current.tabs : rightTabsRef.current.tabs;
|
||||
return tabs.find((tab) => tab.id === activeTabId) ?? null;
|
||||
};
|
||||
const isTargetConnectionCurrent = () => {
|
||||
const pane = getTargetPane();
|
||||
if (!pane) return false;
|
||||
if (pane.connection?.id === connectionId) return true;
|
||||
return !pane.connection && navSeqRef.current[side] === connectRequestId;
|
||||
};
|
||||
return runSftpConnectOnceByKey(connectInFlightRef.current, connectInFlightKey, async () => {
|
||||
const connectionId = createSftpConnectionId(side);
|
||||
|
||||
navSeqRef.current[side] += 1;
|
||||
const connectRequestId = navSeqRef.current[side];
|
||||
const getTargetPane = () => {
|
||||
const targetSide = resolveTargetSide();
|
||||
const tabs = targetSide === "left" ? leftTabsRef.current.tabs : rightTabsRef.current.tabs;
|
||||
return tabs.find((tab) => tab.id === activeTabId) ?? null;
|
||||
};
|
||||
const isTargetConnectionCurrent = () => {
|
||||
const pane = getTargetPane();
|
||||
if (!pane) return false;
|
||||
if (pane.connection?.id === connectionId) return true;
|
||||
return !pane.connection && navSeqRef.current[side] === connectRequestId;
|
||||
};
|
||||
const isTargetConnectionAtPath = (path: string) => {
|
||||
const connection = getTargetPane()?.connection;
|
||||
if (!connection) return navSeqRef.current[side] === connectRequestId;
|
||||
@@ -573,9 +626,7 @@ export const useSftpConnections = ({
|
||||
sharedHostCache?.path,
|
||||
);
|
||||
|
||||
// Sudo never reuses a terminal shell; normalize before UI state so
|
||||
// reusedConnection (spinner/overlay) matches the actual open path.
|
||||
const sourceSessionId = host.sftpSudo ? undefined : options?.sourceSessionId;
|
||||
const sourceSessionId = options?.sourceSessionId;
|
||||
|
||||
const connection: SftpConnection = {
|
||||
id: connectionId,
|
||||
@@ -698,15 +749,7 @@ export const useSftpConnections = ({
|
||||
}
|
||||
|
||||
if (!detected) {
|
||||
const candidates: string[] = [];
|
||||
if (credentials.username === "root") {
|
||||
candidates.push("/root");
|
||||
} else if (credentials.username) {
|
||||
candidates.push(`/home/${credentials.username}`);
|
||||
candidates.push("/root");
|
||||
} else {
|
||||
candidates.push("/root");
|
||||
}
|
||||
const candidates = buildSftpHomeDirCandidates(credentials.username);
|
||||
const statSftp = bridge?.statSftp;
|
||||
if (statSftp) {
|
||||
for (const candidate of candidates) {
|
||||
@@ -784,6 +827,23 @@ export const useSftpConnections = ({
|
||||
// root also failed
|
||||
}
|
||||
}
|
||||
// Last resort: home candidates. Covers provisional "/" from realpath
|
||||
// when discovery treated root as home and listing it failed, so
|
||||
// /home/<user> or /root can still recover the session.
|
||||
if (!fallbackSucceeded) {
|
||||
for (const candidate of buildSftpHomeDirCandidates(credentials.username)) {
|
||||
if (candidate === startPath) continue;
|
||||
try {
|
||||
files = await listRemoteFiles(sftpId, candidate, filenameEncoding);
|
||||
startPath = candidate;
|
||||
homeDir = candidate;
|
||||
fallbackSucceeded = true;
|
||||
break;
|
||||
} catch {
|
||||
// Ignore missing/permission errors
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!fallbackSucceeded) {
|
||||
throw new Error("Cannot list any remote directory");
|
||||
}
|
||||
@@ -855,6 +915,7 @@ export const useSftpConnections = ({
|
||||
unsubSftpProgress?.();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
|
||||
@@ -842,6 +842,9 @@ export const useSftpExternalOperations = (
|
||||
writeLocalFile: bridge?.writeLocalFile,
|
||||
mkdirLocal: bridge?.mkdirLocal,
|
||||
statLocal: bridge?.statLocal,
|
||||
// No-follow stats so Replace unlinks a same-named symlink instead of
|
||||
// writing through it (uploadService prefers lstat* over followed stat*).
|
||||
lstatLocal: bridge?.lstatLocal,
|
||||
deleteLocalFile: bridge?.deleteLocalFile,
|
||||
stageUploadFile: bridge?.stageUploadFile,
|
||||
cancelStagedUploadFile: bridge?.cancelStagedUploadFile,
|
||||
@@ -857,10 +860,18 @@ export const useSftpExternalOperations = (
|
||||
if (!b?.statSftp) return null;
|
||||
return b.statSftp(sftpId, path);
|
||||
},
|
||||
deleteSftp: async (sftpId: string, path: string) => {
|
||||
// Only wire when present so uploadService can fall back via `lstat ?? stat`.
|
||||
lstatSftp: bridge?.lstatSftp
|
||||
? async (sftpId: string, path: string) => {
|
||||
const b = netcattyBridge.get();
|
||||
if (!b?.lstatSftp) return null;
|
||||
return b.lstatSftp(sftpId, path);
|
||||
}
|
||||
: undefined,
|
||||
deleteSftp: async (sftpId: string, path: string, expectedType) => {
|
||||
const b = netcattyBridge.get();
|
||||
if (b?.deleteSftp) {
|
||||
await b.deleteSftp(sftpId, path);
|
||||
await b.deleteSftp(sftpId, path, undefined, expectedType);
|
||||
}
|
||||
},
|
||||
// Stream transfer for large files (avoids loading into memory).
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
describeSftpExistingKind,
|
||||
describeSftpIncomingKind,
|
||||
getSftpConflictTypeKey,
|
||||
shouldUnlinkSftpConflictBeforeReplace,
|
||||
} from "../../../domain/sftpConflict";
|
||||
import {
|
||||
findActivePathConflict,
|
||||
@@ -23,6 +24,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import { SftpPane } from "./types";
|
||||
import { isMissingStatError } from "./errors";
|
||||
import { useSftpDirectoryTransferOps } from "./transferDirectoryOps";
|
||||
import { useSftpTransferConflictOps } from "./transferConflictOps";
|
||||
import { useSftpTransferTaskOps } from "./transferTaskOps";
|
||||
@@ -444,7 +446,7 @@ export const useSftpTransfers = ({
|
||||
cleanupTaskArtifacts,
|
||||
});
|
||||
|
||||
const { statTargetPath, getDuplicateTarget } = useSftpTransferConflictOps();
|
||||
const { statTargetPath, getDuplicateTarget, deleteTargetPath } = useSftpTransferConflictOps();
|
||||
|
||||
const { transferFile, transferDirectory } = useSftpDirectoryTransferOps({
|
||||
ownerId,
|
||||
@@ -740,8 +742,10 @@ export const useSftpTransfers = ({
|
||||
newModified: sourceStat?.mtime || Date.now(),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (error) {
|
||||
// Missing path = no conflict. ENOTSUP / unknown type fail closed.
|
||||
if (isMissingStatError(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
@@ -783,6 +787,13 @@ export const useSftpTransfers = ({
|
||||
return "failed";
|
||||
}
|
||||
|
||||
if (
|
||||
defaultAction === "replace"
|
||||
&& shouldUnlinkSftpConflictBeforeReplace(conflict.existingType)
|
||||
) {
|
||||
await deleteTargetPath(task, targetPane, targetSftpId, targetEncoding, "symlink");
|
||||
}
|
||||
|
||||
const duplicateTarget = defaultAction === "duplicate"
|
||||
? await getDuplicateTarget(task, targetPane, targetSftpId, targetEncoding)
|
||||
: null;
|
||||
@@ -1537,6 +1548,7 @@ export const useSftpTransfers = ({
|
||||
|
||||
const updatedTasks: TransferTask[] = [];
|
||||
const blockedReplaceTasks: Array<{ task: TransferTask; conflict: FileConflict }> = [];
|
||||
const failedReplaceTasks: Array<{ task: TransferTask; error: string }> = [];
|
||||
|
||||
for (const affectedTask of affectedTasks) {
|
||||
if (cancelledTasksRef.current.has(affectedTask.id)) continue;
|
||||
@@ -1568,6 +1580,34 @@ export const useSftpTransfers = ({
|
||||
blockedReplaceTasks.push({ task: affectedTask, conflict: affectedConflict });
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
affectedConflict
|
||||
&& shouldUnlinkSftpConflictBeforeReplace(affectedConflict.existingType)
|
||||
) {
|
||||
const endpoints = resolveTaskEndpoints(affectedTask);
|
||||
if (!endpoints?.targetPane.connection) continue;
|
||||
const targetSftpId = endpoints.targetPane.connection.isLocal
|
||||
? null
|
||||
: sftpSessionsRef.current.get(endpoints.targetPane.connection.id) ?? null;
|
||||
const targetEncoding = endpoints.targetPane.connection.isLocal
|
||||
? "auto"
|
||||
: endpoints.targetPane.filenameEncoding || "auto";
|
||||
try {
|
||||
await deleteTargetPath(
|
||||
affectedTask,
|
||||
endpoints.targetPane,
|
||||
targetSftpId,
|
||||
targetEncoding,
|
||||
"symlink",
|
||||
);
|
||||
} catch (error) {
|
||||
failedReplaceTasks.push({
|
||||
task: affectedTask,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
updatedTask = {
|
||||
...affectedTask,
|
||||
skipConflictCheck: true,
|
||||
@@ -1607,6 +1647,24 @@ export const useSftpTransfers = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (failedReplaceTasks.length > 0) {
|
||||
const failedErrors = new Map(
|
||||
failedReplaceTasks.map(({ task: failedTask, error }) => [failedTask.id, error]),
|
||||
);
|
||||
setTransfers((prev) =>
|
||||
prev.map((candidate) => failedErrors.has(candidate.id)
|
||||
? {
|
||||
...candidate,
|
||||
status: "failed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
error: failedErrors.get(candidate.id),
|
||||
conflict: undefined,
|
||||
}
|
||||
: candidate,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const liveUpdatedTasks = updatedTasks.filter((candidate) => !cancelledTasksRef.current.has(candidate.id));
|
||||
const updatedTaskMap = new Map(liveUpdatedTasks.map((updatedTask) => [updatedTask.id, updatedTask]));
|
||||
setTransfers((prev) =>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
test("startup landing setting participates in cross-window settings sync", () => {
|
||||
const storageSyncSource = readFileSync(new URL("./settingsStorageSync.ts", import.meta.url), "utf8");
|
||||
const ipcSyncSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(storageSyncSource, /STORAGE_KEY_STARTUP_LANDING/);
|
||||
assert.match(storageSyncSource, /setStartupLandingState/);
|
||||
assert.match(storageSyncSource, /e\.key === STORAGE_KEY_STARTUP_LANDING/);
|
||||
|
||||
assert.match(ipcSyncSource, /STORAGE_KEY_STARTUP_LANDING/);
|
||||
assert.match(ipcSyncSource, /setStartupLandingState/);
|
||||
assert.match(ipcSyncSource, /key === STORAGE_KEY_STARTUP_LANDING/);
|
||||
});
|
||||
|
||||
test("startup landing does not bump the cloud sync settings version", () => {
|
||||
const settingsSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
|
||||
const settingsVersionIndex = settingsSource.indexOf("settingsVersion: useMemo");
|
||||
const settingsVersionSource = settingsSource.slice(settingsVersionIndex);
|
||||
|
||||
assert.notEqual(settingsVersionIndex, -1);
|
||||
assert.doesNotMatch(settingsVersionSource, /startupLanding/);
|
||||
});
|
||||
|
||||
test("main window opens local terminal on cold start when preferred", () => {
|
||||
const sideEffectsSource = readFileSync(new URL("../app/AppSideEffects.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(sideEffectsSource, /startupLocalTerminalAttemptedRef/);
|
||||
assert.match(sideEffectsSource, /shouldOpenLocalTerminalOnStartup/);
|
||||
assert.match(sideEffectsSource, /STORAGE_KEY_STARTUP_LANDING/);
|
||||
assert.match(sideEffectsSource, /handleCreateLocalTerminal\(/);
|
||||
assert.match(sideEffectsSource, /onColdStartIntentsSettled/);
|
||||
assert.match(sideEffectsSource, /ensureDiscoveredShells/);
|
||||
assert.match(sideEffectsSource, /hasQueuedStartupIntent:\s*startupLaunchIntentReceivedRef\.current/);
|
||||
|
||||
const latchAt = sideEffectsSource.indexOf("if (startupLocalTerminalAttemptedRef.current) return;");
|
||||
const setLatchAt = sideEffectsSource.indexOf(
|
||||
"startupLocalTerminalAttemptedRef.current = true;",
|
||||
latchAt,
|
||||
);
|
||||
const createAt = sideEffectsSource.indexOf("handleCreateLocalTerminal(", setLatchAt);
|
||||
assert.notEqual(latchAt, -1);
|
||||
assert.notEqual(setLatchAt, -1);
|
||||
assert.notEqual(createAt, -1);
|
||||
assert.ok(setLatchAt < createAt, "StrictMode latch must be set before creating the terminal");
|
||||
});
|
||||
|
||||
test("settings system tab exposes startup landing control", () => {
|
||||
const tabSource = readFileSync(
|
||||
new URL("../../components/settings/tabs/SettingsSystemTab.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(tabSource, /system-startup-landing/);
|
||||
assert.match(tabSource, /settings\.sessionRestore\.startupLanding/);
|
||||
assert.match(tabSource, /local-terminal/);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
normalizeToolbarItemLayout,
|
||||
partitionToolbarItems,
|
||||
setToolbarItemPlacement,
|
||||
} from '../../domain/toolbarItemLayout.ts';
|
||||
import {
|
||||
SYSTEM_MANAGER_TAB_DEFAULT_ORDER,
|
||||
SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS,
|
||||
} from './systemManagerTabLayout.ts';
|
||||
|
||||
test('system manager tab layout defaults include every known section', () => {
|
||||
assert.deepEqual(
|
||||
[...SYSTEM_MANAGER_TAB_DEFAULT_ORDER],
|
||||
['overview', 'processes', 'ports', 'services', 'tmux', 'docker', 'gpu'],
|
||||
);
|
||||
assert.deepEqual(SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS.lockedIds, ['overview']);
|
||||
});
|
||||
|
||||
test('system manager overview cannot be hidden', () => {
|
||||
const layout = normalizeToolbarItemLayout(null, SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS);
|
||||
const next = setToolbarItemPlacement(
|
||||
layout,
|
||||
'overview',
|
||||
'hide',
|
||||
SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS,
|
||||
);
|
||||
assert.equal(next.placement.overview, 'show');
|
||||
});
|
||||
|
||||
test('system manager partition filters to available host tabs', () => {
|
||||
const layout = normalizeToolbarItemLayout(
|
||||
{
|
||||
order: [...SYSTEM_MANAGER_TAB_DEFAULT_ORDER],
|
||||
placement: {
|
||||
overview: 'show',
|
||||
processes: 'show',
|
||||
ports: 'collapse',
|
||||
services: 'hide',
|
||||
tmux: 'show',
|
||||
docker: 'show',
|
||||
gpu: 'show',
|
||||
},
|
||||
},
|
||||
SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS,
|
||||
);
|
||||
|
||||
const part = partitionToolbarItems(layout, ['overview', 'processes', 'ports', 'services']);
|
||||
assert.deepEqual(part.shown, ['overview', 'processes']);
|
||||
assert.deepEqual(part.collapsed, ['ports']);
|
||||
assert.deepEqual(part.hidden, ['services']);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { SystemManagerSubTab } from '../../domain/systemManager/types';
|
||||
import type { ToolbarItemLayoutDefaults } from '../../domain/toolbarItemLayout';
|
||||
|
||||
/** Canonical System Manager sub-tab order (all known sections). */
|
||||
export const SYSTEM_MANAGER_TAB_DEFAULT_ORDER: readonly SystemManagerSubTab[] = [
|
||||
'overview',
|
||||
'processes',
|
||||
'ports',
|
||||
'services',
|
||||
'tmux',
|
||||
'docker',
|
||||
'gpu',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Persistable layout defaults for System Manager sub-tabs.
|
||||
* Overview is locked so the panel never loses every reachable section.
|
||||
*/
|
||||
export const SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS: ToolbarItemLayoutDefaults = {
|
||||
order: [...SYSTEM_MANAGER_TAB_DEFAULT_ORDER],
|
||||
placement: Object.fromEntries(
|
||||
SYSTEM_MANAGER_TAB_DEFAULT_ORDER.map((id) => [id, 'show' as const]),
|
||||
),
|
||||
lockedIds: ['overview'],
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
canReuseTerminalConnection,
|
||||
createCopiedTerminalSessionClone,
|
||||
createSplitTerminalSessionClone,
|
||||
isTerminalSessionEligibleForSftpReuse,
|
||||
} from "./terminalConnectionReuse";
|
||||
|
||||
const session = (overrides: Partial<TerminalSession> = {}): TerminalSession => ({
|
||||
@@ -24,6 +25,14 @@ test("connected SSH sessions can reuse their authenticated connection", () => {
|
||||
assert.equal(canReuseTerminalConnection(session({ protocol: undefined })), true);
|
||||
});
|
||||
|
||||
test("SSH sessions stay SFTP-linkable while reconnecting", () => {
|
||||
assert.equal(isTerminalSessionEligibleForSftpReuse(session({ status: "connecting" })), true);
|
||||
assert.equal(isTerminalSessionEligibleForSftpReuse(session({ status: "disconnected" })), true);
|
||||
assert.equal(isTerminalSessionEligibleForSftpReuse(session({ protocol: "local" })), false);
|
||||
assert.equal(isTerminalSessionEligibleForSftpReuse(session({ moshEnabled: true })), false);
|
||||
assert.equal(isTerminalSessionEligibleForSftpReuse(session({ etEnabled: true })), false);
|
||||
});
|
||||
|
||||
test("non-SSH or unavailable sessions do not reuse a connection", () => {
|
||||
assert.equal(canReuseTerminalConnection(session({ status: "connecting" })), false);
|
||||
assert.equal(canReuseTerminalConnection(session({ status: "disconnected" })), false);
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import type { TerminalSession } from "../../domain/models";
|
||||
|
||||
export function canReuseTerminalConnection(session: TerminalSession): boolean {
|
||||
/** SSH (non-mosh/et) sessions that can back SFTP once the transport is up. */
|
||||
export function isTerminalSessionEligibleForSftpReuse(session: TerminalSession): boolean {
|
||||
return (
|
||||
(session.protocol === "ssh" || session.protocol === undefined) &&
|
||||
!session.moshEnabled &&
|
||||
!session.etEnabled &&
|
||||
session.status === "connected"
|
||||
(session.protocol === "ssh" || session.protocol === undefined)
|
||||
&& !session.moshEnabled
|
||||
&& !session.etEnabled
|
||||
);
|
||||
}
|
||||
|
||||
export function canReuseTerminalConnection(session: TerminalSession): boolean {
|
||||
return isTerminalSessionEligibleForSftpReuse(session) && session.status === "connected";
|
||||
}
|
||||
|
||||
type CloneSessionOptions = {
|
||||
id: string;
|
||||
localShellType?: TerminalSession["shellType"];
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
const TERMINAL_KEYBOARD_TARGET_SELECTOR =
|
||||
".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport";
|
||||
|
||||
type FocusDocument = Pick<Document, "activeElement" | "addEventListener" | "removeEventListener">;
|
||||
type FocusWindow = Pick<Window, "addEventListener" | "removeEventListener">;
|
||||
|
||||
export const isTerminalKeyboardTarget = (target: EventTarget | null): boolean => {
|
||||
const element = target as (Element & {
|
||||
closest?: (selector: string) => Element | null;
|
||||
}) | null;
|
||||
return Boolean(element?.closest?.(TERMINAL_KEYBOARD_TARGET_SELECTOR));
|
||||
};
|
||||
|
||||
export function installTerminalKeyboardFocusTracking(
|
||||
documentRef: FocusDocument,
|
||||
windowRef: FocusWindow,
|
||||
onFocusChange: (focused: boolean) => void,
|
||||
): () => void {
|
||||
let pendingPointerTarget: EventTarget | null | undefined;
|
||||
let pendingPointerClearTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let isTracking = true;
|
||||
let windowFocused = true;
|
||||
|
||||
const clearPendingPointerTarget = () => {
|
||||
if (pendingPointerClearTimer !== undefined) {
|
||||
clearTimeout(pendingPointerClearTimer);
|
||||
pendingPointerClearTimer = undefined;
|
||||
}
|
||||
pendingPointerTarget = undefined;
|
||||
};
|
||||
|
||||
const publishForTarget = (target: EventTarget | null) => {
|
||||
if (!isTracking) return;
|
||||
onFocusChange(windowFocused && isTerminalKeyboardTarget(target));
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: Event) => {
|
||||
clearPendingPointerTarget();
|
||||
pendingPointerTarget = event.target;
|
||||
pendingPointerClearTimer = setTimeout(() => {
|
||||
const pointerTarget = pendingPointerTarget;
|
||||
clearPendingPointerTarget();
|
||||
if (pointerTarget !== undefined) {
|
||||
publishForTarget(documentRef.activeElement);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleFocusIn = (event: Event) => {
|
||||
clearPendingPointerTarget();
|
||||
publishForTarget(event.target);
|
||||
};
|
||||
|
||||
const handleFocusOut = () => {
|
||||
queueMicrotask(() => {
|
||||
if (!isTracking) return;
|
||||
const pointerTarget = pendingPointerTarget;
|
||||
clearPendingPointerTarget();
|
||||
if (!windowFocused) {
|
||||
onFocusChange(false);
|
||||
return;
|
||||
}
|
||||
if (pointerTarget !== undefined) {
|
||||
publishForTarget(documentRef.activeElement);
|
||||
return;
|
||||
}
|
||||
publishForTarget(documentRef.activeElement);
|
||||
});
|
||||
};
|
||||
|
||||
const handleWindowBlur = () => {
|
||||
windowFocused = false;
|
||||
clearPendingPointerTarget();
|
||||
onFocusChange(false);
|
||||
};
|
||||
|
||||
const handleWindowFocus = () => {
|
||||
windowFocused = true;
|
||||
publishForTarget(documentRef.activeElement);
|
||||
};
|
||||
|
||||
publishForTarget(documentRef.activeElement);
|
||||
documentRef.addEventListener("pointerdown", handlePointerDown, true);
|
||||
documentRef.addEventListener("focusin", handleFocusIn, true);
|
||||
documentRef.addEventListener("focusout", handleFocusOut, true);
|
||||
windowRef.addEventListener("blur", handleWindowBlur);
|
||||
windowRef.addEventListener("focus", handleWindowFocus);
|
||||
|
||||
return () => {
|
||||
isTracking = false;
|
||||
documentRef.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
documentRef.removeEventListener("focusin", handleFocusIn, true);
|
||||
documentRef.removeEventListener("focusout", handleFocusOut, true);
|
||||
windowRef.removeEventListener("blur", handleWindowBlur);
|
||||
windowRef.removeEventListener("focus", handleWindowFocus);
|
||||
clearPendingPointerTarget();
|
||||
};
|
||||
}
|
||||
@@ -490,6 +490,374 @@ test("does not replace an existing directory when uploading a same-named file",
|
||||
assert.match(results[0].error ?? "", /directory/i);
|
||||
});
|
||||
|
||||
test("file replace leaves the remote destination so upload can restore mode bits", async () => {
|
||||
// Deleting before overwrite recreates the inode with umask defaults and drops
|
||||
// bits like +x (#2954). Keep the target so stage+rename can restore mode.
|
||||
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
|
||||
Object.defineProperty(file, "path", { value: "/local/tool.sh" });
|
||||
const deletedPaths: string[] = [];
|
||||
const deletedTypes: Array<string | undefined> = [];
|
||||
const uploadedPaths: string[] = [];
|
||||
|
||||
const results = await uploadFromFileList(
|
||||
[file],
|
||||
{
|
||||
targetPath: "/usr/local/bin",
|
||||
sftpId: "sftp-1",
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
statSftp: async (_sftpId, path) =>
|
||||
path === "/usr/local/bin/tool.sh"
|
||||
? { type: "file", size: 9, lastModified: 1000 }
|
||||
: null,
|
||||
deleteSftp: async (_sftpId, path, expectedType) => {
|
||||
deletedPaths.push(path);
|
||||
deletedTypes.push(expectedType);
|
||||
},
|
||||
startStreamTransfer: async ({ targetPath: path }) => {
|
||||
uploadedPaths.push(path);
|
||||
return { transferId: "upload-1" };
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
resolveConflict: async () => "replace",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(deletedPaths, [], "file replace must not delete before upload");
|
||||
assert.deepEqual(uploadedPaths, ["/usr/local/bin/tool.sh"]);
|
||||
assert.equal(results.length, 1);
|
||||
assert.equal(results[0].success, true);
|
||||
});
|
||||
|
||||
test("file replace unlinks an existing symlink before upload", async () => {
|
||||
// Leaving the symlink would let in-place upload follow it and overwrite the
|
||||
// link target outside the displayed directory.
|
||||
// Conflict checks use lstatSftp so Replace unlinks the link first.
|
||||
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
|
||||
Object.defineProperty(file, "path", { value: "/local/tool.sh" });
|
||||
const deletedPaths: string[] = [];
|
||||
const deletedTypes: Array<string | undefined> = [];
|
||||
const uploadedPaths: string[] = [];
|
||||
|
||||
const results = await uploadFromFileList(
|
||||
[file],
|
||||
{
|
||||
targetPath: "/usr/local/bin",
|
||||
sftpId: "sftp-1",
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
// Followed stat would report type "file" and skip pre-delete.
|
||||
statSftp: async () => ({ type: "file", size: 4096, lastModified: 1000 }),
|
||||
lstatSftp: async (_sftpId, path) =>
|
||||
path === "/usr/local/bin/tool.sh"
|
||||
? { type: "symlink", size: 12, lastModified: 1000 }
|
||||
: null,
|
||||
deleteSftp: async (_sftpId, path, expectedType) => {
|
||||
deletedPaths.push(path);
|
||||
deletedTypes.push(expectedType);
|
||||
},
|
||||
startStreamTransfer: async ({ targetPath: path }) => {
|
||||
uploadedPaths.push(path);
|
||||
return { transferId: "upload-1" };
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
resolveConflict: async () => "replace",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(deletedPaths, ["/usr/local/bin/tool.sh"]);
|
||||
assert.deepEqual(deletedTypes, ["symlink"]);
|
||||
assert.deepEqual(uploadedPaths, ["/usr/local/bin/tool.sh"]);
|
||||
assert.equal(results.length, 1);
|
||||
assert.equal(results[0].success, true);
|
||||
});
|
||||
|
||||
test("LSTAT ENOTSUP is not treated as an absent destination during conflict check", async () => {
|
||||
// If unsupported LSTAT is swallowed as "missing", upload skips Replace and may
|
||||
// write through an existing symlink to a target outside the displayed dir.
|
||||
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
|
||||
Object.defineProperty(file, "path", { value: "/local/tool.sh" });
|
||||
let uploaded = 0;
|
||||
let conflictPrompts = 0;
|
||||
|
||||
await assert.rejects(
|
||||
() => uploadFromFileList(
|
||||
[file],
|
||||
{
|
||||
targetPath: "/usr/local/bin",
|
||||
sftpId: "sftp-1",
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
lstatSftp: async () => {
|
||||
const error = new Error(
|
||||
"Remote server does not support LSTAT; cannot classify path without following symlinks",
|
||||
) as Error & { code: string; lstatUnavailable: boolean };
|
||||
error.code = "ENOTSUP";
|
||||
error.lstatUnavailable = true;
|
||||
throw error;
|
||||
},
|
||||
startStreamTransfer: async () => {
|
||||
uploaded += 1;
|
||||
return { transferId: "upload-1" };
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
resolveConflict: async () => {
|
||||
conflictPrompts += 1;
|
||||
return "replace";
|
||||
},
|
||||
},
|
||||
),
|
||||
(error: Error & { code?: string }) => {
|
||||
assert.equal(error.code, "ENOTSUP");
|
||||
assert.match(String(error.message), /does not support LSTAT/i);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(uploaded, 0, "must not upload when destination type is unknown");
|
||||
assert.equal(conflictPrompts, 0, "must not pretend destination is missing");
|
||||
});
|
||||
|
||||
test("ENOENT from lstat still means absent destination for conflict check", async () => {
|
||||
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
|
||||
Object.defineProperty(file, "path", { value: "/local/tool.sh" });
|
||||
const uploadedPaths: string[] = [];
|
||||
|
||||
const results = await uploadFromFileList(
|
||||
[file],
|
||||
{
|
||||
targetPath: "/usr/local/bin",
|
||||
sftpId: "sftp-1",
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
lstatSftp: async () => {
|
||||
const error = new Error("No such file") as Error & { code: string };
|
||||
error.code = "ENOENT";
|
||||
throw error;
|
||||
},
|
||||
startStreamTransfer: async ({ targetPath: path }) => {
|
||||
uploadedPaths.push(path);
|
||||
return { transferId: "upload-1" };
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
resolveConflict: async () => {
|
||||
throw new Error("absent destination must not prompt for conflict");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(uploadedPaths, ["/usr/local/bin/tool.sh"]);
|
||||
assert.equal(results.length, 1);
|
||||
assert.equal(results[0].success, true);
|
||||
});
|
||||
|
||||
test("Electron-wrapped lstat absence still means absent destination", async () => {
|
||||
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
|
||||
Object.defineProperty(file, "path", { value: "/local/tool.sh" });
|
||||
const uploadedPaths: string[] = [];
|
||||
|
||||
const results = await uploadFromFileList(
|
||||
[file],
|
||||
{
|
||||
targetPath: "/usr/local/bin",
|
||||
sftpId: "sftp-1",
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
lstatSftp: async () => {
|
||||
throw new Error("Error invoking remote method 'netcatty:sftp:lstat': Error: No such file");
|
||||
},
|
||||
startStreamTransfer: async ({ targetPath: path }) => {
|
||||
uploadedPaths.push(path);
|
||||
return { transferId: "upload-1" };
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
resolveConflict: async () => {
|
||||
throw new Error("absent destination must not prompt for conflict");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(uploadedPaths, ["/usr/local/bin/tool.sh"]);
|
||||
assert.equal(results.length, 1);
|
||||
assert.equal(results[0].success, true);
|
||||
});
|
||||
|
||||
test("compressed-upload LSTAT ENOTSUP is not treated as an absent destination", async (t) => {
|
||||
let compressedStarts = 0;
|
||||
installCompressedUploadBridge(t, {
|
||||
onStart: () => { compressedStarts += 1; },
|
||||
});
|
||||
let streamCalls = 0;
|
||||
|
||||
await assert.rejects(
|
||||
() => uploadFromFileList(
|
||||
[createPickedFolderFile()],
|
||||
{
|
||||
targetPath: "/remote",
|
||||
sftpId: "sftp-1",
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
lstatSftp: async () => {
|
||||
const error = new Error(
|
||||
"Remote server does not support LSTAT; cannot classify path without following symlinks",
|
||||
) as Error & { code: string; lstatUnavailable: boolean };
|
||||
error.code = "ENOTSUP";
|
||||
error.lstatUnavailable = true;
|
||||
throw error;
|
||||
},
|
||||
startStreamTransfer: async (payload) => {
|
||||
streamCalls += 1;
|
||||
return { transferId: payload.transferId };
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
useCompressedUpload: true,
|
||||
resolveConflict: async () => "merge",
|
||||
},
|
||||
),
|
||||
(error: Error & { code?: string }) => {
|
||||
assert.equal(error.code, "ENOTSUP");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(compressedStarts, 0);
|
||||
assert.equal(streamCalls, 0);
|
||||
});
|
||||
|
||||
test("directory replace unlinks an existing symlink before mkdir", async () => {
|
||||
// No-follow lstat reports symlink; Replace must unlink then create the dir
|
||||
// (followed stat used to classify symlink-to-dir as directory and succeed).
|
||||
const file = new File(["nested"], "file.txt", { lastModified: 1234 });
|
||||
Object.defineProperty(file, "path", { value: "/local/docs/file.txt" });
|
||||
Object.defineProperty(file, "webkitRelativePath", { value: "docs/file.txt" });
|
||||
const deletedPaths: string[] = [];
|
||||
const deletedTypes: Array<string | undefined> = [];
|
||||
const madeDirs: string[] = [];
|
||||
const uploadedPaths: string[] = [];
|
||||
|
||||
const results = await uploadFromFileList(
|
||||
[file],
|
||||
{
|
||||
targetPath: "/remote",
|
||||
sftpId: "sftp-1",
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async (_sftpId, path) => {
|
||||
madeDirs.push(path);
|
||||
},
|
||||
lstatSftp: async (_sftpId, path) =>
|
||||
path === "/remote/docs"
|
||||
? { type: "symlink", size: 8, lastModified: 1000 }
|
||||
: null,
|
||||
deleteSftp: async (_sftpId, path, expectedType) => {
|
||||
deletedPaths.push(path);
|
||||
deletedTypes.push(expectedType);
|
||||
},
|
||||
startStreamTransfer: async ({ targetPath: path }) => {
|
||||
uploadedPaths.push(path);
|
||||
return { transferId: "upload-dir-1" };
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
resolveConflict: async () => "replace",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(deletedPaths, ["/remote/docs"]);
|
||||
assert.deepEqual(deletedTypes, ["symlink"]);
|
||||
assert.ok(madeDirs.includes("/remote/docs"), `expected mkdir after unlink, got ${JSON.stringify(madeDirs)}`);
|
||||
assert.deepEqual(uploadedPaths, ["/remote/docs/file.txt"]);
|
||||
assert.equal(results.some((r) => r.success), true);
|
||||
});
|
||||
|
||||
test("local file replace unlinks an existing symlink before writeLocalFile", async () => {
|
||||
// Pathless File uploads fall back to writeLocalFile, which follows symlinks.
|
||||
// Conflict checks use lstatLocal so Replace unlinks the link first.
|
||||
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
|
||||
const deletedPaths: string[] = [];
|
||||
const deletedTypes: Array<string | undefined> = [];
|
||||
const writtenPaths: string[] = [];
|
||||
|
||||
const results = await uploadFromFileList(
|
||||
[file],
|
||||
{
|
||||
targetPath: "/Users/me/bin",
|
||||
sftpId: null,
|
||||
isLocal: true,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
lstatLocal: async (path) =>
|
||||
path === "/Users/me/bin/tool.sh"
|
||||
? { type: "symlink", size: 12, lastModified: 1000 }
|
||||
: null,
|
||||
deleteLocalFile: async (path, expectedType) => {
|
||||
deletedPaths.push(path);
|
||||
deletedTypes.push(expectedType);
|
||||
},
|
||||
writeLocalFile: async (path) => {
|
||||
writtenPaths.push(path);
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
resolveConflict: async () => "replace",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(deletedPaths, ["/Users/me/bin/tool.sh"]);
|
||||
assert.deepEqual(deletedTypes, ["symlink"]);
|
||||
assert.deepEqual(writtenPaths, ["/Users/me/bin/tool.sh"]);
|
||||
assert.equal(results.length, 1);
|
||||
assert.equal(results[0].success, true);
|
||||
});
|
||||
|
||||
test("local file replace unlinks a regular file before writeLocalFile", async () => {
|
||||
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
|
||||
const operations: string[] = [];
|
||||
|
||||
const results = await uploadFromFileList(
|
||||
[file],
|
||||
{
|
||||
targetPath: "/Users/me/bin",
|
||||
sftpId: null,
|
||||
isLocal: true,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
lstatLocal: async (path) =>
|
||||
path === "/Users/me/bin/tool.sh"
|
||||
? { type: "file", size: 12, lastModified: 1000 }
|
||||
: null,
|
||||
deleteLocalFile: async (path, expectedType) => {
|
||||
operations.push(`delete:${path}:${expectedType}`);
|
||||
},
|
||||
writeLocalFile: async (path) => {
|
||||
operations.push(`write:${path}`);
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
resolveConflict: async () => "replace",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(operations, [
|
||||
"delete:/Users/me/bin/tool.sh:file",
|
||||
"write:/Users/me/bin/tool.sh",
|
||||
]);
|
||||
assert.equal(results.length, 1);
|
||||
assert.equal(results[0].success, true);
|
||||
});
|
||||
|
||||
test("counts apply-to-all upload conflicts by incoming and existing type", async () => {
|
||||
const files = [
|
||||
new File(["local"], "existing-file", { lastModified: 1234 }),
|
||||
|
||||
@@ -73,6 +73,11 @@ import {
|
||||
type PanelViewByScope,
|
||||
} from './aiStateSnapshots';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
import {
|
||||
handoffDissolvedWorkspaceAIScope,
|
||||
retargetWorkspaceActiveChatAfterMemberLoss,
|
||||
seedWorkspaceAIActiveSessionFromMembers,
|
||||
} from '../../domain/workspaceAiScopeHandoff';
|
||||
export function useAIState() {
|
||||
// ── Provider Config ──
|
||||
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
|
||||
@@ -807,12 +812,15 @@ export function useAIState() {
|
||||
scopeKey,
|
||||
fallbackAgentId,
|
||||
(draft) => {
|
||||
const updated = updater(draft);
|
||||
if (updated === draft) return draft;
|
||||
return {
|
||||
...updater(draft),
|
||||
...updated,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
},
|
||||
);
|
||||
if (next === prev) return prev;
|
||||
setLatestAIDraftsByScopeSnapshot(next);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
return next;
|
||||
@@ -968,6 +976,119 @@ export function useAIState() {
|
||||
setPanelViewByScopeRaw(latestAIPanelViewByScopeSnapshot ?? {});
|
||||
}, []);
|
||||
|
||||
const seedWorkspaceActiveSessionFromMembers = useCallback((input: {
|
||||
workspaceId: string;
|
||||
memberTerminalIds: readonly string[];
|
||||
preferredTerminalId?: string | null;
|
||||
}) => {
|
||||
const currentMap =
|
||||
latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {};
|
||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
|
||||
const seeded = seedWorkspaceAIActiveSessionFromMembers({
|
||||
activeSessionIdMap: currentMap,
|
||||
panelViewByScope: currentPanelViewByScope,
|
||||
workspaceId: input.workspaceId,
|
||||
memberTerminalIds: input.memberTerminalIds,
|
||||
preferredTerminalId: input.preferredTerminalId,
|
||||
});
|
||||
if (!seeded) return;
|
||||
setLatestAIActiveSessionMapSnapshot(seeded.activeSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, seeded.activeSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
setActiveSessionIdMapRaw(seeded.activeSessionIdMap);
|
||||
if (seeded.panelViewChanged) {
|
||||
setLatestAIPanelViewByScopeSnapshot(seeded.panelViewByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
setPanelViewByScopeRaw(seeded.panelViewByScope);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handoffDissolvedWorkspaceScope = useCallback((input: {
|
||||
workspaceId: string;
|
||||
terminalIds: readonly string[];
|
||||
preferredTerminalId?: string | null;
|
||||
}) => {
|
||||
const currentMap =
|
||||
latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {};
|
||||
const currentSessions =
|
||||
latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
|
||||
const result = handoffDissolvedWorkspaceAIScope({
|
||||
activeSessionIdMap: currentMap,
|
||||
sessions: currentSessions,
|
||||
workspaceId: input.workspaceId,
|
||||
terminalIds: input.terminalIds,
|
||||
preferredTerminalId: input.preferredTerminalId,
|
||||
panelViewByScope: currentPanelViewByScope,
|
||||
});
|
||||
if (!result.changed) return;
|
||||
|
||||
if (result.activeSessionIdMap !== currentMap) {
|
||||
setLatestAIActiveSessionMapSnapshot(result.activeSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, result.activeSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
setActiveSessionIdMapRaw(result.activeSessionIdMap);
|
||||
}
|
||||
|
||||
if (result.sessions !== currentSessions) {
|
||||
sessionsRef.current = result.sessions;
|
||||
setLatestAISessionsSnapshot(result.sessions);
|
||||
persistSessions(result.sessions);
|
||||
setSessionsRaw(result.sessions);
|
||||
}
|
||||
|
||||
if (result.panelViewByScope !== currentPanelViewByScope) {
|
||||
setLatestAIPanelViewByScopeSnapshot(result.panelViewByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
setPanelViewByScopeRaw(result.panelViewByScope);
|
||||
}
|
||||
}, [persistSessions]);
|
||||
|
||||
const retargetWorkspaceActiveChatForMemberLoss = useCallback((input: {
|
||||
workspaceId: string;
|
||||
previousMemberTerminalIds: readonly string[];
|
||||
currentMemberTerminalIds: readonly string[];
|
||||
preferredTerminalId?: string | null;
|
||||
}) => {
|
||||
const currentMap =
|
||||
latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {};
|
||||
const currentSessions =
|
||||
latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
const result = retargetWorkspaceActiveChatAfterMemberLoss({
|
||||
activeSessionIdMap: currentMap,
|
||||
sessions: currentSessions,
|
||||
workspaceId: input.workspaceId,
|
||||
previousMemberTerminalIds: input.previousMemberTerminalIds,
|
||||
currentMemberTerminalIds: input.currentMemberTerminalIds,
|
||||
preferredTerminalId: input.preferredTerminalId,
|
||||
});
|
||||
if (!result.changed) return;
|
||||
|
||||
if (result.activeSessionIdMap !== currentMap) {
|
||||
setLatestAIActiveSessionMapSnapshot(result.activeSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, result.activeSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
setActiveSessionIdMapRaw(result.activeSessionIdMap);
|
||||
}
|
||||
|
||||
if (result.sessions !== currentSessions) {
|
||||
sessionsRef.current = result.sessions;
|
||||
setLatestAISessionsSnapshot(result.sessions);
|
||||
persistSessions(result.sessions);
|
||||
setSessionsRaw(result.sessions);
|
||||
}
|
||||
}, [persistSessions]);
|
||||
|
||||
// ── Provider CRUD helpers ──
|
||||
const addProvider = useCallback((provider: ProviderConfig) => {
|
||||
setProviders(prev => [...prev, provider]);
|
||||
@@ -1071,6 +1192,9 @@ export function useAIState() {
|
||||
updateMessageById,
|
||||
persistContextCompaction,
|
||||
cleanupOrphanedSessions,
|
||||
seedWorkspaceActiveSessionFromMembers,
|
||||
handoffDissolvedWorkspaceScope,
|
||||
retargetWorkspaceActiveChatForMemberLoss,
|
||||
}), [
|
||||
providers,
|
||||
setProviders,
|
||||
@@ -1124,5 +1248,8 @@ export function useAIState() {
|
||||
updateMessageById,
|
||||
persistContextCompaction,
|
||||
cleanupOrphanedSessions,
|
||||
seedWorkspaceActiveSessionFromMembers,
|
||||
handoffDissolvedWorkspaceScope,
|
||||
retargetWorkspaceActiveChatForMemberLoss,
|
||||
]);
|
||||
}
|
||||
@@ -22,10 +22,16 @@ let agentDiscoveryWriteGeneration = 0;
|
||||
export function useAgentDiscovery(
|
||||
externalAgents: ExternalAgentConfig[],
|
||||
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void,
|
||||
options?: { enabled?: boolean },
|
||||
options?: {
|
||||
enabled?: boolean;
|
||||
schedule?: (task: () => void) => () => void;
|
||||
},
|
||||
) {
|
||||
const enabled = options?.enabled ?? true;
|
||||
const [discoveredAgents, setDiscoveredAgents] = useState<DiscoveredAgent[]>([]);
|
||||
const schedule = options?.schedule;
|
||||
const [discoveredAgents, setDiscoveredAgents] = useState<DiscoveredAgent[]>(
|
||||
() => agentDiscoveryCache?.agents ?? [],
|
||||
);
|
||||
const [isDiscovering, setIsDiscovering] = useState(false);
|
||||
const discoverSeqRef = useRef(0);
|
||||
const mountedRef = useRef(true);
|
||||
@@ -116,8 +122,16 @@ export function useAgentDiscovery(
|
||||
if (!cancelled) void discover();
|
||||
};
|
||||
|
||||
if (schedule) {
|
||||
const cancel = schedule(runDiscover);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancel();
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof requestIdleCallback === 'function') {
|
||||
const idleId = requestIdleCallback(runDiscover, { timeout: 2000 });
|
||||
const idleId = requestIdleCallback(runDiscover);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelIdleCallback(idleId);
|
||||
@@ -129,7 +143,7 @@ export function useAgentDiscovery(
|
||||
cancelled = true;
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [discover, enabled]);
|
||||
}, [discover, enabled, schedule]);
|
||||
|
||||
// Auto-update args for already-configured discovered agents when
|
||||
// the canonical args from discovery change (e.g. after an app update).
|
||||
|
||||
@@ -109,6 +109,7 @@ test("enabled convergent startup checks preview empty-vault recovery before sync
|
||||
"conflictActionOverride: 'download-remote'",
|
||||
recoveryPromptIndex,
|
||||
);
|
||||
const manualTriggerIndex = source.indexOf("trigger: 'manual'", recoveryPromptIndex);
|
||||
const normalSyncIndex = source.indexOf(
|
||||
"syncNowRef.current({ notifyOnFailure, allowEmptyConvergentSync })",
|
||||
cloudWinsIndex,
|
||||
@@ -118,16 +119,41 @@ test("enabled convergent startup checks preview empty-vault recovery before sync
|
||||
assert.notEqual(previewIndex, -1);
|
||||
assert.notEqual(recoveryPromptIndex, -1);
|
||||
assert.notEqual(cloudWinsIndex, -1);
|
||||
assert.notEqual(manualTriggerIndex, -1);
|
||||
assert.notEqual(normalSyncIndex, -1);
|
||||
assert.ok(
|
||||
convergentGuardIndex < previewIndex
|
||||
&& previewIndex < recoveryPromptIndex
|
||||
&& recoveryPromptIndex < cloudWinsIndex
|
||||
&& recoveryPromptIndex < manualTriggerIndex
|
||||
&& manualTriggerIndex < cloudWinsIndex
|
||||
&& cloudWinsIndex < normalSyncIndex,
|
||||
"v2 startup must offer recovery and use cloud-wins before the normal CRDT sync path",
|
||||
);
|
||||
});
|
||||
|
||||
test("empty-vault recovery restore bypasses the auto-sync preference gate", () => {
|
||||
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
||||
const recoveryPromptIndex = source.indexOf("requestEmptyVaultRecovery(recoveryPayload)");
|
||||
const restoreCallIndex = source.indexOf("syncNowRef.current({", recoveryPromptIndex);
|
||||
const triggerIndex = source.indexOf("trigger: 'manual'", restoreCallIndex);
|
||||
const conflictIndex = source.indexOf(
|
||||
"conflictActionOverride: 'download-remote'",
|
||||
restoreCallIndex,
|
||||
);
|
||||
const callEndIndex = source.indexOf("});", conflictIndex);
|
||||
|
||||
assert.notEqual(recoveryPromptIndex, -1);
|
||||
assert.notEqual(restoreCallIndex, -1);
|
||||
assert.notEqual(triggerIndex, -1);
|
||||
assert.notEqual(conflictIndex, -1);
|
||||
assert.ok(
|
||||
restoreCallIndex < triggerIndex
|
||||
&& triggerIndex < conflictIndex
|
||||
&& conflictIndex < callEndIndex,
|
||||
"recovery Restore must pass trigger:manual so disabled auto-sync cannot block it",
|
||||
);
|
||||
});
|
||||
|
||||
test("empty v2 startup checks allow a validated empty sync to open the gate", () => {
|
||||
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
||||
const convergentGuardIndex = source.indexOf(
|
||||
@@ -192,6 +218,38 @@ test("auto-sync skips only the exact remote-applied data hash", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("auto syncNow and debounce re-check manager autoSyncEnabled before pushing", () => {
|
||||
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
||||
const helperIndex = source.indexOf("function isPersistedAutoSyncEnabled");
|
||||
const syncNowIndex = source.indexOf("const syncNow = useCallback");
|
||||
const autoGateIndex = source.indexOf(
|
||||
"if (trigger === 'auto' && !isPersistedAutoSyncEnabled(manager.getState().autoSyncEnabled))",
|
||||
syncNowIndex,
|
||||
);
|
||||
const debounceIndex = source.indexOf("// Debounced auto-sync when data changes");
|
||||
const fireTimeGateIndex = source.indexOf(
|
||||
"if (!isPersistedAutoSyncEnabled(manager.getState().autoSyncEnabled))",
|
||||
debounceIndex,
|
||||
);
|
||||
const clearTimerIndex = source.indexOf(
|
||||
"if (syncTimeoutRef.current) {\n clearTimeout(syncTimeoutRef.current);\n syncTimeoutRef.current = null;\n }\n return;",
|
||||
debounceIndex,
|
||||
);
|
||||
|
||||
assert.notEqual(helperIndex, -1);
|
||||
assert.notEqual(syncNowIndex, -1);
|
||||
assert.notEqual(autoGateIndex, -1);
|
||||
assert.notEqual(fireTimeGateIndex, -1);
|
||||
assert.notEqual(clearTimerIndex, -1);
|
||||
assert.ok(
|
||||
helperIndex < syncNowIndex
|
||||
&& syncNowIndex < autoGateIndex
|
||||
&& debounceIndex < fireTimeGateIndex
|
||||
&& debounceIndex < clearTimerIndex,
|
||||
"auto-sync off must gate both syncNow(auto) and the debounced timer path",
|
||||
);
|
||||
});
|
||||
|
||||
test("startup local-wins and merge round-trips refuse device-bound credential placeholders", () => {
|
||||
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
||||
const uploadLocalIndex = source.indexOf("if (conflictAction === 'upload-local')");
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
healPoisonedSecretsForMerge,
|
||||
stripSyncPayloadEncryptedCredentials,
|
||||
} from '../../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { isProviderReadyForSync, SYNC_STORAGE_KEYS, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { mergeSyncPayloads } from '../../domain/syncMerge';
|
||||
import { materializeSyncPayloadFromConvergentState } from '../../domain/convergentSync';
|
||||
import {
|
||||
@@ -51,6 +51,19 @@ import {
|
||||
import { resolveAutoSyncHashDecision } from './autoSyncHashDecision';
|
||||
import { getNotesSnapshot, subscribeNotes } from './notesStore';
|
||||
|
||||
/** Prefer dedicated SYNC_PREFERENCES; fall back to legacy SYNC_CONFIG fields. */
|
||||
function isPersistedAutoSyncEnabled(fallback: boolean): boolean {
|
||||
const preferences = localStorageAdapter.read<{ autoSync?: boolean }>(
|
||||
SYNC_STORAGE_KEYS.SYNC_PREFERENCES,
|
||||
);
|
||||
if (preferences && typeof preferences === 'object' && preferences.autoSync !== undefined) {
|
||||
return Boolean(preferences.autoSync);
|
||||
}
|
||||
const stored = localStorageAdapter.read<{ autoSync?: boolean }>(SYNC_STORAGE_KEYS.SYNC_CONFIG);
|
||||
if (!stored || typeof stored !== 'object') return fallback;
|
||||
return Boolean(stored.autoSync);
|
||||
}
|
||||
|
||||
interface AutoSyncConfig {
|
||||
enabled?: boolean;
|
||||
// Data to sync
|
||||
@@ -312,6 +325,13 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
const trigger: SyncTrigger = options?.trigger ?? 'auto';
|
||||
|
||||
// Defense for #2976: auto pushes must honor the persisted preference even
|
||||
// when this window's React/manager snapshot is briefly stale after another
|
||||
// window disables auto-sync.
|
||||
if (trigger === 'auto' && !isPersistedAutoSyncEnabled(manager.getState().autoSyncEnabled)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
isSyncRunningRef.current = true;
|
||||
try {
|
||||
// Get fresh state directly from CloudSyncManager singleton
|
||||
@@ -671,7 +691,10 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
if (recoveryPayload && shouldPromptCloudVaultRecovery(localPayload, recoveryPayload)) {
|
||||
const userAction = await requestEmptyVaultRecovery(recoveryPayload);
|
||||
if (userAction === 'restore') {
|
||||
// Explicit recovery must not honor the auto-sync preference gate:
|
||||
// the user already confirmed Restore in the dialog.
|
||||
const restored = await syncNowRef.current({
|
||||
trigger: 'manual',
|
||||
notifyOnFailure,
|
||||
conflictActionOverride: 'download-remote',
|
||||
});
|
||||
@@ -973,6 +996,12 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
// Skip if not ready
|
||||
if (!sync.hasAnyConnectedProvider || !sync.autoSyncEnabled || !sync.isUnlocked) {
|
||||
// Drop any pending debounce when auto-sync (or readiness) turns off so a
|
||||
// timer scheduled while enabled cannot fire after the toggle (#2976).
|
||||
if (syncTimeoutRef.current) {
|
||||
clearTimeout(syncTimeoutRef.current);
|
||||
syncTimeoutRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1030,6 +1059,12 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-check at fire time: auto-sync may have been disabled in another
|
||||
// window during the debounce window (#2976).
|
||||
if (!isPersistedAutoSyncEnabled(manager.getState().autoSyncEnabled)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for the current sync to finish, then this effect will re-run
|
||||
// because sync.isSyncing changed.
|
||||
if (sync.isSyncing || isSyncRunningRef.current) {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createLatestPayloadSync } from './useExternalMcpSessionSync.ts';
|
||||
|
||||
const hookSource = readFileSync(new URL('./useExternalMcpSessionSync.ts', import.meta.url), 'utf8');
|
||||
const appSource = readFileSync(new URL('../app/AppSideEffects.tsx', import.meta.url), 'utf8');
|
||||
const preloadSource = readFileSync(new URL('../../electron/preload/api.cjs', import.meta.url), 'utf8');
|
||||
const handlerSource = readFileSync(
|
||||
new URL('../../electron/bridges/aiBridge/agentProcessHandlers.cjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('app live session sync stays mounted and is independent of External MCP enablement', () => {
|
||||
assert.match(appSource, /useExternalMcpSessionSync\(\{/);
|
||||
assert.match(hookSource, /bridge\.aiMcpUpdateLiveSessions\?\.\(nextPayload\)/);
|
||||
assert.match(hookSource, /liveSyncRef\.current\.push\(payload, serialized\)/);
|
||||
assert.match(hookSource, /bridge\.aiMcpUpdateSessions\?\.\(/);
|
||||
assert.match(hookSource, /externalSyncRef\.current\.push\(payload, serialized\)/);
|
||||
assert.doesNotMatch(
|
||||
hookSource,
|
||||
/if \(!enabled\)[\s\S]{0,300}aiMcpUpdateLiveSessions/,
|
||||
);
|
||||
});
|
||||
|
||||
test('live session sync has a complete renderer to main-process IPC path', () => {
|
||||
assert.match(preloadSource, /netcatty:ai:mcp:update-live-sessions/);
|
||||
assert.match(handlerSource, /netcatty:ai:mcp:update-live-sessions/);
|
||||
assert.match(handlerSource, /mcpServerBridge\.updateLiveSessionMetadata/);
|
||||
});
|
||||
|
||||
test('latest payload sync converges to A-B-A while B is in flight', async () => {
|
||||
let releaseB: (() => void) | undefined;
|
||||
const sent: string[] = [];
|
||||
const sync = createLatestPayloadSync(async (payload: string) => {
|
||||
sent.push(payload);
|
||||
if (payload === 'B') {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseB = resolve;
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
await sync.push('A');
|
||||
const sendingB = sync.push('B');
|
||||
void sync.push('A');
|
||||
releaseB?.();
|
||||
await sendingB;
|
||||
|
||||
assert.deepEqual(sent, ['A', 'B', 'A']);
|
||||
});
|
||||
|
||||
test('cancelling a payload sync drops a queued update', async () => {
|
||||
let releaseA: (() => void) | undefined;
|
||||
const sent: string[] = [];
|
||||
const sync = createLatestPayloadSync(async (payload: string) => {
|
||||
sent.push(payload);
|
||||
if (payload === 'A') {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseA = resolve;
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const sendingA = sync.push('A');
|
||||
void sync.push('B');
|
||||
sync.cancel();
|
||||
releaseA?.();
|
||||
await sendingA;
|
||||
|
||||
assert.deepEqual(sent, ['A']);
|
||||
});
|
||||
@@ -17,6 +17,49 @@ type UseExternalMcpSessionSyncOptions = {
|
||||
portForwardingRules: PortForwardingRule[];
|
||||
};
|
||||
|
||||
export function createLatestPayloadSync<T>(
|
||||
send: (payload: T) => Promise<unknown>,
|
||||
) {
|
||||
let desired: { serialized: string; payload: T } | null = null;
|
||||
let acknowledged = '';
|
||||
let inFlight: Promise<void> | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
const pump = async () => {
|
||||
while (!cancelled && desired && desired.serialized !== acknowledged) {
|
||||
const target = desired;
|
||||
try {
|
||||
const result = await send(target.payload);
|
||||
if (cancelled) break;
|
||||
if (result && typeof result === 'object' && 'ok' in result && (result as { ok?: boolean }).ok === false) {
|
||||
if (desired === target) break;
|
||||
continue;
|
||||
}
|
||||
acknowledged = target.serialized;
|
||||
} catch {
|
||||
if (desired === target) break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
push(payload: T, serialized = JSON.stringify(payload)): Promise<void> {
|
||||
if (cancelled) return Promise.resolve();
|
||||
desired = { payload, serialized };
|
||||
if (!inFlight) {
|
||||
inFlight = pump().finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
}
|
||||
return inFlight;
|
||||
},
|
||||
cancel(): void {
|
||||
cancelled = true;
|
||||
desired = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isMainAppWindow(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const hash = window.location.hash || '';
|
||||
@@ -43,7 +86,6 @@ export function useExternalMcpSessionSync({
|
||||
void enabledTick;
|
||||
return readExternalMcpStoredEnabled();
|
||||
}, [enabledTick]);
|
||||
const syncGenerationRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const bump = () => setEnabledTick((value) => value + 1);
|
||||
@@ -77,34 +119,41 @@ export function useExternalMcpSessionSync({
|
||||
});
|
||||
}, [sessions, sessionHostsMap, hosts, portForwardingRules]);
|
||||
|
||||
const lastSentSerializedRef = useRef('');
|
||||
const liveSyncRef = useRef<ReturnType<typeof createLatestPayloadSync<typeof payload>> | null>(null);
|
||||
const externalSyncRef = useRef<ReturnType<typeof createLatestPayloadSync<typeof payload>> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMainAppWindow()) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.aiMcpUpdateLiveSessions) return;
|
||||
const serialized = JSON.stringify(payload);
|
||||
liveSyncRef.current ??= createLatestPayloadSync((nextPayload) => (
|
||||
Promise.resolve(bridge.aiMcpUpdateLiveSessions?.(nextPayload))
|
||||
));
|
||||
void liveSyncRef.current.push(payload, serialized);
|
||||
}, [payload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMainAppWindow()) return;
|
||||
if (!enabled) {
|
||||
syncGenerationRef.current += 1;
|
||||
lastSentSerializedRef.current = '';
|
||||
// Recreate the synchronizer after re-enabling because disabling can
|
||||
// clear the main-process scope even when the renderer payload is equal.
|
||||
externalSyncRef.current?.cancel();
|
||||
externalSyncRef.current = null;
|
||||
return;
|
||||
}
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.aiMcpUpdateSessions) return;
|
||||
|
||||
const serialized = JSON.stringify(payload);
|
||||
if (serialized === lastSentSerializedRef.current) return;
|
||||
|
||||
const generation = ++syncGenerationRef.current;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void Promise.resolve(bridge.aiMcpUpdateSessions?.(payload, EXTERNAL_MCP_CHAT_SESSION_ID))
|
||||
.then((result) => {
|
||||
if (generation !== syncGenerationRef.current) return;
|
||||
if (result && typeof result === 'object' && 'ok' in result && (result as { ok?: boolean }).ok === false) {
|
||||
return;
|
||||
}
|
||||
lastSentSerializedRef.current = serialized;
|
||||
})
|
||||
.catch(() => {
|
||||
// Leave the ref unset so the next effect can retry.
|
||||
});
|
||||
externalSyncRef.current ??= createLatestPayloadSync((nextPayload) => (
|
||||
Promise.resolve(bridge.aiMcpUpdateSessions?.(
|
||||
nextPayload,
|
||||
EXTERNAL_MCP_CHAT_SESSION_ID,
|
||||
))
|
||||
));
|
||||
void externalSyncRef.current.push(payload, serialized);
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -199,6 +199,36 @@ describe('useExternalMcpToggleState startup ready gate', () => {
|
||||
assert.match(hookSource, /if \(isPeerSessionWindow \|\| !enabled\) return;/);
|
||||
});
|
||||
|
||||
it('restores saved permission before enabling persistent External MCP', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
const storageKeys = await import('../../infrastructure/config/storageKeys.ts');
|
||||
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, 'true');
|
||||
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_PERMISSION_MODE, 'auto');
|
||||
|
||||
const calls: string[] = [];
|
||||
await syncExternalMcpStartupState({
|
||||
aiMcpSetPermissionMode: async (mode) => {
|
||||
calls.push(`permission:${mode}`);
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetConfig: async () => {
|
||||
calls.push('config');
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled) => {
|
||||
calls.push(`enabled:${enabled}`);
|
||||
return { ok: true, enabled };
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['permission:auto', 'config', 'enabled:true']);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('re-reads storage after config await so concurrent top-bar toggles win', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_MODE,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS,
|
||||
STORAGE_KEY_AI_PERMISSION_MODE,
|
||||
STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type { AIPermissionMode } from '../../infrastructure/ai/types';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
@@ -105,6 +107,7 @@ type ExternalMcpConfig = {
|
||||
};
|
||||
|
||||
type ExternalMcpBridge = {
|
||||
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
|
||||
externalMcpSetConfig?: (config: ExternalMcpConfig) => Promise<unknown> | unknown;
|
||||
externalMcpSetEnabled?: (enabled: boolean) => Promise<unknown> | unknown;
|
||||
externalMcpGetStatus?: () => Promise<{
|
||||
@@ -126,6 +129,11 @@ export function normalizeExternalMcpMode(value: string | null): ExternalMcpMode
|
||||
return value === 'persistent' ? 'persistent' : 'temporary';
|
||||
}
|
||||
|
||||
function readAIPermissionMode(): AIPermissionMode {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
|
||||
return stored === 'observer' || stored === 'auto' ? stored : 'confirm';
|
||||
}
|
||||
|
||||
export function normalizeExternalMcpIdleTimeoutMinutes(value: number | null): number {
|
||||
if (!Number.isFinite(value)) return DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES;
|
||||
return Math.min(
|
||||
@@ -280,6 +288,13 @@ export async function syncExternalMcpStartupState(
|
||||
// toggle during boot wins over a stale enable/disable decision.
|
||||
const startupGeneration = externalMcpEnableGeneration;
|
||||
const initialPlan = readExternalMcpStartupSyncPlan();
|
||||
try {
|
||||
// External MCP discovery is written while enabling, so restore the saved
|
||||
// permission first instead of exposing the main-process default (confirm).
|
||||
await Promise.resolve(bridge?.aiMcpSetPermissionMode?.(readAIPermissionMode()));
|
||||
} catch {
|
||||
// Permission sync is best-effort; the main process keeps its safe default.
|
||||
}
|
||||
try {
|
||||
await Promise.resolve(bridge?.externalMcpSetConfig?.(initialPlan.config));
|
||||
} catch {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import {
|
||||
createDeferredOutputTriggerEventProcessor,
|
||||
createOutputTriggerScanBuffer,
|
||||
@@ -7,6 +9,7 @@ import {
|
||||
findMatchEndingAfter,
|
||||
hasApplicableOutputTriggerSnippet,
|
||||
inspectDroppedOutputOverflowAlternateScreenState,
|
||||
useOutputTriggers,
|
||||
} from './useOutputTriggers.ts';
|
||||
import { createTerminalOutputTriggerFilter } from '@/domain/terminalOutputTriggerFilter.ts';
|
||||
import type { Snippet } from '@/domain/models';
|
||||
@@ -93,6 +96,80 @@ test('hasApplicableOutputTriggerSnippet requires a runnable output trigger for t
|
||||
assert.equal(hasApplicableOutputTriggerSnippet(snippets, 'host-a'), true);
|
||||
});
|
||||
|
||||
test('hasApplicableOutputTriggerSnippet follows the host current group', () => {
|
||||
const snippets: Snippet[] = [{
|
||||
id: 'group-trigger',
|
||||
label: 'Group trigger',
|
||||
command: '',
|
||||
kind: 'script',
|
||||
trigger: 'onOutput',
|
||||
triggerPattern: 'READY',
|
||||
targetGroups: ['Production'],
|
||||
}];
|
||||
assert.equal(
|
||||
hasApplicableOutputTriggerSnippet(snippets, { id: 'host-a', group: 'Production/Web' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hasApplicableOutputTriggerSnippet(snippets, { id: 'host-a', group: 'Staging' }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('equivalent host objects do not reset a pending output trigger match', async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const snippets: Snippet[] = [{
|
||||
id: 'stable-host-trigger',
|
||||
label: 'Stable host trigger',
|
||||
command: '',
|
||||
kind: 'script',
|
||||
trigger: 'onOutput',
|
||||
triggerPattern: 'READY',
|
||||
targetGroups: ['Production'],
|
||||
}];
|
||||
let appendOutput: ((chunk: string) => void) | undefined;
|
||||
let runCount = 0;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const onRunScript = () => {
|
||||
runCount += 1;
|
||||
};
|
||||
|
||||
const Probe = ({ host }: { host: { id: string; group: string } }) => {
|
||||
appendOutput = useOutputTriggers({
|
||||
sessionId: 'session-1',
|
||||
host,
|
||||
snippets,
|
||||
onRunScript,
|
||||
}).appendOutput;
|
||||
return null;
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe, {
|
||||
host: { id: 'host-a', group: 'Production/Web' },
|
||||
}));
|
||||
});
|
||||
try {
|
||||
await act(async () => {
|
||||
appendOutput?.('REA');
|
||||
renderer?.update(React.createElement(Probe, {
|
||||
host: { id: 'host-a', group: 'Production/Web' },
|
||||
}));
|
||||
appendOutput?.('DY');
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
});
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
|
||||
assert.equal(runCount, 1);
|
||||
});
|
||||
|
||||
test('output trigger scan buffer consumes scanned content and only keeps overlap', () => {
|
||||
const buffer = createOutputTriggerScanBuffer(4);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import type { Snippet } from '@/domain/models';
|
||||
import type { Host, Snippet } from '@/domain/models';
|
||||
import { snippetAppliesToOutputTrigger } from '@/domain/snippetTargets.ts';
|
||||
import { isScriptSnippet } from '@/domain/snippetScript.ts';
|
||||
import {
|
||||
@@ -19,7 +19,7 @@ const OUTPUT_TRIGGER_DROPPED_OVERFLOW_SCAN_WINDOW_CHARS = 2048;
|
||||
|
||||
type OutputTriggerContext = {
|
||||
sessionId: string;
|
||||
hostId?: string;
|
||||
host?: Pick<Host, 'id' | 'group'>;
|
||||
snippets: Snippet[];
|
||||
onRunScript: (snippet: Snippet, sessionId: string) => void | Promise<void>;
|
||||
};
|
||||
@@ -575,22 +575,31 @@ export function findMatchEndingAfter(text: string, pattern: string, minEndOffset
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasApplicableOutputTriggerSnippet(snippets: Snippet[], hostId?: string): boolean {
|
||||
export function hasApplicableOutputTriggerSnippet(
|
||||
snippets: Snippet[],
|
||||
host?: Pick<Host, 'id' | 'group'> | string,
|
||||
): boolean {
|
||||
return snippets.some((snippet) => (
|
||||
isScriptSnippet(snippet)
|
||||
&& snippet.trigger === 'onOutput'
|
||||
&& Boolean(snippet.triggerPattern)
|
||||
&& Boolean(snippet.id)
|
||||
&& snippetAppliesToOutputTrigger(snippet, hostId)
|
||||
&& snippetAppliesToOutputTrigger(snippet, host)
|
||||
));
|
||||
}
|
||||
|
||||
export function useOutputTriggers({
|
||||
sessionId,
|
||||
hostId,
|
||||
host,
|
||||
snippets,
|
||||
onRunScript,
|
||||
}: OutputTriggerContext) {
|
||||
const targetHostId = host?.id;
|
||||
const targetHostGroup = host?.group;
|
||||
const targetHost = useMemo(
|
||||
() => targetHostId ? { id: targetHostId, group: targetHostGroup } : undefined,
|
||||
[targetHostGroup, targetHostId],
|
||||
);
|
||||
const launchingRef = useRef(false);
|
||||
const lastTriggerMatchEndRef = useRef(new Map<string, number>());
|
||||
const serverOutputFilterRef = useRef(createTerminalOutputTriggerFilter());
|
||||
@@ -600,8 +609,8 @@ export function useOutputTriggers({
|
||||
const pendingDroppedOverflowFinalActionRef = useRef<'leave' | null>(null);
|
||||
const pendingDroppedOverflowScanStateResetRef = useRef(false);
|
||||
const hasOutputTriggers = useMemo(
|
||||
() => hasApplicableOutputTriggerSnippet(snippets, hostId),
|
||||
[hostId, snippets],
|
||||
() => hasApplicableOutputTriggerSnippet(snippets, targetHost),
|
||||
[snippets, targetHost],
|
||||
);
|
||||
|
||||
const scanOutput = useCallback((scannableText: string) => {
|
||||
@@ -621,7 +630,7 @@ export function useOutputTriggers({
|
||||
if (!isScriptSnippet(snippet) || snippet.trigger !== 'onOutput' || !snippet.triggerPattern || !snippet.id) {
|
||||
continue;
|
||||
}
|
||||
if (!snippetAppliesToOutputTrigger(snippet, hostId)) continue;
|
||||
if (!snippetAppliesToOutputTrigger(snippet, targetHost)) continue;
|
||||
try {
|
||||
const matched = findMatchEndingAfter(scanWindow.text, snippet.triggerPattern, scanWindow.minEndOffset);
|
||||
if (!matched) {
|
||||
@@ -647,7 +656,7 @@ export function useOutputTriggers({
|
||||
// ignore invalid regex
|
||||
}
|
||||
}
|
||||
}, [hasOutputTriggers, hostId, onRunScript, sessionId, snippets]);
|
||||
}, [hasOutputTriggers, onRunScript, sessionId, snippets, targetHost]);
|
||||
|
||||
const scanOutputRef = useRef(scanOutput);
|
||||
scanOutputRef.current = scanOutput;
|
||||
@@ -813,7 +822,7 @@ export function useOutputTriggers({
|
||||
outputTriggerScanSuppressedRef.current = false;
|
||||
pendingDroppedOverflowFinalActionRef.current = null;
|
||||
pendingDroppedOverflowScanStateResetRef.current = false;
|
||||
}, [sessionId, hostId, hasOutputTriggers, outputTriggerEventProcessor]);
|
||||
}, [sessionId, targetHost, hasOutputTriggers, outputTriggerEventProcessor]);
|
||||
|
||||
useEffect(() => () => {
|
||||
outputTriggerEventProcessor.reset();
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
syncWithBackend,
|
||||
} from "../../infrastructure/services/portForwardingService";
|
||||
import { logger } from "../../lib/logger";
|
||||
import { applyPortForwardingRuntimeStatus } from "./usePortForwardingState";
|
||||
|
||||
export interface UsePortForwardingAutoStartOptions {
|
||||
enabled?: boolean;
|
||||
@@ -328,24 +329,12 @@ export const usePortForwardingAutoStart = ({
|
||||
[resolveEffectiveHost],
|
||||
);
|
||||
|
||||
// Runtime phases stay in the in-memory projection only (#2288). Writing
|
||||
// active/connecting/error into localStorage would churn sync hashes and
|
||||
// fight the main-process authority model.
|
||||
const updateStoredRuleStatus = useCallback(
|
||||
(ruleId: string, status: PortForwardingRule["status"], error?: string) => {
|
||||
const currentRules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
) ?? [];
|
||||
|
||||
const updatedRules = currentRules.map((rule) =>
|
||||
rule.id === ruleId
|
||||
? {
|
||||
...rule,
|
||||
status,
|
||||
error,
|
||||
lastUsedAt: status === "active" ? Date.now() : rule.lastUsedAt,
|
||||
}
|
||||
: rule,
|
||||
);
|
||||
|
||||
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, updatedRules);
|
||||
applyPortForwardingRuntimeStatus(ruleId, status, error);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -2,12 +2,14 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { Host, PortForwardingRule } from "../../domain/models.ts";
|
||||
import { toPersistedPortForwardingRules } from "../../domain/portForwardingPersistence.ts";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys.ts";
|
||||
import {
|
||||
startPortForward,
|
||||
stopAndCleanupRuleAndWait,
|
||||
} from "../../infrastructure/services/portForwardingService.ts";
|
||||
import {
|
||||
applyPortForwardingRuntimeStatus,
|
||||
createPortForwardingStorageSyncHandlers,
|
||||
havePortForwardingRuntimeStatesChanged,
|
||||
hasPortForwardingRuntimePresenceChanged,
|
||||
@@ -25,6 +27,7 @@ const rule: PortForwardingRule = {
|
||||
autoStart: true,
|
||||
createdAt: 1,
|
||||
status: "inactive",
|
||||
bindAddress: "127.0.0.1",
|
||||
};
|
||||
|
||||
const host: Host = {
|
||||
@@ -80,32 +83,27 @@ function installEnvironment() {
|
||||
};
|
||||
}
|
||||
|
||||
test("same-window auto-start status writes refresh the visible rule from the live tunnel", async (t) => {
|
||||
test("runtime status updates refresh the visible rule without writing storage", async (t) => {
|
||||
const env = installEnvironment();
|
||||
t.after(async () => {
|
||||
await stopAndCleanupRuleAndWait(rule.id);
|
||||
env.restore();
|
||||
});
|
||||
|
||||
env.storage.setItem(STORAGE_KEY_PORT_FORWARDING, JSON.stringify([rule]));
|
||||
env.storage.setItem(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
JSON.stringify(toPersistedPortForwardingRules([rule])),
|
||||
);
|
||||
await startPortForward(rule, host, [host], [], [], () => undefined, true);
|
||||
env.emitStatus("active");
|
||||
applyPortForwardingRuntimeStatus(rule.id, "active");
|
||||
|
||||
const snapshots: PortForwardingRule[][] = [];
|
||||
const handlers = createPortForwardingStorageSyncHandlers({
|
||||
onRules: (rules) => snapshots.push(rules),
|
||||
});
|
||||
|
||||
handlers.handleAdapterChange({
|
||||
type: "netcatty:local-storage-adapter-changed",
|
||||
detail: { key: STORAGE_KEY_PORT_FORWARDING },
|
||||
} as unknown as CustomEvent<{ key: string }>);
|
||||
|
||||
assert.equal(snapshots.length, 1);
|
||||
assert.equal(snapshots[0]?.[0]?.status, "active");
|
||||
const stored = JSON.parse(env.storage.getItem(STORAGE_KEY_PORT_FORWARDING) || "[]") as PortForwardingRule[];
|
||||
assert.equal(stored[0]?.status, "inactive");
|
||||
assert.equal(stored[0]?.error, undefined);
|
||||
});
|
||||
|
||||
test("cross-window status writes stay active before this window discovers the tunnel", (t) => {
|
||||
test("legacy cross-window active status is migrated away on config sync", (t) => {
|
||||
const env = installEnvironment();
|
||||
t.after(() => env.restore());
|
||||
|
||||
@@ -125,7 +123,20 @@ test("cross-window status writes stay active before this window discovers the tu
|
||||
} as StorageEvent);
|
||||
|
||||
assert.equal(snapshots.length, 1);
|
||||
assert.equal(snapshots[0]?.[0]?.status, "active");
|
||||
// Runtime is no longer trusted from storage. Before an authoritative snapshot
|
||||
// succeeds, the projection must be unknown — never a false inactive.
|
||||
assert.equal(snapshots[0]?.[0]?.status, "unknown");
|
||||
});
|
||||
|
||||
test("normalizeRulesWithConnections stays inactive only after authority succeeds", () => {
|
||||
const normalized = normalizeRulesWithConnections([{
|
||||
...rule,
|
||||
id: "authority-ok-rule",
|
||||
status: "active",
|
||||
}], {
|
||||
snapshotAvailable: true,
|
||||
});
|
||||
assert.equal(normalized[0]?.status, "inactive");
|
||||
});
|
||||
|
||||
test("cross-window stale status cannot override a known live tunnel", async (t) => {
|
||||
@@ -164,6 +175,16 @@ test("same-window synchronization preserves an error without a runtime tunnel",
|
||||
status: "error",
|
||||
error: "Host not found",
|
||||
}]));
|
||||
// Authority already confirmed — diagnostic error may remain without a tunnel.
|
||||
const normalized = normalizeRulesWithConnections([{
|
||||
...rule,
|
||||
id: "failed-start-rule",
|
||||
status: "error",
|
||||
error: "Host not found",
|
||||
}], { snapshotAvailable: true });
|
||||
assert.equal(normalized[0]?.status, "error");
|
||||
assert.equal(normalized[0]?.error, "Host not found");
|
||||
|
||||
const snapshots: PortForwardingRule[][] = [];
|
||||
const handlers = createPortForwardingStorageSyncHandlers({
|
||||
onRules: (rules) => snapshots.push(rules),
|
||||
@@ -174,8 +195,8 @@ test("same-window synchronization preserves an error without a runtime tunnel",
|
||||
detail: { key: STORAGE_KEY_PORT_FORWARDING },
|
||||
} as unknown as CustomEvent<{ key: string }>);
|
||||
|
||||
assert.equal(snapshots[0]?.[0]?.status, "error");
|
||||
assert.equal(snapshots[0]?.[0]?.error, "Host not found");
|
||||
// Without a successful snapshot in this module, storage sync projects unknown.
|
||||
assert.equal(snapshots[0]?.[0]?.status, "unknown");
|
||||
});
|
||||
|
||||
test("heartbeat normalization preserves an error without a runtime tunnel", () => {
|
||||
@@ -184,7 +205,7 @@ test("heartbeat normalization preserves an error without a runtime tunnel", () =
|
||||
id: "heartbeat-error-rule",
|
||||
status: "error",
|
||||
error: "Authentication failed",
|
||||
}]);
|
||||
}], { snapshotAvailable: true });
|
||||
|
||||
assert.equal(normalized[0]?.status, "error");
|
||||
assert.equal(normalized[0]?.error, "Authentication failed");
|
||||
@@ -196,12 +217,27 @@ test("heartbeat normalization clears an error for a reconciled-away tunnel", ()
|
||||
id: "cleanup-error-rule",
|
||||
status: "error",
|
||||
error: "Failed to stop tunnel",
|
||||
}], new Set(["cleanup-error-rule"]));
|
||||
}], {
|
||||
reconciledGoneRuleIds: new Set(["cleanup-error-rule"]),
|
||||
snapshotAvailable: true,
|
||||
});
|
||||
|
||||
assert.equal(normalized[0]?.status, "inactive");
|
||||
assert.equal(normalized[0]?.error, undefined);
|
||||
});
|
||||
|
||||
test("snapshot failure projects unknown instead of inactive", () => {
|
||||
const normalized = normalizeRulesWithConnections([{
|
||||
...rule,
|
||||
id: "stale-rule",
|
||||
status: "inactive",
|
||||
}], {
|
||||
snapshotAvailable: false,
|
||||
});
|
||||
|
||||
assert.equal(normalized[0]?.status, "unknown");
|
||||
});
|
||||
|
||||
test("heartbeat writes only when a visible runtime state changes", () => {
|
||||
const current = [{ ...rule, status: "inactive" as const }];
|
||||
const unchanged = [{ ...rule, status: "inactive" as const }];
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Host, Identity, KnownHost, PortForwardingRule, SSHKey } from "../../domain/models";
|
||||
import {
|
||||
migratePortForwardingRulesFromStorage,
|
||||
toPersistedPortForwardingRules,
|
||||
} from "../../domain/portForwardingPersistence";
|
||||
import { getNextVaultOrder, normalizeVaultOrder, reorderVaultItems, sortByVaultOrder, type VaultOrderPosition } from "../../domain/vaultOrder";
|
||||
import {
|
||||
STORAGE_KEY_PF_PREFER_FORM_MODE,
|
||||
@@ -10,9 +14,11 @@ import {
|
||||
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
|
||||
localStorageAdapter,
|
||||
} from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
import {
|
||||
clearReconnectTimer,
|
||||
getActiveConnection,
|
||||
getPortForwardRuntimeAuthority,
|
||||
initReconnectCancelListener,
|
||||
reconcileWithBackend,
|
||||
startPortForward,
|
||||
@@ -32,6 +38,8 @@ let reconnectCancelListenerRefs = 0;
|
||||
let reconnectCancelCleanup: (() => void) | undefined;
|
||||
let heartbeatRefs = 0;
|
||||
let heartbeatIntervalId: ReturnType<typeof setInterval> | undefined;
|
||||
let runtimeSubscriptionRefs = 0;
|
||||
let runtimeSubscriptionCleanup: (() => void) | undefined;
|
||||
|
||||
export type { ViewMode };
|
||||
|
||||
@@ -91,6 +99,8 @@ export interface UsePortForwardingStateResult {
|
||||
// Global Store State
|
||||
let globalRules: PortForwardingRule[] = [];
|
||||
let isInitialized = false;
|
||||
// Until the first successful authoritative snapshot, treat runtime as unknown.
|
||||
let snapshotAvailable = false;
|
||||
const listeners = new Set<(rules: PortForwardingRule[]) => void>();
|
||||
|
||||
// Store Actions
|
||||
@@ -98,16 +108,50 @@ const notifyListeners = () => {
|
||||
listeners.forEach((listener) => listener(globalRules));
|
||||
};
|
||||
|
||||
const persistPortForwardingConfig = (rules: PortForwardingRule[]) => {
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
toPersistedPortForwardingRules(rules),
|
||||
);
|
||||
};
|
||||
|
||||
/** Persist configuration only — never write runtime phases to storage. */
|
||||
const setGlobalRules = (newRules: PortForwardingRule[]) => {
|
||||
globalRules = normalizeVaultOrder(newRules);
|
||||
notifyListeners();
|
||||
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, globalRules);
|
||||
persistPortForwardingConfig(globalRules);
|
||||
};
|
||||
|
||||
/** Update the in-memory projection without touching localStorage. */
|
||||
const setRuntimeProjection = (newRules: PortForwardingRule[]) => {
|
||||
globalRules = normalizeVaultOrder(newRules);
|
||||
notifyListeners();
|
||||
};
|
||||
|
||||
export type NormalizeRulesOptions = {
|
||||
reconciledGoneRuleIds?: ReadonlySet<string>;
|
||||
snapshotAvailable?: boolean;
|
||||
};
|
||||
|
||||
const isGoneRuleIdSet = (
|
||||
value: ReadonlySet<string> | NormalizeRulesOptions,
|
||||
): value is ReadonlySet<string> => (
|
||||
typeof (value as ReadonlySet<string>).has === "function"
|
||||
&& !("snapshotAvailable" in (value as object))
|
||||
&& !("reconciledGoneRuleIds" in (value as object))
|
||||
);
|
||||
|
||||
export const normalizeRulesWithConnections = (
|
||||
rules: PortForwardingRule[],
|
||||
reconciledGoneRuleIds: ReadonlySet<string> = new Set(),
|
||||
reconciledGoneRuleIdsOrOptions: ReadonlySet<string> | NormalizeRulesOptions = new Set(),
|
||||
): PortForwardingRule[] => {
|
||||
const options: NormalizeRulesOptions = isGoneRuleIdSet(reconciledGoneRuleIdsOrOptions)
|
||||
? { reconciledGoneRuleIds: reconciledGoneRuleIdsOrOptions }
|
||||
: reconciledGoneRuleIdsOrOptions;
|
||||
|
||||
const reconciledGoneRuleIds = options.reconciledGoneRuleIds ?? new Set<string>();
|
||||
const authorityAvailable = options.snapshotAvailable ?? snapshotAvailable;
|
||||
|
||||
return rules.map((rule): PortForwardingRule => {
|
||||
const connection = getActiveConnection(rule.id);
|
||||
if (connection) {
|
||||
@@ -126,6 +170,13 @@ export const normalizeRulesWithConnections = (
|
||||
};
|
||||
}
|
||||
|
||||
if (!authorityAvailable) {
|
||||
return {
|
||||
...rule,
|
||||
status: "unknown" as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (rule.status === "error") return rule;
|
||||
|
||||
return {
|
||||
@@ -155,15 +206,42 @@ export const hasPortForwardingRuntimePresenceChanged = (reconciliation: {
|
||||
}): boolean => reconciliation.gone.length > 0 || reconciliation.appeared.length > 0;
|
||||
|
||||
const mergeRulesWithKnownConnections = (rules: PortForwardingRule[]): PortForwardingRule[] => {
|
||||
return rules.map((rule): PortForwardingRule => {
|
||||
const connection = getActiveConnection(rule.id);
|
||||
if (!connection) return rule;
|
||||
return normalizeRulesWithConnections(
|
||||
migratePortForwardingRulesFromStorage(rules),
|
||||
{ snapshotAvailable },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply a runtime status update to the in-memory projection only.
|
||||
* Auto-start and reconnect paths use this instead of writing localStorage.
|
||||
*/
|
||||
export const applyPortForwardingRuntimeStatus = (
|
||||
ruleId: string,
|
||||
status: PortForwardingRule["status"],
|
||||
error?: string,
|
||||
): void => {
|
||||
if (globalRules.length === 0) {
|
||||
const stored = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (stored && Array.isArray(stored)) {
|
||||
globalRules = normalizeVaultOrder(
|
||||
migratePortForwardingRulesFromStorage(stored),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = globalRules.map((rule) => {
|
||||
if (rule.id !== ruleId) return rule;
|
||||
return {
|
||||
...rule,
|
||||
status: connection.status,
|
||||
error: connection.error,
|
||||
status,
|
||||
error,
|
||||
lastUsedAt: status === "active" ? Date.now() : rule.lastUsedAt,
|
||||
};
|
||||
});
|
||||
setRuntimeProjection(updated);
|
||||
};
|
||||
|
||||
const isPortForwardingStorageEvent = (event: Event): boolean => {
|
||||
@@ -199,21 +277,121 @@ export const createPortForwardingStorageSyncHandlers = ({
|
||||
};
|
||||
};
|
||||
|
||||
const applyRuntimeSnapshotProjection = (
|
||||
goneRuleIds: ReadonlySet<string> = new Set(),
|
||||
authorityAvailable = snapshotAvailable,
|
||||
) => {
|
||||
const normalizedRules = normalizeRulesWithConnections(globalRules, {
|
||||
reconciledGoneRuleIds: goneRuleIds,
|
||||
snapshotAvailable: authorityAvailable,
|
||||
});
|
||||
if (havePortForwardingRuntimeStatesChanged(globalRules, normalizedRules)) {
|
||||
setRuntimeProjection(normalizedRules);
|
||||
} else if (hasPortForwardingRuntimePresenceChanged({
|
||||
gone: [...goneRuleIds],
|
||||
appeared: [],
|
||||
})) {
|
||||
globalRules = normalizedRules;
|
||||
notifyListeners();
|
||||
}
|
||||
};
|
||||
|
||||
// Initialization Logic
|
||||
const initializeStore = async () => {
|
||||
if (isInitialized) return;
|
||||
isInitialized = true;
|
||||
|
||||
await syncWithBackend();
|
||||
snapshotAvailable = getPortForwardRuntimeAuthority().available;
|
||||
|
||||
const saved = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (saved && Array.isArray(saved)) {
|
||||
setGlobalRules(normalizeRulesWithConnections(saved));
|
||||
// Hydrate config from storage, then overlay the live runtime projection.
|
||||
// Do not re-persist — that would churn storage with migrated status fields.
|
||||
const migrated = migratePortForwardingRulesFromStorage(saved);
|
||||
setRuntimeProjection(normalizeRulesWithConnections(migrated, {
|
||||
snapshotAvailable,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const subscribeToPortForwardRuntime = (): (() => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.subscribePortForwardRuntime || !bridge.onPortForwardRuntime) {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let unsubscribeEvent: (() => void) | undefined;
|
||||
let observedEpoch: string | undefined;
|
||||
let observedRevision = -1;
|
||||
let syncChain: Promise<void> = Promise.resolve();
|
||||
|
||||
const enqueueRuntimeSync = (task: () => Promise<void>) => {
|
||||
syncChain = syncChain.then(async () => {
|
||||
if (disposed) return;
|
||||
await task();
|
||||
}).catch(() => undefined);
|
||||
return syncChain;
|
||||
};
|
||||
|
||||
const resyncFromSnapshot = async () => {
|
||||
try {
|
||||
const snapshot = await bridge.subscribePortForwardRuntime!();
|
||||
if (disposed) return;
|
||||
observedEpoch = snapshot.epoch;
|
||||
observedRevision = snapshot.revision;
|
||||
// Reconcile (not sync-only) so tunnels absent after an epoch change are pruned.
|
||||
const reconciliation = await reconcileWithBackend();
|
||||
if (disposed) return;
|
||||
snapshotAvailable = reconciliation.snapshotAvailable;
|
||||
if (!reconciliation.snapshotAvailable) {
|
||||
applyRuntimeSnapshotProjection(new Set(), false);
|
||||
return;
|
||||
}
|
||||
applyRuntimeSnapshotProjection(new Set(reconciliation.gone), true);
|
||||
} catch {
|
||||
if (disposed) return;
|
||||
snapshotAvailable = false;
|
||||
applyRuntimeSnapshotProjection(new Set(), false);
|
||||
}
|
||||
};
|
||||
|
||||
unsubscribeEvent = bridge.onPortForwardRuntime((event) => {
|
||||
if (disposed) return;
|
||||
if (observedEpoch && event.epoch !== observedEpoch) {
|
||||
void enqueueRuntimeSync(resyncFromSnapshot);
|
||||
return;
|
||||
}
|
||||
if (observedRevision >= 0 && event.revision > observedRevision + 1) {
|
||||
void enqueueRuntimeSync(resyncFromSnapshot);
|
||||
return;
|
||||
}
|
||||
observedEpoch = event.epoch;
|
||||
observedRevision = event.revision;
|
||||
void enqueueRuntimeSync(async () => {
|
||||
const reconciliation = await reconcileWithBackend();
|
||||
if (disposed) return;
|
||||
snapshotAvailable = reconciliation.snapshotAvailable;
|
||||
if (!reconciliation.snapshotAvailable) {
|
||||
applyRuntimeSnapshotProjection(new Set(), false);
|
||||
return;
|
||||
}
|
||||
applyRuntimeSnapshotProjection(new Set(reconciliation.gone), true);
|
||||
});
|
||||
});
|
||||
|
||||
void enqueueRuntimeSync(resyncFromSnapshot);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
unsubscribeEvent?.();
|
||||
void bridge.unsubscribePortForwardRuntime?.();
|
||||
};
|
||||
};
|
||||
|
||||
export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
const [rules, setRules] = useState<PortForwardingRule[]>(globalRules);
|
||||
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
|
||||
@@ -253,9 +431,9 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
};
|
||||
}, [rules]);
|
||||
|
||||
// Listen for both browser storage events (other windows) and adapter
|
||||
// events (this window). Auto-start writes statuses outside this hook, so
|
||||
// relying on the browser event alone leaves the launching window stale.
|
||||
// Config sync across windows. Runtime phases are no longer written to
|
||||
// storage, so these handlers only refresh rule configuration and overlay
|
||||
// whatever live tunnels this window already knows about.
|
||||
useEffect(() => {
|
||||
const target = globalThis as typeof globalThis & {
|
||||
addEventListener?: (type: string, listener: EventListener) => void;
|
||||
@@ -303,6 +481,22 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Authoritative runtime subscription (snapshot + ordered events). Heartbeat
|
||||
// below remains as a recovery fallback for revision gaps / missed events.
|
||||
useEffect(() => {
|
||||
runtimeSubscriptionRefs++;
|
||||
if (runtimeSubscriptionRefs === 1) {
|
||||
runtimeSubscriptionCleanup = subscribeToPortForwardRuntime();
|
||||
}
|
||||
return () => {
|
||||
runtimeSubscriptionRefs--;
|
||||
if (runtimeSubscriptionRefs === 0 && runtimeSubscriptionCleanup) {
|
||||
runtimeSubscriptionCleanup();
|
||||
runtimeSubscriptionCleanup = undefined;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Periodic heartbeat: reconcile renderer state with the backend every 4s.
|
||||
// Ref-counted — same pattern as the reconnect cancel listener.
|
||||
useEffect(() => {
|
||||
@@ -313,19 +507,24 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
|
||||
const tick = async () => {
|
||||
const reconciliation = await reconcileWithBackend();
|
||||
if (!reconciliation.snapshotAvailable) return;
|
||||
// Always re-derive the visible state. This also repairs a stale
|
||||
// cross-window storage write when the backend map itself did not change.
|
||||
snapshotAvailable = reconciliation.snapshotAvailable;
|
||||
if (!reconciliation.snapshotAvailable) {
|
||||
// Snapshot failure must surface as unknown/stale — never as inactive.
|
||||
applyRuntimeSnapshotProjection(new Set(), false);
|
||||
return;
|
||||
}
|
||||
// Always re-derive the visible state from the live connection map.
|
||||
// Runtime phases stay in memory only.
|
||||
const normalizedRules = normalizeRulesWithConnections(
|
||||
globalRules,
|
||||
new Set(reconciliation.gone),
|
||||
{
|
||||
reconciledGoneRuleIds: new Set(reconciliation.gone),
|
||||
snapshotAvailable: true,
|
||||
},
|
||||
);
|
||||
if (havePortForwardingRuntimeStatesChanged(globalRules, normalizedRules)) {
|
||||
setGlobalRules(normalizedRules);
|
||||
setRuntimeProjection(normalizedRules);
|
||||
} else if (hasPortForwardingRuntimePresenceChanged(reconciliation)) {
|
||||
// Runtime presence affects Start/Stop actions even when the visible
|
||||
// status and error already match. Notify React without rewriting the
|
||||
// unchanged persisted configuration.
|
||||
globalRules = normalizedRules;
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -470,21 +669,14 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
stopAndCleanupRule(existing.id);
|
||||
}
|
||||
}
|
||||
setGlobalRules(normalizeRulesWithConnections(newRules));
|
||||
setGlobalRules(normalizeRulesWithConnections(
|
||||
migratePortForwardingRulesFromStorage(newRules),
|
||||
));
|
||||
}, []);
|
||||
|
||||
const setRuleStatus = useCallback(
|
||||
(id: string, status: PortForwardingRule["status"], error?: string) => {
|
||||
const updated = globalRules.map((r) => {
|
||||
if (r.id !== id) return r;
|
||||
return {
|
||||
...r,
|
||||
status,
|
||||
error,
|
||||
lastUsedAt: status === "active" ? Date.now() : r.lastUsedAt,
|
||||
};
|
||||
});
|
||||
setGlobalRules(updated);
|
||||
applyPortForwardingRuntimeStatus(id, status, error);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export interface DiskInfo {
|
||||
capacityKey?: string; // Filesystem or shared-pool identity for deduplication
|
||||
filesystemType?: string; // Filesystem type reported by df (for network/FUSE filtering)
|
||||
mountPoint: string;
|
||||
used: number; // Used in GB
|
||||
total: number; // Total in GB
|
||||
@@ -183,16 +184,6 @@ function clearServerStatsTimers(session: SharedServerStatsSession): void {
|
||||
session.pollIntervalMs = null;
|
||||
}
|
||||
|
||||
function resetServerStatsSession(session: SharedServerStatsSession): void {
|
||||
clearServerStatsTimers(session);
|
||||
session.connectedAt = 0;
|
||||
session.hasFetched = false;
|
||||
session.fetchGeneration += 1;
|
||||
session.consecutiveFailures = 0;
|
||||
session.givenUp = false;
|
||||
updateServerStatsState(session, createInitialState());
|
||||
}
|
||||
|
||||
function getActiveServerStatsClients(session: SharedServerStatsSession): ServerStatsClient[] {
|
||||
return Array.from(session.clients.values()).filter((client) => (
|
||||
client.enabled && client.isSupportedOs && client.isConnected
|
||||
@@ -312,7 +303,16 @@ function reconcileSharedServerStatsSession(session: SharedServerStatsSession): v
|
||||
const activeClients = getActiveServerStatsClients(session);
|
||||
|
||||
if (activeClients.length === 0) {
|
||||
resetServerStatsSession(session);
|
||||
// Pause polling only. Keep the last successful stats so remounting Overview
|
||||
// (or re-enabling a terminal stats strip) can paint immediately instead of
|
||||
// flashing "No system overview data yet." Still clear give-up / failure so a
|
||||
// later resume can auto-retry (old reset wiped stats + flags together).
|
||||
clearServerStatsTimers(session);
|
||||
session.givenUp = false;
|
||||
session.consecutiveFailures = 0;
|
||||
if (session.state.isLoading) {
|
||||
updateServerStatsState(session, { isLoading: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import { useCallback } from "react";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
|
||||
type ManualChoosePathPayload = {
|
||||
sessionId: string;
|
||||
sessionName?: string;
|
||||
preferredDirectory?: string;
|
||||
format?: "txt" | "raw" | "html";
|
||||
};
|
||||
|
||||
type ManualStartPayload = {
|
||||
sessionId: string;
|
||||
sessionName?: string;
|
||||
preferredDirectory?: string;
|
||||
/**
|
||||
* Opaque token from chooseManualSessionLogPath. Required for the post-dialog
|
||||
* second phase; raw file paths from the renderer are rejected.
|
||||
*/
|
||||
selectionToken?: string;
|
||||
format?: "txt" | "raw" | "html";
|
||||
timestampsEnabled?: boolean;
|
||||
initialLine?: string;
|
||||
alternateScreenActive?: boolean;
|
||||
};
|
||||
|
||||
type ManualStopPayload = {
|
||||
@@ -19,6 +32,12 @@ type ManualStatusPayload = {
|
||||
};
|
||||
|
||||
export const useSessionLogBackend = () => {
|
||||
const chooseManualSessionLogPath = useCallback(async (payload: ManualChoosePathPayload) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.chooseManualSessionLogPath?.(payload)
|
||||
?? { success: false, canceled: false, error: "Session log bridge unavailable" };
|
||||
}, []);
|
||||
|
||||
const startManualSessionLog = useCallback(async (payload: ManualStartPayload) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.startManualSessionLog?.(payload) ?? { success: false, started: false, error: "Session log bridge unavailable" };
|
||||
@@ -34,5 +53,10 @@ export const useSessionLogBackend = () => {
|
||||
return bridge?.getManualSessionLogStatus?.(payload) ?? { success: false, isLogging: false, error: "Session log bridge unavailable" };
|
||||
}, []);
|
||||
|
||||
return { startManualSessionLog, stopManualSessionLog, getManualSessionLogStatus };
|
||||
return {
|
||||
chooseManualSessionLogPath,
|
||||
startManualSessionLog,
|
||||
stopManualSessionLog,
|
||||
getManualSessionLogStatus,
|
||||
};
|
||||
};
|
||||
@@ -797,11 +797,6 @@ export const useSessionState = ({
|
||||
host: Host,
|
||||
direction: SplitDirection = 'vertical',
|
||||
): string | null => {
|
||||
// Serial hosts use a different session constructor; they currently
|
||||
// only enter workspaces via createSerialSession + drag, so reject
|
||||
// them here to avoid a partially-constructed session.
|
||||
if (host.protocol === 'serial') return null;
|
||||
|
||||
// Cheap early-exit using the ref when the workspace is clearly
|
||||
// absent. The authoritative check lives inside the setWorkspaces
|
||||
// updater below so we also cover the concurrent-close race.
|
||||
|
||||
@@ -105,4 +105,8 @@ test('workspace host creation paths use the host session snapshot factory', () =
|
||||
source,
|
||||
/createWorkspaceHostTerminalSession\(newSessionId,\s*host,\s*workspaceId\)/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/if \(host\.protocol === 'serial'\) return null/,
|
||||
);
|
||||
});
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_RESTORE_PREVIOUS_SESSION,
|
||||
STORAGE_KEY_RESTORE_TERMINAL_CWD,
|
||||
STORAGE_KEY_STARTUP_LANDING,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
|
||||
@@ -75,6 +76,7 @@ import {
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
@@ -121,6 +123,7 @@ import {
|
||||
DEFAULT_SHOW_SFTP_TAB,
|
||||
DEFAULT_SHOW_HOST_TREE_SIDEBAR,
|
||||
DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
DEFAULT_SHOW_TAB_NUMBER_BADGES,
|
||||
DEFAULT_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
|
||||
DEFAULT_SSH_DEEP_LINK_ENABLED,
|
||||
@@ -144,6 +147,10 @@ import {
|
||||
type HostClickBehavior,
|
||||
} from './settingsStateDefaults';
|
||||
import { isHostClickBehavior } from '../../domain/hostClickBehavior';
|
||||
import {
|
||||
resolveStartupLandingSetting,
|
||||
type StartupLanding,
|
||||
} from '../../domain/startupLanding';
|
||||
import { resolveRestorePreviousSessionSetting, resolveRestoreTerminalCwdSetting } from './sessionRestoreSettings';
|
||||
import { sessionRestoreStorage } from './sessionRestoreStorage';
|
||||
import { useSettingsStorageSync } from './settingsStorageSync';
|
||||
@@ -413,6 +420,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
return stored ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS;
|
||||
});
|
||||
const [showTabNumberBadges, setShowTabNumberBadgesState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES);
|
||||
return stored ?? DEFAULT_SHOW_TAB_NUMBER_BADGES;
|
||||
});
|
||||
const [disableTerminalFontZoom, setDisableTerminalFontZoomState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
|
||||
return stored ?? DEFAULT_DISABLE_TERMINAL_FONT_ZOOM;
|
||||
@@ -425,6 +436,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_TERMINAL_CWD);
|
||||
return resolveRestoreTerminalCwdSetting(stored);
|
||||
});
|
||||
const [startupLanding, setStartupLandingState] = useState<StartupLanding>(() => {
|
||||
return resolveStartupLandingSetting(readStoredString(STORAGE_KEY_STARTUP_LANDING));
|
||||
});
|
||||
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
|
||||
return resolveSftpTransferConcurrency(() =>
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
|
||||
@@ -941,12 +955,15 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
);
|
||||
const storedShellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
setShellOnlyTabNumberShortcutsState(storedShellOnlyTabNumberShortcuts ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
const storedShowTabNumberBadges = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES);
|
||||
setShowTabNumberBadgesState(storedShowTabNumberBadges ?? DEFAULT_SHOW_TAB_NUMBER_BADGES);
|
||||
const storedDisableTerminalFontZoom = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
|
||||
setDisableTerminalFontZoomState(storedDisableTerminalFontZoom ?? DEFAULT_DISABLE_TERMINAL_FONT_ZOOM);
|
||||
const storedRestorePreviousSession = localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION);
|
||||
setRestorePreviousSessionState(resolveRestorePreviousSessionSetting(storedRestorePreviousSession));
|
||||
const storedRestoreTerminalCwd = localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_TERMINAL_CWD);
|
||||
setRestoreTerminalCwdState(resolveRestoreTerminalCwdSetting(storedRestoreTerminalCwd));
|
||||
setStartupLandingState(resolveStartupLandingSetting(readStoredString(STORAGE_KEY_STARTUP_LANDING)));
|
||||
|
||||
// Workspace focus style
|
||||
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
|
||||
@@ -1115,6 +1132,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
setStartupLandingState,
|
||||
setSftpTransferConcurrencyState,
|
||||
setSshTransportIdleTtlMsState,
|
||||
});
|
||||
@@ -1145,7 +1163,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
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, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode,
|
||||
@@ -1155,7 +1173,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize: applyIncomingTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
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, setStartupLandingState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
@@ -1321,6 +1339,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
notifySettingsChanged(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setShowTabNumberBadges = useCallback((enabled: boolean) => {
|
||||
setShowTabNumberBadgesState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES, enabled);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setDisableTerminalFontZoom = useCallback((enabled: boolean) => {
|
||||
setDisableTerminalFontZoomState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM, enabled);
|
||||
@@ -1345,6 +1370,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
notifySettingsChanged(STORAGE_KEY_RESTORE_TERMINAL_CWD, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setStartupLanding = useCallback((landing: StartupLanding) => {
|
||||
setStartupLandingState(landing);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_STARTUP_LANDING, landing);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_STARTUP_LANDING, landing);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
// Apply and persist custom CSS
|
||||
useEffect(() => {
|
||||
applyCustomCssToDocument(customCSS);
|
||||
@@ -1746,6 +1778,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
dynamicTabTitleMode: terminalSettings.dynamicTabTitleMode,
|
||||
disableTerminalFontZoom,
|
||||
hotkeyScheme,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
showTabNumberBadges,
|
||||
restoreTerminalCwd,
|
||||
terminalSidePanelAutoOpen,
|
||||
terminalSidePanelAutoOpenTab,
|
||||
@@ -1754,13 +1789,16 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
darkUiThemeId,
|
||||
disableTerminalFontZoom,
|
||||
hostClickBehavior,
|
||||
hotkeyScheme,
|
||||
lightUiThemeId,
|
||||
resolvedTheme,
|
||||
restoreTerminalCwd,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
showHostTreeSidebar,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
showRecentHosts,
|
||||
showSftpTab,
|
||||
showTabNumberBadges,
|
||||
terminalSettings.dynamicTabTitleMode,
|
||||
terminalSidePanelAutoOpen,
|
||||
terminalSidePanelAutoOpenTab,
|
||||
@@ -1935,12 +1973,16 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setTerminalSidePanelAutoOpenTab,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
setShellOnlyTabNumberShortcuts,
|
||||
showTabNumberBadges,
|
||||
setShowTabNumberBadges,
|
||||
disableTerminalFontZoom,
|
||||
setDisableTerminalFontZoom,
|
||||
restorePreviousSession,
|
||||
setRestorePreviousSession,
|
||||
restoreTerminalCwd,
|
||||
setRestoreTerminalCwd,
|
||||
startupLanding,
|
||||
setStartupLanding,
|
||||
sftpTransferConcurrency,
|
||||
setSftpTransferConcurrency,
|
||||
sshTransportIdleTtlMs,
|
||||
@@ -1994,7 +2036,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ import { buildSftpHostCredentials } from "./sftp/useSftpHostCredentials";
|
||||
import { useSftpFileWatch } from "./sftp/useSftpFileWatch";
|
||||
import { useSftpSessionCleanup } from "./sftp/useSftpSessionCleanup";
|
||||
import { useSftpSessionErrors } from "./sftp/useSftpSessionErrors";
|
||||
import { ensureRemoteSftpSession } from "./sftp/ensureRemoteSftpSession";
|
||||
import { ensureRemoteSftpSession, probeSftpSession } from "./sftp/ensureRemoteSftpSession";
|
||||
import { openTransferSftpSession } from "./sftp/dedicatedTransferResume";
|
||||
import {
|
||||
createTransferPoolKeyCache,
|
||||
@@ -316,9 +316,7 @@ export const useSftpState = (
|
||||
// Prefer borrowing the live (or parked) terminal SSH transport so MFA is
|
||||
// not repeated. Transport leases keep the shared conn alive after the
|
||||
// terminal tab closes until the transfer SFTP lease is returned.
|
||||
const sourceSessionId = !host.sftpSudo
|
||||
? resolveTransferSourceSessionId?.(host.id, host)
|
||||
: undefined;
|
||||
const sourceSessionId = resolveTransferSourceSessionId?.(host.id, host);
|
||||
if (sourceSessionId) {
|
||||
try {
|
||||
logger.info(
|
||||
@@ -608,16 +606,7 @@ export const useSftpState = (
|
||||
forceReconnect: ensureOptions?.forceReconnect,
|
||||
releaseConnection,
|
||||
tabId,
|
||||
probeSession: async (sftpId) => {
|
||||
// Lightweight liveness check; any session-error from the bridge
|
||||
// triggers a reconnect in ensureRemoteSftpSession.
|
||||
if (!bridge?.getSftpHomeDir) return true;
|
||||
const result = await bridge.getSftpHomeDir(sftpId);
|
||||
if (result && result.success === false) {
|
||||
throw new Error(result.error || "SFTP session not found");
|
||||
}
|
||||
return true;
|
||||
},
|
||||
probeSession: (sftpId) => probeSftpSession(bridge, sftpId),
|
||||
});
|
||||
},
|
||||
resolveConnectedHost: (tabId) => connectedHostByTabIdRef.current.get(tabId) ?? null,
|
||||
@@ -643,10 +632,11 @@ export const useSftpState = (
|
||||
[resolveTransferConflict, resolveUploadConflict, uploadConflicts],
|
||||
);
|
||||
|
||||
// FileZilla-style: when the browser UI is hidden, soft-close browse SFTP
|
||||
// channels. Defer park while this owner still has unfinished transfers so
|
||||
// pre-lease prep (conflict/stat) cannot race a hard-close of the browse id.
|
||||
// In-flight streams also soft-close via leases; pool handles bulk I/O.
|
||||
// FileZilla-style: when the side panel is closed (not merely showing another
|
||||
// tool), soft-close browse SFTP channels. Defer park while this owner still
|
||||
// has unfinished transfers so pre-lease prep (conflict/stat) cannot race a
|
||||
// hard-close of the browse id. In-flight streams also soft-close via leases;
|
||||
// pool handles bulk I/O.
|
||||
const interactive = options?.interactive !== false;
|
||||
useEffect(() => {
|
||||
const gen = ++browseLifecycleGenRef.current;
|
||||
@@ -703,6 +693,12 @@ export const useSftpState = (
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection || pane.connection.isLocal) continue;
|
||||
if (sftpSessionsRef.current.has(pane.connection.id)) continue;
|
||||
const connectedHost = connectedHostByTabIdRef.current.get(pane.id) ?? null;
|
||||
const vaultHost = hosts.find((host) => host.id === pane.connection?.hostId) ?? null;
|
||||
const targetHost = connectedHost && connectedHost !== "local" ? connectedHost : vaultHost;
|
||||
const resolvedSourceSessionId = targetHost
|
||||
? resolveBrowseSourceSessionId?.(targetHost.id, targetHost)
|
||||
: undefined;
|
||||
try {
|
||||
await ensureRemoteSftpSession({
|
||||
side,
|
||||
@@ -712,16 +708,10 @@ export const useSftpState = (
|
||||
connect,
|
||||
resolveConnectedHost: (id) => connectedHostByTabIdRef.current.get(id) ?? null,
|
||||
resolveHostById: (hostId) => hosts.find((host) => host.id === hostId) ?? null,
|
||||
resolveSourceSessionId: resolveBrowseSourceSessionId,
|
||||
probeSession: async (sftpId) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getSftpHomeDir) return true;
|
||||
const result = await bridge.getSftpHomeDir(sftpId);
|
||||
if (result && result.success === false) {
|
||||
throw new Error(result.error || "SFTP session not found");
|
||||
}
|
||||
return true;
|
||||
},
|
||||
resolveSourceSessionId: resolvedSourceSessionId
|
||||
? () => resolvedSourceSessionId
|
||||
: resolveBrowseSourceSessionId,
|
||||
probeSession: (sftpId) => probeSftpSession(netcattyBridge.get(), sftpId),
|
||||
releaseConnection,
|
||||
});
|
||||
logger.info(`[SFTP] Restored browse session on ${side}`);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
getShortcutModifierRequirements,
|
||||
isShortcutModifierHeld,
|
||||
shouldReleaseShortcutModifier,
|
||||
type ShortcutModifierEvent,
|
||||
} from './useShortcutModifierHeld.ts';
|
||||
|
||||
const event = (overrides: Partial<ShortcutModifierEvent> = {}): ShortcutModifierEvent => ({
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('shortcut modifier requirements follow the effective Mac or PC binding', () => {
|
||||
const mac = getShortcutModifierRequirements('⌘ + [1...9]', 'mac');
|
||||
assert.deepEqual(mac, { metaKey: true, ctrlKey: false, altKey: false, shiftKey: false });
|
||||
assert.equal(isShortcutModifierHeld(event({ metaKey: true }), mac), true);
|
||||
assert.equal(isShortcutModifierHeld(event({ ctrlKey: true }), mac), false);
|
||||
|
||||
const pc = getShortcutModifierRequirements('Alt + Shift + [1...9]', 'pc');
|
||||
assert.deepEqual(pc, { metaKey: false, ctrlKey: false, altKey: true, shiftKey: true });
|
||||
assert.equal(isShortcutModifierHeld(event({ altKey: true, shiftKey: true }), pc), true);
|
||||
assert.equal(isShortcutModifierHeld(event({ altKey: true }), pc), false);
|
||||
assert.equal(isShortcutModifierHeld(event({ altKey: true, metaKey: true, shiftKey: true }), pc), false);
|
||||
});
|
||||
|
||||
test('shortcut number preview is unavailable for disabled, non-range, or modifier-free bindings', () => {
|
||||
assert.equal(getShortcutModifierRequirements('Disabled', 'mac'), null);
|
||||
assert.equal(getShortcutModifierRequirements('⌘ + K', 'mac'), null);
|
||||
assert.equal(getShortcutModifierRequirements('[1...9]', 'mac'), null);
|
||||
assert.equal(getShortcutModifierRequirements('⌘ + [1...9]', 'disabled'), null);
|
||||
});
|
||||
|
||||
test('shortcut modifier state clears when the required combination stops matching', () => {
|
||||
const requirements = getShortcutModifierRequirements('⌘ + [1...9]', 'mac');
|
||||
assert.equal(shouldReleaseShortcutModifier(event({ metaKey: true }), requirements), false);
|
||||
assert.equal(shouldReleaseShortcutModifier(event(), requirements), true);
|
||||
});
|
||||
|
||||
test('shortcut modifier held state tracks exact combo when extras are pressed or released', () => {
|
||||
const requirements = getShortcutModifierRequirements('⌘ + [1...9]', 'mac');
|
||||
// Already matching, then an extra modifier: preview must clear.
|
||||
assert.equal(isShortcutModifierHeld(event({ metaKey: true }), requirements), true);
|
||||
assert.equal(
|
||||
isShortcutModifierHeld(event({ metaKey: true, shiftKey: true }), requirements),
|
||||
false,
|
||||
);
|
||||
// Extra pressed first, then released while required modifiers remain: preview re-arms.
|
||||
assert.equal(
|
||||
isShortcutModifierHeld(event({ metaKey: true, altKey: true }), requirements),
|
||||
false,
|
||||
);
|
||||
assert.equal(isShortcutModifierHeld(event({ metaKey: true }), requirements), true);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { parseKeyCombo, type HotkeyScheme } from '../../domain/models/keyBindings';
|
||||
|
||||
export type ShortcutModifierEvent = Pick<KeyboardEvent, 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'>;
|
||||
|
||||
export type ShortcutModifierRequirements = Readonly<ShortcutModifierEvent>;
|
||||
|
||||
const MODIFIER_TOKENS = {
|
||||
mac: {
|
||||
'⌘': 'metaKey',
|
||||
'⌃': 'ctrlKey',
|
||||
'⌥': 'altKey',
|
||||
Shift: 'shiftKey',
|
||||
},
|
||||
pc: {
|
||||
Ctrl: 'ctrlKey',
|
||||
Alt: 'altKey',
|
||||
Shift: 'shiftKey',
|
||||
Win: 'metaKey',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Resolve the modifier combination for the effective [1...9] binding.
|
||||
* A non-range or disabled binding cannot reveal tab numbers because the
|
||||
* dispatcher does not use it for the number-switch action.
|
||||
*/
|
||||
export function getShortcutModifierRequirements(
|
||||
keyBinding: string | null,
|
||||
scheme: HotkeyScheme,
|
||||
): ShortcutModifierRequirements | null {
|
||||
if (scheme === 'disabled' || !keyBinding || !keyBinding.includes('[1...9]')) return null;
|
||||
const parsed = parseKeyCombo(keyBinding);
|
||||
if (!parsed || parsed.modifiers.length === 0) return null;
|
||||
|
||||
const requirements: Record<keyof ShortcutModifierEvent, boolean> = {
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
};
|
||||
const tokenMap = MODIFIER_TOKENS[scheme];
|
||||
for (const modifier of parsed.modifiers) {
|
||||
const flag = tokenMap[modifier as keyof typeof tokenMap];
|
||||
if (!flag) return null;
|
||||
requirements[flag] = true;
|
||||
}
|
||||
|
||||
return requirements;
|
||||
}
|
||||
|
||||
/** Whether a keyboard event has exactly the modifiers required by the binding. */
|
||||
export function isShortcutModifierHeld(
|
||||
event: ShortcutModifierEvent,
|
||||
requirements: ShortcutModifierRequirements | null,
|
||||
): boolean {
|
||||
if (!requirements) return false;
|
||||
return event.metaKey === requirements.metaKey
|
||||
&& event.ctrlKey === requirements.ctrlKey
|
||||
&& event.altKey === requirements.altKey
|
||||
&& event.shiftKey === requirements.shiftKey;
|
||||
}
|
||||
|
||||
/** Whether a keyup event means the required modifier combination is no longer held. */
|
||||
export function shouldReleaseShortcutModifier(
|
||||
event: ShortcutModifierEvent,
|
||||
requirements: ShortcutModifierRequirements | null,
|
||||
): boolean {
|
||||
return !isShortcutModifierHeld(event, requirements);
|
||||
}
|
||||
|
||||
export function useShortcutModifierHeld(
|
||||
keyBinding: string | null,
|
||||
scheme: HotkeyScheme,
|
||||
): boolean {
|
||||
const [isHeld, setIsHeld] = useState(false);
|
||||
const requirements = useMemo(
|
||||
() => getShortcutModifierRequirements(keyBinding, scheme),
|
||||
[keyBinding, scheme],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsHeld(false);
|
||||
if (!requirements) return;
|
||||
|
||||
// Sync on every modifier transition so extras clear the preview and
|
||||
// releasing an extra modifier can re-arm when the exact combo remains.
|
||||
const syncHeldState = (event: KeyboardEvent) => {
|
||||
setIsHeld(isShortcutModifierHeld(event, requirements));
|
||||
};
|
||||
const clearHeldState = () => setIsHeld(false);
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
clearHeldState();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', syncHeldState, true);
|
||||
window.addEventListener('keyup', syncHeldState, true);
|
||||
window.addEventListener('blur', clearHeldState);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', syncHeldState, true);
|
||||
window.removeEventListener('keyup', syncHeldState, true);
|
||||
window.removeEventListener('blur', clearHeldState);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [requirements]);
|
||||
|
||||
return isHeld;
|
||||
}
|
||||
@@ -59,6 +59,7 @@ export function useTerminalAiContexts({
|
||||
.map((sessionId) => sessionById.get(sessionId)?.hostId)
|
||||
.filter((hostId): hostId is string => !!hostId),
|
||||
scopeLabel: workspace.title,
|
||||
focusedSessionId: workspace.focusedSessionId,
|
||||
terminalSessions: sessionIds.map((sessionId) =>
|
||||
buildAITerminalSessionInfo(
|
||||
sessionById.get(sessionId),
|
||||
|
||||
@@ -513,7 +513,10 @@ export const useTerminalBackend = () => {
|
||||
return bridge?.onZmodemEvent?.(sessionId, cb) ?? (() => {});
|
||||
}, []);
|
||||
|
||||
const getSessionPwd = useCallback(async (sessionId: string, options?: { allowHomeFallback?: boolean }) => {
|
||||
const getSessionPwd = useCallback(async (
|
||||
sessionId: string,
|
||||
options?: { allowHomeFallback?: boolean; allowLoginShellFallback?: boolean },
|
||||
) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getSessionPwd) return { success: false, error: 'getSessionPwd unavailable' };
|
||||
return bridge.getSessionPwd(sessionId, options);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
installTerminalKeyboardFocusTracking,
|
||||
isTerminalKeyboardTarget,
|
||||
} from "./terminalKeyboardFocus";
|
||||
|
||||
const targetWithClosest = (matches: boolean) => ({
|
||||
closest: (selector: string) => {
|
||||
assert.match(selector, /\.xterm/);
|
||||
return matches ? {} : null;
|
||||
},
|
||||
}) as unknown as EventTarget;
|
||||
|
||||
test("terminal keyboard target detection recognizes xterm descendants", () => {
|
||||
assert.equal(isTerminalKeyboardTarget(targetWithClosest(true)), true);
|
||||
assert.equal(isTerminalKeyboardTarget(targetWithClosest(false)), false);
|
||||
assert.equal(isTerminalKeyboardTarget(null), false);
|
||||
});
|
||||
|
||||
test("terminal keyboard focus tracking publishes deduplicated focus transitions and cleans up", async () => {
|
||||
const listeners = new Map<string, (event?: Event) => void>();
|
||||
const removed: string[] = [];
|
||||
const body = targetWithClosest(false);
|
||||
const terminal = targetWithClosest(true);
|
||||
const documentRef = {
|
||||
activeElement: body as unknown as Element,
|
||||
addEventListener: (type: string, listener: (event?: Event) => void) => {
|
||||
listeners.set(type, listener);
|
||||
},
|
||||
removeEventListener: (type: string) => {
|
||||
removed.push(type);
|
||||
},
|
||||
} as unknown as Document;
|
||||
const windowRef = {
|
||||
addEventListener: (type: string, listener: (event?: Event) => void) => {
|
||||
listeners.set(`window:${type}`, listener);
|
||||
},
|
||||
removeEventListener: (type: string) => {
|
||||
removed.push(`window:${type}`);
|
||||
},
|
||||
} as unknown as Window;
|
||||
const changes: boolean[] = [];
|
||||
const cleanup = installTerminalKeyboardFocusTracking(documentRef, windowRef, (focused) => {
|
||||
if (changes.at(-1) !== focused) changes.push(focused);
|
||||
});
|
||||
|
||||
assert.deepEqual(changes, [false]);
|
||||
|
||||
listeners.get("pointerdown")?.({ target: terminal } as unknown as Event);
|
||||
listeners.get("focusin")?.({ target: terminal } as unknown as Event);
|
||||
assert.deepEqual(changes, [false, true]);
|
||||
|
||||
(documentRef as unknown as { activeElement: Element }).activeElement = terminal as unknown as Element;
|
||||
listeners.get("pointerdown")?.({ target: body } as unknown as Event);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(changes, [false, true]);
|
||||
|
||||
(documentRef as unknown as { activeElement: Element }).activeElement = body as unknown as Element;
|
||||
listeners.get("focusout")?.();
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(changes, [false, true, false]);
|
||||
|
||||
listeners.get("window:blur")?.();
|
||||
assert.deepEqual(changes, [false, true, false]);
|
||||
|
||||
(documentRef as unknown as { activeElement: Element }).activeElement = terminal as unknown as Element;
|
||||
listeners.get("window:focus")?.();
|
||||
assert.deepEqual(changes, [false, true, false, true]);
|
||||
|
||||
cleanup();
|
||||
assert.deepEqual(removed.sort(), ["focusin", "focusout", "pointerdown", "window:blur", "window:focus"]);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect } from "react";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
import { installTerminalKeyboardFocusTracking } from "./terminalKeyboardFocus";
|
||||
|
||||
export function useTerminalKeyboardFocus(enabled = true): void {
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.setTerminalKeyboardFocus) return undefined;
|
||||
|
||||
let lastPublished: boolean | undefined;
|
||||
const publish = (focused: boolean) => {
|
||||
if (focused === lastPublished) return;
|
||||
lastPublished = focused;
|
||||
try {
|
||||
bridge.setTerminalKeyboardFocus?.(focused);
|
||||
} catch {
|
||||
// Browser preview or a disposed Electron bridge.
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupTracking = installTerminalKeyboardFocusTracking(
|
||||
document,
|
||||
window,
|
||||
publish,
|
||||
);
|
||||
|
||||
return () => {
|
||||
cleanupTracking();
|
||||
publish(false);
|
||||
};
|
||||
}, [enabled]);
|
||||
}
|
||||
@@ -123,6 +123,7 @@ export function useTerminalSidePanelLayoutState() {
|
||||
setSidePanelOpenTabs,
|
||||
sidePanelOpenTabsRef,
|
||||
sidePanelLayouts,
|
||||
setSidePanelLayouts,
|
||||
sidePanelLayoutsRef,
|
||||
focusPane,
|
||||
splitPane,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
resolveVaultAgentEffectiveHost,
|
||||
resolveVaultAgentNotes,
|
||||
} from './useVaultAgentBridge';
|
||||
import type { Host } from '../../domain/models';
|
||||
import { publishNotesSnapshot } from './notesStore';
|
||||
|
||||
type Snapshot = Parameters<typeof haveSameVaultAgentSnapshot>[0];
|
||||
@@ -28,10 +29,10 @@ describe('haveSameVaultAgentSnapshot', () => {
|
||||
|
||||
describe('resolveVaultAgentEffectiveHost', () => {
|
||||
it('uses the latest snapshotted group defaults', () => {
|
||||
const host = {
|
||||
const host: Host = {
|
||||
id: 'host-1', label: 'Host', hostname: 'host.test', username: 'root',
|
||||
group: 'production', tags: [], os: 'linux',
|
||||
} as const;
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
resolveVaultAgentEffectiveHost(host, [{ path: 'production', protocol: 'telnet' }], []).protocol,
|
||||
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
resolveDefaultKeyPassphraseAliases,
|
||||
} from '../defaultKeyPassphrases';
|
||||
import { getNotesActions, getNotesSnapshot, subscribeNotes } from './notesStore';
|
||||
import type {
|
||||
VaultGroupMutationResult,
|
||||
VaultGroupMutationState,
|
||||
} from '../../domain/vaultGroupMutation';
|
||||
|
||||
export interface UseVaultAgentBridgeInput {
|
||||
hosts: Host[];
|
||||
@@ -29,12 +33,17 @@ export interface UseVaultAgentBridgeInput {
|
||||
terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>;
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
updateKeys: (keys: SSHKey[]) => Promise<unknown> | unknown;
|
||||
updateSnippets: (snippets: Snippet[]) => void;
|
||||
updateSnippets: (
|
||||
snippets: Snippet[] | ((current: Snippet[]) => Snippet[]),
|
||||
) => void;
|
||||
customGroups: string[];
|
||||
updateCustomGroups: (groups: string[]) => void;
|
||||
groupConfigs: GroupConfig[];
|
||||
updateGroupConfigs: (configs: GroupConfig[]) => void;
|
||||
updateManagedSources: (sources: ManagedSource[]) => void;
|
||||
commitVaultGroupMutation: (
|
||||
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
|
||||
) => Promise<VaultGroupMutationResult | { ok: false; superseded: true }>;
|
||||
updatePortForwardingRules: (rules: PortForwardingRule[]) => void;
|
||||
/** Optional override; defaults to notesStore so App need not subscribe to notes. */
|
||||
notes?: VaultNote[];
|
||||
@@ -185,6 +194,17 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
|
||||
vaultSnapshotRef.current.hosts = hosts;
|
||||
current.updateHosts(hosts);
|
||||
},
|
||||
commitVaultGroupMutation: async (mutate) => {
|
||||
const result = await current.commitVaultGroupMutation(mutate);
|
||||
if (result.ok) {
|
||||
vaultSnapshotRef.current.customGroups = result.state.groups;
|
||||
vaultSnapshotRef.current.groupConfigs = result.state.configs;
|
||||
vaultSnapshotRef.current.hosts = result.state.hosts;
|
||||
vaultSnapshotRef.current.managedSources = result.state.managedSources;
|
||||
vaultSnapshotRef.current.snippets = result.state.snippets;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
saveKeyPassphrase: (keyPath, passphrase) => rememberKeyPassphrase({
|
||||
keyPath,
|
||||
passphrase,
|
||||
@@ -222,9 +242,11 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
|
||||
vaultSnapshotRef.current.notes = notes;
|
||||
applyUpdateNotes(notes);
|
||||
},
|
||||
updateSnippets: (nextSnippets) => {
|
||||
vaultSnapshotRef.current.snippets = nextSnippets;
|
||||
current.updateSnippets(nextSnippets);
|
||||
updateSnippets: (snippetUpdate) => {
|
||||
vaultSnapshotRef.current.snippets = typeof snippetUpdate === 'function'
|
||||
? snippetUpdate(vaultSnapshotRef.current.snippets)
|
||||
: snippetUpdate;
|
||||
current.updateSnippets(snippetUpdate);
|
||||
},
|
||||
startTunnel: current.startTunnel,
|
||||
stopTunnel: current.stopTunnel,
|
||||
|
||||
@@ -31,19 +31,16 @@ test("group deletion removes saved settings in the same Vault transaction", asyn
|
||||
managedSources: storedSources,
|
||||
onReadPersistedHosts: async () => [],
|
||||
onReadPersistedManagedSources: () => storedSources,
|
||||
onCommitVaultImportTransaction: async (
|
||||
_hosts,
|
||||
updateGroups,
|
||||
updateSources,
|
||||
updateGroupConfigs,
|
||||
) => {
|
||||
committedGroupConfigs = updateGroupConfigs?.(storedGroupConfigs) ?? storedGroupConfigs;
|
||||
return {
|
||||
status: "persisted",
|
||||
groups: updateGroups(storedGroups),
|
||||
sources: updateSources(storedSources),
|
||||
groupConfigs: committedGroupConfigs,
|
||||
};
|
||||
onCommitVaultGroupMutation: async (mutate) => {
|
||||
const result = mutate({
|
||||
groups: storedGroups,
|
||||
configs: storedGroupConfigs,
|
||||
hosts: [],
|
||||
managedSources: storedSources,
|
||||
snippets: [],
|
||||
});
|
||||
if (result.ok) committedGroupConfigs = result.state.configs;
|
||||
return result;
|
||||
},
|
||||
});
|
||||
return null;
|
||||
@@ -102,14 +99,9 @@ test("group deletion restores managed files when the persisted Vault cannot be r
|
||||
onClearAndRemoveManagedSources: async () => async () => {
|
||||
restoreCount += 1;
|
||||
},
|
||||
onCommitVaultImportTransaction: async () => {
|
||||
onCommitVaultGroupMutation: async () => {
|
||||
commitCount += 1;
|
||||
return {
|
||||
status: "persisted",
|
||||
groups: [],
|
||||
sources: [],
|
||||
groupConfigs: [],
|
||||
};
|
||||
throw new Error("unexpected commit");
|
||||
},
|
||||
});
|
||||
return null;
|
||||
@@ -187,21 +179,15 @@ test("group deletion retries file clearing when another managed source enters th
|
||||
restoreCount += 1;
|
||||
};
|
||||
},
|
||||
onCommitVaultImportTransaction: async (
|
||||
_hosts,
|
||||
updateGroups,
|
||||
updateSources,
|
||||
updateGroupConfigs,
|
||||
expectedHosts,
|
||||
) => {
|
||||
onCommitVaultGroupMutation: async (mutate) => {
|
||||
commitCount += 1;
|
||||
assert.deepEqual(expectedHosts, []);
|
||||
return {
|
||||
status: "persisted",
|
||||
groups: updateGroups(["Production"]),
|
||||
sources: updateSources([firstSource, concurrentSource]),
|
||||
groupConfigs: updateGroupConfigs?.([]) ?? [],
|
||||
};
|
||||
return mutate({
|
||||
groups: ["Production"],
|
||||
configs: [],
|
||||
hosts: [],
|
||||
managedSources: [firstSource, concurrentSource],
|
||||
snippets: [],
|
||||
});
|
||||
},
|
||||
});
|
||||
return null;
|
||||
@@ -228,3 +214,67 @@ test("group deletion retries file clearing when another managed source enters th
|
||||
assert.equal(restoreCount, 1);
|
||||
assert.equal(commitCount, 1);
|
||||
});
|
||||
|
||||
test("group deletion restores managed files and does not report success when commit fails", async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const source: ManagedSource = {
|
||||
id: "managed-production",
|
||||
type: "ssh_config",
|
||||
filePath: "/tmp/managed-production.conf",
|
||||
groupName: "Production",
|
||||
lastSyncedAt: 1,
|
||||
};
|
||||
let deleteGroups: ((paths: Iterable<string>) => Promise<void>) | undefined;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
let restoreCount = 0;
|
||||
let reportedCount = 0;
|
||||
|
||||
const Probe = () => {
|
||||
deleteGroups = useVaultGroupDeletion({
|
||||
customGroups: ["Production"],
|
||||
hosts: [],
|
||||
groupConfigs: [],
|
||||
managedSources: [source],
|
||||
onReadPersistedHosts: async () => [],
|
||||
onReadPersistedManagedSources: () => [source],
|
||||
onClearAndRemoveManagedSources: async () => async () => {
|
||||
restoreCount += 1;
|
||||
},
|
||||
onCommitVaultGroupMutation: async () => {
|
||||
throw new Error("Vault quota exhausted");
|
||||
},
|
||||
onDeletedPaths: () => {
|
||||
reportedCount += 1;
|
||||
},
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
let deletionError: unknown;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
try {
|
||||
await act(async () => {
|
||||
await deleteGroups?.(["Production"]);
|
||||
});
|
||||
} catch (error) {
|
||||
deletionError = error;
|
||||
}
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
|
||||
assert.match(String(deletionError), /quota exhausted/i);
|
||||
assert.equal(restoreCount, 1);
|
||||
assert.equal(reportedCount, 0);
|
||||
});
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
import type { GroupConfig, Host, ManagedSource } from "../../domain/models";
|
||||
import { removeSnippetTargetGroupPaths } from "../../domain/hostGroupPathMutations";
|
||||
import { buildVaultGroupDeletion } from "../../domain/vaultGroupDeletion";
|
||||
import type {
|
||||
VaultGroupMutationResult,
|
||||
VaultGroupMutationState,
|
||||
} from "../../domain/vaultGroupMutation";
|
||||
import {
|
||||
type VaultLockHandle,
|
||||
withVaultImportLock,
|
||||
@@ -26,7 +31,7 @@ export function useVaultGroupDeletion({
|
||||
managedSources,
|
||||
onReadPersistedHosts,
|
||||
onReadPersistedManagedSources,
|
||||
onCommitVaultImportTransaction,
|
||||
onCommitVaultGroupMutation,
|
||||
onClearAndRemoveManagedSource,
|
||||
onClearAndRemoveManagedSources,
|
||||
onDeletedPaths,
|
||||
@@ -37,22 +42,10 @@ export function useVaultGroupDeletion({
|
||||
managedSources: ManagedSource[];
|
||||
onReadPersistedHosts: () => Promise<Host[]>;
|
||||
onReadPersistedManagedSources: () => ManagedSource[];
|
||||
onCommitVaultImportTransaction: (
|
||||
hosts: Host[],
|
||||
updateGroups: (current: string[]) => string[],
|
||||
updateSources: (current: ManagedSource[]) => ManagedSource[],
|
||||
updateGroupConfigs?: (current: GroupConfig[]) => GroupConfig[],
|
||||
expectedHosts?: Host[],
|
||||
onCommitVaultGroupMutation: (
|
||||
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
|
||||
lock?: VaultLockHandle | null,
|
||||
) => Promise<
|
||||
| {
|
||||
status: "persisted";
|
||||
groups: string[];
|
||||
sources: ManagedSource[];
|
||||
groupConfigs: GroupConfig[];
|
||||
}
|
||||
| { status: "superseded" }
|
||||
>;
|
||||
) => Promise<VaultGroupMutationResult | { ok: false; superseded: true }>;
|
||||
onClearAndRemoveManagedSource?: (source: ManagedSource) => Promise<() => Promise<void>>;
|
||||
onClearAndRemoveManagedSources?: (sources: ManagedSource[]) => Promise<() => Promise<void>>;
|
||||
onDeletedPaths?: (selectedRoots: string[]) => void;
|
||||
@@ -120,29 +113,16 @@ export function useVaultGroupDeletion({
|
||||
}
|
||||
deletion = refreshedDeletion;
|
||||
|
||||
const expectedHosts = latestRef.current.hosts;
|
||||
const expectedSources = deletion.sourcesToRemove;
|
||||
const nextHosts = deletion.hosts.filter(
|
||||
(host) => !additionallyDeletedHostIds.has(host.id),
|
||||
);
|
||||
const transaction = await onCommitVaultImportTransaction(
|
||||
nextHosts,
|
||||
(currentGroups) => buildVaultGroupDeletion({
|
||||
selectedPaths,
|
||||
deleteHosts,
|
||||
customGroups: currentGroups,
|
||||
hosts: [],
|
||||
groupConfigs: [],
|
||||
managedSources: [],
|
||||
}).customGroups,
|
||||
(currentSources) => {
|
||||
const transaction = await onCommitVaultGroupMutation(
|
||||
(current) => {
|
||||
const currentDeletion = buildVaultGroupDeletion({
|
||||
selectedPaths,
|
||||
deleteHosts,
|
||||
customGroups: [],
|
||||
hosts: [],
|
||||
groupConfigs: [],
|
||||
managedSources: currentSources,
|
||||
customGroups: current.groups,
|
||||
hosts: current.hosts,
|
||||
groupConfigs: current.configs,
|
||||
managedSources: current.managedSources,
|
||||
});
|
||||
if (!managedSourceSnapshotsMatch(
|
||||
expectedSources,
|
||||
@@ -150,29 +130,40 @@ export function useVaultGroupDeletion({
|
||||
)) {
|
||||
throw RETRY_VAULT_GROUP_DELETION;
|
||||
}
|
||||
const removedIds = new Set(expectedSources.map((source) => source.id));
|
||||
return currentSources.filter((source) => !removedIds.has(source.id));
|
||||
return {
|
||||
ok: true,
|
||||
state: {
|
||||
groups: currentDeletion.customGroups,
|
||||
hosts: currentDeletion.hosts.filter(
|
||||
(host) => !additionallyDeletedHostIds.has(host.id),
|
||||
),
|
||||
configs: currentDeletion.groupConfigs,
|
||||
managedSources: current.managedSources.filter(
|
||||
(source) => !currentDeletion.sourcesToRemove.some(
|
||||
(removed) => removed.id === source.id,
|
||||
),
|
||||
),
|
||||
snippets: removeSnippetTargetGroupPaths(
|
||||
current.snippets,
|
||||
currentDeletion.selectedRoots,
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
(currentGroupConfigs) => buildVaultGroupDeletion({
|
||||
selectedPaths,
|
||||
deleteHosts,
|
||||
customGroups: [],
|
||||
hosts: [],
|
||||
groupConfigs: currentGroupConfigs,
|
||||
managedSources: [],
|
||||
}).groupConfigs,
|
||||
expectedHosts,
|
||||
lock,
|
||||
);
|
||||
if (transaction.status === "superseded") {
|
||||
// Release the lock so concurrent host saves can finish before retry.
|
||||
if (!transaction.ok && "superseded" in transaction) {
|
||||
// Release the lock so concurrent saves can finish before retry.
|
||||
return { status: "retry" as const, restoreManagedFiles };
|
||||
}
|
||||
if ("error" in transaction) {
|
||||
throw new Error(transaction.error);
|
||||
}
|
||||
latestRef.current = {
|
||||
customGroups: transaction.groups,
|
||||
hosts: nextHosts,
|
||||
groupConfigs: transaction.groupConfigs,
|
||||
managedSources: transaction.sources,
|
||||
customGroups: transaction.state.groups,
|
||||
hosts: transaction.state.hosts,
|
||||
groupConfigs: transaction.state.configs,
|
||||
managedSources: transaction.state.managedSources,
|
||||
};
|
||||
return {
|
||||
status: "done" as const,
|
||||
@@ -205,7 +196,7 @@ export function useVaultGroupDeletion({
|
||||
}, [
|
||||
onClearAndRemoveManagedSource,
|
||||
onClearAndRemoveManagedSources,
|
||||
onCommitVaultImportTransaction,
|
||||
onCommitVaultGroupMutation,
|
||||
onDeletedPaths,
|
||||
onReadPersistedHosts,
|
||||
onReadPersistedManagedSources,
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const source = readFileSync(new URL("./useVaultState.ts", import.meta.url), "utf8");
|
||||
|
||||
test("deleteSelectedSnippets merges into persisted vault under the shared lock", () => {
|
||||
assert.match(source, /const deleteSelectedSnippets = useCallback\(async/);
|
||||
assert.match(
|
||||
source,
|
||||
/deleteSelectedSnippets[\s\S]*withVaultImportLock\("vault"/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/deleteSelectedSnippetsFromVault\(\s*latestSnippets\s*,\s*latestHosts\s*,\s*selectedSnippetIds,?\s*\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/deleteSelectedSnippets[\s\S]*commitPluginImporterTransaction\(localStorageAdapter, \[[\s\S]*STORAGE_KEY_HOSTS[\s\S]*STORAGE_KEY_SNIPPETS/,
|
||||
);
|
||||
// Persistence rejection must be caught: callers void the promise after the
|
||||
// confirm dialog closes, so an uncaught throw would leave no retry feedback.
|
||||
assert.match(
|
||||
source,
|
||||
/deleteSelectedSnippets[\s\S]*try \{\s*commitPluginImporterTransaction[\s\S]*catch \{[\s\S]*notify\.error\([\s\S]*Snippets could not be deleted/,
|
||||
);
|
||||
// Must not rebuild hosts from a per-window in-memory snapshot (popup race).
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/deleteSelectedSnippetsFromVault\(\s*snippetsRef\.current\s*,\s*hostsRef\.current/,
|
||||
);
|
||||
// Paired writes must not publish in-memory state if only one key lands.
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/deleteSelectedSnippets[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_HOSTS, encryptedHosts\)[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS/,
|
||||
);
|
||||
});
|
||||
|
||||
test("updateHosts prunes stale snippet bindings under the vault lock", () => {
|
||||
// Cross-window bulk-delete can land while a host writer is queued with a
|
||||
// pre-delete encrypted blob. Re-check the live snippet catalog under the lock
|
||||
// before persisting so login/connect bindings are not restored.
|
||||
assert.match(
|
||||
source,
|
||||
/commitEncryptedHostsUnderVaultLock[\s\S]*pruneHostsStaleSnippetBindings\(hostsToPersist, latestSnippets\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const writePromise = encryptPromise\.then\(async \(enc\) => \{[\s\S]*return commitEncryptedHostsUnderVaultLock\(ver, cleaned, enc\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("importData replaces snippets instead of additive-rebasing", () => {
|
||||
// Sync restore / backup import must match the selected snapshot. Additive
|
||||
// rebase would keep concurrent disk-only ids and report a successful restore
|
||||
// that still contains snippets absent from the imported payload.
|
||||
assert.match(
|
||||
source,
|
||||
/updateSnippets\(payload\.snippets,\s*\{\s*replace:\s*true\s*\}\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("updateSnippets disk writes take the shared vault lock", () => {
|
||||
// Unlocked snippet saves can interleave with bulk-delete's journaled commit
|
||||
// and discard concurrent edits. Ordinary writers must queue on the same lock
|
||||
// and be visible to waitForPendingVaultWrites.
|
||||
assert.match(
|
||||
source,
|
||||
/const updateSnippets = useCallback\(\([\s\S]*withVaultImportLock\("vault", async \(\) => \{[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, rebased\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/snippetsWritePendingRef\.current = writePromise/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/waitForPendingVaultWrites[\s\S]*snippetsWritePendingRef\.current/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/const updateSnippets = useCallback\(\([\s\S]*?\) => \{[\s\S]*setSnippets\(cleaned\);\s*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, cleaned\);\s*\}, \[\]\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test("updateSnippets rebases onto the latest persisted snapshot under the lock", () => {
|
||||
// Web Locks serialize writers but do not merge snapshots. A popup bulk-delete
|
||||
// that lands while a main-window save is queued must not be resurrected.
|
||||
assert.match(
|
||||
source,
|
||||
/rebaseSnippetVaultWrite\(\{\s*base,\s*ours: cleaned,\s*theirs:/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/snippetsWriteOwnerRef\.current !== ver/,
|
||||
);
|
||||
});
|
||||
|
||||
test("updateSnippets functional updates derive from the latest in-memory snapshot", () => {
|
||||
// Group deletion can wait for managed-source cleanup. A script created while
|
||||
// it waits must still be present when the deletion callback removes the old
|
||||
// group path, so updater callbacks must not derive from the initiating render.
|
||||
assert.match(
|
||||
source,
|
||||
/data: Snippet\[\] \| \(\(current: Snippet\[\]\) => Snippet\[\]\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const updater = typeof data === "function" \? data : null;[\s\S]*const current = snippetsRef\.current;[\s\S]*typeof data === "function" \? data\(current\) : data/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/updater\(rebaseSnippetVaultWrite\(\{\s*base,\s*ours: current,\s*theirs: latestPersisted,\s*\}\)\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("clearVaultData replaces snippets without additive rebase", () => {
|
||||
// Clear All Local Data must not preserve concurrent disk-only snippet adds
|
||||
// that rebaseSnippetVaultWrite would otherwise keep.
|
||||
assert.match(
|
||||
source,
|
||||
/updateSnippets\(\[\],\s*\{\s*replace:\s*true\s*\}\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const replace =\s*options\?\.replace === true \|\| snippetsWriteReplaceRef\.current/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/replace\s*\?\s*cleaned\s*:\s*updater\s*\?\s*updater\(rebaseSnippetVaultWrite/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(replace\) \{\s*\/\/ Restore\/import\/clear[\s\S]*snippetsWriteBaseRef\.current = null/,
|
||||
);
|
||||
});
|
||||
|
||||
test("updateSnippets preserves replace mode across superseded local saves", () => {
|
||||
// A clear/restore queued behind the vault lock must not lose replacement
|
||||
// intent when a later create/edit becomes the write owner — otherwise that
|
||||
// save additively rebases against the stale disk catalog and resurrects
|
||||
// every pre-replacement snippet.
|
||||
assert.match(
|
||||
source,
|
||||
/const snippetsWriteReplaceRef = useRef\(false\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(options\?\.replace === true\) \{\s*snippetsWriteReplaceRef\.current = true;\s*\}/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/snippetsWriteBaseRef\.current = null;\s*snippetsWriteReplaceRef\.current = false;/,
|
||||
);
|
||||
});
|
||||
|
||||
test("updateSnippets keeps the persisted rebase ancestor across superseded saves", () => {
|
||||
// Two queued local saves must not let the second treat the first save's
|
||||
// optimistic in-memory array as base — that array never hit disk, so an add
|
||||
// would look like a concurrent delete and be dropped.
|
||||
assert.match(
|
||||
source,
|
||||
/const snippetsWriteBaseRef = useRef<Snippet\[\] \| null>\(null\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const current = snippetsRef\.current;\s*const base = snippetsWriteBaseRef\.current \?\? current/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(snippetsWriteBaseRef\.current === null\) \{\s*snippetsWriteBaseRef\.current = base;\s*\}/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const persisted = localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, rebased\)/,
|
||||
);
|
||||
// Quota failure must keep the ancestor; clearing it would drop a local add on
|
||||
// the next save after space is freed.
|
||||
assert.match(
|
||||
source,
|
||||
/if \(!persisted\) \{[\s\S]*return "failed" as const;[\s\S]*\}\s*\/\/ Disk caught up[\s\S]*snippetsWriteBaseRef\.current = null/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(!persisted\) \{[\s\S]*Snippets could not be saved/,
|
||||
);
|
||||
});
|
||||
|
||||
test("snippet storage events keep queued local edits and their rebase ancestor", () => {
|
||||
// A cross-window write must not clear snippetsWriteBaseRef / replace optimistic
|
||||
// state while a local owner is still queued — the next edit would supersede
|
||||
// that owner from a remote-only snapshot and drop the first local mutation.
|
||||
assert.match(
|
||||
source,
|
||||
/if \(key === STORAGE_KEY_SNIPPETS\) \{[\s\S]*const pendingBase = snippetsWriteBaseRef\.current;\s*if \(pendingBase !== null\) \{[\s\S]*rebaseSnippetVaultWrite\(\{\s*base: pendingBase,\s*ours: snippetsRef\.current,\s*theirs: next,\s*\}\)/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/if \(key === STORAGE_KEY_SNIPPETS\) \{[\s\S]*snippetsWriteBaseRef\.current = null;\s*snippetsRef\.current = next/,
|
||||
);
|
||||
});
|
||||
|
||||
test("snippet storage events retain outstanding replace snapshots", () => {
|
||||
// clear/restore sets replace with base null. Adopting a remote old catalog
|
||||
// before the lock is acquired would pollute memory; a later local edit would
|
||||
// still replace disk with that polluted snapshot.
|
||||
assert.match(
|
||||
source,
|
||||
/if \(key === STORAGE_KEY_SNIPPETS\) \{[\s\S]*if \(snippetsWriteReplaceRef\.current\) \{[\s\S]*return;\s*\}[\s\S]*snippetsRef\.current = next/,
|
||||
);
|
||||
});
|
||||
|
||||
test("snippet storage events reject payloads older than current disk", () => {
|
||||
// After a queued replace commits, snippetsWriteReplaceRef is false again. A
|
||||
// delayed peer StorageEvent still carries the pre-replacement newValue; if
|
||||
// adopted, the next edit would persist that resurrected catalog.
|
||||
assert.match(
|
||||
source,
|
||||
/if \(key === STORAGE_KEY_SNIPPETS\) \{[\s\S]*event\.newValue !== localStorageAdapter\.readString\(STORAGE_KEY_SNIPPETS\)[\s\S]*return;/,
|
||||
);
|
||||
});
|
||||
|
||||
test("startup host re-encryption uses the locked prune writer", () => {
|
||||
// A second renderer migrating saved hosts must not write the pre-delete
|
||||
// encryption blob unlocked after bulk-delete cleared login/connect bindings.
|
||||
assert.match(
|
||||
source,
|
||||
/migrateHostsFromLegacyLineTimestamps[\s\S]*commitEncryptedHostsUnderVaultLock\(ver, sanitized, enc\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/migrateHostsFromLegacyLineTimestamps[\s\S]*hostsWritePendingRef\.current = writePromise/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/migrateHostsFromLegacyLineTimestamps[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_HOSTS, enc\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("startup snippet order backfill uses the locked vault writer", () => {
|
||||
// An initializing renderer must not rewrite a pre-delete snippet snapshot
|
||||
// unlocked after peer bulk-delete cleared host bindings — that resurrects
|
||||
// every selected snippet while binding cleanup remains committed.
|
||||
assert.match(
|
||||
source,
|
||||
/needsOrderPersist[\s\S]*withVaultImportLock\("vault", async \(\) => \{[\s\S]*readStoredArray<Snippet>\([\s\S]*STORAGE_KEY_SNIPPETS[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, latest\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/needsOrderPersist[\s\S]*snippetsWritePendingRef\.current = writePromise/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/const orderedSnippets = normalizeVaultOrder\(savedSnippets\);\s*setSnippets\(orderedSnippets\);\s*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, orderedSnippets\);/,
|
||||
);
|
||||
});
|
||||
Loaded 100 of 531 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user